@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
@@ -23,8 +23,16 @@
23
23
  * on-device identity and throw on web (where `KeyManager.getPublicKey()` is
24
24
  * always `null`).
25
25
  */
26
+ import {
27
+ IDENTITY_PROOF_ACTIONS,
28
+ identityLinkCreateResponseSchema,
29
+ identityLinkStateSchema,
30
+ safeParseContract,
31
+ } from '@oxy.so/contracts';
26
32
  import type {
27
33
  AuthMethodsResponse,
34
+ IdentityLinkCreateResponse,
35
+ IdentityLinkState,
28
36
  IdentityRootStatus,
29
37
  DidDocument,
30
38
  DomainVerificationInstructions,
@@ -38,6 +46,8 @@ import type {
38
46
  import { signMessage } from '@oxy.so/protocol';
39
47
  import type { OxyServicesBase } from '../OxyServices.base';
40
48
  import { KeyManager } from '../crypto/keyManager';
49
+ import { deriveIdentityLinkCode } from '../crypto/identityLink';
50
+ import { signIdentityProof } from '../crypto/identityProof';
41
51
  import { SignatureService } from '../crypto/signatureService';
42
52
  import { RecoveryPhraseService, type PendingIdentityResult } from '../crypto/recoveryPhrase';
43
53
  import { isWeb } from '../utils/platform';
@@ -219,11 +229,9 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
219
229
  }
220
230
 
221
231
  /**
222
- * The signed-in account's root readiness (ADR 0024 D5): whether a root is
223
- * linked, how many passkeys can open the web holder, whether the root has a
224
- * phrase and whether it is saved and was shown to recover it. Metadata only —
225
- * nothing here can open the root — so any first-party surface may show a
226
- * "save your recovery phrase" reminder from it.
232
+ * How the signed-in account is kept (ADR 0029 D3): whether Commons' root is
233
+ * linked (self-custody), or the recovery email of a passkey account. Any
234
+ * first-party surface may read it to recommend linking Commons.
227
235
  */
228
236
  async getIdentityRootStatus(): Promise<IdentityRootStatus> {
229
237
  try {
@@ -233,15 +241,125 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
233
241
  }
234
242
  }
235
243
 
244
+ /**
245
+ * Open a request to link Commons to the signed-in passkey account (ADR 0029
246
+ * D3). auth.oxy.so shows its `qrPayload`; Commons scans it and signs with
247
+ * {@link signIdentityLink}.
248
+ */
249
+ async createIdentityLink(): Promise<IdentityLinkCreateResponse> {
250
+ try {
251
+ const res = await this.makeRequest<unknown>('POST', '/identity/link', undefined, { cache: false });
252
+ const parsed = safeParseContract(identityLinkCreateResponseSchema, res);
253
+ if (!parsed) throw new Error('identity/link returned an unexpected response shape');
254
+ return parsed;
255
+ } catch (error) {
256
+ throw this.handleError(error);
257
+ }
258
+ }
259
+
260
+ /** Where a link request stands. Both devices poll it; it needs no session. */
261
+ async getIdentityLink(linkId: string): Promise<IdentityLinkState> {
262
+ try {
263
+ const res = await this.makeRequest<unknown>('GET', `/identity/link/${encodeURIComponent(linkId)}`, undefined, {
264
+ cache: false,
265
+ skipAuth: true,
266
+ });
267
+ const parsed = safeParseContract(identityLinkStateSchema, res);
268
+ if (!parsed) throw new Error('identity/link returned an unexpected response shape');
269
+ return parsed;
270
+ } catch (error) {
271
+ throw this.handleError(error);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Commons' half: sign the scanned request's `link_identity` proof with THIS
277
+ * device's identity key and post it. NATIVE-ONLY (the key lives in native
278
+ * secure storage). Resolves to the key and the 6-digit code auth.oxy.so will
279
+ * show for it, for the person to compare.
280
+ */
281
+ async signIdentityLink(linkId: string, challenge: string): Promise<{ publicKey: string; code: string; username: string | null }> {
282
+ try {
283
+ const [privateKey, publicKey] = await Promise.all([KeyManager.getPrivateKey(), KeyManager.getPublicKey()]);
284
+ if (!privateKey || !publicKey) {
285
+ throw new Error('No identity on this device to link');
286
+ }
287
+ const root = publicKey.trim().toLowerCase();
288
+ const state = await this.getIdentityLink(linkId);
289
+ const proof = await signIdentityProof(
290
+ { privateKey, publicKey: root },
291
+ {
292
+ action: IDENTITY_PROOF_ACTIONS.link,
293
+ subject: state.userId,
294
+ actor: state.userId,
295
+ rootPublicKey: root,
296
+ payloadDigest: null,
297
+ expectedRevision: null,
298
+ audience: state.audience,
299
+ challenge,
300
+ expiresAt: state.expiresAt,
301
+ },
302
+ );
303
+ await this.makeRequest<unknown>(
304
+ 'POST',
305
+ `/identity/link/${encodeURIComponent(linkId)}/proof`,
306
+ { publicKey: root, proof },
307
+ { cache: false, skipAuth: true },
308
+ );
309
+ return { publicKey: root, code: deriveIdentityLinkCode(linkId, root), username: state.username };
310
+ } catch (error) {
311
+ throw this.handleError(error);
312
+ }
313
+ }
314
+
315
+ /** WebAuthn request options over the account's passkeys, whose challenge is the link's. Opaque. */
316
+ async getIdentityLinkAssertionOptions(linkId: string, challenge: string): Promise<unknown> {
317
+ try {
318
+ return await this.makeRequest<unknown>(
319
+ 'POST',
320
+ `/identity/link/${encodeURIComponent(linkId)}/options`,
321
+ { challenge },
322
+ { cache: false },
323
+ );
324
+ } catch (error) {
325
+ throw this.handleError(error);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * auth.oxy.so's half: the passkey assertion over the link's challenge. The
331
+ * account gains Commons' root and loses its recovery email.
332
+ */
333
+ async completeIdentityLink(linkId: string, assertion: unknown): Promise<{ success: true }> {
334
+ try {
335
+ const result = await this.makeRequest<{ success: true }>(
336
+ 'POST',
337
+ `/identity/link/${encodeURIComponent(linkId)}/complete`,
338
+ { assertion },
339
+ { cache: false },
340
+ );
341
+ this._invalidateIdentityCaches(this.getCurrentUserId());
342
+ return result;
343
+ } catch (error) {
344
+ throw this.handleError(error);
345
+ }
346
+ }
347
+
348
+ /** Withdraw a link request that has not completed. */
349
+ async cancelIdentityLink(linkId: string): Promise<void> {
350
+ try {
351
+ await this.makeRequest<unknown>('DELETE', `/identity/link/${encodeURIComponent(linkId)}`, undefined, { cache: false });
352
+ } catch (error) {
353
+ throw this.handleError(error);
354
+ }
355
+ }
356
+
236
357
  /**
237
358
  * Remove ONE passkey (WebAuthn credential) from the current account.
238
359
  *
239
360
  * Passkeys are per-credential, so this targets a specific credential id.
240
361
  * The server refuses to remove the last remaining auth method (the account
241
362
  * would become inaccessible) and deletes the stored `WebauthnCredential`.
242
- * A passkey that also opens the account's root on the web takes its wrap with
243
- * it, and the ONLY such passkey is refused with
244
- * `code: 'IDENTITY_LAST_WEB_HOLDER'` (ADR 0024 D6).
245
363
  *
246
364
  * @param credentialId - The passkey's public credential id
247
365
  * (`AuthMethodEntry.credentialId`).
@@ -5,6 +5,7 @@ import type { CreateOxyNotificationRequest } from '@oxy.so/contracts';
5
5
  import type {
6
6
  User,
7
7
  Notification,
8
+ NotificationPage,
8
9
  NotificationPreferences,
9
10
  UserPreferences,
10
11
  SearchProfilesResponse,
@@ -755,6 +756,35 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
755
756
  }
756
757
 
757
758
 
759
+ /**
760
+ * WebAuthn request options to delete a PASSKEY account (ADR 0029 D3): its own
761
+ * passkeys, user verification required, and a challenge bound to it. Opaque —
762
+ * hand them to the browser's authentication ceremony, on auth.oxy.so. An
763
+ * account with a Commons key deletes with {@link deleteAccount} instead.
764
+ */
765
+ async getAccountDeletionOptions(): Promise<unknown> {
766
+ try {
767
+ return await this.makeRequest<unknown>('POST', '/users/me/delete/options', undefined, { cache: false });
768
+ } catch (error) {
769
+ throw this.handleError(error);
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Delete a PASSKEY account permanently, with an assertion by one of its
775
+ * passkeys over {@link getAccountDeletionOptions}' challenge.
776
+ *
777
+ * @param confirmText - Must equal the user's username (verified server-side)
778
+ * @param assertion - The ceremony's opaque `AuthenticationResponseJSON`
779
+ */
780
+ async deleteAccountWithPasskey(confirmText: string, assertion: unknown): Promise<{ message: string }> {
781
+ try {
782
+ return await this.makeRequest<{ message: string }>('DELETE', '/users/me', { confirmText, assertion }, { cache: false });
783
+ } catch (error) {
784
+ throw this.handleError(error);
785
+ }
786
+ }
787
+
758
788
  /**
759
789
  * Invalidate every cached read a follow/unfollow write invalidates.
760
790
  *
@@ -1092,11 +1122,13 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
1092
1122
  }
1093
1123
 
1094
1124
  /**
1095
- * Get notifications
1125
+ * One page of the signed-in user's Oxy notifications, newest first
1126
+ * (`GET /notifications`), with the unread count across all pages.
1096
1127
  */
1097
- async getNotifications(): Promise<Notification[]> {
1128
+ async getNotifications(params: { page?: number; limit?: number } = {}): Promise<NotificationPage> {
1129
+ const query = buildQueryParams({ page: params.page, limit: params.limit });
1098
1130
  return this.withAuthRetry(async () => {
1099
- return await this.makeRequest<Notification[]>('GET', '/notifications', undefined, {
1131
+ return await this.makeRequest<NotificationPage>('GET', '/notifications', query, {
1100
1132
  cache: false, // Don't cache notifications - always get fresh data
1101
1133
  });
1102
1134
  }, 'getNotifications');
@@ -1107,10 +1139,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
1107
1139
  */
1108
1140
  async getUnreadCount(): Promise<number> {
1109
1141
  try {
1110
- const res = await this.makeRequest<{ count: number }>('GET', '/notifications/unread-count', undefined, {
1142
+ const res = await this.makeRequest<{ unreadCount: number }>('GET', '/notifications/unread-count', undefined, {
1111
1143
  cache: false, // Don't cache unread count - always get fresh data
1112
1144
  });
1113
- return res.count;
1145
+ return res.unreadCount;
1114
1146
  } catch (error) {
1115
1147
  throw this.handleError(error);
1116
1148
  }
@@ -1123,20 +1155,29 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
1123
1155
  * from an Oxy service about the recipient's own account, with the recipient
1124
1156
  * as actor and their profile as the entity.
1125
1157
  */
1126
- async createNotification(data: CreateOxyNotificationRequest | Partial<Notification>): Promise<Notification> {
1158
+ async createNotification(data: CreateOxyNotificationRequest): Promise<Notification> {
1127
1159
  try {
1128
- return await this.makeRequest<Notification>('POST', '/notifications', data, { cache: false });
1160
+ const res = await this.makeRequest<{ notification: Notification }>('POST', '/notifications', data, { cache: false });
1161
+ return res.notification;
1129
1162
  } catch (error) {
1130
1163
  throw this.handleError(error);
1131
1164
  }
1132
1165
  }
1133
1166
 
1134
1167
  /**
1135
- * Mark notification as read
1168
+ * Mark one of the signed-in user's notifications read and return it as
1169
+ * stored. Scoped to the recipient server-side, so it is also the
1170
+ * authoritative read of a notification by id: a foreign or unknown id 404s.
1136
1171
  */
1137
- async markNotificationAsRead(notificationId: string): Promise<void> {
1172
+ async markNotificationAsRead(notificationId: string): Promise<Notification> {
1138
1173
  try {
1139
- await this.makeRequest('PUT', `/notifications/${notificationId}/read`, undefined, { cache: false });
1174
+ const res = await this.makeRequest<{ notification: Notification }>(
1175
+ 'PUT',
1176
+ `/notifications/${encodeURIComponent(notificationId)}/read`,
1177
+ undefined,
1178
+ { cache: false },
1179
+ );
1180
+ return res.notification;
1140
1181
  } catch (error) {
1141
1182
  throw this.handleError(error);
1142
1183
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Linking Commons to a passkey account (ADR 0029 D3): what each device sends.
3
+ * `makeRequest` is stubbed; the proof Commons signs is verified for real.
4
+ */
5
+ import { buildIdentityProofMessage } from '@oxy.so/contracts';
6
+ import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
7
+ import { verifySignature } from '@oxy.so/protocol';
8
+ import { OxyServices } from '../../OxyServices';
9
+ import { KeyManager } from '../../crypto/keyManager';
10
+ import { deriveIdentityLinkCode } from '../../crypto/identityLink';
11
+
12
+ const LINK_ID = 'ab'.repeat(16);
13
+ const CHALLENGE = 'cd'.repeat(32);
14
+ const PRIVATE_KEY = '3a'.repeat(32);
15
+ const PUBLIC_KEY = deriveSecp256k1PublicKey(PRIVATE_KEY).toLowerCase();
16
+ const STATE = {
17
+ status: 'pending',
18
+ userId: 'user-1',
19
+ username: 'ada',
20
+ publicKey: null,
21
+ audience: 'oxy-api/identity',
22
+ expiresAt: 1_900_000_000_000,
23
+ };
24
+
25
+ describe('linking Commons', () => {
26
+ let oxy: OxyServices;
27
+ let makeRequest: jest.SpyInstance;
28
+
29
+ beforeEach(() => {
30
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
31
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
32
+ });
33
+ afterEach(() => jest.restoreAllMocks());
34
+
35
+ it('Commons signs the link proof for the account the request names, with its own key', async () => {
36
+ jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(PRIVATE_KEY);
37
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(PUBLIC_KEY.toUpperCase());
38
+ makeRequest.mockResolvedValueOnce(STATE).mockResolvedValueOnce({ status: 'signed' });
39
+
40
+ const result = await oxy.signIdentityLink(LINK_ID, CHALLENGE);
41
+
42
+ expect(result).toEqual({ publicKey: PUBLIC_KEY, code: deriveIdentityLinkCode(LINK_ID, PUBLIC_KEY), username: 'ada' });
43
+ expect(makeRequest).toHaveBeenNthCalledWith(1, 'GET', `/identity/link/${LINK_ID}`, undefined, { cache: false, skipAuth: true });
44
+ const [, path, body, options] = makeRequest.mock.calls[1] as [string, string, { publicKey: string; proof: { signature: string; challenge: string; expiresAt: number } }, unknown];
45
+ expect(path).toBe(`/identity/link/${LINK_ID}/proof`);
46
+ expect(options).toEqual({ cache: false, skipAuth: true });
47
+ expect(body.publicKey).toBe(PUBLIC_KEY);
48
+ expect(body.proof).toMatchObject({ v: 2, challenge: CHALLENGE, expiresAt: STATE.expiresAt });
49
+ const message = buildIdentityProofMessage({
50
+ action: 'link_identity',
51
+ subject: 'user-1',
52
+ actor: 'user-1',
53
+ rootPublicKey: PUBLIC_KEY,
54
+ payloadDigest: null,
55
+ expectedRevision: null,
56
+ audience: STATE.audience,
57
+ challenge: CHALLENGE,
58
+ expiresAt: STATE.expiresAt,
59
+ });
60
+ expect(await verifySignature(message, body.proof.signature, PUBLIC_KEY)).toBe(true);
61
+ });
62
+
63
+ it('refuses to sign without an identity on this device', async () => {
64
+ jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(null);
65
+ jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
66
+ await expect(oxy.signIdentityLink(LINK_ID, CHALLENGE)).rejects.toThrow('No identity');
67
+ expect(makeRequest).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('the web opens, asks for the assertion options, completes and cancels', async () => {
71
+ makeRequest
72
+ .mockResolvedValueOnce({ linkId: LINK_ID, challenge: CHALLENGE, expiresAt: 1, qrPayload: `oxycommons://link?id=${LINK_ID}&c=${CHALLENGE}` })
73
+ .mockResolvedValueOnce({ challenge: 'x' })
74
+ .mockResolvedValueOnce({ success: true })
75
+ .mockResolvedValueOnce({});
76
+
77
+ await expect(oxy.createIdentityLink()).resolves.toMatchObject({ linkId: LINK_ID });
78
+ await oxy.getIdentityLinkAssertionOptions(LINK_ID, CHALLENGE);
79
+ await oxy.completeIdentityLink(LINK_ID, { id: 'cred' });
80
+ await oxy.cancelIdentityLink(LINK_ID);
81
+
82
+ expect(makeRequest.mock.calls.map(([method, path, body]) => [method, path, body])).toEqual([
83
+ ['POST', '/identity/link', undefined],
84
+ ['POST', `/identity/link/${LINK_ID}/options`, { challenge: CHALLENGE }],
85
+ ['POST', `/identity/link/${LINK_ID}/complete`, { assertion: { id: 'cred' } }],
86
+ ['DELETE', `/identity/link/${LINK_ID}`, undefined],
87
+ ]);
88
+ });
89
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The notification READ methods against the wire bodies `GET /notifications`,
3
+ * `GET /notifications/unread-count` and `PUT /notifications/:id/read` actually
4
+ * send (`packages/api/src/controllers/notification.controller.ts`), driven
5
+ * through the real HTTP stack with only `fetch` stubbed.
6
+ *
7
+ * They used to be typed against a shape the API never sent: `getNotifications`
8
+ * claimed an array while the server answers a page object, `getUnreadCount`
9
+ * read `count` from a body that carries `unreadCount` (so it always returned
10
+ * `undefined`), and `markNotificationAsRead` dropped the stored notification the
11
+ * server returns — the one authoritative read of a notification by id.
12
+ */
13
+
14
+ import { OxyServices } from '../../OxyServices';
15
+
16
+ const SYSTEM_NOTIFICATION = {
17
+ _id: '0192f0c4-7b1e-7000-8000-000000000001',
18
+ recipientId: 'user-1',
19
+ actorId: 'user-1',
20
+ type: 'system',
21
+ entityId: 'job-42',
22
+ entityType: 'app',
23
+ title: 'Your Mastodon account moved',
24
+ message: '312 followers came with you.',
25
+ url: 'https://move.oxy.so/jobs/job-42',
26
+ read: false,
27
+ createdAt: '2026-09-26T10:00:00.000Z',
28
+ updatedAt: '2026-09-26T10:00:00.000Z',
29
+ };
30
+
31
+ describe('OxyServices notification reads', () => {
32
+ const originalFetch = globalThis.fetch;
33
+ let fetchMock: jest.Mock;
34
+
35
+ function answer(body: unknown): void {
36
+ fetchMock.mockResolvedValueOnce(
37
+ new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }),
38
+ );
39
+ }
40
+
41
+ let oxy: OxyServices;
42
+ beforeEach(() => {
43
+ fetchMock = jest.fn();
44
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
45
+ oxy = new OxyServices({ baseURL: 'http://api.test.invalid' });
46
+ oxy.setTokens('test-token');
47
+ });
48
+
49
+ afterEach(() => {
50
+ globalThis.fetch = originalFetch;
51
+ });
52
+
53
+ it('getNotifications returns the page the API sends, with the paging it asked for', async () => {
54
+ const page = { notifications: [SYSTEM_NOTIFICATION], unreadCount: 1, hasMore: false, page: 2, limit: 10 };
55
+ answer({ data: page });
56
+
57
+ await expect(oxy.getNotifications({ page: 2, limit: 10 })).resolves.toEqual(page);
58
+
59
+ const url = new URL(String(fetchMock.mock.calls[0][0]));
60
+ expect(url.pathname).toBe('/notifications');
61
+ expect(url.searchParams.get('page')).toBe('2');
62
+ expect(url.searchParams.get('limit')).toBe('10');
63
+ });
64
+
65
+ it('getUnreadCount reads `unreadCount`', async () => {
66
+ answer({ data: { unreadCount: 3 } });
67
+
68
+ await expect(oxy.getUnreadCount()).resolves.toBe(3);
69
+ });
70
+
71
+ it('markNotificationAsRead returns the stored notification', async () => {
72
+ answer({ data: { notification: { ...SYSTEM_NOTIFICATION, read: true } } });
73
+
74
+ await expect(oxy.markNotificationAsRead(SYSTEM_NOTIFICATION._id)).resolves.toEqual({
75
+ ...SYSTEM_NOTIFICATION,
76
+ read: true,
77
+ });
78
+ expect(String(fetchMock.mock.calls[0][0])).toBe(
79
+ `http://api.test.invalid/notifications/${SYSTEM_NOTIFICATION._id}/read`,
80
+ );
81
+ });
82
+ });
@@ -86,7 +86,7 @@ describe('pre-session public endpoints use skipAuth', () => {
86
86
  });
87
87
 
88
88
  it('webauthnRegisterOptions skips auth preflight on signup (username provided)', async () => {
89
- await oxy.webauthnRegisterOptions('alice');
89
+ await oxy.webauthnRegisterOptions({ username: 'alice' });
90
90
  expect(makeRequest).toHaveBeenCalledWith(
91
91
  'POST',
92
92
  '/auth/webauthn/register/options',
@@ -42,7 +42,7 @@ describe('webauthnRegisterOptions', () => {
42
42
  it('POSTs to /auth/webauthn/register/options with the username', async () => {
43
43
  const options = { challenge: 'c', rp: { id: 'oxy.so' } };
44
44
  makeRequest.mockResolvedValueOnce(options);
45
- const result = await oxy.webauthnRegisterOptions('alice');
45
+ const result = await oxy.webauthnRegisterOptions({ username: 'alice' });
46
46
  expect(result).toBe(options);
47
47
  expect(makeRequest).toHaveBeenCalledWith(
48
48
  'POST',
@@ -52,6 +52,17 @@ describe('webauthnRegisterOptions', () => {
52
52
  );
53
53
  });
54
54
 
55
+ it('sends a recovery ticket signed out', async () => {
56
+ makeRequest.mockResolvedValueOnce({ challenge: 'c' });
57
+ await oxy.webauthnRegisterOptions({ recoveryTicket: 't'.repeat(43) });
58
+ expect(makeRequest).toHaveBeenCalledWith(
59
+ 'POST',
60
+ '/auth/webauthn/register/options',
61
+ { recoveryTicket: 't'.repeat(43) },
62
+ { cache: false, skipAuth: true },
63
+ );
64
+ });
65
+
55
66
  it('omits username from the body when not provided (link flow)', async () => {
56
67
  makeRequest.mockResolvedValueOnce({ challenge: 'c' });
57
68
  await oxy.webauthnRegisterOptions();
@@ -64,6 +75,48 @@ describe('webauthnRegisterOptions', () => {
64
75
  });
65
76
  });
66
77
 
78
+ describe('email verification', () => {
79
+ let oxy: OxyServices;
80
+ let makeRequest: jest.SpyInstance;
81
+
82
+ beforeEach(() => {
83
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
84
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
85
+ });
86
+ afterEach(() => jest.restoreAllMocks());
87
+
88
+ it('starts a verification signed out and parses its id', async () => {
89
+ makeRequest.mockResolvedValueOnce({ verificationId: 'v-1', expiresAt: 1_900_000_000_000 });
90
+ await expect(oxy.startEmailVerification({ purpose: 'signup', email: 'ada@example.com' })).resolves.toEqual({
91
+ verificationId: 'v-1',
92
+ expiresAt: 1_900_000_000_000,
93
+ });
94
+ expect(makeRequest).toHaveBeenCalledWith(
95
+ 'POST',
96
+ '/auth/email/verify/start',
97
+ { purpose: 'signup', email: 'ada@example.com' },
98
+ { cache: false, skipAuth: true },
99
+ );
100
+ });
101
+
102
+ it('confirms a code into a ticket', async () => {
103
+ const confirmed = { ticket: 'T'.repeat(43), expiresAt: 1_900_000_000_000, username: 'ada' };
104
+ makeRequest.mockResolvedValueOnce(confirmed);
105
+ await expect(oxy.confirmEmailVerification('v-1', '123456')).resolves.toEqual(confirmed);
106
+ expect(makeRequest).toHaveBeenCalledWith(
107
+ 'POST',
108
+ '/auth/email/verify/confirm',
109
+ { verificationId: 'v-1', code: '123456' },
110
+ { cache: false, skipAuth: true },
111
+ );
112
+ });
113
+
114
+ it('refuses a malformed answer rather than handing on a ticket it cannot read', async () => {
115
+ makeRequest.mockResolvedValueOnce({ ticket: 'short', expiresAt: 1 });
116
+ await expect(oxy.confirmEmailVerification('v-1', '123456')).rejects.toThrow();
117
+ });
118
+ });
119
+
67
120
  describe('webauthnLoginOptions', () => {
68
121
  let oxy: OxyServices;
69
122
  let makeRequest: jest.SpyInstance;
@@ -203,3 +256,24 @@ describe('webauthnLoginVerify', () => {
203
256
  expect(setTokens).not.toHaveBeenCalled();
204
257
  });
205
258
  });
259
+
260
+ describe('deleting a passkey account', () => {
261
+ let oxy: OxyServices;
262
+ let makeRequest: jest.SpyInstance;
263
+
264
+ beforeEach(() => {
265
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
266
+ makeRequest = jest.spyOn(oxy, 'makeRequest');
267
+ });
268
+ afterEach(() => jest.restoreAllMocks());
269
+
270
+ it('asks for the options, then deletes with the assertion and the confirmation', async () => {
271
+ makeRequest.mockResolvedValueOnce({ challenge: 'c' }).mockResolvedValueOnce({ message: 'deleted' });
272
+
273
+ await expect(oxy.getAccountDeletionOptions()).resolves.toEqual({ challenge: 'c' });
274
+ await expect(oxy.deleteAccountWithPasskey('ada', GET_RESPONSE)).resolves.toEqual({ message: 'deleted' });
275
+
276
+ expect(makeRequest).toHaveBeenNthCalledWith(1, 'POST', '/users/me/delete/options', undefined, { cache: false });
277
+ expect(makeRequest).toHaveBeenNthCalledWith(2, 'DELETE', '/users/me', { confirmText: 'ada', assertion: GET_RESPONSE }, { cache: false });
278
+ });
279
+ });
@@ -4,6 +4,8 @@ import type {
4
4
  UserNameResponse,
5
5
  UserRelationship,
6
6
  ThemePreference,
7
+ OxyNotificationEntityType,
8
+ OxyNotificationType,
7
9
  } from '@oxy.so/contracts';
8
10
 
9
11
  export interface OxyConfig {
@@ -20,6 +22,27 @@ export interface OxyConfig {
20
22
  * bounce (which uses the RP origin, not this registered client id).
21
23
  */
22
24
  clientId?: string;
25
+ /**
26
+ * How a BACKEND identifies itself on requests that carry no user session.
27
+ *
28
+ * - `'never'` (default): such requests go out anonymous. Right for browsers
29
+ * and apps, which have no service identity to offer.
30
+ * - `'when-anonymous'`: they carry this process's service token instead —
31
+ * from `configureServiceAuth()`'s key pair, or, with none, from workload
32
+ * attestation (ADR 0026). A user session, when there is one, still wins,
33
+ * and a process that can produce no service token (a local checkout) keeps
34
+ * sending the request anonymous.
35
+ *
36
+ * Why a server wants it: oxy-api charges anonymous traffic to the SOURCE
37
+ * ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15
38
+ * minutes), and a fleet leaves through one NAT address, so every anonymous
39
+ * read a backend makes shares — and past the threshold pays for — one budget.
40
+ * A first-party service token is exempt from both and charged to its own
41
+ * application's budget instead. Measured from Mention's task (2026-09-25):
42
+ * `GET /users/:id` 543–575 ms anonymous past the threshold, `POST
43
+ * /users/by-ids` 19–24 ms with the service token.
44
+ */
45
+ serviceIdentity?: 'never' | 'when-anonymous';
23
46
  // Performance & caching options
24
47
  /**
25
48
  * Enable the per-instance GET response cache. Defaults to `true` (5-minute
@@ -267,25 +290,57 @@ export interface LoginResponse {
267
290
  message?: string;
268
291
  }
269
292
 
293
+ /**
294
+ * The actor of a notification as `GET /notifications` populates it — the raw
295
+ * stored name, not a composed display name.
296
+ */
297
+ export interface NotificationActor {
298
+ _id: string;
299
+ username?: string;
300
+ name?: { first: string; last: string };
301
+ avatar?: string;
302
+ }
303
+
304
+ /**
305
+ * One Oxy notification exactly as the API serializes it
306
+ * (`packages/api/src/controllers/notification.controller.ts`). `_id` — not
307
+ * `id` — is the key every notification endpoint addresses it by.
308
+ */
270
309
  export interface Notification {
271
- id: string;
272
- message: string;
310
+ _id: string;
311
+ recipientId: string;
312
+ /** Populated on a list read; the bare id on a create or a mark-read. */
313
+ actorId: string | NotificationActor;
273
314
  /** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
274
- type?: string;
315
+ type: OxyNotificationType;
275
316
  /**
276
- * `system` notifications only (with `message`): the text an Oxy service sent
277
- * about the recipient's own account. Render these two instead of composing a
278
- * sentence from actor + entity.
317
+ * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
318
+ * in the notifying application's namespace (`system` notifications only).
319
+ */
320
+ entityType: OxyNotificationEntityType;
321
+ entityId: string;
322
+ /**
323
+ * `system` notifications only: the text an Oxy service sent about the
324
+ * recipient's own account. Render these instead of composing a sentence from
325
+ * actor + entity.
279
326
  */
280
327
  title?: string;
328
+ message?: string;
281
329
  /** `system` notifications only: an optional deep link (https or the sending app's scheme). */
282
330
  url?: string;
283
- /**
284
- * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
285
- * in the notifying application's namespace (`system` notifications only).
286
- */
287
- entityType?: string;
288
- entityId?: string;
331
+ read: boolean;
332
+ createdAt: string;
333
+ updatedAt: string;
334
+ }
335
+
336
+ /** One page of `GET /notifications`, newest first. */
337
+ export interface NotificationPage {
338
+ notifications: Notification[];
339
+ /** Unread notifications across ALL pages. */
340
+ unreadCount: number;
341
+ hasMore: boolean;
342
+ page: number;
343
+ limit: number;
289
344
  }
290
345
 
291
346
  export interface Wallet {