Skip to content

Commit 8aa2e91

Browse files
feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging (#29794)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8MDJvNuJodyHTc3YS13avjC)_ Fixes #29041 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 1. 改了什么:通知现在能推送到手机。新增推送服务包 `@objectstack/service-push`(苹果 APNs、谷歌 FCM),消息服务新增“推送”渠道:流程里的通知节点选了推送,就发到收件人登记过的每一台手机,点开推送进入这条通知对应的页面;手机端应用已卸载(令牌失效)时,自动停用那台设备。同时修订 ADR-0012(通知平台):推送包名定为 `service-push`,§4 推送一行改为“已上线”,§6 写明失效令牌由推送渠道停用;ADR-0030 未改。推送服务目前只支持直连(运营方自己的 APNs 密钥和 Firebase 项目,适合白牌应用);cloud#3519 定下的默认方式“托管中继”还没有上线(#29920),配置成中继会在启动时被拒绝;中继本身的 ADR 改写由 #29817 负责,本 PR 不写。 2. 为什么改:#29041 的验收条件。之前流程里选了推送也显示“运行成功”,实际一条推送都没有发出(已在 main 上复现)。 3. 风险与回滚:纯新增。没有启用推送服务的部署,行为完全不变;启用了推送服务却没写配置、或配置为中继(relay)的部署,会在启动时报错并提示改用 `transport: direct`,不会出现以为能推送、实际什么都发不出去的情况;推送密钥只能通过引用(`credentialRef`)配置,写明文密钥会在启动时被拒绝;推送里带的跳转只会是本服务器内的路径,外站链接一律不带。回滚即还原本 PR。 4. 先看:`packages/services/service-messaging/src/push-channel.ts`(发给哪些手机、失效设备停用、结果怎么分级),再看 `docs/adr/0012-notification-platform.md` 的修订处。 5. 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-push-delivery.integration.test.ts`,预期 3 条全过:模拟的苹果和谷歌推送服务各收到一条带跳转的推送,被报失效的设备变为停用,同一用户的另一台手机照常收到。 6. 需要你做的:席位契约复核已通过(评论 6606770732,`Head-sha: 5b70955`),请批准 ADR-0012(通知平台)的这次修订。本 PR 属于 Tier H 就是因为它,只改了状态行的 `Amended` 说明和 §4、§6 两处。 ## What changes - **New package `@objectstack/service-push`** (`packages/services/service-push`, named per the maintainer's 2026-11-03 ruling: kernel services are `service-*`). `PushServicePlugin` registers the kernel's `push` service, an `IPushService` with one transport per configured vendor: - `ApnsPushTransport`: APNs over HTTP/2 (`node:http2`, one held session) with token-based auth. An ES256 provider token is signed with the `.p8` key and reused for fifty minutes (APNs refuses one older than an hour and throttles refreshes under twenty). `200` is sent; `BadDeviceToken` / `Unregistered` / `410` is `invalid_token`; `ExpiredProviderToken` re-signs once; `429` / `5xx` are retryable; other refusals are not. A token that is not hex is answered `invalid_token` and never reaches the request path. - `FcmPushTransport`: FCM HTTP v1. An RS256 assertion signed with the service account's key is exchanged for an OAuth access token, kept until a minute before expiry. `UNREGISTERED` (or `404 NOT_FOUND`) is `invalid_token`; a `401` refreshes the token once; `429` / `5xx` are retryable. - **Transport:** it serves the direct transport (`transport: 'direct'`, or a vendor block with no `transport`; the operator's own APNs key and Firebase project, for a white-label build). The hosted relay, the default of `PushServiceConfigSchema` since #29816 and so also an empty config, is refused at init with `PUSH_RELAY_NOT_SERVED_MESSAGE` and registers nothing, until #29920 ships its sender. - Configuration is the plugin's `config` option, parsed with `PushServiceConfigSchema` at init, so an inline secret or a misspelt key fails the boot with the schema's guidance. Each secret is a `credentialRef`, resolved when the transport first signs through `resolveCredential` (default: the open tier's rule, the name of an environment variable; ADR-0143 D3). No error or log line carries a secret or a whole device token. - **`@objectstack/service-messaging`: the `push` channel shell** (`push-channel.ts`), mounted through the same lazy provider as the sms channel, so it is present exactly while a `push` service is registered. One delivery reaches every enabled device of the recipient on a vendor the service can reach. The push carries the notification's title and body (already in the recipient's language) and the delivery data `PushDeliveryDataSchema` declares: `notificationId`, plus `route` when the notification's `actionUrl` passes `PushRouteSchema` (an off-server, dot-segment or encoded path is left out; the delivery still goes). Title and body are cut to 256 / 2048 UTF-8 bytes so the payload stays under the vendors' 4 KB. - A token reported dead is retired through `invalidateDeviceToken` (row kept, `enabled: false`, `invalidatedAt`); the user's other devices still get the push. - Grades: one device pushed ⇒ delivered (a failure beside it is logged, not retried, so no device is pushed twice); every device dead, or no enabled device ⇒ `invalid_recipient` (suppressed); devices only on an unconfigured vendor, or a refusal no retry repairs ⇒ `permanent`; timeout / `429` / `5xx` with nothing delivered ⇒ `retryable`; no push service ⇒ refused `transport_not_configured`, as the sms and email channels refuse it. `isAvailable` answers from the push service's vendors, no I/O. - **Who owns `sys_user_device`** (contract-review item 3 of 6593642547): service-messaging owns it; its push channel reads it and retires tokens in it; service-push never touches it. `user-device.object.ts` and `push-device.zod.ts` now say the same. - **`packages/spec`: PLANNED flips and text only.** The `[PLANNED — not yet read]` markers come off `PushServiceConfigSchema` and `PushDirectTransportConfigSchema` (the APNs and FCM blocks carried none on main) and `[PLANNED — not yet served]` off `PushDeliveryDataSchema` (their reader and producer ship here). `PushRelayTransportConfigSchema` keeps a marker, `[PLANNED — not yet served]`, saying the push service refuses it at boot; one docblock paragraph in `push-config.zod.ts` says what is served today; the two marker pins flip to "no PLANNED marker". `push-config.zod.ts`'s docblock names `@objectstack/service-push` where it named `plugin-notification-push`. `NotificationChannelSchema`'s JSDoc lists `push` among the registered channels (Wanted 7 of #29067). The three items of 6593642547: the `NOT_FOUND` synonym waiver's reason names service-messaging's provenance row; `INotificationService`'s device comments say the push channel reads `listDevices` and calls `invalidateDeviceToken` (true as of this PR); the ownership sentence above. Reference pages regenerated (`push-config.mdx`, `push-delivery.mdx`). No key, shape or export changes. - **ADR-0012** (superseded by ADR-0030, which is unchanged): the status line gains `Amended (2026-11-03, #29041)`: push ships as `service-push` plus the channel shell in service-messaging, which owns `sys_user_device`, and every `plugin-notification-push` in the ADR reads that way. §4's push row names the shipped packages; §6 records that the channel, not the dispatcher, retires the dead token (one delivery fans out to several devices) and that the shipped device shape is `system/push-device.zod.ts`; the amendment also says `service-push` ships the direct transport and that the hosted relay, the default per cloud#3519, is not yet served (#29920). §6's domestic-vendor sentence is left exactly as on `main`: #29817 rewrites §4 and §6 for the relay itself. - Docs and registration: `content/docs/plugins/packages.mdx` (service count 17 → 18, a `service-push` entry, the messaging line), `CONTRIBUTING.md` package row, `.changeset/config.json` fixed group, `packages/spec/llms.txt` package count 73 → 74 (forced by `check:llms-txt`; prose in the spec tree, not a schema), README. - Changeset `.changeset/29041-push-notification-channel.md`: `service-push`, `service-messaging` and `spec` all `patch` (`Metadata-protocol: none` takes `patch`, ruling A on #25105). ## Why #29041 asks that a `notify` node deliver a push to a registered device through APNs/FCM, carry `route`, and retire a token reported invalid. The protocol face (#29067, PR #29218) and the device registry (#29068, PR #29628) had landed; nothing sent. ## Tests - **Red first, on `origin/main` `7b55ea95`** (a throwaway probe, not committed): a `notify` node with `channels: ['push']` over the real `MessagingService` with a registered APNs device and a local HTTP/2 server → `run.success: true`, `summary.acted: 0`, `APNS requests received: 0`; the probe's `expect(received).toHaveLength(1)` failed. - **Acceptance** `service-automation/src/builtin/notify-push-delivery.integration.test.ts` (3): the node delivers to an iOS and an Android device through mock APNs (HTTP/2) and mock Google (OAuth + FCM v1) with the real transports, `route` and `notificationId` in both payloads; the source record's `/OBJECT/ID` rides as `route` when no url is given; a token APNs answers `410 Unregistered` is retired (`enabled: false`, `invalidatedAt` set), the other device still gets the push, and the retired device is not pushed again. - `service-push` (30): APNs request shape and an ES256 token the public key verifies, dead-token reasons, re-sign on `ExpiredProviderToken`, token reuse for fifty minutes, retryable vs not, unresolved and unusable credentials never echoed and never sent, a non-hex token never in the path; FCM assertion verified with the public key, message shape, `UNREGISTERED`, access-token reuse and 401 refresh, retryable vs not, bad service account, token-endpoint refusal; plugin config parse, inline-secret refusal at init, host transport override, masked token in logs, the env resolver. - `service-messaging/src/push-channel.test.ts` (14): device selection, data shape against `PushDeliveryDataSchema`, off-server routes left out, dead-token retirement with the other device still pushed, every grade above, byte truncation, `isAvailable`, and the plugin mount: refused before a `push` service exists, delivered through it once registered. - Ablations (each restored, blob equal to HEAD, `git diff HEAD` empty; `ablation-replace.mjs` under a trap): the `invalidateDeviceToken` call removed → `push-channel.test.ts` 2 failed of 14 (the two retirement cases); `PushRouteSchema.safeParse(actionUrl).success` replaced by `true` → 1 failed of 14 (the off-server route case). - **Round 3** `service-push/src/push-plugin.test.ts`: direct boots both named and inferred from a vendor block; relay is refused four ways (no config, `{}`, `transport: 'relay'`, a staging endpoint), each registering nothing; the message content. `service-push` 35/35. Ablation: the refusal line replaced by a comment → 4 failed of 12 (the four relay arms); restored, `git diff HEAD` empty. - Forced test edits: `push-config.test.ts` and `push-delivery.test.ts` marker pins flip from "carries the PLANNED marker" to "carries no PLANNED marker". <details><summary>Local readings (tests at `1ebbde26b`; `b6f8119b` changes only the changeset level)</summary> - Closure build: `pnpm exec turbo run build --concurrency=2 --filter='./packages/**'` under os-verify-lock, VERDICT command-exit 0 (6m18s). Spec rebuilt and `check:generated --fix` regenerated `references/api/push-delivery.mdx` and `references/system/push-config.mdx`, re-checked green. - `pnpm --filter @objectstack/service-push test`: Test Files 3 passed (3), Tests 30 passed (30); `typecheck` exit 0. - `pnpm --filter @objectstack/service-messaging test`: Test Files 66 passed (66), Tests 801 passed (801); `typecheck` exit 0 (both programs). - `pnpm --filter @objectstack/service-automation typecheck` exit 0 (tsc + check:test-typecheck); `notify-push-delivery.integration.test.ts` + `notify-node.test.ts` + `notify-both-channels-unheld-address.integration.test.ts`: Test Files 3 passed (3), Tests 27 passed (27). - `packages/spec`: `push-config.test.ts`, `push-delivery.test.ts`, `push-device.test.ts`, `contracts/notification-service.test.ts`, `api/error-code-ledger.test.ts`: Test Files 5 passed (5), Tests 107 passed (107); `pnpm --filter @objectstack/spec typecheck` exit 0. The rest of the spec suite and `test:repo` are CI's (`Test Core`). - `check-changeset-no-major.mjs --base origin/main --event` with this body: exit 0 (level axis `yes (widening)` + `none`, every entry `patch`). `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)</summary> - Round 3 at `b0503c1db` (merged `origin/main` `a7e984ab4`, which carries #29816 `1bb9536e`): 135 derived commands, 135 of 135 exit 0, `pnpm lint` exit 0; `--ran` 135 run, 0 NOT-MEASURED. Round 3b at `5b7095572` (four-file delta): `check:doc-authoring`, `check:generated`, `check-adr-links`, `pnpm lint`, `tsc` exit 0; `service-push` 35/35. - At the head `b6f8119b` (and before it at `1ebbde26b`): 134 derived commands, all 134 exit 0 (each run with stdin closed, exit captured before any pipe), plus `pnpm lint` exit 0. `dispatch-gates --ran`: 134 derived, 134 run, 0 NOT-MEASURED, 0 UNRUN; `pnpm lint` recorded outside the derivation. - First pass at `391ab42b`: five reds, each fixed in `1ebbde26b`: `check:llms-txt` (spec's `llms.txt` said 73 packages, the workspace now publishes 74), `check:engine-double-contract` (the two new test doubles' `findOne` / `update` / `delete` now route through the metadata-core predicates; pinned ledger rows written with `--write`), `check:objectql-double-limit` (the doubles hold the caller's `limit`, after the filter and before the copy), `check:test-source-alias` (service-automation aliases `@objectstack/service-push`, service-push aliases `@objectstack/spec/system`, to source), and `check-plugin-teardown-shape.mjs --self-test` exit 3 on the shallow clone (its pinned fixture commit fetched; no file change). </details> ## Acceptance notes - **No served door yet.** Push is configured only where a host composes `new PushServicePlugin({ config })`. `os serve` has no `push` capability (`packages/spec/src/kernel/platform-capabilities.ts`) and the stack definition no `config.push` key, so a served deployment cannot switch push on from its configuration. Both are spec edits outside this card's surface; named for the seat to card (a capability token, a stack door and a `resolvePushCapabilityArg` beside `resolveSmsCapabilityArg`). - **Text is the recipient's, not the device's.** The push sends the notification's title and body as fan-out rendered them for the recipient (`sys_user.locale`, else the deployment default), the same text the inbox row shows. `UserDeviceSchema.locale`'s description says pushes to the device are rendered in the device locale; nothing reads it. Reported to the seat. - **No push template.** `sys_notification_template` accepts `email` and `sms` only (`TEMPLATE_CHANNELS`), so there is no per-topic push template; widening it is not this card. - A delivery with one device pushed and another failing retryably is recorded delivered and not retried, on purpose: a retry would push the delivered devices again. - HMS / Xiaomi / OPPO / vivo stay out of scope (ADR-0012 §6), as does a hosted configuration surface (triage 6570782253). ## Verification Readings above. Round 3 merged `origin/main` `a7e984ab4` (carries #29816 `1bb9536e`); head `5b7095572`, 135 derived gates. Contract review PASS 6606770732 on that head. ## 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. - [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/`. - [ ] 8. Title, body and comments are in English. (The 维护者速读 block is Chinese by the dispatch contract.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P)_ <!-- signed: domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d46b1ff commit 8aa2e91

41 files changed

Lines changed: 2531 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-push': patch
3+
'@objectstack/service-messaging': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(notifications): the `push` channel — a `notify` node naming `push` reaches the recipient's phones through APNs and FCM
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **New package `@objectstack/service-push`.** `PushServicePlugin` registers the kernel's `push` service: APNs over HTTP/2 with token-based auth (an ES256 provider token signed with the `.p8` key, reused for fifty minutes) and FCM over the HTTP v1 API (an OAuth access token from the service account). It reads `PushServiceConfigSchema` from its `config` option and refuses an inline secret at boot with the schema's guidance. It serves the direct transport (`transport: 'direct'`, the operator's own APNs key and Firebase project, for a white-label build); the hosted relay, the schema's default and so also an empty config, is refused at init until its sender ships (#29920). Each secret is named by `credentialRef` and resolved when the transport first signs; in the open tier the reference is the name of an environment variable (ADR-0143 D3).
13+
- **`@objectstack/service-messaging`: the `push` channel.** Mounted while a `push` service is registered, as the sms channel is while an `sms` service is. One delivery reaches every enabled device the recipient registered, on each vendor the push service has a transport for. The push carries the notification id and, when it is a same-server path (`PushRouteSchema`), the notification's `actionUrl` as `route`; any other `actionUrl` is left out and the delivery still goes. Each device's push also carries the origin stored on its row (`PushDeliveryDataSchema.origin`) when the row holds one; the registry does not store it yet, so today none does. A token the vendor reports dead (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`) is retired through `invalidateDeviceToken`: the device row is kept, disabled, and the user's other devices still get the push. A recipient with no enabled device, or whose every device was reported dead, is graded `invalid_recipient`; no push service is refused `transport_not_configured`, as the sms and email channels refuse it.
14+
- **`@objectstack/spec`: description and comment text only.** The `[PLANNED]` markers come off `PushServiceConfigSchema`, `PushDirectTransportConfigSchema`, `ApnsTransportConfigSchema`, `FcmTransportConfigSchema` and `PushDeliveryDataSchema`, which now have their reader and producer; `PushRelayTransportConfigSchema` keeps `[PLANNED — not yet served]`, now saying the push service refuses it at boot (#29920). `NotificationChannelSchema`'s JSDoc lists `push` among the registered channels, the docblocks name `@objectstack/service-push` where they named `plugin-notification-push`, and the `NOT_FOUND` synonym waiver's reason names service-messaging's own provenance row. No key, schema shape or export changes.

‎.changeset/config.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@objectstack/plugin-security",
5050
"@objectstack/plugin-sharing",
5151
"@objectstack/service-sms",
52+
"@objectstack/service-push",
5253
"@objectstack/plugin-webhooks",
5354
"@objectstack/trigger-record-change",
5455
"@objectstack/trigger-api",

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ start when you add a component.
416416
| [`@objectstack/service-i18n`](packages/services/service-i18n) | Internationalization |
417417
| [`@objectstack/service-messaging`](packages/services/service-messaging) | Outbound notification dispatch across channels |
418418
| [`@objectstack/service-sms`](packages/services/service-sms) | SMS delivery (Aliyun, Twilio, log) |
419+
| [`@objectstack/service-push`](packages/services/service-push) | Mobile push delivery (APNs, FCM) |
419420
| [`@objectstack/service-knowledge`](packages/services/service-knowledge) | Knowledge / RAG orchestration over pluggable adapters |
420421
| [`@objectstack/service-package`](packages/services/service-package) | Package registry — publish, install, manage metadata packages |
421422
| [`@objectstack/cloud-connection`](packages/cloud-connection) | Runtime-side client for an ObjectStack cloud control plane |

‎content/docs/plugins/packages.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ ObjectStack is organized into package manifests across multiple categories. This
1515
| **Framework adapters** | 1 | `hono` (other frameworks: build a thin adapter on `HttpDispatcher` — see below) |
1616
| **Drivers** | 5 | `driver-memory`, `driver-sql`, `driver-sqlite-wasm`, `driver-mongodb`, `driver-turso` |
1717
| **Plugins** | 17 | `plugin-auth`, `plugin-security`, `plugin-audit`, `plugin-approvals`, `plugin-sharing`, `plugin-email`, `plugin-webhooks`, `plugin-hono-server`, `plugin-dev`, `plugin-pinyin-search`, `mcp`, trigger plugins (`trigger-api`, `trigger-record-change`, `trigger-schedule`), and knowledge/embedder plugins (`knowledge-memory`, `knowledge-ragflow`, `embedder-openai`) |
18-
| **Platform services** | 17 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-print`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` |
18+
| **Platform services** | 18 | `service-analytics`, `service-automation`, `service-cache`, `service-cluster`, `service-cluster-redis`, `service-datasource`, `service-i18n`, `service-job`, `service-knowledge`, `service-messaging`, `service-package`, `service-print`, `service-push`, `service-queue`, `service-realtime`, `service-settings`, `service-sms`, `service-storage` |
1919

2020

2121
## Core Packages
@@ -240,7 +240,7 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
240240

241241
**Messaging Service** — Outbound notification dispatch (ADR-0012).
242242

243-
- **Features**: `MessagingChannel` registry, `emit()` fan-out, always-on inbox channel; email/webhook/push/IM channels plug in
243+
- **Features**: `MessagingChannel` registry, `emit()` fan-out, always-on inbox channel; the email, sms and push channels mount while their transport service is registered; the push device registry (`sys_user_device`, `/api/v1/notifications/devices`); webhook/IM channels plug in
244244
- **When to use**: Notifying users across channels from flows, hooks, and plugins
245245

246246
### @objectstack/service-sms
@@ -250,6 +250,13 @@ All services implement contracts from `@objectstack/spec/contracts` and are kern
250250
- **Features**: Provider adapters (Aliyun SMS, Twilio), `sms` settings namespace with live rebind, backs phone-number OTP sign-in/reset and the messaging `sms` channel
251251
- **When to use**: Phone OTP first-login / self-service reset, SMS notifications
252252

253+
### @objectstack/service-push
254+
255+
**Push Service** — Outbound mobile push through APNs and FCM (`push` service).
256+
257+
- **Features**: APNs over HTTP/2 with token-based auth (`.p8` signing key) and FCM over the HTTP v1 API (service account), configured by `PushServiceConfigSchema` with each secret named by `credentialRef`, never inline; backs the messaging `push` channel, which delivers to the recipient's registered devices, carries the notification's `route`, and retires a token the vendor reports dead
258+
- **When to use**: Phone notifications from `notify` flow nodes and `emit()` — compose `new PushServicePlugin({ config })` beside `MessagingServicePlugin`
259+
253260
### @objectstack/service-print
254261

255262
**Print Service** — Prints a record's print page to a PDF in headless Chromium, as the requesting user, and archives it as a file attached to the record (`print` service).

‎content/docs/references/api/push-delivery.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ vendor payload. On FCM (HTTP v1) these members go in `message.data`, a map
1818
of string values. On APNs they sit at the top level of the payload, beside
1919
`aps`, and never under a `data` key.
2020

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.
21+
**Produced by the `push` channel** (`push-channel.ts` in
22+
`@objectstack/service-messaging`), which builds it per delivery and hands it
23+
to the push service's transports (`@objectstack/service-push`). The route
24+
rule below is the contract that channel keeps.
2525

2626
**What `route` is — the inbox's `actionUrl`, derived, never authored.**
2727
There is no `notify` key for it. It is the value the inbox already serves:
@@ -81,7 +81,7 @@ const result = PushDeliveryDataSchema.parse(data);
8181

8282
## PushDeliveryData
8383

84-
[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id, its actionUrl as a same-server route, and the origin of the server that sent it. No push channel delivers yet
84+
The data a push delivery carries to the shell: the notification id, its actionUrl as a same-server route, and the origin of the server that sent it
8585

8686
### Properties
8787

‎content/docs/references/system/push-config.mdx‎

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ description: "Push Service Configuration Protocol — how the push channel reach
99
Push Service Configuration Protocol — how the push channel reaches the
1010
vendors.
1111

12-
**Declared ahead of its reader.** Every schema here carries a
13-
`[PLANNED — not yet read]` marker in its `.describe()`: nothing reads this
14-
shape until the push channel plugin lands, so a value written to it today
15-
changes nothing. The marker comes off with the change that wires the reader.
12+
**Read by `@objectstack/service-push`.** `PushServicePlugin` parses its
13+
`config` option with `PushServiceConfigSchema` when it initialises:
14+
for the direct transport it builds one vendor transport per block, and it
15+
refuses the relay transport, not yet served (#29920). The `push` channel in
16+
`@objectstack/service-messaging` delivers through the push service it
17+
registers.
1618

1719
**Two transports, and the hosted relay is the default.** The store-published
1820
ObjectOS app is signed with ObjectStack's own APNs key and Firebase project,
@@ -41,28 +43,35 @@ credential (a vendor block, or a key an operator would reach for), and a
4143
direct block with no vendor block, which names no `credentialRef` and so
4244
could deliver to nothing.
4345

46+
**What is served today.** The direct transport is: it ships as
47+
`@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041). The
48+
relay transport is not yet: the relay sender is follow-up card #29920, and
49+
until it ships `PushServicePlugin` refuses a relay block, the default `{}`
50+
included, when it initialises, so a server is never left believing it
51+
pushes when nothing would deliver.
52+
4453
It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the
4554
declaration of the email channel's transport selection.
4655

4756
**Secrets are references, never values (ADR-0143 D3, ADR-0097 §3).** Each
4857
direct vendor block has one secret — the APNs `.p8` signing key and the FCM
4958
service-account JSON — and each is named by a `credentialRef`, the same key
5059
every other secret an author names is written under (ADR-0143 D3), which
51-
the push channel resolves through the secrets layer when it sends (in the
52-
open tier, the name of an environment variable). An inline secret is
60+
the push transport resolves through the secrets layer when it first signs
61+
(in the open tier, the name of an environment variable). An inline secret is
5362
refused two ways: written under a key an operator would reach for (`key`,
5463
`privateKey`, `serviceAccount`, …), it is refused by name with
5564
`credentialRef` as the prescription; written into the reference itself, it
5665
is refused because a reference is one token — a PEM block carries
5766
whitespace and a JSON document carries braces, and a name carries neither.
5867

59-
**What reads it, and why it is not a stack key yet.** The push channel
60-
plugin (`plugin-notification-push`, ADR-0012 §11) is its one reader, and it
61-
is a later change than this declaration. The email transport selection it
62-
sits beside is read from `config.email` of the stack definition
63-
(`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape gets its own
64-
configuration door in the change that ships the reader, not here, so a key
65-
is never authorable before something honours it.
68+
**What reads it, and why it is not a stack key.** `PushServicePlugin`
69+
(`@objectstack/service-push`) is its one reader, through the plugin's
70+
constructor: the host that composes the plugin hands it the value. The email
71+
transport selection it sits beside is read from `config.email` of the stack
72+
definition (`StackEmailConfigSchema`) and `OS_EMAIL_*`; this shape has no
73+
stack-definition key and no `os serve` capability yet, so it is authorable
74+
only where a host composes the plugin.
6675

6776
Out of scope here: the other vendors ADR-0012 §6 routes to alternate
6877
channel plugins (HMS, Xiaomi, OPPO, vivo).
@@ -85,7 +94,7 @@ const result = ApnsTransportConfigSchema.parse(data);
8594

8695
## ApnsTransportConfig
8796

88-
[PLANNED — not yet read] APNs transport with token-based auth (.p8 signing key by reference)
97+
APNs transport with token-based auth (.p8 signing key by reference)
8998

9099
### Properties
91100

@@ -102,7 +111,7 @@ const result = ApnsTransportConfigSchema.parse(data);
102111

103112
## FcmTransportConfig
104113

105-
[PLANNED — not yet read] FCM HTTP v1 transport (service-account JSON by reference)
114+
FCM HTTP v1 transport (service-account JSON by reference)
106115

107116
### Properties
108117

@@ -116,7 +125,7 @@ const result = ApnsTransportConfigSchema.parse(data);
116125

117126
## PushDirectTransportConfig
118127

119-
[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
128+
Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference; read by the push service
120129

121130
### Properties
122131

@@ -148,7 +157,7 @@ const result = ApnsTransportConfigSchema.parse(data);
148157

149158
## PushRelayTransportConfig
150159

151-
[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none
160+
[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. The push service refuses it at boot until the relay sender ships
152161

153162
### Properties
154163

@@ -162,15 +171,15 @@ const result = ApnsTransportConfigSchema.parse(data);
162171

163172
## PushServiceConfig
164173

165-
[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
174+
Push channel transport configuration: the hosted relay (the default) or direct delivery to apns and fcm for a white-label build. The push service reads it: direct drives the vendor transports, and relay is refused at boot until the relay sender ships
166175

167176
### Union Options
168177

169178
This schema accepts one of the following structures:
170179

171180
#### Option 1
172181

173-
[PLANNED — not yet read] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none
182+
[PLANNED — not yet served] The hosted push relay transport (the default): authenticated by the server cloud binding credential, so it carries none. The push service refuses it at boot until the relay sender ships
174183

175184
### Properties
176185

@@ -183,7 +192,7 @@ This schema accepts one of the following structures:
183192

184193
#### Option 2
185194

186-
[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
195+
Direct push transport for a white-label build: one block per vendor (apns, fcm), at least one, each naming its credential by reference; read by the push service
187196

188197
### Properties
189198

‎docs/adr/0012-notification-platform.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox
22

3-
**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model.
3+
**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041)**: push ships as `@objectstack/service-push` (the direct APNs and FCM transports, the kernel's `push` service; the hosted relay, the default transport per cloud#3519, is not yet served, #29920) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. ADR-0030 is unchanged.
44
**Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel")
55
**Consumers**: `@objectstack/spec` (new `messaging/` domain), `@objectstack/service-messaging` (new), `@objectstack/plugin-notification-inbox` (new), `@objectstack/plugin-notification-email` (replaces `plugin-email`), `@objectstack/plugin-notification-webhook` (extracted from `plugin-webhooks`), `@objectstack/plugin-notification-push` (new), every flow that has a `notify` node, every template that ships notification rules
66
**Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md)
@@ -306,7 +306,7 @@ These cost ~5 columns and one empty table in M1. They are non-negotiable: introd
306306
| `inbox` | `plugin-notification-inbox` | Writes to `sys_inbox_message`; fires `service-realtime` event for online users | **MUST ship M1** |
307307
| `email` | `plugin-notification-email` (replaces `plugin-email`) | SMTP + transport plugins (SendGrid baseline) | **MUST ship M1** |
308308
| `webhook` | `plugin-notification-webhook` (extracted from `plugin-webhooks`) | HTTP POST with HMAC | **MUST ship M1** (port existing) |
309-
| `push` | `plugin-notification-push` | APNs (HTTP/2) + FCM (HTTP v1) | **MUST ship M1** |
309+
| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 | Direct: APNs (HTTP/2, token auth) + FCM (HTTP v1) with the operator's own credentials, for a white-label build. The hosted relay is the default transport (cloud#3519) and is not yet served (#29920) | Direct shipped (#29041); relay not yet served (#29920) |
310310

311311
All four bind into `channel-registry` during `onEnable`.
312312

@@ -375,6 +375,7 @@ Lifecycle hooks:
375375

376376
* Client SDK calls `POST /api/v1/notifications/devices` on login → `device-registry.upsert(...)`.
377377
* APNs / FCM `BadDeviceToken` / `Unregistered` response → channel returns `classifyError = 'invalid_recipient'` → dispatcher calls `device-registry.invalidate(token)` (sets `invalidated_at`, `enabled=false`) without dead-lettering the notification (it just routes around the dead token).
378+
> Amended (2026-11-03, #29041): the push channel (`push-channel.ts` in service-messaging) retires the dead token itself, through `INotificationService.invalidateDeviceToken`, because one delivery fans out to all of the recipient's devices and only the channel knows which token died; the other devices still get the push. The delivery is graded `invalid_recipient` (suppressed, not dead-lettered) only when every device was reported dead. The shipped device shape is `UserDeviceSchema` in `packages/spec/src/system/push-device.zod.ts`, narrowed to `apns` | `fcm` and `ios` | `android` | `web`. `service-push` ships the direct transport; the hosted relay, the default per cloud#3519, is not yet served (#29920), and `PushServicePlugin` refuses a relay configuration at init until it is.
378379
* Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (`plugin-notification-push-hms` etc.), not as transports — their on-device SDKs and quotas are too different to share a code path.
379380

380381
### 7. Topic + Preference matrix

‎packages/services/service-automation/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"@objectstack/refd-timer-testkit": "workspace:*",
4646
"@objectstack/service-job": "workspace:*",
4747
"@objectstack/service-messaging": "workspace:*",
48+
"@objectstack/service-push": "workspace:*",
4849
"@types/node": "^26.6.5",
4950
"tsx": "^4.23.15",
5051
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)