@foir/sdk 0.27.1 → 0.29.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/CHANGELOG.md CHANGED
@@ -108,6 +108,76 @@ nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
108
108
 
109
109
  Changes on `dev` that have not been published to npm.
110
110
 
111
+ ## [0.29.0] — 2026-10-21
112
+
113
+ ### Added
114
+
115
+ - **`AuthorizedApp` says whose app it is**: `clientLogoUrl`, `clientLogoAlt`
116
+ and `clientHomepageUrl`, where the app set them.
117
+ - **`beginSignIn` and `completeSignIn`** (`@foir/sdk/auth`): the OpenID Connect
118
+ code flow for a server app with no OpenID Connect library. `beginSignIn`
119
+ finds the issuer's endpoints from its discovery document and returns the
120
+ authorization URL, with a PKCE S256 challenge, a state and a nonce, and what
121
+ to keep until the person comes back. `completeSignIn` checks the callback
122
+ against it, redeems the code at the token endpoint and returns the tokens,
123
+ with the ID token's issuer, audience, nonce and expiry checked. A refusal is
124
+ a `CodeFlowError` naming the OAuth error. It uses `fetch` and Web Crypto
125
+ only, so it runs in Workers, Oxygen and Node 18 or later.
126
+ - **A link for somebody new** (`@foir/sdk/auth`): `hostedLoginUrl`,
127
+ `signInUrl` and `useFoir().hostedSignInUrl` take `{ signUp: true }`, which
128
+ opens the hosted sign-in at the login domain's `/signup`, on the project's
129
+ way in rather than the sign-in form: joining where sign-up is public, the
130
+ waitlist in waitlist mode, or the invitation-only or closed notice. For a
131
+ "Sign up" or "Join the waitlist" button. In the code flow,
132
+ `beginSignIn(config, { prompt: 'create' })` asks for the same.
133
+ - **`<ProgressiveMedia>` video props** (`@foir/sdk/media`):
134
+ - `objectPosition` keeps a focal point in view when the image, the video or
135
+ its thumbnail is cropped to fit;
136
+ - `controls` shows the browser's controls, which default to `!autoplay` as
137
+ before. A video with controls shows from the start, its poster standing
138
+ until it plays;
139
+ - `preload` sets how much the browser fetches before a video plays;
140
+ - `videoRef` hands over the `<video>` element, for your own play and pause
141
+ controls;
142
+ - `onVideoError` is called when a video's source cannot play.
143
+
144
+ ### Fixed
145
+
146
+ - **A video that cannot play leaves its thumbnail**, labelled with `alt`, where
147
+ it left a blank box.
148
+ - **A video's `alt` labels the `<video>`**; it was dropped.
149
+ - **A video of unknown size takes its own shape** once its metadata loads,
150
+ where it was cropped to 16:10.
151
+
152
+ ## [0.28.0] — 2026-10-20
153
+
154
+ ### Added
155
+
156
+ - **`<FoirFeedback appearance>`** (`@foir/sdk/react`): the built-in styles'
157
+ colours, radius and type, as the new `FoirFeedbackAppearance`. Each token is
158
+ also a CSS custom property on the wrapper (`--foir-feedback-text`,
159
+ `--foir-feedback-radius`, …), so a stylesheet can set it from a design
160
+ system's own variables. Unset, the widget looks as it did.
161
+ - **`<FoirFeedback layout="dialog">`**: the panel opens over the page in a
162
+ modal `<dialog>`, with a backdrop and a Close button, closed by Escape and
163
+ the backdrop too. `inline`, the default, is the panel under the button.
164
+ - **`<FoirFeedback signIn={false}>`**: a site where nobody can sign in says so,
165
+ and a visitor the project takes no notes from reads "Feedback is not open
166
+ right now" rather than "Sign in to send feedback".
167
+
168
+ ### Changed
169
+
170
+ - **Breaking: a visitor's thank-you shows no id.** Nothing opens a visitor's
171
+ note from its id, so `<FoirFeedback>` no longer renders the
172
+ `data-foir-feedback-receipt` line. The receipt, `{ id, createdAt }`, still
173
+ reaches `onVisitorSubmitted`.
174
+ - **A visitor whose note is refused because the project stopped taking
175
+ visitors' notes is shown that**, as the panel would show it on opening,
176
+ rather than asked to try again. The widget asks `noteConfig` again after a
177
+ visitor's send fails for a reason it cannot name.
178
+ - **The widget's smaller lines are sized relative to its font size**, so
179
+ `fontSize` scales them; they were fixed at 13 and 15px.
180
+
111
181
  ## [0.27.1] — 2026-10-20
