@oxy.so/services 12.13.0 → 13.0.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 (63) hide show
  1. package/lib/commonjs/ui/boot/runProviderColdBoot.js +188 -88
  2. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyConsentScreen.js +10 -7
  4. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +116 -3
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/oauth/explicitOAuthConsent.js +10 -5
  8. package/lib/commonjs/ui/oauth/explicitOAuthConsent.js.map +1 -1
  9. package/lib/commonjs/ui/session/cachedBoot.js +95 -0
  10. package/lib/commonjs/ui/session/cachedBoot.js.map +1 -0
  11. package/lib/module/ui/boot/runProviderColdBoot.js +188 -88
  12. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  13. package/lib/module/ui/components/OxyConsentScreen.js +10 -7
  14. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -1
  15. package/lib/module/ui/context/OxyContext.js +117 -4
  16. package/lib/module/ui/context/OxyContext.js.map +1 -1
  17. package/lib/module/ui/oauth/explicitOAuthConsent.js +10 -5
  18. package/lib/module/ui/oauth/explicitOAuthConsent.js.map +1 -1
  19. package/lib/module/ui/session/cachedBoot.js +92 -0
  20. package/lib/module/ui/session/cachedBoot.js.map +1 -0
  21. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +24 -0
  22. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +3 -1
  24. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  26. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +2 -1
  27. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +28 -50
  29. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts +13 -13
  31. package/lib/typescript/commonjs/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/hooks/queries/useSupervision.d.ts +108 -108
  33. package/lib/typescript/commonjs/ui/oauth/explicitOAuthConsent.d.ts +4 -6
  34. package/lib/typescript/commonjs/ui/oauth/explicitOAuthConsent.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts +48 -0
  36. package/lib/typescript/commonjs/ui/session/cachedBoot.d.ts.map +1 -0
  37. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +24 -0
  38. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  39. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +3 -1
  40. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -1
  41. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  42. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +2 -1
  43. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  44. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +28 -50
  45. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts +13 -13
  47. package/lib/typescript/module/ui/hooks/queries/usePersonalPlans.d.ts.map +1 -1
  48. package/lib/typescript/module/ui/hooks/queries/useSupervision.d.ts +108 -108
  49. package/lib/typescript/module/ui/oauth/explicitOAuthConsent.d.ts +4 -6
  50. package/lib/typescript/module/ui/oauth/explicitOAuthConsent.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/session/cachedBoot.d.ts +48 -0
  52. package/lib/typescript/module/ui/session/cachedBoot.d.ts.map +1 -0
  53. package/package.json +5 -5
  54. package/src/ui/boot/__tests__/cachedColdBoot.test.ts +187 -0
  55. package/src/ui/boot/runProviderColdBoot.ts +229 -95
  56. package/src/ui/components/OxyConsentScreen.tsx +40 -31
  57. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +13 -0
  58. package/src/ui/context/OxyContext.tsx +146 -3
  59. package/src/ui/context/oxyContextTypes.ts +2 -1
  60. package/src/ui/oauth/__tests__/explicitOAuthConsent.test.ts +17 -0
  61. package/src/ui/oauth/explicitOAuthConsent.ts +10 -5
  62. package/src/ui/session/__tests__/cachedBoot.test.ts +32 -0
  63. package/src/ui/session/cachedBoot.ts +139 -0
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Explicit OAuth consent for an already-authenticated Oxy user.
3
3
  *
4
- * This is deliberately separate from sign-in routing. First-party applications
5
- * normally use the account dialog, but a consent-required scope (for example
6
- * `acting-as:offline`) still has to go through the OAuth authorize surface. The
7
- * caller must invoke this operation from a real user gesture; it never runs on
8
- * mount, retries silently, or grants anything locally.
4
+ * External applications may request additional scopes through OAuth. Own Oxy
5
+ * applications use the account dialog and never create OAuth consent grants.
6
+ * The caller invokes this operation from a user gesture.
9
7
  */
10
8
  import type { OxyServices } from "@oxy.so/core";
11
9
  import type { OAuthSessionCommitInput, WebAuthMode, WebOAuthFailureReason, WebOAuthRedirectReason } from "./types.js";
@@ -18,7 +16,7 @@ export interface RequestOAuthConsentOptions {
18
16
  timeoutMs?: number;
19
17
  }
20
18
  export type OAuthConsentFailureReason = WebOAuthFailureReason | "invalid-redirect-uri" | "invalid-scopes" | "scope-not-configured" | "client-resolution-failed" | "native-callback-invalid" | "state-mismatch" | "exchange-failed" | "subject-mismatch";
