@shipfox/client-shell 25.0.0 → 27.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.
@@ -9,6 +9,12 @@ export interface UserIdentity {
9
9
  export interface AuthenticatedSession {
10
10
  accessToken: string;
11
11
  user: UserIdentity;
12
+ /**
13
+ * Present only for externally minted sessions (for example an adopted
14
+ * session whose token carries the claim); ordinary cookie sessions never
15
+ * set it.
16
+ */
17
+ impersonatorId?: string;
12
18
  }
13
19
  export interface WorkspaceMembership {
14
20
  id: string;
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/core/session.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,gBAAgB,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAE5E,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,YAAY,CAAC;CACpB;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;CAC7C"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/core/session.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,gBAAgB,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAE5E,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,YAAY,CAAC;IACnB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;CAC7C"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/session.ts"],"sourcesContent":["export type AdminRole = 'admin-observer' | 'admin-operator' | 'admin-owner';\n\nexport interface UserIdentity {\n id: string;\n email: string;\n name?: string;\n emailVerifiedAt?: string;\n adminRole?: AdminRole;\n}\n\nexport interface AuthenticatedSession {\n accessToken: string;\n user: UserIdentity;\n}\n\nexport interface WorkspaceMembership {\n id: string;\n workspaceId: string;\n}\n\nexport interface WorkspaceSummary {\n id: string;\n name: string;\n slug: string;\n membershipId: string;\n status?: 'active' | 'suspended' | 'deleted';\n}\n"],"names":[],"mappings":"AAoBA,WAMC"}
1
+ {"version":3,"sources":["../../src/core/session.ts"],"sourcesContent":["export type AdminRole = 'admin-observer' | 'admin-operator' | 'admin-owner';\n\nexport interface UserIdentity {\n id: string;\n email: string;\n name?: string;\n emailVerifiedAt?: string;\n adminRole?: AdminRole;\n}\n\nexport interface AuthenticatedSession {\n accessToken: string;\n user: UserIdentity;\n /**\n * Present only for externally minted sessions (for example an adopted\n * session whose token carries the claim); ordinary cookie sessions never\n * set it.\n */\n impersonatorId?: string;\n}\n\nexport interface WorkspaceMembership {\n id: string;\n workspaceId: string;\n}\n\nexport interface WorkspaceSummary {\n id: string;\n name: string;\n slug: string;\n membershipId: string;\n status?: 'active' | 'suspended' | 'deleted';\n}\n"],"names":[],"mappings":"AA0BA,WAMC"}
@@ -1,5 +1,6 @@
1
- import type { LoginResponseDto, UserDto } from '@shipfox/api-auth-dto';
1
+ import type { SessionResponseDto, UserDto } from '@shipfox/api-auth-dto';
2
2
  import type { AuthenticatedSession, UserIdentity } from '#core/session.js';
3
+ export type { SessionResponseDto };
3
4
  export declare function toUserIdentity(dto: UserDto): UserIdentity;
4
- export declare function toAuthenticatedSession(dto: LoginResponseDto): AuthenticatedSession;
5
+ export declare function toAuthenticatedSession(dto: SessionResponseDto): AuthenticatedSession;
5
6
  //# sourceMappingURL=session-mapper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-mapper.d.ts","sourceRoot":"","sources":["../../../src/hooks/api/session-mapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,gBAAgB,EAAE,OAAO,EAAC,MAAM,uBAAuB,CAAC;AACrE,OAAO,KAAK,EAAC,oBAAoB,EAAE,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAEzE,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG,YAAY,CAOzD;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,gBAAgB,GAAG,oBAAoB,CAQlF"}
1
+ {"version":3,"file":"session-mapper.d.ts","sourceRoot":"","sources":["../../../src/hooks/api/session-mapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,kBAAkB,EAAE,OAAO,EAAC,MAAM,uBAAuB,CAAC;AACvE,OAAO,KAAK,EAAC,oBAAoB,EAAE,YAAY,EAAC,MAAM,kBAAkB,CAAC;AAEzE,YAAY,EAAC,kBAAkB,EAAC,CAAC;AAEjC,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG,YAAY,CAOzD;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,kBAAkB,GAAG,oBAAoB,CASpF"}
@@ -18,7 +18,10 @@ export function toAuthenticatedSession(dto) {
18
18
  ...dto.admin_role ? {
19
19
  adminRole: dto.admin_role
20
20
  } : {}
21
- }
21
+ },
22
+ ...dto.impersonator_id ? {
23
+ impersonatorId: dto.impersonator_id
24
+ } : {}
22
25
  };
23
26
  }
