@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
|
@@ -9,7 +9,15 @@ import type {
|
|
|
9
9
|
LoginResult,
|
|
10
10
|
CommonsDenyReason,
|
|
11
11
|
} from '@oxy.so/contracts';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
emailVerificationConfirmResponseSchema,
|
|
14
|
+
emailVerificationStartResponseSchema,
|
|
15
|
+
loginResultSchema,
|
|
16
|
+
safeParseContract,
|
|
17
|
+
type EmailVerificationConfirmResponse,
|
|
18
|
+
type EmailVerificationStartRequest,
|
|
19
|
+
type EmailVerificationStartResponse,
|
|
20
|
+
} from '@oxy.so/contracts';
|
|
13
21
|
import type { SessionLoginResponse } from '../models/session';
|
|
14
22
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
15
23
|
import type { PublicApplication } from './OxyServices.connectedApps';
|
|
@@ -1692,12 +1700,43 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1692
1700
|
}
|
|
1693
1701
|
|
|
1694
1702
|
/**
|
|
1695
|
-
*
|
|
1703
|
+
* Send a 6-digit code to an email (ADR 0029 D3): the recovery email of a
|
|
1704
|
+
* new passkey account (`purpose: 'signup'`), or — named by username or
|
|
1705
|
+
* email — the recovery email of an account being recovered
|
|
1706
|
+
* (`purpose: 'recovery'`). The answer is the same whether or not an
|
|
1707
|
+
* account exists; nothing is revealed about who has one.
|
|
1696
1708
|
*/
|
|
1697
|
-
async
|
|
1709
|
+
async startEmailVerification(request: EmailVerificationStartRequest): Promise<EmailVerificationStartResponse> {
|
|
1698
1710
|
try {
|
|
1699
|
-
|
|
1700
|
-
|
|
1711
|
+
const res = await this.makeRequest<unknown>('POST', '/auth/email/verify/start', request, {
|
|
1712
|
+
cache: false,
|
|
1713
|
+
skipAuth: true,
|
|
1714
|
+
});
|
|
1715
|
+
const parsed = safeParseContract(emailVerificationStartResponseSchema, res);
|
|
1716
|
+
if (!parsed) throw new Error('auth/email/verify/start returned an unexpected response shape');
|
|
1717
|
+
return parsed;
|
|
1718
|
+
} catch (error) {
|
|
1719
|
+
throw this.handleError(error);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Confirm the code {@link startEmailVerification} sent. Resolves to a
|
|
1725
|
+
* short-lived one-use ticket: a sign-up passes it (with the email) to
|
|
1726
|
+
* {@link webauthnRegisterVerify}; a recovery passes it as `recoveryTicket`
|
|
1727
|
+
* to {@link webauthnRegisterOptions} and {@link webauthnRegisterVerify}.
|
|
1728
|
+
*/
|
|
1729
|
+
async confirmEmailVerification(verificationId: string, code: string): Promise<EmailVerificationConfirmResponse> {
|
|
1730
|
+
try {
|
|
1731
|
+
const res = await this.makeRequest<unknown>(
|
|
1732
|
+
'POST',
|
|
1733
|
+
'/auth/email/verify/confirm',
|
|
1734
|
+
{ verificationId, code },
|
|
1735
|
+
{ cache: false, skipAuth: true },
|
|
1736
|
+
);
|
|
1737
|
+
const parsed = safeParseContract(emailVerificationConfirmResponseSchema, res);
|
|
1738
|
+
if (!parsed) throw new Error('auth/email/verify/confirm returned an unexpected response shape');
|
|
1739
|
+
return parsed;
|
|
1701
1740
|
} catch (error) {
|
|
1702
1741
|
throw this.handleError(error);
|
|
1703
1742
|
}
|
|
@@ -1708,19 +1747,24 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1708
1747
|
* `PublicKeyCredentialCreationOptions` the browser's `navigator.credentials
|
|
1709
1748
|
* .create()` (or `@simplewebauthn/browser`'s `startRegistration`) needs.
|
|
1710
1749
|
*
|
|
1711
|
-
* With a bearer token planted this
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
* as `unknown` for the caller
|
|
1750
|
+
* With a bearer token planted this adds a passkey to the signed-in account.
|
|
1751
|
+
* Without one, `username` is a prospective sign-up's handle, and
|
|
1752
|
+
* `recoveryTicket` a recovery's new passkey for the account it names. The
|
|
1753
|
+
* returned options are OPAQUE — Oxy does not own their shape (the browser /
|
|
1754
|
+
* `@simplewebauthn` does), so they pass through as `unknown` for the caller
|
|
1755
|
+
* to hand straight to the ceremony.
|
|
1716
1756
|
*/
|
|
1717
|
-
async webauthnRegisterOptions(username?: string): Promise<unknown> {
|
|
1757
|
+
async webauthnRegisterOptions(request: { username?: string; recoveryTicket?: string } = {}): Promise<unknown> {
|
|
1758
|
+
const signedOut = request.username !== undefined || request.recoveryTicket !== undefined;
|
|
1718
1759
|
try {
|
|
1719
1760
|
return await this.makeRequest<unknown>(
|
|
1720
1761
|
'POST',
|
|
1721
1762
|
'/auth/webauthn/register/options',
|
|
1722
|
-
{
|
|
1723
|
-
|
|
1763
|
+
{
|
|
1764
|
+
...(request.username !== undefined ? { username: request.username } : {}),
|
|
1765
|
+
...(request.recoveryTicket !== undefined ? { recoveryTicket: request.recoveryTicket } : {}),
|
|
1766
|
+
},
|
|
1767
|
+
{ cache: false, ...(signedOut ? { skipAuth: true } : {}) },
|
|
1724
1768
|
);
|
|
1725
1769
|
} catch (error) {
|
|
1726
1770
|
throw this.handleError(error);
|
|
@@ -1732,10 +1776,12 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1732
1776
|
* browser `RegistrationResponseJSON` (`response`) alongside the Oxy envelope
|
|
1733
1777
|
* (desired `username` for signup + the device-session naming fields).
|
|
1734
1778
|
*
|
|
1735
|
-
*
|
|
1736
|
-
* - **
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1779
|
+
* Server branches, disambiguated by the response shape:
|
|
1780
|
+
* - **Sign-up** (no bearer; `username`, `email`, `emailTicket`) and
|
|
1781
|
+
* **recovery** (no bearer; `recoveryTicket`): the account is created, or
|
|
1782
|
+
* gains the passkey, and a session is minted — the response carries
|
|
1783
|
+
* `sessionId`, is the SAME {@link LoginResult} contract as
|
|
1784
|
+
* `POST /auth/verify`, and its access token is planted here.
|
|
1739
1785
|
* - **Link** (bearer present): the passkey is attached to the signed-in
|
|
1740
1786
|
* account and the server returns `{ success, message }` with no session,
|
|
1741
1787
|
* which is returned verbatim (no token planting).
|
|
@@ -1744,14 +1790,13 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1744
1790
|
response: unknown,
|
|
1745
1791
|
envelope: {
|
|
1746
1792
|
username?: string;
|
|
1793
|
+
/** Sign-up: the recovery email, confirmed with `emailTicket`. */
|
|
1794
|
+
email?: string;
|
|
1795
|
+
emailTicket?: string;
|
|
1796
|
+
/** Recovery: the ticket {@link confirmEmailVerification} returned. */
|
|
1797
|
+
recoveryTicket?: string;
|
|
1747
1798
|
deviceName?: string;
|
|
1748
1799
|
deviceFingerprint?: string;
|
|
1749
|
-
/**
|
|
1750
|
-
* Sign-up only: the root created on the holder before this call, sealed
|
|
1751
|
-
* under the passkey being registered, plus its `enroll_identity` proof
|
|
1752
|
-
* (ADR 0024 D4). The account is created WITH it or not at all.
|
|
1753
|
-
*/
|
|
1754
|
-
identity?: { envelope: WebIdentityEnvelope; proof: IdentityProof };
|
|
1755
1800
|
} = {},
|
|
1756
1801
|
): Promise<{ success: true; message: string } | LoginResult> {
|
|
1757
1802
|
try {
|
|
@@ -1759,7 +1804,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1759
1804
|
'POST',
|
|
1760
1805
|
'/auth/webauthn/register/verify',
|
|
1761
1806
|
{ response, ...envelope },
|
|
1762
|
-
{
|
|
1807
|
+
{
|
|
1808
|
+
cache: false,
|
|
1809
|
+
...(envelope.username !== undefined || envelope.recoveryTicket !== undefined ? { skipAuth: true } : {}),
|
|
1810
|
+
},
|
|
1763
1811
|
);
|
|
1764
1812
|
if (res && typeof res === 'object') {
|
|
1765
1813
|
const record = res as Record<string, unknown>;
|
|
@@ -23,8 +23,16 @@
|
|
|
23
23
|
* on-device identity and throw on web (where `KeyManager.getPublicKey()` is
|
|
24
24
|
* always `null`).
|
|
25
25
|
*/
|
|
26
|
+
import {
|
|
27
|
+
IDENTITY_PROOF_ACTIONS,
|
|
28
|
+
identityLinkCreateResponseSchema,
|
|
29
|
+
identityLinkStateSchema,
|
|
30
|
+
safeParseContract,
|
|
31
|
+
} from '@oxy.so/contracts';
|
|
26
32
|
import type {
|
|
27
33
|
AuthMethodsResponse,
|
|
34
|
+
IdentityLinkCreateResponse,
|
|
35
|
+
IdentityLinkState,
|
|
28
36
|
IdentityRootStatus,
|
|
29
37
|
DidDocument,
|
|
30
38
|
DomainVerificationInstructions,
|
|
@@ -38,6 +46,8 @@ import type {
|
|
|
38
46
|
import { signMessage } from '@oxy.so/protocol';
|
|
39
47
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
40
48
|
import { KeyManager } from '../crypto/keyManager';
|
|
49
|
+
import { deriveIdentityLinkCode } from '../crypto/identityLink';
|
|
50
|
+
import { signIdentityProof } from '../crypto/identityProof';
|
|
41
51
|
import { SignatureService } from '../crypto/signatureService';
|
|
42
52
|
import { RecoveryPhraseService, type PendingIdentityResult } from '../crypto/recoveryPhrase';
|
|
43
53
|
import { isWeb } from '../utils/platform';
|
|
@@ -219,11 +229,9 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
/**
|
|
222
|
-
*
|
|
223
|
-
* linked,
|
|
224
|
-
*
|
|
225
|
-
* nothing here can open the root — so any first-party surface may show a
|
|
226
|
-
* "save your recovery phrase" reminder from it.
|
|
232
|
+
* How the signed-in account is kept (ADR 0029 D3): whether Commons' root is
|
|
233
|
+
* linked (self-custody), or the recovery email of a passkey account. Any
|
|
234
|
+
* first-party surface may read it to recommend linking Commons.
|
|
227
235
|
*/
|
|
228
236
|
async getIdentityRootStatus(): Promise<IdentityRootStatus> {
|
|
229
237
|
try {
|
|
@@ -233,15 +241,125 @@ export function OxyServicesIdentityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
233
241
|
}
|
|
234
242
|
}
|
|
235
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Open a request to link Commons to the signed-in passkey account (ADR 0029
|
|
246
|
+
* D3). auth.oxy.so shows its `qrPayload`; Commons scans it and signs with
|
|
247
|
+
* {@link signIdentityLink}.
|
|
248
|
+
*/
|
|
249
|
+
async createIdentityLink(): Promise<IdentityLinkCreateResponse> {
|
|
250
|
+
try {
|
|
251
|
+
const res = await this.makeRequest<unknown>('POST', '/identity/link', undefined, { cache: false });
|
|
252
|
+
const parsed = safeParseContract(identityLinkCreateResponseSchema, res);
|
|
253
|
+
if (!parsed) throw new Error('identity/link returned an unexpected response shape');
|
|
254
|
+
return parsed;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw this.handleError(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Where a link request stands. Both devices poll it; it needs no session. */
|
|
261
|
+
async getIdentityLink(linkId: string): Promise<IdentityLinkState> {
|
|
262
|
+
try {
|
|
263
|
+
const res = await this.makeRequest<unknown>('GET', `/identity/link/${encodeURIComponent(linkId)}`, undefined, {
|
|
264
|
+
cache: false,
|
|
265
|
+
skipAuth: true,
|
|
266
|
+
});
|
|
267
|
+
const parsed = safeParseContract(identityLinkStateSchema, res);
|
|
268
|
+
if (!parsed) throw new Error('identity/link returned an unexpected response shape');
|
|
269
|
+
return parsed;
|
|
270
|
+
} catch (error) {
|
|
271
|
+
throw this.handleError(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Commons' half: sign the scanned request's `link_identity` proof with THIS
|
|
277
|
+
* device's identity key and post it. NATIVE-ONLY (the key lives in native
|
|
278
|
+
* secure storage). Resolves to the key and the 6-digit code auth.oxy.so will
|
|
279
|
+
* show for it, for the person to compare.
|
|
280
|
+
*/
|
|
281
|
+
async signIdentityLink(linkId: string, challenge: string): Promise<{ publicKey: string; code: string; username: string | null }> {
|
|
282
|
+
try {
|
|
283
|
+
const [privateKey, publicKey] = await Promise.all([KeyManager.getPrivateKey(), KeyManager.getPublicKey()]);
|
|
284
|
+
if (!privateKey || !publicKey) {
|
|
285
|
+
throw new Error('No identity on this device to link');
|
|
286
|
+
}
|
|
287
|
+
const root = publicKey.trim().toLowerCase();
|
|
288
|
+
const state = await this.getIdentityLink(linkId);
|
|
289
|
+
const proof = await signIdentityProof(
|
|
290
|
+
{ privateKey, publicKey: root },
|
|
291
|
+
{
|
|
292
|
+
action: IDENTITY_PROOF_ACTIONS.link,
|
|
293
|
+
subject: state.userId,
|
|
294
|
+
actor: state.userId,
|
|
295
|
+
rootPublicKey: root,
|
|
296
|
+
payloadDigest: null,
|
|
297
|
+
expectedRevision: null,
|
|
298
|
+
audience: state.audience,
|
|
299
|
+
challenge,
|
|
300
|
+
expiresAt: state.expiresAt,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
await this.makeRequest<unknown>(
|
|
304
|
+
'POST',
|
|
305
|
+
`/identity/link/${encodeURIComponent(linkId)}/proof`,
|
|
306
|
+
{ publicKey: root, proof },
|
|
307
|
+
{ cache: false, skipAuth: true },
|
|
308
|
+
);
|
|
309
|
+
return { publicKey: root, code: deriveIdentityLinkCode(linkId, root), username: state.username };
|
|
310
|
+
} catch (error) {
|
|
311
|
+
throw this.handleError(error);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** WebAuthn request options over the account's passkeys, whose challenge is the link's. Opaque. */
|
|
316
|
+
async getIdentityLinkAssertionOptions(linkId: string, challenge: string): Promise<unknown> {
|
|
317
|
+
try {
|
|
318
|
+
return await this.makeRequest<unknown>(
|
|
319
|
+
'POST',
|
|
320
|
+
`/identity/link/${encodeURIComponent(linkId)}/options`,
|
|
321
|
+
{ challenge },
|
|
322
|
+
{ cache: false },
|
|
323
|
+
);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
throw this.handleError(error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* auth.oxy.so's half: the passkey assertion over the link's challenge. The
|
|
331
|
+
* account gains Commons' root and loses its recovery email.
|
|
332
|
+
*/
|
|
333
|
+
async completeIdentityLink(linkId: string, assertion: unknown): Promise<{ success: true }> {
|
|
334
|
+
try {
|
|
335
|
+
const result = await this.makeRequest<{ success: true }>(
|
|
336
|
+
'POST',
|
|
337
|
+
`/identity/link/${encodeURIComponent(linkId)}/complete`,
|
|
338
|
+
{ assertion },
|
|
339
|
+
{ cache: false },
|
|
340
|
+
);
|
|
341
|
+
this._invalidateIdentityCaches(this.getCurrentUserId());
|
|
342
|
+
return result;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
throw this.handleError(error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Withdraw a link request that has not completed. */
|
|
349
|
+
async cancelIdentityLink(linkId: string): Promise<void> {
|
|
350
|
+
try {
|
|
351
|
+
await this.makeRequest<unknown>('DELETE', `/identity/link/${encodeURIComponent(linkId)}`, undefined, { cache: false });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
throw this.handleError(error);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
236
357
|
/**
|
|
237
358
|
* Remove ONE passkey (WebAuthn credential) from the current account.
|
|
238
359
|
*
|
|
239
360
|
* Passkeys are per-credential, so this targets a specific credential id.
|
|
240
361
|
* The server refuses to remove the last remaining auth method (the account
|
|
241
362
|
* would become inaccessible) and deletes the stored `WebauthnCredential`.
|
|
242
|
-
* A passkey that also opens the account's root on the web takes its wrap with
|
|
243
|
-
* it, and the ONLY such passkey is refused with
|
|
244
|
-
* `code: 'IDENTITY_LAST_WEB_HOLDER'` (ADR 0024 D6).
|
|
245
363
|
*
|
|
246
364
|
* @param credentialId - The passkey's public credential id
|
|
247
365
|
* (`AuthMethodEntry.credentialId`).
|
|
@@ -5,6 +5,7 @@ import type { CreateOxyNotificationRequest } from '@oxy.so/contracts';
|
|
|
5
5
|
import type {
|
|
6
6
|
User,
|
|
7
7
|
Notification,
|
|
8
|
+
NotificationPage,
|
|
8
9
|
NotificationPreferences,
|
|
9
10
|
UserPreferences,
|
|
10
11
|
SearchProfilesResponse,
|
|
@@ -755,6 +756,35 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
755
756
|
}
|
|
756
757
|
|
|
757
758
|
|
|
759
|
+
/**
|
|
760
|
+
* WebAuthn request options to delete a PASSKEY account (ADR 0029 D3): its own
|
|
761
|
+
* passkeys, user verification required, and a challenge bound to it. Opaque —
|
|
762
|
+
* hand them to the browser's authentication ceremony, on auth.oxy.so. An
|
|
763
|
+
* account with a Commons key deletes with {@link deleteAccount} instead.
|
|
764
|
+
*/
|
|
765
|
+
async getAccountDeletionOptions(): Promise<unknown> {
|
|
766
|
+
try {
|
|
767
|
+
return await this.makeRequest<unknown>('POST', '/users/me/delete/options', undefined, { cache: false });
|
|
768
|
+
} catch (error) {
|
|
769
|
+
throw this.handleError(error);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Delete a PASSKEY account permanently, with an assertion by one of its
|
|
775
|
+
* passkeys over {@link getAccountDeletionOptions}' challenge.
|
|
776
|
+
*
|
|
777
|
+
* @param confirmText - Must equal the user's username (verified server-side)
|
|
778
|
+
* @param assertion - The ceremony's opaque `AuthenticationResponseJSON`
|
|
779
|
+
*/
|
|
780
|
+
async deleteAccountWithPasskey(confirmText: string, assertion: unknown): Promise<{ message: string }> {
|
|
781
|
+
try {
|
|
782
|
+
return await this.makeRequest<{ message: string }>('DELETE', '/users/me', { confirmText, assertion }, { cache: false });
|
|
783
|
+
} catch (error) {
|
|
784
|
+
throw this.handleError(error);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
758
788
|
/**
|
|
759
789
|
* Invalidate every cached read a follow/unfollow write invalidates.
|
|
760
790
|
*
|
|
@@ -1092,11 +1122,13 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1092
1122
|
}
|
|
1093
1123
|
|
|
1094
1124
|
/**
|
|
1095
|
-
*
|
|
1125
|
+
* One page of the signed-in user's Oxy notifications, newest first
|
|
1126
|
+
* (`GET /notifications`), with the unread count across all pages.
|
|
1096
1127
|
*/
|
|
1097
|
-
async getNotifications(): Promise<
|
|
1128
|
+
async getNotifications(params: { page?: number; limit?: number } = {}): Promise<NotificationPage> {
|
|
1129
|
+
const query = buildQueryParams({ page: params.page, limit: params.limit });
|
|
1098
1130
|
return this.withAuthRetry(async () => {
|
|
1099
|
-
return await this.makeRequest<
|
|
1131
|
+
return await this.makeRequest<NotificationPage>('GET', '/notifications', query, {
|
|
1100
1132
|
cache: false, // Don't cache notifications - always get fresh data
|
|
1101
1133
|
});
|
|
1102
1134
|
}, 'getNotifications');
|
|
@@ -1107,10 +1139,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1107
1139
|
*/
|
|
1108
1140
|
async getUnreadCount(): Promise<number> {
|
|
1109
1141
|
try {
|
|
1110
|
-
const res = await this.makeRequest<{
|
|
1142
|
+
const res = await this.makeRequest<{ unreadCount: number }>('GET', '/notifications/unread-count', undefined, {
|
|
1111
1143
|
cache: false, // Don't cache unread count - always get fresh data
|
|
1112
1144
|
});
|
|
1113
|
-
return res.
|
|
1145
|
+
return res.unreadCount;
|
|
1114
1146
|
} catch (error) {
|
|
1115
1147
|
throw this.handleError(error);
|
|
1116
1148
|
}
|
|
@@ -1123,20 +1155,29 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1123
1155
|
* from an Oxy service about the recipient's own account, with the recipient
|
|
1124
1156
|
* as actor and their profile as the entity.
|
|
1125
1157
|
*/
|
|
1126
|
-
async createNotification(data: CreateOxyNotificationRequest
|
|
1158
|
+
async createNotification(data: CreateOxyNotificationRequest): Promise<Notification> {
|
|
1127
1159
|
try {
|
|
1128
|
-
|
|
1160
|
+
const res = await this.makeRequest<{ notification: Notification }>('POST', '/notifications', data, { cache: false });
|
|
1161
|
+
return res.notification;
|
|
1129
1162
|
} catch (error) {
|
|
1130
1163
|
throw this.handleError(error);
|
|
1131
1164
|
}
|
|
1132
1165
|
}
|
|
1133
1166
|
|
|
1134
1167
|
/**
|
|
1135
|
-
* Mark
|
|
1168
|
+
* Mark one of the signed-in user's notifications read and return it as
|
|
1169
|
+
* stored. Scoped to the recipient server-side, so it is also the
|
|
1170
|
+
* authoritative read of a notification by id: a foreign or unknown id 404s.
|
|
1136
1171
|
*/
|
|
1137
|
-
async markNotificationAsRead(notificationId: string): Promise<
|
|
1172
|
+
async markNotificationAsRead(notificationId: string): Promise<Notification> {
|
|
1138
1173
|
try {
|
|
1139
|
-
await this.makeRequest
|
|
1174
|
+
const res = await this.makeRequest<{ notification: Notification }>(
|
|
1175
|
+
'PUT',
|
|
1176
|
+
`/notifications/${encodeURIComponent(notificationId)}/read`,
|
|
1177
|
+
undefined,
|
|
1178
|
+
{ cache: false },
|
|
1179
|
+
);
|
|
1180
|
+
return res.notification;
|
|
1140
1181
|
} catch (error) {
|
|
1141
1182
|
throw this.handleError(error);
|
|
1142
1183
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linking Commons to a passkey account (ADR 0029 D3): what each device sends.
|
|
3
|
+
* `makeRequest` is stubbed; the proof Commons signs is verified for real.
|
|
4
|
+
*/
|
|
5
|
+
import { buildIdentityProofMessage } from '@oxy.so/contracts';
|
|
6
|
+
import { deriveSecp256k1PublicKey } from '@oxy.so/protocol/secp256k1';
|
|
7
|
+
import { verifySignature } from '@oxy.so/protocol';
|
|
8
|
+
import { OxyServices } from '../../OxyServices';
|
|
9
|
+
import { KeyManager } from '../../crypto/keyManager';
|
|
10
|
+
import { deriveIdentityLinkCode } from '../../crypto/identityLink';
|
|
11
|
+
|
|
12
|
+
const LINK_ID = 'ab'.repeat(16);
|
|
13
|
+
const CHALLENGE = 'cd'.repeat(32);
|
|
14
|
+
const PRIVATE_KEY = '3a'.repeat(32);
|
|
15
|
+
const PUBLIC_KEY = deriveSecp256k1PublicKey(PRIVATE_KEY).toLowerCase();
|
|
16
|
+
const STATE = {
|
|
17
|
+
status: 'pending',
|
|
18
|
+
userId: 'user-1',
|
|
19
|
+
username: 'ada',
|
|
20
|
+
publicKey: null,
|
|
21
|
+
audience: 'oxy-api/identity',
|
|
22
|
+
expiresAt: 1_900_000_000_000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('linking Commons', () => {
|
|
26
|
+
let oxy: OxyServices;
|
|
27
|
+
let makeRequest: jest.SpyInstance;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
31
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => jest.restoreAllMocks());
|
|
34
|
+
|
|
35
|
+
it('Commons signs the link proof for the account the request names, with its own key', async () => {
|
|
36
|
+
jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(PRIVATE_KEY);
|
|
37
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(PUBLIC_KEY.toUpperCase());
|
|
38
|
+
makeRequest.mockResolvedValueOnce(STATE).mockResolvedValueOnce({ status: 'signed' });
|
|
39
|
+
|
|
40
|
+
const result = await oxy.signIdentityLink(LINK_ID, CHALLENGE);
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({ publicKey: PUBLIC_KEY, code: deriveIdentityLinkCode(LINK_ID, PUBLIC_KEY), username: 'ada' });
|
|
43
|
+
expect(makeRequest).toHaveBeenNthCalledWith(1, 'GET', `/identity/link/${LINK_ID}`, undefined, { cache: false, skipAuth: true });
|
|
44
|
+
const [, path, body, options] = makeRequest.mock.calls[1] as [string, string, { publicKey: string; proof: { signature: string; challenge: string; expiresAt: number } }, unknown];
|
|
45
|
+
expect(path).toBe(`/identity/link/${LINK_ID}/proof`);
|
|
46
|
+
expect(options).toEqual({ cache: false, skipAuth: true });
|
|
47
|
+
expect(body.publicKey).toBe(PUBLIC_KEY);
|
|
48
|
+
expect(body.proof).toMatchObject({ v: 2, challenge: CHALLENGE, expiresAt: STATE.expiresAt });
|
|
49
|
+
const message = buildIdentityProofMessage({
|
|
50
|
+
action: 'link_identity',
|
|
51
|
+
subject: 'user-1',
|
|
52
|
+
actor: 'user-1',
|
|
53
|
+
rootPublicKey: PUBLIC_KEY,
|
|
54
|
+
payloadDigest: null,
|
|
55
|
+
expectedRevision: null,
|
|
56
|
+
audience: STATE.audience,
|
|
57
|
+
challenge: CHALLENGE,
|
|
58
|
+
expiresAt: STATE.expiresAt,
|
|
59
|
+
});
|
|
60
|
+
expect(await verifySignature(message, body.proof.signature, PUBLIC_KEY)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('refuses to sign without an identity on this device', async () => {
|
|
64
|
+
jest.spyOn(KeyManager, 'getPrivateKey').mockResolvedValue(null);
|
|
65
|
+
jest.spyOn(KeyManager, 'getPublicKey').mockResolvedValue(null);
|
|
66
|
+
await expect(oxy.signIdentityLink(LINK_ID, CHALLENGE)).rejects.toThrow('No identity');
|
|
67
|
+
expect(makeRequest).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('the web opens, asks for the assertion options, completes and cancels', async () => {
|
|
71
|
+
makeRequest
|
|
72
|
+
.mockResolvedValueOnce({ linkId: LINK_ID, challenge: CHALLENGE, expiresAt: 1, qrPayload: `oxycommons://link?id=${LINK_ID}&c=${CHALLENGE}` })
|
|
73
|
+
.mockResolvedValueOnce({ challenge: 'x' })
|
|
74
|
+
.mockResolvedValueOnce({ success: true })
|
|
75
|
+
.mockResolvedValueOnce({});
|
|
76
|
+
|
|
77
|
+
await expect(oxy.createIdentityLink()).resolves.toMatchObject({ linkId: LINK_ID });
|
|
78
|
+
await oxy.getIdentityLinkAssertionOptions(LINK_ID, CHALLENGE);
|
|
79
|
+
await oxy.completeIdentityLink(LINK_ID, { id: 'cred' });
|
|
80
|
+
await oxy.cancelIdentityLink(LINK_ID);
|
|
81
|
+
|
|
82
|
+
expect(makeRequest.mock.calls.map(([method, path, body]) => [method, path, body])).toEqual([
|
|
83
|
+
['POST', '/identity/link', undefined],
|
|
84
|
+
['POST', `/identity/link/${LINK_ID}/options`, { challenge: CHALLENGE }],
|
|
85
|
+
['POST', `/identity/link/${LINK_ID}/complete`, { assertion: { id: 'cred' } }],
|
|
86
|
+
['DELETE', `/identity/link/${LINK_ID}`, undefined],
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification READ methods against the wire bodies `GET /notifications`,
|
|
3
|
+
* `GET /notifications/unread-count` and `PUT /notifications/:id/read` actually
|
|
4
|
+
* send (`packages/api/src/controllers/notification.controller.ts`), driven
|
|
5
|
+
* through the real HTTP stack with only `fetch` stubbed.
|
|
6
|
+
*
|
|
7
|
+
* They used to be typed against a shape the API never sent: `getNotifications`
|
|
8
|
+
* claimed an array while the server answers a page object, `getUnreadCount`
|
|
9
|
+
* read `count` from a body that carries `unreadCount` (so it always returned
|
|
10
|
+
* `undefined`), and `markNotificationAsRead` dropped the stored notification the
|
|
11
|
+
* server returns — the one authoritative read of a notification by id.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
|
|
16
|
+
const SYSTEM_NOTIFICATION = {
|
|
17
|
+
_id: '0192f0c4-7b1e-7000-8000-000000000001',
|
|
18
|
+
recipientId: 'user-1',
|
|
19
|
+
actorId: 'user-1',
|
|
20
|
+
type: 'system',
|
|
21
|
+
entityId: 'job-42',
|
|
22
|
+
entityType: 'app',
|
|
23
|
+
title: 'Your Mastodon account moved',
|
|
24
|
+
message: '312 followers came with you.',
|
|
25
|
+
url: 'https://move.oxy.so/jobs/job-42',
|
|
26
|
+
read: false,
|
|
27
|
+
createdAt: '2026-09-26T10:00:00.000Z',
|
|
28
|
+
updatedAt: '2026-09-26T10:00:00.000Z',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('OxyServices notification reads', () => {
|
|
32
|
+
const originalFetch = globalThis.fetch;
|
|
33
|
+
let fetchMock: jest.Mock;
|
|
34
|
+
|
|
35
|
+
function answer(body: unknown): void {
|
|
36
|
+
fetchMock.mockResolvedValueOnce(
|
|
37
|
+
new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let oxy: OxyServices;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
fetchMock = jest.fn();
|
|
44
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
45
|
+
oxy = new OxyServices({ baseURL: 'http://api.test.invalid' });
|
|
46
|
+
oxy.setTokens('test-token');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
globalThis.fetch = originalFetch;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('getNotifications returns the page the API sends, with the paging it asked for', async () => {
|
|
54
|
+
const page = { notifications: [SYSTEM_NOTIFICATION], unreadCount: 1, hasMore: false, page: 2, limit: 10 };
|
|
55
|
+
answer({ data: page });
|
|
56
|
+
|
|
57
|
+
await expect(oxy.getNotifications({ page: 2, limit: 10 })).resolves.toEqual(page);
|
|
58
|
+
|
|
59
|
+
const url = new URL(String(fetchMock.mock.calls[0][0]));
|
|
60
|
+
expect(url.pathname).toBe('/notifications');
|
|
61
|
+
expect(url.searchParams.get('page')).toBe('2');
|
|
62
|
+
expect(url.searchParams.get('limit')).toBe('10');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('getUnreadCount reads `unreadCount`', async () => {
|
|
66
|
+
answer({ data: { unreadCount: 3 } });
|
|
67
|
+
|
|
68
|
+
await expect(oxy.getUnreadCount()).resolves.toBe(3);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('markNotificationAsRead returns the stored notification', async () => {
|
|
72
|
+
answer({ data: { notification: { ...SYSTEM_NOTIFICATION, read: true } } });
|
|
73
|
+
|
|
74
|
+
await expect(oxy.markNotificationAsRead(SYSTEM_NOTIFICATION._id)).resolves.toEqual({
|
|
75
|
+
...SYSTEM_NOTIFICATION,
|
|
76
|
+
read: true,
|
|
77
|
+
});
|
|
78
|
+
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
|
79
|
+
`http://api.test.invalid/notifications/${SYSTEM_NOTIFICATION._id}/read`,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -86,7 +86,7 @@ describe('pre-session public endpoints use skipAuth', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
it('webauthnRegisterOptions skips auth preflight on signup (username provided)', async () => {
|
|
89
|
-
await oxy.webauthnRegisterOptions('alice');
|
|
89
|
+
await oxy.webauthnRegisterOptions({ username: 'alice' });
|
|
90
90
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
91
91
|
'POST',
|
|
92
92
|
'/auth/webauthn/register/options',
|