@oxy.so/protocol 1.6.0 → 1.7.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.
@@ -151,6 +151,20 @@ function narrowDescription(value) {
151
151
  return null;
152
152
  return { v, publicKey };
153
153
  }
154
+ /**
155
+ * The organization answer must name the organization asked for. A Commons that
156
+ * predates organization keys ignores the `accountId` extra and answers for the
157
+ * PERSONAL identity, without an echo; that answer is refused here, because a
158
+ * personal key taken for an organization's is money sent to the wrong person.
159
+ */
160
+ function echoes(value, accountId) {
161
+ const r = record(value);
162
+ return r && r.accountId === accountId ? r : null;
163
+ }
164
+ function narrowOrganizationDescription(value, accountId) {
165
+ const description = narrowDescription(echoes(value, accountId));
166
+ return description ? { ...description, accountId } : null;
167
+ }
154
168
  function narrowProof(value) {
155
169
  const r = record(value);
156
170
  const publicKey = hexField(r?.publicKey, 130);
@@ -191,10 +205,33 @@ function loadCommonsIdentityBridge() {
191
205
  }
192
206
  const m = native;
193
207
  return {
194
- describe: () => ask(() => m.describe(), narrowDescription),
208
+ describe: (accountId) => {
209
+ if (accountId === undefined)
210
+ return ask(() => m.describe(), narrowDescription);
211
+ const describeOrganization = m.describeOrganization;
212
+ if (typeof describeOrganization !== 'function')
213
+ return Promise.resolve(null);
214
+ return ask(() => describeOrganization.call(m, accountId), (v) => narrowOrganizationDescription(v, accountId));
215
+ },
195
216
  proveIdentity: (challenge) => ask(() => m.proveIdentity(challenge), narrowProof),
196
- deriveScopedSeed: (info) => ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64)),
197
- signSocialReceive: (index, digest) => ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
217
+ deriveScopedSeed: (info, accountId) => {
218
+ if (accountId === undefined) {
219
+ return ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64));
220
+ }
221
+ const derive = m.deriveOrganizationScopedSeed;
222
+ if (typeof derive !== 'function')
223
+ return Promise.resolve(null);
224
+ return ask(() => derive.call(m, info, accountId), (v) => hexField(echoes(v, accountId)?.seed, 64));
225
+ },
226
+ signSocialReceive: (index, digest, accountId) => {
227
+ if (accountId === undefined) {
228
+ return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
229
+ }
230
+ const sign = m.signOrganizationSocialReceive;
231
+ if (typeof sign !== 'function')
232
+ return Promise.resolve(null);
233
+ return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
234
+ },
198
235
  };
199
236
  });
200
237
  }
@@ -142,6 +142,20 @@ function narrowDescription(value) {
142
142
  return null;
143
143
  return { v, publicKey };
144
144
  }
