From 8d9303150770cffd9d772276d1f519f29ecb032a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:17:10 +0000 Subject: [PATCH 1/7] wip(spec): declare the mobile sign-in hand-off surface --- packages/spec/src/api/auth-endpoints.test.ts | 80 +++++++++++++++++ packages/spec/src/api/auth-endpoints.zod.ts | 94 ++++++++++++++++++++ packages/spec/src/system/auth-config.test.ts | 29 ++++++ packages/spec/src/system/auth-config.zod.ts | 41 +++++++++ 4 files changed, 244 insertions(+) diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index 5ca56fc43f3..5181f80feb4 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -10,6 +10,9 @@ import { GetAuthConfigResponseSchema, EndpointMapping, getAuthEndpointUrl, + OneTimeTokenHandoffQuerySchema, + OneTimeTokenVerifyRequestSchema, + OneTimeTokenVerifyResponseSchema, } from './auth-endpoints.zod'; import { PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS, @@ -355,3 +358,80 @@ describe('DeviceRequestResponse.interval \u2192 intervalSeconds', () => { expect(DeviceRequestResponseSchema.parse(base).intervalSeconds).toBe(2); }); }); + +describe('mobile sign-in hand-off (one-time token)', () => { + // 43 base64url characters: an S256 challenge, and a verifier at the RFC 7636 minimum. + const challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const token = 'aB3-_xYz0123456789abcdefGHIJKLmn'; + + it('declares the hand-off and the vendor verify route, and not the client-refused generate', () => { + expect(AuthEndpointPaths.oneTimeTokenHandoff).toBe('/one-time-token/handoff'); + expect(AuthEndpointPaths.oneTimeTokenVerify).toBe('/one-time-token/verify'); + expect(Object.values(AuthEndpointPaths)).not.toContain('/one-time-token/generate'); + }); + + it('accepts a hand-off query with and without the challenge method', () => { + const base = { redirect_uri: 'com.example.app://auth/callback', code_challenge: challenge }; + expect(OneTimeTokenHandoffQuerySchema.safeParse(base).success).toBe(true); + expect( + OneTimeTokenHandoffQuerySchema.safeParse({ ...base, code_challenge_method: 'S256' }).success, + ).toBe(true); + }); + + it('closes the hand-off parameter set: an unknown parameter is refused', () => { + const result = OneTimeTokenHandoffQuerySchema.safeParse({ + redirect_uri: 'com.example.app://auth/callback', + code_challenge: challenge, + state: 'x', + }); + expect(result.success).toBe(false); + expect(result.error!.issues[0].code).toBe('unrecognized_keys'); + }); + + it('refuses the plain method, a malformed challenge and a missing challenge', () => { + const base = { redirect_uri: 'com.example.app://cb', code_challenge: challenge }; + const plain = OneTimeTokenHandoffQuerySchema.safeParse({ ...base, code_challenge_method: 'plain' }); + expect(plain.success).toBe(false); + expect(plain.error!.issues[0].path).toEqual(['code_challenge_method']); + const short = OneTimeTokenHandoffQuerySchema.safeParse({ ...base, code_challenge: challenge.slice(1) }); + expect(short.success).toBe(false); + expect(short.error!.issues[0].path).toEqual(['code_challenge']); + const padded = OneTimeTokenHandoffQuerySchema.safeParse({ ...base, code_challenge: `${challenge.slice(1)}=` }); + expect(padded.success).toBe(false); + const missing = OneTimeTokenHandoffQuerySchema.safeParse({ redirect_uri: base.redirect_uri }); + expect(missing.success).toBe(false); + expect(missing.error!.issues[0].path).toEqual(['code_challenge']); + }); + + it('accepts a verify body carrying the token, a dot and the verifier', () => { + expect(OneTimeTokenVerifyRequestSchema.safeParse({ token: `${token}.${verifier}` }).success).toBe(true); + // A verifier may itself contain dots and tildes (RFC 7636 unreserved set): the split is on the FIRST dot. + expect( + OneTimeTokenVerifyRequestSchema.safeParse({ token: `${token}.${verifier.slice(0, 40)}.~_` }).success, + ).toBe(true); + }); + + it('refuses a verify body without the verifier binding, or with a short verifier', () => { + const bare = OneTimeTokenVerifyRequestSchema.safeParse({ token }); + expect(bare.success).toBe(false); + expect(bare.error!.issues[0].path).toEqual(['token']); + const short = OneTimeTokenVerifyRequestSchema.safeParse({ token: `${token}.${verifier.slice(1)}` }); + expect(short.success).toBe(false); + }); + + it('parses the vendor verify body: the session and its user, unwrapped', () => { + const body = { + session: { + id: 's1', + token: 'session-token', + userId: 'u1', + expiresAt: '2026-11-10T03:00:00.000Z', + createdAt: '2026-11-03T03:00:00.000Z', + }, + user: { id: 'u1', email: 'ada@example.com', emailVerified: true, name: 'Ada', image: null }, + }; + const result = OneTimeTokenVerifyResponseSchema.safeParse(body); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 667b56fddbe..690b0a69984 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -27,6 +27,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { retiredKey } from '../shared/retired-key'; import { TenancyPostureSchema } from '../security/tenancy-posture'; import { AUDIENCE_POSTURES } from '../system/auth-config.zod'; +import { SessionSchema, SessionUserSchema } from './auth.zod'; export const AuthEndpointPaths = { // Email/Password Authentication signInEmail: '/sign-in/email', @@ -64,6 +65,17 @@ export const AuthEndpointPaths = { deviceRequest: '/device/request', deviceToken: '/device/token', deviceApprove: '/device/approve', + + // Mobile sign-in hand-off (when at least one app scheme is registered in + // `AuthConfig.trustedAppSchemes`). `handoff` is ObjectStack's own route; + // `verify` is better-auth's one-time-token plugin route as better-auth 1.7.12 + // names it. The plugin's third route, `GET /one-time-token/generate`, is + // mounted but refuses every client request (`disableClientRequest`), so it is + // deliberately not declared here: the hand-off is the only way to mint a token. + // Request/response shapes: `OneTimeTokenHandoffQuerySchema`, + // `OneTimeTokenVerifyRequestSchema`, `OneTimeTokenVerifyResponseSchema`. + oneTimeTokenHandoff: '/one-time-token/handoff', + oneTimeTokenVerify: '/one-time-token/verify', } as const; /** @@ -392,3 +404,85 @@ export type DeviceRequestResponse = z.input; /** Post-parse shape of {@link DeviceRequestResponse} — defaults applied, transforms run (ADR-0122). */ export type DeviceRequestResponseParsed = z.infer; export type DeviceTokenResponse = z.input; + +// ========================================== +// Mobile Sign-In Hand-Off (One-Time Token) +// ========================================== + +/** + * Query of `GET /api/v1/auth/one-time-token/handoff` — the step that hands a + * sign-in completed in the system browser back to a native app. + * + * Some identity providers refuse embedded WebViews, so the mobile shell opens + * the sign-in in the system authentication session (`ASWebAuthenticationSession` + * on iOS, Custom Tabs on Android). Once that session is signed in, the shell + * navigates it here. The server mints a single-use token bound to the shell's + * PKCE challenge (RFC 7636) and redirects to the app's registered scheme; the + * WebView then redeems the token at `POST /one-time-token/verify` together with + * the matching verifier, so a callback intercepted by another app on the device + * cannot be redeemed without the verifier only the shell holds. + * + * The route requires a signed-in session (the system browser's cookie). The + * parameter set is CLOSED: an unknown or repeated parameter is refused. + * + * Answers: + * - `302`: `Location` is `redirect_uri` with a `token` query parameter appended + * (the single-use token, valid for one minute), sent with + * `Cache-Control: no-store`. + * - `400 VALIDATION_ERROR`, with NO redirect: an unknown or repeated parameter, + * a `redirect_uri` whose scheme is not registered in + * `AuthConfig.trustedAppSchemes` (compared case-insensitively), a malformed + * `code_challenge`, or a `code_challenge_method` other than `S256`. + * - `401 UNAUTHENTICATED`: no signed-in session. + * + * The parameter names are the OAuth 2.0 / RFC 7636 wire names, kept verbatim. + */ +export const OneTimeTokenHandoffQuerySchema = lazySchema(() => z.object({ + redirect_uri: z.string().describe( + 'Where to send the token: a URI whose scheme is registered in AuthConfig.trustedAppSchemes ' + + '(e.g. com.example.app://auth/callback); any other scheme is refused with 400 and no redirect', + ), + code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/).describe( + 'RFC 7636 S256 code challenge: the unpadded base64url SHA-256 of the verifier the app keeps (43 characters)', + ), + code_challenge_method: z.literal('S256').optional().describe( + 'RFC 7636 challenge method; only S256 is accepted (plain is refused), and absent means S256', + ), +}).strict()); + +/** + * Body of `POST /api/v1/auth/one-time-token/verify` — better-auth's + * one-time-token verify route, body unchanged so its stock client works. + * + * The interception binding rides inside `token`: the app sends the token it + * received on its scheme, a dot, and the RFC 7636 code verifier whose S256 + * challenge it sent to the hand-off. The token itself never contains a dot + * (better-auth's alphabet is `a-z A-Z 0-9 - _`), so the server splits on the + * FIRST dot. A missing or wrong verifier finds no stored token and consumes + * nothing. + */ +export const OneTimeTokenVerifyRequestSchema = lazySchema(() => z.object({ + token: z.string().regex(/^[A-Za-z0-9_-]+\.[A-Za-z0-9._~-]{43,128}$/).describe( + 'The hand-off token, a dot, then the RFC 7636 code verifier (43-128 characters) whose S256 challenge was sent to the hand-off', + ), +})); + +/** + * Response of `POST /api/v1/auth/one-time-token/verify`: better-auth's body, + * not wrapped in the ObjectStack envelope — the redeemed session and its user — + * sent with the session cookie (`Set-Cookie`), so the WebView is signed in. + * + * Refused with better-auth's `400` ("Invalid token") when the token is unknown, + * already used, expired (one minute after the hand-off) or paired with the + * wrong verifier; every refusal looks the same on purpose. + */ +export const OneTimeTokenVerifyResponseSchema = lazySchema(() => z.object({ + session: SessionSchema.describe('The session the token was minted from'), + user: SessionUserSchema.describe('The signed-in user'), +})); + +export type OneTimeTokenHandoffQuery = z.input; +export type OneTimeTokenVerifyRequest = z.input; +export type OneTimeTokenVerifyResponse = z.input; +/** Post-parse shape of {@link OneTimeTokenVerifyResponse} — defaults applied, transforms run (ADR-0122). */ +export type OneTimeTokenVerifyResponseParsed = z.infer; diff --git a/packages/spec/src/system/auth-config.test.ts b/packages/spec/src/system/auth-config.test.ts index ab99ea91144..87d3437187a 100644 --- a/packages/spec/src/system/auth-config.test.ts +++ b/packages/spec/src/system/auth-config.test.ts @@ -13,6 +13,7 @@ import { AUDIENCE_POSTURES, isAudiencePosture, audiencePermitsSelfRegistration, + TrustedAppSchemeSchema, } from './auth-config.zod'; describe('AuthProviderConfigSchema', () => { @@ -139,6 +140,34 @@ describe('AuthConfigSchema', () => { expect(config.trustedOrigins).toBeUndefined(); }); + + it('accepts reverse-domain app schemes in trustedAppSchemes, and leaves the list absent by default', () => { + const config = AuthConfigSchema.parse({ + trustedAppSchemes: ['com.example.app', 'io.objectstack.mobile', 'com.googleusercontent.apps.123-abc'], + }); + expect(config.trustedAppSchemes).toEqual([ + 'com.example.app', + 'io.objectstack.mobile', + 'com.googleusercontent.apps.123-abc', + ]); + expect(AuthConfigSchema.parse({}).trustedAppSchemes).toBeUndefined(); + }); + + it.each([ + ['a web scheme', 'https'], + ['a script scheme', 'javascript'], + ['a scheme without a dot', 'myapp'], + ['a scheme written with its suffix', 'com.example.app://'], + ['a scheme written with a colon', 'com.example.app:'], + ['an uppercase scheme', 'Com.Example.App'], + ['an empty label', 'com..app'], + ['a trailing dot', 'com.example.'], + ])('refuses %s in trustedAppSchemes (%s), never dropping or rewriting it', (_label, scheme) => { + expect(TrustedAppSchemeSchema.safeParse(scheme).success).toBe(false); + const result = AuthConfigSchema.safeParse({ trustedAppSchemes: ['com.example.app', scheme] }); + expect(result.success).toBe(false); + expect(result.error!.issues[0].path).toEqual(['trustedAppSchemes', 1]); + }); }); // ========================================== diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 1acecf8a93d..976f9a5aa16 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -542,6 +542,28 @@ export const AdvancedAuthConfigSchema = lazySchema(() => z.object({ cookiePrefix: z.string().optional().describe('Prefix for auth cookie names'), }).optional().describe('Advanced / low-level Better-Auth options')); +/** + * One registered native-app URI scheme for the mobile sign-in hand-off + * (`GET /api/v1/auth/one-time-token/handoff`, see `OneTimeTokenHandoffQuerySchema`). + * + * Strict, never normalised: a lowercase RFC 3986 scheme written bare (no `:` or + * `://` suffix) that contains at least one dot. The dot is RFC 8252 section 7.1's + * rule that a private-use scheme be a reverse domain name the app's owner + * controls (`com.example.app`), which is also what keeps two apps on one device + * from claiming the same scheme and intercepting the token. The rule refuses, by + * construction, every scheme the hand-off must never redirect to (`http`, + * `https`, `ws`, `wss`, `javascript`, `data`, `file`, `blob`, `about`, + * `vbscript`): none contains a dot. + */ +export const TrustedAppSchemeSchema = lazySchema(() => z.string().regex(/^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/, { + message: + 'An app scheme is a lowercase reverse-domain URI scheme written bare, e.g. "com.example.app": ' + + 'no ":" or "://" suffix, no uppercase, and at least one dot (RFC 8252 section 7.1). ' + + 'http, https and other web schemes are never app schemes.', +}).describe( + 'A registered native-app URI scheme: lowercase, reverse-domain with at least one dot, written bare (e.g. com.example.app)', +)); + export const AuthConfigSchema = lazySchema(() => z.object({ secret: z.string().optional().describe('Encryption secret'), baseUrl: z.string().optional().describe('Base URL for auth routes'), @@ -573,6 +595,25 @@ export const AuthConfigSchema = lazySchema(() => z.object({ 'Trusted origins for CSRF protection. Supports wildcards (e.g. "https://*.example.com"). ' + 'The baseUrl origin is always trusted implicitly.' ), + /** + * Native-app URI schemes the mobile sign-in hand-off may redirect a + * one-time token to (`GET /api/v1/auth/one-time-token/handoff`). Each entry + * is a {@link TrustedAppSchemeSchema}; an entry that is not is refused, never + * dropped or rewritten. + * + * Absent or empty: no app scheme is registered and the hand-off is not + * offered. A hand-off whose `redirect_uri` scheme is not on this list + * (compared case-insensitively) is refused with `400 VALIDATION_ERROR` and + * NO redirect, so an unregistered scheme never receives a token. + * + * Separate from `trustedOrigins` on purpose: an origin listed there is + * trusted for every redirect better-auth makes, while a scheme listed here + * is trusted only as the hand-off's destination. + */ + trustedAppSchemes: z.array(TrustedAppSchemeSchema).optional().describe( + 'Native-app URI schemes the mobile sign-in hand-off may redirect a one-time token to (e.g. ["com.example.app"]). ' + + 'A hand-off to any other scheme is refused with 400 VALIDATION_ERROR and no redirect; absent or empty means the hand-off is not offered', + ), socialProviders: SocialProviderConfigSchema, oidcProviders: OidcProvidersConfigSchema, emailAndPassword: EmailAndPasswordConfigSchema, From 205ba3b889592a915b2c4854e16a8db83ef56715 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:26:26 +0000 Subject: [PATCH 2/7] wip(spec): regenerate artifacts; add the TrustedAppScheme type alias Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG Co-authored-by: Claude --- .../docs/references/api/auth-endpoints.mdx | 57 ++++++++++++++++++- content/docs/references/index.mdx | 16 +++--- .../docs/references/system/auth-config.mdx | 14 ++++- .../api.md | 2 +- packages/spec/api-surface/api.json | 7 +++ packages/spec/api-surface/system.json | 1 + packages/spec/authorable-surface/api.json | 6 ++ packages/spec/authorable-surface/system.json | 1 + packages/spec/declaration-map/api.json | 6 ++ packages/spec/declaration-map/system.json | 1 + packages/spec/export-origins/api.json | 7 +++ packages/spec/export-origins/system.json | 1 + packages/spec/json-schema.manifest/api.json | 3 + .../spec/json-schema.manifest/system.json | 1 + packages/spec/src/system/auth-config.zod.ts | 1 + 15 files changed, 111 insertions(+), 13 deletions(-) diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 2a00bef7525..b7700520f95 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -22,8 +22,8 @@ the canonical API contract. ## TypeScript Usage ```typescript -import { AuthEndpointSchema, AuthFeaturesConfigSchema, AuthProviderInfoSchema, DeviceRequestResponseSchema, DeviceTokenResponseSchema, EmailPasswordConfigPublicSchema, GetAuthConfigResponseSchema } from '@objectstack/spec/api'; -import type { AuthEndpoint, AuthFeaturesConfig, AuthProviderInfo, DeviceRequestResponse, DeviceTokenResponse, EmailPasswordConfigPublic, GetAuthConfigResponse } from '@objectstack/spec/api'; +import { AuthEndpointSchema, AuthFeaturesConfigSchema, AuthProviderInfoSchema, DeviceRequestResponseSchema, DeviceTokenResponseSchema, EmailPasswordConfigPublicSchema, GetAuthConfigResponseSchema, OneTimeTokenHandoffQuerySchema, OneTimeTokenVerifyRequestSchema, OneTimeTokenVerifyResponseSchema } from '@objectstack/spec/api'; +import type { AuthEndpoint, AuthFeaturesConfig, AuthProviderInfo, DeviceRequestResponse, DeviceTokenResponse, EmailPasswordConfigPublic, GetAuthConfigResponse, OneTimeTokenHandoffQuery, OneTimeTokenVerifyRequest, OneTimeTokenVerifyResponse } from '@objectstack/spec/api'; // Validate data const result = AuthEndpointSchema.parse(data); @@ -215,3 +215,56 @@ This schema accepts one of the following structures: --- +## OneTimeTokenHandoffQuery + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **redirect_uri** | `string` | ✅ | Where to send the token: a URI whose scheme is registered in AuthConfig.trustedAppSchemes (e.g. com.example.app://auth/callback); any other scheme is refused with 400 and no redirect | +| **code_challenge** | `string` | ✅ | RFC 7636 S256 code challenge: the unpadded base64url SHA-256 of the verifier the app keeps (43 characters) | +| **code_challenge_method** | `'S256'` | optional | RFC 7636 challenge method; only S256 is accepted (plain is refused), and absent means S256 | + + +--- + +## OneTimeTokenVerifyRequest + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **token** | `string` | ✅ | The hand-off token, a dot, then the RFC 7636 code verifier (43-128 characters) whose S256 challenge was sent to the hand-off | + + +--- + +## OneTimeTokenVerifyResponse + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **session** | `{ id: string; expiresAt: string; token?: string; ipAddress?: string; … }` | ✅ | The session the token was minted from | +| **user** | `{ id: string; email: string; emailVerified?: boolean; name: string; … }` | ✅ | The signed-in user | + +### Nested Shape: `OneTimeTokenVerifyResponse.user` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | User ID | +| **email** | `string` | ✅ | Email address | +| **emailVerified** | `boolean` | optional (default: `false`) | Is email verified? | +| **name** | `string` | ✅ | Display name | +| **image** | `string \| null` | optional | Avatar URL | +| **username** | `string` | optional | Username (optional) | +| **roles** | `string[]` | optional (default: `[]`) | Assigned role IDs | +| **tenantId** | `string` | optional | Current tenant ID | +| **language** | `never` | optional | [REMOVED] `SessionUser.language` was removed in @objectstack/spec 17.4.0 (ADR-0049 enforce-or-remove) — it was declared with a permanent default of `'en'` and never produced by any session endpoint nor read by any client, so a reader keying on it saw a constant, not the user's language. Delete the key. Read the signed-in user's language from `GET /auth/me/localization` (`locale`: the user's own `sys_user.locale` when set → the request's `Accept-Language` → the deployment default). | +| **timezone** | `string` | optional | Preferred timezone | +| **createdAt** | `string` | optional | | +| **updatedAt** | `string` | optional | | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 22671ce949d..623df4afae1 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1605 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1609 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 35 | 474 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 35 | 477 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 6 | 37 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 39 | 292 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 39 | 293 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **208** | **1605** | 14 protocol modules | +| **Total** | **208** | **1609** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 474 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 477 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -72,7 +72,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`analytics.zod.ts`](/docs/references/api/analytics) | `AnalyticsEndpoint`, `AnalyticsMetadataResponse`, `AnalyticsQueryRequest`, `AnalyticsResultResponse`, `AnalyticsSqlResponse`, `DatasetCompareTo`, `DatasetSelection`, `DatasetTotals`, `GetAnalyticsMetaRequest` | | [`app-bundle.zod.ts`](/docs/references/api/app-bundle) | `AppMetadataBundleResponse` | | [`auth.zod.ts`](/docs/references/api/auth) | `AuthProvider`, `LoginRequest`, `LoginType`, `MePermissionsAnonymousResponse`, `MePermissionsAuthenticatedResponse`, `MePermissionsResponse`, `RefreshTokenRequest`, `RegisterRequest`, `Session`, `SessionResponse`, `SessionUser`, `UserProfileResponse` | -| [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse` | +| [`auth-endpoints.zod.ts`](/docs/references/api/auth-endpoints) | `AuthEndpoint`, `AuthFeaturesConfig`, `AuthProviderInfo`, `DeviceRequestResponse`, `DeviceTokenResponse`, `EmailPasswordConfigPublic`, `GetAuthConfigResponse`, `OneTimeTokenHandoffQuery`, `OneTimeTokenVerifyRequest`, `OneTimeTokenVerifyResponse` | | [`automation-api.zod.ts`](/docs/references/api/automation-api) | `AutomationApiErrorCode`, `AutomationFlowPathParams`, `AutomationRunPathParams`, `CloneFlowRequest`, `CloneFlowResponse`, `CreateFlowRequest`, `CreateFlowResponse`, `DeleteFlowRequest`, `DeleteFlowResponse`, `FlowFailureDetails`, `FlowInnerRefusal`, `GetFlowRequest`, `GetFlowResponse`, `GetRunRequest`, `GetRunResponse`, `ListRunsRequest`, `ListRunsResponse`, `ResumeFailureDetails`, `ToggleFlowRequest`, `ToggleFlowResponse`, `TriggerFlowRequest`, `TriggerFlowResponse`, `UpdateFlowRequest`, `UpdateFlowResponse` | | [`batch.zod.ts`](/docs/references/api/batch) | `BatchConfig`, `BatchOperationResult`, `BatchOperationType`, `BatchOptions`, `BatchRecord`, `BatchUpdateRequest`, `BatchUpdateResponse`, `CrossObjectBatchDroppedFields`, `CrossObjectBatchOperation`, `CrossObjectBatchRequest`, `CrossObjectBatchResponse`, `DeleteManyRequest`, `UpdateManyRecord`, `UpdateManyRequest` | | [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` | @@ -325,14 +325,14 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **39 pages, 292 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **39 pages, 293 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | File | Schemas | | :--- | :--- | | [`app-install.zod.ts`](/docs/references/system/app-install) | `AppCompatibilityCheck`, `AppInstallRequest`, `AppInstallResult`, `AppManifest` | -| [`auth-config.zod.ts`](/docs/references/system/auth-config) | `AdvancedAuthConfig`, `AudienceConfig`, `AuthConfig`, `AuthPluginConfig`, `AuthProviderConfig`, `EmailAndPasswordConfig`, `EmailVerificationConfig`, `MutualTLSConfig`, `OidcProviderConfig`, `OidcProvidersConfig`, `SocialProviderConfig` | +| [`auth-config.zod.ts`](/docs/references/system/auth-config) | `AdvancedAuthConfig`, `AudienceConfig`, `AuthConfig`, `AuthPluginConfig`, `AuthProviderConfig`, `EmailAndPasswordConfig`, `EmailVerificationConfig`, `MutualTLSConfig`, `OidcProviderConfig`, `OidcProvidersConfig`, `SocialProviderConfig`, `TrustedAppScheme` | | [`book.zod.ts`](/docs/references/system/book) | `Book`, `BookAudience`, `BookGroup`, `BookInclude`, `BookNode`, `ResolvedBook`, `ResolvedEntry`, `ResolvedGroup` | | [`cache.zod.ts`](/docs/references/system/cache) | `CacheAvalanchePrevention`, `CacheConfig`, `CacheConsistency`, `CacheInvalidation`, `CacheStrategy`, `CacheTier`, `CacheWarmup`, `DistributedCacheConfig` | | [`collaboration.zod.ts`](/docs/references/system/collaboration) | `AwarenessEvent`, `AwarenessSession`, `AwarenessUpdate`, `AwarenessUserState`, `CRDTMergeResult`, `CRDTState`, `CRDTType`, `CollaborationMode`, `CollaborationSession`, `CollaborationSessionConfig`, `CollaborativeCursor`, `CounterOperation`, `CursorColorPreset`, `CursorSelection`, `CursorStyle`, `CursorUpdate`, `GCounter`, `LWWRegister`, `ORSet`, `ORSetElement`, `OTComponent`, `OTOperation`, `OTOperationType`, `OTTransformResult`, `PNCounter`, `TextCRDTOperation`, `TextCRDTState`, `UserActivityStatus`, `VectorClock` | diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index fb7fd0a8541..2fac11a3b74 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -1,7 +1,7 @@ --- title: Auth Config schema — System Protocol reference navTitle: Auth Config -description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAuthConfig and 10 more — each property with its type, default and a TypeScript example." +description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAuthConfig and 11 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,7 +13,7 @@ description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAu ## TypeScript Usage ```typescript -import { AdvancedAuthConfigSchema, AudienceConfigSchema, AuthConfigSchema, AuthPluginConfigSchema, AuthProviderConfigSchema, EmailAndPasswordConfigSchema, EmailVerificationConfigSchema, MutualTLSConfigSchema, OidcProviderConfigSchema, OidcProvidersConfigSchema, SocialProviderConfigSchema } from '@objectstack/spec/system'; +import { AdvancedAuthConfigSchema, AudienceConfigSchema, AuthConfigSchema, AuthPluginConfigSchema, AuthProviderConfigSchema, EmailAndPasswordConfigSchema, EmailVerificationConfigSchema, MutualTLSConfigSchema, OidcProviderConfigSchema, OidcProvidersConfigSchema, SocialProviderConfigSchema, TrustedAppSchemeSchema } from '@objectstack/spec/system'; import type { AdvancedAuthConfig, AudienceConfig, AuthConfig, AuthPluginConfig, AuthProviderConfig, EmailAndPasswordConfig, EmailVerificationConfig, MutualTLSConfig, OidcProviderConfig, OidcProvidersConfig, SocialProviderConfig } from '@objectstack/spec/system'; // Validate data @@ -73,6 +73,7 @@ Advanced / low-level Better-Auth options | **plugins** | `{ organization?: boolean; twoFactor?: boolean; passkeys?: boolean; passwordRejectBreached?: boolean; … }` | optional | | | **session** | `{ expiresIn?: number; updateAge?: number }` | optional | | | **trustedOrigins** | `string[]` | optional | Trusted origins for CSRF protection. Supports wildcards (e.g. "https://*.example.com"). The baseUrl origin is always trusted implicitly. | +| **trustedAppSchemes** | `string[]` | optional | Native-app URI schemes the mobile sign-in hand-off may redirect a one-time token to (e.g. ["com.example.app"]). A hand-off to any other scheme is refused with 400 VALIDATION_ERROR and no redirect; absent or empty means the hand-off is not offered | | **socialProviders** | `Record>` | optional | Social/OAuth provider map forwarded to better-auth socialProviders. Keys are provider ids (google, github, apple, …). | | **oidcProviders** | `{ providerId: string; name?: string; discoveryUrl?: string; issuer?: string; … }[]` | optional | List of OIDC/OAuth2 providers for enterprise SSO. Product or enterprise packages can pass this directly or contribute it through auth:configure. | | **emailAndPassword** | `{ enabled?: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean; minPasswordLength?: number; … }` | optional | Email and password authentication options forwarded to better-auth | @@ -339,3 +340,12 @@ Social/OAuth provider map forwarded to better-auth socialProviders. Keys are pro --- +## TrustedAppScheme + +A registered native-app URI scheme: lowercase, reverse-domain with at least one dot, written bare (e.g. com.example.app) + +**Type:** `string` + + +--- + diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 58421963d0c..b60586bb356 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 490 | +| `api/` | 493 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 3ac6b824000..5dd3f0d2c03 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -762,6 +762,13 @@ "ObjectDefinitionResponseSchema (const)", "ObjectSortability (type)", "ObjectSortabilitySchema (const)", + "OneTimeTokenHandoffQuery (type)", + "OneTimeTokenHandoffQuerySchema (const)", + "OneTimeTokenVerifyRequest (type)", + "OneTimeTokenVerifyRequestSchema (const)", + "OneTimeTokenVerifyResponse (type)", + "OneTimeTokenVerifyResponseParsed (type)", + "OneTimeTokenVerifyResponseSchema (const)", "OpenApiGenerationConfig (type)", "OpenApiGenerationConfigParsed (type)", "OpenApiGenerationConfigSchema (const)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 1ac5c7b2198..71859e79b68 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -809,6 +809,7 @@ "TranslationDiffStatusSchema (const)", "TranslationItem (type)", "TranslationItemSchema (const)", + "TrustedAppSchemeSchema (const)", "UserActivityStatus (const)", "UserActivityStatus (type)", "UserDevice (type)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5593fb514f7..72f8fbc1d6e 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1273,6 +1273,12 @@ "api/ObjectDefinitionResponse:meta", "api/ObjectDefinitionResponse:success", "api/ObjectSortability:fields", + "api/OneTimeTokenHandoffQuery:code_challenge", + "api/OneTimeTokenHandoffQuery:code_challenge_method", + "api/OneTimeTokenHandoffQuery:redirect_uri", + "api/OneTimeTokenVerifyRequest:token", + "api/OneTimeTokenVerifyResponse:session", + "api/OneTimeTokenVerifyResponse:user", "api/OpenApiGenerationConfig:apiVersion", "api/OpenApiGenerationConfig:contact", "api/OpenApiGenerationConfig:description", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 71186982f34..884a2a55f5c 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -76,6 +76,7 @@ "system/AuthConfig:session", "system/AuthConfig:socialProviders", "system/AuthConfig:ssoOnlyMode", + "system/AuthConfig:trustedAppSchemes", "system/AuthConfig:trustedOrigins", "system/AuthConfig:uiBasePath", "system/AuthPluginConfig:admin", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 74683917166..42361d3d816 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -559,6 +559,12 @@ "ObjectDefinitionResponseSchema": "api/ObjectDefinitionResponse", "ObjectSortability": "api/ObjectSortability", "ObjectSortabilitySchema": "api/ObjectSortability", + "OneTimeTokenHandoffQuery": "api/OneTimeTokenHandoffQuery", + "OneTimeTokenHandoffQuerySchema": "api/OneTimeTokenHandoffQuery", + "OneTimeTokenVerifyRequest": "api/OneTimeTokenVerifyRequest", + "OneTimeTokenVerifyRequestSchema": "api/OneTimeTokenVerifyRequest", + "OneTimeTokenVerifyResponse": "api/OneTimeTokenVerifyResponse", + "OneTimeTokenVerifyResponseSchema": "api/OneTimeTokenVerifyResponse", "OpenApiGenerationConfig": "api/OpenApiGenerationConfig", "OpenApiGenerationConfigSchema": "api/OpenApiGenerationConfig", "OpenApiSecurityScheme": "api/OpenApiSecurityScheme", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 2667d05f1d1..1007d26e279 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -537,6 +537,7 @@ "TranslationDiffStatusSchema": "system/TranslationDiffStatus", "TranslationItem": "system/TranslationItem", "TranslationItemSchema": "system/TranslationItem", + "TrustedAppSchemeSchema": "system/TrustedAppScheme", "UserActivityStatus": "system/UserActivityStatus", "UserDevice": "system/UserDevice", "UserDeviceSchema": "system/UserDevice", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index fca37e62787..ca1eb739a35 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -736,6 +736,13 @@ "ObjectDefinitionResponseSchema": "src/api/metadata.zod.ts#ObjectDefinitionResponseSchema (const)", "ObjectSortability": "src/api/sortability.zod.ts#ObjectSortability (type)", "ObjectSortabilitySchema": "src/api/sortability.zod.ts#ObjectSortabilitySchema (const)", + "OneTimeTokenHandoffQuery": "src/api/auth-endpoints.zod.ts#OneTimeTokenHandoffQuery (type)", + "OneTimeTokenHandoffQuerySchema": "src/api/auth-endpoints.zod.ts#OneTimeTokenHandoffQuerySchema (const)", + "OneTimeTokenVerifyRequest": "src/api/auth-endpoints.zod.ts#OneTimeTokenVerifyRequest (type)", + "OneTimeTokenVerifyRequestSchema": "src/api/auth-endpoints.zod.ts#OneTimeTokenVerifyRequestSchema (const)", + "OneTimeTokenVerifyResponse": "src/api/auth-endpoints.zod.ts#OneTimeTokenVerifyResponse (type)", + "OneTimeTokenVerifyResponseParsed": "src/api/auth-endpoints.zod.ts#OneTimeTokenVerifyResponseParsed (type)", + "OneTimeTokenVerifyResponseSchema": "src/api/auth-endpoints.zod.ts#OneTimeTokenVerifyResponseSchema (const)", "OpenApiGenerationConfig": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfig (type)", "OpenApiGenerationConfigParsed": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfigParsed (type)", "OpenApiGenerationConfigSchema": "src/api/plugin-rest-api.zod.ts#OpenApiGenerationConfigSchema (const)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 84dea07f904..654400e99c4 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -772,6 +772,7 @@ "TranslationDiffStatusSchema": "src/system/translation.zod.ts#TranslationDiffStatusSchema (const)", "TranslationItem": "src/system/translation.zod.ts#TranslationItem (type)", "TranslationItemSchema": "src/system/translation.zod.ts#TranslationItemSchema (const)", + "TrustedAppSchemeSchema": "src/system/auth-config.zod.ts#TrustedAppSchemeSchema (const)", "UserActivityStatus": "src/system/collaboration.zod.ts#UserActivityStatus (type)", "UserDevice": "src/system/push-device.zod.ts#UserDevice (type)", "UserDeviceSchema": "src/system/push-device.zod.ts#UserDeviceSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 9d1ebd2e95f..aebb2784fe6 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -305,6 +305,9 @@ "api/ODataResponse", "api/ObjectDefinitionResponse", "api/ObjectSortability", + "api/OneTimeTokenHandoffQuery", + "api/OneTimeTokenVerifyRequest", + "api/OneTimeTokenVerifyResponse", "api/OpenApiGenerationConfig", "api/OpenApiSecurityScheme", "api/OpenApiServer", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 44877f71954..3ad58b74f4b 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -290,6 +290,7 @@ "system/TranslationDiffItem", "system/TranslationDiffStatus", "system/TranslationItem", + "system/TrustedAppScheme", "system/UserActivityStatus", "system/UserDevice", "system/VectorClock", diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 976f9a5aa16..2bcaed805ec 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -670,6 +670,7 @@ export type EmailAndPasswordConfig = z.input; export type EmailVerificationConfig = z.input; export type AdvancedAuthConfig = z.input; +export type TrustedAppScheme = z.input; export type AuthConfig = z.input; /** Post-parse shape of {@link AuthConfig} — defaults applied, transforms run (ADR-0122). */ export type AuthConfigParsed = z.infer; From 4b54dade0c51a50747e80b189c938a55323d8b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:30:53 +0000 Subject: [PATCH 3/7] feat(spec): declare the mobile sign-in hand-off surface Declares the one-time-token hand-off and verify routes with their request and response schemas, and AuthConfig.trustedAppSchemes, the registered native-app scheme list the hand-off may redirect a token to. Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG Co-authored-by: Claude --- .../29736-mobile-sign-in-handoff-spec.md | 18 ++++++++++++++++++ content/docs/references/system/auth-config.mdx | 2 +- packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + 4 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/29736-mobile-sign-in-handoff-spec.md diff --git a/.changeset/29736-mobile-sign-in-handoff-spec.md b/.changeset/29736-mobile-sign-in-handoff-spec.md new file mode 100644 index 00000000000..be631fbaa8d --- /dev/null +++ b/.changeset/29736-mobile-sign-in-handoff-spec.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): declare the mobile sign-in hand-off surface (one-time-token hand-off and verify, and the registered app-scheme list) + +Clause-②: yes +Metadata-protocol: added + +Some identity providers refuse embedded WebViews, so a mobile app signs in through the system browser and needs the session handed back to its WebView. This declares the contract the auth plugin serves for that, before the plugin enables it: + +- **`AuthEndpointPaths.oneTimeTokenHandoff`** (`GET /one-time-token/handoff`) with `OneTimeTokenHandoffQuerySchema`: a closed query of `redirect_uri`, an RFC 7636 S256 `code_challenge` and an optional `code_challenge_method` (only `S256`). A signed-in session is redirected to `redirect_uri` with a single-use `token` that expires after one minute. An unregistered scheme, an unknown or repeated parameter, or a bad challenge is refused with `400 VALIDATION_ERROR` and no redirect. +- **`AuthEndpointPaths.oneTimeTokenVerify`** (`POST /one-time-token/verify`, better-auth's own route) with `OneTimeTokenVerifyRequestSchema` and `OneTimeTokenVerifyResponseSchema`. The body is `{ token }`, where the token is followed by a dot and the code verifier. A callback intercepted by another app cannot be redeemed without the verifier. The response is better-auth's unwrapped `{ session, user }` with the session cookie. better-auth's `GET /one-time-token/generate` refuses client requests, so it is not declared. +- **`AuthConfig.trustedAppSchemes`** (new authorable key, beside `trustedOrigins`): the native-app schemes the hand-off may redirect to. Each entry is a `TrustedAppSchemeSchema`: a lowercase reverse-domain scheme written bare, with at least one dot (RFC 8252 section 7.1), such as `com.example.app`. A malformed entry is refused, not dropped or rewritten. That includes a `://` suffix, an uppercase letter, a dotless name such as `myapp`, and every web scheme such as `https`. If the list is absent or empty, the hand-off is not offered. + +No `/auth/config` feature flag is added, because nothing reads one yet. + +Nothing to do for existing configurations. To offer the hand-off, list the app's scheme in `trustedAppSchemes`. diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 2fac11a3b74..0036d859665 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -14,7 +14,7 @@ description: "Auth Config schemas of the ObjectStack System Protocol: AdvancedAu ```typescript import { AdvancedAuthConfigSchema, AudienceConfigSchema, AuthConfigSchema, AuthPluginConfigSchema, AuthProviderConfigSchema, EmailAndPasswordConfigSchema, EmailVerificationConfigSchema, MutualTLSConfigSchema, OidcProviderConfigSchema, OidcProvidersConfigSchema, SocialProviderConfigSchema, TrustedAppSchemeSchema } from '@objectstack/spec/system'; -import type { AdvancedAuthConfig, AudienceConfig, AuthConfig, AuthPluginConfig, AuthProviderConfig, EmailAndPasswordConfig, EmailVerificationConfig, MutualTLSConfig, OidcProviderConfig, OidcProvidersConfig, SocialProviderConfig } from '@objectstack/spec/system'; +import type { AdvancedAuthConfig, AudienceConfig, AuthConfig, AuthPluginConfig, AuthProviderConfig, EmailAndPasswordConfig, EmailVerificationConfig, MutualTLSConfig, OidcProviderConfig, OidcProvidersConfig, SocialProviderConfig, TrustedAppScheme } from '@objectstack/spec/system'; // Validate data const result = AdvancedAuthConfigSchema.parse(data); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 71859e79b68..cfdd31535d7 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -809,6 +809,7 @@ "TranslationDiffStatusSchema (const)", "TranslationItem (type)", "TranslationItemSchema (const)", + "TrustedAppScheme (type)", "TrustedAppSchemeSchema (const)", "UserActivityStatus (const)", "UserActivityStatus (type)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 654400e99c4..cb600304680 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -772,6 +772,7 @@ "TranslationDiffStatusSchema": "src/system/translation.zod.ts#TranslationDiffStatusSchema (const)", "TranslationItem": "src/system/translation.zod.ts#TranslationItem (type)", "TranslationItemSchema": "src/system/translation.zod.ts#TranslationItemSchema (const)", + "TrustedAppScheme": "src/system/auth-config.zod.ts#TrustedAppScheme (type)", "TrustedAppSchemeSchema": "src/system/auth-config.zod.ts#TrustedAppSchemeSchema (const)", "UserActivityStatus": "src/system/collaboration.zod.ts#UserActivityStatus (type)", "UserDevice": "src/system/push-device.zod.ts#UserDevice (type)", From faf8acb2aeddc3704c4083309331931c71a0e979 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:30:54 +0000 Subject: [PATCH 4/7] test(spec): pin the three new isomorphic hand-off aliases Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG Co-authored-by: Claude --- .../spec/src/type-alias-convention.pin.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 44a19e3a384..c6fc7ace910 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -289,7 +289,7 @@ import type * as M197 from './api/push-delivery.zod.js'; import type * as M198 from './system/push-device.zod.js'; // --------------------------------------------------------------------------- -// 819 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 822 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -429,6 +429,8 @@ export type Iso_api_analytics__GetAnalyticsMetaRequestSchema = Assert, z.infer< typeof M12.AuthEndpointSchema > >>; export type Iso_api_authEndpoints__DeviceTokenResponseSchema = Assert, z.infer< typeof M12.DeviceTokenResponseSchema > >>; export type Iso_api_authEndpoints__EmailPasswordConfigPublicSchema = Assert, z.infer< typeof M12.EmailPasswordConfigPublicSchema > >>; +export type Iso_api_authEndpoints__OneTimeTokenHandoffQuerySchema = Assert, z.infer< typeof M12.OneTimeTokenHandoffQuerySchema > >>; +export type Iso_api_authEndpoints__OneTimeTokenVerifyRequestSchema = Assert, z.infer< typeof M12.OneTimeTokenVerifyRequestSchema > >>; // api/auth.zod.ts export type Iso_api_auth__AuthProvider = Assert, z.infer< typeof M13.AuthProvider > >>; @@ -1275,6 +1277,7 @@ export type Iso_system_authConfig__AuthProviderConfigSchema = Assert, z.infer< typeof M120.EmailVerificationConfigSchema > >>; export type Iso_system_authConfig__OidcProviderConfigSchema = Assert, z.infer< typeof M120.OidcProviderConfigSchema > >>; export type Iso_system_authConfig__OidcProvidersConfigSchema = Assert, z.infer< typeof M120.OidcProvidersConfigSchema > >>; +export type Iso_system_authConfig__TrustedAppSchemeSchema = Assert, z.infer< typeof M120.TrustedAppSchemeSchema > >>; // system/cache.zod.ts export type Iso_system_cache__CacheConsistencySchema = Assert, z.infer< typeof M121.CacheConsistencySchema > >>; @@ -1763,7 +1766,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 819 isomorphic pins', () => { + it('still declares all 822 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2760,7 +2763,14 @@ describe('ADR-0122 type-alias convention', () => { // its tree, so it is pinned here // (Iso_data_fieldValue__ReferenceNameOnlyValueSchema) rather than given a // `ReferenceNameOnlyValueParsed` synonym. +1 added. - expect(pins).toHaveLength(819); + // 819 -> 822 is the mobile sign-in hand-off: the hand-off query + // (`api/auth-endpoints.zod.ts`, a strict object of strings, a regex and + // an optional literal), the verify body (one regex string) and one + // registered app scheme (`system/auth-config.zod.ts`, a regex string), + // no default and no transform in any of them + // (Iso_api_authEndpoints__OneTimeToken*, Iso_system_authConfig__TrustedAppSchemeSchema). + // +3 added. + expect(pins).toHaveLength(822); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From b73cfb24d031d8653401e9cf49fc64e4a4c07bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 04:34:38 +0000 Subject: [PATCH 5/7] chore(spec): regenerate the declaration map for TrustedAppScheme Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG Co-authored-by: Claude --- packages/spec/declaration-map/system.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 1007d26e279..5a25801fc67 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -537,6 +537,7 @@ "TranslationDiffStatusSchema": "system/TranslationDiffStatus", "TranslationItem": "system/TranslationItem", "TranslationItemSchema": "system/TranslationItem", + "TrustedAppScheme": "system/TrustedAppScheme", "TrustedAppSchemeSchema": "system/TrustedAppScheme", "UserActivityStatus": "system/UserActivityStatus", "UserDevice": "system/UserDevice", From ffba6e29c7eee768e227ac21d6cdb5319656077e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:47:44 +0000 Subject: [PATCH 6/7] fix(spec): say what the app-scheme rule enforces; refuse an empty redirect_uri Contract-review should-fixes: the scheme prose now states the regex (two or more non-empty labels joined by single dots), and redirect_uri takes min(1) and is described as an absolute URI. Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG Co-authored-by: Claude --- .changeset/29736-mobile-sign-in-handoff-spec.md | 4 ++-- packages/spec/src/api/auth-endpoints.test.ts | 5 ++++- packages/spec/src/api/auth-endpoints.zod.ts | 4 ++-- packages/spec/src/system/auth-config.test.ts | 1 + packages/spec/src/system/auth-config.zod.ts | 9 ++++++--- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.changeset/29736-mobile-sign-in-handoff-spec.md b/.changeset/29736-mobile-sign-in-handoff-spec.md index be631fbaa8d..f2590f7de95 100644 --- a/.changeset/29736-mobile-sign-in-handoff-spec.md +++ b/.changeset/29736-mobile-sign-in-handoff-spec.md @@ -9,9 +9,9 @@ Metadata-protocol: added Some identity providers refuse embedded WebViews, so a mobile app signs in through the system browser and needs the session handed back to its WebView. This declares the contract the auth plugin serves for that, before the plugin enables it: -- **`AuthEndpointPaths.oneTimeTokenHandoff`** (`GET /one-time-token/handoff`) with `OneTimeTokenHandoffQuerySchema`: a closed query of `redirect_uri`, an RFC 7636 S256 `code_challenge` and an optional `code_challenge_method` (only `S256`). A signed-in session is redirected to `redirect_uri` with a single-use `token` that expires after one minute. An unregistered scheme, an unknown or repeated parameter, or a bad challenge is refused with `400 VALIDATION_ERROR` and no redirect. +- **`AuthEndpointPaths.oneTimeTokenHandoff`** (`GET /one-time-token/handoff`) with `OneTimeTokenHandoffQuerySchema`: a closed query of `redirect_uri` (an absolute URI), an RFC 7636 S256 `code_challenge` and an optional `code_challenge_method` (only `S256`). A signed-in session is redirected to `redirect_uri` with a single-use `token` that expires after one minute. An unregistered scheme, an unknown or repeated parameter, or a bad challenge is refused with `400 VALIDATION_ERROR` and no redirect. - **`AuthEndpointPaths.oneTimeTokenVerify`** (`POST /one-time-token/verify`, better-auth's own route) with `OneTimeTokenVerifyRequestSchema` and `OneTimeTokenVerifyResponseSchema`. The body is `{ token }`, where the token is followed by a dot and the code verifier. A callback intercepted by another app cannot be redeemed without the verifier. The response is better-auth's unwrapped `{ session, user }` with the session cookie. better-auth's `GET /one-time-token/generate` refuses client requests, so it is not declared. -- **`AuthConfig.trustedAppSchemes`** (new authorable key, beside `trustedOrigins`): the native-app schemes the hand-off may redirect to. Each entry is a `TrustedAppSchemeSchema`: a lowercase reverse-domain scheme written bare, with at least one dot (RFC 8252 section 7.1), such as `com.example.app`. A malformed entry is refused, not dropped or rewritten. That includes a `://` suffix, an uppercase letter, a dotless name such as `myapp`, and every web scheme such as `https`. If the list is absent or empty, the hand-off is not offered. +- **`AuthConfig.trustedAppSchemes`** (new authorable key, beside `trustedOrigins`): the native-app schemes the hand-off may redirect to. Each entry is a `TrustedAppSchemeSchema`: a lowercase reverse-domain scheme written bare: two or more non-empty labels joined by single dots, the first starting with a letter (RFC 8252 section 7.1), such as `com.example.app`. A malformed entry is refused, not dropped or rewritten. That includes a `://` suffix, an uppercase letter, a dotless name such as `myapp`, and every web scheme such as `https`. If the list is absent or empty, the hand-off is not offered. No `/auth/config` feature flag is added, because nothing reads one yet. diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index 5181f80feb4..02748193780 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -389,7 +389,7 @@ describe('mobile sign-in hand-off (one-time token)', () => { expect(result.error!.issues[0].code).toBe('unrecognized_keys'); }); - it('refuses the plain method, a malformed challenge and a missing challenge', () => { + it('refuses the plain method, a malformed challenge, a missing challenge and an empty redirect_uri', () => { const base = { redirect_uri: 'com.example.app://cb', code_challenge: challenge }; const plain = OneTimeTokenHandoffQuerySchema.safeParse({ ...base, code_challenge_method: 'plain' }); expect(plain.success).toBe(false); @@ -399,6 +399,9 @@ describe('mobile sign-in hand-off (one-time token)', () => { expect(short.error!.issues[0].path).toEqual(['code_challenge']); const padded = OneTimeTokenHandoffQuerySchema.safeParse({ ...base, code_challenge: `${challenge.slice(1)}=` }); expect(padded.success).toBe(false); + const emptyUri = OneTimeTokenHandoffQuerySchema.safeParse({ ...base, redirect_uri: '' }); + expect(emptyUri.success).toBe(false); + expect(emptyUri.error!.issues[0].path).toEqual(['redirect_uri']); const missing = OneTimeTokenHandoffQuerySchema.safeParse({ redirect_uri: base.redirect_uri }); expect(missing.success).toBe(false); expect(missing.error!.issues[0].path).toEqual(['code_challenge']); diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index 690b0a69984..8c78b0780ab 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -438,8 +438,8 @@ export type DeviceTokenResponse = z.input; * The parameter names are the OAuth 2.0 / RFC 7636 wire names, kept verbatim. */ export const OneTimeTokenHandoffQuerySchema = lazySchema(() => z.object({ - redirect_uri: z.string().describe( - 'Where to send the token: a URI whose scheme is registered in AuthConfig.trustedAppSchemes ' + redirect_uri: z.string().min(1).describe( + 'Where to send the token: an absolute URI whose scheme is registered in AuthConfig.trustedAppSchemes ' + '(e.g. com.example.app://auth/callback); any other scheme is refused with 400 and no redirect', ), code_challenge: z.string().regex(/^[A-Za-z0-9_-]{43}$/).describe( diff --git a/packages/spec/src/system/auth-config.test.ts b/packages/spec/src/system/auth-config.test.ts index 87d3437187a..50ac335d689 100644 --- a/packages/spec/src/system/auth-config.test.ts +++ b/packages/spec/src/system/auth-config.test.ts @@ -162,6 +162,7 @@ describe('AuthConfigSchema', () => { ['an uppercase scheme', 'Com.Example.App'], ['an empty label', 'com..app'], ['a trailing dot', 'com.example.'], + ['a leading dot', '.com.example'], ])('refuses %s in trustedAppSchemes (%s), never dropping or rewriting it', (_label, scheme) => { expect(TrustedAppSchemeSchema.safeParse(scheme).success).toBe(false); const result = AuthConfigSchema.safeParse({ trustedAppSchemes: ['com.example.app', scheme] }); diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 2bcaed805ec..42df324f621 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -547,7 +547,9 @@ export const AdvancedAuthConfigSchema = lazySchema(() => z.object({ * (`GET /api/v1/auth/one-time-token/handoff`, see `OneTimeTokenHandoffQuerySchema`). * * Strict, never normalised: a lowercase RFC 3986 scheme written bare (no `:` or - * `://` suffix) that contains at least one dot. The dot is RFC 8252 section 7.1's + * `://` suffix) made of two or more non-empty labels joined by single dots, the + * first starting with a letter (so `com..app`, `.com.app` and `com.example.` are + * refused). The dot is RFC 8252 section 7.1's * rule that a private-use scheme be a reverse domain name the app's owner * controls (`com.example.app`), which is also what keeps two apps on one device * from claiming the same scheme and intercepting the token. The rule refuses, by @@ -558,10 +560,11 @@ export const AdvancedAuthConfigSchema = lazySchema(() => z.object({ export const TrustedAppSchemeSchema = lazySchema(() => z.string().regex(/^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+$/, { message: 'An app scheme is a lowercase reverse-domain URI scheme written bare, e.g. "com.example.app": ' - + 'no ":" or "://" suffix, no uppercase, and at least one dot (RFC 8252 section 7.1). ' + + 'no ":" or "://" suffix, no uppercase, and two or more non-empty labels joined by single dots, ' + + 'the first starting with a letter (RFC 8252 section 7.1). ' + 'http, https and other web schemes are never app schemes.', }).describe( - 'A registered native-app URI scheme: lowercase, reverse-domain with at least one dot, written bare (e.g. com.example.app)', + 'A registered native-app URI scheme: lowercase, reverse-domain (two or more non-empty labels joined by single dots, the first starting with a letter), written bare (e.g. com.example.app)', )); export const AuthConfigSchema = lazySchema(() => z.object({ From 9f3ce17783b6e3a1cd94f44745ff9687663831d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 05:52:24 +0000 Subject: [PATCH 7/7] chore(spec): regenerate the authorable surface and reference pages after the main merge Claude-Session: https://claude.ai/code/session_01JQwxbXghRkYmSR5dVh8PFG Co-authored-by: Claude --- content/docs/references/api/auth-endpoints.mdx | 2 +- content/docs/references/system/auth-config.mdx | 2 +- packages/spec/authorable-surface/api.json | 3 +++ packages/spec/authorable-surface/system.json | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index b7700520f95..9778aa73122 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -221,7 +221,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **redirect_uri** | `string` | ✅ | Where to send the token: a URI whose scheme is registered in AuthConfig.trustedAppSchemes (e.g. com.example.app://auth/callback); any other scheme is refused with 400 and no redirect | +| **redirect_uri** | `string` | ✅ | Where to send the token: an absolute URI whose scheme is registered in AuthConfig.trustedAppSchemes (e.g. com.example.app://auth/callback); any other scheme is refused with 400 and no redirect | | **code_challenge** | `string` | ✅ | RFC 7636 S256 code challenge: the unpadded base64url SHA-256 of the verifier the app keeps (43 characters) | | **code_challenge_method** | `'S256'` | optional | RFC 7636 challenge method; only S256 is accepted (plain is refused), and absent means S256 | diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 0036d859665..849cab2475e 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -342,7 +342,7 @@ Social/OAuth provider map forwarded to better-auth socialProviders. Keys are pro ## TrustedAppScheme -A registered native-app URI scheme: lowercase, reverse-domain with at least one dot, written bare (e.g. com.example.app) +A registered native-app URI scheme: lowercase, reverse-domain (two or more non-empty labels joined by single dots, the first starting with a letter), written bare (e.g. com.example.app) **Type:** `string` diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 72f8fbc1d6e..23d8b3ed1f5 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -835,6 +835,7 @@ "api/GetMetaItemResponse:sortability", "api/GetMetaItemResponse:type", "api/GetMetaItemResponse:version", + "api/GetMetaItemsRequest:names", "api/GetMetaItemsRequest:packageId", "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", @@ -1444,6 +1445,7 @@ "api/PublishPackageDraftsResponse:unhiddenApps", "api/PublishPackageDraftsResponse:unhideError", "api/PushDeliveryData:notificationId", + "api/PushDeliveryData:origin", "api/PushDeliveryData:route", "api/QueryAdapterConfig:odata", "api/QueryAdapterConfig:operatorMappings", @@ -1547,6 +1549,7 @@ "api/RegisterDeviceRequest:deviceId", "api/RegisterDeviceRequest:locale", "api/RegisterDeviceRequest:name", + "api/RegisterDeviceRequest:origin", "api/RegisterDeviceRequest:platform", "api/RegisterDeviceRequest:token", "api/RegisterDeviceRequest:vendor", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 884a2a55f5c..07484fe16ad 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1406,6 +1406,7 @@ "system/UserDevice:lastSeenAt", "system/UserDevice:locale", "system/UserDevice:name", + "system/UserDevice:origin", "system/UserDevice:platform", "system/UserDevice:token", "system/UserDevice:userId",