@foir/sdk 0.26.0 → 0.27.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,67 @@ 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.27.0] — 2026-10-20
112
+
113
+ ### Added
114
+
115
+ - **`organizations.startPool({ movePeople, dryRun })`** (`@foir/sdk/app`):
116
+ starting the run's organization's own pool takes its people along when
117
+ asked. Somebody who stands only there moves whole, and somebody who also
118
+ stands elsewhere is given a new user there with their memberships, roles and
119
+ what they own there. `dryRun` counts what would move and changes nothing.
120
+ The `AppOrganizationPoolMove` and `AppOrganizationPoolStart` types.
121
+ - **`stepUpRequired(error)`** and the `STEP_UP_REQUIRED` error code: what an
122
+ organization's sign-in rules ask of the signed-in user before a request
123
+ standing there can succeed, `second_factor` or `sign_in_method`.
124
+ - **`myOrganizationChoices`**: the organizations the signed-in user can enter,
125
+ as an organization switcher offers them: member, invited, may join or
126
+ requested, each marked available or not, and whether they stand at the
127
+ project. Requires the user's bearer.
128
+ - **`organizations.provision({ joining })`** (`@foir/sdk/app`): who may join
129
+ the new organization without an invitation. `'invitation'` takes only
130
+ invitations and staff adding people, `'open'` lets someone who can already
131
+ sign in at its page join by signing in there, and `'request'` makes signing
132
+ in there a request its staff approve or decline. Left out, the nearest
133
+ organization above that chose, then the project.
134
+ - **`organizations.provision({ users })`** (`@foir/sdk/app`): where somebody
135
+ who signs up at the new organization stands. `'joined'` makes them a user of
136
+ that organization, and `'everyone'` a user of the project, so every
137
+ organization that shares its sign-in can find, email and sign them in. Left
138
+ out, the nearest organization above that chose, then the project.
139
+ - **`<FoirFeedback>` takes a note from a visitor who is not signed in**
140
+ (`@foir/sdk/react`), on a project that takes visitors' notes ("Accept
141
+ anonymous notes") through a key that gives them `notes:create`. The visitor
142
+ gets the composer rather than a sign-in prompt, with an optional email to
143
+ reply to, and is shown the note's `id` as a reference. The new
144
+ `onVisitorSubmitted` prop is called with the receipt; `onSubmitted` stays the
145
+ signed-in user's note. A project that does not take visitors' notes still
146
+ asks them to sign in.
147
+ - **`FoirNoteReceipt`** (`@foir/sdk/data`): `{ id, createdAt }`, what
148
+ `writeNote` answers a visitor with.
149
+ - **`FoirNoteConfig.signedIn` and `FoirNoteConfig.anonymousAccepted`**:
150
+ whether the caller writes as a signed-in user, and whether the project takes
151
+ visitors' notes. `screenshotsEnabled` is false for a visitor, whose note
152
+ carries no attachment.
153
+ - **`FoirFeedbackClient` carries an optional `writeNote`**, so a client typed
154
+ before it still satisfies the prop.
155
+
156
+ ### Changed
157
+
158
+ - **`<FoirFeedback>` writes a signed-in user's note through `writeNote`**, the
159
+ one note create, rather than `createMyNote`, its older name. The same note
160
+ on the same data API. A client whose generated module predates `writeNote`
161
+ is refused it, saying to re-run `foir types`; a client typed without it at
162
+ all still writes through `createMyNote`.
163
+ - **Breaking: `organizations.startPool()` answers `{ pool, moved }`**
164
+ (`AppOrganizationPoolStart`): the pool it signs its people in to now, and
165
+ what moved into it by kind. It answered the pool alone.
166
+ - **`organizations.provision({ ownPool: false })` is sent.** It used to be
167
+ dropped, so it read as leaving the choice to the project; it now says the
168
+ organization shares the pool above it, whatever the project chose. Left out,
169
+ it still takes the project's choice for a top-level organization and its
170
+ parent's pool for one beneath another.
171
+
111
172
  ## [0.26.0] — 2026-10-19
112
173
 
113
174
  ### Changed
@@ -241,9 +241,26 @@ export interface AppOrganizationProvision {
241
241
  * The organisation signs its own people in, to a pool of its own, apart
242
242
  * from its parent's and the project's. Its descendants share that pool
243
243
  * unless one of them starts another. One that exists already starts one
244
- * with `organizations.startPool`.
244
+ * with `organizations.startPool`. Left out, the project's choice for one at
245
+ * the top, and its parent's pool for one beneath another.
245
246
  */
246
247
  ownPool?: boolean;
248
+ /**
249
+ * Where somebody who signs up at it stands: `'joined'` makes them a user of
250
+ * this organisation, and `'everyone'` a user of the project, so every
251
+ * organisation that shares its sign-in can find, email and sign them in.
252
+ * Left out, the nearest organisation above that chose, then the project.
253
+ * Its first member is a member of it either way.
254
+ */
255
+ users?: 'joined' | 'everyone';
256
+ /**
257
+ * Who may join it without an invitation: `'invitation'` (only an invitation
258
+ * or staff adding them), `'open'` (someone who can already sign in at its
259
+ * page joins by signing in there) or `'request'` (signing in there asks, and
260
+ * its staff approve or decline). Left out, the nearest organisation above
261
+ * that chose, then the project.
262
+ */
263
+ joining?: 'invitation' | 'open' | 'request';
247
264
  }
248
265
  /**
249
266
  * The pool an organisation's people sign in to, and where it comes from: its
@@ -258,6 +275,44 @@ export interface AppOrganizationPool {
258
275
  /** How many people the pool holds, when it is the organisation's own. Null for any other. */
259
276
  people: number | null;
260
277
  }
278
+ /** What starting an organisation's own pool moved, or for a dry run would move, by kind. */
279
+ export interface AppOrganizationPoolMove {
280
+ /** Whether nothing was changed. */
281
+ dryRun: boolean;
282
+ /** People who stood only there, moved whole into the new pool. */
283
+ peopleMoved: number;
284
+ /** People who also stand elsewhere, each given a new user in the new pool. */
285
+ peopleMade: number;
286
+ /** Of those, how many are emailed a link to set up their account. */
287
+ peopleTold: number;
288
+ /** Memberships of the people given a new user, moved to that new user. */
289
+ membershipsMoved: number;
290
+ /** Active roles held in those memberships. */
291
+ rolesMoved: number;
292
+ recordsMoved: number;
293
+ filesMoved: number;
294
+ /** Notes assigned to them there, now assigned to the new user. A note stays its author's. */
295
+ notesReassigned: number;
296
+ notificationsMoved: number;
297
+ /** Their choices of what they are told there. */
298
+ notificationChoicesMoved: number;
299
+ /** The access they were given to single resources there. */
300
+ accessGrantsMoved: number;
301
+ /** Open invitations there, now to the new pool. */
302
+ invitationsMoved: number;
303
+ /** Requests to join from people it no longer signs in, withdrawn. */
304
+ joinRequestsWithdrawn: number;
305
+ /** Apps that now sign in the new pool's people. */
306
+ appsMoved: number;
307
+ /** Sign-ins through those apps of anybody not in the new pool, ended. */
308
+ signInsEnded: number;
309
+ }
310
+ /** Starting an organisation's own pool: the pool it signs its people in to now, and what moved into it. */
311
+ export interface AppOrganizationPoolStart {
312
+ /** Its pool, now its own; for a dry run, the pool it signs in to still. */
313
+ pool: AppOrganizationPool;
314
+ moved: AppOrganizationPoolMove;
315
+ }
261
316
  /**
262
317
  * Who a consent change is for: a user whose id the app holds, or an
263
318
  * address.
@@ -483,9 +538,12 @@ export interface AppClient {
483
538
  };
484
539
  users: {
485
540
  /**
486
- * The user who has this email at the install's level, made when there
487
- * is none. The same user every time for one email. Needs
488
- * `users:create`.
541
+ * The user who has this email in the sign-in the install's place uses,
542
+ * made when nobody has it. The same user every time for one email. One
543
+ * the place does not reach is answered only where its organization lets
544
+ * them in (open, or their verified address at its open domain), joining
545
+ * it first; anybody else is refused without saying who has the address.
546
+ * Needs `users:create`.
489
547
  *
490
548
  * `verified` says whether that address is one the user has proved is
491
549
  * theirs. A user this call makes has not, and neither has one an
@@ -586,17 +644,27 @@ export interface AppClient {
586
644
  pool(): Promise<AppOrganizationPool>;
587
645
  /**
588
646
  * Start the own pool of the organisation this run acts in, which it and
589
- * everything beneath it sign their people in to. Needs
590
- * `organizations:write`. Refused while
591
- * anybody of the pool it signs in to now holds a membership in it or
592
- * beneath it, or an invitation there is open to that pool, and the
593
- * refusal says how many: nobody is moved between pools, so those
594
- * memberships are removed first and those people sign up again in the
595
- * new pool. Its apps, and those beneath it, then sign their people in
596
- * there, and anybody signed in through them is signed out. Safe to run
597
- * again: it answers the pool it has.
647
+ * everything beneath it that shares its pool sign their people in to.
648
+ * Needs `organizations:write`.
649
+ *
650
+ * Without `movePeople`, refused while anybody of the pool it signs in to
651
+ * now holds a membership in it or beneath it, or an invitation there is
652
+ * open to that pool, and the refusal says how many. With it, somebody who
653
+ * stands only there moves whole, with their addresses, sign-ins and
654
+ * sessions, and somebody who also stands elsewhere is given a new user
655
+ * there, with their memberships, roles and what they own there, who
656
+ * proves their address before they sign in; refused while the new pool
657
+ * would hold an address twice. `dryRun` answers what would move and
658
+ * changes nothing.
659
+ *
660
+ * Its apps, and those beneath it, then sign their people in there, and
661
+ * anybody signed in through them who is not in the new pool is signed
662
+ * out. Safe to run again: it answers the pool it has and moves nobody.
598
663
  */
