@oxy.so/core 1.13.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 +105 -85
- package/dist/cjs/i18n/locales/ca-ES.json +107 -87
- package/dist/cjs/i18n/locales/de-DE.json +105 -85
- package/dist/cjs/i18n/locales/en-US.json +105 -164
- package/dist/cjs/i18n/locales/es-ES.json +104 -163
- package/dist/cjs/i18n/locales/fr-FR.json +105 -85
- package/dist/cjs/i18n/locales/it-IT.json +105 -85
- package/dist/cjs/i18n/locales/ja-JP.json +105 -85
- package/dist/cjs/i18n/locales/ko-KR.json +105 -85
- package/dist/cjs/i18n/locales/locales/ar-SA.json +105 -85
- package/dist/cjs/i18n/locales/locales/ca-ES.json +107 -87
- package/dist/cjs/i18n/locales/locales/de-DE.json +105 -85
- package/dist/cjs/i18n/locales/locales/en-US.json +105 -164
- package/dist/cjs/i18n/locales/locales/es-ES.json +104 -163
- package/dist/cjs/i18n/locales/locales/fr-FR.json +105 -85
- package/dist/cjs/i18n/locales/locales/it-IT.json +105 -85
- package/dist/cjs/i18n/locales/locales/ja-JP.json +105 -85
- package/dist/cjs/i18n/locales/locales/ko-KR.json +105 -85
- package/dist/cjs/i18n/locales/locales/pt-PT.json +105 -85
- package/dist/cjs/i18n/locales/locales/zh-CN.json +106 -86
- package/dist/cjs/i18n/locales/pt-PT.json +105 -85
- package/dist/cjs/i18n/locales/zh-CN.json +106 -86
- package/dist/cjs/index.js +10 -43
- 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/server/securityHeaders.js +19 -1
- package/dist/cjs/session/accountDialogController.js +37 -142
- package/dist/cjs/utils/authWebUrl.js +6 -6
- package/dist/cjs/utils/oauthPkce.js +5 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/crypto/identityLink.js +15 -0
- package/dist/esm/i18n/locales/ar-SA.json +105 -85
- package/dist/esm/i18n/locales/ca-ES.json +107 -87
- package/dist/esm/i18n/locales/de-DE.json +105 -85
- package/dist/esm/i18n/locales/en-US.json +105 -164
- package/dist/esm/i18n/locales/es-ES.json +104 -163
- package/dist/esm/i18n/locales/fr-FR.json +105 -85
- package/dist/esm/i18n/locales/it-IT.json +105 -85
- package/dist/esm/i18n/locales/ja-JP.json +105 -85
- package/dist/esm/i18n/locales/ko-KR.json +105 -85
- package/dist/esm/i18n/locales/locales/ar-SA.json +105 -85
- package/dist/esm/i18n/locales/locales/ca-ES.json +107 -87
- package/dist/esm/i18n/locales/locales/de-DE.json +105 -85
- package/dist/esm/i18n/locales/locales/en-US.json +105 -164
- package/dist/esm/i18n/locales/locales/es-ES.json +104 -163
- package/dist/esm/i18n/locales/locales/fr-FR.json +105 -85
- package/dist/esm/i18n/locales/locales/it-IT.json +105 -85
- package/dist/esm/i18n/locales/locales/ja-JP.json +105 -85
- package/dist/esm/i18n/locales/locales/ko-KR.json +105 -85
- package/dist/esm/i18n/locales/locales/pt-PT.json +105 -85
- package/dist/esm/i18n/locales/locales/zh-CN.json +106 -86
- package/dist/esm/i18n/locales/pt-PT.json +105 -85
- package/dist/esm/i18n/locales/zh-CN.json +106 -86
- package/dist/esm/index.js +5 -5
- 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/server/securityHeaders.js +19 -1
- package/dist/esm/session/accountDialogController.js +37 -142
- package/dist/esm/utils/authWebUrl.js +5 -5
- package/dist/esm/utils/oauthPkce.js +5 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/crypto/identityLink.d.ts +8 -0
- package/dist/types/index.d.ts +5 -8
- 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 +9 -12
- package/dist/types/session/accountDialogController.d.ts +29 -98
- package/dist/types/utils/authWebUrl.d.ts +5 -5
- package/dist/types/utils/oauthPkce.d.ts +10 -0
- 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 +105 -85
- package/src/i18n/locales/ca-ES.json +107 -87
- package/src/i18n/locales/de-DE.json +105 -85
- package/src/i18n/locales/en-US.json +105 -164
- package/src/i18n/locales/es-ES.json +104 -163
- package/src/i18n/locales/fr-FR.json +105 -85
- package/src/i18n/locales/it-IT.json +105 -85
- package/src/i18n/locales/ja-JP.json +105 -85
- package/src/i18n/locales/ko-KR.json +105 -85
- package/src/i18n/locales/pt-PT.json +105 -85
- package/src/i18n/locales/zh-CN.json +106 -86
- package/src/index.ts +8 -51
- 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/__tests__/securityHeaders.test.ts +17 -0
- package/src/server/securityHeaders.ts +29 -1
- package/src/session/__tests__/accountDialogController.test.ts +42 -184
- package/src/session/accountDialogController.ts +50 -185
- package/src/utils/__tests__/oauthPkce.test.ts +7 -0
- package/src/utils/authWebUrl.ts +5 -5
- package/src/utils/oauthPkce.ts +16 -1
- 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
|
@@ -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',
|
|
@@ -42,7 +42,7 @@ describe('webauthnRegisterOptions', () => {
|
|
|
42
42
|
it('POSTs to /auth/webauthn/register/options with the username', async () => {
|
|
43
43
|
const options = { challenge: 'c', rp: { id: 'oxy.so' } };
|
|
44
44
|
makeRequest.mockResolvedValueOnce(options);
|
|
45
|
-
const result = await oxy.webauthnRegisterOptions('alice');
|
|
45
|
+
const result = await oxy.webauthnRegisterOptions({ username: 'alice' });
|
|
46
46
|
expect(result).toBe(options);
|
|
47
47
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
48
48
|
'POST',
|
|
@@ -52,6 +52,17 @@ describe('webauthnRegisterOptions', () => {
|
|
|
52
52
|
);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
it('sends a recovery ticket signed out', async () => {
|
|
56
|
+
makeRequest.mockResolvedValueOnce({ challenge: 'c' });
|
|
57
|
+
await oxy.webauthnRegisterOptions({ recoveryTicket: 't'.repeat(43) });
|
|
58
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
59
|
+
'POST',
|
|
60
|
+
'/auth/webauthn/register/options',
|
|
61
|
+
{ recoveryTicket: 't'.repeat(43) },
|
|
62
|
+
{ cache: false, skipAuth: true },
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
55
66
|
it('omits username from the body when not provided (link flow)', async () => {
|
|
56
67
|
makeRequest.mockResolvedValueOnce({ challenge: 'c' });
|
|
57
68
|
await oxy.webauthnRegisterOptions();
|
|
@@ -64,6 +75,48 @@ describe('webauthnRegisterOptions', () => {
|
|
|
64
75
|
});
|
|
65
76
|
});
|
|
66
77
|
|
|
78
|
+
describe('email verification', () => {
|
|
79
|
+
let oxy: OxyServices;
|
|
80
|
+
let makeRequest: jest.SpyInstance;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
84
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
85
|
+
});
|
|
86
|
+
afterEach(() => jest.restoreAllMocks());
|
|
87
|
+
|
|
88
|
+
it('starts a verification signed out and parses its id', async () => {
|
|
89
|
+
makeRequest.mockResolvedValueOnce({ verificationId: 'v-1', expiresAt: 1_900_000_000_000 });
|
|
90
|
+
await expect(oxy.startEmailVerification({ purpose: 'signup', email: 'ada@example.com' })).resolves.toEqual({
|
|
91
|
+
verificationId: 'v-1',
|
|
92
|
+
expiresAt: 1_900_000_000_000,
|
|
93
|
+
});
|
|
94
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
95
|
+
'POST',
|
|
96
|
+
'/auth/email/verify/start',
|
|
97
|
+
{ purpose: 'signup', email: 'ada@example.com' },
|
|
98
|
+
{ cache: false, skipAuth: true },
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('confirms a code into a ticket', async () => {
|
|
103
|
+
const confirmed = { ticket: 'T'.repeat(43), expiresAt: 1_900_000_000_000, username: 'ada' };
|
|
104
|
+
makeRequest.mockResolvedValueOnce(confirmed);
|
|
105
|
+
await expect(oxy.confirmEmailVerification('v-1', '123456')).resolves.toEqual(confirmed);
|
|
106
|
+
expect(makeRequest).toHaveBeenCalledWith(
|
|
107
|
+
'POST',
|
|
108
|
+
'/auth/email/verify/confirm',
|
|
109
|
+
{ verificationId: 'v-1', code: '123456' },
|
|
110
|
+
{ cache: false, skipAuth: true },
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('refuses a malformed answer rather than handing on a ticket it cannot read', async () => {
|
|
115
|
+
makeRequest.mockResolvedValueOnce({ ticket: 'short', expiresAt: 1 });
|
|
116
|
+
await expect(oxy.confirmEmailVerification('v-1', '123456')).rejects.toThrow();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
67
120
|
describe('webauthnLoginOptions', () => {
|
|
68
121
|
let oxy: OxyServices;
|
|
69
122
|
let makeRequest: jest.SpyInstance;
|
|
@@ -203,3 +256,24 @@ describe('webauthnLoginVerify', () => {
|
|
|
203
256
|
expect(setTokens).not.toHaveBeenCalled();
|
|
204
257
|
});
|
|
205
258
|
});
|
|
259
|
+
|
|
260
|
+
describe('deleting a passkey account', () => {
|
|
261
|
+
let oxy: OxyServices;
|
|
262
|
+
let makeRequest: jest.SpyInstance;
|
|
263
|
+
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
266
|
+
makeRequest = jest.spyOn(oxy, 'makeRequest');
|
|
267
|
+
});
|
|
268
|
+
afterEach(() => jest.restoreAllMocks());
|
|
269
|
+
|
|
270
|
+
it('asks for the options, then deletes with the assertion and the confirmation', async () => {
|
|
271
|
+
makeRequest.mockResolvedValueOnce({ challenge: 'c' }).mockResolvedValueOnce({ message: 'deleted' });
|
|
272
|
+
|
|
273
|
+
await expect(oxy.getAccountDeletionOptions()).resolves.toEqual({ challenge: 'c' });
|
|
274
|
+
await expect(oxy.deleteAccountWithPasskey('ada', GET_RESPONSE)).resolves.toEqual({ message: 'deleted' });
|
|
275
|
+
|
|
276
|
+
expect(makeRequest).toHaveBeenNthCalledWith(1, 'POST', '/users/me/delete/options', undefined, { cache: false });
|
|
277
|
+
expect(makeRequest).toHaveBeenNthCalledWith(2, 'DELETE', '/users/me', { confirmText: 'ada', assertion: GET_RESPONSE }, { cache: false });
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/models/interfaces.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type {
|
|
|
4
4
|
UserNameResponse,
|
|
5
5
|
UserRelationship,
|
|
6
6
|
ThemePreference,
|
|
7
|
+
OxyNotificationEntityType,
|
|
8
|
+
OxyNotificationType,
|
|
7
9
|
} from '@oxy.so/contracts';
|
|
8
10
|
|
|
9
11
|
export interface OxyConfig {
|
|
@@ -267,25 +269,57 @@ export interface LoginResponse {
|
|
|
267
269
|
message?: string;
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
/**
|
|
273
|
+
* The actor of a notification as `GET /notifications` populates it — the raw
|
|
274
|
+
* stored name, not a composed display name.
|
|
275
|
+
*/
|
|
276
|
+
export interface NotificationActor {
|
|
277
|
+
_id: string;
|
|
278
|
+
username?: string;
|
|
279
|
+
name?: { first: string; last: string };
|
|
280
|
+
avatar?: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* One Oxy notification exactly as the API serializes it
|
|
285
|
+
* (`packages/api/src/controllers/notification.controller.ts`). `_id` — not
|
|
286
|
+
* `id` — is the key every notification endpoint addresses it by.
|
|
287
|
+
*/
|
|
270
288
|
export interface Notification {
|
|
271
|
-
|
|
272
|
-
|
|
289
|
+
_id: string;
|
|
290
|
+
recipientId: string;
|
|
291
|
+
/** Populated on a list read; the bare id on a create or a mark-read. */
|
|
292
|
+
actorId: string | NotificationActor;
|
|
273
293
|
/** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
|
|
274
|
-
type
|
|
294
|
+
type: OxyNotificationType;
|
|
275
295
|
/**
|
|
276
|
-
* `
|
|
277
|
-
*
|
|
278
|
-
|
|
296
|
+
* What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
|
|
297
|
+
* in the notifying application's namespace (`system` notifications only).
|
|
298
|
+
*/
|
|
299
|
+
entityType: OxyNotificationEntityType;
|
|
300
|
+
entityId: string;
|
|
301
|
+
/**
|
|
302
|
+
* `system` notifications only: the text an Oxy service sent about the
|
|
303
|
+
* recipient's own account. Render these instead of composing a sentence from
|
|
304
|
+
* actor + entity.
|
|
279
305
|
*/
|
|
280
306
|
title?: string;
|
|
307
|
+
message?: string;
|
|
281
308
|
/** `system` notifications only: an optional deep link (https or the sending app's scheme). */
|
|
282
309
|
url?: string;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
310
|
+
read: boolean;
|
|
311
|
+
createdAt: string;
|
|
312
|
+
updatedAt: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One page of `GET /notifications`, newest first. */
|
|
316
|
+
export interface NotificationPage {
|
|
317
|
+
notifications: Notification[];
|
|
318
|
+
/** Unread notifications across ALL pages. */
|
|
319
|
+
unreadCount: number;
|
|
320
|
+
hasMore: boolean;
|
|
321
|
+
page: number;
|
|
322
|
+
limit: number;
|
|
289
323
|
}
|
|
290
324
|
|
|
291
325
|
export interface Wallet {
|
|
@@ -218,6 +218,23 @@ describe('@oxy.so/core/server buildOxyPagesHeaders on a sensitive origin (ADR 00
|
|
|
218
218
|
);
|
|
219
219
|
expect(buildOxyPagesHeaders()).not.toContain('Cache-Control');
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
it('serves the rest of the holder policy there, and only there (ADR 0028)', () => {
|
|
223
|
+
const sensitive = buildOxyPagesHeaders({ sensitive: true });
|
|
224
|
+
const normal = buildOxyPagesHeaders();
|
|
225
|
+
|
|
226
|
+
expect(sensitive).toContain("base-uri 'none'");
|
|
227
|
+
expect(sensitive).toContain("form-action 'none'");
|
|
228
|
+
expect(sensitive).toContain('Referrer-Policy: no-referrer');
|
|
229
|
+
expect(sensitive).toContain('Permissions-Policy: publickey-credentials-get=(self), publickey-credentials-create=(self)');
|
|
230
|
+
expect(sensitive).toContain('Cross-Origin-Resource-Policy: same-origin');
|
|
231
|
+
// The third-party OAuth popup reports back to its opener: COOP would sever it.
|
|
232
|
+
expect(sensitive).not.toContain('Cross-Origin-Opener-Policy');
|
|
233
|
+
|
|
234
|
+
expect(normal).toContain('Referrer-Policy: strict-origin-when-cross-origin');
|
|
235
|
+
expect(normal).not.toContain('Permissions-Policy');
|
|
236
|
+
expect(normal).not.toContain("base-uri 'none'");
|
|
237
|
+
});
|
|
221
238
|
});
|
|
222
239
|
|
|
223
240
|
describe('@oxy.so/core/server extractInlineScripts', () => {
|
|
@@ -371,6 +371,15 @@ export interface OxyPagesHeadersOptions {
|
|
|
371
371
|
* injecting the beacon at all — otherwise every page load logs a CSP
|
|
372
372
|
* violation for a script we never asked for. The rest of the value is Pages'
|
|
373
373
|
* own default, so caching is unchanged.
|
|
374
|
+
*
|
|
375
|
+
* And, because this origin is where a web account is created, recovered and
|
|
376
|
+
* deleted and every Oxy passkey is asserted (ADR 0028, ADR 0029) — the rest
|
|
377
|
+
* of the holder policy: no `<base>` and no form posts (`base-uri` /
|
|
378
|
+
* `form-action 'none'`), no Referer to anyone, passkeys for this origin only
|
|
379
|
+
* with every other powerful feature off (`Permissions-Policy`), and its
|
|
380
|
+
* responses readable by no other origin (`Cross-Origin-Resource-Policy`).
|
|
381
|
+
* Never `Cross-Origin-Opener-Policy`: the third-party OAuth popup reports
|
|
382
|
+
* its code to the app that opened it, and COOP severs `window.opener`.
|
|
374
383
|
*/
|
|
375
384
|
sensitive?: boolean;
|
|
376
385
|
/**
|
|
@@ -402,6 +411,21 @@ export interface OxyPagesHeadersOptions {
|
|
|
402
411
|
* react-native-web's runtime stylesheet, and a style hash would silently switch
|
|
403
412
|
* that off and render every Oxy web app unstyled.)
|
|
404
413
|
*/
|
|
414
|
+
/** Passkeys for this origin itself; every other powerful feature off. */
|
|
415
|
+
const SENSITIVE_PERMISSIONS_POLICY = [
|
|
416
|
+
'publickey-credentials-get=(self)',
|
|
417
|
+
'publickey-credentials-create=(self)',
|
|
418
|
+
'camera=()',
|
|
419
|
+
'microphone=()',
|
|
420
|
+
'geolocation=()',
|
|
421
|
+
'payment=()',
|
|
422
|
+
'usb=()',
|
|
423
|
+
'serial=()',
|
|
424
|
+
'hid=()',
|
|
425
|
+
'bluetooth=()',
|
|
426
|
+
'display-capture=()',
|
|
427
|
+
].join(', ');
|
|
428
|
+
|
|
405
429
|
export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): string {
|
|
406
430
|
const hashes = [
|
|
407
431
|
...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
|
|
@@ -422,6 +446,8 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
|
|
|
422
446
|
for (const name of ['script-src', 'connect-src']) {
|
|
423
447
|
directives[name] = (directives[name] ?? []).filter((source) => !beacon.has(source));
|
|
424
448
|
}
|
|
449
|
+
directives['base-uri'] = ["'none'"];
|
|
450
|
+
directives['form-action'] = ["'none'"];
|
|
425
451
|
}
|
|
426
452
|
const csp = formatOxyCspPolicy(directives);
|
|
427
453
|
const lines = [
|
|
@@ -429,13 +455,15 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
|
|
|
429
455
|
` Content-Security-Policy: ${csp}`,
|
|
430
456
|
' X-Frame-Options: DENY',
|
|
431
457
|
' X-Content-Type-Options: nosniff',
|
|
432
|
-
|
|
458
|
+
` Referrer-Policy: ${options.sensitive ? 'no-referrer' : 'strict-origin-when-cross-origin'}`,
|
|
433
459
|
];
|
|
434
460
|
if (options.hsts !== false) {
|
|
435
461
|
lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
|
436
462
|
}
|
|
437
463
|
if (options.sensitive) {
|
|
438
464
|
lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
|
|
465
|
+
lines.push(` Permissions-Policy: ${SENSITIVE_PERMISSIONS_POLICY}`);
|
|
466
|
+
lines.push(' Cross-Origin-Resource-Policy: same-origin');
|
|
439
467
|
}
|
|
440
468
|
lines.push('');
|
|
441
469
|
return lines.join('\n');
|