Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/29768-push-device-origin-stored.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


---
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/push-device.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, string> }> = [];
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);
});
});
2 changes: 2 additions & 0 deletions packages/services/service-messaging/src/device-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const arObjects: NonNullable<TranslationData['objects']> = {
label: "اللغة المحلية",
help: "لغة الجهاز كما ذكرها التسجيل. تُخزَّن للجهاز، ولا يُعرض أي إشعار فوري بها: يصل الإشعار الفوري بلغة المستلم (لغة المستخدم لديه، ثم اللغة الافتراضية للنشر)."
},
origin: {
label: "أصل الخادم",
help: "الأصل الذي حُمِّلت منه وحدة التحكم عند تسجيل الجهاز (مثل https://crm.example.com). يحمله كل إشعار فوري يُرسل إلى الجهاز، فيفتح تطبيق الهاتف الذي حفظ عدة خوادم الإشعار على هذا الخادم. فارغ للجهاز المسجَّل بدونه."
},
name: {
label: "اسم الجهاز"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const deObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const frObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
label: "ロケール",
help: "登録時に指定されたデバイスのロケール。デバイスに保存されますが、プッシュ通知の表示には使われません。プッシュ通知は受信者の言語(ユーザーのロケール、次にデプロイメントの既定)で届きます。"
},
origin: {
label: "サーバーオリジン",
help: "デバイス登録時にコンソールが読み込まれたオリジン(例: https://crm.example.com)。このデバイスへのすべてのプッシュ通知に含まれるため、複数のサーバーを保存しているモバイルシェルはこのサーバーでプッシュ通知を開きます。オリジンなしで登録されたデバイスでは空です。"
},
name: {
label: "デバイス名"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const koObjects: NonNullable<TranslationData['objects']> = {
label: "로캘",
help: "등록 시 전달된 기기 로캘입니다. 기기에 저장되지만 어떤 푸시도 이 값으로 렌더링되지 않습니다. 푸시는 수신자의 언어(사용자 로캘, 없으면 배포 기본 언어)로 도착합니다."
},
origin: {
label: "서버 출처",
help: "기기를 등록할 때 콘솔이 로드된 출처입니다(예: https://crm.example.com). 이 기기로 보내는 모든 푸시 알림에 포함되므로, 여러 서버를 저장한 모바일 앱은 이 서버에서 푸시 알림을 엽니다. 출처 없이 등록된 기기에서는 비어 있습니다."
},
name: {
label: "기기 이름"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,10 @@ export const ptBRObjects: NonNullable<TranslationData['objects']> = {
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"
},
Expand Down
Loading
Loading