@oxyhq/core 3.14.0 → 3.16.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.applications.js +43 -0
- package/dist/cjs/mixins/OxyServices.assets.js +14 -35
- package/dist/cjs/mixins/OxyServices.auth.js +12 -1
- package/dist/cjs/mixins/OxyServices.links.js +68 -0
- package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
- package/dist/cjs/mixins/index.js +8 -0
- package/dist/cjs/utils/ssoBounce.js +48 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.applications.js +43 -0
- package/dist/esm/mixins/OxyServices.assets.js +14 -35
- package/dist/esm/mixins/OxyServices.auth.js +12 -1
- package/dist/esm/mixins/OxyServices.links.js +65 -0
- package/dist/esm/mixins/OxyServices.nodes.js +172 -0
- package/dist/esm/mixins/index.js +8 -0
- package/dist/esm/utils/ssoBounce.js +46 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.applications.d.ts +51 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +8 -29
- package/dist/types/mixins/OxyServices.auth.d.ts +8 -0
- package/dist/types/mixins/OxyServices.links.d.ts +102 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +242 -0
- package/dist/types/mixins/index.d.ts +3 -1
- package/dist/types/utils/ssoBounce.d.ts +61 -0
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/mixins/OxyServices.applications.ts +79 -0
- package/src/mixins/OxyServices.assets.ts +14 -44
- package/src/mixins/OxyServices.auth.ts +35 -1
- package/src/mixins/OxyServices.links.ts +103 -0
- package/src/mixins/OxyServices.nodes.ts +348 -0
- package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +41 -16
- package/src/mixins/__tests__/connectedApps.test.ts +123 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +10 -24
- package/src/mixins/index.ts +10 -0
- package/src/utils/__tests__/ssoBounce.test.ts +28 -0
- package/src/utils/ssoBounce.ts +69 -0
|
@@ -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
|
+
});
|
|
@@ -102,26 +102,27 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
describe('getCommonsApprovalInfo (approver)', () => {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
isInternal: false,
|
|
113
|
-
scopes: ['profile'],
|
|
114
|
-
},
|
|
105
|
+
const baseInfo = {
|
|
106
|
+
application: {
|
|
107
|
+
id: 'app1',
|
|
108
|
+
name: 'Mention',
|
|
109
|
+
type: 'first_party' as const,
|
|
110
|
+
isOfficial: true,
|
|
111
|
+
isInternal: false,
|
|
115
112
|
scopes: ['profile'],
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
113
|
+
},
|
|
114
|
+
scopes: ['profile'],
|
|
115
|
+
boundOrigin: 'https://mention.earth',
|
|
116
|
+
expiresAt: 1700000300000,
|
|
117
|
+
status: 'pending',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
it('GETs the server-resolved approval info by authorizeCode', async () => {
|
|
121
|
+
makeRequestSpy.mockResolvedValue({ ...baseInfo, originVerified: true });
|
|
121
122
|
|
|
122
123
|
const result = await oxy.getCommonsApprovalInfo('code-1');
|
|
123
124
|
|
|
124
|
-
expect(result).toEqual(
|
|
125
|
+
expect(result).toEqual({ ...baseInfo, originVerified: true });
|
|
125
126
|
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
126
127
|
'GET',
|
|
127
128
|
'/auth/session/approve-info/code-1',
|
|
@@ -129,6 +130,30 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
129
130
|
expect.objectContaining({ cache: false }),
|
|
130
131
|
);
|
|
131
132
|
});
|
|
133
|
+
|
|
134
|
+
it('coerces a missing originVerified to false (fail-safe to "not verified")', async () => {
|
|
135
|
+
makeRequestSpy.mockResolvedValue(baseInfo);
|
|
136
|
+
|
|
137
|
+
const result = await oxy.getCommonsApprovalInfo('code-1');
|
|
138
|
+
|
|
139
|
+
expect(result.originVerified).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('coerces a non-boolean originVerified to false', async () => {
|
|
143
|
+
makeRequestSpy.mockResolvedValue({ ...baseInfo, originVerified: 'yes' });
|
|
144
|
+
|
|
145
|
+
const result = await oxy.getCommonsApprovalInfo('code-1');
|
|
146
|
+
|
|
147
|
+
expect(result.originVerified).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('passes through a server originVerified:false unchanged', async () => {
|
|
151
|
+
makeRequestSpy.mockResolvedValue({ ...baseInfo, originVerified: false });
|
|
152
|
+
|
|
153
|
+
const result = await oxy.getCommonsApprovalInfo('code-1');
|
|
154
|
+
|
|
155
|
+
expect(result.originVerified).toBe(false);
|
|
156
|
+
});
|
|
132
157
|
});
|
|
133
158
|
|
|
134
159
|
describe('approveCommonsSignIn (approver)', () => {
|
|
@@ -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
|
+
});
|
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
* - PUBLIC (no access token planted, no `expiresIn`) → the clean CDN form
|
|
8
8
|
* `${cloudURL}/<id>[?variant=...]` (default `https://cloud.oxy.so/<id>`),
|
|
9
9
|
* which CloudFront resolves against the public media origin.
|
|
10
|
-
* -
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
* the public CDN.
|
|
10
|
+
* - EXPIRING ORIGIN FALLBACK (`expiresIn` is passed) → the API origin
|
|
11
|
+
* stream form without a bearer token in the query string. Callers that need
|
|
12
|
+
* private access should use `getFileDownloadUrlAsync()` for a scoped URL.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import { OxyServices } from '../../OxyServices';
|
|
@@ -50,38 +49,24 @@ describe('OxyServices.getFileDownloadUrl', () => {
|
|
|
50
49
|
);
|
|
51
50
|
});
|
|
52
51
|
|
|
53
|
-
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('token-safe URL generation', () => {
|
|
55
|
+
it('does not include the in-memory access token in synchronous image URLs', () => {
|
|
54
56
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
55
57
|
oxy.setTokens('access-token-abc');
|
|
56
58
|
|
|
57
|
-
const url = oxy.getFileDownloadUrl('file123', 'thumb'
|
|
58
|
-
omitToken: true,
|
|
59
|
-
});
|
|
59
|
+
const url = oxy.getFileDownloadUrl('file123', 'thumb');
|
|
60
60
|
|
|
61
61
|
expect(url).toBe('https://cloud.oxy.so/file123?variant=thumb');
|
|
62
62
|
expect(url).not.toContain('access-token-abc');
|
|
63
63
|
expect(url).not.toContain('token=');
|
|
64
64
|
});
|
|
65
|
-
});
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
it('returns the stream endpoint with the token when an access token is present', () => {
|
|
66
|
+
it('routes through the stream endpoint when expiresIn is requested without embedding a token', () => {
|
|
69
67
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
70
68
|
oxy.setTokens('access-token-abc');
|
|
71
69
|
|
|
72
|
-
const url = oxy.getFileDownloadUrl('file123', 'thumb');
|
|
73
|
-
|
|
74
|
-
expect(url.startsWith('https://api.oxy.so/assets/file123/stream?')).toBe(true);
|
|
75
|
-
const params = new URLSearchParams(url.split('?')[1]);
|
|
76
|
-
expect(params.get('variant')).toBe('thumb');
|
|
77
|
-
expect(params.get('token')).toBe('access-token-abc');
|
|
78
|
-
expect(params.get('fallback')).toBe('placeholderVisible');
|
|
79
|
-
expect(url).not.toContain('cloud.oxy.so');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('routes through the stream endpoint when expiresIn is requested even without a token', () => {
|
|
83
|
-
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
84
|
-
|
|
85
70
|
const url = oxy.getFileDownloadUrl('file123', 'thumb', 3600);
|
|
86
71
|
|
|
87
72
|
expect(url.startsWith('https://api.oxy.so/assets/file123/stream?')).toBe(true);
|
|
@@ -90,6 +75,7 @@ describe('OxyServices.getFileDownloadUrl', () => {
|
|
|
90
75
|
expect(params.get('variant')).toBe('thumb');
|
|
91
76
|
expect(params.get('fallback')).toBe('placeholderVisible');
|
|
92
77
|
expect(params.get('token')).toBeNull();
|
|
78
|
+
expect(url).not.toContain('access-token-abc');
|
|
93
79
|
expect(url).not.toContain('cloud.oxy.so');
|
|
94
80
|
});
|
|
95
81
|
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ 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';
|
|
35
|
+
import { OxyServicesLinksMixin } from './OxyServices.links';
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -66,6 +68,8 @@ type AllMixinInstances =
|
|
|
66
68
|
& InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
|
|
67
69
|
& InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
|
|
68
70
|
& InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
|
|
71
|
+
& InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>>
|
|
72
|
+
& InstanceType<ReturnType<typeof OxyServicesLinksMixin<typeof OxyServicesBase>>>
|
|
69
73
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
70
74
|
|
|
71
75
|
/**
|
|
@@ -134,6 +138,12 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
134
138
|
OxyServicesAppDataMixin,
|
|
135
139
|
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
136
140
|
OxyServicesCivicMixin,
|
|
141
|
+
// User nodes / decentralization (Fase 5): register/read/revoke/manage the
|
|
142
|
+
// caller's personal data node + ingest hint.
|
|
143
|
+
OxyServicesNodesMixin,
|
|
144
|
+
// Link previews / unfurls: SDK-owned link-metadata resolution via oxy-api,
|
|
145
|
+
// so apps stop scraping link metadata locally.
|
|
146
|
+
OxyServicesLinksMixin,
|
|
137
147
|
|
|
138
148
|
// Utility (last, can use all above)
|
|
139
149
|
OxyServicesUtilityMixin,
|