@flytedesk/app-kit 6.0.0 → 7.1.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 (45) hide show
  1. package/README.md +192 -41
  2. package/dist/auth/client/authSession.d.ts +168 -0
  3. package/dist/auth/client/authSession.js +581 -0
  4. package/dist/auth/client/authSession.js.map +1 -0
  5. package/dist/auth/client/browserEnvironment.d.ts +22 -0
  6. package/dist/auth/client/browserEnvironment.js +87 -0
  7. package/dist/auth/client/browserEnvironment.js.map +1 -0
  8. package/dist/auth/client/httpClient.d.ts +32 -28
  9. package/dist/auth/client/httpClient.js +91 -84
  10. package/dist/auth/client/httpClient.js.map +1 -1
  11. package/dist/auth/client/index.d.ts +15 -8
  12. package/dist/auth/client/index.js +13 -7
  13. package/dist/auth/client/index.js.map +1 -1
  14. package/dist/auth/client/testing.d.ts +6 -0
  15. package/dist/auth/client/testing.js +20 -0
  16. package/dist/auth/client/testing.js.map +1 -0
  17. package/dist/auth/client/useAuthSession.d.ts +54 -91
  18. package/dist/auth/client/useAuthSession.js +65 -252
  19. package/dist/auth/client/useAuthSession.js.map +1 -1
  20. package/dist/auth/index.d.ts +3 -4
  21. package/dist/auth/index.js +1 -2
  22. package/dist/auth/index.js.map +1 -1
  23. package/dist/auth/oidc-client.d.ts +18 -4
  24. package/dist/auth/oidc-client.js +18 -1
  25. package/dist/auth/oidc-client.js.map +1 -1
  26. package/dist/auth/plugin.js +244 -225
  27. package/dist/auth/plugin.js.map +1 -1
  28. package/dist/auth/returnTarget.d.ts +30 -0
  29. package/dist/auth/returnTarget.js +48 -0
  30. package/dist/auth/returnTarget.js.map +1 -0
  31. package/dist/auth/shared-types.d.ts +18 -41
  32. package/dist/auth/shared-types.js +18 -45
  33. package/dist/auth/shared-types.js.map +1 -1
  34. package/dist/auth/testing/fake-idp.d.ts +29 -1
  35. package/dist/auth/testing/fake-idp.js +142 -31
  36. package/dist/auth/testing/fake-idp.js.map +1 -1
  37. package/dist/auth/types.d.ts +26 -29
  38. package/dist/bigquery/index.d.ts +1 -1
  39. package/dist/bigquery/params.d.ts +6 -0
  40. package/dist/bigquery/params.js +79 -0
  41. package/dist/bigquery/params.js.map +1 -0
  42. package/dist/bigquery/query.js +3 -0
  43. package/dist/bigquery/query.js.map +1 -1
  44. package/dist/bigquery/types.d.ts +37 -4
  45. package/package.json +9 -3
package/README.md CHANGED
@@ -27,6 +27,156 @@ own domain work resumes.
27
27
  pnpm add @flytedesk/app-kit
