Skip to content

Commit 9ebca94

Browse files
feat(push): requires push and the stack push key mount PushServicePlugin under os serve (#29968)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBY61hHdLdFdzwXbxaNtv58y)_ Fixes #29820 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up owed — #29969 (skills/objectstack-platform/evals/config-plugins-ops.json: no eval asks for `requires: ['push']` or the `push` key); this diff teaches both in `skills/objectstack-platform/SKILL.md` (the top-level key enumeration and the `requires` table) and in the new `content/docs/kernel/runtime-services/push-service.mdx` ## 维护者速读 - **改了什么**:`requires: ['push']` 加上栈定义里新的顶层 `push` 键,`objectstack serve`(和 `@objectstack/verify` 的 `bootStack`)就会挂上 `PushServicePlugin`,不再需要宿主代码;托管 relay(含不写 `push` 键、`push: {}`)与内联密钥在启动时被拒,报错写明原因与改法。 - **为什么改**:此前只有在代码里手动组合插件才有推送服务,按栈文件运行的部署里 `notify` 选 `push` 渠道无处投递;relay 发送端尚未上线,若照常启动会挂上推送渠道、把每条推送都答成已发送而实际什么都没发出。 - **风险与回滚**:纯增量:只有声明 `requires: ['push']` 的栈会走新路径,未声明的启动不变;回滚即回退本 PR。唯一新增的启动失败是"声明了 push 却只配了 relay(或没配)",这是本卡要求的拒绝。 - **先看**:① `packages/services/service-push/src/capability-arg.ts`(relay 与 schema 拒绝及其报错文字)② `packages/core/src/capability-composition.ts` 的 `push` 分支(serve 与 verify 共用的一条规则)③ `skills/objectstack-platform/SKILL.md` 的两处改动(Tier H,受管面)。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project unit src/commands/serve-stack-email-sms-door.contract.test.ts`,预期 push 段全部通过:direct 配置注册出 vendors 为 `['apns']` 的推送服务,无键 / `{}` / relay 三种都以 "its sender has not shipped … transport: 'direct'" 被拒。 - **需要你做的**:本 PR 触及 `skills/**`(Tier H),需要一位 GOVERNED_APPROVERS 维护者批准后才能合并。 ## What changes - **`@objectstack/spec`** - `push` joins `PLATFORM_CAPABILITY_TOKENS`, provided by `@objectstack/service-push`, edition `open`. It is requested, never always-on, because its schema default (the relay) is refused at boot. - The stack definition gains a top-level `push` key (`PushServiceConfigSchema`, secrets by `credentialRef` only) with a `COMPOSE_KEY_DISPOSITIONS` row `single`, classified `deployment` in the unmanaged-install table and listed among the artifact-envelope keys. - `push-config.zod.ts`'s docblock now names the stack key and `objectstack serve` as its reader. Only `PushRelayTransportConfigSchema` keeps `[PLANNED — not yet served]`. - **`@objectstack/core`**: `CAPABILITY_PROVIDERS.push` names `PushServicePlugin`, and `resolveCapabilityArgument('push', …)` reads `resolvePushCapabilityArg` off the provider module, as the `email` and `sms` readers are read. One rule serves both `objectstack serve` and `bootStack`. - **`@objectstack/service-push`**: `resolvePushCapabilityArg(stack.push)` parses the key and throws, before the kernel boots: - on the relay (absent key, `null`, `{}`, `transport: 'relay'`): `PUSH_RELAY_NOT_SERVED_MESSAGE` (names the missing relay sender, prescribes `transport: 'direct'` with an `apns` and/or `fcm` block), plus where the key is written on this boot path; - on anything the schema refuses, an inline secret first among them: each path and the schema's guidance, never the value. A block with no `transport` has its `invalid_union` opened to the member the schema's own rule picks, so the guidance is not lost to "Invalid input". - On a stack that requires `push`, either refusal fails the boot as `Capability "push" (@objectstack/service-push) failed to start: …`. - **`@objectstack/cli`, `@objectstack/verify`**: depend on `@objectstack/service-push`, so the provider the open-edition token names resolves out of the box. The cli's vitest config gains the anchored source alias. - **Docs**: a new `services.push` page (`content/docs/kernel/runtime-services/push-service.mdx`): the two vendors, credential references, the relay's not-served status and its boot message, the device routes, dead-token retirement and the grading table; index rows in the runtime-services index, the kernel index and the versioning matrix. The generated `push-config` reference is regenerated. - **Skill** (`skills/objectstack-platform/SKILL.md`, Tier H): `push` is added to the top-level key enumeration (the required gate `check:skill-top-level-keys` refuses the diff without it) and to the `requires` table. The new tokens are paid for in the same file by deleting a stale path and count ("registry in `packages/cli/src/commands/serve.ts` — its 19 opt-in entries" becomes "registry in `@objectstack/core`"; the registry moved to core and holds more than 19 entries). - File: 429 → 429 lines, 6011 → 6004 tokens (ceiling 6007 before this diff's deletion paid it). - Bundle (every `SKILL.md`): 4539 → 4539 lines; whole shipped tree 224969 tokens after, 7 fewer than before (this is the only `skills/**` file touched). ## Why Ruling C on #29820: the token, the key and the `os serve` reader land together, so no released version has a `push` token that boots to nothing or a key nothing reads. The relay is refused rather than served because a served relay would mount the messaging `push` channel and answer every push `sent` while nothing left the server. A token with no `CAPABILITY_PROVIDERS` row is not dropped silently: a new reverse pin asserts every open-edition token has a serve mount. ## Tests - `packages/spec/src/kernel/platform-capabilities.test.ts`: the token, its package and edition, not always-on, `classify` installable. - `packages/spec/src/stack-email-sms.test.ts`: the key: a direct block is carried through; a vendor block without `transport` reads as direct; relay, `{}` and absence are accepted at authoring; verdict parity with `PushServiceConfigSchema`; disposition `single`; an inline `key` refused at `['push','apns']` with `STACK_SCHEMA_INVALID`/422, value not echoed; a secret pasted into `credentialRef` refused; compose names `push`. - `packages/services/service-push/src/capability-arg.test.ts` (new): the direct argument constructs a plugin that registers `push`; absent / `null` / `{}` / relay / staging relay are refused with the relay message; inline APNs key, PEM in `credentialRef`, a relay credential and a vendor-less direct block are refused with their guidance and no echoed value. - `packages/core/src/capability-composition.test.ts`: the reader is called with `stack.push` only (`{}` when absent); its refusal propagates; a provider module without the export is refused by name. - `packages/cli/src/commands/serve-stack-email-sms-door.contract.test.ts`: serve composition: the table row; `resolveServedCapabilities(['push'])` contains push and `([])` does not; a direct block reaches the plugin and init registers vendors `['apns']`; no key / `{}` / relay refused with the relay message; a raw-config inline `serviceAccount` refused, value not echoed. - `packages/cli/test/serve-capability-vocabulary.test.ts`: new reverse pin, every open-edition token has a serve mount (a provider entry or a tier); `serve-capability-identity.test.ts` gains the push plugin name; `option-b-reader-acceptance.pin.test.ts` lists `push` among envelope keys. - `packages/verify/src/harness.served-composition.test.ts`: `bootStack` mounts the push plugin from a direct `fcm` block; a plain boot has none; `requires: ['push']` with no key rejects with the relay refusal. ## Acceptance notes - ADR-0012 is not edited (#29817 owns it). - The relay sender itself is out of scope (#29920). - Not in this diff: the platform skill's `requires` table already omitted `mailbox` and `package-registry` before this change; noted for the skills lane, not fixed here. ## Verification <details><summary>Local test runs (all under scripts/pm/os-verify-lock.sh)</summary> - Full build `pnpm exec turbo run build --concurrency=2`: 120/120 tasks, VERDICT command-exit 0; `@objectstack/spec` rebuilt again after the last spec src change, exit 0. - `pnpm --filter @objectstack/spec test`: 808/808 files, 24288 passed, 1 todo. `pnpm --filter @objectstack/spec typecheck`: exit 0. - `pnpm --filter @objectstack/spec test:repo`: 89/89 files, 1524/1524 tests. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 377/377 files, 5316/5316 tests. cli typecheck exit 0. The integration tier was not run locally: the diff touches no spawn entry, so CI runs it. - `pnpm --filter @objectstack/core test` and typecheck: exit 0. `pnpm --filter @objectstack/service-push test` and typecheck: exit 0. `pnpm --filter @objectstack/verify test`: 48/48 files, 408/408 tests; typecheck exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: 311 passed, 4 skipped files; 22320 passed, 26 skipped tests. - ESLint (`--no-inline-config`) over the 19 changed `.ts`/`.mjs` files: exit 0. CI runs the full `pnpm lint`. </details> <details><summary>Ablation: the relay boot refusal</summary> `node scripts/ablation-replace.mjs` turned `if (config.transport !== 'direct') {` in `packages/services/service-push/src/capability-arg.ts` into `if (false /* ablated-29820 */) {` (anchor count 1 → 0, blob 8638e28 → 2c1a8ad32f78). - Ablated: `src/capability-arg.test.ts` 5 failed / 7 passed (all five relay rows: absent, `null`, `{}`, `transport: 'relay'`, staging endpoint); cli `serve-stack-email-sms-door.contract.test.ts` 3 failed / 7 passed (no key, `{}`, relay). - Restored: blob back to HEAD 8638e28, `git diff HEAD` empty; the same two files 12/12 and 10/10. </details> <details><summary>Gates: dispatch-gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands`: 142 commands on the final tree, each run and its exit code recorded; all 142 exit 0. - `--ran`: "142 derived famil(ies) accounted for — 142 run, 0 NOT-MEASURED". - Four gates were red on the first pass and are fixed in this diff: `check:skill-top-level-keys` (push missing from the skill's key list), `check:test-source-alias` (cli vitest alias), `check:docs-audit-scope` (the new page in the audit scope), and `check-plugin-teardown-shape --self-test` (exit 3 from a shallow clone; green after fetching its base commit). - `pnpm --filter @objectstack/spec run check:generated`: exit 0. - `check-changeset-no-major` with this body: exit 0 ("no `major` bump"; LEVEL AXIS ✓ for `yes (widening)` and `added`; no moved package graded `patch`). Control: the same diff declared `Metadata-protocol: none` exits 1, refused only because the entries are graded `minor`. </details> ## 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. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. ESLint over the 19 changed files passes; the full `pnpm lint` is left to CI.) - [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 with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzgjFwEiezTWdb7bk9i <!-- signed: domain:spec#2 · os-support-ai · session_01WGufzgjFwEiezTWdb7bk9i --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 237f513 commit 9ebca94

34 files changed

Lines changed: 873 additions & 50 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/core': minor
4+
'@objectstack/service-push': minor
5+
'@objectstack/cli': minor
6+
'@objectstack/verify': minor
7+
---
8+
9+
feat(push): `requires: ['push']` and the stack's `push` key turn mobile push on under `objectstack serve`, and a relay configuration is refused at boot
10+
11+
Clause-②: yes (widening)
12+
Metadata-protocol: added
13+
Skills-docs: follow-up owed — #29969 (skills/objectstack-platform/evals/config-plugins-ops.json: no eval asks for `requires: ['push']` or the `push` key); this diff teaches both in `skills/objectstack-platform/SKILL.md` (the top-level key enumeration and the `requires` table) and in the new `content/docs/kernel/runtime-services/push-service.mdx`
14+
15+
A deployment that runs `objectstack serve` from a stack file can now turn on mobile push without host code. Before this, the only way to register a push service was a host that composed `PushServicePlugin` in code, so a `notify` node naming `push` had nothing to deliver through on a served stack.
16+
17+
- **`@objectstack/spec`: the `push` capability token and the `push` stack key.** `push` joins `PLATFORM_CAPABILITY_TOKENS`, provided by `@objectstack/service-push` in the open edition. It is requested, never on the always-on slate. The stack definition gains a top-level `push` key beside `email` and `sms`: it is `PushServiceConfigSchema`, composed as a single value (`COMPOSE_KEY_DISPOSITIONS`). Authoring accepts a direct block, a relay block and absence; it refuses an inline secret, by key name (`key`, `privateKey`, `serviceAccount`, …) with `credentialRef` as the remedy, or pasted into a `credentialRef` (ADR-0143 D3). `PushServiceConfigSchema`'s docblock now names the stack key and `objectstack serve` as its reader; `PushRelayTransportConfigSchema` keeps its `[PLANNED — not yet served]` marker.
18+
- **`@objectstack/core`: the `push` provider row and its argument.** `CAPABILITY_PROVIDERS.push` names `PushServicePlugin`, and `resolveCapabilityArgument('push', …)` builds its argument from the stack's `push` key through `resolvePushCapabilityArg`, read off the provider module as the `email` and `sms` readers are. Both `objectstack serve` and `@objectstack/verify`'s `bootStack` read this table, so an app's tests get the push service its server gets.
19+
- **`@objectstack/service-push`: `resolvePushCapabilityArg`.** It parses the `push` key with `PushServiceConfigSchema` and refuses, before the kernel boots, two configurations: the relay transport (an absent key and `push: {}` read as it), whose sender has not shipped, with a message that says so and prescribes `transport: 'direct'` with an `apns` and/or `fcm` block; and anything the schema refuses, an inline secret first among them, naming each path and the schema's guidance and never the value. On a stack that declares `requires: ['push']` either refusal fails the boot as `Capability "push" (@objectstack/service-push) failed to start: …`.
20+
- **`@objectstack/cli` and `@objectstack/verify`** depend on `@objectstack/service-push`, so the provider the token names resolves out of the box, as the open edition promises.
21+
22+
To turn push on, add `requires: ['push']` and a direct block naming each vendor's secret by reference:
23+
24+
```ts
25+
push: {
26+
transport: 'direct',
27+
apns: { teamId: 'ABCDE12345', keyId: 'KEY1234567', topic: 'com.example.app', environment: 'production', credentialRef: 'APNS_SIGNING_KEY' },
28+
fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' },
29+
},
30+
```
31+
32+
In the open tier each `credentialRef` is the name of an environment variable holding the secret. A `push` key on a stack whose `requires` does not name `push` is not read, and `objectstack serve` logs one boot warning saying so. The runtime-services chapter has a new `services.push` page.

‎content/docs/getting-started/quick-start.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,10 +232,11 @@ schema page is the authority until one lands.
232232
**Not in the table, and why.** `viewItems` and `runtimeModule` are not authorable
233233
at all (`viewItems` is `z.never()`, the machine-assembled channel for
234234
runtime-assembled manifests; `runtimeModule` is written by `objectstack build`);
235-
`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `requires`,
235+
`manifest`, `i18n`, `api`, `server`, `appName`, `email`, `sms`, `push`, `requires`,
236236
`tiers`, `plugins`, `devPlugins`, `devHint`, `devLogins` and `demoStaff` configure
237237
the stack rather than declare metadata items (`email` and `sms` carry no
238-
credentials — those stay in the environment or in Settings; `demoStaff` names the
238+
credentials — those stay in the environment or in Settings; `push` names each
239+
vendor secret by `credentialRef` and is read only when `requires` names `push`; `demoStaff` names the
239240
demo accounts a local `os dev` boot creates, and `os build` leaves it out of
240241
`objectstack.json`); and
241242
`functions` and `onEnable` are code the runtime calls, not metadata it stores.

‎content/docs/kernel/index.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The kernel is ObjectStack's runtime: it loads your metadata artifact, hosts plug
1818
| [`services.sharing`](/docs/kernel/runtime-services/sharing-service) | stable | `buildReadFilter`, `canEdit`, `canDelete`, `canManageShares`, `grant`/`revoke`, `listShares` |
1919
| [`services.email`](/docs/kernel/runtime-services/email-service) | stable | `send`, `sendTemplate` |
2020
| [`services.sms`](/docs/kernel/runtime-services/sms-service) | stable | Outbound SMS delivery through pluggable providers (Aliyun SMS, Twilio) |
21+
| [`services.push`](/docs/kernel/runtime-services/push-service) | experimental | Mobile push to APNs and FCM, turned on by `requires: ['push']` |
2122
| [`services.queue`](/docs/kernel/runtime-services/queue-service) | stable | Background work and queues |
2223
| [`services.settings`](/docs/kernel/runtime-services/settings-service) | stable | App/environment settings |
2324
| [`services.storage`](/docs/kernel/runtime-services/storage-service) | stable | File storage |

‎content/docs/kernel/runtime-services/index.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ This chapter documents the runtime `services.*` APIs used in hook/action/flow/pl
3030
- `services.queue`
3131
- `services.email`
3232
- `services.sms`
33+
- `services.push`
3334
- `services.settings`
3435
- `services.storage`
3536

@@ -56,6 +57,7 @@ Each page links the canonical TypeScript source used to derive signatures.
5657
- Queue: `packages/spec/src/contracts/queue-service.ts`
5758
- Email: `packages/spec/src/contracts/email-service.ts`
5859
- SMS: `packages/spec/src/contracts/sms-service.ts`
60+
- Push: `packages/services/service-push/src/push-service.ts`
5961
- Storage: `packages/spec/src/contracts/storage-service.ts`
6062
- Settings: `packages/services/service-settings/src/settings-service.ts`
6163
- Audit: `packages/plugins/plugin-audit/src/auth-event-audit.ts`

‎content/docs/kernel/runtime-services/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"queue-service",
88
"email-service",
99
"sms-service",
10+
"push-service",
1011
"settings-service",
1112
"storage-service",
1213
"examples",
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
---
2+
title: services.push — the mobile push service API
3+
navTitle: services.push
4+
description: Mobile push delivery to APNs and FCM, configured from the stack's push key with credentials by reference.
5+
---
6+
7+
- **Stability:** `experimental`
8+
- **Canonical source:** `packages/services/service-push/src/push-service.ts`
9+
- **Registry slot:** `push` — resolve with `ctx.getService('push')`.
10+
11+
## Methods
12+
13+
```ts
14+
services.push.vendors(): PushVendor[]
15+
services.push.send(message: PushMessage): Promise<PushSendResult>
16+
```
17+
18+
`PushMessage` is one push to one device: the `vendor` the token belongs to
19+
(`apns` or `fcm`), the device `token`, a plain-text `title` and `body`, and
20+
`data`, the delivery data the app shell reads (string values only, because FCM
21+
accepts only strings there).
22+
23+
`vendors()` lists the vendors this deployment has a transport for. An empty list
24+
means push delivers nothing.
25+
26+
Application code rarely calls this service directly. A `notify` flow node or a
27+
`NotificationService.emit` that names the `push` channel goes through the
28+
messaging service, whose `push` channel reads the recipient's devices and sends
29+
through this service.
30+
31+
## Returns
32+
33+
`PushSendResult`, one of:
34+
35+
- `{ status: 'sent', messageId? }`: the vendor accepted the push. `messageId`
36+
is APNs' `apns-id` or FCM's message `name`.
37+
- `{ status: 'invalid_token', reason }`: the vendor reported the token dead
38+
(APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`).
39+
- `{ status: 'failed', error, retryable }`: anything else. `retryable` is true
40+
for a timeout, a 429 or a 5xx, and false for a refused credential or payload.
41+
42+
`send()` never throws: a transport error is answered `failed`.
43+
44+
## Turning it on
45+
46+
Push is a requested capability, never on by default. A stack turns it on with
47+
`requires: ['push']` and configures it with the top-level `push` key, which is
48+
`PushServiceConfigSchema`:
49+
50+
```ts
51+
import { defineStack } from '@objectstack/spec';
52+
53+
export default defineStack({
54+
manifest: { /* … */ },
55+
requires: ['push'],
56+
push: {
57+
transport: 'direct',
58+
apns: {
59+
teamId: 'ABCDE12345',
60+
keyId: 'KEY1234567',
61+
topic: 'com.example.app',
62+
environment: 'production',
63+
credentialRef: 'APNS_SIGNING_KEY',
64+
},
65+
fcm: { projectId: 'example-app', credentialRef: 'FCM_SERVICE_ACCOUNT' },
66+
},
67+
});
68+
```
69+
70+
A `push` key without `push` in `requires` is not read: `objectstack serve` logs
71+
one boot warning naming the key and the fix. With the token, `objectstack serve`
72+
mounts `PushServicePlugin` from
73+
`@objectstack/service-push`, which the CLI ships with. `@objectstack/verify`'s
74+
`bootStack` mounts it the same way, so an app's tests run with the same push
75+
service its server gets.
76+
77+
### The two vendors
78+
79+
- **APNs** (Apple Push Notification service), token-based authentication: the
80+
Apple Developer `teamId`, the auth key's `keyId`, the app bundle ID as
81+
`topic`, and the `environment`, `sandbox` for development builds or
82+
`production` for TestFlight and App Store builds.
83+
- **FCM** (Firebase Cloud Messaging) over the HTTP v1 API: the Firebase
84+
`projectId`, authenticated by a service account.
85+
86+
Each vendor block is optional, and at least one is required. A vendor with no
87+
block is a vendor this deployment does not push to; devices registered on it are
88+
skipped. Other Android vendors (HMS, Xiaomi, OPPO, vivo) are not transports of
89+
this service: they plug in as separate channel implementations.
90+
91+
### Credential references
92+
93+
Each vendor has one secret, the APNs `.p8` signing key and the FCM
94+
service-account JSON, and the stack names it by `credentialRef`, never inline.
95+
In the open tier a `credentialRef` is the name of an environment variable, read
96+
when the transport first signs a request. For the example above, set
97+
`APNS_SIGNING_KEY` to the contents of the `.p8` file and `FCM_SERVICE_ACCOUNT` to
98+
the service-account JSON.
99+
100+
The stack definition is compiled into the published artifact, so an inline
101+
secret is refused twice:
102+
103+
- at authoring, by `defineStack` and `os validate`. A key an operator would reach
104+
for (`key`, `privateKey`, `serviceAccount`, …) is refused by name with
105+
`credentialRef` as the remedy, and a PEM block or JSON document pasted into
106+
`credentialRef` itself is refused, because a reference never contains
107+
whitespace or braces;
108+
- at boot, because a compiled artifact or a plain config object can reach
109+
`objectstack serve` without passing `defineStack`. The `push` capability fails
110+
to start, naming each refused path and its remedy. The refusal never repeats
111+
the value.
112+
113+
A `credentialRef` that names an unset variable is not a boot error: the push to
114+
that vendor fails with `credential_unresolved`, naming the reference.
115+
116+
### The hosted relay: not served yet
117+
118+
`PushServiceConfigSchema` has a second transport, `transport: 'relay'`, and it is
119+
the schema's default: a `push` block with no vendor block, `push: {}` and an
120+
absent `push` key all read as the relay. The relay delivers to the
121+
store-published ObjectOS app, whose APNs key and Firebase project stay with
122+
ObjectStack, and authenticates the server by its cloud binding credential.
123+
124+
**Its sender has not shipped.** Until it does, a stack that requires `push` with
125+
a relay configuration (including no `push` key) fails at boot:
126+
127+
```
128+
Capability "push" (@objectstack/service-push) failed to start: PushServicePlugin: the hosted push relay
129+
(transport: 'relay', which is also what an empty push config reads as) is not served yet: its sender has
130+
not shipped, and nothing would deliver through it. …
131+
```
132+
133+
The fix is the direct transport: `transport: 'direct'` with an `apns` and/or
134+
`fcm` block for an app signed with your own APNs key and Firebase project. The
135+
boot refuses rather than serving the relay because a served relay would mount
136+
the push channel and answer every push as sent while nothing left the server.
137+
Authoring still accepts a relay block, so a stack written for the relay does not
138+
need to change when the sender ships.
139+
140+
## Devices
141+
142+
The push channel addresses the recipient's devices in the device registry,
143+
`sys_user_device`, owned by the messaging service. A signed-in app registers
144+
its device and removes it through two routes:
145+
146+
- `POST /api/v1/notifications/devices` registers the current user's device, or
147+
refreshes it when its `deviceId` is already registered
148+
(`RegisterDeviceRequestSchema`: `token`, `platform`, optional `deviceId`,
149+
`vendor`, `appId`, `locale`, `origin`).
150+
- `DELETE /api/v1/notifications/devices/:deviceId` removes one of the current
151+
user's devices. A device the user does not hold answers 404.
152+
153+
### Dead-token retirement
154+
155+
When a vendor answers `invalid_token`, the push channel retires that device: the
156+
row is kept, disabled, with the time it was retired
157+
(`INotificationService.invalidateDeviceToken`). The channel does it, not the
158+
dispatcher, because one notification fans out to every device of the recipient
159+
and only the channel knows which token died. The recipient's other devices
160+
still receive the push.
161+
162+
## How a delivery is graded
163+
164+
The push channel grades one delivery, one recipient user, by what all of that
165+
user's devices answered:
166+
167+
| What happened | Outcome |
168+
|:--|:--|
169+
| at least one device accepted it | delivered; a device that failed beside it is logged, never retried, so the devices that received it are not pushed twice |
170+
| every addressed device was reported dead | `invalid_recipient`: suppressed, not dead-lettered |
171+
| the user has no enabled device | `invalid_recipient` |
172+
| devices exist, but none on a vendor this deployment has a transport for, or the vendor refused the push for a reason no retry repairs | `permanent` |
173+
| a timeout, a 429 or a 5xx with nothing delivered | `retryable` |
174+
| no push service registered | refused `transport_not_configured`, `permanent`, as the email and SMS channels answer it |
175+
176+
## Consumers
177+
178+
- **Messaging `push` channel**: `notify(channels: ['push'])` renders the
179+
notification in each recipient's language and pushes it to their enabled
180+
devices. It is mounted while a `push` service is registered.

