|
20 | 20 | * as it did from the if-chain. |
21 | 21 | * |
22 | 22 | * 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) |
26 | 28 | */ |
27 | 29 |
|
28 | 30 | import { |
29 | 31 | shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, |
30 | 32 | } from '@objectstack/core'; |
31 | 33 | import { CoreServiceName } from '@objectstack/spec/system'; |
32 | | -import { MarkNotificationsReadRequestSchema } from '@objectstack/spec/api'; |
| 34 | +import { MarkNotificationsReadRequestSchema, RegisterDeviceRequestSchema } from '@objectstack/spec/api'; |
33 | 35 | import type { INotificationService } from '@objectstack/spec/contracts'; |
34 | 36 | import { isServiceServeable } from '../service-serveable.js'; |
35 | 37 | import { validationFailure, fieldsFromZodIssues } from '../validation-failure.js'; |
@@ -98,6 +100,20 @@ function parseTypeFilter(raw: unknown): string | undefined { |
98 | 100 | return parseStringParam('type', raw); |
99 | 101 | } |
100 | 102 |
|
| 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 | + |
101 | 117 | export function createNotificationsDomain(deps: DomainHandlerDeps): DomainRoute { |
102 | 118 | return { |
103 | 119 | prefix: '/notifications', |
@@ -169,19 +185,60 @@ export async function handleNotificationRequest( |
169 | 185 | // is mounted, so that produced a ROUTE_NOT_FOUND whose hint ("check the API |
170 | 186 | // discovery endpoint") pointed at a page that correctly does not list the |
171 | 187 | // route. See ./unavailable.ts. The slot key is `notification`, singular. |
172 | | - if (!service || !isServiceServeable(service) || typeof service.listInbox !== 'function') { |
| 188 | + if (!service || !isServiceServeable(service)) { |
173 | 189 | return capabilityUnavailable(deps, 'notification'); |
174 | 190 | } |
175 | | - // Narrowed for the routes below: the entry probe established `listInbox`. |
176 | | - const inbox = service as INotificationService & Required<Pick<INotificationService, 'listInbox'>>; |
177 | 191 |
|
178 | 192 | const m = method.toUpperCase(); |
179 | 193 | // split+filter drops leading/trailing/duplicate slashes without a regex |
180 | 194 | // over request-controlled input (CodeQL js/polynomial-redos) — same |
181 | 195 | // treatment the security domain got for the identical latent pattern. |
182 | 196 | // Surfaced when the extraction (#3507) made this line "changed code": |
183 | 197 | // 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'>>; |
185 | 242 |
|
186 | 243 | // Each write route probes its OWN method rather than riding the entry |
187 | 244 | // `listInbox` probe (#4127). The three are separately optional on the |
|
0 commit comments