145
+ /**
146
+ * The organization answer must name the organization asked for. A Commons that
147
+ * predates organization keys ignores the `accountId` extra and answers for the
148
+ * PERSONAL identity, without an echo; that answer is refused here, because a
149
+ * personal key taken for an organization's is money sent to the wrong person.
150
+ */
151
+ function echoes(value, accountId) {
152
+ const r = record(value);
153
+ return r && r.accountId === accountId ? r : null;
154
+ }
155
+ function narrowOrganizationDescription(value, accountId) {
156
+ const description = narrowDescription(echoes(value, accountId));
157
+ return description ? { ...description, accountId } : null;
158
+ }
145
159
  function narrowProof(value) {
146
160
  const r = record(value);
147
161
  const publicKey = hexField(r?.publicKey, 130);
@@ -182,10 +196,33 @@ export function loadCommonsIdentityBridge() {
182
196
  }
183
197
  const m = native;
184
198
  return {
185
- describe: () => ask(() => m.describe(), narrowDescription),
199
+ describe: (accountId) => {
200
+ if (accountId === undefined)
201
+ return ask(() => m.describe(), narrowDescription);
202
+ const describeOrganization = m.describeOrganization;
203
+ if (typeof describeOrganization !== 'function')
204
+ return Promise.resolve(null);
205
+ return ask(() => describeOrganization.call(m, accountId), (v) => narrowOrganizationDescription(v, accountId));
206
+ },
186
207
  proveIdentity: (challenge) => ask(() => m.proveIdentity(challenge), narrowProof),
187
- deriveScopedSeed: (info) => ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64)),
188
- signSocialReceive: (index, digest) => ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
208
+ deriveScopedSeed: (info, accountId) => {
209
+ if (accountId === undefined) {
210
+ return ask(() => m.deriveScopedSeed(info), (v) => hexField(v, 64));
211
+ }
212
+ const derive = m.deriveOrganizationScopedSeed;
213
+ if (typeof derive !== 'function')
214
+ return Promise.resolve(null);
215
+ return ask(() => derive.call(m, info, accountId), (v) => hexField(echoes(v, accountId)?.seed, 64));
216
+ },
217
+ signSocialReceive: (index, digest, accountId) => {
218
+ if (accountId === undefined) {
219
+ return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
220
+ }
221
+ const sign = m.signOrganizationSocialReceive;
222
+ if (typeof sign !== 'function')
223
+ return Promise.resolve(null);
224
+ return ask(() => sign.call(m, index, digest, accountId), (v) => narrowSocialReceive(echoes(v, accountId)));
225
+ },
189
226
  };
190
227
  });
191
228
  }
@@ -79,6 +79,12 @@ export interface CommonsIdentityDescription {
79
79
  v: number;
80
80
  /** The identity's public key, lowercase uncompressed SEC1 hex. */
81
81
  publicKey: string;
82
+ /**
83
+ * Set only on an answer for an ORGANIZATION key (ADR 0061 D6): the account id
84
+ * Commons echoed, always equal to the one asked for. An answer without it is
85
+ * the personal identity and is never taken as an organization's.
86
+ */
87
+ accountId?: string;
82
88
  }
83
89
  /**
84
90
  * A signed server challenge from Commons (`proveIdentity`). The fields plug
@@ -113,19 +119,24 @@ export interface CommonsSocialReceiveSignature {
113
119
  * path in `@oxy.so/core`'s `KeyManager` owns iOS sharing.
114
120
  */
115
121
  export interface CommonsIdentityBridge {
116
- /** The public key of the identity Commons holds. */
117
- describe(): Promise<CommonsIdentityDescription | null>;
122
+ /**
123
+ * The public key of the identity Commons holds, or, with `accountId`, of that
124
+ * organization's key (ADR 0061 D6).
125
+ */
126
+ describe(accountId?: string): Promise<CommonsIdentityDescription | null>;
118
127
  /** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
119
128
  proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
120
129
  /**
121
- * HKDF-SHA256 over the identity key, 32 bytes as lowercase hex, identical to
122
- * `KeyManager.deriveScopedSeed(info)` on the device that holds the key.
123
- * Commons allows each caller package only its own `info` labels.
130
+ * HKDF-SHA256 over the identity key (or, with `accountId`, the organization's
131
+ * key), 32 bytes as lowercase hex, identical to `KeyManager.deriveScopedSeed`
132
+ * on the device that holds the key. Commons allows each caller package only
133
+ * its own `info` labels.
124
134
  */
125
- deriveScopedSeed(info: string): Promise<string | null>;
135
+ deriveScopedSeed(info: string, accountId?: string): Promise<string | null>;
126
136
  /**
127
137
  * Sign a 32-byte digest (64 hex) with the social-receive child key `index`
128
- * of the identity. Allowed for the wallet app only.
138
+ * of the identity, or with `accountId`, of that organization's key. Allowed
139
+ * for the wallet app only.
129
140
  */
130
- signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
141
+ signSocialReceive(index: number, digest: string, accountId?: string): Promise<CommonsSocialReceiveSignature | null>;
131
142
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/protocol",
3
- "version": "1.6.0",
3
+ "version": "1.7.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",
@@ -133,7 +133,7 @@
133
133
  },
