Skip to content

Decisions

Each entry: what was decided, why, and what it costs. Newest last. Research notes behind these are in docs/research/.

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_compat and imports node:crypto itself.
  • @cloudflare/vitest-plugin next to voidPlugin(): 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-plugin 1.x, cf, miniflare and 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 deploy has 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/db and void/schema-d1, not drizzle-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.

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.

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.

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: tsc already type-checks every package.
  • Rule choices: no-explicit-any is 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 /jwks handler 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/server 2.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, ...) in auth.ts, a one-line routes/mcp.ts, and a one-line middleware/00.well-known.ts: Void doesn’t route routes/.well-known/* (its route scan skips dot directories). mcpAuth records Better Auth’s context per request in a plugin init, 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_id claim). 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.

@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 requireMember or 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/create writes the session’s active organization into the key’s metadata, over the body (decision 4); /api-key/update from 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 put is missed until search.reindex; the CRM runs it nightly.
  • A regular table under the index. D1’s export refuses a virtual table, and void db export dumps FTS5’s shadow tables, which don’t load back. With the text in search_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_id is 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; readStream yields 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’s createMany / updateMany write 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.

@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/access resource, and every call is requireScope(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; onUpdate and onDelete add 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 into src/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 of docs/ 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.ts describes the Worker. cf sees Astro and expects the @astrojs/cloudflare adapter to write its Build Output, but the adapter makes a server Worker with KV sessions and an Images binding. So wrangler.config.ts names dist/, wrangler build --experimental-new-config --experimental-cf-build-output writes the Build Output, and cf deploy --prebuilt uploads it.
  • No script. auto-trailing-slash serves Starlight’s page/index.html at /page/ and redirects /page there; 404-page serves its 404.html with 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.

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.