diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md new file mode 100644 index 00000000000..096f4cb0670 --- /dev/null +++ b/.changeset/29067-push-device-registry-spec.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): the push channel's protocol face, declared ahead of its producer: the widened device registration request, the stored device shape, optional `INotificationService` device members, the push delivery data with its same-server `route`, and the APNs / FCM transport configuration by `credentialRef` (ADR-0012 §6) + +Clause-②: yes (widening) +Metadata-protocol: added + +Nothing here is served or read yet. Every new or touched schema carries a `[PLANNED — not yet served]` or `[PLANNED — not yet read]` marker in its description, and no route row is added: the device routes land in `DEFAULT_NOTIFICATION_ROUTES` together with their handlers, so the catalog never advertises a route that answers 404. Nothing a client or an operator writes today is refused. + +- **`RegisterDeviceRequestSchema`** gains three optional members: `vendor` (`apns` | `fcm`; absent, the server derives it from `platform`, `ios` → `apns` and `android` → `fcm`, and a `web` registration names `apns` or `fcm`), `appId` and `locale` (a BCP 47 tag, the house `LocaleSchema`). `token`, `platform`, `deviceId` and `name` are unchanged, so every body a client sent before still parses as it did. The describes now say that re-registering a `deviceId` refreshes that device, and that a request without one gets a server-minted id the client keeps. A `vendor` outside `apns` / `fcm` is refused; since the member is new, no body sent before carries one. `RegisterDeviceResponseSchema` and `UnregisterDevice*` keep their members and gain only their description. +- **`UserDeviceSchema`** (new, `system/push-device.zod.ts`) is the stored device: `deviceId`, `userId`, `vendor`, `token`, `platform`, `enabled`, and optional `appId`, `locale`, `name`, `invalidatedAt` and `lastSeenAt`. A device belongs to its user alone (no organization member), a token has one live row (the same user's registration without a `deviceId` refreshes the row that holds it, and a second user's registration takes it over), and a token the vendor reports dead keeps its row with `enabled: false` and `invalidatedAt`. +- **`INotificationService`** gains four optional members: `registerDevice(userId, request)`, `unregisterDevice(userId, deviceId)`, `listDevices(userId)` and `invalidateDeviceToken(token)`. Their docs state the semantics an implementation owes. Absence means the capability is unavailable, so every existing implementation stays valid. +- **`PushDeliveryDataSchema`** (new, `api/push-delivery.zod.ts`) is the data a push carries to the shell: `notificationId` and an optional `route`. `PushRouteSchema` admits only a same-server relative path (one leading `/`; no scheme, host, backslash, whitespace or control character; no dot segment and no percent-encoded `/`, `\`, `.` or `%` in its path). The route is derived, never authored: it is the notification's existing `actionUrl` (app-relative `/OBJECT/ID` or an `/apps/…` route) when that passes the schema, and absent otherwise. The shell resolves it on tap as the inbox does, never verbatim. FCM carries this data in `message.data`; APNs at the top level of the payload, beside `aps`. No `notify` key is added. +- **`PushServiceConfigSchema`** (new, `system/push-config.zod.ts`, beside the email transport configuration): an `apns` block (token auth: `teamId`, `keyId`, `topic`, `environment: sandbox | production`, the `.p8` key by `credentialRef`) and an `fcm` block (HTTP v1: `projectId`, the service-account JSON by `credentialRef`). Secrets are references only (ADR-0143 D3). An inline secret is refused by name under the keys an operator would reach for, and refused as the content of a reference (a PEM block or a JSON document). `PushVendorSchema` names the two vendors. It is not a stack-definition key until its reader ships. diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index ab2e5e47354..2a07a1bdd46 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -1,7 +1,7 @@ --- title: API Protocol — complete schema reference navTitle: API Protocol -description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack API Protocol in 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults 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/. */} @@ -33,6 +33,7 @@ This section contains all protocol schemas for the api layer of ObjectStack. + diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index c9836a69072..3a723ef6c4d 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -37,6 +37,7 @@ "misc", "package-api-assembled", "package-lifecycle", + "push-delivery", "record-page", "sortability" ] diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 7b34900ed57..498519f23de 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2761,20 +2761,27 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' ## RegisterDeviceRequest +[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers + ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **token** | `string` | ✅ | Device push notification token | | **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform | -| **deviceId** | `string` | optional | Unique device identifier | +| **deviceId** | `string` | optional | Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; absent, the server mints one and answers it, and the client keeps it for later registrations and unregister | | **name** | `string` | optional | Device friendly name | +| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused | +| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID | +| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in | --- ## RegisterDeviceResponse +[PLANNED — not yet served] Answer to a device registration: the device id the client keeps + ### Properties | Property | Type | Required | Description | @@ -3019,6 +3026,8 @@ Uninstall package response ## UnregisterDeviceRequest +[PLANNED — not yet served] Unregister one of the signed-in user's push devices + ### Properties | Property | Type | Required | Description | @@ -3030,6 +3039,8 @@ Uninstall package response ## UnregisterDeviceResponse +[PLANNED — not yet served] Answer to a device unregistration + ### Properties | Property | Type | Required | Description | diff --git a/content/docs/references/api/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx new file mode 100644 index 00000000000..edfb78e9492 --- /dev/null +++ b/content/docs/references/api/push-delivery.mdx @@ -0,0 +1,92 @@ +--- +title: Push Delivery schema — API Protocol reference +navTitle: Push Delivery +description: "Push delivery data — what a push carries to the shell. Reference for PushDeliveryData, PushRoute: every property with its type and default." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Push delivery data — what a push carries to the shell. + +The shell reads it when the user taps the push: `notificationId` names the +notification (the same id the inbox lists and `markRead` takes), and +`route` is the notification's `actionUrl`, the link the inbox serves for it. + +**Where it sits in the vendor payload.** ADR-0012 §8 names it the `data` +member of the push renderer's output; that is the renderer's shape, not the +vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map +of string values. On APNs they sit at the top level of the payload, beside +`aps`, and never under a `data` key. + +**Declared ahead of its producer.** No push channel delivers yet, so +nothing produces this data today; `PushDeliveryDataSchema` carries a +`[PLANNED — not yet served]` marker in its `.describe()` until the push +channel ships. The route rule below is the contract that channel owes. + +**What `route` is — the inbox's `actionUrl`, derived, never authored.** +There is no `notify` key for it. It is the value the inbox already serves: +an explicit `url` / `actionUrl` in the notification's payload, else +`/OBJECT/ID` built from the notification's source record, else none. So it +is either app-relative (`/opportunity/123`) or already a Console route +(`/apps/crm/opportunity/123`). When that value passes +`PushRouteSchema` the push carries it as `route`; when it does not +(an absolute URL to another site, say) or there is none, the push carries +no `route` and the tap opens the shell's default screen. The delivery is +never failed for it. + +**The shell never navigates it verbatim.** On tap it resolves `route` +exactly as the inbox bell and Home do for the same notification: through +`resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`) +with its host app segment, which hosts an app-relative path under +`/apps/APP/` and passes an `/apps/…` route through. One resolver, so a +push and the inbox row open the same screen. + +A shell with no app list of its own (a native shell that has not loaded +the Console's catalog, say) does not guess either. It hands `route` to the +Console to resolve, or hosts it under the segment the Console would answer +(`resolveHostAppSegment` in the same file: the preferred app the user last +had open, re-checked against the active apps, then the first active app, +then `setup`). It never re-implements the resolver and never opens `route` +verbatim. + +Not to be confused with the `PushNotificationSchema` that `system/` once +declared and then removed (ADR-0049): that was an authoring template shape; +this is the wire data of a delivery, produced by the platform. + + +**Source:** `packages/spec/src/api/push-delivery.zod.ts` + + +## TypeScript Usage + +```typescript +import { PushDeliveryDataSchema, PushRouteSchema } from '@objectstack/spec/api'; +import type { PushDeliveryData, PushRoute } from '@objectstack/spec/api'; + +// Validate data +const result = PushDeliveryDataSchema.parse(data); +``` + +--- + +## PushDeliveryData + +[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route. No push channel delivers yet + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes | +| **route** | `string` | optional | The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. The shell resolves it on tap as the inbox bell and Home do, never verbatim | + + +--- + +## PushRoute + +The notification actionUrl as a same-server relative path: app-relative (/opportunity/123) or a Console route (/apps/crm/opportunity/123). The shell resolves it on tap as the inbox does, never verbatim; never an absolute or off-server URL + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 194eeb5d8af..4b1feb3488a 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 — 1596 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1603 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) | 34 | 471 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 35 | 473 | 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 | 174 | 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) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 39 | 292 | 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** | **205** | **1596** | 14 protocol modules | +| **Total** | **208** | **1603** | 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` · **34 pages, 471 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 473 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -93,6 +93,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `EnsureCustomizationPackageRequest`, `EnsureCustomizationPackageResponse`, `GetPackageCustomizationsRequest`, `GetPackageCustomizationsResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | | [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublicFormSubmitResponse`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | +| [`push-delivery.zod.ts`](/docs/references/api/push-delivery) | `PushDeliveryData`, `PushRoute` | | [`query-adapter.zod.ts`](/docs/references/api/query-adapter) | `ODataQueryAdapter`, `OperatorMapping`, `QueryAdapterConfig`, `QueryAdapterTarget`, `RestQueryAdapter` | | [`realtime.zod.ts`](/docs/references/api/realtime) | `RealtimeConfig`, `RealtimeEvent`, `RealtimeEventType`, `RealtimePresence`, `Subscription`, `SubscriptionEvent`, `TransportProtocol` | | [`realtime-shared.zod.ts`](/docs/references/api/realtime-shared) | `BasePresence`, `PresenceStatus`, `RealtimeRecordAction` | @@ -324,7 +325,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **37 pages, 287 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **39 pages, 292 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -355,6 +356,8 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`migration.zod.ts`](/docs/references/system/migration) | `AddFieldOperation`, `ChangeSet`, `CreateObjectOperation`, `DataMigrationFlag`, `DeleteObjectOperation`, `ExecuteSqlOperation`, `MigrationDependency`, `MigrationJournalEvent`, `MigrationOperation`, `ModifyFieldOperation`, `OrganizationOwnershipBirthMarker`, `RemoveFieldOperation`, `RenameObjectOperation` | | [`notification.zod.ts`](/docs/references/system/notification) | `NotificationChannel` | | [`object-storage.zod.ts`](/docs/references/system/object-storage) | `AccessControlConfig`, `BucketConfig`, `FileMetadata`, `LifecycleAction`, `LifecyclePolicyConfig`, `LifecyclePolicyRule`, `MultipartUploadConfig`, `ObjectMetadata`, `ObjectStorageConfig`, `PresignedUrlConfig`, `StorageAcl`, `StorageClass`, `StorageConnection`, `StorageProvider`, `StorageScope` | +| [`push-config.zod.ts`](/docs/references/system/push-config) | `ApnsTransportConfig`, `FcmTransportConfig`, `PushServiceConfig`, `PushVendor` | +| [`push-device.zod.ts`](/docs/references/system/push-device) | `UserDevice` | | [`registry-config.zod.ts`](/docs/references/system/registry-config) | `RegistryConfig`, `RegistrySyncPolicy`, `RegistryUpstream` | | [`search-engine.zod.ts`](/docs/references/system/search-engine) | `AnalyzerConfig`, `FacetConfig`, `SearchConfig`, `SearchIndexConfig`, `SearchProvider` | | [`security-context.zod.ts`](/docs/references/system/security-context) | `ComplianceAuditRequirement`, `ComplianceEncryptionRequirement`, `ComplianceFramework`, `DataClassification`, `DataClassificationPolicy`, `MaskingVisibilityRule`, `SecurityContextConfig`, `SecurityEventCorrelation` | diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx index a6d8e0451cd..70f0952a67e 100644 --- a/content/docs/references/system/index.mdx +++ b/content/docs/references/system/index.mdx @@ -1,7 +1,7 @@ --- title: System Protocol — complete schema reference navTitle: System Protocol -description: "The ObjectStack System Protocol in 37 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack System Protocol in 39 reference pages: every schema in @objectstack/spec with its properties, types, defaults 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/. */} @@ -34,6 +34,8 @@ This section contains all protocol schemas for the system layer of ObjectStack. + + diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index ebc13f270fd..2aaf272deec 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -42,6 +42,8 @@ "demo-staff", "dev-login", "fate-supplement", + "push-config", + "push-device", "sms-config", "stack-server" ] diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx new file mode 100644 index 00000000000..20b31ca09c7 --- /dev/null +++ b/content/docs/references/system/push-config.mdx @@ -0,0 +1,135 @@ +--- +title: Push Config schema — System Protocol reference +navTitle: Push Config +description: "Push Service Configuration Protocol — the push channel's transports. Declared ahead of its reader." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Push Service Configuration Protocol — the push channel's transports. + +**Declared ahead of its reader.** Every schema here carries a +`[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this +shape until the push channel plugin lands, so a value written to it today +changes nothing. The marker comes off with the change that wires the reader. + +Operator-facing configuration for the `push` channel ADR-0012 §4 ships in +core: Apple Push Notification service with token-based auth, and Firebase +Cloud Messaging over its HTTP v1 API. It sits beside +`EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of +the email channel's transport selection. + +**Secrets are references, never values (ADR-0143 D3, ADR-0097 §3).** Each +transport has one secret — the APNs `.p8` signing key and the FCM +service-account JSON — and each is named by a `credentialRef`, the same key +every other secret an author names is written under (ADR-0143 D3), which +the push channel resolves through the secrets layer when it sends (in the +open tier, the name of an environment variable). An inline secret is +refused two ways: written under a key an operator would reach for (`key`, +`privateKey`, `serviceAccount`, …), it is refused by name with +`credentialRef` as the prescription; written into the reference itself, it +is refused because a reference is one token — a PEM block carries +whitespace and a JSON document carries braces, and a name carries neither. + +**What reads it, and why it is not a stack key yet.** The push channel +plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it +is a later change than this declaration. The email transport selection it +sits beside is read from `config.email` of the stack definition +(`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own +configuration door in the change that ships the reader, not here, so a key +is never authorable before something honours it. + +Out of scope here: a hosted (cloud-side) configuration surface, and the +other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, +Xiaomi, OPPO, vivo). + + +**Source:** `packages/spec/src/system/push-config.zod.ts` + + +## TypeScript Usage + +```typescript +import { ApnsTransportConfigSchema, FcmTransportConfigSchema, PushServiceConfigSchema, PushVendorSchema } from '@objectstack/spec/system'; +import type { ApnsTransportConfig, FcmTransportConfig, PushServiceConfig, PushVendor } from '@objectstack/spec/system'; + +// Validate data +const result = ApnsTransportConfigSchema.parse(data); +``` + +--- + +## ApnsTransportConfig + +[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference) + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **teamId** | `string` | ✅ | Apple Developer Team ID (10 characters) that issued the signing key | +| **keyId** | `string` | ✅ | Key ID of the APNs auth key (10 characters) | +| **topic** | `string` | ✅ | APNs topic: the app bundle ID the pushes are for | +| **environment** | `Enum<'sandbox' \| 'production'>` | ✅ | APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds) | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | + + +--- + +## FcmTransportConfig + +[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference) + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | + + +--- + +## PushServiceConfig + +[PLANNED — not yet read] Push channel transport configuration: one optional block per vendor (apns, fcm). Nothing reads it until the push channel plugin ships + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **apns** | `{ teamId: string; keyId: string; topic: string; environment: Enum<'sandbox' \| 'production'>; … }` | optional | APNs transport (token-based auth). Absent: no delivery to apns devices | +| **fcm** | `{ projectId: string; credentialRef: string }` | optional | FCM HTTP v1 transport. Absent: no delivery to fcm devices | + +### Nested Shape: `PushServiceConfig.apns` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **teamId** | `string` | ✅ | Apple Developer Team ID (10 characters) that issued the signing key | +| **keyId** | `string` | ✅ | Key ID of the APNs auth key (10 characters) | +| **topic** | `string` | ✅ | APNs topic: the app bundle ID the pushes are for | +| **environment** | `Enum<'sandbox' \| 'production'>` | ✅ | APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds) | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | + +### Nested Shape: `PushServiceConfig.fcm` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | + + +--- + +## PushVendor + +Push vendor core delivers through: apns (Apple Push Notification service) or fcm (Firebase Cloud Messaging HTTP v1) + +### Allowed Values + +* `apns` +* `fcm` + + +--- + diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx new file mode 100644 index 00000000000..d88aa29e12a --- /dev/null +++ b/content/docs/references/system/push-device.mdx @@ -0,0 +1,86 @@ +--- +title: Push Device schema — System Protocol reference +navTitle: Push Device +description: "Push Device Protocol — the stored device shape of the push device registry." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Push Device Protocol — the stored device shape of the push device registry +(ADR-0012 §6, narrowed to what core ships). + +One row per (user, app install): what a device registration +(`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the +push channel reads to address a phone, and what the registry's listing +answers (`INotificationService.listDevices`). + +**Declared ahead of its producer.** No table stores it and no route serves +it yet; the schema carries a `[PLANNED — not yet served]` marker in its +`.describe()` until the device registry lands (in the push channel plugin's +`sys_user_device`, ADR-0012 §11). + +Three rules the registry owes, stated here because they decide the shape: + +- **(a) A device belongs to a user, not to an organization.** The row has no + organization member. A phone signed in to one person receives that + person's notifications whichever organization they come from; the + organization travels on the notification, never on the device. The + identity of a row is (`userId`, `deviceId`). +- **(b) One live row per token.** A vendor token is delivered to one row + only. A token this user already holds under another `deviceId` (the app's + storage was cleared, so the registration carries no `deviceId`) refreshes + that row and answers its `deviceId`; it never makes a second live row. + When a second user registers a token another user's device holds, the + token moves to the second user and the first user's row for it is + removed, so a push meant for the phone's previous user never reaches its + new one. +- **(c) A dead token keeps its row.** When the vendor reports a token dead + (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is + kept with `enabled: false` and `invalidatedAt` set, so "why did this phone + stop getting pushes" stays answerable. Nothing is delivered to a disabled + row, and a later registration of the same device re-enables it and clears + `invalidatedAt`. + +Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the +domestic Android vendors plug in as alternate channel implementations) and +`platform` is the registration's own three (`ios` | `android` | `web`). + + +**Source:** `packages/spec/src/system/push-device.zod.ts` + + +## TypeScript Usage + +```typescript +import { UserDeviceSchema } from '@objectstack/spec/system'; +import type { UserDevice } from '@objectstack/spec/system'; + +// Validate data +const result = UserDeviceSchema.parse(data); +``` + +--- + +## UserDevice + +[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; the device registry lands with the push channel + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **deviceId** | `string` | ✅ | Client-held device id, stable per app install: the one the registration carried or the server minted. Unique per user | +| **userId** | `string` | ✅ | The user the device belongs to: the session user who registered it. No organization member | +| **vendor** | `Enum<'apns' \| 'fcm'>` | ✅ | Push vendor the token belongs to: the registration's vendor, or the one derived from platform | +| **token** | `string` | ✅ | Vendor push token. Held by one live device at a time: a second user's registration moves it | +| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform, as the registration named it | +| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID | +| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in | +| **name** | `string` | optional | Device friendly name, as the registration named it | +| **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device | +| **invalidatedAt** | `string` | optional | When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, and cleared by the registration that re-enables it | +| **lastSeenAt** | `string` | optional | When the device last registered or refreshed. Every registration and refresh sets it, so it is absent only on a row no registration wrote; listDevices answers such a device last | + + +--- + 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 fb80356f948..2124b03f462 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/` | 487 | +| `api/` | 488 | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 3c1fa09d7cb..57a076a97fc 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 390 | +| `system/` | 394 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 49e0ba93f57..376090a851d 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -836,6 +836,10 @@ "PublishPackageDraftsRequestSchema (const)", "PublishPackageDraftsResponse (type)", "PublishPackageDraftsResponseSchema (const)", + "PushDeliveryData (type)", + "PushDeliveryDataSchema (const)", + "PushRoute (type)", + "PushRouteSchema (const)", "QueryAdapterConfig (type)", "QueryAdapterConfigParsed (type)", "QueryAdapterConfigSchema (const)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 438310b2db7..1ac5c7b2198 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -17,6 +17,8 @@ "AdvancedAuthConfigSchema (const)", "AnalyzerConfig (type)", "AnalyzerConfigSchema (const)", + "ApnsTransportConfig (type)", + "ApnsTransportConfigSchema (const)", "AppCompatibilityCheck (type)", "AppCompatibilityCheckParsed (type)", "AppCompatibilityCheckSchema (const)", @@ -254,6 +256,8 @@ "FailoverConfigSchema (const)", "FailoverMode (type)", "FailoverModeSchema (const)", + "FcmTransportConfig (type)", + "FcmTransportConfigSchema (const)", "Feature (type)", "FeatureParsed (type)", "FeatureSchema (const)", @@ -534,6 +538,10 @@ "PlatformTranslationDataSchema (const)", "PresignedUrlConfig (type)", "PresignedUrlConfigSchema (const)", + "PushServiceConfig (type)", + "PushServiceConfigSchema (const)", + "PushVendor (type)", + "PushVendorSchema (const)", "QueueConfig (const)", "QueueConfig (type)", "QueueConfigParsed (type)", @@ -803,6 +811,8 @@ "TranslationItemSchema (const)", "UserActivityStatus (const)", "UserActivityStatus (type)", + "UserDevice (type)", + "UserDeviceSchema (const)", "VALIDATION_MESSAGE_FALLBACK_LOCALE (const)", "VALIDATION_MESSAGE_KEY_PREFIX (const)", "VALUE_SHAPES_MIGRATION_ID (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 9471c74aa49..85fb30b1dd3 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1423,6 +1423,8 @@ "api/PublishPackageDraftsResponse:success", "api/PublishPackageDraftsResponse:unhiddenApps", "api/PublishPackageDraftsResponse:unhideError", + "api/PushDeliveryData:notificationId", + "api/PushDeliveryData:route", "api/QueryAdapterConfig:odata", "api/QueryAdapterConfig:operatorMappings", "api/QueryAdapterConfig:rest", @@ -1521,10 +1523,13 @@ "api/RecordPageTimelineRequest:discussion", "api/RecordPageTimelineRequest:fieldChanges", "api/RefreshTokenRequest:refreshToken", + "api/RegisterDeviceRequest:appId", "api/RegisterDeviceRequest:deviceId", + "api/RegisterDeviceRequest:locale", "api/RegisterDeviceRequest:name", "api/RegisterDeviceRequest:platform", "api/RegisterDeviceRequest:token", + "api/RegisterDeviceRequest:vendor", "api/RegisterDeviceResponse:deviceId", "api/RegisterDeviceResponse:success", "api/RegisterRequest:email", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 3d7d1b68865..71186982f34 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -29,6 +29,11 @@ "system/AnalyzerConfig:language", "system/AnalyzerConfig:stopwords", "system/AnalyzerConfig:type", + "system/ApnsTransportConfig:credentialRef", + "system/ApnsTransportConfig:environment", + "system/ApnsTransportConfig:keyId", + "system/ApnsTransportConfig:teamId", + "system/ApnsTransportConfig:topic", "system/AppCompatibilityCheck:compatible", "system/AppCompatibilityCheck:issues", "system/AppInstallRequest:appId", @@ -435,6 +440,8 @@ "system/FailoverConfig:healthCheckIntervalSeconds", "system/FailoverConfig:mode", "system/FailoverConfig:regions", + "system/FcmTransportConfig:credentialRef", + "system/FcmTransportConfig:projectId", "system/Feature:code", "system/Feature:description", "system/Feature:label", @@ -928,6 +935,8 @@ "system/PresignedUrlConfig:operation", "system/PresignedUrlConfig:responseContentDisposition", "system/PresignedUrlConfig:responseContentType", + "system/PushServiceConfig:apns", + "system/PushServiceConfig:fcm", "system/QueueConfig:autoScale", "system/QueueConfig:concurrency", "system/QueueConfig:deadLetterQueue", @@ -1389,6 +1398,17 @@ "system/TranslationItem:positions", "system/TranslationItem:reports", "system/TranslationItem:settingsCommon", + "system/UserDevice:appId", + "system/UserDevice:deviceId", + "system/UserDevice:enabled", + "system/UserDevice:invalidatedAt", + "system/UserDevice:lastSeenAt", + "system/UserDevice:locale", + "system/UserDevice:name", + "system/UserDevice:platform", + "system/UserDevice:token", + "system/UserDevice:userId", + "system/UserDevice:vendor", "system/VectorClock:clock", "system/WorkerStats:active", "system/WorkerStats:avgExecutionMs", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 8fe294f4f8d..c4243b537c9 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -613,6 +613,10 @@ "PublishPackageDraftsRequestSchema": "api/PublishPackageDraftsRequest", "PublishPackageDraftsResponse": "api/PublishPackageDraftsResponse", "PublishPackageDraftsResponseSchema": "api/PublishPackageDraftsResponse", + "PushDeliveryData": "api/PushDeliveryData", + "PushDeliveryDataSchema": "api/PushDeliveryData", + "PushRoute": "api/PushRoute", + "PushRouteSchema": "api/PushRoute", "QueryAdapterConfig": "api/QueryAdapterConfig", "QueryAdapterConfigSchema": "api/QueryAdapterConfig", "QueryAdapterTarget": "api/QueryAdapterTarget", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index b68745bb36f..2667d05f1d1 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -11,6 +11,8 @@ "AdvancedAuthConfigSchema": "system/AdvancedAuthConfig", "AnalyzerConfig": "system/AnalyzerConfig", "AnalyzerConfigSchema": "system/AnalyzerConfig", + "ApnsTransportConfig": "system/ApnsTransportConfig", + "ApnsTransportConfigSchema": "system/ApnsTransportConfig", "AppCompatibilityCheck": "system/AppCompatibilityCheck", "AppCompatibilityCheckSchema": "system/AppCompatibilityCheck", "AppInstallRequest": "system/AppInstallRequest", @@ -166,6 +168,8 @@ "FailoverConfigSchema": "system/FailoverConfig", "FailoverMode": "system/FailoverMode", "FailoverModeSchema": "system/FailoverMode", + "FcmTransportConfig": "system/FcmTransportConfig", + "FcmTransportConfigSchema": "system/FcmTransportConfig", "Feature": "system/Feature", "FeatureSchema": "system/Feature", "FieldEncryption": "system/FieldEncryption", @@ -348,6 +352,10 @@ "PlatformTranslationDataSchema": "system/PlatformTranslationData", "PresignedUrlConfig": "system/PresignedUrlConfig", "PresignedUrlConfigSchema": "system/PresignedUrlConfig", + "PushServiceConfig": "system/PushServiceConfig", + "PushServiceConfigSchema": "system/PushServiceConfig", + "PushVendor": "system/PushVendor", + "PushVendorSchema": "system/PushVendor", "QueueConfig": "system/QueueConfig", "QueueConfigSchema": "system/QueueConfig", "QuotaEnforcementResult": "system/QuotaEnforcementResult", @@ -530,6 +538,8 @@ "TranslationItem": "system/TranslationItem", "TranslationItemSchema": "system/TranslationItem", "UserActivityStatus": "system/UserActivityStatus", + "UserDevice": "system/UserDevice", + "UserDeviceSchema": "system/UserDevice", "VectorClock": "system/VectorClock", "VectorClockSchema": "system/VectorClock", "WorkerStats": "system/WorkerStats", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index d034a0295fe..99bb8369ee7 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -807,6 +807,10 @@ "PublishPackageDraftsRequestSchema": "src/api/protocol.zod.ts#PublishPackageDraftsRequestSchema (const)", "PublishPackageDraftsResponse": "src/api/protocol.zod.ts#PublishPackageDraftsResponse (type)", "PublishPackageDraftsResponseSchema": "src/api/protocol.zod.ts#PublishPackageDraftsResponseSchema (const)", + "PushDeliveryData": "src/api/push-delivery.zod.ts#PushDeliveryData (type)", + "PushDeliveryDataSchema": "src/api/push-delivery.zod.ts#PushDeliveryDataSchema (const)", + "PushRoute": "src/api/push-delivery.zod.ts#PushRoute (type)", + "PushRouteSchema": "src/api/push-delivery.zod.ts#PushRouteSchema (const)", "QueryAdapterConfig": "src/api/query-adapter.zod.ts#QueryAdapterConfig (type)", "QueryAdapterConfigParsed": "src/api/query-adapter.zod.ts#QueryAdapterConfigParsed (type)", "QueryAdapterConfigSchema": "src/api/query-adapter.zod.ts#QueryAdapterConfigSchema (const)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 3739d2d59a3..84dea07f904 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -17,6 +17,8 @@ "AdvancedAuthConfigSchema": "src/system/auth-config.zod.ts#AdvancedAuthConfigSchema (const)", "AnalyzerConfig": "src/system/search-engine.zod.ts#AnalyzerConfig (type)", "AnalyzerConfigSchema": "src/system/search-engine.zod.ts#AnalyzerConfigSchema (const)", + "ApnsTransportConfig": "src/system/push-config.zod.ts#ApnsTransportConfig (type)", + "ApnsTransportConfigSchema": "src/system/push-config.zod.ts#ApnsTransportConfigSchema (const)", "AppCompatibilityCheck": "src/system/app-install.zod.ts#AppCompatibilityCheck (type)", "AppCompatibilityCheckParsed": "src/system/app-install.zod.ts#AppCompatibilityCheckParsed (type)", "AppCompatibilityCheckSchema": "src/system/app-install.zod.ts#AppCompatibilityCheckSchema (const)", @@ -246,6 +248,8 @@ "FailoverConfigSchema": "src/system/disaster-recovery.zod.ts#FailoverConfigSchema (const)", "FailoverMode": "src/system/disaster-recovery.zod.ts#FailoverMode (type)", "FailoverModeSchema": "src/system/disaster-recovery.zod.ts#FailoverModeSchema (const)", + "FcmTransportConfig": "src/system/push-config.zod.ts#FcmTransportConfig (type)", + "FcmTransportConfigSchema": "src/system/push-config.zod.ts#FcmTransportConfigSchema (const)", "Feature": "src/system/license.zod.ts#Feature (type)", "FeatureParsed": "src/system/license.zod.ts#FeatureParsed (type)", "FeatureSchema": "src/system/license.zod.ts#FeatureSchema (const)", @@ -513,6 +517,10 @@ "PlatformTranslationDataSchema": "src/system/translation.zod.ts#PlatformTranslationDataSchema (const)", "PresignedUrlConfig": "src/system/object-storage.zod.ts#PresignedUrlConfig (type)", "PresignedUrlConfigSchema": "src/system/object-storage.zod.ts#PresignedUrlConfigSchema (const)", + "PushServiceConfig": "src/system/push-config.zod.ts#PushServiceConfig (type)", + "PushServiceConfigSchema": "src/system/push-config.zod.ts#PushServiceConfigSchema (const)", + "PushVendor": "src/system/push-config.zod.ts#PushVendor (type)", + "PushVendorSchema": "src/system/push-config.zod.ts#PushVendorSchema (const)", "QueueConfig": "src/system/worker.zod.ts#QueueConfig (type)", "QueueConfigParsed": "src/system/worker.zod.ts#QueueConfigParsed (type)", "QueueConfigSchema": "src/system/worker.zod.ts#QueueConfigSchema (const)", @@ -765,6 +773,8 @@ "TranslationItem": "src/system/translation.zod.ts#TranslationItem (type)", "TranslationItemSchema": "src/system/translation.zod.ts#TranslationItemSchema (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)", "VALIDATION_MESSAGE_FALLBACK_LOCALE": "src/system/validation-message.ts#VALIDATION_MESSAGE_FALLBACK_LOCALE (const)", "VALIDATION_MESSAGE_KEY_PREFIX": "src/system/validation-message.ts#VALIDATION_MESSAGE_KEY_PREFIX (const)", "VALUE_SHAPES_MIGRATION_ID": "src/system/migration.zod.ts#VALUE_SHAPES_MIGRATION_ID (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index f39f4ac6218..9872fdfaa37 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -334,6 +334,8 @@ "api/PublishMetaItemResponse", "api/PublishPackageDraftsRequest", "api/PublishPackageDraftsResponse", + "api/PushDeliveryData", + "api/PushRoute", "api/QueryAdapterConfig", "api/QueryAdapterTarget", "api/QueryOptimizationConfig", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 1ab9724c7cd..44877f71954 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -7,6 +7,7 @@ "system/AddFieldOperation", "system/AdvancedAuthConfig", "system/AnalyzerConfig", + "system/ApnsTransportConfig", "system/AppCompatibilityCheck", "system/AppInstallRequest", "system/AppInstallResult", @@ -89,6 +90,7 @@ "system/FacetConfig", "system/FailoverConfig", "system/FailoverMode", + "system/FcmTransportConfig", "system/Feature", "system/FieldEncryption", "system/FieldTranslation", @@ -189,6 +191,8 @@ "system/PlatformTranslationBundle", "system/PlatformTranslationData", "system/PresignedUrlConfig", + "system/PushServiceConfig", + "system/PushVendor", "system/QueueConfig", "system/QuotaEnforcementResult", "system/RPO", @@ -287,6 +291,7 @@ "system/TranslationDiffStatus", "system/TranslationItem", "system/UserActivityStatus", + "system/UserDevice", "system/VectorClock", "system/WorkerStats" ] diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index 5af8969ab70..d69713fcb3d 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (213 schemas) +## 3. Schema Inventory by Domain (216 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -85,10 +85,10 @@ here is verifiable from the installed package. | Domain | Count | Key Schemas | |--------|-------|-------------| -| system | 37 | Auth, Cache, Demo Staff, Dev Login, Email / SMS Config, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics | +| system | 39 | Auth, Cache, Demo Staff, Dev Login, Email / SMS / Push Config, Push Device, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics | | kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry, Override Declaration | | data | 32 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube, Seed | -| api | 33 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle | +| api | 34 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle, Push Delivery | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index e25be91e0e9..d6f5dcb3efd 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -61,6 +61,7 @@ export * from './errors.zod'; export { zodIssuesToFields } from './zod-issues-to-fields'; export * from './error-code-ledger.zod'; export * from './protocol.zod'; +export * from './push-delivery.zod'; // [#10235] Per-column sortability projection: the serve-time signal the object // metadata read publishes so grids never re-derive "virtual ⇒ unsortable" from // field type (2026-08-23 ruling, option A). diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 59c1daeffb6..86476ea097b 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -28,7 +28,9 @@ import { import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod'; import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod'; import { ActionDescriptorSchema } from '../automation/node-executor.zod'; -import { ServedTranslationDataSchema } from '../system/translation.zod'; +import { ServedTranslationDataSchema, LocaleSchema } from '../system/translation.zod'; +import { PushVendorSchema } from '../system/push-config.zod'; +import { PUSH_DEVICE_PLATFORMS } from '../system/push-device-platform'; // #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item // responses publish. Same three vocabularies the resolver filters against, so a // value this spec cannot name is a value the resolver would have dropped. @@ -3507,25 +3509,61 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({ // Notification Operations // ========================================== +/** + * Register or refresh a push device for the signed-in user (ADR-0012 §6). + * + * **Declared ahead of its producer.** No route serves this body yet: the + * device routes land together with their handlers, and until then each of + * the four device schemas below carries a `[PLANNED — not yet served]` marker + * in its `.describe()`. What follows is the contract that route owes, not a + * description of something running. + * + * One request does both: registering a `deviceId` the user already has + * refreshes that device (its token, vendor, app and locale) instead of adding + * a second one. A request without `deviceId` gets one minted by the server + * and answered in {@link RegisterDeviceResponseSchema}; the client keeps it + * and sends it on every later registration and on unregister. + * + * The members after `name` (`vendor`, `appId`, `locale`) were added for the + * push channel, all optional, so the body a client sent before still parses + * unchanged. The device's owner is the session's user, never a body member. + * The stored device a registration produces is `UserDeviceSchema` + * (`system/push-device.zod.ts`). + */ +// The marker comes off when the server device-registry slice (#29068) mounts +// POST /api/v1/notifications/devices and the unregister route, adds their rows +// to DEFAULT_NOTIFICATION_ROUTES and flips that table's catalog pin. export const RegisterDeviceRequestSchema = lazySchema(() => z.object({ token: z.string().describe('Device push notification token'), - platform: z.enum(['ios', 'android', 'web']).describe('Device platform'), - deviceId: z.string().optional().describe('Unique device identifier'), + platform: z.enum(PUSH_DEVICE_PLATFORMS).describe('Device platform'), + deviceId: z.string().optional().describe( + 'Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; ' + + 'absent, the server mints one and answers it, and the client keeps it for later registrations and unregister', + ), name: z.string().optional().describe('Device friendly name'), -})); + vendor: PushVendorSchema.optional().describe( + 'Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, ' + + 'android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused', + ), + appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), + locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), +}).describe( + '[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; ' + + 'the device routes land together with their handlers', +)); export const RegisterDeviceResponseSchema = lazySchema(() => z.object({ deviceId: z.string().describe('Registered device ID'), success: z.boolean().describe('Whether registration succeeded'), -})); +}).describe('[PLANNED — not yet served] Answer to a device registration: the device id the client keeps')); export const UnregisterDeviceRequestSchema = lazySchema(() => z.object({ deviceId: z.string().describe('Device ID to unregister'), -})); +}).describe('[PLANNED — not yet served] Unregister one of the signed-in user\'s push devices')); export const UnregisterDeviceResponseSchema = lazySchema(() => z.object({ success: z.boolean().describe('Whether unregistration succeeded'), -})); +}).describe('[PLANNED — not yet served] Answer to a device unregistration')); export const NotificationPreferencesSchema = lazySchema(() => z.object({ email: z.boolean().default(true).describe('Receive email notifications'), diff --git a/packages/spec/src/api/push-delivery.test.ts b/packages/spec/src/api/push-delivery.test.ts new file mode 100644 index 00000000000..929b6a47b3c --- /dev/null +++ b/packages/spec/src/api/push-delivery.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The data a push delivery carries to the shell. Its `route` is the +// notification's actionUrl, which the shell resolves on tap as the inbox does, +// so the contract admits only a same-server relative path: a value that could +// navigate anywhere else (another origin, a scheme, a protocol-relative +// reference, a path a URL parser rewrites into one, or a path a router would +// normalise or decode elsewhere) is refused here, and the server leaves +// `route` out instead of sending it. + +import { describe, it, expect } from 'vitest'; +import { PushDeliveryDataSchema, PushRouteSchema } from './push-delivery.zod'; + +describe('PushDeliveryDataSchema', () => { + // The two shapes the inbox serves as actionUrl: app-relative (the shell + // hosts it under its app) and a route already addressed to an app. + it.each(['/opportunity/123', '/apps/crm/opportunity/123'])('accepts the inbox actionUrl %j as route', (route) => { + const data = { notificationId: 'ntf_1', route }; + expect(PushDeliveryDataSchema.parse(data)).toEqual(data); + }); + + it('accepts an absent route', () => { + expect(PushDeliveryDataSchema.parse({ notificationId: 'ntf_1' })).toEqual({ notificationId: 'ntf_1' }); + }); + + it('accepts the derived record route `/OBJECT/ID` and a query or fragment', () => { + expect(PushRouteSchema.safeParse('/opportunity/123').success).toBe(true); + expect(PushRouteSchema.safeParse('/apps/crm/opportunity/123?tab=notes#activity').success).toBe(true); + expect(PushRouteSchema.safeParse('/').success).toBe(true); + }); + + it.each(['/apps/crm/.well-known', '/apps/crm/...', '/apps/crm/x?next=%2Fapps%2Fcrm', '/apps/crm/x#%2e%2e', '/apps/crm/x?q=100%25'])( + 'accepts %j: no dot segment, and a query or fragment keeps its own encoding', + (route) => { + expect(PushRouteSchema.safeParse(route).success).toBe(true); + }, + ); + + it('requires a notification id', () => { + const parsed = PushDeliveryDataSchema.safeParse({ route: '/x' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['notificationId']); + }); + + it.each([ + 'https://evil.example/x', + '//evil.example', + 'javascript:alert(1)', + 'apps/crm/opportunity/123', + '', + // A URL parser reads a backslash as a slash and strips tab / newline, so + // each of these becomes a protocol-relative reference once parsed. + '/\\evil.example', + '/\t/evil.example', + '/\n/evil.example', + '/apps/crm\\..\\x', + // A router that normalises or decodes the path would open somewhere else. + '/../../api/v1/me', + '/%2e%2e/x', + '/./x', + '/a%2fb', + '/a%5cb', + '/apps/crm/..', + '/a%2Fb', + '/apps/crm/./x?tab=notes', + // Double-encoded: one decode leaves %2F / %2E / %5C for the next one. + '/a%252Fb', + '/%252e%252e/x', + '/a%255cb', + ])('refuses route %j', (route) => { + const parsed = PushDeliveryDataSchema.safeParse({ notificationId: 'ntf_1', route }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['route']); + }); +}); + +describe('PushDeliveryDataSchema is declared, not yet served', () => { + it('carries the PLANNED marker until a push channel produces it', () => { + expect(PushDeliveryDataSchema.description).toMatch(/^\[PLANNED — /); + }); +}); diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts new file mode 100644 index 00000000000..f69f8a90678 --- /dev/null +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + +/** + * Push delivery data — what a push carries to the shell. + * + * The shell reads it when the user taps the push: `notificationId` names the + * notification (the same id the inbox lists and `markRead` takes), and + * `route` is the notification's `actionUrl`, the link the inbox serves for it. + * + * **Where it sits in the vendor payload.** ADR-0012 §8 names it the `data` + * member of the push renderer's output; that is the renderer's shape, not the + * vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map + * of string values. On APNs they sit at the top level of the payload, beside + * `aps`, and never under a `data` key. + * + * **Declared ahead of its producer.** No push channel delivers yet, so + * nothing produces this data today; {@link PushDeliveryDataSchema} carries a + * `[PLANNED — not yet served]` marker in its `.describe()` until the push + * channel ships. The route rule below is the contract that channel owes. + * + * **What `route` is — the inbox's `actionUrl`, derived, never authored.** + * There is no `notify` key for it. It is the value the inbox already serves: + * an explicit `url` / `actionUrl` in the notification's payload, else + * `/OBJECT/ID` built from the notification's source record, else none. So it + * is either app-relative (`/opportunity/123`) or already a Console route + * (`/apps/crm/opportunity/123`). When that value passes + * {@link PushRouteSchema} the push carries it as `route`; when it does not + * (an absolute URL to another site, say) or there is none, the push carries + * no `route` and the tap opens the shell's default screen. The delivery is + * never failed for it. + * + * **The shell never navigates it verbatim.** On tap it resolves `route` + * exactly as the inbox bell and Home do for the same notification: through + * `resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`) + * with its host app segment, which hosts an app-relative path under + * `/apps/APP/` and passes an `/apps/…` route through. One resolver, so a + * push and the inbox row open the same screen. + * + * A shell with no app list of its own (a native shell that has not loaded + * the Console's catalog, say) does not guess either. It hands `route` to the + * Console to resolve, or hosts it under the segment the Console would answer + * (`resolveHostAppSegment` in the same file: the preferred app the user last + * had open, re-checked against the active apps, then the first active app, + * then `setup`). It never re-implements the resolver and never opens `route` + * verbatim. + * + * Not to be confused with the `PushNotificationSchema` that `system/` once + * declared and then removed (ADR-0049): that was an authoring template shape; + * this is the wire data of a delivery, produced by the platform. + */ + +/** + * A same-server relative path: it starts with exactly one `/`, so it carries + * no scheme and no host, and it contains no backslash, whitespace or control + * character — a URL parser reads `\` as `/` and strips tab and newline, which + * would turn `/\host` — or `/`, a tab, then `/host` — into a + * protocol-relative reference to another site. + * + * Its path (the part before any `?` or `#`) also carries no dot segment (`.` + * or `..`), no percent-encoded `/`, `\` or `.` (`%2F`, `%5C`, `%2E`, either + * case) and no encoded `%` (`%25`), which closes the double-encoded forms + * (`%252F`, `%252E`, `%255C`): a router that normalises or decodes the path, + * once or more, would move it somewhere the notification did not point. A + * path that needs a literal `%` is refused with them, so its push carries no + * `route` and the tap opens the shell's default screen. Both rules are + * patterns, so the published JSON Schema carries them too. A query or + * fragment keeps its own encoding. + */ +export const PushRouteSchema = z.string() + .regex(/^\/(?![/\\])[^\s\\\x00-\x1f\x7f]*$/, { + message: 'A push route is a same-server relative path: it starts with a single "/" and carries no scheme, host, ' + + 'backslash, whitespace or control character.', + }) + .regex(/^(?![^?#]*(?:\/\.\.?(?:[/?#]|$)|%(?:2[eEfF]|5[cC]|25)))/, { + message: 'A push route path carries no dot segment ("." or "..") and no percent-encoded "/", "\\", "." or "%" ' + + '(%2F, %5C, %2E, %25): a router that normalises or decodes the path would open somewhere else.', + }) + .describe( + 'The notification actionUrl as a same-server relative path: app-relative (/opportunity/123) or a Console route ' + + '(/apps/crm/opportunity/123). The shell resolves it on tap as the inbox does, never verbatim; never an absolute or ' + + 'off-server URL', + ); +export type PushRoute = z.input; + +/** The data a push delivery carries to the shell. */ +// The marker comes off when the push channel (#29041) produces this data. +export const PushDeliveryDataSchema = lazySchema(() => z.object({ + notificationId: z.string().min(1).describe('Notification id — the one the inbox lists and markRead takes'), + route: PushRouteSchema.optional().describe( + 'The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. ' + + 'The shell resolves it on tap as the inbox bell and Home do, never verbatim', + ), +}).describe( + '[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a ' + + 'same-server route. No push channel delivers yet', +)); +export type PushDeliveryData = z.input; diff --git a/packages/spec/src/api/register-device-request.test.ts b/packages/spec/src/api/register-device-request.test.ts new file mode 100644 index 00000000000..13fa98a8cee --- /dev/null +++ b/packages/spec/src/api/register-device-request.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The device registration request is WIDENED for the push channel (ADR-0012 +// §6): it gains an optional `vendor`, `appId` and `locale`. Pure widening, so +// the body every client sends today must keep parsing unchanged, and the new +// members are optional. The one value the widening refuses is a vendor +// outside the pair core delivers through (`apns` / `fcm`); other vendors are +// alternate channel plugins (ADR-0012 §6), not values of this field. + +import { describe, it, expect } from 'vitest'; +import { + RegisterDeviceRequestSchema, + RegisterDeviceResponseSchema, + UnregisterDeviceRequestSchema, + UnregisterDeviceResponseSchema, +} from './protocol.zod'; + +describe('RegisterDeviceRequestSchema (push device registry)', () => { + it('still parses the minimal body a client sends today, unchanged', () => { + const body = { token: 'fcm_token_xyz', platform: 'android' }; + const parsed = RegisterDeviceRequestSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(body); + }); + + it('still parses the full pre-widening body (deviceId and name stay optional members)', () => { + const body = { token: 't', platform: 'ios', deviceId: 'install-1', name: 'iPhone' }; + expect(RegisterDeviceRequestSchema.parse(body)).toEqual(body); + }); + + it('round-trips the widened members: vendor, appId, locale', () => { + const body = { + token: 'apns-device-token', + platform: 'ios', + vendor: 'apns', + deviceId: 'install-1', + appId: 'com.example.shell', + locale: 'zh-CN', + name: 'iPhone', + } as const; + const parsed = RegisterDeviceRequestSchema.safeParse(body); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(body); + expect(RegisterDeviceRequestSchema.parse({ token: 't', platform: 'android', vendor: 'fcm' }).vendor).toBe('fcm'); + }); + + it('applies no default for an absent vendor: the server derives it from platform', () => { + // No `.default()` on purpose: the derivation (ios → apns, android → fcm) + // is the server's, stated in the describe, and the bare alias stays + // isomorphic (type-alias-convention.pin.test.ts). + const parsed = RegisterDeviceRequestSchema.parse({ token: 't', platform: 'ios' }); + expect('vendor' in parsed).toBe(false); + }); + + // web has no default vendor, and the schema does not tie a vendor to a + // platform: a web registration names either one. Refusing a web body WITHOUT + // vendor is the route's (the server device-registry slice), not this schema's. + it.each(['apns', 'fcm'] as const)('admits a web registration naming vendor %s', (vendor) => { + const body = { token: 't', platform: 'web', vendor } as const; + expect(RegisterDeviceRequestSchema.parse(body)).toEqual(body); + }); + + it.each(['hms', 'xiaomi', 'web_push', 'APNS', ''])('refuses vendor %j (core delivers through apns / fcm only)', (vendor) => { + const parsed = RegisterDeviceRequestSchema.safeParse({ token: 't', platform: 'android', vendor }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['vendor']); + expect(parsed.error?.issues[0]?.code).toBe('invalid_value'); + }); + + // The locale a push is rendered in must be a tag a translation bundle can + // answer: the house LocaleSchema, the shape every other locale key takes. + it.each(['zh_CN', 'Chinese', 'zh-CN ', ''])('refuses locale %j (not a BCP-47 language tag)', (locale) => { + const parsed = RegisterDeviceRequestSchema.safeParse({ token: 't', platform: 'ios', locale }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['locale']); + expect(parsed.error?.issues[0]?.code).toBe('invalid_format'); + }); + + it.each(['en', 'zh-CN', 'zh-Hans-CN', 'es-419'])('accepts locale %j', (locale) => { + expect(RegisterDeviceRequestSchema.safeParse({ token: 't', platform: 'ios', locale }).success).toBe(true); + }); + + it('keeps the response and unregister shapes as they were', () => { + expect(RegisterDeviceResponseSchema.parse({ deviceId: 'install-1', success: true })) + .toEqual({ deviceId: 'install-1', success: true }); + expect(UnregisterDeviceRequestSchema.parse({ deviceId: 'install-1' })).toEqual({ deviceId: 'install-1' }); + expect(UnregisterDeviceResponseSchema.parse({ success: true })).toEqual({ success: true }); + }); +}); + +// Declared ahead of its producer (ADR-0049): no route serves the device +// schemas until the server device-registry slice lands, and each says so. +describe('device registration schemas are declared, not yet served', () => { + it.each([ + ['RegisterDeviceRequestSchema', RegisterDeviceRequestSchema], + ['RegisterDeviceResponseSchema', RegisterDeviceResponseSchema], + ['UnregisterDeviceRequestSchema', UnregisterDeviceRequestSchema], + ['UnregisterDeviceResponseSchema', UnregisterDeviceResponseSchema], + ] as const)('%s carries the PLANNED marker', (_name, schema) => { + expect(schema.description).toMatch(/^\[PLANNED — /); + }); +}); diff --git a/packages/spec/src/contracts/notification-service.test.ts b/packages/spec/src/contracts/notification-service.test.ts index 2455939c359..3c914a36f34 100644 --- a/packages/spec/src/contracts/notification-service.test.ts +++ b/packages/spec/src/contracts/notification-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { INotificationService, NotificationMessage, NotificationResult } from './notification-service'; +import type { UserDevice } from '../system/push-device.zod'; describe('Notification Service Contract', () => { it('should allow a minimal INotificationService implementation with required methods', () => { @@ -207,4 +208,55 @@ describe('Notification Service Contract', () => { expect((await service.listInbox!('u-1', { read: false })).notifications).toHaveLength(0); }); }); + + describe('push device members (ADR-0012 §6)', () => { + it('are optional: a send-only provider is still a complete implementation', () => { + const service: INotificationService = { send: async () => ({ success: true }) }; + expect(service.registerDevice).toBeUndefined(); + expect(service.unregisterDevice).toBeUndefined(); + expect(service.invalidateDeviceToken).toBeUndefined(); + expect(service.listDevices).toBeUndefined(); + }); + + it('type the registry an implementation provides: register, unregister, list, invalidate', async () => { + // A toy registry written to the documented semantics, so the member + // signatures are exercised by a real implementation, not just named. + const rows = new Map(); + let minted = 0; + const service: INotificationService = { + send: async () => ({ success: true }), + registerDevice: async (userId, request) => { + const deviceId = request.deviceId ?? `dev-${++minted}`; + for (const [id, row] of rows) if (row.token === request.token && id !== deviceId) rows.delete(id); + rows.set(deviceId, { + deviceId, userId, token: request.token, platform: request.platform, + vendor: request.vendor ?? (request.platform === 'ios' ? 'apns' : 'fcm'), enabled: true, + }); + return { deviceId, success: true }; + }, + listDevices: async (userId) => [...rows.values()].filter((row) => row.userId === userId), + unregisterDevice: async (userId, deviceId) => { + if (rows.get(deviceId)?.userId !== userId) throw Object.assign(new Error('Device not found'), { code: 'NOT_FOUND' }); + rows.delete(deviceId); + return { success: true }; + }, + invalidateDeviceToken: async (token) => { + for (const row of rows.values()) { + if (row.token === token) Object.assign(row, { enabled: false, invalidatedAt: '2026-01-01T00:00:00.000Z' }); + } + }, + }; + + const minted1 = await service.registerDevice!('u-1', { token: 'tok', platform: 'ios', vendor: 'apns' }); + expect(minted1).toEqual({ deviceId: 'dev-1', success: true }); + // A second user registering the same token takes it over. + await service.registerDevice!('u-2', { token: 'tok', platform: 'ios', deviceId: 'install-2' }); + expect([...rows.keys()]).toEqual(['install-2']); + expect(await service.listDevices!('u-1')).toEqual([]); + expect((await service.listDevices!('u-2')).map((d) => d.deviceId)).toEqual(['install-2']); + await expect(service.unregisterDevice!('u-1', 'install-2')).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await service.invalidateDeviceToken!('tok'); + expect(rows.get('install-2')).toMatchObject({ enabled: false, invalidatedAt: '2026-01-01T00:00:00.000Z' }); + }); + }); }); diff --git a/packages/spec/src/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts index 8515cd667bf..30a88309c93 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -26,6 +26,8 @@ * addressed to an unregistered channel are dead-lettered, not delivered. */ import type { NotificationChannel } from '../system/notification.zod'; +import type { RegisterDeviceRequest, RegisterDeviceResponse, UnregisterDeviceResponse } from '../api/protocol.zod'; +import type { UserDevice } from '../system/push-device.zod'; export type { NotificationChannel } from '../system/notification.zod'; /** @@ -207,4 +209,93 @@ export interface INotificationService { * @param userId - Owner of the inbox */ markAllRead?(userId: string): Promise; + + /* ------------------------------------------------------------------ */ + /* Push devices — the device registry (ADR-0012 §6) */ + /* ------------------------------------------------------------------ */ + + /* + * All four are OPTIONAL, for the reason the inbox trio is: a provider + * with no push channel (SMTP, an SMS gateway, the dev stub) fills this + * slot legitimately with no device registry. Absence means the + * capability is unavailable — the device routes answer that, not a + * crash — so every implementation written before these members stays + * valid. Shapes are the wire contract the device routes serialize: + * `RegisterDeviceRequestSchema` / `RegisterDeviceResponseSchema` / + * `UnregisterDeviceResponseSchema` (`api/protocol.zod.ts`), and the stored + * device `UserDeviceSchema` (`system/push-device.zod.ts`). + * + * Declared ahead of their implementation: no provider implements them + * and no route calls them yet. They are the contract the device registry + * and its routes owe when they land, not a description of running code. + */ + // First implementation and callers: the server device-registry slice + // (#29068); the push channel's invalid-recipient path calls + // invalidateDeviceToken (#29041). + + /** + * Register or refresh a push device for this user. + * + * - **Refresh, not duplicate.** A `request.deviceId` this user already + * holds refreshes that device (token, vendor, app, locale, last seen) + * and re-enables it; an absent `deviceId` is minted here and answered + * in the response, which the client keeps. + * - **One live row per token.** A token this user already holds under + * another `deviceId` (the app's storage was cleared, so the request + * carries no `deviceId`) refreshes that row and answers its `deviceId`, + * never a second live row. Registering a token another user's device + * holds moves it to this user, and the other user's device stops + * receiving it. A push meant for the previous owner never reaches the + * phone's new user. + * - An absent `request.vendor` is derived from `request.platform` + * (`ios` → `apns`, `android` → `fcm`). `web` has no default vendor: a + * `web` registration names `apns` or `fcm`, and one without `vendor` is + * refused as a validation error, never stored with a guessed vendor. + * + * @param userId - The signed-in user, from the session; never a body member + * @param request - The registration body + */ + registerDevice?(userId: string, request: RegisterDeviceRequest): Promise; + + /** + * Unregister one of this user's devices (sign-out, account switch). + * + * Resolves `{ success: true }` once the device is removed. A `deviceId` + * this user does not hold — unknown, or another user's — never resolves: + * the call throws an error whose `code` is `NOT_FOUND`, which the device + * route answers as 404. It is never a distinct "forbidden", so a caller + * cannot learn that a device id exists for somebody else, and cannot + * remove it. + * + * @param userId - The signed-in user, from the session + * @param deviceId - The id {@link registerDevice} answered + */ + unregisterDevice?(userId: string, deviceId: string): Promise; + + /** + * List this user's devices, newest `lastSeenAt` first; a device with no + * `lastSeenAt` comes last. + * + * Disabled devices (a token the vendor reported dead) are listed too, + * with `enabled: false` and their `invalidatedAt`, so a settings screen + * can say why a phone stopped receiving pushes. Only this user's devices + * are ever answered; there is no listing across users. + * + * @param userId - The signed-in user, from the session + */ + listDevices?(userId: string): Promise; + + /** + * Retire a token the vendor reported dead (APNs `BadDeviceToken` / + * `Unregistered`, FCM `UNREGISTERED`), called by the push channel's + * invalid-recipient path (ADR-0012 §6, §12). + * + * The device row is KEPT, disabled, with the time of invalidation, so + * "why did this phone stop getting pushes" stays answerable and a later + * registration of the same device re-enables it. Nothing is delivered to + * a disabled device. A token no device holds is a no-op. + * + * @param token - The vendor token as the vendor reported it + */ + invalidateDeviceToken?(token: string): Promise; } diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index cdacdd5a470..ddaae025180 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -77,6 +77,8 @@ export * from './email-config.zod'; export * from './email-template.zod'; export * from './email-template.form'; export * from './sms-config.zod'; +export * from './push-config.zod'; +export * from './push-device.zod'; export * from './metadata-form-registry'; // compliance.zod (GDPR/HIPAA/PCI configs) and masking.zod (role-based data // masking) were REMOVED per ADR-0056 D8 "design+enforce or remove": both were diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts new file mode 100644 index 00000000000..327e6066ab0 --- /dev/null +++ b/packages/spec/src/system/push-config.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Push transport configuration (APNs token auth, FCM HTTP v1). Its two +// secrets — the APNs `.p8` signing key and the FCM service-account JSON — are +// named by reference only (ADR-0143 D3), so an inline secret is refused two +// ways: under the key an operator would reach for (refused by name, with the +// reference key as the prescription), and as the CONTENT of a reference (a +// PEM block or a JSON document is a secret pasted where its name belongs). + +import { describe, it, expect } from 'vitest'; +import { + ApnsTransportConfigSchema, + FcmTransportConfigSchema, + PushServiceConfigSchema, + PushVendorSchema, +} from './push-config.zod'; + +const APNS = { + teamId: 'ABCDE12345', + keyId: 'KEY1234567', + topic: 'com.example.shell', + environment: 'production', + credentialRef: 'OS_PUSH_APNS_KEY', +} as const; + +const FCM = { projectId: 'example-project', credentialRef: 'OS_PUSH_FCM_SERVICE_ACCOUNT' } as const; + +const PEM = '-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----'; +const SERVICE_ACCOUNT_JSON = '{"type":"service_account","project_id":"example-project","private_key":"x"}'; + +describe('PushVendorSchema', () => { + it('names exactly the two vendors core delivers through', () => { + expect(PushVendorSchema.options).toEqual(['apns', 'fcm']); + }); +}); + +describe('PushServiceConfigSchema', () => { + it('accepts both transports configured by reference', () => { + const config = { apns: APNS, fcm: FCM }; + expect(PushServiceConfigSchema.parse(config)).toEqual(config); + }); + + it('accepts either transport alone, and the sandbox environment', () => { + expect(PushServiceConfigSchema.safeParse({ apns: { ...APNS, environment: 'sandbox' } }).success).toBe(true); + expect(PushServiceConfigSchema.safeParse({ fcm: FCM }).success).toBe(true); + }); + + it.each([ + 'OS_PUSH_APNS_KEY', + 'push/apns-signing-key', + 'vault:push.apns.p8', + ])('accepts the reference form %j', (credentialRef) => { + expect(ApnsTransportConfigSchema.safeParse({ ...APNS, credentialRef }).success).toBe(true); + expect(FcmTransportConfigSchema.safeParse({ ...FCM, credentialRef }).success).toBe(true); + }); + + it.each(['key', 'privateKey', 'p8'])('refuses an inline APNs key under %j, by name', (key) => { + const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, [key]: PEM }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('credentialRef'); + }); + + it.each(['serviceAccount', 'serviceAccountJson', 'privateKey'])('refuses an inline FCM credential under %j, by name', (key) => { + const parsed = FcmTransportConfigSchema.safeParse({ ...FCM, [key]: SERVICE_ACCOUNT_JSON }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('credentialRef'); + }); + + it.each([ + ['APNs', 'keyRef'], + ['FCM', 'serviceAccountRef'], + ] as const)('answers the %s near-miss %j with credentialRef, the house key for a secret reference', (transport, key) => { + const block = transport === 'APNs' ? APNS : FCM; + const schema = transport === 'APNs' ? ApnsTransportConfigSchema : FcmTransportConfigSchema; + const { credentialRef, ...rest } = block; + const parsed = schema.safeParse({ ...rest, [key]: credentialRef }); + expect(parsed.success).toBe(false); + const issues = parsed.error?.issues ?? []; + expect(issues.some((i) => i.code === 'unrecognized_keys' && i.message.includes('credentialRef'))).toBe(true); + }); + + it('refuses a PEM block pasted into the APNs credentialRef', () => { + const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, credentialRef: PEM }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['credentialRef']); + }); + + it('refuses a service-account JSON document pasted into the FCM credentialRef', () => { + const parsed = FcmTransportConfigSchema.safeParse({ ...FCM, credentialRef: SERVICE_ACCOUNT_JSON }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['credentialRef']); + }); + + it('refuses an environment outside sandbox / production', () => { + const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, environment: 'development' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['environment']); + }); + + it('refuses an unknown transport key on the block', () => { + const parsed = PushServiceConfigSchema.safeParse({ fcm: FCM, hms: {} }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.code).toBe('unrecognized_keys'); + }); +}); + +// Declared ahead of its reader (ADR-0049): nothing reads this shape until the +// push channel plugin ships, and each block says so in its description. +describe('push transport configuration is declared, not yet read', () => { + it.each([ + ['PushServiceConfigSchema', PushServiceConfigSchema], + ['ApnsTransportConfigSchema', ApnsTransportConfigSchema], + ['FcmTransportConfigSchema', FcmTransportConfigSchema], + ] as const)('%s carries the PLANNED marker', (_name, schema) => { + expect(schema.description).toMatch(/^\[PLANNED — /); + }); +}); diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts new file mode 100644 index 00000000000..6b45637ae9c --- /dev/null +++ b/packages/spec/src/system/push-config.zod.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { strictObject } from '../shared/strict-object'; + +/** + * Push Service Configuration Protocol — the push channel's transports. + * + * **Declared ahead of its reader.** Every schema here carries a + * `[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this + * shape until the push channel plugin lands, so a value written to it today + * changes nothing. The marker comes off with the change that wires the reader. + * + * Operator-facing configuration for the `push` channel ADR-0012 §4 ships in + * core: Apple Push Notification service with token-based auth, and Firebase + * Cloud Messaging over its HTTP v1 API. It sits beside + * `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of + * the email channel's transport selection. + * + * **Secrets are references, never values (ADR-0143 D3, ADR-0097 §3).** Each + * transport has one secret — the APNs `.p8` signing key and the FCM + * service-account JSON — and each is named by a `credentialRef`, the same key + * every other secret an author names is written under (ADR-0143 D3), which + * the push channel resolves through the secrets layer when it sends (in the + * open tier, the name of an environment variable). An inline secret is + * refused two ways: written under a key an operator would reach for (`key`, + * `privateKey`, `serviceAccount`, …), it is refused by name with + * `credentialRef` as the prescription; written into the reference itself, it + * is refused because a reference is one token — a PEM block carries + * whitespace and a JSON document carries braces, and a name carries neither. + * + * **What reads it, and why it is not a stack key yet.** The push channel + * plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it + * is a later change than this declaration. The email transport selection it + * sits beside is read from `config.email` of the stack definition + * (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own + * configuration door in the change that ships the reader, not here, so a key + * is never authorable before something honours it. + * + * Out of scope here: a hosted (cloud-side) configuration surface, and the + * other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, + * Xiaomi, OPPO, vivo). + */ + +/** + * Push vendors core delivers through: `apns` (Apple) and `fcm` (Firebase). + * + * Closed on purpose. ADR-0012 §6 plugs the domestic Android vendors in as + * alternate channel implementations, not as transports of this channel, so a + * new value here is a spec change made together with its transport. + */ +export const PushVendorSchema = z.enum(['apns', 'fcm']) + .describe('Push vendor core delivers through: apns (Apple Push Notification service) or fcm (Firebase Cloud Messaging HTTP v1)'); +export type PushVendor = z.input; + +/** + * A secrets-layer reference: one token, never the secret it names. + * Whitespace and braces are refused because the secrets in question cannot + * be written without them (a PEM block, a JSON document) and a reference + * never needs them. + */ +// Stricter than the house `credentialRef` (`z.string().min(1)`, +// shared/connector-auth.zod.ts) on purpose. A bearer token or an API key looks +// like a name, so a connector's reference cannot tell a pasted secret from a +// reference and checks only that one is there. The two push secrets can be +// told apart: a .p8 key is a PEM block and a service-account file is a JSON +// document, and every name the house writes (an environment-variable name in +// the open tier) has neither whitespace nor braces. If a secrets layer ever +// names a secret with either, this widens to the house shape. +function secretReference(secret: string) { + return z.string() + .min(1) + .regex(/^[^\s{}]+$/, { + message: `This names the ${secret} in the secrets layer and never contains it: put the value in the secrets layer ` + + '(an environment variable in the open tier) and write its name here.', + }); +} + +/** The guidance an inline secret meets on a push transport block. */ +function inlineSecretGuidance(transport: string, secret: string, refKey: string): string { + return `Not authorable on the ${transport} push transport. The ${secret} is a credential and is never written inline — ` + + `put it in the secrets layer (an environment variable in the open tier) and name it with \`${refKey}\`.`; +} + +const APNS_KEY_GUIDANCE = inlineSecretGuidance('APNs', '.p8 signing key', 'credentialRef'); +const FCM_ACCOUNT_GUIDANCE = inlineSecretGuidance('FCM', 'service-account JSON', 'credentialRef'); + +/** APNs with token-based (`.p8` signing key) authentication. */ +export const ApnsTransportConfigSchema = lazySchema(() => strictObject( + { + surface: 'the APNs push transport', + history: 'This block is strict from birth — no APNs configuration existed before it.', + aliases: { bundleId: 'topic', keyRef: 'credentialRef' }, + guidance: { + key: APNS_KEY_GUIDANCE, + privateKey: APNS_KEY_GUIDANCE, + p8: APNS_KEY_GUIDANCE, + authKey: APNS_KEY_GUIDANCE, + signingKey: APNS_KEY_GUIDANCE, + secret: APNS_KEY_GUIDANCE, + }, + }, + { + teamId: z.string().min(1).describe('Apple Developer Team ID (10 characters) that issued the signing key'), + keyId: z.string().min(1).describe('Key ID of the APNs auth key (10 characters)'), + topic: z.string().min(1).describe('APNs topic: the app bundle ID the pushes are for'), + environment: z.enum(['sandbox', 'production']) + .describe('APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds)'), + credentialRef: secretReference('APNs .p8 signing key') + .describe('Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself'), + }, +).describe('[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference)')); +export type ApnsTransportConfig = z.input; + +/** FCM over the HTTP v1 API, authenticated by a service account. */ +export const FcmTransportConfigSchema = lazySchema(() => strictObject( + { + surface: 'the FCM push transport', + history: 'This block is strict from birth — no FCM configuration existed before it.', + aliases: { serviceAccountRef: 'credentialRef' }, + guidance: { + serviceAccount: FCM_ACCOUNT_GUIDANCE, + serviceAccountJson: FCM_ACCOUNT_GUIDANCE, + credentials: FCM_ACCOUNT_GUIDANCE, + privateKey: FCM_ACCOUNT_GUIDANCE, + secret: FCM_ACCOUNT_GUIDANCE, + serverKey: + 'Not authorable. FCM legacy server keys belong to the retired legacy API; the HTTP v1 transport authenticates ' + + 'with a service account — name its JSON with `credentialRef`.', + }, + }, + { + projectId: z.string().min(1).describe('Firebase project ID the HTTP v1 send endpoint is addressed to'), + credentialRef: secretReference('FCM service-account JSON') + .describe('Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself'), + }, +).describe('[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference)')); +export type FcmTransportConfig = z.input; + +/** + * The push channel's transport configuration: one optional block per vendor. + * A vendor with no block is a vendor this deployment does not deliver + * through. + */ +export const PushServiceConfigSchema = lazySchema(() => strictObject( + { + surface: 'the push transport configuration', + history: 'This block is strict from birth — no push configuration existed before it.', + }, + { + apns: ApnsTransportConfigSchema.optional().describe('APNs transport (token-based auth). Absent: no delivery to apns devices'), + fcm: FcmTransportConfigSchema.optional().describe('FCM HTTP v1 transport. Absent: no delivery to fcm devices'), + }, +).describe('[PLANNED — not yet read] Push channel transport configuration: one optional block per vendor (apns, fcm). Nothing reads it until the push channel plugin ships')); +export type PushServiceConfig = z.input; diff --git a/packages/spec/src/system/push-device-platform.ts b/packages/spec/src/system/push-device-platform.ts new file mode 100644 index 00000000000..501479e51d5 --- /dev/null +++ b/packages/spec/src/system/push-device-platform.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The device platforms a push device registration names: `ios`, `android` + * and `web`. + * + * One list for the two schemas that carry it, the registration body + * (`RegisterDeviceRequestSchema.platform`, `api/protocol.zod.ts`) and the + * stored device (`UserDeviceSchema.platform`, `system/push-device.zod.ts`), so + * a stored device never names a platform its registration would refuse. + * + * Internal to the package: no barrel re-exports this module, so it adds no + * public name. Each schema builds its own `z.enum` from it and describes it. + */ +export const PUSH_DEVICE_PLATFORMS = ['ios', 'android', 'web'] as const; diff --git a/packages/spec/src/system/push-device.test.ts b/packages/spec/src/system/push-device.test.ts new file mode 100644 index 00000000000..a3598c5cd96 --- /dev/null +++ b/packages/spec/src/system/push-device.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The stored device shape of the push device registry (ADR-0012 §6, narrowed +// to core). It belongs to a user alone (no organization member), names one of +// the two core vendors, and keeps a dead token's row disabled with the time +// the vendor reported it. + +import { describe, it, expect } from 'vitest'; +import { UserDeviceSchema } from './push-device.zod'; +import { RegisterDeviceRequestSchema } from '../api/protocol.zod'; + +const DEVICE = { + deviceId: 'install-1', + userId: 'usr_1', + vendor: 'apns', + token: 'apns-device-token', + platform: 'ios', + appId: 'com.example.shell', + locale: 'zh-CN', + name: 'iPhone', + enabled: true, + lastSeenAt: '2026-11-02T08:00:00.000Z', +} as const; + +describe('UserDeviceSchema', () => { + it('round-trips an enabled device', () => { + expect(UserDeviceSchema.parse(DEVICE)).toEqual(DEVICE); + }); + + it('round-trips a device whose token the vendor reported dead', () => { + const dead = { ...DEVICE, enabled: false, invalidatedAt: '2026-11-02T09:00:00.000Z' }; + expect(UserDeviceSchema.parse(dead)).toEqual(dead); + }); + + it('needs only the identity, the vendor, the token, the platform and enabled', () => { + const minimal = { deviceId: 'install-2', userId: 'usr_1', vendor: 'fcm', token: 't', platform: 'android', enabled: true }; + expect(UserDeviceSchema.parse(minimal)).toEqual(minimal); + }); + + it.each(['deviceId', 'userId', 'vendor', 'token', 'platform', 'enabled'] as const)('requires %s', (key) => { + const { [key]: _dropped, ...rest } = DEVICE; + const parsed = UserDeviceSchema.safeParse(rest); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual([key]); + }); + + it.each(['hms', 'web_push', 'APNS'])('refuses vendor %j (core delivers through apns / fcm only)', (vendor) => { + const parsed = UserDeviceSchema.safeParse({ ...DEVICE, vendor }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['vendor']); + }); + + it.each(['zh_CN', 'Chinese', 'zh-CN '])('refuses locale %j (not a BCP-47 language tag)', (locale) => { + const parsed = UserDeviceSchema.safeParse({ ...DEVICE, locale }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['locale']); + expect(parsed.error?.issues[0]?.code).toBe('invalid_format'); + }); + + it('refuses an invalidation time that is not an ISO-8601 instant', () => { + const parsed = UserDeviceSchema.safeParse({ ...DEVICE, enabled: false, invalidatedAt: 'yesterday' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['invalidatedAt']); + }); + + it('names the same platforms its registration does', () => { + expect(UserDeviceSchema.shape.platform.options).toEqual(RegisterDeviceRequestSchema.shape.platform.options); + expect(UserDeviceSchema.shape.platform.options).toEqual(['ios', 'android', 'web']); + }); + + it('has no organization member: a device belongs to its user alone', () => { + expect(Object.keys(UserDeviceSchema.shape)).not.toContain('organizationId'); + }); + + it('carries the PLANNED marker until the device registry stores it', () => { + expect(UserDeviceSchema.description).toMatch(/^\[PLANNED — /); + }); +}); diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts new file mode 100644 index 00000000000..5329cce138b --- /dev/null +++ b/packages/spec/src/system/push-device.zod.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; +import { PushVendorSchema } from './push-config.zod'; +import { LocaleSchema } from './translation.zod'; +import { PUSH_DEVICE_PLATFORMS } from './push-device-platform'; + +/** + * Push Device Protocol — the stored device shape of the push device registry + * (ADR-0012 §6, narrowed to what core ships). + * + * One row per (user, app install): what a device registration + * (`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the + * push channel reads to address a phone, and what the registry's listing + * answers (`INotificationService.listDevices`). + * + * **Declared ahead of its producer.** No table stores it and no route serves + * it yet; the schema carries a `[PLANNED — not yet served]` marker in its + * `.describe()` until the device registry lands (in the push channel plugin's + * `sys_user_device`, ADR-0012 §11). + * + * Three rules the registry owes, stated here because they decide the shape: + * + * - **(a) A device belongs to a user, not to an organization.** The row has no + * organization member. A phone signed in to one person receives that + * person's notifications whichever organization they come from; the + * organization travels on the notification, never on the device. The + * identity of a row is (`userId`, `deviceId`). + * - **(b) One live row per token.** A vendor token is delivered to one row + * only. A token this user already holds under another `deviceId` (the app's + * storage was cleared, so the registration carries no `deviceId`) refreshes + * that row and answers its `deviceId`; it never makes a second live row. + * When a second user registers a token another user's device holds, the + * token moves to the second user and the first user's row for it is + * removed, so a push meant for the phone's previous user never reaches its + * new one. + * - **(c) A dead token keeps its row.** When the vendor reports a token dead + * (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is + * kept with `enabled: false` and `invalidatedAt` set, so "why did this phone + * stop getting pushes" stays answerable. Nothing is delivered to a disabled + * row, and a later registration of the same device re-enables it and clears + * `invalidatedAt`. + * + * Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the + * domestic Android vendors plug in as alternate channel implementations) and + * `platform` is the registration's own three (`ios` | `android` | `web`). + */ +// The marker comes off when the server device-registry slice (#29068) stores +// and serves this shape. +export const UserDeviceSchema = lazySchema(() => z.object({ + deviceId: z.string().min(1).describe( + 'Client-held device id, stable per app install: the one the registration carried or the server minted. Unique per user', + ), + userId: z.string().min(1).describe('The user the device belongs to: the session user who registered it. No organization member'), + vendor: PushVendorSchema.describe('Push vendor the token belongs to: the registration\'s vendor, or the one derived from platform'), + token: z.string().min(1).describe('Vendor push token. Held by one live device at a time: a second user\'s registration moves it'), + platform: z.enum(PUSH_DEVICE_PLATFORMS).describe('Device platform, as the registration named it'), + appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), + locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), + name: z.string().optional().describe('Device friendly name, as the registration named it'), + enabled: z.boolean().describe('False once the vendor reported the token dead; nothing is delivered to a disabled device'), + invalidatedAt: z.string().datetime().optional().describe( + 'When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, ' + + 'and cleared by the registration that re-enables it', + ), + lastSeenAt: z.string().datetime().optional().describe( + 'When the device last registered or refreshed. Every registration and refresh sets it, so it is absent only on a ' + + 'row no registration wrote; listDevices answers such a device last', + ), +}).describe( + '[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; ' + + 'the device registry lands with the push channel', +)); +export type UserDevice = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1de234178af..d23cfeab05b 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -283,9 +283,13 @@ import type * as M193 from './data/seed-file.zod.js'; // [ADR-0142] Ownership queues and assignment rules -- new modules, next free indices. import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; +// The push channel's transport configuration and delivery data (ADR-0012 §6) -- new modules, next free indices. +import type * as M196 from './system/push-config.zod.js'; +import type * as M197 from './api/push-delivery.zod.js'; +import type * as M198 from './system/push-device.zod.js'; // --------------------------------------------------------------------------- -// 811 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 818 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 @@ -652,6 +656,10 @@ export type Iso_api_protocol__ValidateDataIssueSchema = Assert, z.infer< typeof M28.ValidateDataRequestSchema > >>; export type Iso_api_protocol__ValidateDataResponseSchema = Assert, z.infer< typeof M28.ValidateDataResponseSchema > >>; +// api/push-delivery.zod.ts +export type Iso_api_pushDelivery__PushDeliveryDataSchema = Assert, z.infer< typeof M197.PushDeliveryDataSchema > >>; +export type Iso_api_pushDelivery__PushRouteSchema = Assert, z.infer< typeof M197.PushRouteSchema > >>; + // api/query-adapter.zod.ts export type Iso_api_queryAdapter__OperatorMappingSchema = Assert, z.infer< typeof M29.OperatorMappingSchema > >>; export type Iso_api_queryAdapter__QueryAdapterTargetSchema = Assert, z.infer< typeof M29.QueryAdapterTargetSchema > >>; @@ -1408,6 +1416,15 @@ export type Iso_system_objectStorage__StorageClassSchema = Assert, z.infer< typeof M142.StorageProviderSchema > >>; export type Iso_system_objectStorage__StorageScopeSchema = Assert, z.infer< typeof M142.StorageScopeSchema > >>; +// system/push-config.zod.ts +export type Iso_system_pushConfig__ApnsTransportConfigSchema = Assert, z.infer< typeof M196.ApnsTransportConfigSchema > >>; +export type Iso_system_pushConfig__FcmTransportConfigSchema = Assert, z.infer< typeof M196.FcmTransportConfigSchema > >>; +export type Iso_system_pushConfig__PushServiceConfigSchema = Assert, z.infer< typeof M196.PushServiceConfigSchema > >>; +export type Iso_system_pushConfig__PushVendorSchema = Assert, z.infer< typeof M196.PushVendorSchema > >>; + +// system/push-device.zod.ts +export type Iso_system_pushDevice__UserDeviceSchema = Assert, z.infer< typeof M198.UserDeviceSchema > >>; + // system/registry-config.zod.ts export type Iso_system_registryConfig__RegistrySyncPolicySchema = Assert, z.infer< typeof M143.RegistrySyncPolicySchema > >>; @@ -1745,7 +1762,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 811 isomorphic pins', () => { + it('still declares all 818 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 @@ -2723,7 +2740,19 @@ describe('ADR-0122 type-alias convention', () => { // (`api/auth.zod.ts`), strict objects with no default and no transform — // the anonymous branch, the authenticated branch and their discriminated // union (Iso_api_auth__MePermissions*ResponseSchema). +3 added. - expect(pins).toHaveLength(811); + // 811 -> 817 is the push channel's spec face (ADR-0012 §6): the push + // transport configuration (`system/push-config.zod.ts`: the vendor enum, + // the APNs and FCM blocks and the block holding them) and the push + // delivery data and its route (`api/push-delivery.zod.ts`), strict or plain objects of + // required and optional strings, enums and regex refinements, no default + // and no transform (Iso_system_pushConfig__*, Iso_api_pushDelivery__*). + // +6 added. + // 817 -> 818 is the push device registry's stored device shape + // (`system/push-device.zod.ts`, `UserDeviceSchema`): a plain object of + // required and optional strings, enums, a boolean and ISO instants, no + // default and no transform (Iso_system_pushDevice__UserDeviceSchema). + // +1 added. + expect(pins).toHaveLength(818); // 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