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",