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
18 changes: 18 additions & 0 deletions .changeset/29642-push-server-origin.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |


---
Expand Down
13 changes: 12 additions & 1 deletion content/docs/references/api/push-delivery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -71,14 +81,15 @@ 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

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |


---
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/system/push-device.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/src/api/protocol.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand Down
21 changes: 19 additions & 2 deletions packages/spec/src/api/push-delivery.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<typeof PushDeliveryDataSchema>;
6 changes: 6 additions & 0 deletions packages/spec/src/system/push-device.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, '
Expand Down
112 changes: 112 additions & 0 deletions packages/spec/src/system/push-server-origin.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends object>(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\] /);
});
});
Loading
Loading