@oxy.so/core 1.16.0 → 1.18.1

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 (109) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +30 -1
  3. package/dist/cjs/crypto/identityLink.js +18 -0
  4. package/dist/cjs/i18n/locales/ar-SA.json +78 -3
  5. package/dist/cjs/i18n/locales/ca-ES.json +78 -3
  6. package/dist/cjs/i18n/locales/de-DE.json +78 -3
  7. package/dist/cjs/i18n/locales/en-US.json +75 -28
  8. package/dist/cjs/i18n/locales/es-ES.json +75 -28
  9. package/dist/cjs/i18n/locales/fr-FR.json +78 -3
  10. package/dist/cjs/i18n/locales/it-IT.json +78 -3
  11. package/dist/cjs/i18n/locales/ja-JP.json +78 -3
  12. package/dist/cjs/i18n/locales/ko-KR.json +78 -3
  13. package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
  14. package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
  15. package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
  16. package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
  17. package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
  18. package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
  19. package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
  20. package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
  21. package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
  22. package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
  23. package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
  24. package/dist/cjs/i18n/locales/pt-PT.json +78 -3
  25. package/dist/cjs/i18n/locales/zh-CN.json +78 -3
  26. package/dist/cjs/index.js +9 -42
  27. package/dist/cjs/mixins/OxyServices.auth.js +110 -17
  28. package/dist/cjs/mixins/OxyServices.identity.js +128 -8
  29. package/dist/cjs/mixins/OxyServices.user.js +42 -7
  30. package/dist/cjs/utils/authWebUrl.js +4 -5
  31. package/dist/esm/.tsbuildinfo +1 -1
  32. package/dist/esm/HttpService.js +30 -1
  33. package/dist/esm/crypto/identityLink.js +15 -0
  34. package/dist/esm/i18n/locales/ar-SA.json +78 -3
  35. package/dist/esm/i18n/locales/ca-ES.json +78 -3
  36. package/dist/esm/i18n/locales/de-DE.json +78 -3
  37. package/dist/esm/i18n/locales/en-US.json +75 -28
  38. package/dist/esm/i18n/locales/es-ES.json +75 -28
  39. package/dist/esm/i18n/locales/fr-FR.json +78 -3
  40. package/dist/esm/i18n/locales/it-IT.json +78 -3
  41. package/dist/esm/i18n/locales/ja-JP.json +78 -3
  42. package/dist/esm/i18n/locales/ko-KR.json +78 -3
  43. package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
  44. package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
  45. package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
  46. package/dist/esm/i18n/locales/locales/en-US.json +75 -28
  47. package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
  48. package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
  49. package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
  50. package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
  51. package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
  52. package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
  53. package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
  54. package/dist/esm/i18n/locales/pt-PT.json +78 -3
  55. package/dist/esm/i18n/locales/zh-CN.json +78 -3
  56. package/dist/esm/index.js +4 -4
  57. package/dist/esm/mixins/OxyServices.auth.js +110 -17
  58. package/dist/esm/mixins/OxyServices.identity.js +128 -8
  59. package/dist/esm/mixins/OxyServices.user.js +42 -7
  60. package/dist/esm/utils/authWebUrl.js +4 -5
  61. package/dist/types/.tsbuildinfo +1 -1
  62. package/dist/types/HttpService.d.ts +21 -0
  63. package/dist/types/crypto/identityLink.d.ts +8 -0
  64. package/dist/types/index.d.ts +2 -5
  65. package/dist/types/mixins/OxyServices.auth.d.ts +68 -25
  66. package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
  67. package/dist/types/mixins/OxyServices.user.d.ts +29 -6
  68. package/dist/types/models/interfaces.d.ts +67 -13
  69. package/dist/types/server/securityHeaders.d.ts +3 -3
  70. package/dist/types/utils/authWebUrl.d.ts +4 -5
  71. package/package.json +3 -3
  72. package/src/HttpService.ts +35 -1
  73. package/src/__tests__/serviceIdentityWhenAnonymous.test.ts +159 -0
  74. package/src/crypto/__tests__/identityLink.test.ts +18 -0
  75. package/src/crypto/__tests__/identityProof.test.ts +6 -6
  76. package/src/crypto/identityLink.ts +18 -0
  77. package/src/i18n/locales/ar-SA.json +78 -3
  78. package/src/i18n/locales/ca-ES.json +78 -3
  79. package/src/i18n/locales/de-DE.json +78 -3
  80. package/src/i18n/locales/en-US.json +75 -28
  81. package/src/i18n/locales/es-ES.json +75 -28
  82. package/src/i18n/locales/fr-FR.json +78 -3
  83. package/src/i18n/locales/it-IT.json +78 -3
  84. package/src/i18n/locales/ja-JP.json +78 -3
  85. package/src/i18n/locales/ko-KR.json +78 -3
  86. package/src/i18n/locales/pt-PT.json +78 -3
  87. package/src/i18n/locales/zh-CN.json +78 -3
  88. package/src/index.ts +6 -48
  89. package/src/mixins/OxyServices.auth.ts +127 -24
  90. package/src/mixins/OxyServices.identity.ts +126 -8
  91. package/src/mixins/OxyServices.user.ts +51 -10
  92. package/src/mixins/__tests__/identityLink.test.ts +89 -0
  93. package/src/mixins/__tests__/notifications.read.test.ts +82 -0
  94. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
  95. package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
  96. package/src/models/interfaces.ts +67 -12
  97. package/src/server/securityHeaders.ts +3 -3
  98. package/src/utils/authWebUrl.ts +4 -5
  99. package/dist/cjs/crypto/identityMove.js +0 -142
  100. package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
  101. package/dist/esm/crypto/identityMove.js +0 -128
  102. package/dist/esm/crypto/webIdentityCarrier.js +0 -303
  103. package/dist/types/crypto/identityMove.d.ts +0 -75
  104. package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
  105. package/src/crypto/__tests__/identityMove.test.ts +0 -146
  106. package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
  107. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
  108. package/src/crypto/identityMove.ts +0 -167
  109. package/src/crypto/webIdentityCarrier.ts +0 -394
