Repository navigation
Commit 7b55ea9
feat(notifications): push device registry — sys_user_device, the two device routes and the client methods (#29628)
<!-- claude-projects-attribution: {"github_login":"os-warren"} -->
_Requested by **Steve, Warren** · [project
thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Ku7jRvrz4ma8UUATyNH88K)_
Fixes #29068
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
1. 改了什么:手机推送设备登记上线了——新表 `sys_user_device`,新接口 `POST
/api/v1/notifications/devices`(登记/刷新)和 `DELETE
/api/v1/notifications/devices/:deviceId`(注销自己的设备),客户端恢复
`notifications.registerDevice` / `unregisterDevice`。
2. 为什么改:#29067 已把契约定好但标着“计划中、未提供”,推送渠道没有地方取设备令牌;这次把四个服务方法和两条路由真正接上,去掉
PLANNED 标记。
3. 风险与回滚:只新增,不改已有行为;设备表是系统表,通用数据接口读写都拒绝(404 OBJECT_API_DISABLED),未登录
401,删别人的设备 404 且不删;回滚即还原本 PR,新表无人引用。
4. 先看:`service-messaging/src/device-registry.ts`(令牌唯一、换人接管、失效后重新启用),再看
`runtime/src/domains/notifications.ts` 的设备分支。
5. 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run
src/device-registry.integration.test.ts` 和 dogfood
`test/push-device-registry.dogfood.test.ts`,两者全绿。
6. 需要你做的:无;三个实现选择已由合同评审 6590640619 全部定为 A,令牌长度上限(评审 S3)是收窄,另走 spec
卡。席位意见:第 3 轮契约复核 PASS(6593441550),CI 全绿,席位落地。
## What changes
- **`@objectstack/service-messaging`**
- New `sys_user_device` (`src/objects/user-device.object.ts`):
engine-owned, `tenancy: { enabled: false }` (a device belongs to its
user, not to an organization, so no injected `organization_id`),
`enable: { apiEnabled: false, apiMethods: [] }`, `user_id` a `sys_user`
lookup with cascade delete, unique indexes on `(user_id, device_id)` and
on `token`. `nameField: 'name'` only (no `titleFormat`, per #29499 which
landed during this card).
- New `DeviceRegistry` (`src/device-registry.ts`) behind
`MessagingService`'s four `INotificationService` members:
`registerDevice` (refresh a held device id, mint one when absent, move a
token another device holds to the registering device and remove the
previous holder's row, re-enable an invalidated device; the takeover
delete of the previous holder and the registering write run in one
engine transaction where the engine offers one, so a refused write rolls
the delete back (the module docblock names the window on an engine
without one); `REGISTER_ATTEMPTS` is 3, and a writer that still loses a
cross-node unique-index race sees `409 UNIQUE_VIOLATION`, the stated
ceiling), `unregisterDevice` (the caller's own device, else `NOT_FOUND`
404 and nothing removed), `listDevices` (the caller's, newest
`last_seen_at` first), `invalidateDeviceToken` (row kept, `enabled:
false` + `invalidated_at`). Vendor derives from platform (ios→apns,
android→fcm); a `web` registration without `vendor` is refused `400
VALIDATION_FAILED` at `vendor`.
- The plugin registers and provisions the table; translation bundles
regenerated (zh-CN / ja-JP / es-ES translated by hand).
- **`@objectstack/runtime`**
- `/notifications` domain: `POST /devices` (body parsed with
`RegisterDeviceRequestSchema`, owner always the session user, a body
`userId` is never read) and `DELETE /devices/:deviceId` (segment
decoded; a malformed escape is passed raw, i.e. an unknown id → 404).
Both sit behind the domain's anonymous floor (401). Each face probes its
own service member, so a provider without the device members answers 501
and one without an inbox still serves them.
- `dispatcher-plugin.ts` mounts both on the standalone server, and the
other verbs on the two paths refusal-only (the #24024 pattern the inbox
faces use).
- `route-ledger.ts`: the two SDK rows plus eight server-only refusal
rows. The device refusal rows say 404 with or without an inbox and 501
only without a notification service, pinned in
`notifications-devices.test.ts`.
- **`@objectstack/client`**: `notifications.registerDevice(request)` and
`notifications.unregisterDevice(deviceId)` (id `encodeURIComponent`-ed)
are back.
- **`@objectstack/spec`** (the four allowed hunks):
`DEFAULT_NOTIFICATION_ROUTES` lists the two device routes (+ `methods`
entries) with the comment rewritten; the `[PLANNED — not yet served]`
markers and their "marker comes off" comments leave the four
registration schemas and `UserDeviceSchema`;
`PLATFORM_OBJECTS_BY_PACKAGE` gains `sys_user_device`;
`error-code-ledger.zod.ts` lists `NOT_FOUND` under
`@objectstack/service-messaging` with its wire path (`unregisterDevice`,
`deviceNotFoundError`, `DELETE /api/v1/notifications/devices/:deviceId`
answers 404 through `resolveThrownHttpError`), the hunk contract review
6590640619 allowed. Generated reference docs regenerated.
- Census last: `organization-ownership-inventory.ts` row and
`scripts/platform-object-tenancy-census.json` regenerated
(sys_user_device: tenancy disabled, reach out). The tenant-audit census
(`content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`) is
regenerated for the registry's five engine write sites (256 → 261 on the
current main), with the page's hand-written figures moved to match; the
platform-object tenancy census is registered 92 → 93, outOfReach 45 →
46, `tenancy.enabled: false` 2 → 3, and lists both `sys_personal_view`
(#29575) and `sys_user_device`; the route-ledger census sentence moves
116 → 126.
- No GET route (the card says so); `listDevices` is service-only for the
push channel.
### Forced test edits
-
`packages/services/service-messaging/src/messaging-service-plugin.test.ts`:
both OBJECTS lists gain `sys_user_device`.
-
`packages/services/service-messaging/src/translations/bundle-ownership.test.ts`:
the owned-object list gains `sys_user_device`.
- `packages/runtime/src/dispatcher-plugin.routes.test.ts`: the
refusal-mount pin covers five faces × five verbs (count 15 → 25).
- `packages/runtime/src/request-schema-gate.conformance.test.ts`: the
ratchet demands a violating-body case for the new catalog row with a
request schema; a `registerDevice` spy and the case are added.
- `packages/spec/src/api/plugin-rest-api.test.ts`: the default
notification routes pin flips to five endpoints including both device
routes.
- `packages/spec/src/system/constants/platform-object-names.test.ts`:
the service-messaging roster pin names `sys_user_device` (forced by the
roster hunk; review must-fix 2).
- `packages/spec/src/api/register-device-request.test.ts`,
`packages/spec/src/system/push-device.test.ts`: the "carries the PLANNED
marker" pins flip to "carries no PLANNED marker".
## Tests
New: `device-registry.integration.test.ts` (10, real ObjectQL +
SqlDriver + `sys_user`; the tenth: a takeover whose write is refused
rolls the previous holder's delete back),
`objects/user-device.object.test.ts` (4),
`runtime/src/domains/notifications-devices.test.ts` (7), two client
tests, and
`packages/qa/dogfood/test/push-device-registry.dogfood.test.ts` (7, the
real showcase stack: anonymous 401 on both routes, register/refresh,
non-owner unregister 404 with the row surviving, token takeover,
invalidate then re-register, body/vendor 400, and the generic data API
refused `404 OBJECT_API_DISABLED` on list/read/create/patch/delete for a
member).
Red first, on the base: service-messaging fails to resolve the registry;
runtime 6 of 14 fail across the new device-face file and the conformance
case (the two anonymous cases pass on base because the domain's floor
already refuses anonymous; the A4 ablation is what pins them); client 2
fail plus `route-ledger-coverage` "every ledger entry naming a client
method resolves".
Ablations (each via `scripts/ablation-replace.mjs`, each restored with
`git diff HEAD` empty):
| # | Mutation | Red |
|---|---|---|
| A1 | `unregister` drops the `user_id` filter | non-owner unregister
case |
| A2 | takeover no longer deletes the previous holder | 3 registry cases
|
| A3 | `web` derives `apns` | vendor case |
| A4 | the domain's anonymous floor skipped | both anonymous dispatcher
cases |
| A5 | owner read from the body `userId` | "registers for the session
user" |
| A6 | `apiEnabled` true, `apiMethods` removed; service-messaging
rebuilt, `ablation-dist-preflight` marker present in dist | dogfood
`[data-api]` (the permission layer then answers 403, not the exposure
gate's 404); restored, rebuilt, preflight `--absent` ✓, file 7/7 |
| A7 | the registration transaction removed | a takeover whose write is
refused rolls the previous holder's delete back; restored with `git diff
HEAD` empty |
## Acceptance notes and deviations
- **Changesets are all `patch`**, not the minors the card's delta
suggested: `Metadata-protocol` measures `none`, and the changeset gate
refuses `minor` with `none`.
- `packages/spec/src/contracts/notification-service.ts`: a comment-only
change (the members are now implemented by service-messaging), outside
the three named spec hunks; it would otherwise state the opposite of
what ships.
- The `methods` array entries in `DEFAULT_NOTIFICATION_ROUTES` ride with
hunk 1's two route rows.
- A fourth spec hunk: the `NOT_FOUND` row in `error-code-ledger.zod.ts`,
allowed by review 6590640619.
- Review S3 (`RegisterDeviceRequestSchema.token` unbounded while
`sys_user_device.token` stores at most 512) stays out of this PR: a
narrowing, for a spec follow-up card.
- `packages/metadata-protocol/src/protocol.ts` is untouched.
## Open questions (ruled: A on all three, kept, by contract review
6590640619)
1. Cross-node token race: a unique index on `token` plus one retry on a
unique violation, beside an in-process per-token lock. Options: A keep
(the database is the arbiter across nodes); B add a distributed lock.
Recommendation A.
2. `user_id` is a `sys_user` lookup with `deleteBehavior: 'cascade'`, so
deleting a user removes their devices. Options: A keep; B `set_null` and
sweep. Recommendation A: a device row without a user can never receive a
push.
3. `DELETE /devices/:deviceId` with a malformed percent-escape answers
404 (the raw segment is an unknown id) rather than 400. Options: A keep;
B refuse 400. Recommendation A: the caller learns nothing about other
users' ids either way.
## Verification
Head `3cabcf90` (origin/main merged at `e0187d0f`, clean, no overlap
with the PR's 44 files; no authored change in round 3).
- cli unit: 375/375 files, 5287 tests pass; service-messaging 787,
client 695, spec `test:repo` 1522, runtime device files 31: all pass.
- Dogfood device file `push-device-registry.dogfood.test.ts` 7/7.
Dogfood battery under the verify lock: VERDICT 0; Test Files 400 passed
/ 3 skipped; Tests 3304 passed / 28 skipped.
- `dispatch-gates --ran`: 122 derived, 122 run, 0 NOT-MEASURED (4
prerequisite refusals rerun green after their prerequisites, named in
os-dev-report 6593261698).
- eslint `--no-inline-config` on all 38 touched code files: exit 0.
- CI on `3cabcf90`: 57 success, 8 skipped, 0 failure.
- `packages/metadata-protocol/src/protocol.ts` is untouched.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6)_ · body
amended by the seat
<!-- signed: domain:server#5 · os-warren ·
session_01JQwxbZUR3xvHyDPCjNL5P4 -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0eb97fa commit 7b55ea9
44 files changed
Lines changed: 1829 additions & 155 deletions
File tree
- .changeset
- content/docs
- permissions
- references
- api
- system
- docs/audits
- packages
- client/src
- cli/src/utils
- qa/dogfood/test
- runtime/src
- domains
- services/service-messaging
- scripts
- src
- objects
- translations
- spec/src
- api
- contracts
- system
- constants
- 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 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
163 | | - | |
164 | | - | |
| 163 | + | |
| 164 | + | |
165 | 165 | | |
166 | 166 | | |
167 | 167 | | |
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
| 200 | + | |
201 | 201 | | |
202 | | - | |
203 | | - | |
| 202 | + | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
| |||
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
| 220 | + | |
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
| 224 | + | |
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
237 | | - | |
| 236 | + | |
| 237 | + | |
238 | 238 | | |
239 | 239 | | |
240 | | - | |
241 | | - | |
| 240 | + | |
| 241 | + | |
242 | 242 | | |
243 | 243 | | |
244 | | - | |
| 244 | + | |
245 | 245 | | |
246 | 246 | | |
247 | 247 | | |
248 | | - | |
| 248 | + | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | | - | |
| 252 | + | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | 257 | | |
258 | | - | |
| 258 | + | |
259 | 259 | | |
260 | 260 | | |
261 | 261 | | |
| |||
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
310 | | - | |
| 310 | + | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
315 | | - | |
316 | | - | |
317 | | - | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
318 | 318 | | |
319 | 319 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2761 | 2761 | | |
2762 | 2762 | | |
2763 | 2763 | | |
2764 | | - | |
| 2764 | + | |
2765 | 2765 | | |
2766 | 2766 | | |
2767 | 2767 | | |
| |||
2780 | 2780 | | |
2781 | 2781 | | |
2782 | 2782 | | |
2783 | | - | |
| 2783 | + | |
2784 | 2784 | | |
2785 | 2785 | | |
2786 | 2786 | | |
| |||
3026 | 3026 | | |
3027 | 3027 | | |
3028 | 3028 | | |
3029 | | - | |
| 3029 | + | |
3030 | 3030 | | |
3031 | 3031 | | |
3032 | 3032 | | |
| |||
3039 | 3039 | | |
3040 | 3040 | | |
3041 | 3041 | | |
3042 | | - | |
| 3042 | + | |
3043 | 3043 | | |
3044 | 3044 | | |
3045 | 3045 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
10 | | - | |
11 | | - | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
16 | | - | |
17 | | - | |
18 | | - | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
23 | | - | |
24 | | - | |
25 | | - | |
26 | | - | |
27 | | - | |
28 | | - | |
29 | | - | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | | - | |
44 | | - | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | 9 | | |
49 | 10 | | |
50 | 11 | | |
| |||
63 | 24 | | |
64 | 25 | | |
65 | 26 | | |
66 | | - | |
| 27 | + | |
67 | 28 | | |
68 | 29 | | |
69 | 30 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | | - | |
41 | | - | |
| 40 | + | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
| 44 | + | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
99 | | - | |
100 | | - | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
236 | 239 | | |
237 | 240 | | |
238 | 241 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
352 | 352 | | |
353 | 353 | | |
354 | 354 | | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
355 | 363 | | |
356 | 364 | | |
357 | 365 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
937 | 937 | | |
938 | 938 | | |
939 | 939 | | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
940 | 962 | | |
941 | 963 | | |
942 | 964 | | |
| |||
0 commit comments