Repository navigation
Commit 8aa2e91
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
File tree
- .changeset
- content/docs
- plugins
- references
- api
- system
- docs/adr
- packages
- services
- service-automation
- src/builtin
- service-messaging/src
- objects
- service-push
- src
- transports
- spec
- src
- api
- contracts
- system
- scripts
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| 52 | + | |
52 | 53 | | |
53 | 54 | | |
54 | 55 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
416 | 416 | | |
417 | 417 | | |
418 | 418 | | |
| 419 | + | |
419 | 420 | | |
420 | 421 | | |
421 | 422 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
18 | | - | |
| 18 | + | |
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
| |||
240 | 240 | | |
241 | 241 | | |
242 | 242 | | |
243 | | - | |
| 243 | + | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
| |||
250 | 250 | | |
251 | 251 | | |
252 | 252 | | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
253 | 260 | | |
254 | 261 | | |
255 | 262 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
81 | 81 | | |
82 | 82 | | |
83 | 83 | | |
84 | | - | |
| 84 | + | |
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
16 | 18 | | |
17 | 19 | | |
18 | 20 | | |
| |||
41 | 43 | | |
42 | 44 | | |
43 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
44 | 53 | | |
45 | 54 | | |
46 | 55 | | |
47 | 56 | | |
48 | 57 | | |
49 | 58 | | |
50 | 59 | | |
51 | | - | |
52 | | - | |
| 60 | + | |
| 61 | + | |
53 | 62 | | |
54 | 63 | | |
55 | 64 | | |
56 | 65 | | |
57 | 66 | | |
58 | 67 | | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
66 | 75 | | |
67 | 76 | | |
68 | 77 | | |
| |||
85 | 94 | | |
86 | 95 | | |
87 | 96 | | |
88 | | - | |
| 97 | + | |
89 | 98 | | |
90 | 99 | | |
91 | 100 | | |
| |||
102 | 111 | | |
103 | 112 | | |
104 | 113 | | |
105 | | - | |
| 114 | + | |
106 | 115 | | |
107 | 116 | | |
108 | 117 | | |
| |||
116 | 125 | | |
117 | 126 | | |
118 | 127 | | |
119 | | - | |
| 128 | + | |
120 | 129 | | |
121 | 130 | | |
122 | 131 | | |
| |||
148 | 157 | | |
149 | 158 | | |
150 | 159 | | |
151 | | - | |
| 160 | + | |
152 | 161 | | |
153 | 162 | | |
154 | 163 | | |
| |||
162 | 171 | | |
163 | 172 | | |
164 | 173 | | |
165 | | - | |
| 174 | + | |
166 | 175 | | |
167 | 176 | | |
168 | 177 | | |
169 | 178 | | |
170 | 179 | | |
171 | 180 | | |
172 | 181 | | |
173 | | - | |
| 182 | + | |
174 | 183 | | |
175 | 184 | | |
176 | 185 | | |
| |||
183 | 192 | | |
184 | 193 | | |
185 | 194 | | |
186 | | - | |
| 195 | + | |
187 | 196 | | |
188 | 197 | | |
189 | 198 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
306 | 306 | | |
307 | 307 | | |
308 | 308 | | |
309 | | - | |
| 309 | + | |
310 | 310 | | |
311 | 311 | | |
312 | 312 | | |
| |||
375 | 375 | | |
376 | 376 | | |
377 | 377 | | |
| 378 | + | |
378 | 379 | | |
379 | 380 | | |
380 | 381 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
0 commit comments