24
27
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/hooks/api/session-mapper.ts"],"sourcesContent":["import type {LoginResponseDto, UserDto} from '@shipfox/api-auth-dto';\nimport type {AuthenticatedSession, UserIdentity} from '#core/session.js';\n\nexport function toUserIdentity(dto: UserDto): UserIdentity {\n return {\n id: dto.id,\n email: dto.email,\n ...(dto.name ? {name: dto.name} : {}),\n ...(dto.email_verified_at ? {emailVerifiedAt: dto.email_verified_at} : {}),\n };\n}\n\nexport function toAuthenticatedSession(dto: LoginResponseDto): AuthenticatedSession {\n return {\n accessToken: dto.token,\n user: {\n ...toUserIdentity(dto.user),\n ...(dto.admin_role ? {adminRole: dto.admin_role} : {}),\n },\n };\n}\n"],"names":["toUserIdentity","dto","id","email","name","email_verified_at","emailVerifiedAt","toAuthenticatedSession","accessToken","token","user","admin_role","adminRole"],"mappings":"AAGA,OAAO,SAASA,eAAeC,GAAY;IACzC,OAAO;QACLC,IAAID,IAAIC,EAAE;QACVC,OAAOF,IAAIE,KAAK;QAChB,GAAIF,IAAIG,IAAI,GAAG;YAACA,MAAMH,IAAIG,IAAI;QAAA,IAAI,CAAC,CAAC;QACpC,GAAIH,IAAII,iBAAiB,GAAG;YAACC,iBAAiBL,IAAII,iBAAiB;QAAA,IAAI,CAAC,CAAC;IAC3E;AACF;AAEA,OAAO,SAASE,uBAAuBN,GAAqB;IAC1D,OAAO;QACLO,aAAaP,IAAIQ,KAAK;QACtBC,MAAM;YACJ,GAAGV,eAAeC,IAAIS,IAAI,CAAC;YAC3B,GAAIT,IAAIU,UAAU,GAAG;gBAACC,WAAWX,IAAIU,UAAU;YAAA,IAAI,CAAC,CAAC;QACvD;IACF;AACF"}
1
+ {"version":3,"sources":["../../../src/hooks/api/session-mapper.ts"],"sourcesContent":["import type {SessionResponseDto, UserDto} from '@shipfox/api-auth-dto';\nimport type {AuthenticatedSession, UserIdentity} from '#core/session.js';\n\nexport type {SessionResponseDto};\n\nexport function toUserIdentity(dto: UserDto): UserIdentity {\n return {\n id: dto.id,\n email: dto.email,\n ...(dto.name ? {name: dto.name} : {}),\n ...(dto.email_verified_at ? {emailVerifiedAt: dto.email_verified_at} : {}),\n };\n}\n\nexport function toAuthenticatedSession(dto: SessionResponseDto): AuthenticatedSession {\n return {\n accessToken: dto.token,\n user: {\n ...toUserIdentity(dto.user),\n ...(dto.admin_role ? {adminRole: dto.admin_role} : {}),\n },\n ...(dto.impersonator_id ? {impersonatorId: dto.impersonator_id} : {}),\n };\n}\n"],"names":["toUserIdentity","dto","id","email","name","email_verified_at","emailVerifiedAt","toAuthenticatedSession","accessToken","token","user","admin_role","adminRole","impersonator_id","impersonatorId"],"mappings":"AAKA,OAAO,SAASA,eAAeC,GAAY;IACzC,OAAO;QACLC,IAAID,IAAIC,EAAE;QACVC,OAAOF,IAAIE,KAAK;QAChB,GAAIF,IAAIG,IAAI,GAAG;YAACA,MAAMH,IAAIG,IAAI;QAAA,IAAI,CAAC,CAAC;QACpC,GAAIH,IAAII,iBAAiB,GAAG;YAACC,iBAAiBL,IAAII,iBAAiB;QAAA,IAAI,CAAC,CAAC;IAC3E;AACF;AAEA,OAAO,SAASE,uBAAuBN,GAAuB;IAC5D,OAAO;QACLO,aAAaP,IAAIQ,KAAK;QACtBC,MAAM;YACJ,GAAGV,eAAeC,IAAIS,IAAI,CAAC;YAC3B,GAAIT,IAAIU,UAAU,GAAG;gBAACC,WAAWX,IAAIU,UAAU;YAAA,IAAI,CAAC,CAAC;QACvD;QACA,GAAIV,IAAIY,eAAe,GAAG;YAACC,gBAAgBb,IAAIY,eAAe;QAAA,IAAI,CAAC,CAAC;IACtE;AACF"}
@@ -1,5 +1,43 @@
1
1
  import type { PropsWithChildren } from 'react';
2
2
  import type { AuthenticatedSession, UserIdentity, WorkspaceSummary } from '#core/session.js';
