@oxyhq/core 3.13.1 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * User-Node Mixin tests (self-sovereign identity layer — Fase 5 user nodes).
3
+ *
4
+ * Stubs `makeRequest` so the tests run with no network, then asserts:
5
+ * - `registerNode` fetches the caller's chain head (uncached), signs a
6
+ * self-issued v2 `type:'node'` envelope (record `{ endpoint, nodePublicKey,
7
+ * mode }`, seq=head+1, prev=head id, collection `app.oxy.node`, rkey `self`),
8
+ * POSTs it to the EXISTING `/identity/records` path, sweeps the node +
9
+ * `/users/me` GET caches, then returns the freshly-read status. `mode`
10
+ * defaults to `pull`; an explicit `mode` is forwarded; genesis coords apply
11
+ * when there is no head. It is NATIVE-ONLY: a signing failure (no on-device
12
+ * identity) propagates, and no-auth throws before any network. It does NOT
13
+ * sweep when the POST fails, and throws when the node fails to materialize.
14
+ * - `getMyNode` shapes the cached GET `/nodes/me` and unwraps `.node` (or null).
15
+ * - `removeMyNode` DELETEs `/nodes/me`, maps `{success}`→`{revoked}`, and sweeps.
16
+ * - `provisionManagedVault` POSTs `/nodes/managed`, returns `.node`, and sweeps.
17
+ * - `notifyNodeIngest` POSTs the URL-encoded hint path and resolves void.
18
+ *
19
+ * The write tests mock `SignatureService.signRecordV2` (asserting the exact
20
+ * record + chain coords) so they isolate the SDK's request shaping from native
21
+ * key storage — mirroring the civic mixin tests.
22
+ */
23
+
24
+ import type { SignedRecordEnvelope } from '@oxyhq/contracts';
25
+ import { OxyServices } from '../../OxyServices';
26
+ import { SignatureService } from '../../crypto/signatureService';
27
+ import type { UserNodeStatus } from '../OxyServices.nodes';
28
+
29
+ const NODE_PUBLIC_KEY = `04${'ab'.repeat(63)}`; // 128 hex chars (uncompressed secp256k1)
30
+
31
+ const sampleNode: UserNodeStatus = {
32
+ endpoint: 'https://node.example.com',
33
+ nodePublicKey: NODE_PUBLIC_KEY,
34
+ mode: 'pull',
35
+ managed: false,
36
+ controller: 'self',
37
+ status: 'active',
38
+ createdAt: '2026-06-27T00:00:00.000Z',
39
+ updatedAt: '2026-06-27T00:00:00.000Z',
40
+ };
41
+
42
+ describe('OxyServices.nodes', () => {
43
+ let oxy: OxyServices;
44
+ let makeRequestSpy: jest.SpyInstance;
45
+
46
+ beforeEach(() => {
47
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
48
+ makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
49
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue('user-123');
50
+ });
51
+
52
+ afterEach(() => {
53
+ jest.restoreAllMocks();
54
+ });
55
+
56
+ describe('registerNode', () => {
57
+ const signedEnvelope: SignedRecordEnvelope = {
58
+ version: 2,
59
+ type: 'node',
60
+ subject: 'did:web:oxy.so:u:user-123',
61
+ issuer: 'did:web:oxy.so:u:user-123',
62
+ record: { endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY, mode: 'pull' },
63
+ issuedAt: 1700000000000,
64
+ seq: 4,
65
+ prev: 'rec-3',
66
+ collection: 'app.oxy.node',
67
+ rkey: 'self',
68
+ publicKey: 'pub',
69
+ alg: 'ES256K-DER-SHA256',
70
+ signature: 'sig',
71
+ };
72
+
73
+ it('signs a v2 node record on the caller chain, POSTs /identity/records, sweeps, and returns the status', async () => {
74
+ const signV2Spy = jest.spyOn(SignatureService, 'signRecordV2').mockResolvedValue(signedEnvelope);
75
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
76
+ // 1st makeRequest = chain head; 2nd = POST /identity/records; 3rd = GET /nodes/me.
77
+ makeRequestSpy
78
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
79
+ .mockResolvedValueOnce({ envelope: signedEnvelope, verified: true })
80
+ .mockResolvedValueOnce({ node: sampleNode });
81
+
82
+ const result = await oxy.registerNode({
83
+ endpoint: 'https://node.example.com',
84
+ nodePublicKey: NODE_PUBLIC_KEY,
85
+ mode: 'pull',
86
+ });
87
+
88
+ // Fetched the caller's chain head first (uncached).
89
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
90
+ 1,
91
+ 'GET',
92
+ '/identity/records/user-123/chain/head',
93
+ undefined,
94
+ expect.objectContaining({ cache: false }),
95
+ );
96
+ // Signed a self-issued v2 `node` record: subject=caller DID, exact record,
97
+ // seq=head+1, prev=head id, collection app.oxy.node, rkey self.
98
+ expect(signV2Spy).toHaveBeenCalledWith(
99
+ 'node',
100
+ 'did:web:oxy.so:u:user-123',
101
+ { endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY, mode: 'pull' },
102
+ { seq: 4, prev: 'rec-3', collection: 'app.oxy.node', rkey: 'self' },
103
+ );
104
+ // Published via the EXISTING /identity/records path (NOT a bespoke endpoint).
105
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
106
+ 2,
107
+ 'POST',
108
+ '/identity/records',
109
+ signedEnvelope,
110
+ expect.objectContaining({ cache: false }),
111
+ );
112
+ // Re-read the freshly-materialized status (cached GET /nodes/me).
113
+ expect(makeRequestSpy).toHaveBeenNthCalledWith(
114
+ 3,
115
+ 'GET',
116
+ '/nodes/me',
117
+ undefined,
118
+ expect.objectContaining({ cache: true }),
119
+ );
120
+ // Swept the node + /users/me GET caches after the publish.
121
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/nodes/');
122
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/users/me');
123
+ expect(result).toEqual(sampleNode);
124
+ });
125
+
126
+ it('forwards an explicit push mode in the signed record', async () => {
127
+ const signV2Spy = jest
128
+ .spyOn(SignatureService, 'signRecordV2')
129
+ .mockResolvedValue(signedEnvelope);
130
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
131
+ makeRequestSpy
132
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
133
+ .mockResolvedValueOnce({ envelope: signedEnvelope, verified: true })
134
+ .mockResolvedValueOnce({ node: sampleNode });
135
+
136
+ await oxy.registerNode({
137
+ endpoint: 'https://node.example.com',
138
+ nodePublicKey: NODE_PUBLIC_KEY,
139
+ mode: 'push',
140
+ });
141
+
142
+ expect(signV2Spy).toHaveBeenCalledWith(
143
+ 'node',
144
+ 'did:web:oxy.so:u:user-123',
145
+ { endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY, mode: 'push' },
146
+ { seq: 4, prev: 'rec-3', collection: 'app.oxy.node', rkey: 'self' },
147
+ );
148
+ });
149
+
150
+ it('defaults mode to pull and uses genesis coords when there is no chain head', async () => {
151
+ const signV2Spy = jest
152
+ .spyOn(SignatureService, 'signRecordV2')
153
+ .mockResolvedValue(signedEnvelope);
154
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
155
+ makeRequestSpy
156
+ .mockResolvedValueOnce({ headRecordId: null, seq: -1, recordCount: 0 })
157
+ .mockResolvedValueOnce({ envelope: signedEnvelope, verified: true })
158
+ .mockResolvedValueOnce({ node: sampleNode });
159
+
160
+ await oxy.registerNode({
161
+ endpoint: 'https://node.example.com',
162
+ nodePublicKey: NODE_PUBLIC_KEY,
163
+ });
164
+
165
+ expect(signV2Spy).toHaveBeenCalledWith(
166
+ 'node',
167
+ 'did:web:oxy.so:u:user-123',
168
+ { endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY, mode: 'pull' },
169
+ { seq: 0, prev: null, collection: 'app.oxy.node', rkey: 'self' },
170
+ );
171
+ });
172
+
173
+ it('throws when no user is authenticated (before any network)', async () => {
174
+ jest.spyOn(oxy, 'getCurrentUserId').mockReturnValue(null);
175
+ const signV2Spy = jest.spyOn(SignatureService, 'signRecordV2');
176
+
177
+ await expect(
178
+ oxy.registerNode({ endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY }),
179
+ ).rejects.toThrow(/No authenticated user/);
180
+ expect(makeRequestSpy).not.toHaveBeenCalled();
181
+ expect(signV2Spy).not.toHaveBeenCalled();
182
+ });
183
+
184
+ it('propagates a signing failure (native-only: no on-device identity)', async () => {
185
+ const signV2Spy = jest
186
+ .spyOn(SignatureService, 'signRecordV2')
187
+ .mockRejectedValue(new Error('No identity found. Please create or import an identity first.'));
188
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
189
+ makeRequestSpy.mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 });
190
+
191
+ await expect(
192
+ oxy.registerNode({ endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY }),
193
+ ).rejects.toThrow(/No identity found/);
194
+ // Only the chain-head read happened; no publish, no sweep.
195
+ expect(signV2Spy).toHaveBeenCalledTimes(1);
196
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
197
+ expect(sweepSpy).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('does NOT sweep caches when the publish POST fails', async () => {
201
+ jest.spyOn(SignatureService, 'signRecordV2').mockResolvedValue(signedEnvelope);
202
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
203
+ makeRequestSpy
204
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
205
+ .mockRejectedValueOnce(new Error('Signed record rejected: chain_fork'));
206
+
207
+ await expect(
208
+ oxy.registerNode({ endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY }),
209
+ ).rejects.toThrow();
210
+ expect(sweepSpy).not.toHaveBeenCalled();
211
+ });
212
+
213
+ it('throws when the node was stored on the chain but not materialized', async () => {
214
+ jest.spyOn(SignatureService, 'signRecordV2').mockResolvedValue(signedEnvelope);
215
+ jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
216
+ makeRequestSpy
217
+ .mockResolvedValueOnce({ headRecordId: 'rec-3', seq: 3, recordCount: 4 })
218
+ .mockResolvedValueOnce({ envelope: signedEnvelope, verified: true })
219
+ .mockResolvedValueOnce({ node: null });
220
+
221
+ await expect(
222
+ oxy.registerNode({ endpoint: 'https://node.example.com', nodePublicKey: NODE_PUBLIC_KEY }),
223
+ ).rejects.toThrow(/could not be materialized/);
224
+ });
225
+ });
226
+
227
+ describe('getMyNode', () => {
228
+ it('GETs /nodes/me (cached) and unwraps the node', async () => {
229
+ makeRequestSpy.mockResolvedValue({ node: sampleNode });
230
+
231
+ const result = await oxy.getMyNode();
232
+
233
+ expect(result).toEqual(sampleNode);
234
+ expect(makeRequestSpy).toHaveBeenCalledWith(
235
+ 'GET',
236
+ '/nodes/me',
237
+ undefined,
238
+ expect.objectContaining({ cache: true }),
239
+ );
240
+ });
241
+
242
+ it('returns null when the caller has no node', async () => {
243
+ makeRequestSpy.mockResolvedValue({ node: null });
244
+ await expect(oxy.getMyNode()).resolves.toBeNull();
245
+ });
246
+
247
+ it('rejects on a transport failure', async () => {
248
+ makeRequestSpy.mockRejectedValue(new Error('network down'));
249
+ await expect(oxy.getMyNode()).rejects.toThrow();
250
+ });
251
+ });
252
+
253
+ describe('removeMyNode', () => {
254
+ it('DELETEs /nodes/me, maps success→revoked, and sweeps caches', async () => {
255
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
256
+ makeRequestSpy.mockResolvedValue({ success: true });
257
+
258
+ const result = await oxy.removeMyNode();
259
+
260
+ expect(result).toEqual({ revoked: true });
261
+ expect(makeRequestSpy).toHaveBeenCalledWith(
262
+ 'DELETE',
263
+ '/nodes/me',
264
+ undefined,
265
+ expect.objectContaining({ cache: false }),
266
+ );
267
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/nodes/');
268
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/users/me');
269
+ });
270
+
271
+ it('does NOT sweep caches when the DELETE fails', async () => {
272
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
273
+ makeRequestSpy.mockRejectedValue(new Error('No active node registration to revoke'));
274
+
275
+ await expect(oxy.removeMyNode()).rejects.toThrow();
276
+ expect(sweepSpy).not.toHaveBeenCalled();
277
+ });
278
+ });
279
+
280
+ describe('provisionManagedVault', () => {
281
+ it('POSTs /nodes/managed, returns the node, and sweeps caches', async () => {
282
+ const managedNode: UserNodeStatus = {
283
+ ...sampleNode,
284
+ managed: true,
285
+ controller: 'oxy',
286
+ };
287
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
288
+ makeRequestSpy.mockResolvedValue({ node: managedNode });
289
+
290
+ const result = await oxy.provisionManagedVault();
291
+
292
+ expect(result).toEqual(managedNode);
293
+ expect(makeRequestSpy).toHaveBeenCalledWith(
294
+ 'POST',
295
+ '/nodes/managed',
296
+ undefined,
297
+ expect.objectContaining({ cache: false }),
298
+ );
299
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/nodes/');
300
+ expect(sweepSpy).toHaveBeenCalledWith('GET:/users/me');
301
+ });
302
+
303
+ it('does NOT sweep caches when provisioning fails', async () => {
304
+ const sweepSpy = jest.spyOn(oxy, 'clearCacheByPrefix').mockReturnValue(0);
305
+ makeRequestSpy.mockRejectedValue(new Error('Managed vaults are not available right now'));
306
+
307
+ await expect(oxy.provisionManagedVault()).rejects.toThrow();
308
+ expect(sweepSpy).not.toHaveBeenCalled();
309
+ });
310
+ });
311
+
312
+ describe('notifyNodeIngest', () => {
313
+ it('POSTs the ingest-notify hint and resolves void', async () => {
314
+ makeRequestSpy.mockResolvedValue({ accepted: true });
315
+
316
+ await expect(oxy.notifyNodeIngest('user-9')).resolves.toBeUndefined();
317
+ expect(makeRequestSpy).toHaveBeenCalledWith(
318
+ 'POST',
319
+ '/nodes/ingest/notify/user-9',
320
+ undefined,
321
+ expect.objectContaining({ cache: false }),
322
+ );
323
+ });
324
+
325
+ it('URL-encodes the userId path segment', async () => {
326
+ makeRequestSpy.mockResolvedValue({ accepted: true });
327
+ await oxy.notifyNodeIngest('a/b');
328
+ expect(makeRequestSpy).toHaveBeenCalledWith(
329
+ 'POST',
330
+ '/nodes/ingest/notify/a%2Fb',
331
+ undefined,
332
+ expect.anything(),
333
+ );
334
+ });
335
+
336
+ it('rejects on a transport failure', async () => {
337
+ makeRequestSpy.mockRejectedValue(new Error('network down'));
338
+ await expect(oxy.notifyNodeIngest('user-9')).rejects.toThrow();
339
+ });
340
+ });
341
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Connected-apps (OAuth grants) SDK tests.
3
+ *
4
+ * `listConnectedApps()` reads the user's authorized applications from
5
+ * `GET /auth/grants` and caches the response (identity-scoped). `revokeAppGrant`
6
+ * deletes a grant via `DELETE /auth/grants/:applicationId` and MUST invalidate
7
+ * the cached `GET:/auth/grants` so a re-read observes the removal instead of the
8
+ * STALE pre-revoke list (mirrors the privacy/follow invalidation contract).
9
+ */
10
+
11
+ import { OxyServices } from '../../OxyServices';
12
+ import type { ConnectedApp } from '../OxyServices.applications';
13
+
14
+ /** Build a non-verified JWT whose payload decodes to the given claims. */
15
+ function makeJwt(payload: Record<string, unknown>): string {
16
+ const b64url = (obj: Record<string, unknown>): string =>
17
+ Buffer.from(JSON.stringify(obj)).toString('base64url');
18
+ const fullPayload = { exp: Math.floor(Date.now() / 1000) + 3600, ...payload };
19
+ return `${b64url({ alg: 'none', typ: 'JWT' })}.${b64url(fullPayload)}.sig`;
20
+ }
21
+
22
+ /** A JSON `Response` mimicking the API's `{ data: ... }` success envelope. */
23
+ function jsonResponse(data: unknown): Response {
24
+ return new Response(JSON.stringify({ data }), {
25
+ status: 200,
26
+ headers: { 'content-type': 'application/json' },
27
+ });
28
+ }
29
+
30
+ const APP_A: ConnectedApp = {
31
+ applicationId: 'app-a',
32
+ name: 'App A',
33
+ logoUrl: 'https://cdn.example/a.png',
34
+ scopes: ['profile', 'email'],
35
+ firstGrantedAt: '2026-01-01T00:00:00.000Z',
36
+ lastUsedAt: '2026-06-01T00:00:00.000Z',
37
+ };
38
+
39
+ const APP_B: ConnectedApp = {
40
+ applicationId: 'app-b',
41
+ name: 'App B',
42
+ scopes: ['profile'],
43
+ firstGrantedAt: '2026-02-01T00:00:00.000Z',
44
+ lastUsedAt: '2026-06-02T00:00:00.000Z',
45
+ };
46
+
47
+ describe('connected apps (OAuth grants)', () => {
48
+ let originalFetch: typeof globalThis.fetch;
49
+ let fetchMock: jest.Mock<Promise<Response>, [RequestInfo | URL, RequestInit?]>;
50
+ let oxy: OxyServices;
51
+
52
+ beforeEach(() => {
53
+ originalFetch = globalThis.fetch;
54
+ fetchMock = jest.fn();
55
+ globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
56
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
57
+ oxy.httpService.setTokens(makeJwt({ userId: 'me' }));
58
+ });
59
+
60
+ afterEach(() => {
61
+ globalThis.fetch = originalFetch;
62
+ jest.clearAllMocks();
63
+ });
64
+
65
+ it('lists connected apps, unwrapping the { data } envelope', async () => {
66
+ fetchMock.mockResolvedValueOnce(jsonResponse([APP_A, APP_B]));
67
+ const apps = await oxy.listConnectedApps();
68
+ expect(apps).toEqual([APP_A, APP_B]);
69
+ expect(fetchMock).toHaveBeenCalledTimes(1);
70
+ const [url, init] = fetchMock.mock.calls[0];
71
+ expect(String(url)).toBe('http://test.invalid/auth/grants');
72
+ expect(init?.method).toBe('GET');
73
+ });
74
+
75
+ it('caches the list within the TTL (second read is a cache hit)', async () => {
76
+ fetchMock.mockResolvedValueOnce(jsonResponse([APP_A]));
77
+ expect(await oxy.listConnectedApps()).toEqual([APP_A]);
78
+ expect(fetchMock).toHaveBeenCalledTimes(1);
79
+
80
+ // Second read within the TTL must not hit the network.
81
+ expect(await oxy.listConnectedApps()).toEqual([APP_A]);
82
+ expect(fetchMock).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it('revokes a grant via DELETE /auth/grants/:applicationId', async () => {
86
+ fetchMock.mockResolvedValueOnce(jsonResponse({ revoked: true }));
87
+ await expect(oxy.revokeAppGrant('app-a')).resolves.toBeUndefined();
88
+ expect(fetchMock).toHaveBeenCalledTimes(1);
89
+ const [url, init] = fetchMock.mock.calls[0];
90
+ expect(String(url)).toBe('http://test.invalid/auth/grants/app-a');
91
+ expect(init?.method).toBe('DELETE');
92
+ });
93
+
94
+ it('busts the cached list after revokeAppGrant', async () => {
95
+ // 1) Warm the cache.
96
+ fetchMock.mockResolvedValueOnce(jsonResponse([APP_A, APP_B]));
97
+ expect(await oxy.listConnectedApps()).toEqual([APP_A, APP_B]);
98
+ expect(fetchMock).toHaveBeenCalledTimes(1);
99
+
100
+ // A second read is a cache hit (no extra network call).
101
+ await oxy.listConnectedApps();
102
+ expect(fetchMock).toHaveBeenCalledTimes(1);
103
+
104
+ // 2) Revoke — must invalidate the cached list.
105
+ fetchMock.mockResolvedValueOnce(jsonResponse({ revoked: true }));
106
+ await oxy.revokeAppGrant('app-a');
107
+ expect(fetchMock).toHaveBeenCalledTimes(2);
108
+
109
+ // 3) Re-read MUST re-fetch and observe the revoked app gone.
110
+ fetchMock.mockResolvedValueOnce(jsonResponse([APP_B]));
111
+ const after = await oxy.listConnectedApps();
112
+ expect(fetchMock).toHaveBeenCalledTimes(3);
113
+ expect(after).toEqual([APP_B]);
114
+ });
115
+
116
+ it('invalidates the exact GET:/auth/grants key on revoke', async () => {
117
+ const clearSpy = jest.spyOn(oxy, 'clearCacheEntry');
118
+ fetchMock.mockResolvedValueOnce(jsonResponse({ revoked: true }));
119
+ await oxy.revokeAppGrant('app-a');
120
+ expect(clearSpy).toHaveBeenCalledWith('GET:/auth/grants');
121
+ clearSpy.mockRestore();
122
+ });
123
+ });
@@ -31,6 +31,7 @@ import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
31
31
  import { OxyServicesContactsMixin } from './OxyServices.contacts';
32
32
  import { OxyServicesAppDataMixin } from './OxyServices.appData';
33
33
  import { OxyServicesCivicMixin } from './OxyServices.civic';
34
+ import { OxyServicesNodesMixin } from './OxyServices.nodes';
34
35
 
35
36
  /**
36
37
  * Instance shape of every mixin in the pipeline, intersected. The runtime
@@ -66,6 +67,7 @@ type AllMixinInstances =
66
67
  & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
67
68
  & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
68
69
  & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
70
+ & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
69
71
  & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
70
72
 
71
73
  /**
@@ -134,6 +136,9 @@ const MIXIN_PIPELINE: MixinFunction[] = [
134
136
  OxyServicesAppDataMixin,
135
137
  // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
136
138
  OxyServicesCivicMixin,
139
+ // User nodes / decentralization (Fase 5): register/read/revoke/manage the
140
+ // caller's personal data node + ingest hint.
141
+ OxyServicesNodesMixin,
137
142
 
138
143
  // Utility (last, can use all above)
139
144
  OxyServicesUtilityMixin,
@@ -15,9 +15,11 @@ import {
15
15
  ssoDestKey,
16
16
  ssoNoSessionKey,
17
17
  ssoAttemptedKey,
18
+ ssoPriorSessionKey,
18
19
  buildSsoBounceUrl,
19
20
  isCentralIdPOrigin,
20
21
  guardActive,
22
+ allowSsoBounce,
21
23
  } from '../ssoBounce';
22
24
  import { CENTRAL_AUTH_URL } from '../authWebUrl';
23
25
 
@@ -37,10 +39,36 @@ describe('per-origin key builders', () => {
37
39
  expect(ssoDestKey(origin)).toBe('oxy_sso_dest:https://mention.earth');
38
40
  expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
39
41
  expect(ssoAttemptedKey(origin)).toBe('oxy_sso_attempted:https://mention.earth');
42
+ expect(ssoPriorSessionKey(origin)).toBe('oxy_sso_prior_session:https://mention.earth');
40
43
  });
41
44
 
42
45
  it('namespaces keys per origin so two RPs never collide', () => {
43
46
  expect(ssoStateKey('https://a.test')).not.toBe(ssoStateKey('https://b.test'));
47
+ expect(ssoPriorSessionKey('https://a.test')).not.toBe(ssoPriorSessionKey('https://b.test'));
48
+ });
49
+ });
50
+
51
+ describe('allowSsoBounce (smart returning-visitor gate)', () => {
52
+ it('ALLOWS a returning visitor (prior-session hint) with no local session', () => {
53
+ // The core of fix B: a returning user whose local session has expired still
54
+ // gets ONE establish bounce so a central-only cross-domain session recovers.
55
+ expect(
56
+ allowSsoBounce({ hasPriorSession: true, hasLocalSession: false }),
57
+ ).toBe(true);
58
+ });
59
+
60
+ it('SUPPRESSES a truly first-time anonymous visitor (no hint, no local session)', () => {
61
+ // The smart default (the ONLY behaviour): a first-time visitor browses
62
+ // anonymously instead of being force-redirected to the central IdP.
63
+ expect(
64
+ allowSsoBounce({ hasPriorSession: false, hasLocalSession: false }),
65
+ ).toBe(false);
66
+ });
67
+
68
+ it('ALLOWS when a local session was recovered this boot (spec fidelity)', () => {
69
+ expect(
70
+ allowSsoBounce({ hasPriorSession: false, hasLocalSession: true }),
71
+ ).toBe(true);
44
72
  });
45
73
  });
46
74
 
@@ -68,6 +68,7 @@ const DEST_KEY_PREFIX = 'oxy_sso_dest:';
68
68
  const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
69
69
  const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
70
70
  const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
71
+ const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
71
72
 
72
73
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
73
74
  export function ssoStateKey(origin: string): string {
@@ -106,6 +107,24 @@ export function ssoAttemptedKey(origin: string): string {
106
107
  return `${ATTEMPTED_KEY_PREFIX}${origin}`;
107
108
  }
108
109
 
110
+ /**
111
+ * Per-origin DURABLE "this device/origin has had a signed-in Oxy session
112
+ * before" hint.
113
+ *
114
+ * Unlike every other key in this module — which lives in per-tab
115
+ * `sessionStorage` — this hint is written to DURABLE storage (web
116
+ * `localStorage`; the services provider uses its own `storageKeyPrefix`-scoped
117
+ * key in `@oxyhq/services`). It is set whenever a session is established or
118
+ * restored and survives a session expiring; it is cleared ONLY on an explicit
119
+ * full sign-out. It exists purely to drive {@link allowSsoBounce}: a returning
120
+ * visitor (hint present) whose local session has lapsed still gets ONE terminal
121
+ * `/sso` establish bounce to recover a session that lives only at the central
122
+ * IdP, while a truly first-time anonymous visitor is never force-bounced.
123
+ */
124
+ export function ssoPriorSessionKey(origin: string): string {
125
+ return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
126
+ }
127
+
109
128
  /**
110
129
  * Per-origin marker written by the pre-hydration callback bootstrap.
111
130
  *
@@ -247,3 +266,53 @@ export function guardActive(
247
266
  }
248
267
  return now - ts < SSO_GUARD_TTL_MS;
249
268
  }
269
+
270
+ /**
271
+ * Inputs to the smart {@link allowSsoBounce} gate.
272
+ */
273
+ export interface SsoBounceGate {
274
+ /**
275
+ * Whether this device/origin has had a signed-in Oxy session before (the
276
+ * durable {@link ssoPriorSessionKey} hint). Set whenever a session is
277
+ * established or restored; survives session expiry; cleared only on explicit
278
+ * full sign-out. `true` ⇒ a returning visitor.
279
+ */
280
+ readonly hasPriorSession: boolean;
281
+ /**
282
+ * Whether a local/stored session was recovered earlier this cold boot. At the
283
+ * terminal bounce gate this is effectively always `false` (an earlier step
284
+ * would have won and short-circuited), but it is part of the contract — "no
285
+ * prior hint AND no local session" — so it is passed explicitly for fidelity
286
+ * and robustness.
287
+ */
288
+ readonly hasLocalSession: boolean;
289
+ }
290
+
291
+ /**
292
+ * Decide whether the terminal `/sso` establish-bounce is ALLOWED for this
293
+ * visitor (the smart `enabled` gate for the `sso-bounce` cold-boot step).
294
+ *
295
+ * The terminal bounce is the ONLY cold-boot step that can recover a session
296
+ * that lives SOLELY at the central IdP — the cross-apex Relying-Party case
297
+ * (e.g. `mention.earth`, a different apex from `oxy.so`) whose device-local
298
+ * session has expired and whose `Domain=oxy.so` refresh cookie never reaches
299
+ * `api.<apex>`. It is also what plants the first-party per-apex `fedcm_session`
300
+ * cookie that the EARLIER `silent-iframe` step later relies on. So it must fire
301
+ * for a RETURNING user, yet it must NOT force a truly first-time anonymous
302
+ * visitor off to the IdP.
303
+ *
304
+ * - ALLOW when there is a prior-signed-in hint OR a local session was
305
+ * recovered this boot (a returning user) — so a central-only cross-domain
306
+ * session recovers via ONE bounce, after which the per-apex cookie is
307
+ * planted and subsequent loads restore silently with no bounce.
308
+ * - else (no hint, no local session) SUPPRESS — a first-time anonymous
309
+ * visitor browses without a forced redirect.
310
+ *
311
+ * This is the smart DEFAULT and the ONLY behaviour: apps never configure it.
312
+ * It is also the GATE DECISION ONLY — callers still apply the per-tab loop
313
+ * guards (`ssoAttemptedKey`, `ssoNoSessionKey`, {@link guardActive}) so an
314
+ * allowed bounce still fires at most once per cold boot.
315
+ */
316
+ export function allowSsoBounce(gate: SsoBounceGate): boolean {
317
+ return gate.hasPriorSession || gate.hasLocalSession;
318
+ }