@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.
- package/dist/core/session.d.ts +6 -0
- package/dist/core/session.d.ts.map +1 -1
- package/dist/core/session.js.map +1 -1
- package/dist/hooks/api/session-mapper.d.ts +3 -2
- package/dist/hooks/api/session-mapper.d.ts.map +1 -1
- package/dist/hooks/api/session-mapper.js +4 -1
- package/dist/hooks/api/session-mapper.js.map +1 -1
- package/dist/runtime/auth.d.ts +67 -0
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +319 -11
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +5 -5
package/dist/core/session.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/core/session.js.map
CHANGED
|
@@ -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":"
|
|
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 {
|
|
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:
|
|
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,
|
|
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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/hooks/api/session-mapper.ts"],"sourcesContent":["import type {
|
|
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"}
|
package/dist/runtime/auth.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/runtime/auth.js
CHANGED
|
@@ -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)
|
|
107
|
-
|
|
108
|
-
|
|
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 ()=>
|
|
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)
|
|
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
|
|