3
+ /**
4
+ * A renewal response for an adopted session. `expiresAt` and `serverTime` are
5
+ * both issuer timestamps; their difference is the server-side remaining
6
+ * lifetime, which is the renewal scheduling anchor.
7
+ */
8
+ export interface AdoptedSessionRenewal {
9
+ session: AuthenticatedSession;
10
+ expiresAt: string;
11
+ serverTime: string;
12
+ }
13
+ export type AdoptedSessionRenewalSupplier = () => Promise<AdoptedSessionRenewal | null>;
14
+ /**
15
+ * Options for the adopted-session renewal. A `null` renewal result ends the
16
+ * adoption and falls back to the ordinary cookie refresh.
17
+ */
18
+ export interface AdoptSessionOptions {
19
+ expiresAt: string;
20
+ serverTime: string;
21
+ renew: AdoptedSessionRenewalSupplier;
22
+ }
23
+ /** The adopted session as exposed to composing consumers. */
24
+ export interface AdoptedSessionState {
25
+ session: AuthenticatedSession;
26
+ expiresAt: string;
27
+ serverTime: string;
28
+ }
29
+ interface AdoptedSessionRuntimeState extends AdoptedSessionState {
30
+ generation: number;
31
+ receivedAtMs: number;
32
+ renew: AdoptedSessionRenewalSupplier;
33
+ /**
34
+ * Consecutive malformed or non-advancing renewal responses (responses that
35
+ * merely lost a race to a longer expiry excluded). Capped at
36
+ * {@link ADOPTED_RENEWAL_STALL_LIMIT} before the adoption falls back to the
37
+ * ordinary cookie refresh.
38
+ */
39
+ stalledRenewals: number;
40
+ }
3
41
  export type AuthStatus = 'loading' | 'authenticated' | 'guest';
4
42
  export type Workspace = WorkspaceSummary;
