diff --git a/.changeset/29068-push-device-registry.md b/.changeset/29068-push-device-registry.md
new file mode 100644
index 00000000000..f1a02ad1499
--- /dev/null
+++ b/.changeset/29068-push-device-registry.md
@@ -0,0 +1,18 @@
+---
+'@objectstack/service-messaging': patch
+'@objectstack/runtime': patch
+'@objectstack/client': patch
+'@objectstack/spec': patch
+---
+
+feat(notifications): the push device registry is served: `sys_user_device`, `POST /api/v1/notifications/devices` and `DELETE /api/v1/notifications/devices/:deviceId`, and the client's `notifications.registerDevice` / `unregisterDevice` (ADR-0012 §6)
+
+Clause-②: yes (widening)
+Metadata-protocol: none
+
+Nothing a client or an operator sends today is refused, and no metadata an author writes changes: the routes and the client methods are new, and the spec change is two catalog rows and description text.
+
+- **`@objectstack/service-messaging`** registers `sys_user_device` (one row per user and app install: `device_id`, `user_id`, `vendor`, `token`, `platform`, `app_id`, `locale`, `name`, `enabled`, `invalidated_at`, `last_seen_at`) and implements `INotificationService`'s four device members on `MessagingService`. The table is system-owned: the generic data API neither reads nor writes it (`apiEnabled: false`), and it has no organization column, because a device belongs to its user. `registerDevice` refreshes a device id the user already holds, mints one when the request carries none, and moves a token another device holds to the registering device (the previous holder's row is removed, so a push meant for a phone's previous user never reaches its new one). A token has one holder: a unique index on `token` refuses a second one, and a registration that loses a race re-reads and moves the token. `invalidateDeviceToken` keeps the row with `enabled: false` and `invalidated_at`, and the device's next registration re-enables it. A `web` registration without `vendor` is refused `400 VALIDATION_FAILED`.
+- **`@objectstack/runtime`** serves `POST /api/v1/notifications/devices` (body: `RegisterDeviceRequestSchema`; answers `{ deviceId, success }`) and `DELETE /api/v1/notifications/devices/:deviceId` (the caller's own device; another user's or an unknown id answers `404 NOT_FOUND` and nothing is removed), behind the domain's anonymous floor (`401 UNAUTHENTICATED`). A notification service without the device members answers `501`. The standalone server mounts both, with the other verbs on the two paths mounted refusal-only like the inbox faces.
+- **`@objectstack/client`**: `client.notifications.registerDevice(request)` and `client.notifications.unregisterDevice(deviceId)`, removed earlier because no server served them, are back.
+- **`@objectstack/spec`**: `DEFAULT_NOTIFICATION_ROUTES` lists the two device routes again, and the `[PLANNED — not yet served]` markers come off the four device registration schemas and `UserDeviceSchema`.
diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx
index c80c91b6a78..fd8f21f0912 100644
--- a/content/docs/permissions/tenant-audit-census.mdx
+++ b/content/docs/permissions/tenant-audit-census.mdx
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
-forwarding shim cannot, and **51 of the 256 sites are spelled that way**. A
+forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.
@@ -160,8 +160,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla
**"No tenant context" counted sites it had not read.** An options argument the
walker could not parse was folded into the same bucket as one it had read and
-found empty. That published **59 sites "carrying no tenant context at all"**
-when 8 said so and 51 were simply unread — an over-claim in the *alarming*
+found empty. That published **63 sites "carrying no tenant context at all"**
+when 8 said so and 55 were simply unread — an over-claim in the *alarming*
direction, on the very figure this page tells other cards to cite. `carries` is
now three-valued, and an unreadable argument can never contribute to the
provable count.
@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
| carried figure | where it survives | this census |
| :--- | :--- | ---: |
-| 175 write call sites | quoted in the merged changeset | **256** |
+| 175 write call sites | quoted in the merged changeset | **261** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
-| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 256** decidable, **79** undecidable |
-| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 102 undecidable |
+| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **182 of 261** decidable, **79** undecidable |
+| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 107 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |
**The differences are not reconciled, and deliberately so.** The old census's
@@ -211,17 +211,17 @@ at any commit.
Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 62 sites reached
-through an erased (`any`) receiver, and the 61 that name their object through a
+through an erased (`any`) receiver, and the 66 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
-146 of 256 (57%) as decidably elevated, with 102 more whose elevation is a
+146 of 261 (56%) as decidably elevated, with 107 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.
-⇒ **Cite `2 / 256`, and say what it is**: the sites whose options argument was
+⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **29 further sites** have an options argument this
@@ -233,29 +233,29 @@ cannot read, and they are neither in nor out.
| what | count |
| :--- | ---: |
-| write call sites on the application surface | **256** |
-| …whose object name is statically decidable | 177 |
+| write call sites on the application surface | **261** |
+| …whose object name is statically decidable | 182 |
| …whose object name is chosen at run time | 79 |
| …against an object with tenancy ENABLED | 176 |
-| …against an object that declares tenancy off | 1 |
-| threading a tenant context | 197 |
+| …against an object that declares tenancy off | 6 |
+| threading a tenant context | 198 |
| PROVABLY carrying none (options read, no context key) | **8** |
| …of those, against a decidably tenancy-enabled object | **2** |
-| options argument UNREADABLE — may or may not carry one | 51 |
+| options argument UNREADABLE — may or may not carry one | 55 |
| …of those, against a decidably tenancy-enabled object | 29 |
| threading a decidably ELEVATED (`isSystem`) context | 146 |
| threading a context that is decidably NOT elevated | 0 |
-| threading a context whose elevation is a run-time fact | 102 |
+| threading a context whose elevation is a run-time fact | 107 |
| how the instrument reached the site | count |
| :--- | ---: |
-| receiver carried a readable engine type | 194 |
+| receiver carried a readable engine type | 199 |
| receiver erased, placed by the object NAME | 52 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
| object name spelled inline | 116 |
-| object name spelled through a `const` | 61 |
+| object name spelled through a `const` | 66 |
| object name is an `object: string` parameter | 7 |
| object name is some other run-time expression | 72 |
@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.
-Measured on 2026-11-02 at `ac273cf07`.
+Measured on 2026-11-02 at `187d77e7f`.
| corpus scale (not enforced) | count |
| :--- | ---: |
-| tracked non-test sources scanned | 852 |
-| engine-shaped types recognised | 99 |
-| declared objects in the registry | 125 |
-| same-named calls subtracted as non-engine | 227 |
+| tracked non-test sources scanned | 854 |
+| engine-shaped types recognised | 101 |
+| declared objects in the registry | 126 |
+| same-named calls subtracted as non-engine | 228 |
{/* END GENERATED: tenant-audit-census */}
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index 6dc761fcc64..cee001e6c49 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -2761,7 +2761,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
## RegisterDeviceRequest
-[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers
+Register or refresh a push device for the signed-in user
### Properties
@@ -2780,7 +2780,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
## RegisterDeviceResponse
-[PLANNED — not yet served] Answer to a device registration: the device id the client keeps
+Answer to a device registration: the device id the client keeps
### Properties
@@ -3026,7 +3026,7 @@ Uninstall package response
## UnregisterDeviceRequest
-[PLANNED — not yet served] Unregister one of the signed-in user's push devices
+Unregister one of the signed-in user's push devices
### Properties
@@ -3039,7 +3039,7 @@ Uninstall package response
## UnregisterDeviceResponse
-[PLANNED — not yet served] Answer to a device unregistration
+Answer to a device unregistration
### Properties
diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx
index d88aa29e12a..0dda05e0312 100644
--- a/content/docs/references/system/push-device.mdx
+++ b/content/docs/references/system/push-device.mdx
@@ -1,50 +1,11 @@
---
title: Push Device schema — System Protocol reference
navTitle: Push Device
-description: "Push Device Protocol — the stored device shape of the push device registry."
+description: "Push Device schemas of the ObjectStack System Protocol: UserDevice — each property with its type, default and a TypeScript example."
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-Push Device Protocol — the stored device shape of the push device registry
-(ADR-0012 §6, narrowed to what core ships).
-
-One row per (user, app install): what a device registration
-(`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the
-push channel reads to address a phone, and what the registry's listing
-answers (`INotificationService.listDevices`).
-
-**Declared ahead of its producer.** No table stores it and no route serves
-it yet; the schema carries a `[PLANNED — not yet served]` marker in its
-`.describe()` until the device registry lands (in the push channel plugin's
-`sys_user_device`, ADR-0012 §11).
-
-Three rules the registry owes, stated here because they decide the shape:
-
-- **(a) A device belongs to a user, not to an organization.** The row has no
- organization member. A phone signed in to one person receives that
- person's notifications whichever organization they come from; the
- organization travels on the notification, never on the device. The
- identity of a row is (`userId`, `deviceId`).
-- **(b) One live row per token.** A vendor token is delivered to one row
- only. A token this user already holds under another `deviceId` (the app's
- storage was cleared, so the registration carries no `deviceId`) refreshes
- that row and answers its `deviceId`; it never makes a second live row.
- When a second user registers a token another user's device holds, the
- token moves to the second user and the first user's row for it is
- removed, so a push meant for the phone's previous user never reaches its
- new one.
-- **(c) A dead token keeps its row.** When the vendor reports a token dead
- (APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is
- kept with `enabled: false` and `invalidatedAt` set, so "why did this phone
- stop getting pushes" stays answerable. Nothing is delivered to a disabled
- row, and a later registration of the same device re-enables it and clears
- `invalidatedAt`.
-
-Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the
-domestic Android vendors plug in as alternate channel implementations) and
-`platform` is the registration's own three (`ios` | `android` | `web`).
-
**Source:** `packages/spec/src/system/push-device.zod.ts`
@@ -63,7 +24,7 @@ const result = UserDeviceSchema.parse(data);
## UserDevice
-[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; the device registry lands with the push channel
+A push device of one user: one row per app install
### Properties
diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
index d20a85bc20c..a0096c68e3f 100644
--- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
+++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
@@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
| Measure | Value |
|---|---:|
-| Write call sites | 256 |
-| Object name statically decidable | 177 |
+| Write call sites | 261 |
+| Object name statically decidable | 182 |
| Object name chosen at run time | 79 |
| Against a tenancy-enabled object | 176 |
-| Against an object declaring tenancy off | 1 |
-| Threading a tenant context | 197 |
+| Against an object declaring tenancy off | 6 |
+| Threading a tenant context | 198 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
-| Options argument unreadable | 51 |
+| Options argument unreadable | 55 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 146 |
| Threading a decidably non-elevated context | 0 |
-| Threading a context of undecidable elevation | 102 |
+| Threading a context of undecidable elevation | 107 |
## Subtractions the census could NOT defend — enforced
@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.
-Measured on 2026-11-02 at `ac273cf07`.
+Measured on 2026-11-02 at `187d77e7f`.
| corpus scale (not enforced) | count |
| :--- | ---: |
-| tracked non-test sources scanned | 852 |
-| engine-shaped types recognised | 99 |
-| declared objects in the registry | 125 |
-| same-named calls subtracted as non-engine | 227 |
+| tracked non-test sources scanned | 854 |
+| engine-shaped types recognised | 101 |
+| declared objects in the registry | 126 |
+| same-named calls subtracted as non-engine | 228 |
## Every site
@@ -233,6 +233,9 @@ Measured on 2026-11-02 at `ac273cf07`.
| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts` | `insert` | `MAIL_MESSAGE_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts` | `update` | `MAIL_MESSAGE_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts` | `update` | `MAILBOX_OBJECT` | undecidable | elevated | 1 |
+| `packages/services/service-messaging/src/device-registry.ts` | `delete` | `sys_user_device` | disabled | options unreadable | 2 |
+| `packages/services/service-messaging/src/device-registry.ts` | `insert` | `sys_user_device` | disabled | context, elevation undecidable | 1 |
+| `packages/services/service-messaging/src/device-registry.ts` | `update` | `sys_user_device` | disabled | options unreadable | 2 |
| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | context, elevation undecidable | 1 |
diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts
index bc85c039d4e..3549c8493c1 100644
--- a/packages/cli/src/utils/organization-ownership-inventory.ts
+++ b/packages/cli/src/utils/organization-ownership-inventory.ts
@@ -352,6 +352,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
),
{ object: 'sys_metadata_activation', census: P1, fate: 'column-drop', citation: `${D7}, D6, D14 (C0): the tenant-less activation ledger` },
{ object: 'sys_presence', census: P1, fate: 'column-drop', citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}` },
+ {
+ object: 'sys_user_device',
+ census: PMAIN,
+ fate: 'column-drop',
+ citation:
+ `${CENSUS_MAIN}: tenancy.enabled: false and no organization_id field; ${D7}: a push device belongs to its user, ` +
+ 'never to an organization (spec UserDeviceSchema, rule a)',
+ },
{
object: 'sys_platform_setting',
census: PMAIN,
diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts
index 5a68ab1601c..2cea6d94707 100644
--- a/packages/client/src/client.test.ts
+++ b/packages/client/src/client.test.ts
@@ -937,6 +937,28 @@ describe('Notifications namespace', () => {
expect(url).toContain('/api/v1/notifications/read/all');
expect(opts.method).toBe('POST');
});
+
+ it('registers a push device with POST /notifications/devices (ADR-0012 §6)', async () => {
+ const { client, fetchMock } = createMockClient({
+ success: true,
+ data: { deviceId: 'install-1', success: true }
+ });
+ const result = await client.notifications.registerDevice({ token: 'tok', platform: 'ios', deviceId: 'install-1', locale: 'zh-CN' });
+ expect(result).toEqual({ deviceId: 'install-1', success: true });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe('http://localhost:3000/api/v1/notifications/devices');
+ expect(opts.method).toBe('POST');
+ expect(JSON.parse(opts.body)).toEqual({ token: 'tok', platform: 'ios', deviceId: 'install-1', locale: 'zh-CN' });
+ });
+
+ it('unregisters a push device with DELETE /notifications/devices/:deviceId, the id encoded', async () => {
+ const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
+ const result = await client.notifications.unregisterDevice('install/1');
+ expect(result).toEqual({ success: true });
+ const [url, opts] = fetchMock.mock.calls[0];
+ expect(url).toBe('http://localhost:3000/api/v1/notifications/devices/install%2F1');
+ expect(opts.method).toBe('DELETE');
+ });
});
describe('AI namespace (#3718)', () => {
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 76fa83d3fb0..68303ead330 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -46,6 +46,9 @@ import {
ListNotificationsResponse,
MarkNotificationsReadResponse,
MarkAllNotificationsReadResponse,
+ RegisterDeviceRequest,
+ RegisterDeviceResponse,
+ UnregisterDeviceResponse,
// The AI wire types (#3718). `Ai{Nlq,Suggest,Insights}{Request,Response}`
// used to be here; they typed three endpoints nothing has ever mounted and
// went with the methods that called them. These type the routes that exist.
@@ -6806,10 +6809,11 @@ export class ObjectStackClient {
/**
* Notification Services
*
- * Device registration and preference methods were removed in #3612 — the
- * /notifications/devices and /notifications/preferences server routes that
- * ADR-0012 describes were never built. They return together with the server
- * (and a route-ledger row keeps them honest).
+ * The device methods (`registerDevice` / `unregisterDevice`) returned with
+ * the server's device registry (ADR-0012 §6), and their route-ledger rows
+ * (`runtime/src/route-ledger.ts`) keep them honest. The preference methods
+ * removed in #3612 stay gone: no server route serves
+ * `/notifications/preferences`.
*/
notifications = {
/**
@@ -6854,6 +6858,37 @@ export class ObjectStackClient {
method: 'POST'
});
return this.unwrapResponse(res);
+ },
+
+ /**
+ * Register or refresh this user's push device (`POST /notifications/devices`).
+ *
+ * Send the `deviceId` the server answered on every later registration:
+ * re-registering it refreshes that device (token, vendor, app, locale)
+ * instead of adding another. Without one the server mints an id and
+ * answers it. `vendor` may be omitted for `ios` (apns) and `android`
+ * (fcm); a `web` registration names it.
+ */
+ registerDevice: async (request: RegisterDeviceRequest): Promise => {
+ const route = this.getRoute('notifications');
+ const res = await this.fetch(`${this.baseUrl}${route}/devices`, {
+ method: 'POST',
+ body: JSON.stringify(request)
+ });
+ return this.unwrapResponse(res);
+ },
+
+ /**
+ * Unregister one of this user's push devices
+ * (`DELETE /notifications/devices/:deviceId`), on sign-out or account
+ * switch. A device id this user does not hold answers 404 `NOT_FOUND`.
+ */
+ unregisterDevice: async (deviceId: string): Promise => {
+ const route = this.getRoute('notifications');
+ const res = await this.fetch(`${this.baseUrl}${route}/devices/${encodeURIComponent(deviceId)}`, {
+ method: 'DELETE'
+ });
+ return this.unwrapResponse(res);
}
};
diff --git a/packages/qa/dogfood/test/push-device-registry.dogfood.test.ts b/packages/qa/dogfood/test/push-device-registry.dogfood.test.ts
new file mode 100644
index 00000000000..89f18ceb885
--- /dev/null
+++ b/packages/qa/dogfood/test/push-device-registry.dogfood.test.ts
@@ -0,0 +1,181 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The push device registry over HTTP on the real showcase stack (ADR-0012
+ * §6): `POST /api/v1/notifications/devices` and
+ * `DELETE /api/v1/notifications/devices/:deviceId`, with the real
+ * composition, the real auth, the real messaging service and the real
+ * `sys_user_device` table.
+ *
+ * The engine-level pins live beside the registry
+ * (`service-messaging/src/device-registry.integration.test.ts`); this file is
+ * the face a signed-in mobile session actually reaches:
+ *
+ * - `[anonymous]` both routes answer `401 UNAUTHENTICATED`;
+ * - `[register]` a member registers, then refreshes the same device id: one
+ * row, the new token;
+ * - `[owner]` another member's unregister of that device answers `404
+ * NOT_FOUND` and the row survives; the owner's removes it;
+ * - `[takeover]` a second member registering the same token takes it over,
+ * and the first member's row for it is gone (their own unregister is 404);
+ * - `[invalidate]` a token the push channel reports dead keeps its row,
+ * disabled; the device's next registration re-enables it and clears the
+ * time;
+ * - `[body]` a body outside `RegisterDeviceRequestSchema`, and a web
+ * registration with no vendor, answer `400 VALIDATION_FAILED`;
+ * - `[data-api]` the generic data API neither lists, reads nor writes
+ * `sys_user_device` for a plain member (`404 OBJECT_API_DISABLED`, the
+ * exposure gate's answer): the device routes are the only door.
+ *
+ * Refusals assert `code` AND `status` (ADR-0112).
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { type VerifyStack } from '@objectstack/verify';
+import { bootShowcase } from './showcase-boot.js';
+
+const OBJ = 'sys_user_device';
+const SYS = { isSystem: true } as const;
+const ALICE = 'device.alice@verify.test';
+const BOB = 'device.bob@verify.test';
+
+const codeOf = (b: any) => (b?.error && typeof b.error === 'object' ? b.error.code : undefined) ?? b?.code ?? null;
+
+describe('push device registry over HTTP (ADR-0012 §6)', () => {
+ let stack: VerifyStack;
+ let ql: any;
+ let aliceTok: string;
+ let bobTok: string;
+ let aliceId: string;
+ let bobId: string;
+
+ const userIdOf = async (email: string): Promise => {
+ const id = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
+ expect(id, `${email} provisioned`).toBeTruthy();
+ return String(id);
+ };
+ const rows = async (where: Record) =>
+ (await ql.find(OBJ, { where, context: SYS })) as Array>;
+ const call = async (token: string, method: string, path: string, body?: unknown) => {
+ const res = await stack.apiAs(token, method, path, body);
+ const text = await res.text();
+ let json: any = null;
+ try { json = JSON.parse(text); } catch { /* not JSON */ }
+ return { status: res.status, body: json, text };
+ };
+
+ beforeAll(async () => {
+ stack = await bootShowcase();
+ aliceTok = await stack.signUp(ALICE);
+ bobTok = await stack.signUp(BOB);
+ ql = await stack.kernel.getServiceAsync('objectql');
+ aliceId = await userIdOf(ALICE);
+ bobId = await userIdOf(BOB);
+ }, 180_000);
+
+ afterAll(async () => { await stack?.stop?.(); });
+
+ it('[anonymous] both routes answer 401 UNAUTHENTICATED', async () => {
+ for (const [method, path, body] of [
+ ['POST', '/api/v1/notifications/devices', { token: 'anon', platform: 'ios' }],
+ ['DELETE', '/api/v1/notifications/devices/anything', undefined],
+ ] as const) {
+ const res = await stack.raw(path, {
+ method,
+ headers: { 'content-type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const text = await res.text();
+ expect(res.status, `${method} ${path}: ${text}`).toBe(401);
+ expect(codeOf(JSON.parse(text))).toBe('UNAUTHENTICATED');
+ }
+ expect(await rows({ token: 'anon' })).toHaveLength(0);
+ });
+
+ it('[register] registers, then refreshes the same device id: one row, the new token', async () => {
+ const first = await call(aliceTok, 'POST', '/notifications/devices', { token: 'alice-tok-1', platform: 'ios', deviceId: 'alice-phone' });
+ expect(first.status, first.text).toBe(200);
+ expect(first.body?.data).toEqual({ deviceId: 'alice-phone', success: true });
+
+ const again = await call(aliceTok, 'POST', '/notifications/devices', { token: 'alice-tok-2', platform: 'ios', deviceId: 'alice-phone' });
+ expect(again.status, again.text).toBe(200);
+
+ const mine = await rows({ user_id: aliceId });
+ expect(mine).toHaveLength(1);
+ expect(mine[0]).toMatchObject({ device_id: 'alice-phone', token: 'alice-tok-2', vendor: 'apns', enabled: true });
+ });
+
+ it('[owner] another member\'s unregister is 404 NOT_FOUND and the row survives; the owner removes it', async () => {
+ await call(aliceTok, 'POST', '/notifications/devices', { token: 'alice-tok-own', platform: 'android', deviceId: 'alice-tablet' });
+
+ const refused = await call(bobTok, 'DELETE', '/notifications/devices/alice-tablet');
+ expect(refused.status, refused.text).toBe(404);
+ expect(codeOf(refused.body)).toBe('NOT_FOUND');
+ expect(await rows({ device_id: 'alice-tablet' })).toHaveLength(1);
+
+ const removed = await call(aliceTok, 'DELETE', '/notifications/devices/alice-tablet');
+ expect(removed.status, removed.text).toBe(200);
+ expect(await rows({ device_id: 'alice-tablet' })).toHaveLength(0);
+ });
+
+ it('[takeover] a second member registering the token takes it over; the first member\'s row is gone', async () => {
+ await call(aliceTok, 'POST', '/notifications/devices', { token: 'shared-phone-tok', platform: 'ios', deviceId: 'alice-old-phone' });
+
+ const taken = await call(bobTok, 'POST', '/notifications/devices', { token: 'shared-phone-tok', platform: 'ios', deviceId: 'bob-phone' });
+ expect(taken.status, taken.text).toBe(200);
+
+ const holders = await rows({ token: 'shared-phone-tok' });
+ expect(holders).toHaveLength(1);
+ expect(String(holders[0].user_id)).toBe(bobId);
+ const gone = await call(aliceTok, 'DELETE', '/notifications/devices/alice-old-phone');
+ expect(gone.status, gone.text).toBe(404);
+ expect(codeOf(gone.body)).toBe('NOT_FOUND');
+ });
+
+ it('[invalidate] a dead token keeps its row disabled; the next registration re-enables it', async () => {
+ await call(bobTok, 'POST', '/notifications/devices', { token: 'bob-dead-tok', platform: 'android', deviceId: 'bob-tablet' });
+ const notification = await stack.kernel.getServiceAsync('notification');
+ await notification.invalidateDeviceToken('bob-dead-tok');
+
+ const [dead] = await rows({ device_id: 'bob-tablet' });
+ expect(dead.enabled).toBe(false);
+ expect(dead.invalidated_at).toBeTruthy();
+
+ const revived = await call(bobTok, 'POST', '/notifications/devices', { token: 'bob-dead-tok', platform: 'android', deviceId: 'bob-tablet' });
+ expect(revived.status, revived.text).toBe(200);
+ const [after] = await rows({ device_id: 'bob-tablet' });
+ expect(after.id).toBe(dead.id);
+ expect(after.enabled).toBe(true);
+ expect(after.invalidated_at ?? null).toBeNull();
+ });
+
+ it('[body] a body outside the schema, and a web registration with no vendor, answer 400 VALIDATION_FAILED', async () => {
+ for (const body of [{ platform: 'ios' }, { token: 't', platform: 'blackberry' }, { token: 'web-tok', platform: 'web' }]) {
+ const res = await call(aliceTok, 'POST', '/notifications/devices', body);
+ expect(res.status, `${JSON.stringify(body)}: ${res.text}`).toBe(400);
+ expect(codeOf(res.body)).toBe('VALIDATION_FAILED');
+ }
+ expect(await rows({ token: 'web-tok' })).toHaveLength(0);
+ });
+
+ it('[data-api] the generic data API neither lists, reads nor writes sys_user_device for a member', async () => {
+ await call(bobTok, 'POST', '/notifications/devices', { token: 'bob-data-api-tok', platform: 'ios', deviceId: 'bob-data-api' });
+ const [own] = await rows({ device_id: 'bob-data-api' });
+ expect(own, 'bob holds a device row to aim at').toBeTruthy();
+ const attempts = [
+ await call(bobTok, 'GET', `/data/${OBJ}`),
+ await call(bobTok, 'GET', `/data/${OBJ}/${own.id}`),
+ await call(bobTok, 'POST', `/data/${OBJ}`, { user_id: bobId, device_id: 'via-data-api', token: 'via-data-api', vendor: 'apns', platform: 'ios' }),
+ await call(bobTok, 'PATCH', `/data/${OBJ}/${own.id}`, { token: 'patched' }),
+ await call(bobTok, 'DELETE', `/data/${OBJ}/${own.id}`),
+ ];
+ // The exposure gate's answer for an object not exposed via the API.
+ for (const res of attempts) {
+ expect(res.status, res.text).toBe(404);
+ expect(codeOf(res.body), res.text).toBe('OBJECT_API_DISABLED');
+ }
+ expect(await rows({ token: 'via-data-api' })).toHaveLength(0);
+ const [still] = await rows({ id: own.id });
+ expect(still?.token).toBe(own.token);
+ });
+});
diff --git a/packages/runtime/src/dispatcher-plugin.routes.test.ts b/packages/runtime/src/dispatcher-plugin.routes.test.ts
index dcda7fd2ec1..e022b9ea7d6 100644
--- a/packages/runtime/src/dispatcher-plugin.routes.test.ts
+++ b/packages/runtime/src/dispatcher-plugin.routes.test.ts
@@ -138,17 +138,22 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
// domain's floor can only answer a verb that is mounted. No sub-path beyond
// the three faces is mounted. The wire answer is pinned in
// `notifications-every-verb.hono.integration.test.ts`.
- it('mounts every verb on each of the three /notifications faces, and no sub-path beyond them', async () => {
+ it('mounts every verb on each of the five /notifications faces, and no sub-path beyond them', async () => {
const { server, routes } = makeFakeServer();
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(makeCtx(server));
- for (const face of ['/notifications', '/notifications/read', '/notifications/read/all']) {
+ // The three inbox faces, and the device registry's two (ADR-0012 §6).
+ const faces = [
+ '/notifications', '/notifications/read', '/notifications/read/all',
+ '/notifications/devices', '/notifications/devices/:deviceId',
+ ];
+ for (const face of faces) {
for (const verb of ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) {
expect(routes.filter((r) => r === `${verb} /api/v1${face}`), `${verb} ${face}`).toHaveLength(1);
}
}
- expect(routes.filter((r) => r.includes('/notifications'))).toHaveLength(15);
+ expect(routes.filter((r) => r.includes('/notifications'))).toHaveLength(25);
});
it('also mounts a known existing route (sanity that start() ran)', async () => {
diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts
index 352715cbdc9..53fa8fce6b8 100644
--- a/packages/runtime/src/dispatcher-plugin.ts
+++ b/packages/runtime/src/dispatcher-plugin.ts
@@ -1537,6 +1537,70 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
domainDecidesEveryVerb('POST', `${prefix}/notifications/read`);
domainDecidesEveryVerb('POST', `${prefix}/notifications/read/all`);
+ // ── Push devices (ADR-0012 §6) ──────────────────────────────
+ // The device registry's two faces, served by the same domain
+ // (`handleNotificationRequest`) behind the same anonymous floor:
+ // `POST /notifications/devices` registers or refreshes the
+ // caller's device, `DELETE /notifications/devices/:deviceId`
+ // removes the caller's own. Mounted here because this is the only
+ // HTTP owner the standalone server has for `/notifications*`; the
+ // cloud hosts reach the domain through `dispatch()` as before.
+ //
+ // The device id is re-encoded on the way in: the router hands
+ // `req.params` decoded, and the domain decodes the segment it
+ // reads, so an id carrying `/` or `%` reaches the service as sent.
+ server.post(`${prefix}/notifications/devices`, async (req: any, res: any) => {
+ try {
+ const result = await dispatcher.dispatch('POST', '/notifications/devices', deferRequestBody(req), req.query, { request: req });
+ sendResult(result, res);
+ } catch (err: any) {
+ errorResponse(err, res);
+ }
+ });
+ server.delete(`${prefix}/notifications/devices/:deviceId`, async (req: any, res: any) => {
+ try {
+ const path = `/notifications/devices/${encodeURIComponent(String(req.params?.deviceId ?? ''))}`;
+ const result = await dispatcher.dispatch('DELETE', path, undefined, req.query, { request: req });
+ sendResult(result, res);
+ } catch (err: any) {
+ errorResponse(err, res);
+ }
+ });
+ // [#24024] As for the three inbox faces: every other verb on the two
+ // device paths is mounted too, so it reaches the domain's anonymous
+ // floor (401) instead of the adapter's 405, and a signed-in caller
+ // gets the domain's 404. No body is handed on: none is a face.
+ const mountDevicesRefusal = (
+ pattern: '/notifications/devices' | '/notifications/devices/:deviceId',
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
+ ) => {
+ const register =
+ method === 'GET' ? server.get
+ : method === 'POST' ? server.post
+ : method === 'PUT' ? server.put
+ : method === 'PATCH' ? server.patch
+ : server.delete;
+ register.call(server, `${prefix}${pattern}`, async (req: any, res: any) => {
+ try {
+ const path = pattern === '/notifications/devices'
+ ? pattern
+ : `/notifications/devices/${encodeURIComponent(String(req.params?.deviceId ?? ''))}`;
+ const result = await dispatcher.dispatch(method, path, undefined, req.query, { request: req });
+ sendResult(result, res);
+ } catch (err: any) {
+ errorResponse(err, res);
+ }
+ });
+ };
+ for (const method of ['GET', 'PUT', 'PATCH', 'DELETE'] as const) {
+ mountDevicesRefusal('/notifications/devices', method);
+ }
+ for (const method of ['GET', 'POST', 'PUT', 'PATCH'] as const) {
+ mountDevicesRefusal('/notifications/devices/:deviceId', method);
+ }
+ domainDecidesEveryVerb('POST', `${prefix}/notifications/devices`);
+ domainDecidesEveryVerb('GET', `${prefix}/notifications/devices/:deviceId`);
+
// ── Packages ────────────────────────────────────────────────
// Single pipeline (ADR-0076 D11 / OQ#9): every package route flows
// through dispatch() — like analytics / i18n / automation / AI —
diff --git a/packages/runtime/src/domains/notifications-devices.test.ts b/packages/runtime/src/domains/notifications-devices.test.ts
new file mode 100644
index 00000000000..b3404464e04
--- /dev/null
+++ b/packages/runtime/src/domains/notifications-devices.test.ts
@@ -0,0 +1,145 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The device registry's two faces on the `/notifications` domain (ADR-0012
+ * §6): `POST /devices` and `DELETE /devices/:deviceId`, driven at the
+ * dispatcher facade with a recording service.
+ *
+ * - anonymous is refused `401 UNAUTHENTICATED` on both, ahead of the service
+ * (ADR-0138 D2, the domain's first statement);
+ * - the owner is the session's user, never a body member: a body naming
+ * another `userId` registers for the caller;
+ * - a device id the caller does not hold surfaces the service's
+ * `NOT_FOUND` throw (the dispatcher answers it 404);
+ * - the device faces probe their OWN members: a provider without them
+ * answers the 501 capability disclosure, and a provider with them and no
+ * inbox still serves them;
+ * - the path segment is decoded, and a malformed escape is passed raw (an
+ * unknown id) rather than thrown on.
+ *
+ * The wire-level proof (real composition, real registry, real auth) lives in
+ * `packages/qa/dogfood/test/push-device-registry.dogfood.test.ts`.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { HttpDispatcher } from '../http-dispatcher.js';
+
+function makeDispatcher(service: Record | null) {
+ const kernel: any = {
+ getService: (name: string) => (name === 'notification' ? service : null),
+ getServiceAsync: async (name: string) => (name === 'notification' ? service : null),
+ context: { getService: (name: string) => (name === 'notification' ? service : null) },
+ };
+ return new HttpDispatcher(kernel);
+}
+
+function deviceService() {
+ return {
+ listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })),
+ registerDevice: vi.fn(async () => ({ deviceId: 'install-1', success: true })),
+ unregisterDevice: vi.fn(async (_userId: string, deviceId: string) => {
+ if (deviceId !== 'mine') {
+ const err = new Error('Device not found.') as Error & { code?: string; status?: number };
+ err.code = 'NOT_FOUND';
+ err.status = 404;
+ throw err;
+ }
+ return { success: true };
+ }),
+ };
+}
+
+const SIGNED_IN = () => ({ request: {}, executionContext: { userId: 'user_1' } } as any);
+const ANONYMOUS = () => ({ request: {}, executionContext: {} } as any);
+
+async function thrown(p: Promise): Promise {
+ try {
+ await p;
+ } catch (e) {
+ return e;
+ }
+ throw new Error('the request was served; a refusal was expected');
+}
+
+describe('/notifications/devices — the device registry faces', () => {
+ it.each([
+ ['POST', '/devices', { token: 't', platform: 'ios' }],
+ ['DELETE', '/devices/mine', undefined],
+ ])('anonymous %s %s is refused 401 UNAUTHENTICATED before the service is asked', async (method, path, body) => {
+ const service = deviceService();
+ const result = await makeDispatcher(service).handleNotification(path, method, body, {}, ANONYMOUS());
+ expect(result.handled).toBe(true);
+ expect(result.response?.status).toBe(401);
+ expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED');
+ expect(service.registerDevice).not.toHaveBeenCalled();
+ expect(service.unregisterDevice).not.toHaveBeenCalled();
+ });
+
+ it('registers for the session user; a body userId is not read', async () => {
+ const service = deviceService();
+ const body = { token: 't', platform: 'ios', deviceId: 'install-1', userId: 'someone_else' };
+ const result = await makeDispatcher(service).handleNotification('/devices', 'POST', body, {}, SIGNED_IN());
+ expect(result.response?.status).toBe(200);
+ expect(result.response?.body?.data).toEqual({ deviceId: 'install-1', success: true });
+ expect(service.registerDevice).toHaveBeenCalledTimes(1);
+ const [userId, request] = service.registerDevice.mock.calls[0] as unknown as [string, Record];
+ expect(userId).toBe('user_1');
+ expect(request).toEqual({ token: 't', platform: 'ios', deviceId: 'install-1' });
+ });
+
+ it('unregisters the caller\'s own device, and surfaces NOT_FOUND (404) for one it does not hold', async () => {
+ const service = deviceService();
+ const dispatcher = makeDispatcher(service);
+ const ok = await dispatcher.handleNotification('/devices/mine', 'DELETE', undefined, {}, SIGNED_IN());
+ expect(ok.response?.status).toBe(200);
+ expect(ok.response?.body?.data).toEqual({ success: true });
+ expect(service.unregisterDevice).toHaveBeenLastCalledWith('user_1', 'mine');
+
+ const refused = await thrown(dispatcher.handleNotification('/devices/someone-elses', 'DELETE', undefined, {}, SIGNED_IN()));
+ expect(refused.code).toBe('NOT_FOUND');
+ expect(refused.status).toBe(404);
+ });
+
+ it('decodes the device id segment, and passes a malformed escape through raw', async () => {
+ const service = deviceService();
+ const dispatcher = makeDispatcher(service);
+ await thrown(dispatcher.handleNotification('/devices/a%2Fb', 'DELETE', undefined, {}, SIGNED_IN()));
+ expect(service.unregisterDevice).toHaveBeenLastCalledWith('user_1', 'a/b');
+ await thrown(dispatcher.handleNotification('/devices/%E0%A4%A', 'DELETE', undefined, {}, SIGNED_IN()));
+ expect(service.unregisterDevice).toHaveBeenLastCalledWith('user_1', '%E0%A4%A');
+ });
+
+ it('answers 501 when the provider has no device registry, and serves one that has no inbox', async () => {
+ const inboxOnly = { listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })) };
+ const noRegistry = await makeDispatcher(inboxOnly)
+ .handleNotification('/devices', 'POST', { token: 't', platform: 'ios' }, {}, SIGNED_IN());
+ expect(noRegistry.response?.status).toBe(501);
+ const noUnregister = await makeDispatcher(inboxOnly)
+ .handleNotification('/devices/mine', 'DELETE', undefined, {}, SIGNED_IN());
+ expect(noUnregister.response?.status).toBe(501);
+
+ const { listInbox: _dropped, ...registryOnly } = deviceService();
+ const served = await makeDispatcher(registryOnly)
+ .handleNotification('/devices', 'POST', { token: 't', platform: 'android' }, {}, SIGNED_IN());
+ expect(served.response?.status).toBe(200);
+ });
+
+ it('does not serve a verb or sub-path the two faces do not name', async () => {
+ const service = deviceService();
+ const dispatcher = makeDispatcher(service);
+ for (const [method, path] of [['GET', '/devices'], ['PUT', '/devices'], ['POST', '/devices/mine'], ['DELETE', '/devices/a/b']]) {
+ const result = await dispatcher.handleNotification(path, method, undefined, {}, SIGNED_IN());
+ expect(result.handled, `${method} ${path}`).toBe(false);
+ }
+ expect(service.registerDevice).not.toHaveBeenCalled();
+ expect(service.unregisterDevice).not.toHaveBeenCalled();
+
+ // Unserved with or without an inbox (the route ledger's refusal rows):
+ // only a missing notification service answers 501.
+ const { listInbox: _dropped, ...registryOnly } = deviceService();
+ const noInbox = await makeDispatcher(registryOnly).handleNotification('/devices', 'GET', undefined, {}, SIGNED_IN());
+ expect(noInbox.handled).toBe(false);
+ const noService = await makeDispatcher(null).handleNotification('/devices', 'GET', undefined, {}, SIGNED_IN());
+ expect(noService.response?.status).toBe(501);
+ });
+});
diff --git a/packages/runtime/src/domains/notifications.ts b/packages/runtime/src/domains/notifications.ts
index 1391e051e57..217064cda08 100644
--- a/packages/runtime/src/domains/notifications.ts
+++ b/packages/runtime/src/domains/notifications.ts
@@ -20,16 +20,18 @@
* as it did from the if-chain.
*
* Routes (path is the sub-path after `/notifications`):
- * GET '' → listInbox (query: read, type, limit — validated, #6928)
- * POST /read → markRead (body: { ids: string[] })
- * POST /read/all → markAllRead
+ * GET '' → listInbox (query: read, type, limit — validated, #6928)
+ * POST /read → markRead (body: { ids: string[] })
+ * POST /read/all → markAllRead
+ * POST /devices → registerDevice (body: RegisterDeviceRequestSchema, ADR-0012 §6)
+ * DELETE /devices/:deviceId → unregisterDevice (the caller's own device; another's is 404)
*/
import {
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
} from '@objectstack/core';
import { CoreServiceName } from '@objectstack/spec/system';
-import { MarkNotificationsReadRequestSchema } from '@objectstack/spec/api';
+import { MarkNotificationsReadRequestSchema, RegisterDeviceRequestSchema } from '@objectstack/spec/api';
import type { INotificationService } from '@objectstack/spec/contracts';
import { isServiceServeable } from '../service-serveable.js';
import { validationFailure, fieldsFromZodIssues } from '../validation-failure.js';
@@ -98,6 +100,20 @@ function parseTypeFilter(raw: unknown): string | undefined {
return parseStringParam('type', raw);
}
+/**
+ * The `:deviceId` path segment, decoded as the self-hosted router decodes a
+ * parameter. An escape that does not decode is kept raw rather than thrown on
+ * (the `meta` / `automation-hooks` precedent): a malformed id names no device
+ * this caller holds, so it gets the ordinary 404, never a 500.
+ */
+function decodeDeviceSegment(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+}
+
export function createNotificationsDomain(deps: DomainHandlerDeps): DomainRoute {
return {
prefix: '/notifications',
@@ -169,11 +185,9 @@ export async function handleNotificationRequest(
// is mounted, so that produced a ROUTE_NOT_FOUND whose hint ("check the API
// discovery endpoint") pointed at a page that correctly does not list the
// route. See ./unavailable.ts. The slot key is `notification`, singular.
- if (!service || !isServiceServeable(service) || typeof service.listInbox !== 'function') {
+ if (!service || !isServiceServeable(service)) {
return capabilityUnavailable(deps, 'notification');
}
- // Narrowed for the routes below: the entry probe established `listInbox`.
- const inbox = service as INotificationService & Required>;
const m = method.toUpperCase();
// split+filter drops leading/trailing/duplicate slashes without a regex
@@ -181,7 +195,50 @@ export async function handleNotificationRequest(
// treatment the security domain got for the identical latent pattern.
// Surfaced when the extraction (#3507) made this line "changed code":
// the legacy `.replace(/\/+$/, '')` had carried the trap since ADR-0030.
- const subPath = path.split('/').filter(Boolean).join('/');
+ const segments = path.split('/').filter(Boolean);
+ const subPath = segments.join('/');
+
+ // ── Push devices (ADR-0012 §6) ─────────────────────────────────────────
+ // The device registry is a capability of its own, separately optional on
+ // the contract from the inbox, so these faces probe THEIR member and sit
+ // ahead of the inbox probe below: a provider with a device registry and no
+ // inbox still serves them, and one with an inbox and no registry answers
+ // the 501 capability disclosure, never a 404. The owner is the session's
+ // user (bound above, after the anonymous floor); no body member names one.
+ if (segments[0] === 'devices') {
+ // POST /notifications/devices — register or refresh this user's device.
+ if (segments.length === 1 && m === 'POST') {
+ if (typeof service.registerDevice !== 'function') return capabilityUnavailable(deps, 'notification');
+ // Validated against the declared contract (`RegisterDeviceRequestSchema`,
+ // the catalog row's `requestSchema`), as mark-read is (#3899).
+ const parsed = RegisterDeviceRequestSchema.safeParse(body ?? {});
+ if (!parsed.success) {
+ const fields = fieldsFromZodIssues(parsed.error.issues);
+ throw validationFailure(
+ `Invalid device registration body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`,
+ fields,
+ );
+ }
+ const result = await service.registerDevice(userId, parsed.data);
+ return { handled: true, response: deps.success(result) };
+ }
+ // DELETE /notifications/devices/:deviceId — the caller's own device only.
+ // A device id the caller does not hold (unknown, or another user's) is
+ // the service's `NOT_FOUND` throw, which the dispatcher answers 404:
+ // never a distinct "forbidden", so the id's existence is not disclosed.
+ if (segments.length === 2 && m === 'DELETE') {
+ if (typeof service.unregisterDevice !== 'function') return capabilityUnavailable(deps, 'notification');
+ const result = await service.unregisterDevice(userId, decodeDeviceSegment(segments[1]));
+ return { handled: true, response: deps.success(result) };
+ }
+ return { handled: false };
+ }
+
+ if (typeof service.listInbox !== 'function') {
+ return capabilityUnavailable(deps, 'notification');
+ }
+ // Narrowed for the routes below: the entry probe established `listInbox`.
+ const inbox = service as INotificationService & Required>;
// Each write route probes its OWN method rather than riding the entry
// `listInbox` probe (#4127). The three are separately optional on the
diff --git a/packages/runtime/src/request-schema-gate.conformance.test.ts b/packages/runtime/src/request-schema-gate.conformance.test.ts
index 1fca9f33d23..eb51ab93de3 100644
--- a/packages/runtime/src/request-schema-gate.conformance.test.ts
+++ b/packages/runtime/src/request-schema-gate.conformance.test.ts
@@ -44,12 +44,13 @@ const DISPATCHER_GROUPS = [
function makeDispatcher() {
const spies = {
markRead: vi.fn(async () => ({ success: true, readCount: 1 })),
+ registerDevice: vi.fn(async () => ({ deviceId: 'install-1', success: true })),
listInbox: vi.fn(async () => ({ notifications: [], unreadCount: 0 })),
query: vi.fn(async () => ({ data: [] })),
getMeta: vi.fn(async () => ({ cubes: [] })),
};
const services: Record = {
- notification: { listInbox: spies.listInbox, markRead: spies.markRead },
+ notification: { listInbox: spies.listInbox, markRead: spies.markRead, registerDevice: spies.registerDevice },
analytics: { query: spies.query, getMeta: spies.getMeta },
};
const resolve = (name: string) => services[name];
@@ -90,6 +91,21 @@ const CASES: GateCase[] = [
drive: (d, body) => d.handleNotification('/read', 'POST', body, {}, CTX),
spy: (s) => s.markRead,
},
+ {
+ route: 'POST /api/v1/notifications/devices',
+ // A missing token, a platform outside the three, a non-string token,
+ // a vendor outside the pair, and a body that is not an object.
+ violating: [
+ { platform: 'ios' },
+ { token: 't', platform: 'blackberry' },
+ { token: 42, platform: 'ios' },
+ { token: 't', platform: 'web', vendor: 'huawei' },
+ 'garbage',
+ ],
+ valid: { token: 't', platform: 'ios' },
+ drive: (d, body) => d.handleNotification('/devices', 'POST', body, {}, CTX),
+ spy: (s) => s.registerDevice,
+ },
{
route: 'POST /api/v1/analytics/query',
// Missing measures / retired envelope key — the #3878 shapes.
diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts
index 60b255866d0..9d6b83d2478 100644
--- a/packages/runtime/src/route-ledger.ts
+++ b/packages/runtime/src/route-ledger.ts
@@ -301,7 +301,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [
/**
* The ledger.
*
- * CENSUS (generated): this list holds 116 rows.
+ * CENSUS (generated): this list holds 126 rows.
*
* ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand.
* `pnpm check:route-ledger-census` counts the rows below and fails when the two
@@ -399,6 +399,26 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' },
{ route: 'DELETE /notifications/read/all', domain: '/notifications', disposition: 'server-only',
note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 (or its 501 with no inbox)' },
+ // Push devices (ADR-0012 §6): the device registry's two faces and their
+ // refusal-only verbs, mounted for the same every-face anonymous floor.
+ { route: 'POST /notifications/devices', domain: '/notifications', disposition: 'sdk', client: 'notifications.registerDevice' },
+ { route: 'DELETE /notifications/devices/:deviceId', domain: '/notifications', disposition: 'sdk', client: 'notifications.unregisterDevice' },
+ { route: 'GET /notifications/devices', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'PUT /notifications/devices', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'PATCH /notifications/devices', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'DELETE /notifications/devices', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'GET /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'POST /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'PUT /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
+ { route: 'PATCH /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
+ note: 'refusal-only, mounted for the v18 every-face anonymous floor (changeset 23225): anonymous 401 UNAUTHENTICATED, signed-in the notifications domain\'s 404 with or without an inbox (its 501 only when no notification service is registered)' },
// ── security (ADR-0090 suggested audience bindings) ───────────────────────
{ route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.list' },
diff --git a/packages/services/service-messaging/scripts/i18n-extract.config.ts b/packages/services/service-messaging/scripts/i18n-extract.config.ts
index 447aa408b15..6d74097c7dc 100644
--- a/packages/services/service-messaging/scripts/i18n-extract.config.ts
+++ b/packages/services/service-messaging/scripts/i18n-extract.config.ts
@@ -43,6 +43,7 @@ import {
NotificationPreference,
NotificationTemplate,
HttpDelivery,
+ UserDevice,
} from '../src/objects/index.js';
import { enObjects } from '../src/translations/en.objects.generated.js';
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
@@ -71,6 +72,7 @@ const config: ObjectStackDefinition = defineStack({
NotificationPreference,
NotificationTemplate,
HttpDelivery,
+ UserDevice,
] as any,
translations: [
{ en: { objects: enObjects } },
diff --git a/packages/services/service-messaging/src/device-registry.integration.test.ts b/packages/services/service-messaging/src/device-registry.integration.test.ts
new file mode 100644
index 00000000000..627bd165e79
--- /dev/null
+++ b/packages/services/service-messaging/src/device-registry.integration.test.ts
@@ -0,0 +1,262 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The push device registry (ADR-0012 §6): `MessagingService`'s four
+ * `INotificationService` device members over `sys_user_device`, pinned on a
+ * real `ObjectQL` over `SqlDriver` (better-sqlite3) with the real `sys_user`.
+ *
+ * What the contract (`@objectstack/spec/contracts` `INotificationService`)
+ * and spec's three rules (`system/push-device.zod.ts`) owe, one case each:
+ *
+ * - register, then refresh the same `deviceId`: one row, token and
+ * `last_seen_at` moved;
+ * - a registration without `deviceId` mints one; the same user registering
+ * the same token again without it gets the SAME id back, never a second row;
+ * - a second user registering the token takes it over, and the first user's
+ * row for it is removed (rule b);
+ * - unregister by another user answers `NOT_FOUND` (404) and the row
+ * survives; the owner's unregister removes it;
+ * - `invalidateDeviceToken` keeps the row, disabled with its time (rule c);
+ * a later registration of the same device re-enables it and clears the
+ * time; a token nobody holds is a no-op;
+ * - the vendor derives from the platform, and a `web` registration without
+ * one is refused `VALIDATION_FAILED` at `vendor`, storing nothing;
+ * - `listDevices` answers only the caller's devices, newest first;
+ * - 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.
+ *
+ * Refusals assert `code` AND `status` (ADR-0112).
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { ObjectQL } from '@objectstack/objectql';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { SysUser } from '@objectstack/platform-objects';
+import { validationFailureDetails } from '@objectstack/types';
+import { UserDevice } from './objects/user-device.object.js';
+import { MessagingService } from './messaging-service.js';
+import { USER_DEVICE_OBJECT } from './device-registry.js';
+import { installDefaultOrganization } from './default-organization.testkit.js';
+
+const SYS = { context: { isSystem: true } } as any;
+const silent: any = { debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return silent; } };
+
+let engine: ObjectQL | undefined;
+
+afterEach(async () => {
+ try { await engine?.destroy(); } catch { /* noop */ }
+ engine = undefined;
+});
+
+/** A clock the test moves, so `last_seen_at` ordering is not a race. */
+function clock() {
+ let t = Date.parse('2026-11-02T10:00:00.000Z');
+ return { now: () => new Date(t).toISOString(), tick: (ms = 60_000) => { t += ms; } };
+}
+
+async function boot() {
+ const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
+ const ql = new ObjectQL({ logger: silent });
+ ql.registerDriver(driver, true);
+ await ql.init();
+ ql.registry.registerObject(SysUser as any, '@objectstack/plugin-auth');
+ ql.registry.registerObject(UserDevice as any, '@objectstack/service-messaging');
+ await ql.syncSchemas();
+ await installDefaultOrganization(ql as any);
+ engine = ql;
+ const time = clock();
+ const service = new MessagingService({ logger: silent, getData: () => ql as any, now: time.now });
+ const alice = String((await ql.insert('sys_user', { name: 'Alice', email: 'alice@device.test' }, SYS)).id);
+ const bob = String((await ql.insert('sys_user', { name: 'Bob', email: 'bob@device.test' }, SYS)).id);
+ const rows = async (where: Record = {}) =>
+ (await ql.find(USER_DEVICE_OBJECT, { where }, SYS)) as Array>;
+ return { ql, service, time, alice, bob, rows };
+}
+
+async function thrown(p: Promise): Promise {
+ try {
+ await p;
+ } catch (e) {
+ return e;
+ }
+ throw new Error('the call resolved; a refusal was expected');
+}
+
+describe('push device registry: INotificationService device members over sys_user_device', () => {
+ it('register, then refresh the same deviceId: one row, token and last_seen_at updated', async () => {
+ const { ql, service, time, alice, rows } = await boot();
+ const first = await service.registerDevice(alice, { token: 'tok-1', platform: 'ios', deviceId: 'dev-a', name: 'iPhone' });
+ expect(first).toEqual({ deviceId: 'dev-a', success: true });
+ const [before] = await rows({ user_id: alice });
+
+ time.tick();
+ const again = await service.registerDevice(alice, { token: 'tok-2', platform: 'ios', deviceId: 'dev-a', locale: 'zh-CN' });
+ expect(again).toEqual({ deviceId: 'dev-a', success: true });
+
+ const after = await rows({ user_id: alice });
+ expect(after).toHaveLength(1);
+ // Rule (a): the registered schema carries no organization column.
+ expect(Object.keys((ql.registry.getObject(USER_DEVICE_OBJECT) as any).fields)).not.toContain('organization_id');
+ expect(after[0].id).toBe(before.id);
+ expect(after[0].token).toBe('tok-2');
+ expect(after[0].locale).toBe('zh-CN');
+ expect(new Date(after[0].last_seen_at).getTime()).toBeGreaterThan(new Date(before.last_seen_at).getTime());
+ });
+
+ it('mints a deviceId when absent, and answers the same id for the same token without one', async () => {
+ const { service, alice, rows } = await boot();
+ const minted = await service.registerDevice(alice, { token: 'tok-m', platform: 'android' });
+ expect(minted.success).toBe(true);
+ expect(minted.deviceId).toMatch(/\S/);
+
+ const cleared = await service.registerDevice(alice, { token: 'tok-m', platform: 'android' });
+ expect(cleared.deviceId).toBe(minted.deviceId);
+ expect(await rows({ token: 'tok-m' })).toHaveLength(1);
+ });
+
+ it('a second user registering the token takes it over; the first user\'s row for it is removed', async () => {
+ const { service, alice, bob, rows } = await boot();
+ await service.registerDevice(alice, { token: 'tok-shared', platform: 'ios', deviceId: 'alice-phone' });
+ await service.registerDevice(alice, { token: 'tok-other', platform: 'ios', deviceId: 'alice-tablet' });
+
+ const taken = await service.registerDevice(bob, { token: 'tok-shared', platform: 'ios', deviceId: 'bob-phone' });
+ expect(taken).toEqual({ deviceId: 'bob-phone', success: true });
+
+ const holders = await rows({ token: 'tok-shared' });
+ expect(holders).toHaveLength(1);
+ expect(String(holders[0].user_id)).toBe(bob);
+ expect((await rows({ user_id: alice })).map((r) => r.device_id)).toEqual(['alice-tablet']);
+ expect((await service.listDevices(alice)).map((d) => d.deviceId)).toEqual(['alice-tablet']);
+ });
+
+ it('a token this user holds under another deviceId moves to the named device; the stale row goes', async () => {
+ const { service, alice, rows } = await boot();
+ await service.registerDevice(alice, { token: 'tok-x', platform: 'ios', deviceId: 'old-install' });
+ await service.registerDevice(alice, { token: 'tok-x', platform: 'ios', deviceId: 'new-install' });
+ expect((await rows({ user_id: alice })).map((r) => r.device_id)).toEqual(['new-install']);
+ });
+
+ it('unregister by a non-owner is NOT_FOUND (404) and the row survives; the owner removes it', async () => {
+ const { service, alice, bob, rows } = await boot();
+ await service.registerDevice(alice, { token: 'tok-u', platform: 'android', deviceId: 'alice-dev' });
+
+ const refused = await thrown(service.unregisterDevice(bob, 'alice-dev'));
+ expect(refused.code).toBe('NOT_FOUND');
+ expect(refused.status).toBe(404);
+ expect(await rows({ device_id: 'alice-dev' })).toHaveLength(1);
+
+ const unknown = await thrown(service.unregisterDevice(alice, 'no-such-device'));
+ expect(unknown.code).toBe('NOT_FOUND');
+ expect(unknown.status).toBe(404);
+
+ expect(await service.unregisterDevice(alice, 'alice-dev')).toEqual({ success: true });
+ expect(await rows({ device_id: 'alice-dev' })).toHaveLength(0);
+ });
+
+ it('invalidateDeviceToken keeps the row disabled with its time; re-registration re-enables and clears it', async () => {
+ const { service, time, alice, rows } = await boot();
+ await service.registerDevice(alice, { token: 'tok-dead', platform: 'ios', deviceId: 'dev-d' });
+
+ time.tick();
+ await service.invalidateDeviceToken('tok-dead');
+ const [dead] = await rows({ device_id: 'dev-d' });
+ expect(dead.enabled).toBe(false);
+ expect(dead.invalidated_at).toBeTruthy();
+ const [listed] = await service.listDevices(alice);
+ expect(listed.enabled).toBe(false);
+ expect(listed.invalidatedAt).toBe(time.now());
+
+ // A token nobody holds is a no-op.
+ await expect(service.invalidateDeviceToken('tok-nobody')).resolves.toBeUndefined();
+
+ time.tick();
+ await service.registerDevice(alice, { token: 'tok-dead', platform: 'ios', deviceId: 'dev-d' });
+ const [revived] = await rows({ device_id: 'dev-d' });
+ expect(revived.id).toBe(dead.id);
+ expect(revived.enabled).toBe(true);
+ expect(revived.invalidated_at ?? null).toBeNull();
+ const [relisted] = await service.listDevices(alice);
+ expect(relisted.enabled).toBe(true);
+ expect(relisted).not.toHaveProperty('invalidatedAt');
+ });
+
+ it('derives the vendor from the platform, and refuses a web registration without one', async () => {
+ const { service, alice, rows } = await boot();
+ await service.registerDevice(alice, { token: 'tok-ios', platform: 'ios' });
+ await service.registerDevice(alice, { token: 'tok-and', platform: 'android' });
+ await service.registerDevice(alice, { token: 'tok-web', platform: 'web', vendor: 'fcm' });
+ const vendorOf = async (token: string) => (await rows({ token }))[0]?.vendor;
+ expect(await vendorOf('tok-ios')).toBe('apns');
+ expect(await vendorOf('tok-and')).toBe('fcm');
+ expect(await vendorOf('tok-web')).toBe('fcm');
+
+ const refused = await thrown(service.registerDevice(alice, { token: 'tok-web-2', platform: 'web' }));
+ const details = validationFailureDetails(refused);
+ expect(details?.code).toBe('VALIDATION_FAILED');
+ expect(details?.fields).toEqual([expect.objectContaining({ field: 'vendor', code: 'required' })]);
+ expect(await rows({ token: 'tok-web-2' })).toHaveLength(0);
+ });
+
+ it('listDevices answers only the caller\'s devices, newest last_seen_at first', async () => {
+ const { service, time, alice, bob } = await boot();
+ await service.registerDevice(alice, { token: 't1', platform: 'ios', deviceId: 'older' });
+ time.tick();
+ await service.registerDevice(alice, { token: 't2', platform: 'android', deviceId: 'newer', appId: 'ai.objectos.app' });
+ await service.registerDevice(bob, { token: 't3', platform: 'ios', deviceId: 'bobs' });
+
+ const mine = await service.listDevices(alice);
+ expect(mine.map((d) => d.deviceId)).toEqual(['newer', 'older']);
+ expect(mine[0]).toMatchObject({ userId: alice, vendor: 'fcm', token: 't2', platform: 'android', appId: 'ai.objectos.app', enabled: true });
+ expect(mine.every((d) => d.userId === alice)).toBe(true);
+ });
+
+ it('a takeover whose write is refused rolls the previous holder\'s delete back: one unit, not two steps', async () => {
+ const { ql, time, alice, bob, rows } = await boot();
+ // The real engine, but the registry's insert is refused once armed, as a
+ // database refusal of the write would be (after the delete has run).
+ let refuseInsert = false;
+ const refusing = new Proxy(ql as any, {
+ get(target, prop, receiver) {
+ if (prop === 'insert') {
+ return async (object: string, ...rest: unknown[]) => {
+ if (refuseInsert && object === USER_DEVICE_OBJECT) throw new Error('write refused by the database');
+ return (target.insert as (...a: unknown[]) => unknown).call(target, object, ...rest);
+ };
+ }
+ const value = Reflect.get(target, prop, receiver);
+ return typeof value === 'function' ? value.bind(target) : value;
+ },
+ });
+ const service = new MessagingService({ logger: silent, getData: () => refusing, now: time.now });
+ await service.registerDevice(alice, { token: 'tok-unit', platform: 'ios', deviceId: 'alice-phone' });
+
+ refuseInsert = true;
+ const refused = await thrown(service.registerDevice(bob, { token: 'tok-unit', platform: 'ios', deviceId: 'bob-phone' }));
+ expect(String(refused?.message)).toBe('write refused by the database');
+
+ const holders = await rows({ token: 'tok-unit' });
+ expect(holders).toHaveLength(1);
+ expect(String(holders[0].user_id)).toBe(alice);
+ expect(await rows({ user_id: bob })).toHaveLength(0);
+ });
+
+ it('the database refuses a second holder of a token, and concurrent registrations end with one row', async () => {
+ const { ql, service, alice, bob, rows } = await boot();
+ await service.registerDevice(alice, { token: 'tok-race', platform: 'ios', deviceId: 'a1' });
+
+ // The unique index itself: a direct second holder is refused.
+ const direct = await thrown(ql.insert(USER_DEVICE_OBJECT, {
+ id: 'manual', user_id: bob, device_id: 'b-manual', token: 'tok-race', vendor: 'apns', platform: 'ios', enabled: true,
+ }, SYS));
+ expect(String(direct?.message ?? direct)).toMatch(/unique|duplicate/i);
+
+ await Promise.all([
+ service.registerDevice(bob, { token: 'tok-race', platform: 'ios', deviceId: 'b1' }),
+ service.registerDevice(alice, { token: 'tok-race', platform: 'ios', deviceId: 'a2' }),
+ service.registerDevice(bob, { token: 'tok-race', platform: 'ios', deviceId: 'b2' }),
+ ]);
+ expect(await rows({ token: 'tok-race' })).toHaveLength(1);
+ });
+});
diff --git a/packages/services/service-messaging/src/device-registry.ts b/packages/services/service-messaging/src/device-registry.ts
new file mode 100644
index 00000000000..89dbbf873b8
--- /dev/null
+++ b/packages/services/service-messaging/src/device-registry.ts
@@ -0,0 +1,305 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The push device registry (ADR-0012 §6): the four `INotificationService`
+ * device members, over `sys_user_device` (`./objects/user-device.object.ts`).
+ *
+ * `MessagingService` delegates `registerDevice` / `unregisterDevice` /
+ * `listDevices` / `invalidateDeviceToken` here; their contract, word for word,
+ * is the docblocks on `INotificationService` (`@objectstack/spec/contracts`).
+ *
+ * ## Whose rows a call may touch
+ *
+ * Every call runs under {@link DEVICE_SYSTEM_CONTEXT}: the table is
+ * system-owned and closed to the data API, so no principal could reach it.
+ * What keeps a call to the caller's own rows is the `userId` the device route
+ * takes from the session (`runtime/src/domains/notifications.ts`), and these
+ * rules:
+ *
+ * - a read of the caller's devices is keyed `where: { user_id }`;
+ * - an update or delete addresses only an id that a read of the SAME call
+ * returned;
+ * - the only row of ANOTHER user a call touches is the one holding the token
+ * the caller registers, which it deletes (spec rule b: the token moves).
+ *
+ * ## One holder per token, and how that is enforced (spec rule b)
+ *
+ * Three layers, each closing what the one before cannot:
+ *
+ * 1. Within this process, registrations of one token are serialised
+ * ({@link withTokenLock}), so two requests from the same phone cannot
+ * interleave their read and their write.
+ * 2. Across processes, `sys_user_device.token` carries a unique index, so a
+ * second holder can never be stored: a racing writer's insert or update is
+ * refused by the database.
+ * 3. That refusal is not the caller's error. The registration runs again
+ * ({@link REGISTER_ATTEMPTS}): the re-read now sees the winner's row and
+ * moves the token from it, which is what the losing request asked for.
+ *
+ * ## One registration is one unit
+ *
+ * A registration that moves a token deletes the previous holder's row and then
+ * writes its own. Where the engine offers a transaction (`IObjectQLEngine.
+ * transaction`, the ObjectQL engine always does) the reads, the delete and the
+ * write run in ONE, so a write the database refuses rolls the delete back with
+ * it and the previous holder keeps its row. On an engine without one the steps
+ * run in order with no rollback: a refused write after the delete leaves the
+ * token held by nobody until the device registers again (the next attempt, or
+ * the app's next start), which loses a push target but never misdirects a push.
+ */
+
+import { randomUUID } from 'node:crypto';
+import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts';
+import type {
+ RegisterDeviceRequest,
+ RegisterDeviceResponse,
+ UnregisterDeviceResponse,
+} from '@objectstack/spec/api';
+import type { UserDevice } from '@objectstack/spec/system';
+import { isUniqueViolationError, validationFailure } from '@objectstack/types';
+
+/** The registry's table. */
+export const USER_DEVICE_OBJECT = 'sys_user_device';
+
+/**
+ * The explicit system opt-in every registry call carries. See the module
+ * docblock for what bounds it instead of row-level security.
+ */
+export const DEVICE_SYSTEM_CONTEXT = { isSystem: true } as const;
+
+/**
+ * How many times a registration runs before a unique-index refusal is let
+ * through. Three: the first attempt, the one that re-reads after a racing
+ * writer on another node won, and one more for a second such race in the same
+ * window. Past that the refusal surfaces (the dispatcher answers it `409
+ * UNIQUE_VIOLATION`) and the client's next registration settles it; the
+ * in-process lock means only writers on OTHER nodes can race at all.
+ */
+const REGISTER_ATTEMPTS = 3;
+
+type Vendor = 'apns' | 'fcm';
+
+/** `platform` → the vendor a registration without `vendor` gets. `web` has none. */
+const DEFAULT_VENDOR: Readonly> = {
+ ios: 'apns',
+ android: 'fcm',
+};
+
+type DeviceRow = Record & { id?: unknown };
+
+/** The engine surface the registry uses: every engine's reads and writes, plus its transaction where it offers one. */
+export type DeviceRegistryEngine = IDataEngine & Partial>;
+
+export interface DeviceRegistryOptions {
+ getData: () => DeviceRegistryEngine | undefined;
+ now?: () => string;
+}
+
+/**
+ * The error `unregisterDevice` throws for a device id this user does not hold,
+ * whether it is unknown or another user's: `code: 'NOT_FOUND'`, `status: 404`,
+ * which every HTTP door answers as that (`resolveThrownHttpError`). Never a
+ * distinct "forbidden", so a caller cannot learn that the id exists for
+ * somebody else.
+ */
+export function deviceNotFoundError(): Error {
+ const err = new Error('Device not found.') as Error & { code?: string; status?: number };
+ err.code = 'NOT_FOUND';
+ err.status = 404;
+ return err;
+}
+
+export class DeviceRegistry {
+ private readonly now: () => string;
+ /** The in-process tail of each token's registration chain. */
+ private readonly tokenTails = new Map>();
+
+ constructor(private readonly opts: DeviceRegistryOptions) {
+ this.now = opts.now ?? (() => new Date().toISOString());
+ }
+
+ private engine(): DeviceRegistryEngine {
+ const data = this.opts.getData();
+ if (!data) throw new Error('[messaging] the device registry has no data engine');
+ return data;
+ }
+
+ /** `INotificationService.registerDevice`. */
+ async register(userId: string, request: RegisterDeviceRequest): Promise {
+ const vendor = request.vendor ?? DEFAULT_VENDOR[request.platform];
+ if (!vendor) {
+ throw validationFailure(
+ 'A web device registration names its vendor (apns or fcm): web has no default vendor.',
+ [{ field: 'vendor', code: 'required', message: 'Required for a web registration: apns or fcm' }],
+ );
+ }
+ return this.withTokenLock(request.token, async () => {
+ for (let attempt = 1; ; attempt += 1) {
+ try {
+ return await this.inOneUnit((context) => this.registerOnce(userId, request, vendor, context));
+ } catch (err) {
+ if (attempt >= REGISTER_ATTEMPTS || !isUniqueViolationError(err)) throw err;
+ }
+ }
+ });
+ }
+
+ /**
+ * Run `work` in one engine transaction when the engine offers one, with the
+ * system context the transaction hands back; else directly, with the
+ * system context (see the module docblock's "One registration is one unit").
+ */
+ private async inOneUnit(work: (context: Record) => Promise): Promise {
+ const data = this.engine();
+ if (typeof data.transaction !== 'function') return work({ ...DEVICE_SYSTEM_CONTEXT });
+ return data.transaction((trxCtx) => work(trxCtx as Record), { ...DEVICE_SYSTEM_CONTEXT });
+ }
+
+ private async registerOnce(
+ userId: string,
+ request: RegisterDeviceRequest,
+ vendor: Vendor,
+ context: Record,
+ ): Promise {
+ const data = this.engine();
+ const holder = await this.findOne(data, { token: request.token }, context);
+
+ // The row this registration refreshes: the named device, else (no
+ // deviceId) the caller's own row that already holds this token.
+ let target: DeviceRow | null = null;
+ if (request.deviceId) {
+ target = await this.findOne(data, { user_id: userId, device_id: request.deviceId }, context);
+ } else if (holder && String(holder.user_id) === userId) {
+ target = holder;
+ }
+
+ // The token moves: whoever else holds it — another user's device, or
+ // this user's stale row under another deviceId — gives it up, by row.
+ if (holder && (!target || String(holder.id) !== String(target.id))) {
+ await data.delete(USER_DEVICE_OBJECT, { where: { id: holder.id }, context } as never);
+ }
+
+ const at = this.now();
+ const values = {
+ token: request.token,
+ vendor,
+ platform: request.platform,
+ app_id: request.appId ?? null,
+ locale: request.locale ?? null,
+ name: request.name ?? null,
+ enabled: true,
+ invalidated_at: null,
+ last_seen_at: at,
+ };
+
+ if (target) {
+ await data.update(
+ USER_DEVICE_OBJECT,
+ values,
+ { where: { id: target.id }, context } as never,
+ );
+ return { deviceId: String(target.device_id), success: true };
+ }
+
+ const deviceId = request.deviceId ?? randomUUID();
+ await data.insert(
+ USER_DEVICE_OBJECT,
+ { id: randomUUID(), user_id: userId, device_id: deviceId, ...values },
+ { context },
+ );
+ return { deviceId, success: true };
+ }
+
+ /** `INotificationService.unregisterDevice`. */
+ async unregister(userId: string, deviceId: string): Promise {
+ const data = this.engine();
+ const own = await this.findOne(data, { user_id: userId, device_id: deviceId });
+ if (!own) throw deviceNotFoundError();
+ await data.delete(USER_DEVICE_OBJECT, { where: { id: own.id }, context: DEVICE_SYSTEM_CONTEXT } as never);
+ return { success: true };
+ }
+
+ /** `INotificationService.listDevices`: newest `lastSeenAt` first, never-seen last. */
+ async list(userId: string): Promise {
+ const data = this.engine();
+ const rows = await data.find(
+ USER_DEVICE_OBJECT,
+ { where: { user_id: userId } },
+ { context: DEVICE_SYSTEM_CONTEXT },
+ ) as DeviceRow[];
+ return rows
+ .map(toUserDevice)
+ .sort((a, b) => {
+ if (a.lastSeenAt === b.lastSeenAt) return 0;
+ if (!a.lastSeenAt) return 1;
+ if (!b.lastSeenAt) return -1;
+ return a.lastSeenAt < b.lastSeenAt ? 1 : -1;
+ });
+ }
+
+ /** `INotificationService.invalidateDeviceToken`: the row is kept, disabled. */
+ async invalidate(token: string): Promise {
+ const data = this.engine();
+ const holder = await this.findOne(data, { token });
+ if (!holder) return;
+ await data.update(
+ USER_DEVICE_OBJECT,
+ { enabled: false, invalidated_at: this.now() },
+ { where: { id: holder.id }, context: DEVICE_SYSTEM_CONTEXT } as never,
+ );
+ }
+
+ private async findOne(
+ data: IDataEngine,
+ where: Record,
+ context: Record = DEVICE_SYSTEM_CONTEXT,
+ ): Promise {
+ const row = await data.findOne(USER_DEVICE_OBJECT, { where }, { context });
+ return (row as DeviceRow | null) ?? null;
+ }
+
+ /**
+ * Run `work` after every earlier registration of `token` in this process
+ * has settled, so the read and the write of one registration are never
+ * interleaved with another's for the same token.
+ */
+ private async withTokenLock(token: string, work: () => Promise): Promise {
+ const previous = this.tokenTails.get(token) ?? Promise.resolve();
+ const run = previous.then(work, work);
+ const tail = run.catch(() => undefined);
+ this.tokenTails.set(token, tail);
+ try {
+ return await run;
+ } finally {
+ if (this.tokenTails.get(token) === tail) this.tokenTails.delete(token);
+ }
+ }
+}
+
+function iso(value: unknown): string | undefined {
+ if (value === null || value === undefined || value === '') return undefined;
+ if (value instanceof Date) return value.toISOString();
+ return new Date(String(value)).toISOString();
+}
+
+/** A stored row in spec's `UserDeviceSchema` shape. */
+function toUserDevice(row: DeviceRow): UserDevice {
+ // The engine answers a boolean column as a boolean on every driver.
+ const enabled = row.enabled === true;
+ const device: UserDevice = {
+ deviceId: String(row.device_id),
+ userId: String(row.user_id),
+ vendor: row.vendor as Vendor,
+ token: String(row.token),
+ platform: row.platform as UserDevice['platform'],
+ enabled,
+ };
+ if (row.app_id) device.appId = String(row.app_id);
+ if (row.locale) device.locale = String(row.locale);
+ if (row.name) device.name = String(row.name);
+ const invalidatedAt = iso(row.invalidated_at);
+ if (!enabled && invalidatedAt) device.invalidatedAt = invalidatedAt;
+ const lastSeenAt = iso(row.last_seen_at);
+ if (lastSeenAt) device.lastSeenAt = lastSeenAt;
+ return device;
+}
diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts
index 5b19cc9d39b..626f4c329f0 100644
--- a/packages/services/service-messaging/src/index.ts
+++ b/packages/services/service-messaging/src/index.ts
@@ -219,4 +219,8 @@ export {
NotificationTemplate,
HttpDelivery,
SYS_HTTP_DELIVERY,
+ UserDevice,
} from './objects/index.js';
+
+// Push device registry (ADR-0012 §6) — `INotificationService`'s device members.
+export { USER_DEVICE_OBJECT } from './device-registry.js';
diff --git a/packages/services/service-messaging/src/messaging-service-plugin.test.ts b/packages/services/service-messaging/src/messaging-service-plugin.test.ts
index 46904433d33..d94a8cdc5da 100644
--- a/packages/services/service-messaging/src/messaging-service-plugin.test.ts
+++ b/packages/services/service-messaging/src/messaging-service-plugin.test.ts
@@ -230,7 +230,7 @@ describe('MessagingServicePlugin — system table provisioning', () => {
describe('MessagingServicePlugin — boot provisioning honours skipSchemaSync (#22984)', () => {
const OBJECTS = [
'sys_notification', 'sys_inbox_message', 'sys_notification_receipt', 'sys_notification_delivery',
- 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery',
+ 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery', 'sys_user_device',
];
const SKIP = 'NOT provisioned at boot';
@@ -357,7 +357,7 @@ describe('MessagingServicePlugin — boot provisioning honours skipSchemaSync (#
describe('MessagingServicePlugin — a failed provision is an error, once per object (#22985)', () => {
const OBJECTS = [
'sys_notification', 'sys_inbox_message', 'sys_notification_receipt', 'sys_notification_delivery',
- 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery',
+ 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery', 'sys_user_device',
];
/** Every DDL succeeds except the named object's, which the driver refuses. */
diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts
index 9a679d16170..c1753da8d5e 100644
--- a/packages/services/service-messaging/src/messaging-service-plugin.ts
+++ b/packages/services/service-messaging/src/messaging-service-plugin.ts
@@ -32,6 +32,7 @@ import {
NotificationPreference,
NotificationTemplate,
HttpDelivery,
+ UserDevice,
} from './objects/index.js';
// The L2 event this service writes on every `emit()`. It is OWNED by
// platform-objects (it is in `PLATFORM_OBJECTS_BY_PACKAGE`), and — like
@@ -84,7 +85,7 @@ function lostWithoutStorage(objectName: string): string {
* durability degradation: ① the consequence, and that the deployment keeps
* looking healthy; ② the fix. Both remedies are measured: this provisioning
* runs on every boot that runs DDL, and `os migrate apply` creates all eight
- * of these tables.
+ * of these tables (measured before `sys_user_device` joined them as the ninth).
*/
function provisionFailureLine(objectName: string): string {
return (
@@ -352,6 +353,9 @@ export class MessagingServicePlugin implements Plugin {
NotificationPreference,
NotificationTemplate,
HttpDelivery,
+ // The push device registry (ADR-0012 §6): the
+ // `notification` slot's device members write it.
+ UserDevice,
],
navigationContributions: [
{
@@ -609,7 +613,7 @@ export class MessagingServicePlugin implements Plugin {
* cold-start budget) and says once, at `info`, what it did not provision and
* why. Not `warn`: nothing is degraded, because the operator declared this.
* `os migrate apply`, the documented path, was measured to create all eight
- * tables. `syncObjectSchema` itself stays ungated, because it is also how a
+ * tables (before `sys_user_device` joined them as the ninth). `syncObjectSchema` itself stays ungated, because it is also how a
* runtime publish gives a new object its table.
*/
private async provisionSystemTables(engine: IDataEngine, ctx: PluginContext): Promise {
@@ -626,6 +630,7 @@ export class MessagingServicePlugin implements Plugin {
NotificationPreference,
NotificationTemplate,
HttpDelivery,
+ UserDevice,
];
// [#22984] FIRST: see the docblock. One line, naming every skipped object.
if (bootSchemaSyncSkipped(engine)) {
diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts
index c48be0bdccc..eb8a0135d30 100644
--- a/packages/services/service-messaging/src/messaging-service.ts
+++ b/packages/services/service-messaging/src/messaging-service.ts
@@ -26,6 +26,13 @@ import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js';
import { INBOX_SYSTEM_CONTEXT } from './inbox-system-context.js';
import { assertActorReferenceResolves } from './actor-reference.js';
import { USER_OBJECT } from './recipient-locale.js';
+import { DeviceRegistry } from './device-registry.js';
+import type {
+ RegisterDeviceRequest,
+ RegisterDeviceResponse,
+ UnregisterDeviceResponse,
+} from '@objectstack/spec/api';
+import type { UserDevice } from '@objectstack/spec/system';
import {
assertNotificationTextKeys,
type NotificationTextKeys,
@@ -264,6 +271,8 @@ export class MessagingService {
private readonly redeliverGuards = new Map();
/** [#24978] Whether keyed text met a composition with no renderer — said once. */
private keyedTextWithoutRendererAnnounced = false;
+ /** The push device registry behind the four device members. */
+ private readonly devices: DeviceRegistry;
constructor(private readonly ctx: MessagingServiceContext) {
this.now = ctx.now ?? (() => new Date().toISOString());
@@ -278,6 +287,44 @@ export class MessagingService {
mandatoryTopics: ctx.mandatoryTopics,
});
this.outbox = ctx.outbox;
+ this.devices = new DeviceRegistry({ getData: () => ctx.getData?.(), now: this.now });
+ }
+
+ /* ------------------------------------------------------------------ */
+ /* Push devices — `INotificationService`'s device members (ADR-0012 */
+ /* §6), over `sys_user_device`. See `./device-registry.ts`. */
+ /* ------------------------------------------------------------------ */
+
+ /**
+ * Register or refresh a push device for this user (the REST door's
+ * method: `POST /api/v1/notifications/devices`, whose `userId` is the
+ * session's). Semantics: `INotificationService.registerDevice`.
+ */
+ registerDevice(userId: string, request: RegisterDeviceRequest): Promise {
+ return this.devices.register(userId, request);
+ }
+
+ /**
+ * Unregister one of this user's devices; a device id this user does not
+ * hold throws `NOT_FOUND` (404). Semantics:
+ * `INotificationService.unregisterDevice`.
+ */
+ unregisterDevice(userId: string, deviceId: string): Promise {
+ return this.devices.unregister(userId, deviceId);
+ }
+
+ /** This user's devices, newest first. Semantics: `INotificationService.listDevices`. */
+ listDevices(userId: string): Promise {
+ return this.devices.list(userId);
+ }
+
+ /**
+ * Retire a token the vendor reported dead: the row is kept, disabled, with
+ * the time. Called by the push channel. Semantics:
+ * `INotificationService.invalidateDeviceToken`.
+ */
+ invalidateDeviceToken(token: string): Promise {
+ return this.devices.invalidate(token);
}
/**
diff --git a/packages/services/service-messaging/src/objects/index.ts b/packages/services/service-messaging/src/objects/index.ts
index fe72cde6f0e..2ca5bdfd65f 100644
--- a/packages/services/service-messaging/src/objects/index.ts
+++ b/packages/services/service-messaging/src/objects/index.ts
@@ -6,3 +6,4 @@ export { NotificationDelivery } from './notification-delivery.object.js';
export { NotificationPreference } from './notification-preference.object.js';
export { NotificationTemplate } from './notification-template.object.js';
export { HttpDelivery, SYS_HTTP_DELIVERY } from './http-delivery.object.js';
+export { UserDevice } from './user-device.object.js';
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
new file mode 100644
index 00000000000..ed73e979e4d
--- /dev/null
+++ b/packages/services/service-messaging/src/objects/user-device.object.test.ts
@@ -0,0 +1,39 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `sys_user_device`'s declaration (ADR-0012 §6): system-owned and closed to
+ * the generic data API, no organization column (spec rule a), and the same
+ * platform vocabulary as the registration it stores.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { RegisterDeviceRequestSchema } from '@objectstack/spec/api';
+import { UserDeviceSchema } from '@objectstack/spec/system';
+import { UserDevice } from './user-device.object.js';
+
+describe('sys_user_device declaration', () => {
+ it('is closed to the generic data API: no method is exposed', () => {
+ expect(UserDevice.enable?.apiEnabled).toBe(false);
+ expect(UserDevice.enable?.apiMethods).toEqual([]);
+ expect(UserDevice.managedBy).toBe('engine-owned');
+ });
+
+ it('has no organization column and opts out of tenancy (a device belongs to a user)', () => {
+ expect(Object.keys(UserDevice.fields)).not.toContain('organization_id');
+ expect(UserDevice.tenancy).toEqual({ enabled: false });
+ });
+
+ it('stores the platform vocabulary the registration admits, no more and no less', () => {
+ const declared = (UserDevice.fields.platform as any).options.map((o: any) => (typeof o === 'string' ? o : o.value));
+ const admitted = (RegisterDeviceRequestSchema as any).shape.platform.options;
+ expect([...declared].sort()).toEqual([...admitted].sort());
+ expect((UserDeviceSchema as any).shape.platform.options.slice().sort()).toEqual([...admitted].sort());
+ });
+
+ 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' }),
+ expect.objectContaining({ fields: ['token'], 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
new file mode 100644
index 00000000000..071a1c1ab99
--- /dev/null
+++ b/packages/services/service-messaging/src/objects/user-device.object.ts
@@ -0,0 +1,166 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { ObjectSchema, Field } from '@objectstack/spec/data';
+
+/**
+ * `sys_user_device` — the push device registry (ADR-0012 §6, §11): one row per
+ * (user, app install), the stored form of spec's `UserDeviceSchema`
+ * (`system/push-device.zod.ts`).
+ *
+ * Belongs to `service-messaging` because no push channel plugin exists yet and
+ * this service owns the `notification` slot whose `INotificationService`
+ * device members write it (`../device-registry.ts`). When the push channel
+ * lands it reads this table; it does not own it.
+ *
+ * The three rules spec states for the shape, and where each is kept:
+ *
+ * - **(a) A device belongs to a user, not to an organization.** No organization
+ * column: `tenancy: { enabled: false }` keeps the registry from injecting
+ * one, so the Layer 0 wall never applies and a phone receives its user's
+ * notifications whichever organization they come from.
+ * - **(b) One live row per token.** `token` is unique across the table, so a
+ * second writer racing the first is refused by the database rather than
+ * creating a second holder; the registry moves the token (deletes the
+ * previous holder) before it writes, and retries once on that refusal.
+ * - **(c) A dead token keeps its row.** `enabled: false` and `invalidated_at`,
+ * written by `invalidateDeviceToken`; a later registration re-enables it.
+ *
+ * System-owned: ⛔ the generic data API neither reads nor writes it
+ * (`apiEnabled: false`, `apiMethods: []`, the credential-table precedent). A
+ * user reaches their own rows only through the device routes
+ * (`/api/v1/notifications/devices`, `runtime/src/domains/notifications.ts`),
+ * whose user id is the session's, never a body member.
+ */
+export const UserDevice = ObjectSchema.create({
+ name: 'sys_user_device',
+ label: 'User Device',
+ pluralLabel: 'User Devices',
+ icon: 'smartphone',
+ isSystem: true,
+ // [ADR-0103] Engine-owned: written only by the messaging service's device
+ // registry (system-context engine writes), never by a person at a grid.
+ managedBy: 'engine-owned',
+ // Rule (a): the device has no organization. Without this the registry
+ // injects `organization_id` on an engine-owned object and the Layer 0 wall
+ // would scope a person's phone to one of their organizations.
+ tenancy: { enabled: false },
+ description: "A user's push device: one row per app install, holding its vendor push token.",
+ nameField: 'name', // [ADR-0079] canonical primary-title pointer
+ highlightFields: ['user_id', 'platform', 'vendor', 'enabled', 'last_seen_at'],
+
+ fields: {
+ id: Field.text({
+ label: 'Row ID',
+ required: true,
+ readonly: true,
+ }),
+
+ device_id: Field.text({
+ label: 'Device ID',
+ required: true,
+ // A client-held id (a UUID the app keeps, or the one the server
+ // minted); bounded like the other machine ids in this package.
+ maxLength: 255,
+ description: 'Client-held device id, stable per app install. Unique per user.',
+ }),
+
+ user_id: Field.lookup('sys_user', {
+ label: 'User',
+ required: true,
+ // [ADR-0118] A person's devices go with the person.
+ deleteBehavior: 'cascade',
+ description: 'The user the device belongs to: the signed-in user who registered it.',
+ }),
+
+ vendor: Field.select([
+ { label: 'APNs', value: 'apns' },
+ { label: 'FCM', value: 'fcm' },
+ ], {
+ label: 'Vendor',
+ required: true,
+ description: 'Push vendor the token belongs to.',
+ }),
+
+ token: Field.text({
+ label: 'Push Token',
+ required: true,
+ // APNs device tokens are 64 hex characters and FCM registration
+ // tokens run to about 160; 512 leaves room without an unindexable
+ // column (the unique index below covers it).
+ maxLength: 512,
+ description: 'Vendor push token. Held by one device at a time.',
+ }),
+
+ // Spec's `PUSH_DEVICE_PLATFORMS` is not on a public entry, so the three
+ // are spelled here; `user-device.object.test.ts` holds them equal to
+ // the registration schema's enum.
+ platform: Field.select([
+ { label: 'iOS', value: 'ios' },
+ { label: 'Android', value: 'android' },
+ { label: 'Web', value: 'web' },
+ ], {
+ label: 'Platform',
+ required: true,
+ }),
+
+ app_id: Field.text({
+ label: 'App ID',
+ required: false,
+ maxLength: 255,
+ description: 'The iOS bundle ID or Android application ID the token was issued to.',
+ }),
+
+ locale: Field.text({
+ label: 'Locale',
+ required: false,
+ // BCP 47 tags are short; 35 is the RFC 5646 practical maximum.
+ maxLength: 35,
+ description: 'Device locale that pushes to this device are rendered in.',
+ }),
+
+ name: Field.text({
+ label: 'Device Name',
+ required: false,
+ maxLength: 255,
+ }),
+
+ enabled: Field.boolean({
+ label: 'Enabled',
+ defaultValue: true,
+ description: 'False once the vendor reported the token dead; nothing is delivered to a disabled device.',
+ }),
+
+ invalidated_at: Field.datetime({
+ label: 'Invalidated At',
+ required: false,
+ description: 'When the vendor reported the token dead. Cleared by the registration that re-enables it.',
+ }),
+
+ last_seen_at: Field.datetime({
+ label: 'Last Seen At',
+ required: false,
+ description: 'When the device last registered or refreshed.',
+ }),
+
+ created_at: Field.datetime({
+ label: 'Created At',
+ defaultValue: 'NOW()',
+ readonly: true,
+ }),
+ },
+
+ indexes: [
+ // The identity of a row (spec rule a).
+ { fields: ['user_id', 'device_id'], unique: 'global' },
+ // One holder per token (spec rule b), enforced by the database.
+ { fields: ['token'], unique: 'global' },
+ ],
+
+ enable: {
+ trackHistory: false,
+ searchable: false,
+ // System-owned: reached only through the device routes.
+ apiEnabled: false,
+ apiMethods: [],
+ },
+});
diff --git a/packages/services/service-messaging/src/translations/bundle-ownership.test.ts b/packages/services/service-messaging/src/translations/bundle-ownership.test.ts
index b1450d53076..b30899d8924 100644
--- a/packages/services/service-messaging/src/translations/bundle-ownership.test.ts
+++ b/packages/services/service-messaging/src/translations/bundle-ownership.test.ts
@@ -23,6 +23,7 @@ const OWNED_OBJECTS = new Set([
'sys_notification_preference',
'sys_notification_template',
'sys_http_delivery',
+ 'sys_user_device',
]);
describe('objects translation bundle ownership (ADR-0029 D8)', () => {
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 a781ce040a7..0380395b780 100644
--- a/packages/services/service-messaging/src/translations/en.objects.generated.ts
+++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts
@@ -417,5 +417,69 @@ export const enObjects: NonNullable = {
label: "Pending"
}
}
+ },
+ sys_user_device: {
+ label: "User Device",
+ pluralLabel: "User Devices",
+ description: "A user's push device: one row per app install, holding its vendor push token.",
+ fields: {
+ id: {
+ label: "Row ID"
+ },
+ device_id: {
+ label: "Device ID",
+ help: "Client-held device id, stable per app install. Unique per user."
+ },
+ user_id: {
+ label: "User",
+ help: "The user the device belongs to: the signed-in user who registered it."
+ },
+ vendor: {
+ label: "Vendor",
+ help: "Push vendor the token belongs to.",
+ options: {
+ apns: "APNs",
+ fcm: "FCM"
+ }
+ },
+ token: {
+ label: "Push Token",
+ help: "Vendor push token. Held by one device at a time."
+ },
+ platform: {
+ label: "Platform",
+ options: {
+ ios: "iOS",
+ android: "Android",
+ web: "Web"
+ }
+ },
+ app_id: {
+ label: "App ID",
+ help: "The iOS bundle ID or Android application ID the token was issued to."
+ },
+ locale: {
+ label: "Locale",
+ help: "Device locale that pushes to this device are rendered in."
+ },
+ name: {
+ label: "Device Name"
+ },
+ enabled: {
+ label: "Enabled",
+ help: "False once the vendor reported the token dead; nothing is delivered to a disabled device."
+ },
+ invalidated_at: {
+ label: "Invalidated At",
+ help: "When the vendor reported the token dead. Cleared by the registration that re-enables it."
+ },
+ last_seen_at: {
+ label: "Last Seen At",
+ help: "When the device last registered or refreshed."
+ },
+ created_at: {
+ label: "Created At"
+ }
+ }
}
};
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 94baa8226a6..25499f4a26d 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
@@ -417,5 +417,69 @@ export const esESObjects: NonNullable = {
label: "Pending"
}
}
+ },
+ sys_user_device: {
+ label: "Dispositivo de usuario",
+ pluralLabel: "Dispositivos de usuario",
+ description: "Dispositivo de notificaciones push de un usuario: una fila por instalación de la aplicación, con su token push del proveedor.",
+ fields: {
+ id: {
+ label: "ID de fila"
+ },
+ device_id: {
+ label: "ID del dispositivo",
+ help: "ID del dispositivo que guarda el cliente, estable en cada instalación de la aplicación. Único por usuario."
+ },
+ user_id: {
+ label: "Usuario",
+ help: "El usuario al que pertenece el dispositivo: el usuario con sesión iniciada que lo registró."
+ },
+ vendor: {
+ label: "Proveedor",
+ help: "Proveedor push al que pertenece el token.",
+ options: {
+ apns: "APNs",
+ fcm: "FCM"
+ }
+ },
+ token: {
+ label: "Token push",
+ help: "Token push del proveedor. Solo lo tiene un dispositivo a la vez."
+ },
+ platform: {
+ label: "Plataforma",
+ options: {
+ ios: "iOS",
+ android: "Android",
+ web: "Web"
+ }
+ },
+ app_id: {
+ label: "ID de la aplicación",
+ help: "El ID de paquete de iOS o el ID de aplicación de Android para el que se emitió el token."
+ },
+ locale: {
+ label: "Configuración regional",
+ help: "Configuración regional del dispositivo en la que se muestran las notificaciones push enviadas a él."
+ },
+ name: {
+ label: "Nombre del dispositivo"
+ },
+ enabled: {
+ label: "Habilitado",
+ help: "Falso cuando el proveedor informa de que el token ya no es válido; a un dispositivo deshabilitado no se le entrega nada."
+ },
+ invalidated_at: {
+ label: "Invalidado el",
+ help: "Cuándo informó el proveedor de que el token ya no es válido. Se borra con el registro que vuelve a habilitar el dispositivo."
+ },
+ last_seen_at: {
+ label: "Visto por última vez",
+ help: "Cuándo se registró o actualizó el dispositivo por última vez."
+ },
+ created_at: {
+ label: "Creado el"
+ }
+ }
}
};
diff --git a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts
index 857a198185b..11ff43896f5 100644
--- a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts
+++ b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts
@@ -150,4 +150,9 @@ export const esESGeneratedSourceHashes: Readonly> = {
"objects.sys_notification_template.fields.version.label": "b74a37c0879ec50e",
"objects.sys_notification_template.label": "30ecafa4dac4b40d",
"objects.sys_notification_template.pluralLabel": "9e270c87ef2578df",
+ "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812",
+ "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91",
+ "objects.sys_user_device.fields.platform.options.web": "2a7ead3da8c035c5",
+ "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698",
+ "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4",
};
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 85307839291..b58d056f490 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
@@ -417,5 +417,69 @@ export const jaJPObjects: NonNullable = {
label: "Pending"
}
}
+ },
+ sys_user_device: {
+ label: "ユーザーデバイス",
+ pluralLabel: "ユーザーデバイス",
+ description: "ユーザーのプッシュ通知デバイス:アプリのインストールごとに 1 行で、ベンダーのプッシュトークンを保持します。",
+ fields: {
+ id: {
+ label: "行 ID"
+ },
+ device_id: {
+ label: "デバイス ID",
+ help: "クライアントが保持するデバイス ID。アプリのインストールごとに不変で、ユーザー内で一意です。"
+ },
+ user_id: {
+ label: "ユーザー",
+ help: "デバイスの所有ユーザー:デバイスを登録したサインイン中のユーザーです。"
+ },
+ vendor: {
+ label: "ベンダー",
+ help: "トークンが属するプッシュベンダー。",
+ options: {
+ apns: "APNs",
+ fcm: "FCM"
+ }
+ },
+ token: {
+ label: "プッシュトークン",
+ help: "ベンダーのプッシュトークン。同時に保持できるのは 1 台のデバイスのみです。"
+ },
+ platform: {
+ label: "プラットフォーム",
+ options: {
+ ios: "iOS",
+ android: "Android",
+ web: "Web"
+ }
+ },
+ app_id: {
+ label: "アプリ ID",
+ help: "トークンの発行先である iOS のバンドル ID または Android のアプリケーション ID。"
+ },
+ locale: {
+ label: "ロケール",
+ help: "このデバイスへのプッシュ通知の表示に使うデバイスのロケール。"
+ },
+ name: {
+ label: "デバイス名"
+ },
+ enabled: {
+ label: "有効",
+ help: "ベンダーがトークンの無効を報告すると false になります。無効なデバイスには何も配信されません。"
+ },
+ invalidated_at: {
+ label: "無効化日時",
+ help: "ベンダーがトークンの無効を報告した日時。デバイスを再度有効にする登録でクリアされます。"
+ },
+ last_seen_at: {
+ label: "最終確認日時",
+ help: "デバイスが最後に登録または更新された日時。"
+ },
+ created_at: {
+ label: "作成日時"
+ }
+ }
}
};
diff --git a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts
index 8810915fcf7..33486c906e7 100644
--- a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts
+++ b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts
@@ -149,4 +149,9 @@ export const jaJPGeneratedSourceHashes: Readonly> = {
"objects.sys_notification_template.fields.version.label": "b74a37c0879ec50e",
"objects.sys_notification_template.label": "30ecafa4dac4b40d",
"objects.sys_notification_template.pluralLabel": "9e270c87ef2578df",
+ "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812",
+ "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91",
+ "objects.sys_user_device.fields.platform.options.web": "2a7ead3da8c035c5",
+ "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698",
+ "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4",
};
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 b74d4378cc6..19806bf48a5 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
@@ -417,5 +417,69 @@ export const zhCNObjects: NonNullable = {
label: "待处理"
}
}
+ },
+ sys_user_device: {
+ label: "用户设备",
+ pluralLabel: "用户设备",
+ description: "用户的推送设备:每次应用安装一行,保存其厂商推送令牌。",
+ fields: {
+ id: {
+ label: "行 ID"
+ },
+ device_id: {
+ label: "设备 ID",
+ help: "由客户端保存的设备 ID,每次应用安装保持不变。同一用户内唯一。"
+ },
+ user_id: {
+ label: "用户",
+ help: "设备所属的用户:注册该设备的已登录用户。"
+ },
+ vendor: {
+ label: "厂商",
+ help: "令牌所属的推送厂商。",
+ options: {
+ apns: "APNs",
+ fcm: "FCM"
+ }
+ },
+ token: {
+ label: "推送令牌",
+ help: "厂商推送令牌。同一时间只由一台设备持有。"
+ },
+ platform: {
+ label: "平台",
+ options: {
+ ios: "iOS",
+ android: "Android",
+ web: "Web"
+ }
+ },
+ app_id: {
+ label: "应用 ID",
+ help: "令牌签发给的 iOS Bundle ID 或 Android 应用 ID。"
+ },
+ locale: {
+ label: "语言区域",
+ help: "发送到此设备的推送所使用的设备语言区域。"
+ },
+ name: {
+ label: "设备名称"
+ },
+ enabled: {
+ label: "已启用",
+ help: "厂商报告令牌失效后为否;不会向已停用的设备投递任何内容。"
+ },
+ invalidated_at: {
+ label: "失效时间",
+ help: "厂商报告令牌失效的时间。重新启用该设备的注册会将其清除。"
+ },
+ last_seen_at: {
+ label: "最近活跃时间",
+ help: "设备最近一次注册或刷新的时间。"
+ },
+ created_at: {
+ label: "创建时间"
+ }
+ }
}
};
diff --git a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts
index 23b7b41ada6..0ea88c365eb 100644
--- a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts
+++ b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts
@@ -23,4 +23,9 @@ export const zhCNGeneratedSourceHashes: Readonly> = {
"objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715",
"objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5",
"objects.sys_notification_preference.fields.display_title.label": "70f7aadecce647a5",
+ "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812",
+ "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91",
+ "objects.sys_user_device.fields.platform.options.web": "2a7ead3da8c035c5",
+ "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698",
+ "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4",
};
diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts
index 55e45caaa86..7fa06605d96 100644
--- a/packages/spec/src/api/error-code-ledger.zod.ts
+++ b/packages/spec/src/api/error-code-ledger.zod.ts
@@ -1208,6 +1208,13 @@ export const ERROR_CODE_LEDGER = {
// operation, try when it settles"; that one says "never, fix the
// configuration instead".
'DELIVERY_NOT_ELIGIBLE',
+ // [#29068] `unregisterDevice`'s refusal for a device id the caller does
+ // not hold, unknown or another user's alike (`deviceNotFoundError` in
+ // `device-registry.ts`), so the answer never says the id exists for
+ // somebody else. Wire path: `DELETE /api/v1/notifications/devices/:deviceId`
+ // throws it out of the `/notifications` domain and the dispatcher answers
+ // `404 NOT_FOUND` through `resolveThrownHttpError`.
+ 'NOT_FOUND',
],
'@objectstack/trigger-api': [
'ENQUEUE_FAILED', // queue accepted the call but publish threw
diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts
index 3354a2ed147..7058c898d51 100644
--- a/packages/spec/src/api/plugin-rest-api.test.ts
+++ b/packages/spec/src/api/plugin-rest-api.test.ts
@@ -514,13 +514,17 @@ describe('plugin-rest-api.zod', () => {
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('listNotifications');
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('markNotificationsRead');
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('markAllNotificationsRead');
- // 3 = list + read + read/all, the routes the dispatcher actually mounts.
- // The devices/preferences endpoints this table used to declare were
- // removed as server routes in #3612 (in fact never built) and are gone
- // from the catalog too (#3899) — same cure as DEFAULT_AI_ROUTES (#3718).
- expect(DEFAULT_NOTIFICATION_ROUTES.endpoints).toHaveLength(3);
+ expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('registerDevice');
+ expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('unregisterDevice');
+ // 5 = list + read + read/all + the two device routes, the routes the
+ // dispatcher actually mounts. The device rows came back with their
+ // handlers (#29068); `/preferences` stays gone, removed as a server
+ // route in #3612 (in fact never built) and from the catalog in #3899.
+ expect(DEFAULT_NOTIFICATION_ROUTES.endpoints).toHaveLength(5);
+ const routes = DEFAULT_NOTIFICATION_ROUTES.endpoints?.map(e => `${e.method} ${e.path}`) ?? [];
+ expect(routes).toContain('POST /devices');
+ expect(routes).toContain('DELETE /devices/:deviceId');
const paths = DEFAULT_NOTIFICATION_ROUTES.endpoints?.map(e => e.path) ?? [];
- expect(paths).not.toContain('/devices');
expect(paths).not.toContain('/preferences');
});
diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts
index f365b815fca..a7d10a0a6bc 100644
--- a/packages/spec/src/api/plugin-rest-api.zod.ts
+++ b/packages/spec/src/api/plugin-rest-api.zod.ts
@@ -1204,17 +1204,18 @@ export const DEFAULT_NOTIFICATION_ROUTES: RestApiRouteRegistration = {
category: 'notification',
methods: [
'listNotifications', 'markNotificationsRead', 'markAllNotificationsRead',
+ 'registerDevice', 'unregisterDevice',
],
authRequired: true,
- // The device-registration and preferences endpoints are GONE from this table
- // (#3899). `POST/DELETE /devices*` and `GET/PATCH /preferences` were removed
- // as SERVER routes in #3612 — in fact they were never built ("the
- // /notifications/devices and /notifications/preferences server routes that
- // ADR-0012 describes were never built", packages/client/src/index.ts) — yet
- // this table kept declaring them, requestSchema and all, so the catalog
- // promised validation on routes that 404. Same disease, same cure as
- // DEFAULT_AI_ROUTES (#3718). The `RegisterDevice*` / `*Preferences*` schemas
- // in protocol.zod.ts remain exported as protocol-method contracts.
+ // The two device rows came back with their handlers (#29068): the device
+ // registry's `POST /devices` and `DELETE /devices/:deviceId` are served by
+ // the runtime's notifications domain and mounted by its dispatcher plugin,
+ // and `request-schema-gate.conformance.test.ts` walks this table against
+ // that dispatcher, so a row and its handler cannot land apart. They were
+ // removed in #3899 because nothing served them then (#3612: "never
+ // built"), the same cure as DEFAULT_AI_ROUTES (#3718). `/preferences` stays
+ // gone: no server route serves it, and its `*Preferences*` schemas in
+ // protocol.zod.ts remain exported as protocol-method contracts only.
endpoints: [
{
method: 'GET',
@@ -1258,6 +1259,31 @@ export const DEFAULT_NOTIFICATION_ROUTES: RestApiRouteRegistration = {
responseSchema: 'MarkAllNotificationsReadResponseSchema',
cacheable: false,
},
+ {
+ method: 'POST',
+ path: '/devices',
+ handler: 'registerDevice',
+ category: 'notification',
+ public: false,
+ summary: 'Register or refresh a push device',
+ description: 'Registers the current user\'s push device, or refreshes it when its deviceId is already registered',
+ tags: ['Notifications'],
+ requestSchema: 'RegisterDeviceRequestSchema',
+ responseSchema: 'RegisterDeviceResponseSchema',
+ cacheable: false,
+ },
+ {
+ method: 'DELETE',
+ path: '/devices/:deviceId',
+ handler: 'unregisterDevice',
+ category: 'notification',
+ public: false,
+ summary: 'Unregister a push device',
+ description: 'Removes one of the current user\'s push devices; a device the user does not hold answers 404',
+ tags: ['Notifications'],
+ responseSchema: 'UnregisterDeviceResponseSchema',
+ cacheable: false,
+ },
],
middleware: [
{ name: 'auth', type: 'authentication', enabled: true, order: 10 },
diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts
index 744b13f7428..edec2696839 100644
--- a/packages/spec/src/api/protocol.zod.ts
+++ b/packages/spec/src/api/protocol.zod.ts
@@ -3519,11 +3519,9 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({
/**
* Register or refresh a push device for the signed-in user (ADR-0012 §6).
*
- * **Declared ahead of its producer.** No route serves this body yet: the
- * device routes land together with their handlers, and until then each of
- * the four device schemas below carries a `[PLANNED — not yet served]` marker
- * in its `.describe()`. What follows is the contract that route owes, not a
- * description of something running.
+ * Served by `POST /api/v1/notifications/devices` (its row in
+ * `DEFAULT_NOTIFICATION_ROUTES`); unregister is
+ * `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
@@ -3537,9 +3535,6 @@ export const GetPresenceResponseSchema = lazySchema(() => z.object({
* The stored device a registration produces is `UserDeviceSchema`
* (`system/push-device.zod.ts`).
*/
-// The marker comes off when the server device-registry slice (#29068) mounts
-// POST /api/v1/notifications/devices and the unregister route, adds their rows
-// to DEFAULT_NOTIFICATION_ROUTES and flips that table's catalog pin.
export const RegisterDeviceRequestSchema = lazySchema(() => z.object({
token: z.string().describe('Device push notification token'),
platform: z.enum(PUSH_DEVICE_PLATFORMS).describe('Device platform'),
@@ -3554,23 +3549,20 @@ 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'),
-}).describe(
- '[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; '
- + 'the device routes land together with their handlers',
-));
+}).describe('Register or refresh a push device for the signed-in user'));
export const RegisterDeviceResponseSchema = lazySchema(() => z.object({
deviceId: z.string().describe('Registered device ID'),
success: z.boolean().describe('Whether registration succeeded'),
-}).describe('[PLANNED — not yet served] Answer to a device registration: the device id the client keeps'));
+}).describe('Answer to a device registration: the device id the client keeps'));
export const UnregisterDeviceRequestSchema = lazySchema(() => z.object({
deviceId: z.string().describe('Device ID to unregister'),
-}).describe('[PLANNED — not yet served] Unregister one of the signed-in user\'s push devices'));
+}).describe('Unregister one of the signed-in user\'s push devices'));
export const UnregisterDeviceResponseSchema = lazySchema(() => z.object({
success: z.boolean().describe('Whether unregistration succeeded'),
-}).describe('[PLANNED — not yet served] Answer to a device unregistration'));
+}).describe('Answer to a device unregistration'));
export const NotificationPreferencesSchema = lazySchema(() => z.object({
email: z.boolean().default(true).describe('Receive email notifications'),
diff --git a/packages/spec/src/api/register-device-request.test.ts b/packages/spec/src/api/register-device-request.test.ts
index 13fa98a8cee..634859e1675 100644
--- a/packages/spec/src/api/register-device-request.test.ts
+++ b/packages/spec/src/api/register-device-request.test.ts
@@ -88,15 +88,16 @@ describe('RegisterDeviceRequestSchema (push device registry)', () => {
});
});
-// Declared ahead of its producer (ADR-0049): no route serves the device
-// schemas until the server device-registry slice lands, and each says so.
-describe('device registration schemas are declared, not yet served', () => {
+// Served (ADR-0049): the device routes landed with the server device-registry
+// slice, so a PLANNED marker on these schemas would be a false claim.
+describe('device registration schemas are served', () => {
it.each([
['RegisterDeviceRequestSchema', RegisterDeviceRequestSchema],
['RegisterDeviceResponseSchema', RegisterDeviceResponseSchema],
['UnregisterDeviceRequestSchema', UnregisterDeviceRequestSchema],
['UnregisterDeviceResponseSchema', UnregisterDeviceResponseSchema],
- ] as const)('%s carries the PLANNED marker', (_name, schema) => {
- expect(schema.description).toMatch(/^\[PLANNED — /);
+ ] as const)('%s carries no PLANNED marker', (_name, schema) => {
+ expect(schema.description).toBeTruthy();
+ expect(schema.description).not.toMatch(/PLANNED/);
});
});
diff --git a/packages/spec/src/contracts/notification-service.ts b/packages/spec/src/contracts/notification-service.ts
index 30a88309c93..041ed494530 100644
--- a/packages/spec/src/contracts/notification-service.ts
+++ b/packages/spec/src/contracts/notification-service.ts
@@ -225,13 +225,12 @@ export interface INotificationService {
* `UnregisterDeviceResponseSchema` (`api/protocol.zod.ts`), and the stored
* device `UserDeviceSchema` (`system/push-device.zod.ts`).
*
- * Declared ahead of their implementation: no provider implements them
- * and no route calls them yet. They are the contract the device registry
- * and its routes owe when they land, not a description of running code.
+ * Implemented by `@objectstack/service-messaging` (`MessagingService`,
+ * over `sys_user_device`); `registerDevice` and `unregisterDevice` are
+ * served by `POST` / `DELETE /api/v1/notifications/devices`.
+ * `listDevices` has no route, and `invalidateDeviceToken` is called by
+ * the push channel's invalid-recipient path.
*/
- // First implementation and callers: the server device-registry slice
- // (#29068); the push channel's invalid-recipient path calls
- // invalidateDeviceToken (#29041).
/**
* Register or refresh a push device for this user.
diff --git a/packages/spec/src/system/constants/platform-object-names.test.ts b/packages/spec/src/system/constants/platform-object-names.test.ts
index 2b34e49e9cd..afa1d6f7a66 100644
--- a/packages/spec/src/system/constants/platform-object-names.test.ts
+++ b/packages/spec/src/system/constants/platform-object-names.test.ts
@@ -175,6 +175,7 @@ describe('platform-object predicates', () => {
'sys_notification_preference',
'sys_notification_receipt',
'sys_notification_template',
+ 'sys_user_device',
]);
expect(isPlatformProvidedObjectName('sys_notification_subscription')).toBe(false);
expect(isPlatformProvidedObjectName('sys_notification_preference')).toBe(true);
diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts
index 45229789bcb..f6a95cac013 100644
--- a/packages/spec/src/system/constants/platform-object-names.ts
+++ b/packages/spec/src/system/constants/platform-object-names.ts
@@ -136,6 +136,7 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly {
expect(Object.keys(UserDeviceSchema.shape)).not.toContain('organizationId');
});
- it('carries the PLANNED marker until the device registry stores it', () => {
- expect(UserDeviceSchema.description).toMatch(/^\[PLANNED — /);
+ it('carries no PLANNED marker: the device registry stores it', () => {
+ expect(UserDeviceSchema.description).toBeTruthy();
+ expect(UserDeviceSchema.description).not.toMatch(/PLANNED/);
});
});
diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts
index 5329cce138b..bacdd09e5b9 100644
--- a/packages/spec/src/system/push-device.zod.ts
+++ b/packages/spec/src/system/push-device.zod.ts
@@ -15,10 +15,10 @@ import { PUSH_DEVICE_PLATFORMS } from './push-device-platform';
* push channel reads to address a phone, and what the registry's listing
* answers (`INotificationService.listDevices`).
*
- * **Declared ahead of its producer.** No table stores it and no route serves
- * it yet; the schema carries a `[PLANNED — not yet served]` marker in its
- * `.describe()` until the device registry lands (in the push channel plugin's
- * `sys_user_device`, ADR-0012 §11).
+ * Stored in `sys_user_device`, the device registry's table (ADR-0012 §11),
+ * which `@objectstack/service-messaging` owns while no push channel plugin
+ * exists: its `INotificationService` device members write it, and the device
+ * routes under `/api/v1/notifications/devices` are the only door to it.
*
* Three rules the registry owes, stated here because they decide the shape:
*
@@ -46,8 +46,6 @@ import { PUSH_DEVICE_PLATFORMS } from './push-device-platform';
* domestic Android vendors plug in as alternate channel implementations) and
* `platform` is the registration's own three (`ios` | `android` | `web`).
*/
-// The marker comes off when the server device-registry slice (#29068) stores
-// and serves this shape.
export const UserDeviceSchema = lazySchema(() => z.object({
deviceId: z.string().min(1).describe(
'Client-held device id, stable per app install: the one the registration carried or the server minted. Unique per user',
@@ -68,8 +66,5 @@ export const UserDeviceSchema = lazySchema(() => z.object({
'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',
),
-}).describe(
- '[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; '
- + 'the device registry lands with the push channel',
-));
+}).describe('A push device of one user: one row per app install'));
export type UserDevice = z.input;
diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json
index 101dac5ed9f..a775e6db527 100644
--- a/scripts/platform-object-tenancy-census.json
+++ b/scripts/platform-object-tenancy-census.json
@@ -26,14 +26,14 @@
"predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.",
"population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.",
"totals": {
- "registered": 92,
+ "registered": 93,
"inReach": 47,
- "outOfReach": 45
+ "outOfReach": 46
},
"reasonTotals": {
"managedBy: 'better-auth'": 25,
"systemFields.tenant: false": 20,
- "tenancy.enabled: false": 2
+ "tenancy.enabled: false": 3
},
"unexplained": [],
"objects": [
@@ -742,6 +742,15 @@
"managedBy: 'better-auth'"
]
},
+ {
+ "name": "sys_user_device",
+ "file": "packages/services/service-messaging/src/objects/user-device.object.ts",
+ "reach": "out",
+ "tenantField": null,
+ "reasons": [
+ "tenancy.enabled: false"
+ ]
+ },
{
"name": "sys_user_permission_set",
"file": "packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts",