@oxy.so/protocol 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/chain/continuity.js +54 -0
- package/dist/cjs/chain/engine.js +34 -0
- package/dist/cjs/chain/recordStore.js +25 -0
- package/dist/cjs/chain/types.js +22 -0
- package/dist/cjs/chain/verify.js +82 -0
- package/dist/cjs/envelope/canonicalJson.js +107 -0
- package/dist/cjs/envelope/recordId.js +60 -0
- package/dist/cjs/envelope/sign.js +75 -0
- package/dist/cjs/envelope/signingInput.js +32 -0
- package/dist/cjs/identity/resolver.js +50 -0
- package/dist/cjs/index.js +71 -0
- package/dist/cjs/node/constants.js +85 -0
- package/dist/cjs/node/didWebResolver.js +126 -0
- package/dist/cjs/node/httpFetch.js +61 -0
- package/dist/cjs/node/index.js +71 -0
- package/dist/cjs/node/nodeApp.js +344 -0
- package/dist/cjs/node/nodeClient.js +204 -0
- package/dist/cjs/node/rateLimit.js +187 -0
- package/dist/cjs/node/verifyRecord.js +51 -0
- package/dist/cjs/platform/crypto.js +186 -0
- package/dist/cjs/platform/crypto.native.js +204 -0
- package/dist/cjs/platform/expoTypes.js +24 -0
- package/dist/cjs/platform/platform.js +33 -0
- package/dist/cjs/secp256k1.js +148 -0
- package/dist/cjs/transparency/checkpoint.js +79 -0
- package/dist/cjs/transparency/tree.js +197 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/chain/continuity.js +51 -0
- package/dist/esm/chain/engine.js +31 -0
- package/dist/esm/chain/recordStore.js +24 -0
- package/dist/esm/chain/types.js +19 -0
- package/dist/esm/chain/verify.js +78 -0
- package/dist/esm/envelope/canonicalJson.js +104 -0
- package/dist/esm/envelope/recordId.js +56 -0
- package/dist/esm/envelope/sign.js +69 -0
- package/dist/esm/envelope/signingInput.js +29 -0
- package/dist/esm/identity/resolver.js +47 -0
- package/dist/esm/index.js +36 -0
- package/dist/esm/node/constants.js +82 -0
- package/dist/esm/node/didWebResolver.js +122 -0
- package/dist/esm/node/httpFetch.js +55 -0
- package/dist/esm/node/index.js +28 -0
- package/dist/esm/node/nodeApp.js +336 -0
- package/dist/esm/node/nodeClient.js +198 -0
- package/dist/esm/node/rateLimit.js +182 -0
- package/dist/esm/node/verifyRecord.js +48 -0
- package/dist/esm/platform/crypto.js +145 -0
- package/dist/esm/platform/crypto.native.js +196 -0
- package/dist/esm/platform/expoTypes.js +23 -0
- package/dist/esm/platform/platform.js +29 -0
- package/dist/esm/secp256k1.js +137 -0
- package/dist/esm/transparency/checkpoint.js +73 -0
- package/dist/esm/transparency/tree.js +189 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/chain/continuity.d.ts +28 -0
- package/dist/types/chain/engine.d.ts +27 -0
- package/dist/types/chain/recordStore.d.ts +85 -0
- package/dist/types/chain/types.d.ts +79 -0
- package/dist/types/chain/verify.d.ts +45 -0
- package/dist/types/envelope/canonicalJson.d.ts +44 -0
- package/dist/types/envelope/recordId.d.ts +30 -0
- package/dist/types/envelope/sign.d.ts +47 -0
- package/dist/types/envelope/signingInput.d.ts +33 -0
- package/dist/types/identity/resolver.d.ts +67 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/node/constants.d.ts +80 -0
- package/dist/types/node/didWebResolver.d.ts +47 -0
- package/dist/types/node/httpFetch.d.ts +60 -0
- package/dist/types/node/index.d.ts +28 -0
- package/dist/types/node/nodeApp.d.ts +120 -0
- package/dist/types/node/nodeClient.d.ts +135 -0
- package/dist/types/node/rateLimit.d.ts +95 -0
- package/dist/types/node/verifyRecord.d.ts +41 -0
- package/dist/types/platform/crypto.d.ts +93 -0
- package/dist/types/platform/crypto.native.d.ts +77 -0
- package/dist/types/platform/expoTypes.d.ts +99 -0
- package/dist/types/platform/platform.d.ts +25 -0
- package/dist/types/secp256k1.d.ts +45 -0
- package/dist/types/transparency/checkpoint.d.ts +71 -0
- package/dist/types/transparency/tree.d.ts +135 -0
- package/package.json +157 -0
- package/src/__tests__/canonicalJson.test.ts +116 -0
- package/src/__tests__/chain.test.ts +279 -0
- package/src/__tests__/didWebResolver.test.ts +132 -0
- package/src/__tests__/envelope.test.ts +267 -0
- package/src/__tests__/nodeApp.test.ts +410 -0
- package/src/__tests__/nodeClient.test.ts +177 -0
- package/src/__tests__/nodeHarness.ts +151 -0
- package/src/__tests__/optionalNativePeers.test.ts +233 -0
- package/src/__tests__/rateLimit.test.ts +268 -0
- package/src/__tests__/runnerGuard.test.ts +85 -0
- package/src/__tests__/secp256k1.test.ts +118 -0
- package/src/__tests__/transparency.test.ts +353 -0
- package/src/chain/continuity.ts +59 -0
- package/src/chain/engine.ts +43 -0
- package/src/chain/recordStore.ts +98 -0
- package/src/chain/types.ts +85 -0
- package/src/chain/verify.ts +102 -0
- package/src/envelope/canonicalJson.ts +120 -0
- package/src/envelope/recordId.ts +63 -0
- package/src/envelope/sign.ts +86 -0
- package/src/envelope/signingInput.ts +48 -0
- package/src/identity/resolver.ts +90 -0
- package/src/index.ts +101 -0
- package/src/node/constants.ts +105 -0
- package/src/node/didWebResolver.ts +162 -0
- package/src/node/httpFetch.ts +88 -0
- package/src/node/index.ts +87 -0
- package/src/node/nodeApp.ts +471 -0
- package/src/node/nodeClient.ts +322 -0
- package/src/node/rateLimit.ts +233 -0
- package/src/node/verifyRecord.ts +60 -0
- package/src/platform/crypto.native.ts +251 -0
- package/src/platform/crypto.ts +172 -0
- package/src/platform/expoTypes.ts +99 -0
- package/src/platform/platform.ts +31 -0
- package/src/secp256k1.ts +207 -0
- package/src/transparency/checkpoint.ts +109 -0
- package/src/transparency/tree.ts +258 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `NodeClient` round-trip tests — drive the client against a REAL `createNodeApp`
|
|
3
|
+
* server (booted on an ephemeral port) through a Node `http` transport adapter
|
|
4
|
+
* (the same `NodeFetch` shape oxy-api adapts from `safeFetch`). Locks the
|
|
5
|
+
* head/log/records/blobs surface end-to-end, including the gap/fork rejection
|
|
6
|
+
* reasons surfaced as `NodeClientError` and the blob pin/serve round trip.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import type { AddressInfo } from 'node:net';
|
|
12
|
+
import { createNodeApp, type NodeAppConfig } from '../node/nodeApp';
|
|
13
|
+
import { NodeClient, NodeClientError, trimTrailingSlashes } from '../node/nodeClient';
|
|
14
|
+
import type { NodeFetch } from '../node/httpFetch';
|
|
15
|
+
import { computeRecordId } from '../envelope/recordId';
|
|
16
|
+
import {
|
|
17
|
+
buildSignedEnvelope,
|
|
18
|
+
createInMemoryNodeStore,
|
|
19
|
+
createTestOwnerAuth,
|
|
20
|
+
generateKeyPair,
|
|
21
|
+
signBlobPin,
|
|
22
|
+
silentLogger,
|
|
23
|
+
type TestKeyPair,
|
|
24
|
+
} from './nodeHarness';
|
|
25
|
+
|
|
26
|
+
/** A Node `http` transport satisfying the injected `NodeFetch` contract. */
|
|
27
|
+
const httpFetch: NodeFetch = (url, init) =>
|
|
28
|
+
new Promise((resolve, reject) => {
|
|
29
|
+
const u = new URL(url);
|
|
30
|
+
const req = http.request(
|
|
31
|
+
{
|
|
32
|
+
hostname: u.hostname,
|
|
33
|
+
port: u.port,
|
|
34
|
+
path: `${u.pathname}${u.search}`,
|
|
35
|
+
method: init.method,
|
|
36
|
+
headers: init.headers,
|
|
37
|
+
},
|
|
38
|
+
(res) => {
|
|
39
|
+
resolve({
|
|
40
|
+
status: res.statusCode ?? 0,
|
|
41
|
+
headers: res.headers,
|
|
42
|
+
body: res,
|
|
43
|
+
destroy: () => res.destroy(),
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
req.on('error', reject);
|
|
48
|
+
if (init.body) {
|
|
49
|
+
req.write(Buffer.from(init.body));
|
|
50
|
+
}
|
|
51
|
+
req.end();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function makeConfig(nodePublicKey: string): NodeAppConfig {
|
|
55
|
+
return {
|
|
56
|
+
wellKnownPath: '/.well-known/oxy-node.json',
|
|
57
|
+
protocolId: 'oxy-node/1',
|
|
58
|
+
serviceType: 'OxyPersonalDataNode',
|
|
59
|
+
mode: 'self-hosted',
|
|
60
|
+
nodePublicKey,
|
|
61
|
+
maxBlobBytes: 25 * 1024 * 1024,
|
|
62
|
+
collections: [],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('trimTrailingSlashes', () => {
|
|
67
|
+
it('removes one or many trailing slashes', () => {
|
|
68
|
+
expect(trimTrailingSlashes('https://node.example')).toBe('https://node.example');
|
|
69
|
+
expect(trimTrailingSlashes('https://node.example/')).toBe('https://node.example');
|
|
70
|
+
expect(trimTrailingSlashes('https://node.example////')).toBe('https://node.example');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('preserves interior slashes and the empty string', () => {
|
|
74
|
+
expect(trimTrailingSlashes('https://node.example/oxy/log')).toBe('https://node.example/oxy/log');
|
|
75
|
+
expect(trimTrailingSlashes('')).toBe('');
|
|
76
|
+
expect(trimTrailingSlashes('///')).toBe('');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is linear-time on a long all-slash input (no ReDoS backtracking)', () => {
|
|
80
|
+
const pathological = `https://node.example${'/'.repeat(200_000)}`;
|
|
81
|
+
const start = Date.now();
|
|
82
|
+
expect(trimTrailingSlashes(pathological)).toBe('https://node.example');
|
|
83
|
+
// A linear scan of 200k chars completes in well under a tenth of a second;
|
|
84
|
+
// a backtracking regex would be orders of magnitude slower.
|
|
85
|
+
expect(Date.now() - start).toBeLessThan(100);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('NodeClient (end-to-end against createNodeApp)', () => {
|
|
90
|
+
let owner: TestKeyPair;
|
|
91
|
+
let app: ReturnType<typeof createNodeApp>;
|
|
92
|
+
let server: http.Server;
|
|
93
|
+
let client: NodeClient;
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
owner = generateKeyPair();
|
|
97
|
+
app = createNodeApp({
|
|
98
|
+
store: createInMemoryNodeStore(),
|
|
99
|
+
config: makeConfig(owner.publicKey),
|
|
100
|
+
ownerAuth: createTestOwnerAuth(owner.publicKey),
|
|
101
|
+
logger: silentLogger,
|
|
102
|
+
});
|
|
103
|
+
server = await new Promise<http.Server>((resolve) => {
|
|
104
|
+
const s = app.listen(0, () => resolve(s));
|
|
105
|
+
});
|
|
106
|
+
const { port } = server.address() as AddressInfo;
|
|
107
|
+
client = new NodeClient({ baseUrl: `http://127.0.0.1:${port}`, fetch: httpFetch });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
app.stop();
|
|
112
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('head() reports an empty chain, then advances after writes', async () => {
|
|
116
|
+
expect(await client.head()).toEqual({ seq: null, headRecordId: null, recordCount: 0 });
|
|
117
|
+
|
|
118
|
+
const genesis = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
119
|
+
const genesisId = await computeRecordId(genesis);
|
|
120
|
+
expect(await client.writeRecord(genesis)).toEqual({ recordId: genesisId, seq: 0 });
|
|
121
|
+
|
|
122
|
+
expect(await client.head()).toEqual({ seq: 0, headRecordId: genesisId, recordCount: 1 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('writeRecord chains records and log() returns them in order', async () => {
|
|
126
|
+
const genesis = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
127
|
+
const genesisId = await computeRecordId(genesis);
|
|
128
|
+
await client.writeRecord(genesis);
|
|
129
|
+
|
|
130
|
+
const second = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 1, prev: genesisId });
|
|
131
|
+
const secondId = await computeRecordId(second);
|
|
132
|
+
expect(await client.writeRecord(second)).toEqual({ recordId: secondId, seq: 1 });
|
|
133
|
+
|
|
134
|
+
const page = await client.log(-1, 100);
|
|
135
|
+
expect(page.count).toBe(2);
|
|
136
|
+
expect(page.records.map((r) => (r as { seq: number }).seq)).toEqual([0, 1]);
|
|
137
|
+
expect((page.records[1] as { recordId: string }).recordId).toBe(secondId);
|
|
138
|
+
expect(page.head).toEqual({ seq: 1, headRecordId: secondId });
|
|
139
|
+
|
|
140
|
+
const since = await client.log(0, 100);
|
|
141
|
+
expect(since.records.map((r) => (r as { seq: number }).seq)).toEqual([1]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('writeRecord surfaces a chain gap as a NodeClientError (422 chain_gap)', async () => {
|
|
145
|
+
const gap = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 3, prev: null });
|
|
146
|
+
await expect(client.writeRecord(gap)).rejects.toMatchObject({ status: 422, reason: 'chain_gap' });
|
|
147
|
+
await expect(client.writeRecord(gap)).rejects.toBeInstanceOf(NodeClientError);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('writeRecord surfaces a chain fork as a NodeClientError (422 chain_fork)', async () => {
|
|
151
|
+
const genesis = await buildSignedEnvelope({ privateKey: owner.privateKey, seq: 0, prev: null });
|
|
152
|
+
await client.writeRecord(genesis);
|
|
153
|
+
|
|
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' });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('writeRecord surfaces a non-owner write as a NodeClientError (403 not_owner)', async () => {
|
|
159
|
+
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' });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('putBlob then getBlob round-trips; getBlob returns null when absent', async () => {
|
|
165
|
+
const bytes = Buffer.from('client-pinned blob bytes');
|
|
166
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
167
|
+
|
|
168
|
+
expect(await client.getBlob(hash)).toBeNull();
|
|
169
|
+
|
|
170
|
+
const auth = await signBlobPin(hash, owner);
|
|
171
|
+
expect(await client.putBlob(hash, bytes, auth)).toEqual({ hash, size: bytes.length });
|
|
172
|
+
|
|
173
|
+
const fetched = await client.getBlob(hash);
|
|
174
|
+
expect(fetched).not.toBeNull();
|
|
175
|
+
expect(Buffer.from(fetched as Uint8Array).equals(bytes)).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test harness for the `@oxy.so/protocol/node` suites — an in-memory
|
|
3
|
+
* `RecordStore`/`BlobStore`, an owner-key `OwnerAuth`, a silent logger, and a
|
|
4
|
+
* signed-envelope forge. Not a test file (no `.test.ts` suffix) — imported by
|
|
5
|
+
* the node app / client suites so they exercise `createNodeApp` + `NodeClient`
|
|
6
|
+
* against a faithful (continuity-enforcing) store with no DB and no real crypto
|
|
7
|
+
* service.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { generateSecp256k1KeyPair } from '../secp256k1';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import type { SignedRecordEnvelope } from '@oxy.so/contracts';
|
|
13
|
+
import { checkContinuity } from '../chain/continuity';
|
|
14
|
+
import { signEnvelope, signMessage, verifySignature } from '../envelope/sign';
|
|
15
|
+
import type { AppendOutcome, ChainHead } from '../chain/types';
|
|
16
|
+
import type { BlobStore, RecordStore } from '../chain/recordStore';
|
|
17
|
+
import { BlobHashMismatchError, type NodeLogger, type OwnerAuth } from '../node/nodeApp';
|
|
18
|
+
|
|
19
|
+
export interface TestKeyPair {
|
|
20
|
+
privateKey: string;
|
|
21
|
+
publicKey: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Generate a secp256k1 keypair (uncompressed hex public key, matching the signer). */
|
|
25
|
+
export function generateKeyPair(): TestKeyPair {
|
|
26
|
+
const kp = generateSecp256k1KeyPair();
|
|
27
|
+
return { privateKey: kp.privateKey, publicKey: kp.publicKey };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_SUBJECT = 'did:web:node.example:u:owner';
|
|
31
|
+
|
|
32
|
+
export interface BuildEnvelopeOptions {
|
|
33
|
+
privateKey: string;
|
|
34
|
+
seq: number;
|
|
35
|
+
prev: string | null;
|
|
36
|
+
subject?: string;
|
|
37
|
+
issuer?: string;
|
|
38
|
+
type?: string;
|
|
39
|
+
collection?: string;
|
|
40
|
+
rkey?: string;
|
|
41
|
+
record?: Record<string, unknown>;
|
|
42
|
+
issuedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Forge a fully-signed v2 envelope with the protocol signer. */
|
|
46
|
+
export function buildSignedEnvelope(options: BuildEnvelopeOptions): Promise<SignedRecordEnvelope> {
|
|
47
|
+
const subject = options.subject ?? DEFAULT_SUBJECT;
|
|
48
|
+
return signEnvelope(
|
|
49
|
+
{
|
|
50
|
+
version: 2,
|
|
51
|
+
type: options.type ?? 'app_record',
|
|
52
|
+
subject,
|
|
53
|
+
issuer: options.issuer ?? subject,
|
|
54
|
+
record: options.record ?? { hello: 'world' },
|
|
55
|
+
issuedAt: options.issuedAt ?? 1_700_000_000_000 + options.seq,
|
|
56
|
+
seq: options.seq,
|
|
57
|
+
prev: options.prev,
|
|
58
|
+
collection: options.collection ?? 'app.oxy.identity',
|
|
59
|
+
rkey: options.rkey ?? 'self',
|
|
60
|
+
},
|
|
61
|
+
options.privateKey,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A continuity-enforcing in-memory `RecordStore` + `BlobStore` (single chain). */
|
|
66
|
+
export function createInMemoryNodeStore(): RecordStore & BlobStore {
|
|
67
|
+
const records: Array<{ env: SignedRecordEnvelope; recordId: string }> = [];
|
|
68
|
+
const recordIds = new Set<string>();
|
|
69
|
+
const blobs = new Map<string, Buffer>();
|
|
70
|
+
let head: ChainHead | null = null;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
async getHead(): Promise<ChainHead | null> {
|
|
74
|
+
return head;
|
|
75
|
+
},
|
|
76
|
+
async append(_subject, env, recordId): Promise<AppendOutcome> {
|
|
77
|
+
const continuity = checkContinuity(head, env);
|
|
78
|
+
if (!continuity.ok) {
|
|
79
|
+
return continuity;
|
|
80
|
+
}
|
|
81
|
+
if (recordIds.has(recordId)) {
|
|
82
|
+
return { ok: false, reason: 'chain_conflict' };
|
|
83
|
+
}
|
|
84
|
+
const seq = env.seq ?? -1;
|
|
85
|
+
records.push({ env, recordId });
|
|
86
|
+
recordIds.add(recordId);
|
|
87
|
+
head = { headRecordId: recordId, seq, recordCount: records.length };
|
|
88
|
+
return { ok: true, recordId, seq };
|
|
89
|
+
},
|
|
90
|
+
async getLogSince(_subject, sinceSeq, limit): Promise<SignedRecordEnvelope[]> {
|
|
91
|
+
return records
|
|
92
|
+
.filter((r) => (r.env.seq ?? -1) > sinceSeq)
|
|
93
|
+
.slice(0, limit)
|
|
94
|
+
.map((r) => r.env);
|
|
95
|
+
},
|
|
96
|
+
async resolveCursorSeq(_subject, recordId): Promise<number | null> {
|
|
97
|
+
const found = records.find((r) => r.recordId === recordId);
|
|
98
|
+
return found ? found.env.seq ?? -1 : null;
|
|
99
|
+
},
|
|
100
|
+
async materializeCurrent(_subject, collection, rkey): Promise<SignedRecordEnvelope | null> {
|
|
101
|
+
const matching = records.filter((r) => r.env.collection === collection && r.env.rkey === rkey);
|
|
102
|
+
return matching.length ? matching[matching.length - 1].env : null;
|
|
103
|
+
},
|
|
104
|
+
async latestIssuedAtForKey(_subject, env): Promise<number | null> {
|
|
105
|
+
const matching = records.filter((r) => r.env.collection === env.collection && r.env.rkey === env.rkey);
|
|
106
|
+
return matching.length ? matching[matching.length - 1].env.issuedAt : null;
|
|
107
|
+
},
|
|
108
|
+
async putBlob(hash, bytes): Promise<void> {
|
|
109
|
+
const buf = Buffer.from(bytes);
|
|
110
|
+
const actual = createHash('sha256').update(buf).digest('hex');
|
|
111
|
+
const address = hash.toLowerCase();
|
|
112
|
+
if (actual !== address) {
|
|
113
|
+
throw new BlobHashMismatchError(address, actual);
|
|
114
|
+
}
|
|
115
|
+
blobs.set(address, buf);
|
|
116
|
+
},
|
|
117
|
+
async getBlob(hash): Promise<Uint8Array | null> {
|
|
118
|
+
return blobs.get(hash.toLowerCase()) ?? null;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** An `OwnerAuth` bound to `ownerPublicKey`, verifying the node-style pin message. */
|
|
124
|
+
export function createTestOwnerAuth(ownerPublicKey: string): OwnerAuth {
|
|
125
|
+
const owner = ownerPublicKey.toLowerCase();
|
|
126
|
+
return {
|
|
127
|
+
isOwnerKey(publicKey: string): boolean {
|
|
128
|
+
return publicKey.toLowerCase() === owner;
|
|
129
|
+
},
|
|
130
|
+
async verifyBlobPin(hash, auth): Promise<boolean> {
|
|
131
|
+
if (auth.publicKey.toLowerCase() !== owner) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const message = `oxy-node:blob-pin:${hash}:${auth.timestamp}`;
|
|
135
|
+
return verifySignature(message, auth.signature, auth.publicKey);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Sign the owner pin authorization headers for a blob hash. */
|
|
141
|
+
export async function signBlobPin(
|
|
142
|
+
hash: string,
|
|
143
|
+
owner: TestKeyPair,
|
|
144
|
+
): Promise<{ publicKey: string; signature: string; timestamp: number }> {
|
|
145
|
+
const timestamp = Date.now();
|
|
146
|
+
const signature = await signMessage(`oxy-node:blob-pin:${hash}:${timestamp}`, owner.privateKey);
|
|
147
|
+
return { publicKey: owner.publicKey, signature, timestamp };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A logger that swallows output (the suites assert on responses, not logs). */
|
|
151
|
+
export const silentLogger: NodeLogger = { error() {} };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the optional-peer contract of the React Native platform fork.
|
|
3
|
+
*
|
|
4
|
+
* `expo-crypto`, `expo-secure-store` and `@react-native-async-storage/async-storage`
|
|
5
|
+
* are declared OPTIONAL peer dependencies. A STATIC `import` of any of them
|
|
6
|
+
* contradicts that: Metro resolves every static import in the eager graph, so an
|
|
7
|
+
* app that omits the optional peer does not degrade — its whole native bundle
|
|
8
|
+
* fails with `Unable to resolve module <peer>`, pointing at a dependency the app
|
|
9
|
+
* never mentions. Because `@oxy.so/core`'s `crypto/polyfill` imports
|
|
10
|
+
* `@oxy.so/protocol`'s ROOT entry, `platform/crypto.native.ts` sits in the eager
|
|
11
|
+
* graph of every React Native app in the fleet, so the blast radius is total.
|
|
12
|
+
*
|
|
13
|
+
* Two tests, deliberately different in kind:
|
|
14
|
+
*
|
|
15
|
+
* 1. A STATIC guard over the module graph reachable from `src/index.ts`
|
|
16
|
+
* (including the `.native.ts` siblings Metro substitutes), which fails on
|
|
17
|
+
* any future static import of an optional peer anywhere in that graph.
|
|
18
|
+
* 2. A BEHAVIOURAL check that the fork imports cleanly when the peers are
|
|
19
|
+
* missing and only throws — with an actionable message — at the point where
|
|
20
|
+
* the capability is used.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const SRC_DIR = resolve(__dirname, '..');
|
|
27
|
+
const ROOT_ENTRY = join(SRC_DIR, 'index.ts');
|
|
28
|
+
|
|
29
|
+
const packageJson = JSON.parse(
|
|
30
|
+
readFileSync(resolve(SRC_DIR, '..', 'package.json'), 'utf8'),
|
|
31
|
+
) as {
|
|
32
|
+
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const optionalPeers = Object.entries(packageJson.peerDependenciesMeta ?? {})
|
|
36
|
+
.filter(([, meta]) => meta.optional === true)
|
|
37
|
+
.map(([name]) => name);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Static `import`/`export … from` specifiers, excluding the `import type` /
|
|
41
|
+
* `export type` forms, which are fully erased at build time and therefore never
|
|
42
|
+
* become a Metro dependency.
|
|
43
|
+
*/
|
|
44
|
+
function valueImportSpecifiers(source: string): string[] {
|
|
45
|
+
const specifiers: string[] = [];
|
|
46
|
+
const pattern = /(?:^|\n)\s*(?:import|export)\s+(?!type\s)([\s\S]*?)\sfrom\s+['"]([^'"]+)['"]/g;
|
|
47
|
+
let match = pattern.exec(source);
|
|
48
|
+
while (match !== null) {
|
|
49
|
+
specifiers.push(match[2]);
|
|
50
|
+
match = pattern.exec(source);
|
|
51
|
+
}
|
|
52
|
+
// Bare side-effect imports (`import 'foo';`) have no `from` clause.
|
|
53
|
+
const sideEffectPattern = /(?:^|\n)\s*import\s+['"]([^'"]+)['"]/g;
|
|
54
|
+
let sideEffect = sideEffectPattern.exec(source);
|
|
55
|
+
while (sideEffect !== null) {
|
|
56
|
+
specifiers.push(sideEffect[1]);
|
|
57
|
+
sideEffect = sideEffectPattern.exec(source);
|
|
58
|
+
}
|
|
59
|
+
return specifiers;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveRelative(fromFile: string, specifier: string): string | null {
|
|
63
|
+
const base = resolve(dirname(fromFile), specifier);
|
|
64
|
+
for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) {
|
|
65
|
+
if (existsSync(candidate)) {
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every source file Metro can pull into a React Native bundle through the root
|
|
74
|
+
* entry. Metro substitutes `<base>.native.ts` for `<base>.ts` on native, so each
|
|
75
|
+
* reachable module contributes its `.native` sibling too.
|
|
76
|
+
*/
|
|
77
|
+
function reachableFromRootEntry(): string[] {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const queue = [ROOT_ENTRY];
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const file = queue.shift() as string;
|
|
82
|
+
if (seen.has(file)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
seen.add(file);
|
|
86
|
+
|
|
87
|
+
const nativeSibling = file.replace(/\.ts$/, '.native.ts');
|
|
88
|
+
if (nativeSibling !== file && existsSync(nativeSibling) && !seen.has(nativeSibling)) {
|
|
89
|
+
queue.push(nativeSibling);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const specifier of valueImportSpecifiers(readFileSync(file, 'utf8'))) {
|
|
93
|
+
if (!specifier.startsWith('.')) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const resolved = resolveRelative(file, specifier);
|
|
97
|
+
if (resolved !== null) {
|
|
98
|
+
queue.push(resolved);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...seen];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('optional peer dependencies are never statically imported', () => {
|
|
106
|
+
it('declares the React Native modules as optional peers', () => {
|
|
107
|
+
expect(optionalPeers).toEqual(
|
|
108
|
+
expect.arrayContaining([
|
|
109
|
+
'expo-crypto',
|
|
110
|
+
'expo-secure-store',
|
|
111
|
+
'@react-native-async-storage/async-storage',
|
|
112
|
+
]),
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('has no static import of an optional peer anywhere in the root-entry graph', () => {
|
|
117
|
+
const offenders: string[] = [];
|
|
118
|
+
for (const file of reachableFromRootEntry()) {
|
|
119
|
+
for (const specifier of valueImportSpecifiers(readFileSync(file, 'utf8'))) {
|
|
120
|
+
if (optionalPeers.includes(specifier)) {
|
|
121
|
+
offenders.push(`${file.slice(SRC_DIR.length + 1)} statically imports '${specifier}'`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
expect(offenders).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reaches the React Native crypto fork from the root entry', () => {
|
|
129
|
+
// Sanity check on the walker itself: if this ever stops holding, the guard
|
|
130
|
+
// above would pass vacuously.
|
|
131
|
+
expect(reachableFromRootEntry()).toEqual(
|
|
132
|
+
expect.arrayContaining([join(SRC_DIR, 'platform', 'crypto.native.ts')]),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('crypto.native optional-peer degradation', () => {
|
|
138
|
+
const MODULE_PATH = '../platform/crypto.native';
|
|
139
|
+
|
|
140
|
+
function unresolvable(name: string): () => never {
|
|
141
|
+
return () => {
|
|
142
|
+
throw new Error(`Cannot find module '${name}'`);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
jest.resetModules();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('imports cleanly when every optional peer is missing', () => {
|
|
151
|
+
jest.isolateModules(() => {
|
|
152
|
+
jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => null }), {
|
|
153
|
+
virtual: true,
|
|
154
|
+
});
|
|
155
|
+
jest.doMock('expo-crypto', unresolvable('expo-crypto'), { virtual: true });
|
|
156
|
+
jest.doMock('expo-secure-store', unresolvable('expo-secure-store'), { virtual: true });
|
|
157
|
+
jest.doMock(
|
|
158
|
+
'@react-native-async-storage/async-storage',
|
|
159
|
+
unresolvable('@react-native-async-storage/async-storage'),
|
|
160
|
+
{ virtual: true },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(() => require(MODULE_PATH)).not.toThrow();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('throws an actionable error per capability when the peer is missing', async () => {
|
|
168
|
+
await jest.isolateModulesAsync(async () => {
|
|
169
|
+
jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => null }), {
|
|
170
|
+
virtual: true,
|
|
171
|
+
});
|
|
172
|
+
jest.doMock('expo-crypto', unresolvable('expo-crypto'), { virtual: true });
|
|
173
|
+
jest.doMock('expo-secure-store', unresolvable('expo-secure-store'), { virtual: true });
|
|
174
|
+
jest.doMock(
|
|
175
|
+
'@react-native-async-storage/async-storage',
|
|
176
|
+
unresolvable('@react-native-async-storage/async-storage'),
|
|
177
|
+
{ virtual: true },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const mod = require(MODULE_PATH) as typeof import('../platform/crypto.native');
|
|
181
|
+
|
|
182
|
+
await expect(mod.loadExpoCrypto()).rejects.toThrow(/npx expo install expo-crypto/);
|
|
183
|
+
await expect(mod.loadSecureStore()).rejects.toThrow(/npx expo install expo-secure-store/);
|
|
184
|
+
await expect(mod.loadAsyncStorage()).rejects.toThrow(
|
|
185
|
+
/npx expo install @react-native-async-storage\/async-storage/,
|
|
186
|
+
);
|
|
187
|
+
expect(() => mod.getRandomBytesRN(8)).toThrow(/npx expo install expo-crypto/);
|
|
188
|
+
|
|
189
|
+
// The Metro resolution failure is reported, not swallowed.
|
|
190
|
+
expect(() => mod.getRandomBytesRN(8)).toThrow(/Cannot find module 'expo-crypto'/);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns the real modules when the optional peers are installed', async () => {
|
|
195
|
+
const bytes = new Uint8Array([1, 2, 3, 4]);
|
|
196
|
+
const fakeCrypto = {
|
|
197
|
+
getRandomBytes: () => bytes,
|
|
198
|
+
getRandomBytesAsync: async () => bytes,
|
|
199
|
+
digestStringAsync: async () => 'digest',
|
|
200
|
+
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
|
201
|
+
};
|
|
202
|
+
const fakeSecureStore = {
|
|
203
|
+
setItemAsync: async () => undefined,
|
|
204
|
+
getItemAsync: async () => null,
|
|
205
|
+
deleteItemAsync: async () => undefined,
|
|
206
|
+
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 1,
|
|
207
|
+
WHEN_UNLOCKED: 2,
|
|
208
|
+
};
|
|
209
|
+
const fakeAsyncStorage = {
|
|
210
|
+
getItem: async () => null,
|
|
211
|
+
setItem: async () => undefined,
|
|
212
|
+
removeItem: async () => undefined,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
await jest.isolateModulesAsync(async () => {
|
|
216
|
+
jest.doMock('expo-modules-core', () => ({ requireOptionalNativeModule: () => null }), {
|
|
217
|
+
virtual: true,
|
|
218
|
+
});
|
|
219
|
+
jest.doMock('expo-crypto', () => fakeCrypto, { virtual: true });
|
|
220
|
+
jest.doMock('expo-secure-store', () => fakeSecureStore, { virtual: true });
|
|
221
|
+
jest.doMock('@react-native-async-storage/async-storage', () => ({ default: fakeAsyncStorage }), {
|
|
222
|
+
virtual: true,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const mod = require(MODULE_PATH) as typeof import('../platform/crypto.native');
|
|
226
|
+
|
|
227
|
+
await expect(mod.loadExpoCrypto()).resolves.toBe(fakeCrypto);
|
|
228
|
+
await expect(mod.loadSecureStore()).resolves.toBe(fakeSecureStore);
|
|
229
|
+
await expect(mod.loadAsyncStorage()).resolves.toEqual({ default: fakeAsyncStorage });
|
|
230
|
+
expect(mod.getRandomBytesRN(4)).toBe(bytes);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|