@oxy.so/contracts 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/identity.js +3 -2
  3. package/dist/cjs/identityMove.js +111 -40
  4. package/dist/cjs/identityProof.js +164 -0
  5. package/dist/cjs/identityRecovery.js +51 -0
  6. package/dist/cjs/index.js +50 -21
  7. package/dist/cjs/inference/identifiers.js +3 -1
  8. package/dist/cjs/inference/providerConnection.js +1 -1
  9. package/dist/cjs/inference/request.js +15 -1
  10. package/dist/cjs/inference/streamEvents.js +24 -2
  11. package/dist/cjs/username.js +70 -2
  12. package/dist/cjs/webIdentityCarrier.js +112 -53
  13. package/dist/cjs/webauthn.js +14 -0
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/identity.js +3 -2
  16. package/dist/esm/identityMove.js +105 -39
  17. package/dist/esm/identityProof.js +159 -0
  18. package/dist/esm/identityRecovery.js +48 -0
  19. package/dist/esm/index.js +11 -8
  20. package/dist/esm/inference/identifiers.js +2 -0
  21. package/dist/esm/inference/providerConnection.js +2 -2
  22. package/dist/esm/inference/request.js +14 -0
  23. package/dist/esm/inference/streamEvents.js +24 -2
  24. package/dist/esm/username.js +69 -1
  25. package/dist/esm/webIdentityCarrier.js +111 -52
  26. package/dist/esm/webauthn.js +14 -0
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/accountGraph.d.ts +7 -7
  29. package/dist/types/agency.d.ts +24 -24
  30. package/dist/types/browserHub.d.ts +16 -16
  31. package/dist/types/deviceDirectory.d.ts +28 -28
  32. package/dist/types/externalIdentity.d.ts +7 -7
  33. package/dist/types/identity.d.ts +3 -2
  34. package/dist/types/identityMove.d.ts +119 -43
  35. package/dist/types/identityProof.d.ts +156 -0
  36. package/dist/types/identityRecovery.d.ts +246 -0
  37. package/dist/types/index.d.ts +13 -11
  38. package/dist/types/inference/identifiers.d.ts +2 -0
  39. package/dist/types/inference/providerConnection.d.ts +40 -40
  40. package/dist/types/inference/request.d.ts +84 -36
  41. package/dist/types/inference/streamEvents.d.ts +57 -1
  42. package/dist/types/keyRotation.d.ts +2 -2
  43. package/dist/types/oauth.d.ts +30 -30
  44. package/dist/types/sessionStatus.d.ts +6 -6
  45. package/dist/types/transparency.d.ts +10 -10
  46. package/dist/types/userResponse.d.ts +18 -18
  47. package/dist/types/username.d.ts +25 -2
  48. package/dist/types/webIdentityCarrier.d.ts +767 -159
  49. package/dist/types/webauthn.d.ts +208 -0
  50. package/package.json +1 -1
  51. package/dist/cjs/devicePairing.js +0 -138
  52. package/dist/esm/devicePairing.js +0 -135
  53. package/dist/types/devicePairing.d.ts +0 -130
@@ -14,8 +14,9 @@
14
14
  * - DID = `did:web:oxy.so:u:<userId>` — anchored on the stable account id, NOT
15
15
  * the keypair. The keypair is a *verification method* that maps 1:1 to the
16
16
  * existing `authMethods[]`. Custodial (password-only) users get a DID
17
- * controlled solely by Oxy (`OXY_DID`); creating a Commons key upgrades them
18
- * to self-sovereign (`controller = [userDid, OXY_DID]`); fully reversible.
17
+ * controlled solely by Oxy (`OXY_DID`); linking a root makes them
18
+ * self-sovereign (`controller = [userDid]`, ADR 0024 D9). A root is never
19
+ * unlinked back into a custodial account.
19
20
  * - Verification methods use the secp256k1 `EcdsaSecp256k1VerificationKey2019`
