@oxy.so/protocol 1.2.2 → 1.3.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.
Files changed (58) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/chain/engine.js +1 -1
  3. package/dist/cjs/chain/recordStore.js +3 -3
  4. package/dist/cjs/chain/verify.js +8 -4
  5. package/dist/cjs/envelope/signingInput.js +12 -1
  6. package/dist/cjs/identity/resolver.js +2 -1
  7. package/dist/cjs/node/didWebResolver.js +1 -3
  8. package/dist/cjs/node/nodeApp.js +9 -3
  9. package/dist/cjs/node/nodeClient.js +7 -2
  10. package/dist/cjs/platform/platform.js +1 -1
  11. package/dist/cjs/secp256k1.js +25 -26
  12. package/dist/cjs/sign.js +17 -0
  13. package/dist/esm/.tsbuildinfo +1 -1
  14. package/dist/esm/chain/engine.js +1 -1
  15. package/dist/esm/chain/recordStore.js +3 -3
  16. package/dist/esm/chain/verify.js +8 -4
  17. package/dist/esm/envelope/sign.js +1 -1
  18. package/dist/esm/envelope/signingInput.js +12 -1
  19. package/dist/esm/identity/resolver.js +2 -1
  20. package/dist/esm/node/didWebResolver.js +1 -3
  21. package/dist/esm/node/nodeApp.js +9 -3
  22. package/dist/esm/node/nodeClient.js +8 -3
  23. package/dist/esm/platform/platform.js +1 -1
  24. package/dist/esm/secp256k1.js +26 -27
  25. package/dist/esm/sign.js +10 -0
  26. package/dist/types/.tsbuildinfo +1 -1
  27. package/dist/types/chain/engine.d.ts +1 -1
  28. package/dist/types/chain/recordStore.d.ts +3 -3
  29. package/dist/types/chain/verify.d.ts +3 -2
  30. package/dist/types/sign.d.ts +10 -0
  31. package/package.json +18 -4
  32. package/src/__tests__/canonicalJson.test.ts +2 -6
  33. package/src/__tests__/chain.test.ts +42 -11
  34. package/src/__tests__/commonsIdentityBridge.test.ts +7 -2
  35. package/src/__tests__/didWebResolver.test.ts +36 -7
  36. package/src/__tests__/nodeApp.test.ts +76 -21
  37. package/src/__tests__/nodeClient.test.ts +30 -7
  38. package/src/__tests__/nodeHarness.ts +7 -3
  39. package/src/__tests__/optionalNativePeers.test.ts +8 -6
  40. package/src/__tests__/rateLimit.test.ts +2 -1
  41. package/src/__tests__/registrationPow.test.ts +35 -37
  42. package/src/__tests__/secp256k1.test.ts +41 -70
  43. package/src/__tests__/transparency.test.ts +4 -1
  44. package/src/auth/registrationPow.ts +5 -1
  45. package/src/chain/engine.ts +1 -1
  46. package/src/chain/recordStore.ts +3 -3
  47. package/src/chain/verify.ts +11 -4
  48. package/src/envelope/recordId.ts +1 -4
  49. package/src/envelope/sign.ts +6 -6
  50. package/src/envelope/signingInput.ts +12 -1
  51. package/src/identity/resolver.ts +9 -2
  52. package/src/node/didWebResolver.ts +2 -7
  53. package/src/node/nodeApp.ts +85 -67
  54. package/src/node/nodeClient.ts +35 -16
  55. package/src/platform/crypto.native.ts +9 -2
  56. package/src/platform/platform.ts +3 -1
  57. package/src/secp256k1.ts +33 -58
  58. package/src/sign.ts +16 -0
@@ -9,7 +9,7 @@
9
9
  * engine owns the verification + ordering policy.
10
10
  *
11
11
  * Storage and identity are both injected, so the engine has zero knowledge of
12
- * Mongo/SQLite, Oxy DIDs, or any app's lexicon — exactly what makes it reusable.
12
+ * the database, Oxy DIDs, or any app's lexicon — exactly what makes it reusable.
13
13
  */
14
14
  import type { SignedRecordEnvelope } from '@oxy.so/contracts';
15
15
  import type { VerificationMethodResolver } from '../identity/resolver';
@@ -4,8 +4,8 @@
4
4
  * The engine ({@link ./verify}, {@link ./engine}) is storage-agnostic: it owns
5
5
  * the verification state machine and continuity logic, and delegates EVERY read
6
6
  * and write to an injected {@link RecordStore}. An app supplies a store over its