599
- startPool(): Promise<AppOrganizationPool>;
664
+ startPool(options?: {
665
+ movePeople?: boolean;
666
+ dryRun?: boolean;
667
+ }): Promise<AppOrganizationPoolStart>;
600
668
  /**
601
669
  * Drop the own pool of the organisation this run acts in, so it signs its
602
670
  * people in to the pool above it. Needs `organizations:write`. Refused
@@ -21,7 +21,7 @@
21
21
  * The manifest's own messages are on `@foir/sdk/app/contract`.
22
22
  */
23
23
  export { createAppClient } from './client';
24
- export type { AppBinding, AppClient, AppClientConfig, AppConsentChange, AppConsentSubject, AppUser, AppUserEraseResult, AppUserList, AppUserSaveResult, AppFieldMapping, AppInstallConfig, AppNotification, AppOperationStart, AppOrganizationPool, AppOrganizationProvision, AppRecord, AppRecordList, AppStartedOperation, AppStatusReport, AppSyncDirection, AppUpsertResult, AppWhoami, } from './client';
24
+ export type { AppBinding, AppClient, AppClientConfig, AppConsentChange, AppConsentSubject, AppUser, AppUserEraseResult, AppUserList, AppUserSaveResult, AppFieldMapping, AppInstallConfig, AppNotification, AppOperationStart, AppOrganizationPool, AppOrganizationPoolMove, AppOrganizationPoolStart, AppOrganizationProvision, AppRecord, AppRecordList, AppStartedOperation, AppStatusReport, AppSyncDirection, AppUpsertResult, AppWhoami, } from './client';
25
25
  export { appUserEmailUnavailableOf, appUserUnlinkedOf, appLookupConflictOf, isAppFieldWithheld } from './errors';
26
26
  export type { AppUserEmailUnavailableReason, AppLookupConflict } from './errors';
27
27
  export { appModelFieldName, appModelTypeName, appSelectorEnumValue } from './naming';
@@ -3,7 +3,7 @@
3
3
  * manifest's hook may subscribe to (an event of the project, that something
4
4
  * emits) is the platform's to say: `validateAppManifest` answers it.
5
5
  */
6
- export declare const APP_HOOK_EVENTS: readonly ["RECORD_CREATED", "RECORD_UPDATED", "RECORD_DELETED", "RECORD_PUBLISHED", "RECORD_UNPUBLISHED", "USER_CREATED", "USER_UPDATED", "USER_DELETED", "USER_MERGED", "SESSION_REVOKED", "OPERATION_COMPLETED", "OPERATION_FAILED", "OPERATION_CANCELLED", "OPERATION_TIMED_OUT", "MEMBERSHIP_GRANTED", "MEMBERSHIP_REVOKED", "MEMBERSHIP_ROLE_CHANGED", "TENANT_PLAN_CHANGED", "TENANT_SUBSCRIPTION_STATUS_CHANGED", "PROJECT_CREATED", "PROJECT_ARCHIVED", "INVITATION_ACCEPTED", "IMPORT_COMPLETED", "MIGRATION_APPLIED", "BULK_UPDATE_COMPLETED", "BULK_PUBLISH_COMPLETED", "USER_PREFERENCE_CHANGED", "ORGANIZATION_CREATED", "ORGANIZATION_UPDATED", "ORGANIZATION_MOVED", "ORGANIZATION_SUSPENDED", "ORGANIZATION_RESUMED", "ORGANIZATION_DELETION_SCHEDULED", "ORGANIZATION_DELETION_CANCELLED", "ORGANIZATION_DELETED", "ORGANIZATION_MEMBER_ADDED", "ORGANIZATION_MEMBER_REMOVED", "ORGANIZATION_MEMBER_ROLE_CHANGED", "PROJECT_REACTIVATED"];
6
+ export declare const APP_HOOK_EVENTS: readonly ["RECORD_CREATED", "RECORD_UPDATED", "RECORD_DELETED", "RECORD_PUBLISHED", "RECORD_UNPUBLISHED", "USER_CREATED", "USER_UPDATED", "USER_DELETED", "USER_MERGED", "SESSION_REVOKED", "OPERATION_COMPLETED", "OPERATION_FAILED", "OPERATION_CANCELLED", "OPERATION_TIMED_OUT", "MEMBERSHIP_GRANTED", "MEMBERSHIP_REVOKED", "MEMBERSHIP_ROLE_CHANGED", "TENANT_PLAN_CHANGED", "TENANT_SUBSCRIPTION_STATUS_CHANGED", "PROJECT_CREATED", "PROJECT_ARCHIVED", "INVITATION_ACCEPTED", "IMPORT_COMPLETED", "MIGRATION_APPLIED", "BULK_UPDATE_COMPLETED", "BULK_PUBLISH_COMPLETED", "USER_PREFERENCE_CHANGED", "ORGANIZATION_CREATED", "ORGANIZATION_UPDATED", "ORGANIZATION_MOVED", "ORGANIZATION_SUSPENDED", "ORGANIZATION_RESUMED", "ORGANIZATION_DELETION_SCHEDULED", "ORGANIZATION_DELETION_CANCELLED", "ORGANIZATION_DELETED", "ORGANIZATION_MEMBER_ADDED", "ORGANIZATION_MEMBER_REMOVED", "ORGANIZATION_MEMBER_ROLE_CHANGED", "PROJECT_REACTIVATED", "JOIN_REQUESTED", "JOIN_REQUEST_APPROVED", "JOIN_REQUEST_DECLINED"];
7
7
  /** An event a hook can name. */
8
8
  export type AppHookEvent = (typeof APP_HOOK_EVENTS)[number];
