@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
@@ -17,6 +17,11 @@ import type { DeviceSecretMintOutcome } from './session/refresh';
17
17
  export type AuthRefreshReason = 'preflight' | 'response-401';
18
18
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
19
19
  export type AccessTokenProvider = () => string | null;
20
+ /**
21
+ * Supplies a bearer for a request made while this client holds NO user session
22
+ * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
23
+ */
24
+ export type AnonymousAuthProvider = () => Promise<string | null>;
20
25
  /**
21
26
  * A low-level authenticated request whose response body remains unread.
22
27
  *
@@ -162,6 +167,7 @@ export declare class HttpService {
162
167
  private noFresherTokenThan;
163
168
  private authRefreshHandler;
164
169
  private accessTokenProvider;
170
+ private anonymousAuthProvider;
165
171
  private deviceSecretMintInFlight;
166
172
  /**
167
173
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -324,6 +330,13 @@ export declare class HttpService {
324
330
  * Get auth header with automatic token refresh
325
331
  */
326
332
  private getAuthHeader;
333
+ /**
334
+ * The bearer for a request that has no user session behind it, from the
335
+ * {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
336
+ * that fails sends the request anonymous, exactly as it went before any
337
+ * provider existed.
338
+ */
339
+ private getAnonymousAuthHeader;
327
340
  refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
328
341
  /**
329
342
  * Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
@@ -442,6 +455,14 @@ export declare class HttpService {
442
455
  hasSessionEnded(): boolean;
443
456
  setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
444
457
  setAccessTokenProvider(provider: AccessTokenProvider | null): void;
458
+ /**
459
+ * Authenticate requests that carry no user session with the bearer this
460
+ * provider returns — in practice a backend's own service token (see
461
+ * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
462
+ * consulted only when there is no access token, and never for `skipAuth`
463
+ * requests. `null` removes it.
464
+ */
465
+ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void;
445
466
  clearTokens(): void;
446
467
  /**
447
468
  * Subscribe to access-token changes on this instance.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The code both screens show while Commons is being linked to a passkey
3
+ * account (ADR 0029 D3): 6 digits derived from the link request and the key
4
+ * Commons signed with. auth.oxy.so derives it from the key the API relayed,
5
+ * Commons from its own; the person checks they match before the passkey is
6
+ * asserted, so a photographed QR cannot slip another key in.
7
+ */
8
+ export declare function deriveIdentityLinkCode(linkId: string, publicKey: string): string;
@@ -72,14 +72,11 @@ export { hkdfSha256 } from './crypto/kdf';
72
72
  export { encryptAead, decryptAead, AEAD_KEY_LENGTH, AEAD_NONCE_LENGTH, } from './crypto/aead';
73
73
  export type { AeadResult } from './crypto/aead';
74
74
  export { deriveSharedSecret } from './crypto/ecdh';
75
- export { WEB_IDENTITY_PRF_INPUT, WebIdentityUnlockError, WEB_IDENTITY_PRF_OUTPUT_LENGTH, addWrap, deriveIdentityFromMnemonic, deriveIdentityFromPrivateKey, deriveIdentityFromRecoveryMaterial, deriveKeyEncryptionKey, generateDataKey, generateWebIdentity, isUsablePrfOutput, markWrapVerified, normalizeMnemonic, openWebIdentity, parseRecoveryMaterial, removeWrap, sealWebIdentity, unlockWebIdentity, unwrapDataKey, wipeBytes, wipeOpenedIdentity, } from './crypto/webIdentityCarrier';
76
- export type { OpenedMnemonicIdentity, OpenedRawKeyIdentity, OpenedWebIdentity, WebIdentityRecoveryMaterial, WebIdentityUnlockFailure, WrapInput, } from './crypto/webIdentityCarrier';
77
75
  export { digestIdentityPayload, signIdentityProof } from './crypto/identityProof';
78
- export { buildMoveQrPayload, createMoveCommitment, deriveMoveKey, deriveMoveSas, digestMoveCiphertext, generateMoveEphemeralKeyPair, openMovedIdentity, parseMoveQrPayload, sealIdentityForMove, signMoveReceipt, verifyMoveCommitment, verifyMoveReceipt, } from './crypto/identityMove';
79
- export type { MoveReceiptClaims } from './crypto/identityMove';
76
+ export { deriveIdentityLinkCode } from './crypto/identityLink';
80
77
  export { DeviceManager } from './utils/deviceManager';
