Skip to content

Commit 9ca57be

Browse files
committed
test(dogfood): pin the data API's OBJECT_API_DISABLED answer on sys_user_device; changeset
Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2b00aed commit 9ca57be

2 files changed

Lines changed: 25 additions & 5 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/client': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
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)
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
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.
14+
15+
- **`@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`.
16+
- **`@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.
17+
- **`@objectstack/client`**: `client.notifications.registerDevice(request)` and `client.notifications.unregisterDevice(deviceId)`, removed earlier because no server served them, are back.
18+
- **`@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`.

‎packages/qa/dogfood/test/push-device-registry.dogfood.test.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
* - `[body]` a body outside `RegisterDeviceRequestSchema`, and a web
2525
* registration with no vendor, answer `400 VALIDATION_FAILED`;
2626
* - `[data-api]` the generic data API neither lists, reads nor writes
27-
* `sys_user_device` for a plain member: the device routes are the only door.
27+
* `sys_user_device` for a plain member (`404 OBJECT_API_DISABLED`, the
28+
* exposure gate's answer): the device routes are the only door.
2829
*
2930
* Refusals assert `code` AND `status` (ADR-0112).
3031
*/
@@ -158,7 +159,8 @@ describe('push device registry over HTTP (ADR-0012 §6)', () => {
158159
});
159160

160161
it('[data-api] the generic data API neither lists, reads nor writes sys_user_device for a member', async () => {
161-
const [own] = await rows({ user_id: bobId });
162+
await call(bobTok, 'POST', '/notifications/devices', { token: 'bob-data-api-tok', platform: 'ios', deviceId: 'bob-data-api' });
163+
const [own] = await rows({ device_id: 'bob-data-api' });
162164
expect(own, 'bob holds a device row to aim at').toBeTruthy();
163165
const attempts = [
164166
await call(bobTok, 'GET', `/data/${OBJ}`),
@@ -167,10 +169,10 @@ describe('push device registry over HTTP (ADR-0012 §6)', () => {
167169
await call(bobTok, 'PATCH', `/data/${OBJ}/${own.id}`, { token: 'patched' }),
168170
await call(bobTok, 'DELETE', `/data/${OBJ}/${own.id}`),
169171
];
172+
// The exposure gate's answer for an object not exposed via the API.
170173
for (const res of attempts) {
171-
expect(res.status, res.text).toBeGreaterThanOrEqual(400);
172-
expect(res.status, res.text).toBeLessThan(500);
173-
expect(codeOf(res.body), res.text).toBeTruthy();
174+
expect(res.status, res.text).toBe(404);
175+
expect(codeOf(res.body), res.text).toBe('OBJECT_API_DISABLED');
174176
}
175177
expect(await rows({ token: 'via-data-api' })).toHaveLength(0);
176178
const [still] = await rows({ id: own.id });

0 commit comments

Comments
 (0)