28
28
  ```
29
29
 
30
+ ## Building a flytedesk ID app
31
+
32
+ **This is the one supported way to sign people in to a flytedesk app** (PLN-2 R-14). Every
33
+ flytedesk ID app (sms-app, media-planner, and every app after them) uses it unchanged.
34
+ The whole session lifecycle lives in `@flytedesk/app-kit`:
35
+
36
+ - automatic sign-in;
37
+ - the return to the exact link;
38
+ - session-loss detection and re-auth;
39
+ - the periodic check;
40
+ - outages;
41
+ - sign-out;
42
+ - cross-tab behaviour.
43
+
44
+ An app renders its screens and nothing else.
45
+
46
+ ### What the user sees
47
+
48
+ - **Opening the app signed out.** A "Signing you in…" screen, then a top-level
49
+ navigation through flytedesk ID.
50
+ - If flytedesk ID is still signed in, the user is back on **the exact link they
51
+ opened** (path, query and hash) a moment later.
52
+ - Otherwise they are on flytedesk ID's own sign-in page, and signing in there brings
53
+ them back to that link.
54
+ - **There is no sign-in button page, ever.**
55
+ - **The session ending while the app is open.** The session can end when a refresh is
56
+ refused, when it is revoked, or when the user signs out in another tab. The app
57
+ disappears behind a "Session expired — signing you back in" screen and goes through
58
+ the same automatic sign-in back to the same URL. No panel ever shows its own auth
59
+ error.
60
+ - **Checks.** A visible tab verifies its session every 10 minutes. A tab also re-checks
61
+ when it becomes visible or focused, at most once a minute. A hidden tab never
62
+ redirects; it re-checks when it is looked at.
63
+ - **An outage (flytedesk ID or the app's store down) is never a sign-out.** Signed in, the
64
+ app keeps rendering with a `status: "unavailable"` session and retries with backoff.
65
+ Before any user is known, the screen shows "Reconnecting…".
66
+ - **A sign-in that fails, or comes back still signed out, never loops.** The screen says
67
+ what happened and offers **Try again**. This covers:
68
+ - cancelled at flytedesk ID;
69
+ - an attempt that took too long;
70
+ - cookies being blocked;
71
+ - Back from flytedesk ID's page.
72
+ - **Sign-out** ends the app's session **and** flytedesk ID's (RP-initiated logout). The
73
+ next sign-in on that browser must be made at flytedesk ID even if a race left
74
+ flytedesk ID's session alive: the server sets a signed-out marker that forces
75
+ `prompt=login`. **A signed-out user is never bounced straight back in.** Signing out of
76
+ one flytedesk app signs the browser out of flytedesk ID, so every other flytedesk app
77
+ asks again on its next request.
78
+ - **Other tabs.** Signing in or out in one tab is reflected in the others.
79
+ - Only one tab navigates to flytedesk ID at a time; the others wait for it.
80
+ - A tab signed in as a different user reloads, so no page ever shows one user's data
81
+ as another's.
82
+
83
+ ### What an app writes
84
+
85
+ **The server** registers the plugin:
86
+
87
+ ```ts
88
+ await api.register(flytedeskAuth, {
89
+ clientId, clientSecret, issuer, redirectUri, appId,
90
+ // The app's web URL. Return targets resolve against it, a failed sign-in lands on it
91
+ // (with signin_error), and it is the post-logout redirect: register it at flytedesk ID
92
+ // as this client's post_logout_redirect_uri too.
93
+ postLoginRedirect: "https://my-app.flytedesk.com/",
94
+ cookieSecret, secureCookies, accessTokenSecret, store,
95
+ });
96
+ ```
97
+
98
+ Its `AuthStore` persists `IdpSessionRow.idToken`, which is sent back to flytedesk ID as the
99
+ logout's `id_token_hint`. That is what lets flytedesk ID end its session without a
100
+ confirmation step for a real sign-out, while still asking on a forged one.
101
+
102
+ **The web app** configures the client once and wraps its shell:
103
+
104
+ ```tsx
105
+ import { configureApiClient, SessionBoundary, type AuthedUserDto } from "@flytedesk/app-kit/auth/client";
106
+ import { SessionScreen } from "@flytedan/flytebot-design-system";
107
+
108
+ configureApiClient({ baseUrl: import.meta.env.VITE_API_BASE_URL });
109
+
110
+ export function App() {
111
+ return (
112
+ <SessionBoundary<AuthedUserDto>
113
+ screen={(state) => <SessionScreen {...state} productName="SMS" />}
114
+ >
115
+ {(session) => <Shell session={session} />}
116
+ </SessionBoundary>
117
+ );
118
+ }
119
+ ```
120
+
121
+ `SessionBoundary` renders the app only while the session is usable: `authenticated`, or
122
+ `unavailable` with a last-known `user`, which is **display-only** and never a basis for an
123
+ authorization decision. In every other state the app is **unmounted**, so none of its
124
+ polls or timers can keep calling the api for a session that is over.
125
+
126
+ - **The app's own code** calls the api with `apiRequest` / `apiUpload` / `apiDownload`.
127
+ It reads the session with `useAuthSession<TUser>()` in React, or with
128
+ `authSession.getSnapshot()` and `authSession.subscribe()` anywhere else.
129
+ - **Its shell** wires `session.signOut` to the account menu. `signOut()` navigates away
130
+ on success, and rejects only when the server did not end the session.
131
+ - **After changing the user server-side** (a profile edit), the app calls
132
+ `authSession.refreshUser()`.
133
+
134
+ `@flytedesk/app-kit` never imports the design system, and the design system never imports
135
+ `@flytedesk/app-kit`: `SessionScreenState` (`{ kind, error, onRetry }`) is a plain shape
136
+ `SessionScreen` accepts.
137
+
138
+ ### What an app must not build
139
+
140
+ Everything below is app-kit's:
141
+
142
+ - a sign-in page or button;
143
+ - a return-target stash or post-login landing route;
144
+ - a silent / iframe resume;
145
+ - its own session-loss, 401 or "reconnecting" handling;
146
+ - a sign-out flow;
147
+ - cross-tab session code;
148
+ - token handling.
149
+
150
+ `setAccessToken` and the other internals are not exported. An app's unit tests use
151
+ `@flytedesk/app-kit/auth/client/testing` (`setTestAccessToken`,
152
+ `resetAuthSessionForTests`). If an app needs something the lifecycle doesn't do, change
153
+ app-kit.
154
+
155
+ ### The draft-survival contract
156
+
157
+ When a session ends mid-use, the app is unmounted and the tab navigates through
158
+ flytedesk ID. **In-memory state is gone by design.** So:
159
+
160
+ - Anything a user would be upset to lose (an unsaved draft, a half-built query) must
161
+ already be in browser storage, written as they edit, never only on unload. Pending
162
+ debounced writes are flushed on unmount.
163
+ - Keep it **keyed by `user.id`**, so a different user signing in on the same browser
164
+ never sees it.
165
+ - After the re-auth the browser is back on the exact URL, and the app rehydrates from
166
+ storage.
167
+ - app-kit never reads, writes or clears app storage. Sign-out does not delete drafts,
168
+ because they are user-keyed.
169
+
170
+ ### Configuration at flytedesk ID
171
+
172
+ Register the app's client with:
173
+
174
+ - its callback as a `redirect_uri`;
175
+ - its `postLoginRedirect` as a `post_logout_redirect_uri` (FD-136).
176
+
177
+ Sign-in requests `offline_access` with `prompt=consent` (AK-33), so every session gets
178
+ a refresh token.
179
+
30
180
  ## Auth (`@flytedesk/app-kit/auth`)
31
181
 
32
182
  `idpRequestTimeoutMs` (`FlytedeskAuthOptions`/`OidcClientOptions`, default 5s) bounds
@@ -58,10 +208,10 @@ secret), a lock timeout, any `AuthStore` error, a *throwing* `onAuthorizeUser`
58
208
  - The body is always `{ error, code }` with `code` one of `AUTH_UNAVAILABLE_CODES`:
59
209
  `idp_unavailable`, `auth_store_unavailable` or `authorization_hook_failed`.
60
210
  - The browser client raises `AuthUnavailableError` (an `ApiRequestError`) for it and
61
- never runs the auth recovery; `onAuthUnavailable(listener)` announces it. The
62
- automatic refresh treats only the plugin's 401 as "signed out": a network error, its
63
- 10s deadline, a proxy's 502/504 or any other non-200 is an `AuthUnavailableError` too
64
- (code `auth_unreachable`). `useAuthSession` keeps the session in
211
+ never treats it as a session end. The automatic refresh treats only the plugin's 401 as
212
+ "signed out": a network error, its 10s deadline, a proxy's 502/504 or any other non-200
213
+ is an `AuthUnavailableError` too (code `auth_unreachable`). The session lifecycle keeps
214
+ the session in
65
215
  `status: "unavailable"` (with the last verified `user` — display-only, not a verified
66
216
  identity — and `retryInMs`) and re-verifies by itself with jittered exponential
67
217
  backoff (ceilings 1s, 2s, 4s, … capped at 30s, each delay drawn from the ceiling's
@@ -69,10 +219,14 @@ secret), a lock timeout, any `AuthStore` error, a *throwing* `onAuthorizeUser`
69
219
  sign-in screen. A failing `/apps` never blocks the session: `apps` is `[]` and
70
220
  `appsError` says why.
71
221
 
72
- **Sign-out always clears the cookie (4.0).** `POST {routePrefix}/logout` clears the
73
- refresh cookie in a `finally`, attempts each store step (revoke the family, look up and
74
- delete the IdP session) independently, and answers `502 logout_store_failed` (logged)
75
- if any step failed.
222
+ **Sign-out always ends the session, and flytedesk ID's too.** `POST {routePrefix}/logout`
223
+ accepts only the app's own `Origin` (`postLoginRedirect`'s; anything else is `403
224
+ forbidden_origin`, touching nothing). It clears the refresh cookie and sets the
225
+ signed-out marker (so the next `/login` asks flytedesk ID for `prompt=login`) in a
226
+ `finally`, attempts each store step (revoke the family, look up and delete the IdP
227
+ session) independently, and answers `{ endSessionUrl }` — flytedesk ID's RP-initiated
228
+ logout, carrying the session's id_token as `id_token_hint` — or `502
229
+ logout_store_failed` (logged) still carrying `endSessionUrl` when a store step failed.
76
230
 
77
231
  **Replays of ended families are not reuse (4.0).** A presented refresh token that was
78
232
  *rotated* is a concurrent-tab race inside `refreshReuseGraceMs` and reuse outside it
@@ -118,17 +272,11 @@ token back), and a store must not do more work in the transaction after `fn` ret
118
272
  honours the whole contract) and `FakeIdp`, whose controls cover the negative paths:
119
273
  `setForceSubjectMismatch`, `setForceInvalidClient`, an identity's `emailVerified: false`,
120
274
  `setMyAppsFailureStatus`, `setForceRefreshFailure`, the `setHang*Ms` family, and
121
- `refreshGrantCount`.
122
-
123
- **The silent-resume page is hardened (4.0).** The `prompt=none` callback's postMessage
124
- page carries only a fixed `SilentAuthDetailCode` (anything unknown becomes `"other"`),
125
- serializes its payload so `<`, `>`, `&`, U+2028 and U+2029 can't escape the inline
126
- script, and is served with
127
- `Content-Security-Policy: default-src 'none'; script-src 'sha256-…'; frame-ancestors <postLoginRedirect origin>; base-uri 'none'`
128
- plus `X-Content-Type-Options: nosniff`.
129
-
130
- `onSignIn(profile, request, context)` receives `context.mode`: `"silent"` for a
131
- hidden-iframe resume, `"interactive"` for a visible sign-in.
275
+ `refreshGrantCount`. It models flytedesk ID's sign-in and sign-out too: with no live
276
+ session (`setIdpSessionLive(false)`) or on `prompt=login`, `/auth` shows a sign-in
277
+ interaction page (`interactionCount`) instead of answering straight away, and
278
+ `/session/end` performs the RP-initiated logout against its registered
279
+ `postLogoutRedirectUris` (`endSessionCount`).
132
280
 
133
281
  **Residual risk in the refresh-token grace window (RFC 9700 §4.14.2).** `POST