81
78
  export type { DeviceFingerprint, StoredDeviceInfo } from './utils/deviceManager';
82
- export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, BatchFileAccessEntry, BatchFileAccessResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceLinkedSession, DeviceLinkedSessionsResponse, DeviceLinkedSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
79
+ export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferences, User, LoginResponse, Notification, NotificationActor, NotificationPage, Wallet, Transaction, BlockedUser, RestrictedUser, TransferFundsRequest, PurchaseRequest, WithdrawalRequest, TransactionResponse, PaginationInfo, SearchProfilesResponse, ApiError, PaymentMethod, PaymentRequest, PaymentResponse, AnalyticsData, FollowerDetails, ContentViewer, FileMetadata, FileUploadResponse, FileListResponse, FileUpdateRequest, FileDeleteResponse, RNFileDescriptor, AssetUploadInput, FileVisibility, AssetLink, AssetMetadata, AssetVariant, Asset, AssetInitRequest, AssetInitResponse, AssetCompleteRequest, AssetLinkRequest, AssetUnlinkRequest, AssetUrlResponse, BatchFileAccessEntry, BatchFileAccessResponse, AssetDeleteSummary, AssetUpdateVisibilityRequest, AssetUpdateVisibilityResponse, ServiceAssetMetadata, ServiceAssetMetadataBySha, AccountStorageCategoryUsage, AccountStorageUsageResponse, SecurityEventType, SecurityEventSeverity, SecurityActivity, SecurityActivityResponse, AssetUploadProgress, DeviceLinkedSession, DeviceLinkedSessionsResponse, DeviceLinkedSessionLogoutResponse, UpdateDeviceNameResponse, } from './models/interfaces';
83
80
  export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
84
81
  export { TopicType, TopicSource } from './models/Topic';
85
82
  export type { TopicData, TopicTranslation, TopicListResult } from './models/Topic';
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { User } from '../models/interfaces';
7
7
  import type { LoginResult, CommonsDenyReason } from '@oxy.so/contracts';
8
- import { type IdentityProof, type WebIdentityEnvelope } from '@oxy.so/contracts';
8
+ import { type EmailVerificationConfirmResponse, type EmailVerificationStartRequest, type EmailVerificationStartResponse } from '@oxy.so/contracts';
9
9
  import type { SessionLoginResponse } from '../models/session';
10
10
  import type { OxyServicesBase } from '../OxyServices.base';
11
11
  import type { PublicApplication } from './OxyServices.connectedApps';
