@buildbase/sdk 0.0.70 → 0.0.73
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 +5 -2
- package/CHANGELOG.md +114 -0
- package/README.md +141 -4
- package/dist/cli.mjs +2 -0
- package/dist/css/styles.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +589 -14
- package/dist/index.mjs +1 -1
- package/dist/mcp/index.cjs +1 -1
- package/dist/mcp/index.d.ts +102 -0
- package/dist/mcp/index.mjs +1 -1
- package/dist/react/chunks/SettingsDialog-CvBSuMf-.mjs +2 -0
- package/dist/react/chunks/SettingsDialog-DdOUkwIi.cjs +2 -0
- package/dist/react/chunks/SubscriptionDialog-CdZzF5eK.cjs +2 -0
- package/dist/react/chunks/SubscriptionDialog-D8_frxHc.mjs +2 -0
- package/dist/react/chunks/ar-CRVhjpVE.cjs +2 -0
- package/dist/react/chunks/ar-DFPDdu2U.mjs +2 -0
- package/dist/react/chunks/de-BGhzn711.cjs +2 -0
- package/dist/react/chunks/de-Bqi8scew.mjs +2 -0
- package/dist/react/chunks/es-D_Eqhob3.mjs +2 -0
- package/dist/react/chunks/es-DyrMaeUp.cjs +2 -0
- package/dist/react/chunks/fr-C0SkloRr.cjs +2 -0
- package/dist/react/chunks/fr-DkZpFQ1Q.mjs +2 -0
- package/dist/react/chunks/hi-B9TK87T8.cjs +2 -0
- package/dist/react/chunks/hi-DrTcPkiY.mjs +2 -0
- package/dist/react/chunks/ja-Dgahn--0.mjs +2 -0
- package/dist/react/chunks/ja-LjzlAhAT.cjs +2 -0
- package/dist/react/chunks/react-BSHCN-ZU.mjs +2 -0
- package/dist/react/chunks/react-DQqr117G.cjs +2 -0
- package/dist/react/chunks/zh-BezRHDHs.mjs +2 -0
- package/dist/react/chunks/zh-CcodKi4U.cjs +2 -0
- package/dist/react/index.cjs +1 -1
- package/dist/react/index.d.ts +826 -25
- package/dist/react/index.mjs +1 -1
- package/dist/server/browser.cjs +1 -0
- package/dist/server/browser.mjs +1 -0
- package/dist/server/index.cjs +1 -0
- package/dist/server/index.d.ts +556 -0
- package/dist/server/index.mjs +1 -0
- package/dist/server/next/index.cjs +1 -0
- package/dist/server/next/index.d.ts +156 -0
- package/dist/server/next/index.mjs +1 -0
- package/dist/tracking/index.cjs +1 -1
- package/dist/tracking/index.d.ts +205 -8
- package/dist/tracking/index.mjs +1 -1
- package/docs/ERROR_CODES.md +24 -0
- package/docs/SERVER-CONTENT.md +297 -0
- package/docs/UI-CONFIG.md +1 -0
- package/package.json +66 -5
- package/dist/react/chunks/SettingsDialog-VKPguQBs.mjs +0 -2
- package/dist/react/chunks/SettingsDialog-_kfJ6j25.cjs +0 -2
- package/dist/react/chunks/SubscriptionDialog-C_1ipsC2.cjs +0 -2
- package/dist/react/chunks/SubscriptionDialog-Cbk3EGBr.mjs +0 -2
- package/dist/react/chunks/ar-CHTRXlXt.mjs +0 -2
- package/dist/react/chunks/ar-DPJVNHJV.cjs +0 -2
- package/dist/react/chunks/de-CdPeKWZ2.mjs +0 -2
- package/dist/react/chunks/de-DPd09yf-.cjs +0 -2
- package/dist/react/chunks/es-BWGnjJKJ.mjs +0 -2
- package/dist/react/chunks/es-WdOYGt6G.cjs +0 -2
- package/dist/react/chunks/fr-Big5DFPB.mjs +0 -2
- package/dist/react/chunks/fr-LIbCvYeh.cjs +0 -2
- package/dist/react/chunks/hi-BoCY3pFe.cjs +0 -2
- package/dist/react/chunks/hi-jArNRknR.mjs +0 -2
- package/dist/react/chunks/ja-Cwdz0hCu.mjs +0 -2
- package/dist/react/chunks/ja-E0jqCiVq.cjs +0 -2
- package/dist/react/chunks/react-B0MFoaXI.mjs +0 -2
- package/dist/react/chunks/react-DSRfaDnZ.cjs +0 -2
- package/dist/react/chunks/zh-glRKcdia.mjs +0 -2
- package/dist/react/chunks/zh-tU-AqFYI.cjs +0 -2
package/AGENTS.md
CHANGED
|
@@ -83,13 +83,15 @@ Config also accepts: `timeout`, `maxRetries`, `debug`, `headers`, `onError(err,
|
|
|
83
83
|
- **URL / auth-intent / redirects:** `BB_PARAM`, `BBAction`, `BBScreen`, `BBStatus`, `createBBUrl`, `readBBParams`, `cleanBBParams`, `createCheckoutRedirectUrls`, `createCreditPurchaseRedirectUrls`, `safeRedirect`, `validateRedirectUrl`, `saveAuthIntent`, `consumeAuthIntent`, `clearAuthIntent`.
|
|
84
84
|
- **Context invalidation (call after server mutations to refetch client contexts):** `invalidateSubscription`, `invalidateQuotaUsage`, `invalidateCreditBalance`.
|
|
85
85
|
- **Events:** `EventEmitter`, `eventEmitter` (singleton), `SDKEvent` (enum).
|
|
86
|
+
- **Workspace invitations (pure):** `INVITATION_ERROR_CODE`, `isInvitationError`, `sortInvitations`, `actionableInvitations`, `isInvitationActionable`, `isInvitationLapsed`, `resendCooldownSeconds`, `parkPendingInvite`, `readPendingInvite`, `clearPendingInvite`; server factory namespace `invitations` (`list`, `create`, `resend`, `revoke`).
|
|
86
87
|
- **Push:** `PushApi`, `PUSH_SERVICE_WORKER_SCRIPT`.
|
|
88
|
+
- **Notification inbox:** `NotificationsApi` (`list`, `counts`, `markRead`, `markUnread`, `archive`, `unarchive`, `markAllRead`, `markSeen`).
|
|
87
89
|
- **Enums:** `ApiVersion`, `AuthStatus`, `BillingIntervals`, `CreditBucketSource`, `CreditBucketStatus`, `CreditTransactionType`, `DunningState`, `InvoiceStatuses`, `SubscriptionItemType`, `SubscriptionStatus`.
|
|
88
90
|
|
|
89
91
|
### `@buildbase/sdk/react` — client-only additions (core is re-exported here too)
|
|
90
92
|
|
|
91
93
|
- **Providers:** `SaaSOSProvider` (root), `SubscriptionContextProvider`, `QuotaUsageContextProvider`, `CreditBalanceContextProvider`, `PushNotificationProvider`.
|
|
92
|
-
- **State hooks:** `useSaaSAuth`, `useSaaSWorkspaces`, `useSaaSOs`, `useSaaSSettings`, `useUserAttributes`, `useUserFeatures`, `useTranslation`, `usePermissions`, `useSeatStatus`, `useTrialStatus`, `useFullScreenLoader`.
|
|
94
|
+
- **State hooks:** `useSaaSAuth`, `useSaaSWorkspaces`, `useSaaSOs`, `useSaaSSettings`, `useUserAttributes`, `useUserFeatures`, `useTranslation`, `usePermissions`, `useSeatStatus`, `useTrialStatus`, `useFullScreenLoader`, `useMyInvitations`, `useWorkspaceInvitations`, `useNotifications`.
|
|
93
95
|
- **Subscription/billing hooks:** `usePublicPlans`, `useSubscription`, `useSubscriptionContext`, `useSubscriptionManagement`, `usePlanGroup`, `usePlanGroupVersions`, `usePublicPlanGroupVersion`, `useCreateCheckoutSession`, `useUpdateSubscription`, `useCancelSubscription`, `useResumeSubscription`, `useBillingPortal`, `useInvoices`, `useInvoice`.
|
|
94
96
|
- **Quota hooks:** `useQuotaUsageContext`, `useRecordUsage`, `useQuotaUsageStatus`, `useAllQuotaUsage`, `useUsageLogs`.
|
|
95
97
|
- **Credit hooks:** `useCreditBalance`, `useCreditBalanceContext`, `useConsumeCredits`, `usePurchaseCredits`, `useCreditPackages`, `usePublicCreditPackages`, `useCreditTransactions`, `useExpiringCredits`.
|
|
@@ -102,7 +104,8 @@ Config also accepts: `timeout`, `maxRetries`, `debug`, `headers`, `onError(err,
|
|
|
102
104
|
- Trial: `WhenTrialing`, `WhenNotTrialing`, `WhenTrialEnding`
|
|
103
105
|
- Quota: `WhenQuotaAvailable`, `WhenQuotaExhausted`, `WhenQuotaOverage`, `WhenQuotaThreshold`
|
|
104
106
|
- Credits: `WhenCreditsAvailable`, `WhenCreditsExhausted`, `WhenCreditsLow`
|
|
105
|
-
-
|
|
107
|
+
- Invitations: `WhenPendingInvitations`
|
|
108
|
+
- **UI components:** `WorkspaceSwitcher`, `PricingPage`, `CreditStorePage`, `CreditBalance`, `CreditActionsProvider`, `BetaForm`, `FullScreenLoader`, `PendingInvitations`, `NotificationBell`, `NotificationInbox`.
|
|
106
109
|
- **Misc:** `SUPPORTED_LOCALES`, `SETTINGS_SCREENS`, `SettingsScreen`, `workspaceSettingsManager`.
|
|
107
110
|
|
|
108
111
|
### `@buildbase/sdk/data`
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.0.73] - 2026-09-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **An organization's own permissions.** Keys defined in the BuildBase console
|
|
15
|
+
(`app:reports:export`) arrive in `settings.workspace.customPermissions` and
|
|
16
|
+
resolve per role exactly like the platform's own, so `can('app:reports:export')`
|
|
17
|
+
answers from what the organization granted rather than from a prop the app
|
|
18
|
+
shipped. `usePermissions()` returns `appPermissionDefinitions` for rendering
|
|
19
|
+
them, and `appPermissionCatalogue(settings, appPermissions)` is exported for
|
|
20
|
+
the same list outside React. Needs a server from release 23.
|
|
21
|
+
- **Your backend can ask what a member may do.** The server client gains
|
|
22
|
+
`workspace.permissions(workspaceId)`, the server's own answer
|
|
23
|
+
(`{ role, isOwner, permissions }`), and `workspace.can(workspaceId, key)` for
|
|
24
|
+
one key or every key of a list. Check before acting rather than trusting the
|
|
25
|
+
browser.
|
|
26
|
+
- The workspace permissions screen lists the organization's catalogue with its
|
|
27
|
+
labels. Before, it could only infer keys from the `defaultPermissions` prop,
|
|
28
|
+
so a permission nobody had granted yet was invisible and ungrantable.
|
|
29
|
+
Keys passed through `defaultPermissions` keep working and are listed too.
|
|
30
|
+
- `Permission.WORKSPACE_USAGE_RECORD` (`workspace:usage:record`): recording usage
|
|
31
|
+
and spending credits. Editors hold it, viewers do not.
|
|
32
|
+
- **Enhanced conversions.** New `setIdentity({ email, phone })` on `useTracking()`: normalised per Google's spec (trimmed, lower case, dots removed from Gmail local parts; phones in E.164) and hashed with SHA-256 in the browser (WebCrypto, with the SDK's pure fallback). Only the hashes are kept. They are set as `gtag('set', 'user_data', { sha256_email_address, sha256_phone_number })` before each conversion and as Meta advanced matching (`fbq('init', id, { em, ph })`), and only when the app's console setting has enhanced conversions on and the visitor granted marketing consent. `SaaSOSProvider` supplies the signed-in user's email automatically. An `email` or `phone` passed in `setUser` traits is now routed here instead of reaching vendors raw.
|
|
33
|
+
- **Consent records.** `consent.set(state, { source?, consentString? })` also sends a record to `POST /api/v1/public/:orgId/consent` (choices, manifest version, a `bb_cid` visitor id, and the session when signed in), so the app can answer a data-subject request. The same answer restored on every page load is recorded once.
|
|
34
|
+
- **Attribution reaches the server, not only the vendors.** The `bb_attr` cookie now records when the landing was captured and its origin and path (never the query). `signIn()` sends it with the auth request as `trackingAttribution`, workspace creation sends it with the create call, and checkout metadata carries `bb_attr_ts`. The server stores it on the user and the workspace, so a trial that converts a week later from a Stripe webhook can be joined to the ad click. New `getAttributionRecord()` and `attributionPayload()`. Against an older server that rejects the new fields, sign-in and workspace creation retry without them.
|
|
35
|
+
- **Regional consent defaults.** `consent` also takes `{ default: 'granted', regions: { denied: ['EEA', 'GB', 'CH'] } }`; `'EEA'` expands to the 30 member states. The country comes from the tracking-config response (the edge's country header), never from the browser; an unknown country resolves to denied. With `consent: 'auto'` the client's regional default from the console applies when one is set. The rule is also sent to Google in its own `region` syntax. New `consent.effective`, `consent.default` and `consent.country` on `useTracking()`; `consent.state` is still only the visitor's answer. Checkout metadata now carries `consent.effective`, so a server-side send sees a granted regional default as granted. `EEA_COUNTRIES`, `SAFE_REGIONAL_CONSENT`, `expandRegions` and `resolveConsentDefault` are exported from `@buildbase/sdk/tracking`. Needs a server that returns `country`; against an older one every visitor resolves to denied, as before.
|
|
36
|
+
|
|
37
|
+
- **`@buildbase/sdk/server`: content from your server.** A client for blogs, docs, FAQs, testimonials, rich content and collection data that authenticates with an org API token and so only runs on a server: the package's `browser` export condition resolves to a module that throws at load, and `createServerClient` refuses to construct in a browser. It uses only standard `fetch`, so it runs on Node 18+, Bun, Deno, Cloudflare Workers and edge runtimes. Every read is cached (`memoryCache` by default, `redisCache` for ioredis or node-redis, `kvCache` for Workers KV or Upstash, or your own `CacheStore`) and revalidated with `If-None-Match`, so an unchanged resource costs one `304`. Identical concurrent reads share a request, and the last good copy is served if BuildBase is unreachable. Lists return published content unless asked otherwise. See `docs/SERVER-CONTENT.md`.
|
|
38
|
+
- **Content webhooks.** `handleContentWebhook(request, { secret, cache })` verifies a BuildBase webhook and drops the cached reads its event names (`blog.published`, `doc.updated`, `faq.updated`, `rich_content.updated`, `collection.record_created` and the rest in `CONTENT_WEBHOOK_EVENTS`), using the tag vocabulary in `contentTags`. `toNodeHandler()` adapts it to Express, Fastify and NestJS. Needs a server that sends content events; against an older one reads still revalidate on their TTL.
|
|
39
|
+
- **`@buildbase/sdk/server/next`.** `withNextCache()` hands caching to Next's data cache (`{ next: { tags, revalidate } }` on every read) and `createNextWebhookHandler()` calls `revalidateTag`. `next` is an optional peer.
|
|
40
|
+
- **Stale content is served while it refreshes.** A read past `max-age` but inside the response's `stale-while-revalidate` returns the cached copy at once and revalidates in the background, one request however many reads arrive. BuildBase sends `private, max-age=60, stale-while-revalidate=300`; `private` does not stop the client caching, since the cache is on your server.
|
|
41
|
+
- FAQ collections and their questions are read by slug through `GET /api/faqs/collections/slug/:slug` (and `/faqs`), a testimonial by id through `GET /api/testimonials/:id`, and live collection data with `latest=true` alone. These need the server release that ships content delivery.
|
|
42
|
+
- **`buildbase content pull`.** A CLI (`npx buildbase content pull --out content.json`) that writes all published content to one JSON file, for static sites and single-page apps with no backend. Reads the token from `BUILDBASE_API_TOKEN` only. `pullContent()` does the same from a build script.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **⚠️ Tags that honour Google Consent Mode now load before a consent answer, whatever their category.** Google Ads is a `marketing` tag, so it used to stay off the page until `consent.set({ marketing: true })`, which meant no cookieless pings and nothing for Google to model the unobserved conversions from. A vendor with `supportsConsentMode: true` (GTM, GA4, Google Ads) now loads in the denied state and stays after a refusal; every other vendor still waits for a yes in its category. `consent: 'denied'` still loads nothing at all.
|
|
47
|
+
- With a Google tag configured, the SDK sets `url_passthrough: true`, and `ads_data_redaction: true` while `ad_storage` is denied (cleared on a grant).
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **A grant made in the console reaches the member.** A role's app permissions
|
|
52
|
+
were read from the workspace override or the `defaultPermissions` prop only,
|
|
53
|
+
never from the organization's matrix, so a custom permission granted to a role
|
|
54
|
+
in the console answered `false` in the app while the server said yes. The SDK
|
|
55
|
+
now resolves in the server's order: workspace override, then the organization,
|
|
56
|
+
then the SDK defaults. A key the console does not catalogue keeps the prop's
|
|
57
|
+
default.
|
|
58
|
+
- **Admin holds every permission**, a custom one added after admin's list was
|
|
59
|
+
saved included, as the server and both settings screens say.
|
|
60
|
+
- **The Permissions screen appears for a console-only catalogue.** It was shown
|
|
61
|
+
only when the app passed `defaultPermissions`.
|
|
62
|
+
- **Saving the Permissions screen no longer strips platform permissions.** A
|
|
63
|
+
role the workspace had no override for was saved with its app keys only, so
|
|
64
|
+
an editor lost settings, members and usage access in that workspace. The
|
|
65
|
+
screen also starts from what each role resolves to now, the console's grants
|
|
66
|
+
included, and groups rows as the console does.
|
|
67
|
+
- `ISettings['workspace']` declares `customPermissions`, which the server already
|
|
68
|
+
sent but the type did not name.
|
|
69
|
+
- New helper `resolveRolePermissions(role, ctx)`: what one role may do, by the
|
|
70
|
+
same rules as `resolvePermissions`.
|
|
71
|
+
|
|
72
|
+
- **Inviting a member never preselects `admin`.** The invite form preselects the
|
|
73
|
+
org's default role, but not when that is `admin`: an invite sent with one click
|
|
74
|
+
handed over settings, members and billing. It falls back to `viewer`, then to
|
|
75
|
+
any other role. New helper `pickDefaultRole(roles, defaultRole)`.
|
|
76
|
+
- **Custom roles appear in the role dropdown.** Changing a member's role listed
|
|
77
|
+
the workspace's copy of the roles, made when it was created and never updated,
|
|
78
|
+
so a role added later in the console was missing. It lists the org's roles.
|
|
79
|
+
- **`DEFAULT_ROLE_PERMISSIONS` matches the server.** It had a `member` entry,
|
|
80
|
+
which no one can hold, and no `editor`, so an editor was shown nothing the
|
|
81
|
+
server allowed. It now names `admin`, `editor` and `viewer`. Servers from this
|
|
82
|
+
release send every role's effective permissions, so these defaults apply only
|
|
83
|
+
to older servers.
|
|
84
|
+
- An unknown role never falls back to `admin`, even when that is the org's
|
|
85
|
+
default role.
|
|
86
|
+
- **`useSeatStatus` applies the org's member ceiling by default.** It used the
|
|
87
|
+
org's `maxWorkspaceUsers` only when the caller passed it, so an app page that
|
|
88
|
+
did not showed no limit beside the SDK's own members screen showing 1/50 for
|
|
89
|
+
the same workspace. An explicit `settingsMaxUsers` still wins.
|
|
90
|
+
|
|
91
|
+
## [0.0.71] - 2026-09-25
|
|
92
|
+
|
|
93
|
+
Ships with BuildBase server release 22. Every item below that says it needs a
|
|
94
|
+
server from this release is listed in the README's server compatibility table,
|
|
95
|
+
with what an older server answers instead.
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
|
|
99
|
+
- **The inbox updates live.** `useNotifications`, `<NotificationBell />` and `<NotificationInbox />` hear about new and changed items the moment they happen, over one connection per tab however many are mounted, and refetch. The connection carries no notification content, only "your inbox changed"; reads still go through the inbox routes. Reads made in another tab or on another device show at once. The one-minute poll still runs as the fallback. `live: false` turns it off. Adds `socket.io-client` as a dependency, loaded only when an inbox is shown. Requires a server that ships live inbox updates; against an older one the connection is refused and the poll carries on.
|
|
100
|
+
- **A notification inbox.** Everything the app sends a user, by email, push or both, is now a record they can come back to: one item per notification, however it was delivered, with what each channel did (email opened or clicked, push clicked). New `<NotificationBell />` (unread count, inbox in a panel) and `<NotificationInbox />` (a full list with All / Unread, mark read or unread, archive, mark all read, load more), both on the new `useNotifications({ workspaceId })` hook; `workspaceId` takes an id, `'current'`, `'none'` for account notices, or nothing for everything. An item is read when opened in the inbox or when its email or push link is clicked, never by an email open. Consumers of the same scope share one list and one poll. Core `NotificationsApi` for the same outside React. Requires a server that ships the notification center.
|
|
101
|
+
- **Workspace invitations.** `inviteMember(workspaceId, email, role)` reaches an address that has no account yet: the person gets an email whose link lands on the app's own auth redirect URL, signs in or registers, and accepts. The SDK handles the landing end to end (`?bb=action:invite,token:…` is read, parked for a day so the hosted sign-in's ten-minute return window cannot lose it, and resolved to an accept dialog). New on `useSaaSWorkspaces()`: `listInvitations`, `inviteMember`, `resendInvitation`, `revokeInvitation`, `myInvitations`, `acceptInvitation`, `declineInvitation`, `previewInvitation`. New hooks `useMyInvitations()` and `useWorkspaceInvitations(workspaceId)`; new `<PendingInvitations />` and `<WhenPendingInvitations>`; the workspace switcher's empty state shows the user's pending invitations, so an invite-only app's new user is no longer stuck on "No workspaces available". New SDK events `workspace:invitation-sent|accepted|declined|revoked`; new server-factory namespace `invitations` (`list`, `create`, `resend`, `revoke`); new UI switch `settings.users.invitations`.
|
|
102
|
+
- **An invitee does not have to verify their email before accepting.** Following the invitation link is the proof: the token was mailed to that address and to nowhere else, so `acceptInvitation(id, { token })` lets a just-registered account in and marks it verified, the same standard a magic link sets. The SDK's own landing passes the token for you. Accepting from the in-app list carries no token, so there a verified email is still required and a stolen invitation id proves nothing.
|
|
103
|
+
- Invitation errors carry `status`, a `code` and the server's fields (`currentUserCount`/`limit` on a seat limit, `retryAfterSeconds` on a resend cooldown, the scrambled `invitedEmail` on a mismatch). See `docs/ERROR_CODES.md`.
|
|
104
|
+
- **The workspace `mode` no longer restricts any UI.** Settings screens, the settings sidebar and the seat-pricing displays keyed off `mode === 'personal'`, but the mode is a preset: choosing it seeds the individual settings and an admin can then change any of them and leave the mode alone. Two rules read the fields instead. `canInviteMembers` decides whether the invite surfaces show - the members screen's invite form, Resend, and the invitee's own list of invitations - and nothing else: with it off, the members already in a workspace stay listed, their roles stay editable and Remove still works, pending rows sent earlier keep Revoke, and seat pricing shows wherever the plan prices seats. `maxWorkspacesPerUser` decides whether deleting a workspace can be offered at all. Nothing changes for an app that has not overridden the preset.
|
|
105
|
+
- `IWorkspaceInvitationPreview.joinable`: `false` when the organization has switched invitations off, so the landing says so before the invitee signs up rather than after. An older server omits it, which reads as joinable.
|
|
106
|
+
- `useSeatStatus(workspace, { pendingInvitations })` counts live pending invitations against the ceiling, the way the server does, so an invite form at the limit gives way to the limit message instead of an invite that can only answer 402.
|
|
107
|
+
- **A slug you never set up registers itself on its first send** (server release 22). `notification.send(workspaceId, 'deploy_failed', …)` with an event the console has not seen still delivers, and now also creates the event: it appears under Notifications > Custom, where the owner can rename it, attach an email template or switch it off, and members can mute it from their own settings. It counts toward the plan's custom-event limit like one made in the console; at that limit a new slug answers 403 `NOTIFICATION_EVENT_NOT_SET_UP` with a message written for the end user, and slugs the org already has keep sending. Event slugs keep `.`, `_` and `:`, so `comment_added` is stored as `comment_added` (older servers stored it as `commentadded`, which is why a slug sent from code never matched the event made in the console; those events are still found).
|
|
108
|
+
- The Resend button counts down to `resendAvailableAt`, the instant the server puts on every pending invitation, rather than to a copy of the cooldown kept here. The rule stays on the server beside the 429 that enforces it, so the button cannot unlock a second early; a server that does not send the field leaves the button enabled and the 429 answers.
|
|
109
|
+
|
|
110
|
+
### Changed
|
|
111
|
+
|
|
112
|
+
- **⚠️ Notification toggles are each member's own.** The Notifications settings screen used to change the workspace's settings for everyone, so one member turning an email off turned it off for the whole workspace. Every member now sets their own under "Your notifications", and a member who may edit workspace settings also sets "Workspace defaults": what everyone gets until they choose otherwise, plus a Required switch that stops members changing an event. Existing workspace settings become those defaults, so nothing changes for anyone until they choose. New `getMyNotificationPreferences` and `updateMyNotificationPreferences` on `WorkspaceApi` (`null` for a channel goes back to the default; a required event answers 409 `NOTIFICATION_PREFERENCE_REQUIRED`). Turning both channels off does not remove an event from the inbox: the toggles choose how you are interrupted, not whether it is recorded.
|
|
113
|
+
- **⚠️ The members screen's invite form now sends an invitation instead of adding an existing account.** The address no longer needs an account, and the row appears under the form as pending (with Resend and Revoke) until accepted. `addUser` is unchanged and still adds an existing account at once; code that calls it keeps its behaviour.
|
|
114
|
+
- **A pending invitation is billed as a seat** until it is accepted, declined, revoked or expires. The seat block on the members screen counts pending invitations and says so.
|
|
115
|
+
- Requires a server that ships workspace invitations; on an older server the invite form answers 404.
|
|
116
|
+
|
|
117
|
+
### Fixed
|
|
118
|
+
|
|
119
|
+
- **A member who is refused something is no longer signed out.** Server release 22 answers a signed-in member's forbidden request with 403 rather than 401; the SDK signs out only on 401, so a viewer opening the members or billing screens now sees "Access Restricted" and keeps their session. On an older server the 401 still ends the session, which is why those screens are not requested for roles that cannot read them.
|
|
120
|
+
- Seat-limit refusals carry `SEAT_LIMIT_REACHED` from every door on server release 22, including a direct `addUser` into a full workspace and an accept that finds the workspace full, so the seat block renders from the code rather than the status.
|
|
121
|
+
- **⚠️ `invalidateCreditBalance()`, `invalidateSubscription()` and `invalidateQuotaUsage()` imported from `@buildbase/sdk` now refresh the UI.** The root entry and `@buildbase/sdk/react` are separate bundles, and each had its own listener set, so a call from the root entry notified nobody: a credits badge kept its old number after every spend and purchase, with no error. The listener sets now live in a `Symbol.for` registry shared by every copy of the SDK in the page, as the tracking context already does.
|
|
122
|
+
- **`useCreditBalance(workspaceId)`, `useSubscription(workspaceId)` and `useAllQuotaUsage(workspaceId)` refetch on invalidation wherever they are used.** Only the copies inside the built-in context providers listened before, so a component using the hook directly, as the `useCreditBalance` docs example does, never refreshed. The providers no longer subscribe separately, so nothing refetches twice.
|
|
123
|
+
|
|
10
124
|
## [0.0.70] - 2026-09-21
|
|
11
125
|
|
|
12
126
|
### Fixed
|
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Also works server-side (Next.js API routes, Express, Hono) — see [Server-Side
|
|
|
21
21
|
- [Trial Gates](#-trial-gates)
|
|
22
22
|
- [Push Notifications](#-push-notifications)
|
|
23
23
|
- [Notifications](#-notifications)
|
|
24
|
+
- [Content from your server](#️-content-from-your-server)
|
|
24
25
|
- [Connected Agents](#-connected-agents)
|
|
25
26
|
- [Devices & Sessions](#-devices--sessions)
|
|
26
27
|
- [User Management](#-user-management)
|
|
@@ -106,6 +107,28 @@ Everything the SDK lets you configure, override, or extend — one row per knob,
|
|
|
106
107
|
npm install @buildbase/sdk react@^19.0.0 react-dom@^19.0.0
|
|
107
108
|
```
|
|
108
109
|
|
|
110
|
+
### Server compatibility
|
|
111
|
+
|
|
112
|
+
The SDK talks to a BuildBase server, and a few features need a server release
|
|
113
|
+
that has them. Everything else works on any server. Where a feature needs a
|
|
114
|
+
newer server than yours, the SDK degrades the way the table says rather than
|
|
115
|
+
breaking.
|
|
116
|
+
|
|
117
|
+
| Feature | SDK | Server | On an older server |
|
|
118
|
+
| ---------------------------------------------------------------------------------------- | ------ | ---------------- | --------------------------------------------------------------- |
|
|
119
|
+
| Workspace invitations (`inviteMember`, `<PendingInvitations />`, the landing) | 0.0.71 | release 22 | The invite form answers 404 |
|
|
120
|
+
| Notification inbox (`useNotifications`, `<NotificationBell />`, `<NotificationInbox />`) | 0.0.71 | release 22 | The inbox routes answer 404 |
|
|
121
|
+
| Live inbox updates | 0.0.71 | release 22 | The socket is refused; the one-minute poll carries on |
|
|
122
|
+
| Per-member notification preferences | 0.0.71 | release 22 | The screen edits the workspace defaults, as before |
|
|
123
|
+
| A slug not set up registers itself on first send | any | release 22 | The notification is sent unregistered, push and inbox only |
|
|
124
|
+
| Forbidden requests answer 403, so a viewer stays signed in | any | release 22 | A 401 ends the session |
|
|
125
|
+
| Content from your server (`@buildbase/sdk/server`: blogs, docs, rich content, lists) | next | any | |
|
|
126
|
+
| FAQ collections by slug, `testimonials.get(id)`, collection data without a `version` | next | content delivery | 404 (FAQ, testimonial) or 400 (collection data) |
|
|
127
|
+
| Content reads answered `304` from BuildBase's cache, with `stale-while-revalidate` | next | content delivery | Every read gets the full body; the client caches on its own TTL |
|
|
128
|
+
| Content webhooks (`handleContentWebhook`, `createNextWebhookHandler`) | next | content delivery | No events arrive; cached reads expire on their TTL |
|
|
129
|
+
| Tracking (`tracking` prop, `/tracking` entry) | 0.0.68 | any | |
|
|
130
|
+
| `signOut()` revokes the session on the server | 0.0.70 | any | |
|
|
131
|
+
|
|
109
132
|
## Quick Start
|
|
110
133
|
|
|
111
134
|
### 1. Import CSS
|
|
@@ -870,7 +893,7 @@ Send email and push notifications to workspace members. The system has three lay
|
|
|
870
893
|
|
|
871
894
|
- **System notifications** — Automatically triggered by platform events (workspace invite, payment failed, trial ending, etc.). Managed by the developer in the admin dashboard.
|
|
872
895
|
- **Custom notifications** — Defined by the developer, triggered from app code via SDK. Can be made user-configurable.
|
|
873
|
-
- **Ad-hoc notifications** — Send
|
|
896
|
+
- **Ad-hoc notifications** — Send with any event slug without setting it up first: pass `title`, `message`, and optionally `icon`, `image`, `url`. On server release 22 the first send registers the event, so it shows in the console and counts toward the plan's custom events. Email requires an event with a linked template.
|
|
874
897
|
|
|
875
898
|
### Sending Notifications (Server-Side)
|
|
876
899
|
|
|
@@ -910,7 +933,9 @@ await notification.send(workspaceId, 'deployment_success', userId, {
|
|
|
910
933
|
});
|
|
911
934
|
```
|
|
912
935
|
|
|
913
|
-
> **Note:**
|
|
936
|
+
> **Note:** An ad-hoc send delivers push and the inbox item. Email starts once an email template is attached to the event in the console.
|
|
937
|
+
>
|
|
938
|
+
> On server release 22 the first send registers the event: it appears under **Notifications > Custom** for the owner to configure and for members to mute, and it counts toward the plan's custom-event limit. At that limit a new slug answers `403 NOTIFICATION_EVENT_NOT_SET_UP` with a message meant for the end user; slugs the org already has keep sending. Slugs keep `.`, `_` and `:` (`comment_added` stays `comment_added`).
|
|
914
939
|
|
|
915
940
|
### Push Options
|
|
916
941
|
|
|
@@ -1124,6 +1149,34 @@ interface NotificationEvent {
|
|
|
1124
1149
|
}
|
|
1125
1150
|
```
|
|
1126
1151
|
|
|
1152
|
+
## 🗂️ Content from your server
|
|
1153
|
+
|
|
1154
|
+
Blogs, docs, FAQs, testimonials, rich content and collection data are read with an
|
|
1155
|
+
**org API token**, so they are read on your server or at build time, never in a browser.
|
|
1156
|
+
`@buildbase/sdk/server` reads them, caches every read, revalidates with ETags, and
|
|
1157
|
+
drops what changed when BuildBase sends a content webhook. It runs on Node 18+, Bun,
|
|
1158
|
+
Deno, Cloudflare Workers and edge runtimes, and refuses to load in a browser.
|
|
1159
|
+
|
|
1160
|
+
```ts
|
|
1161
|
+
import { createServerClient } from '@buildbase/sdk/server';
|
|
1162
|
+
|
|
1163
|
+
const content = createServerClient({
|
|
1164
|
+
serverUrl: 'https://api.console.buildbase.app',
|
|
1165
|
+
apiToken: process.env.BUILDBASE_API_TOKEN!, // <orgId>:<secret>, read-only content-reader role
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
const posts = await content.blogs.list({ limit: 20 });
|
|
1169
|
+
const refund = await content.richContent.get('refund-policy');
|
|
1170
|
+
const releases = await content.collections.data('release-notes');
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
- **A server (any framework):** the client above, plus `handleContentWebhook` (standard `Request`/`Response`) or `toNodeHandler(...)` for Express, Fastify and NestJS.
|
|
1174
|
+
- **No backend (static sites, single-page apps):** `npx buildbase content pull --out content.json` in CI; the token never leaves CI.
|
|
1175
|
+
- **Next.js:** `withNextCache()` and `createNextWebhookHandler()` from `@buildbase/sdk/server/next` hand caching to Next's tag cache.
|
|
1176
|
+
- **Another language:** the same HTTP rules (`If-None-Match`, bracket-encoded filters, HMAC-signed webhooks).
|
|
1177
|
+
|
|
1178
|
+
The full guide, with Express, Hono/Workers, Next and Python examples, is [`docs/SERVER-CONTENT.md`](docs/SERVER-CONTENT.md).
|
|
1179
|
+
|
|
1127
1180
|
## 🌐 Internationalization (i18n)
|
|
1128
1181
|
|
|
1129
1182
|
The SDK supports 8 locales with ICU MessageFormat for plurals, selects, and number formatting.
|
|
@@ -1407,7 +1460,14 @@ function WorkspaceManager() {
|
|
|
1407
1460
|
updateWorkspace, // Update workspace
|
|
1408
1461
|
deleteWorkspace, // Delete workspace
|
|
1409
1462
|
getUsers, // Get workspace users
|
|
1410
|
-
addUser, // Add
|
|
1463
|
+
addUser, // Add an EXISTING account to the workspace (404 when the address has none)
|
|
1464
|
+
inviteMember, // Invite an email address that may have no account yet
|
|
1465
|
+
listInvitations, // The workspace's invitations, pending first
|
|
1466
|
+
resendInvitation, // Send one again (five-minute cooldown)
|
|
1467
|
+
revokeInvitation, // Withdraw a pending invitation
|
|
1468
|
+
myInvitations, // The signed-in user's own pending invitations
|
|
1469
|
+
acceptInvitation, // Accept one (joins the workspace)
|
|
1470
|
+
declineInvitation, // Decline one
|
|
1411
1471
|
removeUser, // Remove user from workspace
|
|
1412
1472
|
updateUser, // Update user role/permissions
|
|
1413
1473
|
getFeatures, // Get all available features
|
|
@@ -1423,15 +1483,92 @@ function WorkspaceManager() {
|
|
|
1423
1483
|
await createWorkspace('My Workspace', 'https://example.com/logo.png');
|
|
1424
1484
|
};
|
|
1425
1485
|
|
|
1426
|
-
// Example: Add
|
|
1486
|
+
// Example: Add an existing account to the workspace
|
|
1427
1487
|
const handleAddUser = async () => {
|
|
1428
1488
|
await addUser(currentWorkspace._id, 'user@example.com', 'member');
|
|
1429
1489
|
};
|
|
1430
1490
|
|
|
1491
|
+
// Example: Invite someone who may not have an account yet
|
|
1492
|
+
const handleInvite = async () => {
|
|
1493
|
+
await inviteMember(currentWorkspace._id, 'teammate@example.com', 'editor');
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1431
1496
|
return <div>{/* Your workspace UI */}</div>;
|
|
1432
1497
|
}
|
|
1433
1498
|
```
|
|
1434
1499
|
|
|
1500
|
+
### Workspace invitations
|
|
1501
|
+
|
|
1502
|
+
`inviteMember` reaches an address that has no account yet. The person gets an
|
|
1503
|
+
email whose link lands on your app's auth redirect URL (the same one the app
|
|
1504
|
+
returns to after login), signs in or registers, and accepts. A pending
|
|
1505
|
+
invitation holds a seat until it is accepted, declined, revoked or expires
|
|
1506
|
+
(after 14 days). The members settings screen sends invitations and lists the
|
|
1507
|
+
pending ones with Resend and Revoke; nothing else is needed for the admin's
|
|
1508
|
+
side.
|
|
1509
|
+
|
|
1510
|
+
The invitee's side is handled for you too: the SDK reads the link, shows what
|
|
1511
|
+
it resolves to, offers sign-in when needed, and accepts on the user's behalf.
|
|
1512
|
+
A brand-new invitee is not asked to verify their email first, because arriving
|
|
1513
|
+
with the link already proves the address.
|
|
1514
|
+
An invite-only app (workspace settings with `autoCreateFirst: false`) shows the
|
|
1515
|
+
user's pending invitations in the workspace switcher's empty state instead of
|
|
1516
|
+
"No workspaces available". To place them on your own screen:
|
|
1517
|
+
|
|
1518
|
+
```tsx
|
|
1519
|
+
import { PendingInvitations, WhenPendingInvitations } from '@buildbase/sdk/react';
|
|
1520
|
+
|
|
1521
|
+
function NoWorkspaceScreen() {
|
|
1522
|
+
return (
|
|
1523
|
+
<WhenPendingInvitations fallbackComponent={<p>Ask your team for an invitation.</p>}>
|
|
1524
|
+
<PendingInvitations onAccepted={ws => console.log('joined', ws.name)} />
|
|
1525
|
+
</WhenPendingInvitations>
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
```
|
|
1529
|
+
|
|
1530
|
+
`useMyInvitations()` and `useWorkspaceInvitations(workspaceId)` are the hooks
|
|
1531
|
+
behind those. Errors from every invitation call carry `status` and a `code`
|
|
1532
|
+
(`SEAT_LIMIT_REACHED` with `currentUserCount` and `limit`,
|
|
1533
|
+
`INVITATION_RATE_LIMITED` with `retryAfterSeconds`,
|
|
1534
|
+
`INVITATION_EMAIL_MISMATCH` with the scrambled `invitedEmail`,
|
|
1535
|
+
`EMAIL_NOT_VERIFIED`, `INVITATION_EXPIRED`, `INVITATION_ALREADY_HANDLED`); see
|
|
1536
|
+
`docs/ERROR_CODES.md`.
|
|
1537
|
+
|
|
1538
|
+
### Notification inbox
|
|
1539
|
+
|
|
1540
|
+
Everything your app sends a user, by email, push or both, is kept for them as
|
|
1541
|
+
an inbox they can come back to. One notification is one item however it was
|
|
1542
|
+
delivered, and the item says what each channel did (email opened or clicked,
|
|
1543
|
+
push clicked). Opening an email does not mark the item read; opening it in the
|
|
1544
|
+
inbox or clicking its email or push link does. Sign-in codes, magic links and
|
|
1545
|
+
password resets are never in it.
|
|
1546
|
+
|
|
1547
|
+
```tsx
|
|
1548
|
+
import { NotificationBell, NotificationInbox } from '@buildbase/sdk/react';
|
|
1549
|
+
|
|
1550
|
+
function Header() {
|
|
1551
|
+
// Unread count on a bell, the inbox in a panel. Opening it marks the items
|
|
1552
|
+
// seen, which clears the highlight without reading anything.
|
|
1553
|
+
return <NotificationBell />;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function NotificationsPage() {
|
|
1557
|
+
// A full page, for the current workspace only. Omit workspaceId for every
|
|
1558
|
+
// workspace, or pass 'none' for account notices that belong to none.
|
|
1559
|
+
return <NotificationInbox workspaceId="current" />;
|
|
1560
|
+
}
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
`useNotifications(options)` is the hook behind both, for your own UI: items,
|
|
1564
|
+
`counts.unread` and `counts.unseen`, `markRead`, `markUnread`, `archive`,
|
|
1565
|
+
`markAllRead`, `markSeen` and `loadMore`. Every consumer of the same scope
|
|
1566
|
+
shares one list, so a bell and an inbox page make one request and a read in
|
|
1567
|
+
either updates both. New items are picked up by polling the count every
|
|
1568
|
+
minute while the tab is visible (`pollIntervalMs`, 0 to turn it off). A user
|
|
1569
|
+
only ever sees their own notifications; the whole workspace's history is in
|
|
1570
|
+
the console.
|
|
1571
|
+
|
|
1435
1572
|
## 💰 Public Pricing (No Login)
|
|
1436
1573
|
|
|
1437
1574
|
Display subscription plans and pricing on public pages (e.g. marketing site, pricing page) without requiring users to log in.
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{mkdir as e,writeFile as t}from"node:fs/promises";import{resolve as o,dirname as n}from"node:path";const s=["blogs","docs","faqs","testimonials","richContent","collections"];async function r(e){const t=[];for(let o=1;o<=1e4;o++){const n=await e(o);t.push(...n.docs);if(!(n.hasNextPage??(void 0!==n.totalPages&&o<n.totalPages))||0===n.docs.length)break}return t}const a=`Usage: buildbase content pull --out <file> [options]\n\nFetch published content once and write it to a JSON file (run it in CI or at build time).\n\nOptions:\n --out <file> where to write the JSON (required)\n --modules <list> comma-separated: ${s.join(",")} (default: all)\n --collections <slugs> collection slugs to pull records for (default: every live collection)\n --server-url <url> overrides BUILDBASE_SERVER_URL\n --page-size <n> page size while paging lists (default 100)\n\nEnvironment:\n BUILDBASE_API_TOKEN org API token, <orgId>:<secret> (required)\n BUILDBASE_SERVER_URL e.g. https://api.console.buildbase.app`;function i(e){return(e??"").split(",").map(e=>e.trim()).filter(Boolean)}function l(e={}){const t=Math.max(1,e.maxEntries??1e3),o=new Map,n=new Map,s=e=>{const t=o.get(e);t&&(o.delete(e),((e,t)=>{for(const o of t.tags){const t=n.get(o);t&&(t.delete(e),0===t.size&&n.delete(o))}})(e,t.entry))};return{async get(e){const t=o.get(e);if(t){if(!(t.expiresAt<=Date.now()))return o.delete(e),o.set(e,t),t.entry;s(e)}},async set(e,r,a){s(e),o.set(e,{entry:r,expiresAt:Date.now()+1e3*a});for(const t of r.tags){let o=n.get(t);o||n.set(t,o=new Set),o.add(e)}for(;o.size>t;){const e=o.keys().next().value;if(void 0===e)break;s(e)}},async delete(e){s(e)},async deleteByTag(e){const t=n.get(e);if(t)for(const e of[...t])s(e)}}}function c(e,t,o){if(null!=o)if(Array.isArray(o))o.forEach((o,n)=>c(e,`${t}[${n}]`,o));else if("object"!=typeof o)e.append(t,String(o));else for(const[n,s]of Object.entries(o))c(e,`${t}[${n}]`,s)}const u="buildbase:all",d=e=>`buildbase:${e}`,f=(e,t)=>`buildbase:${e}:${t}`,p=(e,t)=>`buildbase:${e}:id:${t}`,h=e=>`buildbase:collection:${e}`;new Set(["blog.published","blog.updated","blog.unpublished","blog.deleted","doc.published","doc.updated","doc.unpublished","doc.deleted","faq.updated","faq_collection.updated","testimonial.updated","testimonial.deleted","rich_content.updated","rich_content.deleted","collection.version_published","collection.record_created","collection.record_updated","collection.record_deleted"]);class g extends Error{constructor(e,t,o,n){super(e),this.status=t,this.url=o,this.body=n,this.name="BuildBaseContentError"}}const m=/^[0-9a-f]{24}$/i,w=new WeakMap;function b(e){return Array.isArray(e)?e:e&&"object"==typeof e&&Array.isArray(e.docs)?e.docs:[]}function y(e){const t=e.split("/").filter(Boolean);return t[t.length-1]??e}function v(e={},t={}){const o={...t,...e.filter};return{$page:e.page,$limit:e.limit,filter:Object.keys(o).length>0?o:void 0,sort:e.sort}}function $(e){if("undefined"!=typeof window&&"undefined"!=typeof document)throw new Error("@buildbase/sdk/server holds your org API token and must only run on a server. Import it from server code (an API route, a server component, a build script), never from code that ships to the browser.");if(!e||"string"!=typeof e.serverUrl||!e.serverUrl)throw new Error("createServerClient: `serverUrl` is required");if("string"!=typeof e.apiToken||!e.apiToken.includes(":"))throw new Error("createServerClient: `apiToken` must be an org API token of the form <orgId>:<secret> (console, Settings, API tokens)");const t=e.serverUrl.replace(/\/+$/,""),o=e.apiToken,n=o.slice(0,o.indexOf(":")),s=!1===e.cache?null:e.cache??l(),r=e.fetch??((e,t)=>globalThis.fetch(e,t)),a=e.timeoutMs??1e4,i=e.defaultTtlSeconds??60,$=Math.max(e.retainSeconds??86400,1),q=e.staleOnError??!0,U=new Map,E=(e,t)=>(e&&"object"==typeof e&&w.set(e,t),e);async function I(n,s,i){const l="function"==typeof e.requestInit?e.requestInit({path:n,tags:s}):e.requestInit??{},c=new Headers(l.headers);c.set("Authorization",`Bearer ${o}`),c.set("Accept","application/json"),i&&c.set("If-None-Match",i);const u=new AbortController,d=setTimeout(()=>u.abort(),a);try{return await r(`${t}${n}`,{...l,method:"GET",headers:c,signal:l.signal??u.signal})}finally{clearTimeout(d)}}async function _(e){const o=`${e.path}${function(e){if(!e)return"";const t=new URLSearchParams;for(const o of Object.keys(e).sort())c(t,o,e[o]);t.sort();const o=t.toString();return o?`?${o}`:""}(e.query)}`,r=`${n}:${o}`,a=s?await s.get(r):void 0,l=Date.now();if(a&&a.freshUntil>l)return E(a.data,a.tags);const d=()=>{const n=async function(){let n;try{n=await I(o,e.tags,a?.etag),304!==n.status||a||(n=await I(o,e.tags))}catch(e){if(q&&a)return E(a.data,a.tags);throw new g(`BuildBase request failed: ${e instanceof Error?e.message:String(e)}`,0,`${t}${o}`)}const l=function(e,t){const o=e.headers.get("cache-control");if(!o)return t;const n=o.toLowerCase().split(",").map(e=>e.trim());if(n.includes("no-store"))return null;if(n.includes("no-cache"))return 0;const s=n.find(e=>e.startsWith("max-age="));if(s){const e=Number(s.slice(8));if(Number.isFinite(e)&&e>=0)return e}return t}(n,i),c=function(e){const t=e.headers.get("cache-control");if(!t)return 0;const o=t.toLowerCase().split(",").map(e=>e.trim()).find(e=>e.startsWith("stale-while-revalidate=")),n=o?Number(o.slice(23)):0;return Number.isFinite(n)&&n>0?n:0}(n);if(304===n.status&&a){const e={...a,etag:n.headers.get("etag")??a.etag,freshUntil:Date.now()+1e3*(l??0),staleUntil:c>0?Date.now()+1e3*((l??0)+c):void 0};return s&&await s.set(r,e,$),E(a.data,a.tags)}if(404===n.status&&e.nullOnNotFound)return null;if(!n.ok){const s=await n.text().catch(()=>"");let r=s;try{r=s?JSON.parse(s):void 0}catch{}if(q&&a&&401!==n.status&&403!==n.status)return E(a.data,a.tags);const i=r?.message??`BuildBase responded ${n.status} for ${e.path}`;throw new g(i,n.status,`${t}${o}`,r)}const d=(f=await n.json().catch(()=>null),f&&"object"==typeof f&&!Array.isArray(f)&&"data"in f&&"success"in f&&!("docs"in f)?f.data:f);var f;if(null==d&&e.nullOnNotFound)return null;const p=e.transform?e.transform(d):d,h=[...new Set([...e.tags,...e.tagsFrom?e.tagsFrom(p):[],u])];s&&null!==l&&await s.set(r,{data:p,etag:n.headers.get("etag")??void 0,freshUntil:Date.now()+1e3*l,staleUntil:c>0?Date.now()+1e3*(l+c):void 0,tags:h},Math.max($,l));return E(p,h)}();return U.set(r,n),n.then(()=>U.delete(r),()=>U.delete(r)),n};if(a&&void 0!==a.staleUntil&&a.staleUntil>l)return U.has(r)||d().catch(()=>{}),E(a.data,a.tags);const f=U.get(r);return f||d()}const A=e=>t=>{const o=[];return t?._id&&o.push(p(e,t._id)),t?.slug&&o.push(f(e,t.slug)),o};function S(e,t){const o=d(t);return{list:(t={})=>_({path:`/api/${e}`,query:v(t,t.includeDrafts?{}:{published:!0}),tags:[o]}),get:o=>_({path:`/api/${e}/${encodeURIComponent(o)}`,tags:[p(t,o)],tagsFrom:A(t),nullOnNotFound:!0}),byPath:(o,n={})=>_({path:`/api/${e}/path`,query:{path:o,draftPreview:!!n.draftPreview||void 0},tags:[f(t,y(o))],tagsFrom:A(t),nullOnNotFound:!0}),tree:(t={})=>_({path:`/api/${e}/folders/tree`,query:{folder:t.folder},tags:[o]}),tags:(t={})=>_({path:`/api/${e}/tags`,query:v(t),tags:[o]}),authors:(t={})=>_({path:`/api/${e}/authors`,query:v(t),tags:[o]})}}const B=d("faq_collection");async function O(e){const t=m.test(e);return _({path:t?`/api/faqs/collections/${encodeURIComponent(e)}`:`/api/faqs/collections/slug/${encodeURIComponent(e)}`,tags:[t?p("faq_collection",e):f("faq_collection",e)],tagsFrom:A("faq_collection"),nullOnNotFound:!0})}return{blogs:S("blogs","blog"),docs:S("docs","doc"),faqs:{collections:(e={})=>_({path:"/api/faqs/collections",query:v(e,{archived:!1}),tags:[B]}),collection:O,async questions(e){const t=m.test(e);if(t&&!await O(e))return[];return await _({path:t?`/api/faqs/collections/${encodeURIComponent(e)}/faqs`:`/api/faqs/collections/slug/${encodeURIComponent(e)}/faqs`,query:{pagination:!1},tags:[d("faq"),t?p("faq_collection",e):f("faq_collection",e)],transform:e=>b(e).filter(e=>!e.archived),nullOnNotFound:!0})??[]}},testimonials:{list:(e={})=>_({path:"/api/testimonials",query:v(e,e.includeUnpublished?{archived:!1}:{published:!0,archived:!1}),tags:[d("testimonial")]}),get:e=>_({path:`/api/testimonials/${encodeURIComponent(e)}`,tags:[p("testimonial",e)],nullOnNotFound:!0})},richContent:{get:e=>_({path:`/api/rich-content/slug/${encodeURIComponent(e)}`,tags:[f("rich_content",e)],tagsFrom:A("rich_content"),nullOnNotFound:!0}),list:(e={})=>_({path:"/api/rich-content",query:v(e,{archived:!1}),tags:[d("rich_content")]})},collections:{list:(e={})=>_({path:"/api/collections",query:v(e,{archived:!1}),tags:[d("collection")]}),data(e,t={}){const o=void 0===t.version;return _({path:`/api/collections/data/${encodeURIComponent(e)}`,query:o?{latest:!0}:{version:t.version},tags:[h(e)],transform:e=>b(e).filter(e=>!e.archived)})}},async revalidate(e){s&&await s.deleteByTag(e)},async revalidateAll(){s&&await s.deleteByTag(u)},cache:s}}(async function(l){let c;try{c=function(e){const[t,o,...n]=e;if(!t||"--help"===t||"-h"===t||"help"===t)return{command:"help"};if("content"!==t||"pull"!==o)throw new Error(`Unknown command: ${[t,o].filter(Boolean).join(" ")}`);const r=new Map;for(let e=0;e<n.length;e++){const t=n[e];if("--help"===t||"-h"===t)return{command:"help"};if(!t.startsWith("--"))throw new Error(`Unexpected argument: ${t}`);const o=t.indexOf("=");if(o>-1)r.set(t.slice(2,o),t.slice(o+1));else{const o=n[e+1];if(void 0===o||o.startsWith("--"))throw new Error(`Missing value for ${t}`);r.set(t.slice(2),o),e++}}const a=new Set(["out","modules","collections","server-url","page-size"]);for(const e of r.keys())if(!a.has(e))throw new Error(`Unknown option: --${e}`);const l=r.get("out");if(!l)throw new Error("--out <file> is required");let c,u;if(r.has("modules")){const e=i(r.get("modules")),t=e.filter(e=>!s.includes(e));if(t.length)throw new Error(`Unknown module(s): ${t.join(", ")}. Use: ${s.join(", ")}`);c=e}if(r.has("page-size")&&(u=Number(r.get("page-size")),!Number.isInteger(u)||u<1))throw new Error("--page-size must be a positive integer");return{command:"content-pull",out:l,modules:c,collections:r.has("collections")?i(r.get("collections")):void 0,serverUrl:r.get("server-url"),pageSize:u}}(l)}catch(e){return console.error(`buildbase: ${e.message}\n\n${a}`),2}if("help"===c.command)return console.log(a),0;const u=process.env.BUILDBASE_API_TOKEN,d=c.serverUrl??process.env.BUILDBASE_SERVER_URL??process.env.NEXT_PUBLIC_BUILDBASE_SERVER_URL;if(!u)return console.error("buildbase: set BUILDBASE_API_TOKEN to an org API token (<orgId>:<secret>)"),2;if(!d)return console.error("buildbase: set BUILDBASE_SERVER_URL (for example https://api.console.buildbase.app)"),2;try{const a=$({serverUrl:d,apiToken:u,cache:!1}),i=await async function(e,t={}){const o=new Set(t.modules??s),n=t.pageSize??100,a={generatedAt:(new Date).toISOString()};if(o.has("blogs")&&(a.blogs=await r(t=>e.blogs.list({page:t,limit:n}))),o.has("docs")&&(a.docs=await r(t=>e.docs.list({page:t,limit:n}))),o.has("faqs")){const t=await r(t=>e.faqs.collections({page:t,limit:n}));a.faqs=[];for(const o of t)a.faqs.push({...o,questions:await e.faqs.questions(o._id)})}if(o.has("testimonials")&&(a.testimonials=await r(t=>e.testimonials.list({page:t,limit:n}))),o.has("richContent")&&(a.richContent=await r(t=>e.richContent.list({page:t,limit:n}))),o.has("collections")){const o=t.collections??(await r(t=>e.collections.list({page:t,limit:n}))).map(e=>e.slug);a.collections={};for(const t of o)a.collections[t]=await e.collections.data(t)}return a}(a,{modules:c.modules,collections:c.collections,pageSize:c.pageSize}),l=o(process.cwd(),c.out);await e(n(l),{recursive:!0}),await t(l,`${JSON.stringify(i,null,2)}\n`,"utf8");const f=Object.entries(i).filter(([e])=>"generatedAt"!==e).map(([e,t])=>`${e}: ${Array.isArray(t)?t.length:Object.keys(t).length}`).join(", ");return console.log(`buildbase: wrote ${c.out} (${f})`),0}catch(e){return e instanceof g&&(401===e.status||403===e.status)?(console.error(`buildbase: the API token was refused (${e.status}). Check BUILDBASE_API_TOKEN and that its role can read content.`),1):(console.error(`buildbase: ${e.message}`),1)}})(process.argv.slice(2)).then(e=>process.exit(e),e=>{console.error(e),process.exit(1)});
|