From 8dcce8b7399d529022e44fa5f7aa1bb7aad21cdc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 07:06:02 +0000 Subject: [PATCH 1/3] 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 2/3] =?UTF-8?q?feat(spec):=20push=20channel=20protocol=20f?= =?UTF-8?q?ace=20=E2=80=94=20device=20registration=20widening,=20device=20?= =?UTF-8?q?contract=20members,=20push=20delivery=20data,=20APNs/FCM=20tran?= =?UTF-8?q?sport=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 3/3] 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