20
21
  * type with `publicKeyHex` for now (a `Multikey`/`publicKeyMultibase` form may
21
22
  * be added later — see the plan's open risks).
@@ -1,34 +1,38 @@
1
1
  /**
2
- * Identity move contract — take a web identity INTO Commons (a MOVE, not a copy).
2
+ * Identity move contract — give a web root to Commons (add it, or keep it only in
3
+ * Commons; ADR 0024 D6).
3
4
  *
4
- * Design: `docs/superpowers/specs/2026-09-15-one-identity-two-carriers-design.md` §5.
5
+ * The web (holding the root) shows a QR carrying only a move id; Commons scans.
6
+ * The relay in between must not be able to take the root, so:
5
7
  *
6
- * Flow (the web — the OLD carrier — shows the QR; Commons — the NEW carrier —
7
- * scans it):
8
- * 1. `id.oxy.so` generates an ephemeral secp256k1 pair and calls
9
- * `POST /identity/move { initiatorEphemeralPublicKey }` (bearer) →
10
- * `{ moveId, expiresAt }`. The QR carries `moveId` only.
11
- * 2. Commons scans, generates its own ephemeral pair, and calls
12
- * `POST /identity/move/:moveId/join { responderEphemeralPublicKey }` (no
13
- * bearer — Commons has no identity yet).
14
- * 3. Both sides derive the same 6-digit SAS from the move id and BOTH ephemeral
15
- * keys and show it. The person confirms on the web that they match. A relay
16
- * that substituted either key produces two different codes.
17
- * 4. The web seals the BIP-39 entropy under
18
- * `HKDF(ECDH(initiatorEph, responderEph), moveId, 'oxy-identity-move-v1')` and
19
- * calls `POST /identity/move/:moveId/seal` (bearer + identity-key proof).
20
- * 5. Commons decrypts, imports the identity, and posts a RECEIPT: a signature by
21
- * the identity key over `{ action:'identity_move_received', moveId, timestamp }`
22
- * (`POST /identity/move/:moveId/receipt`). The server checks it against the
23
- * account's key; the WEB verifies it again locally before destroying its copy,
24
- * so not even the server can fake a completed move.
8
+ * 1. `POST /identity/move { initiatorCommitment }` — the web publishes only
9
+ * `H(initiatorKey, nonce)`, never the key.
10
+ * 2. `POST /identity/move/:moveId/join { responderEphemeralPublicKey }` —
11
+ * Commons, having read the commitment first, joins with its own key.
12
+ * 3. `POST /identity/move/:moveId/reveal` — only then does the web reveal its
13
+ * key and nonce; Commons checks them against the commitment it read.
14
+ * 4. Both screens show a 6-digit code over `(moveId, both keys, commitment)`;
15
+ * the person confirms on the web that they match.
16
+ * 5. `POST /identity/move/:moveId/seal` — the web seals the phrase entropy under
17
+ * `HKDF(ECDH(both keys), moveId)`, authorized by a one-use root proof over the
18
+ * exact sealed bytes.
19
+ * 6. `POST /identity/move/:moveId/receipt` — Commons stores the root, reads it
20
+ * back from its keychain, and signs a receipt over the move, the root, both
21
+ * keys and the digest of the ciphertext it opened. The server verifies it; the
22
+ * web verifies it again from what IT sealed before removing anything.
25
23
  *
26
- * The server holds two ephemeral public keys, an opaque ciphertext, and a
27
- * receipt. It never holds anything that decrypts the ciphertext.
24
+ * Why the commitment: without it the relay sees both keys before committing to
25
+ * anything and can grind a substituted key until the two codes agree (~10⁶
26
+ * tries, seconds). With it, the relay must fix the key it shows Commons before
27
+ * learning Commons' key, and the key it shows the web before the web reveals.
28
+ *
29
+ * The server holds a commitment, two ephemeral public keys, an opaque ciphertext
30
+ * and a receipt — nothing that decrypts the ciphertext.
28
31
  *
29
32
  * Platform-agnostic — zod only, ESM-safe (no `require()`).
30
33
  */
31
34
  import { z } from 'zod';
35
+ import { canonicalJson, identityProofSchema } from './identityProof.js';
32
36
  /** A move lives this long: one interactive handoff. */
33
37
  export const IDENTITY_MOVE_TTL_MS = 5 * 60 * 1000;
34
38
  /** 128-bit move id, lowercase hex. */
@@ -44,9 +48,13 @@ export const identityMoveEphemeralKeySchema = z
44
48
  export const IDENTITY_MOVE_STATUSES = ['pending', 'joined', 'sealed', 'completed', 'cancelled', 'expired'];
45
49
  /** The QR payload Commons scans. Carries the move id only. */
46
50
  export const IDENTITY_MOVE_QR_PREFIX = 'oxycommons://move?id=';
47
- export const identityMoveCreateRequestSchema = z.object({
48
- initiatorEphemeralPublicKey: identityMoveEphemeralKeySchema,
49
- });
51
+ const hex64 = (label) => z.string().trim().regex(/^[0-9a-f]{64}$/, `${label} must be 64 lowercase hex characters`);
52
+ export const identityMoveCreateRequestSchema = z
53
+ .object({
54
+ /** `H(initiator ephemeral key, nonce)` — the key itself is revealed only after Commons joins. */
55
+ initiatorCommitment: hex64('initiatorCommitment'),
56
+ })
57
+ .strict();
50
58
  export const identityMoveCreateResponseSchema = z.object({
51
59
  moveId: identityMoveIdSchema,
52
60
  expiresAt: z.string().datetime(),
@@ -54,29 +62,87 @@ export const identityMoveCreateResponseSchema = z.object({
54
62
  export const identityMoveJoinRequestSchema = z.object({
55
63
  responderEphemeralPublicKey: identityMoveEphemeralKeySchema,
56
64
  });
57
- export const identityMoveSealRequestSchema = z.object({
65
+ /** After Commons joined: the committed key and its nonce. */
66
+ export const identityMoveRevealRequestSchema = z
67
+ .object({
68
+ initiatorEphemeralPublicKey: identityMoveEphemeralKeySchema,
69
+ commitmentNonce: hex64('commitmentNonce'),
70
+ })
71
+ .strict();
72
+ export const identityMoveSealRequestSchema = z
73
+ .object({
58
74
  /** 24-byte XChaCha20-Poly1305 nonce, hex. */
59
75
  nonce: z.string().trim().regex(/^[0-9a-f]{48}$/, 'nonce must be 48 lowercase hex characters'),
60
- /** The 16-byte entropy, tag appended (32 bytes), hex. */
61
- ciphertext: z.string().trim().regex(/^[0-9a-f]{64}$/, 'ciphertext must be 64 lowercase hex characters'),
62
- /** Identity-key proof over `{ action:'identity_move_seal', moveId, timestamp }`. */
63
- signature: z.string().trim().min(1).max(512),
64
- timestamp: z.number().int().positive(),
65
- });
66
- export const identityMoveReceiptRequestSchema = z.object({
67
- /** Identity-key signature over `{ action:'identity_move_received', moveId, timestamp }`. */
76
+ /** The 16–32-byte phrase entropy (12–24 words), tag appended, hex. */
77
+ ciphertext: z
78
+ .string()
79
+ .trim()
80
+ .regex(/^(?:[0-9a-f]{64}|[0-9a-f]{72}|[0-9a-f]{80}|[0-9a-f]{88}|[0-9a-f]{96})$/, 'ciphertext has an unsupported length'),
81
+ /**
82
+ * Root proof (`identity_move_seal`), payload `{ moveId, nonce, ciphertext }`,
83
+ * over a one-use challenge from `POST /identity/proof-challenge`.
84
+ */
85
+ proof: identityProofSchema,
86
+ })
87
+ .strict();
88
+ export const identityMoveReceiptRequestSchema = z
89
+ .object({
90
+ /** Root signature over `buildMoveReceiptMessage(...)`. */
68
91
  signature: z.string().trim().min(1).max(512),
69
- timestamp: z.number().int().positive(),
70
- });
92
+ })
93
+ .strict();
71
94
  export const identityMoveStateSchema = z.object({
72
95
  moveId: identityMoveIdSchema,
73
96
  status: z.enum(IDENTITY_MOVE_STATUSES),
97
+ initiatorCommitment: z.string(),
98
+ initiatorCommitmentNonce: z.string().nullable(),
74
99
  publicKey: z.string().regex(/^04[0-9a-f]{128}$/),
75
- initiatorEphemeralPublicKey: identityMoveEphemeralKeySchema,
100
+ initiatorEphemeralPublicKey: identityMoveEphemeralKeySchema.nullable(),
76
101
  responderEphemeralPublicKey: identityMoveEphemeralKeySchema.nullable(),
77
102
  nonce: z.string().nullable(),
78
103
  ciphertext: z.string().nullable(),
79
104
  receiptSignature: z.string().nullable(),
80
- receiptTimestamp: z.number().int().nullable(),
81
105
  expiresAt: z.string().datetime(),
82
106
  });
107
+ /**
108
+ * The initiator's commitment input, `canonicalJson({ v, purpose, key, nonce })`,
109
+ * which the caller hashes with SHA-256.
110
+ */
111
+ export function buildMoveCommitmentInput(initiatorEphemeralPublicKey, nonce) {
112
+ return canonicalJson({
113
+ v: 2,
114
+ purpose: 'oxy-identity-move-initiator-commitment',
115
+ initiatorEphemeralPublicKey: initiatorEphemeralPublicKey.toLowerCase(),
116
+ nonce: nonce.toLowerCase(),
117
+ });
118
+ }
119
+ /** The bytes both sides hash for the 6-digit code. */
120
+ export function buildMoveSasInput(input) {
121
+ return canonicalJson({
122
+ v: 'oxy-identity-transfer-sas-v2',
123
+ moveId: input.moveId.toLowerCase(),
124
+ initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
125
+ responder: input.responderEphemeralPublicKey.toLowerCase(),
126
+ commitment: input.initiatorCommitment.toLowerCase(),
127
+ });
128
+ }
129
+ /** The ciphertext digest input the receipt binds: `canonicalJson({ nonce, ciphertext })`. */
130
+ export function buildMoveCiphertextDigestInput(sealed) {
131
+ return canonicalJson({ nonce: sealed.nonce.toLowerCase(), ciphertext: sealed.ciphertext.toLowerCase() });
132
+ }
133
+ /** The payload a seal proof digests. */
134
+ export function buildMoveSealPayload(moveId, sealed) {
135
+ return { moveId: moveId.toLowerCase(), nonce: sealed.nonce.toLowerCase(), ciphertext: sealed.ciphertext.toLowerCase() };
136
+ }
137
+ /** The exact bytes a receipt signs. */
138
+ export function buildMoveReceiptMessage(input) {
139
+ return canonicalJson({
140
+ v: 2,
141
+ domain: 'oxy-identity-move-receipt',
142
+ moveId: input.moveId.toLowerCase(),
143
+ rootPublicKey: input.rootPublicKey.toLowerCase(),
144
+ initiator: input.initiatorEphemeralPublicKey.toLowerCase(),
145
+ responder: input.responderEphemeralPublicKey.toLowerCase(),
146
+ ciphertextDigest: input.ciphertextDigest.toLowerCase(),
147
+ });
148
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Identity proof contract — the ONE signed format for operations on a personal root.
3
+ *
4
+ * ADR 0024 D7. A proof is a signature by the root key over the canonical bytes
5
+ * of {@link IdentityProofClaims}. Every field the verifier cares about is IN the
6
+ * signed bytes, so a signature cannot be moved to another operation, account,
7
+ * root, payload, revision or audience, and the one-use `challenge` means it
8
+ * cannot be replayed either (a timestamp window is not replay protection).
9
+ *
10
+ * Both sides build the bytes with {@link buildIdentityProofMessage} and hash
11
+ * payloads with {@link canonicalJson}; neither writes its own JSON template, so
12
+ * the client and the verifier cannot drift apart.
13
+ *
14
+ * Platform-agnostic — zod only, ESM-safe (no `require()`), no hashing here (the
15
+ * caller hashes `canonicalJson(payload)` with SHA-256 using its platform's
16
+ * primitive and passes the hex digest in).
17
+ */
18
+ import { z } from 'zod';
19
+ export const IDENTITY_PROOF_VERSION = 2;
20
+ export const IDENTITY_PROOF_DOMAIN = 'oxy-identity-proof';
21
+ /** The audience every API-verified identity proof names. */
22
+ export const IDENTITY_PROOF_AUDIENCE = 'oxy-api/identity';
23
+ /** How long a proof challenge lives: one interactive ceremony. */
24
+ export const IDENTITY_PROOF_CHALLENGE_TTL_MS = 5 * 60 * 1000;
25
+ /**
26
+ * Everything a root proof may authorize. A challenge is minted for exactly one
27
+ * action and spent only by a proof for that action.
28
+ */
29
+ export const IDENTITY_PROOF_ACTIONS = {
30
+ /** A keyless account's FIRST root, stored with its web envelope. */
31
+ establish: 'web_envelope_establish',
32
+ /** Replace the web envelope (add or remove a wrap, re-seal). */
33
+ put: 'web_envelope_put',
34
+ /** Record that the recovery material is written down. */
35
+ phraseConfirmed: 'web_envelope_phrase_confirmed',
36
+ /** Record that the recovery material re-derived the root. */
37
+ recoveryVerified: 'web_envelope_recovery_verified',
38
+ /** Remove the web holder. */
39
+ delete: 'web_envelope_delete',
40
+ /** Link a keyless account's first root without a web envelope (`POST /auth/link`). */
41
+ link: 'link_identity',
42
+ /** Create a personal account together with its root (passkey sign-up). */
43
+ enroll: 'enroll_identity',
44
+ /** Prove the root to start signed-out recovery. */
45
+ recoverStart: 'recover_account_start',
46
+ /** Bind the new passkey and envelope when completing signed-out recovery. */
47
+ recoverComplete: 'recover_account_complete',
48
+ /** Seal the root for the Commons device that joined a move (payload: move id + sealed bytes). */
49
+ moveSeal: 'identity_move_seal',
50
+ };
51
+ export const IDENTITY_PROOF_ACTION_VALUES = Object.values(IDENTITY_PROOF_ACTIONS);
52
+ const HEX_DIGEST = /^[0-9a-f]{64}$/;
53
+ const ROOT_KEY = /^04[0-9a-f]{128}$/;
54
+ const CHALLENGE = /^[0-9a-f]{64}$/;
55
+ /**
56
+ * Canonical JSON: object keys sorted by UTF-16 code unit, no whitespace,
57
+ * `undefined` members omitted, arrays in order. Numbers must be finite. This is
58
+ * the ONLY serializer for anything digested into a proof.
59
+ */
60
+ export function canonicalJson(value) {
61
+ if (value === null)
62
+ return 'null';
63
+ switch (typeof value) {
64
+ case 'string':
65
+ case 'boolean':
66
+ return JSON.stringify(value);
67
+ case 'number':
68
+ if (!Number.isFinite(value))
69
+ throw new Error('canonicalJson: non-finite number');
70
+ return JSON.stringify(value);
71
+ case 'object': {
72
+ if (Array.isArray(value)) {
73
+ return `[${value.map((entry) => (entry === undefined ? 'null' : canonicalJson(entry))).join(',')}]`;
74
+ }
75
+ const record = value;
76
+ const keys = Object.keys(record)
77
+ .filter((key) => record[key] !== undefined)
78
+ .sort();
79
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
80
+ }
81
+ default:
82
+ throw new Error(`canonicalJson: unsupported ${typeof value}`);
83
+ }
84
+ }
85
+ /**
86
+ * The exact bytes a root signs. Throws on a malformed claim rather than signing
87
+ * (or verifying) something ambiguous.
88
+ */
89
+ export function buildIdentityProofMessage(claims) {
90
+ if (!IDENTITY_PROOF_ACTION_VALUES.includes(claims.action))
91
+ throw new Error('identity proof: unknown action');
92
+ if (!claims.subject || !claims.actor)
93
+ throw new Error('identity proof: subject and actor are required');
94
+ if (!ROOT_KEY.test(claims.rootPublicKey))
95
+ throw new Error('identity proof: rootPublicKey must be canonical');
96
+ if (claims.payloadDigest !== null && !HEX_DIGEST.test(claims.payloadDigest)) {
97
+ throw new Error('identity proof: payloadDigest must be a lowercase SHA-256 hex digest');
98
+ }
99
+ if (claims.expectedRevision !== null && (!Number.isSafeInteger(claims.expectedRevision) || claims.expectedRevision < 0)) {
100
+ throw new Error('identity proof: expectedRevision must be a non-negative integer');
101
+ }
102
+ if (!CHALLENGE.test(claims.challenge))
103
+ throw new Error('identity proof: challenge must be 64 lowercase hex characters');
104
+ if (!Number.isSafeInteger(claims.expiresAt) || claims.expiresAt <= 0)
105
+ throw new Error('identity proof: expiresAt must be unix milliseconds');
106
+ return canonicalJson({
107
+ v: IDENTITY_PROOF_VERSION,
108
+ domain: IDENTITY_PROOF_DOMAIN,
109
+ action: claims.action,
110
+ subject: claims.subject,
111
+ actor: claims.actor,
112
+ rootPublicKey: claims.rootPublicKey,
113
+ payloadDigest: claims.payloadDigest,
114
+ expectedRevision: claims.expectedRevision,
115
+ audience: claims.audience,
116
+ challenge: claims.challenge,
117
+ expiresAt: claims.expiresAt,
118
+ });
119
+ }
120
+ /** The proof as it travels: the signature plus the two claims the verifier cannot derive. */
121
+ export const identityProofSchema = z.object({
122
+ v: z.literal(IDENTITY_PROOF_VERSION),
123
+ challenge: z.string().trim().regex(CHALLENGE, 'challenge must be 64 lowercase hex characters'),
124
+ expiresAt: z.number().int().positive(),
125
+ signature: z.string().trim().min(1).max(512),
126
+ });
127
+ /** `POST /identity/proof-challenge` */
128
+ export const identityProofChallengeRequestSchema = z.object({
129
+ action: z.enum(IDENTITY_PROOF_ACTION_VALUES),
130
+ });
131
+ export const identityProofChallengeResponseSchema = z.object({
132
+ challenge: z.string().regex(CHALLENGE),
133
+ expiresAt: z.number().int().positive(),
134
+ audience: z.string().min(1),
135
+ });
136
+ /**
137
+ * Stable error codes the root routes answer with (`error.code` in the API error
138
+ * body). Clients map these through their localization, never the English message.
139
+ */
140
+ export const IDENTITY_ERROR_CODES = {
141
+ proofInvalid: 'IDENTITY_PROOF_INVALID',
142
+ revisionConflict: 'IDENTITY_ENVELOPE_REVISION_CONFLICT',
143
+ rootAlreadyLinked: 'IDENTITY_ROOT_ALREADY_LINKED',
144
+ rootLinkedElsewhere: 'IDENTITY_ROOT_LINKED_ELSEWHERE',
145
+ noRoot: 'IDENTITY_NO_ROOT',
146
+ freshFactorRequired: 'IDENTITY_FRESH_FACTOR_REQUIRED',
147
+ lastWebHolder: 'IDENTITY_LAST_WEB_HOLDER',
148
+ enrollmentRequired: 'IDENTITY_ENROLLMENT_REQUIRED',
149
+ enrollmentInvalid: 'IDENTITY_ENROLLMENT_INVALID',
150
+ notPersonal: 'IDENTITY_NOT_PERSONAL_ACCOUNT',
151
+ recoveryFailed: 'IDENTITY_RECOVERY_FAILED',
152
+ };
153
+ export const identityRootStatusSchema = z.object({
154
+ rootLinked: z.boolean(),
155
+ webHolder: z.object({ passkeys: z.number().int().nonnegative(), verifiedPasskeys: z.number().int().nonnegative() }).nullable(),
156
+ hasPhrase: z.boolean().nullable(),
157
+ phraseConfirmedAt: z.string().datetime().nullable(),
158
+ recoveryVerifiedAt: z.string().datetime().nullable(),
159
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Signed-out recovery contract (ADR 0024 D5) — get an EXISTING account back from
3
+ * its root alone, with no passkey, session or email.
4
+ *
5
+ * 1. `POST /identity/recovery/challenge` → a one-use challenge. It says nothing
6
+ * about any account.
7
+ * 2. The holder derives the root locally from recovery material (12/24 words or a
8
+ * raw key) and signs `recover_account_start` over that challenge:
9
+ * `POST /identity/recovery/start { publicKey, proof }`. Only a valid proof
10
+ * learns which account the root belongs to; the response carries passkey
11
+ * registration options for THAT account and a short-lived ticket.
12
+ * 3. The holder creates the passkey, seals the root under it (PRF), and signs
13
+ * `recover_account_complete` over the registration challenge and the envelope
14
+ * digest: `POST /identity/recovery/complete`. The passkey, the envelope and a
15
+ * session are created in one transaction.
16
+ *
17
+ * The recovery material and the root never leave the holder. Platform-agnostic —
18
+ * zod only, ESM-safe.
19
+ */
20
+ import { z } from 'zod';
21
+ import { identityProofSchema } from './identityProof.js';
22
+ import { webIdentityEnvelopeSchema, webIdentityPublicKeySchema } from './webIdentityCarrier.js';
23
+ /** A recovery attempt lives this long between steps. */
24
+ export const IDENTITY_RECOVERY_TTL_MS = 5 * 60 * 1000;
25
+ export const identityRecoveryChallengeResponseSchema = z.object({
26
+ challenge: z.string().regex(/^[0-9a-f]{64}$/),
27
+ expiresAt: z.number().int().positive(),
28
+ });
29
+ export const identityRecoveryStartRequestSchema = z
30
+ .object({
31
+ publicKey: webIdentityPublicKeySchema,
32
+ /** `recover_account_start`, subject `root:<publicKey>`, actor `anonymous`. */
33
+ proof: identityProofSchema,
34
+ })
35
+ .strict();
36
+ export const identityRecoveryCompleteRequestSchema = z
37
+ .object({
38
+ ticket: z.string().regex(/^[0-9a-f]{64}$/),
39
+ /** The browser `RegistrationResponseJSON`, verified by the API's WebAuthn library. */
40
+ response: z.record(z.string(), z.unknown()),
41
+ /** The root sealed under the new passkey — exactly one wrap, for that credential. */
42
+ envelope: webIdentityEnvelopeSchema,
43
+ /** `recover_account_complete`, subject = account id, actor `credential:<id>`, challenge = registration challenge (hex). */
44
+ proof: identityProofSchema,
45
+ deviceName: z.string().trim().max(100).optional(),
46
+ deviceFingerprint: z.string().trim().max(512).optional(),
47
+ })
48
+ .strict();
package/dist/esm/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * expo, no `require()` in the ESM build.
11
11
  */
12
12
  export { ACCOUNT_KINDS, accountKindSchema, CHILD_ACCOUNT_KINDS, childAccountKindSchema, isAccountKind, isDelegatedActAsEligibleKind, isOperatorSwitchTargetKind, ACCOUNT_CATEGORY_IDS, ACCOUNT_CATEGORY_KINDS, accountCategoriesSchema, accountCategoryIdSchema, isSelectableAccountCategoryId, kindAcceptsAccountCategories, MAX_ACCOUNT_CATEGORIES, newlyAddedRetiredCategories, RETIRED_ACCOUNT_CATEGORY_IDS, SELECTABLE_ACCOUNT_CATEGORY_IDS, createAccountRequestSchema, } from './accountGraph.js';
13
- export { usernameSchema, usernameSchemaForAccountKind, isValidUsername, stripDisallowedUsernameCharacters, applyBotUsernameSuffix, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_INVALID_MESSAGE, BOT_USERNAME_INVALID_MESSAGE, } from './username.js';
13
+ export { usernameSchema, usernameSchemaForAccountKind, isValidUsername, stripDisallowedUsernameCharacters, applyBotUsernameSuffix, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_INVALID_MESSAGE, BOT_USERNAME_INVALID_MESSAGE, RESERVED_USERNAME_MESSAGE, NUMERIC_USERNAME_MESSAGE, } from './username.js';
14
14
  export {
15
15
  // Schemas
16
16
  userNameSchema, userRelationshipSchema, themePreferenceSchema, dateOfBirthSchema, userResponseSchema, userProfileUpdateSchema, currentUserResponseSchema, deviceLinkedSessionSchema, deviceLinkedSessionsResponseSchema,
@@ -82,10 +82,16 @@ export {
82
82
  backupLookupIdSchema, encryptedBackupEnvelopeSchema, backupUploadRequestSchema, backupStatusResponseSchema, } from './keyRecovery.js';
83
83
  export {
84
84
  // Schemas — web identity carrier (one identity, two carriers)
85
- WEB_IDENTITY_ENVELOPE_VERSION, webIdentityPublicKeySchema, webauthnCredentialIdSchema, webIdentityWrapSchema, webIdentityEnvelopeSchema, webIdentityEnvelopeUploadSchema, webIdentityEnvelopeResponseSchema, webIdentityEnvelopeProofSchema, webIdentityEnvelopePutSchema, webIdentityEnvelopeEstablishSchema, } from './webIdentityCarrier.js';
85
+ WEB_IDENTITY_ENVELOPE_VERSION, WEB_IDENTITY_SECRET_KINDS, webIdentityPublicKeySchema, webauthnCredentialIdSchema, webauthnRpIdSchema, webIdentityWrapSchema, webIdentityEnvelopeSchema, webIdentityEnvelopeUploadSchema, webIdentityHolderSchema, webIdentityEnvelopeResponseSchema, webIdentityEnvelopeProofFieldsSchema, webIdentityEnvelopeActionSchema, webIdentityEnvelopePutSchema, webauthnAssertionResponseSchema, webIdentityEnvelopeEstablishSchema, } from './webIdentityCarrier.js';
86
+ export {
87
+ // Identity proofs (ADR 0024 D7) — the one signed format for root operations
88
+ IDENTITY_PROOF_VERSION, IDENTITY_PROOF_DOMAIN, IDENTITY_PROOF_AUDIENCE, IDENTITY_PROOF_CHALLENGE_TTL_MS, IDENTITY_PROOF_ACTIONS, IDENTITY_PROOF_ACTION_VALUES, IDENTITY_ERROR_CODES, canonicalJson, buildIdentityProofMessage, identityProofSchema, identityProofChallengeRequestSchema, identityProofChallengeResponseSchema, identityRootStatusSchema, } from './identityProof.js';
89
+ export {
90
+ // Signed-out recovery (ADR 0024 D5)
91
+ IDENTITY_RECOVERY_TTL_MS, identityRecoveryChallengeResponseSchema, identityRecoveryStartRequestSchema, identityRecoveryCompleteRequestSchema, } from './identityRecovery.js';
86
92
  export {
87
93
  // Schemas — moving a web identity into Commons
88
- IDENTITY_MOVE_TTL_MS, IDENTITY_MOVE_STATUSES, IDENTITY_MOVE_QR_PREFIX, identityMoveIdSchema, identityMoveEphemeralKeySchema, identityMoveCreateRequestSchema, identityMoveCreateResponseSchema, identityMoveJoinRequestSchema, identityMoveSealRequestSchema, identityMoveReceiptRequestSchema, identityMoveStateSchema, } from './identityMove.js';
94
+ IDENTITY_MOVE_TTL_MS, IDENTITY_MOVE_STATUSES, IDENTITY_MOVE_QR_PREFIX, identityMoveRevealRequestSchema, buildMoveCommitmentInput, buildMoveSasInput, buildMoveSealPayload, buildMoveCiphertextDigestInput, buildMoveReceiptMessage, identityMoveIdSchema, identityMoveEphemeralKeySchema, identityMoveCreateRequestSchema, identityMoveCreateResponseSchema, identityMoveJoinRequestSchema, identityMoveSealRequestSchema, identityMoveReceiptRequestSchema, identityMoveStateSchema, } from './identityMove.js';
89
95
  export {
90
96
  // Shared primitives
91
97
  updatePlatformSchema, updateStatusSchema, updateAssetStatusSchema, sha256HexSchema, channelNameSchema, runtimeVersionSchema, rolloutPercentSchema,
@@ -101,9 +107,6 @@ export {
101
107
  // Schemas
102
108
  webauthnRegisterOptionsRequestSchema, webauthnLoginOptionsRequestSchema, webauthnRegisterVerifyRequestSchema, webauthnLoginVerifyRequestSchema, } from './webauthn.js';
103
109
  export {
104
- // Schemas
105
- devicePairingStatusSchema, deviceTransferInitRequestSchema, deviceTransferInitResponseSchema, deviceTransferInfoResponseSchema, deviceTransferApproveRequestSchema, deviceTransferApproveResponseSchema, deviceTransferDenyResponseSchema, } from './devicePairing.js';
106
- export {
107
110
  // Schemas — transparency log (checkpoints + inclusion proofs)
108
111
  transparencyCheckpointSignatureSchema, transparencyAnchorSchema, transparencyCheckpointSchema, transparencyInclusionProofSchema, transparencyCheckpointListSchema, } from './transparency.js';
109
112
  /* -------------------------------------------------------------------------- */
@@ -152,10 +155,10 @@ export {
152
155
  modelDistributionMethodSchema, modelSystemicRiskTierSchema, trainingComputeFlopsSchema, SYSTEMIC_RISK_COMPUTE_THRESHOLD_FLOPS, modelDownstreamDocumentationSchema, modelGpaiDocumentationSchema, modelLineDeclarationSchema, modelReleaseIngestionRequestSchema, modelReleaseIngestionResultSchema, modelDocumentationSchema, } from './inference/modelDocumentation.js';
153
156
  export {
154
157
  // The normalized Oxy→data-plane request envelope.
155
- inferenceContentSourceSchema, inferenceContentPartSchema, inferenceToolCallSchema, inferenceMessageRoleSchema, inferenceMessageSchema, inferenceInputSchema, samplingParametersSchema, toolDefinitionSchema, toolChoiceSchema, responseFormatSchema, clientRequestMetadataSchema, inferenceRequestSchema, } from './inference/request.js';
158
+ inferenceContentSourceSchema, inferenceContentPartSchema, inferenceToolCallSchema, inferenceMessageRoleSchema, inferenceMessageSchema, inferenceInputSchema, samplingParametersSchema, toolDefinitionSchema, toolChoiceSchema, responseFormatSchema, clientRequestMetadataSchema, inferenceRequestSchema, inferenceSpeechParametersSchema, } from './inference/request.js';
156
159
  export {
157
160
  // Normalized SSE events.
158
- inferenceStreamStartEventSchema, inferenceStreamDeltaEventSchema, inferenceStreamToolCallEventSchema, inferenceStreamUsageEventSchema, inferenceRouteSwitchDetailSchema, inferenceRouteSwitchReasonSchema, inferenceStreamRouteSwitchEventSchema, inferenceStreamErrorEventSchema, inferenceFinishReasonSchema, inferenceStreamDoneEventSchema, inferenceStreamEventSchema, } from './inference/streamEvents.js';
161
+ inferenceStreamStartEventSchema, inferenceStreamDeltaEventSchema, inferenceAudioMediaTypeSchema, MAX_INFERENCE_AUDIO_BYTES, inferenceStreamAudioEventSchema, inferenceStreamToolCallEventSchema, inferenceStreamUsageEventSchema, inferenceRouteSwitchDetailSchema, inferenceRouteSwitchReasonSchema, inferenceStreamRouteSwitchEventSchema, inferenceStreamErrorEventSchema, inferenceFinishReasonSchema, inferenceStreamDoneEventSchema, inferenceStreamEventSchema, } from './inference/streamEvents.js';
159
162
  export {
160
163
  // Reserve → settle → refund.
161
164
  usageReservationRequestSchema, usageReservationStatusSchema, usageReservationSchema, inferenceRequestOutcomeSchema, normalizedUsageReportSchema, usageReceiptSchema, usageRefundSubjectSchema, usageRefundReasonSchema, usageRefundSchema, } from './inference/usage.js';
@@ -126,6 +126,8 @@ export const inferenceHttpsUrlSchema = z
126
126
  export const sha256DigestSchema = z
127
127
  .string()
128
128
  .regex(/^sha256:[a-f0-9]{64}$/, 'digest must be sha256:<64 lowercase hex>');
129
+ /** Standard base64 in whole, padded 4-character groups. */
130
+ export const PADDED_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
129
131
  /* -------------------------------------------------------------------------- */
130
132
  /* Catalogue references */
131
133
  /* -------------------------------------------------------------------------- */
@@ -24,7 +24,7 @@
24
24
  * Decided in: issue #972 workstream 10.
25
25
  */
26
26
  import { z } from 'zod';
27
- import { deploymentIdSchema, inferenceEnvironmentSchema, inferenceProviderSlugSchema, inferenceTimestampSchema, oxyAccountIdSchema, oxyApplicationIdSchema, } from './identifiers.js';
27
+ import { deploymentIdSchema, inferenceEnvironmentSchema, inferenceProviderSlugSchema, inferenceTimestampSchema, oxyAccountIdSchema, oxyApplicationIdSchema, PADDED_BASE64_PATTERN, } from './identifiers.js';
28
28
  /**
29
29
  * How widely a connection applies.
30
30
  *
@@ -111,7 +111,7 @@ const kaanaCredentialSecretBase64Schema = z
111
111
  .string()
112
112
  .min(1)
113
113
  .max(8192)
114
- .regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/)
114
+ .regex(PADDED_BASE64_PATTERN)
115
115
  .refine(isVisibleASCIIProviderCredential, {
116
116
  message: 'a decoded provider credential is 1-4096 visible ASCII bytes',
117
117
  });
@@ -311,6 +311,12 @@ const modelLineOf = (reference) => {
311
311
  const at = reference.indexOf("@");
312
312
  return at === -1 ? reference : reference.slice(0, at);
313
313
  };
314
+ /** Parameters for text-to-speech, preserved in the signed request. */
315
+ export const inferenceSpeechParametersSchema = z.object({
316
+ voice: z.string().min(1).max(64),
317
+ responseFormat: z.enum(["mp3", "opus", "aac", "flac", "wav", "pcm"]),
318
+ speed: z.number().min(0.25).max(4).optional(),
319
+ }).strict();
314
320
  /**
315
321
  * The canonical internal request Oxy forwards to the data plane.
316
322
  *
@@ -330,6 +336,7 @@ export const inferenceRequestSchema = z
330
336
  stream: z.boolean(),
331
337
  maxOutputTokens: z.number().int().positive().safe().optional(),
332
338
  sampling: samplingParametersSchema,
339
+ speech: inferenceSpeechParametersSchema.optional(),
333
340
  tools: z.array(toolDefinitionSchema).default([]),
334
341
  toolChoice: toolChoiceSchema.optional(),
335
342
  responseFormat: responseFormatSchema.optional(),
@@ -372,6 +379,13 @@ export const inferenceRequestSchema = z
372
379
  authorizedRoutes: z.array(authorizedRouteSchema).min(1).optional(),
373
380
  })
374
381
  .superRefine((request, ctx) => {
382
+ const isSpeech = request.client.apiFormat === "audio_speech";
383
+ if (isSpeech && request.speech !== undefined && (request.modality !== "audio" || request.input.format !== "text" || request.stream)) {
384
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["speech"], message: "speech requires audio modality, text input, parameters and non-streaming output" });
385
+ }
386
+ if (!isSpeech && request.speech !== undefined) {
387
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["speech"], message: "speech parameters require the audio_speech API format" });
388
+ }
375
389
  if (request.toolChoice !== undefined && request.tools.length === 0) {
376
390
  ctx.addIssue({
377
391
  code: z.ZodIssueCode.custom,
@@ -2,7 +2,7 @@
2
2
  * Normalized stream events — what the data plane emits and the Oxy edge
3
3
  * forwards as SSE.
4
4
  *
5
- * One discriminated union, seven shapes, all carrying `requestId` and a
5
+ * One discriminated union, eight shapes, all carrying `requestId` and a
6
6
  * monotonic `sequence`. `requestId` is on EVERY event rather than only the
7
7
  * first because a proxy that re-frames or a client that reconnects would
8
8
  * otherwise be holding events it cannot attribute; `sequence` is what makes a
@@ -21,7 +21,7 @@
21
21
  * Decided in: docs/adr/0010-public-api-compatibility.md, docs/adr/0008-catalogue-concept-separation.md.
22
22
  */
23
23
  import { z } from 'zod';
24
- import { deploymentIdSchema, generationIdSchema, inferenceProviderSlugSchema, inferenceTimestampSchema, modelIdSchema, modelReferenceSchema, requestIdSchema, } from './identifiers.js';
24
+ import { deploymentIdSchema, generationIdSchema, inferenceProviderSlugSchema, inferenceTimestampSchema, modelIdSchema, modelReferenceSchema, PADDED_BASE64_PATTERN, requestIdSchema, } from './identifiers.js';
25
25
  import { inferenceErrorSchema } from './errors.js';
26
26
  import { usageQuantitySchema, usageSourceSchema } from './money.js';
27
27
  /**
@@ -62,6 +62,27 @@ export const inferenceStreamDeltaEventSchema = z.object({
62
62
  channel: z.enum(['output_text', 'reasoning', 'refusal']),
63
63
  text: z.string(),
64
64
  });
65
+ /** The encodings an audio output may carry. */
66
+ export const inferenceAudioMediaTypeSchema = z.enum([
67
+ 'audio/mpeg',
68
+ 'audio/wav',
69
+ 'audio/ogg',
70
+ 'audio/aac',
71
+ 'audio/flac',
72
+ 'audio/pcm',
73
+ ]);
74
+ /** The most bytes one folded audio output may hold, at the edge and in the SDK. */
75
+ export const MAX_INFERENCE_AUDIO_BYTES = 20 * 1024 * 1024;
76
+ /** A bounded, independently base64-encoded chunk of one audio output. */
77
+ export const inferenceStreamAudioEventSchema = z.object({
78
+ schemaVersion: z.literal(1),
79
+ type: z.literal('audio'),
80
+ requestId: requestIdSchema,
81
+ sequence: z.number().int().nonnegative().safe(),
82
+ outputIndex: z.number().int().nonnegative().safe(),
83
+ mediaType: inferenceAudioMediaTypeSchema,
84
+ data: z.string().min(4).max(65536).regex(PADDED_BASE64_PATTERN),
85
+ });
65
86
  /**
66
87
  * A tool call being streamed.
67
88
  *
@@ -247,6 +268,7 @@ export const inferenceStreamDoneEventSchema = z.object({
247
268
  export const inferenceStreamEventSchema = z.discriminatedUnion('type', [
248
269
  inferenceStreamStartEventSchema,
249
270
  inferenceStreamDeltaEventSchema,
271
+ inferenceStreamAudioEventSchema,
250
272
  inferenceStreamToolCallEventSchema,
251
273
  inferenceStreamUsageEventSchema,
252
274
  inferenceStreamRouteSwitchEventSchema,