@oxyhq/core 3.11.0 → 3.12.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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Normalized profile link shape for display.
3
+ *
4
+ * `id` is a stable key for list rendering (the source entry's id when present,
5
+ * otherwise the source index as a string). `url` is always a non-empty string.
6
+ */
7
+ export interface ProfileLink {
8
+ id: string;
9
+ title?: string;
10
+ url: string;
11
+ }
12
+ /** Source shape of a single `User.linksMetadata` entry. */
13
+ export interface ProfileLinkMetadata {
14
+ url: string;
15
+ title?: string;
16
+ description?: string;
17
+ image?: string;
18
+ id?: string;
19
+ }
20
+ /**
21
+ * Normalizes a user's profile links into a clean display shape.
22
+ *
23
+ * Pure, no side effects, no I/O.
24
+ *
25
+ * - Prefers `linksMetadata` when it is a non-empty array: maps each entry to
26
+ * `{ id, title, url }`, using `entry.id` when present and falling back to the
27
+ * entry index. Entries without a non-empty string `url` are dropped.
28
+ * - Otherwise falls back to the legacy `links` string array: maps each string to
29
+ * `{ id: <index>, url }` (no title). Empty/non-string values are dropped.
30
+ * - Returns `[]` when both are absent or empty (including when `linksMetadata`
31
+ * is present but every entry is dropped — it does NOT fall back to `links`).
32
+ *
33
+ * URLs are trimmed and blanks are filtered out. This does NOT add a scheme such
34
+ * as `https://`; prefixing is a display concern left to the caller.
35
+ */
36
+ export declare function normalizeProfileLinks(linksMetadata?: ProfileLinkMetadata[], links?: string[]): ProfileLink[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.11.0",
3
+ "version": "3.12.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -98,7 +98,7 @@
98
98
  }
99
99
  },
100
100
  "dependencies": {
101
- "@oxyhq/contracts": "^0.3.0",
101
+ "@oxyhq/contracts": "0.4.0",
102
102
  "bip39": "^3.1.0",
103
103
  "buffer": "^6.0.3",
104
104
  "elliptic": "^6.6.1",
@@ -11,7 +11,7 @@
11
11
  import { ec as EC } from 'elliptic';
12
12
  import type { SignedRecordEnvelope } from '@oxyhq/contracts';
13
13
  import { KeyManager } from '../keyManager';
14
- import { SignatureService, signedRecordSigningInput } from '../signatureService';
14
+ import { SignatureService, signedRecordSigningInput, computeRecordId } from '../signatureService';
15
15
  import { canonicalize } from '../canonicalJson';
16
16
 
17
17
  const ec = new EC('secp256k1');
@@ -123,3 +123,223 @@ describe('SignatureService.signRecord / verifyRecord', () => {
123
123
  ).rejects.toThrow(/No identity found/);
124
124
  });
125
125
  });