134
134
  "dependencies": {
135
135
  "@noble/curves": "^2.4.0",
136
- "@oxy.so/contracts": "^6.5.0"
136
+ "@oxy.so/contracts": "^6.11.0"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@react-native-async-storage/async-storage": "<4.0.0",
@@ -158,16 +158,16 @@
158
158
  },
159
159
  "devDependencies": {
160
160
  "@react-native-async-storage/async-storage": "^3.1.1",
161
- "@types/express": "^4.17.25",
162
- "@types/node": "^22.20.1",
163
- "@types/supertest": "^6.0.2",
164
- "expo-crypto": "~56.0.3",
165
- "expo-modules-core": "~58.0.26",
166
- "expo-secure-store": "~57.0.4",
167
- "express": "^4.22.2",
168
- "jest": "^30.5.1",
169
- "supertest": "^7.0.0",
170
- "ts-jest": "^29.4.12",
171
- "typescript": "^5.9.2"
161
+ "@types/express": "^5.0.6",
162
+ "@types/node": "^26.6.6",
163
+ "@types/supertest": "^7.2.1",
164
+ "expo-crypto": "~58.0.7",
165
+ "expo-modules-core": "~58.0.30",
166
+ "expo-secure-store": "~58.0.2",
167
+ "express": "^5.3.0",
168
+ "jest": "^30.5.2",
169
+ "supertest": "^7.3.1",
170
+ "ts-jest": "^29.4.15",
171
+ "typescript": "~6.0.3"
172
172
  }
173
173
  }
@@ -87,6 +87,85 @@ describe('loadCommonsIdentityBridge (native)', () => {
87
87
  await expect(bridge?.signSocialReceive(0, '11'.repeat(32))).resolves.toBeNull();
88
88
  });
89
89
 
