Skip to content

Commit 97e4249

Browse files
feat(service-push)!: push sends through the hosted push relay, authenticated by the server's cloud binding (#30749)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DXpyUwekyjYswzDhuddMaAP)_ Fixes #29920 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: none — the narrowing is on the server-to-relay wire and the relay's error answer, which no metadata author or skill writes; the operator page `content/docs/kernel/runtime-services/push-service.mdx` is updated here domain:server#5 card thread · session `session_016WV4cznEfnAJ8CyWBdiL2G` · branch `claude/project-thread-yvu7tv` (the card thread's platform-assigned branch, named in claim amendment 6671581752). Resumes os-bill's released WIP (Release 6670797151, `claude/project-thread-tk5wos@634f414`); that work is carried here commit for commit, with one `origin/main` merge and two gate fixes on top. ## 维护者速读 - 改了什么:移动推送默认走云端托管中继——服务器用自己的云绑定凭据把每条推送以「只带 ID」的形式发到 `cloud.objectos.ai/api/v1/cloud/push/send`(或 `OS_CLOUD_URL`、或配置里的 `endpoint`),标题和正文不出服务器;原来这种配置在启动时直接被拒。 - 为什么改:`push: {}` 本来就是规格里的默认(中继),但发送端一直没做,推送等于不能用;按裁决 Q1 A / Q2 A / Q3 A / Q4 C 实现。 - 风险与回滚:BREAKING——中继报文的 `data` 改为严格(多余键被拒而不是被丢),限流错误必须 `retryable: true`,导出 `PUSH_RELAY_NOT_SERVED_MESSAGE` 改名为 `PUSH_RELAY_AIR_GAPPED_MESSAGE`;未绑定的服务器照常启动、推送暂停(走站内信),声明无控制面(`OS_CLOUD_URL=off`)的部署配中继会在启动时拒绝;direct 直连通道不变。回滚即还原本 PR。 - 先看:`packages/services/service-push/src/transports/relay.ts`(发送、401/429/invalid 的映射);`packages/cloud-connection/src/cloud-binding.ts` + `packages/spec/src/contracts/cloud-binding-service.ts`(新的 `cloud-binding` 服务与接口);`packages/spec/src/api/push-relay.zod.ts`(严格 `data`、`retryable`)。 - 两分钟验证:`pnpm --filter @objectstack/service-push exec vitest run src/transports/relay.test.ts src/push-plugin.test.ts`,预期 2 个文件 32 例全绿,其中未绑定时 `vendors()` 为空、401 不重试、`OS_CLOUD_URL=off` 时启动被拒。 - 需要你做的:本 PR 为 BREAKING,入队前需要你对本 PR 的明确同意(席位先做契约档复核)。 ## What changes - **`@objectstack/service-push` sends through the hosted relay** (`transports/relay.ts`, `RelayPushSender` + `relayTransport`). One `PushRelayRequestSchema` POST per push, one delivery per device token, parsed before it leaves the server. The endpoint is the relay block's `endpoint`, else the control-plane base URL plus `PUSH_RELAY_PATH` (`/api/v1/cloud/push/send`, the constant lives here and not in `packages/spec`, ruling Q3 A, comment 6670221824). The bearer is read from the `cloud-binding` service on every send and goes into the `Authorization` header only. - **Answers:** `ok` is sent; `invalid` answers `invalid_token`, so the channel retires the device row as for the direct transports; `retry` is a retryable failure; 401 fails non-retryable, naming the lapsed binding; 429 follows the relay's `retryable` (`RATE_LIMIT_EXCEEDED` retryable, `QUOTA_EXCEEDED` not); 5xx retryable; any other status not. - **Init (ruling Q2 A):** `PushServicePlugin.init` and `resolvePushCapabilityArg` refuse a relay configuration only when the deployment declared it has no control plane (`isControlPlaneDeclined()`), with `PUSH_RELAY_AIR_GAPPED_MESSAGE` naming the in-app inbox and the direct transport. Anywhere else the relay boots, bound or not. While unbound the relay transport is unavailable (`IPushTransport.available()`), `vendors()` lists nothing, the push channel reports `transport_not_configured` through its existing `isAvailable` reading, and the plugin warns once at `kernel:ready`. A bind done while the server runs takes effect without a restart. - **`@objectstack/cloud-connection` registers `cloud-binding`** in `init()` (ruling Q1 A): `ICloudBindingService` (`credential()`, `bound()`, `controlPlaneUrl()`), new in `packages/spec/src/contracts/cloud-binding-service.ts`, additive. Its own `kernel:ready` control-plane routes now read the credential through the same instance, so the credential rule keeps one owner. - **`@objectstack/core`** gains `CLOUD_DECLINED_SPELLINGS`, `isControlPlaneDeclinedSpelling` and `isControlPlaneDeclined` beside `resolveControlPlaneApiKey`; cloud-connection reads them from there and re-exports `isControlPlaneDeclined` unchanged. Moved so service-push can read the air-gap declaration without depending on cloud-connection (which would close a cycle). - **The notice line (ruling Q4 C):** the push channel (`service-messaging/src/push-channel.ts`) picks the generic line by `resolveRecipientLocale` (`sys_user.locale`, then the deployment default, then English) and passes it as `PushMessage.notice`; `messaging-service-plugin.ts` hands the push channel `getData` / `getDefaultTemplateLocale` as it does for sms and email (one line). - **Spec (`push-relay.zod.ts`, `push-config.zod.ts`, `stack.zod.ts`):** the not-served markers flip to served; `PushRelayPayloadSchema.data` is judged strictly (a content key is refused with the ID-only guidance instead of stripped); `RATE_LIMIT_EXCEEDED` carries `retryable: true` (default `true`); the docblock sentence carried in by comment 6606506582 and the `text` describe ("in the recipient's language") are corrected. Regenerated: reference docs, api-surface, export-origins, the strictness ledger counts. - Changeset `.changeset/29920-push-relay-cloud-binding.md`: five packages at `patch` (`Metadata-protocol: none`), `feat(service-push)!`, BREAKING section with the FROM/TO for the retired export, ADR-0087 `not-required (runtime-export-retired …)`. ## Why `PushServiceConfigSchema` reads `{}` as the hosted relay, and until now the boot refused it, so a stack that required `push` with the default configuration could not push at all. The card's ask, as amended by the maintainer's rulings on it (Q1 A, Q2 A, Q3 A, Q4 C; summary 6670237128, correction 6670407970). ## Tests - `service-push/src/transports/relay.test.ts` (new, 13): POST shape and bearer, explicit endpoint wins, English default line, credential read per send, `invalid` / `retry` / 401 / 429 / 5xx mapping, unbound and no-address failures, content key refused before sending, vendors listed only while bound. - `service-push/src/push-plugin.test.ts`: relay boots for no config, `{}`, `transport: 'relay'` and a staging endpoint; bound sends one POST and no log line carries the credential; unbound warns once at `kernel:ready` and the bind takes effect without a restart; `OS_CLOUD_URL=off` refuses each relay form and still boots a direct block. `capability-arg.test.ts` follows the same split. - `cloud-connection/src/cloud-binding.test.ts` (new): the credential rule (service key over the bound `oscc_` bearer, `''` unbound), per-call reads, `controlPlaneUrl()` and the declined spellings, the service registered in `init()`. `core/src/cloud-managed.test.ts`: the declined-spelling reader. - `service-messaging/src/push-channel.test.ts`: zh-CN line for a zh-CN recipient; deployment default then English; a failed locale read never costs the push. - `spec/src/api/push-relay.test.ts`, `system/push-config.test.ts`, `stack-email-sms.test.ts`: served markers, strict `data`, `retryable` pinned on the rate-limit answer. - `cli/src/commands/serve-stack-email-sms-door.contract.test.ts` and `verify/src/harness.served-composition.test.ts`: the served composition boots the relay instead of refusing it. ## Acceptance notes - **Contract review owed on the schema face** before ACCEPT (BREAKING): `packages/spec/src/contracts/cloud-binding-service.ts`, `packages/spec/src/api/push-relay.zod.ts`, `packages/spec/src/system/push-config.zod.ts`, `packages/spec/src/stack.zod.ts`, `packages/core/src/stack-auth.ts`, the `service-push` index exports, `packages/cloud-connection/src/cloud-binding.ts` and the changeset. Queueing needs the maintainer's per-PR word. - **Changeset rewritten in this PR (gate fix, commit 2e35f60).** As released, the marker was red on `check:adr-0087-registration` predicate 3: `runtime-export-retired` reads every FROM line as a retired export, so the package name in parentheses and the two wire lines (`data`, `RATE_LIMIT_EXCEEDED` / `retryable`) were read as unnamed operands. The FROM/TO now names only `PUSH_RELAY_NOT_SERVED_MESSAGE`; the two wire narrowings are stated as plain instructions. No category covers a `*.zod.ts` wire narrowing other than `registered`, and registering one would mean a `packages/spec/src/migrations/` entry outside this card's surface; the marker's reason says why the ledger has nothing to rewrite (no author writes the wire, nothing stores it). The reviewer should confirm that reading. - **Test alias added in this PR (gate fix, commit 1b82707).** `check:test-source-alias` was red: the new relay tests import `@objectstack/spec/api` and the plugin reads `@objectstack/core`, both resolving to `dist/`. Two anchored aliases in `service-push/vitest.config.ts`. - `CLOUD_BINDING_SERVICE` (`'cloud-binding'`) is spelled once in cloud-connection and once in service-push, because service-push does not depend on cloud-connection; the interface lives in spec contracts. Noted, not filed. - When a 200 answer has no result for the delivery's token, the sender reads the first result (`find(…) ?? results[0]`); with one delivery per request the two coincide. Noted, not filed. - Docs: `content/docs/kernel/runtime-services/push-service.mdx` (hand-written operator page) is updated for the served relay; it sits outside the claim's listed file surface, carried from the released branch. ADR-0012 §6.1's "device locale" wording stays for #30670. - A deployment with `OS_CLOUD_URL` unset but the cloud-connection plugin's own `controlPlaneUrl` option set to `off` boots the relay (the init check reads the environment) and every send then fails `relay_address_not_published`. Noted, not filed. ## Verification Readings against objectstack-ai/objectstack. Code head `2d054bbe` (the `origin/main` merge at `4bf9a2d7`) for the package suites, ablations and dogfood; the two later commits change only the changeset text (`2e35f600`) and the service-push vitest alias (`1b82707d`), and the service-push suite and the derived gates were re-run on `1b82707d`. <details><summary>Package suites and typecheck (os-verify-lock, after the dependency-closure build)</summary> - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/serve-stack-email-sms-door.contract.test.ts`: 1 file, 10 passed. - `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2 src/harness.served-composition.test.ts`: 1 file, 10 passed. - `pnpm --filter @objectstack/spec test` (full suite): 817 files, 25796 passed, 1 todo. - `pnpm --filter @objectstack/service-push test`: 5 files, 74 passed (re-run on `1b82707d` with the source aliases: 74 passed). - `pnpm --filter @objectstack/service-messaging test`: 66 files, 849 passed. - `pnpm --filter @objectstack/cloud-connection test`: 71 files, 896 passed. - `pnpm --filter @objectstack/core test`: 136 files, 3087 passed. - `typecheck` exit 0 for spec, core, cloud-connection, service-push (also on `1b82707d`), service-messaging, cli, verify. - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: OK, 11 anchors judged, 0 lost. - `pnpm lint` (eslint . --no-inline-config): exit 0 at `2e35f600`; the one file `1b82707d` adds lines to, `service-push/vitest.config.ts`, re-linted: exit 0. - Each lock run's VERDICT line read command-exit 0. </details> <details><summary>Ablations (scripts/ablation-replace.mjs; each restored to blob == HEAD, git diff HEAD empty)</summary> Seven legs at `2d054bbe`; each red where expected, then restored (blob == HEAD). - A1 401 answered `retryable: true`: 1 failed / 12 passed (relay.test, the 401 case). - A2 `invalid` no longer maps to `invalid_token`: 1 failed / 12 passed (the dead-token case). - A3 unbound guard removed (`if (!credential)` made false): 2 failed / 11 passed (unbound send; bind-at-runtime). - A4 init air-gap refusal removed in `PushServicePlugin.init`: 4 failed / 15 passed (each relay form under `OS_CLOUD_URL=off`). - A5 `vendors()` ignores `available()`: 6 failed / 26 passed (push-plugin + relay.test, every unbound case). - A6 `PushRelayPayloadSchema.data` back to the stripping `PushDeliveryDataSchema`: 1 failed / 39 passed (spec push-relay.test, the strict-data case). - A7 the notice line ignores the recipient locale (Q4 C): 2 failed / 17 passed (push-channel.test, zh-CN line; default chain). - The service-push tests import the mutated sources relatively, and the spec test reads spec source, so no leg needed a rebuild. Tree clean after the last leg (`git status --porcelain` empty). </details> <details><summary>Full dogfood (BREAKING)</summary> - `pnpm --filter @objectstack/dogfood test` under `os-verify-lock` at `2d054bbe` (no `OS_TEST_SHARD`, no path), after the dogfood dependency-closure build: Test Files 421 passed, 3 skipped (424); Tests 3513 passed, 36 skipped (3549); VERDICT command-exit 0. No failures. - The two commits after it touch no code a dogfood test loads (changeset text; a vitest alias in service-push's own config). </details> <details><summary>Derived gates (dispatch-gates --commands)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 129 families derived; the list is identical at `2d054bbe` and `1b82707d`. - First pass, at `2d054bbe`: two real reds, both fixed in this PR — `check-adr-0087-registration` (the changeset, see Acceptance notes) and `check:test-source-alias` (service-push aliases). Three exited 3 (prerequisite): `check-plugin-teardown-shape --self-test` (pinned fixture commit fetched), `check:skill-examples` and `check:dual-build-cjs-loads` (whole-tree `pnpm build`, 118 tasks green). - Second pass, at `1b82707d`, after the whole-tree build: all 129 exit 0. - `--ran` reconciliation at `1b82707d`: 129 derived, 129 run, 0 NOT-MEASURED, 0 UNRUN. - `check:commit-card-trailers` passed on each push. - Families CI runs beyond the derived set: read from this PR's check-runs. </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. - [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/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cznEfnAJ8CyWBdiL2G)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d996c38 commit 97e4249

