@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/chain/engine.js +1 -1
- package/dist/cjs/chain/recordStore.js +3 -3
- package/dist/cjs/chain/verify.js +8 -4
- package/dist/cjs/envelope/signingInput.js +12 -1
- package/dist/cjs/identity/resolver.js +2 -1
- package/dist/cjs/node/didWebResolver.js +1 -3
- package/dist/cjs/node/nodeApp.js +9 -3
- package/dist/cjs/node/nodeClient.js +7 -2
- package/dist/cjs/platform/platform.js +1 -1
- package/dist/cjs/secp256k1.js +25 -26
- package/dist/cjs/sign.js +17 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/chain/engine.js +1 -1
- package/dist/esm/chain/recordStore.js +3 -3
- package/dist/esm/chain/verify.js +8 -4
- package/dist/esm/envelope/sign.js +1 -1
- package/dist/esm/envelope/signingInput.js +12 -1
- package/dist/esm/identity/resolver.js +2 -1
- package/dist/esm/node/didWebResolver.js +1 -3
- package/dist/esm/node/nodeApp.js +9 -3
- package/dist/esm/node/nodeClient.js +8 -3
- package/dist/esm/platform/platform.js +1 -1
- package/dist/esm/secp256k1.js +26 -27
- package/dist/esm/sign.js +10 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/chain/engine.d.ts +1 -1
- package/dist/types/chain/recordStore.d.ts +3 -3
- package/dist/types/chain/verify.d.ts +3 -2
- package/dist/types/sign.d.ts +10 -0
- package/package.json +18 -4
- package/src/__tests__/canonicalJson.test.ts +2 -6
- package/src/__tests__/chain.test.ts +42 -11
- package/src/__tests__/commonsIdentityBridge.test.ts +7 -2
- package/src/__tests__/didWebResolver.test.ts +36 -7
- package/src/__tests__/nodeApp.test.ts +76 -21
- package/src/__tests__/nodeClient.test.ts +30 -7
- package/src/__tests__/nodeHarness.ts +7 -3
- package/src/__tests__/optionalNativePeers.test.ts +8 -6
- package/src/__tests__/rateLimit.test.ts +2 -1
- package/src/__tests__/registrationPow.test.ts +35 -37
- package/src/__tests__/secp256k1.test.ts +41 -70
- package/src/__tests__/transparency.test.ts +4 -1
- package/src/auth/registrationPow.ts +5 -1
- package/src/chain/engine.ts +1 -1
- package/src/chain/recordStore.ts +3 -3
- package/src/chain/verify.ts +11 -4
- package/src/envelope/recordId.ts +1 -4
- package/src/envelope/sign.ts +6 -6
- package/src/envelope/signingInput.ts +12 -1
- package/src/identity/resolver.ts +9 -2
- package/src/node/didWebResolver.ts +2 -7
- package/src/node/nodeApp.ts +85 -67
- package/src/node/nodeClient.ts +35 -16
- package/src/platform/crypto.native.ts +9 -2
- package/src/platform/platform.ts +3 -1
- package/src/secp256k1.ts +33 -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
|
-
*
|
|
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
|
|
8
|
-
* Mention over its own
|
|
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' }` (
|
|
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 `
|
|
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 {
|
|
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.
|
|
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.
|
|
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": "~
|
|
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 => ({
|
|
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
|
-
{
|
|
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({
|
|
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(
|
|
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, {
|
|
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, {
|
|
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(
|
|
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, {
|
|
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 = {
|
|
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, {
|
|
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(
|
|
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 }), {
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
{
|
|
79
|
-
|
|
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
|
-
{
|
|
94
|
-
|
|
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 () =>
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
166
|
-
|
|
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({
|
|
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)
|
|
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)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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) => {
|
|
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) => {
|
|
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({
|
|
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({
|
|
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)
|
|
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({
|
|
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)
|
|
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(
|
|
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({
|
|
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({
|
|
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({
|
|
155
|
-
|
|
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({
|
|
161
|
-
|
|
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(
|
|
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(
|
|
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> {
|