112
182
 
113
183
  ### Fixed
@@ -123,6 +193,17 @@ Changes on `dev` that have not been published to npm.
123
193
 
124
194
  ### Added
125
195
 
196
+ - **`createCollabSession({ orgId })`** (`@foir/sdk/realtime`): the
197
+ organization the signed-in user stands at in the room, sent as the
198
+ connection's `org`. The user joins there only as a member of it; omitted,
199
+ the join stands at the project.
200
+ - **The subscription socket honours close codes 4402 and 4403.** On 4402,
201
+ the socket's credential is no longer honoured: the bearer is renewed through
202
+ `refreshBearer` and the socket reconnects at once, failing its subscriptions
203
+ with `FoirSessionExpiredError` when the session is over. On 4403, the
204
+ signed-in user is no member of the organization the client names: the
205
+ subscriptions fail rather than reconnecting into the same refusal.
206
+
126
207
  - **`organizations.startPool({ movePeople, dryRun })`** (`@foir/sdk/app`):
127
208
  starting the run's organization's own pool takes its people along when
128
209
  asked. Somebody who stands only there moves whole, and somebody who also
@@ -166,6 +247,15 @@ Changes on `dev` that have not been published to npm.
166
247
 
167
248
  ### Changed
168
249
 
250
+ - **Breaking: signing up with a password proves the address first.**
251
+ `register()` answers `true` and `signUpWithPassword(env, vars)` answers
252
+ nothing, and neither signs anybody in: the address is mailed a code, and
253
+ `signInWithOTP` / `loginWithOTP` with it finishes the account and starts the
254
+ session, also where the project takes no code to sign in. The answer is the
255
+ same whether or not the address has an account already, or the project takes
256
+ nobody new; whoever holds the address is told by email. `signUpWithPassword`
257
+ no longer takes a session adapter, `useFoirAuth().register()` answers
258
+ nothing, and `foirRegisterWithPassword` takes no cookie store.
169
259
  - **`<FoirFeedback>` writes a signed-in user's note through `writeNote`**, the
170
260
  one note create, rather than `createMyNote`, its older name. The same note
171
261
  on the same data API. A client whose generated module predates `writeNote`
@@ -43,11 +43,16 @@ export declare function login(env: FoirEnv, vars: {
43
43
  email: string;
44
44
  password: string;
45
45
  }): Promise<AuthResult>;
46
- /** Register a new user with email + password. */
46
+ /**
47
+ * Start an account with email + password. The address is mailed a code, and
48
+ * signing in with it ({@link loginWithOTP}) proves the address and finishes
49
+ * the account. Answered the same whether or not the address has an account
50
+ * already, or the project takes nobody new: its holder is told by email.
51
+ */
47
52
  export declare function register(env: FoirEnv, vars: {
48
53
  email: string;
49
54
  password: string;
50
- }): Promise<AuthResult>;
55
+ }): Promise<boolean>;
51
56
  /** Request a one-time password be emailed to the user. */
52
57
  export declare function requestOTP(env: FoirEnv, email: string): Promise<boolean>;
53
58
  /** Complete an OTP login. */
@@ -444,11 +449,15 @@ export declare function signInWithPassword(adapter: SessionAdapter, env: FoirEnv
444
449
  email: string;
445
450
  password: string;
446
451
  }): Promise<FoirUser>;
447
- /** Register with email + password and persist the session. */
448
- export declare function signUpWithPassword(adapter: SessionAdapter, env: FoirEnv, vars: {
452
+ /**
453
+ * Start a sign-up with email + password: the address is mailed a code, and
454
+ * {@link signInWithOTP} with it finishes the account and persists the
455
+ * session. Nobody is signed in until then.
456
+ */
457
+ export declare function signUpWithPassword(env: FoirEnv, vars: {
449
458
  email: string;
450
459
  password: string;
451
- }): Promise<FoirUser>;
460
+ }): Promise<void>;
452
461
  /** Complete an OTP login and persist the session. */
