@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/crypto/identityLink.js +18 -0
- package/dist/cjs/i18n/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/cjs/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/cjs/i18n/locales/locales/de-DE.json +78 -3
- package/dist/cjs/i18n/locales/locales/en-US.json +75 -28
- package/dist/cjs/i18n/locales/locales/es-ES.json +75 -28
- package/dist/cjs/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/cjs/i18n/locales/locales/it-IT.json +78 -3
- package/dist/cjs/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/cjs/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/cjs/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/cjs/i18n/locales/pt-PT.json +78 -3
- package/dist/cjs/i18n/locales/zh-CN.json +78 -3
- package/dist/cjs/index.js +9 -42
- package/dist/cjs/mixins/OxyServices.auth.js +54 -16
- package/dist/cjs/mixins/OxyServices.identity.js +128 -8
- package/dist/cjs/mixins/OxyServices.user.js +42 -7
- package/dist/cjs/utils/authWebUrl.js +4 -5
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/identityLink.js +15 -0
- package/dist/esm/i18n/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/ar-SA.json +78 -3
- package/dist/esm/i18n/locales/locales/ca-ES.json +78 -3
- package/dist/esm/i18n/locales/locales/de-DE.json +78 -3
- package/dist/esm/i18n/locales/locales/en-US.json +75 -28
- package/dist/esm/i18n/locales/locales/es-ES.json +75 -28
- package/dist/esm/i18n/locales/locales/fr-FR.json +78 -3
- package/dist/esm/i18n/locales/locales/it-IT.json +78 -3
- package/dist/esm/i18n/locales/locales/ja-JP.json +78 -3
- package/dist/esm/i18n/locales/locales/ko-KR.json +78 -3
- package/dist/esm/i18n/locales/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/locales/zh-CN.json +78 -3
- package/dist/esm/i18n/locales/pt-PT.json +78 -3
- package/dist/esm/i18n/locales/zh-CN.json +78 -3
- package/dist/esm/index.js +4 -4
- package/dist/esm/mixins/OxyServices.auth.js +55 -17
- package/dist/esm/mixins/OxyServices.identity.js +128 -8
- package/dist/esm/mixins/OxyServices.user.js +42 -7
- package/dist/esm/utils/authWebUrl.js +4 -5
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/identityLink.d.ts +8 -0
- package/dist/types/index.d.ts +2 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -25
- package/dist/types/mixins/OxyServices.identity.d.ts +34 -34
- package/dist/types/mixins/OxyServices.user.d.ts +29 -6
- package/dist/types/models/interfaces.d.ts +46 -13
- package/dist/types/server/securityHeaders.d.ts +3 -3
- package/dist/types/utils/authWebUrl.d.ts +4 -5
- package/package.json +3 -3
- package/src/crypto/__tests__/identityLink.test.ts +18 -0
- package/src/crypto/__tests__/identityProof.test.ts +6 -6
- package/src/crypto/identityLink.ts +18 -0
- package/src/i18n/locales/ar-SA.json +78 -3
- package/src/i18n/locales/ca-ES.json +78 -3
- package/src/i18n/locales/de-DE.json +78 -3
- package/src/i18n/locales/en-US.json +75 -28
- package/src/i18n/locales/es-ES.json +75 -28
- package/src/i18n/locales/fr-FR.json +78 -3
- package/src/i18n/locales/it-IT.json +78 -3
- package/src/i18n/locales/ja-JP.json +78 -3
- package/src/i18n/locales/ko-KR.json +78 -3
- package/src/i18n/locales/pt-PT.json +78 -3
- package/src/i18n/locales/zh-CN.json +78 -3
- package/src/index.ts +6 -48
- package/src/mixins/OxyServices.auth.ts +72 -24
- package/src/mixins/OxyServices.identity.ts +126 -8
- package/src/mixins/OxyServices.user.ts +51 -10
- package/src/mixins/__tests__/identityLink.test.ts +89 -0
- package/src/mixins/__tests__/notifications.read.test.ts +82 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +1 -1
- package/src/mixins/__tests__/webauthnAuth.test.ts +75 -1
- package/src/models/interfaces.ts +46 -12
- package/src/server/securityHeaders.ts +3 -3
- package/src/utils/authWebUrl.ts +4 -5
- package/dist/cjs/crypto/identityMove.js +0 -142
- package/dist/cjs/crypto/webIdentityCarrier.js +0 -325
- package/dist/esm/crypto/identityMove.js +0 -128
- package/dist/esm/crypto/webIdentityCarrier.js +0 -303
- package/dist/types/crypto/identityMove.d.ts +0 -75
- package/dist/types/crypto/webIdentityCarrier.d.ts +0 -150
- package/src/crypto/__tests__/identityMove.test.ts +0 -146
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +0 -169
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +0 -117
- package/src/crypto/identityMove.ts +0 -167
- 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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
* as `unknown` for the caller
|
|
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(
|
|
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
|
-
*
|
|
817
|
-
* - **
|
|
818
|
-
*
|
|
819
|
-
*
|
|
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
|
-
*
|
|
144
|
-
* linked,
|
|
145
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
535
|
+
createNotification(data: CreateOxyNotificationRequest): Promise<Notification>;
|
|
515
536
|
/**
|
|
516
|
-
* Mark
|
|
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<
|
|
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
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* open
|
|
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.
|
|
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.
|
|
122
|
-
"@oxy.so/protocol": "^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 {
|
|
9
|
+
import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
10
10
|
|
|
11
|
-
const
|
|
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: '
|
|
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":"
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|