134
282
  {routePrefix}/refresh` rotates the presented refresh token on every call and treats a
@@ -172,28 +320,15 @@ token (a lost response, a concurrent tab) that lands on an instance with the OTH
172
320
  cannot re-derive the successor and answers 401 — the client treats that browser as signed
173
321
  out. Keep the change short, or drain traffic to one secret at a time.
174
322
 
175
- **Failed interactive sign-ins land back in the app, not on a raw JSON error page.**
176
- `signInUrl` (`FlytedeskAuthOptions`, required) is where `GET {routePrefix}/callback`
177
- redirects an INTERACTIVE (not `prompt=none`) sign-in failure — idp_error (including
178
- `access_denied` and `login_required`), an invalid/expired login-attempt cookie, a state
179
- mismatch, or a token exchange/verification failure — with `?signin_error=<code>`
180
- appended, `<code>` being one of `"expired"`, `"cancelled"`, or `"other"` (never the raw
181
- internal failure reason, which is logged server-side only). A **silent** (`prompt=none`)
182
- callback failure is unaffected: it still answers through the existing postMessage
183
- contract (`SilentAuthMessage`), consumed only inside `./client/silentAuth.js`'s hidden
184
- iframe. When the login-attempt cookie is missing entirely (its 10-minute
185
- `loginAttemptTtlSeconds` already elapsed), the `silent` flag can't be recovered — this is
186
- deliberately always treated as interactive (`signin_error=expired`): a genuinely silent
187
- attempt runs in a hidden iframe and resolves within seconds, so an expired cookie in
188
- practice only ever means an interactive one, and a redirect delivered inside a hidden
189
- iframe is harmless (invisible to the user; the parent page's own listener just times out
190
- as it would have anyway).
191
-
192
- `./client/signInError.js`'s `consumeSignInError()` reads and clears `signin_error` from
193
- the current page's URL and returns the plain message to render (or `null` when there
194
- isn't one) — e.g. `error={consumeSignInError()}` straight into
195
- `@flytedan/flytebot-design-system`'s `SignInPage` (0.21.0+). `@flytedesk/app-kit` itself
196
- never depends on the design system; this returns a plain string.
323
+ **Sign-in returns to the exact link; a failed one lands back in the app, never on a raw
324
+ error page.** `GET {routePrefix}/login?returnTo=<path?query#hash>` keeps a same-origin
325
+ target (`safeReturnTarget`; an unsafe or oversize one is dropped and logged, never a
326
+ 400) in a signed login-attempt cookie named after the attempt's `state` — so two tabs
327
+ signing in at once never break each other, and at most two attempt cookies exist at a
328
+ time. The callback redirects to that target, or to `postLoginRedirect`. A failed
329
+ callback redirects to the same place with `?signin_error=<expired|cancelled|other>`
330
+ (never the raw internal reason, which is logged server-side only); the browser lifecycle
331
+ reads and strips it and shows the sign-in-failed state with a manual retry.
197
332
 
