Skip to content

Commit e8dc483

Browse files
feat(messaging): store a push device's origin at registration and carry it in every push to the device (#30705)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DUpvxmC7UGJmuMQW4ZsJoLR)_ domain:server#3 · os-elon-musk · session_016WV4czbHcjvoBYWDw2gRAq Fixes #29768 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:手机注册推送设备时带上的 `origin`(控制台加载自哪台服务器)现在会存到 `sys_user_device` 上,`listDevices` 读得回来,每条推送的 `data` 里都带着它。 - 为什么改:裁决 B(#29642)的服务端一半。保存了两台服务器的手机壳,点开 B 服务器发来的推送会落到 A 上;有了 `origin`,壳就能按它打开正确的服务器。 - 风险与回滚:新增一个可空列,不带 `origin` 的注册与推送完全照旧;spec 只改描述和注释,不改校验。回滚即 revert。 - 先看:`packages/services/service-messaging/src/device-registry.ts` 的写行和 `toUserDevice`;推送通道的复制在 main 上已存在(`push-channel.ts` 的 `withOrigin`),本 PR 未改它。 - 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run src/device-registry.integration.test.ts`,预期 14 passed。 - 需要你做的:无;席位验收、CI 全绿后落地。 ## What changes **`@objectstack/service-messaging`.** - `sys_user_device` gains an optional `origin` text column, deliberately without `maxLength`: spec's origin pattern bounds the shape, not the length, so a bounded column could refuse a value the registration accepts. - `DeviceRegistry` writes `origin: request.origin ?? null` beside `app_id` / `locale` on both the insert and the refresh (a refresh replaces it, as it does those two), and `toUserDevice` sets `device.origin` when the row holds one. - The push channel already copied `device.origin` into each device's push `data` (`withOrigin` in `push-channel.ts`, landed with the channel); until now the registry never stored one, so nothing reached it. No change there. - The field's label and help are translated in all ten shipped translated locales, ru, de and ko included since main added them (`check-i18n-bundles.mjs --write`, then hand translation, then `--write` again; the en bundle is regenerated from the source). **`@objectstack/spec`, description and docblock only.** - `RegisterDeviceRequestSchema.origin` and `UserDeviceSchema.origin` drop their `[PLANNED — not yet stored]` prefix; the marker pin in `push-server-origin.test.ts` is flipped to assert that no origin member is marked PLANNED. - `push-server-origin.ts` docblock takes the three should-fix items of contract review 6595162668 by the docblock route: it names the DNS-label limit (`_` and a trailing-dot host are refused although `window.location.origin` can answer them), the port and IPv6 laxness with the Console as the one producer, and that a dotted IPv4 host is accepted. ⛔ The validator is unchanged. - The `RegisterDeviceRequestSchema` docblock's refresh list now names every request member a refresh rewrites: token, vendor, platform, app, locale, origin and name. - Regenerated: `content/docs/references/api/protocol.mdx`, `content/docs/references/system/push-device.mdx` (`gen:schema && gen:docs`). **`@objectstack/service-push`, docblock only.** The `PushMessage` docblock in `push-service.ts` names `origin` as the third delivery-data member, present when the device registered one; the changeset carries `'@objectstack/service-push': patch` for it. **Not changed, verified:** `packages/runtime/src/domains/notifications.ts` already hands `RegisterDeviceRequestSchema.safeParse(body).data` to `registerDevice`, so `origin` reaches the registry. Both transports in `service-push` spread `message.data` unchanged (FCM into `message.data`, APNs at the payload top level). ## Why Ruling B on #29642 (record 6593872191, maintainer 「同意」): "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`." The spec half (PR #29767) is merged; this is the server half. The Console producer stays with #29042. ## Verification Measured on `origin/main` `b77c160a` (registry and object reverted to main, the new pins kept): a registration with `origin: 'https://crm.example.com'` stored a row whose `origin` is `undefined`, `listDevices` answered no `origin`, and a push through the real channel over the real registry carried `{ notificationId, route }` only. - `src/device-registry.integration.test.ts`, new top-level describe (4, real `ObjectQL` over `SqlDriver`): the registration's origin is stored and `listDevices` answers it; a registration without one stores `null` and the device has no `origin` member; a refresh replaces the stored origin and a refresh without one clears it; a push through `createPushChannel` with the `MessagingService` as its device registry carries the stored origin for that device and none for a device without one, and every data object parses as `PushDeliveryDataSchema`. - `src/objects/user-device.object.test.ts` (+1): the `origin` column is optional text with no `maxLength`. - `packages/spec/src/system/push-server-origin.test.ts`: the marker pin flipped. - Ablations: registry and object at main ⇒ 4 red (the null/absent case is the negative control and stays green); only the read-back line removed ⇒ 2 red (list and push), the row pin green; the PLANNED prefix put back on `UserDeviceSchema.origin` ⇒ the flipped pin red. All green at head. - `@objectstack/service-messaging`: 66 files / 850 tests green at `48036dd1`, 67 files / 858 tests green on the merged tree at `6dc84f5e`; `typecheck` green. `check-i18n-bundles.mjs`: in sync; `check:i18n-coverage`: none new. `@objectstack/spec` `check:generated`: every artifact current (`check:test-typecheck` needs an 8 GB heap in this container; green with it). - Every `dispatch-gates --commands` family at `48036dd1`: 108 derived, 108 run, all exit 0, 0 NOT-MEASURED (`--ran` reconcile). `pnpm lint`: exit 0. The same sweep on the final head `6dc84f5e` is reported on the card. Skills-docs: `git grep -n -i -E "registerDevice|notifications/devices|RegisterDeviceRequest|PushDeliveryData|push.*origin" -- skills content/docs` read: no `skills/**` hit; `content/docs/kernel/runtime-services/push-service.mdx` already lists `origin` among the registration members and says nothing about it being unstored, still true; release notes untouched. Generated `content/docs/references/**` regenerated, not hand-edited. Contract review 6672159811 (PASS on `48036dd1`) left two should-fix docblock items, the `PushMessage` data list and the refresh list; both are folded in at `7e4b969d`, delta review 6672299353 PASS. `ccea409a` merges `main`, whose new ru, de and ko service-messaging bundles lacked the new key (CI `check:i18n` / `check:i18n-coverage` on the merge ref), and `6dc84f5e` translates it in those three. ## 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 (the 维护者速读 block is the lane's maintainer summary). <!-- signed: domain:server#3 · os-elon-musk · session_016WV4czbHcjvoBYWDw2gRAq --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96a7d20 commit e8dc483