126
+
127
+ /**
128
+ * Critical regression guard: the v1 signing input MUST be byte-identical to the
129
+ * original scheme. Every `identity`/`profile` record already in production was
130
+ * signed over exactly these bytes, so any change to {@link signedRecordSigningInput}
131
+ * for v1 would invalidate them all. This locks the exact canonical string.
132
+ */
133
+ describe('signedRecordSigningInput — v1 byte stability (regression guard)', () => {
134
+ it('produces the exact original canonical bytes for a v1 record', () => {
135
+ const input = signedRecordSigningInput({
136
+ version: 1,
137
+ type: 'identity',
138
+ subject: 'did:web:oxy.so:u:u1',
139
+ issuer: 'did:web:oxy.so:u:u1',
140
+ record: { handle: '@nate' },
141
+ issuedAt: 1750000000000,
142
+ });
143
+ expect(input).toBe(
144
+ '{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
145
+ );
146
+ });
147
+
148
+ it('does NOT include any v2 chain fields for a v1 record even if they are passed', () => {
149
+ const input = signedRecordSigningInput({
150
+ version: 1,
151
+ type: 'identity',
152
+ subject: 'did:web:oxy.so:u:u1',
153
+ issuer: 'did:web:oxy.so:u:u1',
154
+ record: { handle: '@nate' },
155
+ issuedAt: 1750000000000,
156
+ // These must be ignored by the v1 branch — proving v1 bytes are immutable.
157
+ seq: 5,
158
+ prev: 'deadbeef',
159
+ collection: 'app.oxy.identity',
160
+ rkey: 'self',
161
+ });
162
+ expect(input).not.toContain('seq');
163
+ expect(input).not.toContain('prev');
164
+ expect(input).not.toContain('collection');
165
+ expect(input).not.toContain('rkey');
166
+ expect(input).toBe(
167
+ '{"issuedAt":1750000000000,"issuer":"did:web:oxy.so:u:u1","record":{"handle":"@nate"},"subject":"did:web:oxy.so:u:u1","type":"identity","version":1}',
168
+ );
169
+ });
170
+ });
171
+
172
+ describe('SignatureService.signRecordV2 / verifyRecord (v2 hash chain)', () => {
173
+ const keyPair = ec.genKeyPair();
174
+ const publicKey = keyPair.getPublic('hex');
175
+
176
+ beforeEach(() => {
177
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(publicKey);
178
+ jest.spyOn(KeyManager, 'getKeyPairObject').mockResolvedValue(keyPair);
179
+ });
180
+
181
+ afterEach(() => {
182
+ jest.restoreAllMocks();
183
+ });
184
+
185
+ const chain = {
186
+ seq: 0,
187
+ prev: null as string | null,
188
+ collection: 'app.oxy.reputation',
189
+ rkey: 'rt_1',
190
+ };
191
+
192
+ it('builds a well-formed v2 envelope carrying the chain fields', async () => {
193
+ const subject = 'did:web:oxy.so:u:abc123';
194
+ const envelope = await SignatureService.signRecordV2(
195
+ 'reputation_attestation',
196
+ subject,
197
+ { points: 25 },
198
+ chain,
199
+ );
200
+
201
+ expect(envelope.version).toBe(2);
202
+ expect(envelope.type).toBe('reputation_attestation');
203
+ expect(envelope.subject).toBe(subject);
204
+ expect(envelope.issuer).toBe(subject); // self-issued
205
+ expect(envelope.seq).toBe(0);
206
+ expect(envelope.prev).toBeNull();
207
+ expect(envelope.collection).toBe('app.oxy.reputation');
208
+ expect(envelope.rkey).toBe('rt_1');
209
+ expect(envelope.publicKey).toBe(publicKey);
210
+ expect(envelope.alg).toBe('ES256K-DER-SHA256');
211
+ expect(typeof envelope.signature).toBe('string');
212
+ });
213
+
214
+ it('round-trips: a freshly signed v2 record verifies', async () => {
215
+ const envelope = await SignatureService.signRecordV2(
216
+ 'validation_verdict',
217
+ 'did:web:oxy.so:u:v1',
218
+ { verdict: 'approve' },
219
+ { seq: 3, prev: 'a'.repeat(64), collection: 'app.oxy.validation', rkey: 'req_9' },
220
+ );
221
+ await expect(SignatureService.verifyRecord(envelope)).resolves.toBe(true);
222
+ });
223
+
224
+ it('covers the chain fields in the signed bytes (tampering breaks verify)', async () => {
225
+ const envelope = await SignatureService.signRecordV2(
226
+ 'reputation_attestation',
227
+ 'did:web:oxy.so:u:t1',
228
+ { points: 8 },
229
+ chain,
230
+ );
231
+
232
+ await expect(
233
+ SignatureService.verifyRecord({ ...envelope, seq: 1 }),
234
+ ).resolves.toBe(false);
235
+ await expect(
236
+ SignatureService.verifyRecord({ ...envelope, prev: 'b'.repeat(64) }),
237
+ ).resolves.toBe(false);
238
+ await expect(
239
+ SignatureService.verifyRecord({ ...envelope, collection: 'app.evil' }),
240
+ ).resolves.toBe(false);
241
+ await expect(
242
+ SignatureService.verifyRecord({ ...envelope, rkey: 'other' }),
243
+ ).resolves.toBe(false);
244
+ });
245
+
246
+ it('a v2 envelope signs over DIFFERENT bytes than the same base fields as v1', async () => {
247
+ const subject = 'did:web:oxy.so:u:diff';
248
+ const record = { x: 1 };
249
+ const v2 = await SignatureService.signRecordV2('profile', subject, record, chain);
250
+
251
+ const v1Input = signedRecordSigningInput({
252
+ version: 1,
253
+ type: 'profile',
254
+ subject,
255
+ issuer: subject,
256
+ record,
257
+ issuedAt: v2.issuedAt,
258
+ });
259
+ const v2Input = signedRecordSigningInput(v2);
260
+ expect(v2Input).not.toBe(v1Input);
261
+ expect(v2Input).toContain('"seq":0');
262
+ expect(v2Input).toContain('"prev":null');
263
+ expect(v2Input).toContain('"collection":"app.oxy.reputation"');
264
+ });
265
+
266
+ it('throws when no identity is stored', async () => {
267
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
268
+ await expect(
269
+ SignatureService.signRecordV2('identity', 'did:web:oxy.so:u:u4', {}, chain),
270
+ ).rejects.toThrow(/No identity found/);
271
+ });
272
+ });
273
+
274
+ describe('computeRecordId — deterministic content address', () => {
275
+ const base: SignedRecordEnvelope = {
276
+ version: 2,
277
+ type: 'reputation_attestation',
278
+ subject: 'did:web:oxy.so:u:rid',
279
+ issuer: 'did:web:oxy.so',
280
+ record: { points: 25 },
281
+ issuedAt: 1750000000000,
282
+ seq: 0,
283
+ prev: null,
284
+ collection: 'app.oxy.reputation',
285
+ rkey: 'rt_1',
286
+ publicKey: '03oxykey',
287
+ alg: 'ES256K-DER-SHA256',
288
+ signature: 'sig-is-not-part-of-the-id',
289
+ };
290
+
291
+ it('is a 64-char lowercase hex SHA-256 digest', async () => {
292
+ const id = await computeRecordId(base);
293
+ expect(id).toMatch(/^[0-9a-f]{64}$/);
294
+ });
295
+
296
+ it('is deterministic for identical signing fields', async () => {
297
+ const a = await computeRecordId(base);
298
+ const b = await computeRecordId(base);
299
+ expect(a).toBe(b);
300
+ });
301
+
302
+ it('equals sha256 of the canonical signing input (content address, excludes publicKey/signature)', async () => {
303
+ const id = await computeRecordId(base);
304
+ const expected = await SignatureService.hashMessage(signedRecordSigningInput(base));
305
+ expect(id).toBe(expected);
306
+ // Changing only signature/publicKey does NOT change the recordId.
307
+ const sameId = await computeRecordId({
308
+ ...base,
309
+ signature: 'totally-different',
310
+ publicKey: 'different-key',
311
+ });
312
+ expect(sameId).toBe(id);
313
+ });
314
+
315
+ it('changes when any signed field changes', async () => {
316
+ const id = await computeRecordId(base);
317
+ expect(await computeRecordId({ ...base, seq: 1 })).not.toBe(id);
318
+ expect(await computeRecordId({ ...base, record: { points: 26 } })).not.toBe(id);
319
+ expect(await computeRecordId({ ...base, prev: 'a'.repeat(64) })).not.toBe(id);
320
+ });
321
+
322
+ it('a v1 record and a v2 record with the same base fields have different recordIds', async () => {
323
+ const v1Id = await computeRecordId({
324
+ version: 1,
325
+ type: 'profile',
326
+ subject: 'did:web:oxy.so:u:rid',
327
+ issuer: 'did:web:oxy.so:u:rid',
328
+ record: { points: 25 },
329
+ issuedAt: 1750000000000,
330
+ });
331
+ const v2Id = await computeRecordId({
332
+ version: 2,
333
+ type: 'profile',
334
+ subject: 'did:web:oxy.so:u:rid',
335
+ issuer: 'did:web:oxy.so:u:rid',
336
+ record: { points: 25 },
337
+ issuedAt: 1750000000000,
338
+ seq: 0,
339
+ prev: null,
340
+ collection: 'app.oxy.profile',
341
+ rkey: 'self',
342
+ });
343
+ expect(v1Id).not.toBe(v2Id);
344
+ });
345
+ });
@@ -21,25 +21,58 @@ const ec = new EC('secp256k1');
21
21
  * EXCEPT the `publicKey` and `signature`. Both the client (when signing) and