38 files changed

Lines changed: 1411 additions & 229 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/service-push': patch
3+
'@objectstack/cloud-connection': patch
4+
'@objectstack/service-messaging': patch
5+
'@objectstack/spec': patch
6+
'@objectstack/core': patch
7+
---
8+
9+
feat(service-push)!: push sends through the hosted push relay, authenticated by the server's cloud binding
10+
11+
Clause-②: yes (narrowing) — the relay transport (`transport: 'relay'`, and the default `{}`), refused at init until now, is served; the relay wire narrows: `PushRelayPayloadSchema.data` refuses a key `PushDeliveryDataSchema` does not declare instead of stripping it, and a `RATE_LIMIT_EXCEEDED` relay error must not answer `retryable: false`; `PUSH_RELAY_NOT_SERVED_MESSAGE` leaves `@objectstack/service-push`.
12+
Metadata-protocol: none
13+
14+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/service-push#PUSH_RELAY_NOT_SERVED_MESSAGE) the retired export is the text of an init refusal that no longer happens; no packages/spec schema, registered node type, ADR-0087 conversion surface or stored shape names it. The two wire narrowings are on the server-to-relay request and the relay's error answer, which no metadata author writes and nothing stores, so there is nothing for objectstack migrate meta to rewrite -->
15+
16+
**The hosted push relay is served.** A stack that requires `push` with a relay configuration (`transport: 'relay'`, `push: {}`, or no `push` key) now boots and sends each push to the cloud's push relay at `/api/v1/cloud/push/send` under the control-plane URL (`OS_CLOUD_URL`, else `https://cloud.objectos.ai`); a relay block's `endpoint` overrides it. Each request carries one ID-only delivery (the notification id, its same-server route, the server's origin and one generic line in the recipient's language); the title and body never leave the server.
17+
18+
- **Authentication.** `@objectstack/cloud-connection` registers a new kernel service, `cloud-binding` (`ICloudBindingService` in `@objectstack/spec/contracts`: `credential()`, `bound()`, `controlPlaneUrl()`), and its own control-plane routes read the credential through it. The push service reads it on every send and puts it in the `Authorization` header only.
19+
- **An unbound server boots.** It logs one warning at `kernel:ready`; until the server is bound on the Cloud connection setup page, the push service lists no vendor, so the push channel is suppressed as `transport_not_configured` and notifications reach users in the in-app inbox. The bind takes effect without a restart.
20+
- **A deployment with no control plane** (`OS_CLOUD_URL=off`, `none`, `local` or `disabled`) refuses a relay configuration at boot, naming the in-app inbox and the direct transport as the two ways out. The direct transport is unchanged.
21+
- **Answers.** `ok` is sent, `invalid` retires the device row, `retry` is retryable; a 401 fails the push without a retry, naming the lapsed binding; a 429 rate limit is retryable and a used-up quota is not.
22+
- **The notice line.** The push channel picks the generic line by the recipient's language (`sys_user.locale`, then the deployment default, then English) and hands it to the push service as `PushMessage.notice`.
23+
- `isControlPlaneDeclined` and the "no control plane" spellings move to `@objectstack/core`; `@objectstack/cloud-connection` re-exports `isControlPlaneDeclined` unchanged.
24+
25+
**BREAKING — what narrows, and what to do:**
26+
27+
- FROM `PUSH_RELAY_NOT_SERVED_MESSAGE` TO `PUSH_RELAY_AIR_GAPPED_MESSAGE` (both exported by `@objectstack/service-push`): the relay is no longer refused for being unserved, only on a deployment that declared it has no control plane. A test that matched the old text matches the new constant, or asserts that the relay boots.
28+
- The relay payload's `data` is judged strictly: a sender puts only `notificationId`, `route` and `origin` in it. A content key such as `title` was stripped before and is now refused with the ID-only guidance.
29+
- A relay's `RATE_LIMIT_EXCEEDED` error answers `retryable: true`, or leaves `retryable` out (it defaults to `true`). A relay that answered `retryable: false` on a rate limit is now refused by the wire schema.

