@oxy.so/core 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/identityLink.js +18 -0
  3. package/dist/cjs/i18n/locales/ar-SA.json +78 -3
  4. package/dist/cjs/i18n/locales/ca-ES.json +78 -3
  5. package/dist/cjs/i18n/locales/de-DE.json +78 -3
  6. package/dist/cjs/i18n/locales/en-US.json +75 -28
  7. package/dist/cjs/i18n/locales/es-ES.json +75 -28
  8. package/dist/cjs/i18n/locales/fr-FR.json +78 -3
  9. package/dist/cjs/i18n/locales/it-IT.json +78 -3
  10. package/dist/cjs/i18n/locales/ja-JP.json +78 -3
  11. package/dist/cjs/i18n/locales/ko-KR.json +78 -3
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
  15. package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
  23. package/dist/cjs/i18n/locales/pt-PT.json +78 -3
  24. package/dist/cjs/i18n/locales/zh-CN.json +78 -3
  25. package/dist/cjs/index.js +9 -42
  26. package/dist/cjs/mixins/OxyServices.auth.js +54 -16
  27. package/dist/cjs/mixins/OxyServices.identity.js +128 -8
  28. package/dist/cjs/mixins/OxyServices.user.js +42 -7
  29. package/dist/cjs/utils/authWebUrl.js +4 -5
  30. package/dist/esm/.tsbuildinfo +1 -1
  31. package/dist/esm/crypto/identityLink.js +15 -0
  32. package/dist/esm/i18n/locales/ar-SA.json +78 -3
  33. package/dist/esm/i18n/locales/ca-ES.json +78 -3
  34. package/dist/esm/i18n/locales/de-DE.json +78 -3
  35. package/dist/esm/i18n/locales/en-US.json +75 -28
  36. package/dist/esm/i18n/locales/es-ES.json +75 -28
  37. package/dist/esm/i18n/locales/fr-FR.json +78 -3
  38. package/dist/esm/i18n/locales/it-IT.json +78 -3
  39. package/dist/esm/i18n/locales/ja-JP.json +78 -3
  40. package/dist/esm/i18n/locales/ko-KR.json +78 -3
  41. package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
  42. package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
  43. package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
  44. package/dist/esm/i18n/locales/locales/en-US.json +75 -28
  45. package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
  46. package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
  47. package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
  48. package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
  49. package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
  50. package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
  51. package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
  52. package/dist/esm/i18n/locales/pt-PT.json +78 -3
  53. package/dist/esm/i18n/locales/zh-CN.json +78 -3
  54. package/dist/esm/index.js +4 -4
  55. package/dist/esm/mixins/OxyServices.auth.js +55 -17
  56. package/dist/esm/mixins/OxyServices.identity.js +128 -8
  57. package/dist/esm/mixins/OxyServices.user.js +42 -7
  58. package/dist/esm/utils/authWebUrl.js +4 -5
  59. package/dist/types/.tsbuildinfo +1 -1
  60. package/dist/types/crypto/identityLink.d.ts +8 -0
  61. package/dist/types/index.d.ts +2 -5
  62. package/dist/types/mixins/OxyServices.auth.d.ts +35 -25
  63. package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
  64. package/dist/types/mixins/OxyServices.user.d.ts +29 -6
  65. package/dist/types/models/interfaces.d.ts +46 -13
  66. package/dist/types/server/securityHeaders.d.ts +3 -3
  67. package/dist/types/utils/authWebUrl.d.ts +4 -5
  68. package/package.json +3 -3
  69. package/src/crypto/__tests__/identityLink.test.ts +18 -0
  70. package/src/crypto/__tests__/identityProof.test.ts +6 -6
  71. package/src/crypto/identityLink.ts +18 -0
  72. package/src/i18n/locales/ar-SA.json +78 -3
  73. package/src/i18n/locales/ca-ES.json +78 -3
  74. package/src/i18n/locales/de-DE.json +78 -3
  75. package/src/i18n/locales/en-US.json +75 -28
  76. package/src/i18n/locales/es-ES.json +75 -28
  77. package/src/i18n/locales/fr-FR.json +78 -3
  78. package/src/i18n/locales/it-IT.json +78 -3
  79. package/src/i18n/locales/ja-JP.json +78 -3
  80. package/src/i18n/locales/ko-KR.json +78 -3
  81. package/src/i18n/locales/pt-PT.json +78 -3
  82. package/src/i18n/locales/zh-CN.json +78 -3
  83. package/src/index.ts +6 -48
  84. package/src/mixins/OxyServices.auth.ts +72 -24
  85. package/src/mixins/OxyServices.identity.ts +126 -8
  86. package/src/mixins/OxyServices.user.ts +51 -10
  87. package/src/mixins/__tests__/identityLink.test.ts +89 -0
  88. package/src/mixins/__tests__/notifications.read.test.ts +82 -0
  89. package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
  90. package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
  91. package/src/models/interfaces.ts +46 -12
  92. package/src/server/securityHeaders.ts +3 -3
  93. package/src/utils/authWebUrl.ts +4 -5
  94. package/dist/cjs/crypto/identityMove.js +0 -142
  95. package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
  96. package/dist/esm/crypto/identityMove.js +0 -128
  97. package/dist/esm/crypto/webIdentityCarrier.js +0 -303
  98. package/dist/types/crypto/identityMove.d.ts +0 -75
  99. package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
  100. package/src/crypto/__tests__/identityMove.test.ts +0 -146
  101. package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
  102. package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
  103. package/src/crypto/identityMove.ts +0 -167
  104. package/src/crypto/webIdentityCarrier.ts +0 -394