7
- * own backend (oxy-api over Mongo `SignedRecord`/`RepoHead`, a node over SQLite,
8
- * Mention over its own Mongo) without the engine knowing anything Oxy- or
7
+ * own backend (oxy-api over Postgres `signed_records`/`repo_heads`, a node over
8
+ * SQLite, Mention over its own database) without the engine knowing anything Oxy- or
9
9
  * app-specific.
10
10
  *
11
11
  * All methods are **subject-keyed**: `subject` is the chain's subject DID
@@ -17,7 +17,7 @@
17
17
  * `append` MUST be atomic (record insert + head advance in one unit) and MUST
18
18
  * translate a duplicate-key collision on the unique `(subject, seq)` /
19
19
  * `recordId` index — i.e. a concurrent writer that already took this `seq` — into
20
- * `{ ok: false, reason: 'chain_conflict' }` (Mongo E11000 / SQLite
20
+ * `{ ok: false, reason: 'chain_conflict' }` (Postgres `23505` / SQLite
21
21
  * `SQLITE_CONSTRAINT`). That is the real multi-writer race guard; the engine's
22
22
  * pre-append continuity check is only the fast-path rejection.
23
23
  */
@@ -10,7 +10,8 @@
10
10
  * - the {@link RecordStore} owns the monotonicity frontier + the chain head.
11
11
  *
12
12
  * The ordered checks (first failure wins):
13
- * 1. **shape** — the base `signedRecordEnvelopeSchema` (open `type`, opaque
13
+ * 1. **shape** — the base `signedRecordEnvelopeCheck`, the Zod-free twin of
14
+ * `signedRecordEnvelopeSchema` (open `type`, opaque
14
15
  * `record`). An app re-narrows `type` to its own set in its adapter.
15
16
  * 2. **signature** — `verifyEnvelopeSignature` recomputes the canonical signing
16
17
  * input and checks the secp256k1 signature against the embedded `publicKey`.
@@ -24,7 +25,7 @@
24
25
  * subject?") is intentionally NOT here — it is an adapter-policy decision the
25
26
  * caller makes before invoking the engine, not a property of the envelope.
26
27
  */
27
- import { type SignedRecordEnvelope } from '@oxy.so/contracts';
28
+ import type { SignedRecordEnvelope } from '@oxy.so/contracts';
28
29
  import { type VerificationMethodResolver } from '../identity/resolver';
29
30
  import type { RecordStore } from './recordStore';
30
31
  import type { VerifyOutcome } from './types';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `@oxy.so/protocol/sign` — message and envelope signatures (secp256k1).
3
+ *
4
+ * The same functions the root entry exports, on their own entry so a client
5
+ * can load the signer (and `@noble/curves` under it) where it signs — inside
6
+ * `@oxy.so/core/crypto`, which apps load on first use — while the root entry
7
+ * it imports at startup for hashing and platform loaders stays free of the
8
+ * curve library once a bundler drops the root's unused re-exports.
9
+ */
10
+ export { signEnvelope, signMessage, verifyEnvelopeSignature, verifySignature, } from './envelope/sign';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Oxy Protocol — the app-agnostic base substrate: signed-record envelope, canonical JSON, signature/verification, and platform crypto. Reused by any Oxy app to decentralize its own content.",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -15,6 +15,9 @@
15
15
  ],
16
16
  "random": [
17
17
  "dist/types/random.d.ts"
18
+ ],
19
+ "sign": [
20
+ "dist/types/sign.d.ts"
18
21
  ]
19
22
  }
20
23
  },
@@ -71,6 +74,18 @@
71
74
  },
72
75
  "default": "./dist/esm/random.js"
73
76
  },
77
+ "./sign": {
78
+ "react-native": "./dist/esm/sign.js",
79
+ "import": {
80
+ "types": "./dist/types/sign.d.ts",
81
+ "default": "./dist/esm/sign.js"
82
+ },
83
+ "require": {
84
+ "types": "./dist/types/sign.d.ts",
85
+ "default": "./dist/cjs/sign.js"
86
+ },
87
+ "default": "./dist/esm/sign.js"
88
+ },
74
89
  "./package.json": "./package.json"
75
90
  },
76
91
  "react-native": {
@@ -131,7 +146,7 @@
131
146
  },
132
147
  "dependencies": {
133
148
  "@noble/curves": "^1.9.7",
134
- "@oxy.so/contracts": "^4.9.0",
149
+ "@oxy.so/contracts": "^4.17.0",
135
150
  "zod": "^3.25.64"
136
151
  },
137
152
  "peerDependencies": {
@@ -156,14 +171,13 @@
156
171
  }
157
172
  },
