@buildbase/sdk 0.0.88 → 0.0.90

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.
Files changed (58) hide show
  1. package/AGENTS.md +7 -3
  2. package/CHANGELOG.md +515 -0
  3. package/README.md +120 -3
  4. package/dist/cli.mjs +1 -1
  5. package/dist/css/styles.css +1 -1
  6. package/dist/index.cjs +1 -1
  7. package/dist/index.d.ts +1351 -12
  8. package/dist/index.mjs +1 -1
  9. package/dist/mcp/index.cjs +1 -1
  10. package/dist/mcp/index.d.ts +120 -2
  11. package/dist/mcp/index.mjs +1 -1
  12. package/dist/react/chunks/SettingsDialog-BVyV5Zfm.mjs +2 -0
  13. package/dist/react/chunks/SettingsDialog-Ch8X6WXy.cjs +2 -0
  14. package/dist/react/chunks/{SubscriptionDialog-B8FTFRtp.cjs → SubscriptionDialog-CGIvMKpT.cjs} +1 -1
  15. package/dist/react/chunks/SubscriptionDialog-g0LwHhbe.mjs +2 -0
  16. package/dist/react/chunks/ar-3aqWucom.mjs +2 -0
  17. package/dist/react/chunks/ar-DQVB89r8.cjs +2 -0
  18. package/dist/react/chunks/de-DO10bhnU.cjs +2 -0
  19. package/dist/react/chunks/de-HXQk5D6k.mjs +2 -0
  20. package/dist/react/chunks/es-BZsmhBN3.mjs +2 -0
  21. package/dist/react/chunks/es-Cxg-qhUh.cjs +2 -0
  22. package/dist/react/chunks/fr-COCMy3vW.cjs +2 -0
  23. package/dist/react/chunks/fr-rdnNHzxy.mjs +2 -0
  24. package/dist/react/chunks/hi-BB0Z-Dgn.cjs +2 -0
  25. package/dist/react/chunks/hi-t3RH7ITu.mjs +2 -0
  26. package/dist/react/chunks/ja-CDyla2g0.mjs +2 -0
  27. package/dist/react/chunks/ja-D92JgUlk.cjs +2 -0
  28. package/dist/react/chunks/react-DTq7A_PT.mjs +2 -0
  29. package/dist/react/chunks/react-DeapSbq6.cjs +2 -0
  30. package/dist/react/chunks/zh-C5HABZZV.mjs +2 -0
  31. package/dist/react/chunks/zh-DK2yA5wB.cjs +2 -0
  32. package/dist/react/index.cjs +1 -1
  33. package/dist/react/index.d.ts +1517 -14
  34. package/dist/react/index.mjs +1 -1
  35. package/docs/ERROR_CODES.md +218 -0
  36. package/docs/THEMING.md +89 -21
  37. package/docs/UI-CONFIG.md +23 -4
  38. package/docs/USER-IMPORT.md +320 -0
  39. package/package.json +2 -1
  40. package/dist/react/chunks/SettingsDialog-CKVE1PpH.mjs +0 -2
  41. package/dist/react/chunks/SettingsDialog-DrDmxFEj.cjs +0 -2
  42. package/dist/react/chunks/SubscriptionDialog-UN7o9Hqb.mjs +0 -2
  43. package/dist/react/chunks/ar-B2l90OZJ.cjs +0 -2
  44. package/dist/react/chunks/ar-DVVNZhXD.mjs +0 -2
  45. package/dist/react/chunks/de-BspJtvAC.mjs +0 -2
  46. package/dist/react/chunks/de-dURUqbl0.cjs +0 -2
  47. package/dist/react/chunks/es-DArjREi5.cjs +0 -2
  48. package/dist/react/chunks/es-vu4BmI3g.mjs +0 -2
  49. package/dist/react/chunks/fr-DVm5WCvZ.mjs +0 -2
  50. package/dist/react/chunks/fr-DhYDaYrX.cjs +0 -2
  51. package/dist/react/chunks/hi-C_nv_c1T.cjs +0 -2
  52. package/dist/react/chunks/hi-VW8xCj_K.mjs +0 -2
  53. package/dist/react/chunks/ja-C-b88dvw.mjs +0 -2
  54. package/dist/react/chunks/ja-CqtDkLHr.cjs +0 -2
  55. package/dist/react/chunks/react-CHivaJhA.mjs +0 -2
  56. package/dist/react/chunks/react-Ch_Suxxy.cjs +0 -2
  57. package/dist/react/chunks/zh-D1yfhUf0.mjs +0 -2
  58. package/dist/react/chunks/zh-D_tMpsNt.cjs +0 -2
package/AGENTS.md CHANGED
@@ -33,6 +33,8 @@ Cloud `serverUrl` is `https://api.console.buildbase.app`; a self-hosted stack us
33
33
 
34
34
  In an existing Next.js, Vite or Express app: `npm install @buildbase/sdk && npx buildbase init --org-id <24 hex>`. It writes the provider, the auth routes, a webhook route that switches on `payment.failed`, and `.env.example` / `.env.local` placeholders (secrets always empty), wraps the root layout, and is idempotent: a second run says "already configured" and changes nothing. Non-interactive runs need `--framework` for an empty directory and `--org-id` always. Secrets are never flags.
35
35
 
36
+ Moving users in from Clerk, Auth0, Firebase or Supabase: `npx buildbase import --from <source> <file> [--passwords <file>] [--overwrite] [--dry-run]` with `BUILDBASE_API_TOKEN` and `BUILDBASE_SERVER_URL`, as for `content pull`. It converts the vendor's own export and posts batches of 500 to `POST /api/users/import`. Firebase's hash parameters are flags; pass the signer key as `BUILDBASE_FIREBASE_SIGNER_KEY` rather than `--signer-key` to keep it out of shell history. Vendor steps: `docs/USER-IMPORT.md`.
37
+
36
38
  ## Client setup (React)
37
39
 
