@oxy.so/protocol 1.0.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/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/chain/continuity.js +54 -0
- package/dist/cjs/chain/engine.js +34 -0
- package/dist/cjs/chain/recordStore.js +25 -0
- package/dist/cjs/chain/types.js +22 -0
- package/dist/cjs/chain/verify.js +82 -0
- package/dist/cjs/envelope/canonicalJson.js +107 -0
- package/dist/cjs/envelope/recordId.js +60 -0
- package/dist/cjs/envelope/sign.js +75 -0
- package/dist/cjs/envelope/signingInput.js +32 -0
- package/dist/cjs/identity/resolver.js +50 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/node/constants.js +85 -0
- package/dist/cjs/node/didWebResolver.js +126 -0
- package/dist/cjs/node/httpFetch.js +61 -0
- package/dist/cjs/node/index.js +71 -0
- package/dist/cjs/node/nodeApp.js +344 -0
- package/dist/cjs/node/nodeClient.js +204 -0
- package/dist/cjs/node/rateLimit.js +187 -0
- package/dist/cjs/node/verifyRecord.js +51 -0
- package/dist/cjs/platform/crypto.js +186 -0
- package/dist/cjs/platform/crypto.native.js +204 -0
- package/dist/cjs/platform/expoTypes.js +24 -0
- package/dist/cjs/platform/platform.js +33 -0
- package/dist/cjs/secp256k1.js +148 -0
- package/dist/cjs/transparency/checkpoint.js +79 -0
- package/dist/cjs/transparency/tree.js +197 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/chain/continuity.js +51 -0
- package/dist/esm/chain/engine.js +31 -0
- package/dist/esm/chain/recordStore.js +24 -0
- package/dist/esm/chain/types.js +19 -0
- package/dist/esm/chain/verify.js +78 -0
- package/dist/esm/envelope/canonicalJson.js +104 -0
- package/dist/esm/envelope/recordId.js +56 -0
- package/dist/esm/envelope/sign.js +69 -0
- package/dist/esm/envelope/signingInput.js +29 -0
- package/dist/esm/identity/resolver.js +47 -0
- package/dist/esm/index.js +36 -0
- package/dist/esm/node/constants.js +82 -0
- package/dist/esm/node/didWebResolver.js +122 -0
- package/dist/esm/node/httpFetch.js +55 -0
- package/dist/esm/node/index.js +28 -0
- package/dist/esm/node/nodeApp.js +336 -0
- package/dist/esm/node/nodeClient.js +198 -0
- package/dist/esm/node/rateLimit.js +182 -0
- package/dist/esm/node/verifyRecord.js +48 -0
- package/dist/esm/platform/crypto.js +145 -0
- package/dist/esm/platform/crypto.native.js +196 -0
- package/dist/esm/platform/expoTypes.js +23 -0
- package/dist/esm/platform/platform.js +29 -0
- package/dist/esm/secp256k1.js +137 -0
- package/dist/esm/transparency/checkpoint.js +73 -0
- package/dist/esm/transparency/tree.js +189 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/chain/continuity.d.ts +28 -0
- package/dist/types/chain/engine.d.ts +27 -0
- package/dist/types/chain/recordStore.d.ts +85 -0
- package/dist/types/chain/types.d.ts +79 -0
- package/dist/types/chain/verify.d.ts +45 -0
- package/dist/types/envelope/canonicalJson.d.ts +44 -0
- package/dist/types/envelope/recordId.d.ts +30 -0
- package/dist/types/envelope/sign.d.ts +47 -0
- package/dist/types/envelope/signingInput.d.ts +33 -0
- package/dist/types/identity/resolver.d.ts +67 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/node/constants.d.ts +80 -0
- package/dist/types/node/didWebResolver.d.ts +47 -0
- package/dist/types/node/httpFetch.d.ts +60 -0
- package/dist/types/node/index.d.ts +28 -0
- package/dist/types/node/nodeApp.d.ts +120 -0
- package/dist/types/node/nodeClient.d.ts +135 -0
- package/dist/types/node/rateLimit.d.ts +95 -0
- package/dist/types/node/verifyRecord.d.ts +41 -0
- package/dist/types/platform/crypto.d.ts +93 -0
- package/dist/types/platform/crypto.native.d.ts +77 -0
- package/dist/types/platform/expoTypes.d.ts +99 -0
- package/dist/types/platform/platform.d.ts +25 -0
- package/dist/types/secp256k1.d.ts +45 -0
- package/dist/types/transparency/checkpoint.d.ts +71 -0
- package/dist/types/transparency/tree.d.ts +135 -0
- package/package.json +157 -0
- package/src/__tests__/canonicalJson.test.ts +116 -0
- package/src/__tests__/chain.test.ts +279 -0
- package/src/__tests__/didWebResolver.test.ts +132 -0
- package/src/__tests__/envelope.test.ts +267 -0
- package/src/__tests__/nodeApp.test.ts +410 -0
- package/src/__tests__/nodeClient.test.ts +177 -0
- package/src/__tests__/nodeHarness.ts +151 -0
- package/src/__tests__/optionalNativePeers.test.ts +233 -0
- package/src/__tests__/rateLimit.test.ts +268 -0
- package/src/__tests__/runnerGuard.test.ts +85 -0
- package/src/__tests__/secp256k1.test.ts +118 -0
- package/src/__tests__/transparency.test.ts +353 -0
- package/src/chain/continuity.ts +59 -0
- package/src/chain/engine.ts +43 -0
- package/src/chain/recordStore.ts +98 -0
- package/src/chain/types.ts +85 -0
- package/src/chain/verify.ts +102 -0
- package/src/envelope/canonicalJson.ts +120 -0
- package/src/envelope/recordId.ts +63 -0
- package/src/envelope/sign.ts +86 -0
- package/src/envelope/signingInput.ts +48 -0
- package/src/identity/resolver.ts +90 -0
- package/src/index.ts +101 -0
- package/src/node/constants.ts +105 -0
- package/src/node/didWebResolver.ts +162 -0
- package/src/node/httpFetch.ts +88 -0
- package/src/node/index.ts +87 -0
- package/src/node/nodeApp.ts +471 -0
- package/src/node/nodeClient.ts +322 -0
- package/src/node/rateLimit.ts +233 -0
- package/src/node/verifyRecord.ts +60 -0
- package/src/platform/crypto.native.ts +251 -0
- package/src/platform/crypto.ts +172 -0
- package/src/platform/expoTypes.ts +99 -0
- package/src/platform/platform.ts +31 -0
- package/src/secp256k1.ts +207 -0
- package/src/transparency/checkpoint.ts +109 -0
- package/src/transparency/tree.ts +258 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed-record envelope tests — the protocol's canonical signing input,
|
|
3
|
+
* content address, and explicit-key sign/verify.
|
|
4
|
+
*
|
|
5
|
+
* Includes the byte-stability regression guards moved from `@oxy.so/core`'s
|
|
6
|
+
* signed-record suite (the v1 signing-input string and the deterministic
|
|
7
|
+
* `computeRecordId`), plus a CANONICAL-BYTES FIXTURE: signing a fixed envelope
|
|
8
|
+
* with a fixed key must produce the exact same signing input, recordId, derived
|
|
9
|
+
* publicKey, and DER signature as before the crypto was moved into protocol.
|
|
10
|
+
* Any drift here invalidates every production signature, so the literals are
|
|
11
|
+
* locked.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { deriveSecp256k1PublicKey } from '../secp256k1';
|
|
15
|
+
import { signedRecordEnvelopeSchema } from '@oxy.so/contracts';
|
|
16
|
+
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
17
|
+
import {
|
|
18
|
+
canonicalize,
|
|
19
|
+
signedRecordSigningInput,
|
|
20
|
+
computeRecordId,
|
|
21
|
+
sha256,
|
|
22
|
+
signMessage,
|
|
23
|
+
verifySignature,
|
|
24
|
+
signEnvelope,
|
|
25
|
+
verifyEnvelopeSignature,
|
|
26
|
+
} from '../index';
|
|
27
|
+
|
|
28
|
+
describe('signedRecordSigningInput — v1 byte stability (regression guard)', () => {
|
|
29
|
+
it('produces the exact original canonical bytes for a v1 record', () => {
|
|
30
|
+
const input = signedRecordSigningInput({
|
|
31
|
+
version: 1,
|
|
32
|
+
type: 'identity',
|
|
33
|
+
subject: 'did:web:oxy.so:u:u1',
|
|
34
|
+
issuer: 'did:web:oxy.so:u:u1',
|
|
35
|
+
record: { handle: '@nate' },
|
|
36
|
+
issuedAt: 1750000000000,
|
|
37
|
+
});
|
|
38
|
+
expect(input).toBe(
|
|
39
|
+
'{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does NOT include any v2 chain fields for a v1 record even if they are passed', () => {
|
|
44
|
+
const input = signedRecordSigningInput({
|
|
45
|
+
version: 1,
|
|
46
|
+
type: 'identity',
|
|
47
|
+
subject: 'did:web:oxy.so:u:u1',
|
|
48
|
+
issuer: 'did:web:oxy.so:u:u1',
|
|
49
|
+
record: { handle: '@nate' },
|
|
50
|
+
issuedAt: 1750000000000,
|
|
51
|
+
// These must be ignored by the v1 branch — proving v1 bytes are immutable.
|
|
52
|
+
seq: 5,
|
|
53
|
+
prev: 'deadbeef',
|
|
54
|
+
collection: 'app.oxy.identity',
|
|
55
|
+
rkey: 'self',
|
|
56
|
+
});
|
|
57
|
+
expect(input).not.toContain('seq');
|
|
58
|
+
expect(input).not.toContain('prev');
|
|
59
|
+
expect(input).not.toContain('collection');
|
|
60
|
+
expect(input).not.toContain('rkey');
|
|
61
|
+
expect(input).toBe(
|
|
62
|
+
'{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('a v2 record signs over DIFFERENT bytes than the same base fields as v1', () => {
|
|
67
|
+
const base = {
|
|
68
|
+
type: 'profile' as const,
|
|
69
|
+
subject: 'did:web:oxy.so:u:diff',
|
|
70
|
+
issuer: 'did:web:oxy.so:u:diff',
|
|
71
|
+
record: { x: 1 },
|
|
72
|
+
issuedAt: 1750000000000,
|
|
73
|
+
};
|
|
74
|
+
const v1Input = signedRecordSigningInput({ version: 1, ...base });
|
|
75
|
+
const v2Input = signedRecordSigningInput({
|
|
76
|
+
version: 2,
|
|
77
|
+
...base,
|
|
78
|
+
seq: 0,
|
|
79
|
+
prev: null,
|
|
80
|
+
collection: 'app.oxy.reputation',
|
|
81
|
+
rkey: 'rt_1',
|
|
82
|
+
});
|
|
83
|
+
expect(v2Input).not.toBe(v1Input);
|
|
84
|
+
expect(v2Input).toContain('"seq":0');
|
|
85
|
+
expect(v2Input).toContain('"prev":null');
|
|
86
|
+
expect(v2Input).toContain('"collection":"app.oxy.reputation"');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('computeRecordId — deterministic content address', () => {
|
|
91
|
+
const base: SignedRecordEnvelope = {
|
|
92
|
+
version: 2,
|
|
93
|
+
type: 'reputation_attestation',
|
|
94
|
+
subject: 'did:web:oxy.so:u:rid',
|
|
95
|
+
issuer: 'did:web:oxy.so',
|
|
96
|
+
record: { points: 25 },
|
|
97
|
+
issuedAt: 1750000000000,
|
|
98
|
+
seq: 0,
|
|
99
|
+
prev: null,
|
|
100
|
+
collection: 'app.oxy.reputation',
|
|
101
|
+
rkey: 'rt_1',
|
|
102
|
+
publicKey: '03oxykey',
|
|
103
|
+
alg: 'ES256K-DER-SHA256',
|
|
104
|
+
signature: 'sig-is-not-part-of-the-id',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
it('is a 64-char lowercase hex SHA-256 digest', async () => {
|
|
108
|
+
const id = await computeRecordId(base);
|
|
109
|
+
expect(id).toMatch(/^[0-9a-f]{64}$/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('is deterministic for identical signing fields', async () => {
|
|
113
|
+
const a = await computeRecordId(base);
|
|
114
|
+
const b = await computeRecordId(base);
|
|
115
|
+
expect(a).toBe(b);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('equals sha256 of the canonical signing input (content address, excludes publicKey/signature)', async () => {
|
|
119
|
+
const id = await computeRecordId(base);
|
|
120
|
+
const expected = await sha256(signedRecordSigningInput(base));
|
|
121
|
+
expect(id).toBe(expected);
|
|
122
|
+
// Changing only signature/publicKey does NOT change the recordId.
|
|
123
|
+
const sameId = await computeRecordId({
|
|
124
|
+
...base,
|
|
125
|
+
signature: 'totally-different',
|
|
126
|
+
publicKey: 'different-key',
|
|
127
|
+
});
|
|
128
|
+
expect(sameId).toBe(id);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('changes when any signed field changes', async () => {
|
|
132
|
+
const id = await computeRecordId(base);
|
|
133
|
+
expect(await computeRecordId({ ...base, seq: 1 })).not.toBe(id);
|
|
134
|
+
expect(await computeRecordId({ ...base, record: { points: 26 } })).not.toBe(id);
|
|
135
|
+
expect(await computeRecordId({ ...base, prev: 'a'.repeat(64) })).not.toBe(id);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('a v1 record and a v2 record with the same base fields have different recordIds', async () => {
|
|
139
|
+
const v1Id = await computeRecordId({
|
|
140
|
+
version: 1,
|
|
141
|
+
type: 'profile',
|
|
142
|
+
subject: 'did:web:oxy.so:u:rid',
|
|
143
|
+
issuer: 'did:web:oxy.so:u:rid',
|
|
144
|
+
record: { points: 25 },
|
|
145
|
+
issuedAt: 1750000000000,
|
|
146
|
+
});
|
|
147
|
+
const v2Id = await computeRecordId({
|
|
148
|
+
version: 2,
|
|
149
|
+
type: 'profile',
|
|
150
|
+
subject: 'did:web:oxy.so:u:rid',
|
|
151
|
+
issuer: 'did:web:oxy.so:u:rid',
|
|
152
|
+
record: { points: 25 },
|
|
153
|
+
issuedAt: 1750000000000,
|
|
154
|
+
seq: 0,
|
|
155
|
+
prev: null,
|
|
156
|
+
collection: 'app.oxy.profile',
|
|
157
|
+
rkey: 'self',
|
|
158
|
+
});
|
|
159
|
+
expect(v1Id).not.toBe(v2Id);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* CANONICAL-BYTES FIXTURE — the load-bearing "did the move change anything?"
|
|
165
|
+
* guard. A fixed private key (a well-known secp256k1 test scalar) signing a
|
|
166
|
+
* fixed v2 envelope must reproduce these exact bytes. The signer uses RFC 6979
|
|
167
|
+
* deterministic nonces, so the DER signature is reproducible.
|
|
168
|
+
*/
|
|
169
|
+
describe('canonical-bytes fixture (move-invariance guard)', () => {
|
|
170
|
+
const PRIVATE_KEY = '4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318';
|
|
171
|
+
const PUBLIC_KEY =
|
|
172
|
+
'044e3b81af9c2234cad09d679ce6035ed1392347ce64ce405f5dcd36228a25de6e47fd35c4215d1edf53e6f83de344615ce719bdb0fd878f6ed76f06dd277956de';
|
|
173
|
+
const FIELDS = {
|
|
174
|
+
version: 2 as const,
|
|
175
|
+
type: 'app_record',
|
|
176
|
+
subject: 'did:web:oxy.so:u:fixture',
|
|
177
|
+
issuer: 'did:web:oxy.so:u:fixture',
|
|
178
|
+
record: { hello: 'world', n: 1 },
|
|
179
|
+
issuedAt: 1750000000000,
|
|
180
|
+
seq: 0,
|
|
181
|
+
prev: null as string | null,
|
|
182
|
+
collection: 'app.mention.feed.post',
|
|
183
|
+
rkey: 'r1',
|
|
184
|
+
};
|
|
185
|
+
const EXPECTED_SIGNING_INPUT =
|
|
186
|
+
'{"collection":"app.mention.feed.post","issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:fixture","prev":null,"record":{"hello":"world","n":1},"rkey":"r1","seq":0,"subject":"did:web:oxy.so:u:fixture","type":"app_record","version":2}';
|
|
187
|
+
const EXPECTED_RECORD_ID = '46585cdd862525a1437d1fbd17d060ed053b978bb0f9095a3f22818719cab6d1';
|
|
188
|
+
const EXPECTED_SIGNATURE =
|
|
189
|
+
'304602210098643efdead01cb84c9ba2162fc7d013ccdb10fe4ce34fe4c52df9e1cfc9e7f7022100c7a94ac532cff4a61d4301b12aeea0598a47ad2a664a8f521f89d68deda63656';
|
|
190
|
+
|
|
191
|
+
it('signing input matches the locked canonical bytes', () => {
|
|
192
|
+
expect(signedRecordSigningInput(FIELDS)).toBe(EXPECTED_SIGNING_INPUT);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('recordId matches the locked content address', async () => {
|
|
196
|
+
expect(await computeRecordId(FIELDS)).toBe(EXPECTED_RECORD_ID);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('signEnvelope derives the matching public key and the locked signature', async () => {
|
|
200
|
+
const env = await signEnvelope(FIELDS, PRIVATE_KEY);
|
|
201
|
+
expect(env.publicKey).toBe(PUBLIC_KEY);
|
|
202
|
+
expect(env.alg).toBe('ES256K-DER-SHA256');
|
|
203
|
+
expect(env.signature).toBe(EXPECTED_SIGNATURE);
|
|
204
|
+
// The derived key is the standard uncompressed SEC1 point for this scalar.
|
|
205
|
+
expect(env.publicKey).toBe(deriveSecp256k1PublicKey(PRIVATE_KEY));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('signEnvelope is deterministic (RFC 6979) — same input, same signature', async () => {
|
|
209
|
+
const a = await signEnvelope(FIELDS, PRIVATE_KEY);
|
|
210
|
+
const b = await signEnvelope(FIELDS, PRIVATE_KEY);
|
|
211
|
+
expect(a.signature).toBe(b.signature);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('round-trips: the signed envelope verifies and tampering breaks it', async () => {
|
|
215
|
+
const env = await signEnvelope(FIELDS, PRIVATE_KEY);
|
|
216
|
+
await expect(verifyEnvelopeSignature(env)).resolves.toBe(true);
|
|
217
|
+
await expect(verifyEnvelopeSignature({ ...env, seq: 1 })).resolves.toBe(false);
|
|
218
|
+
await expect(
|
|
219
|
+
verifyEnvelopeSignature({ ...env, record: { hello: 'tampered', n: 1 } }),
|
|
220
|
+
).resolves.toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('low-level signMessage / verifySignature agree over the canonical input', async () => {
|
|
224
|
+
const sig = await signMessage(EXPECTED_SIGNING_INPUT, PRIVATE_KEY);
|
|
225
|
+
expect(sig).toBe(EXPECTED_SIGNATURE);
|
|
226
|
+
await expect(verifySignature(EXPECTED_SIGNING_INPUT, sig, PUBLIC_KEY)).resolves.toBe(true);
|
|
227
|
+
await expect(verifySignature(EXPECTED_SIGNING_INPUT, sig, 'not-a-key')).resolves.toBe(false);
|
|
228
|
+
expect(canonicalize(FIELDS)).toBe(EXPECTED_SIGNING_INPUT);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A2 type-widening guard: opening the envelope `type` from a closed enum to a
|
|
233
|
+
* string is canonical-bytes-safe AND lets app records onto the shared grammar.
|
|
234
|
+
* `signedRecordEnvelopeSchema` (the widened contract) is exercised end-to-end
|
|
235
|
+
* against the real signer/verifier — the schema change must not reject or alter
|
|
236
|
+
* a production envelope, and an `app.mention.*` record must now validate.
|
|
237
|
+
*/
|
|
238
|
+
it('the widened base schema accepts a production identity envelope with unchanged canonical bytes', async () => {
|
|
239
|
+
const identityFields = {
|
|
240
|
+
version: 1 as const,
|
|
241
|
+
type: 'identity',
|
|
242
|
+
subject: 'did:web:oxy.so:u:u1',
|
|
243
|
+
issuer: 'did:web:oxy.so:u:u1',
|
|
244
|
+
record: { handle: '@nate' },
|
|
245
|
+
issuedAt: 1750000000000,
|
|
246
|
+
};
|
|
247
|
+
const signed = await signEnvelope(identityFields, PRIVATE_KEY);
|
|
248
|
+
// The widened schema accepts the production envelope unchanged.
|
|
249
|
+
const parsed = signedRecordEnvelopeSchema.safeParse(signed);
|
|
250
|
+
expect(parsed.success).toBe(true);
|
|
251
|
+
// Its canonical signing bytes are byte-identical to the locked v1 golden —
|
|
252
|
+
// the enum→string widening does not touch what the signature covers.
|
|
253
|
+
expect(signedRecordSigningInput(signed)).toBe(
|
|
254
|
+
'{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
|
|
255
|
+
);
|
|
256
|
+
await expect(verifyEnvelopeSignature(signed)).resolves.toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('the widened base schema accepts the signed app_record envelope (app.mention.feed.post)', async () => {
|
|
260
|
+
const signed = await signEnvelope(FIELDS, PRIVATE_KEY);
|
|
261
|
+
expect(signed.type).toBe('app_record');
|
|
262
|
+
expect(signed.collection).toBe('app.mention.feed.post');
|
|
263
|
+
const parsed = signedRecordEnvelopeSchema.safeParse(signed);
|
|
264
|
+
expect(parsed.success).toBe(true);
|
|
265
|
+
await expect(verifyEnvelopeSignature(signed)).resolves.toBe(true);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createNodeApp` handler tests — supertest over the generic Express factory,
|
|
3
|
+
* driven by the in-memory `RecordStore`/`BlobStore` harness + an owner-key
|
|
4
|
+
* `OwnerAuth`. Covers the well-known manifest, owner-authenticated record writes
|
|
5
|
+
* (happy path, chaining, wrong-signer + malformed + gap rejection), the
|
|
6
|
+
* collection allowlist, the batch push, the log cursor/cap, and the blob
|
|
7
|
+
* pin/serve path with signed-header auth + hash validation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { connect, type AddressInfo } from 'node:net';
|
|
13
|
+
import request from 'supertest';
|
|
14
|
+
import { createNodeApp, type NodeAppConfig } from '../node/nodeApp';
|
|
15
|
+
import { computeRecordId } from '../envelope/recordId';
|
|
16
|
+
import {
|
|
17
|
+
buildSignedEnvelope,
|
|
18
|
+
createInMemoryNodeStore,
|
|
19
|
+
createTestOwnerAuth,
|
|
20
|
+
generateKeyPair,
|
|
21
|
+
signBlobPin,
|
|
22
|
+
silentLogger,
|
|
23
|
+
type TestKeyPair,
|
|
24
|
+
} from './nodeHarness';
|
|
25
|
+
|
|
26
|
+
function makeConfig(
|
|
27
|
+
nodePublicKey: string,
|
|
28
|
+
collections: readonly string[] = [],
|
|
29
|
+
writeRateLimit?: { windowMs: number; max: number; maxEntries?: number },
|
|
30
|
+
): NodeAppConfig {
|
|
31
|
+
return {
|
|
32
|
+
wellKnownPath: '/.well-known/oxy-node.json',
|
|
33
|
+
protocolId: 'oxy-node/1',
|
|
34
|
+
serviceType: 'OxyPersonalDataNode',
|
|
35
|
+
mode: 'self-hosted',
|
|
36
|
+
nodePublicKey,
|
|
37
|
+
maxBlobBytes: 25 * 1024 * 1024,
|
|
38
|
+
collections,
|
|
39
|
+
writeRateLimit,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Apps built during a test own a background rate-limiter sweep timer; track them
|
|
44
|
+
// so `afterEach` can stop the timers and leave no open handles between tests.
|
|
45
|
+
const builtApps: Array<ReturnType<typeof createNodeApp>> = [];
|
|
46
|
+
|
|
47
|
+
function buildApp(
|
|
48
|
+
owner: TestKeyPair,
|
|
49
|
+
collections: readonly string[] = [],
|
|
50
|
+
writeRateLimit?: { windowMs: number; max: number; maxEntries?: number },
|
|
51
|
+
) {
|
|
52
|
+
const store = createInMemoryNodeStore();
|
|
53
|
+
const app = createNodeApp({
|
|
54
|
+
store,
|
|
55
|
+
config: makeConfig(owner.publicKey, collections, writeRateLimit),
|
|
56
|
+
ownerAuth: createTestOwnerAuth(owner.publicKey),
|
|
57
|
+
logger: silentLogger,
|
|
58
|
+
});
|
|
59
|
+
builtApps.push(app);
|
|
60
|
+
return { app, store };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('createNodeApp', () => {
|
|
64
|
+
let owner: TestKeyPair;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
owner = generateKeyPair();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
for (const app of builtApps.splice(0)) {
|
|
72
|
+
app.stop();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('GET /.well-known/oxy-node.json returns identity + liveness (incl. serviceType)', async () => {
|
|
77
|
+
const { app } = buildApp(owner);
|
|
78
|
+
const res = await request(app).get('/.well-known/oxy-node.json');
|
|
79
|
+
expect(res.status).toBe(200);
|
|
80
|
+
expect(res.body).toEqual({
|
|
81
|
+
nodePublicKey: owner.publicKey,
|
|
82
|
+
mode: 'self-hosted',
|
|
83
|
+
version: 'oxy-node/1',
|
|
84
|
+
serviceType: 'OxyPersonalDataNode',
|
|
85
|
+
head: null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('GET /oxy/head reports an empty chain then advances after a write', async () => {
|
|
90
|
+
const { app } = buildApp(owner);
|
|
91
|
+
const empty = await request(app).get('/oxy/head');
|
|
92
|
+
expect(empty.body).toEqual({ seq: null, headRecordId: null, recordCount: 0 });
|
|
93
|
+
|
|
94
|
+
const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
95
|
+
const recordId = await computeRecordId(envelope);
|
|
96
|
+
const write = await request(app).post('/records').send(envelope);
|
|
97
|
+
expect(write.status).toBe(201);
|
|
98
|
+
expect(write.body).toEqual({ recordId, seq: 0 });
|
|
99
|
+
|
|
100
|
+
const head = await request(app).get('/oxy/head');
|
|
101
|
+
expect(head.body).toEqual({ seq: 0, headRecordId: recordId, recordCount: 1 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('POST /records accepts an owner-signed envelope and chains the next one', async () => {
|
|
105
|
+
const { app } = buildApp(owner);
|
|
106
|
+
const genesis = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
107
|
+
const genesisId = await computeRecordId(genesis);
|
|
108
|
+
await request(app).post('/records').send(genesis).expect(201);
|
|
109
|
+
|
|
110
|
+
const second = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 1, prev: genesisId, record: { step: 2 } });
|
|
111
|
+
const secondId = await computeRecordId(second);
|
|
112
|
+
const res = await request(app).post('/records').send(second);
|
|
113
|
+
expect(res.status).toBe(201);
|
|
114
|
+
expect(res.body).toEqual({ recordId: secondId, seq: 1 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('POST /records rejects a record signed by a NON-owner key (403 not_owner)', async () => {
|
|
118
|
+
const { app } = buildApp(owner);
|
|
119
|
+
const attacker = generateKeyPair();
|
|
120
|
+
const envelope = await buildSignedEnvelope({ privateKey: attacker.privateKey, seq: 0, prev: null });
|
|
121
|
+
const res = await request(app).post('/records').send(envelope);
|
|
122
|
+
expect(res.status).toBe(403);
|
|
123
|
+
expect(res.body).toEqual({ error: 'not_owner' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('POST /records rejects a malformed envelope (400 invalid_envelope)', async () => {
|
|
127
|
+
const { app } = buildApp(owner);
|
|
128
|
+
const res = await request(app).post('/records').send({ not: 'an envelope' });
|
|
129
|
+
expect(res.status).toBe(400);
|
|
130
|
+
expect(res.body).toEqual({ error: 'invalid_envelope' });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('POST /records rejects a chain gap (422 chain_gap)', async () => {
|
|
134
|
+
const { app } = buildApp(owner);
|
|
135
|
+
const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 3, prev: null });
|
|
136
|
+
const res = await request(app).post('/records').send(envelope);
|
|
137
|
+
expect(res.status).toBe(422);
|
|
138
|
+
expect(res.body).toEqual({ error: 'chain_gap' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('POST /records rejects a foreign collection when an allowlist is set (403)', async () => {
|
|
142
|
+
const { app } = buildApp(owner, ['app.mention.feed.post']);
|
|
143
|
+
const inAllow = await buildSignedEnvelope({
|
|
144
|
+
privateKey: owner.privateKey,
|
|
145
|
+
seq: 0,
|
|
146
|
+
prev: null,
|
|
147
|
+
collection: 'app.mention.feed.post',
|
|
148
|
+
});
|
|
149
|
+
await request(app).post('/records').send(inAllow).expect(201);
|
|
150
|
+
|
|
151
|
+
const foreign = await buildSignedEnvelope({
|
|
152
|
+
privateKey: owner.privateKey,
|
|
153
|
+
seq: 1,
|
|
154
|
+
prev: await computeRecordId(inAllow),
|
|
155
|
+
collection: 'app.oxy.identity',
|
|
156
|
+
});
|
|
157
|
+
const res = await request(app).post('/records').send(foreign);
|
|
158
|
+
expect(res.status).toBe(403);
|
|
159
|
+
expect(res.body).toEqual({ error: 'foreign_collection' });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('POST /sync/push verifies + appends a batch in order', async () => {
|
|
163
|
+
const { app } = buildApp(owner);
|
|
164
|
+
const genesis = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
165
|
+
const second = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 1, prev: await computeRecordId(genesis) });
|
|
166
|
+
const res = await request(app).post('/sync/push').send({ records: [genesis, second] });
|
|
167
|
+
expect(res.status).toBe(200);
|
|
168
|
+
expect(res.body.accepted).toBe(2);
|
|
169
|
+
expect(res.body.results.every((r: { ok: boolean }) => r.ok)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('GET /oxy/log returns the ordered log from a cursor and caps the limit', async () => {
|
|
173
|
+
const { app } = buildApp(owner);
|
|
174
|
+
let prev: string | null = null;
|
|
175
|
+
const ids: string[] = [];
|
|
176
|
+
for (let seq = 0; seq < 4; seq += 1) {
|
|
177
|
+
const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq, prev, record: { seq } });
|
|
178
|
+
const id = await computeRecordId(envelope);
|
|
179
|
+
await request(app).post('/records').send(envelope).expect(201);
|
|
180
|
+
ids.push(id);
|
|
181
|
+
prev = id;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const full = await request(app).get('/oxy/log');
|
|
185
|
+
expect(full.body.count).toBe(4);
|
|
186
|
+
expect(full.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([0, 1, 2, 3]);
|
|
187
|
+
expect(full.body.records[0].recordId).toBe(ids[0]);
|
|
188
|
+
expect(full.body.head).toEqual({ seq: 3, headRecordId: ids[3] });
|
|
189
|
+
|
|
190
|
+
const capped = await request(app).get('/oxy/log').query({ limit: 2 });
|
|
191
|
+
expect(capped.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([0, 1]);
|
|
192
|
+
|
|
193
|
+
const sinceCursor = await request(app).get('/oxy/log').query({ since: ids[1] });
|
|
194
|
+
expect(sinceCursor.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([2, 3]);
|
|
195
|
+
|
|
196
|
+
const unknownCursor = await request(app).get('/oxy/log').query({ since: 'f'.repeat(64) });
|
|
197
|
+
expect(unknownCursor.body.records).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('PUT then GET a blob with owner auth + hash validation', async () => {
|
|
201
|
+
const { app } = buildApp(owner);
|
|
202
|
+
const bytes = Buffer.from('a pinned media blob');
|
|
203
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
204
|
+
|
|
205
|
+
const auth = await signBlobPin(hash, owner);
|
|
206
|
+
const put = await request(app)
|
|
207
|
+
.put(`/blobs/${hash}`)
|
|
208
|
+
.set({
|
|
209
|
+
'x-oxy-node-public-key': auth.publicKey,
|
|
210
|
+
'x-oxy-node-signature': auth.signature,
|
|
211
|
+
'x-oxy-node-timestamp': String(auth.timestamp),
|
|
212
|
+
})
|
|
213
|
+
.set('Content-Type', 'application/octet-stream')
|
|
214
|
+
.send(bytes);
|
|
215
|
+
expect(put.status).toBe(201);
|
|
216
|
+
expect(put.body).toEqual({ hash, size: bytes.length });
|
|
217
|
+
|
|
218
|
+
const get = await request(app).get(`/blobs/${hash}`).buffer(true).parse((res, cb) => {
|
|
219
|
+
const chunks: Buffer[] = [];
|
|
220
|
+
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
221
|
+
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
|
222
|
+
});
|
|
223
|
+
expect(get.status).toBe(200);
|
|
224
|
+
expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toEqual(bytes);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('GET /blobs/:hash 404s when absent', async () => {
|
|
228
|
+
const { app } = buildApp(owner);
|
|
229
|
+
const hash = createHash('sha256').update(Buffer.from('nope')).digest('hex');
|
|
230
|
+
const res = await request(app).get(`/blobs/${hash}`);
|
|
231
|
+
expect(res.status).toBe(404);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('PUT /blobs/:hash rejects bytes that do not hash to the address (400 hash_mismatch)', async () => {
|
|
235
|
+
const { app } = buildApp(owner);
|
|
236
|
+
const realBytes = Buffer.from('the actual bytes');
|
|
237
|
+
const claimedHash = createHash('sha256').update(Buffer.from('some other bytes')).digest('hex');
|
|
238
|
+
|
|
239
|
+
const auth = await signBlobPin(claimedHash, owner);
|
|
240
|
+
const res = await request(app)
|
|
241
|
+
.put(`/blobs/${claimedHash}`)
|
|
242
|
+
.set({
|
|
243
|
+
'x-oxy-node-public-key': auth.publicKey,
|
|
244
|
+
'x-oxy-node-signature': auth.signature,
|
|
245
|
+
'x-oxy-node-timestamp': String(auth.timestamp),
|
|
246
|
+
})
|
|
247
|
+
.set('Content-Type', 'application/octet-stream')
|
|
248
|
+
.send(realBytes);
|
|
249
|
+
expect(res.status).toBe(400);
|
|
250
|
+
expect(res.body).toEqual({ error: 'hash_mismatch' });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('PUT /blobs/:hash rejects a request with no body framing at all (400 empty_blob)', async () => {
|
|
254
|
+
// `express.raw` assigns `{}` — not an empty Buffer — when the request
|
|
255
|
+
// carries no body framing (`typeis.hasBody(req)` is false), so `.length` on
|
|
256
|
+
// it is `undefined` and an emptiness check alone would let it through to
|
|
257
|
+
// `store.putBlob` and to `size: bytes.length` in the 201 payload. CodeQL
|
|
258
|
+
// flags that `.length` (js/type-confusion-through-parameter-tampering,
|
|
259
|
+
// alert #473) because it does not model `Buffer.isBuffer` as a narrowing
|
|
260
|
+
// guard; this pins the guard that makes the flag a false positive.
|
|
261
|
+
//
|
|
262
|
+
// The request is written onto a raw socket because neither supertest nor
|
|
263
|
+
// `http.request` can express it — both add a `Content-Length` or a
|
|
264
|
+
// `Transfer-Encoding`, either of which makes `hasBody` true and produces an
|
|
265
|
+
// empty Buffer instead, exercising only the other half of the guard.
|
|
266
|
+
const { app, store } = buildApp(owner);
|
|
267
|
+
const bytes = Buffer.from('a blob that never gets sent');
|
|
268
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
269
|
+
const auth = await signBlobPin(hash, owner);
|
|
270
|
+
|
|
271
|
+
const server = createServer(app);
|
|
272
|
+
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
273
|
+
const { port } = server.address() as AddressInfo;
|
|
274
|
+
|
|
275
|
+
const raw = await new Promise<string>((resolve, reject) => {
|
|
276
|
+
const socket = connect(port, '127.0.0.1', () => {
|
|
277
|
+
socket.write(
|
|
278
|
+
[
|
|
279
|
+
`PUT /blobs/${hash} HTTP/1.1`,
|
|
280
|
+
'Host: 127.0.0.1',
|
|
281
|
+
`x-oxy-node-public-key: ${auth.publicKey}`,
|
|
282
|
+
`x-oxy-node-signature: ${auth.signature}`,
|
|
283
|
+
`x-oxy-node-timestamp: ${auth.timestamp}`,
|
|
284
|
+
'Connection: close',
|
|
285
|
+
'',
|
|
286
|
+
'',
|
|
287
|
+
].join('\r\n')
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
let received = '';
|
|
291
|
+
socket.setEncoding('utf8');
|
|
292
|
+
socket.on('data', (chunk: string) => { received += chunk; });
|
|
293
|
+
socket.on('error', reject);
|
|
294
|
+
socket.on('end', () => resolve(received));
|
|
295
|
+
});
|
|
296
|
+
await new Promise<void>((resolve, reject) => {
|
|
297
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(raw).toContain('HTTP/1.1 400');
|
|
301
|
+
expect(raw).toContain('empty_blob');
|
|
302
|
+
// And nothing was pinned under that address.
|
|
303
|
+
expect(await store.getBlob(hash)).toBeNull();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('PUT /blobs/:hash rejects an empty body (400 empty_blob)', async () => {
|
|
307
|
+
const { app, store } = buildApp(owner);
|
|
308
|
+
const bytes = Buffer.from('another blob that never gets sent');
|
|
309
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
310
|
+
const auth = await signBlobPin(hash, owner);
|
|
311
|
+
|
|
312
|
+
const res = await request(app)
|
|
313
|
+
.put(`/blobs/${hash}`)
|
|
314
|
+
.set({
|
|
315
|
+
'x-oxy-node-public-key': auth.publicKey,
|
|
316
|
+
'x-oxy-node-signature': auth.signature,
|
|
317
|
+
'x-oxy-node-timestamp': String(auth.timestamp),
|
|
318
|
+
})
|
|
319
|
+
.set('Content-Type', 'application/octet-stream')
|
|
320
|
+
.send(Buffer.alloc(0));
|
|
321
|
+
|
|
322
|
+
expect(res.status).toBe(400);
|
|
323
|
+
expect(res.body).toEqual({ error: 'empty_blob' });
|
|
324
|
+
expect(await store.getBlob(hash)).toBeNull();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('PUT /blobs/:hash rejects a non-owner signature (403)', async () => {
|
|
328
|
+
const { app } = buildApp(owner);
|
|
329
|
+
const bytes = Buffer.from('blob from an impostor');
|
|
330
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
331
|
+
const attacker = generateKeyPair();
|
|
332
|
+
|
|
333
|
+
const auth = await signBlobPin(hash, attacker);
|
|
334
|
+
const res = await request(app)
|
|
335
|
+
.put(`/blobs/${hash}`)
|
|
336
|
+
.set({
|
|
337
|
+
'x-oxy-node-public-key': auth.publicKey,
|
|
338
|
+
'x-oxy-node-signature': auth.signature,
|
|
339
|
+
'x-oxy-node-timestamp': String(auth.timestamp),
|
|
340
|
+
})
|
|
341
|
+
.set('Content-Type', 'application/octet-stream')
|
|
342
|
+
.send(bytes);
|
|
343
|
+
expect(res.status).toBe(403);
|
|
344
|
+
expect(res.body).toEqual({ error: 'unauthorized' });
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('PUT /blobs/:hash requires owner-auth headers (401)', async () => {
|
|
348
|
+
const { app } = buildApp(owner);
|
|
349
|
+
const bytes = Buffer.from('unauthenticated');
|
|
350
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
351
|
+
const res = await request(app)
|
|
352
|
+
.put(`/blobs/${hash}`)
|
|
353
|
+
.set('Content-Type', 'application/octet-stream')
|
|
354
|
+
.send(bytes);
|
|
355
|
+
expect(res.status).toBe(401);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('rate-limits the owner write route after the configured budget (429 rate_limited)', async () => {
|
|
359
|
+
// A tiny per-window budget makes the limiter deterministic: the 3rd write
|
|
360
|
+
// within the window is rejected 429 BEFORE the handler runs, regardless of
|
|
361
|
+
// each request's chain outcome.
|
|
362
|
+
const { app } = buildApp(owner, [], { windowMs: 60_000, max: 2 });
|
|
363
|
+
const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
364
|
+
|
|
365
|
+
const first = await request(app).post('/records').send(envelope);
|
|
366
|
+
expect(first.status).toBe(201);
|
|
367
|
+
// Second consumes the last slot (a chain_conflict on the replayed genesis,
|
|
368
|
+
// but it still counts against the budget — the limiter precedes the handler).
|
|
369
|
+
const second = await request(app).post('/records').send(envelope);
|
|
370
|
+
expect(second.status).not.toBe(429);
|
|
371
|
+
// Third exceeds the budget.
|
|
372
|
+
const third = await request(app).post('/records').send(envelope);
|
|
373
|
+
expect(third.status).toBe(429);
|
|
374
|
+
expect(third.body).toEqual({ error: 'rate_limited' });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('GET /oxy/log coerces an array `since` param to its first value (no 500)', async () => {
|
|
378
|
+
const { app } = buildApp(owner);
|
|
379
|
+
let prev: string | null = null;
|
|
380
|
+
const ids: string[] = [];
|
|
381
|
+
for (let seq = 0; seq < 3; seq += 1) {
|
|
382
|
+
const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq, prev, record: { seq } });
|
|
383
|
+
const id = await computeRecordId(envelope);
|
|
384
|
+
await request(app).post('/records').send(envelope).expect(201);
|
|
385
|
+
ids.push(id);
|
|
386
|
+
prev = id;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// `?since[]=<id1>&since[]=<id2>` arrives as a string[] — the handler must
|
|
390
|
+
// take the FIRST value (a tampered array can never cause type confusion).
|
|
391
|
+
const res = await request(app).get('/oxy/log').query({ 'since[]': [ids[0], ids[2]] });
|
|
392
|
+
expect(res.status).toBe(200);
|
|
393
|
+
// Cursor resolves off ids[0] → records strictly after seq 0 → [1, 2].
|
|
394
|
+
expect(res.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([1, 2]);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('GET /oxy/log treats an array `limit` param as its first value', async () => {
|
|
398
|
+
const { app } = buildApp(owner);
|
|
399
|
+
let prev: string | null = null;
|
|
400
|
+
for (let seq = 0; seq < 4; seq += 1) {
|
|
401
|
+
const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq, prev, record: { seq } });
|
|
402
|
+
await request(app).post('/records').send(envelope).expect(201);
|
|
403
|
+
prev = await computeRecordId(envelope);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const res = await request(app).get('/oxy/log').query({ 'limit[]': ['2', '99'] });
|
|
407
|
+
expect(res.status).toBe(200);
|
|
408
|
+
expect(res.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([0, 1]);
|
|
409
|
+
});
|
|
410
|
+
});
|