From 8dcce8b7399d529022e44fa5f7aa1bb7aad21cdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:06:02 +0000 Subject: [PATCH 01/11] test(spec): push device registration widening, push route and transport config cases (red) Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt Co-Authored-By: Claude --- packages/spec/src/api/push-delivery.test.ts | 51 ++++++++++ .../src/api/register-device-request.test.ts | 68 +++++++++++++ packages/spec/src/system/push-config.test.ts | 96 +++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 packages/spec/src/api/push-delivery.test.ts create mode 100644 packages/spec/src/api/register-device-request.test.ts create mode 100644 packages/spec/src/system/push-config.test.ts 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..afc3e5cf3a1 --- /dev/null +++ b/packages/spec/src/api/push-delivery.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The data a push delivery carries to the shell. Its `route` is opened by the +// shell on tap, 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, or a path a URL parser rewrites into one) 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', () => { + it('accepts a notification id with a Console route', () => { + const data = { notificationId: 'ntf_1', route: '/apps/crm/opportunity/123' }; + 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('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', + ])('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']); + }); +}); 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..ec47b98df0a --- /dev/null +++ b/packages/spec/src/api/register-device-request.test.ts @@ -0,0 +1,68 @@ +// 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); + }); + + 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'); + }); + + 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 }); + }); +}); 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..cb2d4012303 --- /dev/null +++ b/packages/spec/src/system/push-config.test.ts @@ -0,0 +1,96 @@ +// 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', + keyRef: 'OS_PUSH_APNS_KEY', +} as const; + +const FCM = { projectId: 'example-project', serviceAccountRef: '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', (keyRef) => { + expect(ApnsTransportConfigSchema.safeParse({ ...APNS, keyRef }).success).toBe(true); + expect(FcmTransportConfigSchema.safeParse({ ...FCM, serviceAccountRef: keyRef }).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('keyRef'); + }); + + 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('serviceAccountRef'); + }); + + it('refuses a PEM block pasted into keyRef', () => { + const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, keyRef: PEM }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['keyRef']); + }); + + it('refuses a service-account JSON document pasted into serviceAccountRef', () => { + const parsed = FcmTransportConfigSchema.safeParse({ ...FCM, serviceAccountRef: SERVICE_ACCOUNT_JSON }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['serviceAccountRef']); + }); + + 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'); + }); +}); From 81b25bd250cc018acfe47f80f85d006d6dda6071 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:06:02 +0000 Subject: [PATCH 02/11] =?UTF-8?q?feat(spec):=20push=20channel=20protocol?= =?UTF-8?q?=20face=20=E2=80=94=20device=20registration=20widening,=20devic?= =?UTF-8?q?e=20contract=20members,=20push=20delivery=20data,=20APNs/FCM=20?= =?UTF-8?q?transport=20config=20by=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt Co-Authored-By: Claude --- .changeset/29067-push-device-registry-spec.md | 15 ++ packages/spec/src/api/index.ts | 1 + packages/spec/src/api/protocol.zod.ts | 24 ++- packages/spec/src/api/push-delivery.zod.ts | 48 ++++++ .../contracts/notification-service.test.ts | 44 ++++++ .../src/contracts/notification-service.ts | 62 ++++++++ packages/spec/src/system/index.ts | 1 + packages/spec/src/system/push-config.zod.ts | 141 ++++++++++++++++++ .../src/type-alias-convention.pin.test.ts | 25 +++- 9 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 .changeset/29067-push-device-registry-spec.md create mode 100644 packages/spec/src/api/push-delivery.zod.ts create mode 100644 packages/spec/src/system/push-config.zod.ts diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md new file mode 100644 index 00000000000..4800a1e9415 --- /dev/null +++ b/.changeset/29067-push-device-registry-spec.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': patch +--- + +feat(spec): the push channel's protocol face: the widened device registration request, optional `INotificationService` device members, the push delivery data with its same-server `route`, and the APNs / FCM transport configuration by reference (ADR-0012 §6) + +Clause-②: yes (widening) +Metadata-protocol: none + +- **`RegisterDeviceRequestSchema`** gains three optional members: `vendor` (`apns` | `fcm`; absent, the server derives it from `platform`, `ios` → `apns` and `android` → `fcm`), `appId` and `locale`. `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, and since the member is new, no body sent before carries one. `RegisterDeviceResponseSchema` and `UnregisterDevice*` are unchanged. +- **`INotificationService`** gains three optional members: `registerDevice(userId, request)`, `unregisterDevice(userId, deviceId)` and `invalidateDeviceToken(token)`. Their docs state the semantics an implementation owes: one live owner per token (a second user's registration takes it over), unregistering a device the user does not hold answers not found, and an invalidated token keeps its device row, disabled, with the invalidation time. 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). The route is derived, never authored: it is the notification's existing `actionUrl` when that passes the schema, and absent otherwise. 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 `keyRef`) and an `fcm` block (HTTP v1: `projectId`, the service-account JSON by `serviceAccountRef`). 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. Nothing reads this shape until the push channel plugin lands, and it is not a stack-definition key. + +No route row is added: the device routes land with their handlers in the server slice, so `DEFAULT_NOTIFICATION_ROUTES` is unchanged. Nothing an author or a client writes today is refused. 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 5a069f4ff14..25d27a1c3a0 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -29,6 +29,7 @@ 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 { PushVendorSchema } from '../system/push-config.zod'; // #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. @@ -3506,11 +3507,32 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({ // Notification Operations // ========================================== +/** + * Register or refresh a push device for the signed-in user (ADR-0012 §6). + * + * 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. + */ 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'), + 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 core vendor)', + ), + appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), + locale: z.string().optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), })); export const RegisterDeviceResponseSchema = lazySchema(() => z.object({ 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..185bd000fac --- /dev/null +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -0,0 +1,48 @@ +// 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 (ADR-0012 §8: the + * `data` member of the push channel's rendered body). + * + * 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 Console path the shell opens. + * + * **Where `route` comes from — derived, never authored.** There is no + * `notify` key for it. It is the notification's existing `actionUrl`, 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. 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. + * + * 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 the shell may open: 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. + */ +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.', + }) + .describe('Same-server relative path the shell opens on tap (for example /apps/crm/opportunity/123); never an absolute or off-server URL'); + +/** The data a push delivery carries to the shell. */ +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('Console route opened on tap: the notification actionUrl when it is a same-server relative path, absent otherwise'), +})); +export type PushDeliveryData = z.input; diff --git a/packages/spec/src/contracts/notification-service.test.ts b/packages/spec/src/contracts/notification-service.test.ts index 2455939c359..93b2c6081b3 100644 --- a/packages/spec/src/contracts/notification-service.test.ts +++ b/packages/spec/src/contracts/notification-service.test.ts @@ -207,4 +207,48 @@ 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(); + }); + + it('type the registry an implementation provides: register, unregister, 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, { userId, token: request.token, enabled: true }); + return { deviceId, success: true }; + }, + 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']); + 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..b391468cc0e 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -26,6 +26,7 @@ * 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'; export type { NotificationChannel } from '../system/notification.zod'; /** @@ -207,4 +208,65 @@ export interface INotificationService { * @param userId - Owner of the inbox */ markAllRead?(userId: string): Promise; + + /* ------------------------------------------------------------------ */ + /* Push devices — the device registry (ADR-0012 §6) */ + /* ------------------------------------------------------------------ */ + + /* + * All three 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`). + */ + + /** + * 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 owner per token.** A token is delivered to one user only: + * 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`). + * + * @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). + * + * A `deviceId` this user does not hold — unknown, or another user's — + * answers not found (`NOT_FOUND`, 404 at the device route), never a + * success and never a distinct "forbidden": 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; + + /** + * 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..284e2deaa6d 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -77,6 +77,7 @@ 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 './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.zod.ts b/packages/spec/src/system/push-config.zod.ts new file mode 100644 index 00000000000..35bfae1d178 --- /dev/null +++ b/packages/spec/src/system/push-config.zod.ts @@ -0,0 +1,141 @@ +// 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. + * + * 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 reference 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 the reference key 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.** The push channel plugin (`plugin-notification-push`, + * ADR-0012 §11) is its one reader, and it is a later change than this + * declaration: until it lands nothing reads this shape, and it is not a key + * of the stack definition. Wiring it into a configuration door is that + * change's to make, together with the reader, 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. + */ +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', 'keyRef'); +const FCM_ACCOUNT_GUIDANCE = inlineSecretGuidance('FCM', 'service-account JSON', 'serviceAccountRef'); + +/** 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', credentialRef: 'keyRef' }, + 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)'), + keyRef: 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'), + }, +)); +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: { credentialRef: 'serviceAccountRef' }, + 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 `serviceAccountRef`.', + }, + }, + { + projectId: z.string().min(1).describe('Firebase project ID the HTTP v1 send endpoint is addressed to'), + serviceAccountRef: 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'), + }, +)); +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'), + }, +)); +export type PushServiceConfig = 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..f1469f67650 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -283,9 +283,12 @@ 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'; // --------------------------------------------------------------------------- -// 811 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 816 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 +655,9 @@ 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 > >>; + // 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 +1414,12 @@ 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/registry-config.zod.ts export type Iso_system_registryConfig__RegistrySyncPolicySchema = Assert, z.infer< typeof M143.RegistrySyncPolicySchema > >>; @@ -1745,7 +1757,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 816 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 +2735,14 @@ 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 -> 816 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 (`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__*). + // +5 added. + expect(pins).toHaveLength(816); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From 088613bebab0047841f35dda237422bbe53942bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:17:32 +0000 Subject: [PATCH 03/11] chore(spec): PushRoute alias and regenerated spec artifacts for the push channel protocol face Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt Co-Authored-By: Claude --- content/docs/references/api/index.mdx | 3 +- content/docs/references/api/meta.json | 1 + content/docs/references/api/protocol.mdx | 5 +- content/docs/references/api/push-delivery.mdx | 65 ++++++++++ content/docs/references/index.mdx | 14 +- content/docs/references/system/index.mdx | 3 +- content/docs/references/system/meta.json | 1 + .../docs/references/system/push-config.mdx | 122 ++++++++++++++++++ .../api.md | 2 +- .../system.md | 2 +- packages/spec/api-surface/api.json | 4 + packages/spec/api-surface/system.json | 8 ++ packages/spec/authorable-surface/api.json | 5 + packages/spec/authorable-surface/system.json | 9 ++ packages/spec/declaration-map/api.json | 3 + packages/spec/declaration-map/system.json | 8 ++ packages/spec/export-origins/api.json | 4 + packages/spec/export-origins/system.json | 8 ++ packages/spec/json-schema.manifest/api.json | 2 + .../spec/json-schema.manifest/system.json | 4 + packages/spec/src/api/push-delivery.zod.ts | 1 + .../src/type-alias-convention.pin.test.ts | 13 +- 22 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 content/docs/references/api/push-delivery.mdx create mode 100644 content/docs/references/system/push-config.mdx 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 6e1fe2386da..8b70225fff5 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2767,8 +2767,11 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | :--- | :--- | :--- | :--- | | **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 core vendor) | +| **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 | --- diff --git a/content/docs/references/api/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx new file mode 100644 index 00000000000..9651f065acd --- /dev/null +++ b/content/docs/references/api/push-delivery.mdx @@ -0,0 +1,65 @@ +--- +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 (ADR-0012 §8: the +`data` member of the push channel's rendered body). + +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 Console path the shell opens. + +**Where `route` comes from — derived, never authored.** There is no +`notify` key for it. It is the notification's existing `actionUrl`, 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. 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. + +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 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes | +| **route** | `string` | optional | Console route opened on tap: the notification actionUrl when it is a same-server relative path, absent otherwise | + + +--- + +## PushRoute + +Same-server relative path the shell opens on tap (for example /apps/crm/opportunity/123); never an absolute or off-server URL + +**Type:** `string` + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 194eeb5d8af..2aeb309d975 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 — 1602 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) | 38 | 291 | 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** | **207** | **1602** | 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` · **38 pages, 291 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -355,6 +356,7 @@ 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` | | [`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..b33c2c8a05e 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 38 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,7 @@ 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..4131a2ab398 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -42,6 +42,7 @@ "demo-staff", "dev-login", "fate-supplement", + "push-config", "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..a5ad2db035d --- /dev/null +++ b/content/docs/references/system/push-config.mdx @@ -0,0 +1,122 @@ +--- +title: Push Config schema — System Protocol reference +navTitle: Push Config +description: "Push Service Configuration Protocol — the push channel's transports. Reference for ApnsTransportConfig and 3 more: 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 Service Configuration Protocol — the push channel's transports. + +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 reference 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 the reference key 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.** The push channel plugin (`plugin-notification-push`, +ADR-0012 §11) is its one reader, and it is a later change than this +declaration: until it lands nothing reads this shape, and it is not a key +of the stack definition. Wiring it into a configuration door is that +change's to make, together with the reader, 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 + +### 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) | +| **keyRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | + + +--- + +## FcmTransportConfig + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to | +| **serviceAccountRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | + + +--- + +## PushServiceConfig + +### 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; serviceAccountRef: 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) | +| **keyRef** | `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 | +| **serviceAccountRef** | `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/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 757d7a92bc5..fb80356f948 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/` | 486 | +| `api/` | 487 | 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..931d6033555 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/` | 393 | 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..da3fa7ac8ee 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)", 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..1c14366ca49 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:environment", + "system/ApnsTransportConfig:keyId", + "system/ApnsTransportConfig:keyRef", + "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:projectId", + "system/FcmTransportConfig:serviceAccountRef", "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", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 8fe294f4f8d..9cbeecc1f22 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -613,6 +613,9 @@ "PublishPackageDraftsRequestSchema": "api/PublishPackageDraftsRequest", "PublishPackageDraftsResponse": "api/PublishPackageDraftsResponse", "PublishPackageDraftsResponseSchema": "api/PublishPackageDraftsResponse", + "PushDeliveryData": "api/PushDeliveryData", + "PushDeliveryDataSchema": "api/PushDeliveryData", + "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..997c4adf955 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", 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..ded7079ecf5 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)", 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..fc84343a575 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", diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts index 185bd000fac..8b9153c7a4f 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -38,6 +38,7 @@ export const PushRouteSchema = z.string() + 'backslash, whitespace or control character.', }) .describe('Same-server relative path the shell opens on tap (for example /apps/crm/opportunity/123); never an absolute or off-server URL'); +export type PushRoute = z.input; /** The data a push delivery carries to the shell. */ export const PushDeliveryDataSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index f1469f67650..3be0238e77b 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -288,7 +288,7 @@ import type * as M196 from './system/push-config.zod.js'; import type * as M197 from './api/push-delivery.zod.js'; // --------------------------------------------------------------------------- -// 816 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 817 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 @@ -657,6 +657,7 @@ export type Iso_api_protocol__ValidateDataResponseSchema = 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 > >>; @@ -1757,7 +1758,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 816 isomorphic pins', () => { + it('still declares all 817 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 @@ -2735,14 +2736,14 @@ 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. - // 811 -> 816 is the push channel's spec face (ADR-0012 §6): the push + // 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 (`api/push-delivery.zod.ts`), strict or plain objects of + // 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__*). - // +5 added. - expect(pins).toHaveLength(816); + // +6 added. + expect(pins).toHaveLength(817); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From d6eeb1a282bb79cc3a82d8385599b9d732891fca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:31:51 +0000 Subject: [PATCH 04/11] feat(spec): push device registry protocol face, declared ahead of its producer Completes the card's protocol face on top of the resumed commits: - UserDeviceSchema (system/push-device.zod.ts), the stored device shape: user-owned, one live owner per token, a dead token keeps its row disabled. - INotificationService.listDevices, optional like the other device members. - APNs / FCM transport secrets keyed credentialRef (ADR-0143 D3), the house key for a secret reference; keyRef / serviceAccountRef answer with it. - [PLANNED] markers on the device schemas, the delivery data, the stored device and the transport config: nothing serves or reads them yet, and no route row is added (the rows land with their handlers). - Changeset: minor, Metadata-protocol: added (the authorable surface grew). Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- .changeset/29067-push-device-registry-spec.md | 17 ++--- content/docs/references/api/protocol.mdx | 15 ++--- packages/spec/src/api/protocol.zod.ts | 25 +++++-- packages/spec/src/api/push-delivery.test.ts | 6 ++ packages/spec/src/api/push-delivery.zod.ts | 11 +++- .../src/api/register-device-request.test.ts | 13 ++++ .../contracts/notification-service.test.ts | 14 +++- .../src/contracts/notification-service.ts | 29 ++++++++- packages/spec/src/system/index.ts | 1 + packages/spec/src/system/push-config.test.ts | 51 +++++++++++---- packages/spec/src/system/push-config.zod.ts | 55 +++++++++------- packages/spec/src/system/push-device.test.ts | 65 +++++++++++++++++++ packages/spec/src/system/push-device.zod.ts | 64 ++++++++++++++++++ .../src/type-alias-convention.pin.test.ts | 15 ++++- 14 files changed, 312 insertions(+), 69 deletions(-) create mode 100644 packages/spec/src/system/push-device.test.ts create mode 100644 packages/spec/src/system/push-device.zod.ts diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md index 4800a1e9415..44603b88248 100644 --- a/.changeset/29067-push-device-registry-spec.md +++ b/.changeset/29067-push-device-registry-spec.md @@ -1,15 +1,16 @@ --- -'@objectstack/spec': patch +'@objectstack/spec': minor --- -feat(spec): the push channel's protocol face: the widened device registration request, optional `INotificationService` device members, the push delivery data with its same-server `route`, and the APNs / FCM transport configuration by reference (ADR-0012 §6) +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: none +Metadata-protocol: added -- **`RegisterDeviceRequestSchema`** gains three optional members: `vendor` (`apns` | `fcm`; absent, the server derives it from `platform`, `ios` → `apns` and `android` → `fcm`), `appId` and `locale`. `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, and since the member is new, no body sent before carries one. `RegisterDeviceResponseSchema` and `UnregisterDevice*` are unchanged. -- **`INotificationService`** gains three optional members: `registerDevice(userId, request)`, `unregisterDevice(userId, deviceId)` and `invalidateDeviceToken(token)`. Their docs state the semantics an implementation owes: one live owner per token (a second user's registration takes it over), unregistering a device the user does not hold answers not found, and an invalidated token keeps its device row, disabled, with the invalidation time. 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). The route is derived, never authored: it is the notification's existing `actionUrl` when that passes the schema, and absent otherwise. 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 `keyRef`) and an `fcm` block (HTTP v1: `projectId`, the service-account JSON by `serviceAccountRef`). 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. Nothing reads this shape until the push channel plugin lands, and it is not a stack-definition key. +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. -No route row is added: the device routes land with their handlers in the server slice, so `DEFAULT_NOTIFICATION_ROUTES` is unchanged. Nothing an author or a client 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 its vendor), `appId` and `locale`. `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 owner (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). The route is derived, never authored: it is the notification's existing `actionUrl` when that passes the schema, and absent otherwise. 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/protocol.mdx b/content/docs/references/api/protocol.mdx index 8b70225fff5..feda4f59e89 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -453,7 +453,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot say which row dropped what, so this body field stays the canonical per-row channel; REST also sends the `X-ObjectStack-Dropped-Fields` response header on the answer, naming the union of the rows' drops (each field and reason once). Optional — omit-when-empty keeps the shape backward-compatible. | --- @@ -494,7 +494,7 @@ Canonical cross-paradigm action/node descriptor (ADR-0018) | **id** | `string` | ✅ | The ID of the newly created clone. | | **sourceId** | `string` | ✅ | The ID of the record the clone was copied from. | | **record** | `Record` | ✅ | The created clone, including server-generated fields. Engine-owned values (injected system/audit columns, autonumbers, computed formula/summary fields) are re-derived by the insert path rather than copied from the source; caller-supplied `overrides` win over copied values. | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: fields that were LEGALLY stripped before the clone was written — a non-system clone cannot seed a static `readonly` column, whether the value was COPIED from the source row or supplied through `overrides` (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on `createData`), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the clone still succeeded without them (status/success semantics unchanged). Carried in the 201 body only — this route relays the producer verbatim and sets no `X-ObjectStack-Dropped-Fields` header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: fields that were LEGALLY stripped before the clone was written — a non-system clone cannot seed a static `readonly` column, whether the value was COPIED from the source row or supplied through `overrides` (the strip runs inside `engine.insert`, after the `beforeInsert` hooks, `isSystem`-gated, exactly as on `createData`), so those keys are dropped and the field re-derives its default. Present ONLY when ≥1 field was dropped; the clone still succeeded without them (status/success semantics unchanged). REST additionally surfaces this as the `X-ObjectStack-Dropped-Fields` response header. Optional — omit-when-empty keeps the shape backward-compatible for existing clients. | | **warnings** | `{ field: string; code: string; message: string; rule?: string; … }[]` | optional | The advisory validation-rule hits of THIS write: each `severity: 'warning'` or `'info'` rule whose verdict on the written record was "violated", with its `rule` name, `severity`, `field`, `code` and author-written `message` — the same element the validate-only preview reports in a row's `warnings`. Advisory rules never block, so the write succeeded; this is the advisory to show the person who made it. Present ONLY when at least one advisory rule hit; a write with no hit omits the key. Hits of a nested write a hook or a flow made inside this one are not included, and a rule that could not be evaluated is not a hit. | ### Nested Shape: `CloneDataResponse.droppedFields[number]` @@ -603,7 +603,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **object** | `string` | ✅ | Object name | | **records** | `Record[]` | ✅ | Created records | | **count** | `number` | ✅ | Number of records created | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). Optional — omit-when-empty keeps the shape backward-compatible. (The same reading applies to the partial-success bulk create, whose batch-level `droppedFields` names no row for the same reason. The paths that DO carry per-row `droppedFields` on each result are the bulk UPDATE and the mixed batch, where each row is its own engine call — see BatchOperationResultSchema.) | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied `readonly` fields the in-engine create-side strip (`engine.insert`, `isSystem`-gated) removed before the rows were written. AGGREGATED across the batch (one event per object/reason with the UNION of dropped field names) rather than per-row, because this response is `{ object, records, count }` and has no per-row slot to hang a drop set on — a union is the only view it can represent. So read a name here as "at least one row dropped this field", NOT "every row dropped the same set": the strip runs INSIDE `engine.insert` after the `beforeInsert` hooks and exempts keys a hook itself wrote, tracked per row: rows where a hook stamped a protected key drop a different set from rows where it did not. Present ONLY when ≥1 field was dropped; the creates still succeeded without them (count/success unchanged). REST additionally surfaces this union as the `X-ObjectStack-Dropped-Fields` response header, each field and reason once. Optional — omit-when-empty keeps the shape backward-compatible. (The same reading applies to the partial-success bulk create, whose batch-level `droppedFields` names no row for the same reason. The paths that DO carry per-row `droppedFields` on each result are the bulk UPDATE and the mixed batch, where each row is its own engine call — see BatchOperationResultSchema.) | ### Nested Shape: `CreateManyDataResponse.droppedFields[number]` @@ -713,7 +713,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot say which row dropped what, so this body field stays the canonical per-row channel; REST also sends the `X-ObjectStack-Dropped-Fields` response header on the answer, naming the union of the rows' drops (each field and reason once). Optional — omit-when-empty keeps the shape backward-compatible. | --- @@ -2767,11 +2767,8 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | :--- | :--- | :--- | :--- | | **token** | `string` | ✅ | Device push notification token | | **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform | -| **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 | +| **deviceId** | `string` | optional | Unique device identifier | | **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 core vendor) | -| **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 | --- @@ -3181,7 +3178,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa | **errors** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }[]` | optional | Array of errors if operation failed. Branch on `errors[0].code` — an atomic batch that rolled back marks rows that were written then undone with code ROLLED_BACK and rows never reached with NOT_ATTEMPTED, while the causal row keeps its own error. A NON-atomic batch that stopped (the `continueOnError: false` default) marks its un-attempted tail with the same NOT_ATTEMPTED code — rows before the failure stay written and keep reporting success, since nothing was rolled back. | | **data** | `Record` | optional | Full record data (if returnRecords=true) | | **index** | `number` | optional | Index of the record in the request array | -| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. | +| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when' \| 'primary_key' \| 'computed' \| 'stored_expression'> }[]` | optional | Write-observability: caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` / TRUE `readonlyWhen` on update, or the in-engine static `readonly` strip on create. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot say which row dropped what, so this body field stays the canonical per-row channel; REST also sends the `X-ObjectStack-Dropped-Fields` response header on the answer, naming the union of the rows' drops (each field and reason once). Optional — omit-when-empty keeps the shape backward-compatible. | --- diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index fb500771bb8..e99781e94fc 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3511,6 +3511,12 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({ /** * 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 @@ -3520,7 +3526,12 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({ * 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'), @@ -3530,24 +3541,28 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({ ), 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 core vendor)', + '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 core vendor, so a web registration names fcm or is refused', ), appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), locale: z.string().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 index afc3e5cf3a1..ca9ff89e19e 100644 --- a/packages/spec/src/api/push-delivery.test.ts +++ b/packages/spec/src/api/push-delivery.test.ts @@ -49,3 +49,9 @@ describe('PushDeliveryDataSchema', () => { 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 index 8b9153c7a4f..a8b2d1ab2eb 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -11,6 +11,11 @@ import { lazySchema } from '../shared/lazy-schema'; * notification (the same id the inbox lists and `markRead` takes), and * `route` is the Console path the shell opens. * + * **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. + * * **Where `route` comes from — derived, never authored.** There is no * `notify` key for it. It is the notification's existing `actionUrl`, the * value the inbox already serves: an explicit `url` / `actionUrl` in the @@ -41,9 +46,13 @@ export const PushRouteSchema = z.string() 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('Console route opened on tap: the notification actionUrl when it is a same-server relative path, absent otherwise'), -})); +}).describe( + '[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and the same-server route ' + + 'opened on tap. 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 index ec47b98df0a..ff8e9e48916 100644 --- a/packages/spec/src/api/register-device-request.test.ts +++ b/packages/spec/src/api/register-device-request.test.ts @@ -66,3 +66,16 @@ describe('RegisterDeviceRequestSchema (push device registry)', () => { 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 93b2c6081b3..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', () => { @@ -214,21 +215,26 @@ describe('Notification Service Contract', () => { 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, invalidate', async () => { + 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(); + 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, { userId, token: request.token, enabled: true }); + 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); @@ -246,6 +252,8 @@ describe('Notification Service Contract', () => { // 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 b391468cc0e..ad41328334b 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -27,6 +27,7 @@ */ 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'; /** @@ -214,15 +215,23 @@ export interface INotificationService { /* ------------------------------------------------------------------ */ /* - * All three are OPTIONAL, for the reason the inbox trio is: a provider + * 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`). + * `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. @@ -236,7 +245,9 @@ export interface INotificationService { * 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`). + * (`ios` → `apns`, `android` → `fcm`). A `web` registration names its + * vendor; without one there is nothing to derive, and it 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 @@ -256,6 +267,18 @@ export interface INotificationService { */ unregisterDevice?(userId: string, deviceId: string): Promise; + /** + * List this user's devices, newest `lastSeenAt` first. + * + * 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 diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index 284e2deaa6d..ddaae025180 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -78,6 +78,7 @@ 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 index cb2d4012303..327e6066ab0 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -20,10 +20,10 @@ const APNS = { keyId: 'KEY1234567', topic: 'com.example.shell', environment: 'production', - keyRef: 'OS_PUSH_APNS_KEY', + credentialRef: 'OS_PUSH_APNS_KEY', } as const; -const FCM = { projectId: 'example-project', serviceAccountRef: 'OS_PUSH_FCM_SERVICE_ACCOUNT' } 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"}'; @@ -49,9 +49,9 @@ describe('PushServiceConfigSchema', () => { 'OS_PUSH_APNS_KEY', 'push/apns-signing-key', 'vault:push.apns.p8', - ])('accepts the reference form %j', (keyRef) => { - expect(ApnsTransportConfigSchema.safeParse({ ...APNS, keyRef }).success).toBe(true); - expect(FcmTransportConfigSchema.safeParse({ ...FCM, serviceAccountRef: keyRef }).success).toBe(true); + ])('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) => { @@ -59,7 +59,7 @@ describe('PushServiceConfigSchema', () => { expect(parsed.success).toBe(false); const issue = parsed.error?.issues[0]; expect(issue?.code).toBe('unrecognized_keys'); - expect(issue?.message).toContain('keyRef'); + expect(issue?.message).toContain('credentialRef'); }); it.each(['serviceAccount', 'serviceAccountJson', 'privateKey'])('refuses an inline FCM credential under %j, by name', (key) => { @@ -67,19 +67,32 @@ describe('PushServiceConfigSchema', () => { expect(parsed.success).toBe(false); const issue = parsed.error?.issues[0]; expect(issue?.code).toBe('unrecognized_keys'); - expect(issue?.message).toContain('serviceAccountRef'); + expect(issue?.message).toContain('credentialRef'); }); - it('refuses a PEM block pasted into keyRef', () => { - const parsed = ApnsTransportConfigSchema.safeParse({ ...APNS, keyRef: PEM }); + 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(['keyRef']); + expect(parsed.error?.issues[0]?.path).toEqual(['credentialRef']); }); - it('refuses a service-account JSON document pasted into serviceAccountRef', () => { - const parsed = FcmTransportConfigSchema.safeParse({ ...FCM, serviceAccountRef: SERVICE_ACCOUNT_JSON }); + 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(['serviceAccountRef']); + expect(parsed.error?.issues[0]?.path).toEqual(['credentialRef']); }); it('refuses an environment outside sandbox / production', () => { @@ -94,3 +107,15 @@ describe('PushServiceConfigSchema', () => { 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 index 35bfae1d178..fe95b4f97be 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -7,6 +7,11 @@ 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 @@ -15,21 +20,23 @@ import { strictObject } from '../shared/strict-object'; * * **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 reference 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 the reference key 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. + * 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.** The push channel plugin (`plugin-notification-push`, - * ADR-0012 §11) is its one reader, and it is a later change than this - * declaration: until it lands nothing reads this shape, and it is not a key - * of the stack definition. Wiring it into a configuration door is that - * change's to make, together with the reader, so a key is never authorable - * before something honours it. + * **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, @@ -68,15 +75,15 @@ function inlineSecretGuidance(transport: string, secret: string, refKey: string) + `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', 'keyRef'); -const FCM_ACCOUNT_GUIDANCE = inlineSecretGuidance('FCM', 'service-account JSON', 'serviceAccountRef'); +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', credentialRef: 'keyRef' }, + aliases: { bundleId: 'topic', keyRef: 'credentialRef' }, guidance: { key: APNS_KEY_GUIDANCE, privateKey: APNS_KEY_GUIDANCE, @@ -92,10 +99,10 @@ export const ApnsTransportConfigSchema = lazySchema(() => strictObject( 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)'), - keyRef: secretReference('APNs .p8 signing key') + 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. */ @@ -103,7 +110,7 @@ export const FcmTransportConfigSchema = lazySchema(() => strictObject( { surface: 'the FCM push transport', history: 'This block is strict from birth — no FCM configuration existed before it.', - aliases: { credentialRef: 'serviceAccountRef' }, + aliases: { serviceAccountRef: 'credentialRef' }, guidance: { serviceAccount: FCM_ACCOUNT_GUIDANCE, serviceAccountJson: FCM_ACCOUNT_GUIDANCE, @@ -112,15 +119,15 @@ export const FcmTransportConfigSchema = lazySchema(() => strictObject( 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 `serviceAccountRef`.', + + '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'), - serviceAccountRef: secretReference('FCM service-account JSON') + 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; /** @@ -137,5 +144,5 @@ export const PushServiceConfigSchema = lazySchema(() => strictObject( 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.test.ts b/packages/spec/src/system/push-device.test.ts new file mode 100644 index 00000000000..96ff98a9fd9 --- /dev/null +++ b/packages/spec/src/system/push-device.test.ts @@ -0,0 +1,65 @@ +// 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'; + +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('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('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..4fff7835023 --- /dev/null +++ b/packages/spec/src/system/push-device.zod.ts @@ -0,0 +1,64 @@ +// 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'; + +/** + * 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 owner per token.** A vendor token is delivered to one user + * only. 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(['ios', 'android', 'web']).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: z.string().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; absent while the device is enabled'), + lastSeenAt: z.string().datetime().optional().describe('When the device last registered or refreshed'), +}).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 3be0238e77b..d23cfeab05b 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -286,9 +286,10 @@ 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'; // --------------------------------------------------------------------------- -// 817 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 @@ -1421,6 +1422,9 @@ export type Iso_system_pushConfig__FcmTransportConfigSchema = 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 > >>; @@ -1758,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 817 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 @@ -2743,7 +2747,12 @@ describe('ADR-0122 type-alias convention', () => { // required and optional strings, enums and regex refinements, no default // and no transform (Iso_system_pushConfig__*, Iso_api_pushDelivery__*). // +6 added. - expect(pins).toHaveLength(817); + // 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 From ed89754e06cada08733090028746ca9249041776 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:32:01 +0000 Subject: [PATCH 05/11] chore(spec): regenerate spec artifacts for the push device registry protocol face Regenerated from the merged tree by check:generated --fix after the origin/main merge: api-surface, export-origins, declaration-map, docs references, strictness ledger counts, json-schema manifest and the authorable-surface shard (system shard taken from main, then gen:schema). Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 13 ++- content/docs/references/api/push-delivery.mdx | 7 ++ content/docs/references/index.mdx | 9 +- content/docs/references/system/index.mdx | 3 +- content/docs/references/system/meta.json | 1 + .../docs/references/system/push-config.mdx | 55 +++++++----- .../docs/references/system/push-device.mdx | 83 +++++++++++++++++++ .../system.md | 2 +- packages/spec/api-surface/system.json | 2 + packages/spec/authorable-surface/system.json | 15 +++- packages/spec/declaration-map/api.json | 1 + packages/spec/declaration-map/system.json | 2 + packages/spec/export-origins/system.json | 2 + .../spec/json-schema.manifest/system.json | 1 + 14 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 content/docs/references/system/push-device.mdx diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index feda4f59e89..bf543da86c7 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 core vendor, so a web registration names fcm or 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 index 9651f065acd..67a092cd514 100644 --- a/content/docs/references/api/push-delivery.mdx +++ b/content/docs/references/api/push-delivery.mdx @@ -13,6 +13,11 @@ 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 Console path the shell opens. +**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. + **Where `route` comes from — derived, never authored.** There is no `notify` key for it. It is the notification's existing `actionUrl`, the value the inbox already serves: an explicit `url` / `actionUrl` in the @@ -44,6 +49,8 @@ const result = PushDeliveryDataSchema.parse(data); ## PushDeliveryData +[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and the same-server route opened on tap. No push channel delivers yet + ### Properties | Property | Type | Required | Description | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 2aeb309d975..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 — 1602 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/. */} @@ -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) | 38 | 291 | 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** | **207** | **1602** | 14 protocol modules | +| **Total** | **208** | **1603** | 14 protocol modules | --- @@ -325,7 +325,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **38 pages, 291 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. @@ -357,6 +357,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`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 b33c2c8a05e..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 38 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/. */} @@ -35,6 +35,7 @@ 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 4131a2ab398..2aaf272deec 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -43,6 +43,7 @@ "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 index a5ad2db035d..20b31ca09c7 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -1,13 +1,18 @@ --- title: Push Config schema — System Protocol reference navTitle: Push Config -description: "Push Service Configuration Protocol — the push channel's transports. Reference for ApnsTransportConfig and 3 more: every property with its type and default." +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 @@ -16,21 +21,23 @@ 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 reference 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 the reference key 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.** The push channel plugin (`plugin-notification-push`, -ADR-0012 §11) is its one reader, and it is a later change than this -declaration: until it lands nothing reads this shape, and it is not a key -of the stack definition. Wiring it into a configuration door is that -change's to make, together with the reader, so a key is never authorable -before something honours it. +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, @@ -54,6 +61,8 @@ 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 | @@ -62,31 +71,35 @@ const result = ApnsTransportConfigSchema.parse(data); | **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) | -| **keyRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | +| **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 | -| **serviceAccountRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | +| **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; serviceAccountRef: string }` | optional | FCM HTTP v1 transport. Absent: no delivery to fcm devices | +| **fcm** | `{ projectId: string; credentialRef: string }` | optional | FCM HTTP v1 transport. Absent: no delivery to fcm devices | ### Nested Shape: `PushServiceConfig.apns` @@ -96,14 +109,14 @@ const result = ApnsTransportConfigSchema.parse(data); | **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) | -| **keyRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | +| **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 | -| **serviceAccountRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | --- diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx new file mode 100644 index 00000000000..2d1f72df00e --- /dev/null +++ b/content/docs/references/system/push-device.mdx @@ -0,0 +1,83 @@ +--- +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 owner per token.** A vendor token is delivered to one user + only. 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; absent while the device is enabled | +| **lastSeenAt** | `string` | optional | When the device last registered or refreshed | + + +--- + 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 931d6033555..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/` | 393 | +| `system/` | 394 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index da3fa7ac8ee..1ac5c7b2198 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -811,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/system.json b/packages/spec/authorable-surface/system.json index 1c14366ca49..71186982f34 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -29,9 +29,9 @@ "system/AnalyzerConfig:language", "system/AnalyzerConfig:stopwords", "system/AnalyzerConfig:type", + "system/ApnsTransportConfig:credentialRef", "system/ApnsTransportConfig:environment", "system/ApnsTransportConfig:keyId", - "system/ApnsTransportConfig:keyRef", "system/ApnsTransportConfig:teamId", "system/ApnsTransportConfig:topic", "system/AppCompatibilityCheck:compatible", @@ -440,8 +440,8 @@ "system/FailoverConfig:healthCheckIntervalSeconds", "system/FailoverConfig:mode", "system/FailoverConfig:regions", + "system/FcmTransportConfig:credentialRef", "system/FcmTransportConfig:projectId", - "system/FcmTransportConfig:serviceAccountRef", "system/Feature:code", "system/Feature:description", "system/Feature:label", @@ -1398,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 9cbeecc1f22..c4243b537c9 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -615,6 +615,7 @@ "PublishPackageDraftsResponseSchema": "api/PublishPackageDraftsResponse", "PushDeliveryData": "api/PushDeliveryData", "PushDeliveryDataSchema": "api/PushDeliveryData", + "PushRoute": "api/PushRoute", "PushRouteSchema": "api/PushRoute", "QueryAdapterConfig": "api/QueryAdapterConfig", "QueryAdapterConfigSchema": "api/QueryAdapterConfig", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 997c4adf955..2667d05f1d1 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -538,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/system.json b/packages/spec/export-origins/system.json index ded7079ecf5..84dea07f904 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -773,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/system.json b/packages/spec/json-schema.manifest/system.json index fc84343a575..44877f71954 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -291,6 +291,7 @@ "system/TranslationDiffStatus", "system/TranslationItem", "system/UserActivityStatus", + "system/UserDevice", "system/VectorClock", "system/WorkerStats" ] From 73fabdf2eb794ad9698257c6a1588c8e74c89bae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:17:33 +0000 Subject: [PATCH 06/11] docs(spec): llms.txt schema inventory counts the push channel modules check:llms-txt: system 37 -> 39 (push-config, push-device), api 33 -> 34 (push-delivery), heading 213 -> 216; the rows name the new modules. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- packages/spec/llms.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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) | From 833e9bd7ba11aa8cef7dd50872e23ae981741c1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:16:55 +0000 Subject: [PATCH 07/11] fix(spec): push device locale members take the house LocaleSchema RegisterDeviceRequestSchema.locale and UserDeviceSchema.locale were a bare string their describes called BCP 47; a device saved as a non-tag spelling would be rendered in a locale no bundle answers. Both now take LocaleSchema (system/translation.zod.ts), with refusal tests for non-tag spellings. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- packages/spec/src/api/protocol.zod.ts | 4 ++-- .../spec/src/api/register-device-request.test.ts | 13 +++++++++++++ packages/spec/src/system/push-device.test.ts | 7 +++++++ packages/spec/src/system/push-device.zod.ts | 3 ++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index e99781e94fc..30d7cba4642 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -28,7 +28,7 @@ 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'; // #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item // responses publish. Same three vocabularies the resolver filters against, so a @@ -3545,7 +3545,7 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({ + 'android means fcm; web has no core vendor, so a web registration names fcm or is refused', ), appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), - locale: z.string().optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), + 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', diff --git a/packages/spec/src/api/register-device-request.test.ts b/packages/spec/src/api/register-device-request.test.ts index ff8e9e48916..0dd241476fe 100644 --- a/packages/spec/src/api/register-device-request.test.ts +++ b/packages/spec/src/api/register-device-request.test.ts @@ -59,6 +59,19 @@ describe('RegisterDeviceRequestSchema (push device registry)', () => { 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 }); diff --git a/packages/spec/src/system/push-device.test.ts b/packages/spec/src/system/push-device.test.ts index 96ff98a9fd9..39bd07f73a7 100644 --- a/packages/spec/src/system/push-device.test.ts +++ b/packages/spec/src/system/push-device.test.ts @@ -49,6 +49,13 @@ describe('UserDeviceSchema', () => { 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); diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index 4fff7835023..89dc0cfe3cb 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { PushVendorSchema } from './push-config.zod'; +import { LocaleSchema } from './translation.zod'; /** * Push Device Protocol — the stored device shape of the push device registry @@ -52,7 +53,7 @@ export const UserDeviceSchema = lazySchema(() => z.object({ 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(['ios', 'android', 'web']).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: z.string().optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), + 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; absent while the device is enabled'), From e7b06bc44093d85fc37cee9415f1f08f396b5321 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:27:38 +0000 Subject: [PATCH 08/11] fix(spec): push device contract review should-fixes - RegisterDeviceRequestSchema.vendor and INotificationService.registerDevice now say the same thing: web has no default vendor, a web registration names apns or fcm, and one without vendor is refused. - unregisterDevice states its mechanism: a device the user does not hold throws an error whose code is NOT_FOUND, which the route answers as 404. - A comment says why the push secretReference is stricter than the house credentialRef: the two push secrets are recognisable by shape (PEM block, JSON document), a bearer token or API key is not. - One platform list for the registration and the stored device, in an internal module no barrel re-exports, so no new public name. - UserDeviceSchema describes say when lastSeenAt and invalidatedAt are absent. - The changeset names LocaleSchema for the registration's locale. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- .changeset/29067-push-device-registry-spec.md | 2 +- content/docs/references/api/protocol.mdx | 2 +- .../docs/references/system/push-device.mdx | 4 ++-- packages/spec/src/api/protocol.zod.ts | 5 +++-- .../src/api/register-device-request.test.ts | 8 ++++++++ .../src/contracts/notification-service.ts | 19 +++++++++++-------- packages/spec/src/system/push-config.zod.ts | 8 ++++++++ .../spec/src/system/push-device-platform.ts | 15 +++++++++++++++ packages/spec/src/system/push-device.test.ts | 6 ++++++ packages/spec/src/system/push-device.zod.ts | 13 ++++++++++--- 10 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 packages/spec/src/system/push-device-platform.ts diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md index 44603b88248..5a4853f3fe7 100644 --- a/.changeset/29067-push-device-registry-spec.md +++ b/.changeset/29067-push-device-registry-spec.md @@ -9,7 +9,7 @@ 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 its vendor), `appId` and `locale`. `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. +- **`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 owner (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). The route is derived, never authored: it is the notification's existing `actionUrl` when that passes the schema, and absent otherwise. No `notify` key is added. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index bf543da86c7..eda3cf376ea 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2771,7 +2771,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant' | **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform | | **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 core vendor, so a web registration names fcm or is refused | +| **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 | diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx index 2d1f72df00e..c5d75f3b3fc 100644 --- a/content/docs/references/system/push-device.mdx +++ b/content/docs/references/system/push-device.mdx @@ -75,8 +75,8 @@ const result = UserDeviceSchema.parse(data); | **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; absent while the device is enabled | -| **lastSeenAt** | `string` | optional | When the device last registered or refreshed | +| **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/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 30d7cba4642..86476ea097b 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -30,6 +30,7 @@ import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissio import { ActionDescriptorSchema } from '../automation/node-executor.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. @@ -3534,7 +3535,7 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({ // 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'), + 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', @@ -3542,7 +3543,7 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({ 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 core vendor, so a web registration names fcm or is refused', + + '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'), diff --git a/packages/spec/src/api/register-device-request.test.ts b/packages/spec/src/api/register-device-request.test.ts index 0dd241476fe..13fa98a8cee 100644 --- a/packages/spec/src/api/register-device-request.test.ts +++ b/packages/spec/src/api/register-device-request.test.ts @@ -52,6 +52,14 @@ describe('RegisterDeviceRequestSchema (push device registry)', () => { 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); diff --git a/packages/spec/src/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts index ad41328334b..3f2ab220c34 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -245,9 +245,9 @@ export interface INotificationService { * 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`). A `web` registration names its - * vendor; without one there is nothing to derive, and it is refused as - * a validation error, never stored with a guessed vendor. + * (`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 @@ -257,10 +257,12 @@ export interface INotificationService { /** * Unregister one of this user's devices (sign-out, account switch). * - * A `deviceId` this user does not hold — unknown, or another user's — - * answers not found (`NOT_FOUND`, 404 at the device route), never a - * success and never a distinct "forbidden": a caller cannot learn that a - * device id exists for somebody else, and cannot remove it. + * 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 @@ -268,7 +270,8 @@ export interface INotificationService { unregisterDevice?(userId: string, deviceId: string): Promise; /** - * List this user's devices, newest `lastSeenAt` first. + * 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 diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index fe95b4f97be..6b45637ae9c 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -60,6 +60,14 @@ export type PushVendor = z.input; * 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) 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 index 39bd07f73a7..a3598c5cd96 100644 --- a/packages/spec/src/system/push-device.test.ts +++ b/packages/spec/src/system/push-device.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { UserDeviceSchema } from './push-device.zod'; +import { RegisterDeviceRequestSchema } from '../api/protocol.zod'; const DEVICE = { deviceId: 'install-1', @@ -62,6 +63,11 @@ describe('UserDeviceSchema', () => { 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'); }); diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index 89dc0cfe3cb..b12e7255c78 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -4,6 +4,7 @@ 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 @@ -51,13 +52,19 @@ export const UserDeviceSchema = lazySchema(() => z.object({ 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(['ios', 'android', 'web']).describe('Device platform, as the registration named 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; absent while the device is enabled'), - lastSeenAt: z.string().datetime().optional().describe('When the device last registered or refreshed'), + 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', From eb9b2371965884d081fe784a9522175176135cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:23:15 +0000 Subject: [PATCH 09/11] fix(spec): push route is the inbox actionUrl the shell resolves; path refusals Delta contract review round 3 on the push device registry spec. - PushDeliveryData.route is the notification's actionUrl, the same value the inbox serves: app-relative (/OBJECT/ID) or already /apps/... The describes and the docblock say the shell resolves it on tap exactly as the bell and Home do (resolveNotificationTarget with its host app segment) and never navigates it verbatim. - The docblock says where the data sits per vendor: FCM message.data, APNs top level beside aps, never under a data key. - PushRouteSchema refuses dot segments and percent-encoded /, \ and . in its path, as a pattern the published JSON Schema carries too. - The device docblock and registerDevice say a token the user already holds under another deviceId refreshes that row, never a second live row. - The changeset follows. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- .changeset/29067-push-device-registry-spec.md | 4 +- content/docs/references/api/push-delivery.mdx | 44 +++++++----- .../docs/references/system/push-device.mdx | 9 ++- packages/spec/src/api/push-delivery.test.ts | 34 +++++++-- packages/spec/src/api/push-delivery.zod.ts | 72 +++++++++++++------ .../src/contracts/notification-service.ts | 11 +-- packages/spec/src/system/push-device.zod.ts | 9 ++- 7 files changed, 127 insertions(+), 56 deletions(-) diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md index 5a4853f3fe7..dd892fe07aa 100644 --- a/.changeset/29067-push-device-registry-spec.md +++ b/.changeset/29067-push-device-registry-spec.md @@ -10,7 +10,7 @@ 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 owner (a second user's registration takes it over), and a token the vendor reports dead keeps its row with `enabled: false` and `invalidatedAt`. +- **`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). The route is derived, never authored: it is the notification's existing `actionUrl` when that passes the schema, and absent otherwise. No `notify` key is added. +- **`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/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx index 67a092cd514..e691b10f771 100644 --- a/content/docs/references/api/push-delivery.mdx +++ b/content/docs/references/api/push-delivery.mdx @@ -6,26 +6,40 @@ description: "Push delivery data — what a push carries to the shell. Reference {/* ⚠️ 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 (ADR-0012 §8: the -`data` member of the push channel's rendered body). +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 Console path the shell opens. +`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. -**Where `route` comes from — derived, never authored.** There is no -`notify` key for it. It is the notification's existing `actionUrl`, 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. 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. +**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. Not to be confused with the `PushNotificationSchema` that `system/` once declared and then removed (ADR-0049): that was an authoring template shape; @@ -49,23 +63,21 @@ const result = PushDeliveryDataSchema.parse(data); ## PushDeliveryData -[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and the same-server route opened on tap. No push channel delivers yet +[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 | Console route opened on tap: the notification actionUrl when it is a same-server relative path, absent otherwise | +| **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 -Same-server relative path the shell opens on tap (for example /apps/crm/opportunity/123); never an absolute or off-server URL - -**Type:** `string` +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/system/push-device.mdx b/content/docs/references/system/push-device.mdx index c5d75f3b3fc..d88aa29e12a 100644 --- a/content/docs/references/system/push-device.mdx +++ b/content/docs/references/system/push-device.mdx @@ -26,9 +26,12 @@ Three rules the registry owes, stated here because they decide the shape: 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 owner per token.** A vendor token is delivered to one user - only. 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 +- **(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 diff --git a/packages/spec/src/api/push-delivery.test.ts b/packages/spec/src/api/push-delivery.test.ts index ca9ff89e19e..77d6d3de299 100644 --- a/packages/spec/src/api/push-delivery.test.ts +++ b/packages/spec/src/api/push-delivery.test.ts @@ -1,17 +1,21 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// The data a push delivery carries to the shell. Its `route` is opened by the -// shell on tap, 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, or a path a URL parser rewrites into one) is -// refused here, and the server leaves `route` out instead of sending it. +// 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', () => { - it('accepts a notification id with a Console route', () => { - const data = { notificationId: 'ntf_1', route: '/apps/crm/opportunity/123' }; + // 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); }); @@ -25,6 +29,13 @@ describe('PushDeliveryDataSchema', () => { 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'])( + '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); @@ -43,6 +54,15 @@ describe('PushDeliveryDataSchema', () => { '/\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', ])('refuses route %j', (route) => { const parsed = PushDeliveryDataSchema.safeParse({ notificationId: 'ntf_1', route }); expect(parsed.success).toBe(false); diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts index a8b2d1ab2eb..67063019bba 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -4,26 +4,40 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; /** - * Push delivery data — what a push carries to the shell (ADR-0012 §8: the - * `data` member of the push channel's rendered body). + * 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 Console path the shell opens. + * `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. * - * **Where `route` comes from — derived, never authored.** There is no - * `notify` key for it. It is the notification's existing `actionUrl`, 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. 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. + * **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. * * Not to be confused with the `PushNotificationSchema` that `system/` once * declared and then removed (ADR-0049): that was an authoring template shape; @@ -31,28 +45,44 @@ import { lazySchema } from '../shared/lazy-schema'; */ /** - * A same-server relative path the shell may open: 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. + * 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 `..`) and no percent-encoded `/`, `\` or `.` (`%2F`, `%5C`, `%2E`, either + * case): a router that normalises or decodes the path would move it somewhere + * the notification did not point. 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.', }) - .describe('Same-server relative path the shell opens on tap (for example /apps/crm/opportunity/123); never an absolute or off-server URL'); + .regex(/^(?![^?#]*(?:\/\.\.?(?:[/?#]|$)|%(?:2[eEfF]|5[cC])))/, { + message: 'A push route path carries no dot segment ("." or "..") and no percent-encoded "/", "\\" or "." ' + + '(%2F, %5C, %2E): 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('Console route opened on tap: the notification actionUrl when it is a same-server relative path, absent otherwise'), + 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 the same-server route ' - + 'opened on tap. No push channel delivers yet', + '[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/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts index 3f2ab220c34..30a88309c93 100644 --- a/packages/spec/src/contracts/notification-service.ts +++ b/packages/spec/src/contracts/notification-service.ts @@ -240,10 +240,13 @@ export interface INotificationService { * 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 owner per token.** A token is delivered to one user only: - * 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. + * - **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 diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index b12e7255c78..5329cce138b 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -27,9 +27,12 @@ import { PUSH_DEVICE_PLATFORMS } from './push-device-platform'; * 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 owner per token.** A vendor token is delivered to one user - * only. 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 + * - **(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 From aff751deae7c9c9f0876e667ec6eb8683cf800d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:14:33 +0000 Subject: [PATCH 10/11] docs(spec): push route with no app list, and the encoded percent refusal Delta review round 4 prose should-fixes in push-delivery.zod.ts. - A shell with no app list hands route to the Console to resolve, or hosts it under the segment the Console would answer (resolveHostAppSegment: preferred, then first active app, then setup). It never re-implements the resolver and never opens route verbatim. - PushRouteSchema also refuses %25 in the path, which closes the double-encoded separators (%252F, %252E, %255C), pinned; a query or fragment keeps its own encoding. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- .changeset/29067-push-device-registry-spec.md | 2 +- content/docs/references/api/push-delivery.mdx | 8 ++++++ packages/spec/src/api/push-delivery.test.ts | 6 ++++- packages/spec/src/api/push-delivery.zod.ts | 26 ++++++++++++++----- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.changeset/29067-push-device-registry-spec.md b/.changeset/29067-push-device-registry-spec.md index dd892fe07aa..096f4cb0670 100644 --- a/.changeset/29067-push-device-registry-spec.md +++ b/.changeset/29067-push-device-registry-spec.md @@ -12,5 +12,5 @@ Nothing here is served or read yet. Every new or touched schema carries a `[PLAN - **`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. +- **`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/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx index e691b10f771..edfb78e9492 100644 --- a/content/docs/references/api/push-delivery.mdx +++ b/content/docs/references/api/push-delivery.mdx @@ -41,6 +41,14 @@ 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. diff --git a/packages/spec/src/api/push-delivery.test.ts b/packages/spec/src/api/push-delivery.test.ts index 77d6d3de299..929b6a47b3c 100644 --- a/packages/spec/src/api/push-delivery.test.ts +++ b/packages/spec/src/api/push-delivery.test.ts @@ -29,7 +29,7 @@ describe('PushDeliveryDataSchema', () => { 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'])( + 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); @@ -63,6 +63,10 @@ describe('PushDeliveryDataSchema', () => { '/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); diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts index 67063019bba..f69f8a90678 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -39,6 +39,14 @@ import { lazySchema } from '../shared/lazy-schema'; * `/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. @@ -52,19 +60,23 @@ import { lazySchema } from '../shared/lazy-schema'; * protocol-relative reference to another site. * * Its path (the part before any `?` or `#`) also carries no dot segment (`.` - * or `..`) and no percent-encoded `/`, `\` or `.` (`%2F`, `%5C`, `%2E`, either - * case): a router that normalises or decodes the path would move it somewhere - * the notification did not point. Both rules are patterns, so the published - * JSON Schema carries them too. A query or fragment keeps its own encoding. + * 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])))/, { - message: 'A push route path carries no dot segment ("." or "..") and no percent-encoded "/", "\\" or "." ' - + '(%2F, %5C, %2E): a router that normalises or decodes the path would open somewhere else.', + .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 ' From 918921642593dd4097831bd1ef1582ae15f584fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 17:52:48 +0000 Subject: [PATCH 11/11] chore(spec): regenerate on the tree merged with main The os-regen merge took main's protocol reference page; regenerated from the merged tree it carries both main's view changes and this branch's device describes. The api strictness-ledger count is 488 on the merged tree (main and this branch each added one site since the merge base). Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 12 ++++++------ .../api.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index eda3cf376ea..498519f23de 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1877,7 +1877,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1916,8 +1916,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | @@ -1956,7 +1956,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout | | **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **description** | `string \| Record` | optional | View description for documentation/tooltips | -| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration | +| **sharing** | `{ lockedBy?: string }` | optional | View sharing configuration: who locked the view configuration | | **requiredPermissions** | `string[]` | optional | [ADR-0066] Capabilities a user must ALL hold for this list view or dashboard to be served to them — names that permission sets grant through `systemPermissions`, the same list as `requiredPermissions` on an app, a navigation item or an action. Absent or empty: no audience gate. There is no any-of form: to serve one item to several audiences (legal OR admin), declare one capability and grant it to each of their permission sets. The server enforces it: a user who does not hold them all is not listed the item and is refused it by name; a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. | | **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting | | **grouping** | `{ fields: object[] }` | optional | Group records by one or more fields — server-side: the groups and their header numbers come from an aggregate query over the whole filtered set, rows within a group are paged (see GroupingConfigSchema) | @@ -1995,8 +1995,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **splitDirection** | `never` | optional | [REMOVED] `view.form.splitDirection` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it set the pane orientation of a split form, and the split form renderer retired in the same release, so no form draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitSize** | `never` | optional | [REMOVED] `view.form.splitSize` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it sized the primary pane of a `type: 'split'` form, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **splitResizable** | `never` | optional | [REMOVED] `view.form.splitResizable` was removed in @objectstack/spec 18.3.0 (ADR-0050) — it made the panes of a `type: 'split'` form resizable, and `split` left the form view `type` in the same release, so no form view draws panes. Delete the key. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **drawerSide** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Edge the drawer container opens from, when the React `ObjectForm` block opens this form with formType 'drawer' | -| **drawerWidth** | `string` | optional | [DEPRECATED → size buckets] Width of the drawer container the React `ObjectForm` block opens with formType 'drawer', e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it. | +| **drawerSide** | `never` | optional | [REMOVED] `view.form.drawerSide` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it. Delete the key: a list opens a clicked record in its drawer (`navigation.mode`), and the React `ObjectForm` block keeps its own `drawerSide` prop for the drawer it opens with `formType='drawer'`. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | +| **drawerWidth** | `never` | optional | [REMOVED] `view.form.drawerWidth` was removed in @objectstack/spec 18.3.0 (ADR-0049) — no form view opens in a drawer, so no form view ever read it, and a pixel width cannot be chosen without knowing the client viewport. Delete the key: a list's record drawer takes its starting width from the `navigation.size` bucket, and the React `ObjectForm` block keeps its own `drawerWidth` prop for the drawer it opens. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Size bucket of the dialog a caller opens this form in: the console's New / Edit dialog, or the modal container of the React `ObjectForm` block (formType 'modal') | | **data** | `{ provider: 'object'; object: string } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record }` | optional | Data source configuration (defaults to "object" provider) | | **sections** | `{ name?: string; label?: string \| Record; description?: string; collapsible?: boolean; … }[]` | optional | | 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 |