@foir/sdk 0.28.0 → 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,47 @@ 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
+
111
152
  ## [0.28.0] — 2026-10-20
112
153
 
113
154
  ### Added
@@ -152,6 +193,17 @@ Changes on `dev` that have not been published to npm.
152
193
 
153
194
  ### Added
154
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
+
155
207
  - **`organizations.startPool({ movePeople, dryRun })`** (`@foir/sdk/app`):
156
208
  starting the run's organization's own pool takes its people along when
157
209
  asked. Somebody who stands only there moves whole, and somebody who also
@@ -195,6 +247,15 @@ Changes on `dev` that have not been published to npm.
195
247
 
196
248
  ### Changed
197
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.
198
259
  - **`<FoirFeedback>` writes a signed-in user's note through `writeNote`**, the
199
260
  one note create, rather than `createMyNote`, its older name. The same note
200
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,
@@ -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;
package/dist/data.js CHANGED
@@ -1380,6 +1380,8 @@ function printSubscriptionDocument(input) {
1380
1380
  }
1381
1381
 
1382
1382
  // src/data/ws-transport.ts
1383
+ var CLOSE_CREDENTIAL_ENDED = 4402;
1384
+ var CLOSE_FORBIDDEN = 4403;
1383
1385
  var GQL = {
1384
1386
  connectionInit: "connection_init",
1385
1387
  connectionAck: "connection_ack",
@@ -1414,6 +1416,9 @@ var FoirSocketTransport = class {
1414
1416
  disposed = false;
1415
1417
  keepAlive = null;
1416
1418
  retryTimer = null;
1419
+ // A bearer renewed after the socket's lapsed (4402), presented from the next
1420
+ // connect on in place of the client's own.
1421
+ renewedBearer = null;
1417
1422
  constructor(config) {
1418
1423
  if (config.dispatch !== void 0) {
1419
1424
  throw new FoirRequestError(
@@ -1486,12 +1491,14 @@ var FoirSocketTransport = class {
1486
1491
  socket.onmessage = (event) => this.receive(event.data);
1487
1492
  socket.onerror = () => {
1488
1493
  };
1489
- socket.onclose = () => this.handleClose();
1494
+ socket.onclose = (event) => this.handleClose(event);
1490
1495
  }
1491
1496
  async sendConnectionInit(socket) {
1492
1497
  this.connecting = false;
1493
1498
  try {
1494
- const { headers } = await resolveFoirAuth(this.config);
1499
+ const { headers } = await resolveFoirAuth(
1500
+ this.renewedBearer ? { ...this.config, bearerToken: this.renewedBearer } : this.config
1501
+ );
1495
1502
  const { "Content-Type": _contentType, ...params } = headers;
1496
1503
  const extra = await this.config.connectionParams?.() ?? {};
1497
1504
  this.send(socket, {
@@ -1505,19 +1512,53 @@ var FoirSocketTransport = class {
1505
1512
  this.teardownSocket(true);
1506
1513
  }
1507
1514
  }
1508
- handleClose() {
1515
+ handleClose(event) {
1516
+ const code = event?.code;
1509
1517
  const wasAcknowledged = this.acknowledged;
1510
1518
  this.teardownSocket(false);
1511
1519
  if (this.disposed || this.registrations.size === 0) return;
1512
1520
  for (const registration of this.registrations.values()) {
1513
1521
  registration.sent = false;
1514
1522
  }
1523
+ if (code === CLOSE_FORBIDDEN) {
1524
+ this.failAll(
1525
+ new FoirRequestError(
1526
+ "[foir] the subscription socket was refused where it asked to stand: the signed-in user is no member of the organization this client names"
1527
+ )
1528
+ );
1529
+ return;
1530
+ }
1531
+ if (code === CLOSE_CREDENTIAL_ENDED) {
1532
+ void this.reconnectWithARenewedBearer();
1533
+ return;
1534
+ }
1515
1535
  this.scheduleReconnect(
1516
1536
  new FoirRequestError(
1517
1537
  wasAcknowledged ? "[foir] the subscription socket closed and could not be re-established" : "[foir] the subscription socket closed before the credential was accepted"
1518
1538
  )
1519
1539
  );
1520
1540
  }
1541
+ /**
1542
+ * Reconnect after the socket's credential lapsed: with the bearer renewed
1543
+ * through the client's `refreshBearer` where it has one, failing every
1544
+ * subscription with `FoirSessionExpiredError` when the session is over. A
1545
+ * client without one reconnects at once, presenting what its bearer
1546
+ * provider gives now.
1547
+ */
1548
+ async reconnectWithARenewedBearer() {
1549
+ const refresh = this.config.refreshBearer;
1550
+ if (refresh) {
1551
+ const token = await renewBearerOnce(refresh).catch(() => null);
1552
+ if (this.disposed) return;
1553
+ if (!token) {
1554
+ this.failAll(new FoirSessionExpiredError());
1555
+ return;
1556
+ }
1557
+ this.renewedBearer = token;
1558
+ }
1559
+ this.attempt = 0;
1560
+ this.ensureConnected();
1561
+ }
1521
1562
  scheduleReconnect(error) {
1522
1563
  if (this.disposed || this.registrations.size === 0) return;
1523
1564
  if (this.attempt >= this.policy.maxAttempts) {
@@ -1,3 +1,4 @@
1
+ import type { Ref } from 'react';
1
2
  import type { ProgressiveImageSource, ProgressiveVideoSource, VariantTierKey } from './media';
2
3
  export interface ProgressiveMediaProps {
3
4
  image?: ProgressiveImageSource;
@@ -25,6 +26,31 @@ export interface ProgressiveMediaProps {
25
26
  muted?: boolean;
26
27
  /** Video playback — loop on end. Default true. */
27
28
  loop?: boolean;
29
+ /**
30
+ * Video playback: show the browser's controls. Defaults to `!autoplay`. A
31
+ * video with controls shows from the start, its poster standing until it
32
+ * plays, so the controls can always be reached.
33
+ */
34
+ controls?: boolean;
35
+ /** Video: how much the browser fetches before it plays (`<video preload>`). */
36
+ preload?: 'none' | 'metadata' | 'auto';
37
+ /**
38
+ * Where the media sits in its box when it is cropped to fit, as CSS
39
+ * `object-position` (`'30% 60%'`), for a focal point. Applies to the image,
40
+ * the video and its thumbnail. Default centre.
41
+ */
42
+ objectPosition?: string;
43
+ /**
44
+ * The `<video>` element, once it mounts: for a caller's own play and pause
45
+ * controls, and the events that keep them in step.
46
+ */
47
+ videoRef?: Ref<HTMLVideoElement>;
48
+ /**
49
+ * Called once when a video's source cannot be played. The video is then
50
+ * removed, and its thumbnail, labelled with `alt`, stands in for it over
51
+ * the colour and blurhash layers.
52
+ */
53
+ onVideoError?: () => void;
28
54
  /**
29
55
  * When false, skip loading the full media layer (no `<video>`/full `<img>`
30
56
  * mount, no HLS attach, no full-image preload). The lower layers — colour,
@@ -53,4 +79,4 @@ export interface ProgressiveMediaProps {
53
79
  */
54
80
  variantTiers?: readonly VariantTierKey[];
55
81
  }
56
- export declare function ProgressiveMedia({ image, video, alt, className, fill, fit, placeholder, autoplay, muted, loop, active, sizes, priority, variantTiers, }: ProgressiveMediaProps): import("react/jsx-runtime").JSX.Element | null;
82
+ export declare function ProgressiveMedia({ image, video, alt, className, fill, fit, placeholder, autoplay, muted, loop, controls, preload, objectPosition, videoRef: externalVideoRef, onVideoError, active, sizes, priority, variantTiers, }: ProgressiveMediaProps): import("react/jsx-runtime").JSX.Element | null;
package/dist/media.js CHANGED
@@ -213,6 +213,11 @@ function ProgressiveMedia({
213
213
  autoplay = true,
214
214
  muted = true,
215
215
  loop = true,
216
+ controls,
217
+ preload,
218
+ objectPosition,
219
+ videoRef: externalVideoRef,
220
+ onVideoError,
216
221
  active = true,
217
222
  sizes = "100vw",
218
223
  priority = false,
@@ -225,7 +230,13 @@ function ProgressiveMedia({
225
230
  const source = video ?? image;
226
231
  const isVideo = !!video;
227
232
  const [wrapperRef, inViewport] = useInViewport("300px", priority);
228
- const videoActive = isVideo && active && (priority || inViewport);
233
+ const [failedUrl, setFailedUrl] = useState2(void 0);
234
+ const videoFailed = isVideo && failedUrl !== void 0 && failedUrl === video?.url;
235
+ const [videoSize, setVideoSize] = useState2(
236
+ null
237
+ );
238
+ const videoActive = isVideo && active && !videoFailed && (priority || inViewport);
239
+ const showControls = controls ?? !autoplay;
229
240
  const imageActive = !isVideo && active;
230
241
  const hlsUrl = video?.hlsUrl;
231
242
  const useHls = isVideo && !!hlsUrl;
@@ -282,6 +293,8 @@ function ProgressiveMedia({
282
293
  cleanupRef.current = null;
283
294
  }
284
295
  videoRef.current = el;
296
+ if (typeof externalVideoRef === "function") externalVideoRef(el);
297
+ else if (externalVideoRef) externalVideoRef.current = el;
285
298
  if (!el) return;
286
299
  if (el.readyState >= 3) {
287
300
  onFullReady();
@@ -299,7 +312,7 @@ function ProgressiveMedia({
299
312
  el.removeEventListener("playing", handler);
300
313
  };
301
314
  },
302
- [onFullReady]
315
+ [onFullReady, externalVideoRef]
303
316
  );
304
317
  if (!source) {
305
318
  if (!placeholder) return null;
@@ -314,8 +327,9 @@ function ProgressiveMedia({
314
327
  );
315
328
  }
316
329
  const effective = !isVideo ? effectiveImageDimensions(image) : null;
317
- const w = effective?.width ?? source.width;
318
- const h = effective?.height ?? source.height;
330
+ const w = effective?.width ?? source.width ?? videoSize?.w;
331
+ const h = effective?.height ?? source.height ?? videoSize?.h;
332
+ const positioned = objectPosition ? { objectPosition } : void 0;
319
333
  const aspect = w && h ? w / h : 16 / 10;
320
334
  return /* @__PURE__ */ jsxs(
321
335
  "div",
@@ -342,13 +356,14 @@ function ProgressiveMedia({
342
356
  "img",
343
357
  {
344
358
  src: thumbnailUrl,
345
- alt: "",
346
- "aria-hidden": true,
359
+ alt: videoFailed ? alt ?? "" : "",
360
+ "aria-hidden": videoFailed ? void 0 : true,
347
361
  width: w,
348
362
  height: h,
349
363
  loading: priority ? "eager" : "lazy",
350
364
  fetchPriority: priority ? "high" : void 0,
351
- className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-400 ease-in-out ${stage === "thumbnail" || stage === "full" ? "opacity-100" : "opacity-0"}`
365
+ className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-400 ease-in-out ${stage === "thumbnail" || stage === "full" ? "opacity-100" : "opacity-0"}`,
366
+ style: positioned
352
367
  }
353
368
  ),
354
369
  videoActive ? /* @__PURE__ */ jsx2(
@@ -363,8 +378,21 @@ function ProgressiveMedia({
363
378
  muted,
364
379
  loop,
365
380
  playsInline: true,
366
- controls: !autoplay,
367
- className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-500 ease-in-out ${stage === "full" ? "opacity-100" : "opacity-0"}`
381
+ controls: showControls,
382
+ preload,
383
+ "aria-label": alt || void 0,
384
+ onLoadedMetadata: (e) => {
385
+ const v = e.currentTarget;
386
+ if (!source.width && v.videoWidth && v.videoHeight) {
387
+ setVideoSize({ w: v.videoWidth, h: v.videoHeight });
388
+ }
389
+ },
390
+ onError: () => {
391
+ setFailedUrl(video?.url);
392
+ onVideoError?.();
393
+ },
394
+ className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-500 ease-in-out ${stage === "full" || showControls ? "opacity-100" : "opacity-0"}`,
395
+ style: positioned
368
396
  }
369
397
  ) : imageActive ? (
370
398
  // <picture>: browser picks AVIF when it can decode it, else WebP. The
@@ -383,7 +411,8 @@ function ProgressiveMedia({
383
411
  height: h,
384
412
  loading: priority ? "eager" : "lazy",
385
413
  fetchPriority: priority ? "high" : void 0,
386
- className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-500 ease-in-out ${stage === "full" ? "opacity-100" : "opacity-0"}`
414
+ className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-500 ease-in-out ${stage === "full" ? "opacity-100" : "opacity-0"}`,
415
+ style: positioned
387
416
  }
388
417
  )
389
418
  ] })
@@ -27,8 +27,12 @@ interface PasswordArgs {
27
27
  }
28
28
  /** Password sign-in in a route handler; persists the session to cookies. */
29
29
  export declare function foirSignInWithPassword({ env, store, email, password, cookieOptions, }: PasswordArgs): Promise<FoirUser>;
30
- /** Register a user in a route handler; persists the session to cookies. */
31
- export declare function foirRegisterWithPassword({ env, store, email, password, cookieOptions, }: PasswordArgs): Promise<FoirUser>;
30
+ /**
31
+ * Start a sign-up in a route handler: the address is mailed a code, and
32
+ * {@link foirSignInWithOtp} with it finishes the account and persists the
33
+ * session to cookies.
34
+ */
35
+ export declare function foirRegisterWithPassword({ env, email, password, }: Pick<PasswordArgs, 'env' | 'email' | 'password'>): Promise<void>;
32
36
  /** Complete an OTP sign-in in a route handler; persists the session to cookies. */
33
37
  export declare function foirSignInWithOtp({ env, store, email, otp, cookieOptions, }: {
34
38
  env: FoirEnv;
package/dist/next.js CHANGED
@@ -1127,8 +1127,8 @@ async function getSessionUser(adapter) {
1127
1127
  async function signInWithPassword(adapter, env, vars) {
1128
1128
  return saveSession(adapter, await login(env, vars));
1129
1129
  }
1130
- async function signUpWithPassword(adapter, env, vars) {
1131
- return saveSession(adapter, await register(env, vars));
1130
+ async function signUpWithPassword(env, vars) {
1131
+ await register(env, vars);
1132
1132
  }
1133
1133
  async function signInWithOTP(adapter, env, vars) {
1134
1134
  return saveSession(adapter, await loginWithOTP(env, vars));
@@ -1158,15 +1158,10 @@ function foirSignInWithPassword({
1158
1158
  }
1159
1159
  function foirRegisterWithPassword({
1160
1160
  env,
1161
- store,
1162
1161
  email,
1163
- password,
1164
- cookieOptions
1162
+ password
1165
1163
  }) {
1166
- return signUpWithPassword(createFoirCookieAdapter(store, { cookieOptions }), env, {
1167
- email,
1168
- password
1169
- });
1164
+ return signUpWithPassword(env, { email, password });
1170
1165
  }
1171
1166
  function foirSignInWithOtp({
1172
1167
  env,
package/dist/placement.js CHANGED
@@ -70,6 +70,7 @@ var GRANT_TABLE = [
70
70
  ["apikeys:write", "none", [], []],
71
71
  ["organizations:read", "none", [], []],
72
72
  ["organizations:write", "none", [], []],
73
+ ["orgsettings:write", "none", [], []],
73
74
  ["organizations:create", "none", [], []],
74
75
  ["organizations:delete", "none", [], []],
75
76
  ["organizations:reach", "none", [], []],
@@ -80,7 +81,7 @@ var GRANT_TABLE = [
80
81
  ["projects:read", "none", [], []],
81
82
  ["projects:write", "none", [], []],
82
83
  ["project:stand", "none", [], []],
83
- ["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"]],
84
+ ["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"]],
84
85
  ["projects:create", "none", [], []],
85
86
  ["projects:delete", "none", [], []],
86
87
  ["tenants:read", "none", [], []],
@@ -111,7 +112,6 @@ var GRANT_TABLE = [
111
112
  ["usersecrets:list", "none", [], []],
112
113
  ["usersecrets:put", "none", [], []],
113
114
  ["usersecrets:delete", "none", [], []],
114
- ["auth:login_with_foir", "none", [], []],
115
115
  ["signinproviders:read", "none", [], []],
116
116
  ["signinproviders:write", "none", [], []]
117
117
  ];
@@ -28,10 +28,14 @@ export interface FoirContextValue {
28
28
  email: string;
29
29
  password: string;
30
30
  }): Promise<FoirUser>;
31
+ /**
32
+ * Start a sign-up: the address is mailed a code, and `signInWithOtp` with it
33
+ * finishes the account and signs them in.
34
+ */
31
35
  register(vars: {
32
36
  email: string;
33
37
  password: string;
34
- }): Promise<FoirUser>;
38
+ }): Promise<void>;
35
39
  requestOtp(email: string): Promise<boolean>;
36
40
  signInWithOtp(vars: {
37
41
  email: string;
@@ -138,7 +142,7 @@ export declare function useFoirAuth(): {
138
142
  register: (vars: {
139
143
  email: string;
140
144
  password: string;
141
- }) => Promise<FoirUser>;
145
+ }) => Promise<void>;
142
146
  requestOtp: (email: string) => Promise<boolean>;
143
147
  signInWithOtp: (vars: {
144
148
  email: string;
package/dist/react.js CHANGED
@@ -1133,8 +1133,8 @@ async function getSessionUser(adapter) {
1133
1133
  async function signInWithPassword(adapter, env, vars) {
1134
1134
  return saveSession(adapter, await login(env, vars));
1135
1135
  }
1136
- async function signUpWithPassword(adapter, env, vars) {
1137
- return saveSession(adapter, await register(env, vars));
1136
+ async function signUpWithPassword(env, vars) {
1137
+ await register(env, vars);
1138
1138
  }
1139
1139
  async function signInWithOTP(adapter, env, vars) {
1140
1140
  return saveSession(adapter, await loginWithOTP(env, vars));
@@ -1280,6 +1280,7 @@ var GRANT_TABLE = [
1280
1280
  ["apikeys:write", "none", [], []],
1281
1281
  ["organizations:read", "none", [], []],
1282
1282
  ["organizations:write", "none", [], []],
1283
+ ["orgsettings:write", "none", [], []],
1283
1284
  ["organizations:create", "none", [], []],
1284
1285
  ["organizations:delete", "none", [], []],
1285
1286
  ["organizations:reach", "none", [], []],
@@ -1290,7 +1291,7 @@ var GRANT_TABLE = [
1290
1291
  ["projects:read", "none", [], []],
1291
1292
  ["projects:write", "none", [], []],
1292
1293
  ["project:stand", "none", [], []],
1293
- ["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"]],
1294
+ ["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"]],
1294
1295
  ["projects:create", "none", [], []],
1295
1296
  ["projects:delete", "none", [], []],
1296
1297
  ["tenants:read", "none", [], []],
@@ -1321,7 +1322,6 @@ var GRANT_TABLE = [
1321
1322
  ["usersecrets:list", "none", [], []],
1322
1323
  ["usersecrets:put", "none", [], []],
1323
1324
  ["usersecrets:delete", "none", [], []],
1324
- ["auth:login_with_foir", "none", [], []],
1325
1325
  ["signinproviders:read", "none", [], []],
1326
1326
  ["signinproviders:write", "none", [], []]
1327
1327
  ];
@@ -1680,13 +1680,11 @@ function FoirProvider({
1680
1680
  [apply, assertTokenMode]
1681
1681
  );
1682
1682
  const register2 = useCallback(
1683
- async (vars) => {
1683
+ (vars) => {
1684
1684
  assertTokenMode("register");
1685
- const c = await signUpWithPassword(adapterRef.current, envRef.current, vars);
1686
- apply(c);
1687
- return c;
1685
+ return signUpWithPassword(envRef.current, vars);
1688
1686
  },
1689
- [apply, assertTokenMode]
1687
+ [assertTokenMode]
1690
1688
  );
1691
1689
  const requestOtp = useCallback(
1692
1690
  (email) => {
@@ -71,6 +71,12 @@ export interface CreateCollabSessionOptions {
71
71
  room: RoomAddress | string;
72
72
  /** Async token source. Invoked before the first connect and on each refresh tick. */
73
73
  getToken: () => string | Promise<string>;
74
+ /**
75
+ * The organization the signed-in user stands at in the room, as a request
76
+ * names it. Omit for the project. The platform admits the join only where
77
+ * the user is a member.
78
+ */
79
+ orgId?: string;
74
80
  /** Caller-owned `Y.Doc` carrying the app-defined shared types. */
75
81
  doc: Y.Doc;
76
82
  /** Local presence. Sets awareness under the `user` field (contract shape). Omit for no presence. */
package/dist/realtime.js CHANGED
@@ -39,12 +39,13 @@ function createCollabSession(options) {
39
39
  refreshMs = 4 * 60 * 1e3,
40
40
  connect = true,
41
41
  params: extraParams,
42
+ orgId,
42
43
  WebSocketPolyfill
43
44
  } = options;
44
45
  const room = roomPath(options.room);
45
46
  const provider = new WebsocketProvider(url, room, doc, {
46
47
  connect: false,
47
- params: { ...extraParams },
48
+ params: { ...extraParams, ...orgId ? { org: orgId } : {} },
48
49
  ...WebSocketPolyfill ? { WebSocketPolyfill } : {}
49
50
  });
50
51
  const awareness = provider.awareness;
package/dist/server.js CHANGED
@@ -144,6 +144,7 @@ var GRANT_TABLE = [
144
144
  ["apikeys:write", "none", [], []],
145
145
  ["organizations:read", "none", [], []],
146
146
  ["organizations:write", "none", [], []],
147
+ ["orgsettings:write", "none", [], []],
147
148
  ["organizations:create", "none", [], []],
148
149
  ["organizations:delete", "none", [], []],
149
150
  ["organizations:reach", "none", [], []],
@@ -154,7 +155,7 @@ var GRANT_TABLE = [
154
155
  ["projects:read", "none", [], []],
155
156
  ["projects:write", "none", [], []],
156
157
  ["project:stand", "none", [], []],
157
- ["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"]],
158
+ ["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"]],
158
159
  ["projects:create", "none", [], []],
159
160
  ["projects:delete", "none", [], []],
160
161
  ["tenants:read", "none", [], []],
@@ -185,7 +186,6 @@ var GRANT_TABLE = [
185
186
  ["usersecrets:list", "none", [], []],
186
187
  ["usersecrets:put", "none", [], []],
187
188
  ["usersecrets:delete", "none", [], []],
188
- ["auth:login_with_foir", "none", [], []],
189
189
  ["signinproviders:read", "none", [], []],
190
190
  ["signinproviders:write", "none", [], []]
191
191
  ];
@@ -1107,8 +1107,18 @@ async function resolveFoirAuth(config) {
1107
1107
  key: { apiKey: config.apiKey, bearerToken }
1108
1108
  };
1109
1109
  }
1110
+ var renewing = /* @__PURE__ */ new WeakMap();
1111
+ function renewBearerOnce(refresh) {
1112
+ const running = renewing.get(refresh);
1113
+ if (running) return running;
1114
+ const next = refresh().finally(() => renewing.delete(refresh));
1115
+ renewing.set(refresh, next);
1116
+ return next;
1117
+ }
1110
1118
 
1111
1119
  // src/data/ws-transport.ts
1120
+ var CLOSE_CREDENTIAL_ENDED = 4402;
1121
+ var CLOSE_FORBIDDEN = 4403;
1112
1122
  var GQL = {
1113
1123
  connectionInit: "connection_init",
1114
1124
  connectionAck: "connection_ack",
@@ -1143,6 +1153,9 @@ var FoirSocketTransport = class {
1143
1153
  disposed = false;
1144
1154
  keepAlive = null;
1145
1155
  retryTimer = null;
1156
+ // A bearer renewed after the socket's lapsed (4402), presented from the next
1157
+ // connect on in place of the client's own.
1158
+ renewedBearer = null;
1146
1159
  constructor(config) {
1147
1160
  if (config.dispatch !== void 0) {
1148
1161
  throw new FoirRequestError(
@@ -1215,12 +1228,14 @@ var FoirSocketTransport = class {
1215
1228
  socket.onmessage = (event) => this.receive(event.data);
1216
1229
  socket.onerror = () => {
1217
1230
  };
1218
- socket.onclose = () => this.handleClose();
1231
+ socket.onclose = (event) => this.handleClose(event);
1219
1232
  }
1220
1233
  async sendConnectionInit(socket) {
1221
1234
  this.connecting = false;
1222
1235
  try {
1223
- const { headers } = await resolveFoirAuth(this.config);
1236
+ const { headers } = await resolveFoirAuth(
1237
+ this.renewedBearer ? { ...this.config, bearerToken: this.renewedBearer } : this.config
1238
+ );
1224
1239
  const { "Content-Type": _contentType, ...params } = headers;
1225
1240
  const extra = await this.config.connectionParams?.() ?? {};
1226
1241
  this.send(socket, {
@@ -1234,19 +1249,53 @@ var FoirSocketTransport = class {
1234
1249
  this.teardownSocket(true);
1235
1250
  }
1236
1251
  }
1237
- handleClose() {
1252
+ handleClose(event) {
1253
+ const code = event?.code;
1238
1254
  const wasAcknowledged = this.acknowledged;
1239
1255
  this.teardownSocket(false);
1240
1256
  if (this.disposed || this.registrations.size === 0) return;
1241
1257
  for (const registration of this.registrations.values()) {
1242
1258
  registration.sent = false;
1243
1259
  }
1260
+ if (code === CLOSE_FORBIDDEN) {
1261
+ this.failAll(
1262
+ new FoirRequestError(
1263
+ "[foir] the subscription socket was refused where it asked to stand: the signed-in user is no member of the organization this client names"
1264
+ )
1265
+ );
1266
+ return;
1267
+ }
1268
+ if (code === CLOSE_CREDENTIAL_ENDED) {
1269
+ void this.reconnectWithARenewedBearer();
1270
+ return;
1271
+ }
1244
1272
  this.scheduleReconnect(
1245
1273
  new FoirRequestError(
1246
1274
  wasAcknowledged ? "[foir] the subscription socket closed and could not be re-established" : "[foir] the subscription socket closed before the credential was accepted"
1247
1275
  )
1248
1276
  );
1249
1277
  }
1278
+ /**
1279
+ * Reconnect after the socket's credential lapsed: with the bearer renewed
1280
+ * through the client's `refreshBearer` where it has one, failing every
1281
+ * subscription with `FoirSessionExpiredError` when the session is over. A
1282
+ * client without one reconnects at once, presenting what its bearer
1283
+ * provider gives now.
1284
+ */
1285
+ async reconnectWithARenewedBearer() {
1286
+ const refresh = this.config.refreshBearer;
1287
+ if (refresh) {
1288
+ const token = await renewBearerOnce(refresh).catch(() => null);
1289
+ if (this.disposed) return;
1290
+ if (!token) {
1291
+ this.failAll(new FoirSessionExpiredError());
1292
+ return;
1293
+ }
1294
+ this.renewedBearer = token;
1295
+ }
1296
+ this.attempt = 0;
1297
+ this.ensureConnected();
1298
+ }
1250
1299
  scheduleReconnect(error) {
1251
1300
  if (this.disposed || this.registrations.size === 0) return;
1252
1301
  if (this.attempt >= this.policy.maxAttempts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -173,12 +173,12 @@
173
173
  "react-dom": "^19.2.0",
174
174
  "tsup": "^8.5.1",
175
175
  "typescript": "5.9.2",
176
- "vitest": "^3.2.4",
176
+ "vitest": "^5.0.5",
177
177
  "ws": "^8.18.0",
178
178
  "y-websocket": "^3.0.0",
179
179
  "yjs": "^13.6.30",
180
- "@foir/proto-ts": "0.109.0",
181
- "@foir/access-vocab": "0.0.0"
180
+ "@foir/access-vocab": "0.0.0",
181
+ "@foir/proto-ts": "0.109.0"
182
182
  },
183
183
  "engines": {
184
184
  "node": ">=18.0.0"