@@ -1,4 +1,4 @@
1
- import { loginResultSchema, safeParseContract } from '@oxy.so/contracts';
1
+ import { emailVerificationConfirmResponseSchema, emailVerificationStartResponseSchema, loginResultSchema, safeParseContract, } from '@oxy.so/contracts';
2
2
  export { getCommonsApprovalBlockingReason, parseCommonsApprovalExpiresAt, } from '../utils/commonsApproval.js';
3
3
  import { OxyAuthenticationError } from '../OxyServices.errors.js';
4
4
  import { KeyManager } from '../crypto/keyManager.js';
@@ -85,6 +85,11 @@ export class ServiceCredentialMismatchError extends Error {
85
85
  this.name = 'ServiceCredentialMismatchError';
86
86
  }
87
87
  }
88
+ /**
89
+ * How long a failed service-token mint sends session-less requests anonymous
90
+ * before the next attempt, under `serviceIdentity: 'when-anonymous'`.
91
+ */
92
+ export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30000;
88
93
  export function OxyServicesAuthMixin(Base) {
89
94
  return class extends Base {
90
95
  constructor(...args) {
@@ -111,6 +116,56 @@ export function OxyServicesAuthMixin(Base) {
111
116
  this._serviceApiKey = null;
112
117
  /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
113
118
  this._serviceApiSecret = null;
119
+ /**
120
+ * Before this instant, a request without a user session does not try to
121
+ * mint a service token and goes out anonymous. Set after a failed mint; see
122
+ * {@link _serviceTokenForAnonymousRequest}.
123
+ *
124
+ * @internal
125
+ */
126
+ this._anonymousServiceTokenRetryAt = 0;
127
+ if (this.config.serviceIdentity === 'when-anonymous') {
128
+ this.httpService.setAnonymousAuthProvider(() => this._serviceTokenForAnonymousRequest());
129
+ }
130
+ }
131
+ /**
132
+ * The bearer for a request with no user session, under `serviceIdentity:
133
+ * 'when-anonymous'`: this process's service token, or `null` to send the
134
+ * request anonymous as before.
135
+ *
136
+ * `null`, never a throw, whenever there is no token to offer:
137
+ * - no key pair and no attestable workload (a local checkout) — asking
138
+ * costs nothing and there is nothing to ask for;
139
+ * - the mint failed. The request still goes out, anonymous, and so does
140
+ * every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
141
+ * an Oxy that is refusing attestation (seen for ~2.5 minutes after a
142
+ * deploy on 2026-09-25) is asked once per window rather than once per
143
+ * read, and no read waits on a mint that just failed.
144
+ *
145
+ * The token itself comes from {@link getServiceToken}: cached for its
146
+ * lifetime and single-flight, so concurrent reads share one mint.
147
+ *
148
+ * @internal
149
+ */
150
+ async _serviceTokenForAnonymousRequest() {
151
+ if (Date.now() < this._anonymousServiceTokenRetryAt)
152
+ return null;
153
+ const hasKeyPair = Boolean(this._serviceApiKey && this._serviceApiSecret);
154
+ if (!hasKeyPair && !(await this._canUseWorkloadIdentity()))
155
+ return null;
156
+ try {
157
+ return await this.getServiceToken();
158
+ }
159
+ catch (error) {
160
+ this._anonymousServiceTokenRetryAt = Date.now() + ANONYMOUS_SERVICE_TOKEN_RETRY_MS;
161
+ logger.warn('[oxy.auth] No service token for a request without a session; sending it anonymous', {
162
+ component: 'auth',
163
+ method: '_serviceTokenForAnonymousRequest',
164
+ retryInMs: ANONYMOUS_SERVICE_TOKEN_RETRY_MS,
165
+ error: error instanceof Error ? error.message : String(error),
166
+ });
167
+ return null;
168
+ }
114
169
  }
115
170
  /**
116
171
  * Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
@@ -1126,12 +1181,40 @@ export function OxyServicesAuthMixin(Base) {
1126
1181
  }
1127
1182
  }
1128
1183
  /**
1129
- * Check email availability
1184
+ * Send a 6-digit code to an email (ADR 0029 D3): the recovery email of a
1185
+ * new passkey account (`purpose: 'signup'`), or — named by username or
1186
+ * email — the recovery email of an account being recovered
1187
+ * (`purpose: 'recovery'`). The answer is the same whether or not an
1188
+ * account exists; nothing is revealed about who has one.
1130
1189
  */
1131
- async checkEmailAvailability(email) {
1190
+ async startEmailVerification(request) {
1132
1191
  try {
1133
- // Public availability lookup (pre-session) — skip the bearer preflight.
1134
- return await this.makeRequest('GET', `/auth/check-email/${email}`, undefined, { cache: false, skipAuth: true });
1192
+ const res = await this.makeRequest('POST', '/auth/email/verify/start', request, {
1193
+ cache: false,
1194
+ skipAuth: true,
1195
+ });
1196
+ const parsed = safeParseContract(emailVerificationStartResponseSchema, res);
1197
+ if (!parsed)
1198
+ throw new Error('auth/email/verify/start returned an unexpected response shape');
1199
+ return parsed;
1200
+ }
1201
+ catch (error) {
1202
+ throw this.handleError(error);
1203
+ }
1204
+ }
1205
+ /**
1206
+ * Confirm the code {@link startEmailVerification} sent. Resolves to a
1207
+ * short-lived one-use ticket: a sign-up passes it (with the email) to
1208
+ * {@link webauthnRegisterVerify}; a recovery passes it as `recoveryTicket`
1209
+ * to {@link webauthnRegisterOptions} and {@link webauthnRegisterVerify}.
1210
+ */
1211
+ async confirmEmailVerification(verificationId, code) {
1212
+ try {
1213
+ const res = await this.makeRequest('POST', '/auth/email/verify/confirm', { verificationId, code }, { cache: false, skipAuth: true });
1214
+ const parsed = safeParseContract(emailVerificationConfirmResponseSchema, res);
1215
+ if (!parsed)
1216
+ throw new Error('auth/email/verify/confirm returned an unexpected response shape');
1217
+ return parsed;
1135
1218
  }
1136
1219
  catch (error) {
1137
1220
  throw this.handleError(error);
@@ -1142,15 +1225,20 @@ export function OxyServicesAuthMixin(Base) {
1142
1225
  * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
1143
1226
  * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
1144
1227
  *
1145
- * With a bearer token planted this links a passkey to the signed-in account
1146
- * (`username` ignored); without one it is a prospective signup and `username`
1147
- * is the desired handle. The returned options are OPAQUE — Oxy does not own
1148
- * their shape (the browser / `@simplewebauthn` does), so they pass through
1149
- * as `unknown` for the caller to hand straight to the ceremony.
1228
+ * With a bearer token planted this adds a passkey to the signed-in account.
1229
+ * Without one, `username` is a prospective sign-up's handle, and
1230
+ * `recoveryTicket` a recovery's new passkey for the account it names. The
1231
+ * returned options are OPAQUE — Oxy does not own their shape (the browser /
1232
+ * `@simplewebauthn` does), so they pass through as `unknown` for the caller
1233
+ * to hand straight to the ceremony.
1150
1234
  */
1151
- async webauthnRegisterOptions(username) {
1235
+ async webauthnRegisterOptions(request = {}) {
1236
+ const signedOut = request.username !== undefined || request.recoveryTicket !== undefined;
1152
1237
  try {
1153
- return await this.makeRequest('POST', '/auth/webauthn/register/options', { ...(username !== undefined ? { username } : {}) }, { cache: false, ...(username !== undefined ? { skipAuth: true } : {}) });
1238
+ return await this.makeRequest('POST', '/auth/webauthn/register/options', {
1239
+ ...(request.username !== undefined ? { username: request.username } : {}),
1240
+ ...(request.recoveryTicket !== undefined ? { recoveryTicket: request.recoveryTicket } : {}),
1241
+ }, { cache: false, ...(signedOut ? { skipAuth: true } : {}) });
1154
1242
  }
1155
1243
  catch (error) {
1156
1244
  throw this.handleError(error);
@@ -1161,17 +1249,22 @@ export function OxyServicesAuthMixin(Base) {
1161
1249
  * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
1162
1250
  * (desired `username` for signup + the device-session naming fields).
1163
1251
  *
1164
- * Two server branches, disambiguated by the response shape:
1165
- * - **Signup** (no bearer): the account is created and a session minted —
1166
- * the response carries `sessionId`, is the SAME {@link LoginResult}
1167
- * contract as `POST /auth/verify`, and its access token is planted here.
1252
+ * Server branches, disambiguated by the response shape:
1253
+ * - **Sign-up** (no bearer; `username`, `email`, `emailTicket`) and
1254
+ * **recovery** (no bearer; `recoveryTicket`): the account is created, or
1255
+ * gains the passkey, and a session is minted — the response carries
1256
+ * `sessionId`, is the SAME {@link LoginResult} contract as
1257
+ * `POST /auth/verify`, and its access token is planted here.
1168
1258
  * - **Link** (bearer present): the passkey is attached to the signed-in
1169
1259
  * account and the server returns `{ success, message }` with no session,
1170
1260
  * which is returned verbatim (no token planting).
1171
1261
  */
1172
1262
  async webauthnRegisterVerify(response, envelope = {}) {
1173
1263
  try {
1174
- const res = await this.makeRequest('POST', '/auth/webauthn/register/verify', { response, ...envelope }, { cache: false, ...(envelope.username !== undefined ? { skipAuth: true } : {}) });
1264
+ const res = await this.makeRequest('POST', '/auth/webauthn/register/verify', { response, ...envelope }, {
1265
+ cache: false,
1266
+ ...(envelope.username !== undefined || envelope.recoveryTicket !== undefined ? { skipAuth: true } : {}),
1267
+ });
1175
1268
  if (res && typeof res === 'object') {
1176
1269
  const record = res;
1177
1270
  // Signup branch: mints a session (LoginSessionResult, carries
@@ -1,5 +1,33 @@
1
+ /**
2
+ * Identity Methods Mixin (self-sovereign identity layer)
3
+ *
4
+ * Provides typed access to Oxy's AtProto/Bluesky-flavoured identity &
5
+ * portability layer:
6
+ * - DID resolution (`did:web:oxy.so:u:<userId>`, derived on demand by the API).
7
+ * - The auth-method ↔ DID verification-method mapping (`GET /auth/methods`) and
8
+ * passkey removal. A root is linked by the holder flows (ADR 0024) and never
9
+ * unlinked; it is replaced by rotation.
10
+ * - Signed records: clients sign an envelope with their own cryptographic key
11
+ * (`SignatureService.signRecord` + the shared `canonicalize`) and publish it;
12
+ * anyone can fetch and verify it.
13
+ * - The signed data-export ("credible exit") bundle.
14
+ * - Verified-domain badges (prove ownership of `nate.com`).
15
+ *
16
+ * Wire shapes come from `@oxy.so/contracts` (`DidDocument`,
17
+ * `SignedRecordEnvelope`, `AuthMethodsResponse`, `VerifiedDomain`,
18
+ * `DomainVerificationInstructions`, `ExportBundle`) — the single source of truth
19
+ * the API validates its output against, so producer and consumer cannot drift.
20
+ *
21
+ * Identity signing is NATIVE-ONLY: the private key lives in native secure
22
+ * storage, so `signRecord` and `publishRecord` require an
23
+ * on-device identity and throw on web (where `KeyManager.getPublicKey()` is
24
+ * always `null`).
25
+ */
26
+ import { IDENTITY_PROOF_ACTIONS, identityLinkCreateResponseSchema, identityLinkStateSchema, safeParseContract, } from '@oxy.so/contracts';
1
27
  import { signMessage } from '@oxy.so/protocol';
2
28
  import { KeyManager } from '../crypto/keyManager.js';
29
+ import { deriveIdentityLinkCode } from '../crypto/identityLink.js';
30
+ import { signIdentityProof } from '../crypto/identityProof.js';
3
31
  import { SignatureService } from '../crypto/signatureService.js';
4
32
  import { RecoveryPhraseService } from '../crypto/recoveryPhrase.js';
5
33
  import { isWeb } from '../utils/platform.js';
@@ -71,11 +99,9 @@ export function OxyServicesIdentityMixin(Base) {
71
99
  }
72
100
  }
73
101
  /**
74
- * The signed-in account's root readiness (ADR 0024 D5): whether a root is
75
- * linked, how many passkeys can open the web holder, whether the root has a
76
- * phrase and whether it is saved and was shown to recover it. Metadata only —
77
- * nothing here can open the root — so any first-party surface may show a
78
- * "save your recovery phrase" reminder from it.
102
+ * How the signed-in account is kept (ADR 0029 D3): whether Commons' root is
103
+ * linked (self-custody), or the recovery email of a passkey account. Any
104
+ * first-party surface may read it to recommend linking Commons.
79
105
  */
80
106
  async getIdentityRootStatus() {
81
107
  try {
@@ -85,15 +111,109 @@ export function OxyServicesIdentityMixin(Base) {
85
111
  throw this.handleError(error);
86
112
  }
87
113
  }
114
+ /**
115
+ * Open a request to link Commons to the signed-in passkey account (ADR 0029
116
+ * D3). auth.oxy.so shows its `qrPayload`; Commons scans it and signs with
117
+ * {@link signIdentityLink}.
118
+ */
119
+ async createIdentityLink() {
120
+ try {
121
+ const res = await this.makeRequest('POST', '/identity/link', undefined, { cache: false });
122
+ const parsed = safeParseContract(identityLinkCreateResponseSchema, res);
123
+ if (!parsed)
124
+ throw new Error('identity/link returned an unexpected response shape');
125
+ return parsed;
126
+ }
127
+ catch (error) {
128
+ throw this.handleError(error);
129
+ }
130
+ }
131
+ /** Where a link request stands. Both devices poll it; it needs no session. */
132
+ async getIdentityLink(linkId) {
133
+ try {
134
+ const res = await this.makeRequest('GET', `/identity/link/${encodeURIComponent(linkId)}`, undefined, {
135
+ cache: false,
136
+ skipAuth: true,
137
+ });
138
+ const parsed = safeParseContract(identityLinkStateSchema, res);
139
+ if (!parsed)
140
+ throw new Error('identity/link returned an unexpected response shape');
141
+ return parsed;
142
+ }
143
+ catch (error) {
144
+ throw this.handleError(error);
145
+ }
146
+ }
147
+ /**
148
+ * Commons' half: sign the scanned request's `link_identity` proof with THIS
149
+ * device's identity key and post it. NATIVE-ONLY (the key lives in native
150
+ * secure storage). Resolves to the key and the 6-digit code auth.oxy.so will
151
+ * show for it, for the person to compare.
152
+ */
153
+ async signIdentityLink(linkId, challenge) {
154
+ try {
155
+ const [privateKey, publicKey] = await Promise.all([KeyManager.getPrivateKey(), KeyManager.getPublicKey()]);
156
+ if (!privateKey || !publicKey) {
157
+ throw new Error('No identity on this device to link');
158
+ }
159
+ const root = publicKey.trim().toLowerCase();
160
+ const state = await this.getIdentityLink(linkId);
161
+ const proof = await signIdentityProof({ privateKey, publicKey: root }, {
162
+ action: IDENTITY_PROOF_ACTIONS.link,
163
+ subject: state.userId,
164
+ actor: state.userId,
165
+ rootPublicKey: root,
166
+ payloadDigest: null,
167
+ expectedRevision: null,
168
+ audience: state.audience,
169
+ challenge,
170
+ expiresAt: state.expiresAt,
171
+ });
172
+ await this.makeRequest('POST', `/identity/link/${encodeURIComponent(linkId)}/proof`, { publicKey: root, proof }, { cache: false, skipAuth: true });
173
+ return { publicKey: root, code: deriveIdentityLinkCode(linkId, root), username: state.username };
174
+ }
175
+ catch (error) {
176
+ throw this.handleError(error);
177
+ }
178
+ }
179
+ /** WebAuthn request options over the account's passkeys, whose challenge is the link's. Opaque. */
180
+ async getIdentityLinkAssertionOptions(linkId, challenge) {
181
+ try {
182
+ return await this.makeRequest('POST', `/identity/link/${encodeURIComponent(linkId)}/options`, { challenge }, { cache: false });
183
+ }
184
+ catch (error) {
185
+ throw this.handleError(error);
186
+ }
187
+ }
188
+ /**
189
+ * auth.oxy.so's half: the passkey assertion over the link's challenge. The
190
+ * account gains Commons' root and loses its recovery email.
191
+ */
192
+ async completeIdentityLink(linkId, assertion) {
193
+ try {
194
+ const result = await this.makeRequest('POST', `/identity/link/${encodeURIComponent(linkId)}/complete`, { assertion }, { cache: false });
195
+ this._invalidateIdentityCaches(this.getCurrentUserId());
196
+ return result;
197
+ }
198
+ catch (error) {
199
+ throw this.handleError(error);
200
+ }
201
+ }
202
+ /** Withdraw a link request that has not completed. */
203
+ async cancelIdentityLink(linkId) {
204
+ try {
205
+ await this.makeRequest('DELETE', `/identity/link/${encodeURIComponent(linkId)}`, undefined, { cache: false });
206
+ }
207
+ catch (error) {
208
+ throw this.handleError(error);
209
+ }
210
+ }
88
211
  /**
89
212
  * Remove ONE passkey (WebAuthn credential) from the current account.
90
213
  *
91
214
  * Passkeys are per-credential, so this targets a specific credential id.
92
215
  * The server refuses to remove the last remaining auth method (the account
93
216
  * would become inaccessible) and deletes the stored `WebauthnCredential`.
94
- * A passkey that also opens the account's root on the web takes its wrap with
95
- * it, and the ONLY such passkey is refused with
96
- * `code: 'IDENTITY_LAST_WEB_HOLDER'` (ADR 0024 D6).
97
217
  *
98
218
  * @param credentialId - The passkey's public credential id
99
219
  * (`AuthMethodEntry.credentialId`).
@@ -532,6 +532,35 @@ export function OxyServicesUserMixin(Base) {
532
532
  throw this.handleError(error);
533
533
  }
534
534
  }
535
+ /**
536
+ * WebAuthn request options to delete a PASSKEY account (ADR 0029 D3): its own
537
+ * passkeys, user verification required, and a challenge bound to it. Opaque —
538
+ * hand them to the browser's authentication ceremony, on auth.oxy.so. An
539
+ * account with a Commons key deletes with {@link deleteAccount} instead.
540
+ */
541
+ async getAccountDeletionOptions() {
542
+ try {
543
+ return await this.makeRequest('POST', '/users/me/delete/options', undefined, { cache: false });
544
+ }
545
+ catch (error) {
546
+ throw this.handleError(error);
547
+ }
548
+ }
549
+ /**
550
+ * Delete a PASSKEY account permanently, with an assertion by one of its
551
+ * passkeys over {@link getAccountDeletionOptions}' challenge.
552
+ *
553
+ * @param confirmText - Must equal the user's username (verified server-side)
554
+ * @param assertion - The ceremony's opaque `AuthenticationResponseJSON`
555
+ */
556
+ async deleteAccountWithPasskey(confirmText, assertion) {
557
+ try {
558
+ return await this.makeRequest('DELETE', '/users/me', { confirmText, assertion }, { cache: false });
559
+ }
560
+ catch (error) {
561
+ throw this.handleError(error);
562
+ }
563
+ }
535
564
  /**
536
565
  * Invalidate every cached read a follow/unfollow write invalidates.
537
566
  *
@@ -841,11 +870,13 @@ export function OxyServicesUserMixin(Base) {
841
870
  }
842
871
  }
843
872
  /**
844
- * Get notifications
873
+ * One page of the signed-in user's Oxy notifications, newest first
874
+ * (`GET /notifications`), with the unread count across all pages.
845
875
  */
846
- async getNotifications() {
876
+ async getNotifications(params = {}) {
877
+ const query = buildQueryParams({ page: params.page, limit: params.limit });
847
878
  return this.withAuthRetry(async () => {
848
- return await this.makeRequest('GET', '/notifications', undefined, {
879
+ return await this.makeRequest('GET', '/notifications', query, {
849
880
  cache: false, // Don't cache notifications - always get fresh data
850
881
  });
851
882
  }, 'getNotifications');
@@ -858,7 +889,7 @@ export function OxyServicesUserMixin(Base) {
858
889
  const res = await this.makeRequest('GET', '/notifications/unread-count', undefined, {
859
890
  cache: false, // Don't cache unread count - always get fresh data
860
891
  });
861
- return res.count;
892
+ return res.unreadCount;
862
893
  }
863
894
  catch (error) {
864
895
  throw this.handleError(error);
@@ -873,18 +904,22 @@ export function OxyServicesUserMixin(Base) {
873
904
  */
874
905
  async createNotification(data) {
875
906
  try {
876
- return await this.makeRequest('POST', '/notifications', data, { cache: false });
907
+ const res = await this.makeRequest('POST', '/notifications', data, { cache: false });
908
+ return res.notification;
877
909
  }
878
910
  catch (error) {
879
911
  throw this.handleError(error);
880
912
  }
881
913
  }
882
914
  /**
883
- * Mark notification as read
915
+ * Mark one of the signed-in user's notifications read and return it as
916
+ * stored. Scoped to the recipient server-side, so it is also the
917
+ * authoritative read of a notification by id: a foreign or unknown id 404s.
884
918
  */
885
919
  async markNotificationAsRead(notificationId) {
886
920
  try {
887
- await this.makeRequest('PUT', `/notifications/${notificationId}/read`, undefined, { cache: false });
921
+ const res = await this.makeRequest('PUT', `/notifications/${encodeURIComponent(notificationId)}/read`, undefined, { cache: false });
922
+ return res.notification;
888
923
  }
889
924
  catch (error) {
890
925
  throw this.handleError(error);
@@ -15,10 +15,9 @@
15
15
  */
16
16
  export const CENTRAL_IDP_APEX = 'oxy.so';
17
17
  /**
18
- * The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface AND the
19
- * web identity carrier: where a person signs in or creates an account with a
20
- * passkey from another origin's popup, and the one origin that may create,
21
- * open, recover or move their self-custody identity (one identity, two
22
- * carriers). Apps open it; they never handle the identity themselves.
18
+ * The IdP's origin, `auth.oxy.so` — the OAuth authorize/consent surface and the
19
+ * one origin that asserts Oxy passkeys: where a person signs in, creates,
20
+ * recovers or deletes a web account, in the window an app opens (ADR 0029).
21
+ * Apps open it; they never run the passkey themselves.
23
22
  */
24
23
  export const AUTH_WEB_ORIGIN = `https://auth.${CENTRAL_IDP_APEX}`;