Skip to content

Commit 2b00aed

Browse files
committed
feat(client): restore notifications.registerDevice / unregisterDevice on the served device routes; dogfood the registry over HTTP
Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent edb33e1 commit 2b00aed

2 files changed

Lines changed: 218 additions & 4 deletions

File tree

‎packages/client/src/index.ts‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ import {
4646
ListNotificationsResponse,
4747
MarkNotificationsReadResponse,
4848
MarkAllNotificationsReadResponse,
49+
RegisterDeviceRequest,
50+
RegisterDeviceResponse,
51+
UnregisterDeviceResponse,
4952
// The AI wire types (#3718). `Ai{Nlq,Suggest,Insights}{Request,Response}`
5053
// used to be here; they typed three endpoints nothing has ever mounted and
5154
// went with the methods that called them. These type the routes that exist.
@@ -6806,10 +6809,11 @@ export class ObjectStackClient {
68066809
/**
68076810
* Notification Services
68086811
*
6809-
* Device registration and preference methods were removed in #3612 — the
6810-
* /notifications/devices and /notifications/preferences server routes that
6811-
* ADR-0012 describes were never built. They return together with the server
6812-
* (and a route-ledger row keeps them honest).
6812+
* The device methods (`registerDevice` / `unregisterDevice`) returned with
6813+
* the server's device registry (ADR-0012 §6), and their route-ledger rows
6814+
* (`runtime/src/route-ledger.ts`) keep them honest. The preference methods
6815+
* removed in #3612 stay gone: no server route serves
6816+
* `/notifications/preferences`.
68136817
*/
68146818
notifications = {
68156819
/**
@@ -6854,6 +6858,37 @@ export class ObjectStackClient {
68546858
method: 'POST'
68556859
});
68566860
return this.unwrapResponse<MarkAllNotificationsReadResponse>(res);
6861+
},
6862+
6863+
/**
6864+
* Register or refresh this user's push device (`POST /notifications/devices`).
6865+
*
6866+
* Send the `deviceId` the server answered on every later registration:
6867+
* re-registering it refreshes that device (token, vendor, app, locale)
6868+
* instead of adding another. Without one the server mints an id and
6869+
* answers it. `vendor` may be omitted for `ios` (apns) and `android`
6870+
* (fcm); a `web` registration names it.
6871+
*/
6872+
registerDevice: async (request: RegisterDeviceRequest): Promise<RegisterDeviceResponse> => {
6873+
const route = this.getRoute('notifications');
6874+
const res = await this.fetch(`${this.baseUrl}${route}/devices`, {
6875+
method: 'POST',
6876+
body: JSON.stringify(request)
6877+
});
6878+
return this.unwrapResponse<RegisterDeviceResponse>(res);
6879+
},
6880+
6881+
/**
6882+
* Unregister one of this user's push devices
6883+
* (`DELETE /notifications/devices/:deviceId`), on sign-out or account
6884+
* switch. A device id this user does not hold answers 404 `NOT_FOUND`.
6885+
*/
6886+
unregisterDevice: async (deviceId: string): Promise<UnregisterDeviceResponse> => {
6887+
const route = this.getRoute('notifications');
6888+
const res = await this.fetch(`${this.baseUrl}${route}/devices/${encodeURIComponent(deviceId)}`, {
6889+
method: 'DELETE'
6890+
});
6891+
return this.unwrapResponse<UnregisterDeviceResponse>(res);
68576892
}
68586893
};
68596894

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The push device registry over HTTP on the real showcase stack (ADR-0012
5+
* §6): `POST /api/v1/notifications/devices` and
6+
* `DELETE /api/v1/notifications/devices/:deviceId`, with the real
7+
* composition, the real auth, the real messaging service and the real
8+
* `sys_user_device` table.
9+
*
10+
* The engine-level pins live beside the registry
11+
* (`service-messaging/src/device-registry.integration.test.ts`); this file is
12+
* the face a signed-in mobile session actually reaches:
13+
*
14+
* - `[anonymous]` both routes answer `401 UNAUTHENTICATED`;
15+
* - `[register]` a member registers, then refreshes the same device id: one
16+
* row, the new token;
17+
* - `[owner]` another member's unregister of that device answers `404
18+
* NOT_FOUND` and the row survives; the owner's removes it;
19+
* - `[takeover]` a second member registering the same token takes it over,
20+
* and the first member's row for it is gone (their own unregister is 404);
21+
* - `[invalidate]` a token the push channel reports dead keeps its row,
22+
* disabled; the device's next registration re-enables it and clears the
23+
* time;
24+
* - `[body]` a body outside `RegisterDeviceRequestSchema`, and a web
25+
* registration with no vendor, answer `400 VALIDATION_FAILED`;
26+
* - `[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.
28+
*
29+
* Refusals assert `code` AND `status` (ADR-0112).
30+
*/
31+
32+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
33+
import { type VerifyStack } from '@objectstack/verify';
34+
import { bootShowcase } from './showcase-boot.js';
35+
36+
const OBJ = 'sys_user_device';
37+
const SYS = { isSystem: true } as const;
38+
const ALICE = 'device.alice@verify.test';
39+
const BOB = 'device.bob@verify.test';
40+
41+
const codeOf = (b: any) => (b?.error && typeof b.error === 'object' ? b.error.code : undefined) ?? b?.code ?? null;
42+
43+
describe('push device registry over HTTP (ADR-0012 §6)', () => {
44+
let stack: VerifyStack;
45+
let ql: any;
46+
let aliceTok: string;
47+
let bobTok: string;
48+
let aliceId: string;
49+
let bobId: string;
50+
51+
const userIdOf = async (email: string): Promise<string> => {
52+
const id = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id;
53+
expect(id, `${email} provisioned`).toBeTruthy();
54+
return String(id);
55+
};
56+
const rows = async (where: Record<string, unknown>) =>
57+
(await ql.find(OBJ, { where, context: SYS })) as Array<Record<string, any>>;
58+
const call = async (token: string, method: string, path: string, body?: unknown) => {
59+
const res = await stack.apiAs(token, method, path, body);
60+
const text = await res.text();
61+
let json: any = null;
62+
try { json = JSON.parse(text); } catch { /* not JSON */ }
63+
return { status: res.status, body: json, text };
64+
};
65+
66+
beforeAll(async () => {
67+
stack = await bootShowcase();
68+
aliceTok = await stack.signUp(ALICE);
69+
bobTok = await stack.signUp(BOB);
70+
ql = await stack.kernel.getServiceAsync('objectql');
71+
aliceId = await userIdOf(ALICE);
72+
bobId = await userIdOf(BOB);
73+
}, 180_000);
74+
75+
afterAll(async () => { await stack?.stop?.(); });
76+
77+
it('[anonymous] both routes answer 401 UNAUTHENTICATED', async () => {
78+
for (const [method, path, body] of [
79+
['POST', '/api/v1/notifications/devices', { token: 'anon', platform: 'ios' }],
80+
['DELETE', '/api/v1/notifications/devices/anything', undefined],
81+
] as const) {
82+
const res = await stack.raw(path, {
83+
method,
84+
headers: { 'content-type': 'application/json' },
85+
body: body ? JSON.stringify(body) : undefined,
86+
});
87+
const text = await res.text();
88+
expect(res.status, `${method} ${path}: ${text}`).toBe(401);
89+
expect(codeOf(JSON.parse(text))).toBe('UNAUTHENTICATED');
90+
}
91+
expect(await rows({ token: 'anon' })).toHaveLength(0);
92+
});
93+
94+
it('[register] registers, then refreshes the same device id: one row, the new token', async () => {
95+
const first = await call(aliceTok, 'POST', '/notifications/devices', { token: 'alice-tok-1', platform: 'ios', deviceId: 'alice-phone' });
96+
expect(first.status, first.text).toBe(200);
97+
expect(first.body?.data).toEqual({ deviceId: 'alice-phone', success: true });
98+
99+
const again = await call(aliceTok, 'POST', '/notifications/devices', { token: 'alice-tok-2', platform: 'ios', deviceId: 'alice-phone' });
100+
expect(again.status, again.text).toBe(200);
101+
102+
const mine = await rows({ user_id: aliceId });
103+
expect(mine).toHaveLength(1);
104+
expect(mine[0]).toMatchObject({ device_id: 'alice-phone', token: 'alice-tok-2', vendor: 'apns', enabled: true });
105+
});
106+
107+
it('[owner] another member\'s unregister is 404 NOT_FOUND and the row survives; the owner removes it', async () => {
108+
await call(aliceTok, 'POST', '/notifications/devices', { token: 'alice-tok-own', platform: 'android', deviceId: 'alice-tablet' });
109+
110+
const refused = await call(bobTok, 'DELETE', '/notifications/devices/alice-tablet');
111+
expect(refused.status, refused.text).toBe(404);
112+
expect(codeOf(refused.body)).toBe('NOT_FOUND');
113+
expect(await rows({ device_id: 'alice-tablet' })).toHaveLength(1);
114+
115+
const removed = await call(aliceTok, 'DELETE', '/notifications/devices/alice-tablet');
116+
expect(removed.status, removed.text).toBe(200);
117+
expect(await rows({ device_id: 'alice-tablet' })).toHaveLength(0);
118+
});
119+
120+
it('[takeover] a second member registering the token takes it over; the first member\'s row is gone', async () => {
121+
await call(aliceTok, 'POST', '/notifications/devices', { token: 'shared-phone-tok', platform: 'ios', deviceId: 'alice-old-phone' });
122+
123+
const taken = await call(bobTok, 'POST', '/notifications/devices', { token: 'shared-phone-tok', platform: 'ios', deviceId: 'bob-phone' });
124+
expect(taken.status, taken.text).toBe(200);
125+
126+
const holders = await rows({ token: 'shared-phone-tok' });
127+
expect(holders).toHaveLength(1);
128+
expect(String(holders[0].user_id)).toBe(bobId);
129+
const gone = await call(aliceTok, 'DELETE', '/notifications/devices/alice-old-phone');
130+
expect(gone.status, gone.text).toBe(404);
131+
expect(codeOf(gone.body)).toBe('NOT_FOUND');
132+
});
133+
134+
it('[invalidate] a dead token keeps its row disabled; the next registration re-enables it', async () => {
135+
await call(bobTok, 'POST', '/notifications/devices', { token: 'bob-dead-tok', platform: 'android', deviceId: 'bob-tablet' });
136+
const notification = await stack.kernel.getServiceAsync<any>('notification');
137+
await notification.invalidateDeviceToken('bob-dead-tok');
138+
139+
const [dead] = await rows({ device_id: 'bob-tablet' });
140+
expect(dead.enabled).toBe(false);
141+
expect(dead.invalidated_at).toBeTruthy();
142+
143+
const revived = await call(bobTok, 'POST', '/notifications/devices', { token: 'bob-dead-tok', platform: 'android', deviceId: 'bob-tablet' });
144+
expect(revived.status, revived.text).toBe(200);
145+
const [after] = await rows({ device_id: 'bob-tablet' });
146+
expect(after.id).toBe(dead.id);
147+
expect(after.enabled).toBe(true);
148+
expect(after.invalidated_at ?? null).toBeNull();
149+
});
150+
151+
it('[body] a body outside the schema, and a web registration with no vendor, answer 400 VALIDATION_FAILED', async () => {
152+
for (const body of [{ platform: 'ios' }, { token: 't', platform: 'blackberry' }, { token: 'web-tok', platform: 'web' }]) {
153+
const res = await call(aliceTok, 'POST', '/notifications/devices', body);
154+
expect(res.status, `${JSON.stringify(body)}: ${res.text}`).toBe(400);
155+
expect(codeOf(res.body)).toBe('VALIDATION_FAILED');
156+
}
157+
expect(await rows({ token: 'web-tok' })).toHaveLength(0);
158+
});
159+
160+
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+
expect(own, 'bob holds a device row to aim at').toBeTruthy();
163+
const attempts = [
164+
await call(bobTok, 'GET', `/data/${OBJ}`),
165+
await call(bobTok, 'GET', `/data/${OBJ}/${own.id}`),
166+
await call(bobTok, 'POST', `/data/${OBJ}`, { user_id: bobId, device_id: 'via-data-api', token: 'via-data-api', vendor: 'apns', platform: 'ios' }),
167+
await call(bobTok, 'PATCH', `/data/${OBJ}/${own.id}`, { token: 'patched' }),
168+
await call(bobTok, 'DELETE', `/data/${OBJ}/${own.id}`),
169+
];
170+
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+
}
175+
expect(await rows({ token: 'via-data-api' })).toHaveLength(0);
176+
const [still] = await rows({ id: own.id });
177+
expect(still?.token).toBe(own.token);
178+
});
179+
});

0 commit comments

Comments
 (0)