@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.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/dist/cjs/.tsbuildinfo +1 -0
  4. package/dist/cjs/chain/continuity.js +54 -0
  5. package/dist/cjs/chain/engine.js +34 -0
  6. package/dist/cjs/chain/recordStore.js +25 -0
  7. package/dist/cjs/chain/types.js +22 -0
  8. package/dist/cjs/chain/verify.js +82 -0
  9. package/dist/cjs/envelope/canonicalJson.js +107 -0
  10. package/dist/cjs/envelope/recordId.js +60 -0
  11. package/dist/cjs/envelope/sign.js +75 -0
  12. package/dist/cjs/envelope/signingInput.js +32 -0
  13. package/dist/cjs/identity/resolver.js +50 -0
  14. package/dist/cjs/index.js +71 -0
  15. package/dist/cjs/node/constants.js +85 -0
  16. package/dist/cjs/node/didWebResolver.js +126 -0
  17. package/dist/cjs/node/httpFetch.js +61 -0
  18. package/dist/cjs/node/index.js +71 -0
  19. package/dist/cjs/node/nodeApp.js +344 -0
  20. package/dist/cjs/node/nodeClient.js +204 -0
  21. package/dist/cjs/node/rateLimit.js +187 -0
  22. package/dist/cjs/node/verifyRecord.js +51 -0
  23. package/dist/cjs/platform/crypto.js +186 -0
  24. package/dist/cjs/platform/crypto.native.js +204 -0
  25. package/dist/cjs/platform/expoTypes.js +24 -0
  26. package/dist/cjs/platform/platform.js +33 -0
  27. package/dist/cjs/secp256k1.js +148 -0
  28. package/dist/cjs/transparency/checkpoint.js +79 -0
  29. package/dist/cjs/transparency/tree.js +197 -0
  30. package/dist/esm/.tsbuildinfo +1 -0
  31. package/dist/esm/chain/continuity.js +51 -0
  32. package/dist/esm/chain/engine.js +31 -0
  33. package/dist/esm/chain/recordStore.js +24 -0
  34. package/dist/esm/chain/types.js +19 -0
  35. package/dist/esm/chain/verify.js +78 -0
  36. package/dist/esm/envelope/canonicalJson.js +104 -0
  37. package/dist/esm/envelope/recordId.js +56 -0
  38. package/dist/esm/envelope/sign.js +69 -0
  39. package/dist/esm/envelope/signingInput.js +29 -0
  40. package/dist/esm/identity/resolver.js +47 -0
  41. package/dist/esm/index.js +36 -0
  42. package/dist/esm/node/constants.js +82 -0
  43. package/dist/esm/node/didWebResolver.js +122 -0
  44. package/dist/esm/node/httpFetch.js +55 -0
  45. package/dist/esm/node/index.js +28 -0
  46. package/dist/esm/node/nodeApp.js +336 -0
  47. package/dist/esm/node/nodeClient.js +198 -0
  48. package/dist/esm/node/rateLimit.js +182 -0
  49. package/dist/esm/node/verifyRecord.js +48 -0
  50. package/dist/esm/platform/crypto.js +145 -0
  51. package/dist/esm/platform/crypto.native.js +196 -0
  52. package/dist/esm/platform/expoTypes.js +23 -0
  53. package/dist/esm/platform/platform.js +29 -0
  54. package/dist/esm/secp256k1.js +137 -0
  55. package/dist/esm/transparency/checkpoint.js +73 -0
  56. package/dist/esm/transparency/tree.js +189 -0
  57. package/dist/types/.tsbuildinfo +1 -0
  58. package/dist/types/chain/continuity.d.ts +28 -0
  59. package/dist/types/chain/engine.d.ts +27 -0
  60. package/dist/types/chain/recordStore.d.ts +85 -0
  61. package/dist/types/chain/types.d.ts +79 -0
  62. package/dist/types/chain/verify.d.ts +45 -0
  63. package/dist/types/envelope/canonicalJson.d.ts +44 -0
  64. package/dist/types/envelope/recordId.d.ts +30 -0
  65. package/dist/types/envelope/sign.d.ts +47 -0
  66. package/dist/types/envelope/signingInput.d.ts +33 -0
  67. package/dist/types/identity/resolver.d.ts +67 -0
  68. package/dist/types/index.d.ts +32 -0
  69. package/dist/types/node/constants.d.ts +80 -0
  70. package/dist/types/node/didWebResolver.d.ts +47 -0
  71. package/dist/types/node/httpFetch.d.ts +60 -0
  72. package/dist/types/node/index.d.ts +28 -0
  73. package/dist/types/node/nodeApp.d.ts +120 -0
  74. package/dist/types/node/nodeClient.d.ts +135 -0
  75. package/dist/types/node/rateLimit.d.ts +95 -0
  76. package/dist/types/node/verifyRecord.d.ts +41 -0
  77. package/dist/types/platform/crypto.d.ts +93 -0
  78. package/dist/types/platform/crypto.native.d.ts +77 -0
  79. package/dist/types/platform/expoTypes.d.ts +99 -0
  80. package/dist/types/platform/platform.d.ts +25 -0
  81. package/dist/types/secp256k1.d.ts +45 -0
  82. package/dist/types/transparency/checkpoint.d.ts +71 -0
  83. package/dist/types/transparency/tree.d.ts +135 -0
  84. package/package.json +157 -0
  85. package/src/__tests__/canonicalJson.test.ts +116 -0
  86. package/src/__tests__/chain.test.ts +279 -0
  87. package/src/__tests__/didWebResolver.test.ts +132 -0
  88. package/src/__tests__/envelope.test.ts +267 -0
  89. package/src/__tests__/nodeApp.test.ts +410 -0
  90. package/src/__tests__/nodeClient.test.ts +177 -0
  91. package/src/__tests__/nodeHarness.ts +151 -0
  92. package/src/__tests__/optionalNativePeers.test.ts +233 -0
  93. package/src/__tests__/rateLimit.test.ts +268 -0
  94. package/src/__tests__/runnerGuard.test.ts +85 -0
  95. package/src/__tests__/secp256k1.test.ts +118 -0
  96. package/src/__tests__/transparency.test.ts +353 -0
  97. package/src/chain/continuity.ts +59 -0
  98. package/src/chain/engine.ts +43 -0
  99. package/src/chain/recordStore.ts +98 -0
  100. package/src/chain/types.ts +85 -0
  101. package/src/chain/verify.ts +102 -0
  102. package/src/envelope/canonicalJson.ts +120 -0
  103. package/src/envelope/recordId.ts +63 -0
  104. package/src/envelope/sign.ts +86 -0
  105. package/src/envelope/signingInput.ts +48 -0
  106. package/src/identity/resolver.ts +90 -0
  107. package/src/index.ts +101 -0
  108. package/src/node/constants.ts +105 -0
  109. package/src/node/didWebResolver.ts +162 -0
  110. package/src/node/httpFetch.ts +88 -0
  111. package/src/node/index.ts +87 -0
  112. package/src/node/nodeApp.ts +471 -0
  113. package/src/node/nodeClient.ts +322 -0
  114. package/src/node/rateLimit.ts +233 -0
  115. package/src/node/verifyRecord.ts +60 -0
  116. package/src/platform/crypto.native.ts +251 -0
  117. package/src/platform/crypto.ts +172 -0
  118. package/src/platform/expoTypes.ts +99 -0
  119. package/src/platform/platform.ts +31 -0
  120. package/src/secp256k1.ts +207 -0
  121. package/src/transparency/checkpoint.ts +109 -0
  122. package/src/transparency/tree.ts +258 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Canonical JSON tests.