23 files changed

Lines changed: 190 additions & 22 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
'@objectstack/spec': patch
4+
'@objectstack/service-push': patch
5+
---
6+
7+
feat(messaging,spec): a push device's `origin` is stored at registration and every push to the device carries it back
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **`@objectstack/service-messaging`**: `sys_user_device` gains an optional `origin` column. `POST /api/v1/notifications/devices` stores the registration's `origin` on the device row (`null` when the registration names none, and a refresh replaces it as it does `appId` and `locale`); `listDevices` answers it; and the push channel, which already copied a device's `origin` into the push data, now finds one to copy. A mobile shell with several saved servers can therefore open a tapped push on the server that sent it. Registrations without `origin` behave exactly as before.
13+
- **`@objectstack/spec`**: `RegisterDeviceRequestSchema.origin` and `UserDeviceSchema.origin` drop their `[PLANNED — not yet stored]` prefix, since the server now stores the value. The push server origin docblock names where its pattern is knowingly narrower than a URL origin (an underscore in a label, a trailing-dot host) and looser (a written-out default port, a leading zero or an out-of-range port, any bracketed run of hex digits, colons and dots), and that a dotted IPv4 host is accepted. Description and docblock text only; nothing a caller sends is accepted or refused differently.
14+
- **`@objectstack/service-push`**: the `PushMessage` docblock names `origin` among the delivery data the transports forward. Docblock only; both transports already forward `data` whole.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2782,7 +2782,7 @@ Register or refresh a push device for the signed-in user
27822782
| **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 |
27832783
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
27842784
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN). Stored on the device row; no push renders by it: a push arrives in the recipient's language (sys_user.locale, then the deployment default) |
2785-
| **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 |
2785+
| **origin** | `string` | optional | 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 |
27862786

27872787

27882788
---

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +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), as the registration named it. Stored for the device; no push renders by it: a push arrives in the recipient's language (sys_user.locale, then the deployment default) |
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 |
41+
| **origin** | `string` | optional | 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 |
4242
| **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device |
4343
| **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 |
4444
| **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/services/service-messaging/src/device-registry.integration.test.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@
2525
* - a takeover whose write is refused leaves the previous holder's row in
2626
* place: the delete and the write are one engine transaction;
2727
* - the database refuses a second holder of a token (the unique index), and
28-
* two concurrent registrations of one token end with one row.
28+
* two concurrent registrations of one token end with one row;
29+
* - the registration's `origin` (ruling B on the push delivery decision) is
30+
* stored on the row, `null` without one, replaced by a refresh, answered
31+
* by `listDevices`, and copied into the data of a push to that device.
2932
*
3033
* Refusals assert `code` AND `status` (ADR-0112).
3134
*/
@@ -35,9 +38,11 @@ import { ObjectQL } from '@objectstack/objectql';
3538
import { SqlDriver } from '@objectstack/driver-sql';
3639
import { SysUser } from '@objectstack/platform-objects';
3740
import { validationFailureDetails } from '@objectstack/types';
41+
import { PushDeliveryDataSchema } from '@objectstack/spec/api';
3842
import { UserDevice } from './objects/user-device.object.js';
3943
import { MessagingService } from './messaging-service.js';
4044
import { USER_DEVICE_OBJECT } from './device-registry.js';
45+
import { createPushChannel } from './push-channel.js';
4146
import { installDefaultOrganization } from './default-organization.testkit.js';
4247

