Skip to content

Commit 5c4e752

Browse files
feat(spec): push delivery data names the server that sent it — RegisterDeviceRequest, UserDevice and PushDeliveryData gain an optional origin (#29767)
domain:spec#3 · os-support-ai · session_01WGufzeYAsmoHHYA59sCBZa <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBQgSBJZBjCuYUW1nMNHz3yx)_ Fixes #29642 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — no file under `skills/**` teaches device registration or push delivery data (a `git grep -i` of `skills/` for `push`, `device`, `notifications/devices` and `window.location.origin` names only unrelated lines: `Array.push` calls, query push-down, and the viewport and touch-device notes in `skills/objectui/guides/mobile.md`), so no taught line changes. ## 维护者速读 - 改了什么:设备注册请求 `RegisterDeviceRequest`、设备记录 `UserDevice`、推送数据 `PushDeliveryData` 各加一个可选字段 `origin`,同一条规则:`http(s)://` + 小写主机 + 可选端口,不带路径。 - 为什么改:App 里存了多台服务器时,点开 B 服务器发来的推送会在 A 上打开;按裁决 B,由 Console 注册时报上自己的来源,服务器存进设备记录,推送原样带回,App 按它选服务器。 - 风险与回滚:纯放宽,三个字段都可选,不带它的请求照旧通过;唯一新增的拒收是注册时带了格式不对的 `origin`(今天没有客户端会发)。服务器暂不存、推送暂不带,设备侧两个字段标了 `[PLANNED — not yet stored]`。回滚即 revert。 - 先看:`packages/spec/src/system/push-server-origin.ts`(共享的正则和拒收文案),再看 `push-server-origin.test.ts`(三个字段同一组接受/拒收用例)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/system/push-server-origin.test.ts`,预期 68 个测试全过。 - 需要你做的:无,席位落地。 ## The ruling 「Ruling: batch #1 item 1 · letter B · maintainer 「同意」 2026-11-03T02:26Z」 (record 6593872191): **Option B:** "the Console sends the origin it was loaded from in `RegisterDeviceRequest`. The server stores it on the `UserDevice` row, and the push channel copies it into `PushDeliveryDataSchema`. All three are new optional members." #29218 has merged, so this is its own spec PR widening three optional members. ## The name: `origin` `packages/spec` already spells a URL origin `origin`: the action `navigate` template's `${ctx.origin}` (`ui/action.zod.ts`, the `navigate` describe, "`${ctx.*}` (origin/apiBase/user/org/recordId/selection)"; the Console fills it with `window.location.origin`, `ui/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx`), and CORS `origins` (`shared/http.zod.ts`, values like `https://app.example.com`). `serverOrigin` has zero hits in `packages/spec/src`. The value is exactly `window.location.origin`, so the one name is `origin` on all three schemas. ## What changes - **`system/push-server-origin.ts` (new, internal).** `PUSH_SERVER_ORIGIN_PATTERN` and `PUSH_SERVER_ORIGIN_MESSAGE`, the one rule the three members share. It follows the `system/push-device-platform.ts` precedent: no barrel re-exports it, so it adds no public name (`api-surface/` is unchanged), and each schema builds its own `z.string().regex(...)` from it. The pattern accepts `http://` or `https://`, a lowercase host (DNS labels, punycode included, or a bracketed IPv6 address) and an optional `:PORT`, and nothing after it. That is the serialized form `window.location.origin` answers, so the shell can compare it with a saved server byte for byte. - **`RegisterDeviceRequestSchema.origin`** (`api/protocol.zod.ts`): the Console writes it at registration. Describe opens with `[PLANNED — not yet stored]`. - **`UserDeviceSchema.origin`** (`system/push-device.zod.ts`): the registration's value, kept on the row. Same marker. - **`PushDeliveryDataSchema.origin`** (`api/push-delivery.zod.ts`): the push channel copies it from the device; the shell opens the tap on the saved server with that origin and resolves `route` there. The schema-level `[PLANNED — not yet served]` marker stays, and its describe now names the origin. The file's docblock gains a "Which server sent it" paragraph. - **Generated:** `authorable-surface/api.json` (+`api/PushDeliveryData:origin`, +`api/RegisterDeviceRequest:origin`), `authorable-surface/system.json` (+`system/UserDevice:origin`), and the three `content/docs/references/**` pages (`api/protocol.mdx`, `api/push-delivery.mdx`, `system/push-device.mdx`), written by `gen:docs`. - **Changeset:** `.changeset/29642-push-server-origin.md`, `@objectstack/spec` minor, carrying both declaration lines. ## Tests `packages/spec/src/system/push-server-origin.test.ts`, 68 cases, `describe.each` over the three carriers so each member is held to the same rule: - optional: the body without `origin` parses unchanged (no `origin` key appears); - five valid origins round-trip: `https://crm.example.com`, `http://localhost:3000`, a punycode host with a port, an IPv4 host with a port, `http://[::1]:3000`; - sixteen refusals, each answering exactly one issue at path `['origin']` with code `invalid_format` and the one shared message: a trailing `/`, a path, a query, a fragment; `ftp:`, `wss:`, `javascript:`; a bare host, a bare host with a port, a protocol-relative `//host`; an upper-case scheme, an upper-case host, user info, an empty port, a leading space, the empty string; - one contract: all three JSON Schemas publish the same `pattern` and none lists `origin` as required; - the markers: both device-side members open with `[PLANNED — not yet stored]`, and the push data keeps its schema-level `[PLANNED — not yet served]`. **Ablation** (fix committed first, `scripts/ablation-replace.mjs` on the pattern line of `push-server-origin.ts`, replaced with `/[\s\S]*/;`): Tests 48 failed | 20 passed (68). Every refusal case went red on all three carriers (16 × 3); the optional, valid and contract cases stayed green. Restored: blob equals HEAD (`8c3209e4`), `git diff HEAD` empty. The suite imports the schemas from source, so no `dist/` was involved. ## Acceptance notes - **`Metadata-protocol: added`, not `none`.** All three schemas are listed on `packages/spec/authorable-surface/`, and adding the members moved two shards. `check-changeset-no-major` refuses `none` over a moved authorable-surface shard, so the kind is `added` and `@objectstack/spec` takes `minor`. The claim's `none` line needs amending to match. - **No liveness rows.** None of the three schemas is a governed metadata type (`packages/spec/liveness/` has no row for any of them), and `check:liveness` is green with no change. - **Iso_ pins hold unchanged.** `Iso_api_protocol__RegisterDeviceRequestSchema`, `Iso_api_pushDelivery__PushDeliveryDataSchema` and `Iso_system_pushDevice__UserDeviceSchema` in `type-alias-convention.pin.test.ts` still assert input equals output: an optional string with no default or transform keeps them equal. No new export, so no new pin. - **No refinement.** The rule is a `.regex()`, which the JSON Schema projects, so `dropped-refinements.baseline.json` does not move. - **The one new refusal.** From this release, `POST /api/v1/notifications/devices` (`packages/runtime/src/domains/notifications.ts`, which `safeParse`s the body with `RegisterDeviceRequestSchema`) refuses a registration whose `origin` is malformed. Before, an unknown `origin` key was stripped. No client sends one today: `git grep` for a `registerDevice` caller under `ui/` finds none. - **The producers are not in this PR** (named for the `domain:server` follow-up): - `packages/services/service-messaging/src/objects/user-device.object.ts`: `sys_user_device` has no `origin` field (beside `app_id` and `locale`). - `packages/services/service-messaging/src/device-registry.ts`: the row write (`app_id: request.appId ?? null`, `locale: …`) and the row read (`if (row.locale) device.locale = …`) carry no `origin`. - The push channel (#29041): nothing outside `packages/spec` produces `PushDeliveryData` yet (`git grep PushDeliveryData -- packages ':!packages/spec'` is empty). - The Console registration call rides #29042. - That follow-up takes the `[PLANNED — not yet stored]` markers off and flips the marker pin in `push-server-origin.test.ts`. - `packages/client` `registerDevice(request: RegisterDeviceRequest)` widens with the type and needs no edit. ## Verification All on head `2aa2ae0a7` (base `9b4fa6cc`; `origin/main` has since moved to `3eeb81d19`, and none of its changed files is in this diff — the one shared directory, `packages/spec/authorable-surface/`, moved only `data.json` there — so no merge was made). <details><summary>Package readings</summary> - `@objectstack/spec` under `scripts/pm/os-verify-lock.sh`: `test` exit 0 (Test Files 804 passed (804), Tests 23860 passed, 1 todo); `typecheck` exit 0 (includes `check:test-typecheck`, which reads `type-alias-convention.pin.test.ts` and the new test). - `check:generated` (after `build`): 14 of 14 artifacts current. The first run named `content/docs/references/**` stale, regenerated with `gen:docs`, and a type error in the new test, fixed in the test (no ledger entry). - Spec consumers, public surface changed: `pnpm --filter @objectstack/service-messaging --filter @objectstack/runtime --filter @objectstack/client run typecheck` exit 0; `@objectstack/service-messaging` `test` exit 0 (65 files, 787 tests). Lock verdict command-exit 0. - `test:repo` (the vitest `repo` project) not run locally: the diff edits none of its tests and the derived list does not name it; CI `Test Core` runs it. - Full `pnpm build` (117 of 117 tasks, exit 0) for the built-output gates below. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 102 families, each run and recorded as `CMD :: exit N`. `--ran` reconciles: "102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN". - 95 exited 0 on the first serial pass. Seven exited 3 (prerequisite not met, nothing measured): `check-plugin-teardown-shape --self-test` (shallow clone; its pinned fixture commit fetched), and `check:doc-formula-expressions`, `check:doc-security-posture`, `check:skill-examples`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` (no full build). After the fetch and the full build, all seven exit 0. - Local-only families: `check:dev-prereqs` exit 0, `check:skill-frame-freshness` exit 0. - `check-changeset-no-major` with this body as the `pull_request` event: exit 0 ("LEVEL AXIS: this PR declares clause-② `yes (widening)` and metadata-protocol `added`, and no package whose published source … it moves is graded `patch`"). Control: the same body with `Metadata-protocol: none` exits 1. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzeYAsmoHHYA59sCBZa)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c28972 commit 5c4e752

11 files changed

Lines changed: 221 additions & 3 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): push delivery data names the server that sent it — `RegisterDeviceRequest`, `UserDevice` and `PushDeliveryData` each gain an optional `origin`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: added
9+
10+
APNs and FCM tell a mobile shell nothing about who sent a push, and every server the shell has saved registers the same vendor token, so a shell with several saved servers opened a tap from server B on server A. The push now names its server, round-tripped from the device registration:
11+
12+
- **`RegisterDeviceRequestSchema.origin`** (the `POST /api/v1/notifications/devices` body): the origin the Console was loaded from (`window.location.origin`), which is the address the shell saved for that server. The Console writes it.
13+
- **`UserDeviceSchema.origin`** (the stored device row): the registration's value, kept on the device.
14+
- **`PushDeliveryDataSchema.origin`** (the data a push carries to the shell): the push channel copies it from the device the push goes to, and the shell opens the tap on the saved server with that origin.
15+
16+
All three are optional and share one rule: `http://` or `https://`, a lowercase host (a DNS name or a bracketed IPv6 address) and an optional port, with no path (not even a trailing `/`), query, fragment or user info. That is exactly the form `window.location.origin` answers, so the shell can compare it with a saved server byte for byte. A path, another scheme or a bare host is refused with the same message in all three schemas, and the rule is a pattern, so the published JSON Schema carries it too.
17+
18+
The device-side members carry a `[PLANNED — not yet stored]` marker: the server parses `origin` on a registration from this release, and refuses a malformed one, but the device registry does not keep it and the push channel does not send it yet. A registration body without `origin` parses exactly as before.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2775,6 +2775,7 @@ Register or refresh a push device for the signed-in user
27752775
| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused |
27762776
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
27772777
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
2778+
| **origin** | `string` | optional | [PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example https://crm.example.com): the address the mobile shell saved for this server. The Console writes it; the server stores it on the device row and every push to the device carries it back, so a shell with several saved servers opens the push on the one that sent it. http or https, a lowercase host and an optional port; a path, another scheme or a bare host is refused |
27782779

27792780

27802781
---

‎content/docs/references/api/push-delivery.mdx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ had open, re-checked against the active apps, then the first active app,
4949
then `setup`). It never re-implements the resolver and never opens `route`
5050
verbatim.
5151