3
+ *
4
+ * The whole point of `canonicalize` is that two structurally-equal values
5
+ * produce identical strings regardless of how their keys were ordered, so a
6
+ * client which signs and a server which verifies agree on the signing input.
7
+ * These tests pin that determinism, the array-order guarantee, the nesting
8
+ * behaviour, and the JSON value/omit semantics.
9
+ */
10
+
11
+ import { canonicalize } from '../envelope/canonicalJson';
12
+
13
+ describe('canonicalize', () => {
14
+ describe('object key ordering', () => {
15
+ it('produces identical output regardless of insertion order', () => {
16
+ const a = canonicalize({ b: 1, a: 2, c: 3 });
17
+ const b = canonicalize({ c: 3, a: 2, b: 1 });
18
+ const c = canonicalize({ a: 2, b: 1, c: 3 });
19
+ expect(a).toBe(b);
20
+ expect(b).toBe(c);
21
+ expect(a).toBe('{"a":2,"b":1,"c":3}');
22
+ });
23
+
24
+ it('sorts keys recursively at every level', () => {
25
+ const value = {
26
+ z: { y: 1, x: 2 },
27
+ a: { c: 3, b: { e: 5, d: 4 } },
28
+ };
29
+ expect(canonicalize(value)).toBe(
30
+ '{"a":{"b":{"d":4,"e":5},"c":3},"z":{"x":2,"y":1}}',
31
+ );
32
+ });
33
+
34
+ it('is order-insensitive across deep nesting', () => {
35
+ const first = canonicalize({
36
+ outer: { inner: { p: 1, q: 2 }, lead: 'x' },
37
+ meta: { issuedAt: 10, version: 1 },
38
+ });
39
+ const second = canonicalize({
40
+ meta: { version: 1, issuedAt: 10 },
41
+ outer: { lead: 'x', inner: { q: 2, p: 1 } },
42
+ });
43
+ expect(first).toBe(second);
44
+ });
45
+ });
46
+
47
+ describe('array ordering', () => {
48
+ it('preserves array element order (never sorts arrays)', () => {
49
+ expect(canonicalize([3, 1, 2])).toBe('[3,1,2]');
50
+ expect(canonicalize(['b', 'a', 'c'])).toBe('["b","a","c"]');
51
+ });
52
+
53
+ it('distinguishes arrays that differ only in order', () => {
54
+ expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
55
+ });
56
+
57
+ it('canonicalizes objects inside arrays without reordering the array', () => {
58
+ const value = [
59
+ { b: 1, a: 2 },
60
+ { d: 3, c: 4 },
61
+ ];
62
+ expect(canonicalize(value)).toBe('[{"a":2,"b":1},{"c":4,"d":3}]');
63
+ });
64
+ });
65
+
66
+ describe('primitives', () => {
67
+ it('serializes null, booleans, strings and numbers as JSON', () => {
68
+ expect(canonicalize(null)).toBe('null');
69
+ expect(canonicalize(true)).toBe('true');
70
+ expect(canonicalize(false)).toBe('false');
71
+ expect(canonicalize('hi')).toBe('"hi"');
72
+ expect(canonicalize(42)).toBe('42');
73
+ expect(canonicalize(-1.5)).toBe('-1.5');
74
+ expect(canonicalize(0)).toBe('0');
75
+ });
76
+
77
+ it('escapes strings the same way JSON does', () => {
78
+ expect(canonicalize('a"b\\c\n')).toBe(JSON.stringify('a"b\\c\n'));
79
+ });
80
+ });
81
+
82
+ describe('JSON value/omit semantics', () => {
83
+ it('omits object properties whose value is undefined', () => {
84
+ expect(canonicalize({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}');
85
+ });
86
+
87
+ it('renders undefined array elements as null (preserving length)', () => {
88
+ expect(canonicalize([1, undefined, 3])).toBe('[1,null,3]');
89
+ });
90
+
91
+ it('respects toJSON (Date and its ISO string canonicalize identically)', () => {
92
+ const date = new Date('2026-06-26T00:00:00.000Z');
93
+ expect(canonicalize(date)).toBe(JSON.stringify(date.toISOString()));
94
+ expect(canonicalize({ at: date })).toBe(
95
+ canonicalize({ at: '2026-06-26T00:00:00.000Z' }),
96
+ );
97
+ });
98
+ });
99
+
100
+ describe('rejects values outside the JSON data model', () => {
101
+ it('throws on non-finite numbers', () => {
102
+ expect(() => canonicalize(Number.NaN)).toThrow();
103
+ expect(() => canonicalize(Number.POSITIVE_INFINITY)).toThrow();
104
+ expect(() => canonicalize({ x: Number.POSITIVE_INFINITY })).toThrow();
105
+ });
106
+
107
+ it('throws on bigint', () => {
108
+ expect(() => canonicalize(BigInt(1))).toThrow();
109
+ });
110
+
111
+ it('throws on a bare undefined / function at the top level', () => {
112
+ expect(() => canonicalize(undefined)).toThrow();
113
+ expect(() => canonicalize(() => 1)).toThrow();
114
+ });
115
+ });
116
+ });
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Chain engine tests — continuity, the verification state machine, and
3
+ * verify-then-append, all driven over STUB stores + resolvers (no DB, no app
4
+ * specifics). These lock the engine behaviour the oxy-api adapter relies on:
5
+ * the same rejection reasons, the same ordering, and the `chain_conflict`
6
+ * backstop surfaced from the store.
7
+ */
8
+
9
+ import { generateSecp256k1KeyPair } from '../secp256k1';
10
+ import type { SignedRecordEnvelope } from '@oxy.so/contracts';
11
+ import {
12
+ checkContinuity,
13
+ verifyEnvelope,
14
+ verifyAndAppend,
15
+ isAuthorizedKey,
16
+ type ChainHead,
17
+ type RecordStore,
18
+ type AppendOutcome,
19
+ type ResolvedVerificationMethods,
20
+ type VerificationMethodResolver,
21
+ } from '../index';
22
+ import { signEnvelope } from '../envelope/sign';
23
+
24
+ const keyPair = generateSecp256k1KeyPair();
25
+ const PUBLIC_KEY = keyPair.publicKey;
26
+ const PRIVATE_KEY = keyPair.privateKey;
27
+
28
+ const SUBJECT = 'did:web:oxy.so:u:u1';
29
+ const CUSTODIAL_ISSUER = 'did:web:oxy.so';
30
+ const custodialKeyPair = generateSecp256k1KeyPair();
31
+ const CUSTODIAL_PUBLIC_KEY = custodialKeyPair.publicKey;
32
+ const CUSTODIAL_PRIVATE_KEY = custodialKeyPair.privateKey;
33
+
34
+ type V2Fields = Parameters<typeof signEnvelope>[0];
35
+
36
+ function v2Fields(overrides: Partial<V2Fields> = {}): V2Fields {
37
+ return {
38
+ version: 2,
39
+ type: 'app_record',
40
+ subject: SUBJECT,
41
+ issuer: SUBJECT,
42
+ record: { hello: 'world' },
43
+ issuedAt: 1_700_000_000_000,
44
+ seq: 0,
45
+ prev: null,
46
+ collection: 'app.mention.feed.post',
47
+ rkey: 'r1',
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ /** A resolver that authorizes the subject's own key + an optional custodial key. */
53
+ function resolver(resolved: ResolvedVerificationMethods | null): VerificationMethodResolver {
54
+ return { resolve: async () => resolved };
55
+ }
56
+
57
+ const SELF_RESOLVED: ResolvedVerificationMethods = {
58
+ currentPublicKeys: [PUBLIC_KEY],
59
+ custodialIssuer: CUSTODIAL_ISSUER,
60
+ custodialPublicKey: CUSTODIAL_PUBLIC_KEY,
61
+ };
62
+
63
+ /** A store stub: no chain, no prior record, append echoes a fixed outcome. */
64
+ function stubStore(overrides: Partial<RecordStore> = {}): RecordStore {
65
+ return {
66
+ getHead: async () => null,
67
+ latestIssuedAtForKey: async () => null,
68
+ append: async (_subject, env, recordId): Promise<AppendOutcome> => ({
69
+ ok: true,
70
+ recordId,
71
+ seq: typeof env.seq === 'number' ? env.seq : -1,
72
+ }),
73
+ getLogSince: async () => [],
74
+ resolveCursorSeq: async () => null,
75
+ materializeCurrent: async () => null,
76
+ ...overrides,
77
+ };
78
+ }
79
+
80
+ const FAR_FUTURE_NOW = 1_700_000_000_000 + 1_000;
81
+
82
+ describe('checkContinuity', () => {
83
+ const head = (headRecordId: string, seq: number): ChainHead => ({ headRecordId, seq, recordCount: seq + 1 });
84
+
85
+ it('accepts a v1 record (unchained) regardless of head', async () => {
86
+ const env = await signEnvelope(
87
+ { version: 1, type: 'identity', subject: SUBJECT, issuer: SUBJECT, record: { a: 1 }, issuedAt: 1 },
88
+ PRIVATE_KEY,
89
+ );
90
+ expect(checkContinuity(head('a'.repeat(64), 3), env)).toEqual({ ok: true });
91
+ });
92
+
93
+ it('accepts a genesis when there is no head', async () => {
94
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
95
+ expect(checkContinuity(null, env)).toEqual({ ok: true });
96
+ });
97
+
98
+ it('rejects a non-genesis with no head as chain_gap', async () => {
99
+ const env = await signEnvelope(v2Fields({ seq: 1, prev: 'a'.repeat(64) }), PRIVATE_KEY);
100
+ expect(checkContinuity(null, env)).toEqual({ ok: false, reason: 'chain_gap' });
101
+ });
102
+
103
+ it('rejects a wrong prev as chain_fork', async () => {
104
+ 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' });
106
+ });
107
+
108
+ it('rejects a seq gap (correct prev) as bad_seq', async () => {
109
+ const env = await signEnvelope(v2Fields({ seq: 5, prev: 'a'.repeat(64) }), PRIVATE_KEY);
110
+ expect(checkContinuity(head('a'.repeat(64), 0), env)).toEqual({ ok: false, reason: 'bad_seq' });
111
+ });
112
+
113
+ it('accepts a correct extension', async () => {
114
+ const env = await signEnvelope(v2Fields({ seq: 1, prev: 'a'.repeat(64) }), PRIVATE_KEY);
115
+ expect(checkContinuity(head('a'.repeat(64), 0), env)).toEqual({ ok: true });
116
+ });
117
+ });
118
+
119
+ describe('isAuthorizedKey', () => {
120
+ it('accepts a self-issued record signed by a current key', async () => {
121
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
122
+ expect(isAuthorizedKey(SELF_RESOLVED, env)).toEqual({ ok: true });
123
+ });
124
+
125
+ it('rejects a self-issued record whose key is not a current VM', async () => {
126
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
127
+ expect(isAuthorizedKey({ currentPublicKeys: ['cafe'] }, env)).toEqual({
128
+ ok: false,
129
+ reason: 'public_key_not_a_current_verification_method',
130
+ });
131
+ });
132
+
133
+ it('accepts a custodial record signed by the custodial key', async () => {
134
+ const env = await signEnvelope(v2Fields({ issuer: CUSTODIAL_ISSUER }), CUSTODIAL_PRIVATE_KEY);
135
+ expect(isAuthorizedKey(SELF_RESOLVED, env)).toEqual({ ok: true });
136
+ });
137
+
138
+ it('rejects a custodial record signed by the wrong key', async () => {
139
+ const env = await signEnvelope(v2Fields({ issuer: CUSTODIAL_ISSUER }), PRIVATE_KEY);
140
+ expect(isAuthorizedKey(SELF_RESOLVED, env)).toEqual({
141
+ ok: false,
142
+ reason: 'public_key_not_a_current_verification_method',
143
+ });
144
+ });
145
+
146
+ it('rejects an unknown issuer as untrusted_issuer', async () => {
147
+ const env = await signEnvelope(v2Fields({ issuer: 'did:web:evil.example' }), PRIVATE_KEY);
148
+ expect(isAuthorizedKey(SELF_RESOLVED, env)).toEqual({ ok: false, reason: 'untrusted_issuer' });
149
+ });
150
+
151
+ it('rejects when the subject cannot be resolved (null) as untrusted_issuer', async () => {
152
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
153
+ expect(isAuthorizedKey(null, env)).toEqual({ ok: false, reason: 'untrusted_issuer' });
154
+ });
155
+ });
156
+
157
+ describe('verifyEnvelope', () => {
158
+ it('accepts a fresh, well-signed genesis record', async () => {
159
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
160
+ await expect(verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW })).resolves.toEqual({
161
+ ok: true,
162
+ });
163
+ });
164
+
165
+ it('rejects a malformed envelope as invalid_envelope', async () => {
166
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
167
+ // v2 envelope missing its required chain `seq` fails the base schema.
168
+ const malformed = { ...env, seq: undefined } as unknown as SignedRecordEnvelope;
169
+ await expect(verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), malformed)).resolves.toEqual({
170
+ ok: false,
171
+ reason: 'invalid_envelope',
172
+ });
173
+ });
174
+
175
+ // A v2 envelope MUST carry seq/collection/rkey. The base schema enforces this,
176
+ // and the engine has a defense-in-depth guard (verify.ts) that re-checks them
177
+ // AFTER the signature check. These lock the observable contract: a properly
178
+ // SIGNED v2 envelope with a required chain field stripped is rejected
179
+ // `invalid_envelope` — the engine never appends it on ANY missing field.
180
+ it.each(['seq', 'collection', 'rkey'] as const)(
181
+ 'rejects a signed v2 envelope missing `%s` as invalid_envelope',
182
+ async (field) => {
183
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
184
+ // Strip the field AFTER signing, so the envelope's signature is still
185
+ // internally valid — the rejection is purely the missing-chain-field guard.
186
+ const stripped = { ...env };
187
+ delete (stripped as Record<string, unknown>)[field];
188
+ await expect(
189
+ verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), stripped as SignedRecordEnvelope, { now: FAR_FUTURE_NOW }),
190
+ ).resolves.toEqual({ ok: false, reason: 'invalid_envelope' });
191
+ },
192
+ );
193
+
194
+ it('rejects a tampered record as bad_signature', async () => {
195
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
196
+ const tampered: SignedRecordEnvelope = { ...env, record: { hello: 'tampered' } };
197
+ await expect(verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), tampered)).resolves.toEqual({
198
+ ok: false,
199
+ reason: 'bad_signature',
200
+ });
201
+ });
202
+
203
+ it('rejects an unauthorized key as public_key_not_a_current_verification_method', async () => {
204
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
205
+ await expect(
206
+ verifyEnvelope(stubStore(), resolver({ currentPublicKeys: ['cafe'] }), env, { now: FAR_FUTURE_NOW }),
207
+ ).resolves.toEqual({ ok: false, reason: 'public_key_not_a_current_verification_method' });
208
+ });
209
+
210
+ it('rejects an untrusted issuer', async () => {
211
+ const env = await signEnvelope(v2Fields({ issuer: 'did:web:evil.example' }), PRIVATE_KEY);
212
+ await expect(
213
+ verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW }),
214
+ ).resolves.toEqual({ ok: false, reason: 'untrusted_issuer' });
215
+ });
216
+
217
+ it('rejects an issuedAt too far in the future', async () => {
218
+ const env = await signEnvelope(v2Fields({ issuedAt: 2_000_000_000_000 }), PRIVATE_KEY);
219
+ await expect(
220
+ verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW }),
221
+ ).resolves.toEqual({ ok: false, reason: 'issued_in_future' });
222
+ });
223
+
224
+ it('rejects an issuedAt not newer than the latest stored record as stale_issued_at', async () => {
225
+ const env = await signEnvelope(v2Fields({ issuedAt: 1_700_000_000_000 }), PRIVATE_KEY);
226
+ 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({
228
+ ok: false,
229
+ reason: 'stale_issued_at',
230
+ });
231
+ });
232
+
233
+ it('rejects a non-genesis record with no head as chain_gap', async () => {
234
+ const env = await signEnvelope(v2Fields({ seq: 1, prev: 'a'.repeat(64) }), PRIVATE_KEY);
235
+ await expect(
236
+ verifyEnvelope(stubStore(), resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW }),
237
+ ).resolves.toEqual({ ok: false, reason: 'chain_gap' });
238
+ });
239
+ });
240
+
241
+ describe('verifyAndAppend', () => {
242
+ it('verifies then appends, returning the store outcome with the computed recordId', async () => {
243
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
244
+ const append = jest.fn(
245
+ async (_s: string, e: SignedRecordEnvelope, recordId: string): Promise<AppendOutcome> => ({
246
+ ok: true,
247
+ recordId,
248
+ seq: e.seq ?? -1,
249
+ }),
250
+ );
251
+ const store = stubStore({ append });
252
+ const result = await verifyAndAppend(store, resolver(SELF_RESOLVED), env, { now: FAR_FUTURE_NOW });
253
+ expect(result.ok).toBe(true);
254
+ expect(append).toHaveBeenCalledTimes(1);
255
+ const [subject, passedEnv, recordId] = append.mock.calls[0];
256
+ expect(subject).toBe(SUBJECT);
257
+ expect(passedEnv).toBe(env);
258
+ expect(recordId).toMatch(/^[0-9a-f]{64}$/);
259
+ expect(result).toEqual({ ok: true, recordId, seq: 0 });
260
+ });
261
+
262
+ it('does NOT append when verification fails', async () => {
263
+ const tampered = { ...(await signEnvelope(v2Fields(), PRIVATE_KEY)), record: { x: 'tampered' } };
264
+ const append = jest.fn();
265
+ const store = stubStore({ append });
266
+ const result = await verifyAndAppend(store, resolver(SELF_RESOLVED), tampered, { now: FAR_FUTURE_NOW });
267
+ expect(result).toEqual({ ok: false, reason: 'bad_signature' });
268
+ expect(append).not.toHaveBeenCalled();
269
+ });
270
+
271
+ it('surfaces the store chain_conflict backstop', async () => {
272
+ const env = await signEnvelope(v2Fields(), PRIVATE_KEY);
273
+ 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({
275
+ ok: false,
276
+ reason: 'chain_conflict',
277
+ });
278
+ });
279
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `createDidWebResolver` tests — `did:web` → `did.json` URL mapping, and the
3
+ * resolution of a fetched DID document to its current verification keys, driven
4
+ * by a STUB `NodeFetch` (no network). Locks the multi-subject authorization
5
+ * input the chain engine consults on relay/ingest paths.
6
+ */
7
+
8
+ import type { DidDocument } from '@oxy.so/contracts';
9
+ import { createDidWebResolver, didWebToUrl } from '../node/didWebResolver';
10
+ import type { NodeFetch, NodeFetchResponse } from '../node/httpFetch';
11
+
12
+ const SUBJECT = 'did:web:node.example:u:owner';
13
+ const KEY_A = `04${'a'.repeat(128)}`;
14
+ const KEY_B = `04${'b'.repeat(128)}`;
15
+
16
+ function didDoc(overrides: Partial<DidDocument> = {}): DidDocument {
17
+ return {
18
+ '@context': ['https://www.w3.org/ns/did/v1'],
19
+ id: SUBJECT,
20
+ controller: [SUBJECT],
21
+ verificationMethod: [
22
+ { id: `${SUBJECT}#key-1`, type: 'EcdsaSecp256k1VerificationKey2019', controller: SUBJECT, publicKeyHex: KEY_A },
23
+ ],
24
+ authentication: [`${SUBJECT}#key-1`],
25
+ assertionMethod: [`${SUBJECT}#key-1`],
26
+ alsoKnownAs: [],
27
+ service: [],
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function jsonResponse(obj: unknown, status = 200): NodeFetchResponse {
33
+ return {
34
+ status,
35
+ headers: {},
36
+ body: (async function* () {
37
+ yield Buffer.from(JSON.stringify(obj));
38
+ })(),
39
+ destroy() {},
40
+ };
41
+ }
42
+
43
+ describe('didWebToUrl', () => {
44
+ it('maps an apex did:web to its well-known did.json', () => {
45
+ expect(didWebToUrl('did:web:example.com')).toBe('https://example.com/.well-known/did.json');
46
+ });
47
+
48
+ it('maps a pathed did:web to a path did.json', () => {
49
+ expect(didWebToUrl('did:web:example.com:u:123')).toBe('https://example.com/u/123/did.json');
50
+ });
51
+
52
+ it('decodes a %3A port in the host segment', () => {
53
+ expect(didWebToUrl('did:web:localhost%3A3000')).toBe('https://localhost:3000/.well-known/did.json');
54
+ });
55
+
56
+ it('returns null for a non-did:web identifier', () => {
57
+ expect(didWebToUrl('did:key:z6Mk')).toBeNull();
58
+ expect(didWebToUrl('not-a-did')).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('createDidWebResolver', () => {
63
+ it('resolves a subject to the verification keys in its DID document', async () => {
64
+ const calls: string[] = [];
65
+ const fetch: NodeFetch = async (url) => {
66
+ calls.push(url);
67
+ return jsonResponse(didDoc());
68
+ };
69
+ const resolver = createDidWebResolver(fetch);
70
+ const resolved = await resolver.resolve(SUBJECT);
71
+ expect(resolved).toEqual({ currentPublicKeys: [KEY_A] });
72
+ expect(calls).toEqual(['https://node.example/u/owner/did.json']);
73
+ });
74
+
75
+ it('collects every assertionMethod key (deduped)', async () => {
76
+ const doc = didDoc({
77
+ 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 },
80
+ ],
81
+ assertionMethod: [`${SUBJECT}#key-1`, `${SUBJECT}#key-2`],
82
+ });
83
+ const resolver = createDidWebResolver(async () => jsonResponse(doc));
84
+ expect(await resolver.resolve(SUBJECT)).toEqual({ currentPublicKeys: [KEY_A, KEY_B] });
85
+ });
86
+
87
+ it('ignores a Multikey VM and collects only the secp256k1 hex keys', async () => {
88
+ // An atproto-bridged document carries an extra `Multikey` VM (the SAME key in
89
+ // multibase form, no `publicKeyHex`). The resolver must skip it and resolve to
90
+ // the hex signing key only.
91
+ const doc = didDoc({
92
+ verificationMethod: [
93
+ { id: `${SUBJECT}#key-1`, type: 'EcdsaSecp256k1VerificationKey2019', controller: SUBJECT, publicKeyHex: KEY_A },
94
+ { id: `${SUBJECT}#atproto`, type: 'Multikey', controller: SUBJECT, publicKeyMultibase: 'zQ3shFakeMultibaseKey' },
95
+ ],
96
+ authentication: [`${SUBJECT}#key-1`, `${SUBJECT}#atproto`],
97
+ assertionMethod: [`${SUBJECT}#key-1`, `${SUBJECT}#atproto`],
98
+ });
99
+ const resolver = createDidWebResolver(async () => jsonResponse(doc));
100
+ expect(await resolver.resolve(SUBJECT)).toEqual({ currentPublicKeys: [KEY_A] });
101
+ });
102
+
103
+ it('returns null for a non-did:web subject (no fetch)', async () => {
104
+ const fetch = jest.fn();
105
+ const resolver = createDidWebResolver(fetch as unknown as NodeFetch);
106
+ expect(await resolver.resolve('did:key:z6Mk')).toBeNull();
107
+ expect(fetch).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it('returns null on a non-2xx did.json fetch', async () => {
111
+ const resolver = createDidWebResolver(async () => jsonResponse({ error: 'nope' }, 404));
112
+ expect(await resolver.resolve(SUBJECT)).toBeNull();
113
+ });
114
+
115
+ 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' })));
117
+ expect(await resolver.resolve(SUBJECT)).toBeNull();
118
+ });
119
+
120
+ it('returns null and reports onError when the fetch throws', async () => {
121
+ const onError = jest.fn();
122
+ const boom = new Error('ECONNREFUSED');
123
+ const resolver = createDidWebResolver(
124
+ async () => {
125
+ throw boom;
126
+ },
127
+ { onError },
128
+ );
129
+ expect(await resolver.resolve(SUBJECT)).toBeNull();
130
+ expect(onError).toHaveBeenCalledWith(boom, SUBJECT);
131
+ });
132
+ });