22
22
  * the server (when verifying) canonicalize exactly these fields, so they agree
23
23
  * on the bytes that the signature covers.
24
+ *
25
+ * The v2 chain fields (`seq`/`prev`/`collection`/`rkey`) are optional: a v1
26
+ * envelope omits them and is signed over only the base fields; a v2 envelope
27
+ * carries them and includes them in the signed bytes.
24
28
  */
25
29
  export type SignedRecordSigningFields = Pick<
26
30
  SignedRecordEnvelope,
27
31
  'version' | 'type' | 'subject' | 'issuer' | 'record' | 'issuedAt'
28
- >;
32
+ > &
33
+ Partial<Pick<SignedRecordEnvelope, 'seq' | 'prev' | 'collection' | 'rkey'>>;
29
34
 
30
35
  /**
31
36
  * Compute the canonical signing input for a signed-record envelope.
32
37
  *
33
- * This is the single definition of "what the signature covers": the canonical
34
- * JSON of `{version, type, subject, issuer, record, issuedAt}`. `@oxyhq/core`
38
+ * This is the single definition of "what the signature covers". `@oxyhq/core`
35
39
  * (client signing) and `@oxyhq/api` (server verification) both call this, so a
36
40
  * record signed by a client and verified by the server cannot drift.
41
+ *
42
+ * - **v1**: the canonical JSON of `{version, type, subject, issuer, record,
43
+ * issuedAt}` — BYTE-IDENTICAL to the original scheme, so every signature
44
+ * already in production keeps verifying.
45
+ * - **v2**: the canonical JSON additionally includes the hash-chain fields
46
+ * `{seq, prev, collection, rkey}`. Because {@link canonicalize} sorts keys,
47
+ * the on-the-wire field order is irrelevant; the resulting canonical key
48
+ * order is `collection, issuedAt, issuer, prev, record, rkey, seq, subject,
49
+ * type, version`. `prev` is `null` at genesis (serialized as `null`, not
50
+ * omitted), so it is always part of the signed bytes.
37
51
  */