@@ -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';
@@ -790,50 +790,60 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
790
790
  message: string;
791
791
  }>;
792
792
  /**
793
- * Check email availability
793
+ * Send a 6-digit code to an email (ADR 0029 D3): the recovery email of a
794
+ * new passkey account (`purpose: 'signup'`), or — named by username or
795
+ * email — the recovery email of an account being recovered
796
+ * (`purpose: 'recovery'`). The answer is the same whether or not an
797
+ * account exists; nothing is revealed about who has one.
794
798
  */
795
- checkEmailAvailability(email: string): Promise<{
796
- available: boolean;
797
- message: string;
798
- }>;
799
+ startEmailVerification(request: EmailVerificationStartRequest): Promise<EmailVerificationStartResponse>;
800
+ /**
801
+ * Confirm the code {@link startEmailVerification} sent. Resolves to a
802
+ * short-lived one-use ticket: a sign-up passes it (with the email) to
803
+ * {@link webauthnRegisterVerify}; a recovery passes it as `recoveryTicket`
804
+ * to {@link webauthnRegisterOptions} and {@link webauthnRegisterVerify}.
805
+ */
806
+ confirmEmailVerification(verificationId: string, code: string): Promise<EmailVerificationConfirmResponse>;
799
807
  /**
800
808
  * Begin a WebAuthn / passkey REGISTRATION ceremony. Requests the
801
809
  * `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
802
810
  * .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
803
811
  *
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.
812
+ * With a bearer token planted this adds a passkey to the signed-in account.
813
+ * Without one, `username` is a prospective sign-up's handle, and
814
+ * `recoveryTicket` a recovery's new passkey for the account it names. The
815
+ * returned options are OPAQUE — Oxy does not own their shape (the browser /
816
+ * `@simplewebauthn` does), so they pass through as `unknown` for the caller
817
+ * to hand straight to the ceremony.
809
818
  */
810
- webauthnRegisterOptions(username?: string): Promise<unknown>;
819
+ webauthnRegisterOptions(request?: {
820
+ username?: string;
821
+ recoveryTicket?: string;
822
+ }): Promise<unknown>;
811
823
  /**
812
824
  * Finish a WebAuthn / passkey REGISTRATION ceremony. Forwards the opaque
813
825
  * browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
814
826
  * (desired `username` for signup + the device-session naming fields).
815
827
  *
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.
828
+ * Server branches, disambiguated by the response shape:
829
+ * - **Sign-up** (no bearer; `username`, `email`, `emailTicket`) and
830
+ * **recovery** (no bearer; `recoveryTicket`): the account is created, or
831
+ * gains the passkey, and a session is minted — the response carries
832
+ * `sessionId`, is the SAME {@link LoginResult} contract as
833
+ * `POST /auth/verify`, and its access token is planted here.
820
834
  * - **Link** (bearer present): the passkey is attached to the signed-in
821
835
  * account and the server returns `{ success, message }` with no session,
822
836
  * which is returned verbatim (no token planting).
823
837
  */