@@ -322,6 +322,11 @@ interface ServiceTokenCacheEntry {
322
322
  export declare class ServiceCredentialMismatchError extends Error {
323
323
  constructor();
324
324
  }
325
+ /**
326
+ * How long a failed service-token mint sends session-less requests anonymous
327
+ * before the next attempt, under `serviceIdentity: 'when-anonymous'`.
328
+ */
329
+ export declare const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30000;
325
330
  export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T): {
326
331
  new (...args: any[]): {
327
332
  /**
@@ -346,6 +351,34 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
346
351
  _serviceApiKey: string | null;
347
352
  /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
348
353
  _serviceApiSecret: string | null;
354
+ /**
355
+ * Before this instant, a request without a user session does not try to
356
+ * mint a service token and goes out anonymous. Set after a failed mint; see
357
+ * {@link _serviceTokenForAnonymousRequest}.
358
+ *
359
+ * @internal
360
+ */
361
+ _anonymousServiceTokenRetryAt: number;
362
+ /**
363
+ * The bearer for a request with no user session, under `serviceIdentity:
364
+ * 'when-anonymous'`: this process's service token, or `null` to send the
365
+ * request anonymous as before.
366
+ *
367
+ * `null`, never a throw, whenever there is no token to offer:
368
+ * - no key pair and no attestable workload (a local checkout) — asking
369
+ * costs nothing and there is nothing to ask for;
370
+ * - the mint failed. The request still goes out, anonymous, and so does
371
+ * every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
372
+ * an Oxy that is refusing attestation (seen for ~2.5 minutes after a
373
+ * deploy on 2026-09-25) is asked once per window rather than once per
374
+ * read, and no read waits on a mint that just failed.
375
+ *
376
+ * The token itself comes from {@link getServiceToken}: cached for its
377
+ * lifetime and single-flight, so concurrent reads share one mint.
378
+ *
379
+ * @internal
380
+ */
381
+ _serviceTokenForAnonymousRequest(): Promise<string | null>;
349
382
  /**
350
383
  * Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
351
384
  * tokens are only ever issued by a Node host (the SDK on web/RN never has
@@ -790,50 +823,60 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
790
823
  message: string;
791
824
  }>;
792
825
  /**
793
- * Check email availability
826
+ * Send a 6-digit code to an email (ADR 0029 D3): the recovery email of a
827
+ * new passkey account (`purpose: 'signup'`), or — named by username or
828
+ * email — the recovery email of an account being recovered
829
+ * (`purpose: 'recovery'`). The answer is the same whether or not an
830
+ * account exists; nothing is revealed about who has one.
794
831
  */
795
- checkEmailAvailability(email: string): Promise<{
796
- available: boolean;
797
- message: string;
798
- }>;
832
+ startEmailVerification(request: EmailVerificationStartRequest): Promise<EmailVerificationStartResponse>;
833
+ /**
834
+ * Confirm the code {@link startEmailVerification} sent. Resolves to a
835
+ * short-lived one-use ticket: a sign-up passes it (with the email) to
836
+ * {@link webauthnRegisterVerify}; a recovery passes it as `recoveryTicket`
837
+ * to {@link webauthnRegisterOptions} and {@link webauthnRegisterVerify}.
838
+ */
839
+ confirmEmailVerification(verificationId: string, code: string): Promise<EmailVerificationConfirmResponse>;
799
840
  /**
800
841
  * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
801
842
  * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
802
843
  * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
803
844
  *
804
- * With a bearer token planted this links a passkey to the signed-in account
805
- * (`username` ignored); without one it is a prospective signup and `username`
806
- * is the desired handle. The returned options are OPAQUE — Oxy does not own
807
- * their shape (the browser / `@simplewebauthn` does), so they pass through
808
- * as `unknown` for the caller to hand straight to the ceremony.
845
+ * With a bearer token planted this adds a passkey to the signed-in account.
846
+ * Without one, `username` is a prospective sign-up's handle, and
847
+ * `recoveryTicket` a recovery's new passkey for the account it names. The
848
+ * returned options are OPAQUE — Oxy does not own their shape (the browser /
849
+ * `@simplewebauthn` does), so they pass through as `unknown` for the caller
850
+ * to hand straight to the ceremony.
809
851
  */
810
- webauthnRegisterOptions(username?: string): Promise<unknown>;
852
+ webauthnRegisterOptions(request?: {
853
+ username?: string;
854
+ recoveryTicket?: string;
855
+ }): Promise<unknown>;
811
856
  /**
812
857
  * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
813
858
  * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
814
859
  * (desired `username` for signup + the device-session naming fields).
815
860
  *
816
- * Two server branches, disambiguated by the response shape:
817
- * - **Signup** (no bearer): the account is created and a session minted —
818
- * the response carries `sessionId`, is the SAME {@link LoginResult}
819
- * contract as `POST /auth/verify`, and its access token is planted here.
861
+ * Server branches, disambiguated by the response shape:
862
+ * - **Sign-up** (no bearer; `username`, `email`, `emailTicket`) and
863
+ * **recovery** (no bearer; `recoveryTicket`): the account is created, or
864
+ * gains the passkey, and a session is minted — the response carries
865
+ * `sessionId`, is the SAME {@link LoginResult} contract as
866
+ * `POST /auth/verify`, and its access token is planted here.
820
867
  * - **Link** (bearer present): the passkey is attached to the signed-in
821
868
  * account and the server returns `{ success, message }` with no session,
822
869
  * which is returned verbatim (no token planting).
823
870
  */
824
871
  webauthnRegisterVerify(response: unknown, envelope?: {
825
872
  username?: string;
873
+ /** Sign-up: the recovery email, confirmed with `emailTicket`. */
874
+ email?: string;
875
+ emailTicket?: string;
876
+ /** Recovery: the ticket {@link confirmEmailVerification} returned. */
877
+ recoveryTicket?: string;
826
878
  deviceName?: string;
827
879
  deviceFingerprint?: string;
828
- /**
829
- * Sign-up only: the root created on the holder before this call, sealed
830
- * under the passkey being registered, plus its `enroll_identity` proof
831
- * (ADR 0024 D4). The account is created WITH it or not at all.
832
- */
833
- identity?: {
834
- envelope: WebIdentityEnvelope;
835
- proof: IdentityProof;
836
- };
837
880
  }): Promise<{
838
881
  success: true;
839
882
  message: string;
@@ -1,29 +1,4 @@
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 type { AuthMethodsResponse, IdentityRootStatus, DidDocument, DomainVerificationInstructions, ExportBundle, OxySignedRecordType, SignedRecordEnvelope, VerifiedDomain } from '@oxy.so/contracts';
1
+ import type { AuthMethodsResponse, IdentityLinkCreateResponse, IdentityLinkState, IdentityRootStatus, DidDocument, DomainVerificationInstructions, ExportBundle, OxySignedRecordType, SignedRecordEnvelope, VerifiedDomain } from '@oxy.so/contracts';
27
2
  import type { OxyServicesBase } from '../OxyServices.base';
28
3
  import { type PendingIdentityResult } from '../crypto/recoveryPhrase';
29
4
  /**
@@ -140,22 +115,47 @@ export declare function OxyServicesIdentityMixin<T extends typeof OxyServicesBas
140
115
  */
141
116
  listAuthMethods(): Promise<AuthMethodsResponse>;
142
117
  /**
143
- * The signed-in account's root readiness (ADR 0024 D5): whether a root is
144
- * linked, how many passkeys can open the web holder, whether the root has a
145
- * phrase and whether it is saved and was shown to recover it. Metadata only —
146
- * nothing here can open the root — so any first-party surface may show a
147
- * "save your recovery phrase" reminder from it.
118
+ * How the signed-in account is kept (ADR 0029 D3): whether Commons' root is
119
+ * linked (self-custody), or the recovery email of a passkey account. Any
120
+ * first-party surface may read it to recommend linking Commons.
148
121
  */
149
122
  getIdentityRootStatus(): Promise<IdentityRootStatus>;
123
+ /**
124
+ * Open a request to link Commons to the signed-in passkey account (ADR 0029
125
+ * D3). auth.oxy.so shows its `qrPayload`; Commons scans it and signs with
126
+ * {@link signIdentityLink}.
127
+ */
128
+ createIdentityLink(): Promise<IdentityLinkCreateResponse>;
129
+ /** Where a link request stands. Both devices poll it; it needs no session. */
130
+ getIdentityLink(linkId: string): Promise<IdentityLinkState>;
131
+ /**
132
+ * Commons' half: sign the scanned request's `link_identity` proof with THIS
133
+ * device's identity key and post it. NATIVE-ONLY (the key lives in native
134
+ * secure storage). Resolves to the key and the 6-digit code auth.oxy.so will
135
+ * show for it, for the person to compare.
136
+ */
137
+ signIdentityLink(linkId: string, challenge: string): Promise<{
138
+ publicKey: string;
139
+ code: string;
140
+ username: string | null;
141
+ }>;
142
+ /** WebAuthn request options over the account's passkeys, whose challenge is the link's. Opaque. */
143
+ getIdentityLinkAssertionOptions(linkId: string, challenge: string): Promise<unknown>;
144
+ /**
145
+ * auth.oxy.so's half: the passkey assertion over the link's challenge. The
146
+ * account gains Commons' root and loses its recovery email.
147
+ */
148
+ completeIdentityLink(linkId: string, assertion: unknown): Promise<{
149
+ success: true;
150
+ }>;
151
+ /** Withdraw a link request that has not completed. */
152
+ cancelIdentityLink(linkId: string): Promise<void>;
150
153
  /**
151
154
  * Remove ONE passkey (WebAuthn credential) from the current account.
152
155
  *
153
156
  * Passkeys are per-credential, so this targets a specific credential id.
154
157
  * The server refuses to remove the last remaining auth method (the account
155
158
  * would become inaccessible) and deletes the stored `WebauthnCredential`.
156
- * A passkey that also opens the account's root on the web takes its wrap with
157
- * it, and the ONLY such passkey is refused with
158
- * `code: 'IDENTITY_LAST_WEB_HOLDER'` (ADR 0024 D6).
159
159
  *
160
160
  * @param credentialId - The passkey's public credential id
161
161
  * (`AuthMethodEntry.credentialId`).
@@ -2,7 +2,7 @@
2
2
  * User Management Methods Mixin
3
3
  */
4
4
  import type { CreateOxyNotificationRequest } from '@oxy.so/contracts';
5
- import type { User, Notification, NotificationPreferences, UserPreferences, SearchProfilesResponse, PrivacySettings } from '../models/interfaces';
5
+ import type { User, Notification, NotificationPage, NotificationPreferences, UserPreferences, SearchProfilesResponse, PrivacySettings } from '../models/interfaces';
6
6
  import type { UserNameResponse, UserProfileUpdate, RecommendationRequest, RecommendationItem, ThemePreference } from '@oxy.so/contracts';
7
7
  import type { OxyServicesBase } from '../OxyServices.base';
8
8
  import { type PaginationParams, type FollowGraphParams } from '../utils/apiUtils';
@@ -350,6 +350,23 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
350
350
  deleteAccount(confirmText: string): Promise<{
351
351
  message: string;
352
352
  }>;
353
+ /**
354
+ * WebAuthn request options to delete a PASSKEY account (ADR 0029 D3): its own
355
+ * passkeys, user verification required, and a challenge bound to it. Opaque —
356
+ * hand them to the browser's authentication ceremony, on auth.oxy.so. An
357
+ * account with a Commons key deletes with {@link deleteAccount} instead.
358
+ */
359
+ getAccountDeletionOptions(): Promise<unknown>;
360
+ /**
361
+ * Delete a PASSKEY account permanently, with an assertion by one of its
362
+ * passkeys over {@link getAccountDeletionOptions}' challenge.
363
+ *
364
+ * @param confirmText - Must equal the user's username (verified server-side)
365
+ * @param assertion - The ceremony's opaque `AuthenticationResponseJSON`
366
+ */
367
+ deleteAccountWithPasskey(confirmText: string, assertion: unknown): Promise<{
368
+ message: string;
369
+ }>;
353
370
  /**
354
371
  * Invalidate every cached read a follow/unfollow write invalidates.
355
372
  *
@@ -497,9 +514,13 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
497
514
  */
498
515
  getViewerGraph(): Promise<ViewerGraph>;
499
516
  /**
500
- * Get notifications
517
+ * One page of the signed-in user's Oxy notifications, newest first
518
+ * (`GET /notifications`), with the unread count across all pages.
501
519
  */
502
- getNotifications(): Promise<Notification[]>;
520
+ getNotifications(params?: {
521
+ page?: number;
522
+ limit?: number;
523
+ }): Promise<NotificationPage>;
503
524
  /**
504
525
  * Get unread notification count
505
526
  */
@@ -511,11 +532,13 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
511
532
  * from an Oxy service about the recipient's own account, with the recipient
512
533
  * as actor and their profile as the entity.
513
534
  */
514
- createNotification(data: CreateOxyNotificationRequest | Partial<Notification>): Promise<Notification>;
535
+ createNotification(data: CreateOxyNotificationRequest): Promise<Notification>;
515
536
  /**
516
- * Mark notification as read
537
+ * Mark one of the signed-in user's notifications read and return it as
538
+ * stored. Scoped to the recipient server-side, so it is also the
539
+ * authoritative read of a notification by id: a foreign or unknown id 404s.
517
540
  */
518
- markNotificationAsRead(notificationId: string): Promise<void>;
541
+ markNotificationAsRead(notificationId: string): Promise<Notification>;
519
542
  /**
520
543
  * Mark all notifications as read
521
544
  */
@@ -1,4 +1,4 @@
1
- import type { AccountKind, AccountCategoryId, UserNameResponse, UserRelationship, ThemePreference } from '@oxy.so/contracts';
1
+ import type { AccountKind, AccountCategoryId, UserNameResponse, UserRelationship, ThemePreference, OxyNotificationEntityType, OxyNotificationType } from '@oxy.so/contracts';
2
2
  export interface OxyConfig {
3
3
  baseURL: string;
4
4
  cloudURL?: string;
@@ -13,6 +13,27 @@ export interface OxyConfig {
13
13
  * bounce (which uses the RP origin, not this registered client id).
14
14
  */
15
15
  clientId?: string;
16
+ /**
17
+ * How a BACKEND identifies itself on requests that carry no user session.
18
+ *
19
+ * - `'never'` (default): such requests go out anonymous. Right for browsers
20
+ * and apps, which have no service identity to offer.
21
+ * - `'when-anonymous'`: they carry this process's service token instead —
22
+ * from `configureServiceAuth()`'s key pair, or, with none, from workload
23
+ * attestation (ADR 0026). A user session, when there is one, still wins,
24
+ * and a process that can produce no service token (a local checkout) keeps
25
+ * sending the request anonymous.
26
+ *
27
+ * Why a server wants it: oxy-api charges anonymous traffic to the SOURCE
28
+ * ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15
29
+ * minutes), and a fleet leaves through one NAT address, so every anonymous
30
+ * read a backend makes shares — and past the threshold pays for — one budget.
31
+ * A first-party service token is exempt from both and charged to its own
32
+ * application's budget instead. Measured from Mention's task (2026-09-25):
33
+ * `GET /users/:id` 543–575 ms anonymous past the threshold, `POST
34
+ * /users/by-ids` 19–24 ms with the service token.
35
+ */
36
+ serviceIdentity?: 'never' | 'when-anonymous';
16
37
  /**
17
38
  * Enable the per-instance GET response cache. Defaults to `true` (5-minute
18
39
  * TTL). Set to `false` to disable caching entirely for this instance — GET
@@ -241,25 +262,58 @@ export interface LoginResponse {
241
262
  user: User;
242
263
  message?: string;
243
264
  }
265
+ /**
266
+ * The actor of a notification as `GET /notifications` populates it — the raw
267
+ * stored name, not a composed display name.
268
+ */
269
+ export interface NotificationActor {
270
+ _id: string;
271
+ username?: string;
272
+ name?: {
273
+ first: string;
274
+ last: string;
275
+ };
276
+ avatar?: string;
277
+ }
278
+ /**
279
+ * One Oxy notification exactly as the API serializes it
280
+ * (`packages/api/src/controllers/notification.controller.ts`). `_id` — not
281
+ * `id` — is the key every notification endpoint addresses it by.
282
+ */
244
283
  export interface Notification {
245
- id: string;
246
- message: string;
284
+ _id: string;
285
+ recipientId: string;
286
+ /** Populated on a list read; the bare id on a create or a mark-read. */
287
+ actorId: string | NotificationActor;
247
288
  /** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
248
- type?: string;
289
+ type: OxyNotificationType;
249
290
  /**
250
- * `system` notifications only (with `message`): the text an Oxy service sent
251
- * about the recipient's own account. Render these two instead of composing a
252
- * sentence from actor + entity.
291
+ * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
292
+ * in the notifying application's namespace (`system` notifications only).
293
+ */
294
+ entityType: OxyNotificationEntityType;
295
+ entityId: string;
296
+ /**
297
+ * `system` notifications only: the text an Oxy service sent about the
298
+ * recipient's own account. Render these instead of composing a sentence from
299
+ * actor + entity.
253
300
  */
254
301
  title?: string;
302
+ message?: string;
255
303
  /** `system` notifications only: an optional deep link (https or the sending app's scheme). */
256
304
  url?: string;
257
- /**
258
- * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
259
- * in the notifying application's namespace (`system` notifications only).
260
- */
261
- entityType?: string;
262
- entityId?: string;
305
+ read: boolean;
306
+ createdAt: string;
307
+ updatedAt: string;
308
+ }
309
+ /** One page of `GET /notifications`, newest first. */
310
+ export interface NotificationPage {
311
+ notifications: Notification[];
312
+ /** Unread notifications across ALL pages. */
313
+ unreadCount: number;
314
+ hasMore: boolean;
315
+ page: number;
316
+ limit: number;
263
317
  }
264
318
  export interface Wallet {
265
319
  id: string;
@@ -167,9 +167,9 @@ export interface OxyPagesHeadersOptions {
167
167
  * violation for a script we never asked for. The rest of the value is Pages'
168
168
  * own default, so caching is unchanged.
169
169
  *
170
- * And, because this origin is the web identity carrier — where a person's
171
- * self-custody root is created, opened, recovered and moved (ADR 0028) — the
172
- * rest of the holder policy: no `<base>` and no form posts (`base-uri` /
170
+ * And, because this origin is where a web account is created, recovered and
171
+ * deleted and every Oxy passkey is asserted (ADR 0028, ADR 0029) — the rest
172
+ * of the holder policy: no `<base>` and no form posts (`base-uri` /
173
173
  * `form-action 'none'`), no Referer to anyone, passkeys for this origin only
174
174
  * with every other powerful feature off (`Permissions-Policy`), and its
175
175
  * responses readable by no other origin (`Cross-Origin-Resource-Policy`).
@@ -15,10 +15,9 @@
15
15
  */
16
16
  export declare 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 declare const AUTH_WEB_ORIGIN = "https://auth.oxy.so";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "1.16.0",
3
+ "version": "1.18.1",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -118,8 +118,8 @@
118
118
  "@oxy.so/telemetry": "^1.1.1",
119
119
  "@noble/ciphers": "^1.3.0",
120
120
  "@noble/hashes": "^1.8.0",
121
- "@oxy.so/contracts": "^2.0.0",
122
- "@oxy.so/protocol": "^1.1.1",
121
+ "@oxy.so/contracts": "^2.2.0",
122
+ "@oxy.so/protocol": "^1.1.2",
123
123
  "@scure/bip39": "^1.6.0",
124
124
  "buffer": "^6.0.3",
125
125
  "express-rate-limit": "^8.6.0",
@@ -38,6 +38,11 @@ interface JwtPayload {
38
38
  export type AuthRefreshReason = 'preflight' | 'response-401';
39
39
  export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
40
40
  export type AccessTokenProvider = () => string | null;
41
+ /**
42
+ * Supplies a bearer for a request made while this client holds NO user session
43
+ * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
44
+ */
45
+ export type AnonymousAuthProvider = () => Promise<string | null>;
41
46
 
42
47
  /**
43
48
  * A low-level authenticated request whose response body remains unread.
@@ -378,6 +383,7 @@ export class HttpService {
378
383
  private noFresherTokenThan: string | null = null;
379
384
  private authRefreshHandler: AuthRefreshHandler | null = null;
380
385
  private accessTokenProvider: AccessTokenProvider | null = null;
386
+ private anonymousAuthProvider: AnonymousAuthProvider | null = null;
381
387
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
382
388
  /**
383
389
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -1191,7 +1197,7 @@ export class HttpService {
1191
1197
  private async getAuthHeader(): Promise<string | null> {
1192
1198
  const accessToken = this.syncAccessTokenFromProvider();
1193
1199
  if (!accessToken) {
1194
- return null;
1200
+ return this.getAnonymousAuthHeader();
1195
1201
  }
1196
1202
 
1197
1203
  try {
@@ -1222,6 +1228,23 @@ export class HttpService {
1222
1228
  }
1223
1229
  }
1224
1230
 
1231
+ /**
1232
+ * The bearer for a request that has no user session behind it, from the
1233
+ * {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
1234
+ * that fails sends the request anonymous, exactly as it went before any
1235
+ * provider existed.
1236
+ */
1237
+ private async getAnonymousAuthHeader(): Promise<string | null> {
1238
+ if (!this.anonymousAuthProvider) return null;
1239
+ try {
1240
+ const token = await this.anonymousAuthProvider();
1241
+ return token ? `Bearer ${token}` : null;
1242
+ } catch (error) {
1243
+ this.logger.debug('Anonymous auth provider failed; sending the request anonymous:', error);
1244
+ return null;
1245
+ }
1246
+ }
1247
+
1225
1248
  async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
1226
1249
  if (!this.authRefreshHandler) {
1227
1250
  return null;
@@ -1565,6 +1588,17 @@ export class HttpService {
1565
1588
  this.accessTokenProvider = provider;
1566
1589
  }
1567
1590
 
1591
+ /**
1592
+ * Authenticate requests that carry no user session with the bearer this
1593
+ * provider returns — in practice a backend's own service token (see
1594
+ * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1595
+ * consulted only when there is no access token, and never for `skipAuth`
1596
+ * requests. `null` removes it.
1597
+ */
1598
+ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void {
1599
+ this.anonymousAuthProvider = provider;
1600
+ }
1601
+
1568
1602
  clearTokens(): void {
1569
1603
  this.tokenStore.clearTokens();
1570
1604
  // Drop the response cache on logout. The cache is identity-scoped, so a