38
52
  export function signedRecordSigningInput(fields: SignedRecordSigningFields): string {
39
53
  const { version, type, subject, issuer, record, issuedAt } = fields;
54
+ if (version === 2) {
55
+ const { seq, prev, collection, rkey } = fields;
56
+ return canonicalize({ version, type, subject, issuer, record, issuedAt, seq, prev, collection, rkey });
57
+ }
40
58
  return canonicalize({ version, type, subject, issuer, record, issuedAt });
41
59
  }
42
60
 
61
+ /**
62
+ * Compute the `recordId` (content address) of a signed record: the SHA-256 hex
63
+ * digest of its canonical {@link signedRecordSigningInput}.
64
+ *
65
+ * Deterministic and stable across runtimes (it reuses the same canonicalization
66
+ * + SHA-256 the signature itself is built on). The recordId is what `prev`
67
+ * references in the per-subject hash chain, so `@oxyhq/core` (client) and
68
+ * `@oxyhq/api` (server) MUST compute it identically — both call this function.
69
+ * It is taken over the SIGNING input (excluding `publicKey`/`signature`), so it
70
+ * is a pure content address of the record's meaning, independent of who signed.
71
+ */
72
+ export async function computeRecordId(fields: SignedRecordSigningFields): Promise<string> {
73
+ return sha256(signedRecordSigningInput(fields));
74
+ }
75
+
43
76
  /**
44
77
  * Compute SHA-256 hash of a string
45
78
  */
@@ -419,6 +452,72 @@ export class SignatureService {
419
452
  };
420
453
  }
421
454
 
