Decisions
Each entry: what was decided, why, and what it costs. Newest last. Research
notes behind these are in docs/research/.
1. Batteries for Void, as plugins
Section titled “1. Batteries for Void, as plugins”An app is a Void app (void 1.1.11, Cloudflare’s Vite plugin and CLI).
Void owns dev, build, routing, middleware, auth, the database workflow,
env and secrets, crons, queues, Durable Objects and deploys. Flare makes
it batteries-included: optional plugins for what a business app needs and
Void lacks, plus a CLI that creates an app and adds them. Every Recranet
app gets roles, auditing, jobs and the rest the same way, so the apps’
code looks the same. There is no server factory and no runtime
registry. starters/api is the reference: a tenant-scoped tasks API
with roles in about 250 lines of app code. The research is in
research/void-spike.md.
Why: one way to build every app, without a second framework. Each
plugin builds on what Void and Better Auth already offer and never wraps or
replaces them. Flare sits on the packages we would pick anyway (Hono, Drizzle,
D1, Better Auth, @cloudflare/vite-plugin, cf, @cloudflare/config).
A framework that rewires them again is code to keep in step with Void for
no gain.
What a plugin is. Void has no third-party plugin API, so a plugin touches the app only through seams the app already owns, each a line or a file:
| Seam | How the app uses it |
|---|---|
| Code | import { requireMember } from "@recranet/flare/access" |
| Tables | export * from "@recranet/flare/audit/schema" in db/schema.ts |
| Auth | a Better Auth plugin or option in auth.ts |
| Routes | a one-line route file that re-exports the plugin’s handler |
| Middleware | a one-line file in middleware/ |
| Bindings | declared in void.config.ts, read from c.env |
flare add <plugin> writes those lines, the way shadcn add does.
Generated code belongs to the app; library code stays in the package. One
@recranet/flare package with a subpath per plugin, split later if needed. A
plugin imports nothing from another plugin unless it says so (admin
needs access), and nothing from Void’s internals.
Order: access (roles on Better Auth’s access control,
requireMember), then resource (a generator: table,
schemas and two route files), audit, openapi. Later: admin,
flags, i18n, search, csv. When needed: api-keys, then mcp.
Not plugins, because Void or Better Auth does it: the HTTP app,
validation, the database and migrations, sessions and auth middleware,
jobs (crons/, queues/), mail (void/email), storage, AI, caching,
realtime (*.ws.ts routes, durable-objects/), observability, security
headers (routing.headers) and deploys.
Left out, to stay close to the platform:
- Preview stages. Void has no named environments: one project config per target, or Workers version preview URLs.
- A baked-in app URL. Set a custom domain, or read the origin from the request.
- Custom auth table and column names. Plugins assume Better Auth’s defaults.
- Bundler aliases. Void turns on
nodejs_compatand importsnode:cryptoitself. @cloudflare/vitest-pluginnext tovoidPlugin(): it can’t load there. App tests run on the built Worker through Wrangler’s test harness. Plugin unit tests, which don’t need Void, still run in workerd through the Vitest plugin.- A resource abstraction that hides the routes. A resource is about 130 lines of plain routes. If that gets tiresome, a list-query helper (cursor, search, filters) can become a library without hiding them.
- An Angular client. UI comes from Void’s pages; Recranet’s Verano design system is being made framework-agnostic (shared CSS classes and a Vue package) in its own repository.
Cost:
- Void is young (1.1.x) and pins its own
@cloudflare/vite-plugin1.x,cf,miniflareand Wrangler. Every Void release moves those versions for us, and its install brings packages a Cloudflare-only app never runs (pg,mysql2,better-sqlite3, the Void Platform). - About +21 KiB gzip in the Worker, mostly Void Platform, ISR and edge-rule code an app doesn’t use.
- Queue consumers get no ExecutionContext, and Void configures no dead-letter queue.
void deployhas no dry run.- Better Auth only lets a verified email accept an invitation, so an app
with organizations needs verification mail (
void/email). - Import Drizzle through
void/dbandvoid/schema-d1, notdrizzle-orm. Void installs its own copy, and the two copies’ types clash.
2. Monorepo with pnpm, exact pins, TypeScript 7
Section titled “2. Monorepo with pnpm, exact pins, TypeScript 7”pnpm workspaces (packages/*, starters/*, tools/*) with saveExact.
TypeScript 7.0.2 (the native compiler) everywhere but tools/lint.
allowBuilds lists the only packages that may run install scripts
(esbuild, workerd); Void’s better-sqlite3 is denied, since local D1 runs
on miniflare.
3. Passwords: Better Auth’s own hashing
Section titled “3. Passwords: Better Auth’s own hashing”Apps hash passwords the way Better Auth does by default. access used to
ship a PBKDF2 hasher that also read Recranet Studio’s pbkdf2-sha256
format and, from a subpath, legacy Symfony bcrypt. Those formats are
Studio’s, so Studio keeps them in its own code and passes them as
emailAndPassword.password in its auth.ts.
Cost: Better Auth’s scrypt takes ~45 ms CPU per hash, over the Workers
free plan’s 10 ms. An app on the free plan passes its own
emailAndPassword.password.
4. Tenancy comes from the session, never from the input
Section titled “4. Tenancy comes from the session, never from the input”Row-level isolation in D1. Inserts set organizationId and userId from
the session’s active organization; every query adds the organization
condition. Another organization’s record answers 404, not 403, so ids
don’t leak. requireMember(c, permission) in @recranet/flare/access checks
the member row and the role on every request. Pagination is keyset on
(sort column, id), never OFFSET.
5. Design for D1’s limits
Section titled “5. Design for D1’s limits”D1 refuses statements with more than 100 bound parameters, and allows 50
queries per invocation on the free plan (1000 on Paid). Multi-row inserts
are sized by column count, large imports run as queued slices, and related
writes go in one db.batch(): one round trip, committed together.
6. The name
Section titled “6. The name”The repository is recranet/flare. The packages are scoped:
@recranet/flare (the plugins, plus a flare bin so
pnpm dlx @recranet/flare create my-app works) and
@recranet/flare-cli (the command).
Why: short, and imports read well (@recranet/flare/access). The scope
marks the packages as Recranet’s, not Void’s or Cloudflare’s own, and keeps
them clear of the unscoped flare package on npm. It replaced
void-plugins, which said what it was but read as dull, before anything was
published.
7. ESLint in CI, from its own workspace package
Section titled “7. ESLint in CI, from its own workspace package”pnpm lint runs ESLint 10 with @eslint/js and typescript-eslint’s
recommended rules over the whole repository.
- Its own package (
tools/lint): typescript-eslint needs TypeScript’s JavaScript API (below 6.1), and the repository is on 7. The package pins TypeScript 6.0.3 for ESLint alone. - No type-aware rules:
tscalready type-checks every package. - Rule choices:
no-explicit-anyis off (generic code over Hono and Better Auth types);_-prefixed names may go unused.
8. MCP: Better Auth’s OAuth provider with JWTs, and our own handler
Section titled “8. MCP: Better Auth’s OAuth provider with JWTs, and our own handler”@recranet/flare/mcp follows Recranet Studio’s server: @better-auth/mcp (the
OAuth 2.1 provider) with open dynamic registration for the Claude.ai and
OpenAI connectors, JWT access tokens bound to <origin>/mcp, a
resource_metadata challenge on every 401, and a stateless /mcp on MCP
2026-07-28 plus the 2025 handshake. Details in
plugins/mcp.md.
- JWTs, verified in process. The keys come from Better Auth’s
/jwkshandler called directly, never over HTTP: a Worker fetching its own hostname fails deployed. (The pre-Void core used opaque tokens to avoid that fetch.) - No MCP SDK.
@modelcontextprotocol/server2.0.0 adds ~87 KiB gzip and ~16 ms of startup to a Worker (measured: 41 → 128 KiB, 13 → 29 ms). The ~270-line handler does what the apps need; revisit when one needs subscriptions, input-required rounds, progress or prompts. - Seams.
mcpAuth(request, ...)inauth.ts, a one-lineroutes/mcp.ts, and a one-linemiddleware/00.well-known.ts: Void doesn’t routeroutes/.well-known/*(its route scan skips dot directories).mcpAuthrecords Better Auth’s context per request in a plugininit, since Void keeps the instance to itself. - One organization per token, the session’s active one at consent (the
provider’s consent reference, in an
organization_idclaim). Role and membership are read on every tool call; writes are refused for a role that can’t change anything.
Cost: the CRM’s Worker grows from 483 to 576 KiB gzip and from ~51 to
~57 ms startup, mostly the provider and the JWT plugin. Seven OAuth tables.
The provider only loads on /api/auth/*, /.well-known/* and /mcp, so
an expired session deleted elsewhere doesn’t revoke its OAuth tokens; they
stay valid until they expire.
9. Feature flags in D1, not KV
Section titled “9. Feature flags in D1, not KV”@recranet/flare/flags keeps overrides in a feature_flag table: one row per
flag and level (everyone, an organization, a user, a user in an
organization), keyed on (organization, user, flag) with '' for “any”.
Flags and their defaults are declared in code. Details in
plugins/flags.md.
Why D1:
- A switch is seen at once. KV is eventually consistent: a write can take 60 seconds or more to reach other locations, and a cached read hides it as long. A toggle that seems not to work gets clicked again.
- It’s already there. Every Flare app has D1; KV is one more binding to declare and create.
- One query reads every level.
organization_id IN ('', ?) AND user_id IN ('', ?)on the primary key returns every flag for a request. In KV that is four reads, or one key per organization holding every user’s overrides. - It joins the change’s batch. A switch commits in one
db.batch()with its audit row, and an organization’s overrides are a plain query. - Writes are cheap. KV allows one write per second per key, and 1,000 writes a day on the free plan.
Cost: one D1 query per request that reads flags (indexed, a few rows),
where KV would be a local cache hit after the first read. An app that
reads flags on every request at high volume can cache get() itself.
Booleans only: no percentages, variants or rules.
10. API keys: Better Auth’s plugin, keys that act as their member
Section titled “10. API keys: Better Auth’s plugin, keys that act as their member”@recranet/flare/api-keys sets up Better Auth’s API key plugin for keys that
belong to a member and one organization, and act as that member on the JSON
API. Details in plugins/api-keys.md.
- User-owned, organization in the metadata. Better Auth’s
organization-owned keys sign nobody in, so they couldn’t pass
requireMemberor name an actor in the audit log. A key that acts as its member keeps one permission check for people and scripts, and removing a member removes their keys’ access. - The organization from the session. A hook on
/api-key/createwrites the session’s active organization into the key’s metadata, over the body (decision 4);/api-key/updatefrom a browser can’t change metadata. - Limits narrow, never widen. A key may carry actions per resource;
withApiKeys(access)checks them after the role. - The JSON API only. A key on a page or on
/api/auth/*is a 403, so a key can’t make more keys or invite members.
Cost: one more query per key request (the key’s metadata), on top of
Better Auth’s lookup of the key and its user. The plugin’s own rate limit is
off, as it writes the key’s row per request; a key’s request has no session
cookie, so a rate limit counted by session doesn’t see it (count by user).
11. Search: FTS5 over a documents table, written in the change’s batch
Section titled “11. Search: FTS5 over a documents table, written in the change’s batch”@recranet/flare/search keeps a document per record (title, subtitle, body) in a
regular table, search_documents, and an external-content FTS5 index over
it that three triggers keep in step. The app writes the document with
search.put in the same db.batch() as the change, as it does an audit
row. defineList is the list-query helper decision 1 left room for: it
returns where, orderBy and page(), and the route still writes its
select. Details in plugins/search.md.
- Documents, not triggers on the app’s tables. What a record is found
by is TypeScript (a contact’s full name, a deal’s stage), so it can’t
drift from what the pages show, and adding search needs no SQL per
table. A write that skips
putis missed untilsearch.reindex; the CRM runs it nightly. - A regular table under the index. D1’s export refuses a virtual
table, and
void db exportdumps FTS5’s shadow tables, which don’t load back. With the text insearch_documents, the index is disposable: drop it (and its triggers) to export, recreate and rebuild after. - The tenant inside the index. The organization is an indexed column,
hex-encoded to one token, and every query requires it, so FTS5 only
ranks the tenant’s matches;
organization_idis checked again on the join. - No query syntax from users. Input becomes quoted words, all required,
the last a prefix. No
OR, phrases or column filters, and no FTS5 syntax errors to catch.
Cost: an FTS migration appended by hand, since Drizzle can’t declare
one. Word-prefix matching replaces the CRM’s LIKE %q%: mith no longer
finds Smith. Each write carries one more statement, plus the trigger’s
index writes.
12. CSV: a column list on the resource’s Zod schema, and inserts sized for D1
Section titled “12. CSV: a column list on the resource’s Zod schema, and inserts sized for D1”@recranet/flare/csv reads and writes a resource’s CSV from a list of columns
(key, header, aliases, required) and the schema the API already validates
with. Details in plugins/csv.md.
- The API’s schema per row. An import accepts what the API accepts, with the same messages, prefixed by the column’s header. Errors are by spreadsheet row and never stop the other rows; a missing required column or too many rows refuses the file.
- Excel’s files as they come. Semicolons (Excel in Dutch and most of Europe) and tabs are found from the header line, a byte order mark is dropped, and exports start with one so Excel reads UTF-8.
- Streams both ways. Exports pull a page per chunk the client reads;
readStreamyields slices for a queue. The CRM still reads its 1 MB imports whole. - Inserts sized from the table.
inserts()counts the table’s columns to fit 100 parameters, so a 500-row import is a few dozen statements, not one per row. The CRM’screateMany/updateManywrite records, audit rows and search documents that way.
Cost: batches don’t commit together, so a failed import can stop part-way. The CRM’s import drops from 1000 to 500 rows, to stay under the Paid plan’s 1000 queries per invocation when every row is an update.
13. i18n on Intl, with a cookie override
Section titled “13. i18n on Intl, with a cookie override”@recranet/flare/i18n is about 200 lines over Intl: messages per locale in
code, ICU-style {arg}, plural and select, Intl.PluralRules for the
categories and Intl.NumberFormat, DateTimeFormat, RelativeTimeFormat
and ListFormat for the values. The locale is a locale cookie the user
menu sets, else Accept-Language, else the source locale. Details in
plugins/i18n.md.
Why:
- Intl is in workerd and every browser. Plural rules and formats for every locale come with the runtime; a library would ship its own or wrap these.
- No library. vue-i18n is Vue-only (server messages need it too) and i18next and FormatJS’s message parser add tens of KiB to the Worker and the page for features these apps don’t use (namespaces, lazy backends, the full ICU grammar).
- One module for both sides. It imports nothing, so a loader, an error body and a Vue page translate with the same table and agree on SSR.
- The browser’s language by default, the user’s when they pick one. A cookie, not a column on the user: no migration, it works signed out, and the server reads it on the first request. No locale in the URL: the pages are behind a sign-in.
Cost: a subset of ICU (no selectordinal, no number or date skeletons
in messages). The choice is per browser, not per account. Every locale’s
messages are in the page bundle. Mail and jobs have no request, so they
need a locale stored somewhere first.
14. Admin: a table admin over the app’s tables, behind access
Section titled “14. Admin: a table admin over the app’s tables, behind access”@recranet/flare/admin reads the columns off the app’s Drizzle tables and serves
list, read, update and delete for each, through one catch-all route. Details
in plugins/admin.md.
- Access decides. Each resource is a
@recranet/flare/accessresource, and every call isrequireScope(c, { [resource]: [action] }): members in their organization with their role, platform admins across organizations with?context=admin. No separate admin roles. - Display-ready rows. References come back as names and the tenant and ids aren’t columns, so a page built on it shows no ids.
- The page is the app’s. A plugin can’t add Void pages, and a generic Vue
admin would tie the package to Vue and Verano.
examples/crm/pages/admin/is ~200 lines to copy. - Edits are plain. No create, references read-only, keyset paging (so
only non-null columns sort). The app’s own schema validates updates;
onUpdateandonDeleteadd the app’s statements (search documents, cleared references) to the write’s batch.
Cost: writes skip the app’s record functions, so rules that live only
there (derived fields, reference checks) need the column kept out of edit
or an input schema that checks them.
15. Docs site: Starlight reads docs/ in place
Section titled “15. Docs site: Starlight reads docs/ in place”docs-site/ is an Astro Starlight site whose pages are the Markdown in
docs/, loaded where it lives by a glob loader. Only the landing page and
Getting started live in the site.
- One source, two readers. The guides stay plain Markdown that GitHub
renders, with file links (
access.md,../decisions.md#9-…). Moving them intosrc/content/docs/would need frontmatter and route-style links, which break on GitHub. - Small glue. A page’s title is its first
# heading, and a Markdown plugin turns file links into routes (links out ofdocs/go to GitHub). starlight-links-validator then checks every link in the build, so a link that works on one side and not the other fails CI. - Plain Starlight. Its default theme and the repository’s logo; no design system of our own.
Cost: the glue leans on Starlight and Astro details (the Sätteri
processor, markdown.processedDirs, the validator’s slug), so an upgrade
can break it; the build says so when it does.
16. Docs hosting: a static-assets Worker on flare.recranet.dev
Section titled “16. Docs hosting: a static-assets Worker on flare.recranet.dev”The docs site deploys as a Cloudflare Worker with only static assets, on the
custom domain flare.recranet.dev, with the cf CLI. Its own workflow,
docs-deploy.yml, builds and deploys it when the docs change on main, or by
hand; the build’s link check is the gate, not the code tests.
- Same platform as the plugins. Workers is where Flare apps run, and Cloudflare now points new static sites at Workers rather than Pages.
- cf, with wrangler building.
cloudflare.config.tsdescribes the Worker. cf sees Astro and expects the@astrojs/cloudflareadapter to write its Build Output, but the adapter makes a server Worker with KV sessions and an Images binding. Sowrangler.config.tsnamesdist/,wrangler build --experimental-new-config --experimental-cf-build-outputwrites the Build Output, andcf deploy --prebuiltuploads it. - No script.
auto-trailing-slashserves Starlight’spage/index.htmlat/page/and redirects/pagethere;404-pageserves its404.htmlwith a 404 status. - No previews. workers.dev and preview URLs are off; pull requests only build the site.
Cost: cf is a beta and both wrangler flags are experimental, so an
upgrade of either can break the deploy. Deploys need CLOUDFLARE_API_TOKEN
and CLOUDFLARE_ACCOUNT_ID in the repository’s secrets, and a token that can
edit Workers and the recranet.dev zone’s routes.
17. Releases: changesets, SemVer, both packages together
Section titled “17. Releases: changesets, SemVer, both packages together”@recranet/flare and @recranet/flare-cli follow SemVer and release
together at one version (a fixed group). Every change an app can notice
gets a changeset (pnpm changeset) in the same commit or PR;
.changeset/README.md says which bump.
release.yml runs changesets/action: it keeps a “Version Packages” pull
request open, and merging it publishes both packages to npm, restricted to
the Recranet org like Verano’s, with tags and GitHub Releases. The first
release is 1.0.0.
Publishing is npm’s trusted publishing, as in Verano: each package trusts
release.yml on npmjs.com, and the job’s OIDC token (id-token: write)
stands in for a publish token. pnpm release (scripts/release.ts) packs
each unpublished package with pnpm, which rewrites workspace:*, and
publishes the tarball with npm, whose CLI does the OIDC exchange. npm only
lets a package that exists trust a workflow, so 1.0.0 went up by hand.
Why: the bump and the release notes come from what the author wrote at the
time, not from rereading commits. @changesets/cli does the bookkeeping
(versions, CHANGELOG.md, workspace:* rewritten to the real version,
tags) that Verano’s hand-run release command does by hand.
Cost: installing either package needs an npm login with access to the
Recranet org, pnpm dlx @recranet/flare create included. The “Version
Packages” pull request is opened with GITHUB_TOKEN, which doesn’t trigger
CI, so the required test check never runs on it: merge it with --admin,
or push an empty commit to it.
18. The Verano starter is the default
Section titled “18. The Verano starter is the default”flare create copies starters/verano: a Void app with Verano’s Vue
components (@recranet/verano-vue and @recranet/verano-css) in the
console’s shell, with sign-in, organizations and roles, invitations, English
and Dutch, and tasks as the one example resource (a list, a detail page, a
JSON API). It is examples/crm cut down to what every app needs.
--starter api gives the JSON-API-only starter, starters/api, which was the
default before.
Why: one way to build a Recranet app, from the first commit. The shell,
auth pages, tenancy, translations and the list and detail patterns are the
same in every app, so an app starts with them instead of rebuilding them from
the CRM. The starter carries a CLAUDE.md, a release skill and changesets
(guidelines), so an agent works in it the same way.
Cost: installing an app needs an npm login with access to the Recranet org: Verano’s packages are restricted like Flare’s. The changed default is a major release (2.0.0) of both packages. Each starter is code to keep in step with Void, Better Auth and Verano; both run their tests in CI.