@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.
@@ -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 inside Alia never implies
138
- * permission to resell the same provider/model publicly, which is why this is
139
- * an explicit scope on the route rather than a boolean derived from "it works".
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
- 'internal_alia',
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(1),
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(2),
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 = '2.0.0';
102
+ export const INFERENCE_CONTRACT_VERSION = '3.0.0';
@@ -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
+ });