@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.
- package/AGENTS.md +7 -3
- package/CHANGELOG.md +515 -0
- package/README.md +120 -3
- package/dist/cli.mjs +1 -1
- package/dist/css/styles.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1351 -12
- package/dist/index.mjs +1 -1
- package/dist/mcp/index.cjs +1 -1
- package/dist/mcp/index.d.ts +120 -2
- package/dist/mcp/index.mjs +1 -1
- package/dist/react/chunks/SettingsDialog-BVyV5Zfm.mjs +2 -0
- package/dist/react/chunks/SettingsDialog-Ch8X6WXy.cjs +2 -0
- package/dist/react/chunks/{SubscriptionDialog-B8FTFRtp.cjs → SubscriptionDialog-CGIvMKpT.cjs} +1 -1
- package/dist/react/chunks/SubscriptionDialog-g0LwHhbe.mjs +2 -0
- package/dist/react/chunks/ar-3aqWucom.mjs +2 -0
- package/dist/react/chunks/ar-DQVB89r8.cjs +2 -0
- package/dist/react/chunks/de-DO10bhnU.cjs +2 -0
- package/dist/react/chunks/de-HXQk5D6k.mjs +2 -0
- package/dist/react/chunks/es-BZsmhBN3.mjs +2 -0
- package/dist/react/chunks/es-Cxg-qhUh.cjs +2 -0
- package/dist/react/chunks/fr-COCMy3vW.cjs +2 -0
- package/dist/react/chunks/fr-rdnNHzxy.mjs +2 -0
- package/dist/react/chunks/hi-BB0Z-Dgn.cjs +2 -0
- package/dist/react/chunks/hi-t3RH7ITu.mjs +2 -0
- package/dist/react/chunks/ja-CDyla2g0.mjs +2 -0
- package/dist/react/chunks/ja-D92JgUlk.cjs +2 -0
- package/dist/react/chunks/react-DTq7A_PT.mjs +2 -0
- package/dist/react/chunks/react-DeapSbq6.cjs +2 -0
- package/dist/react/chunks/zh-C5HABZZV.mjs +2 -0
- package/dist/react/chunks/zh-DK2yA5wB.cjs +2 -0
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.d.ts +1517 -14
- package/dist/react/index.mjs +1 -1
- package/docs/ERROR_CODES.md +218 -0
- package/docs/THEMING.md +89 -21
- package/docs/UI-CONFIG.md +23 -4
- package/docs/USER-IMPORT.md +320 -0
- package/package.json +2 -1
- package/dist/react/chunks/SettingsDialog-CKVE1PpH.mjs +0 -2
- package/dist/react/chunks/SettingsDialog-DrDmxFEj.cjs +0 -2
- package/dist/react/chunks/SubscriptionDialog-UN7o9Hqb.mjs +0 -2
- package/dist/react/chunks/ar-B2l90OZJ.cjs +0 -2
- package/dist/react/chunks/ar-DVVNZhXD.mjs +0 -2
- package/dist/react/chunks/de-BspJtvAC.mjs +0 -2
- package/dist/react/chunks/de-dURUqbl0.cjs +0 -2
- package/dist/react/chunks/es-DArjREi5.cjs +0 -2
- package/dist/react/chunks/es-vu4BmI3g.mjs +0 -2
- package/dist/react/chunks/fr-DVm5WCvZ.mjs +0 -2
- package/dist/react/chunks/fr-DhYDaYrX.cjs +0 -2
- package/dist/react/chunks/hi-C_nv_c1T.cjs +0 -2
- package/dist/react/chunks/hi-VW8xCj_K.mjs +0 -2
- package/dist/react/chunks/ja-C-b88dvw.mjs +0 -2
- package/dist/react/chunks/ja-CqtDkLHr.cjs +0 -2
- package/dist/react/chunks/react-CHivaJhA.mjs +0 -2
- package/dist/react/chunks/react-Ch_Suxxy.cjs +0 -2
- package/dist/react/chunks/zh-D1yfhUf0.mjs +0 -2
- 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` (
|
|
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
|