9
9
  /**
package/dist/app.js CHANGED
@@ -353,8 +353,8 @@ function createAppClient(config) {
353
353
  throw new FoirRequestError(`[foir] ${message}`);
354
354
  };
355
355
  const POOL_FIELDS = "organizationId poolId source sourceOrganizationId people";
356
- async function poolOf(name, document) {
357
- const answer = await field(name, document, {});
356
+ const POOL_MOVE_FIELDS = "dryRun peopleMoved peopleMade peopleTold membershipsMoved rolesMoved recordsMoved filesMoved notesReassigned notificationsMoved notificationChoicesMoved accessGrantsMoved invitationsMoved joinRequestsWithdrawn appsMoved signInsEnded";
357
+ function poolFrom(name, answer) {
358
358
  if (!answer?.poolId || !answer.organizationId || answer.source !== "OWN" && answer.source !== "ANCESTOR" && answer.source !== "PROJECT") {
359
359
  return refuse(`${name} reported no error and answered no pool`);
360
360
  }
@@ -366,6 +366,9 @@ function createAppClient(config) {
366
366
  people: answer.people ?? null
367
367
  };
368
368
  }
369
+ async function poolOf(name, document) {
370
+ return poolFrom(name, await field(name, document, {}));
371
+ }
369
372
  let surface;
370
373
  function served() {
371
374
  surface ??= withFoirRetry(async () => {
@@ -854,7 +857,9 @@ function createAppClient(config) {
854
857
  input.firstMember = { userId: first.userId };
855
858
  }
856
859
  }
857
- if (organization.ownPool === true) input.ownPool = true;
860
+ if (typeof organization.ownPool === "boolean") input.ownPool = organization.ownPool;
861
+ if (organization.users !== void 0) input.users = organization.users;
862
+ if (organization.joining !== void 0) input.joining = organization.joining;
858
863
  const answered = settings === void 0 ? "organizationId status" : "organizationId settingsRecordId status";
859
864
  const answer = await field(
860
865
  "provisionOrganization",
@@ -882,8 +887,18 @@ function createAppClient(config) {
882
887
  async pool() {
883
888
  return poolOf("organizationPool", `query AppOrganizationPool { organizationPool { ${POOL_FIELDS} } }`);
884
889
  },
885
- async startPool() {
886
- return poolOf("startOrganizationPool", `mutation AppStartOrganizationPool { startOrganizationPool { ${POOL_FIELDS} } }`);
890
+ async startPool(options = {}) {
891
+ const asks = `movePeople: ${options.movePeople === true}, dryRun: ${options.dryRun === true}`;
892
+ const answer = await field(
893
+ "startOrganizationPool",
894
+ `mutation AppStartOrganizationPool { startOrganizationPool(${asks}) { pool { ${POOL_FIELDS} } moved { ${POOL_MOVE_FIELDS} } } }`,
895
+ {}
896
+ );
897
+ const pool = poolFrom("startOrganizationPool", answer?.pool);
898
+ if (!answer?.moved) {
899
+ return refuse("startOrganizationPool reported no error and answered nothing of what moved");
900
+ }
901
+ return { pool, moved: answer.moved };
887
902
  },
888
903
  async dropPool() {
889
904
  return poolOf("dropOrganizationPool", `mutation AppDropOrganizationPool { dropOrganizationPool { ${POOL_FIELDS} } }`);
@@ -1218,7 +1233,10 @@ var APP_HOOK_EVENTS = [
1218
1233
  "ORGANIZATION_MEMBER_ADDED",
1219
1234
  "ORGANIZATION_MEMBER_REMOVED",
1220
1235
  "ORGANIZATION_MEMBER_ROLE_CHANGED",
1221
- "PROJECT_REACTIVATED"
1236
+ "PROJECT_REACTIVATED",
1237
+ "JOIN_REQUESTED",
1238
+ "JOIN_REQUEST_APPROVED",
1239
+ "JOIN_REQUEST_DECLINED"
1222
1240
  ];
1223
1241
  var APP_GRANTS = [
1224
1242
  "appconfig:read",
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { type FoirEnv } from '../graphql/foir-request';
19
19
  import { type AuthOpName, type AuthOpOptions } from './ops';
20
- import { type AuthConfig, type AuthProviderPublic, type CurrentUser, type AuthResult, type DefaultAuthProvider, type AuthorizedApp, type DeleteAccountResult, type MyOrganization, type MyProfile, type MyProfileChange, type MySession, type MyAddress, type MyDataExport, type MyFactor, type StepUp, type FactorEnrollment, type MySignInMethod, type NewPersonalAccessToken, type ProviderCallbackInput, type ProviderLoginInput, type ProviderLoginResult, type ProviderOTPVerifyInput, type ProviderToken, type SignupContext } from './operations';
20
+ import { type AuthConfig, type AuthProviderPublic, type CurrentUser, type AuthResult, type DefaultAuthProvider, type AuthorizedApp, type DeleteAccountResult, type MyOrganization, type MyOrganizationChoices, type MyProfile, type MyProfileChange, type MySession, type MyAddress, type MyDataExport, type MyFactor, type StepUp, type FactorEnrollment, type MySignInMethod, type NewPersonalAccessToken, type ProviderCallbackInput, type ProviderLoginInput, type ProviderLoginResult, type ProviderOTPVerifyInput, type ProviderToken, type SignupContext } from './operations';
21
21
  import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
22
22
  /** An access token + its rotating refresh token. */
