diff --git a/.changeset/29768-push-device-origin-stored.md b/.changeset/29768-push-device-origin-stored.md new file mode 100644 index 00000000000..9930163d4e3 --- /dev/null +++ b/.changeset/29768-push-device-origin-stored.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-messaging': patch +'@objectstack/spec': patch +'@objectstack/service-push': patch +--- + +feat(messaging,spec): a push device's `origin` is stored at registration and every push to the device carries it back + +Clause-②: no +Metadata-protocol: none + +- **`@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. +- **`@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. +- **`@objectstack/service-push`**: the `PushMessage` docblock names `origin` among the delivery data the transports forward. Docblock only; both transports already forward `data` whole. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c5f062da017..b746519cb90 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2782,7 +2782,7 @@ Register or refresh a push device for the signed-in user | **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 | | **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID | | **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) | -| **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 | +| **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 | --- diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx index d88bd1ced0b..7fb7ca78772 100644 --- a/content/docs/references/system/push-device.mdx +++ b/content/docs/references/system/push-device.mdx @@ -38,7 +38,7 @@ A push device of one user: one row per app install | **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID | | **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) | | **name** | `string` | optional | Device friendly name, as the registration named it | -| **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 | +| **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 | | **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device | | **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 | | **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 | diff --git a/packages/services/service-messaging/src/device-registry.integration.test.ts b/packages/services/service-messaging/src/device-registry.integration.test.ts index 627bd165e79..7a98fecb419 100644 --- a/packages/services/service-messaging/src/device-registry.integration.test.ts +++ b/packages/services/service-messaging/src/device-registry.integration.test.ts @@ -25,7 +25,10 @@ * - a takeover whose write is refused leaves the previous holder's row in * place: the delete and the write are one engine transaction; * - the database refuses a second holder of a token (the unique index), and - * two concurrent registrations of one token end with one row. + * two concurrent registrations of one token end with one row; + * - the registration's `origin` (ruling B on the push delivery decision) is + * stored on the row, `null` without one, replaced by a refresh, answered + * by `listDevices`, and copied into the data of a push to that device. * * Refusals assert `code` AND `status` (ADR-0112). */ @@ -35,9 +38,11 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SysUser } from '@objectstack/platform-objects'; import { validationFailureDetails } from '@objectstack/types'; +import { PushDeliveryDataSchema } from '@objectstack/spec/api'; import { UserDevice } from './objects/user-device.object.js'; import { MessagingService } from './messaging-service.js'; import { USER_DEVICE_OBJECT } from './device-registry.js'; +import { createPushChannel } from './push-channel.js'; import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; @@ -260,3 +265,61 @@ describe('push device registry: INotificationService device members over sys_use expect(await rows({ token: 'tok-race' })).toHaveLength(1); }); }); + +describe('push device registry: the sending server (origin) is stored, read back and pushed', () => { + it('stores the registration\'s origin on the row and listDevices answers it', async () => { + const { service, alice, rows } = await boot(); + await service.registerDevice(alice, { token: 'tok-o', platform: 'ios', deviceId: 'dev-o', origin: 'https://crm.example.com' }); + + const [row] = await rows({ device_id: 'dev-o' }); + expect(row.origin).toBe('https://crm.example.com'); + const [listed] = await service.listDevices(alice); + expect(listed.origin).toBe('https://crm.example.com'); + }); + + it('stores null for a registration without origin, and the device answers no origin member', async () => { + const { service, alice, rows } = await boot(); + await service.registerDevice(alice, { token: 'tok-n', platform: 'android', deviceId: 'dev-n' }); + + const [row] = await rows({ device_id: 'dev-n' }); + expect(row.origin ?? null).toBeNull(); + const [listed] = await service.listDevices(alice); + expect(listed).not.toHaveProperty('origin'); + }); + + it('a refresh replaces the stored origin with the one it names, as it does appId and locale', async () => { + const { service, alice, rows } = await boot(); + await service.registerDevice(alice, { token: 'tok-r', platform: 'ios', deviceId: 'dev-r', origin: 'https://a.example.com' }); + await service.registerDevice(alice, { token: 'tok-r', platform: 'ios', deviceId: 'dev-r', origin: 'http://localhost:3000' }); + expect((await rows({ device_id: 'dev-r' }))[0].origin).toBe('http://localhost:3000'); + + await service.registerDevice(alice, { token: 'tok-r', platform: 'ios', deviceId: 'dev-r' }); + expect((await rows({ device_id: 'dev-r' }))[0].origin ?? null).toBeNull(); + }); + + it('a push to the device carries the stored origin in its data; a device without one gets none', async () => { + const { service, alice } = await boot(); + await service.registerDevice(alice, { token: 'tok-s', platform: 'ios', deviceId: 'with-origin', origin: 'https://crm.example.com' }); + await service.registerDevice(alice, { token: 'tok-t', platform: 'android', deviceId: 'without-origin' }); + + const sent: Array<{ token: string; data: Record }> = []; + const channel = createPushChannel({ + getPush: () => ({ + vendors: () => ['apns', 'fcm'], + async send(m) { sent.push({ token: m.token, data: m.data }); return { status: 'sent', messageId: m.token }; }, + }), + devices: service, + }); + const result = await channel.send({ logger: silent } as any, { + channel: 'push', + recipient: alice, + notification: { notificationId: 'ntf_o', title: 'Deal won', body: 'Acme', actionUrl: '/opportunity/1' }, + } as any); + + expect(result.ok).toBe(true); + const byToken = Object.fromEntries(sent.map((m) => [m.token, m.data])); + expect(byToken['tok-s']).toEqual({ notificationId: 'ntf_o', route: '/opportunity/1', origin: 'https://crm.example.com' }); + expect(byToken['tok-t']).toEqual({ notificationId: 'ntf_o', route: '/opportunity/1' }); + for (const m of sent) expect(PushDeliveryDataSchema.safeParse(m.data).success).toBe(true); + }); +}); diff --git a/packages/services/service-messaging/src/device-registry.ts b/packages/services/service-messaging/src/device-registry.ts index 89dbbf873b8..579368a050c 100644 --- a/packages/services/service-messaging/src/device-registry.ts +++ b/packages/services/service-messaging/src/device-registry.ts @@ -186,6 +186,7 @@ export class DeviceRegistry { platform: request.platform, app_id: request.appId ?? null, locale: request.locale ?? null, + origin: request.origin ?? null, name: request.name ?? null, enabled: true, invalidated_at: null, @@ -296,6 +297,7 @@ function toUserDevice(row: DeviceRow): UserDevice { }; if (row.app_id) device.appId = String(row.app_id); if (row.locale) device.locale = String(row.locale); + if (row.origin) device.origin = String(row.origin); if (row.name) device.name = String(row.name); const invalidatedAt = iso(row.invalidated_at); if (!enabled && invalidatedAt) device.invalidatedAt = invalidatedAt; diff --git a/packages/services/service-messaging/src/objects/user-device.object.test.ts b/packages/services/service-messaging/src/objects/user-device.object.test.ts index ed73e979e4d..efb620207a0 100644 --- a/packages/services/service-messaging/src/objects/user-device.object.test.ts +++ b/packages/services/service-messaging/src/objects/user-device.object.test.ts @@ -30,6 +30,16 @@ describe('sys_user_device declaration', () => { expect((UserDeviceSchema as any).shape.platform.options.slice().sort()).toEqual([...admitted].sort()); }); + it('has an optional, unbounded origin column for the origin the registration names', () => { + const origin = UserDevice.fields.origin as any; + expect(origin).toMatchObject({ type: 'text', required: false }); + // Spec's origin pattern bounds the shape, not the length: a bounded column would + // refuse a value the registration accepts. + expect(origin.maxLength).toBeUndefined(); + expect((RegisterDeviceRequestSchema as any).shape.origin).toBeDefined(); + expect((UserDeviceSchema as any).shape.origin).toBeDefined(); + }); + it('carries the two unique indexes the registry relies on', () => { expect(UserDevice.indexes).toEqual(expect.arrayContaining([ expect.objectContaining({ fields: ['user_id', 'device_id'], unique: 'global' }), diff --git a/packages/services/service-messaging/src/objects/user-device.object.ts b/packages/services/service-messaging/src/objects/user-device.object.ts index 7120c49f7a8..c0b53e3e14c 100644 --- a/packages/services/service-messaging/src/objects/user-device.object.ts +++ b/packages/services/service-messaging/src/objects/user-device.object.ts @@ -121,6 +121,17 @@ export const UserDevice = ObjectSchema.create({ + 'arrives in the recipient\'s language (their user locale, then the deployment default).', }), + origin: Field.text({ + label: 'Server Origin', + required: false, + // No maxLength: spec's origin pattern (`system/push-server-origin.ts`) + // bounds the shape, not the length, so an unbounded column stores + // every origin the registration accepts. + description: '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.', + }), + name: Field.text({ label: 'Device Name', required: false, diff --git a/packages/services/service-messaging/src/translations/ar.objects.generated.ts b/packages/services/service-messaging/src/translations/ar.objects.generated.ts index 174616e0136..86696740ee0 100644 --- a/packages/services/service-messaging/src/translations/ar.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ar.objects.generated.ts @@ -462,6 +462,10 @@ export const arObjects: NonNullable = { label: "اللغة المحلية", help: "لغة الجهاز كما ذكرها التسجيل. تُخزَّن للجهاز، ولا يُعرض أي إشعار فوري بها: يصل الإشعار الفوري بلغة المستلم (لغة المستخدم لديه، ثم اللغة الافتراضية للنشر)." }, + origin: { + label: "أصل الخادم", + help: "الأصل الذي حُمِّلت منه وحدة التحكم عند تسجيل الجهاز (مثل https://crm.example.com). يحمله كل إشعار فوري يُرسل إلى الجهاز، فيفتح تطبيق الهاتف الذي حفظ عدة خوادم الإشعار على هذا الخادم. فارغ للجهاز المسجَّل بدونه." + }, name: { label: "اسم الجهاز" }, diff --git a/packages/services/service-messaging/src/translations/de.objects.generated.ts b/packages/services/service-messaging/src/translations/de.objects.generated.ts index ce110b13356..8b849bd71b1 100644 --- a/packages/services/service-messaging/src/translations/de.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/de.objects.generated.ts @@ -462,6 +462,10 @@ export const deObjects: NonNullable = { label: "Gebietsschema", 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)." }, + origin: { + label: "Server-Ursprung", + 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." + }, name: { label: "Gerätename" }, diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index d8eaaf580be..01ff567b0c9 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -462,6 +462,10 @@ export const enObjects: NonNullable = { label: "Locale", 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)." }, + origin: { + label: "Server Origin", + 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." + }, name: { label: "Device Name" }, diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index 4f270f3cf71..dff73e023c6 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -462,6 +462,10 @@ export const esESObjects: NonNullable = { label: "Configuración regional", help: "Configuración regional del dispositivo, tal como la indicó el registro. Se guarda para el dispositivo; ninguna notificación push se muestra según ella: llega en el idioma del destinatario (su configuración regional de usuario y, si no, la predeterminada de la implantación)." }, + origin: { + label: "Origen del servidor", + help: "El origen desde el que se cargó la consola al registrar el dispositivo (por ejemplo, https://crm.example.com). Todas las notificaciones push al dispositivo lo incluyen, de modo que una app móvil con varios servidores guardados abre la notificación en este. Vacío en un dispositivo registrado sin él." + }, name: { label: "Nombre del dispositivo" }, diff --git a/packages/services/service-messaging/src/translations/fr.objects.generated.ts b/packages/services/service-messaging/src/translations/fr.objects.generated.ts index 13a07b901c9..d376b19c98b 100644 --- a/packages/services/service-messaging/src/translations/fr.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/fr.objects.generated.ts @@ -462,6 +462,10 @@ export const frObjects: NonNullable = { label: "Paramètres régionaux", help: "Paramètres régionaux de l’appareil, tels que l’enregistrement les a indiqués. Conservés pour l’appareil ; aucune notification push n’est rendue selon eux : une notification push arrive dans la langue du destinataire (sa langue d’utilisateur, sinon la langue par défaut du déploiement)." }, + origin: { + label: "Origine du serveur", + help: "L’origine depuis laquelle la console a été chargée lors de l’enregistrement de l’appareil (par exemple https://crm.example.com). Chaque notification push envoyée à l’appareil la porte, afin qu’une application mobile ayant plusieurs serveurs enregistrés ouvre la notification sur celui-ci. Vide pour un appareil enregistré sans origine." + }, name: { label: "Nom de l’appareil" }, diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index 658bdd1716c..d2a2cc6875d 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -462,6 +462,10 @@ export const jaJPObjects: NonNullable = { label: "ロケール", help: "登録時に指定されたデバイスのロケール。デバイスに保存されますが、プッシュ通知の表示には使われません。プッシュ通知は受信者の言語(ユーザーのロケール、次にデプロイメントの既定)で届きます。" }, + origin: { + label: "サーバーオリジン", + help: "デバイス登録時にコンソールが読み込まれたオリジン(例: https://crm.example.com)。このデバイスへのすべてのプッシュ通知に含まれるため、複数のサーバーを保存しているモバイルシェルはこのサーバーでプッシュ通知を開きます。オリジンなしで登録されたデバイスでは空です。" + }, name: { label: "デバイス名" }, diff --git a/packages/services/service-messaging/src/translations/ko.objects.generated.ts b/packages/services/service-messaging/src/translations/ko.objects.generated.ts index 786af772fbd..715ce578dab 100644 --- a/packages/services/service-messaging/src/translations/ko.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ko.objects.generated.ts @@ -462,6 +462,10 @@ export const koObjects: NonNullable = { label: "로캘", help: "등록 시 전달된 기기 로캘입니다. 기기에 저장되지만 어떤 푸시도 이 값으로 렌더링되지 않습니다. 푸시는 수신자의 언어(사용자 로캘, 없으면 배포 기본 언어)로 도착합니다." }, + origin: { + label: "서버 출처", + help: "기기를 등록할 때 콘솔이 로드된 출처입니다(예: https://crm.example.com). 이 기기로 보내는 모든 푸시 알림에 포함되므로, 여러 서버를 저장한 모바일 앱은 이 서버에서 푸시 알림을 엽니다. 출처 없이 등록된 기기에서는 비어 있습니다." + }, name: { label: "기기 이름" }, diff --git a/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts b/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts index 499966afd74..4a7049b0cb2 100644 --- a/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts @@ -462,6 +462,10 @@ export const ptBRObjects: NonNullable = { label: "Localidade", help: "Localidade do dispositivo, conforme informada no registro. Armazenada para o dispositivo; nenhum push é renderizado por ela: um push chega no idioma do destinatário (o idioma do usuário dele e, em seguida, o padrão da implantação)." }, + origin: { + label: "Origem do servidor", + help: "A origem a partir da qual o console foi carregado ao registrar o dispositivo (por exemplo, https://crm.example.com). Toda notificação push para o dispositivo a inclui, para que um app móvel com vários servidores salvos abra a notificação neste. Vazio para um dispositivo registrado sem ela." + }, name: { label: "Nome do dispositivo" }, diff --git a/packages/services/service-messaging/src/translations/ru.objects.generated.ts b/packages/services/service-messaging/src/translations/ru.objects.generated.ts index fbfaa4c30ec..4f5cd48b144 100644 --- a/packages/services/service-messaging/src/translations/ru.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ru.objects.generated.ts @@ -462,6 +462,10 @@ export const ruObjects: NonNullable = { label: "Языковой стандарт", help: "Языковой стандарт устройства, указанный при регистрации. Хранится для устройства; ни одно push-уведомление не отображается по нему: push-уведомление приходит на языке получателя (его языке пользователя, затем на языке развёртывания по умолчанию)." }, + origin: { + label: "Источник сервера", + help: "Источник, с которого была загружена консоль при регистрации устройства (например, https://crm.example.com). Его несёт каждое push-уведомление на это устройство, поэтому мобильное приложение с несколькими сохранёнными серверами открывает уведомление на этом сервере. Пусто для устройства, зарегистрированного без него." + }, name: { label: "Имя устройства" }, diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 9686ee20225..fe36823acf1 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -462,6 +462,10 @@ export const zhCNObjects: NonNullable = { label: "语言区域", help: "注册时提交的设备语言区域,随设备保存;推送不按它渲染:推送以收件人的语言送达(其用户语言,其次部署默认语言)。" }, + origin: { + label: "服务器来源", + help: "注册设备时控制台的加载来源(例如 https://crm.example.com)。发往该设备的每条推送都会携带它,因此保存了多个服务器的移动端外壳会在这个服务器上打开推送。未带来源注册的设备此项为空。" + }, name: { label: "设备名称" }, diff --git a/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts index 9699ed24988..356092240d1 100644 --- a/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts @@ -462,6 +462,10 @@ export const zhHantObjects: NonNullable = { label: "地區語言", help: "註冊時提交的裝置地區語言,隨裝置儲存;推播不依它呈現:推播以收件人的語言送達(其使用者語言,其次為部署預設語言)。" }, + origin: { + label: "伺服器來源", + help: "註冊裝置時主控台的載入來源(例如 https://crm.example.com)。發往該裝置的每則推播都會攜帶它,因此儲存了多個伺服器的行動端外殼會在這個伺服器上開啟推播。未帶來源註冊的裝置此項為空。" + }, name: { label: "裝置名稱" }, diff --git a/packages/services/service-push/src/push-service.ts b/packages/services/service-push/src/push-service.ts index 5092a4e237f..a64d82d8373 100644 --- a/packages/services/service-push/src/push-service.ts +++ b/packages/services/service-push/src/push-service.ts @@ -6,8 +6,10 @@ import type { PushVendor } from '@objectstack/spec/system'; * One push to one device, as the push channel hands it over. * * `data` is the push's delivery data (`PushDeliveryDataSchema`, - * `@objectstack/spec/api`): the notification id and, when it is a same-server - * path, its `route`. Every value is a string, because FCM's `message.data` + * `@objectstack/spec/api`): the notification id; when it is a same-server + * path, its `route`; and, when the device registered one, its `origin` (the + * server the Console was loaded from, by which a shell with several saved + * servers opens the push). Every value is a string, because FCM's `message.data` * takes only strings. Each transport places it where its vendor reads it: FCM * in `message.data`, APNs at the top level of the payload beside `aps`. */ diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index a49f085dd09..ec862aaeebf 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -3689,10 +3689,11 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({ * `DELETE /api/v1/notifications/devices/:deviceId`. * * One request does both: registering a `deviceId` the user already has - * refreshes that device (its token, vendor, app and locale) instead of adding - * a second one. A request without `deviceId` gets one minted by the server - * and answered in {@link RegisterDeviceResponseSchema}; the client keeps it - * and sends it on every later registration and on unregister. + * refreshes that device (its token, vendor, platform, app, locale, origin and + * name) instead of adding a second one. A request without `deviceId` gets + * one minted by the server and answered in {@link RegisterDeviceResponseSchema}; + * the client keeps it and sends it on every later registration and on + * unregister. * * The members after `name` (`vendor`, `appId`, `locale`) were added for the * push channel, all optional, so the body a client sent before still parses @@ -3718,7 +3719,7 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({ + 'recipient\'s language (sys_user.locale, then the deployment default)', ), origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe( - '[PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example ' + '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 ' diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index 3e39a9d8c29..aced5b50aac 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -68,7 +68,7 @@ export const UserDeviceSchema = lazySchema(() => z.object({ ), name: z.string().optional().describe('Device friendly name, as the registration named it'), origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe( - '[PLANNED — not yet stored] The origin the Console was loaded from, as the registration named it (for example ' + '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', ), diff --git a/packages/spec/src/system/push-server-origin.test.ts b/packages/spec/src/system/push-server-origin.test.ts index 08fa6e6f031..e6f346ab067 100644 --- a/packages/spec/src/system/push-server-origin.test.ts +++ b/packages/spec/src/system/push-server-origin.test.ts @@ -103,10 +103,12 @@ describe('the three origin members are one contract', () => { expect(new Set(patterns).size).toBe(1); }); - it('marks the device-side members PLANNED until the server stores the origin', () => { - expect(RegisterDeviceRequestSchema.shape.origin.description).toMatch(/^\[PLANNED — not yet stored\] /); - expect(UserDeviceSchema.shape.origin.description).toMatch(/^\[PLANNED — not yet stored\] /); - // The push data is served: the push channel produces it and copies a stored origin (#29041). + it('marks no member PLANNED: the server stores the origin and every push carries it back', () => { + // The device registry stores the registration's origin on sys_user_device and the push + // channel copies it into the push data (service-messaging), so all three members are served. + expect(RegisterDeviceRequestSchema.shape.origin.description).not.toMatch(/PLANNED/); + expect(UserDeviceSchema.shape.origin.description).not.toMatch(/PLANNED/); + expect(PushDeliveryDataSchema.shape.origin.description).not.toMatch(/PLANNED/); expect(PushDeliveryDataSchema.description).not.toMatch(/PLANNED/); }); }); diff --git a/packages/spec/src/system/push-server-origin.ts b/packages/spec/src/system/push-server-origin.ts index 8c3209e4218..17eb89440b7 100644 --- a/packages/spec/src/system/push-server-origin.ts +++ b/packages/spec/src/system/push-server-origin.ts @@ -20,13 +20,32 @@ * the push goes to, and the shell picks the saved server by it. * * The shape is a serialized URL origin, exactly what `window.location.origin` - * answers: `http` or `https`, `://`, a lowercase host (a DNS name or a - * bracketed IPv6 address) and an optional port. Nothing else: no path (not - * even a trailing `/`), no query, fragment or user info. Because the value is - * that canonical form, the shell can compare it with a saved server's origin - * byte for byte. A path, another scheme or a bare host is refused, with the - * same message in all three schemas. The rule is a pattern, so the published - * JSON Schema carries it too. + * answers: `http` or `https`, `://`, a lowercase host (a DNS name, a dotted + * IPv4 address, which the DNS-name arm admits, or a bracketed IPv6 address) + * and an optional port. Nothing else: no path (not even a trailing `/`), no + * query, fragment or user info. Because the value is that canonical form, the + * shell can compare it with a saved server's origin byte for byte. A path, + * another scheme or a bare host is refused, with the same message in all three + * schemas. The rule is a pattern, so the published JSON Schema carries it too. + * + * Where the pattern is narrower or looser than a URL origin, knowingly: + * + * - **Narrower: DNS-hostname labels.** A label is letters, digits and inner + * hyphens, so an origin `window.location.origin` can answer is still refused + * when its host has an underscore (`http://my_svc:3000`, a container or + * compose service name) or ends in a dot (a fully qualified + * `https://crm.example.com.`). A registration naming one is refused as a + * whole, like any refused member, so the producer leaves `origin` out for + * such a host; that device's pushes then carry no origin. + * - **Looser: the port and the IPv6 arm.** The port is one to five digits, so + * a default port written out (`:443` on https, `:80` on http), a leading + * zero or a number past 65535 is admitted, none of which + * `window.location.origin` ever answers; the bracketed arm admits any run of + * hex digits, colons and dots, `[:::]` included. Nothing here canonicalises + * or range-checks either. The bound is the producer: the Console is the one + * writer, and it sends `window.location.origin` as the browser serialised + * it, which is already canonical. A value no browser produces equals no + * saved server's origin byte for byte, so it can never name the wrong one. * * Internal to the package: no barrel re-exports this module, so it adds no * public name. Each schema builds its own `z.string().regex(...)` from it and