@oxy.so/core 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/crypto/deviceBackup.js +75 -0
  3. package/dist/cjs/crypto/keyManager.js +285 -12
  4. package/dist/cjs/mixins/OxyServices.linkedAccounts.js +79 -0
  5. package/dist/cjs/mixins/OxyServices.user.js +5 -1
  6. package/dist/cjs/mixins/index.js +4 -0
  7. package/dist/cjs/server/securityHeaders.js +3 -0
  8. package/dist/cjs/session/accountDialogController.js +41 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/crypto/deviceBackup.js +70 -0
  11. package/dist/esm/crypto/keyManager.js +252 -12
  12. package/dist/esm/mixins/OxyServices.linkedAccounts.js +76 -0
  13. package/dist/esm/mixins/OxyServices.user.js +5 -1
  14. package/dist/esm/mixins/index.js +4 -0
  15. package/dist/esm/server/securityHeaders.js +3 -0
  16. package/dist/esm/session/accountDialogController.js +41 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/crypto/deviceBackup.d.ts +60 -0
  19. package/dist/types/crypto/keyManager.d.ts +79 -12
  20. package/dist/types/index.d.ts +2 -1
  21. package/dist/types/mixins/OxyServices.linkedAccounts.d.ts +107 -0
  22. package/dist/types/mixins/OxyServices.user.d.ts +7 -2
  23. package/dist/types/mixins/index.d.ts +2 -1
  24. package/dist/types/models/interfaces.d.ts +30 -0
  25. package/dist/types/server/securityHeaders.d.ts +5 -0
  26. package/dist/types/session/accountDialogController.d.ts +45 -0
  27. package/package.json +2 -2
  28. package/src/crypto/__tests__/keyManager.deviceBackup.test.ts +310 -0
  29. package/src/crypto/deviceBackup.ts +108 -0
  30. package/src/crypto/keyManager.ts +278 -13
  31. package/src/index.ts +2 -0
  32. package/src/mixins/OxyServices.linkedAccounts.ts +129 -0
  33. package/src/mixins/OxyServices.user.ts +7 -2
  34. package/src/mixins/__tests__/linkedAccounts.test.ts +109 -0
  35. package/src/mixins/index.ts +6 -0
  36. package/src/models/interfaces.ts +30 -1
  37. package/src/server/__tests__/securityHeaders.test.ts +7 -0
  38. package/src/server/securityHeaders.ts +8 -0
  39. package/src/session/__tests__/accountDialogController.test.ts +173 -0
  40. package/src/session/__tests__/accountDialogShape.test.ts +1 -0
  41. package/src/session/accountDialogController.ts +65 -0
@@ -115,6 +115,13 @@ export interface User {
115
115
  * lists, profile search) — the two are separate fields on the User document.
116
116
  */
117
117
  description?: string;
118
+ /**
119
+ * ActivityPub actor URIs this account is also known as — its live,
120
+ * OAuth-proven linked Mastodon-API accounts. Emitted by
121
+ * `GET /profiles/username/:username`; a relying app passes it to the shared
122
+ * actor builder (`@oxy.so/federation`) so its actor publishes it.
123
+ */
124
+ alsoKnownAs?: string[];
118
125
  phone?: string;
119
126
  address?: string;
120
127
  /** Legacy free-text birthday, kept for backward compatibility. Prefer `dateOfBirth`. */