4348
const SYS = { context: { isSystem: true } } as any;
@@ -260,3 +265,61 @@ describe('push device registry: INotificationService device members over sys_use
260265
expect(await rows({ token: 'tok-race' })).toHaveLength(1);
261266
});
262267
});
268+
269+
describe('push device registry: the sending server (origin) is stored, read back and pushed', () => {
270+
it('stores the registration\'s origin on the row and listDevices answers it', async () => {
271+
const { service, alice, rows } = await boot();
272+
await service.registerDevice(alice, { token: 'tok-o', platform: 'ios', deviceId: 'dev-o', origin: 'https://crm.example.com' });
273+
274+
const [row] = await rows({ device_id: 'dev-o' });
275+
expect(row.origin).toBe('https://crm.example.com');
276+
const [listed] = await service.listDevices(alice);
277+
expect(listed.origin).toBe('https://crm.example.com');
278+
});
279+
280+
it('stores null for a registration without origin, and the device answers no origin member', async () => {
281+
const { service, alice, rows } = await boot();
282+
await service.registerDevice(alice, { token: 'tok-n', platform: 'android', deviceId: 'dev-n' });
283+
284+
const [row] = await rows({ device_id: 'dev-n' });
285+
expect(row.origin ?? null).toBeNull();
286+
const [listed] = await service.listDevices(alice);
287+
expect(listed).not.toHaveProperty('origin');
288+
});
289+
290+
it('a refresh replaces the stored origin with the one it names, as it does appId and locale', async () => {
291+
const { service, alice, rows } = await boot();
292+
await service.registerDevice(alice, { token: 'tok-r', platform: 'ios', deviceId: 'dev-r', origin: 'https://a.example.com' });
293+
await service.registerDevice(alice, { token: 'tok-r', platform: 'ios', deviceId: 'dev-r', origin: 'http://localhost:3000' });
294+
expect((await rows({ device_id: 'dev-r' }))[0].origin).toBe('http://localhost:3000');
295+
296+
await service.registerDevice(alice, { token: 'tok-r', platform: 'ios', deviceId: 'dev-r' });
297+
expect((await rows({ device_id: 'dev-r' }))[0].origin ?? null).toBeNull();
298+
});
299+
300+
it('a push to the device carries the stored origin in its data; a device without one gets none', async () => {
301+
const { service, alice } = await boot();
302+
await service.registerDevice(alice, { token: 'tok-s', platform: 'ios', deviceId: 'with-origin', origin: 'https://crm.example.com' });
303+
await service.registerDevice(alice, { token: 'tok-t', platform: 'android', deviceId: 'without-origin' });
304+
305+
const sent: Array<{ token: string; data: Record<string, string> }> = [];
306+
const channel = createPushChannel({
307+
getPush: () => ({
308+
vendors: () => ['apns', 'fcm'],
309+
async send(m) { sent.push({ token: m.token, data: m.data }); return { status: 'sent', messageId: m.token }; },
310+
}),
311+
devices: service,
312+
});
313+
const result = await channel.send({ logger: silent } as any, {
314+
channel: 'push',
315+
recipient: alice,
316+
notification: { notificationId: 'ntf_o', title: 'Deal won', body: 'Acme', actionUrl: '/opportunity/1' },
317+
} as any);
318+
319+
expect(result.ok).toBe(true);
320+
const byToken = Object.fromEntries(sent.map((m) => [m.token, m.data]));
321+
expect(byToken['tok-s']).toEqual({ notificationId: 'ntf_o', route: '/opportunity/1', origin: 'https://crm.example.com' });
322+
expect(byToken['tok-t']).toEqual({ notificationId: 'ntf_o', route: '/opportunity/1' });
323+
for (const m of sent) expect(PushDeliveryDataSchema.safeParse(m.data).success).toBe(true);
324+
});
325+
});

