@oxy.so/contracts 1.1.1 → 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 (65) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/identity.js +3 -2
  3. package/dist/cjs/identityMove.js +156 -0
  4. package/dist/cjs/identityProof.js +164 -0
  5. package/dist/cjs/identityRecovery.js +51 -0
  6. package/dist/cjs/index.js +75 -19
  7. package/dist/cjs/inference/catalogue.js +7 -6
  8. package/dist/cjs/inference/identifiers.js +3 -1
  9. package/dist/cjs/inference/providerConnection.js +1 -1
  10. package/dist/cjs/inference/request.js +15 -1
  11. package/dist/cjs/inference/streamEvents.js +24 -2
  12. package/dist/cjs/inference/version.js +1 -1
  13. package/dist/cjs/userResponse.js +82 -1
  14. package/dist/cjs/username.js +70 -2
  15. package/dist/cjs/webIdentityCarrier.js +181 -0
  16. package/dist/cjs/webauthn.js +14 -0
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/identity.js +3 -2
  19. package/dist/esm/identityMove.js +148 -0
  20. package/dist/esm/identityProof.js +159 -0
  21. package/dist/esm/identityRecovery.js +48 -0
  22. package/dist/esm/index.js +16 -7
  23. package/dist/esm/inference/catalogue.js +7 -6
  24. package/dist/esm/inference/identifiers.js +2 -0
  25. package/dist/esm/inference/providerConnection.js +2 -2
  26. package/dist/esm/inference/request.js +14 -0
  27. package/dist/esm/inference/streamEvents.js +24 -2
  28. package/dist/esm/inference/version.js +1 -1
  29. package/dist/esm/userResponse.js +81 -0
  30. package/dist/esm/username.js +69 -1
  31. package/dist/esm/webIdentityCarrier.js +178 -0
  32. package/dist/esm/webauthn.js +14 -0
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/accountGraph.d.ts +7 -7
  35. package/dist/types/agency.d.ts +24 -24
  36. package/dist/types/browserHub.d.ts +16 -16
  37. package/dist/types/deviceDirectory.d.ts +28 -28
  38. package/dist/types/externalIdentity.d.ts +21 -7
  39. package/dist/types/identity.d.ts +3 -2
  40. package/dist/types/identityMove.d.ts +185 -0
  41. package/dist/types/identityProof.d.ts +156 -0
  42. package/dist/types/identityRecovery.d.ts +246 -0
  43. package/dist/types/index.d.ts +14 -8
  44. package/dist/types/inference/catalogue.d.ts +21 -20
  45. package/dist/types/inference/embeddings.d.ts +12 -12
  46. package/dist/types/inference/errors.d.ts +4 -4
  47. package/dist/types/inference/identifiers.d.ts +2 -0
  48. package/dist/types/inference/inbox.d.ts +6 -6
  49. package/dist/types/inference/providerConnection.d.ts +40 -40
  50. package/dist/types/inference/request.d.ts +84 -36
  51. package/dist/types/inference/streamEvents.d.ts +69 -13
  52. package/dist/types/inference/usage.d.ts +8 -8
  53. package/dist/types/inference/version.d.ts +1 -1
  54. package/dist/types/keyRotation.d.ts +2 -2
  55. package/dist/types/oauth.d.ts +30 -30
  56. package/dist/types/sessionStatus.d.ts +6 -6
  57. package/dist/types/transparency.d.ts +10 -10
  58. package/dist/types/userResponse.d.ts +386 -18
  59. package/dist/types/username.d.ts +25 -2
  60. package/dist/types/webIdentityCarrier.d.ts +1130 -0
  61. package/dist/types/webauthn.d.ts +208 -0
  62. package/package.json +1 -1
  63. package/dist/cjs/devicePairing.js +0 -138
  64. package/dist/esm/devicePairing.js +0 -135
  65. package/dist/types/devicePairing.d.ts +0 -130
@@ -47,6 +47,60 @@ export const themePreferenceSchema = z.object({
47
47
  mode: z.enum(['light', 'dark', 'system']),
48
48
  colorPreset: z.string(),
49
49
  });
50
+ /**
51
+ * The earliest calendar year `dateOfBirthSchema` accepts.
52
+ *
53
+ * Not a real biological bound — it exists to catch an obviously-transposed
54
+ * year (`1027` for `2027`, a stray OCR/typo digit) with a clear message
55
+ * instead of the value quietly becoming a 150-year-old account. 1900 is
56
+ * generous enough that no living person's real birthdate is rejected by it.
57
+ */
58
+ const MIN_BIRTH_YEAR = 1900;
59
+ /**
60
+ * `true` when `year`/`month`/`day` name a date that actually exists on the
61
+ * Gregorian calendar — the check `z.string().regex(...)` alone cannot make,
62
+ * since the regex only constrains digit COUNT and would pass `2024-02-30`.
63
+ */
64
+ function isRealCalendarDate(year, month, day) {
65
+ const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
66
+ const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
67
+ return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth[month - 1];
68
+ }
69
+ /**
70
+ * A date of birth, `YYYY-MM-DD`, the sole structured representation this
71
+ * platform stores going forward (`users.date_of_birth` — see
72
+ * `packages/api/src/db/schema/users.ts`). `birthday` (below) stays as the
73
+ * legacy free-text field for backward compatibility with existing readers;
74
+ * this schema is what both the write path (`user.service.ts`) and the read
75
+ * path (nothing — a date of birth is owner-only, never echoed to another
76
+ * viewer) validate against.
77
+ *
78
+ * Three checks, in order, because a regex alone would accept a string-shaped
79
+ * lie:
80
+ * 1. Exactly `YYYY-MM-DD` — the wire format, nothing looser.
81
+ * 2. A real Gregorian date — rejects `2024-02-30`, a date the regex cannot see
82
+ * is impossible.
83
+ * 3. Bounded to a plausible human lifetime — not before {@link MIN_BIRTH_YEAR}
84
+ * and not after today (comparing the zero-padded ISO strings directly is
85
+ * a valid, simpler stand-in for a numeric comparison here, since two
86
+ * `YYYY-MM-DD` strings of equal length sort exactly the way their dates
87
+ * do). "Today" is UTC — see `computeIsAdult` in `user.service.ts` for why
88
+ * a date with no timezone of its own is evaluated in UTC rather than any
89
+ * particular caller's local zone.
90
+ */
91
+ export const dateOfBirthSchema = z
92
+ .string()
93
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'dateOfBirth must be an ISO 8601 calendar date (YYYY-MM-DD)')
94
+ .refine((value) => {
95
+ const [year, month, day] = value.split('-').map(Number);
96
+ return isRealCalendarDate(year, month, day);
97
+ }, { message: 'dateOfBirth is not a real calendar date' })
98
+ .refine((value) => Number(value.slice(0, 4)) >= MIN_BIRTH_YEAR, {
99
+ message: `dateOfBirth must not be before ${MIN_BIRTH_YEAR}`,
100
+ })
101
+ .refine((value) => value <= new Date().toISOString().slice(0, 10), {
102
+ message: 'dateOfBirth must not be in the future',
103
+ });
50
104
  /**
51
105
  * The canonical user object emitted by `formatUserResponse`.
52
106
  *
@@ -76,6 +130,26 @@ export const userResponseSchema = z
76
130
  phone: z.string().optional(),
77
131
  address: z.string().optional(),
78
132
  birthday: z.string().optional(),
133
+ /**
134
+ * Structured date of birth, `YYYY-MM-DD`. Present only on the
135
+ * account's OWN profile response (`GET /users/me`, `PUT /users/me`
136
+ * with `includePrivateFields`) — never on another account's profile,
137
+ * the same visibility `phone`/`address`/`birthday` already have. See
138
+ * {@link dateOfBirthSchema}.
139
+ */
140
+ dateOfBirth: dateOfBirthSchema.optional(),
141
+ /**
142
+ * Derived, non-PII signal: whether the account holder is at least 18
143
+ * (see `computeIsAdult` in `user.service.ts` for the exact threshold
144
+ * and the UTC-"today" choice). Computed fresh on every read — age
145
+ * changes daily, so this is never stored. `undefined` when
146
+ * `dateOfBirth` is unset ("unknown"), distinct from `false` ("known,
147
+ * not yet 18"). Rides the same owner-only visibility as
148
+ * `dateOfBirth`; a future pass may widen this specific field to
149
+ * other viewers without exposing the birthdate itself, but that is
150
+ * not decided here.
151
+ */
152
+ isAdult: z.boolean().optional(),
79
153
  /** Avatar file id (string) or null. */
80
154
  avatar: z.string().nullable().optional(),
81
155
  /** Named Bloom color preset (e.g. `"blue"`) or null. */
@@ -171,6 +245,13 @@ export const userProfileUpdateSchema = z
171
245
  phone: z.string().optional(),
172
246
  address: z.string().optional(),
173
247
  birthday: z.string().optional(),
248
+ /**
249
+ * Structured date of birth. `null` (or `''`, at the service layer)
250
+ * clears it. Independently settable from `birthday` — see
251
+ * `user.service.ts`'s `updateUserProfile` for why the two legacy and
252
+ * structured fields are not kept in sync with each other.
253
+ */
254
+ dateOfBirth: dateOfBirthSchema.nullable().optional(),
174
255
  locations: z.array(z.unknown()).optional(),
175
256
  links: z.array(z.string()).optional(),
176
257
  linksMetadata: z
@@ -106,6 +106,71 @@ export const USERNAME_MAX_LENGTH = 30;
106
106
  /** The 400 / inline-validation copy for every path that rejects a handle. */
107
107
  export const USERNAME_INVALID_MESSAGE = 'Username must be 3-30 characters, use only letters, numbers, hyphens and underscores, ' +
108
108
  'start and end with a letter or number, and never repeat a separator';
109
+ /**
110
+ * Exact-match handles nobody may newly claim, regardless of account kind.
111
+ *
112
+ * This is NOT a namespace tightening like the bot suffix — it is a list of
113
+ * names withheld from `users_lower_username_key` before anybody asks for
114
+ * them. Compared against the same normalization the unique index applies
115
+ * (`lower(btrim(username))`), so `Admin`, ` ADMIN ` and `admin` are the one
116
+ * name this list means.
117
+ *
118
+ * `oxy`, `mention`, `homiio`, `clarity`, `faircoin`, `astro` and `mercaria`
119
+ * are deliberately NOT here: each already has an `organization`/`project`
120
+ * row, so the unique index already refuses a second one, and `oxy` is the
121
+ * name `USERNAME_MIN_LENGTH` is pinned against in
122
+ * `__tests__/username.test.ts` — listing it would make
123
+ * `usernameSchema.safeParse('oxy')` fail and falsify that comment. A brand
124
+ * with no account of its own yet (measured 2026-09-18: Alia, Allo, TNP,
125
+ * Kaana, Bloom) has no row to protect it, so it is listed until one exists.
126
+ */
127
+ export const RESERVED_USERNAMES = new Set([
128
+ // Oxy product lines with no account row of their own yet.
129
+ 'alia',
130
+ 'allo',
131
+ 'tnp',
132
+ 'kaana',
133
+ 'bloom',
134
+ // System / role words a signup impersonating staff or Oxy itself would reach for.
135
+ 'admin',
136
+ 'administrator',
137
+ 'root',
138
+ 'superadmin',
139
+ 'super',
140
+ 'superuser',
141
+ 'support',
142
+ 'staff',
143
+ 'moderator',
144
+ 'mod',
145
+ 'security',
146
+ 'system',
147
+ 'official',
148
+ 'help',
149
+ 'noreply',
150
+ 'anonymous',
151
+ 'everyone',
152
+ 'owner',
153
+ ]);
154
+ /** The 400 / inline-validation copy for a handle on {@link RESERVED_USERNAMES}. */
155
+ export const RESERVED_USERNAME_MESSAGE = 'This username is reserved and cannot be registered';
156
+ /**
157
+ * {@link RESERVED_USERNAMES}, minus `alia`, checked against each `-`/`_`
158
+ * separated SEGMENT of a candidate rather than the whole string — so
159
+ * `official-oxy`, `super-admin` and `team-kaana` are refused the same as the
160
+ * bare words, without banning every word that merely CONTAINS one as a
161
+ * substring (`superman`, `modern`, `grassroot`, `homeowner` all stay legal:
162
+ * none of them separates the reserved word from the rest with `-` or `_`).
163
+ *
164
+ * `alia` is excluded because `alia-` is the live internal-cost-centre
165
+ * namespace: `alia-production-chat` is a minted account and
166
+ * `alia-research` / `alia-voice` / `alia-evaluations` are pinned as legal
167
+ * slugs in `__tests__/username.test.ts` and `internalCostCenterSpecs.test.ts`.
168
+ * Segment-matching `alia` would refuse all four. The bare word `alia` is
169
+ * still refused — {@link RESERVED_USERNAMES} above catches it exactly.
170
+ */
171
+ const RESERVED_USERNAME_SEGMENTS = new Set([...RESERVED_USERNAMES].filter((word) => word !== 'alia'));
172
+ /** The 400 / inline-validation copy for a handle that is only digits. */
173
+ export const NUMERIC_USERNAME_MESSAGE = 'Username cannot be only numbers';
109
174
  /**
110
175
  * Alphanumeric runs joined by single separators, as a SOURCE string.
111
176
  *
@@ -142,7 +207,10 @@ export const usernameSchema = z
142
207
  .trim()
143
208
  .min(USERNAME_MIN_LENGTH, USERNAME_INVALID_MESSAGE)
144
209
  .max(USERNAME_MAX_LENGTH, USERNAME_INVALID_MESSAGE)
145
- .regex(USERNAME_PATTERN, USERNAME_INVALID_MESSAGE);
210
+ .regex(USERNAME_PATTERN, USERNAME_INVALID_MESSAGE)
211
+ .refine((username) => !/^[0-9]+$/.test(username), NUMERIC_USERNAME_MESSAGE)
212
+ .refine((username) => !RESERVED_USERNAMES.has(username.toLowerCase()), RESERVED_USERNAME_MESSAGE)
213
+ .refine((username) => !username.split(/[-_]/).some((segment) => RESERVED_USERNAME_SEGMENTS.has(segment.toLowerCase())), RESERVED_USERNAME_MESSAGE);
146
214
  /**
147
215
  * Whether a candidate handle is storable — the boolean form, for input surfaces
148
216
  * that show a message as somebody types rather than throwing.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Web identity holder contract — the sealed envelope that lets a browser hold an
3
+ * account's self-custody root without Oxy ever being able to use it (ADR 0024).
4
+ *
5
+ * A root is a BIP-39 phrase (12–24 words) whose seed's first 32 bytes are the
6
+ * secp256k1 key — exactly the Commons derivation — or, for a few imported
7
+ * identities, a raw private key that never had a phrase. On the web it travels as:
8
+ *
9
+ * secret ── XChaCha20-Poly1305 under a random DEK ──▶ sealedSecret
10
+ * DEK ── XChaCha20-Poly1305 under KEK_i ──▶ wraps[i]
11
+ * KEK_i = HKDF(PRF output of passkey i)
12
+ *
13
+ * The server stores the envelope and can open NONE of it: the PRF output never
14
+ * leaves the user's authenticator, and the secret is never uploaded. The AEAD
15
+ * associated data binds the secret to the root's public key and kind, and each
16
+ * wrap to its credential and RP ID, so a re-labelled or transplanted envelope
17
+ * fails to open instead of decrypting into the wrong identity.
18
+ *
19
+ * Platform-agnostic — zod only, ESM-safe (no `require()`).
20
+ */
21
+ import { z } from 'zod';
22
+ import { identityProofSchema } from './identityProof.js';
23
+ /** The envelope scheme. A scheme change is a new literal, never a mutation. */
24
+ export const WEB_IDENTITY_ENVELOPE_VERSION = 2;
25
+ /**
26
+ * What an envelope seals. A raw-key identity stays a raw-key identity:
27
+ * nothing ever derives or displays a phrase for it.
28
+ */
29
+ export const WEB_IDENTITY_SECRET_KINDS = ['mnemonic-entropy', 'raw-private-key'];
30
+ const hex = (bytes, label) => z
31
+ .string()
32
+ .trim()
33
+ .regex(new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`), `${label} must be ${bytes * 2} hex characters`);
34
+ /**
35
+ * The identity's secp256k1 public key in Oxy's canonical form: uncompressed SEC1
36
+ * (`04` + 64 bytes), lowercase hex — what `KeyManager.derivePublicKey` produces
37
+ * and `users.public_key` stores.
38
+ */
39
+ export const webIdentityPublicKeySchema = z
40
+ .string()
41
+ .trim()
42
+ .regex(/^04[0-9a-f]{128}$/, 'publicKey must be an uncompressed, lowercase secp256k1 key (130 hex characters)');
43
+ /** A WebAuthn credential id, base64url as the browser reports it. */
44
+ export const webauthnCredentialIdSchema = z
45
+ .string()
46
+ .trim()
47
+ .min(16)
48
+ .max(1024)
49
+ .regex(/^[A-Za-z0-9_-]+$/, 'credentialId must be base64url');
50
+ /** A WebAuthn RP ID: a bare registrable host name, lowercase. */
51
+ export const webauthnRpIdSchema = z
52
+ .string()
53
+ .trim()
54
+ .min(1)
55
+ .max(253)
56
+ .regex(/^(localhost|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+)$/, 'rpId must be a lowercase host name');
57
+ /** One passkey's wrap of the envelope's data key. */
58
+ export const webIdentityWrapSchema = z.object({
59
+ credentialId: webauthnCredentialIdSchema,
60
+ /** 24-byte XChaCha20-Poly1305 nonce. */
61
+ nonce: hex(24, 'nonce'),
62
+ /** The 32-byte DEK sealed under this passkey's KEK, with the 16-byte tag appended (48 bytes). */
63
+ wrappedKey: hex(48, 'wrappedKey'),
64
+ createdAt: z.string().datetime(),
65
+ /** The RP ID the passkey was created under, asserted explicitly by every later ceremony (ADR 0024 D2). */
66
+ rpId: webauthnRpIdSchema,
67
+ /**
68
+ * When this passkey's PRF output was shown to open the envelope. A wrap is a
69
+ * root HOLDER only once this is set; a login passkey never is by default.
70
+ */
71
+ verifiedAt: z.string().datetime().optional(),
72
+ });
73
+ /**
74
+ * The sealed identity as it is stored (server copy and local copy alike).
75
+ *
76
+ * `wraps` holds one entry per passkey able to open it; at least one, and a
77
+ * bounded number so an envelope cannot grow without limit.
78
+ */
79
+ export const webIdentityEnvelopeSchema = z
80
+ .object({
81
+ version: z.literal(WEB_IDENTITY_ENVELOPE_VERSION),
82
+ algorithm: z.literal('xchacha20poly1305'),
83
+ publicKey: webIdentityPublicKeySchema,
84
+ secretKind: z.enum(WEB_IDENTITY_SECRET_KINDS),
85
+ /** 24-byte nonce of the secret seal. */
86
+ secretNonce: hex(24, 'secretNonce'),
87
+ /**
88
+ * The sealed secret, tag appended: 16/20/24/28/32 bytes of BIP-39 entropy
89
+ * (12–24 words) or a 32-byte private key, plus 16.
90
+ */
91
+ sealedSecret: z
92
+ .string()
93
+ .trim()
94
+ .regex(/^(?:[0-9a-fA-F]{64}|[0-9a-fA-F]{72}|[0-9a-fA-F]{80}|[0-9a-fA-F]{88}|[0-9a-fA-F]{96})$/, 'sealedSecret has an unsupported length'),
95
+ wraps: z.array(webIdentityWrapSchema).min(1).max(10),
96
+ })
97
+ .refine((envelope) => envelope.secretKind === 'mnemonic-entropy' || envelope.sealedSecret.length === 96, {
98
+ message: 'a raw private key seals to 48 bytes',
99
+ path: ['sealedSecret'],
100
+ });
101
+ /**
102
+ * `PUT /identity/web-envelope` — store or replace the caller's envelope.
103
+ *
104
+ * Refused unless `envelope.publicKey` is the identity key already linked to the
105
+ * account: an envelope can only ever carry the account's own identity.
106
+ */
107
+ export const webIdentityEnvelopeUploadSchema = z.object({
108
+ envelope: webIdentityEnvelopeSchema,
109
+ });
110
+ /** A root holder as the status read reports it — metadata only, nothing that opens anything. */
111
+ export const webIdentityHolderSchema = z.object({
112
+ credentialId: webauthnCredentialIdSchema,
113
+ rpId: webauthnRpIdSchema,
114
+ verifiedAt: z.string().datetime().nullable(),
115
+ createdAt: z.string().datetime(),
116
+ });
117
+ /**
118
+ * `GET /identity/web-envelope` — the caller's envelope and the readiness facts
119
+ * ADR 0024 D5 keeps separate. A client decides what to show from these fields
120
+ * WITHOUT decrypting anything.
121
+ */
122
+ export const webIdentityEnvelopeResponseSchema = z.object({
123
+ envelope: webIdentityEnvelopeSchema.nullable(),
124
+ /** The revision a write must name as `expectedRevision`; `0` when there is no envelope. */
125
+ revision: z.number().int().nonnegative(),
126
+ /** Whether the account has a linked root at all (it may live only in Commons). */
127
+ rootLinked: z.boolean(),
128
+ /** The web wraps, as metadata. */
129
+ holders: z.array(webIdentityHolderSchema),
130
+ /** When the owner confirmed the recovery material is written down, or `null`. */
131
+ phraseConfirmedAt: z.string().datetime().nullable(),
132
+ /** When the recovery material was shown to re-derive this root, or `null`. */
133
+ recoveryVerifiedAt: z.string().datetime().nullable(),
134
+ updatedAt: z.string().datetime().nullable(),
135
+ });
136
+ /** A root proof, plus the envelope revision the write expects to replace. */
137
+ export const webIdentityEnvelopeProofFieldsSchema = z.object({
138
+ proof: identityProofSchema,
139
+ expectedRevision: z.number().int().nonnegative(),
140
+ });
141
+ /**
142
+ * `POST /identity/web-envelope/phrase-confirmed`, `/recovery-verified` and
143
+ * `DELETE /identity/web-envelope` prove control of the root, not just a bearer.
144
+ */
145
+ export const webIdentityEnvelopeActionSchema = webIdentityEnvelopeProofFieldsSchema.strict();
146
+ /** `PUT /identity/web-envelope` body. */
147
+ export const webIdentityEnvelopePutSchema = webIdentityEnvelopeUploadSchema.extend(webIdentityEnvelopeProofFieldsSchema.shape).strict();
148
+ /**
149
+ * A WebAuthn assertion by one of the account's EXISTING passkeys whose
150
+ * `clientDataJSON.challenge` is the proof challenge — the fresh use of the
151
+ * existing factor a keyless account needs before its first root is linked.
152
+ */
153
+ export const webauthnAssertionResponseSchema = z
154
+ .object({
155
+ id: webauthnCredentialIdSchema,
156
+ rawId: z.string().min(1).max(2048),
157
+ type: z.literal('public-key'),
158
+ response: z
159
+ .object({
160
+ clientDataJSON: z.string().min(1).max(8192),
161
+ authenticatorData: z.string().min(1).max(8192),
162
+ signature: z.string().min(1).max(2048),
163
+ userHandle: z.string().max(2048).optional(),
164
+ })
165
+ .passthrough(),
166
+ clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
167
+ authenticatorAttachment: z.string().optional(),
168
+ })
169
+ .passthrough();
170
+ /**
171
+ * `POST /identity/web-envelope/establish` body — an account's FIRST root, linked
172
+ * and stored with its envelope in ONE transaction: one root proof
173
+ * (`web_envelope_establish`, digest of the envelope) plus a fresh `assertion` by
174
+ * an existing passkey over the same challenge.
175
+ */
176
+ export const webIdentityEnvelopeEstablishSchema = webIdentityEnvelopeUploadSchema
177
+ .extend({ proof: identityProofSchema, assertion: webauthnAssertionResponseSchema })
178
+ .strict();
@@ -11,6 +11,8 @@
11
11
  * create a second, drift-prone definition of a shape we do not own.
12
12
  */
13
13
  import { z } from 'zod';
14
+ import { identityProofSchema } from './identityProof.js';
15
+ import { webIdentityEnvelopeSchema } from './webIdentityCarrier.js';
14
16
  /**
15
17
  * Device-session options shared by every first-party sign-in body
16
18
  * (`deviceName`/`deviceFingerprint`). Mirrors what
@@ -56,6 +58,18 @@ export const webauthnLoginOptionsRequestSchema = z.object({
56
58
  */
57
59
  export const webauthnRegisterVerifyRequestSchema = z.object({
58
60
  username: z.string().trim().min(1).max(60).optional(),
61
+ /**
62
+ * Sign-up only (ADR 0024 D4): the account's root, created on the holder BEFORE
63
+ * this request — sealed under the passkey being registered — and a root proof
64
+ * (`enroll_identity`) whose challenge is the registration challenge. The
65
+ * account, passkey, root and envelope are then created in one transaction.
66
+ */
67
+ identity: z
68
+ .object({
69
+ envelope: webIdentityEnvelopeSchema,
70
+ proof: identityProofSchema,
71
+ })
72
+ .optional(),
59
73
  ...deviceSessionEnvelope,
60
74
  });
61
75
  /**