@@ -263,7 +270,22 @@ export interface LoginResponse {
263
270
  export interface Notification {
264
271
  id: string;
265
272
  message: string;
266
- // Add other notification fields as needed
273
+ /** One of `OXY_NOTIFICATION_TYPES` (`@oxy.so/contracts`). */
274
+ type?: string;
275
+ /**
276
+ * `system` notifications only (with `message`): the text an Oxy service sent
277
+ * about the recipient's own account. Render these two instead of composing a
278
+ * sentence from actor + entity.
279
+ */
280
+ title?: string;
281
+ /** `system` notifications only: an optional deep link (https or the sending app's scheme). */
282
+ url?: string;
283
+ /**
284
+ * What `entityId` names: `post` | `reply` | `profile`, or `app` — an opaque id
285
+ * in the notifying application's namespace (`system` notifications only).
286
+ */
287
+ entityType?: string;
288
+ entityId?: string;
267
289
  }
268
290
 
269
291
  export interface Wallet {
@@ -666,6 +688,13 @@ export interface ServiceAssetMetadata {
666
688
  * play. Treat absence as "no adaptive stream, play the progressive original".
667
689
  */
668
690
  hlsReadyAt?: string;
691
+ /**
692
+ * The Oxy user who owns the file (`null` for a system-owned file such as the
693
+ * federated media cache). PRESENT ONLY for Oxy's own applications (service
694
+ * tokens with `tier: 'internal'`), so a first-party service can check that an
695
+ * asset a user attached is really that user's. Absent for every other caller.
696
+ */
697
+ ownerUserId?: string | null;
669
698
  }
670
699
 
671
700
  /**
@@ -211,6 +211,13 @@ describe('@oxy.so/core/server buildOxyPagesHeaders on a sensitive origin (ADR 00
211
211
  expect(sensitive).toContain('https://api.oxy.so');
212
212
  expect(sensitive).toContain("frame-ancestors 'none'");
213
213
  });
214
+
215
+ it('sends no-transform so the edge never injects the beacon, and only there', () => {
216
+ expect(buildOxyPagesHeaders({ sensitive: true })).toContain(
217
+ 'Cache-Control: public, max-age=0, must-revalidate, no-transform',
218
+ );
219
+ expect(buildOxyPagesHeaders()).not.toContain('Cache-Control');
220
+ });
214
221
  });
215
222
 
216
223
  describe('@oxy.so/core/server extractInlineScripts', () => {
@@ -366,6 +366,11 @@ export interface OxyPagesHeadersOptions {
366
366
  * the app — is REMOVED from `script-src` and `connect-src`, so third-party
367
367
  * measurement cannot run there even when the zone has Web Analytics enabled.
368
368
  * A structural block in the policy the page is served with, not a build flag.
369
+ *
370
+ * It also sends `Cache-Control: no-transform`, which stops the edge from
371
+ * injecting the beacon at all — otherwise every page load logs a CSP
372
+ * violation for a script we never asked for. The rest of the value is Pages'
373
+ * own default, so caching is unchanged.
369
374
  */
370
375
  sensitive?: boolean;
371
376
  /**
@@ -429,6 +434,9 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
429
434
  if (options.hsts !== false) {
430
435
  lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
431
436
  }
437
+ if (options.sensitive) {
438
+ lines.push(' Cache-Control: public, max-age=0, must-revalidate, no-transform');
439
+ }
432
440
  lines.push('');
433
441
  return lines.join('\n');
434
442
  }
@@ -2469,3 +2469,176 @@ it('createAccountDialogController returns an AccountDialogController instance',
2469
2469
  // Ensure the exported type surface is reachable at compile time for binders.
2470
2470
  const _typecheck: MinimalUserData | null = null;
2471
2471
  void _typecheck;
2472
+
2473
+ describe('AccountDialogController — choosing a device account row (OxyHQ/oxy#1375 items 20 and 21)', () => {
2474
+ /**
2475
+ * The device's directory as Commons leaves it: `qa` on the device and active,
2476
+ * plus (when `withOrg`) an organization `qa` also operates.
2477
+ */
2478
+ const deviceDirectory = (activeContextId: string | null, withOrg = false) => ({
2479
+ deviceId: 'device-1',
2480
+ revision: 4,
2481
+ activeContextId,
2482
+ updatedAt: 1_720_000_000_000,
2483
+ principals: [
2484
+ {
2485
+ id: 'p-qa',
2486
+ userId: 'qa',
2487
+ authuser: 0,
2488
+ user: { id: 'qa', username: 'qatest0925' },
2489
+ contexts: [
2490
+ {
2491
+ id: 'ctx-qa',
2492
+ accountId: 'qa',
2493
+ kind: 'personal' as const,
2494
+ relationship: 'self' as const,
2495
+ account: { id: 'qa', username: 'qatest0925' },
2496
+ onDevice: true,
2497
+ available: true,
2498
+ active: activeContextId === 'ctx-qa',
2499
+ lastUsedAt: null,
2500
+ },
2501
+ ...(withOrg
2502
+ ? [
2503
+ {
2504
+ id: 'ctx-qa-org',
2505
+ accountId: 'org',
2506
+ kind: 'organization' as const,
2507
+ relationship: 'owner' as const,
2508
+ account: { id: 'org', username: 'oxy' },
2509
+ onDevice: true,
2510
+ available: true,
2511
+ active: activeContextId === 'ctx-qa-org',
2512
+ lastUsedAt: null,
2513
+ },
2514
+ ]
2515
+ : []),
2516
+ ],
2517
+ },
2518
+ ],
2519
+ });
2520
+
2521
+ const sharedSession: SessionLoginResponse = {
2522
+ sessionId: 'sess-shared',
2523
+ deviceId: 'device-1',
2524
+ expiresAt: '2030-01-01T00:00:00Z',
2525
+ accessToken: 'access-shared',
2526
+ user: { id: 'qa', username: 'qatest0925', name: { displayName: 'QA' } },
2527
+ };
2528
+
2529
+ /**
2530
+ * A device that still lists `qa` (Commons holds the shared identity) while
2531
+ * THIS app holds no bearer — Mention after "Sign out".
2532
+ */
2533
+ async function signedOutDevice(options: { withOrg?: boolean } = {}) {
2534
+ const oxy = makeOxy();
2535
+ const urls: string[] = [];
2536
+ const sc = new TestSessionClient({
2537
+ makeRequest: jest.fn(async (_method: string, url: string) => {
2538
+ urls.push(url);
2539
+ if (url === '/session/device/directory') return deviceDirectory('ctx-qa', options.withOrg);
2540
+ if (url === '/session/device/activate') {
2541
+ return { directory: { ...deviceDirectory('ctx-qa-org', true), revision: 5 }, activeToken: null };
2542
+ }
2543
+ return undefined;
2544
+ }),
2545
+ getBaseURL: () => 'http://test.invalid',
2546
+ getAccessToken: () => oxy.getAccessToken(),
2547
+ getDeviceCredential: () => null,
2548
+ onTokensChanged: () => () => undefined,
2549
+ setTokens: jest.fn(),
2550
+ getCurrentAccountId: () => null,
2551
+ });
2552
+ // The directory this client read while it was still signed in.
2553
+ await sc.refreshDirectory();
2554
+ oxy.emitTokenChange(null);
2555
+ const commitSession = jest.fn().mockResolvedValue(undefined);
2556
+ const onSignedIn = jest.fn();
2557
+ const controller = createAccountDialogController({
2558
+ oxyServices: oxy as unknown as OxyServices,
2559
+ sessionClient: sc,
2560
+ clientId: 'oxy_dk_test',
2561
+ commitSession,
2562
+ onSignedIn,
2563
+ });
2564
+ controller.start();
2565
+ await flush();
2566
+ urls.length = 0;
2567
+ return { controller, oxy, sc, urls, commitSession, onSignedIn };
2568
+ }
2569
+
2570
+ it('publishes that this client is signed out while the device still lists an active account', async () => {
2571
+ const { controller } = await signedOutDevice();
2572
+ const snap = controller.getSnapshot();
2573
+ expect(snap.hasSession).toBe(false);
2574
+ // The precondition of the bug: the row the sheet renders reads as "active".
2575
+ expect(snap.activeContext?.contextId).toBe('ctx-qa');
2576
+ controller.destroy();
2577
+ });
2578
+
2579
+ it('signed out, choosing the listed account signs in through the shared identity, like "Continue with Oxy"', async () => {
2580
+ const { controller, oxy, urls, commitSession, onSignedIn } = await signedOutDevice();
2581
+ oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
2582
+
2583
+ // It used to short-circuit on "already the active row" and report success,
2584
+ // closing the sheet on an app that was still signed out.
2585
+ expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
2586
+
2587
+ expect(oxy.signInWithSharedIdentity).toHaveBeenCalledWith({ plantTokens: false });
2588
+ expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-shared' }));
2589
+ expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'qa' }));
2590
+ expect(controller.getSnapshot().hasSession).toBe(true);
2591
+ expect(controller.getSnapshot().signIn.phase).toBe('completed');
2592
+ // The silent mint already landed on the chosen pair: no switch on top.
2593
+ expect(urls).not.toContain('/session/device/activate');
2594
+ controller.destroy();
2595
+ });
2596
+
2597
+ it('signed out with no shared identity, choosing the account starts the same request "Continue with Oxy" does', async () => {
2598
+ const { controller, oxy, onSignedIn } = await signedOutDevice();
2599
+ oxy.signInWithSharedIdentity.mockResolvedValue(null);
2600
+ oxy.startCommonsSignIn.mockResolvedValue({
2601
+ sessionToken: 'secret-tok',
2602
+ authorizeCode: 'AUTH-CODE',
2603
+ qrPayload: 'oxycommons://approve?v=1&code=AUTH-CODE',
2604
+ expiresAt: Date.now() + 300_000,
2605
+ status: 'pending',
2606
+ });
2607
+
2608
+ expect(await controller.chooseContext('ctx-qa')).toBe('signing-in');
2609
+
2610
+ expect(oxy.startCommonsSignIn).toHaveBeenCalledWith({ clientId: 'oxy_dk_test' });
2611
+ expect(controller.getSnapshot().view).toBe('qr');
2612
+ expect(controller.getSnapshot().signIn.phase).toBe('waiting');
2613
+ expect(onSignedIn).not.toHaveBeenCalled();
2614
+ controller.destroy();
2615
+ });
2616
+
2617
+ it('signed out, choosing an organization row signs in and then activates that row', async () => {
2618
+ const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
2619
+ oxy.signInWithSharedIdentity.mockResolvedValue(sharedSession);
2620
+
2621
+ expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
2622
+
2623
+ expect(oxy.signInWithSharedIdentity).toHaveBeenCalledTimes(1);
2624
+ expect(urls).toContain('/session/device/activate');
2625
+ expect(controller.getSnapshot().activeContext?.contextId).toBe('ctx-qa-org');
2626
+ controller.destroy();
2627
+ });
2628
+
2629
+ it('signed in, the active row is "current" and any other row is a switch', async () => {
2630
+ const { controller, oxy, urls } = await signedOutDevice({ withOrg: true });
2631
+ oxy.emitTokenChange('access-token');
2632
+ await flush();
2633
+ urls.length = 0;
2634
+ expect(controller.getSnapshot().hasSession).toBe(true);
2635
+
2636
+ expect(await controller.chooseContext('ctx-qa')).toBe('current');
2637
+ expect(urls).not.toContain('/session/device/activate');
2638
+
2639
+ expect(await controller.chooseContext('ctx-qa-org')).toBe('switched');
2640
+ expect(urls).toContain('/session/device/activate');
2641
+ expect(oxy.signInWithSharedIdentity).not.toHaveBeenCalled();
2642
+ controller.destroy();
2643
+ });
2644
+ });
@@ -68,6 +68,7 @@ const SNAPSHOT_KEYS = [
68
68
  'commonsAvailability',
69
69
  'directory',
70
70
  'error',
71
+ 'hasSession',
71
72
  'loading',
72
73
  'removingContextId',
73
74
  'removingPrincipalId',
@@ -310,6 +310,16 @@ export interface AccountDialogSnapshot {
310
310
  * table of their own.
311
311
  */
312
312
  backView: AccountDialogView | null;
313
+ /**
314
+ * Whether THIS client is signed in: a bearer is planted.
315
+ *
316
+ * Not the same question as "does the directory list anyone". The directory is
317
+ * the DEVICE's, and a device can go on listing a shared identity (Commons, or a
318
+ * sibling app) after this app signed out. A host names its sign-in entry, and
319
+ * whether a listed account reads as the current one, from this — never from
320
+ * the directory's size.
321
+ */
322
+ hasSession: boolean;
313
323
  /**
314
324
  * The server-authoritative device directory — principals and the contexts
315
325
  * each may act as (ADR 0002) — or `null` before the first read.
@@ -337,6 +347,21 @@ export interface AccountDialogSnapshot {
337
347
  commonsAvailability: CommonsAvailability;
338
348
  }
339
349
 
350
+ /**
351
+ * What {@link AccountDialogController.chooseContext} did with a chosen row.
352
+ *
353
+ * - `'current'` — the row is already the active account; nothing to do.
354
+ * - `'switched'` — the switch to the row happened (signed in; or signed out,
355
+ * after the silent sign-in landed on a different pair and the chosen one was
356
+ * then activated).
357
+ * - `'signing-in'` — signed out: the row started "Continue with Oxy", and the
358
+ * sign-in flow (its own view, its own completion) owns what happens next —
359
+ * including closing the dialog, through `onSignedIn`.
360
+ * - `'failed'` — the switch was attempted and failed (the reason is `error`).
361
+ * - `'busy'` — another device mutation is in flight; the press was dropped.
362
+ */
363
+ export type ContextChoiceOutcome = 'current' | 'switched' | 'signing-in' | 'failed' | 'busy';
364
+
340
365
  /** Construction options for {@link AccountDialogController}. */
341
366
  export interface AccountDialogControllerOptions {
342
367
  /** The API client. Source of graph accounts, profiles, and the sign-in methods. */
@@ -880,6 +905,45 @@ export class AccountDialogController {
880
905
  );
881
906
  }
882
907
 
908
+ /**
909
+ * A device account row was chosen — the one entry point for "use this
910
+ * account", whether or not anybody is signed in here.
911
+ *
912
+ * Signed in, it is a switch: the active row is already the answer
913
+ * (`'current'`), any other row is {@link activateContext}.
914
+ *
915
+ * Signed out, it is NOT a switch. The row is on the list because the DEVICE
916
+ * still holds that identity (Commons' shared identity, or a sibling app's
917
+ * session), not because this app does, so there is no bearer to activate a
918
+ * context with — and the active-row short-circuit used to read that stale
919
+ * "active" as "already signed in" and close the sheet on a signed-out app
920
+ * (OxyHQ/oxy#1375 item 20). Choosing the row is "Continue as @handle": the
921
+ * same {@link signInWithOxy} path as the "Continue with Oxy" button, which
922
+ * mints silently from the shared identity and otherwise falls back to the
923
+ * request. When that silent mint lands on a different pair from the one
924
+ * chosen (a device holding more than one person, or an organization row), the
925
+ * chosen row is then activated under the new bearer, so the press ends where
926
+ * it pointed.
927
+ */
928
+ async chooseContext(contextId: string): Promise<ContextChoiceOutcome> {
929
+ if (this.isDeviceMutationInFlight()) return 'busy';
930
+ if (this.hasSession()) {
931
+ if (contextId === this.snapshot.activeContext?.contextId) return 'current';
932
+ return (await this.activateContext(contextId)) ? 'switched' : 'failed';
933
+ }
934
+ await this.signInWithOxy();
935
+ // Only a sign-in that finished here (the silent shared-identity mint) can be
936
+ // steered to the chosen row; a request still waiting on approval belongs to
937
+ // whoever approves it.
938
+ if (this.signIn.phase !== 'completed' || !this.hasSession()) return 'signing-in';
939
+ const directory = this.sessionClient.getDirectory();
940
+ const offered = directory?.principals.some((principal) =>
941
+ principal.contexts.some((context) => context.id === contextId),
942
+ );
943
+ if (!offered || resolveActiveContext(directory)?.contextId === contextId) return 'signing-in';
944
+ return (await this.activateContext(contextId)) ? 'switched' : 'signing-in';
945
+ }
946
+
883
947
  /**
884
948
  * Remove ONE `principal → account` pair, and only that pair.
885
949
  *
@@ -1780,6 +1844,7 @@ export class AccountDialogController {
1780
1844
  return {
1781
1845
  view,
1782
1846
  backView: backViewOf(view, hasSession),
1847
+ hasSession,
1783
1848
  directory,
1784
1849
  activeContext: resolveActiveContext(directory),
1785
1850
  loading: this.loading,