90
+ test('organization requests go to the organization methods and must echo the account', async () => {
91
+ const ORG = '0123456789abcdef01234567';
92
+ const calls: unknown[][] = [];
93
+ mockNative({
94
+ describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
95
+ proveIdentity: async () => null,
96
+ deriveScopedSeed: async () => 'cd'.repeat(32),
97
+ signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
98
+ describeOrganization: async (accountId: string) => {
99
+ calls.push(['describeOrganization', accountId]);
100
+ return { v: 3, publicKey: PUBLIC_KEY, accountId };
101
+ },
102
+ deriveOrganizationScopedSeed: async (info: string, accountId: string) => {
103
+ calls.push(['deriveOrganizationScopedSeed', info, accountId]);
104
+ return { seed: 'ee'.repeat(32), accountId };
105
+ },
106
+ signOrganizationSocialReceive: async (index: number, digest: string, accountId: string) => {
107
+ calls.push(['signOrganizationSocialReceive', index, digest, accountId]);
108
+ return { signature: '3046cc', publicKey: `03${'aa'.repeat(32)}`, accountId };
109
+ },
110
+ });
111
+ const bridge = await loadBridge();
112
+ await expect(bridge?.describe(ORG)).resolves.toEqual({
113
+ v: 3,
114
+ publicKey: PUBLIC_KEY,
115
+ accountId: ORG,
116
+ });
117
+ await expect(bridge?.deriveScopedSeed('peable/faircoin/v1', ORG)).resolves.toBe(
118
+ 'ee'.repeat(32),
119
+ );
120
+ await expect(bridge?.signSocialReceive(4, '11'.repeat(32), ORG)).resolves.toEqual({
121
+ signature: '3046cc',
122
+ publicKey: `03${'aa'.repeat(32)}`,
123
+ });
124
+ // The personal path is untouched.
125
+ await expect(bridge?.describe()).resolves.toEqual({ v: 2, publicKey: PUBLIC_KEY });
126
+ expect(calls).toEqual([
127
+ ['describeOrganization', ORG],
128
+ ['deriveOrganizationScopedSeed', 'peable/faircoin/v1', ORG],
129
+ ['signOrganizationSocialReceive', 4, '11'.repeat(32), ORG],
130
+ ]);
131
+ });
132
+
133
+ test('an organization answer without the echo, or for another account, is null', async () => {
134
+ const ORG = '0123456789abcdef01234567';
135
+ mockNative({
136
+ describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
137
+ proveIdentity: async () => null,
138
+ deriveScopedSeed: async () => 'cd'.repeat(32),
139
+ signSocialReceive: async () => null,
140
+ // An old Commons ignored the extra and answered for the personal key.
141
+ describeOrganization: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
142
+ deriveOrganizationScopedSeed: async () => ({ seed: 'ee'.repeat(32), accountId: 'other' }),
143
+ signOrganizationSocialReceive: async () => ({
144
+ signature: '3046cc',
145
+ publicKey: `03${'aa'.repeat(32)}`,
146
+ }),
147
+ });
148
+ const bridge = await loadBridge();
149
+ await expect(bridge?.describe(ORG)).resolves.toBeNull();
150
+ await expect(bridge?.deriveScopedSeed('peable/faircoin/v1', ORG)).resolves.toBeNull();
151
+ await expect(bridge?.signSocialReceive(0, '11'.repeat(32), ORG)).resolves.toBeNull();
152
+ });
153
+
154
+ test('a native module without the organization methods answers no organization', async () => {
155
+ mockNative({
156
+ describe: async () => ({ v: 2, publicKey: PUBLIC_KEY }),
157
+ proveIdentity: async () => null,
158
+ deriveScopedSeed: async () => 'cd'.repeat(32),
159
+ signSocialReceive: async () => ({ signature: '3045bb', publicKey: `02${'ef'.repeat(32)}` }),
160
+ });
161
+ const bridge = await loadBridge();
162
+ await expect(bridge?.describe('0123456789abcdef01234567')).resolves.toBeNull();
163
+ await expect(bridge?.deriveScopedSeed('x', '0123456789abcdef01234567')).resolves.toBeNull();
164
+ await expect(
165
+ bridge?.signSocialReceive(0, '11'.repeat(32), '0123456789abcdef01234567'),
166
+ ).resolves.toBeNull();
167
+ });
168
+
90
169
  test('the web / Node variant never has a bridge', async () => {
91
170
  const mod = await import('../platform/crypto');
92
171
  await expect(mod.loadCommonsIdentityBridge()).resolves.toBeNull();
@@ -432,11 +432,10 @@ describe('createNodeApp', () => {
432
432
  prev = id;
433
433
  }
434
434
 
435
- // `?since[]=<id1>&since[]=<id2>` arrives as a string[] — the handler must
436
- // take the FIRST value (a tampered array can never cause type confusion).
437
- const res = await request(app)
438
- .get('/oxy/log')
439
- .query({ 'since[]': [ids[0], ids[2]] });
435
+ // A repeated `?since=<id1>&since=<id2>` arrives as a string[] under every
436
+ // Express query parser — the handler must take the FIRST value (a tampered
437
+ // array can never cause type confusion).
438
+ const res = await request(app).get('/oxy/log').query(`since=${ids[0]}&since=${ids[2]}`);
440
439
  expect(res.status).toBe(200);
441
440
  // Cursor resolves off ids[0] → records strictly after seq 0 → [1, 2].
442
441
  expect(res.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([1, 2]);
@@ -456,9 +455,7 @@ describe('createNodeApp', () => {
456
455
  prev = await computeRecordId(envelope);
457
456
  }
458
457
 
459
- const res = await request(app)
460
- .get('/oxy/log')
461
- .query({ 'limit[]': ['2', '99'] });
458
+ const res = await request(app).get('/oxy/log').query('limit=2&limit=99');
462
459
  expect(res.status).toBe(200);
463
460
  expect(res.body.records.map((entry: { seq: number }) => entry.seq)).toEqual([0, 1]);
464
461
  });
@@ -399,7 +399,7 @@ export function createNodeApp(deps: NodeAppDependencies): NodeApp {
399
399
  );
400
400
 
401
401
  // ── Serve a content-addressed blob ───────────────────────────────────────────
402
- app.get(`${NODE_BLOBS_PATH}/:hash`, async (req: Request, res: Response, next: NextFunction) => {
402
+ app.get(`${NODE_BLOBS_PATH}/:hash`, async (req, res, next) => {
403
403
  try {
404
404
  const hash = req.params.hash.toLowerCase();
405
405
  if (!SHA256_HEX.test(hash)) {
@@ -204,6 +204,19 @@ interface OxyIdentityNativeModule {
204
204
  proveIdentity(challenge: string): Promise<unknown>;
205
205
  deriveScopedSeed(info: string): Promise<unknown>;
206
206
  signSocialReceive(index: number, digest: string): Promise<unknown>;
207
+ /**
208
+ * Organization keys (ADR 0061 D6), added in `@oxy.so/services` after the four
209
+ * above: an older native module lacks them, and every organization request
210
+ * then answers `null`, never the personal identity. Each answer echoes the
211
+ * `accountId` Commons answered for.
212
+ */
213
+ describeOrganization?(accountId: string): Promise<unknown>;
214
+ deriveOrganizationScopedSeed?(info: string, accountId: string): Promise<unknown>;
215
+ signOrganizationSocialReceive?(
216
+ index: number,
217
+ digest: string,
218
+ accountId: string,
219
+ ): Promise<unknown>;
207
220
  }
208
221
 
209
222
  const LOWER_HEX = /^[0-9a-f]+$/;
@@ -226,6 +239,25 @@ function narrowDescription(value: unknown): CommonsIdentityDescription | null {
226
239
  return { v, publicKey };
227
240
  }
228
241
 
242
+ /**
243
+ * The organization answer must name the organization asked for. A Commons that
244
+ * predates organization keys ignores the `accountId` extra and answers for the
245
+ * PERSONAL identity, without an echo; that answer is refused here, because a
246
+ * personal key taken for an organization's is money sent to the wrong person.
247
+ */
248
+ function echoes(value: unknown, accountId: string): Record<string, unknown> | null {
249
+ const r = record(value);
250
+ return r && r.accountId === accountId ? r : null;
251
+ }
252
+
253
+ function narrowOrganizationDescription(
254
+ value: unknown,
255
+ accountId: string,
256
+ ): CommonsIdentityDescription | null {
257
+ const description = narrowDescription(echoes(value, accountId));
258
+ return description ? { ...description, accountId } : null;
259
+ }
260
+
229
261
  function narrowProof(value: unknown): CommonsIdentityProof | null {
230
262
  const r = record(value);
231
263
  const publicKey = hexField(r?.publicKey, 130);
@@ -272,15 +304,41 @@ export function loadCommonsIdentityBridge(): Promise<CommonsIdentityBridge | nul
272
304
  }
273
305
  const m = native as OxyIdentityNativeModule;
274
306
  return {
275
- describe: () => ask(() => m.describe(), narrowDescription),
307
+ describe: (accountId?: string) => {
308
+ if (accountId === undefined) return ask(() => m.describe(), narrowDescription);
309
+ const describeOrganization = m.describeOrganization;
310
+ if (typeof describeOrganization !== 'function') return Promise.resolve(null);
311
+ return ask(
312
+ () => describeOrganization.call(m, accountId),
313
+ (v) => narrowOrganizationDescription(v, accountId),
314
+ );
315
+ },
276
316
  proveIdentity: (challenge: string) => ask(() => m.proveIdentity(challenge), narrowProof),
277
- deriveScopedSeed: (info: string) =>
278
- ask(
279
- () => m.deriveScopedSeed(info),
280
- (v) => hexField(v, 64),
281
- ),
282
- signSocialReceive: (index: number, digest: string) =>
283
- ask(() => m.signSocialReceive(index, digest), narrowSocialReceive),
317
+ deriveScopedSeed: (info: string, accountId?: string) => {
318
+ if (accountId === undefined) {
319
+ return ask(
320
+ () => m.deriveScopedSeed(info),
321
+ (v) => hexField(v, 64),
322
+ );
323
+ }
324
+ const derive = m.deriveOrganizationScopedSeed;
325
+ if (typeof derive !== 'function') return Promise.resolve(null);
326
+ return ask(
327
+ () => derive.call(m, info, accountId),
328
+ (v) => hexField(echoes(v, accountId)?.seed, 64),
329
+ );
330
+ },
331
+ signSocialReceive: (index: number, digest: string, accountId?: string) => {
332
+ if (accountId === undefined) {
333
+ return ask(() => m.signSocialReceive(index, digest), narrowSocialReceive);
334
+ }
335
+ const sign = m.signOrganizationSocialReceive;
336
+ if (typeof sign !== 'function') return Promise.resolve(null);
337
+ return ask(
338
+ () => sign.call(m, index, digest, accountId),
339
+ (v) => narrowSocialReceive(echoes(v, accountId)),
340
+ );
341
+ },
284
342
  } satisfies CommonsIdentityBridge;
285
343
  });
286
344
  }
@@ -82,6 +82,12 @@ export interface CommonsIdentityDescription {
82
82
  v: number;
83
83
  /** The identity's public key, lowercase uncompressed SEC1 hex. */
84
84
  publicKey: string;
85
+ /**
86
+ * Set only on an answer for an ORGANIZATION key (ADR 0061 D6): the account id
87
+ * Commons echoed, always equal to the one asked for. An answer without it is
88
+ * the personal identity and is never taken as an organization's.
89
+ */
90
+ accountId?: string;
85
91
  }
86
92
 
87
93
  /**
@@ -119,19 +125,28 @@ export interface CommonsSocialReceiveSignature {
119
125
  * path in `@oxy.so/core`'s `KeyManager` owns iOS sharing.
120
126
  */
121
127
  export interface CommonsIdentityBridge {
122
- /** The public key of the identity Commons holds. */
123
- describe(): Promise<CommonsIdentityDescription | null>;
128
+ /**
129
+ * The public key of the identity Commons holds, or, with `accountId`, of that
130
+ * organization's key (ADR 0061 D6).
131
+ */
132
+ describe(accountId?: string): Promise<CommonsIdentityDescription | null>;
124
133
  /** Sign a server challenge (64 lowercase hex) for `POST /auth/verify`. */
125
134
  proveIdentity(challenge: string): Promise<CommonsIdentityProof | null>;
126
135
  /**
127
- * HKDF-SHA256 over the identity key, 32 bytes as lowercase hex, identical to
128
- * `KeyManager.deriveScopedSeed(info)` on the device that holds the key.
129
- * Commons allows each caller package only its own `info` labels.
136
+ * HKDF-SHA256 over the identity key (or, with `accountId`, the organization's
137
+ * key), 32 bytes as lowercase hex, identical to `KeyManager.deriveScopedSeed`
138
+ * on the device that holds the key. Commons allows each caller package only
139
+ * its own `info` labels.
130
140
  */
131
- deriveScopedSeed(info: string): Promise<string | null>;
141
+ deriveScopedSeed(info: string, accountId?: string): Promise<string | null>;
132
142
  /**
133
143
  * Sign a 32-byte digest (64 hex) with the social-receive child key `index`
134
- * of the identity. Allowed for the wallet app only.
144
+ * of the identity, or with `accountId`, of that organization's key. Allowed
145
+ * for the wallet app only.
135
146
  */
136
- signSocialReceive(index: number, digest: string): Promise<CommonsSocialReceiveSignature | null>;
147
+ signSocialReceive(
148
+ index: number,
149
+ digest: string,
150
+ accountId?: string,
151
+ ): Promise<CommonsSocialReceiveSignature | null>;
137
152
  }