@oxyhq/core 3.14.0 → 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,348 @@
1
+ /**
2
+ * User-Node Methods Mixin (self-sovereign identity layer — Fase 5 user nodes)
3
+ *
4
+ * The client surface for a user's personal data NODE — the decentralised store
5
+ * that holds an authentic copy of their signed-record chain. Commons drives all
6
+ * of this:
7
+ *
8
+ * - {@link OxyServicesNodesMixin.registerNode} registers (or re-registers) a
9
+ * SELF-HOSTED node. Registration is NOT a bespoke endpoint — it is a signed
10
+ * `type:'node'` v2 record (`collection: 'app.oxy.node'`, `rkey: 'self'`,
11
+ * last-writer-wins) published through the EXISTING `POST /identity/records`
12
+ * path; the server verifies it and materializes the operational
13
+ * {@link UserNodeStatus} cache as a side effect, so the registration's
14
+ * authority is the user's own signature, never an Oxy grant.
15
+ * - {@link OxyServicesNodesMixin.getMyNode} reads the caller's cached node
16
+ * status (`GET /nodes/me`) — the fast, stale-but-instant projection plus the
17
+ * live liveness badge Oxy maintains with background probes.
18
+ * - {@link OxyServicesNodesMixin.removeMyNode} revokes the registration
19
+ * (`DELETE /nodes/me`) so the node leaves the DID document and the liveness
20
+ * sweeps.
21
+ * - {@link OxyServicesNodesMixin.provisionManagedVault} asks Oxy to operate a
22
+ * MANAGED vault on the caller's behalf (`POST /nodes/managed`) — the
23
+ * "Create your vault" convenience for non-technical users (Oxy custodial-signs
24
+ * the node record; `managed:true, controller:'oxy'`).
25
+ * - {@link OxyServicesNodesMixin.notifyNodeIngest} sends an unauthenticated
26
+ * HINT (`POST /nodes/ingest/notify/:userId`) that a user's node has new
27
+ * records; the server fully re-verifies before ingesting, so the hint can
28
+ * never inject data.
29
+ *
30
+ * `registerNode` signs on the caller's per-subject hash chain with the on-device
31
+ * identity key (reusing {@link SignatureService.signRecordV2} — the same
32
+ * `ES256K-DER-SHA256` scheme + {@link signedRecordSigningInput} the identity and
33
+ * civic mixins use), so it is NATIVE-ONLY: it throws on web (where `KeyManager`
34
+ * has no key) and when no user is authenticated. Reading the node status,
35
+ * revoking, provisioning a managed vault, and sending an ingest hint are plain
36
+ * authenticated/public requests with no signing.
37
+ *
38
+ * The wire shapes here are API-INTERNAL (the F5 user-node surface is not yet a
39
+ * published `@oxyhq/contracts` schema), so {@link UserNodeStatus} mirrors the
40
+ * server's `serializeNode` projection exactly. Dates cross the wire as ISO
41
+ * strings.
42
+ */
43
+ import type { OxyServicesBase } from '../OxyServices.base';
44
+ import { SignatureService } from '../crypto/signatureService';
45
+ import { buildUserDid } from './OxyServices.identity';
46
+ import { CACHE_TIMES } from './mixinHelpers';
47
+
48
+ /**
49
+ * AtProto-style collection (NSID) for a user-node registration record — matches
50
+ * the server's `NODE_COLLECTION`. A user has exactly one node, so the record is
51
+ * keyed by the constant {@link NODE_RKEY} (last-writer-wins): re-registering
52
+ * over-writes the single `self` record rather than appending a second node.
53
+ */
54
+ const NODE_COLLECTION = 'app.oxy.node';
55
+
56
+ /**
57
+ * The AtProto-style record key for the single node registration — matches the
58
+ * server's `NODE_RKEY`. Constant (`'self'`) because a user has one node.
59
+ */
60
+ const NODE_RKEY = 'self';
61
+
62
+ /**
63
+ * Cache-key prefix of every node read (`GET /nodes/me`). Swept after a
64
+ * register / revoke / managed-provision so a re-read reflects the new node
65
+ * (or its absence) instead of a stale cached one. The identity tag is a key
66
+ * SUFFIX, so this prefix invalidates the resource for every cached identity.
67
+ */
68
+ const NODES_CACHE_PREFIX = 'GET:/nodes/';
69
+
70
+ /**
71
+ * Cache-key prefix of the current user's `GET /users/me`. Swept alongside the
72
+ * node caches because the user's derived DID document embeds an `#oxy-node`
73
+ * service entry derived from the node row, so registering / revoking / managing
74
+ * a node changes user-facing identity state.
75
+ */
76
+ const USERS_ME_CACHE_PREFIX = 'GET:/users/me';
77
+
78
+ /** How Oxy and the node move records: the node pulls (default), or Oxy pushes. */
79
+ export type UserNodeMode = 'pull' | 'push';
80
+
81
+ /**
82
+ * Who operates the node:
83
+ * - `self` — the user self-hosts the node (registered by their own signed
84
+ * `type:'node'` record).
85
+ * - `oxy` — Oxy operates a MANAGED vault on the user's behalf (custodial-signed
86
+ * `type:'node'` record; the `controller:[OXY_DID]` model).
87
+ */
88
+ export type UserNodeController = 'self' | 'oxy';
89
+
90
+ /**
91
+ * Liveness badge of a node, maintained ONLY by Oxy's background probes:
92
+ * - `active` — the last probe reached the node's liveness manifest.
93
+ * - `unreachable` — the last probe failed (DNS/connect/timeout/non-2xx); the
94
+ * cached row is still served, only the badge changes.
95
+ * - `revoked` — the user removed the registration; excluded from the DID
96
+ * document and from liveness sweeps.
97
+ */
98
+ export type UserNodeLivenessStatus = 'active' | 'unreachable' | 'revoked';
99
+
100
+ /**
101
+ * The caller's registered node, as projected by the server's `serializeNode`
102
+ * (`GET /nodes/me`, `POST /nodes/managed`). A denormalised, fast-to-read copy of
103
+ * the authoritative signed `type:'node'` record plus the live liveness state Oxy
104
+ * maintains in the background.
105
+ *
106
+ * `mode` / `managed` / `controller` / `status` are always present (server fields
107
+ * with defaults); the probe/sync fields and `nodeDid` are present only once set.
108
+ * The `Date` fields cross the wire as ISO-8601 strings.
109
+ */
110
+ export interface UserNodeStatus {
111
+ /** Optional DID the node advertises for itself (informational). */
112
+ nodeDid?: string;
113
+ /** The node's public HTTPS base URL (where its liveness manifest lives). */
114
+ endpoint: string;
115
+ /** The node's secp256k1 public key (hex) — records it signs verify against this. */
116
+ nodePublicKey: string;
117
+ /** Transport direction. `pull` (the node paces its own sync) by default. */
118
+ mode: UserNodeMode;
119
+ /** Whether Oxy operates this node on the user's behalf (managed vault). */
120
+ managed: boolean;
121
+ /** Operator of the node — `self` (user self-hosts) or `oxy` (managed vault). */
122
+ controller: UserNodeController;
123
+ /** Liveness badge — maintained only by background probes, never a read handler. */
124
+ status: UserNodeLivenessStatus;
125
+ /** Last time a probe reached the node successfully (ISO-8601). */
126
+ lastSeenAt?: string;
127
+ /** Last time a probe ran, success or failure (ISO-8601). */
128
+ lastProbeAt?: string;
129
+ /** Human-readable reason the last probe OR ingest failed (cleared on success). */
130
+ lastError?: string;
131
+ /** Last synced chain `seq` for two-way sync (advanced only by the ingest worker). */
132
+ cursor?: number;
133
+ /** Last time the ingest worker ran a pull for this node (ISO-8601). */
134
+ lastSyncedAt?: string;
135
+ /** When the node was first registered (ISO-8601). */
136
+ createdAt: string;
137
+ /** When the node row was last updated (ISO-8601). */
138
+ updatedAt: string;
139
+ }
140
+
141
+ /**
142
+ * Input for {@link OxyServicesNodesMixin.registerNode} — the operational facts of
143
+ * the user's self-hosted node that go into the signed `type:'node'` record.
144
+ */
145
+ export interface RegisterNodeInput {
146
+ /** The node's public HTTPS base URL (where its liveness manifest is served). */
147
+ endpoint: string;
148
+ /** The node's secp256k1 public key (hex) — records the node signs verify against this. */
149
+ nodePublicKey: string;
150
+ /** Transport direction; defaults to `'pull'` when omitted. */
151
+ mode?: UserNodeMode;
152
+ }
153
+
154
+ /** Result of {@link OxyServicesNodesMixin.removeMyNode} (`DELETE /nodes/me`). */
155
+ export interface RemoveNodeResult {
156
+ /** `true` when an active registration was flipped to `revoked`. */
157
+ revoked: boolean;
158
+ }
159
+
160
+ /**
161
+ * The current chain head as returned by `GET /identity/records/:userId/chain/head`.
162
+ * `headRecordId` is `null` and `seq` is `-1` when the subject has no chain yet,
163
+ * so the next record's coordinates are always `seq: head.seq + 1` (genesis = 0)
164
+ * and `prev: head.headRecordId` (genesis = null).
165
+ */
166
+ interface ChainHeadResponse {
167
+ headRecordId: string | null;
168
+ seq: number;
169
+ recordCount: number;
170
+ }
171
+
172
+ export function OxyServicesNodesMixin<T extends typeof OxyServicesBase>(Base: T) {
173
+ return class extends Base {
174
+ constructor(...args: any[]) {
175
+ super(...(args as [any]));
176
+ }
177
+
178
+ /**
179
+ * Register (or re-register) the caller's SELF-HOSTED personal data node.
180
+ *
181
+ * Builds the `{ endpoint, nodePublicKey, mode }` node record, signs a v2
182
+ * envelope on the caller's own per-subject hash chain (fetching the current
183
+ * chain head first so `seq`/`prev` are never stale), and publishes it through
184
+ * the EXISTING `POST /identity/records` path — which verifies the signature
185
+ * and materializes the operational node cache as a side effect. The signed
186
+ * record (not this call) is the authority; re-registering over-writes the
187
+ * single `self` record (last-writer-wins).
188
+ *
189
+ * NATIVE-ONLY: signs with the on-device identity key (throws on web / when no
190
+ * identity or no authenticated user — the guard fires before any network).
191
+ * `mode` defaults to `'pull'`. After a successful publish the node + `/users/me`
192
+ * GET caches are swept, then the freshly-materialized status is returned.
193
+ *
194
+ * Throws if the chain record stored but the server skipped materialization
195
+ * (e.g. a malformed endpoint the server rejected) — an unexpected state rather
196
+ * than a silent `null`.
197
+ *
198
+ * @param input - The node's endpoint, public key, and optional transport mode.
199
+ */
200
+ async registerNode(input: RegisterNodeInput): Promise<UserNodeStatus> {
201
+ try {
202
+ const userId = this.getCurrentUserId();
203
+ if (!userId) {
204
+ throw new Error('No authenticated user — sign in before registering a node.');
205
+ }
206
+ const subject = buildUserDid(userId);
207
+ const record: Record<string, unknown> = {
208
+ endpoint: input.endpoint,
209
+ nodePublicKey: input.nodePublicKey,
210
+ mode: input.mode ?? 'pull',
211
+ };
212
+
213
+ // Fetch the caller's chain head fresh (uncached) so seq/prev are correct
214
+ // → no bad_seq / chain_fork — exactly as the identity/civic signers do.
215
+ const head = await this.makeRequest<ChainHeadResponse>(
216
+ 'GET',
217
+ `/identity/records/${encodeURIComponent(userId)}/chain/head`,
218
+ undefined,
219
+ { cache: false },
220
+ );
221
+ const envelope = await SignatureService.signRecordV2('node', subject, record, {
222
+ seq: head.seq + 1,
223
+ prev: head.headRecordId,
224
+ collection: NODE_COLLECTION,
225
+ rkey: NODE_RKEY,
226
+ });
227
+
228
+ await this.makeRequest(
229
+ 'POST',
230
+ '/identity/records',
231
+ envelope,
232
+ { cache: false },
233
+ );
234
+ this._sweepNodeCaches();
235
+
236
+ const node = await this.getMyNode();
237
+ if (!node) {
238
+ throw new Error('Node registration stored but the node could not be materialized.');
239
+ }
240
+ return node;
241
+ } catch (error) {
242
+ throw this.handleError(error);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Read the caller's registered node status (`GET /nodes/me`), or `null` when
248
+ * the caller has no node. Auth required; short-TTL cached (the liveness badge
249
+ * is background-maintained) and swept after the caller's own
250
+ * register / revoke / managed-provision.
251
+ */
252
+ async getMyNode(): Promise<UserNodeStatus | null> {
253
+ try {
254
+ const res = await this.makeRequest<{ node: UserNodeStatus | null }>(
255
+ 'GET',
256
+ '/nodes/me',
257
+ undefined,
258
+ { cache: true, cacheTTL: CACHE_TIMES.SHORT },
259
+ );
260
+ return res.node ?? null;
261
+ } catch (error) {
262
+ throw this.handleError(error);
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Revoke the caller's node registration (`DELETE /nodes/me`). The node flips
268
+ * to `revoked` server-side (leaving the DID document and liveness sweeps).
269
+ * Auth required; the node + `/users/me` GET caches are swept on success.
270
+ *
271
+ * Maps the server's `{ success }` to the SDK's `{ revoked }` semantic.
272
+ */
273
+ async removeMyNode(): Promise<RemoveNodeResult> {
274
+ try {
275
+ const res = await this.makeRequest<{ success: boolean }>(
276
+ 'DELETE',
277
+ '/nodes/me',
278
+ undefined,
279
+ { cache: false },
280
+ );
281
+ this._sweepNodeCaches();
282
+ return { revoked: res.success === true };
283
+ } catch (error) {
284
+ throw this.handleError(error);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Provision (or refresh) an Oxy-operated MANAGED vault for the caller
290
+ * (`POST /nodes/managed`) — the "Create your vault" convenience for
291
+ * non-technical users. Oxy custodial-signs the node registration onto the
292
+ * caller's chain and returns the materialized node (`managed:true,
293
+ * controller:'oxy'`). Idempotent server-side. Auth required; the owner id is
294
+ * resolved from the session, never the body. The node + `/users/me` GET caches
295
+ * are swept on success.
296
+ */
297
+ async provisionManagedVault(): Promise<UserNodeStatus> {
298
+ try {
299
+ const res = await this.makeRequest<{ node: UserNodeStatus }>(
300
+ 'POST',
301
+ '/nodes/managed',
302
+ undefined,
303
+ { cache: false },
304
+ );
305
+ this._sweepNodeCaches();
306
+ return res.node;
307
+ } catch (error) {
308
+ throw this.handleError(error);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Send an ingest HINT that a user's node has new records
314
+ * (`POST /nodes/ingest/notify/:userId`). Unauthenticated by design and
315
+ * fire-and-forget on the server (it only schedules a background re-pull of the
316
+ * named user's OWN node, then fully re-verifies — a notify can never inject
317
+ * data), so this resolves once the 202 hint is accepted and returns nothing.
318
+ *
319
+ * @param userId - The user whose node may have new records. URL-encoded.
320
+ */
321
+ async notifyNodeIngest(userId: string): Promise<void> {
322
+ try {
323
+ await this.makeRequest<{ accepted: boolean }>(
324
+ 'POST',
325
+ `/nodes/ingest/notify/${encodeURIComponent(userId)}`,
326
+ undefined,
327
+ { cache: false },
328
+ );
329
+ } catch (error) {
330
+ throw this.handleError(error);
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Sweep the GET caches a node mutation invalidates: every node read
336
+ * (`GET:/nodes/`) so a re-read reflects the new node / its absence, and
337
+ * `/users/me` because the user's derived DID document embeds an `#oxy-node`
338
+ * service entry that changes on register / revoke / manage. Public rather
339
+ * than `private` because mixins compose into an exported anonymous class
340
+ * where TypeScript cannot represent a private member in the emitted
341
+ * declaration file (TS4094) — mirrors the civic / identity cache sweepers.
342
+ */
343
+ _sweepNodeCaches(): void {
344
+ this.clearCacheByPrefix(NODES_CACHE_PREFIX);
345
+ this.clearCacheByPrefix(USERS_ME_CACHE_PREFIX);
346
+ }
347
+ };
348
+ }
@@ -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
+ });