38
40
  ```tsx
@@ -91,7 +93,7 @@ Config also accepts: `timeout`, `maxRetries`, `debug`, `headers`, `onError(err,
91
93
  - **Factory:** `BuildBase` (also default export) → `{ auth, workspace, subscription, usage, credits, plans, invoices, users, features, settings, notification, permissions, withSession, client }`.
92
94
  - **API classes:** `BaseApi` (extend for custom APIs), `AuthApi`, `UserApi`, `WorkspaceApi`, `SettingsApi`, `PushApi`.
93
95
  - **Webhook verification (runtime-agnostic — Node, edge, Deno, Bun, browsers):** `verifyWebhookSignature`, `parseWebhookEvent`. Verify against the **raw** request body (`await req.text()`), not the parsed JSON. Headers: `x-buildbase-signature` (`sha256=` HMAC over `{timestamp}.{body}`), `x-buildbase-timestamp` (Unix seconds), `x-buildbase-event`.
94
- - **Webhook events:** `WEBHOOK_EVENTS` (112 `domain.action` names), `WEBHOOK_EVENTS_BY_CATEGORY`, `WebhookEventName`, `isWebhookEvent(name)`, vendored from the platform catalog. A failed payment is `payment.failed`, with `workspaceId, subscriptionId, invoiceId, dunningState, amount, currency, failedAt, nextRetryAt`. Full list: https://docs.buildbase.app/webhooks/overview. Never invent an event name; narrow with `isWebhookEvent` instead.
96
+ - **Webhook events:** `WEBHOOK_EVENTS` (129 `domain.action` names; payload types `UserDeletionWebhookData`, `UserRegisteredWebhookData`), `WEBHOOK_EVENTS_BY_CATEGORY`, `WebhookEventName`, `isWebhookEvent(name)`, vendored from the platform catalog. A failed payment is `payment.failed`, with `workspaceId, subscriptionId, invoiceId, dunningState, amount, currency, failedAt, nextRetryAt`. Full list: https://docs.buildbase.app/webhooks/overview. Never invent an event name; narrow with `isWebhookEvent` instead.
95
97
  - **Agent readiness (framework-agnostic, returns plain `DiscoveryDocument`):** **App owns discovery content, platform owns auth.** All content — `llms.txt`, `robots.txt`, `sitemap`, `protectedResources` (RFC 9728), API catalog, skills — is defined locally in `AgentReadyConfig` and served with no platform round-trip. The platform bundle (`fetchAgentReadiness`, fail-soft/cached) supplies ONLY `{ enabled, authorizationServer }` (the OAuth AS pointer). Functions: `resolveAgentPath` (dispatch ALL agent paths — root docs + `.well-known/*`; prefer for new wiring), `resolveWellKnown` (`.well-known/*` only), `clearAgentReadinessCache`, `buildAgentCard`, `buildA2AAgentCard` (A2A card at `/.well-known/agent-card.json`; served by DEFAULT, derived from `site`+`skills`; `a2aCard: false` disables, object overrides), `buildProtectedResourceMetadata(path, config)` (local), `buildAgentSkillsIndex`, `buildSkillMd`, `buildSecurityTxt`, `buildLlmsTxt(config)` (from `config.llmsTxt`), `buildLlmsFullTxt` (from `config.llmsFullTxt`), `buildRobotsTxt`, `buildSitemap`, `buildApiCatalog`, `buildMcpServerCard`, `buildAuthMd`, `buildWebBotAuthDirectory` (JWKS at `/.well-known/http-message-signatures-directory` from `config.webBotAuth.keys`; opt-in), `buildDnsAidRecords` (suggested `_agents` DNS records — publish at your DNS provider, can't be served over HTTP), `buildDiscoveryLinkHeader`, `wantsMarkdown`/`negotiateMarkdown`, `sha256Digest`, `AI_BOT_USER_AGENTS`, `DISCOVERY_CACHE_TIMEOUT_MS`. `config.extraPaths` serves ANY literal document by exact path (commerce discovery: x402/UCP/ACP/MPP, `/openapi.json`, future `.well-known` docs) and takes precedence over built-ins. `config.cache` (a `DiscoveryCacheStore`: string `get`/`set`, optional `delete`) shares the platform fetches across replicas instead of caching per process; the default is process memory. Types: `AgentReadyConfig`, `AgentReadinessBundle`, `AgentSkill`, `DiscoveryCacheStore`, `ApiCatalogApi`, `A2ACardConfig`, `A2ACardSkill`, `DnsAidRecord`, `McpServerCard`, `RobotsConfig`, `RobotsPolicy`, `ContentSignals`, `SitemapUrl`, `DiscoveryDocument`.
96
98
  - **OAuth2 app-bridge:** `handleAppTokenRequest`, `handleAppRevokeRequest` (one-call handlers), `bearerChallenge` (RFC 9728/6750 401; header values are quoted-string-escaped + CR/LF-stripped), `verifyAppTokenRequest`, `verifyAppRevokeRequest`, `verifyClientJwt`, `signClientJwt` (HS256 mint — symmetric counterpart of verify), `extractBearerToken`, `appTokenSuccess`, `appTokenFailure`, `AppBridgeError`. Types: `AppTokenRequestClaims`, `AppRevokeRequestClaims`, `AppTokenResult`, `AppTokenResponseBody`, `HandlerResult`, `VerifyClientJwtOptions`. `verifyClientJwt(token, secret, opts?: VerifyClientJwtOptions)` **requires a numeric `exp` by default** (`requireExp: false` to allow non-expiring tokens); optional `clockToleranceSec` / `issuer` / `audience`. **Presets** (also in core): `mintAgentToken` (the whole mint in one call — HS256 with the app's secret, `aud` from the grant's resource, encrypted `sid`) + `buildbaseAuth` (the whole verify) + `createSessionRefCrypto`. Security invariant: the platform relays the minted token but never sees the app secret — it can neither forge nor decode app tokens, and the BuildBase session is never stored and never plaintext in a token.
97
99
  - **Currency & pricing utils (pure):** `formatCents`, `formatOverageRate`, `formatOverageRateWithLabel`, `formatQuotaIncludedOverage`, `formatQuotaWithPrice`, `getCurrencySymbol`, `getCurrencyFlag`, `getQuotaUnitLabelFromName`, `getQuotaDisplayValue`, `CURRENCY_DISPLAY`, `CURRENCY_FLAG`, `PLAN_CURRENCY_CODES`, `PLAN_CURRENCY_OPTIONS`.
@@ -99,6 +101,7 @@ Config also accepts: `timeout`, `maxRetries`, `debug`, `headers`, `onError(err,
99
101
  - **Seat pricing:** `calculateBillableSeats`, `calculateSeatOverageCents`, `calculateTotalSubscriptionCents`, `getPerSeatPriceCents`, `getSeatPricing`.
100
102
  - **RBAC (pure):** `hasPermission`, `resolvePermissions`, `resolveMaxUsers`, `validateInvite`, `DEFAULT_ROLE_PERMISSIONS`, `Permission` (enum).
101
103
  - **URL / auth-intent / redirects:** `BB_PARAM`, `BBAction`, `BBScreen`, `BBStatus`, `createBBUrl`, `readBBParams`, `cleanBBParams`, `createCheckoutRedirectUrls`, `createCreditPurchaseRedirectUrls`, `safeRedirect`, `validateRedirectUrl`, `saveAuthIntent`, `consumeAuthIntent`, `clearAuthIntent`.
104
+ - **Account (pure):** `isReauthRequired(error)` (a `403 reauth_required`: re-confirm with `useSaaSAuth().requireReverification`, never sign out), `isReadOnlySession(error)` (an owner's read-only session), `isImpersonationSignInLocked(error)` (a sign-in change refused while an owner is signed in as the user), `isValidUsername`, `normaliseUsername`, `connectWallet(chain, { api })` (injected `window.ethereum` / `window.solana`, then `WorkspaceApi.linkWallet`), `buildWalletMessage` (EIP-4361 from the nonce answer), `toChecksumAddress` (EIP-55), `WalletFlowError`, `WALLET_LINK_STATEMENT`.
102
105
  - **Context invalidation (call after server mutations to refetch client contexts):** `invalidateSubscription`, `invalidateQuotaUsage`, `invalidateCreditBalance`.
103
106
  - **Regional resolution (server-side, for apps hosting many orgs):** `resolveServerUrl({ orgId, centralUrl, apiKey, cache? })` asks central which cell an organisation lives in and caches the answer for central's own `max-age`; `clearResolvedServerUrl({ orgId, centralUrl, cache? })` forgets it, which is the documented recovery from a `421 misdirected_request`. Types: `RegionAnswer`, `ResolveServerUrlOptions`, `ResolveCacheStore`. Throws on failure rather than falling back to the default cell, because that fallback would send an EU organisation's request to the US. **Pass `apiKey`**: without it central answers the default cell for every id, known or not, on purpose (organisation ids are ObjectIds, so a distinguishable 404 would be an enumeration oracle).
104
107
  - **Events:** `EventEmitter`, `eventEmitter` (singleton), `SDKEvent` (enum).
@@ -111,7 +114,7 @@ Config also accepts: `timeout`, `maxRetries`, `debug`, `headers`, `onError(err,
111
114
  ### `@buildbase/sdk/react` — client-only additions (core is re-exported here too)
112
115
 
113
116
  - **Providers:** `SaaSOSProvider` (root), `SubscriptionContextProvider`, `QuotaUsageContextProvider`, `CreditBalanceContextProvider`, `PushNotificationProvider`.
114
- - **State hooks:** `useSaaSAuth`, `useSaaSWorkspaces`, `useSaaSOs` (exposes `region` when the app passes it to `SaaSOSProvider`; informational, for badges and support tooling), `useSaaSSettings`, `useUserAttributes`, `useUserFeatures`, `useTranslation`, `usePermissions`, `useSeatStatus`, `useTrialStatus`, `useFullScreenLoader`, `useMyInvitations`, `useWorkspaceInvitations`, `useNotifications`.
117
+ - **State hooks:** `useSaaSAuth` (incl. `requireReverification({ returnUrl?, factors?, maxAgeSeconds? })` for a `reauth_required` refusal), `useGuestUpgrade`, `useSaaSWorkspaces`, `useSaaSOs` (exposes `region` when the app passes it to `SaaSOSProvider`; informational, for badges and support tooling), `useSaaSSettings`, `useUserAttributes`, `useUserFeatures`, `useTranslation`, `usePermissions`, `useSeatStatus`, `useTrialStatus`, `useFullScreenLoader`, `useMyInvitations`, `useWorkspaceInvitations`, `useNotifications`.
115
118
  - **Upload hooks:** `useUpload(purpose)` (`{ upload, cancel, loading, error, failure, enabled, maxBytes, maxLabel }`; reads the org's upload settings, refuses a file over its limit before a request, `error` is translated into the active locale and `failure` is the reason; a new upload, `cancel()` or unmount aborts the one in flight), `useUploadsApi`.
116
119
  - **Subscription/billing hooks:** `usePublicPlans`, `useSubscription`, `useSubscriptionContext`, `useSubscriptionManagement`, `usePlanGroup`, `usePlanGroupVersions`, `usePublicPlanGroupVersion`, `useCreateCheckoutSession`, `useUpdateSubscription`, `useCancelSubscription`, `useResumeSubscription`, `useBillingPortal`, `useInvoices`, `useInvoice`.
117
120
  - **Quota hooks:** `useQuotaUsageContext`, `useRecordUsage`, `useQuotaUsageStatus`, `useAllQuotaUsage`, `useUsageLogs`.
@@ -126,7 +129,7 @@ Config also accepts: `timeout`, `maxRetries`, `debug`, `headers`, `onError(err,
126
129
  - Quota: `WhenQuotaAvailable`, `WhenQuotaExhausted`, `WhenQuotaOverage`, `WhenQuotaThreshold`
127
130
  - Credits: `WhenCreditsAvailable`, `WhenCreditsExhausted`, `WhenCreditsLow`
128
131
  - Invitations: `WhenPendingInvitations`
129
- - **UI components:** `WorkspaceSwitcher`, `PricingPage`, `CreditStorePage`, `CreditBalance`, `CreditActionsProvider`, `BetaForm`, `FullScreenLoader`, `PendingInvitations`, `NotificationBell`, `NotificationInbox`.
132
+ - **UI components:** `WorkspaceSwitcher`, `PricingPage`, `CreditStorePage`, `CreditBalance`, `CreditActionsProvider`, `BetaForm`, `FullScreenLoader`, `PendingInvitations`, `NotificationBell`, `NotificationInbox`, `GuestUpgrade` (a guest's "Create your account"), `ImpersonationBanner` (the provider pins it while an owner views as the user; `ui.impersonationBanner: false` hides that one).
130
133
  - **Misc:** `SUPPORTED_LOCALES`, `SETTINGS_SCREENS`, `SettingsScreen`, `workspaceSettingsManager`.
131
134
 
132
135
  ### `@buildbase/sdk/data`
@@ -170,6 +173,7 @@ Expose a live MCP server (stateless Streamable HTTP; Node 18+/edge) with built-i
170
173
  - **Agent-readiness fetches are fail-soft** — a platform outage yields `{ enabled: false }`, never a 500. Don't add your own error handling expecting throws.
171
174
  - **New crypto is dependency-free pure JS** (`src/lib/sha256.ts`) so it behaves identically on browser/edge/Node — don't swap in Web Crypto / Node `crypto` without matching outputs.
172
175
  - **Prefer SDK hooks** (`useSaaS*`, `useSubscription*`, etc.) over `useAppSelector`/internal context so consumer code stays stable across internal refactors.
176
+ - **Status colours have two theme tokens each.** `--destructive`, `--success`, `--warning` and `--info` are fills (buttons, tints, progress bars, borders); their text (`text-destructive` and so on) reads `--destructive-text`, `--success-text`, `--warning-text` and `--info-text`. When rebranding one, set both, in the light rule and the dark one (`docs/THEMING.md`).
173
177
  - **Pass `idempotencyKey` on `usage.record`, `usage.recordBatch` and `credits.consume`.** It is optional in the type and should be treated as required in anything that meters or spends. The SDK retries only idempotent requests, so a `POST` that fails mid-flight is not retried for you: the caller retries, and without a key the second attempt bills twice. Use a key derived from the unit of work (`gen-<requestId>`, `email-<messageId>`), not a fresh UUID per attempt. This matters more against a load-balanced API with several replicas than against one server, because a retry landing on a different replica is the normal case.
174
178
  - **`serverUrl` must be the organisation's REGIONAL URL.** Today every organisation is on the default cell, `https://api.console.buildbase.app`; an organisation in another region uses the URL central answers for it, never a guessed hostname. There is no auto-discovery in the client bundle and there will not be: resolving in the browser costs a round trip before the first API call on every page load. An app serving one organisation bakes the URL into the build; one serving many resolves per request on the server with `resolveServerUrl`. Sending an organisation's request to the wrong cell returns `421 misdirected_request` with the right `serverUrl`, never a silent cross-region read - treat it as the signal to re-resolve once.
175
179
  - **The MCP server card and protected-resource metadata advertise the regional endpoint.** `createAgentStack` derives both from `serverUrl`, so getting `serverUrl` right is the whole of it; there is nothing extra to configure per cell. An agent that discovered a US endpoint for an EU organisation would authenticate against the wrong cell and be refused by the audience check.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,521 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.0.90] - 2026-10-18
9
+
10
+ ### Added
11
+
12
+ - **Change password on the Security screen.** New `WorkspaceApi.changePassword()` takes the
13
+ current and the new password and posts them to the tenant server's new
14
+ `POST /api/v1/public/profile/password`. The Security screen gains a Password section above
15
+ passkeys: both fields, a pending button, and the server's own sentence on a refusal (a wrong
16
+ current password, which counts toward the account's sign-in lockout, or a password found in a
17
+ known breach). On success every other session for the user ends and this one stays. Hide the
18
+ section with `ui.settings.security.password: false` for an app without password sign-in. New
19
+ strings under `security.*` in all eight locales. Needs a tenant server with
20
+ `POST /api/v1/public/profile/password`; an older server answers 404, which the section shows
21
+ as an error.
22
+ - **Two-step sign-in on the Security screen.** Five new `WorkspaceApi` calls against the tenant
23
+ server's session routes under `/api/v1/public/mfa`: `getMfaStatus()`, `startTotpEnrollment()`,
24
+ `confirmTotpEnrollment(code)`, `disableTotp(code)` and `regenerateBackupCodes(code)`. The
25
+ Security screen gains a two-step sign-in section between Password and Passkeys. Setting it up
26
+ shows the QR code the server rendered (as an image, never as markup), the key in groups of four
27
+ for an app that cannot scan, and a field for the first code; the backup codes then appear once,
28
+ with a copy button, until the user presses Done. Once it is on, the section says how many backup
29
+ codes are left and offers New backup codes and Turn off, each of which asks for a current code
30
+ or a backup code first, because a wrong one counts toward the account's lockout. Turn off is not
31
+ offered while the app requires two-step sign-in, and the section says why instead. Every write
32
+ is a POST, so the client never retries one: a code works once, so replaying a request that went
33
+ through would only be refused. A refusal shows the server's own sentence, and the
34
+ error carries its `reason` as `code` (`MfaErrorCode`, listed in `docs/ERROR_CODES.md`). The
35
+ section hides itself while the organization has two-step sign-in off and against a server that
36
+ predates it, except right after a setup the server refused because it was turned off while the
37
+ screen was open: then it stays, with that sentence and no actions, so the user learns why. Hide
38
+ it outright with `ui.settings.security.twoStep: false`. New types `IMfaStatus`,
39
+ `ITotpEnrollment`, `IMfaBackupCodes`, `MfaEnforcement`, `MfaFactorType` and `MfaErrorCode`, and
40
+ new strings under `security.twoStep*` in all eight locales. Needs a tenant server with two-step
41
+ sign-in.
42
+ - **Four webhook events from two-step sign-in.** The vendored catalog is refreshed from the
43
+ platform's and now holds 117 names, up from 113: `user.mfa_enrolled`, `user.mfa_disabled` and
44
+ `user.mfa_reset_by_admin` (an admin removed the second step for a lost device), plus
45
+ `auth.login_failed`, sent when a sign-in is refused for a wrong password or a wrong second-step
46
+ code. A failed sign-in names the user it was attempted against, and an unknown address sends
47
+ nothing. `isWebhookEvent` accepts all four and `WebhookEventName` includes them. Because the
48
+ platform declares `auth.login_failed` beside the user events, the `Auth` group in
49
+ `WEBHOOK_EVENTS_BY_CATEGORY` now comes second, straight after `User`, and starts with it.
50
+ - **`useSaaSAuth().user.mfaEnabled`.** The profile now says whether the user has an authenticator
51
+ app set up, typed as `IUser.mfaEnabled`, and the session user carries it. It reads `false`
52
+ against a server that predates two-step sign-in. Turning two-step sign-in on or off on the
53
+ Security screen updates the session user in place, so an app's own "turn on two-step sign-in"
54
+ prompt goes away without a reload.
55
+ - **Change email on the Profile screen.** Two new `WorkspaceApi` calls beside `changePassword`:
56
+ `requestEmailChange(newEmail)` posts to `POST /api/v1/public/profile/email`, which sends a
57
+ 6-digit code to the new address and resolves with `retryAfter`, the seconds before another code
58
+ can be sent; `confirmEmailChange(code)` posts to `POST /api/v1/public/profile/email/confirm` and
59
+ resolves with the new `email`. The address changes only when the code is accepted; it is then
60
+ verified, the old address gets a notice, and every session stays signed in. The Profile screen
61
+ shows a Change action next to the email: the new address, then "We sent a code to ..." with a
62
+ field for the code, Resend (counting down from `retryAfter`) and Cancel. On success the field
63
+ shows the new address and `useSaaSAuth().user.email` updates in place. Both calls are POSTs and
64
+ never retried, since a replay would send a second code or spend one of the code's tries. Codes
65
+ are sent with spaces dropped. A refusal shows the server's own sentence, and the error carries
66
+ its `reason` as `code` (`EmailChangeErrorCode`: `email_unavailable`, `linked_social_account`,
67
+ `cooldown` with `retryAfter`, `disposable_email`, `invalid_code`, `code_expired`, listed in
68
+ `docs/ERROR_CODES.md`). An expired code is a 410, and does not sign the user out; any 401
69
+ still does. The profile gains `canChangeEmail` and `changeEmailBlockedBy` (typed on `IUser`,
70
+ and carried on the session user as `useSaaSAuth().user.canChangeEmail` and
71
+ `.changeEmailBlockedBy`): the change is offered only while `canChangeEmail` is true, and
72
+ otherwise the email is shown read-only, with one sentence when `changeEmailBlockedBy` says why. A server that
73
+ predates them reads as `canChangeEmail: false`, so the change is never offered against it. Hide it with
74
+ `ui.settings.profile.changeEmail: false`. New types `IEmailChangeRequest`,
75
+ `IEmailChangeResult`, `EmailChangeErrorCode` and `ChangeEmailBlockedBy`, and new strings under
76
+ `profile.changeEmail*` in all eight locales. Needs a tenant server that can change an email
77
+ with a code.
78
+ - **`BaseApi`: a route's own 401.** `fetchResponse(path, init, options)` takes an optional
79
+ `IRequestOptions`; with `deferUnauthorized: true` a 401 is left to the caller instead of calling
80
+ `onUnauthorized`, and the new protected `notifyUnauthorized()` ends the session for a 401 that
81
+ turns out to be the session's after all. For a custom API class whose route answers 401 for a
82
+ refusal of its own; nothing changes for any existing call.
83
+ - **Two webhook events for email changes and bot protection.** The vendored catalog is refreshed from the platform's and
84
+ now holds 119 names, up from 117: `user.email_changed`, sent when a user changes their email
85
+ with a code and carrying `actorId`, `userId`, `oldEmail` and `newEmail`, and
86
+ `auth.signup_challenged`, sent when bot protection refuses a sign-up or an emailed code or link
87
+ (it names the surface, never the address typed). `isWebhookEvent` accepts both and
88
+ `WebhookEventName` includes them. `user.email_changed` comes last in the `User` group and
89
+ `auth.signup_challenged` second in `Auth`, after `auth.login_failed`, as the platform declares
90
+ them.
91
+ - **Connected accounts on the Security screen.** Three new `WorkspaceApi` calls against the tenant
92
+ server's session routes under `/api/v1/public/accounts`:
93
+ `getConnectedAccounts()` resolves the user's linked social sign-ins (`accounts`, each with
94
+ `canUnlink`), the ones the app offers that they have not linked (`available`, each with its
95
+ `methodId`), `signInWays` and `recentSignIn`; `linkAccount(methodId, redirect)` resolves the
96
+ `{ url }` to send the browser to; `unlinkAccount(id)` removes one. The Security screen gains a
97
+ Connected accounts section between two-step sign-in and passkeys: each linked account with its
98
+ icon, label and date, and Remove behind a confirmation; a Connect button per available provider,
99
+ which comes back to the same page with the Security screen open (`?bb=screen:security`), where
100
+ the outcome is shown once and the `linked`, `error` and `reason` parameters are taken off the URL
101
+ with `history.replaceState`, keeping the router's state. Remove is disabled, with the reason
102
+ beside it, on the only way the user can sign in. A remove refused with `reauth_required` says the
103
+ user must sign in again and offers Sign in again, which signs out, signs in and returns to the
104
+ Security screen. Refusals keep the server's sentence and carry `status` and the server's reason
105
+ as `code` (`ConnectedAccountErrorCode`: `reauth_required` 403, `last_sign_in_method` 409,
106
+ `already_linked` 409, `method_unavailable` 404, listed in `docs/ERROR_CODES.md`); none of them
107
+ signs the user out, only a real 401 does. Starting a link is a POST and never retried. Every
108
+ button shows progress, and the section loads behind the screen's one skeleton; it hides while
109
+ there is nothing to list or connect, against a server that predates it (404, not reported), and
110
+ with `ui.settings.security.connectedAccounts: false`. New types `IConnectedAccounts`,
111
+ `IConnectedAccount`, `IAvailableAccountProvider`, `IAccountLinkStart`,
112
+ `ConnectedAccountErrorCode` and `AccountLinkErrorReason`, `BBScreen.Security`, and new strings
113
+ under `security.connectedAccounts*` in all eight locales.
114
+ - **`useSaaSAuth().user.externalId`.** The profile carries the user's id in the system they were
115
+ imported from, typed as `IUser.externalId`, and the session user has it when it is set.
116
+ - **`buildbase import`.** `npx buildbase import --from clerk|auth0|firebase|supabase <file>` reads
117
+ the vendor's own user export (CSV, JSON or NDJSON, gzipped or not), converts each user to the
118
+ platform's import lines (email, verified status, name, the old id as `externalId`, a password
119
+ hash the platform can check, and Google, GitHub, Microsoft, Apple and LinkedIn sign-ins as links)
120
+ and posts them in batches of 500 as a multipart `file` upload to `POST /api/users/import`, with
121
+ `source` set to the vendor, using `BUILDBASE_API_TOKEN` and `BUILDBASE_SERVER_URL` as
122
+ `content pull` does. It prints the totals, the platform's notices once, every failed row and
123
+ every warning, numbered by the user's place in the export. Options: `--passwords <file>`
124
+ (Auth0's hash export from a support request, or Clerk's dashboard CSV beside Backend API JSON),
125
+ `--overwrite`, `--dry-run` (convert and print the first users with hashes redacted, send
126
+ nothing), `--server-url`, and for Firebase `--signer-key` (or `BUILDBASE_FIREBASE_SIGNER_KEY`),
127
+ `--salt-separator`, `--rounds` and `--mem-cost`. bcrypt, argon2id, argon2i, Django
128
+ pbkdf2_sha256 and Firebase scrypt hashes are imported, Clerk's `pbkdf2_sha256` and
129
+ `scrypt_firebase` digests rewritten into the platform's shapes; any other hasher imports that
130
+ user without a password, with a warning. A plaintext password is never sent: the record is
131
+ listed as failed with `plaintext_password`. Sign-ins with providers the platform does not have
132
+ are dropped with a warning. Each vendor's export steps are in the new `docs/USER-IMPORT.md`.
133
+ - **Three webhook events for imports and connected accounts.** `user.imported` (carrying `actorId`, `userId`, `email`,
134
+ `name`, `externalId` and `source`, sent instead of `user.registered` for an imported user),
135
+ `user.account_linked` and `user.account_unlinked` (carrying `actorId`, `userId`, `provider` and
136
+ `accountId`). The vendored catalog holds 122 names, up from 119, with the three at the end of
137
+ the `User` group; `isWebhookEvent` accepts them and `WebhookEventName` includes them.
138
+ - **Phone number on the Security screen.** Three new `WorkspaceApi` calls beside the change of
139
+ email: `addPhone(phone, channel?)` posts an E.164 number
140
+ to `POST /api/v1/public/profile/phone`, which sends a code to it and resolves with `retryAfter`;
141
+ `confirmPhone(code)` posts to `POST /api/v1/public/profile/phone/confirm` and resolves with the
142
+ number, now on the account and verified; `removePhone()` is `DELETE /api/v1/public/profile/phone`.
143
+ `channel` asks for WhatsApp or a voice call where the app offers them, and without it the code
144
+ is a text message. The Security screen gains a Phone number section between Password and two-step
145
+ sign-in: a country picker (every country with its calling code, named in the reader's language)
146
+ and the number as it is written there, or with a leading `+`; then "We sent a code to ..." with a
147
+ code field, Resend counting down from `retryAfter`, and Cancel. Once added, it shows the number
148
+ as verified or not, with Verify for one that came without proof (an import), Change, and Remove
149
+ behind a confirmation. Remove needs a recent sign-in: on `reauth_required` the section says so
150
+ and offers Sign in again, as connected accounts does. It is disabled, with the reason beside it,
151
+ while text message codes for two-step sign-in go to the number, and `last_sign_in_method` and
152
+ `mfa_sms_enrolled` are answered in the screen's own words; every other refusal shows the server's
153
+ sentence. The section loads behind the screen's one skeleton, hides against a server that
154
+ predates phone numbers, and hides with `ui.settings.security.phone: false`. Adding or removing a
155
+ number reloads the connected accounts, since a verified phone is a way to sign in.
156
+ - **Text message codes for two-step sign-in.** `getMfaStatus()` now carries
157
+ `sms: { allowed, enrolled, phone? }` (the number masked, `+1 ••• ••• 0123`), typed as
158
+ `IMfaSmsStatus`, and `MfaFactorType` gains `sms`. Three new `WorkspaceApi` calls:
159
+ `startSmsEnrollment()` (`POST /api/v1/public/mfa/sms/enroll`) sends a code to the verified phone
160
+ and resolves with `retryAfter`; `confirmSmsEnrollment(code)` (`.../enroll/confirm`) turns it on
161
+ and resolves with backup codes when the server issues them; `disableSms()`
162
+ (`POST /api/v1/public/mfa/sms/disable`) turns it off. The two-step section offers Text message
163
+ beside the authenticator app while the app allows it, labelled as less secure than an
164
+ authenticator app. Turning it on needs a verified phone: the section says so and points to the
165
+ Phone number section when it knows there is none, and on `phone_required`. Then comes the code,
166
+ with Resend counting down. Turning it off sits behind a confirmation and needs a recent sign-in,
167
+ with Sign in again on `reauth_required`. Someone who has it on still sees it after the app stops
168
+ offering it, so they can turn it off. Neither factor offers Turn off while the app requires two-step
169
+ sign-in and it is the user's only second step. The authenticator app now shows as a row with Set
170
+ up when it is not set up, to sit beside the text message row. `useSaaSAuth().user.mfaEnabled` is
171
+ kept true while either factor is on.
172
+ - **Phone refusals.** Every refusal from the six phone calls keeps the server's sentence and
173
+ carries `status` and the server's reason as `code` (`PhoneErrorCode`: `invalid_phone`,
174
+ `phone_unavailable`, `cooldown` with `retryAfter`, `rate_limited`, `number_refused`,
175
+ `sms_unavailable`, `invalid_code`, `code_expired`, `reauth_required`, `last_sign_in_method`,
176
+ `mfa_sms_enrolled`, `phone_required`, `factor_not_allowed`, listed in `docs/ERROR_CODES.md`).
177
+ None of them signs the user out: an expired code is a 410 and a stale session's refusal a 403.
178
+ Only a real 401 does. The POSTs are never retried, since a replay would send a second text or
179
+ spend one of the code's tries. Codes are sent with spaces dropped. New types `PhoneChannel`,
180
+ `IPhoneCodeSent`, `IPhoneConfirmed`, `ISmsEnrollmentResult`, `IMfaSmsStatus` and
181
+ `PhoneErrorCode`, and new strings under `security.phone*` and `security.twoStepSms*` in all
182
+ eight locales.
183
+ - **`useSaaSAuth().user.phone` and `.phoneVerified`.** The profile carries the user's phone as
184
+ E.164 and whether it was verified, typed on `IUser`; the session user has both while there is a
185
+ number, and the Security screen keeps them current when the user adds, verifies or removes it.
186
+ - **Four webhook events for phone numbers and phone codes.** `user.phone_added` (`actorId`, `userId`, `phone`,
187
+ `verified`; on a phone sign-up, from the profile, or on an import), `user.phone_verified` and
188
+ `user.phone_removed` (`actorId`, `userId`, `phone`) close the `User` group, and
189
+ `auth.sms_budget_exceeded` (`level` of `warning` or `stopped`, `sent`, `dailyCap`, `alertAt`; at
190
+ most once a day each, as phone codes near or reach the app's daily cap) follows
191
+ `auth.signup_challenged` in `Auth`. The vendored catalog holds 126 names, up from 122;
192
+ `isWebhookEvent` accepts the four and `WebhookEventName` includes them.
193
+ - **`buildbase import` carries phone numbers.** Each line gains `phone` (E.164) and
194
+ `phoneVerified` where the export has them: Clerk's Backend API `phone_numbers[]` (the primary
195
+ one, verified when its `verification.status` is `verified`), Clerk's CSV
196
+ `primary_phone_number`, `verified_phone_numbers` and `unverified_phone_numbers`, Auth0's
197
+ `phone_number` and `phone_verified`, Firebase's `phoneNumber` (verified, since Firebase sets it
198
+ through phone sign-in), and Supabase's `phone` (stored without its `+`, which is added back) and
199
+ `phone_confirmed_at`. A value that is not E.164 is left out with an `invalid_phone` warning and
200
+ the user is imported without it; the server does the same, with a warning, for a number already
201
+ on another account. `docs/USER-IMPORT.md` has the field list per vendor, and the Auth0 export job
202
+ and the Supabase query now ask for the phone fields.
203
+ - **Confirm it's you before a sensitive change.** Any session route may now answer
204
+ `403 reauth_required` with `maxAgeSeconds` and, when it wants the second step too,
205
+ `factors: ['mfa']`; the SDK error carries both. New `useSaaSAuth().requireReverification({
206
+ returnUrl?, factors?, maxAgeSeconds? })` sends the person to the hosted sign-in page through
207
+ `WorkspaceApi.startReverification({ returnUrl, factors })` (`POST /api/v1/public/session/reverify`)
208
+ and back to `returnUrl` (default: this page) with the same session, now recent: no new session
209
+ and no code. The server takes a return URL only when it is one of the auth client's redirect
210
+ URLs, so when it refuses the page with `invalid_return_url` the SDK comes back through the
211
+ registered `auth.redirectUrl` and continues to the page, as it does after signing in. With
212
+ `maxAgeSeconds` it resolves `'recent'` without leaving when the session already is. The provider
213
+ takes `?reverified=1` off the URL without a reload. Every Security section that offered Sign in
214
+ again on `reauth_required` now offers **Confirm it's you**, says once on the way back that the
215
+ change can go ahead, and falls back to signing out and in only where the session cannot be
216
+ re-confirmed (never for a guest). `useSaaSAuth().session` carries `authTime`, `amr` and
217
+ `impersonation` from the profile. New pure helpers `isReauthRequired(error)` and
218
+ `isReadOnlySession(error)`.
219
+ - **Delete my account.** `WorkspaceApi.requestAccountDeletion()` (`POST /api/v1/public/account/delete`)
220
+ resolves `{ deleteAfter, deleted }`: the account is deleted after the app's grace period, every
221
+ other session ends now, and an app with no grace period deletes at once (`deleted: true`).
222
+ `cancelAccountDeletion()` (`POST /account/delete/cancel`) resolves `{ cancelled }`. Refusals:
223
+ `reauth_required` and `owns_workspaces` (409, carrying `workspaces: [{ id, name }]`). The Security
224
+ screen ends with a **Delete account** section behind a typed confirmation (the word in the
225
+ reader's language, or `DELETE`); its warning names the app's grace period, or says the account
226
+ goes at once, from `AuthApi.getAccountDeletionGraceDays(orgId)` (the public auth config); it names
227
+ the workspaces that block it, offers Confirm it's you, signs out when the account went at once,
228
+ and while a deletion is scheduled shows its date with **Cancel deletion**. `IUser.deleteAfter` and `useSaaSAuth().user.deleteAfter` carry the date.
229
+ Hide the section with `ui.settings.security.deleteAccount: false`.
230
+ - **An app owner viewing as a user.** The profile carries `impersonation: { actorName, readOnly,
231
+ expiresAt }` while an owner is signed in as the person from the console. The provider pins
232
+ `<ImpersonationBanner />` to the bottom of the window for as long as it lasts ("You are viewing
233
+ as Ada. Grace signed in as this person, read-only until 3:15 PM.", with Exit); hide it with
234
+ `ui.impersonationBanner: false` and place it yourself with `fixed={false}`. Every change in a
235
+ read-only session is refused with `403 read_only_session`, whose sentence the screens show; the
236
+ Profile save and the passkey actions now show a refusal instead of only reporting it.
237
+ - **Usernames.** `WorkspaceApi.setUsername(username)` (`PUT /api/v1/public/profile/username`)
238
+ resolves the stored name and `removeUsername()` is the `DELETE`; refusals `invalid_username`,
239
+ `username_taken`, `usernames_off` and `username_required`. `AuthApi.getUsernamePolicy(orgId)`
240
+ reads `username: { enabled, required }` from the public auth config. The Profile screen shows a
241
+ Username field while the app has them on or the user has one, checked first by the server's own
242
+ rules (`isValidUsername`, `normaliseUsername`: 3 to 30 of `a-z 0-9 _ . -`, letter or digit at
243
+ both ends, no `..`, no reserved word), with Remove unless the app requires one. Hide it with
244
+ `ui.settings.profile.username: false`. `useSaaSAuth().user.username` carries it.
245
+ - **Guests who create an account.** A guest has `isAnonymous: true`; `useSaaSAuth().user` reads
246
+ their placeholder `@anonymous.invalid` address as an empty `email`, so it is never shown or sent
247
+ to tracking. `WorkspaceApi.requestGuestUpgrade({ email, name?, password? })`
248
+ (`POST /api/v1/public/guest/upgrade`) mails a 6-digit code and resolves `retryAfter`;
249
+ `confirmGuestUpgrade(code)` (`/guest/upgrade/confirm`) resolves the profile, the same user id now
250
+ a real account. New `useGuestUpgrade()` hook and `<GuestUpgrade />` component ("Create your
251
+ account": the address, an optional name and password, then the code with Resend counting down);
252
+ `useSaaSAuth().user` is the account as soon as the code is accepted. For a guest, the Profile
253
+ screen shows it in place of the email, and the Security screen in place of the password, phone,
254
+ two-step and passkey sections; Connect a wallet is not offered to a guest.
255
+ - **Wallets in connected accounts.** `getConnectedAccounts()` rows may be `ethereum` or `solana`
256
+ wallets with their short address as `detail`, and the view carries `wallet: { ethereumChainIds,
257
+ solana }` while the app offers wallet sign-in. `WorkspaceApi.requestWalletNonce(chain)` and
258
+ `linkWallet({ chain, message, signature })` (`POST /api/v1/public/accounts/wallet/nonce` and
259
+ `/accounts/wallet`) connect one; `nonce_expired` is a 401 that does not sign the user out. New
260
+ dependency-free `connectWallet(chain, { api })` drives an injected wallet (EIP-1193
261
+ `window.ethereum`, moving it to a network the app takes; Phantom-style `window.solana`) and
262
+ `buildWalletMessage()` lays out the EIP-4361 message from the nonce answer's `domain`, `uri`,
263
+ `nonce` and `issuedAt`, with the EIP-55 address (`toChecksumAddress`, on a pure keccak-256).
264
+ The Security screen offers **Connect an Ethereum wallet** and **Connect a Solana wallet**, says
265
+ in its own words when the wallet stopped it (`WalletFlowError`: none in the browser, cancelled,
266
+ busy, wrong network), and shows wallets with their address. Hide only that with
267
+ `ui.settings.security.wallets: false`.
268
+ - **The sign-in page in the app's language.** `signIn()` sends `locale` to
269
+ `POST /api/v1/auth/request` (the new `auth.locale`, else the provider's `locale`, narrowed to the
270
+ eight), so the hosted page opens in it; with neither it is left out and the page goes by the
271
+ person's own choice and browser. Dropped, like the tracking fields, for a server that refuses it.
272
+ - **Custom claims, device code and three webhook events.** `IUser.claims` and
273
+ `useSaaSAuth().user.claims` carry the owner's custom claims (`{}` when none);
274
+ `AppTokenRequestClaims` gains `claims` and `grantType: 'device_code'`. `user.deletion_scheduled`,
275
+ `user.deletion_cancelled` and `user.deleted` close the `User` group: 129 names, up from 126. New
276
+ payload types `UserDeletionWebhookData` (`userId`, `by: 'self' | 'admin' | 'guest_expired'`,
277
+ `deleteAfter`) and `UserRegisteredWebhookData` (with `fromGuest`). Strings for all of the above
278
+ in all eight locales; `EmailChangeErrorCode` gains `guest_account`.
279
+ - **A scheduled account deletion shows on every settings screen.** While
280
+ `useSaaSAuth().user.deleteAfter` is set, the workspace settings dialog says "Your account will
281
+ be deleted on {date}." at the top of every screen, outside the screen's own scroll, with
282
+ **Cancel deletion**. Until now the date showed only at the foot of the Security screen. Cancel
283
+ posts to `POST /api/v1/public/account/delete/cancel`, shows progress on the button, then says
284
+ the account will not be deleted; a refusal, such as `read_only_session` while an app owner views
285
+ the account read-only, keeps the notice and shows the server's sentence under it. The date comes
286
+ from the session, so the notice makes no request, and is formatted with `ui.formats.date` (long
287
+ by default), as in the Delete account section. The Security screen keeps its date in the session
288
+ too, so a deletion scheduled or cancelled in either place shows in the other at once, and
289
+ opening the Security screen brings the session's date up to date with the profile. Hidden with
290
+ the section by `ui.settings.security.deleteAccount: false`. New string
291
+ `security.deleteAccountNotice` in all eight locales.
292
+ - **`StatusBanner` `actionProgress`.** Shows the action button as pending, with a spinner, and
293
+ disables it while true.
294
+ - **Plain-language help on the Account, Security and Devices screens.** A small "?" beside a
295
+ title or label opens a short note on a tap, a click or the keyboard (never on hover alone), and
296
+ Escape closes only the note. It explains two-step sign-in and authenticator apps, backup codes,
297
+ passkeys, the password rules, the phone number, connected accounts, "Confirm it's you", the
298
+ account deletion grace period, the email address and each device and session action, using
299
+ only the methods the app offers. `SectionHeader` gains a `help` slot. New strings under `help.*`
300
+ in all eight locales (written by a non-native speaker in es, fr, de, ja, zh, hi and ar; they
301
+ need a native check). The help button is internal and not exported.
302
+ - **Sign-in changes locked while an owner is signed in as the user.** The server now refuses a
303
+ change to how the person signs in (email, phone, username, password, second step, passkeys,
304
+ linked accounts, guest upgrade) during any session opened from the console as them, read-only
305
+ or not, with 403 `impersonation_sign_in_locked` and the sentence "You are signed in as this
306
+ person from the console, so how they sign in can't be changed here." `SessionRefusalCode`
307
+ gains the code, new pure helper `isImpersonationSignInLocked(error)` (core and `/react`) beside
308
+ `isReadOnlySession`, and `docs/ERROR_CODES.md` lists it. The screens show the server's sentence,
309
+ as they do for `read_only_session`, and never ask to confirm it's you or sign out over it. In a
310
+ full-access session `ImpersonationBanner` adds a line under "Changes are saved to their account":
311
+ "How they sign in can't be changed." New string `security.impersonationSignInLocked` in all eight
312
+ locales (written by a non-native speaker in es, fr, de, ja, zh, hi and ar; they need a native
313
+ check).
314
+ - ⚠️ **Passkey refusals carry their reason (behaviour change).** `renamePasskey()` and
315
+ `deletePasskey()` now carry the server's `reason` as `code`, as the other sign-in changes do,
316
+ so `isReadOnlySession`, `isReauthRequired` and `isImpersonationSignInLocked` recognise a
317
+ refused passkey change; they had only the sentence and `status`. Like those, a refusal is no
318
+ longer passed to the `onError` config callback.
319
+
320
+ ### Changed
321
+
322
+ - **The Password section knows who has no password.** `IUser` gains `passwordSet` and
323
+ `passwordSignIn` from `GET /api/v1/public/profile`. Someone who signed up through a provider, a
324
+ wallet, or a code or link is no longer asked for a current password they never had: the section
325
+ says they sign in without one and, where the app offers password sign-in, to sign out and use
326
+ Forgot password? on the sign-in page. Where it does not, the section is left out. Against a
327
+ server without the fields the form stays, with a hint that now covers codes and links too.
328
+ `changePassword()` carries the server's `reason` as `code` (new `PasswordChangeErrorCode`:
329
+ `password_not_set`, `invalid`, `locked`); on `password_not_set` the form goes and the server's
330
+ sentence stays.
331
+ - **Two-step sign-in while the app has it off.** An authenticator app the user set up stays in
332
+ view, with Remove, instead of the whole section disappearing; nothing new is offered, and New
333
+ backup codes is not, since nothing asks for them while it is off. One set up before the app
334
+ stopped allowing authenticator apps stays in view too.
335
+ - **Copy.** No em-dashes in any catalog string, nor in the agent card, `llms.txt` and `robots.txt`
336
+ bodies or the MCP tool errors. Banner titles read Done and Something went wrong;
337
+ `errors.unauthorized` says the session has ended. The passkey hint says when the sign-in page
338
+ actually offers one. The account-deletion refusal names what clears the way (remove the other
339
+ members and cancel the subscription, or delete the workspace) instead of a handover nothing can
340
+ do, and takes `count`. The general deletion warning gives the range (at once, or up to 30 days).
341
+ The username rule says letters a to z.
342
+ - **The lockout sentence.** On a `locked` 429 from `changePassword()` the server now says "Too
343
+ many failed attempts. Wait up to 15 minutes and try again, or reset your password." The SDK
344
+ shows it as sent and goes by `code`, never by the words, so nothing else changes. The docs for
345
+ `locked` now say a password reset lifts it, as the window passing does.
346
+ - **`buildbase import` names LinkedIn in the caveat.** LinkedIn's OpenID Connect user id is
347
+ scoped to the app, as Microsoft's is to the app registration and Apple's to the team, so an
348
+ export with a LinkedIn sign-in now gets a note that an imported LinkedIn link matches only the
349
+ same LinkedIn app. `PER_REGISTRATION_NOTES` gains `linkedIn`, word for word the tenant server's
350
+ new `notices` sentence; a dry run prints it, and a run that sends prints the server's. Against
351
+ an older server a sending run prints no LinkedIn note, since the server's notices replace the
352
+ command's own.
353
+ - ⚠️ **`StatusBanner` `message` takes a node.** `StatusBannerProps['message']` is now
354
+ `React.ReactNode` (was `string`), so a message can carry its own semantics, such as the code
355
+ cooldown's `role="timer"`. Passing a string works as before; code that reads the prop's type as
356
+ `string` needs widening.
357
+
358
+ ### Fixed
359
+
360
+ - **A failed account link always says why, and leaves the address clean.** Coming back from
361
+ connecting a provider on the Security screen, the server sends `?error=...&reason=...`, and the
362
+ screen knew four reasons. The tenant server's social callback sends eight more on that path:
363
+ `no_verified_email` (a provider account with no verified address, as GitHub's can be), and for a
364
+ guest, who becomes the account they connect, `email_unverified`, `email_unavailable`,
365
+ `disposable_email`, `signup_closed`, `signup_restricted`, `waitlist_required` and `not_a_guest`.
366
+ For those the screen said nothing and `error` and `reason` stayed on the URL. Each now shows its
367
+ own sentence in the user's language and the parameters come off the URL without a reload.
368
+ ⚠️ Type change: `AccountLinkErrorReason` gains the eight reasons, so an exhaustive `switch` or
369
+ `Record<AccountLinkErrorReason, ...>` over it needs the new members. New strings
370
+ `security.connectedAccountsNoVerifiedEmail`, `connectedAccountsEmailUnverified`,
371
+ `connectedAccountsEmailUnavailable`, `connectedAccountsDisposableEmail`,
372
+ `connectedAccountsSignupClosed`, `connectedAccountsSignupRestricted`,
373
+ `connectedAccountsWaitlistRequired` and `connectedAccountsNotAGuest` in all eight locales
374
+ (written by a model in es, fr, de, ja, zh, hi and ar, for a native reader to check).
375
+ - **A guest refused two-step sign-in is told so in their language.** The tenant server refuses a
376
+ guest's authenticator app and text message codes with `guest_account` (409). `MfaErrorCode`
377
+ gains `guest_account` (⚠️ type change, as above), and the two-step section shows
378
+ `security.twoStepGuest` ("Create your account before setting up two-step sign-in.", all eight
379
+ locales, model-written outside English) instead of the server's English.
380
+ - **"The server could not be reached" instead of the browser's English.** A call the API never
381
+ answered showed what the browser said ("Failed to fetch", "Load failed", "NetworkError when
382
+ attempting to fetch resource.") or the API layer's own English ("Failed to change password (502:
383
+ Bad Gateway)" for a proxy's error page), and `useGuestUpgrade` fell back to a hard-coded
384
+ "Request failed". The Security screen (password, two-step sign-in, passkeys, phone number,
385
+ connected accounts, deleting the account), Confirm it's you, the scheduled-deletion notice, the
386
+ account screen's email change, username and save, and the guest upgrade now say "The server
387
+ could not be reached. Check your connection and try again." (new `errors.unreachable`, all eight
388
+ locales, model-written outside English). The API layer marks such an error (a network failure, a
389
+ timeout, or a response that is not the API's JSON, which keeps its `status`) without changing
390
+ its shape; a refusal the server wrote keeps its sentence. `GuestUpgradeFailure` gains an
391
+ optional `unreachable: true`, and `useGuestUpgrade().error.message` is always a sentence to
392
+ show.
393
+ - **The phone number field reads every keyboard's digits.** The Security screen's phone input
394
+ refused a number typed in Arabic-Indic, Devanagari or full-width digits, with a full-width plus,
395
+ or pasted with invisible direction marks (Android wraps a copied number in two; the screen's own
396
+ display of a number adds two more, so a number copied from the screen could not be pasted
397
+ back). `toE164` now reads those digits as ASCII and drops the invisible marks, as the server's
398
+ parser already does, before it composes the E.164 number. A letter, an extension or a second
399
+ plus is still refused.
400
+ - **Disconnecting an agent asks first.** The Connected agents screen disconnected an agent on the
401
+ first press. It now asks, as removing a device does ("Disconnect Acme Assistant? It loses access
402
+ to your account at once. You can connect it again at any time.", Cancel / Disconnect), shows the
403
+ row as pending while it runs, and says "Acme Assistant was disconnected." once it is. New strings
404
+ `security.connectedAgentsConfirmTitle`, `connectedAgentsConfirmBody`, `connectedAgentsCancel`
405
+ and `connectedAgentsDisconnected` in all eight locales (written by a non-native speaker in es,
406
+ fr, de, ja, zh, hi and ar; they need a native check). `useConnectedAgents().revoke()` now
407
+ resolves `true` or `false` (was `void`), so a custom list can tell the outcome.
408
+ - **A new authenticator app says "Added: today".** Right after set-up the two-step row said "Last
409
+ used: today": the screen stamped the last use with the set-up time, and the server stamps the
410
+ confirming code as a use too. The confirming code is no longer counted: the row says Added
411
+ until a code passes at least a minute after the set-up, then Last used.
412
+ - **`buildbase` help and import output say one thing once.** `buildbase import --help` (and
413
+ `init --help`, `content pull --help`) prints that command's usage and options only, where it
414
+ printed all three commands'; a parse error shows the usage of the command it was for, and
415
+ `buildbase --help` is the overview. A record the import leaves out before sending (deleted
416
+ upstream, no email) is listed under "Not sent" in a real run as in a dry run, matching the
417
+ totals' "not sent", where the real run listed it under "Failed" with the server's refusals.
418
+ The Microsoft and Apple caveat is the platform's own sentences, one per provider present, in
419
+ a dry run as in a real run, where the dry run had a wording of its own.
420
+ - **Confirm it's you no longer ends an owner's view as the user.** The server answers
421
+ `reverify_unavailable` to a re-confirm inside a session an app owner opened as the user from the
422
+ console, with a sentence that says to sign out and sign in again, and the Security screen did
423
+ exactly that, ending the owner's session as the user. It now stays signed in and says "You are
424
+ signed in as this person from the console, so you can't confirm it's them here. Only they can
425
+ make this change." New string `security.reauthImpersonating` in all eight locales (written by a
426
+ non-native speaker in es, fr, de, ja, zh, hi and ar; they need a native check).
427
+ - **A failed profile load no longer shows a skeleton for ever.** The Account screen says the
428
+ profile could not be loaded, with Retry, and says the same after a failed Reset. New string
429
+ `profile.loadFailed` in all eight locales.
430
+ - **Phone refusals in the Security screen's own words.** `sms_unavailable` said to try another way
431
+ to sign in, and `method_unavailable` to choose a text message; both now read as this screen
432
+ should. `PhoneErrorCode` gains `method_unavailable`.
433
+ - **The phone country picker has an accessible name.** `CommandSelect` takes `aria-labelledby`
434
+ (read with the selected option) and `aria-label`.
435
+ - **The Profile screen's pickers have accessible names.** Language, Country, Currency and
436
+ Timezone each name their combobox by the visible label above it, read with the selected option
437
+ ("Country, Germany"), where a screen reader heard only the value. `SelectCountry`,
438
+ `SelectCurrency`, `SelectLanguage` and `SelectTimeZone` pass `aria-labelledby` and `aria-label`
439
+ through to `CommandSelect`.
440
+ - **"Add and verify a phone number first" goes once the phone is verified.** After Turn on for
441
+ text message codes with no verified phone, the two-step section kept saying so after the number
442
+ had been verified a few lines above. It now goes as soon as the phone number section changes
443
+ the phone; Turn on says it again if it still holds.
444
+ - **One error at a time when changing the password.** A refusal from the server ("Current
445
+ password is incorrect.") stayed on screen beside a newer error of the form's own ("Use at least
446
+ 8 characters."). It now goes as soon as either field is edited, or when the form's own check
447
+ finds something newer. A success stays.
448
+ - **A code cooldown is a wait, not an error.** Asking for a code inside the server's resend
449
+ window, as when the number is changed right after adding it, showed "Something went wrong" over
450
+ the server's static "Wait 29 seconds before asking for another code." The phone number and
451
+ two-step sections now show a neutral notice that counts down ("A code was sent moments ago. You
452
+ can send another in 17 seconds."), inside `role="timer"` so a screen reader does not read out
453
+ every second, and goes at zero, when Send code (or Turn on) is offered again. New string
454
+ `security.phoneWait` in all eight locales.
455
+ - **The confirm-it's-you banners say confirm it's you.** On an old sign-in, removing the phone,
456
+ turning off text message codes and removing a connected account said to sign in again, while
457
+ the only button is Confirm it's you and nobody is signed out. `security.phoneReauth`,
458
+ `security.twoStepSmsReauth` and `security.connectedAccountsReauth` now read as the delete
459
+ account banner does ("For your security, confirm it's you before removing your phone number."),
460
+ in all eight locales.
461
+ - **Phone numbers grouped for reading.** A North American number (+1) shows as "+1 415 555 5829"
462
+ rather than "+1 4155555829": on the phone row, in the remove confirmation and in the sent and
463
+ verified messages. Other countries keep the national number in one block, since grouping them
464
+ needs libphonenumber's per-country rules, which the SDK does not carry. The number kept and sent
465
+ is still E.164.
466
+ - **Contrast on the settings sidebar.** The group labels (Account, Workspace) and the workspace id
467
+ were `muted-foreground` on the `muted` sidebar, 4.3:1 with the default tokens, under the 4.5:1
468
+ small text needs. They are now `foreground` at 70% (7.5:1 light, 7.6:1 dark), so a theme still
469
+ recolours them; no token changed. `SidebarNavSection` labels change with them.
470
+ - ⚠️ **Status colours reach WCAG AA in every use (visual change).** Each of `--destructive`,
471
+ `--success`, `--warning` and `--info` was both a fill and every text in its colour, and no single
472
+ value does every job. With the defaults, red text was 3.3:1 on the error banner and 3.8:1 on the
473
+ page, the label on a Delete button 3.6:1, red text in dark mode (Sign out) 2.0:1, and a red
474
+ progress bar or focus ring in dark mode 2.0:1 against the page (1.5:1 on the seat bar's muted
475
+ track). Success and warning text was 4.4:1 on their banners and 3.2:1 on the banner hint lines,
476
+ their hovered buttons 4.2:1, and the softer warning usage bar 2.6:1 against its track; info hint
477
+ lines were 3.9:1 (3.7:1 dark), info text on the dark sidebar 3.3:1 and a hovered info button in
478
+ dark mode 4.1:1. Small text needs 4.5:1; a bar, a focus ring or a state border 3:1.
479
+ - New text tones `--destructive-text` (light `0 72% 36%`, dark `0 91% 71%`), `--success-text`
480
+ (`142 72% 19%`, `142 69% 58%`), `--warning-text` (`26 90% 24%`, `38 92% 60%`) and
481
+ `--info-text` (`224 76% 38%`, `217 91% 78%`). `tailwind.config.js` points every
482
+ `text-<status>` at its tone; `bg-`, `border-` and `ring-<status>` stay on the fill.
483
+ - New fill defaults: light `--destructive` `0 72% 45%` (was `0 84.2% 60.2%`), `--success`
484
+ `142 72% 26%` (was `142 72% 29%`) and `--warning` `26 90% 33%` (was `26 90% 37%`); dark
485
+ `--destructive` `0 72% 47%` (was `0 62.8% 30.6%`; white text on it is 5.2:1) and `--info`
486
+ `217 91% 66%` (was `217 91% 60%`).
487
+ - The seat bar on the members screen is drawn as the usage bars are, on a 15% tint of its own
488
+ colour, and its card is no longer muted; the softer usage bar (75 to 90% used) is
489
+ `bg-warning/80` (was `/70`).
490
+ - What changes on screen: status text is darker in light mode (success and warning close to
491
+ the 800 and 900 shades), warning and info text lighter in dark mode, light success, warning
492
+ and red fills darker, and the dark red fill brighter.
493
+ - Now at least 4.7:1 for every status text use and every label on a fill (hovered too) and 3.4:1
494
+ for every bar, ring and state border, light and dark; the table is in `docs/THEMING.md`.
495
+ - A theme that sets only a fill keeps its fills, and its text takes the SDK's tone, which reads
496
+ at 4.5:1 on the tints of any colour within 30 degrees of the shipped hue that meets the fill's
497
+ own uses; set `--<status>-text` too, light and dark, for text in your own hue.
498
+ `src/styles/theme-contrast.test.ts` measures every use of every status colour on the built
499
+ CSS, light and dark, and sweeps those fills.
500
+ - **The error boundary's fallback takes the SDK theme.** `SaaSOSProvider` renders it outside
501
+ every `.saas-os-ui` root, so it read the host page's own `--destructive`, in a shadcn app the
502
+ same red that fails as text. It now carries the class, gets the SDK's tokens and an app's
503
+ overrides of them, and colours its text with `--destructive-text`.
504
+ - ⚠️ **`createWorkspace()` honours `ui.behavior.autoOpenPlanDialog: false` (behaviour change).**
505
+ It opened Plan & Billing on the new workspace 300 ms later whatever the app said:
506
+ `UIConfigProvider` sat inside `WorkspaceProvider`, so the workspace actions only ever saw an
507
+ empty config. `UIConfigProvider` now wraps `WorkspaceProvider` (the checkout and permission
508
+ config providers moved outside it too; neither reads it), and `createWorkspace()` opens
509
+ nothing when `autoOpenPlanDialog` is `false` or `ui.settings.sections.subscription` is `false`
510
+ (the dialog cannot show a hidden screen and fell back to Profile). With neither set it opens
511
+ Plan & Billing as before.
512
+
513
+ ## [0.0.89] - 2026-10-18
514
+
515
+ ### Added
516
+
517
+ - `email.complained` in `WEBHOOK_EVENTS` (now 113 names): the platform fires it
518
+ when a recipient reports a message as spam. Before, a complaint arrived as
519
+ `email.bounced`; a handler that caught complaints there should switch on
520
+ `email.complained` too. The payload is the same as `email.bounced`
521
+ (`trackingCode`, `recipientEmail`, `emailHistoryId`, `audienceId`, `subject`).
522
+
8
523
  ## [0.0.88] - 2026-10-18
9
524
 
10
525
  ### Changed