5
43
  export interface AuthState {
@@ -22,12 +60,41 @@ export declare function toAuthenticatedState(session: AuthenticatedSession, work
22
60
  export declare function useAuthState(): AuthStateValue;
23
61
  export { authRefreshQueryKey, authRefreshQueryOptions, listUserWorkspaces, userWorkspacesQueryKey, userWorkspacesQueryOptions, } from '#hooks/api/session-auth.js';
24
62
  export declare function getAuthRefreshDelayMs(token: string, nowMs?: number): number | undefined;
63
+ /**
64
+ * Delay until the adopted session's renewal point, derived from the issuer
65
+ * timestamps only. Both values come from the server, so a skewed browser
66
+ * clock cannot shorten or lengthen the window. Negative when the renewal
67
+ * point already passed; callers clamp.
68
+ */
69
+ export declare function getAdoptedSessionRenewDelayMs(expiresAt: string, serverTime: string): number;
25
70
  export declare function useAuthTransition(): {
26
71
  beginAuthTransition: () => number;
27
72
  enterAuthenticated: (session: AuthenticatedSession, transitionEpoch?: number) => Promise<boolean>;
28
73
  enterGuest: (transitionEpoch?: number) => Promise<boolean>;
29
74
  };
30
75
  export declare function useRefreshAuth(): () => Promise<AuthenticatedSession>;
76
+ /**
77
+ * The adopted-session runtime seam. An adopted session is an externally minted
78
+ * access-token-only session entered through the ordinary authenticated path;
79
+ * it suspends the cookie-based proactive refresh and runs until its issuer
80
+ * expiry, then asks the renewal supplier or falls back to the cookie.
81
+ *
82
+ * Release is terminal for the tab: it increments an adoption generation before
83
+ * ending the adoption, and a mint or renewal response is adopted only when its
84
+ * generation still matches the current one. Tabs share no adopted token and no
85
+ * release state.
86
+ *
87
+ * The renew timer asks the supplier near the issuer expiry without operator
88
+ * action. ADR 0014 renewal is a deliberate administrator action, so callers
89
+ * gate the supplier on the operator's Extend signal (returning `null`
90
+ * otherwise) to keep every extension deliberate and audited.
91
+ */
92
+ export declare function useAdoptedSession(): {
93
+ adoptSession: (session: AuthenticatedSession, options: AdoptSessionOptions) => Promise<boolean>;
94
+ renewAdoptedSession: () => Promise<AdoptedSessionRenewal | null>;
95
+ releaseAdoptedSession: () => Promise<void>;
96
+ adoptedSession: AdoptedSessionRuntimeState | null;
97
+ };
31
98
  export interface AuthRuntimeProps extends PropsWithChildren {
32
99
  effects?: boolean;
33
100
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/runtime/auth.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,OAAO,CAAC;AAE7C,OAAO,KAAK,EAAC,oBAAoB,EAAE,YAAY,EAAE,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAiB3F,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,eAAe,GAAG,OAAO,CAAC;AAE/D,MAAM,MAAM,SAAS,GAAG,gBAAgB,CAAC;AAEzC,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,eAAO,MAAM,gBAAgB,EAAE,SAA+B,CAAC;AAC/D,eAAO,MAAM,aAAa;;CAAoC,CAAC;AAG/D,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,oBAAoB,EAC7B,UAAU,GAAE,gBAAgB,EAAO,GAClC,SAAS,CAOX;AAED,wBAAgB,YAAY,IAAI,cAAc,CAY7C;AAED,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,kBAAkB,EAClB,sBAAsB,EACtB,0BAA0B,GAC3B,MAAM,4BAA4B,CAAC;AAoBpC,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAa,GAAG,MAAM,GAAG,SAAS,CAG3F;AAED,wBAAgB,iBAAiB;;kCAkCb,oBAAoB,oBAAoB,MAAM;mCAhBrC,MAAM;EAiDlC;AAED,wBAAgB,cAAc,wCAuC7B;AAED,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,WAAW,CAAC,EAAC,QAAQ,EAAE,OAAc,EAAC,EAAE,gBAAgB,6BAyEvE"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/runtime/auth.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,iBAAiB,EAAC,MAAM,OAAO,CAAC;AAE7C,OAAO,KAAK,EAAC,oBAAoB,EAAE,YAAY,EAAE,gBAAgB,EAAC,MAAM,kBAAkB,CAAC;AAkB3F;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,6BAA6B,GAAG,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;AAExF;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,6BAA6B,CAAC;CACtC;AAED,6DAA6D;AAC7D,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,UAAU,0BAA2B,SAAQ,mBAAmB;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,6BAA6B,CAAC;IACrC;;;;;OAKG;IACH,eAAe,EAAE,MAAM,CAAC;CACzB;AAqBD,MAAM,MAAM,UAAU,GAAG,SAAS,GAAG,eAAe,GAAG,OAAO,CAAC;AAE/D,MAAM,MAAM,SAAS,GAAG,gBAAgB,CAAC;AAEzC,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,UAAU,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,UAAU,CAAC,EAAE,SAAS,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,cAAe,SAAQ,SAAS;IAC/C,SAAS,EAAE,OAAO,CAAC;IACnB,eAAe,EAAE,OAAO,CAAC;IACzB,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,YAAY,EAAE,OAAO,CAAC;CACvB;AAED,eAAO,MAAM,gBAAgB,EAAE,SAA+B,CAAC;AAC/D,eAAO,MAAM,aAAa;;CAAoC,CAAC;AAG/D,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,oBAAoB,EAC7B,UAAU,GAAE,gBAAgB,EAAO,GAClC,SAAS,CAOX;AAED,wBAAgB,YAAY,IAAI,cAAc,CAY7C;AAED,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,kBAAkB,EAClB,sBAAsB,EACtB,0BAA0B,GAC3B,MAAM,4BAA4B,CAAC;AAoBpC,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAa,GAAG,MAAM,GAAG,SAAS,CAG3F;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAE3F;AAED,wBAAgB,iBAAiB;;kCAyCb,oBAAoB,oBAAoB,MAAM;mCAfrC,MAAM;EAiDlC;AAED,wBAAgB,cAAc,wCAsD7B;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB;4BA6Bb,oBAAoB,WAAW,mBAAmB,KAAG,OAAO,CAAC,OAAO,CAAC;+BAmCrC,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC;;;EAqGxF;AAED,MAAM,WAAW,gBAAiB,SAAQ,iBAAiB;IACzD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,WAAW,CAAC,EAAC,QAAQ,EAAE,OAAc,EAAC,EAAE,gBAAgB,6BAyJvE"}
@@ -6,11 +6,29 @@ import { authRefreshQueryKey, authRefreshQueryOptions, userWorkspacesQueryOption
6
6
  import { lastWorkspaceIdAtom } from './last-workspace.js';
7
7
  const REFRESH_EARLY_MS = 5 * 60 * 1000;
8
8
  const REFRESH_RETRY_DELAY_MS = 60_000;
9
+ // Consecutive renewal responses that do not advance the adopted window (a
10
+ // malformed response, or an expiry no later than the one already held) end
11
+ // the adoption instead of driving an unbounded zero-delay renew loop. A
12
+ // valid response that merely lost a race to a longer expiry is not one.
13
+ const ADOPTED_RENEWAL_STALL_LIMIT = 3;
9
14
  const BASE64_URL_REPLACEMENTS = {
10
15
  dash: /-/g,
11
16
  underscore: /_/g
12
17
  };
13
18
  const refreshPromises = new WeakMap();
19
+ const adoptedSessionAtom = atom(null);
20
+ const adoptionGenerationAtom = atom(0);
21
+ /**
22
+ * Expiry reserved by a renewal whose adoption transition is still running.
23
+ * Overlapping renewals compare against it so a shorter response cannot slip
24
+ * past the later-`expires_at` check while the longer one is entering.
25
+ *
26
+ * It lives in its own atom instead of inside {@link adoptedSessionAtom} so a
27
+ * reservation write does not re-run `AuthRuntime`'s renewal effect: an effect
28
+ * re-run while the first transition is still pending would reset the
29
+ * in-flight guard and schedule another renewal from the old expiry, letting
30
+ * an already-due adoption invoke the supplier a second time.
31
+ */ const adoptedRenewalReservationAtom = atom(0);
14
32
  function invalidateRefresh(queryClient) {
15
33
  refreshPromises.delete(queryClient);
16
34
  }
@@ -58,6 +76,14 @@ export function getAuthRefreshDelayMs(token, nowMs = Date.now()) {
58
76
  const exp = readJwtExp(token);
59
77
  return exp === undefined ? undefined : exp * 1000 - nowMs - REFRESH_EARLY_MS;
60
78
  }
79
+ /**
80
+ * Delay until the adopted session's renewal point, derived from the issuer
81
+ * timestamps only. Both values come from the server, so a skewed browser
82
+ * clock cannot shorten or lengthen the window. Negative when the renewal
83
+ * point already passed; callers clamp.
84
+ */ export function getAdoptedSessionRenewDelayMs(expiresAt, serverTime) {
85
+ return Date.parse(expiresAt) - Date.parse(serverTime) - REFRESH_EARLY_MS;
86
+ }
61
87
  export function useAuthTransition() {
62
88
  const queryClient = useQueryClient();
63
89
  const store = useStore();
@@ -70,19 +96,24 @@ export function useAuthTransition() {
70
96
  }, [
71
97
  store
72
98
  ]);
73
- const clearPrivateState = useCallback(async ()=>{
99
+ const clearPrivateState = useCallback(async (epoch)=>{
74
100
  await queryClient.cancelQueries();
101
+ // A superseded transition must not clear the cache: the clear would
102
+ // destroy an in-flight query owned by the transition that superseded it
103
+ // (for example the cookie fallback started by a release during a mint).
104
+ if (store.get(authTransitionEpochAtom) !== epoch) return false;
75
105
  queryClient.clear();
106
+ return true;
76
107
  }, [
77
- queryClient
108
+ queryClient,
109
+ store
78
110
  ]);
79
111
  const enterGuest = useCallback(async (transitionEpoch)=>{
80
112
  const isExternalTransition = transitionEpoch === undefined;
81
113
  const epoch = transitionEpoch ?? beginAuthTransition();
82
114
  if (isExternalTransition) invalidateRefresh(queryClient);
83
115
  if (store.get(authTransitionEpochAtom) !== epoch) return false;
84
- await clearPrivateState();
85
- if (store.get(authTransitionEpochAtom) !== epoch) return false;
116
+ if (!await clearPrivateState(epoch)) return false;
86
117
  setLastWorkspaceId(undefined);
87
118
  setState({
88
119
  status: 'guest'
@@ -103,9 +134,10 @@ export function useAuthTransition() {
103
134
  if (store.get(authTransitionEpochAtom) !== epoch) return false;
104
135
  const previousState = store.get(authStateAtom);
105
136
  const principalChanged = previousState.status !== 'authenticated' || previousState.user?.id !== session.user.id;
106
- if (principalChanged) await clearPrivateState();
107
- if (store.get(authTransitionEpochAtom) !== epoch) return false;
108
- if (principalChanged) setLastWorkspaceId(undefined);
137
+ if (principalChanged) {
138
+ if (!await clearPrivateState(epoch)) return false;
139
+ setLastWorkspaceId(undefined);
140
+ }
109
141
  queryClient.setQueryData(authRefreshQueryKey, session);
110
142
  let workspaces = [];
111
143
  try {
@@ -133,10 +165,22 @@ export function useAuthTransition() {
133
165
  }
134
166
  export function useRefreshAuth() {
135
167
  const queryClient = useQueryClient();
168
+ const store = useStore();
136
169
  const { beginAuthTransition, enterAuthenticated, enterGuest } = useAuthTransition();
137
170
  return useCallback(()=>{
138
171
  const existingRefresh = refreshPromises.get(queryClient);
139
172
  if (existingRefresh) return existingRefresh;
173
+ if (store.get(adoptedSessionAtom) !== null) {
174
+ // The ordinary refresh restores the cookie principal; running it while
175
+ // an adoption is live would desync the adopted token from its metadata
176
+ // and make product requests carry the administrator credential. ADR
177
+ // 0014: the adopted bearer token is the only request credential.
178
+ return Promise.reject(new ApiError({
179
+ message: 'The adopted session must end before the ordinary cookie refresh.',
180
+ code: 'unauthorized',
181
+ status: 401
182
+ }));
183
+ }
140
184
  const transitionEpoch = beginAuthTransition();
141
185
  const refresh = (async ()=>{
142
186
  try {
@@ -168,22 +212,223 @@ export function useRefreshAuth() {
168
212
  beginAuthTransition,
169
213
  enterAuthenticated,
170
214
  enterGuest,
171
- queryClient
215
+ queryClient,
216
+ store
172
217
  ]);
173
218
  }
219
+ /**
220
+ * The adopted-session runtime seam. An adopted session is an externally minted
221
+ * access-token-only session entered through the ordinary authenticated path;
222
+ * it suspends the cookie-based proactive refresh and runs until its issuer
223
+ * expiry, then asks the renewal supplier or falls back to the cookie.
224
+ *
225
+ * Release is terminal for the tab: it increments an adoption generation before
226
+ * ending the adoption, and a mint or renewal response is adopted only when its
227
+ * generation still matches the current one. Tabs share no adopted token and no
228
+ * release state.
229
+ *
230
+ * The renew timer asks the supplier near the issuer expiry without operator
231
+ * action. ADR 0014 renewal is a deliberate administrator action, so callers
232
+ * gate the supplier on the operator's Extend signal (returning `null`
233
+ * otherwise) to keep every extension deliberate and audited.
234
+ */ export function useAdoptedSession() {
235
+ const store = useStore();
236
+ const queryClient = useQueryClient();
237
+ const { beginAuthTransition, enterAuthenticated, enterGuest } = useAuthTransition();
238
+ const refreshAuth = useRefreshAuth();
239
+ const adoptedSession = useAtomValue(adoptedSessionAtom);
240
+ const endAdoption = useCallback(async ()=>{
241
+ store.set(adoptionGenerationAtom, store.get(adoptionGenerationAtom) + 1);
242
+ store.set(adoptedRenewalReservationAtom, 0);
243
+ store.set(adoptedSessionAtom, null);
244
+ try {
245
+ await refreshAuth();
246
+ } catch (error) {
247
+ // A superseded restore is not a failure: a newer transition (for
248
+ // example a fresh adoption) already owns the session, and forcing
249
+ // guest would log out that newer session.
250
+ if (error instanceof ApiError && error.message === 'Authentication refresh was superseded.') {
251
+ return;
252
+ }
253
+ // The cookie restore failed (transient network error, or a cookie that
254
+ // died while the adoption suspended the refresh). The adopted token must
255
+ // never remain the ambient request credential after Stop, so fall back
256
+ // to guest instead of leaving the tab under the adopted principal.
257
+ if (store.get(authStateAtom).status !== 'guest') await enterGuest();
258
+ }
259
+ }, [
260
+ enterGuest,
261
+ refreshAuth,
262
+ store
263
+ ]);
264
+ const adoptSession = useCallback(async (session, options)=>{
265
+ // Each adoption starts a new generation: a renewal still in flight from
266
+ // a previous adoption, or a release racing the mint, must not land over
267
+ // the new adoption.
268
+ const generation = store.get(adoptionGenerationAtom) + 1;
269
+ store.set(adoptionGenerationAtom, generation);
270
+ // A fresh adoption must not inherit an expiry reserved by a renewal of
271
+ // the previous adoption whose transition is still in flight.
272
+ store.set(adoptedRenewalReservationAtom, 0);
273
+ const transitionEpoch = beginAuthTransition();
274
+ // The transition supersedes any cookie refresh still in flight; leaving
275
+ // it in the map would make the release fallback reuse a promise that
276
+ // rejects as superseded instead of restoring the cookie session.
277
+ invalidateRefresh(queryClient);
278
+ // The token's server-side lifetime started when it was received; a slow
279
+ // workspace hydration must not push the renewal point past expiresAt.
280
+ // The monotonic clock keeps the elapsed measurement immune to wall-clock
281
+ // steps (NTP correction, VM suspend/resume).
282
+ const receivedAtMs = performance.now();
283
+ const accepted = await enterAuthenticated(session, transitionEpoch);
284
+ if (!accepted || store.get(adoptionGenerationAtom) !== generation) return false;
285
+ store.set(adoptedSessionAtom, {
286
+ generation,
287
+ receivedAtMs,
288
+ session,
289
+ expiresAt: options.expiresAt,
290
+ serverTime: options.serverTime,
291
+ renew: options.renew,
292
+ stalledRenewals: 0
293
+ });
294
+ return true;
295
+ }, [
296
+ beginAuthTransition,
297
+ enterAuthenticated,
298
+ queryClient,
299
+ store
300
+ ]);
301
+ const renewAdoptedSession = useCallback(async ()=>{
302
+ const adopted = store.get(adoptedSessionAtom);
303
+ if (adopted === null) return null;
304
+ const generation = store.get(adoptionGenerationAtom);
305
+ let result;
306
+ try {
307
+ result = await adopted.renew();
308
+ } catch {
309
+ // A failed renewal degrades like a refused one: back to the cookie.
310
+ result = null;
311
+ }
312
+ // The generation is captured when the request started; a response that
313
+ // resolves after a release is discarded.
314
+ if (store.get(adoptionGenerationAtom) !== generation) return null;
315
+ if (result === null) {
316
+ await endAdoption();
317
+ return null;
318
+ }
319
+ const candidateExpiryMs = Date.parse(result.expiresAt);
320
+ const candidateServerTimeMs = Date.parse(result.serverTime);
321
+ const candidateValid = Number.isFinite(candidateExpiryMs) && Number.isFinite(candidateServerTimeMs) && candidateExpiryMs > candidateServerTimeMs;
322
+ const current = store.get(adoptedSessionAtom);
323
+ if (current === null) return null;
324
+ const currentExpiryMs = Date.parse(current.expiresAt);
325
+ const bestReservedExpiryMs = Math.max(currentExpiryMs, store.get(adoptedRenewalReservationAtom));
326
+ // A candidate that is malformed, stuck on the expiry the adoption already
327
+ // holds, or shorter than the best expiry adopted or reserved by another
328
+ // renewal did not advance the window and is never adopted.
329
+ const windowDidNotAdvance = !candidateValid || candidateExpiryMs <= bestReservedExpiryMs;
330
+ if (windowDidNotAdvance) {
331
+ // Count a stall only when the window really did not move: a malformed
332
+ // response, or one stuck on an expiry the adoption already holds. A
333
+ // valid response that merely lost a race to a longer expiry adopted or
334
+ // reserved while this renewal was in flight is a healthy concurrent
335
+ // renewal; counting it would burn the stall budget and fire extra
336
+ // supplier requests. The cap ends the adoption instead of driving an
337
+ // unbounded zero-delay renew loop and falls back to the cookie refresh.
338
+ const lostRace = candidateValid && (candidateExpiryMs > currentExpiryMs || currentExpiryMs > Date.parse(adopted.expiresAt));
339
+ if (!candidateValid || !lostRace) {
340
+ const stalled = {
341
+ ...current,
342
+ stalledRenewals: current.stalledRenewals + 1
343
+ };
344
+ store.set(adoptedSessionAtom, stalled);
345
+ if (stalled.stalledRenewals >= ADOPTED_RENEWAL_STALL_LIMIT) {
346
+ await endAdoption();
347
+ }
348
+ }
349
+ return null;
350
+ }
351
+ if (candidateExpiryMs > store.get(adoptedRenewalReservationAtom)) {
352
+ // Reserve the candidate expiry before the asynchronous transition so an
353
+ // overlapping shorter response cannot overwrite the longer token. The
354
+ // reservation lives outside adoptedSessionAtom so this write does not
355
+ // re-run the renew effect while the transition is still pending.
356
+ store.set(adoptedRenewalReservationAtom, candidateExpiryMs);
357
+ }
358
+ const receivedAtMs = performance.now();
359
+ const transitionEpoch = beginAuthTransition();
360
+ // The renewal transition supersedes any cookie refresh still in flight;
361
+ // release must not reuse a promise that will reject as superseded.
362
+ invalidateRefresh(queryClient);
363
+ const accepted = await enterAuthenticated(result.session, transitionEpoch);
364
+ if (!accepted || store.get(adoptionGenerationAtom) !== generation) {
365
+ // Drop our reservation only while it is still the current one; another
366
+ // renewal may have reserved a later expiry meanwhile.
367
+ if (store.get(adoptedRenewalReservationAtom) === candidateExpiryMs) {
368
+ store.set(adoptedRenewalReservationAtom, 0);
369
+ }
370
+ return null;
371
+ }
372
+ store.set(adoptedRenewalReservationAtom, 0);
373
+ store.set(adoptedSessionAtom, {
374
+ generation,
375
+ receivedAtMs,
376
+ session: result.session,
377
+ expiresAt: result.expiresAt,
378
+ serverTime: result.serverTime,
379
+ renew: adopted.renew,
380
+ stalledRenewals: 0
381
+ });
382
+ return result;
383
+ }, [
384
+ beginAuthTransition,
385
+ endAdoption,
386
+ enterAuthenticated,
387
+ queryClient,
388
+ store
389
+ ]);
390
+ const releaseAdoptedSession = useCallback(async ()=>{
391
+ // Always advance the generation and fall back to the cookie: a release
392
+ // while a mint is still entering must still invalidate that mint, or the
393
+ // mint would re-enter the session after Stop.
394
+ await endAdoption();
395
+ }, [
396
+ endAdoption
397
+ ]);
398
+ return {
399
+ adoptSession,
400
+ renewAdoptedSession,
401
+ releaseAdoptedSession,
402
+ adoptedSession
403
+ };
404
+ }
174
405
  export function AuthRuntime({ children, effects = true }) {
175
406
  const store = useStore();
176
407
  const authState = useAtomValue(authStateAtom);
177
408
  const refreshAuth = useRefreshAuth();
409
+ const { adoptedSession, renewAdoptedSession, releaseAdoptedSession } = useAdoptedSession();
178
410
  useEffect(()=>{
179
411
  if (!effects) return;
180
412
  configureApiClient({
181
413
  getAccessToken: ()=>store.get(authStateAtom).token,
182
- refreshAccessToken: async ()=>(await refreshAuth()).accessToken
414
+ refreshAccessToken: async ()=>{
415
+ if (store.get(adoptedSessionAtom) !== null) {
416
+ // A 401 under an adopted token ends the adoption: the cookie
417
+ // refresh restores the cookie's principal, and the renew timer must
418
+ // not resurrect the adopted session afterwards. The falsy result
419
+ // stops the failed request from being re-sent under the restored
420
+ // administrator's principal (ADR 0014: the adopted bearer token is
421
+ // the only request credential).
422
+ await releaseAdoptedSession();
423
+ return undefined;
424
+ }
425
+ return (await refreshAuth()).accessToken;
426
+ }
183
427
  });
184
428
  }, [
185
429
  effects,
186
430
  refreshAuth,
431
+ releaseAdoptedSession,
187
432
  store
188
433
  ]);
189
434
  useEffect(()=>{
@@ -194,7 +439,9 @@ export function AuthRuntime({ children, effects = true }) {
194
439
  refreshAuth
195
440
  ]);
196
441
  useEffect(()=>{
197
- if (!effects || authState.status !== 'authenticated' || !authState.token) return;
442
+ if (!effects || authState.status !== 'authenticated' || !authState.token || adoptedSession !== null) {
443
+ return;
444
+ }
198
445
  let timeout;
199
446
  let disposed = false;
200
447
  let refreshing = false;
@@ -209,11 +456,12 @@ export function AuthRuntime({ children, effects = true }) {
209
456
  const retryIfStillDue = ()=>{
210
457
  const current = store.get(authStateAtom);
211
458
  if (current.status !== 'authenticated' || !current.token) return;
459
+ if (store.get(adoptedSessionAtom) !== null) return;
212
460
  const delay = getAuthRefreshDelayMs(current.token);
213
461
  if (delay !== undefined && delay <= 0) scheduleRefresh(REFRESH_RETRY_DELAY_MS);
214
462
  };
215
463
  function runRefresh() {
216
- if (disposed || refreshing) return;
464
+ if (disposed || refreshing || store.get(adoptedSessionAtom) !== null) return;
217
465
  refreshing = true;
218
466
  clearRefreshTimer();
219
467
  refreshAuth().catch(()=>undefined).finally(()=>{
@@ -224,6 +472,7 @@ export function AuthRuntime({ children, effects = true }) {
224
472
  const refreshIfDue = ()=>{
225
473
  const current = store.get(authStateAtom);
226
474
  if (current.status !== 'authenticated' || !current.token) return;
475
+ if (store.get(adoptedSessionAtom) !== null) return;
227
476
  const delay = getAuthRefreshDelayMs(current.token);
228
477
  if (delay !== undefined && delay <= 0) runRefresh();
229
478
  };
@@ -243,12 +492,71 @@ export function AuthRuntime({ children, effects = true }) {
243
492
  document.removeEventListener('visibilitychange', refreshIfVisible);
244
493
  };
245
494
  }, [
495
+ adoptedSession,
246
496
  authState.status,
247
497
  authState.token,
248
498
  effects,
249
499
  refreshAuth,
250
500
  store
251
501
  ]);
502
+ useEffect(()=>{
503
+ if (!effects || adoptedSession === null) return;
504
+ let timeout;
505
+ let disposed = false;
506
+ let renewing = false;
507
+ let nextFireAtMs = 0;
508
+ const clearRenewTimer = ()=>{
509
+ if (timeout !== undefined) clearTimeout(timeout);
510
+ timeout = undefined;
511
+ };
512
+ const scheduleRenew = ()=>{
513
+ clearRenewTimer();
514
+ const current = store.get(adoptedSessionAtom);
515
+ if (current === null) return;
516
+ // The fire point comes from the issuer timestamps; elapsed time is
517
+ // measured on the monotonic clock so a wall-clock step (NTP correction,
518
+ // VM suspend/resume) cannot shift the renewal point.
519
+ const earlyFireAtMs = current.receivedAtMs + getAdoptedSessionRenewDelayMs(current.expiresAt, current.serverTime);
520
+ // A missed early point (tab suspended, timer throttled) must renew now
521
+ // instead of waiting for the hard expiry.
522
+ nextFireAtMs = Math.max(earlyFireAtMs, performance.now());
523
+ timeout = setTimeout(runRenew, Math.max(0, nextFireAtMs - performance.now()));
524
+ };
525
+ const runRenew = ()=>{
526
+ if (disposed || renewing) return;
527
+ renewing = true;
528
+ clearRenewTimer();
529
+ renewAdoptedSession().catch(()=>undefined).finally(()=>{
530
+ renewing = false;
531
+ // Re-arm when the adoption survived (for example a racing response
532
+ // with an earlier expiry was discarded); the effect re-runs and
533
+ // re-arms when a renewal is adopted instead.
534
+ if (!disposed) scheduleRenew();
535
+ });
536
+ };
537
+ const renewIfDue = ()=>{
538
+ if (performance.now() >= nextFireAtMs) runRenew();
539
+ };
540
+ const renewIfVisible = ()=>{
541
+ if (document.visibilityState === 'visible') renewIfDue();
542
+ };
543
+ scheduleRenew();
544
+ window.addEventListener('focus', renewIfDue);
545
+ window.addEventListener('online', renewIfDue);
546
+ document.addEventListener('visibilitychange', renewIfVisible);
547
+ return ()=>{
548
+ disposed = true;
549
+ clearRenewTimer();
550
+ window.removeEventListener('focus', renewIfDue);
551
+ window.removeEventListener('online', renewIfDue);
552
+ document.removeEventListener('visibilitychange', renewIfVisible);
553
+ };
554
+ }, [
555
+ adoptedSession,
556
+ effects,
557
+ renewAdoptedSession,
558
+ store
559
+ ]);
252
560
  return children;
253
561
  }
254
562