@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 +90 -0
- package/dist/auth/auth.d.ts +14 -5
- package/dist/auth/code-flow.d.ts +97 -0
- package/dist/auth/hosted.d.ts +11 -3
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/operations.d.ts +5 -0
- package/dist/auth.js +177 -8
- package/dist/data/builtins.d.ts +1 -2
- package/dist/data/transport.d.ts +1 -0
- package/dist/data/ws-transport.d.ts +9 -0
- package/dist/data.js +44 -3
- package/dist/media/ProgressiveMedia.d.ts +27 -1
- package/dist/media.js +39 -10
- package/dist/next/session.d.ts +6 -2
- package/dist/next.js +4 -9
- package/dist/placement.js +2 -2
- package/dist/react/FoirFeedback.d.ts +58 -3
- package/dist/react/FoirProvider.d.ts +6 -2
- package/dist/react/index.d.ts +1 -1
- package/dist/react.js +299 -163
- package/dist/realtime/index.d.ts +6 -0
- package/dist/realtime.js +2 -1
- package/dist/server.js +54 -5
- package/package.json +2 -2
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`
|
package/dist/auth/auth.d.ts
CHANGED
|
@@ -43,11 +43,16 @@ export declare function login(env: FoirEnv, vars: {
|
|
|
43
43
|
email: string;
|
|
44
44
|
password: string;
|
|
45
45
|
}): Promise<AuthResult>;
|
|
46
|
-
/**
|
|
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<
|
|
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
|
-
/**
|
|
448
|
-
|
|
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<
|
|
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>;
|
package/dist/auth/hosted.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
package/dist/auth/index.d.ts
CHANGED
|
@@ -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(
|
|
1412
|
-
|
|
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", "
|
|
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 = `${
|
|
1925
|
-
const logoutUrl = `${
|
|
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(
|
|
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
|
|
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,
|
package/dist/data/builtins.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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;
|
package/dist/data/transport.d.ts
CHANGED
|
@@ -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;
|