23
23
  export interface FoirAuthTokens {
@@ -380,6 +380,15 @@ export declare function myOrganizations(env: FoirEnv, vars?: {
380
380
  first?: number;
381
381
  after?: string;
382
382
  }, options?: AuthOpOptions): Promise<MyOrganization[]>;
383
+ /**
384
+ * The organizations the signed-in user can enter, as an organization switcher
385
+ * offers them: those they are a member of, are invited to, may join by signing
386
+ * in there, or asked to join, in their own sign-in. It is theirs alone. An
387
+ * organization that lets them in no way is never named, and one that may not
388
+ * act now is listed with `available: false` rather than left out. Requires the
389
+ * user's bearer.
390
+ */
391
+ export declare function myOrganizationChoices(env: FoirEnv, options?: AuthOpOptions): Promise<MyOrganizationChoices>;
383
392
  /**
384
393
  * Found an organization, as its first member.
385
394
  *
@@ -10,7 +10,7 @@
10
10
  * the caller's own manifest advertises, through the same op transport
11
11
  * `@foir/sdk/data` uses, so `graphql` is not on this module's path at all.
12
12
  */
13
- export { login, register, requestOTP, loginWithOTP, refreshTokens, loginWithProvider, providerCallback, providerVerifyOTP, providerSignInRefusalOf, signInRefusalOf, confirmProviderLink, resendProviderLinkCode, getAuthProviders, getAuthConfig, getDefaultAuthProvider, signupContext, joinWaitlist, requestPasswordReset, resetPassword, updatePassword, currentUser, myProviderToken, logout, deleteMyAccount, leaveMembership, updateMyProfile, myAuthorizedApps, revokeAuthorizedApp, mySessions, endMySession, myAddresses, addMyAddress, proveMyAddress, makeMyAddressPrimary, removeMyAddress, exportMyData, signOutEverywhere, createPersonalAccessToken, mySignInMethods, connectSignInMethod, disconnectSignInMethod, myFactors, beginStepUp, completeStepUp, beginFactorEnrollment, completeFactorEnrollment, removeFactor, renameFactor, makeRecoveryCodes, myOrganizations, registerOrganization, saveSession, clearSession, getSessionToken, getSessionUser, signInWithPassword, signUpWithPassword, signInWithOTP, resetPasswordAndSignIn, refreshSession, refreshSessionOnce, signOut, authedRequest, authedOp, requireAuth, } from './auth';
13
+ export { login, register, requestOTP, loginWithOTP, refreshTokens, loginWithProvider, providerCallback, providerVerifyOTP, providerSignInRefusalOf, signInRefusalOf, confirmProviderLink, resendProviderLinkCode, getAuthProviders, getAuthConfig, getDefaultAuthProvider, signupContext, joinWaitlist, requestPasswordReset, resetPassword, updatePassword, currentUser, myProviderToken, logout, deleteMyAccount, leaveMembership, updateMyProfile, myAuthorizedApps, revokeAuthorizedApp, mySessions, endMySession, myAddresses, addMyAddress, proveMyAddress, makeMyAddressPrimary, removeMyAddress, exportMyData, signOutEverywhere, createPersonalAccessToken, mySignInMethods, connectSignInMethod, disconnectSignInMethod, myFactors, beginStepUp, completeStepUp, beginFactorEnrollment, completeFactorEnrollment, removeFactor, renameFactor, makeRecoveryCodes, myOrganizations, myOrganizationChoices, registerOrganization, saveSession, clearSession, getSessionToken, getSessionUser, signInWithPassword, signUpWithPassword, signInWithOTP, resetPasswordAndSignIn, refreshSession, refreshSessionOnce, signOut, authedRequest, authedOp, requireAuth, } from './auth';
14
14
  export type { FoirAuthTokens, FoirUser, SessionAdapter, RequireAuthOptions, } from './auth';
15
15
  export { userAccess, getSessionAccess, hasRole, hasScope, requireRole, requireScope, FoirAccessDeniedError, } from './access';
16
16
  export type { FoirAccess, FoirAccessDenial } from './access';
@@ -21,4 +21,4 @@ export { hostedLoginUrl, hostedLogoutUrl, fetchHostedSession, hostedSignInNotice
21
21
  export type { HostedLoginConfig, HostedLoginOptions, HostedSession, HostedSessionResult, HostedSignInNotice } from './hosted';
22
22
  export { createFoirSessionClient } from './session-client';
23
23
  export type { FoirSessionClient, FoirSessionConfig, FoirSessionUser, } from './session-client';
24
- export type { AuthResult, ProviderLoginResult, AuthProviderPublic, ProviderLoginInput, ProviderCallbackInput, ProviderOTPVerifyInput, SignupContext, SignupMode, AuthConfig, AuthConfigProvider, DefaultAuthProvider, CurrentUser, ProviderToken, ProviderTokenRefresh, DeleteAccountResult, MyOrganization, MyProfile, MyProfileChange, AuthorizedApp, MySession, MyAddress, MyDataExport, NewPersonalAccessToken, MySignInMethod, MyFactor, StepUp, FactorEnrollment, } from './operations';
24
+ export type { AuthResult, ProviderLoginResult, AuthProviderPublic, ProviderLoginInput, ProviderCallbackInput, ProviderOTPVerifyInput, SignupContext, SignupMode, AuthConfig, AuthConfigProvider, DefaultAuthProvider, CurrentUser, ProviderToken, ProviderTokenRefresh, DeleteAccountResult, MyOrganization, MyOrganizationChoice, MyOrganizationChoices, MyProfile, MyProfileChange, AuthorizedApp, MySession, MyAddress, MyDataExport, NewPersonalAccessToken, MySignInMethod, MyFactor, StepUp, FactorEnrollment, } from './operations';
@@ -322,6 +322,38 @@ export interface AuthorizedApp {
322
322
  /** ISO-8601. */
323
323
  grantedAt: string | null;
324
324
  }
325
+ /**
326
+ * An organization the calling user can enter, and how, as
327
+ * `myOrganizationChoices` lists it. In their own sign-in only; an organization
328
+ * that lets them in no way is never named.
329
+ */
330
+ export interface MyOrganizationChoice {
331
+ orgId: string;
332
+ name: string;
333
+ displayName: string | null;
334
+ parentId: string | null;
335
+ /** `member`, `invited`, `may_join` or `requested`. */
336
+ relation: 'member' | 'invited' | 'may_join' | 'requested';
337
+ /** False for an organization that may not act now: suspended, or due to be deleted. */
338
+ available: boolean;
339
+ /** For `may_join`: `open`, signing in there joins; `request`, it asks. */
340
+ joining: 'open' | 'request' | null;
341
+ /** For `may_join`: a verified address of theirs is what lets them. */
342
+ byAddress: boolean;
343
+ /** For `invited`: the invitation waiting for them. */
344
+ invitationId: string | null;
345
+ /** For `requested`: their waiting request. */
346
+ requestId: string | null;
347
+ }
348
+ /** What `myOrganizationChoices` answers. */
349
+ export interface MyOrganizationChoices {
350
+ organizations: Array<MyOrganizationChoice>;
351
+ /**
352
+ * They stand at the project, and so at every organization sharing its
353
+ * sign-in; those are not listed one by one.
354
+ */
355
+ projectMember: boolean;
356
+ }
325
357
  /**
326
358
  * One organization the calling user may stand in, as `myOrganizations`
327
359
  * returns it.
@@ -82,6 +82,7 @@ export declare const AUTH_OPS: {
82
82
  readonly renameFactor: "renameFactor";
83
83
  readonly makeRecoveryCodes: "makeRecoveryCodes";
84
84
  readonly myOrganizations: "myOrganizations";
85
+ readonly myOrganizationChoices: "myOrganizationChoices";
85
86
  readonly registerOrganization: "registerOrganization";
86
87
  readonly loginWithProvider: "loginWithProvider";
87
88
  readonly providerCallback: "providerCallback";
package/dist/auth.js CHANGED
@@ -1130,6 +1130,9 @@ var AUTH_OPS = {
1130
1130
  // names the organization it acts in (`orgId` or `orgUrl` below), and the
1131
1131
  // platform checks the caller stands there every time.
1132
1132
  myOrganizations: "myOrganizations",
1133
+ // Where they may go: the organizations they are a member of, are invited
1134
+ // to, may join, or asked to join. An organization switcher's choices.
1135
+ myOrganizationChoices: "myOrganizationChoices",
1133
1136
  // Founding one. Present in a manifest only when the project opted in.
1134
1137
  registerOrganization: "registerOrganization",
1135
1138
  // External identity providers.
@@ -1371,6 +1374,9 @@ async function makeRecoveryCodes(env, elevatedHandle, options) {
1371
1374
  async function myOrganizations(env, vars, options) {
1372
1375
  return authOp(env, AUTH_OPS.myOrganizations, vars ?? {}, options);
1373
1376
  }
1377
+ async function myOrganizationChoices(env, options) {
1378
+ return authOp(env, AUTH_OPS.myOrganizationChoices, {}, options);
1379
+ }
1374
1380
  async function registerOrganization(env, vars, options) {
1375
1381
  return authOp(env, AUTH_OPS.registerOrganization, vars, options);
1376
1382
  }
@@ -2088,6 +2094,7 @@ export {
2088
2094
  myAddresses,
2089
2095
  myAuthorizedApps,
2090
2096
  myFactors,
2097
+ myOrganizationChoices,
2091
2098
  myOrganizations,
2092
2099
  myProviderToken,
2093
2100
  mySessions,
@@ -517,22 +517,50 @@ export interface FoirUserNote {
517
517
  createdAt: string;
518
518
  }
519
519
  /**
520
- * What a note surface has to know about the project before it renders anything
521
- * — `foir.ns.notes.noteConfig`.
522
- *
523
- * Charged `notes:create` rather than `notes:read`, and refused to a caller with
524
- * no user session, because the only caller it is useful to is one that can
525
- * submit. So a composer can read it as its single readiness probe: an answer
526
- * means this visitor may write, and a refusal names which half is missing.
520
+ * What a note surface has to know before it renders anything —
521
+ * `foir.ns.notes.noteConfig`.
522
+ *
523
+ * Charged `notes:create` rather than `notes:read`, and answered to exactly the
524
+ * callers `writeNote` takes a note from: a signed-in user, and a visitor with
525
+ * no session on a project that takes visitors' notes, through a key that gives
526
+ * them `notes:create`. Everyone else is refused. So a composer can read it as
527
+ * its single readiness probe: an answer means this caller may write, and says
528
+ * how; a refusal names which half is missing.
527
529
  */
528
530
  export interface FoirNoteConfig {
529
531
  /**
530
- * Whether this project's note surfaces may OFFER to capture a screenshot.
532
+ * Whether this caller's composer may OFFER to capture a screenshot: the
533
+ * project allows it, and the caller is signed in. Always false for a
534
+ * visitor, whose note carries no attachment.
531
535
  *
532
536
  * It gates the offer, not the storing. Capture stays opt-in per submission —
533
537
  * the browser's own picker is the consent gate and it cannot be bypassed.
534
538
  */
535
539
  screenshotsEnabled: boolean;
540
+ /**
541
+ * Whether the project takes notes from visitors who are not signed in
542
+ * (Notes settings → "Accept anonymous notes").
543
+ */
544
+ anonymousAccepted: boolean;
545
+ /**
546
+ * Whether the note this caller writes is a signed-in user's. False is a
547
+ * visitor's: `writeNote` answers it with a {@link FoirNoteReceipt} and nothing
548
+ * more, and it may carry a `contactEmail`.
549
+ */
550
+ signedIn: boolean;
551
+ }
552
+ /**
553
+ * What `writeNote` answers a visitor with: the note's `id` and `createdAt`,
554
+ * and nothing else, because nobody is signed in to read the note back as. Ask
555
+ * for more and the write is refused before anything is written.
556
+ *
557
+ * Show the `id` to the visitor so they can quote it. Nothing on the public
558
+ * plane takes it to read the note.
559
+ */
560
+ export interface FoirNoteReceipt {
561
+ id: string;
562
+ /** ISO-8601. */
563
+ createdAt: string;
536
564
  }
537
565
  /** `myNotes` arguments. Forward-only cursor pagination, like every list read. */
538
566
  export interface FoirMyNotesArgs {
@@ -558,11 +586,8 @@ export type FoirMyNotesResult = FoirConnection<FoirUserNote>;
558
586
  * A signed-in user's note is theirs and comes back in `myNotes`. A
559
587
  * visitor's, through a key that gives `notes:create` to whoever comes through
560
588
  * it, on a project that takes visitors' notes, is nobody's: ask for its `id`
561
- * and `createdAt` and nothing else, because nothing reads it back.
562
- *
563
- * Served by a data API that has the field. `<FoirFeedback>` still writes
564
- * through `createMyNote`, the same create under its older name, which every
565
- * data API serves.
589
+ * and `createdAt` and nothing else (`pick: ['id', 'createdAt']`), because
590
+ * nothing reads it back. What comes back is a {@link FoirNoteReceipt}.
566
591
  */
567
592
  export interface FoirWriteNoteArgs {
568
593
  /** A visitor may write about a record and nothing else. */
@@ -59,7 +59,7 @@ export { planFoirModelCall, planFoirNamespaceCall } from './document';
59
59
  export type { FoirCall, FoirDocumentPlan, FoirNamespaceCall, FoirNamespaceSelections, FoirRecordPath, FoirSelectionModel, FoirSelectionNamespace, FoirSelections, FoirSelectionSource, FoirSelectionUnions, } from './document';
60
60
  export { createFoirSubjectRegistry, recordFoirSubjects } from './subjects';
61
61
  export type { FoirSubject, FoirSubjectEntry, FoirSubjectOptions, FoirSubjectOrigin, FoirSubjectRegistry, } from './subjects';
62
- export type { CollabInfo, FileValue, FoirAccessGrant, FoirAccessLevel, FoirAccessSource, FoirAccessor, FoirCreateMyNoteArgs, FoirWriteNoteArgs, FoirUserNote, FoirUserNoteAttachment, FoirDegraded, FoirDepthBelow, FoirDepthLevel, FoirDeviceType, FoirGranteeKind, FoirJsonValue, FoirMyAccess, FoirMyNotesArgs, FoirMyNotesResult, FoirNoteConfig, FoirNoteSubjectType, FoirPlatformType, FoirRecordOwner, FoirResolveContext, FoirResourceAccess, FoirUnexpandedRecord, FoirUpdateManyPayload, FoirVisibility, GeoPoint, Image, ImageFormat, ImageVariant, ImageVariantSet, MediaValue, Passage, Prose, ProseSpan, RecordAncestor, Reference, Richtext, Video, } from './builtins';
62
+ export type { CollabInfo, FileValue, FoirAccessGrant, FoirAccessLevel, FoirAccessSource, FoirAccessor, FoirCreateMyNoteArgs, FoirWriteNoteArgs, FoirUserNote, FoirUserNoteAttachment, FoirDegraded, FoirDepthBelow, FoirDepthLevel, FoirDeviceType, FoirGranteeKind, FoirJsonValue, FoirMyAccess, FoirMyNotesArgs, FoirMyNotesResult, FoirNoteConfig, FoirNoteReceipt, FoirNoteSubjectType, FoirPlatformType, FoirRecordOwner, FoirResolveContext, FoirResourceAccess, FoirUnexpandedRecord, FoirUpdateManyPayload, FoirVisibility, GeoPoint, Image, ImageFormat, ImageVariant, ImageVariantSet, MediaValue, Passage, Prose, ProseSpan, RecordAncestor, Reference, Richtext, Video, } from './builtins';
63
63
  /**
64
64
  * Pre-rename spellings. The `<Name>Block` wrappers and the `<Name>Value`
65
65
  * split are the same thing as the types above — an item IS its value — so each
@@ -81,5 +81,5 @@ export type { FoirConnection, FoirFieldsShape, FoirModelDefinition, FoirNamespac
81
81
  export type { FoirInclude, FoirModelOperation, FoirNamespaceOperation, FoirOmittableKey, FoirOpResult, FoirQuery, FoirRecordResult, FoirWriteResult, } from './selection';
82
82
  export { FoirDecimal, decimal } from './decimal';
83
83
  export type { FoirDecimalDivOptions, FoirDecimalInput, FoirDecimalRounding, } from './decimal';
84
- export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, FOIR_VISITOR_IP_HEADER, } from '../transport/foir-http';
85
- export type { FoirEnv, FoirGraphQLErrorItem, FoirTokenProvider, FoirAuthDowngrade, FoirVisitor, FoirVisitorSource, } from '../transport/foir-http';
84
+ export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, FOIR_VISITOR_IP_HEADER, stepUpRequired, } from '../transport/foir-http';
85
+ export type { FoirEnv, FoirGraphQLErrorItem, FoirTokenProvider, FoirAuthDowngrade, FoirStepUp, FoirVisitor, FoirVisitorSource, } from '../transport/foir-http';
package/dist/data.js CHANGED
@@ -10,6 +10,15 @@ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
10
10
  "subselections_on_scalar",
11
11
  "missing_subselections"
12
12
  ]);
13
+ function stepUpRequired(error) {
14
+ if (!(error instanceof FoirRequestError)) return null;
15
+ for (const item of error.graphQLErrors ?? []) {
16
+ if (item.extensions?.code !== "STEP_UP_REQUIRED") continue;
17
+ const asked = item.extensions.stepUp;
18
+ if (asked === "second_factor" || asked === "sign_in_method") return asked;
19
+ }
20
+ return null;
21
+ }
13
22
  var FoirRequestError = class extends Error {
14
23
  /** HTTP status code, when the request failed at the HTTP layer. */
15
24
  status;
@@ -2686,5 +2695,6 @@ export {
2686
2695
  planFoirModelCall,
2687
2696
  planFoirNamespaceCall,
2688
2697
  printSubscriptionDocument,
2689
- recordFoirSubjects
2698
+ recordFoirSubjects,
2699
+ stepUpRequired
2690
2700
  };
package/dist/next.js CHANGED
@@ -1010,6 +1010,9 @@ var AUTH_OPS = {
1010
1010
  // names the organization it acts in (`orgId` or `orgUrl` below), and the
1011
1011
  // platform checks the caller stands there every time.
1012
1012
  myOrganizations: "myOrganizations",
1013
+ // Where they may go: the organizations they are a member of, are invited
1014
+ // to, may join, or asked to join. An organization switcher's choices.
1015
+ myOrganizationChoices: "myOrganizationChoices",
1013
1016
  // Founding one. Present in a manifest only when the project opted in.
1014
1017
  registerOrganization: "registerOrganization",
1015
1018
  // External identity providers.
package/dist/push.js CHANGED
@@ -1130,6 +1130,9 @@ var AUTH_OPS = {
1130
1130
  // names the organization it acts in (`orgId` or `orgUrl` below), and the
1131
1131
  // platform checks the caller stands there every time.
1132
1132
  myOrganizations: "myOrganizations",
1133
+ // Where they may go: the organizations they are a member of, are invited
1134
+ // to, may join, or asked to join. An organization switcher's choices.
1135
+ myOrganizationChoices: "myOrganizationChoices",
1133
1136
  // Founding one. Present in a manifest only when the project opted in.
1134
1137
  registerOrganization: "registerOrganization",
1135
1138
  // External identity providers.
@@ -1,4 +1,4 @@
1
- import type { FoirUserNote, FoirSubject, FoirSubjectRegistry } from '../data';
1
+ import type { FoirNoteReceipt, FoirUserNote, FoirSubject, FoirSubjectRegistry } from '../data';
2
2
  import { type FoirFeedbackClient } from './feedback-notes';
3
3
  export type { FoirFeedbackClient } from './feedback-notes';
4
4
  export interface FoirFeedbackProps {
@@ -41,12 +41,19 @@ export interface FoirFeedbackProps {
41
41
  /** Start with the panel open. Default false. */
42
42
  defaultOpen?: boolean;
43
43
  /**
44
- * Called when the visitor has to sign in first. Given a handler, the widget
45
- * renders a button that calls it; given none, it says so and stops.
44
+ * Called when the visitor has to sign in first: the project takes no notes
45
+ * from visitors who are not signed in. Given a handler, the widget renders a
46
+ * button that calls it; given none, it says so and stops.
46
47
  */
47
48
  onSignIn?: () => void;
48
- /** Called with the note once it is written. */
49
+ /** Called with a signed-in user's note once it is written. */
49
50
  onSubmitted?: (note: FoirUserNote) => void;
51
+ /**
52
+ * Called with the receipt once a visitor who is not signed in has sent a
53
+ * note: its id and when it was written, which is all a visitor's note
54
+ * answers with.
55
+ */
56
+ onVisitorSubmitted?: (receipt: FoirNoteReceipt) => void;
50
57
  /**
51
58
  * Called with anything that failed. The widget also logs it — this is for
52
59
  * routing it into your own error reporting, not for making it visible.
@@ -59,4 +66,4 @@ export interface FoirFeedbackProps {
59
66
  */
60
67
  className?: string;
61
68
  }
62
- export declare function FoirFeedback({ client, subjects, subject, label, triggerLabel, title, placeholder, defaultOpen, onSignIn, onSubmitted, onError, className, }: FoirFeedbackProps): import("react/jsx-runtime").JSX.Element;
69
+ export declare function FoirFeedback({ client, subjects, subject, label, triggerLabel, title, placeholder, defaultOpen, onSignIn, onSubmitted, onVisitorSubmitted, onError, className, }: FoirFeedbackProps): import("react/jsx-runtime").JSX.Element;
@@ -7,7 +7,7 @@
7
7
  * shape (see `data/selection.ts`) — so every response here has to be read rather
8
8
  * than cast, and reading is the part worth testing on its own.
9
9
  */
10
- import type { FoirUserNote, FoirNoteConfig, FoirSubject } from '../data';
10
+ import type { FoirUserNote, FoirNoteConfig, FoirNoteReceipt, FoirSubject } from '../data';
11
11
  import type { FoirScreenCapture } from '../media/screen-capture';
12
12
  /**
13
13
  * The client the widget needs, and no more of it.
@@ -15,9 +15,9 @@ import type { FoirScreenCapture } from '../media/screen-capture';
15
15
  * Structural rather than `FoirClient<S>`, for two reasons. `FoirClient` is
16
16
  * generic in the caller's generated schema, so naming it here would force the
17
17
  * component's props to be generic in a parameter nothing else reads. And what
18
- * the widget actually depends on is three root fields — narrowing to them means
19
- * a client whose key cannot reach `files` still satisfies this type, which is
20
- * the honest shape: a project with screenshots off never uploads anything.
18
+ * the widget actually depends on is a handful of root fields — narrowing to them
19
+ * means a client whose key cannot reach `files` still satisfies this type, which
20
+ * is the honest shape: a project with screenshots off never uploads anything.
21
21
  *
22
22
  * The signatures are the ones `foir types` emits for every namespace op —
23
23
  * `Record<string, unknown>` in, `unknown` out — so a real generated client
@@ -27,6 +27,14 @@ export interface FoirFeedbackClient {
27
27
  readonly ns: {
28
28
  readonly notes: {
29
29
  noteConfig(args?: Record<string, unknown>): Promise<unknown>;
30
+ /**
31
+ * The one note create, for a signed-in user and a visitor alike. Optional
32
+ * so a client typed before it still satisfies this; without it a
33
+ * signed-in user's note goes through `createMyNote`, and a visitor's
34
+ * cannot be sent.
35
+ */
36
+ writeNote?(args?: Record<string, unknown>): Promise<unknown>;
37
+ /** The older name of `writeNote` for a signed-in user. */
30
38
  createMyNote(args?: Record<string, unknown>): Promise<unknown>;
31
39
  };
32
40
  /**
@@ -77,6 +85,12 @@ export declare function classifyNotesFault(err: unknown): FoirFeedbackFault;
77
85
  * `screenshotsEnabled` to false. Defaulting would be the quiet version of a
78
86
  * project's setting being ignored: the offer would disappear from every surface
79
87
  * at once and the only symptom would be a missing button.
88
+ *
89
+ * `signedIn` and `anonymousAccepted` are read as the older contract when they
90
+ * are absent, which is not a guess: a data API without them answers only a
91
+ * signed-in user, and refuses everyone else. A generated module that predates
92
+ * them selects neither from a data API that has both, and the client's drift
93
+ * check names that module as the thing to regenerate.
80
94
  */
81
95
  export declare function readNoteConfig(client: FoirFeedbackClient): Promise<FoirNoteConfig>;
82
96
  /** What one submission carries. */
@@ -101,3 +115,23 @@ export interface FoirFeedbackSubmission {
101
115
  * they wrote ("as you can see in the screenshot") stops making sense.
102
116
  */
103
117
  export declare function submitFeedbackNote(client: FoirFeedbackClient, submission: FoirFeedbackSubmission): Promise<FoirUserNote>;
118
+ /** What a visitor's submission carries. */
119
+ export interface FoirVisitorSubmission {
120
+ /** What the note is about, as for a signed-in user's. */
121
+ subject: FoirSubject;
122
+ body: string;
123
+ /**
124
+ * Where the project's team may reply, as the visitor typed it. Sent only
125
+ * when it is not blank, and never verified by anyone.
126
+ */
127
+ contactEmail?: string;
128
+ }
129
+ /**
130
+ * Write a visitor's note, and hand back its receipt.
131
+ *
132
+ * It asks for the note's `id` and `createdAt` and nothing else, because that is
133
+ * all a visitor's note answers with: asked for more, the data API refuses the
134
+ * write before anything is written. And it takes no attachment, which a
135
+ * visitor's note cannot carry.
136
+ */
137
+ export declare function submitVisitorNote(client: FoirFeedbackClient, submission: FoirVisitorSubmission): Promise<FoirNoteReceipt>;
package/dist/react.js CHANGED
@@ -1004,6 +1004,9 @@ var AUTH_OPS = {
1004
1004
  // names the organization it acts in (`orgId` or `orgUrl` below), and the
1005
1005
  // platform checks the caller stands there every time.
1006
1006
  myOrganizations: "myOrganizations",
1007
+ // Where they may go: the organizations they are a member of, are invited
1008
+ // to, may join, or asked to join. An organization switcher's choices.
1009
+ myOrganizationChoices: "myOrganizationChoices",
1007
1010
  // Founding one. Present in a manifest only when the project opted in.
1008
1011
  registerOrganization: "registerOrganization",
1009
1012
  // External identity providers.
@@ -2246,7 +2249,14 @@ async function readNoteConfig(client) {
2246
2249
  "[foir] noteConfig answered without a boolean `screenshotsEnabled`. The widget reads it to decide whether to offer screenshot capture, and will not guess."
2247
2250
  );
2248
2251
  }
2249
- return { screenshotsEnabled };
2252
+ const signedIn = result?.signedIn ?? true;
2253
+ const anonymousAccepted = result?.anonymousAccepted ?? false;
2254
+ if (typeof signedIn !== "boolean" || typeof anonymousAccepted !== "boolean") {
2255
+ throw new Error(
2256
+ "[foir] noteConfig answered `signedIn` or `anonymousAccepted` with something other than a boolean. The widget reads them to decide whether this visitor writes as themselves, and will not guess."
2257
+ );
2258
+ }
2259
+ return { screenshotsEnabled, anonymousAccepted, signedIn };
2250
2260
  }
2251
2261
  async function submitFeedbackNote(client, submission) {
2252
2262
  const attachmentFileIds = [];
@@ -2255,14 +2265,42 @@ async function submitFeedbackNote(client, submission) {
2255
2265
  await uploadFeedbackAttachment(client, submission.screenshot, submission.fetch)
2256
2266
  );
2257
2267
  }
2258
- const args = {
2268
+ const args = noteArgs(submission.subject, submission.body);
2269
+ if (attachmentFileIds.length > 0) args.attachmentFileIds = attachmentFileIds;
2270
+ const { notes } = client.ns;
2271
+ return readCreatedNote(
2272
+ await (notes.writeNote ? notes.writeNote(args) : notes.createMyNote(args))
2273
+ );
2274
+ }
2275
+ async function submitVisitorNote(client, submission) {
2276
+ const { notes } = client.ns;
2277
+ if (!notes.writeNote) {
2278
+ throw new Error(
2279
+ "[foir] this client has no `notes.writeNote`, which is the only create that takes a note from a visitor who is not signed in. Re-run `foir types`."
2280
+ );
2281
+ }
2282
+ const args = noteArgs(submission.subject, submission.body);
2283
+ const contactEmail = submission.contactEmail?.trim();
2284
+ if (contactEmail) args.contactEmail = contactEmail;
2285
+ args.pick = ["id", "createdAt"];
2286
+ const result = await notes.writeNote(args);
2287
+ if (!result || typeof result.id !== "string" || result.id === "") {
2288
+ throw new Error(
2289
+ "[foir] writeNote answered a visitor with no receipt. Nothing was saved \u2014 if it had been, the surface would have returned its id."
2290
+ );
2291
+ }
2292
+ return {
2293
+ id: result.id,
2294
+ createdAt: typeof result.createdAt === "string" ? result.createdAt : ""
2295
+ };
2296
+ }
2297
+ function noteArgs(subject, body) {
2298
+ return {
2259
2299
  subjectType: "record",
2260
- subjectId: submission.subject.recordId,
2261
- subjectModelKey: submission.subject.modelKey,
2262
- body: submission.body
2300
+ subjectId: subject.recordId,
2301
+ subjectModelKey: subject.modelKey,
2302
+ body
2263
2303
  };
2264
- if (attachmentFileIds.length > 0) args.attachmentFileIds = attachmentFileIds;
2265
- return readCreatedNote(await client.ns.notes.createMyNote(args));
2266
2304
  }
2267
2305
  async function uploadFeedbackAttachment(client, screenshot, fetchImpl = globalThis.fetch) {
2268
2306
  const files = client.ns.files;
@@ -2314,7 +2352,7 @@ function readCreatedNote(result) {
2314
2352
  const note = result;
2315
2353
  if (!note || typeof note.id !== "string" || note.id === "") {
2316
2354
  throw new Error(
2317
- "[foir] createMyNote answered with no note. Nothing was saved \u2014 if it had been, the surface would have returned it."
2355
+ "[foir] writeNote answered with no note. Nothing was saved \u2014 if it had been, the surface would have returned it."
2318
2356
  );
2319
2357
  }
2320
2358
  return {
@@ -2364,6 +2402,8 @@ var COPY = {
2364
2402
  loading: "One moment\u2026",
2365
2403
  sendFailed: "Your feedback could not be sent. Please try again.",
2366
2404
  sent: "Thanks. Your feedback has been sent.",
2405
+ reference: "Reference",
2406
+ contactEmail: "Your email, if you would like a reply (optional)",
2367
2407
  send: "Send feedback",
2368
2408
  sending: "Sending\u2026",
2369
2409
  capture: "Add a screenshot",
@@ -2400,6 +2440,13 @@ var textareaStyle = {
2400
2440
  minHeight: 96,
2401
2441
  resize: "vertical"
2402
2442
  };
2443
+ var inputStyle2 = {
2444
+ padding: "8px 10px",
2445
+ borderRadius: 6,
2446
+ border: "1px solid currentColor",
2447
+ background: "transparent",
2448
+ font: "inherit"
2449
+ };
2403
2450
  var headingStyle = { margin: 0, fontSize: 15, fontWeight: 600 };
2404
2451
  var subjectStyle = { margin: 0, fontSize: 13, opacity: 0.75 };
2405
2452
  var noteStyle = { margin: 0, fontSize: 13 };
@@ -2424,12 +2471,15 @@ function FoirFeedback({
2424
2471
  defaultOpen = false,
2425
2472
  onSignIn,
2426
2473
  onSubmitted,
2474
+ onVisitorSubmitted,
2427
2475
  onError,
2428
2476
  className
2429
2477
  }) {
2430
2478
  const [open, setOpen] = useState4(defaultOpen);
2431
2479
  const [probe, setProbe] = useState4({ status: "loading" });
2432
2480
  const [body, setBody] = useState4("");
2481
+ const [contactEmail, setContactEmail] = useState4("");
2482
+ const [receipt, setReceipt] = useState4(null);
2433
2483
  const [screenshot, setScreenshot] = useState4(null);
2434
2484
  const [capturing, setCapturing] = useState4(false);
2435
2485
  const [busy, setBusy] = useState4(false);
@@ -2448,7 +2498,11 @@ function FoirFeedback({
2448
2498
  try {
2449
2499
  const config = await readNoteConfig(client);
2450
2500
  if (!cancelled) {
2451
- setProbe({ status: "ready", screenshotsEnabled: config.screenshotsEnabled });
2501
+ setProbe({
2502
+ status: "ready",
2503
+ screenshotsEnabled: config.screenshotsEnabled,
2504
+ signedIn: config.signedIn
2505
+ });
2452
2506
  }
2453
2507
  } catch (err) {
2454
2508
  if (cancelled) return;
@@ -2479,19 +2533,32 @@ function FoirFeedback({
2479
2533
  const onSubmit = async (event) => {
2480
2534
  event.preventDefault();
2481
2535
  const prose = body.trim();
2482
- if (!resolved || !prose || busy) return;
2536
+ if (!resolved || !prose || busy || probe.status !== "ready") return;
2483
2537
  setBusy(true);
2484
2538
  setFailure(null);
2485
2539
  try {
2486
- const note = await submitFeedbackNote(client, {
2487
- subject: resolved,
2488
- body: prose,
2489
- screenshot
2490
- });
2491
- setBody("");
2492
- setScreenshot(null);
2493
- setSent(true);
2494
- onSubmitted?.(note);
2540
+ if (probe.signedIn) {
2541
+ const note = await submitFeedbackNote(client, {
2542
+ subject: resolved,
2543
+ body: prose,
2544
+ screenshot
2545
+ });
2546
+ setBody("");
2547
+ setScreenshot(null);
2548
+ setSent(true);
2549
+ onSubmitted?.(note);
2550
+ } else {
2551
+ const written = await submitVisitorNote(client, {
2552
+ subject: resolved,
2553
+ body: prose,
2554
+ contactEmail
2555
+ });
2556
+ setBody("");
2557
+ setContactEmail("");
2558
+ setReceipt(written);
2559
+ setSent(true);
2560
+ onVisitorSubmitted?.(written);
2561
+ }
2495
2562
  } catch (err) {
2496
2563
  const fault = classifyNotesFault(err);
2497
2564
  reportFault(fault, err, "sending feedback failed");
@@ -2507,6 +2574,7 @@ function FoirFeedback({
2507
2574
  return;
2508
2575
  }
2509
2576
  setSent(false);
2577
+ setReceipt(null);
2510
2578
  setFailure(null);
2511
2579
  setOpen(true);
2512
2580
  };
@@ -2567,11 +2635,28 @@ function FoirFeedback({
2567
2635
  disabled: busy,
2568
2636
  onChange: (event) => {
2569
2637
  setBody(event.target.value);
2570
- if (sent) setSent(false);
2638
+ if (sent) {
2639
+ setSent(false);
2640
+ setReceipt(null);
2641
+ }
2571
2642
  },
2572
2643
  style: styled ? textareaStyle : void 0
2573
2644
  }
2574
2645
  ),
2646
+ !probe.signedIn && /* @__PURE__ */ jsx5(
2647
+ "input",
2648
+ {
2649
+ type: "email",
2650
+ "data-foir-feedback-contact": true,
2651
+ "aria-label": COPY.contactEmail,
2652
+ placeholder: COPY.contactEmail,
2653
+ autoComplete: "email",
2654
+ value: contactEmail,
2655
+ disabled: busy,
2656
+ onChange: (event) => setContactEmail(event.target.value),
2657
+ style: styled ? inputStyle2 : void 0
2658
+ }
2659
+ ),
2575
2660
  probe.screenshotsEnabled && isFoirScreenCaptureSupported() && /* @__PURE__ */ jsx5(Fragment2, { children: screenshot ? /* @__PURE__ */ jsxs4(Fragment2, { children: [
2576
2661
  /* @__PURE__ */ jsx5(
2577
2662
  "img",
@@ -2614,6 +2699,11 @@ function FoirFeedback({
2614
2699
  children: COPY.sent
2615
2700
  }
2616
2701
  ),
2702
+ sent && !failure && receipt && /* @__PURE__ */ jsxs4("p", { "data-foir-feedback-receipt": true, style: styled ? subjectStyle : void 0, children: [
2703
+ COPY.reference,
2704
+ ": ",
2705
+ receipt.id
2706
+ ] }),
2617
2707
  /* @__PURE__ */ jsx5(
2618
2708
  "button",
2619
2709
  {
@@ -12,5 +12,5 @@ export type { CallbackClient, CallbackResult } from './callback-client';
12
12
  export { createEventsClient, FoirEventCursorExpiredError, isFoirEventCursorExpired, FOIR_EVENTS_PATH, FOIR_EVENTS_SCOPE, FOIR_EVENTS_RETENTION_DAYS, } from './events-client';
13
13
  export type { FoirEvent, FoirEventActor, FoirEventPage, FoirEventRetention, FoirEventsClient, FoirEventsClientConfig, FoirEventsListParams, FoirEventsStreamParams, FoirEventsSubscribeParams, } from './events-client';
14
14
  export type { FoirSubscription, FoirSubscriptionHandlers } from '../data/ws-transport';
15
- export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, } from '../transport/foir-http';
15
+ export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirForbidden, isFoirNotFound, isFoirRateLimited, isFoirUnauthenticated, stepUpRequired, } from '../transport/foir-http';
16
16
  export type { FoirErrorCode, FoirErrorExtensions, FoirGraphQLErrorItem, FoirAuthDowngrade, } from '../transport/foir-http';
package/dist/server.js CHANGED
@@ -699,6 +699,15 @@ function continueRun(continuation) {
699
699
  }
700
700
 
701
701
  // src/transport/foir-http.ts
702
+ function stepUpRequired(error) {
703
+ if (!(error instanceof FoirRequestError)) return null;
704
+ for (const item of error.graphQLErrors ?? []) {
705
+ if (item.extensions?.code !== "STEP_UP_REQUIRED") continue;
706
+ const asked = item.extensions.stepUp;
707
+ if (asked === "second_factor" || asked === "sign_in_method") return asked;
708
+ }
709
+ return null;
710
+ }
702
711
  var FoirRequestError = class extends Error {
703
712
  /** HTTP status code, when the request failed at the HTTP layer. */
704
713
  status;
@@ -1752,6 +1761,7 @@ export {
1752
1761
  recordsReadModels,
1753
1762
  recordsWriteModels,
1754
1763
  requireScopedToken,
1764
+ stepUpRequired,
1755
1765
  verifyScopedToken,
1756
1766
  verifyWebhookSignature
1757
1767
  };
@@ -55,7 +55,14 @@ export declare function resolveFoirApiKey(env: FoirEnv): string | undefined;
55
55
  * because an absent record is an answer rather than a failure. `NOT_FOUND` only
56
56
  * appears when a mutation names something that does not exist.
57
57
  */
58
- export type FoirErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'FAILED_PRECONDITION' | 'PERMISSION_DENIED' | 'UNAUTHENTICATED' | 'RATE_LIMITED' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELED' | 'INTERNAL';
58
+ export type FoirErrorCode = 'INVALID_ARGUMENT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'FAILED_PRECONDITION' | 'PERMISSION_DENIED' | 'UNAUTHENTICATED' | 'RATE_LIMITED' | 'TIMEOUT' | 'UNAVAILABLE' | 'CANCELED' | 'INTERNAL' | 'STEP_UP_REQUIRED';
59
+ /**
60
+ * What a `STEP_UP_REQUIRED` refusal asks of the signed-in user, in
61
+ * `extensions.stepUp`: `second_factor`, prove a second factor (the step-up
62
+ * flow) and retry; `sign_in_method`, sign in again with a method the
63
+ * organization the request stands in accepts.
64
+ */
65
+ export type FoirStepUp = 'second_factor' | 'sign_in_method';
59
66
  export interface FoirErrorExtensions {
60
67
  /** Stable machine-readable code. Branch on this, never on `message`. */
61
68
  code?: FoirErrorCode | string;
@@ -63,6 +70,8 @@ export interface FoirErrorExtensions {
63
70
  requestId?: string;
64
71
  /** On `RATE_LIMITED`: whole seconds to wait. Never less than 1. */
65
72
  retryAfter?: number;
73
+ /** On `STEP_UP_REQUIRED`: what the signed-in user must do before retrying. */
74
+ stepUp?: FoirStepUp;
66
75
  [key: string]: unknown;
67
76
  }
68
77
  export interface FoirGraphQLErrorItem {
@@ -115,6 +124,13 @@ export interface FoirRateLimitSnapshot {
115
124
  /** Unix seconds at which the window resets. */
116
125
  resetAt: number;
117
126
  }
127
+ /**
128
+ * What an error asks of the signed-in user before the request can succeed, or
129
+ * null: an organization's sign-in rules turned the session away, because it
130
+ * requires a second factor the session has not proven (`second_factor`) or does
131
+ * not accept how the user signed in (`sign_in_method`).
132
+ */
133
+ export declare function stepUpRequired(error: unknown): FoirStepUp | null;
118
134
  export declare class FoirRequestError extends Error {
119
135
  /** HTTP status code, when the request failed at the HTTP layer. */
120
136
  readonly status?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {