@oxy.so/contracts 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/identityMove.js +85 -0
- package/dist/cjs/index.js +37 -10
- package/dist/cjs/inference/catalogue.js +7 -6
- package/dist/cjs/inference/version.js +1 -1
- package/dist/cjs/userResponse.js +82 -1
- package/dist/cjs/webIdentityCarrier.js +122 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/identityMove.js +82 -0
- package/dist/esm/index.js +7 -1
- package/dist/esm/inference/catalogue.js +7 -6
- package/dist/esm/inference/version.js +1 -1
- package/dist/esm/userResponse.js +81 -0
- package/dist/esm/webIdentityCarrier.js +119 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/externalIdentity.d.ts +14 -0
- package/dist/types/identityMove.d.ts +109 -0
- package/dist/types/index.d.ts +5 -1
- package/dist/types/inference/catalogue.d.ts +21 -20
- package/dist/types/inference/embeddings.d.ts +12 -12
- package/dist/types/inference/errors.d.ts +4 -4
- package/dist/types/inference/inbox.d.ts +6 -6
- package/dist/types/inference/streamEvents.d.ts +12 -12
- package/dist/types/inference/usage.d.ts +8 -8
- package/dist/types/inference/version.d.ts +1 -1
- package/dist/types/userResponse.d.ts +368 -0
- package/dist/types/webIdentityCarrier.d.ts +522 -0
- package/package.json +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity move contract — take a web identity INTO Commons (a MOVE, not a copy).
|
|
3
|
+
*
|
|
4
|
+
* Design: `docs/superpowers/specs/2026-09-15-one-identity-two-carriers-design.md` §5.
|
|
5
|
+
*
|
|
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.
|
|
25
|
+
*
|
|
26
|
+
* The server holds two ephemeral public keys, an opaque ciphertext, and a
|
|
27
|
+
* receipt. It never holds anything that decrypts the ciphertext.
|
|
28
|
+
*
|
|
29
|
+
* Platform-agnostic — zod only, ESM-safe (no `require()`).
|
|
30
|
+
*/
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
/** A move lives this long: one interactive handoff. */
|
|
33
|
+
export const IDENTITY_MOVE_TTL_MS = 5 * 60 * 1000;
|
|
34
|
+
/** 128-bit move id, lowercase hex. */
|
|
35
|
+
export const identityMoveIdSchema = z
|
|
36
|
+
.string()
|
|
37
|
+
.trim()
|
|
38
|
+
.regex(/^[0-9a-f]{32}$/, 'moveId must be 32 lowercase hex characters');
|
|
39
|
+
/** An ephemeral secp256k1 public key, uncompressed lowercase hex. */
|
|
40
|
+
export const identityMoveEphemeralKeySchema = z
|
|
41
|
+
.string()
|
|
42
|
+
.trim()
|
|
43
|
+
.regex(/^04[0-9a-f]{128}$/, 'ephemeral public key must be uncompressed, lowercase hex');
|
|
44
|
+
export const IDENTITY_MOVE_STATUSES = ['pending', 'joined', 'sealed', 'completed', 'cancelled', 'expired'];
|
|
45
|
+
/** The QR payload Commons scans. Carries the move id only. */
|
|
46
|
+
export const IDENTITY_MOVE_QR_PREFIX = 'oxycommons://move?id=';
|
|
47
|
+
export const identityMoveCreateRequestSchema = z.object({
|
|
48
|
+
initiatorEphemeralPublicKey: identityMoveEphemeralKeySchema,
|
|
49
|
+
});
|
|
50
|
+
export const identityMoveCreateResponseSchema = z.object({
|
|
51
|
+
moveId: identityMoveIdSchema,
|
|
52
|
+
expiresAt: z.string().datetime(),
|
|
53
|
+
});
|
|
54
|
+
export const identityMoveJoinRequestSchema = z.object({
|
|
55
|
+
responderEphemeralPublicKey: identityMoveEphemeralKeySchema,
|
|
56
|
+
});
|
|
57
|
+
export const identityMoveSealRequestSchema = z.object({
|
|
58
|
+
/** 24-byte XChaCha20-Poly1305 nonce, hex. */
|
|
59
|
+
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 }`. */
|
|
68
|
+
signature: z.string().trim().min(1).max(512),
|
|
69
|
+
timestamp: z.number().int().positive(),
|
|
70
|
+
});
|
|
71
|
+
export const identityMoveStateSchema = z.object({
|
|
72
|
+
moveId: identityMoveIdSchema,
|
|
73
|
+
status: z.enum(IDENTITY_MOVE_STATUSES),
|
|
74
|
+
publicKey: z.string().regex(/^04[0-9a-f]{128}$/),
|
|
75
|
+
initiatorEphemeralPublicKey: identityMoveEphemeralKeySchema,
|
|
76
|
+
responderEphemeralPublicKey: identityMoveEphemeralKeySchema.nullable(),
|
|
77
|
+
nonce: z.string().nullable(),
|
|
78
|
+
ciphertext: z.string().nullable(),
|
|
79
|
+
receiptSignature: z.string().nullable(),
|
|
80
|
+
receiptTimestamp: z.number().int().nullable(),
|
|
81
|
+
expiresAt: z.string().datetime(),
|
|
82
|
+
});
|
package/dist/esm/index.js
CHANGED
|
@@ -13,7 +13,7 @@ export { ACCOUNT_KINDS, accountKindSchema, CHILD_ACCOUNT_KINDS, childAccountKind
|
|
|
13
13
|
export { usernameSchema, usernameSchemaForAccountKind, isValidUsername, stripDisallowedUsernameCharacters, applyBotUsernameSuffix, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, USERNAME_INVALID_MESSAGE, BOT_USERNAME_INVALID_MESSAGE, } from './username.js';
|
|
14
14
|
export {
|
|
15
15
|
// Schemas
|
|
16
|
-
userNameSchema, userRelationshipSchema, themePreferenceSchema, userResponseSchema, userProfileUpdateSchema, currentUserResponseSchema, deviceLinkedSessionSchema, deviceLinkedSessionsResponseSchema,
|
|
16
|
+
userNameSchema, userRelationshipSchema, themePreferenceSchema, dateOfBirthSchema, userResponseSchema, userProfileUpdateSchema, currentUserResponseSchema, deviceLinkedSessionSchema, deviceLinkedSessionsResponseSchema,
|
|
17
17
|
// Helpers
|
|
18
18
|
resolveUserId, safeParseContract, } from './userResponse.js';
|
|
19
19
|
export {
|
|
@@ -81,6 +81,12 @@ export {
|
|
|
81
81
|
// Schemas — encrypted off-device identity backup (b3 Feature 1)
|
|
82
82
|
backupLookupIdSchema, encryptedBackupEnvelopeSchema, backupUploadRequestSchema, backupStatusResponseSchema, } from './keyRecovery.js';
|
|
83
83
|
export {
|
|
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';
|
|
86
|
+
export {
|
|
87
|
+
// 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';
|
|
89
|
+
export {
|
|
84
90
|
// Shared primitives
|
|
85
91
|
updatePlatformSchema, updateStatusSchema, updateAssetStatusSchema, sha256HexSchema, channelNameSchema, runtimeVersionSchema, rolloutPercentSchema,
|
|
86
92
|
// Assets: init + complete
|
|
@@ -134,12 +134,13 @@ export const inferenceDataPolicySchema = z
|
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
136
|
/**
|
|
137
|
-
* Who a route may be served to. Availability
|
|
138
|
-
* permission to resell the same provider/model publicly, which is why
|
|
139
|
-
* an explicit scope on the route rather than a boolean derived from
|
|
137
|
+
* Who a route may be served to. Availability to an official Oxy product never
|
|
138
|
+
* implies permission to resell the same provider/model publicly, which is why
|
|
139
|
+
* this is an explicit scope on the route rather than a boolean derived from
|
|
140
|
+
* "it works".
|
|
140
141
|
*/
|
|
141
142
|
export const availabilityScopeSchema = z.enum([
|
|
142
|
-
'
|
|
143
|
+
'platform_internal',
|
|
143
144
|
'public_payg',
|
|
144
145
|
'enterprise',
|
|
145
146
|
'byok_only',
|
|
@@ -332,7 +333,7 @@ export const inferenceProviderSchema = z.object({
|
|
|
332
333
|
export const modelDeploymentSchema = z
|
|
333
334
|
.object({
|
|
334
335
|
/** See `version.ts`: exchanged with the data plane on its own. */
|
|
335
|
-
schemaVersion: z.literal(
|
|
336
|
+
schemaVersion: z.literal(2),
|
|
336
337
|
deploymentId: deploymentIdSchema,
|
|
337
338
|
provider: inferenceProviderSlugSchema,
|
|
338
339
|
/** Always revision-pinned: a deployment serves specific weights. */
|
|
@@ -455,7 +456,7 @@ export const catalogueServingProviderSummarySchema = z
|
|
|
455
456
|
*/
|
|
456
457
|
export const modelCatalogueEntrySchema = z.object({
|
|
457
458
|
/** See `version.ts`: this is the public catalogue response shape. */
|
|
458
|
-
schemaVersion: z.literal(
|
|
459
|
+
schemaVersion: z.literal(3),
|
|
459
460
|
modelId: modelIdSchema,
|
|
460
461
|
publisher: cataloguePublisherSummarySchema,
|
|
461
462
|
displayName: z.string().min(1).max(200),
|
|
@@ -99,4 +99,4 @@
|
|
|
99
99
|
* change to, say, the catalogue reject every in-flight inference request; the
|
|
100
100
|
* per-shape `schemaVersion` is what a message is validated against.
|
|
101
101
|
*/
|
|
102
|
-
export const INFERENCE_CONTRACT_VERSION = '
|
|
102
|
+
export const INFERENCE_CONTRACT_VERSION = '3.0.0';
|
package/dist/esm/userResponse.js
CHANGED
|
@@ -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
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web identity carrier contract — "one identity, two carriers".
|
|
3
|
+
*
|
|
4
|
+
* SINGLE SOURCE OF TRUTH for the sealed envelope that lets a browser carry an
|
|
5
|
+
* account's self-custody identity without Oxy ever holding it
|
|
6
|
+
* (`docs/superpowers/specs/2026-09-15-one-identity-two-carriers-design.md`).
|
|
7
|
+
*
|
|
8
|
+
* The identity is a BIP-39 mnemonic whose seed's first 32 bytes are the
|
|
9
|
+
* secp256k1 key — exactly the Commons derivation — so a web identity and a
|
|
10
|
+
* Commons identity are the same thing. On the web it travels as:
|
|
11
|
+
*
|
|
12
|
+
* entropy (16 bytes) ── XChaCha20-Poly1305 under a random DEK ──▶ sealedEntropy
|
|
13
|
+
* DEK ── XChaCha20-Poly1305 under KEK_i ──▶ wraps[i]
|
|
14
|
+
* KEK_i = HKDF(PRF output of passkey i)
|
|
15
|
+
*
|
|
16
|
+
* The server stores the envelope and can open NONE of it: the PRF output never
|
|
17
|
+
* leaves the user's authenticator, and the mnemonic is never uploaded. The AEAD
|
|
18
|
+
* associated data binds every ciphertext to the identity's public key (and each
|
|
19
|
+
* wrap to its credential), so a re-labelled or transplanted envelope fails to
|
|
20
|
+
* open instead of decrypting into the wrong identity.
|
|
21
|
+
*
|
|
22
|
+
* Every hex field is lowercase-or-uppercase hex. Platform-agnostic — zod only,
|
|
23
|
+
* ESM-safe (no `require()`).
|
|
24
|
+
*/
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
/** The only envelope version. A scheme change is a new literal, never a mutation. */
|
|
27
|
+
export const WEB_IDENTITY_ENVELOPE_VERSION = 1;
|
|
28
|
+
const hex = (bytes, label) => z
|
|
29
|
+
.string()
|
|
30
|
+
.trim()
|
|
31
|
+
.regex(new RegExp(`^[0-9a-fA-F]{${bytes * 2}}$`), `${label} must be ${bytes * 2} hex characters`);
|
|
32
|
+
/**
|
|
33
|
+
* The identity's secp256k1 public key in Oxy's canonical form: uncompressed SEC1
|
|
34
|
+
* (`04` + 64 bytes), lowercase hex — what `KeyManager.derivePublicKey` produces
|
|
35
|
+
* and `users.public_key` stores.
|
|
36
|
+
*/
|
|
37
|
+
export const webIdentityPublicKeySchema = z
|
|
38
|
+
.string()
|
|
39
|
+
.trim()
|
|
40
|
+
.regex(/^04[0-9a-f]{128}$/, 'publicKey must be an uncompressed, lowercase secp256k1 key (130 hex characters)');
|
|
41
|
+
/** A WebAuthn credential id, base64url as the browser reports it. */
|
|
42
|
+
export const webauthnCredentialIdSchema = z
|
|
43
|
+
.string()
|
|
44
|
+
.trim()
|
|
45
|
+
.min(16)
|
|
46
|
+
.max(1024)
|
|
47
|
+
.regex(/^[A-Za-z0-9_-]+$/, 'credentialId must be base64url');
|
|
48
|
+
/** One passkey's wrap of the envelope's data key. */
|
|
49
|
+
export const webIdentityWrapSchema = z.object({
|
|
50
|
+
credentialId: webauthnCredentialIdSchema,
|
|
51
|
+
/** 24-byte XChaCha20-Poly1305 nonce. */
|
|
52
|
+
nonce: hex(24, 'nonce'),
|
|
53
|
+
/** The 32-byte DEK sealed under this passkey's KEK, with the 16-byte tag appended (48 bytes). */
|
|
54
|
+
wrappedKey: hex(48, 'wrappedKey'),
|
|
55
|
+
createdAt: z.string().datetime(),
|
|
56
|
+
});
|
|
57
|
+
/**
|
|
58
|
+
* The sealed identity as it is stored (server copy and local copy alike).
|
|
59
|
+
*
|
|
60
|
+
* `wraps` holds one entry per passkey able to open it; at least one, and a
|
|
61
|
+
* bounded number so an envelope cannot grow without limit.
|
|
62
|
+
*/
|
|
63
|
+
export const webIdentityEnvelopeSchema = z.object({
|
|
64
|
+
version: z.literal(WEB_IDENTITY_ENVELOPE_VERSION),
|
|
65
|
+
algorithm: z.literal('xchacha20poly1305'),
|
|
66
|
+
publicKey: webIdentityPublicKeySchema,
|
|
67
|
+
/** 24-byte nonce of the entropy seal. */
|
|
68
|
+
entropyNonce: hex(24, 'entropyNonce'),
|
|
69
|
+
/** The 16-byte BIP-39 entropy sealed under the DEK, tag appended (32 bytes). */
|
|
70
|
+
sealedEntropy: hex(32, 'sealedEntropy'),
|
|
71
|
+
wraps: z.array(webIdentityWrapSchema).min(1).max(10),
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* `PUT /identity/web-envelope` — store or replace the caller's envelope.
|
|
75
|
+
*
|
|
76
|
+
* Refused unless `envelope.publicKey` is the identity key already linked to the
|
|
77
|
+
* account: an envelope can only ever carry the account's own identity.
|
|
78
|
+
*/
|
|
79
|
+
export const webIdentityEnvelopeUploadSchema = z.object({
|
|
80
|
+
envelope: webIdentityEnvelopeSchema,
|
|
81
|
+
});
|
|
82
|
+
/** `GET /identity/web-envelope` — the caller's envelope and its recovery-phrase state. */
|
|
83
|
+
export const webIdentityEnvelopeResponseSchema = z.object({
|
|
84
|
+
envelope: webIdentityEnvelopeSchema.nullable(),
|
|
85
|
+
/**
|
|
86
|
+
* When the owner confirmed they wrote the recovery phrase down, or `null`.
|
|
87
|
+
* Until then the identity must not be unlocked on a second device, nor used
|
|
88
|
+
* for any operation that needs the key (design decision D2).
|
|
89
|
+
*/
|
|
90
|
+
phraseConfirmedAt: z.string().datetime().nullable(),
|
|
91
|
+
updatedAt: z.string().datetime().nullable(),
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* `POST /identity/web-envelope/phrase-confirmed` and
|
|
95
|
+
* `DELETE /identity/web-envelope` both prove control of the identity key, not
|
|
96
|
+
* just a bearer: a stolen session must not be able to mark a phrase as saved or
|
|
97
|
+
* destroy the web copy of someone's identity.
|
|
98
|
+
*
|
|
99
|
+
* The signed message is `JSON.stringify({ action, userId, timestamp })` — the
|
|
100
|
+
* same scheme as `link_identity`.
|
|
101
|
+
*/
|
|
102
|
+
export const webIdentityEnvelopeProofSchema = z.object({
|
|
103
|
+
signature: z.string().trim().min(1).max(512),
|
|
104
|
+
timestamp: z.number().int().positive(),
|
|
105
|
+
});
|
|
106
|
+
/** `PUT /identity/web-envelope` body: the envelope plus a `web_envelope_put` identity-key proof. */
|
|
107
|
+
export const webIdentityEnvelopePutSchema = webIdentityEnvelopeUploadSchema.extend(webIdentityEnvelopeProofSchema.shape);
|
|
108
|
+
/**
|
|
109
|
+
* `POST /identity/web-envelope/establish` body — create an account's FIRST
|
|
110
|
+
* identity on the web: link the key and store its envelope in ONE transaction.
|
|
111
|
+
*
|
|
112
|
+
* Linking and storing as two calls would let a failure (or a closed tab) in
|
|
113
|
+
* between leave the account bound to a key that nothing carries — an identity
|
|
114
|
+
* lost at birth. `link` is a `link_identity` proof and the outer proof a
|
|
115
|
+
* `web_envelope_put` proof, both signed by the envelope's own key.
|
|
116
|
+
*/
|
|
117
|
+
export const webIdentityEnvelopeEstablishSchema = webIdentityEnvelopePutSchema.extend({
|
|
118
|
+
link: webIdentityEnvelopeProofSchema,
|
|
119
|
+
});
|