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 bbe0ffd42bf..046517b6507 100644
--- a/packages/spec/api-surface/system.json
+++ b/packages/spec/api-surface/system.json
@@ -539,7 +539,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 760d9f43fd4..896dbe8fe59 100644
--- a/packages/spec/export-origins/system.json
+++ b/packages/spec/export-origins/system.json
@@ -518,7 +518,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",
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/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
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",