455
+ /**
456
+ * Build a signed-record envelope (v2) carrying the per-subject hash-chain
457
+ * fields.
458
+ *
459
+ * Identical to {@link signRecord} (self-issued: `issuer === subject`; same
460
+ * `ES256K-DER-SHA256` scheme over {@link signedRecordSigningInput}) but
461
+ * `version` is `2` and the signed bytes additionally cover the chain fields:
462
+ *
463
+ * @param type - The record category.
464
+ * @param subject - The subject DID the record is about (also the issuer).
465
+ * @param record - The arbitrary record payload to attest to.
466
+ * @param chain - The hash-chain coordinates:
467
+ * - `seq` — strictly-increasing sequence number for this subject's chain.
468
+ * - `prev` — the `recordId` of the previous record, or `null` at genesis.
469
+ * - `collection` + `rkey` — the AtProto-style record key.
470
+ *
471
+ * The caller is responsible for fetching the current chain head (so `seq` /
472
+ * `prev` are correct) before signing. Requires a stored identity; throws if
473
+ * none exists.
474
+ */
475
+ static async signRecordV2(
476
+ type: SignedRecordEnvelope['type'],
477
+ subject: string,
478
+ record: Record<string, unknown>,
479
+ chain: { seq: number; prev: string | null; collection: string; rkey: string },
480
+ ): Promise<SignedRecordEnvelope> {
481
+ const publicKey = await KeyManager.getPublicKey();
482
+ if (!publicKey) {
483
+ throw new Error('No identity found. Please create or import an identity first.');
484
+ }
485
+
486
+ const version = 2 as const;
487
+ const issuer = subject;
488
+ const issuedAt = Date.now();
489
+ const { seq, prev, collection, rkey } = chain;
490
+ const signingInput = signedRecordSigningInput({
491
+ version,
492
+ type,
493
+ subject,
494
+ issuer,
495
+ record,
496
+ issuedAt,
497
+ seq,
498
+ prev,
499
+ collection,
500
+ rkey,
501
+ });
502
+ const signature = await SignatureService.sign(signingInput);
503
+
504
+ return {
505
+ version,
506
+ type,
507
+ subject,
508
+ issuer,
509
+ record,
510
+ issuedAt,
511
+ seq,
512
+ prev,
513
+ collection,
514
+ rkey,
515
+ publicKey,
516
+ alg: 'ES256K-DER-SHA256',
517
+ signature,
518
+ };
519
+ }
520
+
422
521
  /**
423
522
  * Verify a signed-record envelope: recompute the canonical signing input from
424
523
  * the envelope's own fields and check the signature against the envelope's
package/src/index.ts CHANGED
@@ -89,6 +89,8 @@ export {
89
89
  getNormalizedUserHandle,
90
90
  } from './utils/userHandle';
91
91
  export type { CanonicalUserHandleInput, UserHandleInput } from './utils/userHandle';
92
+ export { normalizeProfileLinks } from './utils/profileLinks';
93
+ export type { ProfileLink, ProfileLinkMetadata } from './utils/profileLinks';
92
94
 
93
95
  // ---------------------------------------------------------------------------
94
96
  // Applications (multi-user apps: membership, roles, credentials)
@@ -183,6 +185,32 @@ export type {
183
185
  RemoveDomainResult,
184
186
  } from './mixins/OxyServices.identity';
185
187
 
188
+ // ---------------------------------------------------------------------------
189
+ // Civic / Commons "Oxy ID" (public signed cards + Oxy ID QR payload) and Fase 2
190
+ // anti-gaming (real-life attestation QR + validator/jury). Wire shapes
191
+ // (PublicCard, SignedPublicCard, RealLifeAttestationResult,
192
+ // ValidationRequestSummary, ValidationVoteResult, ValidationVerdict, …) live in
193
+ // `@oxyhq/contracts` — import them from there. The SDK adds the client verdict
194
+ // wrapper, the QR payload parsers/builders, and the submit inputs/results.
195
+ // ---------------------------------------------------------------------------
196
+ export {
197
+ parseIdPayload,
198
+ parseAttestPayload,
199
+ verifyPublicCardAttestation,
200
+ } from './mixins/OxyServices.civic';
201
+ export type {
202
+ CivicCardResult,
203
+ IdCardRef,
204
+ AttestQrPayload,
205
+ ParsedAttestPayload,
206
+ SubmitRealLifeAttestationInput,
207
+ DenyValidationResult,
208
+ VouchForPersonInput,
209
+ WithdrawVouchResult,
210
+ IssueCredentialInput,
211
+ RevokeCredentialResult,
212
+ } from './mixins/OxyServices.civic';
213
+
186
214
  // ---------------------------------------------------------------------------
187
215
  // Auth helpers (token refresh, error normalisation, retry policies)
188
216
  // ---------------------------------------------------------------------------
@@ -230,7 +258,7 @@ export {
230
258
  IdentityPersistError,
231
259
  } from './crypto/keyManager';
232
260
  export type { KeyPair } from './crypto/keyManager';
233
- export { SignatureService, signedRecordSigningInput } from './crypto/signatureService';
261
+ export { SignatureService, signedRecordSigningInput, computeRecordId } from './crypto/signatureService';
234
262
  export type { SignedMessage, AuthChallenge, SignedRecordSigningFields } from './crypto/signatureService';
235
263
  export { canonicalize } from './crypto/canonicalJson';
236
264
  export { RecoveryPhraseService } from './crypto/recoveryPhrase';