824
838
  webauthnRegisterVerify(response: unknown, envelope?: {
825
839
  username?: string;
840
+ /** Sign-up: the recovery email, confirmed with `emailTicket`. */
841
+ email?: string;
842
+ emailTicket?: string;
843
+ /** Recovery: the ticket {@link confirmEmailVerification} returned. */
844
+ recoveryTicket?: string;
826
845
  deviceName?: string;
827
846
  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
847
  }): Promise<{
838
848
  success: true;
839
849
  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;
@@ -241,25 +241,58 @@ export interface LoginResponse {
241
241
  user: User;
242
242
  message?: string;
243
243
  }
244
+ /**
245
+ * The actor of a notification as `GET /notifications` populates it — the raw
246
+ * stored name, not a composed display name.
247
+ */
248
+ export interface NotificationActor {
249
+ _id: string;
250
+ username?: string;
251
+ name?: {
252
+ first: string;
253
+ last: string;
254
+ };
255
+ avatar?: string;
256
+ }
257
+ /**
258
+ * One Oxy notification exactly as the API serializes it
259
+ * (`packages/api/src/controllers/notification.controller.ts`). `_id` — not
260
+ * `id` — is the key every notification endpoint addresses it by.
261
+ */
244
262
  export interface Notification {
245
- id: string;
246
- message: string;
263
+ _id: string;
264
+ recipientId: string;
265
+ /** Populated on a list read; the bare id on a create or a mark-read. */
266
+ actorId: string | NotificationActor;
247
267
  /** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
248
- type?: string;
268
+ type: OxyNotificationType;
269
+ /**
270
+ * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
271
+ * in the notifying application's namespace (`system` notifications only).
272
+ */
273
+ entityType: OxyNotificationEntityType;
274
+ entityId: string;
249
275
  /**
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.
276
+ * `system` notifications only: the text an Oxy service sent about the
277
+ * recipient's own account. Render these instead of composing a sentence from
278
+ * actor + entity.
253
279
  */
254
280
  title?: string;
281
+ message?: string;
255
282
  /** `system` notifications only: an optional deep link (https or the sending app's scheme). */
256
283
  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;
284
+ read: boolean;
285
+ createdAt: string;
286
+ updatedAt: string;
287
+ }
288
+ /** One page of `GET /notifications`, newest first. */
289
+ export interface NotificationPage {
290
+ notifications: Notification[];
291
+ /** Unread notifications across ALL pages. */
292
+ unreadCount: number;
293
+ hasMore: boolean;
294
+ page: number;
295
+ limit: number;
263
296
  }
264
297
  export interface Wallet {
265
298
  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.0",
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",
@@ -0,0 +1,18 @@
1
+ import { deriveIdentityLinkCode } from '../identityLink';
2
+
3
+ const LINK_ID = 'ab'.repeat(16);
4
+ const KEY = `04${'1f'.repeat(64)}`;
5
+
6
+ describe('deriveIdentityLinkCode', () => {
7
+ it('is 6 digits, the same on both devices whatever the key spelling', () => {
8
+ const code = deriveIdentityLinkCode(LINK_ID, KEY);
9
+ expect(code).toMatch(/^\d{6}$/);
10
+ expect(deriveIdentityLinkCode(LINK_ID, ` ${KEY.toUpperCase()} `)).toBe(code);
11
+ });
12
+
13
+ it('changes with the key and with the request', () => {
14
+ const code = deriveIdentityLinkCode(LINK_ID, KEY);
15
+ expect(deriveIdentityLinkCode(LINK_ID, `04${'2e'.repeat(64)}`)).not.toBe(code);
16
+ expect(deriveIdentityLinkCode('cd'.repeat(16), KEY)).not.toBe(code);
17
+ });
18
+ });
@@ -6,12 +6,13 @@
6
6
  import { buildIdentityProofMessage, canonicalJson, type IdentityProofClaims } from '@oxy.so/contracts';
7
7
  import { verifySignature } from '@oxy.so/protocol';
8
8
  import { digestIdentityPayload, signIdentityProof } from '../identityProof';
9
- import { deriveIdentityFromMnemonic } from '../webIdentityCarrier';
9
+ import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
10
10
 
11
- const identity = deriveIdentityFromMnemonic('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
11
+ const keyPair = (privateKey: string) => ({ privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) });
12
+ const identity = keyPair('11'.repeat(32));
12
13
 
13
14
  const claims = (overrides: Partial<IdentityProofClaims> = {}): IdentityProofClaims => ({
14
- action: 'web_envelope_put',
15
+ action: 'link_identity',
15
16
  subject: 'user-1',
16
17
  actor: 'user-1',
17
18
  rootPublicKey: identity.publicKey,
@@ -42,7 +43,7 @@ describe('canonical JSON', () => {
42
43
  describe('the signed bytes', () => {
43
44
  it('are a fixed, versioned, domain-separated shape', () => {
44
45
  expect(buildIdentityProofMessage(claims())).toBe(
45
- `{"action":"web_envelope_put","actor":"user-1","audience":"oxy-api/identity","challenge":"${'ab'.repeat(32)}","domain":"oxy-identity-proof","expectedRevision":3,"expiresAt":1800000000000,"payloadDigest":"${claims().payloadDigest}","rootPublicKey":"${identity.publicKey}","subject":"user-1","v":2}`,
46
+ `{"action":"link_identity","actor":"user-1","audience":"oxy-api/identity","challenge":"${'ab'.repeat(32)}","domain":"oxy-identity-proof","expectedRevision":3,"expiresAt":1800000000000,"payloadDigest":"${claims().payloadDigest}","rootPublicKey":"${identity.publicKey}","subject":"user-1","v":2}`,
46
47
  );
47
48
  });
48
49
 
@@ -61,7 +62,6 @@ describe('signing', () => {
61
62
 
62
63
  expect(await verifySignature(buildIdentityProofMessage(claims()), proof.signature, identity.publicKey)).toBe(true);
63
64
  for (const changed of [
64
- claims({ action: 'web_envelope_delete' }),
65
65
  claims({ subject: 'user-2' }),
66
66
  claims({ actor: 'user-2' }),
67
67
  claims({ payloadDigest: digestIdentityPayload({ b: 2 }) }),
@@ -75,7 +75,7 @@ describe('signing', () => {
75
75
  });
76
76
 
77
77
  it('will not sign claims naming a different root', async () => {
78
- const other = deriveIdentityFromMnemonic('legal winner thank year wave sausage worth useful legal winner thank yellow');
78
+ const other = keyPair('22'.repeat(32));
79
79
  await expect(signIdentityProof(identity, claims({ rootPublicKey: other.publicKey }))).rejects.toThrow('different root');
80
80
  });
81
81
  });
@@ -0,0 +1,18 @@
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
+
9
+ import { sha256 } from '@noble/hashes/sha256';
10
+ import { utf8ToBytes } from '@noble/hashes/utils';
11
+
12
+ const DOMAIN = 'oxy-identity-link-code/v1';
13
+
14
+ export function deriveIdentityLinkCode(linkId: string, publicKey: string): string {
15
+ const digest = sha256(utf8ToBytes(`${DOMAIN}|${linkId}|${publicKey.trim().toLowerCase()}`));
16
+ const value = ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;
17
+ return String(value % 1_000_000).padStart(6, '0');
18
+ }
@@ -50,7 +50,26 @@
50
50
  "createInCommons": "أنشئ هويتك في Commons",
51
51
  "createAccount": "إنشاء حساب",
52
52
  "backToSignInLink": "لديك حساب بالفعل؟ سجّل الدخول",
53
- "webSubtitle": "بلا كلمة مرور. ينشئ جهازك مفتاح مرور، ويحصل حسابك على عبارة استرداد تحتفظ بها أنت وحدك."
53
+ "webSubtitle": "بلا كلمة مرور: ينشئ جهازك مفتاح مرور، ويعيدك بريد الاسترداد إذا فقدته.",
54
+ "username": {
55
+ "label": "اسم المستخدم",
56
+ "placeholder": "yourname",
57
+ "required": "اختر اسم مستخدم.",
58
+ "taken": "اسم المستخدم هذا مستخدم بالفعل."
59
+ },
60
+ "email": {
61
+ "title": "بريد الاسترداد",
62
+ "subtitle": "إذا فقدت مفتاح المرور، نرسل إلى هنا رمزًا لتعود. لا يُستخدم لأي شيء آخر.",
63
+ "label": "البريد الإلكتروني",
64
+ "placeholder": "you@example.com",
65
+ "invalid": "أدخل بريدًا إلكترونيًا صالحًا."
66
+ },
67
+ "passkey": {
68
+ "title": "أنشئ مفتاح المرور",
69
+ "subtitle": "تسجّل الدخول به: ببصمتك أو وجهك أو قفل الشاشة. لا شيء تتذكره.",
70
+ "action": "إنشاء مفتاح مرور"
71
+ },
72
+ "commonsInstead": "تريد الاحتفاظ بمفتاحك بنفسك؟ استخدم Commons"
54
73
  },
55
74
  "accountSwitcher": {
56
75
  "loading": "جارٍ تحميل الحسابات...",
@@ -225,7 +244,63 @@
225
244
  "help": "المساعدة والملاحظات",
226
245
  "signOut": "تسجيل الخروج",
227
246
  "privacy": "سياسة الخصوصية",
228
- "terms": "شروط الخدمة",
229
- "identity": "هويتك"
247
+ "terms": "شروط الخدمة"
248
+ },
249
+ "recover": {
250
+ "title": "استرد حسابك",
251
+ "subtitle": "أدخل اسم المستخدم أو بريد الاسترداد. سنرسل رمزًا إلى بريد الاسترداد الخاص بالحساب.",
252
+ "identifier": {
253
+ "label": "اسم المستخدم أو البريد",
254
+ "placeholder": "yourname",
255
+ "required": "أدخل اسم المستخدم أو البريد."
256
+ },
257
+ "commonsNote": "هل يستخدم حسابك Commons؟ استرده في Commons بعبارة الاسترداد.",
258
+ "passkey": {
259
+ "title": "أضف مفتاح مرور جديدًا",
260
+ "subtitle": "لـ @{{username}}. أزل مفاتيح المرور المفقودة لاحقًا من إعدادات أمان حسابك.",
261
+ "action": "إنشاء مفتاح مرور"
262
+ },
263
+ "backToSignIn": "العودة إلى تسجيل الدخول"
264
+ },
265
+ "emailCode": {
266
+ "title": "تحقق من بريدك",
267
+ "sentTo": "أرسلنا رمزًا من 6 أرقام إلى {{email}}.",
268
+ "sentIfAccount": "إذا كان لهذا الحساب بريد استرداد، فقد أرسلنا إليه رمزًا من 6 أرقام.",
269
+ "label": "الرمز",
270
+ "resend": "أرسل رمزًا جديدًا",
271
+ "resent": "أرسلنا رمزًا جديدًا.",
272
+ "changeEmail": "استخدم بريدًا آخر",
273
+ "errors": {
274
+ "codeInvalid": "الرمز غير صحيح أو انتهت صلاحيته.",
275
+ "tooManyAttempts": "رموز خاطئة كثيرة. اطلب رمزًا جديدًا.",
276
+ "expired": "استغرق الأمر وقتًا طويلًا. ابدأ من جديد.",
277
+ "unavailable": "لا يستطيع Oxy إرسال البريد الآن. حاول لاحقًا.",
278
+ "rateLimited": "رموز كثيرة. حاول لاحقًا."
279
+ }
280
+ },
281
+ "deleteAccount": {
282
+ "passkey": {
283
+ "subtitle": "أكّد بمفتاح المرور. سيُحذف @{{username}} وكل ما فيه نهائيًا.",
284
+ "action": "الحذف بمفتاح المرور",
285
+ "done": "حُذف حسابك.",
286
+ "closeWindow": "يمكنك إغلاق هذه النافذة.",
287
+ "commons": "يستخدم هذا الحساب Commons: احذفه في Oxy Commons من الإعدادات > حذف الحساب، بمفتاحه."
288
+ }
289
+ },
290
+ "linkCommons": {
291
+ "title": "اربط Commons",
292
+ "subtitle": "احتفظ بمفتاحك: اربط Commons ليصبح هذا الحساب لك وحدك. يُحذف بريد الاسترداد الخاص به، وتصبح عبارة الاسترداد في Commons طريقك للعودة.",
293
+ "scan": "على هاتفك، افتح Commons واختر «لدي حساب Oxy على الويب» ثم امسح هذا الرمز.",
294
+ "waiting": "بانتظار Commons…",
295
+ "compareTitle": "تحقق من الرمز",
296
+ "compare": "يعرض Commons الرمز نفسه. إن لم يكن كذلك، ألغِ العملية.",
297
+ "confirm": "نعم، اربط Commons",
298
+ "cancel": "إلغاء",
299
+ "doneTitle": "تم ربط Commons",
300
+ "done": "أصبح هذا الحساب ذاتي الحفظ. حُذف بريد الاسترداد الخاص به: عبارة الاسترداد في Commons هي طريقك للعودة.",
301
+ "already": "يستخدم هذا الحساب Commons بالفعل.",
302
+ "expired": "انتهت صلاحية هذا الرمز.",
303
+ "renew": "اعرض رمزًا جديدًا",
304
+ "failed": "تعذّر ربط Commons. حاول مجددًا."
230
305
  }
231
306
  }