‎content/docs/kernel/runtime-services/versioning.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ When a runtime service API changes in a breaking way:
3333
| `services.queue` | `stable` |
3434
| `services.email` | `stable` |
3535
| `services.sms` | `stable` |
36+
| `services.push` | `experimental` |
3637
| `services.settings` | `stable` |
3738
| `services.storage` | `stable` |

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

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ vendors.
1414
for the direct transport it builds one vendor transport per block, and it
1515
refuses the relay transport, not yet served (#29920). The `push` channel in
1616
`@objectstack/service-messaging` delivers through the push service it
17-
registers.
17+
registers. Under `objectstack serve` the value is the stack definition's
18+
`push` key, read when the stack declares `requires: ['push']` (below).
1819

1920
**Two transports, and the hosted relay is the default.** The store-published
2021
ObjectOS app is signed with ObjectStack's own APNs key and Firebase project,
@@ -44,11 +45,13 @@ direct block with no vendor block, which names no `credentialRef` and so
4445
could deliver to nothing.
4546

4647
**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.
48+
`@objectstack/service-push` (ADR-0012, amended 2026-11-03, #29041), and
49+
`objectstack serve` reads it from the stack's `push` key. The relay
50+
transport is not yet: the relay sender is follow-up card #29920, and until
51+
it ships the boot refuses a relay block, the default `{}` included (the
52+
`push` capability's reader before the kernel boots, and `PushServicePlugin`
53+
when it initialises), so a server is never left believing it pushes when
54+
nothing would deliver.
5255

5356
It sits beside `EmailServiceConfigSchema` (`email-config.zod.ts`), the
5457
declaration of the email channel's transport selection.
@@ -65,13 +68,19 @@ refused two ways: written under a key an operator would reach for (`key`,
6568
is refused because a reference is one token — a PEM block carries
6669
whitespace and a JSON document carries braces, and a name carries neither.
6770

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.
71+
**What reads it: the stack's `push` key under `objectstack serve`.** It is
72+
the stack definition's top-level `push` key, beside `email` and `sms`. A
73+
stack that declares `requires: ['push']` has `objectstack serve` (and
74+
`@objectstack/verify`'s `bootStack`) mount `PushServicePlugin`, built by
75+
`resolvePushCapabilityArg` (`@objectstack/service-push`) from that key.
76+
The reader parses the key with this schema again at boot, because a
77+
compiled artifact may not have passed `defineStack`, and it refuses two
78+
things there, failing the boot: a relay block (the default `{}` and an
79+
absent key included), because nothing sends it yet, and an inline secret.
80+
A host that composes the plugin in code hands it the value through the
81+
constructor instead. No `OS_PUSH_*` environment variable carries any of
82+
it: the vendor blocks hold no secret to override, and each `credentialRef`
83+
already names one.
7584

7685
Out of scope here: the other vendors ADR-0012 §6 routes to alternate
7786
channel plugins (HMS, Xiaomi, OPPO, vivo).

‎packages/cli/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"@objectstack/service-mailbox": "workspace:*",
9595
"@objectstack/service-messaging": "workspace:*",
9696
"@objectstack/service-package": "workspace:*",
97+
"@objectstack/service-push": "workspace:*",
9798
"@objectstack/service-queue": "workspace:*",
9899
"@objectstack/service-realtime": "workspace:*",
99100
"@objectstack/service-settings": "workspace:*",

0 commit comments

Comments
 (0)