453
462
  export declare function signInWithOTP(adapter: SessionAdapter, env: FoirEnv, vars: {
454
463
  email: string;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Sign a person in through the OpenID Connect code flow, from a server app
3
+ * that has no OpenID Connect library of its own.
4
+ *
5
+ * `beginSignIn` finds the issuer's endpoints from its discovery document and
6
+ * builds the authorization URL, with a PKCE S256 challenge, a state and a
7
+ * nonce. It returns the URL to send the browser to and what the app keeps
8
+ * until the person comes back: keep it on the server, or in an http-only
9
+ * cookie scoped to the callback. `completeSignIn` checks the callback against
10
+ * it, redeems the code at the token endpoint and returns the tokens.
11
+ *
12
+ * These are the same endpoints any OpenID Connect library uses
13
+ * (`/oauth/authorize` and `/oauth/token`), so an app can move to a library
14
+ * later without changing anything in Foir. Only `fetch` and Web Crypto are
15
+ * used, so the helper runs in Workers, Oxygen and Node 18 or later.
16
+ *
17
+ * The ID token is checked for its issuer, audience, nonce and expiry. Its
18
+ * signature is not checked: it came straight from the issuer's token
19
+ * endpoint over TLS, which OpenID Connect accepts in place of the signature
20
+ * (Core 1.0, section 3.1.3.7).
21
+ */
22
+ /** Where the app signs people in. */
23
+ export interface CodeFlowConfig {
24
+ /**
25
+ * The issuer: Foir's own (`https://api.foir.io`) or a project's login
26
+ * domain (`https://login.example.com`), which is the issuer of the people
27
+ * who sign in through it.
28
+ */
29
+ issuer: string;
30
+ /** The auth client's id. */
31
+ clientId: string;
32
+ /** The callback, exactly as the auth client lists it. */
33
+ redirectUri: string;
34
+ /** A fetch to use in place of the global one. */
35
+ fetch?: typeof fetch;
36
+ }
37
+ /** What one sign-in asks for. */
38
+ export interface BeginSignInOptions {
39
+ /**
40
+ * What the sign-in asks to carry. Default `openid profile email`. Add the
41
+ * permission names the app's list holds to ask for fewer than all of them.
42
+ */
43
+ scope?: string | string[];
44
+ /** The organization the person signs in to, when the app serves several. */
45
+ org?: string;
46
+ /** `login` asks the person to sign in again, `consent` to agree again. */
47
+ prompt?: string;
48
+ /** The address to start the sign-in with. */
49
+ loginHint?: string;
50
+ }
51
+ /** A sign-in begun: where to send the browser, and what to keep for the callback. */
52
+ export interface PendingSignIn {
53
+ /** The authorization URL. */
54
+ url: string;
55
+ /** Kept to check the callback's `state`. */
56
+ state: string;
57
+ /** Kept, and sent with the code. Never put it in a URL. */
58
+ codeVerifier: string;
59
+ /** Kept to check the ID token's `nonce`. */
60
+ nonce: string;
61
+ }
62
+ /** What a completed sign-in hands back. */
63
+ export interface CodeFlowTokens {
64
+ accessToken: string;
65
+ tokenType: string;
66
+ expiresIn?: number;
67
+ refreshToken?: string;
68
+ idToken?: string;
69
+ scope?: string;
70
+ /** The ID token's claims, checked as described above. */
71
+ claims?: Record<string, unknown>;
72
+ }
73
+ /**
74
+ * A sign-in that did not complete. `code` is the OAuth error the issuer
75
+ * answered (`access_denied`, `invalid_grant`, ...), or one of the checks the
76
+ * helper makes: `state_mismatch`, `issuer_mismatch`, `nonce_mismatch`,
77
+ * `audience_mismatch`, `id_token_expired`, `discovery_failed` (the issuer
78
+ * could not be reached or answered no discovery document),
79
+ * `token_request_failed` (the token endpoint could not be reached or answered
80
+ * no access token), `invalid_request` (the callback carries no code).
81
+ */
82
+ export declare class CodeFlowError extends Error {
83
+ readonly code: string;
84
+ readonly description?: string;
85
+ constructor(code: string, description?: string);
86
+ }
87
+ /**
88
+ * Begin a sign-in: the URL to send the browser to, and what to keep until
89
+ * the person comes back to the callback.
90
+ */
91
+ export declare function beginSignIn(config: CodeFlowConfig, options?: BeginSignInOptions): Promise<PendingSignIn>;
92
+ /**
93
+ * Complete a sign-in from its callback: check it against what `beginSignIn`
94
+ * returned, redeem the code at the token endpoint, and return the tokens.
95
+ * `callback` is the URL the browser came back to, or its query.
96
+ */
97
+ export declare function completeSignIn(config: CodeFlowConfig, callback: string | URL | URLSearchParams, pending: Pick<PendingSignIn, 'state' | 'codeVerifier' | 'nonce'>): Promise<CodeFlowTokens>;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Hosted-login bridge — for storefronts that delegate sign-in to a Foir
3
- * white-label front door (the `loginWithFoir` flow) instead of
4
- * rendering their own login UI.
2
+ * The hosted session: for an app on the same registrable domain as its login
3
+ * domain, which trades the login domain's session cookie for a token instead
4
+ * of running the code flow itself (see code-flow.ts for that).
5
5
  *
6
6
  * The front door sets a first-party session cookie on the registrable domain;
7
7
  * because that domain is shared, the cookie reaches the storefront, which
@@ -37,6 +37,14 @@ export interface HostedLoginOptions {
37
37
  * of another project, and one that is not active.
38
38
  */
39
39
  org?: string;
40
+ /**
41
+ * Somebody new is expected: the page opens on the project's way in rather
42
+ * than the sign-in form, for a "Sign up" or "Join the waitlist" button. The
43
+ * project's sign-up still decides what that is: joining where it is public,
44
+ * the waitlist, or the invitation-only or closed notice. The URL is the
45
+ * login domain's `/signup`.
46
+ */
47
+ signUp?: boolean;
40
48
  }
41
49
  /**
42
50
  * Build the redirect URL to the hosted front door, forwarding an absolute
@@ -18,6 +18,8 @@ export { FoirAuthDowngradedError, FoirSessionExpiredError } from '../graphql/foi
18
18
  export { AUTH_OPS } from './ops';
19
19
  export type { AuthOpName, AuthOpOptions } from './ops';
20
20
  export { hostedLoginUrl, hostedLogoutUrl, fetchHostedSession, hostedSignInNoticeOf } from './hosted';
21
+ export { beginSignIn, completeSignIn, CodeFlowError } from './code-flow';
22
+ export type { CodeFlowConfig, BeginSignInOptions, PendingSignIn, CodeFlowTokens } from './code-flow';
21
23
  export type { HostedLoginConfig, HostedLoginOptions, HostedSession, HostedSessionResult, HostedSignInNotice } from './hosted';
22
24
  export { createFoirSessionClient } from './session-client';
23
25
  export type { FoirSessionClient, FoirSessionConfig, FoirSessionUser, } from './session-client';
@@ -317,6 +317,11 @@ export interface AuthorizedApp {
317
317
  id: string;
318
318
  clientId: string;
319
319
  clientName: string;
320
+ /** The app's logo, where it has one, and what it shows. */
321
+ clientLogoUrl?: string | null;
322
+ clientLogoAlt?: string | null;
323
+ /** The app's homepage, where it has one. */
324
+ clientHomepageUrl?: string | null;
320
325
  /** What the app may carry for them, in the permission catalogue's names. */
321
326
  grants: string[];
322
327
  /** ISO-8601. */
package/dist/auth.js CHANGED
@@ -1408,8 +1408,8 @@ async function getSessionUser(adapter) {
1408
1408
  async function signInWithPassword(adapter, env, vars) {
1409
1409
  return saveSession(adapter, await login(env, vars));
1410
1410
  }
1411
- async function signUpWithPassword(adapter, env, vars) {
1412
- return saveSession(adapter, await register(env, vars));
1411
+ async function signUpWithPassword(env, vars) {
1412
+ await register(env, vars);
1413
1413
  }
1414
1414
  async function signInWithOTP(adapter, env, vars) {
1415
1415
  return saveSession(adapter, await loginWithOTP(env, vars));
@@ -1602,6 +1602,7 @@ var GRANT_TABLE = [
1602
1602
  ["apikeys:write", "none", [], []],
1603
1603
  ["organizations:read", "none", [], []],
1604
1604
  ["organizations:write", "none", [], []],
1605
+ ["orgsettings:write", "none", [], []],
1605
1606
  ["organizations:create", "none", [], []],
1606
1607
  ["organizations:delete", "none", [], []],
1607
1608
  ["organizations:reach", "none", [], []],
@@ -1612,7 +1613,7 @@ var GRANT_TABLE = [
1612
1613
  ["projects:read", "none", [], []],
1613
1614
  ["projects:write", "none", [], []],
1614
1615
  ["project:stand", "none", [], []],
1615
- ["project:manage", "none", [], ["project:stand", "records:read.all", "records:create", "records:write.all", "records:delete.all", "records:publish.all", "records:attribute", "search:read", "embeddings:read", "embeddings:write", "schemas:read", "drafts:read", "files:read.all", "files:write.all", "files:delete.all", "users:read.all", "identities:read", "users:create", "identities:delete", "users:write.all", "users:setup", "users:delete.all", "notifications:read.all", "notifications:write.all", "notifications:consent", "pushcredentials:read", "pushcredentials:write", "notes:read.all", "notes:write.all", "notes:create", "approvals:read", "approvals:write", "rooms:read", "rooms:write", "presence:read", "operations:read", "operations:execute", "operations:write", "appconfig:read", "schedules:read", "schedules:write", "hooks:read", "hooks:write", "auth:login_with_foir", "authclients:read", "authclients:write", "domains:read", "domains:write", "loginpages:read", "loginpages:write", "secrets:read", "secrets:list", "secrets:put", "secrets:delete", "appsecrets:read", "appsecrets:list", "appsecrets:put", "appsecrets:delete", "usersecrets:read", "usersecrets:list", "usersecrets:put", "usersecrets:delete", "opsecrets:read", "opsecrets:write", "settings:read", "settings:write", "designtokens:read", "designtokens:write", "models:read", "models:write", "configs:read", "configs:write", "signinproviders:read", "signinproviders:write", "apps:read", "apps:write", "events:read", "targeting:debug", "visitors:forward", "apikeys:read", "apikeys:write", "organizations:read", "organizations:create", "organizations:delete", "organizations:write", "organizations:reach", "members:read", "members:write", "roles:read", "roles:write", "projects:read", "projects:write", "audit:read"]],
1616
+ ["project:manage", "none", [], ["project:stand", "records:read.all", "records:create", "records:write.all", "records:delete.all", "records:publish.all", "records:attribute", "search:read", "embeddings:read", "embeddings:write", "schemas:read", "drafts:read", "files:read.all", "files:write.all", "files:delete.all", "users:read.all", "identities:read", "users:create", "identities:delete", "users:write.all", "users:setup", "users:delete.all", "notifications:read.all", "notifications:write.all", "notifications:consent", "pushcredentials:read", "pushcredentials:write", "notes:read.all", "notes:write.all", "notes:create", "approvals:read", "approvals:write", "rooms:read", "rooms:write", "presence:read", "operations:read", "operations:execute", "operations:write", "appconfig:read", "schedules:read", "schedules:write", "hooks:read", "hooks:write", "authclients:read", "authclients:write", "domains:read", "domains:write", "loginpages:read", "loginpages:write", "secrets:read", "secrets:list", "secrets:put", "secrets:delete", "appsecrets:read", "appsecrets:list", "appsecrets:put", "appsecrets:delete", "usersecrets:read", "usersecrets:list", "usersecrets:put", "usersecrets:delete", "opsecrets:read", "opsecrets:write", "settings:read", "settings:write", "designtokens:read", "designtokens:write", "models:read", "models:write", "configs:read", "configs:write", "signinproviders:read", "signinproviders:write", "apps:read", "apps:write", "events:read", "targeting:debug", "visitors:forward", "apikeys:read", "apikeys:write", "organizations:read", "organizations:create", "organizations:delete", "organizations:write", "orgsettings:write", "organizations:reach", "members:read", "members:write", "roles:read", "roles:write", "projects:read", "projects:write", "audit:read"]],
1616
1617
  ["projects:create", "none", [], []],
1617
1618
  ["projects:delete", "none", [], []],
1618
1619
  ["tenants:read", "none", [], []],
@@ -1643,7 +1644,6 @@ var GRANT_TABLE = [
1643
1644
  ["usersecrets:list", "none", [], []],
1644
1645
  ["usersecrets:put", "none", [], []],
1645
1646
  ["usersecrets:delete", "none", [], []],
1646
- ["auth:login_with_foir", "none", [], []],
1647
1647
  ["signinproviders:read", "none", [], []],
1648
1648
  ["signinproviders:write", "none", [], []]
1649
1649
  ];
@@ -1807,6 +1807,9 @@ var DEFAULT_SESSION_PATH = "/session/token";
1807
1807
  var DEFAULT_END_SESSION_PATH = "/session/end-session";
1808
1808
  function hostedLoginUrl(config, returnTo, options = {}) {
1809
1809
  const dest = new URL(config.loginOrigin);
1810
+ if (options.signUp) {
1811
+ dest.pathname = `${dest.pathname.replace(/\/+$/, "")}/signup`;
1812
+ }
1810
1813
  if (returnTo && returnTo !== "/") {
1811
1814
  dest.searchParams.set("returnTo", returnTo);
1812
1815
  }
@@ -1913,6 +1916,169 @@ function forApp(clientId) {
1913
1916
  return clientId ? `?client_id=${encodeURIComponent(clientId)}` : "";
1914
1917
  }
1915
1918
 
1919
+ // src/auth/code-flow.ts
1920
+ var CodeFlowError = class extends Error {
1921
+ code;
1922
+ description;
1923
+ constructor(code, description) {
1924
+ super(description ? `${code}: ${description}` : code);
1925
+ this.name = "CodeFlowError";
1926
+ this.code = code;
1927
+ this.description = description;
1928
+ }
1929
+ };
1930
+ var discoveries = /* @__PURE__ */ new Map();
1931
+ function trimSlash(url) {
1932
+ return url.replace(/\/+$/, "");
1933
+ }
1934
+ async function discover(config) {
1935
+ const issuer = trimSlash(config.issuer);
1936
+ let found = discoveries.get(issuer);
1937
+ if (!found) {
1938
+ found = (async () => {
1939
+ let res;
1940
+ try {
1941
+ res = await (config.fetch ?? fetch)(`${issuer}/.well-known/openid-configuration`, {
1942
+ headers: { accept: "application/json" }
1943
+ });
1944
+ } catch (e) {
1945
+ throw new CodeFlowError("discovery_failed", `${issuer} could not be reached for its discovery document: ${String(e)}`);
1946
+ }
1947
+ if (!res.ok) {
1948
+ throw new CodeFlowError("discovery_failed", `${issuer} answered ${res.status} for its discovery document`);
1949
+ }
1950
+ const doc = await res.json().catch(() => null);
1951
+ if (!doc) {
1952
+ throw new CodeFlowError("discovery_failed", `${issuer} answered no JSON for its discovery document`);
1953
+ }
1954
+ if (!doc.issuer || trimSlash(doc.issuer) !== issuer) {
1955
+ throw new CodeFlowError("issuer_mismatch", `the discovery document at ${issuer} names ${doc.issuer ?? "no issuer"}`);
1956
+ }
1957
+ if (!doc.authorization_endpoint || !doc.token_endpoint) {
1958
+ throw new CodeFlowError("discovery_failed", `the discovery document at ${issuer} names no authorization or token endpoint`);
1959
+ }
1960
+ return doc;
1961
+ })();
1962
+ discoveries.set(issuer, found);
1963
+ found.catch(() => discoveries.delete(issuer));
1964
+ }
1965
+ return found;
1966
+ }
1967
+ function base64url(bytes) {
1968
+ let s = "";
1969
+ for (const b of bytes) s += String.fromCharCode(b);
1970
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1971
+ }
1972
+ function randomString(byteLength) {
1973
+ const bytes = new Uint8Array(byteLength);
1974
+ crypto.getRandomValues(bytes);
1975
+ return base64url(bytes);
1976
+ }
1977
+ async function s256(verifier) {
1978
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
1979
+ return base64url(new Uint8Array(digest));
1980
+ }
1981
+ async function beginSignIn(config, options = {}) {
1982
+ const endpoints = await discover(config);
1983
+ const state = randomString(24);
1984
+ const nonce = randomString(24);
1985
+ const codeVerifier = randomString(48);
1986
+ const url = new URL(endpoints.authorization_endpoint);
1987
+ const scope = Array.isArray(options.scope) ? options.scope.join(" ") : options.scope ?? "openid profile email";
1988
+ url.searchParams.set("client_id", config.clientId);
1989
+ url.searchParams.set("redirect_uri", config.redirectUri);
1990
+ url.searchParams.set("response_type", "code");
1991
+ url.searchParams.set("scope", scope);
1992
+ url.searchParams.set("state", state);
1993
+ url.searchParams.set("nonce", nonce);
1994
+ url.searchParams.set("code_challenge", await s256(codeVerifier));
1995
+ url.searchParams.set("code_challenge_method", "S256");
1996
+ if (options.org) url.searchParams.set("org", options.org);
1997
+ if (options.prompt) url.searchParams.set("prompt", options.prompt);
1998
+ if (options.loginHint) url.searchParams.set("login_hint", options.loginHint);
1999
+ return { url: url.toString(), state, codeVerifier, nonce };
2000
+ }
2001
+ function callbackParams(callback) {
2002
+ if (callback instanceof URLSearchParams) return callback;
2003
+ if (callback instanceof URL) return callback.searchParams;
2004
+ const query = callback.indexOf("?");
2005
+ return new URLSearchParams(query >= 0 ? callback.slice(query + 1).split("#")[0] : callback);
2006
+ }
2007
+ function claimsOf(idToken) {
2008
+ const payload = idToken.split(".")[1];
2009
+ if (!payload) throw new CodeFlowError("invalid_id_token", "the ID token is not a JWT");
2010
+ const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
2011
+ const bytes = Uint8Array.from(json, (c) => c.charCodeAt(0));
2012
+ return JSON.parse(new TextDecoder().decode(bytes));
2013
+ }
2014
+ async function completeSignIn(config, callback, pending) {
2015
+ const endpoints = await discover(config);
2016
+ const params = callbackParams(callback);
2017
+ if (params.get("state") !== pending.state) {
2018
+ throw new CodeFlowError("state_mismatch", "the callback is not for the sign-in this app began");
2019
+ }
2020
+ const refused = params.get("error");
2021
+ if (refused) {
2022
+ throw new CodeFlowError(refused, params.get("error_description") ?? void 0);
2023
+ }
2024
+ const code = params.get("code");
2025
+ if (!code) {
2026
+ throw new CodeFlowError("invalid_request", "the callback carries no code");
2027
+ }
2028
+ let res;
2029
+ try {
2030
+ res = await (config.fetch ?? fetch)(endpoints.token_endpoint, {
2031
+ method: "POST",
2032
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
2033
+ body: new URLSearchParams({
2034
+ grant_type: "authorization_code",
2035
+ code,
2036
+ redirect_uri: config.redirectUri,
2037
+ client_id: config.clientId,
2038
+ code_verifier: pending.codeVerifier
2039
+ }).toString()
2040
+ });
2041
+ } catch (e) {
2042
+ throw new CodeFlowError("token_request_failed", `the token endpoint could not be reached: ${String(e)}`);
2043
+ }
2044
+ const body = await res.json().catch(() => ({}));
2045
+ if (!res.ok) {
2046
+ throw new CodeFlowError(
2047
+ typeof body.error === "string" ? body.error : "token_request_failed",
2048
+ typeof body.error_description === "string" ? body.error_description : `the token endpoint answered ${res.status}`
2049
+ );
2050
+ }
2051
+ const tokens = {
2052
+ accessToken: String(body.access_token ?? ""),
2053
+ tokenType: String(body.token_type ?? "Bearer"),
2054
+ expiresIn: typeof body.expires_in === "number" ? body.expires_in : void 0,
2055
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
2056
+ idToken: typeof body.id_token === "string" ? body.id_token : void 0,
2057
+ scope: typeof body.scope === "string" ? body.scope : void 0
2058
+ };
2059
+ if (!tokens.accessToken) {
2060
+ throw new CodeFlowError("token_request_failed", "the token endpoint answered no access token");
2061
+ }
2062
+ if (tokens.idToken) {
2063
+ const claims = claimsOf(tokens.idToken);
2064
+ if (typeof claims.iss !== "string" || trimSlash(claims.iss) !== trimSlash(endpoints.issuer)) {
2065
+ throw new CodeFlowError("issuer_mismatch", `the ID token's issuer is ${String(claims.iss)}`);
2066
+ }
2067
+ const audience = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
2068
+ if (!audience.includes(config.clientId)) {
2069
+ throw new CodeFlowError("audience_mismatch", "the ID token is not for this app");
2070
+ }
2071
+ if (claims.nonce !== pending.nonce) {
2072
+ throw new CodeFlowError("nonce_mismatch", "the ID token is not for the sign-in this app began");
2073
+ }
2074
+ if (typeof claims.exp === "number" && claims.exp * 1e3 < Date.now()) {
2075
+ throw new CodeFlowError("id_token_expired", "the ID token has expired");
2076
+ }
2077
+ tokens.claims = claims;
2078
+ }
2079
+ return tokens;
2080
+ }
2081
+
1916
2082
  // src/auth/session-client.ts
1917
2083
  var DEFAULT_SESSION_PATH2 = "/session/token";
1918
2084
  var DEFAULT_LOGOUT_PATH = "/session/logout";
@@ -1921,8 +2087,8 @@ var MIN_REFRESH_DELAY_MS = 5e3;
1921
2087
  var CSRF_HEADER = { "X-Foir-Session": "1" };
1922
2088
  function createFoirSessionClient(config) {
1923
2089
  const forApp2 = config.clientId ? `?client_id=${encodeURIComponent(config.clientId)}` : "";
1924
- const sessionUrl = `${trimSlash(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH2}${forApp2}`;
1925
- const logoutUrl = `${trimSlash(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}${forApp2}`;
2090
+ const sessionUrl = `${trimSlash2(config.loginOrigin)}${config.sessionPath ?? DEFAULT_SESSION_PATH2}${forApp2}`;
2091
+ const logoutUrl = `${trimSlash2(config.loginOrigin)}${config.logoutPath ?? DEFAULT_LOGOUT_PATH}${forApp2}`;
1926
2092
  const skewMs = (config.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1e3;
1927
2093
  let cached = null;
1928
2094
  let inflight = null;
@@ -2029,7 +2195,7 @@ function createFoirSessionClient(config) {
2029
2195
  config.onSignOut?.();
2030
2196
  },
2031
2197
  signInUrl(returnTo, options = {}) {
2032
- const dest = new URL(trimSlash(config.loginOrigin));
2198
+ const dest = new URL(trimSlash2(config.loginOrigin));
2033
2199
  const target = returnTo ?? (typeof window !== "undefined" ? window.location.href : void 0);
2034
2200
  if (target && target !== "/") dest.searchParams.set("returnTo", target);
2035
2201
  if (options.org) dest.searchParams.set("org", options.org);
@@ -2046,11 +2212,12 @@ function createFoirSessionClient(config) {
2046
2212
  }
2047
2213
  };
2048
2214
  }
2049
- function trimSlash(s) {
2215
+ function trimSlash2(s) {
2050
2216
  return s.replace(/\/+$/, "");
2051
2217
  }
2052
2218
  export {
2053
2219
  AUTH_OPS,
2220
+ CodeFlowError,
2054
2221
  FoirAccessDeniedError,
2055
2222
  FoirAuthDowngradedError,
2056
2223
  FoirSessionExpiredError,
@@ -2058,9 +2225,11 @@ export {
2058
2225
  authedOp,
2059
2226
  authedRequest,
2060
2227
  beginFactorEnrollment,
2228
+ beginSignIn,
2061
2229
  beginStepUp,
2062
2230
  clearSession,
2063
2231
  completeFactorEnrollment,
2232
+ completeSignIn,
2064
2233
  completeStepUp,
2065
2234
  confirmProviderLink,
2066
2235
  connectSignInMethod,
@@ -554,8 +554,7 @@ export interface FoirNoteConfig {
554
554
  * and nothing else, because nobody is signed in to read the note back as. Ask
555
555
  * for more and the write is refused before anything is written.
556
556
  *
557
- * Show the `id` to the visitor so they can quote it. Nothing on the public
558
- * plane takes it to read the note.
557
+ * Nothing on the public plane takes the `id` to read the note.
559
558
  */
560
559
  export interface FoirNoteReceipt {
561
560
  id: string;
@@ -725,4 +725,5 @@ export declare function executeFoirDocument<T>(config: FoirClientConfig, plan: F
725
725
  export declare function executeFoirRawDocument<T>(config: FoirClientConfig, document: string, mutation: boolean, variables?: Record<string, unknown>, options?: {
726
726
  strict?: boolean;
727
727
  }): Promise<T>;
728
+ export declare function renewBearerOnce(refresh: () => Promise<string | null>): Promise<string | null>;
728
729
  export {};
@@ -149,6 +149,7 @@ export declare class FoirSocketTransport {
149
149
  private disposed;
150
150
  private keepAlive;
151
151
  private retryTimer;
152
+ private renewedBearer;
152
153
  constructor(config: FoirRequestConfig);
153
154
  /**
154
155
  * Start a subscription.
@@ -167,6 +168,14 @@ export declare class FoirSocketTransport {
167
168
  private ensureConnected;
168
169
  private sendConnectionInit;
169
170
  private handleClose;
171
+ /**
172
+ * Reconnect after the socket's credential lapsed: with the bearer renewed
173
+ * through the client's `refreshBearer` where it has one, failing every
174
+ * subscription with `FoirSessionExpiredError` when the session is over. A
175
+ * client without one reconnects at once, presenting what its bearer
176
+ * provider gives now.
177
+ */
178
+ private reconnectWithARenewedBearer;
170
179
  private scheduleReconnect;
171
180
  private teardownSocket;
172
181
  private receive;