52+
**Which server sent it — `origin`.** APNs and FCM tell the shell nothing
53+
about the sender, and every server a shell has saved registers the same
54+
vendor token, so the data names the server itself. `origin` is the origin
55+
the Console was loaded from when it registered the device
56+
(`RegisterDeviceRequestSchema.origin`), kept on the device row
57+
(`UserDeviceSchema.origin`) and copied here by the push channel. It is the
58+
address the shell saved for that server, so the shell opens the tap on the
59+
saved server whose origin it equals and resolves `route` there. The rule
60+
the three members share lives in `system/push-server-origin.ts`.
61+
5262
Not to be confused with the `PushNotificationSchema` that `system/` once
5363
declared and then removed (ADR-0049): that was an authoring template shape;
5464
this is the wire data of a delivery, produced by the platform.
@@ -71,14 +81,15 @@ const result = PushDeliveryDataSchema.parse(data);
7181

7282
## PushDeliveryData
7383

74-
[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a same-server route. No push channel delivers yet
84+
[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id, its actionUrl as a same-server route, and the origin of the server that sent it. No push channel delivers yet
7585

7686
### Properties
7787

7888
| Property | Type | Required | Description |
7989
| :--- | :--- | :--- | :--- |
8090
| **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes |
8191
| **route** | `string` | optional | The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. The shell resolves it on tap as the inbox bell and Home do, never verbatim |
92+
| **origin** | `string` | optional | The origin of the server that sent the push (for example https://crm.example.com): the push channel copies it from the device row, which holds the origin the Console was loaded from when it registered the device. A mobile shell with several saved servers opens the tap on the saved server with this origin, and resolves route there. Absent when the device was registered without one |
8293

8394

8495
---

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ A push device of one user: one row per app install
3838
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
3939
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
4040
| **name** | `string` | optional | Device friendly name, as the registration named it |
41+
| **origin** | `string` | optional | [PLANNED — not yet stored] The origin the Console was loaded from, as the registration named it (for example https://crm.example.com). The device registry keeps it on the row and the push channel copies it into every push to this device, where the mobile shell picks the saved server by it. Absent on a device registered without one |
4142
| **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device |
4243
| **invalidatedAt** | `string` | optional | When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, and cleared by the registration that re-enables it |
4344
| **lastSeenAt** | `string` | optional | When the device last registered or refreshed. Every registration and refresh sets it, so it is absent only on a row no registration wrote; listDevices answers such a device last |

‎packages/spec/authorable-surface/api.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,7 @@
14391439
"api/PublishPackageDraftsResponse:unhiddenApps",
14401440
"api/PublishPackageDraftsResponse:unhideError",
14411441
"api/PushDeliveryData:notificationId",
1442+
"api/PushDeliveryData:origin",
14421443
"api/PushDeliveryData:route",
14431444
"api/QueryAdapterConfig:odata",
14441445
"api/QueryAdapterConfig:operatorMappings",
@@ -1542,6 +1543,7 @@
15421543
"api/RegisterDeviceRequest:deviceId",
15431544
"api/RegisterDeviceRequest:locale",
15441545
"api/RegisterDeviceRequest:name",
1546+
"api/RegisterDeviceRequest:origin",
15451547
"api/RegisterDeviceRequest:platform",
15461548
"api/RegisterDeviceRequest:token",
15471549
"api/RegisterDeviceRequest:vendor",

‎packages/spec/authorable-surface/system.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1405,6 +1405,7 @@
14051405
"system/UserDevice:lastSeenAt",
14061406
"system/UserDevice:locale",
14071407
"system/UserDevice:name",
1408+
"system/UserDevice:origin",
14081409
"system/UserDevice:platform",
14091410
"system/UserDevice:token",
14101411
"system/UserDevice:userId",

‎packages/spec/src/api/protocol.zod.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { ActionDescriptorSchema } from '../automation/node-executor.zod';
3131
import { ServedTranslationDataSchema, LocaleSchema } from '../system/translation.zod';
3232
import { PushVendorSchema } from '../system/push-config.zod';
3333
import { PUSH_DEVICE_PLATFORMS } from '../system/push-device-platform';
34+
import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from '../system/push-server-origin';
3435
// #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item
3536
// responses publish. Same three vocabularies the resolver filters against, so a
3637
// value this spec cannot name is a value the resolver would have dropped.
@@ -3584,6 +3585,13 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({
35843585
),
35853586
appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
35863587
locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
3588+
origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe(
3589+
'[PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example '
3590+
+ 'https://crm.example.com): the address the mobile shell saved for this server. The Console writes it; the server '
3591+
+ 'stores it on the device row and every push to the device carries it back, so a shell with several saved servers '
3592+
+ 'opens the push on the one that sent it. http or https, a lowercase host and an optional port; a path, another '
3593+
+ 'scheme or a bare host is refused',
3594+
),
35873595
}).describe('Register or refresh a push device for the signed-in user'));
35883596

35893597
export const RegisterDeviceResponseSchema = lazySchema(() => z.object({

‎packages/spec/src/api/push-delivery.zod.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { z } from 'zod';
44
import { lazySchema } from '../shared/lazy-schema';
5+
import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from '../system/push-server-origin';
56

67
/**
78
* Push delivery data — what a push carries to the shell.
@@ -47,6 +48,16 @@ import { lazySchema } from '../shared/lazy-schema';
4748
* then `setup`). It never re-implements the resolver and never opens `route`
4849
* verbatim.
4950
*
51+
* **Which server sent it — `origin`.** APNs and FCM tell the shell nothing
52+
* about the sender, and every server a shell has saved registers the same
53+
* vendor token, so the data names the server itself. `origin` is the origin
54+
* the Console was loaded from when it registered the device
55+
* (`RegisterDeviceRequestSchema.origin`), kept on the device row
56+
* (`UserDeviceSchema.origin`) and copied here by the push channel. It is the
57+
* address the shell saved for that server, so the shell opens the tap on the
58+
* saved server whose origin it equals and resolves `route` there. The rule
59+
* the three members share lives in `system/push-server-origin.ts`.
60+
*
5061
* Not to be confused with the `PushNotificationSchema` that `system/` once
5162
* declared and then removed (ADR-0049): that was an authoring template shape;
5263
* this is the wire data of a delivery, produced by the platform.
@@ -93,8 +104,14 @@ export const PushDeliveryDataSchema = lazySchema(() => z.object({
93104
'The notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. '
94105
+ 'The shell resolves it on tap as the inbox bell and Home do, never verbatim',
95106
),
107+
origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe(
108+
'The origin of the server that sent the push (for example https://crm.example.com): the push channel copies it from '
109+
+ 'the device row, which holds the origin the Console was loaded from when it registered the device. A mobile shell '
110+
+ 'with several saved servers opens the tap on the saved server with this origin, and resolves route there. Absent '
111+
+ 'when the device was registered without one',
112+
),
96113
}).describe(
97-
'[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id and its actionUrl as a '
98-
+ 'same-server route. No push channel delivers yet',
114+
'[PLANNED — not yet served] The data a push delivery carries to the shell: the notification id, its actionUrl as a '
115+
+ 'same-server route, and the origin of the server that sent it. No push channel delivers yet',
99116
));
100117
export type PushDeliveryData = z.input<typeof PushDeliveryDataSchema>;

‎packages/spec/src/system/push-device.zod.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { lazySchema } from '../shared/lazy-schema';
55
import { PushVendorSchema } from './push-config.zod';
66
import { LocaleSchema } from './translation.zod';
77
import { PUSH_DEVICE_PLATFORMS } from './push-device-platform';
8+
import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from './push-server-origin';
89

910
/**
1011
* Push Device Protocol — the stored device shape of the push device registry
@@ -57,6 +58,11 @@ export const UserDeviceSchema = lazySchema(() => z.object({
5758
appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
5859
locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
5960
name: z.string().optional().describe('Device friendly name, as the registration named it'),
61+
origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe(
62+
'[PLANNED — not yet stored] The origin the Console was loaded from, as the registration named it (for example '
63+
+ 'https://crm.example.com). The device registry keeps it on the row and the push channel copies it into every push '
64+
+ 'to this device, where the mobile shell picks the saved server by it. Absent on a device registered without one',
65+
),
6066
enabled: z.boolean().describe('False once the vendor reported the token dead; nothing is delivered to a disabled device'),
6167
invalidatedAt: z.string().datetime().optional().describe(
6268
'When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, '
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The server origin a push names (ruling B on the push delivery decision
4+
// card): the Console sends the origin it was loaded from when it registers
5+
// the device, the device row keeps it, and the push data carries it back, so
6+
// a mobile shell with several saved servers opens the tap on the right one.
7+
// Three optional members, one rule: what one accepts the other two accept,
8+
// and what one refuses the other two refuse, with the same code and message.
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { z } from 'zod';
12+
import { RegisterDeviceRequestSchema } from '../api/protocol.zod';
13+
import { PushDeliveryDataSchema } from '../api/push-delivery.zod';
14+
import { UserDeviceSchema } from './push-device.zod';
15+
import { PUSH_SERVER_ORIGIN_MESSAGE } from './push-server-origin';
16+
17+
const REGISTRATION = { token: 'apns-device-token', platform: 'ios' } as const;
18+
const DEVICE = {
19+
deviceId: 'install-1',
20+
userId: 'usr_1',
21+
vendor: 'apns',
22+
token: 'apns-device-token',
23+
platform: 'ios',
24+
enabled: true,
25+
} as const;
26+
const DELIVERY = { notificationId: 'ntf_1', route: '/opportunity/123' } as const;
27+
const CARRIER_BODY = {
28+
RegisterDeviceRequestSchema: REGISTRATION,
29+
UserDeviceSchema: DEVICE,
30+
PushDeliveryDataSchema: DELIVERY,
31+
} as const;
32+
33+
// Leaves the key out when no origin is given, so the optional leg sends the body clients send today.
34+
const withOrigin = <T extends object>(body: T, origin?: string) => (origin === undefined ? body : { ...body, origin });
35+
36+
const CARRIERS = [
37+
['RegisterDeviceRequestSchema', (origin?: string) => RegisterDeviceRequestSchema.safeParse(withOrigin(REGISTRATION, origin))],
38+
['UserDeviceSchema', (origin?: string) => UserDeviceSchema.safeParse(withOrigin(DEVICE, origin))],
39+
['PushDeliveryDataSchema', (origin?: string) => PushDeliveryDataSchema.safeParse(withOrigin(DELIVERY, origin))],
40+
] as const;
41+
42+
describe.each(CARRIERS)('%s.origin', (_name, parse) => {
43+
it('is optional: the body without it parses unchanged', () => {
44+
const parsed = parse();
45+
expect(parsed.success).toBe(true);
46+
expect(parsed.data).not.toHaveProperty('origin');
47+
expect(parsed.data).toEqual(withOrigin(CARRIER_BODY[_name]));
48+
});
49+
50+
it.each([
51+
'https://crm.example.com',
52+
'http://localhost:3000',
53+
'https://xn--fiq228c.example:8443',
54+
'http://192.168.1.20:3000',
55+
'http://[::1]:3000',
56+
])('round-trips the Console origin %j', (origin) => {
57+
const parsed = parse(origin);
58+
expect(parsed.success).toBe(true);
59+
expect((parsed.data as { origin?: string }).origin).toBe(origin);
60+
});
61+
62+
it.each([
63+
// A path, a trailing slash, a query or a fragment: an origin has none.
64+
'https://crm.example.com/',
65+
'https://crm.example.com/console',
66+
'https://crm.example.com?x=1',
67+
'https://crm.example.com#top',
68+
// Another scheme.
69+
'ftp://crm.example.com',
70+
'wss://crm.example.com',
71+
'javascript:alert(1)',
72+
// A bare host, with or without a port, and a protocol-relative one.
73+
'crm.example.com',
74+
'localhost:3000',
75+
'//crm.example.com',
76+
// Not the canonical form window.location.origin answers.
77+
'HTTPS://crm.example.com',
78+
'https://CRM.example.com',
79+
'https://user@crm.example.com',
80+
'https://crm.example.com:',
81+
' https://crm.example.com',
82+
'',
83+
])('refuses %j with the one origin refusal', (origin) => {
84+
const parsed = parse(origin);
85+
expect(parsed.success).toBe(false);
86+
expect(parsed.error?.issues).toHaveLength(1);
87+
expect(parsed.error?.issues[0]).toMatchObject({
88+
path: ['origin'],
89+
code: 'invalid_format',
90+
message: PUSH_SERVER_ORIGIN_MESSAGE,
91+
});
92+
});
93+
});
94+
95+
describe('the three origin members are one contract', () => {
96+
it('publishes the same pattern in each JSON Schema', () => {
97+
const patterns = [RegisterDeviceRequestSchema, UserDeviceSchema, PushDeliveryDataSchema].map((schema) => {
98+
const json = z.toJSONSchema(schema, { io: 'input' }) as unknown as { properties: { origin: { pattern?: string } }; required?: string[] };
99+
expect(json.required ?? []).not.toContain('origin');
100+
return json.properties.origin.pattern;
101+
});
102+
expect(patterns[0]).toBeTruthy();
103+
expect(new Set(patterns).size).toBe(1);
104+
});
105+
106+
it('marks the device-side members PLANNED until the server stores the origin', () => {
107+
expect(RegisterDeviceRequestSchema.shape.origin.description).toMatch(/^\[PLANNED — not yet stored\] /);
108+
expect(UserDeviceSchema.shape.origin.description).toMatch(/^\[PLANNED — not yet stored\] /);
109+
// The push data carries its marker on the whole schema until a push channel produces it.
110+
expect(PushDeliveryDataSchema.description).toMatch(/^\[PLANNED — not yet served\] /);
111+
});
112+
});

0 commit comments

Comments
 (0)