Skip to content

Commit e9fb529

Browse files
feat(spec): push channel protocol face — device registration widening, stored device shape, device contract members, push route, APNs/FCM config by credentialRef (#29218)
domain:spec#1 · os-steve · session_01ADHkmT6K7dHh79Lej3sWr5 <!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Aggkc5wTqF5ygEUSkDJtkT)_ Fixes #29067 Supersedes #29101 (now closed by the seat as superseded): its three commits at `088613be` are carried unchanged on this branch and continued. Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — declared ahead of its producer: no route serves the device schemas and nothing reads the push transport configuration yet, so there is nothing an author can use to teach; the server slice that serves them owns the docs ## 维护者速读 - 改了什么:spec 里补齐 push 推送渠道的协议面——设备注册请求加宽(vendor/appId/locale 可选,locale 走仓里的 BCP 47 LocaleSchema)、新的设备存储形状 UserDeviceSchema、INotificationService 四个可选设备成员、推送载荷(只收同站相对路由)、APNs/FCM 传输配置(密钥只走 credentialRef)。 - 为什么改:手机壳与控制台要注册原生推送 token,服务端设备注册(#29068)和推送渠道(#29041)都卡在这份协议上。 - 风险与回滚:纯加宽,今天能发的请求体照旧能解析;所有新形状都标了 [PLANNED],还没有路由或读取方;回滚就是撤掉这个 PR。 - 先看:`packages/spec/src/system/push-device.zod.ts`(设备归属规则 a/b/c)、`packages/spec/src/system/push-config.zod.ts`(凭据引用与内联密钥拒收)、`packages/spec/src/api/protocol.zod.ts` 的设备注册块。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/system/push-device.test.ts src/system/push-config.test.ts src/api/push-delivery.test.ts src/api/register-device-request.test.ts`,预期全部通过。 - 需要你做的:批准新的公开名字(schema 名、键名、INotificationService 成员,清单见报告 open_questions);落地本身由席位走。 ## What changes The push channel's protocol face (ADR-0012 §6), the `packages/spec` slice of #29041. **Declared ahead of its producer**: nothing here is served or read yet, and every new or touched schema says so with a `[PLANNED — not yet served]` / `[PLANNED — not yet read]` marker in its description (tracker ids live only in adjacent `//` comments, per `check:doc-authoring`). - **`RegisterDeviceRequestSchema`** (`api/protocol.zod.ts`) gains three optional members: `vendor` (`apns` | `fcm`; absent, derived from `platform`; `web` has no default, so a `web` registration names `apns` or `fcm`), `appId`, and `locale`, a BCP 47 tag through the house `LocaleSchema` (`system/translation.zod.ts`). The describes state the refresh-by-`deviceId` and server-minted-id contract. `RegisterDeviceResponseSchema` / `UnregisterDevice*` keep every member and gain only a description. No published field is removed or renamed, so no ADR-0087 tombstone, conversion or semantic entry is owed. - **`UserDeviceSchema`** (new, `system/push-device.zod.ts`): the stored device. (a) A device belongs to its user alone, no organization member; identity is (`userId`, `deviceId`). (b) One live row per token: a token this user already holds under another `deviceId` (storage cleared, no `deviceId` sent) refreshes that row and answers its `deviceId`; a second user's registration takes the token over and the first user's row for it is removed. (c) A dead token keeps its row with `enabled: false` and `invalidatedAt`; re-registering re-enables it. `invalidatedAt` is present exactly while `enabled` is false, and `lastSeenAt` is absent only on a row no registration wrote (listed last). Its `locale` takes the same `LocaleSchema`, and its `platform` the registration's own list (one internal const, `system/push-device-platform.ts`, re-exported by no barrel, so no public name). - **`INotificationService`** gains four optional members: `registerDevice`, `unregisterDevice`, `listDevices`, `invalidateDeviceToken`, each documenting the semantics an implementation owes (for example, `unregisterDevice` throws an error whose `code` is `NOT_FOUND` for a device the user does not hold, which the route answers as 404). Existing implementations stay valid. - **`PushDeliveryDataSchema`** / **`PushRouteSchema`** (new, `api/push-delivery.zod.ts`): `notificationId` plus an optional `route`. `route` is the notification's `actionUrl`, the same value the inbox serves (explicit `payload.url` / `payload.actionUrl`, else `/OBJECT/ID` from the source record, `actionUrlFor` in service-messaging): app-relative or already `/apps/…`. The shell never navigates it verbatim; on tap it resolves it exactly as the bell and Home do, through `resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`) with its host app segment. A shell with no app list of its own hands `route` to the Console to resolve, or hosts it under the segment the Console would answer (`resolveHostAppSegment`: preferred, then first active app, then `setup`). `PushRouteSchema` admits only a same-server relative path, with no dot segment and no percent-encoded `/`, `\`, `.` or `%` in its path, which closes the double-encoded forms too (both rules are patterns, so the published JSON Schema carries them); a value that fails it is dropped, never sent. The docblock says where the data sits per vendor: FCM `message.data`, APNs top level beside `aps`, never under a `data` key. No `notify` key is added. - **`PushServiceConfigSchema`** (new, `system/push-config.zod.ts`, beside `email-config.zod.ts`): strict `apns` (token auth: `teamId`, `keyId`, `topic`, `environment`, `credentialRef`) and `fcm` (HTTP v1: `projectId`, `credentialRef`) blocks. Secrets go only through `credentialRef` (ADR-0143 D3): an inline secret is refused by name (`key`, `privateKey`, `serviceAccount`, …) and as the content of a reference (a PEM block or a JSON document). `keyRef` / `serviceAccountRef` are answered with `credentialRef`. The reference is stricter than the house `credentialRef` (`z.string().min(1)`) on purpose, and a comment says why: these two secrets are recognisable by shape, a bearer token or API key is not. Not a stack-definition key until its reader ships. - Changeset `.changeset/29067-push-device-registry-spec.md`: `minor`, `Metadata-protocol: added` (the authorable-surface baseline grew, which the level gate reads as `added`). **No route row is added to `DEFAULT_NOTIFICATION_ROUTES`.** The card's Wanted 2 asks for the device rows; measured, there is no house channel for a declared-not-served route row: `handlerStatus: 'planned'` was retired under ADR-0049 with the prescription "an endpoint that has no handler yet is simply not registered" (`HANDLER_STATUS_RETIRED`), the table's own comment records the device rows' removal for that reason, and `packages/runtime/src/request-schema-gate.conformance.test.ts` reds any dispatcher-catalog body route with a `requestSchema` and no served violating-body case. The rows land with their handlers in #29068, which also flips the spec catalog pin (`plugin-rest-api.test.ts`, `toHaveLength(3)` and `not.toContain('/devices')`) and adds the runtime gate case. ## Why The mobile shell and the Console register a native APNs / FCM token under the signed-in session, and `notify` deliveries need a push payload contract. The server device registry (#29068) and the push channel (#29041) are blocked on this protocol face. ## Tests - New / extended: `system/push-device.test.ts` (round-trip, required members, vendor refusal, non-tag locale refusal, the same platform list as the registration, ISO instants, no organization member, PLANNED marker), `system/push-config.test.ts` (`credentialRef` on both blocks, inline-secret refusals by name and by content, the `keyRef` / `serviceAccountRef` near-miss answers, PLANNED markers), `api/register-device-request.test.ts` (pre-widening bodies still parse, widened round-trip, unknown vendor refused, a `web` registration naming either vendor admitted, non-tag locales refused and BCP 47 tags admitted, PLANNED markers), `api/push-delivery.test.ts` (both inbox `actionUrl` shapes admitted, `/opportunity/123` and `/apps/crm/opportunity/123`; off-server, scheme, protocol-relative, backslash, control-character, dot-segment and percent-encoded `/` `\` `.` routes refused, including `/../../api/v1/me`, `/%2e%2e/x`, `/./x`, `/a%2fb`, `/a%5cb` and the double-encoded `/a%252Fb`, `/%252e%252e/x`, `/a%255cb`; a query or fragment keeps its encoding; PLANNED marker), `contracts/notification-service.test.ts` (the four optional members, a toy registry exercising register / list / unregister / invalidate). - **Forced test edit:** `type-alias-convention.pin.test.ts` pin count 817 → 818 (the new isomorphic `UserDevice` alias; the resumed commits had moved it 811 → 817 for the push-config and push-delivery aliases). <details><summary>Local readings (at the head `aff751de`)</summary> - `aff751de` carries the round-4 prose should-fixes (a shell with no app list; `%25` refused in the route path, pinned). `eb9b2371` carried the delta review's round (the push `route` contract, per-vendor placement, the path refusals, the same-user token refresh), `e7b06bc4` the contract review's should-fixes, and `833e9bd7` moved both `locale` members to `LocaleSchema`. Every reading below was taken again at `aff751de`; earlier heads carried the same readings with 23450 (`eb9b2371`), 23437 (`e7b06bc4`), 23434 (`833e9bd7`) and 23423 (`73fabdf2`) spec tests. - Build + regen: `pnpm --filter @objectstack/spec build` then `check:generated --fix` under os-verify-lock, VERDICT command-exit 0; each round's describe and docblock changes regenerated their reference pages (`api/protocol.mdx`, `api/push-delivery.mdx`, `system/push-device.mdx`), committed with the source; `check:generated` without fix then exit 0. Earlier, the authorable-surface system shard was taken from main and regenerated (the two keys renamed here, never on main, left with their rename). - `pnpm --filter @objectstack/spec test`: VERDICT command-exit 0, Test Files 794 passed (794), Tests 23454 passed, 1 todo. - `pnpm --filter @objectstack/spec typecheck`: VERDICT command-exit 0 (tsc, scripts typecheck, and check:test-typecheck: test layer compiles, 131 pinned signatures held). - Consumers (closure built first, VERDICT 0): `@objectstack/service-messaging` test Test Files 62 passed (62), Tests 759 passed, typecheck exit 0; `@objectstack/runtime` `notification-schema-conformance.test.ts` + `request-schema-gate.conformance.test.ts` (the catalog pin consumer) Test Files 2 passed, Tests 25 passed, typecheck exit 0; `@objectstack/client` typecheck exit 0 (it re-exports `RegisterDeviceRequest` / `RegisterDeviceResponse`). - Scoped lint: `pnpm exec eslint --no-inline-config` over the 14 changed .ts files, exit 0, no findings. - Ablation, `credentialRef`: `push-config.zod.ts` replaced by its `088613be` bytes (keyRef / serviceAccountRef, no PLANNED marker; on-disk counts checked) → `push-config.test.ts` exit 1, 19 failed of 21; restored with `git checkout HEAD --`, blob hash equal to HEAD, `git diff HEAD` empty → exit 0, 21 passed. Under a trap. - Ablation, `locale`: both `locale: LocaleSchema.optional()` lines put back to `z.string().optional()` (`ablation-replace.mjs`, anchor 1 → 0 in each file) → `register-device-request.test.ts` + `push-device.test.ts` exit 1, 7 failed of 40; restored, both blob hashes equal to HEAD, `git diff HEAD` empty → exit 0, 40 passed. Under a trap. - Ablation, shared platform list: `UserDeviceSchema.platform` put back on its own `z.enum(['ios', 'android'])` → `push-device.test.ts` exit 1, 1 failed of 19 (the same-platforms test); restored, blob hash equal to the pre-ablation file → exit 0, 19 passed. Under a trap. - Ablation, path refusals: the second `.regex()` (dot segments, percent-encoded `/` `\` `.`) removed from `PushRouteSchema` → `push-delivery.test.ts` exit 1, 8 failed of 27 (exactly the eight new refusals); restored, blob hash equal to the pre-ablation file → exit 0, 27 passed. Under a trap. - Ablation, `%25`: `|25` taken out of that pattern (`ablation-replace.mjs`, anchor 1 → 0) → `push-delivery.test.ts` exit 1, 3 failed of 31 (exactly the three double-encoded pins); restored, blob hash equal to the pre-ablation file → exit 0, 31 passed. Under a trap. - `check-changeset-no-major.mjs --base origin/main --event` with this body: exit 0, level axis `yes (widening)` + `added`. `check-partof-closing-keyword.mjs` with this body: exit 0. - Dogfood battery: n/a, no `!` in the title and no `(narrowing)` arm. </details> <details><summary>Gates (dispatch-gates --commands, 103 commands)</summary> - At the head `aff751de`: the derived list is the same 103 commands as at every earlier head, and all 103 exit 0 (each run with stdin closed, exit captured before any pipe). `dispatch-gates --ran`: 103 derived, 103 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. - At `eb9b2371`, `e7b06bc4`, `833e9bd7` and `73fabdf2`: all 103 exit 0. First round at `ed89754e`: 102 of 103 exit 0; `check:llms-txt` exit 1 on the schema inventory counts (system 37 vs 39, api 33 vs 34, heading 213 vs 216, the three new modules), fixed in `73fabdf2`. - Before the gates: `pnpm turbo run build --concurrency=2 --filter='./packages/**'` under os-verify-lock, VERDICT command-exit 0 (the `check:dual-build-cjs-loads` / `check:skill-examples` prerequisites). - CI on `833e9bd7`, `e7b06bc4` and `eb9b2371`: 57 success, 8 skipped, 0 failure each. CI on `aff751de`: the seat reads it. </details> ## Acceptance notes - Card vs house: Wanted 2 route rows are not in this PR (above). Wanted 7 (`NotificationChannelSchema` JSDoc) rides the server slice that registers `push`. - Hand-off to #29068 (the server device-registry slice): the device route rows and their handlers; the handler's refusal of a `web` registration without `vendor` (the schema admits it, since `vendor` is optional; the refusal needs `platform` and `vendor` together and is the handler's); the `request-schema-gate.conformance.test.ts` violating-body case for the device routes; the catalog pin flip in `plugin-rest-api.test.ts`; and the `DEFAULT_NOTIFICATION_ROUTES` JSDoc correction below. - `DEFAULT_NOTIFICATION_ROUTES`' JSDoc says it covers "device registration, preferences, listing" while the table lists only listing; false on `main` before this PR, left as it is for #29068 to correct with the rows. - Resume: the previous dev's commits `8dcce8b7`, `81b25bd2`, `088613be` (PR #29101) are carried unchanged. Changed on top of them: `keyRef` / `serviceAccountRef` → `credentialRef`, the PLANNED markers, `UserDeviceSchema`, `listDevices`, the changeset level (`patch` / `none` → `minor` / `added`), both `locale` members on `LocaleSchema`, the contract review's should-fixes (one vendor sentence, the `NOT_FOUND` mechanism, the stricter-reference comment, the shared platform list, when the two timestamps are absent), the delta review's round (`route` as the inbox `actionUrl` the shell resolves, per-vendor placement, path refusals, the same-user token refresh), and its prose round (a shell with no app list, the `%25` refusal). ## Verification See the readings under Tests. The PR stays a draft; the seat hands the declaration-face contract review to triage. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (scoped to the changed files locally; the repo-wide run is CI's). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2789ba7 commit e9fb529

37 files changed

Lines changed: 1388 additions & 24 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): the push channel's protocol face, declared ahead of its producer: the widened device registration request, the stored device shape, optional `INotificationService` device members, the push delivery data with its same-server `route`, and the APNs / FCM transport configuration by `credentialRef` (ADR-0012 §6)
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
Nothing here is served or read yet. Every new or touched schema carries a `[PLANNED — not yet served]` or `[PLANNED — not yet read]` marker in its description, and no route row is added: the device routes land in `DEFAULT_NOTIFICATION_ROUTES` together with their handlers, so the catalog never advertises a route that answers 404. Nothing a client or an operator writes today is refused.
11+
12+
- **`RegisterDeviceRequestSchema`** gains three optional members: `vendor` (`apns` | `fcm`; absent, the server derives it from `platform`, `ios` → `apns` and `android` → `fcm`, and a `web` registration names `apns` or `fcm`), `appId` and `locale` (a BCP 47 tag, the house `LocaleSchema`). `token`, `platform`, `deviceId` and `name` are unchanged, so every body a client sent before still parses as it did. The describes now say that re-registering a `deviceId` refreshes that device, and that a request without one gets a server-minted id the client keeps. A `vendor` outside `apns` / `fcm` is refused; since the member is new, no body sent before carries one. `RegisterDeviceResponseSchema` and `UnregisterDevice*` keep their members and gain only their description.
13+
- **`UserDeviceSchema`** (new, `system/push-device.zod.ts`) is the stored device: `deviceId`, `userId`, `vendor`, `token`, `platform`, `enabled`, and optional `appId`, `locale`, `name`, `invalidatedAt` and `lastSeenAt`. A device belongs to its user alone (no organization member), a token has one live row (the same user's registration without a `deviceId` refreshes the row that holds it, and a second user's registration takes it over), and a token the vendor reports dead keeps its row with `enabled: false` and `invalidatedAt`.
14+
- **`INotificationService`** gains four optional members: `registerDevice(userId, request)`, `unregisterDevice(userId, deviceId)`, `listDevices(userId)` and `invalidateDeviceToken(token)`. Their docs state the semantics an implementation owes. Absence means the capability is unavailable, so every existing implementation stays valid.
15+
- **`PushDeliveryDataSchema`** (new, `api/push-delivery.zod.ts`) is the data a push carries to the shell: `notificationId` and an optional `route`. `PushRouteSchema` admits only a same-server relative path (one leading `/`; no scheme, host, backslash, whitespace or control character; no dot segment and no percent-encoded `/`, `\`, `.` or `%` in its path). The route is derived, never authored: it is the notification's existing `actionUrl` (app-relative `/OBJECT/ID` or an `/apps/…` route) when that passes the schema, and absent otherwise. The shell resolves it on tap as the inbox does, never verbatim. FCM carries this data in `message.data`; APNs at the top level of the payload, beside `aps`. No `notify` key is added.
16+
- **`PushServiceConfigSchema`** (new, `system/push-config.zod.ts`, beside the email transport configuration): an `apns` block (token auth: `teamId`, `keyId`, `topic`, `environment: sandbox | production`, the `.p8` key by `credentialRef`) and an `fcm` block (HTTP v1: `projectId`, the service-account JSON by `credentialRef`). Secrets are references only (ADR-0143 D3). An inline secret is refused by name under the keys an operator would reach for, and refused as the content of a reference (a PEM block or a JSON document). `PushVendorSchema` names the two vendors. It is not a stack-definition key until its reader ships.

‎content/docs/references/api/index.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: API Protocol — complete schema reference
33
navTitle: API Protocol
4-
description: "The ObjectStack API Protocol in 34 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
4+
description: "The ObjectStack API Protocol in 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -33,6 +33,7 @@ This section contains all protocol schemas for the api layer of ObjectStack.
3333
<Card href="/docs/references/api/package-lifecycle" title="Package Lifecycle" description="Source: packages/spec/src/api/package-lifecycle.zod.ts" />
3434
<Card href="/docs/references/api/plugin-rest-api" title="Plugin Rest Api" description="Source: packages/spec/src/api/plugin-rest-api.zod.ts" />
3535
<Card href="/docs/references/api/protocol" title="Protocol" description="Source: packages/spec/src/api/protocol.zod.ts" />
36+
<Card href="/docs/references/api/push-delivery" title="Push Delivery" description="Source: packages/spec/src/api/push-delivery.zod.ts" />
3637
<Card href="/docs/references/api/query-adapter" title="Query Adapter" description="Source: packages/spec/src/api/query-adapter.zod.ts" />
3738
<Card href="/docs/references/api/realtime" title="Realtime" description="Source: packages/spec/src/api/realtime.zod.ts" />
3839
<Card href="/docs/references/api/realtime-shared" title="Realtime Shared" description="Source: packages/spec/src/api/realtime-shared.zod.ts" />

‎content/docs/references/api/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"misc",
3838
"package-api-assembled",
3939
"package-lifecycle",
40+
"push-delivery",
4041
"record-page",
4142
"sortability"
4243
]

‎content/docs/references/api/protocol.mdx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2761,20 +2761,27 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
27612761

27622762
## RegisterDeviceRequest
27632763

2764+
[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers
2765+
27642766
### Properties
27652767

27662768
| Property | Type | Required | Description |
27672769
| :--- | :--- | :--- | :--- |
27682770
| **token** | `string` | ✅ | Device push notification token |
27692771
| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform |
2770-
| **deviceId** | `string` | optional | Unique device identifier |
2772+
| **deviceId** | `string` | optional | Client-held device id, stable per app install. Re-registering the same deviceId refreshes that device; absent, the server mints one and answers it, and the client keeps it for later registrations and unregister |
27712773
| **name** | `string` | optional | Device friendly name |
2774+
| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused |
2775+
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
2776+
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
27722777

27732778

27742779
---
27752780

27762781
## RegisterDeviceResponse
27772782

2783+
[PLANNED — not yet served] Answer to a device registration: the device id the client keeps
2784+
27782785
### Properties
27792786

27802787
| Property | Type | Required | Description |
@@ -3019,6 +3026,8 @@ Uninstall package response
30193026

30203027
## UnregisterDeviceRequest
30213028

3029+
[PLANNED — not yet served] Unregister one of the signed-in user's push devices
3030+
30223031
### Properties
30233032

30243033
| Property | Type | Required | Description |
@@ -3030,6 +3039,8 @@ Uninstall package response
30303039

30313040
## UnregisterDeviceResponse
30323041

3042+
[PLANNED — not yet served] Answer to a device unregistration
3043+
30333044
### Properties
30343045

30353046
| Property | Type | Required | Description |
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
title: Push Delivery schema — API Protocol reference
3+
navTitle: Push Delivery
4+
description: "Push delivery data — what a push carries to the shell. Reference for PushDeliveryData, PushRoute: every property with its type and default."
5+
---
6+
7+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
8+
9+
Push delivery data — what a push carries to the shell.
10+
11+
The shell reads it when the user taps the push: `notificationId` names the
12+
notification (the same id the inbox lists and `markRead` takes), and
13+
`route` is the notification's `actionUrl`, the link the inbox serves for it.
14+
15+
**Where it sits in the vendor payload.** ADR-0012 §8 names it the `data`
16+
member of the push renderer's output; that is the renderer's shape, not the
17+
vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map
18+
of string values. On APNs they sit at the top level of the payload, beside
19+
`aps`, and never under a `data` key.
20+
21+
**Declared ahead of its producer.** No push channel delivers yet, so
22+
nothing produces this data today; `PushDeliveryDataSchema` carries a
23+
`[PLANNED — not yet served]` marker in its `.describe()` until the push
24+
channel ships. The route rule below is the contract that channel owes.
25+
26+
**What `route` is — the inbox's `actionUrl`, derived, never authored.**
27+
There is no `notify` key for it. It is the value the inbox already serves:
28+
an explicit `url` / `actionUrl` in the notification's payload, else
29+
`/OBJECT/ID` built from the notification's source record, else none. So it
30+
is either app-relative (`/opportunity/123`) or already a Console route
31+
(`/apps/crm/opportunity/123`). When that value passes
32+
`PushRouteSchema` the push carries it as `route`; when it does not
33+
(an absolute URL to another site, say) or there is none, the push carries
34+
no `route` and the tap opens the shell's default screen. The delivery is
35+
never failed for it.
36+
37+
**The shell never navigates it verbatim.** On tap it resolves `route`
38+
exactly as the inbox bell and Home do for the same notification: through
39+
`resolveNotificationTarget` (`ui/packages/app-shell/src/utils/appRoute.ts`)
40+
with its host app segment, which hosts an app-relative path under
41+
`/apps/APP/` and passes an `/apps/…` route through. One resolver, so a
42+
push and the inbox row open the same screen.
43+
44+
A shell with no app list of its own (a native shell that has not loaded
45+
the Console's catalog, say) does not guess either. It hands `route` to the
46+
Console to resolve, or hosts it under the segment the Console would answer
47+
(`resolveHostAppSegment` in the same file: the preferred app the user last
48+
had open, re-checked against the active apps, then the first active app,
49+
then `setup`). It never re-implements the resolver and never opens `route`
50+
verbatim.
51+
52+
Not to be confused with the `PushNotificationSchema` that `system/` once
53+
declared and then removed (ADR-0049): that was an authoring template shape;
54+
this is the wire data of a delivery, produced by the platform.
55+
56+
<Callout type="info">
57+
**Source:** `packages/spec/src/api/push-delivery.zod.ts`
58+
</Callout>
59+
60+
## TypeScript Usage
61+
62+
```typescript
63+
import { PushDeliveryDataSchema, PushRouteSchema } from '@objectstack/spec/api';
64+
import type { PushDeliveryData, PushRoute } from '@objectstack/spec/api';
65+
66+
// Validate data
67+
const result = PushDeliveryDataSchema.parse(data);
68+
```
69+
70+
---
71+
72+
## PushDeliveryData
73+
74+
[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route. No push channel delivers yet
75+
76+
### Properties
77+
78+
| Property | Type | Required | Description |
79+
| :--- | :--- | :--- | :--- |
80+
| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes |
81+
| **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 |
82+
83+
84+
---
85+
86+
## PushRoute
87+
88+
The notification actionUrl as a same-server relative path: app-relative (/opportunity/123) or a Console route (/apps/crm/opportunity/123). The shell resolves it on tap as the inbox does, never verbatim; never an absolute or off-server URL
89+
90+
91+
---
92+

0 commit comments

Comments
 (0)