‎packages/services/service-messaging/src/device-registry.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export class DeviceRegistry {
186186
platform: request.platform,
187187
app_id: request.appId ?? null,
188188
locale: request.locale ?? null,
189+
origin: request.origin ?? null,
189190
name: request.name ?? null,
190191
enabled: true,
191192
invalidated_at: null,
@@ -296,6 +297,7 @@ function toUserDevice(row: DeviceRow): UserDevice {
296297
};
297298
if (row.app_id) device.appId = String(row.app_id);
298299
if (row.locale) device.locale = String(row.locale);
300+
if (row.origin) device.origin = String(row.origin);
299301
if (row.name) device.name = String(row.name);
300302
const invalidatedAt = iso(row.invalidated_at);
301303
if (!enabled && invalidatedAt) device.invalidatedAt = invalidatedAt;

‎packages/services/service-messaging/src/objects/user-device.object.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ describe('sys_user_device declaration', () => {
3030
expect((UserDeviceSchema as any).shape.platform.options.slice().sort()).toEqual([...admitted].sort());
3131
});
3232

33+
it('has an optional, unbounded origin column for the origin the registration names', () => {
34+
const origin = UserDevice.fields.origin as any;
35+
expect(origin).toMatchObject({ type: 'text', required: false });
36+
// Spec's origin pattern bounds the shape, not the length: a bounded column would
37+
// refuse a value the registration accepts.
38+
expect(origin.maxLength).toBeUndefined();
39+
expect((RegisterDeviceRequestSchema as any).shape.origin).toBeDefined();
40+
expect((UserDeviceSchema as any).shape.origin).toBeDefined();
41+
});
42+
3343
it('carries the two unique indexes the registry relies on', () => {
3444
expect(UserDevice.indexes).toEqual(expect.arrayContaining([
3545
expect.objectContaining({ fields: ['user_id', 'device_id'], unique: 'global' }),

‎packages/services/service-messaging/src/objects/user-device.object.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,17 @@ export const UserDevice = ObjectSchema.create({
121121
+ 'arrives in the recipient\'s language (their user locale, then the deployment default).',
122122
}),
123123

124+
origin: Field.text({
125+
label: 'Server Origin',
126+
required: false,
127+
// No maxLength: spec's origin pattern (`system/push-server-origin.ts`)
128+
// bounds the shape, not the length, so an unbounded column stores
129+
// every origin the registration accepts.
130+
description: 'The origin the Console was loaded from when it registered the device (for example '
131+
+ 'https://crm.example.com). Every push to the device carries it, so a mobile shell with several saved '
132+
+ 'servers opens the push on this one. Empty for a device registered without one.',
133+
}),
134+
124135
name: Field.text({
125136
label: 'Device Name',
126137
required: false,

‎packages/services/service-messaging/src/translations/ar.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,10 @@ export const arObjects: NonNullable<TranslationData['objects']> = {
462462
label: "اللغة المحلية",
463463
help: "لغة الجهاز كما ذكرها التسجيل. تُخزَّن للجهاز، ولا يُعرض أي إشعار فوري بها: يصل الإشعار الفوري بلغة المستلم (لغة المستخدم لديه، ثم اللغة الافتراضية للنشر)."
464464
},
465+
origin: {
466+
label: "أصل الخادم",
467+
help: "الأصل الذي حُمِّلت منه وحدة التحكم عند تسجيل الجهاز (مثل https://crm.example.com). يحمله كل إشعار فوري يُرسل إلى الجهاز، فيفتح تطبيق الهاتف الذي حفظ عدة خوادم الإشعار على هذا الخادم. فارغ للجهاز المسجَّل بدونه."
468+
},
465469
name: {
466470
label: "اسم الجهاز"
467471
},

‎packages/services/service-messaging/src/translations/de.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,10 @@ export const deObjects: NonNullable<TranslationData['objects']> = {
462462
label: "Gebietsschema",
463463
help: "Gebietsschema des Geräts, wie es bei der Registrierung angegeben wurde. Wird für das Gerät gespeichert; keine Push-Nachricht wird danach dargestellt: Eine Push-Nachricht kommt in der Sprache des Empfängers an (seiner Benutzersprache, sonst der Standardsprache der Bereitstellung)."
464464
},
465+
origin: {
466+
label: "Server-Ursprung",
467+
help: "Der Ursprung, von dem die Konsole bei der Registrierung des Geräts geladen wurde (zum Beispiel https://crm.example.com). Jede Push-Benachrichtigung an das Gerät enthält ihn, sodass eine mobile App mit mehreren gespeicherten Servern die Benachrichtigung auf diesem Server öffnet. Leer bei einem Gerät, das ohne ihn registriert wurde."
468+
},
465469
name: {
466470
label: "Gerätename"
467471
},

‎packages/services/service-messaging/src/translations/en.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
462462
label: "Locale",
463463
help: "Device locale, as the registration named it. Stored for the device; no push renders by it: a push arrives in the recipient's language (their user locale, then the deployment default)."
464464
},
465+
origin: {
466+
label: "Server Origin",
467+
help: "The origin the Console was loaded from when it registered the device (for example https://crm.example.com). Every push to the device carries it, so a mobile shell with several saved servers opens the push on this one. Empty for a device registered without one."
468+
},
465469
name: {
466470
label: "Device Name"
467471
},

0 commit comments

Comments
 (0)