From 0eff1432d37e7e20ea0172850cec8086eefe1dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:01:18 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(spec):=20push=20relay=20transport=20?= =?UTF-8?q?=E2=80=94=20transport:=20relay=20|=20direct=20on=20PushServiceC?= =?UTF-8?q?onfigSchema,=20and=20the=20server-to-relay=20wire=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG Co-authored-by: Claude --- packages/spec/src/api/index.ts | 1 + packages/spec/src/api/push-relay.test.ts | 194 +++++++++++++++ packages/spec/src/api/push-relay.zod.ts | 224 ++++++++++++++++++ packages/spec/src/system/push-config.test.ts | 121 ++++++++-- packages/spec/src/system/push-config.zod.ts | 138 +++++++++-- .../src/type-alias-convention.pin.test.ts | 28 ++- 6 files changed, 671 insertions(+), 35 deletions(-) create mode 100644 packages/spec/src/api/push-relay.test.ts create mode 100644 packages/spec/src/api/push-relay.zod.ts diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index d6f5dcb3efd..48523b2baf7 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -62,6 +62,7 @@ export { zodIssuesToFields } from './zod-issues-to-fields'; export * from './error-code-ledger.zod'; export * from './protocol.zod'; export * from './push-delivery.zod'; +export * from './push-relay.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/push-relay.test.ts b/packages/spec/src/api/push-relay.test.ts new file mode 100644 index 00000000000..b881ff43f67 --- /dev/null +++ b/packages/spec/src/api/push-relay.test.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The server → push relay wire shape, pinned by both ends: the push channel +// that sends it and the hosted relay that receives it. The payload is ID-only +// (the push delivery data plus one generic visible line, never a title or +// body), the response answers each delivery token with ok, invalid or retry, +// and a whole-request refusal is the house error envelope for 401 (bad or +// revoked binding) and 429 (rate limit or quota, with the next step). + +import { describe, it, expect } from 'vitest'; +import { + PushRelayDeliverySchema, + PushRelayErrorResponseSchema, + PushRelayErrorSchema, + PushRelayPayloadSchema, + PushRelayPrioritySchema, + PushRelayRequestSchema, + PushRelayResponseSchema, + PushRelayResultSchema, + PushRelayResultStatusSchema, +} from './push-relay.zod'; + +const PAYLOAD = { + data: { notificationId: 'ntf_1', route: '/opportunity/123', origin: 'https://crm.example.com' }, + text: 'You have a new notification', +} as const; + +const DELIVERY = { token: 'apns-device-token-1', vendor: 'apns', payload: PAYLOAD, priority: 'high' } as const; + +describe('PushRelayRequestSchema', () => { + it('accepts one delivery per device token, across both vendors and priorities', () => { + const request = { + deliveries: [ + DELIVERY, + { token: 'fcm-device-token-1', vendor: 'fcm', payload: { ...PAYLOAD, data: { notificationId: 'ntf_1' } }, priority: 'normal' }, + ], + } as const; + expect(PushRelayRequestSchema.parse(request)).toEqual(request); + }); + + it('refuses an empty batch', () => { + const parsed = PushRelayRequestSchema.safeParse({ deliveries: [] }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['deliveries']); + }); + + it('carries no credential in the body: the binding is the bearer', () => { + const parsed = PushRelayRequestSchema.safeParse({ deliveries: [DELIVERY], credential: 'oscc_x' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.code).toBe('unrecognized_keys'); + }); + + it.each(['token', 'vendor', 'payload', 'priority'] as const)('requires %j on every delivery', (key) => { + const { [key]: _omitted, ...rest } = DELIVERY; + const parsed = PushRelayDeliverySchema.safeParse(rest); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual([key]); + }); + + it('keeps the vendor to apns and fcm, and the priority to high and normal', () => { + expect(PushRelayPrioritySchema.options).toEqual(['high', 'normal']); + expect(PushRelayDeliverySchema.safeParse({ ...DELIVERY, vendor: 'hms' }).error?.issues[0]?.path).toEqual(['vendor']); + expect(PushRelayDeliverySchema.safeParse({ ...DELIVERY, priority: 'low' }).error?.issues[0]?.path).toEqual(['priority']); + }); +}); + +describe('PushRelayPayloadSchema — ID-only', () => { + it('holds the push delivery data and one generic visible line', () => { + expect(PushRelayPayloadSchema.parse(PAYLOAD)).toEqual(PAYLOAD); + }); + + it.each(['title', 'body', 'subtitle'])('refuses notification content under %j, by name', (key) => { + const parsed = PushRelayPayloadSchema.safeParse({ ...PAYLOAD, [key]: 'Deal closed: ACME renewal' }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('A push through the relay is ID-only'); + }); + + it('judges data by the push delivery data contract (a route the shell would open off-server is refused)', () => { + const parsed = PushRelayPayloadSchema.safeParse({ ...PAYLOAD, data: { notificationId: 'ntf_1', route: '//evil.example.com' } }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['data', 'route']); + }); + + it('requires the notification id and a non-empty visible line', () => { + expect(PushRelayPayloadSchema.safeParse({ ...PAYLOAD, data: {} }).error?.issues[0]?.path).toEqual(['data', 'notificationId']); + expect(PushRelayPayloadSchema.safeParse({ ...PAYLOAD, text: '' }).error?.issues[0]?.path).toEqual(['text']); + }); +}); + +describe('PushRelayResponseSchema', () => { + it('names exactly three per-token outcomes', () => { + expect(PushRelayResultStatusSchema.options).toEqual(['ok', 'invalid', 'retry']); + }); + + it.each(['ok', 'invalid', 'retry'] as const)('answers a delivery token with %j', (status) => { + const response = { results: [{ token: 'apns-device-token-1', status }] }; + expect(PushRelayResponseSchema.parse(response)).toEqual(response); + }); + + it('answers each delivery of a batch, in request order', () => { + const response = { + results: [ + { token: 't1', status: 'ok' }, + { token: 't2', status: 'invalid' }, + { token: 't3', status: 'retry' }, + ], + } as const; + expect(PushRelayResponseSchema.parse(response).results.map((r) => r.status)).toEqual(['ok', 'invalid', 'retry']); + }); + + it('refuses an outcome outside the three', () => { + const parsed = PushRelayResponseSchema.safeParse({ results: [{ token: 't1', status: 'failed' }] }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['results', 0, 'status']); + }); +}); + +describe('PushRelayErrorResponseSchema', () => { + it.each(['binding_invalid', 'binding_revoked'] as const)('answers a %s binding with 401 UNAUTHENTICATED and its next step', (reason) => { + const body = { + success: false, + error: { + code: 'UNAUTHENTICATED', + httpStatus: 401, + message: 'The push relay did not accept this server binding.', + details: { reason }, + helpText: 'Bind this server to the cloud again, then retry.', + }, + } as const; + const parsed = PushRelayErrorResponseSchema.parse(body); + expect(parsed.error.code).toBe('UNAUTHENTICATED'); + expect(parsed.error.httpStatus).toBe(401); + expect(parsed.error.code === 'UNAUTHENTICATED' && parsed.error.details.reason).toBe(reason); + }); + + it('answers request pacing with 429 RATE_LIMIT_EXCEEDED, the wait and the next step', () => { + const error = { + code: 'RATE_LIMIT_EXCEEDED', + httpStatus: 429, + message: 'Too many push relay requests.', + retryable: true, + retryAfterSeconds: 30, + helpText: 'Send again after 30 seconds.', + } as const; + const parsed = PushRelayErrorSchema.parse(error); + expect(parsed).toMatchObject({ code: 'RATE_LIMIT_EXCEEDED', httpStatus: 429, retryAfterSeconds: 30 }); + }); + + it('answers a used-up quota with 429 QUOTA_EXCEEDED and the next step', () => { + const error = { + code: 'QUOTA_EXCEEDED', + httpStatus: 429, + message: 'The push quota of this plan is used up.', + helpText: 'Raise the plan, or wait for the quota to reset.', + } as const; + expect(PushRelayErrorSchema.parse(error)).toMatchObject({ code: 'QUOTA_EXCEEDED', httpStatus: 429 }); + }); + + it.each([ + ['a 401 without its reason', { code: 'UNAUTHENTICATED', httpStatus: 401, message: 'x', helpText: 'y' }, ['details']], + ['a 401 with a reason outside the two', { code: 'UNAUTHENTICATED', httpStatus: 401, message: 'x', helpText: 'y', details: { reason: 'expired' } }, ['details', 'reason']], + ['a rate limit without the wait', { code: 'RATE_LIMIT_EXCEEDED', httpStatus: 429, message: 'x', helpText: 'y' }, ['retryAfterSeconds']], + ['a quota error without the next step', { code: 'QUOTA_EXCEEDED', httpStatus: 429, message: 'x' }, ['helpText']], + ['a rate limit on the wrong status', { code: 'RATE_LIMIT_EXCEEDED', httpStatus: 503, message: 'x', helpText: 'y', retryAfterSeconds: 1 }, ['httpStatus']], + ] as const)('refuses %s', (_name, error, path) => { + const parsed = PushRelayErrorSchema.safeParse(error); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(path); + }); + + it('refuses a code the relay does not answer with', () => { + const parsed = PushRelayErrorSchema.safeParse({ code: 'PERMISSION_DENIED', httpStatus: 403, message: 'x', helpText: 'y' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['code']); + }); +}); + +// Declared ahead of its producer (ADR-0049): no push channel sends this +// request yet, and each schema says so in its description. +describe('the push relay wire shape is declared, not yet served', () => { + it.each([ + ['PushRelayPayloadSchema', PushRelayPayloadSchema], + ['PushRelayDeliverySchema', PushRelayDeliverySchema], + ['PushRelayRequestSchema', PushRelayRequestSchema], + ['PushRelayResultSchema', PushRelayResultSchema], + ['PushRelayResponseSchema', PushRelayResponseSchema], + ['PushRelayErrorSchema', PushRelayErrorSchema], + ['PushRelayErrorResponseSchema', PushRelayErrorResponseSchema], + ] as const)('%s carries the PLANNED marker', (_name, schema) => { + expect(schema.description).toMatch(/^\[PLANNED — /); + }); +}); diff --git a/packages/spec/src/api/push-relay.zod.ts b/packages/spec/src/api/push-relay.zod.ts new file mode 100644 index 00000000000..f3ab4011573 --- /dev/null +++ b/packages/spec/src/api/push-relay.zod.ts @@ -0,0 +1,224 @@ +// 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'; +import { PushVendorSchema } from '../system/push-config.zod'; +import { EnhancedApiErrorSchema, ErrorResponseSchema } from './errors.zod'; +import { PushDeliveryDataSchema } from './push-delivery.zod'; + +/** + * Push relay wire protocol — what a server sends the hosted push relay, and + * what the relay answers. + * + * **Why there is a relay.** The store-published ObjectOS app is signed with + * ObjectStack's own APNs key and Firebase project, and those credentials + * never leave ObjectStack. So a server whose push transport is the relay + * (`PushRelayTransportConfigSchema`, `system/push-config.zod.ts`, the + * default) does not call APNs or FCM itself: it POSTs one + * {@link PushRelayRequestSchema} to the relay endpoint, and the relay + * forwards each delivery to its vendor. One schema, pinned by both ends: the + * push channel that sends (the server) and the relay that receives it (in + * objectstack-ai/cloud). + * + * **Who is asking.** The relay authenticates the server by its cloud binding + * credential (cloud ADR-0008, the `oscc_…` bearer the server received when + * it was bound), sent as the request's bearer token. The body carries no + * credential. A bad or revoked binding answers 401 + * ({@link PushRelayErrorResponseSchema}). + * + * **ID-only payload.** The relay is a third party to the notification, so it + * never sees its content. A delivery's payload carries the push delivery data + * (`PushDeliveryDataSchema`: the notification id, its same-server route and + * the sending server's origin) and one generic line of visible text, and + * nothing else. There is no title or body: the shell reads the notification + * from the server after the tap, by its id. To the relay the payload is + * opaque: it puts it in the vendor message unchanged. + * + * **Declared ahead of its producer.** No push channel delivers yet, so + * nothing sends this request today; each schema here carries a + * `[PLANNED — not yet served]` marker in its `.describe()` until the push + * channel ships. + */ + +/** Guidance for a notification-content key written into the ID-only payload. */ +const PAYLOAD_CONTENT_GUIDANCE = + 'Not carried. A push through the relay is ID-only: the payload holds the push delivery data (`data`) and one ' + + 'generic visible line (`text`), never the notification content. The shell reads the notification from the ' + + 'server after the tap, by `data.notificationId`.'; + +/** + * How urgently the vendor delivers: `high` at once, waking the device (APNs + * priority 10, FCM `high`); `normal` when the device's power state allows + * (APNs priority 5, FCM `normal`). + */ +export const PushRelayPrioritySchema = z.enum(['high', 'normal']) + .describe('Vendor delivery priority: high delivers at once and wakes the device (APNs 10, FCM high); normal may wait for the device power state (APNs 5, FCM normal)'); +export type PushRelayPriority = z.input; + +/** The payload of one delivery: the push delivery data and one generic visible line. */ +export const PushRelayPayloadSchema = lazySchema(() => strictObject( + { + surface: 'the push relay payload', + history: 'This shape is strict from birth — no relay payload existed before it.', + guidance: { + title: PAYLOAD_CONTENT_GUIDANCE, + body: PAYLOAD_CONTENT_GUIDANCE, + subtitle: PAYLOAD_CONTENT_GUIDANCE, + message: PAYLOAD_CONTENT_GUIDANCE, + content: PAYLOAD_CONTENT_GUIDANCE, + }, + }, + { + data: PushDeliveryDataSchema.describe( + 'The push delivery data: the notification id, its same-server route and the origin of the sending server. ' + + 'The relay puts it in the vendor message unchanged', + ), + text: z.string().min(1).describe( + 'One generic visible line the device shows (for example "You have a new notification"), in the device locale. ' + + 'Never the notification title or body', + ), + }, +).describe('[PLANNED — not yet served] The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay')); +export type PushRelayPayload = z.input; + +/** One delivery: a device token, its vendor, the payload and the priority. */ +export const PushRelayDeliverySchema = lazySchema(() => strictObject( + { + surface: 'a push relay delivery', + history: 'This shape is strict from birth — no relay delivery existed before it.', + }, + { + token: z.string().min(1).describe('The vendor device token, as the device registered it'), + vendor: PushVendorSchema.describe('The vendor the token belongs to: apns or fcm'), + payload: PushRelayPayloadSchema.describe('The ID-only payload'), + priority: PushRelayPrioritySchema.describe('Vendor delivery priority'), + }, +).describe('[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority')); +export type PushRelayDelivery = z.input; + +/** + * The request a server POSTs to the relay endpoint. Its bearer token is the + * server's cloud binding credential; the body carries none. + */ +export const PushRelayRequestSchema = lazySchema(() => strictObject( + { + surface: 'the push relay request', + history: 'This shape is strict from birth — no relay request existed before it.', + }, + { + deliveries: z.array(PushRelayDeliverySchema).min(1).describe( + 'The deliveries, one per device token. The response answers each one, in the same order', + ), + }, +).describe('[PLANNED — not yet served] The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token')); +export type PushRelayRequest = z.input; + +/** + * What the relay did with one delivery: + * + * - `ok` — the vendor accepted it; + * - `invalid` — the vendor reports the token dead (the app was removed, or + * the token was rotated). The sender stops delivering to it: the device row + * keeps `enabled: false` and `invalidatedAt` (`UserDeviceSchema`); + * - `retry` — a transient failure (the vendor was unavailable or throttled). + * The sender may send the delivery again later. + */ +export const PushRelayResultStatusSchema = z.enum(['ok', 'invalid', 'retry']) + .describe('Outcome of one delivery: ok (the vendor accepted it), invalid (the vendor reports the token dead; stop delivering to it) or retry (a transient failure; the delivery may be sent again later)'); +export type PushRelayResultStatus = z.input; + +/** The relay's answer for one delivery. */ +export const PushRelayResultSchema = lazySchema(() => z.object({ + token: z.string().min(1).describe('The device token of the delivery this result answers'), + status: PushRelayResultStatusSchema.describe('What happened to the delivery'), +}).describe('[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome')); +export type PushRelayResult = z.input; + +/** + * The relay's 200 answer: one result per delivery, in request order. A + * delivery the vendor refused is a result, never an error status: an error + * status ({@link PushRelayErrorResponseSchema}) answers the whole request. + */ +export const PushRelayResponseSchema = lazySchema(() => z.object({ + results: z.array(PushRelayResultSchema).describe( + 'One result per delivery, in the order of the request deliveries', + ), +}).describe('[PLANNED — not yet served] The push relay answer: one result per delivery token, ok, invalid or retry')); +export type PushRelayResponse = z.input; + +/** + * Why the relay refused the binding: `binding_invalid` (the credential is not + * one the relay recognises: malformed, unknown, or for another environment) + * or `binding_revoked` (it was valid and has been revoked). + */ +const PushRelayBindingRefusalSchema = z.enum(['binding_invalid', 'binding_revoked']); + +/** + * 401: the binding credential is bad or revoked. `details.reason` says which; + * `helpText` is the next step (bind the server again). + */ +function unauthenticatedError() { + return EnhancedApiErrorSchema.extend({ + code: z.literal('UNAUTHENTICATED').describe('The server binding credential was not accepted'), + httpStatus: z.literal(401).describe('HTTP status code'), + details: z.object({ + reason: PushRelayBindingRefusalSchema.describe( + 'binding_invalid: the relay does not recognise the credential; binding_revoked: it was valid and has been revoked', + ), + }).describe('Why the binding was refused'), + helpText: z.string().min(1).describe('The next step: bind the server to the cloud again'), + }); +} + +/** + * 429, request pacing: the server sent too many requests. `retryAfterSeconds` + * says when to send again, and the relay sets the `Retry-After` header to the + * same value. + */ +function rateLimitError() { + return EnhancedApiErrorSchema.extend({ + code: z.literal('RATE_LIMIT_EXCEEDED').describe('The server sent the relay too many requests'), + httpStatus: z.literal(429).describe('HTTP status code'), + retryAfterSeconds: z.number().int().positive().describe('Seconds to wait before sending again'), + helpText: z.string().min(1).describe('The next step: send again after retryAfterSeconds'), + }); +} + +/** + * 429, quota: the push quota of the server's plan is used up. `helpText` is + * the next step (raise the plan, or wait for the quota to reset); + * `retryAfterSeconds`, when present, is when the quota resets. + */ +function quotaError() { + return EnhancedApiErrorSchema.extend({ + code: z.literal('QUOTA_EXCEEDED').describe('The push quota of the server plan is used up'), + httpStatus: z.literal(429).describe('HTTP status code'), + retryAfterSeconds: z.number().int().positive().optional().describe('Seconds until the quota resets, when known'), + helpText: z.string().min(1).describe('The next step: raise the plan, or wait for the quota to reset'), + }); +} + +/** + * The error the relay answers a whole request with, discriminated by `code`: + * `UNAUTHENTICATED` (401, bad or revoked binding), `RATE_LIMIT_EXCEEDED` + * (429, request pacing) or `QUOTA_EXCEEDED` (429, plan quota). Each is the + * house `EnhancedApiErrorSchema` (ADR-0112) with its code, status and next + * step fixed. + */ +export const PushRelayErrorSchema = lazySchema(() => z.discriminatedUnion('code', [ + unauthenticatedError(), + rateLimitError(), + quotaError(), +]).describe('[PLANNED — not yet served] A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText')); +export type PushRelayError = z.input; +/** Post-parse shape of {@link PushRelayError} — defaults applied (ADR-0122). */ +export type PushRelayErrorParsed = z.infer; + +/** The relay's error response: the house `ErrorResponseSchema` envelope around a {@link PushRelayErrorSchema}. */ +export const PushRelayErrorResponseSchema = lazySchema(() => ErrorResponseSchema.extend({ + error: PushRelayErrorSchema.describe('Error details'), +}).describe('[PLANNED — not yet served] The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota)')); +export type PushRelayErrorResponse = z.input; +/** Post-parse shape of {@link PushRelayErrorResponse} — defaults applied (ADR-0122). */ +export type PushRelayErrorResponseParsed = z.infer; diff --git a/packages/spec/src/system/push-config.test.ts b/packages/spec/src/system/push-config.test.ts index 327e6066ab0..fbdf618daf9 100644 --- a/packages/spec/src/system/push-config.test.ts +++ b/packages/spec/src/system/push-config.test.ts @@ -1,6 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// Push transport configuration (APNs token auth, FCM HTTP v1). Its two +// Push transport configuration: the hosted relay (the default, no credential) +// or direct delivery (APNs token auth, FCM HTTP v1) for a white-label build. +// A block without `transport` reads as the relay unless it carries a vendor +// block, which reads as direct — the shape written before the relay existed +// keeps parsing as it did. The direct transport's 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 @@ -11,6 +15,8 @@ import { describe, it, expect } from 'vitest'; import { ApnsTransportConfigSchema, FcmTransportConfigSchema, + PushDirectTransportConfigSchema, + PushRelayTransportConfigSchema, PushServiceConfigSchema, PushVendorSchema, } from './push-config.zod'; @@ -34,17 +40,108 @@ describe('PushVendorSchema', () => { }); }); -describe('PushServiceConfigSchema', () => { - it('accepts both transports configured by reference', () => { - const config = { apns: APNS, fcm: FCM }; - expect(PushServiceConfigSchema.parse(config)).toEqual(config); +describe('PushServiceConfigSchema — the transport', () => { + it('reads an empty block as the hosted relay, the default', () => { + expect(PushServiceConfigSchema.parse({})).toEqual({ transport: 'relay' }); }); - 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('accepts the relay with no endpoint, and with a staging endpoint', () => { + expect(PushServiceConfigSchema.parse({ transport: 'relay' })).toEqual({ transport: 'relay' }); + const staging = { transport: 'relay', endpoint: 'https://push-relay.staging.example.com/v1/deliveries' } as const; + expect(PushServiceConfigSchema.parse(staging)).toEqual(staging); }); + it('refuses a relay endpoint that is not https', () => { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'relay', endpoint: 'http://push-relay.example.com' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.path).toEqual(['endpoint']); + expect(parsed.error?.issues[0]?.message).toMatch(/^The push relay endpoint is an https URL/); + }); + + it('accepts direct delivery with both vendor blocks, or either alone', () => { + const both = { transport: 'direct', apns: APNS, fcm: FCM } as const; + expect(PushServiceConfigSchema.parse(both)).toEqual(both); + expect(PushServiceConfigSchema.safeParse({ transport: 'direct', apns: { ...APNS, environment: 'sandbox' } }).success).toBe(true); + expect(PushServiceConfigSchema.safeParse({ transport: 'direct', fcm: FCM }).success).toBe(true); + }); + + // The shape this schema had before the relay existed: vendor blocks and no + // `transport`. It keeps parsing, and reads as direct. + it.each([ + ['both vendor blocks', { apns: APNS, fcm: FCM }], + ['apns alone', { apns: APNS }], + ['fcm alone', { fcm: FCM }], + ] as const)('reads a block with %s and no transport as direct', (_name, config) => { + expect(PushServiceConfigSchema.parse(config)).toEqual({ transport: 'direct', ...config }); + }); + + it('refuses a vendor block inside a relay block, by name, pointing at direct', () => { + for (const [key, block] of [['apns', APNS], ['fcm', FCM]] as const) { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'relay', [key]: block }); + expect(parsed.success).toBe(false); + const issues = parsed.error?.issues ?? []; + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('unrecognized_keys'); + expect(issues[0]?.path).toEqual([]); + expect((issues[0] as { keys?: string[] }).keys).toEqual([key]); + expect(issues[0]?.message).toContain('Not authorable on the relay push transport.'); + expect(issues[0]?.message).toContain("transport: 'direct'"); + } + }); + + it.each(['credentialRef', 'token', 'apiKey'])('refuses a credential under %j inside a relay block, by name', (key) => { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'relay', [key]: 'OS_PUSH_RELAY_KEY' }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('authenticates this server by its cloud binding credential'); + }); + + it('refuses a direct block with no vendor block, so no credentialRef', () => { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'direct' }); + expect(parsed.success).toBe(false); + const issues = parsed.error?.issues ?? []; + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('custom'); + expect(issues[0]?.path).toEqual([]); + expect(issues[0]?.message).toMatch(/^A direct push transport names at least one vendor block \(`apns` or `fcm`\)/); + }); + + it('refuses a direct vendor block written without its credentialRef', () => { + const { credentialRef: _omitted, ...apnsWithoutRef } = APNS; + const parsed = PushServiceConfigSchema.safeParse({ transport: 'direct', apns: apnsWithoutRef }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues.map((i) => i.path)).toEqual([['apns', 'credentialRef']]); + }); + + it('refuses an endpoint on a direct block, pointing at the relay', () => { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'direct', fcm: FCM, endpoint: 'https://push-relay.example.com' }); + expect(parsed.success).toBe(false); + const issue = parsed.error?.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain("write `transport: 'relay'`"); + }); + + it('refuses a transport outside relay / direct', () => { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'hms' }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.code).toBe('invalid_union'); + }); + + it('refuses an unknown key on the direct block', () => { + const parsed = PushServiceConfigSchema.safeParse({ transport: 'direct', fcm: FCM, hms: {} }); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues[0]?.code).toBe('unrecognized_keys'); + }); + + it('judges each transport block on its own too', () => { + expect(PushRelayTransportConfigSchema.parse({})).toEqual({ transport: 'relay' }); + expect(PushDirectTransportConfigSchema.parse({ fcm: FCM })).toEqual({ transport: 'direct', fcm: FCM }); + expect(PushDirectTransportConfigSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('PushServiceConfigSchema — direct vendor blocks', () => { it.each([ 'OS_PUSH_APNS_KEY', 'push/apns-signing-key', @@ -100,12 +197,6 @@ describe('PushServiceConfigSchema', () => { expect(parsed.success).toBe(false); expect(parsed.error?.issues[0]?.path).toEqual(['environment']); }); - - it('refuses an unknown transport key on the block', () => { - const parsed = PushServiceConfigSchema.safeParse({ fcm: FCM, hms: {} }); - expect(parsed.success).toBe(false); - expect(parsed.error?.issues[0]?.code).toBe('unrecognized_keys'); - }); }); // Declared ahead of its reader (ADR-0049): nothing reads this shape until the @@ -113,6 +204,8 @@ describe('PushServiceConfigSchema', () => { describe('push transport configuration is declared, not yet read', () => { it.each([ ['PushServiceConfigSchema', PushServiceConfigSchema], + ['PushRelayTransportConfigSchema', PushRelayTransportConfigSchema], + ['PushDirectTransportConfigSchema', PushDirectTransportConfigSchema], ['ApnsTransportConfigSchema', ApnsTransportConfigSchema], ['FcmTransportConfigSchema', FcmTransportConfigSchema], ] as const)('%s carries the PLANNED marker', (_name, schema) => { diff --git a/packages/spec/src/system/push-config.zod.ts b/packages/spec/src/system/push-config.zod.ts index 6b45637ae9c..6f5368357e0 100644 --- a/packages/spec/src/system/push-config.zod.ts +++ b/packages/spec/src/system/push-config.zod.ts @@ -2,24 +2,50 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { requiredOneOf } from '../shared/refinement-projection'; import { strictObject } from '../shared/strict-object'; /** - * Push Service Configuration Protocol — the push channel's transports. + * Push Service Configuration Protocol — how the push channel reaches the + * vendors. * * **Declared ahead of its reader.** Every schema here carries a * `[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this * shape until the push channel plugin lands, so a value written to it today * changes nothing. The marker comes off with the change that wires the reader. * - * Operator-facing configuration for the `push` channel ADR-0012 §4 ships in - * core: Apple Push Notification service with token-based auth, and Firebase - * Cloud Messaging over its HTTP v1 API. It sits beside - * `EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of - * the email channel's transport selection. + * **Two transports, and the hosted relay is the default.** The store-published + * ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, + * and those credentials never leave ObjectStack. So a server delivers to that + * app through the hosted push relay (`transport: 'relay'`), which holds the + * credentials and forwards each delivery to APNs or FCM. The relay + * authenticates the server by its cloud binding credential (cloud ADR-0008, + * the `oscc_…` bearer the server received when it was bound), so the relay + * block carries no credential of its own. It sees only an opaque, ID-only + * payload: the wire shape is `PushRelayRequestSchema` + * (`api/push-relay.zod.ts`). + * + * The direct transport (`transport: 'direct'`) is for a white-label build: an + * app signed with the operator's own APNs key and Firebase project, which the + * relay cannot deliver to. It carries one block per vendor (ADR-0012 §4: + * Apple Push Notification service with token-based auth, and Firebase Cloud + * Messaging over its HTTP v1 API), each naming its secret by reference. + * + * **What a block without `transport` reads as.** `transport` is the + * discriminator, and a block may leave it out: with no vendor block it reads + * as the relay, the default; with an `apns` or `fcm` block it reads as + * direct, which is the shape this schema had before the relay existed. A + * block that names `transport` is judged by that transport alone. Two mixed + * shapes are refused when they are written: a relay block that carries a + * credential (a vendor block, or a key an operator would reach for), and a + * direct block with no vendor block, which names no `credentialRef` and so + * could deliver to nothing. + * + * 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 + * direct vendor block has one secret — the APNs `.p8` signing key and the FCM * service-account JSON — and each is named by a `credentialRef`, the same key * every other secret an author names is written under (ADR-0143 D3), which * the push channel resolves through the secrets layer when it sends (in the @@ -38,9 +64,8 @@ import { strictObject } from '../shared/strict-object'; * configuration door in the change that ships the reader, not here, so a key * is never authorable before something honours it. * - * Out of scope here: a hosted (cloud-side) configuration surface, and the - * other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, - * Xiaomi, OPPO, vivo). + * Out of scope here: the other vendors ADR-0012 §6 routes to alternate + * channel plugins (HMS, Xiaomi, OPPO, vivo). */ /** @@ -138,19 +163,98 @@ export const FcmTransportConfigSchema = lazySchema(() => strictObject( ).describe('[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference)')); export type FcmTransportConfig = z.input; +const RELAY_CREDENTIAL_GUIDANCE = + 'Not authorable on the relay push transport. The hosted relay holds the APNs and FCM credentials and ' + + 'authenticates this server by its cloud binding credential, so a relay block carries no credential. To deliver ' + + "with your own APNs key or Firebase project (a white-label build), write `transport: 'direct'` with the vendor " + + 'blocks.'; + +const DIRECT_ENDPOINT_GUIDANCE = + "Not authorable on the direct push transport: it sends straight to APNs and FCM. `endpoint` addresses the hosted " + + "relay, so write `transport: 'relay'` (or leave out the vendor blocks) to use it."; + +/** The message a direct block with no vendor block meets. */ +const PUSH_DIRECT_TRANSPORT_EMPTY_MESSAGE = + "A direct push transport names at least one vendor block (`apns` or `fcm`), each with its `credentialRef`. " + + "With no vendor block it delivers to nothing: to deliver through the hosted relay, write `transport: 'relay'` " + + 'or leave `transport` out.'; + /** - * 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. + * The hosted push relay: the default transport. The relay holds the APNs and + * FCM credentials of the store-published app and authenticates this server by + * its cloud binding credential, so the block carries none. */ -export const PushServiceConfigSchema = lazySchema(() => strictObject( +export const PushRelayTransportConfigSchema = lazySchema(() => strictObject( { - surface: 'the push transport configuration', - history: 'This block is strict from birth — no push configuration existed before it.', + surface: 'the relay push transport', + history: 'This block is strict from birth — no relay configuration existed before it.', + aliases: { url: 'endpoint', relayUrl: 'endpoint' }, + guidance: { + apns: RELAY_CREDENTIAL_GUIDANCE, + fcm: RELAY_CREDENTIAL_GUIDANCE, + credentialRef: RELAY_CREDENTIAL_GUIDANCE, + credential: RELAY_CREDENTIAL_GUIDANCE, + token: RELAY_CREDENTIAL_GUIDANCE, + apiKey: RELAY_CREDENTIAL_GUIDANCE, + key: RELAY_CREDENTIAL_GUIDANCE, + secret: RELAY_CREDENTIAL_GUIDANCE, + }, }, { + transport: z.literal('relay').default('relay') + .describe('The hosted push relay. The default: a block with no vendor block reads as relay'), + endpoint: z.string().url().regex(/^https:\/\//, { + message: 'The push relay endpoint is an https URL: every request carries the server binding credential.', + }).optional().describe( + 'The push relay URL, for a staging relay only. Absent: the production relay. https only, because every request ' + + 'carries the server binding credential', + ), + }, +).describe('[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none')); +export type PushRelayTransportConfig = z.input; +/** Post-parse shape of {@link PushRelayTransportConfig} — `transport` filled in (ADR-0122). */ +export type PushRelayTransportConfigParsed = z.infer; + +/** + * Direct delivery to APNs and FCM with the operator's own credentials, for a + * white-label build: one optional block per vendor, at least one present. A + * vendor with no block is a vendor this deployment does not deliver through. + */ +export const PushDirectTransportConfigSchema = lazySchema(() => strictObject( + { + surface: 'the direct push transport', + history: 'This block is strict from birth — before the relay existed it was the whole push configuration, with the same two vendor blocks.', + guidance: { + endpoint: DIRECT_ENDPOINT_GUIDANCE, + }, + }, + { + transport: z.literal('direct').default('direct') + .describe('Direct delivery with your own APNs key and Firebase project, for a white-label build. A block with a vendor block and no transport reads as direct'), 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')); +).refine(requiredOneOf(['apns', 'fcm']), { message: PUSH_DIRECT_TRANSPORT_EMPTY_MESSAGE }) + .describe('[PLANNED — not yet read] Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference')); +export type PushDirectTransportConfig = z.input; +/** Post-parse shape of {@link PushDirectTransportConfig} — `transport` filled in (ADR-0122). */ +export type PushDirectTransportConfigParsed = z.infer; + +/** + * The push channel's transport configuration: the hosted relay (the default) + * or direct delivery, discriminated by `transport`. + * + * A block that names `transport` is judged by that transport alone. A block + * that leaves it out is tried as the relay and then as direct, so `{}` reads + * as the relay and a block with a vendor block reads as direct. + */ +export const PushServiceConfigSchema = lazySchema(() => z.discriminatedUnion( + 'transport', + [PushRelayTransportConfigSchema, PushDirectTransportConfigSchema], + // Both members default `transport`, so neither owns its absence: an absent + // tag falls back to trying the members in order, relay first. + { unionFallback: true }, +).describe('[PLANNED — not yet read] Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. Nothing reads it until the push channel plugin ships')); export type PushServiceConfig = z.input; +/** Post-parse shape of {@link PushServiceConfig} — `transport` filled in (ADR-0122). */ +export type PushServiceConfigParsed = z.infer; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index c6fc7ace910..a96abb63767 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -287,9 +287,11 @@ import type * as M195 from './security/queue.zod.js'; 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'; +// The server -> push relay wire shape -- new module, next free index. +import type * as M199 from './api/push-relay.zod.js'; // --------------------------------------------------------------------------- -// 822 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 828 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 @@ -662,6 +664,15 @@ 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/push-relay.zod.ts +export type Iso_api_pushRelay__PushRelayDeliverySchema = Assert, z.infer< typeof M199.PushRelayDeliverySchema > >>; +export type Iso_api_pushRelay__PushRelayPayloadSchema = Assert, z.infer< typeof M199.PushRelayPayloadSchema > >>; +export type Iso_api_pushRelay__PushRelayPrioritySchema = Assert, z.infer< typeof M199.PushRelayPrioritySchema > >>; +export type Iso_api_pushRelay__PushRelayRequestSchema = Assert, z.infer< typeof M199.PushRelayRequestSchema > >>; +export type Iso_api_pushRelay__PushRelayResponseSchema = Assert, z.infer< typeof M199.PushRelayResponseSchema > >>; +export type Iso_api_pushRelay__PushRelayResultSchema = Assert, z.infer< typeof M199.PushRelayResultSchema > >>; +export type Iso_api_pushRelay__PushRelayResultStatusSchema = Assert, z.infer< typeof M199.PushRelayResultStatusSchema > >>; + // 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 > >>; @@ -1423,7 +1434,6 @@ export type Iso_system_objectStorage__StorageScopeSchema = Assert, z.infer< typeof M196.ApnsTransportConfigSchema > >>; export type Iso_system_pushConfig__FcmTransportConfigSchema = Assert, z.infer< typeof M196.FcmTransportConfigSchema > >>; -export type Iso_system_pushConfig__PushServiceConfigSchema = Assert, z.infer< typeof M196.PushServiceConfigSchema > >>; export type Iso_system_pushConfig__PushVendorSchema = Assert, z.infer< typeof M196.PushVendorSchema > >>; // system/push-device.zod.ts @@ -1766,7 +1776,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 822 isomorphic pins', () => { + it('still declares all 828 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 @@ -2770,7 +2780,17 @@ describe('ADR-0122 type-alias convention', () => { // no default and no transform in any of them // (Iso_api_authEndpoints__OneTimeToken*, Iso_system_authConfig__TrustedAppSchemeSchema). // +3 added. - expect(pins).toHaveLength(822); + // 822 -> 828 is the push relay transport: `PushServiceConfigSchema` + // became the `transport` union whose members default `transport`, so + // input !== infer; Iso_system_pushConfig__PushServiceConfigSchema leaves + // and `PushServiceConfigParsed` is declared (-1). The server -> relay wire + // shape (`api/push-relay.zod.ts`: the priority and outcome enums, the + // payload, delivery, request, result and response) is plain or strict + // objects with no default and no transform (Iso_api_pushRelay__*, +7). + // Its error schemas extend the house error envelope, whose `retryable` + // defaults, so they carry `PushRelayErrorParsed` and + // `PushRelayErrorResponseParsed` instead of a pin. + expect(pins).toHaveLength(828); // 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 0632196268030f58c8efe725da5d37ea72eab000 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:10:50 +0000 Subject: [PATCH 2/4] chore(spec): regenerate the push relay transport's generated artifacts, and its changeset Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG Co-authored-by: Claude --- .changeset/29816-push-relay-transport.md | 18 + content/docs/references/api/index.mdx | 3 +- content/docs/references/api/meta.json | 1 + content/docs/references/api/push-relay.mdx | 401 ++++++++++++++++++ content/docs/references/index.mdx | 15 +- .../docs/references/system/push-config.mdx | 126 +++++- .../api.md | 2 +- .../system.md | 2 +- packages/spec/api-surface/api.json | 20 + packages/spec/api-surface/system.json | 7 + packages/spec/authorable-defaults/system.json | 2 + packages/spec/authorable-surface/api.json | 13 + packages/spec/authorable-surface/system.json | 7 +- packages/spec/declaration-map/api.json | 18 + packages/spec/declaration-map/system.json | 4 + packages/spec/export-origins/api.json | 20 + packages/spec/export-origins/system.json | 7 + packages/spec/json-schema.manifest/api.json | 9 + .../spec/json-schema.manifest/system.json | 2 + 19 files changed, 649 insertions(+), 28 deletions(-) create mode 100644 .changeset/29816-push-relay-transport.md create mode 100644 content/docs/references/api/push-relay.mdx diff --git a/.changeset/29816-push-relay-transport.md b/.changeset/29816-push-relay-transport.md new file mode 100644 index 00000000000..899bf8a758d --- /dev/null +++ b/.changeset/29816-push-relay-transport.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): the push channel's hosted relay transport — `PushServiceConfigSchema` becomes `transport: 'relay' | 'direct'`, with the relay as the default, and the server-to-relay wire shape is declared (`api/push-relay.zod.ts`) + +Clause-②: yes (widening) +Metadata-protocol: added + +Nothing here is read or served yet: every new or touched schema keeps or carries a `[PLANNED — not yet read]` or `[PLANNED — not yet served]` marker, and no stack key, route or reader is added. Every push configuration that parsed before still parses. + +- **`PushServiceConfigSchema`** is now a discriminated union on `transport`, built from two strict blocks: + - `PushRelayTransportConfigSchema`, `{ transport: 'relay', endpoint? }`, the default. The hosted relay holds the APNs and FCM credentials of the store-published app and authenticates the server by its cloud binding credential, so the block carries no credential. `endpoint` is an https URL for a staging relay; absent means the production relay. A vendor block or a credential key (`credentialRef`, `token`, `apiKey`, …) written into a relay block is refused by name, pointing at `transport: 'direct'`. + - `PushDirectTransportConfigSchema`, `{ transport: 'direct', apns?, fcm? }`: the shape this schema had, for a white-label build signed with the operator's own credentials. It needs at least one vendor block, so a direct block with no `credentialRef` is refused; `endpoint` on it is refused, pointing at the relay. + - A block without `transport` keeps parsing: `{}` reads as the relay, and a block with an `apns` or `fcm` block reads as direct, exactly the configuration it described before. Parsing fills `transport` in, so `PushServiceConfigParsed`, `PushRelayTransportConfigParsed` and `PushDirectTransportConfigParsed` are declared beside the author types. +- **`PushRelayRequestSchema`** (new) is what a server POSTs to the relay: `{ deliveries: [{ token, vendor, payload, priority }] }`, at least one delivery, with the binding credential as the request's bearer and none in the body. `payload` (`PushRelayPayloadSchema`) is ID-only: `data` is the push delivery data (`PushDeliveryDataSchema`) and `text` is one generic visible line; `title`, `body`, `subtitle`, `message` and `content` are refused by name. `priority` is `high` or `normal` (`PushRelayPrioritySchema`). +- **`PushRelayResponseSchema`** (new) answers one result per delivery, in request order: `{ token, status }` with `status` `ok`, `invalid` (the vendor reports the token dead; stop delivering to it) or `retry` (transient; send again later). +- **`PushRelayErrorResponseSchema`** (new) is the house `ErrorResponseSchema` envelope around `PushRelayErrorSchema`, a union on `code` of the house `EnhancedApiErrorSchema`: `UNAUTHENTICATED` (401, with `details.reason` `binding_invalid` or `binding_revoked`), `RATE_LIMIT_EXCEEDED` (429, with `retryAfterSeconds`) and `QUOTA_EXCEEDED` (429). Each requires `helpText`, the next step. No new error code is added. diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index 2a07a1bdd46..c91135c2aa1 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 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack API Protocol in 36 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 api layer of ObjectStack. + diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index 3a723ef6c4d..0d0ce802c85 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -38,6 +38,7 @@ "package-api-assembled", "package-lifecycle", "push-delivery", + "push-relay", "record-page", "sortability" ] diff --git a/content/docs/references/api/push-relay.mdx b/content/docs/references/api/push-relay.mdx new file mode 100644 index 00000000000..9e690771ade --- /dev/null +++ b/content/docs/references/api/push-relay.mdx @@ -0,0 +1,401 @@ +--- +title: Push Relay schema — API Protocol reference +navTitle: Push Relay +description: "Push relay wire protocol — what a server sends the hosted push relay, and what the relay answers." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +Push relay wire protocol — what a server sends the hosted push relay, and +what the relay answers. + +**Why there is a relay.** The store-published ObjectOS app is signed with +ObjectStack's own APNs key and Firebase project, and those credentials +never leave ObjectStack. So a server whose push transport is the relay +(`PushRelayTransportConfigSchema`, `system/push-config.zod.ts`, the +default) does not call APNs or FCM itself: it POSTs one +`PushRelayRequestSchema` to the relay endpoint, and the relay +forwards each delivery to its vendor. One schema, pinned by both ends: the +push channel that sends (the server) and the relay that receives it (in +objectstack-ai/cloud). + +**Who is asking.** The relay authenticates the server by its cloud binding +credential (cloud ADR-0008, the `oscc_…` bearer the server received when +it was bound), sent as the request's bearer token. The body carries no +credential. A bad or revoked binding answers 401 +(`PushRelayErrorResponseSchema`). + +**ID-only payload.** The relay is a third party to the notification, so it +never sees its content. A delivery's payload carries the push delivery data +(`PushDeliveryDataSchema`: the notification id, its same-server route and +the sending server's origin) and one generic line of visible text, and +nothing else. There is no title or body: the shell reads the notification +from the server after the tap, by its id. To the relay the payload is +opaque: it puts it in the vendor message unchanged. + +**Declared ahead of its producer.** No push channel delivers yet, so +nothing sends this request today; each schema here carries a +`[PLANNED — not yet served]` marker in its `.describe()` until the push +channel ships. + + +**Source:** `packages/spec/src/api/push-relay.zod.ts` + + +## TypeScript Usage + +```typescript +import { PushRelayDeliverySchema, PushRelayErrorSchema, PushRelayErrorResponseSchema, PushRelayPayloadSchema, PushRelayPrioritySchema, PushRelayRequestSchema, PushRelayResponseSchema, PushRelayResultSchema, PushRelayResultStatusSchema } from '@objectstack/spec/api'; +import type { PushRelayDelivery, PushRelayError, PushRelayErrorResponse, PushRelayPayload, PushRelayPriority, PushRelayRequest, PushRelayResponse, PushRelayResult, PushRelayResultStatus } from '@objectstack/spec/api'; + +// Validate data +const result = PushRelayDeliverySchema.parse(data); +``` + +--- + +## PushRelayDelivery + +[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **token** | `string` | ✅ | The vendor device token, as the device registered it | +| **vendor** | `Enum<'apns' \| 'fcm'>` | ✅ | The vendor the token belongs to: apns or fcm | +| **payload** | `{ data: object; text: string }` | ✅ | The ID-only payload | +| **priority** | `Enum<'high' \| 'normal'>` | ✅ | Vendor delivery priority | + +### Nested Shape: `PushRelayDelivery.payload` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **data** | `{ notificationId: string; route?: string; origin?: string }` | ✅ | The push delivery data: the notification id, its same-server route and the origin of the sending server. The relay puts it in the vendor message unchanged | +| **text** | `string` | ✅ | One generic visible line the device shows (for example "You have a new notification"), in the device locale. Never the notification title or body | + + +--- + +## PushRelayError + +[PLANNED — not yet served] A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `'UNAUTHENTICATED'` | ✅ | The server binding credential was not accepted | +| **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | +| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | +| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category | +| **httpStatus** | `401` | ✅ | HTTP status code | +| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | +| **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **details** | `{ reason: Enum<'binding_invalid' \| 'binding_revoked'> }` | ✅ | Why the binding was refused | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **timestamp** | `string` | optional | When the error occurred | +| **requestId** | `string` | optional | Request ID for tracking | +| **traceId** | `string` | optional | Distributed trace ID | +| **documentation** | `string` | optional | URL to error documentation | +| **helpText** | `string` | ✅ | The next step: bind the server to the cloud again | + +### Nested Shape: `PushRelayError[code='UNAUTHENTICATED'].details` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **reason** | `Enum<'binding_invalid' \| 'binding_revoked'>` | ✅ | binding_invalid: the relay does not recognise the credential; binding_revoked: it was valid and has been revoked | + +### Nested Shape: `PushRelayError[code='UNAUTHENTICATED'].fields[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field path (supports dot notation) | +| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) | +| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale | +| **label** | `string` | optional | Field display label in the caller’s locale | +| **value** | `any` | optional | The invalid value that was provided | +| **constraint** | `Record` | optional | The constraint that was violated, as discrete values (e.g. `{ maxLength: 512, actual: 3000 }`) | + +--- + +#### Option 2 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `'RATE_LIMIT_EXCEEDED'` | ✅ | The server sent the relay too many requests | +| **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | +| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | +| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category | +| **httpStatus** | `429` | ✅ | HTTP status code | +| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | +| **retryAfterSeconds** | `integer` | ✅ | Seconds to wait before sending again | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **details** | `any` | optional | Additional error context | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **timestamp** | `string` | optional | When the error occurred | +| **requestId** | `string` | optional | Request ID for tracking | +| **traceId** | `string` | optional | Distributed trace ID | +| **documentation** | `string` | optional | URL to error documentation | +| **helpText** | `string` | ✅ | The next step: send again after retryAfterSeconds | + +### Nested Shape: `PushRelayError[code='RATE_LIMIT_EXCEEDED'].fields[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field path (supports dot notation) | +| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) | +| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale | +| **label** | `string` | optional | Field display label in the caller’s locale | +| **value** | `any` | optional | The invalid value that was provided | +| **constraint** | `Record` | optional | The constraint that was violated, as discrete values (e.g. `{ maxLength: 512, actual: 3000 }`) | + +--- + +#### Option 3 + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `'QUOTA_EXCEEDED'` | ✅ | The push quota of the server plan is used up | +| **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | +| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | +| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category | +| **httpStatus** | `429` | ✅ | HTTP status code | +| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | +| **retryAfterSeconds** | `integer` | optional | Seconds until the quota resets, when known | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **details** | `any` | optional | Additional error context | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **timestamp** | `string` | optional | When the error occurred | +| **requestId** | `string` | optional | Request ID for tracking | +| **traceId** | `string` | optional | Distributed trace ID | +| **documentation** | `string` | optional | URL to error documentation | +| **helpText** | `string` | ✅ | The next step: raise the plan, or wait for the quota to reset | + +### Nested Shape: `PushRelayError[code='QUOTA_EXCEEDED'].fields[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **field** | `string` | ✅ | Field path (supports dot notation) | +| **code** | `Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>` | ✅ | Which constraint the value violated (field-level catalog, ADR-0114) | +| **message** | `string` | ✅ | Human-readable error message, rendered in the caller’s locale | +| **label** | `string` | optional | Field display label in the caller’s locale | +| **value** | `any` | optional | The invalid value that was provided | +| **constraint** | `Record` | optional | The constraint that was violated, as discrete values (e.g. `{ maxLength: 512, actual: 3000 }`) | + +--- + + +--- + +## PushRelayErrorResponse + +[PLANNED — not yet served] The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota) + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **success** | `false` | ✅ | Always false for error responses | +| **error** | `{ code: 'UNAUTHENTICATED'; message: string; userMessage?: string; refusal?: true; … } \| { code: 'RATE_LIMIT_EXCEEDED'; message: string; userMessage?: string; refusal?: true; … } \| { code: 'QUOTA_EXCEEDED'; message: string; userMessage?: string; refusal?: true; … }` | ✅ | Error details | +| **meta** | `{ timestamp?: string; requestId?: string; traceId?: string }` | optional | Response metadata | + +### Nested Shape: `PushRelayErrorResponse.error[code='UNAUTHENTICATED']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `'UNAUTHENTICATED'` | ✅ | The server binding credential was not accepted | +| **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | +| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | +| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>` | optional | Error category | +| **httpStatus** | `401` | ✅ | HTTP status code | +| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | +| **retryAfterSeconds** | `number` | optional | Seconds to wait before retrying | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **details** | `{ reason: Enum<'binding_invalid' \| 'binding_revoked'> }` | ✅ | Why the binding was refused | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **timestamp** | `string` | optional | When the error occurred | +| **requestId** | `string` | optional | Request ID for tracking | +| **traceId** | `string` | optional | Distributed trace ID | +| **documentation** | `string` | optional | URL to error documentation | +| **helpText** | `string` | ✅ | The next step: bind the server to the cloud again | + +### Nested Shape: `PushRelayErrorResponse.error[code='RATE_LIMIT_EXCEEDED']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `'RATE_LIMIT_EXCEEDED'` | ✅ | The server sent the relay too many requests | +| **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | +| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | +| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>` | optional | Error category | +| **httpStatus** | `429` | ✅ | HTTP status code | +| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | +| **retryAfterSeconds** | `integer` | ✅ | Seconds to wait before sending again | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **details** | `any` | optional | Additional error context | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **timestamp** | `string` | optional | When the error occurred | +| **requestId** | `string` | optional | Request ID for tracking | +| **traceId** | `string` | optional | Distributed trace ID | +| **documentation** | `string` | optional | URL to error documentation | +| **helpText** | `string` | ✅ | The next step: send again after retryAfterSeconds | + +### Nested Shape: `PushRelayErrorResponse.error[code='QUOTA_EXCEEDED']` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **code** | `'QUOTA_EXCEEDED'` | ✅ | The push quota of the server plan is used up | +| **message** | `string` | ✅ | Human-readable error message | +| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution. | +| **refusal** | `true` | optional | Producer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose `message` is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld. | +| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| …>` | optional | Error category | +| **httpStatus** | `429` | ✅ | HTTP status code | +| **retryable** | `boolean` | optional (default: `false`) | Whether the request can be retried | +| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy | +| **retryAfterSeconds** | `integer` | optional | Seconds until the quota resets, when known | +| **retryAfter** | `never` | optional | [REMOVED] `EnhancedApiError.retryAfter` was renamed to `retryAfterSeconds` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to `retryAfterSeconds`; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP `Retry-After` response header — that header keeps its RFC 9110 name and is untouched. | +| **details** | `any` | optional | Additional error context | +| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| …>; message: string; label?: string; … }[]` | optional | One entry per offending value | +| **fieldErrors** | `never` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. | +| **timestamp** | `string` | optional | When the error occurred | +| **requestId** | `string` | optional | Request ID for tracking | +| **traceId** | `string` | optional | Distributed trace ID | +| **documentation** | `string` | optional | URL to error documentation | +| **helpText** | `string` | ✅ | The next step: raise the plan, or wait for the quota to reset | + + +--- + +## PushRelayPayload + +[PLANNED — not yet served] The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **data** | `{ notificationId: string; route?: string; origin?: string }` | ✅ | The push delivery data: the notification id, its same-server route and the origin of the sending server. The relay puts it in the vendor message unchanged | +| **text** | `string` | ✅ | One generic visible line the device shows (for example "You have a new notification"), in the device locale. Never the notification title or body | + +### Nested Shape: `PushRelayPayload.data` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes | +| **route** | `string` | optional | The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. The shell resolves it on tap as the inbox bell and Home do, never verbatim | +| **origin** | `string` | optional | The origin of the server that sent the push (for example https://crm.example.com): the push channel copies it from the device row, which holds the origin the Console was loaded from when it registered the device. A mobile shell with several saved servers opens the tap on the saved server with this origin, and resolves route there. Absent when the device was registered without one | + + +--- + +## PushRelayPriority + +Vendor delivery priority: high delivers at once and wakes the device (APNs 10, FCM high); normal may wait for the device power state (APNs 5, FCM normal) + +### Allowed Values + +* `high` +* `normal` + + +--- + +## PushRelayRequest + +[PLANNED — not yet served] The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **deliveries** | `{ token: string; vendor: Enum<'apns' \| 'fcm'>; payload: object; priority: Enum<'high' \| 'normal'> }[]` | ✅ | The deliveries, one per device token. The response answers each one, in the same order | + +### Nested Shape: `PushRelayRequest.deliveries[number]` + +[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **token** | `string` | ✅ | The vendor device token, as the device registered it | +| **vendor** | `Enum<'apns' \| 'fcm'>` | ✅ | The vendor the token belongs to: apns or fcm | +| **payload** | `{ data: object; text: string }` | ✅ | The ID-only payload | +| **priority** | `Enum<'high' \| 'normal'>` | ✅ | Vendor delivery priority | + + +--- + +## PushRelayResponse + +[PLANNED — not yet served] The push relay answer: one result per delivery token, ok, invalid or retry + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **results** | `{ token: string; status: Enum<'ok' \| 'invalid' \| 'retry'> }[]` | ✅ | One result per delivery, in the order of the request deliveries | + +### Nested Shape: `PushRelayResponse.results[number]` + +[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **token** | `string` | ✅ | The device token of the delivery this result answers | +| **status** | `Enum<'ok' \| 'invalid' \| 'retry'>` | ✅ | What happened to the delivery | + + +--- + +## PushRelayResult + +[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **token** | `string` | ✅ | The device token of the delivery this result answers | +| **status** | `Enum<'ok' \| 'invalid' \| 'retry'>` | ✅ | What happened to the delivery | + + +--- + +## PushRelayResultStatus + +Outcome of one delivery: ok (the vendor accepted it), invalid (the vendor reports the token dead; stop delivering to it) or retry (a transient failure; the delivery may be sent again later) + +### Allowed Values + +* `ok` +* `invalid` +* `retry` + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 623df4afae1..ab7006848a7 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 — 1609 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1620 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 35 | 477 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 36 | 486 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 175 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 6 | 37 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 39 | 293 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 39 | 295 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **208** | **1609** | 14 protocol modules | +| **Total** | **209** | **1620** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 477 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **36 pages, 486 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -94,6 +94,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`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` | +| [`push-relay.zod.ts`](/docs/references/api/push-relay) | `PushRelayDelivery`, `PushRelayError`, `PushRelayErrorResponse`, `PushRelayPayload`, `PushRelayPriority`, `PushRelayRequest`, `PushRelayResponse`, `PushRelayResult`, `PushRelayResultStatus` | | [`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` | @@ -325,7 +326,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **39 pages, 293 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **39 pages, 295 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -356,7 +357,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` | +| [`push-config.zod.ts`](/docs/references/system/push-config) | `ApnsTransportConfig`, `FcmTransportConfig`, `PushDirectTransportConfig`, `PushRelayTransportConfig`, `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` | diff --git a/content/docs/references/system/push-config.mdx b/content/docs/references/system/push-config.mdx index 20b31ca09c7..4784f1e77e1 100644 --- a/content/docs/references/system/push-config.mdx +++ b/content/docs/references/system/push-config.mdx @@ -1,26 +1,51 @@ --- title: Push Config schema — System Protocol reference navTitle: Push Config -description: "Push Service Configuration Protocol — the push channel's transports. Declared ahead of its reader." +description: "Push Service Configuration Protocol — how the push channel reaches the vendors." --- {/* ⚠️ 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. +Push Service Configuration Protocol — how the push channel reaches the +vendors. **Declared ahead of its reader.** Every schema here carries a `[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this shape until the push channel plugin lands, so a value written to it today changes nothing. The marker comes off with the change that wires the reader. -Operator-facing configuration for the `push` channel ADR-0012 §4 ships in -core: Apple Push Notification service with token-based auth, and Firebase -Cloud Messaging over its HTTP v1 API. It sits beside -`EmailServiceConfigSchema` (`email-config.zod.ts`), the declaration of -the email channel's transport selection. +**Two transports, and the hosted relay is the default.** The store-published +ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, +and those credentials never leave ObjectStack. So a server delivers to that +app through the hosted push relay (`transport: 'relay'`), which holds the +credentials and forwards each delivery to APNs or FCM. The relay +authenticates the server by its cloud binding credential (cloud ADR-0008, +the `oscc_…` bearer the server received when it was bound), so the relay +block carries no credential of its own. It sees only an opaque, ID-only +payload: the wire shape is `PushRelayRequestSchema` +(`api/push-relay.zod.ts`). + +The direct transport (`transport: 'direct'`) is for a white-label build: an +app signed with the operator's own APNs key and Firebase project, which the +relay cannot deliver to. It carries one block per vendor (ADR-0012 §4: +Apple Push Notification service with token-based auth, and Firebase Cloud +Messaging over its HTTP v1 API), each naming its secret by reference. + +**What a block without `transport` reads as.** `transport` is the +discriminator, and a block may leave it out: with no vendor block it reads +as the relay, the default; with an `apns` or `fcm` block it reads as +direct, which is the shape this schema had before the relay existed. A +block that names `transport` is judged by that transport alone. Two mixed +shapes are refused when they are written: a relay block that carries a +credential (a vendor block, or a key an operator would reach for), and a +direct block with no vendor block, which names no `credentialRef` and so +could deliver to nothing. + +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 +direct vendor block has one secret — the APNs `.p8` signing key and the FCM service-account JSON — and each is named by a `credentialRef`, the same key every other secret an author names is written under (ADR-0143 D3), which the push channel resolves through the secrets layer when it sends (in the @@ -39,9 +64,8 @@ sits beside is read from `config.email` of the stack definition configuration door in the change that ships the reader, not here, so a key is never authorable before something honours it. -Out of scope here: a hosted (cloud-side) configuration surface, and the -other vendors ADR-0012 §6 routes to alternate channel plugins (HMS, -Xiaomi, OPPO, vivo). +Out of scope here: the other vendors ADR-0012 §6 routes to alternate +channel plugins (HMS, Xiaomi, OPPO, vivo). **Source:** `packages/spec/src/system/push-config.zod.ts` @@ -50,8 +74,8 @@ Xiaomi, OPPO, vivo). ## TypeScript Usage ```typescript -import { ApnsTransportConfigSchema, FcmTransportConfigSchema, PushServiceConfigSchema, PushVendorSchema } from '@objectstack/spec/system'; -import type { ApnsTransportConfig, FcmTransportConfig, PushServiceConfig, PushVendor } from '@objectstack/spec/system'; +import { ApnsTransportConfigSchema, FcmTransportConfigSchema, PushDirectTransportConfigSchema, PushRelayTransportConfigSchema, PushServiceConfigSchema, PushVendorSchema } from '@objectstack/spec/system'; +import type { ApnsTransportConfig, FcmTransportConfig, PushDirectTransportConfig, PushRelayTransportConfig, PushServiceConfig, PushVendor } from '@objectstack/spec/system'; // Validate data const result = ApnsTransportConfigSchema.parse(data); @@ -88,20 +112,88 @@ const result = ApnsTransportConfigSchema.parse(data); | **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | +--- + +## PushDirectTransportConfig + +[PLANNED — not yet read] Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **transport** | `'direct'` | optional (default: `"direct"`) | Direct delivery with your own APNs key and Firebase project, for a white-label build. A block with a vendor block and no transport reads as direct | +| **apns** | `{ teamId: string; keyId: string; topic: string; environment: Enum<'sandbox' \| 'production'>; … }` | optional | APNs transport (token-based auth). Absent: no delivery to apns devices | +| **fcm** | `{ projectId: string; credentialRef: string }` | optional | FCM HTTP v1 transport. Absent: no delivery to fcm devices | + +### Nested Shape: `PushDirectTransportConfig.apns` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **teamId** | `string` | ✅ | Apple Developer Team ID (10 characters) that issued the signing key | +| **keyId** | `string` | ✅ | Key ID of the APNs auth key (10 characters) | +| **topic** | `string` | ✅ | APNs topic: the app bundle ID the pushes are for | +| **environment** | `Enum<'sandbox' \| 'production'>` | ✅ | APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds) | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | + +### Nested Shape: `PushDirectTransportConfig.fcm` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to | +| **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | + + +--- + +## PushRelayTransportConfig + +[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **transport** | `'relay'` | optional (default: `"relay"`) | The hosted push relay. The default: a block with no vendor block reads as relay | +| **endpoint** | `string` | optional | The push relay URL, for a staging relay only. Absent: the production relay. https only, because every request carries the server binding credential | + + --- ## 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 +[PLANNED — not yet read] Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. Nothing reads it until the push channel plugin ships + +### Union Options + +This schema accepts one of the following structures: + +#### Option 1 + +[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | +| **transport** | `'relay'` | optional (default: `"relay"`) | The hosted push relay. The default: a block with no vendor block reads as relay | +| **endpoint** | `string` | optional | The push relay URL, for a staging relay only. Absent: the production relay. https only, because every request carries the server binding credential | + +--- + +#### Option 2 + +[PLANNED — not yet read] Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **transport** | `'direct'` | optional (default: `"direct"`) | Direct delivery with your own APNs key and Firebase project, for a white-label build. A block with a vendor block and no transport reads as direct | | **apns** | `{ teamId: string; keyId: string; topic: string; environment: Enum<'sandbox' \| 'production'>; … }` | optional | APNs transport (token-based auth). Absent: no delivery to apns devices | | **fcm** | `{ projectId: string; credentialRef: string }` | optional | FCM HTTP v1 transport. Absent: no delivery to fcm devices | -### Nested Shape: `PushServiceConfig.apns` +### Nested Shape: `PushServiceConfig[transport='direct'].apns` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | @@ -111,13 +203,15 @@ const result = ApnsTransportConfigSchema.parse(data); | **environment** | `Enum<'sandbox' \| 'production'>` | ✅ | APNs gateway: sandbox (development builds) or production (TestFlight and App Store builds) | | **credentialRef** | `string` | ✅ | Secrets-layer reference to the .p8 signing key (an environment-variable name in the open tier). Never the key itself | -### Nested Shape: `PushServiceConfig.fcm` +### Nested Shape: `PushServiceConfig[transport='direct'].fcm` | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **projectId** | `string` | ✅ | Firebase project ID the HTTP v1 send endpoint is addressed to | | **credentialRef** | `string` | ✅ | Secrets-layer reference to the service-account JSON (an environment-variable name in the open tier). Never the JSON itself | +--- + --- 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 b60586bb356..21eda271dce 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/` | 493 | +| `api/` | 499 | 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 57a076a97fc..1f22c8b86e0 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/` | 394 | +| `system/` | 395 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 5dd3f0d2c03..f1f006be6b1 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -848,6 +848,26 @@ "PublishPackageDraftsResponseSchema (const)", "PushDeliveryData (type)", "PushDeliveryDataSchema (const)", + "PushRelayDelivery (type)", + "PushRelayDeliverySchema (const)", + "PushRelayError (type)", + "PushRelayErrorParsed (type)", + "PushRelayErrorResponse (type)", + "PushRelayErrorResponseParsed (type)", + "PushRelayErrorResponseSchema (const)", + "PushRelayErrorSchema (const)", + "PushRelayPayload (type)", + "PushRelayPayloadSchema (const)", + "PushRelayPriority (type)", + "PushRelayPrioritySchema (const)", + "PushRelayRequest (type)", + "PushRelayRequestSchema (const)", + "PushRelayResponse (type)", + "PushRelayResponseSchema (const)", + "PushRelayResult (type)", + "PushRelayResultSchema (const)", + "PushRelayResultStatus (type)", + "PushRelayResultStatusSchema (const)", "PushRoute (type)", "PushRouteSchema (const)", "QueryAdapterConfig (type)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index cfdd31535d7..8d2b284d86e 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -538,7 +538,14 @@ "PlatformTranslationDataSchema (const)", "PresignedUrlConfig (type)", "PresignedUrlConfigSchema (const)", + "PushDirectTransportConfig (type)", + "PushDirectTransportConfigParsed (type)", + "PushDirectTransportConfigSchema (const)", + "PushRelayTransportConfig (type)", + "PushRelayTransportConfigParsed (type)", + "PushRelayTransportConfigSchema (const)", "PushServiceConfig (type)", + "PushServiceConfigParsed (type)", "PushServiceConfigSchema (const)", "PushVendor (type)", "PushVendorSchema (const)", diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 731b4b7aed0..a496ca286f2 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -174,6 +174,8 @@ "system/ObjectStorageConfig:scope = \"global\"", "system/Plan:active = true", "system/Plan:currency = \"USD\"", + "system/PushDirectTransportConfig:transport = \"direct\"", + "system/PushRelayTransportConfig:transport = \"relay\"", "system/QueueConfig:concurrency = 5", "system/QueueConfig:priority = 0", "system/RPO:unit = \"minutes\"", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 23d8b3ed1f5..8fd105f9d75 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1447,6 +1447,19 @@ "api/PushDeliveryData:notificationId", "api/PushDeliveryData:origin", "api/PushDeliveryData:route", + "api/PushRelayDelivery:payload", + "api/PushRelayDelivery:priority", + "api/PushRelayDelivery:token", + "api/PushRelayDelivery:vendor", + "api/PushRelayErrorResponse:error", + "api/PushRelayErrorResponse:meta", + "api/PushRelayErrorResponse:success", + "api/PushRelayPayload:data", + "api/PushRelayPayload:text", + "api/PushRelayRequest:deliveries", + "api/PushRelayResponse:results", + "api/PushRelayResult:status", + "api/PushRelayResult:token", "api/QueryAdapterConfig:odata", "api/QueryAdapterConfig:operatorMappings", "api/QueryAdapterConfig:rest", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 07484fe16ad..b784fc057ff 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -936,8 +936,11 @@ "system/PresignedUrlConfig:operation", "system/PresignedUrlConfig:responseContentDisposition", "system/PresignedUrlConfig:responseContentType", - "system/PushServiceConfig:apns", - "system/PushServiceConfig:fcm", + "system/PushDirectTransportConfig:apns", + "system/PushDirectTransportConfig:fcm", + "system/PushDirectTransportConfig:transport", + "system/PushRelayTransportConfig:endpoint", + "system/PushRelayTransportConfig:transport", "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 42361d3d816..06d2d826fae 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -623,6 +623,24 @@ "PublishPackageDraftsResponseSchema": "api/PublishPackageDraftsResponse", "PushDeliveryData": "api/PushDeliveryData", "PushDeliveryDataSchema": "api/PushDeliveryData", + "PushRelayDelivery": "api/PushRelayDelivery", + "PushRelayDeliverySchema": "api/PushRelayDelivery", + "PushRelayError": "api/PushRelayError", + "PushRelayErrorResponse": "api/PushRelayErrorResponse", + "PushRelayErrorResponseSchema": "api/PushRelayErrorResponse", + "PushRelayErrorSchema": "api/PushRelayError", + "PushRelayPayload": "api/PushRelayPayload", + "PushRelayPayloadSchema": "api/PushRelayPayload", + "PushRelayPriority": "api/PushRelayPriority", + "PushRelayPrioritySchema": "api/PushRelayPriority", + "PushRelayRequest": "api/PushRelayRequest", + "PushRelayRequestSchema": "api/PushRelayRequest", + "PushRelayResponse": "api/PushRelayResponse", + "PushRelayResponseSchema": "api/PushRelayResponse", + "PushRelayResult": "api/PushRelayResult", + "PushRelayResultSchema": "api/PushRelayResult", + "PushRelayResultStatus": "api/PushRelayResultStatus", + "PushRelayResultStatusSchema": "api/PushRelayResultStatus", "PushRoute": "api/PushRoute", "PushRouteSchema": "api/PushRoute", "QueryAdapterConfig": "api/QueryAdapterConfig", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 5a25801fc67..b363f896322 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -352,6 +352,10 @@ "PlatformTranslationDataSchema": "system/PlatformTranslationData", "PresignedUrlConfig": "system/PresignedUrlConfig", "PresignedUrlConfigSchema": "system/PresignedUrlConfig", + "PushDirectTransportConfig": "system/PushDirectTransportConfig", + "PushDirectTransportConfigSchema": "system/PushDirectTransportConfig", + "PushRelayTransportConfig": "system/PushRelayTransportConfig", + "PushRelayTransportConfigSchema": "system/PushRelayTransportConfig", "PushServiceConfig": "system/PushServiceConfig", "PushServiceConfigSchema": "system/PushServiceConfig", "PushVendor": "system/PushVendor", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index ca1eb739a35..789118128ec 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -819,6 +819,26 @@ "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)", + "PushRelayDelivery": "src/api/push-relay.zod.ts#PushRelayDelivery (type)", + "PushRelayDeliverySchema": "src/api/push-relay.zod.ts#PushRelayDeliverySchema (const)", + "PushRelayError": "src/api/push-relay.zod.ts#PushRelayError (type)", + "PushRelayErrorParsed": "src/api/push-relay.zod.ts#PushRelayErrorParsed (type)", + "PushRelayErrorResponse": "src/api/push-relay.zod.ts#PushRelayErrorResponse (type)", + "PushRelayErrorResponseParsed": "src/api/push-relay.zod.ts#PushRelayErrorResponseParsed (type)", + "PushRelayErrorResponseSchema": "src/api/push-relay.zod.ts#PushRelayErrorResponseSchema (const)", + "PushRelayErrorSchema": "src/api/push-relay.zod.ts#PushRelayErrorSchema (const)", + "PushRelayPayload": "src/api/push-relay.zod.ts#PushRelayPayload (type)", + "PushRelayPayloadSchema": "src/api/push-relay.zod.ts#PushRelayPayloadSchema (const)", + "PushRelayPriority": "src/api/push-relay.zod.ts#PushRelayPriority (type)", + "PushRelayPrioritySchema": "src/api/push-relay.zod.ts#PushRelayPrioritySchema (const)", + "PushRelayRequest": "src/api/push-relay.zod.ts#PushRelayRequest (type)", + "PushRelayRequestSchema": "src/api/push-relay.zod.ts#PushRelayRequestSchema (const)", + "PushRelayResponse": "src/api/push-relay.zod.ts#PushRelayResponse (type)", + "PushRelayResponseSchema": "src/api/push-relay.zod.ts#PushRelayResponseSchema (const)", + "PushRelayResult": "src/api/push-relay.zod.ts#PushRelayResult (type)", + "PushRelayResultSchema": "src/api/push-relay.zod.ts#PushRelayResultSchema (const)", + "PushRelayResultStatus": "src/api/push-relay.zod.ts#PushRelayResultStatus (type)", + "PushRelayResultStatusSchema": "src/api/push-relay.zod.ts#PushRelayResultStatusSchema (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)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index cb600304680..2c3212a9659 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -517,7 +517,14 @@ "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)", + "PushDirectTransportConfig": "src/system/push-config.zod.ts#PushDirectTransportConfig (type)", + "PushDirectTransportConfigParsed": "src/system/push-config.zod.ts#PushDirectTransportConfigParsed (type)", + "PushDirectTransportConfigSchema": "src/system/push-config.zod.ts#PushDirectTransportConfigSchema (const)", + "PushRelayTransportConfig": "src/system/push-config.zod.ts#PushRelayTransportConfig (type)", + "PushRelayTransportConfigParsed": "src/system/push-config.zod.ts#PushRelayTransportConfigParsed (type)", + "PushRelayTransportConfigSchema": "src/system/push-config.zod.ts#PushRelayTransportConfigSchema (const)", "PushServiceConfig": "src/system/push-config.zod.ts#PushServiceConfig (type)", + "PushServiceConfigParsed": "src/system/push-config.zod.ts#PushServiceConfigParsed (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)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index aebb2784fe6..6bbfa98c9ff 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -339,6 +339,15 @@ "api/PublishPackageDraftsRequest", "api/PublishPackageDraftsResponse", "api/PushDeliveryData", + "api/PushRelayDelivery", + "api/PushRelayError", + "api/PushRelayErrorResponse", + "api/PushRelayPayload", + "api/PushRelayPriority", + "api/PushRelayRequest", + "api/PushRelayResponse", + "api/PushRelayResult", + "api/PushRelayResultStatus", "api/PushRoute", "api/QueryAdapterConfig", "api/QueryAdapterTarget", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 3ad58b74f4b..fa108a88422 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -191,6 +191,8 @@ "system/PlatformTranslationBundle", "system/PlatformTranslationData", "system/PresignedUrlConfig", + "system/PushDirectTransportConfig", + "system/PushRelayTransportConfig", "system/PushServiceConfig", "system/PushVendor", "system/QueueConfig", From 726a5aa44132289b51ddc48211583c5981c9544f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 07:32:03 +0000 Subject: [PATCH 3/4] chore(spec): count the push relay module in llms.txt, and exempt its error union from the variant-doc ledger as wire protocol Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG Co-authored-by: Claude --- packages/spec/llms.txt | 4 ++-- packages/spec/variant-docs.json | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index d69713fcb3d..bfcf63000d3 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (216 schemas) +## 3. Schema Inventory by Domain (217 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 @@ -88,7 +88,7 @@ here is verifiable from the installed package. | 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 | 34 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle, Push Delivery | +| api | 35 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage), Record Page, App Metadata Bundle, Push Delivery, Push Relay | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | | automation | 15 | Flow, Approval, Assignment Rule, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json index 73d886b9eea..3bbdd9a46cf 100644 --- a/packages/spec/variant-docs.json +++ b/packages/spec/variant-docs.json @@ -131,6 +131,12 @@ "exempt": "not-authorable", "reason": "Loader-derived. Authors write the `list` / `listViews` / `formViews` containers; the loader expands them and stamps `viewKind`. No author ever types it." }, + { + "key": "code:QUOTA_EXCEEDED|RATE_LIMIT_EXCEEDED|UNAUTHENTICATED", + "label": "push relay error", + "exempt": "not-authorable", + "reason": "Wire protocol between a server's push channel and the hosted push relay: the relay answers it and nobody writes it into metadata. The generated page at content/docs/references/api/push-relay.mdx is the reference." + }, { "key": "type:ack|cursor|edit|error|event|ping|pong|presence|subscribe|unsubscribe", "label": "websocket message", From 88fe2df6c30c8ce034b16617a7495cc6bb666b71 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:50:08 +0000 Subject: [PATCH 4/4] chore(spec): regenerate api-surface and export-origins after merging main Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG Co-authored-by: Claude --- packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 8d2b284d86e..046517b6507 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -55,6 +55,7 @@ "AwarenessUserState (type)", "AwarenessUserStateSchema (const)", "BUILTIN_OPERATION_MESSAGES (const)", + "BUILTIN_SYSTEM_FIELD_LABEL_LOCALES (const)", "BUILTIN_VALIDATION_MESSAGES (const)", "BackupConfig (type)", "BackupConfigParsed (type)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 2c3212a9659..896dbe8fe59 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -55,6 +55,7 @@ "AwarenessUserState": "src/system/collaboration.zod.ts#AwarenessUserState (type)", "AwarenessUserStateSchema": "src/system/collaboration.zod.ts#AwarenessUserStateSchema (const)", "BUILTIN_OPERATION_MESSAGES": "src/system/operation-message.ts#BUILTIN_OPERATION_MESSAGES (const)", + "BUILTIN_SYSTEM_FIELD_LABEL_LOCALES": "src/system/i18n-resolver.ts#BUILTIN_SYSTEM_FIELD_LABEL_LOCALES (const)", "BUILTIN_VALIDATION_MESSAGES": "src/system/validation-message.ts#BUILTIN_VALIDATION_MESSAGES (const)", "BackupConfig": "src/system/disaster-recovery.zod.ts#BackupConfig (type)", "BackupConfigParsed": "src/system/disaster-recovery.zod.ts#BackupConfigParsed (type)",