158
173
  "devDependencies": {
159
- "@biomejs/biome": "^1.9.4",
160
174
  "@react-native-async-storage/async-storage": "^2.2.0",
161
175
  "@types/express": "^4.17.25",
162
176
  "@types/node": "^22.20.1",
163
177
  "@types/supertest": "^6.0.2",
164
178
  "expo-crypto": "~56.0.3",
165
179
  "expo-modules-core": "~57.0.17",
166
- "expo-secure-store": "~56.0.4",
180
+ "expo-secure-store": "~57.0.4",
167
181
  "express": "^4.22.2",
168
182
  "jest": "^30.5.1",
169
183
  "release-it": "^19.0.6",
@@ -26,9 +26,7 @@ describe('canonicalize', () => {
26
26
  z: { y: 1, x: 2 },
27
27
  a: { c: 3, b: { e: 5, d: 4 } },
28
28
  };
29
- expect(canonicalize(value)).toBe(
30
- '{"a":{"b":{"d":4,"e":5},"c":3},"z":{"x":2,"y":1}}',
31
- );
29
+ expect(canonicalize(value)).toBe('{"a":{"b":{"d":4,"e":5},"c":3},"z":{"x":2,"y":1}}');
32
30
  });
33
31
 
34
32
  it('is order-insensitive across deep nesting', () => {
@@ -91,9 +89,7 @@ describe('canonicalize', () => {
91
89
  it('respects toJSON (Date and its ISO string canonicalize identically)', () => {
92
90
  const date = new Date('2026-06-26T00:00:00.000Z');
93
91
  expect(canonicalize(date)).toBe(JSON.stringify(date.toISOString()));
94
- expect(canonicalize({ at: date })).toBe(
95
- canonicalize({ at: '2026-06-26T00:00:00.000Z' }),
96
- );
92
+ expect(canonicalize({ at: date })).toBe(canonicalize({ at: '2026-06-26T00:00:00.000Z' }));
97
93
  });
98
94
  });
99
95
 
@@ -80,11 +80,22 @@ function stubStore(overrides: Partial<RecordStore> = {}): RecordStore {
80
80
  const FAR_FUTURE_NOW = 1_700_000_000_000 + 1_000;
81
81
 
82
82
  describe('checkContinuity', () => {
83
- const head = (headRecordId: string, seq: number): ChainHead => ({ headRecordId, seq, recordCount: seq + 1 });
83
+ const head = (headRecordId: string, seq: number): ChainHead => ({
84
+ headRecordId,
85
+ seq,
86
+ recordCount: seq + 1,
87
+ });
84
88
 
85
89
  it('accepts a v1 record (unchained) regardless of head', async () => {
86
90
  const env = await signEnvelope(
87
- { version: 1, type: 'identity', subject: SUBJECT, issuer: SUBJECT, record: { a: 1 }, issuedAt: 1 },
91
+ {
92
+ version: 1,
93
+ type: 'identity',
94
+ subject: SUBJECT,
95
+ issuer: SUBJECT,
96
+ record: { a: 1 },
97
+ issuedAt: 1,
98
+ },
88
99
  PRIVATE_KEY,
89
100
  );
90
101
  expect(checkContinuity(head('a'.repeat(64), 3), env)).toEqual({ ok: true });
@@ -102,7 +113,10 @@ describe('checkContinuity', () => {
102
113
 
103
114
  it('rejects a wrong prev as chain_fork', async () => {
104
115
  const env = await signEnvelope(v2Fields({ seq: 1, prev: 'b'.repeat(64) }), PRIVATE_KEY);
105
- expect(checkContinuity(head('a'.repeat(64), 0), env)).toEqual({ ok: false, reason: 'chain_fork' });
116
+ expect(checkContinuity(head('a'.repeat(64), 0), env)).toEqual({
117
+ ok: false,
118
+ reason: 'chain_fork',
119
+ });
106
120
  });
107
121
 
108
122
  it('rejects a seq gap (correct prev) as bad_seq', async () => {
@@ -157,7 +171,9 @@ describe('isAuthorizedKey', () => {
157
171
  describe('verifyEnvelope', () => {
158
172
  it('accepts a fresh, well-signed genesis record', async () => {
159
173
  const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
160
- await expect(verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW })).resolves.toEqual({
174
+ await expect(
175
+ verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW }),
176
+ ).resolves.toEqual({
161
177
  ok: true,
162
178
  });
163
179
  });
@@ -186,7 +202,9 @@ describe('verifyEnvelope', () => {
186
202
  const stripped = { ...env };
187
203
  delete (stripped as Record<string, unknown>)[field];
188
204
  await expect(
189
- verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), stripped as SignedRecordEnvelope, { now: FAR_FUTURE_NOW }),
205
+ verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), stripped as SignedRecordEnvelope, {
206
+ now: FAR_FUTURE_NOW,
207
+ }),
190
208
  ).resolves.toEqual({ ok: false, reason: 'invalid_envelope' });
191
209
  },
192
210
  );
@@ -203,7 +221,9 @@ describe('verifyEnvelope', () => {
203
221
  it('rejects an unauthorized key as public_key_not_a_current_verification_method', async () => {
204
222
  const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
205
223
  await expect(
206
- verifyEnvelope(stubStore(), resolver({ currentPublicKeys: ['cafe'] }), env, { now: FAR_FUTURE_NOW }),
224
+ verifyEnvelope(stubStore(), resolver({ currentPublicKeys: ['cafe'] }), env, {
225
+ now: FAR_FUTURE_NOW,
226
+ }),
207
227
  ).resolves.toEqual({ ok: false, reason: 'public_key_not_a_current_verification_method' });
208
228
  });
209
229
 
@@ -224,7 +244,9 @@ describe('verifyEnvelope', () => {
224
244
  it('rejects an issuedAt not newer than the latest stored record as stale_issued_at', async () => {
225
245
  const env = await signEnvelope(v2Fields({ issuedAt: 1_700_000_000_000 }), PRIVATE_KEY);
226
246
  const store = stubStore({ latestIssuedAtForKey: async () => 1_700_000_000_000 });
227
- await expect(verifyEnvelope(store, resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW })).resolves.toEqual({
247
+ await expect(
248
+ verifyEnvelope(store, resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW }),
249
+ ).resolves.toEqual({
228
250
  ok: false,
229
251
  reason: 'stale_issued_at',
230
252
  });
@@ -249,7 +271,9 @@ describe('verifyAndAppend', () => {
249
271
  }),
250
272
  );
251
273
  const store = stubStore({ append });
252
- const result = await verifyAndAppend(store, resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW });
274
+ const result = await verifyAndAppend(store, resolver(SELF_RESOLVED), env, {
275
+ now: FAR_FUTURE_NOW,
276
+ });
253
277
  expect(result.ok).toBe(true);
254
278
  expect(append).toHaveBeenCalledTimes(1);
255
279
  const [subject, passedEnv, recordId] = append.mock.calls[0];
@@ -260,10 +284,15 @@ describe('verifyAndAppend', () => {
260
284
  });
261
285
 
262
286
  it('does NOT append when verification fails', async () => {
263
- const tampered = { ...(await signEnvelope(v2Fields(), PRIVATE_KEY)), record: { x: 'tampered' } };
287
+ const tampered = {
288
+ ...(await signEnvelope(v2Fields(), PRIVATE_KEY)),
289
+ record: { x: 'tampered' },
290
+ };
264
291
  const append = jest.fn();
265
292
  const store = stubStore({ append });
266
- const result = await verifyAndAppend(store, resolver(SELF_RESOLVED), tampered, { now: FAR_FUTURE_NOW });
293
+ const result = await verifyAndAppend(store, resolver(SELF_RESOLVED), tampered, {
294
+ now: FAR_FUTURE_NOW,
295
+ });
267
296
  expect(result).toEqual({ ok: false, reason: 'bad_signature' });
268
297
  expect(append).not.toHaveBeenCalled();
269
298
  });
@@ -271,7 +300,9 @@ describe('verifyAndAppend', () => {
271
300
  it('surfaces the store chain_conflict backstop', async () => {
272
301
  const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
273
302
  const store = stubStore({ append: async () => ({ ok: false, reason: 'chain_conflict' }) });
274
- await expect(verifyAndAppend(store, resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW })).resolves.toEqual({
303
+ await expect(
304
+ verifyAndAppend(store, resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW }),
305
+ ).resolves.toEqual({
275
306
  ok: false,
276
307
  reason: 'chain_conflict',
277
308
  });
@@ -9,7 +9,9 @@
9
9
  const PUBLIC_KEY = `04${'ab'.repeat(64)}`;
10
10
 
11
11
  function mockNative(native: unknown): void {
12
- jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => native }), { virtual: true });
12
+ jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => native }), {
13
+ virtual: true,
14
+ });
13
15
  }
14
16
 
15
17
  async function loadBridge() {
@@ -102,7 +104,10 @@ describe('tweakAddSecp256k1PrivateKey', () => {
102
104
  expect(() => tweakAddSecp256k1PrivateKey(nMinusOne, `${'0'.repeat(63)}1`)).toThrow('zero');
103
105
  // A tweak at or above n is invalid.
104
106
  expect(() =>
105
- tweakAddSecp256k1PrivateKey('01', 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141'),
107
+ tweakAddSecp256k1PrivateKey(
108
+ '01',
109
+ 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
110
+ ),
106
111
  ).toThrow('curve order');
107
112
  expect(() => tweakAddSecp256k1PrivateKey('01', 'zz')).toThrow();
108
113
  });
@@ -19,7 +19,12 @@ function didDoc(overrides: Partial<DidDocument> = {}): DidDocument {
19
19
  id: SUBJECT,
20
20
  controller: [SUBJECT],
21
21
  verificationMethod: [
22
- { id: `${SUBJECT}#key-1`, type: 'EcdsaSecp256k1VerificationKey2019', controller: SUBJECT, publicKeyHex: KEY_A },
22
+ {
23
+ id: `${SUBJECT}#key-1`,
24
+ type: 'EcdsaSecp256k1VerificationKey2019',
25
+ controller: SUBJECT,
26
+ publicKeyHex: KEY_A,
27
+ },
23
28
  ],
24
29
  authentication: [`${SUBJECT}#key-1`],
25
30
  assertionMethod: [`${SUBJECT}#key-1`],
@@ -50,7 +55,9 @@ describe('didWebToUrl', () => {
50
55
  });
51
56
 
52
57
  it('decodes a %3A port in the host segment', () => {
53
- expect(didWebToUrl('did:web:localhost%3A3000')).toBe('https://localhost:3000/.well-known/did.json');
58
+ expect(didWebToUrl('did:web:localhost%3A3000')).toBe(
59
+ 'https://localhost:3000/.well-known/did.json',
60
+ );
54
61
  });
55
62
 
56
63
  it('returns null for a non-did:web identifier', () => {
@@ -75,8 +82,18 @@ describe('createDidWebResolver', () => {
75
82
  it('collects every assertionMethod key (deduped)', async () => {
76
83
  const doc = didDoc({
77
84
  verificationMethod: [
78
- { id: `${SUBJECT}#key-1`, type: 'EcdsaSecp256k1VerificationKey2019', controller: SUBJECT, publicKeyHex: KEY_A },
79
- { id: `${SUBJECT}#key-2`, type: 'EcdsaSecp256k1VerificationKey2019', controller: SUBJECT, publicKeyHex: KEY_B },
85
+ {
86
+ id: `${SUBJECT}#key-1`,
87
+ type: 'EcdsaSecp256k1VerificationKey2019',
88
+ controller: SUBJECT,
89
+ publicKeyHex: KEY_A,
90
+ },
91
+ {
92
+ id: `${SUBJECT}#key-2`,
93
+ type: 'EcdsaSecp256k1VerificationKey2019',
94
+ controller: SUBJECT,
95
+ publicKeyHex: KEY_B,
96
+ },
80
97
  ],
81
98
  assertionMethod: [`${SUBJECT}#key-1`, `${SUBJECT}#key-2`],
82
99
  });
@@ -90,8 +107,18 @@ describe('createDidWebResolver', () => {
90
107
  // the hex signing key only.
91
108
  const doc = didDoc({
92
109
  verificationMethod: [
93
- { id: `${SUBJECT}#key-1`, type: 'EcdsaSecp256k1VerificationKey2019', controller: SUBJECT, publicKeyHex: KEY_A },
94
- { id: `${SUBJECT}#atproto`, type: 'Multikey', controller: SUBJECT, publicKeyMultibase: 'zQ3shFakeMultibaseKey' },
110
+ {
111
+ id: `${SUBJECT}#key-1`,
112
+ type: 'EcdsaSecp256k1VerificationKey2019',
113
+ controller: SUBJECT,
114
+ publicKeyHex: KEY_A,
115
+ },
116
+ {
117
+ id: `${SUBJECT}#atproto`,
118
+ type: 'Multikey',
119
+ controller: SUBJECT,
120
+ publicKeyMultibase: 'zQ3shFakeMultibaseKey',
121
+ },
95
122
  ],
96
123
  authentication: [`${SUBJECT}#key-1`, `${SUBJECT}#atproto`],
97
124
  assertionMethod: [`${SUBJECT}#key-1`, `${SUBJECT}#atproto`],
@@ -113,7 +140,9 @@ describe('createDidWebResolver', () => {
113
140
  });
114
141
 
115
142
  it('returns null when the document id does not match the subject', async () => {
116
- const resolver = createDidWebResolver(async () => jsonResponse(didDoc({ id: 'did:web:other.example' })));
143
+ const resolver = createDidWebResolver(async () =>
144
+ jsonResponse(didDoc({ id: 'did:web:other.example' })),
145
+ );
117
146
  expect(await resolver.resolve(SUBJECT)).toBeNull();
118
147
  });
119
148
 
@@ -91,7 +91,11 @@ describe('createNodeApp', () => {
91
91
  const empty = await request(app).get('/oxy/head');
92
92
  expect(empty.body).toEqual({ seq: null, headRecordId: null, recordCount: 0 });
93
93
 
94
- const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
94
+ const envelope = await buildSignedEnvelope({
95
+ privateKey: owner.privateKey,
96
+ seq: 0,
97
+ prev: null,
98
+ });
95
99
  const recordId = await computeRecordId(envelope);
96
100
  const write = await request(app).post('/records').send(envelope);
97
101
  expect(write.status).toBe(201);
@@ -107,7 +111,12 @@ describe('createNodeApp', () => {
107
111
  const genesisId = await computeRecordId(genesis);
108
112
  await request(app).post('/records').send(genesis).expect(201);
109
113
 
110
- const second = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 1, prev: genesisId, record: { step: 2 } });
114
+ const second = await buildSignedEnvelope({
115
+ privateKey: owner.privateKey,
116
+ seq: 1,
117
+ prev: genesisId,
118
+ record: { step: 2 },
119
+ });
111
120
  const secondId = await computeRecordId(second);
112
121
  const res = await request(app).post('/records').send(second);
113
122
  expect(res.status).toBe(201);
@@ -117,7 +126,11 @@ describe('createNodeApp', () => {
117
126
  it('POST /records rejects a record signed by a NON-owner key (403 not_owner)', async () => {
118
127
  const { app } = buildApp(owner);
119
128
  const attacker = generateKeyPair();
120
- const envelope = await buildSignedEnvelope({ privateKey: attacker.privateKey, seq: 0, prev: null });
129
+ const envelope = await buildSignedEnvelope({
130
+ privateKey: attacker.privateKey,
131
+ seq: 0,
132
+ prev: null,
133
+ });
121
134
  const res = await request(app).post('/records').send(envelope);
122
135
  expect(res.status).toBe(403);
123
136
  expect(res.body).toEqual({ error: 'not_owner' });
@@ -132,7 +145,11 @@ describe('createNodeApp', () => {
132
145
 
133
146
  it('POST /records rejects a chain gap (422 chain_gap)', async () => {
134
147
  const { app } = buildApp(owner);
135
- const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 3, prev: null });
148
+ const envelope = await buildSignedEnvelope({
149
+ privateKey: owner.privateKey,
150
+ seq: 3,
151
+ prev: null,
152
+ });
136
153
  const res = await request(app).post('/records').send(envelope);
137
154
  expect(res.status).toBe(422);
138
155
  expect(res.body).toEqual({ error: 'chain_gap' });
@@ -162,8 +179,14 @@ describe('createNodeApp', () => {
162
179
  it('POST /sync/push verifies + appends a batch in order', async () => {
163
180
  const { app } = buildApp(owner);
164
181
  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] });
182
+ const second = await buildSignedEnvelope({
183
+ privateKey: owner.privateKey,
184
+ seq: 1,
185
+ prev: await computeRecordId(genesis),
186
+ });
187
+ const res = await request(app)
188
+ .post('/sync/push')
189
+ .send({ records: [genesis, second] });
167
190
  expect(res.status).toBe(200);
168
191
  expect(res.body.accepted).toBe(2);
169
192
  expect(res.body.results.every((r: { ok: boolean }) => r.ok)).toBe(true);
@@ -174,7 +197,12 @@ describe('createNodeApp', () => {
174
197
  let prev: string | null = null;
175
198
  const ids: string[] = [];
176
199
  for (let seq = 0; seq < 4; seq += 1) {
177
- const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq, prev, record: { seq } });
200
+ const envelope = await buildSignedEnvelope({
201
+ privateKey: owner.privateKey,
202
+ seq,
203
+ prev,
204
+ record: { seq },
205
+ });
178
206
  const id = await computeRecordId(envelope);
179
207
  await request(app).post('/records').send(envelope).expect(201);
180
208
  ids.push(id);
@@ -193,7 +221,9 @@ describe('createNodeApp', () => {
193
221
  const sinceCursor = await request(app).get('/oxy/log').query({ since: ids[1] });
194
222
  expect(sinceCursor.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([2, 3]);
195
223
 
196
- const unknownCursor = await request(app).get('/oxy/log').query({ since: 'f'.repeat(64) });
224
+ const unknownCursor = await request(app)
225
+ .get('/oxy/log')
226
+ .query({ since: 'f'.repeat(64) });
197
227
  expect(unknownCursor.body.records).toEqual([]);
198
228
  });
199
229
 
@@ -215,11 +245,14 @@ describe('createNodeApp', () => {
215
245
  expect(put.status).toBe(201);
216
246
  expect(put.body).toEqual({ hash, size: bytes.length });
217
247
 
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
- });
248
+ const get = await request(app)
249
+ .get(`/blobs/${hash}`)
250
+ .buffer(true)
251
+ .parse((res, cb) => {
252
+ const chunks: Buffer[] = [];
253
+ res.on('data', (chunk: Buffer) => chunks.push(chunk));
254
+ res.on('end', () => cb(null, Buffer.concat(chunks)));
255
+ });
223
256
  expect(get.status).toBe(200);
224
257
  expect(Buffer.isBuffer(get.body) ? get.body : Buffer.from(get.body)).toEqual(bytes);
225
258
  });
@@ -269,7 +302,9 @@ describe('createNodeApp', () => {
269
302
  const auth = await signBlobPin(hash, owner);
270
303
 
271
304
  const server = createServer(app);
272
- await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve); });
305
+ await new Promise<void>((resolve) => {
306
+ server.listen(0, '127.0.0.1', resolve);
307
+ });
273
308
  const { port } = server.address() as AddressInfo;
274
309
 
275
310
  const raw = await new Promise<string>((resolve, reject) => {
@@ -284,12 +319,14 @@ describe('createNodeApp', () => {
284
319
  'Connection: close',
285
320
  '',
286
321
  '',
287
- ].join('\r\n')
322
+ ].join('\r\n'),
288
323
  );
289
324
  });
290
325
  let received = '';
291
326
  socket.setEncoding('utf8');
292
- socket.on('data', (chunk: string) => { received += chunk; });
327
+ socket.on('data', (chunk: string) => {
328
+ received += chunk;
329
+ });
293
330
  socket.on('error', reject);
294
331
  socket.on('end', () => resolve(received));
295
332
  });
@@ -360,7 +397,11 @@ describe('createNodeApp', () => {
360
397
  // within the window is rejected 429 BEFORE the handler runs, regardless of
361
398
  // each request's chain outcome.
362
399
  const { app } = buildApp(owner, [], { windowMs: 60_000, max: 2 });
363
- const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
400
+ const envelope = await buildSignedEnvelope({
401
+ privateKey: owner.privateKey,
402
+ seq: 0,
403
+ prev: null,
404
+ });
364
405
 
365
406
  const first = await request(app).post('/records').send(envelope);
366
407
  expect(first.status).toBe(201);
@@ -379,7 +420,12 @@ describe('createNodeApp', () => {
379
420
  let prev: string | null = null;
380
421
  const ids: string[] = [];
381
422
  for (let seq = 0; seq < 3; seq += 1) {
382
- const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq, prev, record: { seq } });
423
+ const envelope = await buildSignedEnvelope({
424
+ privateKey: owner.privateKey,
425
+ seq,
426
+ prev,
427
+ record: { seq },
428
+ });
383
429
  const id = await computeRecordId(envelope);
384
430
  await request(app).post('/records').send(envelope).expect(201);
385
431
  ids.push(id);
@@ -388,7 +434,9 @@ describe('createNodeApp', () => {
388
434
 
389
435
  // `?since[]=<id1>&since[]=<id2>` arrives as a string[] — the handler must
390
436
  // 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]] });
437
+ const res = await request(app)
438
+ .get('/oxy/log')
439
+ .query({ 'since[]': [ids[0], ids[2]] });
392
440
  expect(res.status).toBe(200);
393
441
  // Cursor resolves off ids[0] → records strictly after seq 0 → [1, 2].
394
442
  expect(res.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([1, 2]);
@@ -398,12 +446,19 @@ describe('createNodeApp', () => {
398
446
  const { app } = buildApp(owner);
399
447
  let prev: string | null = null;
400
448
  for (let seq = 0; seq < 4; seq += 1) {
401
- const envelope = await buildSignedEnvelope({ privateKey: owner.privateKey, seq, prev, record: { seq } });
449
+ const envelope = await buildSignedEnvelope({
450
+ privateKey: owner.privateKey,
451
+ seq,
452
+ prev,
453
+ record: { seq },
454
+ });
402
455
  await request(app).post('/records').send(envelope).expect(201);
403
456
  prev = await computeRecordId(envelope);
404
457
  }
405
458
 
406
- const res = await request(app).get('/oxy/log').query({ 'limit[]': ['2', '99'] });
459
+ const res = await request(app)
460
+ .get('/oxy/log')
461
+ .query({ 'limit[]': ['2', '99'] });
407
462
  expect(res.status).toBe(200);
408
463
  expect(res.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([0, 1]);
409
464
  });
@@ -71,7 +71,9 @@ describe('trimTrailingSlashes', () => {
71
71
  });
72
72
 
73
73
  it('preserves interior slashes and the empty string', () => {
74
- expect(trimTrailingSlashes('https://node.example/oxy/log')).toBe('https://node.example/oxy/log');
74
+ expect(trimTrailingSlashes('https://node.example/oxy/log')).toBe(
75
+ 'https://node.example/oxy/log',
76
+ );
75
77
  expect(trimTrailingSlashes('')).toBe('');
76
78
  expect(trimTrailingSlashes('///')).toBe('');
77
79
  });
@@ -127,7 +129,11 @@ describe('NodeClient (end-to-end against createNodeApp)', () => {
127
129
  const genesisId = await computeRecordId(genesis);
128
130
  await client.writeRecord(genesis);
129
131
 
130
- const second = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 1, prev: genesisId });
132
+ const second = await buildSignedEnvelope({
133
+ privateKey: owner.privateKey,
134
+ seq: 1,
135
+ prev: genesisId,
136
+ });
131
137
  const secondId = await computeRecordId(second);
132
138
  expect(await client.writeRecord(second)).toEqual({ recordId: secondId, seq: 1 });
133
139
 
@@ -143,7 +149,10 @@ describe('NodeClient (end-to-end against createNodeApp)', () => {
143
149
 
144
150
  it('writeRecord surfaces a chain gap as a NodeClientError (422 chain_gap)', async () => {
145
151
  const gap = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 3, prev: null });
146
- await expect(client.writeRecord(gap)).rejects.toMatchObject({ status: 422, reason: 'chain_gap' });
152
+ await expect(client.writeRecord(gap)).rejects.toMatchObject({
153
+ status: 422,
154
+ reason: 'chain_gap',
155
+ });
147
156
  await expect(client.writeRecord(gap)).rejects.toBeInstanceOf(NodeClientError);
148
157
  });
149
158
 
@@ -151,14 +160,28 @@ describe('NodeClient (end-to-end against createNodeApp)', () => {
151
160
  const genesis = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
152
161
  await client.writeRecord(genesis);
153
162
 
154
- const fork = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 1, prev: 'f'.repeat(64) });
155
- await expect(client.writeRecord(fork)).rejects.toMatchObject({ status: 422, reason: 'chain_fork' });
163
+ const fork = await buildSignedEnvelope({
164
+ privateKey: owner.privateKey,
165
+ seq: 1,
166
+ prev: 'f'.repeat(64),
167
+ });
168
+ await expect(client.writeRecord(fork)).rejects.toMatchObject({
169
+ status: 422,
170
+ reason: 'chain_fork',
171
+ });
156
172
  });
157
173
 
158
174
  it('writeRecord surfaces a non-owner write as a NodeClientError (403 not_owner)', async () => {
159
175
  const attacker = generateKeyPair();
160
- const envelope = await buildSignedEnvelope({ privateKey: attacker.privateKey, seq: 0, prev: null });
161
- await expect(client.writeRecord(envelope)).rejects.toMatchObject({ status: 403, reason: 'not_owner' });
176
+ const envelope = await buildSignedEnvelope({
177
+ privateKey: attacker.privateKey,
178
+ seq: 0,
179
+ prev: null,
180
+ });
181
+ await expect(client.writeRecord(envelope)).rejects.toMatchObject({
182
+ status: 403,
183
+ reason: 'not_owner',
184
+ });
162
185
  });
163
186
 
164
187
  it('putBlob then getBlob round-trips; getBlob returns null when absent', async () => {
@@ -95,14 +95,18 @@ export function createInMemoryNodeStore(): RecordStore & BlobStore {
95
95
  },
96
96
  async resolveCursorSeq(_subject, recordId): Promise<number | null> {
97
97
  const found = records.find((r) => r.recordId === recordId);
98
- return found ? found.env.seq ?? -1 : null;
98
+ return found ? (found.env.seq ?? -1) : null;
99
99
  },
100
100
  async materializeCurrent(_subject, collection, rkey): Promise<SignedRecordEnvelope | null> {
101
- const matching = records.filter((r) => r.env.collection === collection && r.env.rkey === rkey);
101
+ const matching = records.filter(
102
+ (r) => r.env.collection === collection && r.env.rkey === rkey,
103
+ );
102
104
  return matching.length ? matching[matching.length - 1].env : null;
103
105
  },
104
106
  async latestIssuedAtForKey(_subject, env): Promise<number | null> {
105
- const matching = records.filter((r) => r.env.collection === env.collection && r.env.rkey === env.rkey);
107
+ const matching = records.filter(
108
+ (r) => r.env.collection === env.collection && r.env.rkey === env.rkey,
109
+ );
106
110
  return matching.length ? matching[matching.length - 1].env.issuedAt : null;
107
111
  },
108
112
  async putBlob(hash, bytes): Promise<void> {