Repository navigation
Commit 97e4249
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
File tree
- .changeset
- content/docs
- kernel/runtime-services
- references
- api
- system
- docs
- adr
- audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- cli/src/commands
- cloud-connection/src
- core/src
- services
- service-messaging/src
- service-push
- src
- transports
- spec
- api-surface
- export-origins
- src
- api
- contracts
- system
- verify/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
20 | | - | |
21 | | - | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
22 | 23 | | |
23 | | - | |
24 | | - | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
25 | 27 | | |
26 | 28 | | |
27 | 29 | | |
| |||
37 | 39 | | |
38 | 40 | | |
39 | 41 | | |
40 | | - | |
| 42 | + | |
| 43 | + | |
41 | 44 | | |
42 | 45 | | |
43 | 46 | | |
| |||
88 | 91 | | |
89 | 92 | | |
90 | 93 | | |
91 | | - | |
| 94 | + | |
92 | 95 | | |
93 | 96 | | |
94 | 97 | | |
| |||
116 | 119 | | |
117 | 120 | | |
118 | 121 | | |
119 | | - | |
| 122 | + | |
120 | 123 | | |
121 | 124 | | |
122 | 125 | | |
123 | 126 | | |
124 | 127 | | |
125 | | - | |
| 128 | + | |
126 | 129 | | |
127 | | - | |
128 | | - | |
129 | | - | |
130 | | - | |
131 | | - | |
132 | | - | |
133 | | - | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
134 | 136 | | |
135 | 137 | | |
136 | | - | |
137 | | - | |
138 | | - | |
139 | | - | |
140 | | - | |
141 | | - | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
142 | 171 | | |
143 | 172 | | |
144 | 173 | | |
| |||
174 | 203 | | |
175 | 204 | | |
176 | 205 | | |
177 | | - | |
| 206 | + | |
178 | 207 | | |
179 | 208 | | |
180 | 209 | | |
| |||
0 commit comments