Skip to content

Commit 3c0d5be

Browse files
committed
feat(runtime,spec): serve POST/DELETE /api/v1/notifications/devices; the device rows return to DEFAULT_NOTIFICATION_ROUTES and the PLANNED markers come off
Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent e2fb96f commit 3c0d5be

10 files changed

Lines changed: 220 additions & 61 deletions

‎packages/runtime/src/dispatcher-plugin.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,6 +1537,70 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
15371537
domainDecidesEveryVerb('POST', `${prefix}/notifications/read`);
15381538
domainDecidesEveryVerb('POST', `${prefix}/notifications/read/all`);
15391539

1540+
// ── Push devices (ADR-0012 §6) ──────────────────────────────
1541+
// The device registry's two faces, served by the same domain
1542+
// (`handleNotificationRequest`) behind the same anonymous floor:
1543+
// `POST /notifications/devices` registers or refreshes the
1544+
// caller's device, `DELETE /notifications/devices/:deviceId`
1545+
// removes the caller's own. Mounted here because this is the only
1546+
// HTTP owner the standalone server has for `/notifications*`; the
1547+
// cloud hosts reach the domain through `dispatch()` as before.
1548+
//
1549+
// The device id is re-encoded on the way in: the router hands
1550+
// `req.params` decoded, and the domain decodes the segment it
1551+
// reads, so an id carrying `/` or `%` reaches the service as sent.
1552+
server.post(`${prefix}/notifications/devices`, async (req: any, res: any) => {
1553+
try {
1554+
const result = await dispatcher.dispatch('POST', '/notifications/devices', deferRequestBody(req), req.query, { request: req });
1555+
sendResult(result, res);
1556+
} catch (err: any) {
1557+
errorResponse(err, res);
1558+
}
1559+
});
1560+
server.delete(`${prefix}/notifications/devices/:deviceId`, async (req: any, res: any) => {
1561+
try {
1562+
const path = `/notifications/devices/${encodeURIComponent(String(req.params?.deviceId ?? ''))}`;
1563+
const result = await dispatcher.dispatch('DELETE', path, undefined, req.query, { request: req });
1564+
sendResult(result, res);
1565+
} catch (err: any) {
1566+
errorResponse(err, res);
1567+
}
1568+
});
1569+
// [#24024] As for the three inbox faces: every other verb on the two
1570+
// device paths is mounted too, so it reaches the domain's anonymous
1571+
// floor (401) instead of the adapter's 405, and a signed-in caller
1572+
// gets the domain's 404. No body is handed on: none is a face.
1573+
const mountDevicesRefusal = (
1574+
pattern: '/notifications/devices' | '/notifications/devices/:deviceId',
1575+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
1576+
) => {
1577+
const register =
1578+
method === 'GET' ? server.get
1579+
: method === 'POST' ? server.post
1580+
: method === 'PUT' ? server.put
1581+
: method === 'PATCH' ? server.patch
1582+
: server.delete;
1583+
register.call(server, `${prefix}${pattern}`, async (req: any, res: any) => {
1584+
try {
1585+
const path = pattern === '/notifications/devices'
1586+
? pattern
1587+
: `/notifications/devices/${encodeURIComponent(String(req.params?.deviceId ?? ''))}`;
1588+
const result = await dispatcher.dispatch(method, path, undefined, req.query, { request: req });
1589+
sendResult(result, res);
1590+
} catch (err: any) {
1591+
errorResponse(err, res);
1592+
}
1593+
});
1594+
};
1595+
for (const method of ['GET', 'PUT', 'PATCH', 'DELETE'] as const) {
1596+
mountDevicesRefusal('/notifications/devices', method);
1597+
}
1598+
for (const method of ['GET', 'POST', 'PUT', 'PATCH'] as const) {
1599+
mountDevicesRefusal('/notifications/devices/:deviceId', method);
1600+
}
1601+
domainDecidesEveryVerb('POST', `${prefix}/notifications/devices`);
1602+
domainDecidesEveryVerb('GET', `${prefix}/notifications/devices/:deviceId`);
1603+
15401604
// ── Packages ────────────────────────────────────────────────
15411605
// Single pipeline (ADR-0076 D11 / OQ#9): every package route flows
15421606
// through dispatch() — like analytics / i18n / automation / AI —