21
- export type OAuthConsentUnsupportedReason = "missing-client-id" | "identity-bound" | "not-authenticated" | "unsupported-platform";
19
+ export type OAuthConsentUnsupportedReason = "missing-client-id" | "first-party-application" | "identity-bound" | "not-authenticated" | "unsupported-platform";
22
20
  export type OAuthConsentResult = {
23
21
  status: "consented";
24
22
  } | {
@@ -1 +1 @@
1
- {"version":3,"file":"explicitOAuthConsent.d.ts","sourceRoot":"","sources":["../../../../../src/ui/oauth/explicitOAuthConsent.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAqB,MAAM,cAAc,CAAC;AAMnE,OAAO,KAAK,EAEX,uBAAuB,EACvB,WAAW,EACX,qBAAqB,EACrB,sBAAsB,EACtB,MAAM,YAAS,CAAC;AAEjB,MAAM,WAAW,0BAA0B;IAC1C,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,yBAAyB,GAClC,qBAAqB,GACrB,sBAAsB,GACtB,gBAAgB,GAChB,sBAAsB,GACtB,0BAA0B,GAC1B,yBAAyB,GACzB,gBAAgB,GAChB,iBAAiB,GACjB,kBAAkB,CAAC;AAEtB,MAAM,MAAM,6BAA6B,GACtC,mBAAmB,GACnB,gBAAgB,GAChB,mBAAmB,GACnB,sBAAsB,CAAC;AAE1B,MAAM,MAAM,kBAAkB,GAC3B;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,sBAAsB,CAAA;CAAE,GACtD;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IACA,MAAM,EAAE,QAAQ,CAAC;IACjB,MAAM,EAAE,yBAAyB,CAAC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;CACpB,GACD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,6BAA6B,CAAA;CAAE,CAAC;AAEpE,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,KAAK,GAAG,QAAQ,GAAG,aAAa,CAAC;IAC3C,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,OAAO,CAAC;IACvB,wFAAwF;IACxF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,aAAa,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjE;AAuDD;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACxC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,0BAA0B,GACjC,OAAO,CAAC,kBAAkB,CAAC,CAqI7B"}
1
+ {"version":3,"file":"explicitOAuthConsent.d.ts","sourceRoot":"","sources":["../../../../../src/ui/oauth/explicitOAuthConsent.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAqB,MAAM,cAAc,CAAC;AAMnE,OAAO,KAAK,EAEX,uBAAuB,EACvB,WAAW,EACX,qBAAqB,EACrB,sBAAsB,EACtB,MAAM,YAAS,CAAC;AAEjB,MAAM,WAAW,0BAA0B;IAC1C,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,yBAAyB,GAClC,qBAAqB,GACrB,sBAAsB,GACtB,gBAAgB,GAChB,sBAAsB,GACtB,0BAA0B,GAC1B,yBAAyB,GACzB,gBAAgB,GAChB,iBAAiB,GACjB,kBAAkB,CAAC;AAEtB,MAAM,MAAM,6BAA6B,GACtC,mBAAmB,GACnB,yBAAyB,GACzB,gBAAgB,GAChB,mBAAmB,GACnB,sBAAsB,CAAC;AAE1B,MAAM,MAAM,kBAAkB,GAC3B;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,GAAG,EAAE,sBAAsB,CAAA;CAAE,GACtD;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,GACvB;IACA,MAAM,EAAE,QAAQ,CAAC;IACjB,MAAM,EAAE,yBAAyB,CAAC;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;CACpB,GACD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,6BAA6B,CAAA;CAAE,CAAC;AAEpE,MAAM,WAAW,mBAAmB;IACnC,QAAQ,EAAE,KAAK,GAAG,QAAQ,GAAG,aAAa,CAAC;IAC3C,IAAI,EAAE,WAAW,CAAC;IAClB,WAAW,EAAE,WAAW,CAAC;IACzB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,OAAO,CAAC;IACvB,wFAAwF;IACxF,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,aAAa,EAAE,CAAC,KAAK,EAAE,uBAAuB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjE;AAuDD;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACxC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,0BAA0B,GACjC,OAAO,CAAC,kBAAkB,CAAC,CA2I7B"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * What a returning session needs for its first paint, kept on this device.
3
+ *
4
+ * A cold boot used to wait on two round trips before it showed anything: the
5
+ * registry's classification of this app and the profile of the signed-in
6
+ * account. With a session saved on the device, the app now paints at once from
7
+ * what the last run left here, and the boot verifies both in the background
8
+ * (`runProviderColdBoot`). This module is that cache:
9
+ *
10
+ * - the registry's last verdict on this app (`device` or `oauth`), tied to the
11
+ * `clientId` it was given for;
12
+ * - the last account profile the runtime held, tied to that account's id.
13
+ *
14
+ * Both live in the provider's ordinary storage (AsyncStorage on native,
15
+ * `localStorage` on web), next to the per-account query cache, which already
16
+ * holds the same profile under the SDK's `users` queries. Neither is a
17
+ * credential: the device secret and the access token stay in the auth store.
18
+ *
19
+ * A cached session is offered only when it matches the auth store: the profile
20
+ * must belong to the account the persisted session is for, so a switch or a
21
+ * sign-out on a previous run can never paint somebody else.
22
+ */
23
+ import type { User } from '@oxy.so/core';
24
+ import { type AuthStateStore } from '@oxy.so/core/session';
25
+ import type { StorageInterface } from '../utils/storageHelpers.js';
26
+ export type SessionLane = 'device' | 'oauth';
27
+ /** A persisted session the app can show before the network answers. */
28
+ export interface CachedSession {
29
+ sessionId: string;
30
+ userId: string;
31
+ deviceId: string;
32
+ /** The account profile the last run held for {@link userId}. */
33
+ user: User;
34
+ /** The persisted access token, when it is still valid past the refresh lead. */
35
+ warmToken: string | null;
36
+ }
37
+ export interface CachedBootStore {
38
+ saveLane(clientId: string, lane: SessionLane): Promise<void>;
39
+ saveAccount(user: User): Promise<void>;
40
+ clearAccount(): Promise<void>;
41
+ /**
42
+ * The cached session for this `clientId`, or `null` when anything a first
43
+ * paint needs is missing or does not match the auth store.
44
+ */
45
+ readSession(clientId: string, authStore: AuthStateStore): Promise<CachedSession | null>;
46
+ }
47
+ export declare function createCachedBootStore(storage: StorageInterface, prefix: string, now?: () => number): CachedBootStore;
48
+ //# sourceMappingURL=cachedBoot.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cachedBoot.d.ts","sourceRoot":"","sources":["../../../../../src/ui/session/cachedBoot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAyB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAClF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAAyB,CAAC;AAEhE,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE7C,uEAAuE;AACvE,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,IAAI,EAAE,IAAI,CAAC;IACX,gFAAgF;IAChF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D,WAAW,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B;;;OAGG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;CACzF;AAuBD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,MAAM,MAAiB,GAC3B,eAAe,CA+DjB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/services",
3
- "version": "12.13.0",
3
+ "version": "13.0.0",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -150,7 +150,7 @@
150
150
  "delete-dts.js": "find ./lib/commonjs -name '*.d.js*' -delete && find ./lib/module -name '*.d.js*' -delete"
151
151
  },
152
152
  "dependencies": {
153
- "@oxy.so/contracts": "^5.8.0",
153
+ "@oxy.so/contracts": "^6.0.0",
154
154
  "color": "^5.0.3",
155
155
  "@peable.to/sdk": "0.4.1"
156
156
  },
@@ -159,7 +159,7 @@
159
159
  "@commitlint/config-conventional": "^21.2.0",
160
160
  "@expo/app-integrity": "~58.0.2",
161
161
  "@expo/vector-icons": "^15.0.3",
162
- "@oxy.so/core": "6.15.2",
162
+ "@oxy.so/core": "7.0.0",
163
163
  "@react-native-async-storage/async-storage": "^2.0.0",
164
164
  "@react-native-community/netinfo": "^12.0.1",
165
165
  "@react-native/babel-preset": "^0.88.0",
@@ -209,12 +209,12 @@
209
209
  "expo-video": "~58.0.11"
210
210
  },
211
211
  "//@oxy.so/bloom": "Bloom 11.1.0 adds the shared grouped sections, usage card, subscription plans, frame appearance and overlay navigation APIs used by this SDK. First-party Bloom peers remain open above this tested minimum.",
212
- "//@oxy.so/core": "Core 6.13.0 supplies the `accounts.settings.*` strings (picture, categories) the account settings speak in every language; 6.12.0 supplies `fileManagement.contentLost` and the `lost` file status a lost file is listed with; 6.11.0 supplies the `follow.errors.*` strings the follow error codes are worded with; 6.4.0 the `follow.*` strings the follow buttons read for every verb and language; 6.1.0 the `oxy.auth.email.{startChange,change,remove}` and `oxy.auth.password.remove` the sign-in & security panels call. Keep one Core instance per app; raise this floor with the root catalogue when new runtime APIs are required.",
212
+ "//@oxy.so/core": "Core 6.16.0 supplies `requireServerVerdict` and `onHolderRejected`, with which the cold boot checks a session it painted from the cache; 6.13.0 supplies the `accounts.settings.*` strings (picture, categories) the account settings speak in every language; 6.12.0 supplies `fileManagement.contentLost` and the `lost` file status a lost file is listed with; 6.11.0 supplies the `follow.errors.*` strings the follow error codes are worded with; 6.4.0 the `follow.*` strings the follow buttons read for every verb and language; 6.1.0 the `oxy.auth.email.{startChange,change,remove}` and `oxy.auth.password.remove` the sign-in & security panels call. Keep one Core instance per app; raise this floor with the root catalogue when new runtime APIs are required.",
213
213
  "peerDependencies": {
214
214
  "@expo/app-integrity": ">=56.0.0 <59.0.0",
215
215
  "@expo/vector-icons": "^15.0.3",
216
216
  "@oxy.so/bloom": ">=11.1.0",
217
- "@oxy.so/core": "^6.14.0",
217
+ "@oxy.so/core": "^7.0.0",
218
218
  "@react-native-async-storage/async-storage": "^2.0.0",
219
219
  "@react-native-community/netinfo": ">=11.4.1 <13.0.0",
220
220
  "@tanstack/query-async-storage-persister": "^5.101",
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `runProviderColdBoot` with a session saved on the device: it paints from the
3
+ * cache before any network answers, then checks the classification and the
4
+ * session in the background. A first boot (nothing cached) is unchanged.
5
+ */
6
+ import type { AuthStateStore, PersistedAuthState, SessionClient } from '@oxy.so/core/session';
7
+
8
+ jest.mock('@oxy.so/core/session', () => {
9
+ const actual = jest.requireActual('@oxy.so/core/session');
10
+ return { __esModule: true, ...actual, runSessionColdBoot: jest.fn() };
11
+ });
12
+
13
+ import { runSessionColdBoot } from '@oxy.so/core/session';
14
+ import { runProviderColdBoot, type RunProviderColdBootOptions } from '../runProviderColdBoot';
15
+
16
+ const mockRunSessionColdBoot = runSessionColdBoot as jest.Mock;
17
+ type BootOptions = Parameters<typeof runSessionColdBoot>[0];
18
+
19
+ const OFFICIAL = {
20
+ id: 'app',
21
+ name: 'App',
22
+ type: 'first_party',
23
+ isOfficial: true,
24
+ isInternal: false,
25
+ scopes: [],
26
+ };
27
+
28
+ function credentialStore(): AuthStateStore {
29
+ const state: PersistedAuthState = {
30
+ sessionId: 's1',
31
+ userId: 'u1',
32
+ deviceId: 'd1',
33
+ deviceSecret: 'secret',
34
+ };
35
+ return {
36
+ load: jest.fn(async () => state),
37
+ save: jest.fn(async () => true),
38
+ clear: jest.fn(async () => undefined),
39
+ } as unknown as AuthStateStore;
40
+ }
41
+
42
+ function makeOptions(paints: boolean): RunProviderColdBootOptions & {
43
+ getPublic: jest.Mock;
44
+ release: (app: unknown) => void;
45
+ fail: (error: Error) => void;
46
+ } {
47
+ let release: (app: unknown) => void = () => undefined;
48
+ let fail: (error: Error) => void = () => undefined;
49
+ const getPublic = jest.fn(
50
+ () =>
51
+ new Promise((resolve, reject) => {
52
+ release = resolve;
53
+ fail = reject;
54
+ }),
55
+ );
56
+ const markAuthResolved = jest.fn();
57
+ return {
58
+ oxyServices: { apps: { getPublic } } as unknown as RunProviderColdBootOptions['oxyServices'],
59
+ authStore: credentialStore(),
60
+ clientId: 'oxy_dk_test',
61
+ sessionClient: { start: jest.fn(async () => undefined) } as unknown as SessionClient,
62
+ syncDeviceCredentialToHost: jest.fn(async () => undefined),
63
+ commitSession: jest.fn(async () => undefined),
64
+ markAuthResolved,
65
+ setTokenReady: jest.fn(),
66
+ paintCachedSession: jest.fn(async () => {
67
+ if (paints) markAuthResolved();
68
+ return paints;
69
+ }),
70
+ onCachedSessionRejected: jest.fn(async () => undefined),
71
+ onCachedSessionUnconfirmed: jest.fn(),
72
+ getPublic,
73
+ release: (app) => release(app),
74
+ fail: (error) => fail(error),
75
+ };
76
+ }
77
+
78
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
79
+
80
+ beforeEach(() => {
81
+ mockRunSessionColdBoot.mockReset();
82
+ window.history.replaceState(null, '', '/');
83
+ });
84
+
85
+ it('paints a saved session before the registry or the mint answers, then mints for a verdict', async () => {
86
+ mockRunSessionColdBoot.mockImplementation(async (opts: BootOptions) => {
87
+ const session = { sessionId: 's1', userId: 'u1', accessToken: 'minted', via: 'mint' };
88
+ await opts.onSession?.(session);
89
+ return { kind: 'session', via: session.via, session };
90
+ });
91
+ const options = makeOptions(true);
92
+
93
+ const boot = runProviderColdBoot(options);
94
+ await tick();
95
+
96
+ expect(options.markAuthResolved).toHaveBeenCalled();
97
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
98
+
99
+ options.release(OFFICIAL);
100
+ await boot;
101
+
102
+ expect(mockRunSessionColdBoot).toHaveBeenCalledWith(
103
+ expect.objectContaining({ requireServerVerdict: true }),
104
+ );
105
+ expect(options.commitSession).toHaveBeenCalledWith(
106
+ { sessionId: 's1', userId: 'u1', accessToken: 'minted', deviceState: undefined },
107
+ { activate: false },
108
+ );
109
+ expect(options.onCachedSessionRejected).not.toHaveBeenCalled();
110
+ expect(options.onCachedSessionUnconfirmed).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it.each([
114
+ ['the server ends the holder', OFFICIAL, true],
115
+ [
116
+ 'the registry now classifies the app OAuth-only',
117
+ { ...OFFICIAL, type: 'third_party', isOfficial: false },
118
+ false,
119
+ ],
120
+ ])('signs a painted session out when %s', async (_case, app, mints) => {
121
+ mockRunSessionColdBoot.mockImplementation(async (opts: BootOptions) => {
122
+ opts.onHolderRejected?.();
123
+ await opts.onSignedOut?.('no_session');
124
+ return { kind: 'unauthenticated' };
125
+ });
126
+ const options = makeOptions(true);
127
+
128
+ const boot = runProviderColdBoot(options);
129
+ await tick();
130
+ options.release(app);
131
+ await boot;
132
+
133
+ expect(mockRunSessionColdBoot).toHaveBeenCalledTimes(mints ? 1 : 0);
134
+ expect(options.onCachedSessionRejected).toHaveBeenCalledTimes(1);
135
+ expect(options.onCachedSessionUnconfirmed).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it('keeps a painted session the server could not be asked about, and asks again', async () => {
139
+ mockRunSessionColdBoot.mockImplementationOnce(async (opts: BootOptions) => {
140
+ await opts.onSignedOut?.('no_session');
141
+ return { kind: 'unauthenticated' };
142
+ });
143
+ const options = makeOptions(true);
144
+
145
+ const boot = runProviderColdBoot(options);
146
+ await tick();
147
+ options.fail(new Error('offline'));
148
+ await boot;
149
+
150
+ expect(options.onCachedSessionRejected).not.toHaveBeenCalled();
151
+ const retry = (options.onCachedSessionUnconfirmed as jest.Mock).mock.calls[0][0];
152
+ mockRunSessionColdBoot.mockImplementationOnce(async (opts: BootOptions) => {
153
+ const session = { sessionId: 's1', userId: 'u1', accessToken: 'minted', via: 'mint' };
154
+ await opts.onSession?.(session);
155
+ return { kind: 'session', via: session.via, session };
156
+ });
157
+ const retried = retry();
158
+ await tick();
159
+ options.release(OFFICIAL);
160
+ await retried;
161
+
162
+ expect(options.commitSession).toHaveBeenCalledTimes(1);
163
+ expect(options.onCachedSessionUnconfirmed).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('a first boot with nothing cached still waits for the verdict and the handoff', async () => {
167
+ mockRunSessionColdBoot.mockImplementation(async (opts: BootOptions) => {
168
+ await opts.onSignedOut?.('no_session');
169
+ return { kind: 'unauthenticated' };
170
+ });
171
+ const options = makeOptions(false);
172
+
173
+ const boot = runProviderColdBoot(options);
174
+ await tick();
175
+
176
+ expect(options.markAuthResolved).not.toHaveBeenCalled();
177
+ expect(mockRunSessionColdBoot).not.toHaveBeenCalled();
178
+
179
+ options.release(OFFICIAL);
180
+ await boot;
181
+
182
+ expect(mockRunSessionColdBoot).toHaveBeenCalledWith(
183
+ expect.objectContaining({ requireServerVerdict: false }),
184
+ );
185
+ expect(options.markAuthResolved).toHaveBeenCalled();
186
+ expect(options.onCachedSessionRejected).not.toHaveBeenCalled();
187
+ });
@@ -121,11 +121,223 @@ export interface RunProviderColdBootOptions {
121
121
  ) => Promise<void>;
122
122
  markAuthResolved: () => void;
123
123
  setTokenReady: (ready: boolean) => void;
124
+ /**
125
+ * Show the session saved on this device before any network: the account from
126
+ * its cached profile, auth resolved. Resolves `true` when it did, `false` when
127
+ * the device holds nothing a first paint can use (then the boot runs as a
128
+ * first boot). Never consulted in `sessionMode: 'identity'` or with an OAuth
129
+ * return on the URL.
130
+ */
131
+ paintCachedSession?: () => Promise<boolean>;
132
+ /**
133
+ * The server ended the painted session: the registry now classifies the app
134
+ * as OAuth-only, or the mint answered this holder `invalid_device_secret` /
135
+ * `no_active_session` and no recovery lane restored one. Sign out now.
136
+ */
137
+ onCachedSessionRejected?: () => Promise<void>;
138
+ /**
139
+ * The painted session could not be checked yet (offline, the registry or the
140
+ * mint unreachable). It stays on screen; call `retry` later to ask again.
141
+ */
142
+ onCachedSessionUnconfirmed?: (retry: () => Promise<void>) => void;
143
+ }
144
+
145
+ type ClassificationLane = 'device' | 'oauth';
146
+
147
+ /** Starts the provider's fresh classification; a rejection is observed, not swallowed. */
148
+ function startClassification(opts: RunProviderColdBootOptions): Promise<ClassificationLane> {
149
+ const classification = (async () =>
150
+ opts.resolveSessionLane?.() ??
151
+ resolveApplicationSessionLane(opts.oxyServices, opts.clientId))();
152
+ // Observed so a rejection that lands before it is awaited is not reported as
153
+ // unhandled; every caller still awaits it and sees the rejection.
154
+ classification.catch(() => undefined);
155
+ return classification;
156
+ }
157
+
158
+ /** What the device lanes made of this holder: a session, a verdict against it, or no answer. */
159
+ type DeviceRestoreOutcome = 'session' | 'rejected' | 'unreachable';
160
+
161
+ /**
162
+ * The device lanes of the boot (`runSessionColdBoot`), for a first boot or for
163
+ * the background check of a painted one.
164
+ *
165
+ * A first boot (`painted: false`) is on the critical path: its committed
166
+ * session resolves auth, bounded by {@link SESSION_HANDOFF_DEADLINE_MS}. A
167
+ * painted boot already resolved auth from the cache, so nothing waits on the
168
+ * handoff and the pure-local warm token is skipped: the check needs the mint's
169
+ * verdict, which a still-valid token cannot give.
170
+ */
171
+ async function restoreDeviceSession(
172
+ opts: RunProviderColdBootOptions,
173
+ painted: boolean,
174
+ ): Promise<DeviceRestoreOutcome> {
175
+ const {
176
+ oxyServices,
177
+ authStore,
178
+ sessionMode = 'account',
179
+ identity,
180
+ sessionClient,
181
+ syncDeviceCredentialToHost,
182
+ commitSession,
183
+ markAuthResolved,
184
+ } = opts;
185
+
186
+ await opts.beforeDeviceBoot?.();
187
+
188
+ // Best-effort connectivity probe up front: an EXPLICIT offline verdict skips
189
+ // the two doomed network steps so routing settles immediately instead of
190
+ // burning the overall deadline on a mint that cannot succeed. Any ambiguity
191
+ // resolves to "online" — the network steps still run.
192
+ const offline = await detectOfflineHint();
193
+
194
+ let outcome: DeviceRestoreOutcome = 'unreachable';
195
+ let holderRejected = false;
196
+ await runSessionColdBoot({
197
+ oxy: oxyServices,
198
+ store: authStore,
199
+ platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
200
+ sessionMode,
201
+ identity,
202
+ // The device-wide shared credential slot, enabling the `shared-device-adopt`
203
+ // lane: a newly installed official app joins this device's existing session
204
+ // instead of falling back to signing a challenge with the Commons identity
205
+ // key. `null` on web, where each origin is its own device by design; the
206
+ // core lane is also skipped outright in `sessionMode: 'identity'`.
207
+ sharedDeviceCredential: createPlatformSharedDeviceCredentialStore() ?? undefined,
208
+ overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE_MS,
209
+ isOffline: () => offline,
210
+ requireServerVerdict: painted,
211
+ onHolderRejected: () => {
212
+ holderRejected = true;
213
+ },
214
+ onStepDeadline: (stepId) => {
215
+ loggerUtil.warn(
216
+ `Cold-boot step "${stepId}" exceeded the ${COLD_BOOT_OVERALL_DEADLINE_MS}ms overall deadline — abandoned; routing proceeds signed-out`,
217
+ { component: 'runProviderColdBoot', method: 'onStepDeadline' },
218
+ );
219
+ },
220
+ onSession: async (session) => {
221
+ outcome = 'session';
222
+ // Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
223
+ // in-memory SessionClient host so sockets + tab-focus re-mint can use it.
224
+ await syncDeviceCredentialToHost();
225
+ const handoff = commitSession(
226
+ {
227
+ sessionId: session.sessionId,
228
+ accessToken: session.accessToken,
229
+ userId: session.userId,
230
+ deviceState: session.state,
231
+ },
232
+ { activate: false },
233
+ );
234
+ if (painted) {
235
+ // The account is already on screen; the commit refreshes its profile.
236
+ await handoff;
237
+ return;
238
+ }
239
+ let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
240
+ await Promise.race([
241
+ handoff,
242
+ new Promise<void>((resolve) => {
243
+ handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE_MS);
244
+ }),
245
+ ]).finally(() => {
246
+ if (handoffDeadlineId !== undefined) {
247
+ clearTimeout(handoffDeadlineId);
248
+ }
249
+ });
250
+ markAuthResolved();
251
+ },
252
+ onSignedOut: async () => {
253
+ outcome = holderRejected ? 'rejected' : 'unreachable';
254
+ await syncDeviceCredentialToHost();
255
+ // A painted session is the caller's to end or keep (see verifyCachedSession).
256
+ if (painted) return;
257
+ const cred = await loadPersistedDeviceCredential(authStore);
258
+ if (cred) {
259
+ try {
260
+ await sessionClient.start();
261
+ } catch (socketError) {
262
+ if (__DEV__) {
263
+ loggerUtil.debug(
264
+ 'Device socket start failed (non-fatal)',
265
+ { component: 'runProviderColdBoot' },
266
+ socketError,
267
+ );
268
+ }
269
+ }
270
+ }
271
+ markAuthResolved();
272
+ },
273
+ onStepError: (id, error) => {
274
+ if (__DEV__) {
275
+ loggerUtil.debug(
276
+ `Cold-boot step "${id}" errored (non-fatal, falling through)`,
277
+ { component: 'runProviderColdBoot' },
278
+ error,
279
+ );
280
+ }
281
+ },
282
+ });
283
+ await opts.afterDeviceBoot?.();
284
+ return outcome;
285
+ }
286
+
287
+ /**
288
+ * The background check of a painted session: the registry's classification and
289
+ * a mint with this holder's own credential (whose commit refreshes the profile).
290
+ * Each is asked until the server answers it. A verdict against the session
291
+ * signs out at that moment; no answer keeps the cached session on screen and
292
+ * hands the caller a retry.
293
+ */
294
+ async function verifyCachedSession(
295
+ opts: RunProviderColdBootOptions,
296
+ verified: { lane: boolean; session: boolean },
297
+ classification: Promise<ClassificationLane>,
298
+ ): Promise<void> {
299
+ if (!verified.lane) {
300
+ let lane: ClassificationLane | null = null;
301
+ try {
302
+ lane = await classification;
303
+ } catch {
304
+ // The registry could not be asked (offline, timeout, 5xx). That is no
305
+ // verdict on this app: the cached `device` classification stands until it
306
+ // answers.
307
+ }
308
+ if (lane === 'oauth') {
309
+ await opts.onCachedSessionRejected?.();
310
+ return;
311
+ }
312
+ verified.lane = lane === 'device';
313
+ }
314
+ if (!verified.session) {
315
+ const outcome = await restoreDeviceSession(opts, true);
316
+ if (outcome === 'rejected') {
317
+ await opts.onCachedSessionRejected?.();
318
+ return;
319
+ }
320
+ verified.session = outcome === 'session';
321
+ }
322
+ if (!verified.lane || !verified.session) {
323
+ opts.onCachedSessionUnconfirmed?.(() =>
324
+ verifyCachedSession(
325
+ opts,
326
+ verified,
327
+ verified.lane ? Promise.resolve('device') : startClassification(opts),
328
+ ),
329
+ );
330
+ }
124
331
  }
125
332
 
126
333
  /**
127
334
  * Device-first cold boot for `@oxy.so/services` providers.
128
335
  *
336
+ * A returning session saved on this device (account mode, the app last
337
+ * classified `device`, its profile cached) paints first, with no network, and
338
+ * is then checked in the background — see `paintCachedSession`. Otherwise, a
339
+ * first boot:
340
+ *
129
341
  * Ordered pipeline:
130
342
  * 1. Complete an OAuth authorization-code return already on the URL (web)
131
343
  * 2. `runSessionColdBoot` — device-secret mint (+ the native Commons-proof lane,
@@ -148,9 +360,6 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
148
360
  clientId,
149
361
  authRedirectUri,
150
362
  sessionMode = 'account',
151
- identity,
152
- sessionClient,
153
- syncDeviceCredentialToHost,
154
363
  commitSession,
155
364
  markAuthResolved,
156
365
  setTokenReady,
@@ -162,14 +371,21 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
162
371
 
163
372
  try {
164
373
  // Classification precedes every USE of a local secret, the shared credential
165
- // and the identity key. Unavailable registry metadata fails closed, including
166
- // offline boot. It starts now, before anything else, so its round trip
167
- // overlaps the local work below.
168
- const classification = (async () =>
169
- opts.resolveSessionLane?.() ?? resolveApplicationSessionLane(oxyServices, clientId))();
170
- // Observed so a rejection that lands before it is awaited is not reported as
171
- // unhandled; every path below still awaits it and sees the rejection.
172
- classification.catch(() => undefined);
374
+ // and the identity key. Unavailable registry metadata fails closed on a first
375
+ // boot, including offline. It starts now, before anything else, so its round
376
+ // trip overlaps the local work below.
377
+ const classification = startClassification(opts);
378
+
379
+ // A returning session saved on this device paints at once from the cache:
380
+ // the account, its cached content, auth resolved, no network on the way.
381
+ // The classification and the session are then checked in the background,
382
+ // and a verdict against either signs out at that moment. Identity-bound
383
+ // clients and OAuth returns keep the path below.
384
+ const freshLaunch = !identityBound && !hasOAuthReturnOnUrl();
385
+ if (freshLaunch && (await opts.paintCachedSession?.())) {
386
+ await verifyCachedSession(opts, { lane: false, session: false }, classification);
387
+ return;
388
+ }
173
389
 
174
390
  // The verdict decides HOW a session is restored, never WHETHER there is one.
175
391
  // A web origin with nothing to restore and no OAuth return on the URL ends
@@ -177,7 +393,7 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
177
393
  // for an answer that cannot change the outcome. The classification still
178
394
  // completes below and still gates every later sign-in
179
395
  // (`ensureDeviceSessionLane` shares this same in-flight lookup).
180
- if (!identityBound && isWebBrowser() && !hasOAuthReturnOnUrl()) {
396
+ if (freshLaunch && isWebBrowser()) {
181
397
  if (!(await hasRestorableState(authStore))) {
182
398
  markAuthResolved();
183
399
  const lane = await classification;
@@ -215,89 +431,7 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
215
431
  return;
216
432
  }
217
433
 
218
- await opts.beforeDeviceBoot?.();
219
-
220
- // Best-effort connectivity probe up front: an EXPLICIT offline verdict skips
221
- // the two doomed network steps so routing settles immediately instead of
222
- // burning the overall deadline on a mint that cannot succeed. Any ambiguity
223
- // resolves to "online" — the network steps still run.
224
- const offline = await detectOfflineHint();
225
-
226
- await runSessionColdBoot({
227
- oxy: oxyServices,
228
- store: authStore,
229
- platform: { isWeb: isWebBrowser(), isNative: !isWebBrowser() },
230
- sessionMode,
231
- identity,
232
- // The device-wide shared credential slot, enabling the `shared-device-adopt`
233
- // lane: a newly installed official app joins this device's existing session
234
- // instead of falling back to signing a challenge with the Commons identity
235
- // key. `null` on web, where each origin is its own device by design; the
236
- // core lane is also skipped outright in `sessionMode: 'identity'`.
237
- sharedDeviceCredential: createPlatformSharedDeviceCredentialStore() ?? undefined,
238
- overallDeadlineMs: COLD_BOOT_OVERALL_DEADLINE_MS,
239
- isOffline: () => offline,
240
- onStepDeadline: (stepId) => {
241
- loggerUtil.warn(
242
- `Cold-boot step "${stepId}" exceeded the ${COLD_BOOT_OVERALL_DEADLINE_MS}ms overall deadline — abandoned; routing proceeds signed-out`,
243
- { component: 'runProviderColdBoot', method: 'onStepDeadline' },
244
- );
245
- },
246
- onSession: async (session) => {
247
- // Mint already persisted `{deviceId, deviceSecret}` to the store; sync the
248
- // in-memory SessionClient host so sockets + tab-focus re-mint can use it.
249
- await syncDeviceCredentialToHost();
250
- const handoff = commitSession(
251
- {
252
- sessionId: session.sessionId,
253
- accessToken: session.accessToken,
254
- userId: session.userId,
255
- deviceState: session.state,
256
- },
257
- { activate: false },
258
- );
259
- let handoffDeadlineId: ReturnType<typeof setTimeout> | undefined;
260
- await Promise.race([
261
- handoff,
262
- new Promise<void>((resolve) => {
263
- handoffDeadlineId = setTimeout(resolve, SESSION_HANDOFF_DEADLINE_MS);
264
- }),
265
- ]).finally(() => {
266
- if (handoffDeadlineId !== undefined) {
267
- clearTimeout(handoffDeadlineId);
268
- }
269
- });
270
- markAuthResolved();
271
- },
272
- onSignedOut: async () => {
273
- await syncDeviceCredentialToHost();
274
- const cred = await loadPersistedDeviceCredential(authStore);
275
- if (cred) {
276
- try {
277
- await sessionClient.start();
278
- } catch (socketError) {
279
- if (__DEV__) {
280
- loggerUtil.debug(
281
- 'Device socket start failed (non-fatal)',
282
- { component: 'runProviderColdBoot' },
283
- socketError,
284
- );
285
- }
286
- }
287
- }
288
- markAuthResolved();
289
- },
290
- onStepError: (id, error) => {
291
- if (__DEV__) {
292
- loggerUtil.debug(
293
- `Cold-boot step "${id}" errored (non-fatal, falling through)`,
294
- { component: 'runProviderColdBoot' },
295
- error,
296
- );
297
- }
298
- },
299
- });
300
- await opts.afterDeviceBoot?.();
434
+ await restoreDeviceSession(opts, false);
301
435
  } catch (error) {
302
436
  if (__DEV__) {
303
437
  loggerUtil.error(