@oxy.so/contracts 2.0.0 → 2.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.
Files changed (53) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/accountEmail.js +83 -0
  3. package/dist/cjs/federationInstanceFetch.js +44 -0
  4. package/dist/cjs/identityLink.js +89 -0
  5. package/dist/cjs/identityProof.js +6 -29
  6. package/dist/cjs/index.js +36 -49
  7. package/dist/cjs/linkedAccounts.js +34 -1
  8. package/dist/cjs/notifications.js +20 -1
  9. package/dist/cjs/webauthn.js +50 -21
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/accountEmail.js +80 -0
  12. package/dist/esm/federationInstanceFetch.js +41 -0
  13. package/dist/esm/identityLink.js +84 -0
  14. package/dist/esm/identityProof.js +6 -29
  15. package/dist/esm/index.js +8 -10
  16. package/dist/esm/linkedAccounts.js +33 -0
  17. package/dist/esm/notifications.js +19 -0
  18. package/dist/esm/webauthn.js +49 -20
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/accountEmail.d.ts +98 -0
  21. package/dist/types/accountGraph.d.ts +4 -4
  22. package/dist/types/agency.d.ts +2 -2
  23. package/dist/types/deviceSession.d.ts +4 -4
  24. package/dist/types/externalIdentity.d.ts +8 -8
  25. package/dist/types/federationInstanceFetch.d.ts +63 -0
  26. package/dist/types/identityLink.d.ts +210 -0
  27. package/dist/types/identityProof.d.ts +15 -42
  28. package/dist/types/index.d.ts +7 -8
  29. package/dist/types/inference/accountBilling.d.ts +2 -2
  30. package/dist/types/inference/aliaModelRelease.d.ts +8 -8
  31. package/dist/types/inference/embeddings.d.ts +16 -16
  32. package/dist/types/inference/errors.d.ts +8 -8
  33. package/dist/types/inference/inbox.d.ts +8 -8
  34. package/dist/types/inference/modelDocumentation.d.ts +8 -8
  35. package/dist/types/inference/providerConnection.d.ts +20 -20
  36. package/dist/types/inference/request.d.ts +16 -16
  37. package/dist/types/inference/streamEvents.d.ts +20 -20
  38. package/dist/types/keyRecovery.d.ts +4 -4
  39. package/dist/types/linkedAccounts.d.ts +33 -0
  40. package/dist/types/notifications.d.ts +26 -0
  41. package/dist/types/sessionStatus.d.ts +2 -2
  42. package/dist/types/userResponse.d.ts +4 -4
  43. package/dist/types/webauthn.d.ts +102 -214
  44. package/package.json +1 -1
  45. package/dist/cjs/identityMove.js +0 -156
  46. package/dist/cjs/identityRecovery.js +0 -51
  47. package/dist/cjs/webIdentityCarrier.js +0 -181
  48. package/dist/esm/identityMove.js +0 -148
  49. package/dist/esm/identityRecovery.js +0 -48
  50. package/dist/esm/webIdentityCarrier.js +0 -178
  51. package/dist/types/identityMove.d.ts +0 -185
  52. package/dist/types/identityRecovery.d.ts +0 -246
  53. package/dist/types/webIdentityCarrier.d.ts +0 -1130
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Recovery email contracts (ADR 0029 D3).
3
+ *
4
+ * A web account is a username, a passkey and a recovery email. The email is
5
+ * proven by a 6-digit code sent to it, and the proof is a short-lived one-use
6
+ * ticket the next step spends:
7
+ *
8
+ * - `signup`: the ticket lets `POST /webauthn/register/verify` create the
9
+ * account with that email;
10
+ * - `recovery`: the person names their username or email, the code goes to the
11
+ * account's recovery email, and the ticket lets them register a new passkey
12
+ * for that account.
13
+ *
14
+ * `start` answers the same whether or not an account exists, so neither purpose
15
+ * tells anyone which emails or usernames have an Oxy account. An account with a
16
+ * Commons key has no recovery email: it recovers in Commons.
17
+ */
18
+ import { z } from 'zod';
19
+ export const EMAIL_VERIFICATION_PURPOSES = ['signup', 'recovery'];
20
+ /** Digits in a code. */
21
+ export const EMAIL_CODE_LENGTH = 6;
22
+ /** How long a code can be confirmed. */
23
+ export const EMAIL_CODE_TTL_MS = 10 * 60 * 1000;
24
+ /** Wrong codes before a verification is spent and a new code is needed. */
25
+ export const EMAIL_CODE_MAX_ATTEMPTS = 5;
26
+ /** How long a confirmed code's ticket can be spent. */
27
+ export const EMAIL_TICKET_TTL_MS = 15 * 60 * 1000;
28
+ /** An email address as the API stores it: trimmed and lowercase. */
29
+ export const emailAddressSchema = z.string().trim().toLowerCase().min(3).max(254).email();
30
+ /** An opaque one-use ticket (32 random bytes, base64url). */
31
+ export const emailTicketSchema = z
32
+ .string()
33
+ .trim()
34
+ .regex(/^[A-Za-z0-9_-]{43}$/, 'ticket must be 32 bytes of base64url');
35
+ /** `POST /auth/email/verify/start` */
36
+ export const emailVerificationStartRequestSchema = z.discriminatedUnion('purpose', [
37
+ z.object({ purpose: z.literal('signup'), email: emailAddressSchema }).strict(),
38
+ z
39
+ .object({
40
+ purpose: z.literal('recovery'),
41
+ /** The account's username, or its recovery email. */
42
+ identifier: z.string().trim().min(1).max(254),
43
+ })
44
+ .strict(),
45
+ ]);
46
+ export const emailVerificationStartResponseSchema = z.object({
47
+ verificationId: z.string().min(1).max(64),
48
+ expiresAt: z.number().int().positive(),
49
+ });
50
+ /** `POST /auth/email/verify/confirm` */
51
+ export const emailVerificationConfirmRequestSchema = z
52
+ .object({
53
+ verificationId: z.string().trim().min(1).max(64),
54
+ code: z
55
+ .string()
56
+ .trim()
57
+ .regex(new RegExp(`^\\d{${EMAIL_CODE_LENGTH}}$`), `code must be ${EMAIL_CODE_LENGTH} digits`),
58
+ })
59
+ .strict();
60
+ export const emailVerificationConfirmResponseSchema = z.object({
61
+ ticket: emailTicketSchema,
62
+ expiresAt: z.number().int().positive(),
63
+ username: z.string().nullable(),
64
+ });
65
+ /**
66
+ * Stable error codes (`error.code` in the API error body). Clients map these
67
+ * through their localization, never the English message.
68
+ */
69
+ export const EMAIL_VERIFICATION_ERROR_CODES = {
70
+ /** The code is wrong, or its verification expired or was spent. */
71
+ codeInvalid: 'EMAIL_CODE_INVALID',
72
+ /** Too many wrong codes: request a new one. */
73
+ tooManyAttempts: 'EMAIL_CODE_TOO_MANY_ATTEMPTS',
74
+ /** The ticket is unknown, expired, spent, or for another email or purpose. */
75
+ ticketInvalid: 'EMAIL_TICKET_INVALID',
76
+ /** A sign-up without a confirmed recovery email. */
77
+ ticketRequired: 'EMAIL_TICKET_REQUIRED',
78
+ /** This server cannot send mail. */
79
+ unavailable: 'EMAIL_UNAVAILABLE',
80
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Wire contract for `POST /federation/instance-fetch/sign`: Oxy's INSTANCE
3
+ * actor signs one ActivityPub GET for a first-party service, so the service can
4
+ * read an instance running in authorized-fetch ("secure") mode without holding
5
+ * a key.
6
+ *
7
+ * The caller sends a URL, never a signing string: Oxy builds the string itself
8
+ * (`(request-target): get <path>`, `host`, `date`), the method is always GET
9
+ * and the key is always the instance actor's. The caller sends the returned
10
+ * headers, unchanged, on exactly that URL, within the few minutes remote
11
+ * servers accept a `Date` for. A redirect is a new URL and needs a new
12
+ * signature.
13
+ *
14
+ * Needs a service token whose application holds the privileged
15
+ * `federation:instance-fetch` scope.
16
+ *
17
+ * Platform-agnostic — zod only.
18
+ */
19
+ import { z } from 'zod';
20
+ /** The longest URL Oxy will sign (the same cap as its own safe fetch). */
21
+ export const INSTANCE_FETCH_MAX_URL_LENGTH = 2048;
22
+ export const instanceFetchSignRequestSchema = z
23
+ .object({
24
+ /** The absolute public `https://` URL the caller is about to GET. */
25
+ url: z.string().trim().min(1).max(INSTANCE_FETCH_MAX_URL_LENGTH),
26
+ })
27
+ .strict();
28
+ export const instanceFetchSignResponseSchema = z
29
+ .object({
30
+ /** The instance actor's key, e.g. `https://oxy.so/ap/users/instance#main-key`. */
31
+ keyId: z.string().url(),
32
+ /** Send all three on the GET, as they are. */
33
+ headers: z
34
+ .object({
35
+ Host: z.string().min(1),
36
+ Date: z.string().min(1),
37
+ Signature: z.string().min(1),
38
+ })
39
+ .strict(),
40
+ })
41
+ .strict();
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Linking Commons to a passkey account (ADR 0029 D3) — the two-device relay.
3
+ *
4
+ * A passkey account links a Commons root once, and becomes self-custodied: its
5
+ * recovery email is deleted and its phrase in Commons is how it gets back in.
6
+ * The authority is the same as `POST /auth/link` (ADR 0024 D8): a root proof
7
+ * (`link_identity`) by the key Commons holds, and a fresh assertion by one of
8
+ * the account's passkeys over the SAME one-use challenge. Only the transport is
9
+ * new, because the two factors live on two devices:
10
+ *
11
+ * 1. auth.oxy.so (signed in) opens a link request → `{ linkId, challenge }`,
12
+ * shown as a QR (`oxycommons://link?id=…&c=…`).
13
+ * 2. Commons scans it, reads the request (the account's id and username),
14
+ * signs the root proof over the challenge and posts it with its key.
15
+ * 3. Both screens show the same 6-digit code, derived from the link id and
16
+ * that key (`deriveIdentityLinkCode` in `@oxy.so/core`); the person checks
17
+ * they match, so a photographed QR cannot slip another key in.
18
+ * 4. auth.oxy.so asserts the passkey over the challenge and completes: the
19
+ * account gains the root, loses the email, and Commons signs in with it.
20
+ *
21
+ * The server stores only the challenge's hash; the challenge travels in the QR.
22
+ */
23
+ import { z } from 'zod';
24
+ import { identityProofSchema } from './identityProof.js';
25
+ import { webauthnAssertionResponseSchema } from './webauthn.js';
26
+ export const IDENTITY_LINK_STATUSES = ['pending', 'signed', 'completed', 'cancelled'];
27
+ /** The scheme and host Commons routes a link QR to. */
28
+ export const IDENTITY_LINK_QR_PREFIX = 'oxycommons://link';
29
+ const LINK_ID = /^[0-9a-f]{32}$/;
30
+ const CHALLENGE = /^[0-9a-f]{64}$/;
31
+ export const identityLinkIdSchema = z.string().trim().regex(LINK_ID, 'linkId must be 32 lowercase hex characters');
32
+ /** The QR auth.oxy.so shows: the request's id and the challenge Commons signs. */
33
+ export function buildIdentityLinkQrPayload(linkId, challenge) {
34
+ return `${IDENTITY_LINK_QR_PREFIX}?id=${linkId}&c=${challenge}`;
35
+ }
36
+ /** The request a scanned code names, or `null` for anything that is not a link QR. */
37
+ export function parseIdentityLinkQrPayload(raw) {
38
+ const value = raw.trim();
39
+ if (!value.startsWith(`${IDENTITY_LINK_QR_PREFIX}?`))
40
+ return null;
41
+ // Parsed by hand: React Native's `URLSearchParams` does not implement `get`.
42
+ const params = new Map();
43
+ for (const pair of value.slice(IDENTITY_LINK_QR_PREFIX.length + 1).split('&')) {
44
+ const separator = pair.indexOf('=');
45
+ if (separator > 0)
46
+ params.set(pair.slice(0, separator), pair.slice(separator + 1));
47
+ }
48
+ const linkId = params.get('id') ?? '';
49
+ const challenge = params.get('c') ?? '';
50
+ if (!LINK_ID.test(linkId) || !CHALLENGE.test(challenge))
51
+ return null;
52
+ return { linkId, challenge };
53
+ }
54
+ export const identityLinkCreateResponseSchema = z.object({
55
+ linkId: identityLinkIdSchema,
56
+ challenge: z.string().regex(CHALLENGE),
57
+ expiresAt: z.number().int().positive(),
58
+ qrPayload: z.string().startsWith(IDENTITY_LINK_QR_PREFIX),
59
+ });
60
+ export const identityLinkStateSchema = z.object({
61
+ status: z.enum(IDENTITY_LINK_STATUSES),
62
+ userId: z.string().min(1),
63
+ username: z.string().nullable(),
64
+ publicKey: z.string().nullable(),
65
+ audience: z.string().min(1),
66
+ expiresAt: z.number().int().positive(),
67
+ });
68
+ /** `POST /identity/link/:linkId/proof` — from Commons, no bearer. */
69
+ export const identityLinkProofRequestSchema = z
70
+ .object({
71
+ publicKey: z
72
+ .string()
73
+ .trim()
74
+ .toLowerCase()
75
+ .regex(/^04[0-9a-f]{128}$/, 'publicKey must be an uncompressed secp256k1 key'),
76
+ proof: identityProofSchema,
77
+ })
78
+ .strict();
79
+ /** `POST /identity/link/:linkId/options` — WebAuthn request options over the challenge. */
80
+ export const identityLinkOptionsRequestSchema = z
81
+ .object({ challenge: z.string().trim().regex(CHALLENGE, 'challenge must be 64 lowercase hex characters') })
82
+ .strict();
83
+ /** `POST /identity/link/:linkId/complete` — the passkey assertion over the challenge. */
84
+ export const identityLinkCompleteRequestSchema = z.object({ assertion: webauthnAssertionResponseSchema }).strict();
@@ -27,26 +27,12 @@ export const IDENTITY_PROOF_CHALLENGE_TTL_MS = 5 * 60 * 1000;
27
27
  * action and spent only by a proof for that action.
28
28
  */
29
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`). */
30
+ /**
31
+ * Link Commons' root to a passkey account that has none (`POST /auth/link`).
32
+ * The account becomes self-custodied and its recovery email is deleted
33
+ * (ADR 0029 D3).
34
+ */
41
35
  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
36
  };
51
37
  export const IDENTITY_PROOF_ACTION_VALUES = Object.values(IDENTITY_PROOF_ACTIONS);
52
38
  const HEX_DIGEST = /^[0-9a-f]{64}$/;
@@ -139,21 +125,12 @@ export const identityProofChallengeResponseSchema = z.object({
139
125
  */
140
126
  export const IDENTITY_ERROR_CODES = {
141
127
  proofInvalid: 'IDENTITY_PROOF_INVALID',
142
- revisionConflict: 'IDENTITY_ENVELOPE_REVISION_CONFLICT',
143
128
  rootAlreadyLinked: 'IDENTITY_ROOT_ALREADY_LINKED',
144
129
  rootLinkedElsewhere: 'IDENTITY_ROOT_LINKED_ELSEWHERE',
145
- noRoot: 'IDENTITY_NO_ROOT',
146
130
  freshFactorRequired: 'IDENTITY_FRESH_FACTOR_REQUIRED',
147
- lastWebHolder: 'IDENTITY_LAST_WEB_HOLDER',
148
- enrollmentRequired: 'IDENTITY_ENROLLMENT_REQUIRED',
149
- enrollmentInvalid: 'IDENTITY_ENROLLMENT_INVALID',
150
131
  notPersonal: 'IDENTITY_NOT_PERSONAL_ACCOUNT',
151
- recoveryFailed: 'IDENTITY_RECOVERY_FAILED',
152
132
  };
153
133
  export const identityRootStatusSchema = z.object({
154
134
  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(),
135
+ recoveryEmail: z.string().nullable(),
159
136
  });
package/dist/esm/index.js CHANGED
@@ -80,18 +80,9 @@ export {
80
80
  // Schemas — encrypted off-device identity backup (b3 Feature 1)
81
81
  backupLookupIdSchema, encryptedBackupEnvelopeSchema, backupUploadRequestSchema, backupStatusResponseSchema, } from './keyRecovery.js';
82
82
  export {
83
- // Schemas — web identity carrier (one identity, two carriers)
84
- WEB_IDENTITY_ENVELOPE_VERSION, WEB_IDENTITY_SECRET_KINDS, webIdentityPublicKeySchema, webauthnCredentialIdSchema, webauthnRpIdSchema, webIdentityWrapSchema, webIdentityEnvelopeSchema, webIdentityEnvelopeUploadSchema, webIdentityHolderSchema, webIdentityEnvelopeResponseSchema, webIdentityEnvelopeProofFieldsSchema, webIdentityEnvelopeActionSchema, webIdentityEnvelopePutSchema, webauthnAssertionResponseSchema, webIdentityEnvelopeEstablishSchema, } from './webIdentityCarrier.js';
85
- export {
86
83
  // Identity proofs (ADR 0024 D7) — the one signed format for root operations
87
84
  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';
88
85
  export {
89
- // Signed-out recovery (ADR 0024 D5)
90
- IDENTITY_RECOVERY_TTL_MS, identityRecoveryChallengeResponseSchema, identityRecoveryStartRequestSchema, identityRecoveryCompleteRequestSchema, } from './identityRecovery.js';
91
- export {
92
- // Schemas — moving a web identity into Commons
93
- 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';
94
- export {
95
86
  // Shared primitives
96
87
  updatePlatformSchema, updateStatusSchema, updateAssetStatusSchema, sha256HexSchema, channelNameSchema, runtimeVersionSchema, rolloutPercentSchema,
97
88
  // Assets: init + complete
@@ -104,7 +95,13 @@ updateSchema, createUpdateResponseSchema, rollbackToEmbeddedEntrySchema, channel
104
95
  rollbackRequestSchema, rollbackToEmbeddedRequestSchema, promoteRequestSchema, updateRolloutPatchSchema, } from './updates.js';
105
96
  export {
106
97
  // Schemas
107
- webauthnRegisterOptionsRequestSchema, webauthnLoginOptionsRequestSchema, webauthnRegisterVerifyRequestSchema, webauthnLoginVerifyRequestSchema, } from './webauthn.js';
98
+ webauthnRegisterOptionsRequestSchema, webauthnLoginOptionsRequestSchema, webauthnRegisterVerifyRequestSchema, webauthnLoginVerifyRequestSchema, webauthnCredentialIdSchema, webauthnAssertionResponseSchema, } from './webauthn.js';
99
+ export {
100
+ // Recovery email of a passkey account (ADR 0029 D3)
101
+ EMAIL_VERIFICATION_PURPOSES, EMAIL_CODE_LENGTH, EMAIL_CODE_TTL_MS, EMAIL_CODE_MAX_ATTEMPTS, EMAIL_TICKET_TTL_MS, EMAIL_VERIFICATION_ERROR_CODES, emailAddressSchema, emailTicketSchema, emailVerificationStartRequestSchema, emailVerificationStartResponseSchema, emailVerificationConfirmRequestSchema, emailVerificationConfirmResponseSchema, } from './accountEmail.js';
102
+ export {
103
+ // Linking Commons to a passkey account (ADR 0029 D3)
104
+ IDENTITY_LINK_STATUSES, IDENTITY_LINK_QR_PREFIX, identityLinkIdSchema, buildIdentityLinkQrPayload, parseIdentityLinkQrPayload, identityLinkCreateResponseSchema, identityLinkStateSchema, identityLinkProofRequestSchema, identityLinkOptionsRequestSchema, identityLinkCompleteRequestSchema, } from './identityLink.js';
108
105
  export {
109
106
  // Schemas — transparency log (checkpoints + inclusion proofs)
110
107
  transparencyCheckpointSignatureSchema, transparencyAnchorSchema, transparencyCheckpointSchema, transparencyInclusionProofSchema, transparencyCheckpointListSchema, } from './transparency.js';
@@ -178,4 +175,5 @@ export { emailContextAddressSchema, emailContextMailboxSchema, emailContextMessa
178
175
  export { inboxComposeRequestSchema, inboxDailyBriefRequestSchema, inboxNaturalSearchRequestSchema, inboxMessageInferenceParamsSchema, inboxInferenceTextResponseSchema, inboxNaturalSearchResponseSchema, inboxSmartRepliesResponseSchema, inboxThreadSummaryResponseSchema, inboxInferenceStreamEventSchema, } from './inference/inbox.js';
179
176
  export * from './externalIdentity.js';
180
177
  export * from './linkedAccounts.js';
178
+ export * from './federationInstanceFetch.js';
181
179
  export * from './notifications.js';
@@ -88,3 +88,36 @@ export const LINKED_ACCOUNT_CALLBACK_ERRORS = [
88
88
  'verification_failed',
89
89
  'provider_unavailable',
90
90
  ];
91
+ /**
92
+ * Why `POST /linked-accounts/:network/start` refused, as `details.reason` on
93
+ * its 400 (`{ error: 'BAD_REQUEST', message, details: { reason } }`). The
94
+ * message is for logs; show the user a text chosen from the reason.
95
+ *
96
+ * - `instance_invalid`: the ActivityPub `instance` is not a server name.
97
+ * - `instance_unreachable`: the server does not resolve to a public address, or
98
+ * could not be connected to.
99
+ * - `handle_unresolvable`: the atproto handle or DID does not resolve to an
100
+ * account. The only reason that means "check what you typed".
101
+ * - `provider_rejected`: the other network answered and refused Oxy's
102
+ * request — for atproto an authorization-server error such as
103
+ * `invalid_client_metadata`; for a Mastodon-API server a refused app
104
+ * registration (often: not a Mastodon-compatible server). Nothing the user
105
+ * typed is wrong.
106
+ * - `provider_unavailable`: the other network could not be asked right now
107
+ * (its OAuth metadata did not load, a 5xx, a timeout). Try again later.
108
+ *
109
+ * A refusal with no `details.reason` (a bad `returnTo`, a missing field) is a
110
+ * client bug, not something to show.
111
+ */
112
+ export const LINKED_ACCOUNT_START_ERROR_REASONS = [
113
+ 'instance_invalid',
114
+ 'instance_unreachable',
115
+ 'handle_unresolvable',
116
+ 'provider_rejected',
117
+ 'provider_unavailable',
118
+ ];
119
+ export const linkedAccountStartErrorReasonSchema = z.enum(LINKED_ACCOUNT_START_ERROR_REASONS);
120
+ /** The `details` of a start refusal. */
121
+ export const linkedAccountStartErrorDetailsSchema = z
122
+ .object({ reason: linkedAccountStartErrorReasonSchema })
123
+ .strict();
@@ -76,3 +76,22 @@ export const createOxyNotificationRequestSchema = z
76
76
  }
77
77
  }
78
78
  });
79
+ /**
80
+ * Android notification channel a `system` notification is pushed on — a wire
81
+ * contract, because Android 8+ silently drops a push whose channel the app has
82
+ * not created. Created by the vault (Commons) before it registers its token.
83
+ */
84
+ export const OXY_ACCOUNT_PUSH_CHANNEL = 'account';
85
+ /** Runtime type discriminator of the push that announces a `system` notification. */
86
+ export const OXY_SYSTEM_NOTIFICATION_PUSH_TYPE = 'oxy_system_notification';
87
+ /**
88
+ * The ONLY data a `system` notification's push carries: its id. The title and
89
+ * message ride as the push's own title and body; the deep link does NOT travel,
90
+ * because a push payload is untrusted at the receiver. On a tap the vault
91
+ * re-reads the notification from Oxy by id (scoped to the signed-in recipient)
92
+ * and opens the `url` stored there.
93
+ */
94
+ export const oxySystemNotificationPushDataSchema = z.object({
95
+ type: z.literal(OXY_SYSTEM_NOTIFICATION_PUSH_TYPE),
96
+ notificationId: z.string().min(1).max(64),
97
+ });
@@ -11,8 +11,37 @@
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
+ import { emailAddressSchema, emailTicketSchema } from './accountEmail.js';
15
+ /** A WebAuthn credential id, base64url as the browser reports it. */
16
+ export const webauthnCredentialIdSchema = z
17
+ .string()
18
+ .trim()
19
+ .min(16)
20
+ .max(1024)
21
+ .regex(/^[A-Za-z0-9_-]+$/, 'credentialId must be base64url');
22
+ /**
23
+ * A WebAuthn assertion by one of the account's EXISTING passkeys over a server
24
+ * challenge — the fresh use of the factor the account already has (linking
25
+ * Commons to a passkey account, deleting a passkey account). The API verifies
26
+ * it with `@simplewebauthn/server`; this only bounds its shape.
27
+ */
28
+ export const webauthnAssertionResponseSchema = z
29
+ .object({
30
+ id: webauthnCredentialIdSchema,
31
+ rawId: z.string().min(1).max(2048),
32
+ type: z.literal('public-key'),
33
+ response: z
34
+ .object({
35
+ clientDataJSON: z.string().min(1).max(8192),
36
+ authenticatorData: z.string().min(1).max(8192),
37
+ signature: z.string().min(1).max(2048),
38
+ userHandle: z.string().max(2048).optional(),
39
+ })
40
+ .passthrough(),
41
+ clientExtensionResults: z.record(z.string(), z.unknown()).optional(),
42
+ authenticatorAttachment: z.string().optional(),
43
+ })
44
+ .passthrough();
16
45
  /**
17
46
  * Device-session options shared by every first-party sign-in body
18
47
  * (`deviceName`/`deviceFingerprint`). Mirrors what
@@ -33,13 +62,16 @@ const deviceSessionEnvelope = {
33
62
  deviceFingerprint: z.string().trim().min(1).max(256).optional(),
34
63
  };
35
64
  /**
36
- * `POST /webauthn/register/options` — request registration options. With a bearer
37
- * token the caller links a passkey to their signed-in account and `username` is
38
- * ignored; without one it is a prospective signup and `username` is the desired
39
- * (not-yet-created) handle.
65
+ * `POST /webauthn/register/options` — request registration options. Three flows:
66
+ *
67
+ * - a bearer: the caller adds a passkey to their signed-in account;
68
+ * - `recoveryTicket` (no bearer): a new passkey for the account a recovery code
69
+ * was confirmed for (`POST /auth/email/verify/confirm`, purpose `recovery`);
70
+ * - `username` (no bearer): a prospective sign-up; the handle is not created yet.
40
71
  */
41
72
  export const webauthnRegisterOptionsRequestSchema = z.object({
42
73
  username: z.string().trim().min(1).max(60).optional(),
74
+ recoveryTicket: emailTicketSchema.optional(),
43
75
  });
44
76
  /**
45
77
  * `POST /webauthn/login/options` — request authentication options. When
@@ -53,23 +85,20 @@ export const webauthnLoginOptionsRequestSchema = z.object({
53
85
  /**
54
86
  * `POST /webauthn/register/verify` — the outer envelope. The browser
55
87
  * `RegistrationResponseJSON` travels alongside these fields under `response` and
56
- * is validated by `@simplewebauthn/server`, not here. `username` is required only
57
- * for the prospective-signup branch (no bearer); the linking branch ignores it.
88
+ * is validated by `@simplewebauthn/server`, not here.
89
+ *
90
+ * - Sign-up (no bearer, ADR 0029 D3): `username`, the recovery `email` and the
91
+ * `emailTicket` its code was confirmed with. The account is created with the
92
+ * passkey and that verified email, and no key.
93
+ * - Recovery (no bearer): `recoveryTicket`; the passkey is added to its account
94
+ * and a session minted.
95
+ * - A bearer: the passkey is added to the signed-in account.
58
96
  */
59
97
  export const webauthnRegisterVerifyRequestSchema = z.object({
60
98
  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(),
99
+ email: emailAddressSchema.optional(),
100
+ emailTicket: emailTicketSchema.optional(),
101
+ recoveryTicket: emailTicketSchema.optional(),
73
102
  ...deviceSessionEnvelope,
74
103
  });
75
104
  /**