diff --git a/.changeset/29642-push-server-origin.md b/.changeset/29642-push-server-origin.md new file mode 100644 index 00000000000..772311c9cd1 --- /dev/null +++ b/.changeset/29642-push-server-origin.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): push delivery data names the server that sent it — `RegisterDeviceRequest`, `UserDevice` and `PushDeliveryData` each gain an optional `origin` + +Clause-②: yes (widening) +Metadata-protocol: added + +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: + +- **`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. +- **`UserDeviceSchema.origin`** (the stored device row): the registration's value, kept on the device. +- **`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. + +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. + +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. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index cee001e6c49..9cb29d34547 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2774,6 +2774,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) that pushes to this device are rendered in | +| **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 | --- diff --git a/content/docs/references/api/push-delivery.mdx b/content/docs/references/api/push-delivery.mdx index edfb78e9492..42146340a7c 100644 --- a/content/docs/references/api/push-delivery.mdx +++ b/content/docs/references/api/push-delivery.mdx @@ -49,6 +49,16 @@ had open, re-checked against the active apps, then the first active app, then `setup`). It never re-implements the resolver and never opens `route` verbatim. +**Which server sent it — `origin`.** APNs and FCM tell the shell nothing +about the sender, and every server a shell has saved registers the same +vendor token, so the data names the server itself. `origin` is the origin +the Console was loaded from when it registered the device +(`RegisterDeviceRequestSchema.origin`), kept on the device row +(`UserDeviceSchema.origin`) and copied here by the push channel. It is the +address the shell saved for that server, so the shell opens the tap on the +saved server whose origin it equals and resolves `route` there. The rule +the three members share lives in `system/push-server-origin.ts`. + Not to be confused with the `PushNotificationSchema` that `system/` once declared and then removed (ADR-0049): that was an authoring template shape; this is the wire data of a delivery, produced by the platform. @@ -71,7 +81,7 @@ const result = PushDeliveryDataSchema.parse(data); ## PushDeliveryData -[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 +[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 ### Properties @@ -79,6 +89,7 @@ const result = PushDeliveryDataSchema.parse(data); | :--- | :--- | :--- | :--- | | **notificationId** | `string` | ✅ | Notification id — the one the inbox lists and markRead takes | | **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 | +| **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 | --- diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx index 0dda05e0312..9db8f6def14 100644 --- a/content/docs/references/system/push-device.mdx +++ b/content/docs/references/system/push-device.mdx @@ -38,6 +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) that pushes to this device are rendered in | | **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 | | **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/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 5593fb514f7..be699d4d15f 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1438,6 +1438,7 @@ "api/PublishPackageDraftsResponse:unhiddenApps", "api/PublishPackageDraftsResponse:unhideError", "api/PushDeliveryData:notificationId", + "api/PushDeliveryData:origin", "api/PushDeliveryData:route", "api/QueryAdapterConfig:odata", "api/QueryAdapterConfig:operatorMappings", @@ -1541,6 +1542,7 @@ "api/RegisterDeviceRequest:deviceId", "api/RegisterDeviceRequest:locale", "api/RegisterDeviceRequest:name", + "api/RegisterDeviceRequest:origin", "api/RegisterDeviceRequest:platform", "api/RegisterDeviceRequest:token", "api/RegisterDeviceRequest:vendor", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 71186982f34..83455a3fc87 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -1405,6 +1405,7 @@ "system/UserDevice:lastSeenAt", "system/UserDevice:locale", "system/UserDevice:name", + "system/UserDevice:origin", "system/UserDevice:platform", "system/UserDevice:token", "system/UserDevice:userId", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index edec2696839..e6131c70352 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -31,6 +31,7 @@ import { ActionDescriptorSchema } from '../automation/node-executor.zod'; import { ServedTranslationDataSchema, LocaleSchema } from '../system/translation.zod'; import { PushVendorSchema } from '../system/push-config.zod'; import { PUSH_DEVICE_PLATFORMS } from '../system/push-device-platform'; +import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from '../system/push-server-origin'; // #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item // responses publish. Same three vocabularies the resolver filters against, so a // value this spec cannot name is a value the resolver would have dropped. @@ -3549,6 +3550,13 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({ ), appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), + 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 ' + + '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', + ), }).describe('Register or refresh a push device for the signed-in user')); export const RegisterDeviceResponseSchema = lazySchema(() => z.object({ diff --git a/packages/spec/src/api/push-delivery.zod.ts b/packages/spec/src/api/push-delivery.zod.ts index f69f8a90678..0352926e6b7 100644 --- a/packages/spec/src/api/push-delivery.zod.ts +++ b/packages/spec/src/api/push-delivery.zod.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; +import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from '../system/push-server-origin'; /** * Push delivery data — what a push carries to the shell. @@ -47,6 +48,16 @@ import { lazySchema } from '../shared/lazy-schema'; * then `setup`). It never re-implements the resolver and never opens `route` * verbatim. * + * **Which server sent it — `origin`.** APNs and FCM tell the shell nothing + * about the sender, and every server a shell has saved registers the same + * vendor token, so the data names the server itself. `origin` is the origin + * the Console was loaded from when it registered the device + * (`RegisterDeviceRequestSchema.origin`), kept on the device row + * (`UserDeviceSchema.origin`) and copied here by the push channel. It is the + * address the shell saved for that server, so the shell opens the tap on the + * saved server whose origin it equals and resolves `route` there. The rule + * the three members share lives in `system/push-server-origin.ts`. + * * Not to be confused with the `PushNotificationSchema` that `system/` once * declared and then removed (ADR-0049): that was an authoring template shape; * this is the wire data of a delivery, produced by the platform. @@ -93,8 +104,14 @@ export const PushDeliveryDataSchema = lazySchema(() => z.object({ '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', ), + origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe( + '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', + ), }).describe( - '[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', + '[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', )); export type PushDeliveryData = z.input; diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index bacdd09e5b9..dac832b122e 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -5,6 +5,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { PushVendorSchema } from './push-config.zod'; import { LocaleSchema } from './translation.zod'; import { PUSH_DEVICE_PLATFORMS } from './push-device-platform'; +import { PUSH_SERVER_ORIGIN_MESSAGE, PUSH_SERVER_ORIGIN_PATTERN } from './push-server-origin'; /** * Push Device Protocol — the stored device shape of the push device registry @@ -57,6 +58,11 @@ export const UserDeviceSchema = lazySchema(() => z.object({ appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'), locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'), 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 ' + + '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: z.boolean().describe('False once the vendor reported the token dead; nothing is delivered to a disabled device'), invalidatedAt: z.string().datetime().optional().describe( 'When the vendor reported the token dead. Present exactly while enabled is false: absent on an enabled device, ' diff --git a/packages/spec/src/system/push-server-origin.test.ts b/packages/spec/src/system/push-server-origin.test.ts new file mode 100644 index 00000000000..200cb5eb42d --- /dev/null +++ b/packages/spec/src/system/push-server-origin.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The server origin a push names (ruling B on the push delivery decision +// card): the Console sends the origin it was loaded from when it registers +// the device, the device row keeps it, and the push data carries it back, so +// a mobile shell with several saved servers opens the tap on the right one. +// Three optional members, one rule: what one accepts the other two accept, +// and what one refuses the other two refuse, with the same code and message. + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { RegisterDeviceRequestSchema } from '../api/protocol.zod'; +import { PushDeliveryDataSchema } from '../api/push-delivery.zod'; +import { UserDeviceSchema } from './push-device.zod'; +import { PUSH_SERVER_ORIGIN_MESSAGE } from './push-server-origin'; + +const REGISTRATION = { token: 'apns-device-token', platform: 'ios' } as const; +const DEVICE = { + deviceId: 'install-1', + userId: 'usr_1', + vendor: 'apns', + token: 'apns-device-token', + platform: 'ios', + enabled: true, +} as const; +const DELIVERY = { notificationId: 'ntf_1', route: '/opportunity/123' } as const; +const CARRIER_BODY = { + RegisterDeviceRequestSchema: REGISTRATION, + UserDeviceSchema: DEVICE, + PushDeliveryDataSchema: DELIVERY, +} as const; + +// Leaves the key out when no origin is given, so the optional leg sends the body clients send today. +const withOrigin = (body: T, origin?: string) => (origin === undefined ? body : { ...body, origin }); + +const CARRIERS = [ + ['RegisterDeviceRequestSchema', (origin?: string) => RegisterDeviceRequestSchema.safeParse(withOrigin(REGISTRATION, origin))], + ['UserDeviceSchema', (origin?: string) => UserDeviceSchema.safeParse(withOrigin(DEVICE, origin))], + ['PushDeliveryDataSchema', (origin?: string) => PushDeliveryDataSchema.safeParse(withOrigin(DELIVERY, origin))], +] as const; + +describe.each(CARRIERS)('%s.origin', (_name, parse) => { + it('is optional: the body without it parses unchanged', () => { + const parsed = parse(); + expect(parsed.success).toBe(true); + expect(parsed.data).not.toHaveProperty('origin'); + expect(parsed.data).toEqual(withOrigin(CARRIER_BODY[_name])); + }); + + it.each([ + 'https://crm.example.com', + 'http://localhost:3000', + 'https://xn--fiq228c.example:8443', + 'http://192.168.1.20:3000', + 'http://[::1]:3000', + ])('round-trips the Console origin %j', (origin) => { + const parsed = parse(origin); + expect(parsed.success).toBe(true); + expect((parsed.data as { origin?: string }).origin).toBe(origin); + }); + + it.each([ + // A path, a trailing slash, a query or a fragment: an origin has none. + 'https://crm.example.com/', + 'https://crm.example.com/console', + 'https://crm.example.com?x=1', + 'https://crm.example.com#top', + // Another scheme. + 'ftp://crm.example.com', + 'wss://crm.example.com', + 'javascript:alert(1)', + // A bare host, with or without a port, and a protocol-relative one. + 'crm.example.com', + 'localhost:3000', + '//crm.example.com', + // Not the canonical form window.location.origin answers. + 'HTTPS://crm.example.com', + 'https://CRM.example.com', + 'https://user@crm.example.com', + 'https://crm.example.com:', + ' https://crm.example.com', + '', + ])('refuses %j with the one origin refusal', (origin) => { + const parsed = parse(origin); + expect(parsed.success).toBe(false); + expect(parsed.error?.issues).toHaveLength(1); + expect(parsed.error?.issues[0]).toMatchObject({ + path: ['origin'], + code: 'invalid_format', + message: PUSH_SERVER_ORIGIN_MESSAGE, + }); + }); +}); + +describe('the three origin members are one contract', () => { + it('publishes the same pattern in each JSON Schema', () => { + const patterns = [RegisterDeviceRequestSchema, UserDeviceSchema, PushDeliveryDataSchema].map((schema) => { + const json = z.toJSONSchema(schema, { io: 'input' }) as unknown as { properties: { origin: { pattern?: string } }; required?: string[] }; + expect(json.required ?? []).not.toContain('origin'); + return json.properties.origin.pattern; + }); + expect(patterns[0]).toBeTruthy(); + 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 carries its marker on the whole schema until a push channel produces it. + expect(PushDeliveryDataSchema.description).toMatch(/^\[PLANNED — not yet served\] /); + }); +}); diff --git a/packages/spec/src/system/push-server-origin.ts b/packages/spec/src/system/push-server-origin.ts new file mode 100644 index 00000000000..8c3209e4218 --- /dev/null +++ b/packages/spec/src/system/push-server-origin.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The server origin a push names: the origin the Console was loaded from + * (`window.location.origin`), which is the address the mobile shell saved + * for that server. A shell with several saved servers reads it on tap to open + * the push on the server that sent it. + * + * One rule for the three schemas that carry it, so a value the registration + * accepts is one the device row stores and the push data carries: + * + * - the registration body (`RegisterDeviceRequestSchema.origin`, + * `api/protocol.zod.ts`): the Console writes it when it registers the + * device; + * - the stored device (`UserDeviceSchema.origin`, + * `system/push-device.zod.ts`): the device registry keeps the + * registration's value on the row; + * - the push data (`PushDeliveryDataSchema.origin`, + * `api/push-delivery.zod.ts`): the push channel copies it from the device + * 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. + * + * 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 + * describes it. + */ +export const PUSH_SERVER_ORIGIN_PATTERN = + /^https?:\/\/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(?::[0-9]{1,5})?$/; + +/** The one refusal message the three `origin` members answer. */ +export const PUSH_SERVER_ORIGIN_MESSAGE = + 'A push server origin is the origin the Console was loaded from: http:// or https://, a lowercase host and an ' + + 'optional port, with no path, query or fragment (for example https://crm.example.com or http://localhost:3000).';