‎content/docs/kernel/runtime-services/push-service.mdx‎

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: services.push — the mobile push service API
33
navTitle: services.push
4-
description: Mobile push delivery to APNs and FCM, configured from the stack's push key with credentials by reference.
4+
description: Mobile push delivery through the hosted push relay or straight to APNs and FCM, configured from the stack's push key.
55
---
66

77
- **Stability:** `experimental`
@@ -16,12 +16,14 @@ services.push.send(message: PushMessage): Promise<PushSendResult>
1616
```
1717

1818
`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).
19+
(`apns` or `fcm`), the device `token`, a plain-text `title` and `body`, `data`,
20+
the delivery data the app shell reads (string values only, because FCM accepts
21+
only strings there), and `notice`, the generic line a relay delivery shows in
22+
place of the title and body.
2223

23-
`vendors()` lists the vendors this deployment has a transport for. An empty list
24-
means push delivers nothing.
24+
`vendors()` lists the vendors this deployment can push to right now. An empty
25+
list means push delivers nothing: no vendor is configured, or the relay is
26+
configured and the server is not bound to the cloud yet.
2527

2628
Application code rarely calls this service directly. A `notify` flow node or a
2729
`NotificationService.emit` that names the `push` channel goes through the
@@ -37,7 +39,8 @@ through this service.
3739
- `{ status: 'invalid_token', reason }`: the vendor reported the token dead
3840
(APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`).
3941
- `{ 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.
42+
for a timeout, a 429 rate limit or a 5xx, and false for a refused credential,
43+
a refused payload, a lapsed cloud binding or a used-up relay quota.
4144

4245
`send()` never throws: a transport error is answered `failed`.
4346

@@ -88,7 +91,7 @@ block is a vendor this deployment does not push to; devices registered on it are
8891
skipped. Only `apns` and `fcm` ship today. The other Android vendors (HMS /
8992
Honor / Xiaomi / OPPO / vivo) are planned for a later phase behind the hosted
9093
relay (below), as further values of `vendor`, the token type the sender routes
91-
on (this service's transports today, the relay once it is served), and not as
94+
on (the direct transports and the relay alike), and not as
9295
plugins or channels of their own.
9396

9497
### Credential references
@@ -116,29 +119,55 @@ secret is refused twice:
116119
A `credentialRef` that names an unset variable is not a boot error: the push to
117120
that vendor fails with `credential_unresolved`, naming the reference.
118121

119-
### The hosted relay: not served yet
122+
### The hosted relay
120123

121124
`PushServiceConfigSchema` has a second transport, `transport: 'relay'`, and it is
122125
the schema's default: a `push` block with no vendor block, `push: {}` and an
123126
absent `push` key all read as the relay. The relay delivers to the
124127
store-published ObjectOS app, whose APNs key and Firebase project stay with
125-
ObjectStack, and authenticates the server by its cloud binding credential.
128+
ObjectStack.
126129

127-
**Its sender has not shipped.** Until it does, a stack that requires `push` with
128-
a relay configuration (including no `push` key) fails at boot:
129-
130-
```
131-
Capability "push" (@objectstack/service-push) failed to start: PushServicePlugin: the hosted push relay
132-
(transport: 'relay', which is also what an empty push config reads as) is not served yet: its sender has
133-
not shipped, and nothing would deliver through it. …
130+
```ts
131+
export default defineStack({
132+
manifest: { /* … */ },
133+
requires: ['push'],
134+
// push: {} — or leave the key out: both read as the relay.
135+
});
134136
```
135137

136-
The fix is the direct transport: `transport: 'direct'` with an `apns` and/or
137-
`fcm` block for an app signed with your own APNs key and Firebase project. The
138-
boot refuses rather than serving the relay because a served relay would mount
139-
the push channel and answer every push as sent while nothing left the server.
140-
Authoring still accepts a relay block, so a stack written for the relay does not
141-
need to change when the sender ships.
138+
- **Authentication.** The server authenticates by its cloud binding
139+
credential: the `oscc_` bearer a self-hosted server receives when it is bound
140+
on the Cloud connection setup page, or the service key the cloud gives the
141+
servers it hosts. The relay block carries no credential of its own. The push
142+
service reads the credential from the `cloud-binding` service
143+
(`@objectstack/cloud-connection`) on every push, and puts it in the request's
144+
`Authorization` header and nowhere else.
145+
- **Address.** The relay is served by the cloud's control plane at
146+
`/api/v1/cloud/push/send`, under the control-plane URL (`OS_CLOUD_URL`, else
147+
`https://cloud.objectos.ai`). A relay block's `endpoint` overrides it, for a
148+
staging relay.
149+
- **ID-only.** A relay delivery carries the notification id, its same-server
150+
route, the server's origin and one generic line ("You have a new
151+
notification", in the recipient's language). The title and body never leave
152+
the server; the app reads the notification from the server after the tap.
153+
154+
**Before the server is bound.** A fresh self-hosted server is not bound on its
155+
first boot, and it still boots: the boot logs one warning that the relay
156+
delivers nothing until the server is bound. Until then the push channel reports
157+
no transport (`transport_not_configured`), so notifications are recorded with
158+
push suppressed and reach users in the in-app inbox. Binding takes effect
159+
without a restart.
160+
161+
**When the deployment has no control plane.** A deployment that declares it has
162+
none (`OS_CLOUD_URL=off`, `none`, `local` or `disabled`) cannot reach the relay,
163+
so a stack that requires `push` with a relay configuration fails at boot,
164+
naming the two ways out: the in-app inbox, which needs no push service, or the
165+
direct transport for an app signed with your own APNs key and Firebase project.
166+
167+
**What the relay answers.** `ok` is a delivered push; `invalid` is a dead token,
168+
which retires the device as below; `retry` is a retryable failure. A 401 means
169+
the cloud binding was revoked or is not recognised: the push fails without a
170+
retry, and the fix is to bind the server again.
142171

143172
## Devices
144173

@@ -174,7 +203,7 @@ user's devices answered:
174203
| the user has no enabled device | `invalid_recipient` |
175204
| 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` |
176205
| a timeout, a 429 or a 5xx with nothing delivered | `retryable` |
177-
| no push service registered | refused `transport_not_configured`, `permanent`, as the email and SMS channels answer it |
206+
| no push service registered, or one with no vendor available (an unbound relay) | suppressed at fan-out as `transport_not_configured`, recorded on the notification; a delivery already queued is refused `transport_not_configured`, `permanent`, as the email and SMS channels answer it |
178207

179208
## Consumers
180209

0 commit comments

Comments
 (0)