@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.
- package/README.md +192 -41
- package/dist/auth/client/authSession.d.ts +168 -0
- package/dist/auth/client/authSession.js +581 -0
- package/dist/auth/client/authSession.js.map +1 -0
- package/dist/auth/client/browserEnvironment.d.ts +22 -0
- package/dist/auth/client/browserEnvironment.js +87 -0
- package/dist/auth/client/browserEnvironment.js.map +1 -0
- package/dist/auth/client/httpClient.d.ts +32 -28
- package/dist/auth/client/httpClient.js +91 -84
- package/dist/auth/client/httpClient.js.map +1 -1
- package/dist/auth/client/index.d.ts +15 -8
- package/dist/auth/client/index.js +13 -7
- package/dist/auth/client/index.js.map +1 -1
- package/dist/auth/client/testing.d.ts +6 -0
- package/dist/auth/client/testing.js +20 -0
- package/dist/auth/client/testing.js.map +1 -0
- package/dist/auth/client/useAuthSession.d.ts +54 -91
- package/dist/auth/client/useAuthSession.js +65 -252
- package/dist/auth/client/useAuthSession.js.map +1 -1
- package/dist/auth/index.d.ts +3 -4
- package/dist/auth/index.js +1 -2
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oidc-client.d.ts +18 -4
- package/dist/auth/oidc-client.js +18 -1
- package/dist/auth/oidc-client.js.map +1 -1
- package/dist/auth/plugin.js +244 -225
- package/dist/auth/plugin.js.map +1 -1
- package/dist/auth/returnTarget.d.ts +30 -0
- package/dist/auth/returnTarget.js +48 -0
- package/dist/auth/returnTarget.js.map +1 -0
- package/dist/auth/shared-types.d.ts +18 -41
- package/dist/auth/shared-types.js +18 -45
- package/dist/auth/shared-types.js.map +1 -1
- package/dist/auth/testing/fake-idp.d.ts +29 -1
- package/dist/auth/testing/fake-idp.js +142 -31
- package/dist/auth/testing/fake-idp.js.map +1 -1
- package/dist/auth/types.d.ts +26 -29
- package/dist/bigquery/index.d.ts +1 -1
- package/dist/bigquery/params.d.ts +6 -0
- package/dist/bigquery/params.js +79 -0
- package/dist/bigquery/params.js.map +1 -0
- package/dist/bigquery/query.js +3 -0
- package/dist/bigquery/query.js.map +1 -1
- package/dist/bigquery/types.d.ts +37 -4
- 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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|