‎packages/runtime/src/domains/notifications.ts‎

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@
2020
* as it did from the if-chain.
2121
*
2222
* Routes (path is the sub-path after `/notifications`):
23-
* GET '' → listInbox (query: read, type, limit — validated, #6928)
24-
* POST /read → markRead (body: { ids: string[] })
25-
* POST /read/all → markAllRead
23+
* GET '' → listInbox (query: read, type, limit — validated, #6928)
24+
* POST /read → markRead (body: { ids: string[] })
25+
* POST /read/all → markAllRead
26+
* POST /devices → registerDevice (body: RegisterDeviceRequestSchema, ADR-0012 §6)
27+
* DELETE /devices/:deviceId → unregisterDevice (the caller's own device; another's is 404)
2628
*/
2729

2830
import {
2931
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
3032
} from '@objectstack/core';
3133
import { CoreServiceName } from '@objectstack/spec/system';
32-
import { MarkNotificationsReadRequestSchema } from '@objectstack/spec/api';
34+
import { MarkNotificationsReadRequestSchema, RegisterDeviceRequestSchema } from '@objectstack/spec/api';
3335
import type { INotificationService } from '@objectstack/spec/contracts';
3436
import { isServiceServeable } from '../service-serveable.js';
3537
import { validationFailure, fieldsFromZodIssues } from '../validation-failure.js';
@@ -98,6 +100,20 @@ function parseTypeFilter(raw: unknown): string | undefined {
98100
return parseStringParam('type', raw);
99101
}
100102

103+
/**
104+
* The `:deviceId` path segment, decoded as the self-hosted router decodes a
105+
* parameter. An escape that does not decode is kept raw rather than thrown on
106+
* (the `meta` / `automation-hooks` precedent): a malformed id names no device
107+
* this caller holds, so it gets the ordinary 404, never a 500.
108+
*/
109+
function decodeDeviceSegment(segment: string): string {
110+
try {
111+
return decodeURIComponent(segment);
112+
} catch {
113+
return segment;
114+
}
115+
}
116+
101117
export function createNotificationsDomain(deps: DomainHandlerDeps): DomainRoute {
102118
return {
103119
prefix: '/notifications',
@@ -169,19 +185,60 @@ export async function handleNotificationRequest(
169185
// is mounted, so that produced a ROUTE_NOT_FOUND whose hint ("check the API
170186
// discovery endpoint") pointed at a page that correctly does not list the
171187
// route. See ./unavailable.ts. The slot key is `notification`, singular.
172-
if (!service || !isServiceServeable(service) || typeof service.listInbox !== 'function') {
188+
if (!service || !isServiceServeable(service)) {
173189
return capabilityUnavailable(deps, 'notification');
174190
}
175-
// Narrowed for the routes below: the entry probe established `listInbox`.
176-
const inbox = service as INotificationService & Required<Pick<INotificationService, 'listInbox'>>;
177191

178192
const m = method.toUpperCase();
179193
// split+filter drops leading/trailing/duplicate slashes without a regex
180194
// over request-controlled input (CodeQL js/polynomial-redos) — same
181195
// treatment the security domain got for the identical latent pattern.
182196
// Surfaced when the extraction (#3507) made this line "changed code":
183197
// the legacy `.replace(/\/+$/, '')` had carried the trap since ADR-0030.
184-
const subPath = path.split('/').filter(Boolean).join('/');
198+
const segments = path.split('/').filter(Boolean);
199+
const subPath = segments.join('/');
200+
201+
// ── Push devices (ADR-0012 §6) ─────────────────────────────────────────
202+
// The device registry is a capability of its own, separately optional on
203+
// the contract from the inbox, so these faces probe THEIR member and sit
204+
// ahead of the inbox probe below: a provider with a device registry and no
205+
// inbox still serves them, and one with an inbox and no registry answers
206+
// the 501 capability disclosure, never a 404. The owner is the session's
207+
// user (bound above, after the anonymous floor); no body member names one.
208+
if (segments[0] === 'devices') {
209+
// POST /notifications/devices — register or refresh this user's device.
210+
if (segments.length === 1 && m === 'POST') {
211+
if (typeof service.registerDevice !== 'function') return capabilityUnavailable(deps, 'notification');
212+
// Validated against the declared contract (`RegisterDeviceRequestSchema`,
213+
// the catalog row's `requestSchema`), as mark-read is (#3899).
214+
const parsed = RegisterDeviceRequestSchema.safeParse(body ?? {});
215+
if (!parsed.success) {
216+
const fields = fieldsFromZodIssues(parsed.error.issues);
217+
throw validationFailure(
218+
`Invalid device registration body: ${fields.map((f) => `${f.field}: ${f.message}`).join('; ')}`,
219+
fields,
220+
);
221+
}
222+
const result = await service.registerDevice(userId, parsed.data);
223+
return { handled: true, response: deps.success(result) };
224+
}
225+
// DELETE /notifications/devices/:deviceId — the caller's own device only.
226+
// A device id the caller does not hold (unknown, or another user's) is
227+
// the service's `NOT_FOUND` throw, which the dispatcher answers 404:
228+
// never a distinct "forbidden", so the id's existence is not disclosed.
229+
if (segments.length === 2 && m === 'DELETE') {
230+
if (typeof service.unregisterDevice !== 'function') return capabilityUnavailable(deps, 'notification');
231+
const result = await service.unregisterDevice(userId, decodeDeviceSegment(segments[1]));
232+
return { handled: true, response: deps.success(result) };
233+
}
234+
return { handled: false };
235+
}
236+
237+
if (typeof service.listInbox !== 'function') {
238+
return capabilityUnavailable(deps, 'notification');
239+
}
240+
// Narrowed for the routes below: the entry probe established `listInbox`.
241+
const inbox = service as INotificationService & Required<Pick<INotificationService, 'listInbox'>>;
185242

186243
// Each write route probes its OWN method rather than riding the entry
187244
// `listInbox` probe (#4127). The three are separately optional on the

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,26 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
399399
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)' },
400400
{ route: 'DELETE /notifications/read/all', domain: '/notifications', disposition: 'server-only',
401401
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)' },
402+
// Push devices (ADR-0012 §6): the device registry's two faces and their
403+
// refusal-only verbs, mounted for the same every-face anonymous floor.
404+
{ route: 'POST /notifications/devices', domain: '/notifications', disposition: 'sdk', client: 'notifications.registerDevice' },
405+
{ route: 'DELETE /notifications/devices/:deviceId', domain: '/notifications', disposition: 'sdk', client: 'notifications.unregisterDevice' },
406+
{ route: 'GET /notifications/devices', domain: '/notifications', disposition: 'server-only',
407+
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 notification service)' },
408+
{ route: 'PUT /notifications/devices', domain: '/notifications', disposition: 'server-only',
409+
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 notification service)' },
410+
{ route: 'PATCH /notifications/devices', domain: '/notifications', disposition: 'server-only',
411+
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 notification service)' },
412+
{ route: 'DELETE /notifications/devices', domain: '/notifications', disposition: 'server-only',
413+
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 notification service)' },
414+
{ route: 'GET /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
415+
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 notification service)' },
416+
{ route: 'POST /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
417+
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 notification service)' },
418+
{ route: 'PUT /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
419+
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 notification service)' },
420+
{ route: 'PATCH /notifications/devices/:deviceId', domain: '/notifications', disposition: 'server-only',
421+
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 notification service)' },
402422

403423
// ── security (ADR-0090 suggested audience bindings) ───────────────────────
404424
{ route: 'GET /security/suggested-bindings', domain: '/security', disposition: 'sdk', client: 'security.suggestedBindings.list' },

‎packages/spec/src/api/plugin-rest-api.test.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -514,13 +514,17 @@ describe('plugin-rest-api.zod', () => {
514514
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('listNotifications');
515515
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('markNotificationsRead');
516516
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('markAllNotificationsRead');
517-
// 3 = list + read + read/all, the routes the dispatcher actually mounts.
518-
// The devices/preferences endpoints this table used to declare were
519-
// removed as server routes in #3612 (in fact never built) and are gone
520-
// from the catalog too (#3899) — same cure as DEFAULT_AI_ROUTES (#3718).
521-
expect(DEFAULT_NOTIFICATION_ROUTES.endpoints).toHaveLength(3);
517+
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('registerDevice');
518+
expect(DEFAULT_NOTIFICATION_ROUTES.methods).toContain('unregisterDevice');
519+
// 5 = list + read + read/all + the two device routes, the routes the
520+
// dispatcher actually mounts. The device rows came back with their
521+
// handlers (#29068); `/preferences` stays gone, removed as a server
522+
// route in #3612 (in fact never built) and from the catalog in #3899.
523+
expect(DEFAULT_NOTIFICATION_ROUTES.endpoints).toHaveLength(5);
524+
const routes = DEFAULT_NOTIFICATION_ROUTES.endpoints?.map(e => `${e.method} ${e.path}`) ?? [];
525+
expect(routes).toContain('POST /devices');
526+
expect(routes).toContain('DELETE /devices/:deviceId');
522527
const paths = DEFAULT_NOTIFICATION_ROUTES.endpoints?.map(e => e.path) ?? [];
523-
expect(paths).not.toContain('/devices');
524528
expect(paths).not.toContain('/preferences');
525529
});
526530

‎packages/spec/src/api/plugin-rest-api.zod.ts‎

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,17 +1204,18 @@ export const DEFAULT_NOTIFICATION_ROUTES: RestApiRouteRegistration = {
12041204
category: 'notification',
12051205
methods: [
12061206
'listNotifications', 'markNotificationsRead', 'markAllNotificationsRead',
1207+
'registerDevice', 'unregisterDevice',
12071208
],
12081209
authRequired: true,
1209-
// The device-registration and preferences endpoints are GONE from this table
1210-
// (#3899). `POST/DELETE /devices*` and `GET/PATCH /preferences` were removed
1211-
// as SERVER routes in #3612 — in fact they were never built ("the
1212-
// /notifications/devices and /notifications/preferences server routes that
1213-
// ADR-0012 describes were never built", packages/client/src/index.ts) — yet
1214-
// this table kept declaring them, requestSchema and all, so the catalog
1215-
// promised validation on routes that 404. Same disease, same cure as
1216-
// DEFAULT_AI_ROUTES (#3718). The `RegisterDevice*` / `*Preferences*` schemas
1217-
// in protocol.zod.ts remain exported as protocol-method contracts.
1210+
// The two device rows came back with their handlers (#29068): the device
1211+
// registry's `POST /devices` and `DELETE /devices/:deviceId` are served by
1212+
// the runtime's notifications domain and mounted by its dispatcher plugin,
1213+
// and `request-schema-gate.conformance.test.ts` walks this table against
1214+
// that dispatcher, so a row and its handler cannot land apart. They were
1215+
// removed in #3899 because nothing served them then (#3612: "never
1216+
// built"), the same cure as DEFAULT_AI_ROUTES (#3718). `/preferences` stays
1217+
// gone: no server route serves it, and its `*Preferences*` schemas in
1218+
// protocol.zod.ts remain exported as protocol-method contracts only.
12181219
endpoints: [
12191220
{
12201221
method: 'GET',
@@ -1258,6 +1259,31 @@ export const DEFAULT_NOTIFICATION_ROUTES: RestApiRouteRegistration = {
12581259
responseSchema: 'MarkAllNotificationsReadResponseSchema',
12591260
cacheable: false,
12601261
},
1262+
{
1263+
method: 'POST',
1264+
path: '/devices',
1265+
handler: 'registerDevice',
1266+
category: 'notification',
1267+
public: false,
1268+
summary: 'Register or refresh a push device',
1269+
description: 'Registers the current user\'s push device, or refreshes it when its deviceId is already registered',
1270+
tags: ['Notifications'],
1271+
requestSchema: 'RegisterDeviceRequestSchema',
1272+
responseSchema: 'RegisterDeviceResponseSchema',
1273+
cacheable: false,
1274+
},
1275+
{
1276+
method: 'DELETE',
1277+
path: '/devices/:deviceId',
1278+
handler: 'unregisterDevice',
1279+
category: 'notification',
1280+
public: false,
1281+
summary: 'Unregister a push device',
1282+
description: 'Removes one of the current user\'s push devices; a device the user does not hold answers 404',
1283+
tags: ['Notifications'],
1284+
responseSchema: 'UnregisterDeviceResponseSchema',
1285+
cacheable: false,
1286+
},
12611287
],
12621288
middleware: [
12631289
{ name: 'auth', type: 'authentication', enabled: true, order: 10 },

0 commit comments

Comments
 (0)