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..f2590f7de95 --- /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 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: 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. + +Nothing to do for existing configurations. To offer the hand-off, list the app's scheme in `trustedAppSchemes`. diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 2a00bef7525..9778aa73122 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: 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 | + + +--- + +## 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..849cab2475e 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,8 +13,8 @@ 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 type { AdvancedAuthConfig, AudienceConfig, AuthConfig, AuthPluginConfig, AuthProviderConfig, EmailAndPasswordConfig, EmailVerificationConfig, MutualTLSConfig, OidcProviderConfig, OidcProvidersConfig, SocialProviderConfig } 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, TrustedAppScheme } from '@objectstack/spec/system'; // Validate data const result = AdvancedAuthConfigSchema.parse(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 (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/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..cfdd31535d7 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -809,6 +809,8 @@ "TranslationDiffStatusSchema (const)", "TranslationItem (type)", "TranslationItemSchema (const)", + "TrustedAppScheme (type)", + "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 fd5b5f0bc17..23d8b3ed1f5 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1274,6 +1274,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 83455a3fc87..07484fe16ad 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..5a25801fc67 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -537,6 +537,8 @@ "TranslationDiffStatusSchema": "system/TranslationDiffStatus", "TranslationItem": "system/TranslationItem", "TranslationItemSchema": "system/TranslationItem", + "TrustedAppScheme": "system/TrustedAppScheme", + "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..cb600304680 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -772,6 +772,8 @@ "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)", "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/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index 5ca56fc43f3..02748193780 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,83 @@ 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, 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); + 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 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']); + }); + + 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..8c78b0780ab 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().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( + '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..50ac335d689 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,35 @@ 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.'], + ['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] }); + 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..42df324f621 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -542,6 +542,31 @@ 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) 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 + * 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 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 (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({ secret: z.string().optional().describe('Encryption secret'), baseUrl: z.string().optional().describe('Base URL for auth routes'), @@ -573,6 +598,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, @@ -629,6 +673,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; 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