198
333
  ## BigQuery (`@flytedesk/app-kit/bigquery`)
199
334
 
@@ -209,6 +344,14 @@ import { createBigQueryReadClient } from "@flytedesk/app-kit/bigquery";
209
344
  const client = createBigQueryReadClient(new BigQuery());
210
345
  ```
211
346
 
347
+ **Query parameter types** (`types` on `runQuery` / `estimateQueryBytes`) take the SDK's own shape
348
+ (`BigQueryParamTypes`): `"INT64"` for a scalar, `["INT64"]` for an ARRAY<INT64>, `{ id: "INT64" }`
349
+ for a STRUCT. Declare the type of every empty array and every NULL — the SDK infers an array's
350
+ element type only from its first element. A bare string (or any value with no `.value`) declared DATE, DATETIME, TIME,
351
+ TIMESTAMP or GEOGRAPHY is bound as NULL by the SDK (it reads `.value` off `BigQuery.date()` and
352
+ friends), so both functions reject it with a `BadQueryError` before creating a job; carry a date as
353
+ a STRING and convert in the SQL (`DATE(@since)`).
354
+
212
355
  **Supported `@google-cloud/bigquery` version range: `^9.0.0`** (verified against
213
356
  `9.1.0`). `AK-21` added a compile-time conformance test
214
357
  (`src/bigquery/type-safety.test.ts`, run as part of `npm run check`) that
@@ -222,6 +365,14 @@ app-kit's own gate instead. Bumping the supported range means re-running that
222
365
  test against the new version and updating this section — a passing gate is
223
366
  the actual proof, not this paragraph.
224
367
 
368
+ ## Tests
369
+
370
+ `npm run check` is the full gate: lint, typecheck, build, unit tests, integration tests
371
+ (Docker, via testcontainers), the real-browser session-lifecycle tests (Playwright, which
372
+ load the built `dist/`), and the package-exports check. The browser tests need Chromium
373
+ once per machine: `npx playwright install chromium`. `npm run test:browser` builds and
374
+ runs them alone.
375
+
225
376
  ## Publishing
226
377
 
227
378
  Published to the public npm registry under the `@flytedesk` org, access `restricted`.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The flytedesk ID session lifecycle — the ONE owner of every sign-in and session
3
+ * decision a flytedesk ID app makes (AK-35, PLN-2 R-14; design: PLN-2 architecture
4
+ * "flytedesk ID session lifecycle"). An app never builds any of this itself: it renders
5
+ * its shell for a usable session and the design system's `SessionScreen` for the rest
6
+ * (`./useAuthSession.ts`'s `SessionBoundary`), and reads the session through
7
+ * `authSession` / `useAuthSession`.
8
+ *
9
+ * - Signed out: shows "signing in" and sends the browser through flytedesk ID with a
10
+ * top-level navigation, carrying the exact `path?query#hash` as the return target.
11
+ * Sign-in is only ever that navigation: it is the one request that can carry
12
+ * `prompt=consent`, which flytedesk ID requires before it issues a refresh token
13
+ * (see `../plugin.ts`'s `/login`).
14
+ * - Session lost mid-use (a refused refresh, `onSessionEnded`): "session expired", then
15
+ * the same automatic sign-in, back to the same URL.
16
+ * - Checked every 10 minutes while the tab is visible, and when it becomes visible or
17
+ * focused (at most once a minute).
18
+ * - An outage (`AuthUnavailableError`) is never a sign-out: "unavailable", retried with
19
+ * backoff (AK-23).
20
+ * - Sign-out ends this app's session and flytedesk ID's (RP-initiated logout), and never
21
+ * bounces straight back in (the server's signed-out marker forces `prompt=login`).
22
+ * - Cross-tab: only a visible tab navigates, and while one tab is signing in (a short
23
+ * claim) the others wait for its `signed-in` message.
24
+ * - Loop guard: a tab that arrives back still signed out within two minutes of starting
25
+ * a sign-in, or with `signin_error`, shows the sign-in-failed state with a manual
26
+ * retry — never another automatic redirect.
27
+ *
28
+ * Framework-agnostic and browser-safe (ZERO Node imports, like `./httpClient.ts`). Every
29
+ * browser API it touches comes through an `AuthSessionEnvironment`, so the state machine
30
+ * is tested with a scripted environment and the real one is `browserEnvironment()`.
31
+ */
32
+ import { AuthUnavailableError } from "./httpClient.js";
33
+ import { type AppSwitcherEntry, type SignInErrorCode } from "../shared-types.js";
34
+ /** How often a visible, signed-in tab verifies its session. */
35
+ export declare const SESSION_CHECK_INTERVAL_MS: number;
36
+ /** A tab becoming visible or focused re-checks at most this often. */
37
+ export declare const FOCUS_CHECK_MIN_INTERVAL_MS = 60000;
38
+ /** A tab arriving back still signed out this soon after starting a sign-in is a loop. */
39
+ export declare const SIGN_IN_LOOP_WINDOW_MS: number;
40
+ /** How long one tab's sign-in claim makes the other visible tabs wait for it — a round
41
+ * trip while flytedesk ID is still signed in. */
42
+ export declare const SIGN_IN_CLAIM_MS = 20000;
43
+ /** A page still here this long after handing the browser to a top-level navigation was
44
+ * not navigated (the user cancelled it): it acts on its own behalf again. Well past a
45
+ * slow round trip, during which the old page legitimately stays up. */
46
+ export declare const NAVIGATION_STALL_MS = 60000;
47
+ /** First delay before re-verifying a session the server could not verify (AK-23). */
48
+ export declare const UNAVAILABLE_RETRY_BASE_MS = 1000;
49
+ /** The backoff's ceiling: an outage is retried every this-many ms at most. */
50
+ export declare const UNAVAILABLE_RETRY_MAX_MS = 30000;
51
+ /** Storage keys and the BroadcastChannel name — one namespace, never an app's. */
52
+ export declare const SIGN_IN_ATTEMPT_KEY = "flytedesk-app-kit.auth.signInStartedAt";
53
+ export declare const SIGN_IN_CLAIM_KEY = "flytedesk-app-kit.auth.signInClaim";
54
+ export declare const SESSION_CHANNEL = "flytedesk-app-kit-session";
55
+ /**
56
+ * Jittered exponential backoff for the `"unavailable"` state's automatic retries: the
57
+ * ceiling doubles per attempt (1s, 2s, 4s, … capped at UNAVAILABLE_RETRY_MAX_MS) and the
58
+ * delay is drawn uniformly from its upper half ("equal jitter", AK-23 L-c) — so every
59
+ * tab that saw the same outage does not retry in the same instant when it ends.
60
+ */
61
+ export declare function unavailableRetryDelayMs(attempt: number, random?: () => number): number;
62
+ /** The plain message each sign-in failure shows (AK-19's closed code set). */
63
+ export declare const SIGN_IN_ERROR_MESSAGES: Record<SignInErrorCode, string>;
64
+ /** Verifying the session (at boot, after a bfcache restore, or on retry). */
65
+ export interface CheckingState {
66
+ status: "checking";
67
+ }
68
+ export interface AuthenticatedState<TUser> {
69
+ status: "authenticated";
70
+ user: TUser;
71
+ /** The "All flytedesk apps" switcher feed — decoration, never the session (AK-23 N2). */
72
+ apps: AppSwitcherEntry[];
73
+ appsError: Error | null;
74
+ }
75
+ /** Signed out, or the session ended: the browser is on its way to flytedesk ID (a
76
+ * hidden tab waits until it is looked at; a visible tab may wait on another tab's
77
+ * sign-in claim). */
78
+ export interface SigningInState {
79
+ status: "signing-in";
80
+ reason: "signed-out" | "expired";
81
+ }
82
+ /**
83
+ * The server could not verify the session right now (AK-23) — flytedesk-id or the app's
84
+ * AuthStore is down. NOT signed out: re-verified with backoff (`retryInMs`). `user`, when
85
+ * set, is who was signed in when the outage began — DISPLAY-ONLY, never a basis for an
86
+ * authorization decision.
87
+ */
88
+ export interface UnavailableState<TUser> {
89
+ status: "unavailable";
90
+ user: TUser | null;
91
+ apps: AppSwitcherEntry[];
92
+ appsError: Error | null;
93
+ error: AuthUnavailableError;
94
+ retryInMs: number;
95
+ }
96
+ export interface SigningOutState {
97
+ status: "signing-out";
98
+ }
99
+ /** A sign-in that did not complete (`sign-in-failed`: retry navigates to flytedesk ID
100
+ * by hand) or a session check that failed for a reason other than being signed out
101
+ * or an outage (`load-failed`: retry checks again). */
102
+ export interface ErrorState {
103
+ status: "error";
104
+ kind: "sign-in-failed" | "load-failed";
105
+ error: Error;
106
+ }
107
+ export type AuthSessionState<TUser> = CheckingState | AuthenticatedState<TUser> | SigningInState | UnavailableState<TUser> | SigningOutState | ErrorState;
108
+ /** Storage the lifecycle keeps its own two keys in. Any method may throw (storage
109
+ * blocked), which the lifecycle handles per key. */
110
+ export interface LifecycleStorage {
111
+ getItem(key: string): string | null;
112
+ setItem(key: string, value: string): void;
113
+ removeItem(key: string): void;
114
+ }
115
+ export type SessionMessage = {
116
+ type: "signed-in";
117
+ userId: string;
118
+ } | {
119
+ type: "signed-out";
120
+ };
121
+ /** Every browser API the lifecycle uses. `browserEnvironment()` is the real one. */
122
+ export interface AuthSessionEnvironment {
123
+ /** The page's current URL. */
124
+ href(): string;
125
+ /** A top-level navigation (`location.assign`). */
126
+ navigate(url: string): void;
127
+ /** Rewrites the current URL without navigating (`history.replaceState`). */
128
+ replaceUrl(url: string): void;
129
+ reload(): void;
130
+ isVisible(): boolean;
131
+ /** Subscribes to becoming visible (`visibilitychange` to "visible") and to `focus`. */
132
+ onAttention(listener: () => void): () => void;
133
+ /** Subscribes to a restore from the back/forward cache (`pageshow` with `persisted`). */
134
+ onRestore(listener: () => void): () => void;
135
+ /** Per tab — survives the sign-in round trip in this tab only. */
136
+ sessionStorage(): LifecycleStorage;
137
+ /** Shared by every tab of the app's origin. */
138
+ localStorage(): LifecycleStorage;
139
+ /** The cross-tab channel (BroadcastChannel `SESSION_CHANNEL`). */
140
+ openChannel(onMessage: (message: SessionMessage) => void): {
141
+ post(message: SessionMessage): void;
142
+ close(): void;
143
+ };
144
+ }
145
+ export interface AuthSessionController {
146
+ /** The current state, as one stable object per change (for `useSyncExternalStore`). */
147
+ getSnapshot<TUser>(): AuthSessionState<TUser>;
148
+ /** Starts the lifecycle on the first subscription. Returns the unsubscribe. */
149
+ subscribe(listener: () => void): () => void;
150
+ /**
151
+ * Explicit sign-out: ends this app's session, then navigates to flytedesk ID's
152
+ * RP-initiated logout so its session ends too. Rejects — leaving the session as the
153
+ * server says it is — when the server did not end it (an `Origin` refusal, a proxy
154
+ * error, no answer).
155
+ */
156
+ signOut(): Promise<void>;
157
+ /** The failure states' action: `sign-in-failed` starts a sign-in by hand;
158
+ * `load-failed` checks the session again. */
159
+ retry(): void;
160
+ /** Re-reads the signed-in user (`GET {routePrefix}/me`) — for an app that just changed
161
+ * it server-side. Resolves once the snapshot carries the user as read AFTER the call.
162
+ * Does nothing unless the session is `authenticated` (there is no user to refresh
163
+ * while signing in or out, and during an outage the next successful load reads it). */
164
+ refreshUser(): Promise<void>;
165
+ /** Stops every timer and listener (tests; a page unloading needs nothing). */
166
+ stop(): void;
167
+ }
168
+ export declare function createAuthSession(env: AuthSessionEnvironment): AuthSessionController;