Version
v0.25.0
Date
2026-08-02
Highlights
-
Public (anonymous) realm — the third authorization realm from ADR-011.
An unauthenticated request now resolves to the site'spublicrole instead
of a blanket 401, so content can be served publicly through the permission
layer rather than around it. Opt-in per site, GET/HEAD on allow-listed
content paths only, and structurally least-privilege (migration0012). -
Cache penetration defence. Public reads stack identifier shape guards,
negative-cache tombstones, and a Delivery IP rate limiter in front of
Postgres, so a flood of nonexistent slugs no longer becomes database load. -
Dependency majors batched — this release requires Node 22+. jose 6,
zod 4, node-cron 4, execa 10, react 19 alignment, graphql 17 and others land
together. Two breaking consequences for consumers:create-lumibasenow
requires Node 22+, and jose v6 removed theKeyLikeexport. See Changed.
Added
-
Public (anonymous) role and principal (ADR-011, migration
0012).
withAuthpreviously returned an unconditional 401 for a caller with no
credential, so there was no way to serve content publicly through the
permission layer at all. An unauthenticated request now resolves to the
site'spublicrole, which keeps row filters and field masks in force. Two
conditions gate it: the site has explicitly enabled public access (enabling
is what creates the role, so existing deployments are unchanged until an
operator turns it on), and the request is a GET/HEAD on an allow-listed
content path. The role is least-privilege by construction —
admin_access/app_accessare pinned off by check constraints on
lumibase_rolesandlumibase_policies, with route guards refusing the same
edits as a readable 4xx rather than a constraint violation, and hand-attached
policies screened at the attach point (a table check cannot see across the
role_policiesjoin). Anonymous role lookups are cached per site, negative
results included, since every visitor shares one compiled bundle under the
role:{id}principal key. GraphQL is deliberately excluded: its operations
arrive over POST, so the read-method rule cannot cover it. -
Cache penetration defence (Delivery API + schema lookup). Public reads
now stack three cheap filters before Postgres: identifier shape guards
(404, zero queries), short-lived negative-cache tombstones on
CacheProvider(getEntry/setNegative, TTL
LUMIBASE_NEGATIVE_CACHE_TTLwith ±20% jitter), and a dedicated Delivery
IP rate limiter (LUMIBASE_DELIVER_RATE_LIMIT, default 1200/min). See
docs/en/features/caching.md. No schema or setup change. -
Studio: edit a publishable key's origin allowlist.
PATCH /api-keys/:id/allowed-originsand the SDK'ssetAllowedOrigins()
already existed — only the Studio affordance was missing, so an allowlist
could be set at create time and never again. Tightening it, or fixing a
mistyped origin, meant rotating the token and redeploying whatever ships the
key, which defeats a control that exists precisely because the key is already
out in clients. The API-key detail panel now has an Allowed origins box,
shown only for publishable keys (theOrigincheck is browser-only; a secret
key is used server-to-server where noOriginexists). Save stays disabled
until the parsed list actually differs, and clearing the box — which widens
access — is confirmed rather than silently accepted. No migration, no seed,
no new endpoint. -
OpenSSF Scorecard workflow with published results, plus the badge in the
README. Supply-chain posture is now measured on a schedule instead of
assumed.
Changed
-
Dependency majors batched (#326,
follow-ups in #327): jose 6,
zod 4, node-cron 4, execa 10,@types/node26, react/react-dom 19 alignment,
graphql 17, tailwind-merge 3, eslint-config-next 16. Two consequences worth
knowing about when you upgrade:- Node 22+ is now required for
create-lumibase(execa 10), enforced by
an engines field plus a runtime guard. - jose v6 removed the
KeyLikeexport; key typing moved toCryptoKey.
Anything importingKeyLikefrom jose transitively through LumiBase needs
the same change.
After pulling this release, run
pnpm installbeforepnpm test— a stale
node_modulesresolves zod 3 against zod-4 call sites and fails at import
time, which looks like broken tests rather than a stale lockfile. - Node 22+ is now required for
Removed
apps/enterprisesubmodule. Referenced only by.gitmodulesand docs —
no workflow, build, or workspace target depended on it, and it tracked a
stale branch.apps/marketplacestays (it is built bypages-deploy.yml,
release.yml, andci.yml).
Fixed
-
Public access grants were inert on the content API.
buildRequestPermissionContexthardcodedroleId: null(two more call sites
hand-rolled the same literal).withAuthdoes setroleIdto the site's
publicrole for an anonymous principal, butPermissionService.compile()
resolves roles fromuser_sites,user_roles,api_key_rolesand
ctx.roleId— and for an anonymous principal the first three are empty by
definition. The role never reached the bundle, so every public grant compiled
to an empty policy set and the feature silently did nothing.auth.roleIdis
now forwarded everywhere a principal is in scope. -
/disablefor public access was not sticky. A grant used to provision the
anonymous realm as a side effect, and since "enabled" is not a flag here — it
is the existence of thepublicrole — that gave two independent ways to
turn anonymous access on. An operator who deliberately closed anonymous access
could have it silently reopened by any later grant call, leaving only
realm_access_grantedin the audit trail and nopublic_access_enabled.
POST /access/grants/publicnow returns 409PUBLIC_ACCESS_DISABLEDwhile
the site has public access off, naming/access/grants/public/enableas the
way in. Onlypublicis gated;subscriberis provisioned on first
registration and is not operator-togglable. -
Cache-penetration follow-ups (audit of the Req 19 implementation). Five
defects found reviewing what shipped above:- The Delivery 404 was an oracle. A shape-rejected identifier answered
{"error":"Not found."}while a real miss answered
{"error":"Page not found."}, so a prober could tell "malformed" from
"well-formed but absent" — the one thing the guard is documented not to
leak. Both paths now return the same body and headers, with a regression
test that fails if they diverge again. LUMIBASE_NEGATIVE_CACHE_TTLwas ignored on Cloudflare Workers.
SchemaServicereadprocess.env, which does not carry wrangler vars, so
the TTL was always the 30s default and0could not disable tombstones on
that runtime. The TTL is now resolved from the request env and threaded
throughItemServiceDeps/SchemaServiceDeps.- The tombstone sat in front of the hot path.
getCompiledprobed the
tombstone before the positive schema cache, so every lookup of a
real collection paid a second cache round-trip — and that runs on every
item list/detail/patch. Positive cache is checked first now; penetration
traffic still never reaches Postgres. - Half the tombstones were invisible in Prometheus. Only the Delivery
routes incrementedcache_negative_hits_total/
cache_negative_writes_total; collection-name tombstones did not, despite
being wired. Both now report. - Unbounded key material, plus a dead key. Collection names bound the
alphabet but not the length, so a long name minted a multi-KB Redis key —
now clamped to 256 like slugs. Theneg:*:item:*key and itsforgetcall
inItemService.createare gone: nothing ever wrote that key (item-by-id
reads are all authenticated, and tombstones are never served to credentialed
requests), so each item create spent a Redis round-trip deleting something
that never existed.
- The Delivery 404 was an oracle. A shape-rejected identifier answered
-
Studio flashed a full-page spinner when the API was down.
GET /setup/statekept auto-refetching on window focus/reconnect after a failure,
and the error screen was not latched during manual retry, so an unreachable
api.lumibase.devlooked like continuous refreshing. -
Landing / sponsor rewards (#296):
saveToDatabase()inapps/landing/src/lib/rewards.tswas an exported no-op
— its body held only a// TODO: Implement database savecomment, so nothing
a caller "saved" was ever written anywhere. The module also kept two
independent in-memoryMaps (one in the lib, one in the GitHub Sponsors
webhook route), andupdateClaimStatus()flippedclaimedwith no guard, so
a token could be claimed twice. The module is nowsrc/lib/rewards/behind a
singleSponsorStoreinterface:InMemorySponsorStore(default; dev/tests)
andD1SponsorStore(Cloudflare D1, persistent). Claiming is atomic in both —
D1 does it as oneUPDATE … WHERE reward_token = ? AND claimed = 0 RETURNING tiercompare-and-set — so N concurrent claims of a valid token yield exactly
one success. The no-opsaveToDatabase()and the unguarded
updateClaimStatus()are gone;createSponsor()persists andclaimReward()
is the only claim path. The webhook's debugGETno longer lists reward
tokens (they are credentials). No upgrade step: the landing app is a static
export, the route handlers that use this live insrc/_api-routes-disabled/,
and persistence activates only when aSPONSORS_DBD1 binding is configured
(see "Sponsor rewards store" inapps/landing/README.md). The/rewards/claim
page that used to POST at this module was removed separately as dead,
always-failing UI; the store now waits, correct, for the managed backend that
removal note anticipated. -
Deployment skills never had a KeyProvider. The deployment skills
(triggerDeployment,listDeploymentTargets,listDeployments,
getDeploymentStatus) build a site-scopedDeploymentServicefrom
db + siteId + keys, but noAISecureHarnessconstruction site passed
keys— so every call failed closed withDEPLOYMENTS_NOT_CONFIGURED: the AI
chat path and MCP endpoint (routes/ai.ts,routes/mcp.ts), the
approval-execution path (an approvedtriggerDeploymentresolved into a
configuration error), and queued agent runs. All four now pass the runtime
KeyProvider;AgentRunWorkerDepsgained an optionalkeys: KeyProvider,
threaded fromruntime.keyswhere the Node/Docker consumer is registered
(serve.ts). Flow-driven deploys (deploy:trigger) were unaffected — the
flow run environment already suppliedkeys. -
Studio / Deployments page was unreachable.
settings/deployments-page.tsx
shipped complete — targets list, deploy trigger, status polling, build logs,
against the already-mounted/api/v1/deployments— but nothing linked to it:
no route inrouter.tsxand no entry in the settings sidebar, so the whole
feature was dead code from the client side. Registered
/settings/deployments(plus the/$adminPath/settings/deploymentsvariant)
and added Deployments to the Integrations group of Settings. No API,
schema, or setup change — the page is reachable as soon as Studio updates. -
Security / mcp-server:
cdc_subscription_replayaccepted its
subscription_idas a barez.string()and encoded it with
encodeURIComponent, which leaves..intact — a crafted id reached
/cdc/subscriptions/../replayand re-pointed the call at a sibling endpoint.
The argument now usesidPathSegmentSchema+encodePathSegment. This is the
same path-traversal / confused-deputy class closed in 0.13.x; the CDC tools
landed afterwards and reintroduced it. -
Shell version drift could recur —
version:syncnow coverssrc-tauri.
v0.24.1 existed only to hand-repairapps/shell/src-taurimetadata
(tauri.conf.json,Cargo.toml,Cargo.lock) that v0.24.0 shipped stale,
but the repair was a one-off edit:scripts/sync-version.mjsstill swept
package.jsonfiles exclusively, so the three Tauri files drifted again the
moment the root version moved. They are now part of the sync (and of
version:check, which gates the release), with theCargo.lockrewrite
pinned to thelumibase-shellentry so no third-party crate pin is touched.
A file that stops exposing its version where expected is reported as a
mismatch rather than skipped — silently syncing nothing is the failure mode
this guards against.tauri.conf.jsonis what the desktop auto-updater
compares against, so drift here ships a build that will not offer its own
update. -
CI: stale
apps/enterprisegitlink broke the Scorecard workflow. The
submodule's.gitmodulesentry was removed without removing the160000
gitlink from the index, sogit submodule foreachfailed withNo url found for submodule path 'apps/enterprise'. That aborted checkout in the one
workflow usingpersist-credentials: false— Scorecard — which is why only
that job was red. The orphaned gitlink is gone.
Added — regression tripwires
-
Harness KeyProvider tripwire.
ai-harness-keys-context.test.tslocks the
class rather than the four call sites: a source scan requires every
new AISecureHarness({…})that wires real services to also passkeys
(registry-only constructions, which never run a handler, are exempt), and a
registry check asserts each deployment skill really does fail with
DEPLOYMENTS_NOT_CONFIGUREDwithout one — so the scan cannot pass vacuously
if the guard moves. Companion behavioural test
agent-run-worker-keys.test.tsdrives a deployment skill through
processAgentRunJobend to end. Same shape as the ItemService RBAC-context
guard: a construction that silently degrades is caught at CI, not at runtime.
DoD §2b gains a matching checklist line for background/queue workers. -
Orphaned-page tripwire (Studio settings).
settings/__tests__/deployments-page.test.tsx
locks the class behind the fix above rather than the single page: a source scan
requires every*-page.tsxinmodules/settings/to be referenced by
router.tsx, and every sidebarto:target to have a matching route path.
deployments-route.test.tsxcomplements it by driving the real route tree over
a memory history, so "the URL resolves to the page and the nav link points at
it" is asserted rather than assumed. -
Path-traversal tripwire (mcp-server).
path-hardening.wiring.test.ts
locks the class instead of the individual call sites: a source scan requires
every/${…}path interpolation intools/*.tsto go through
encodePathSegment/encodeMediaKey, and a registry scan walks every
registered tool and fails if an argument that reaches a path segment accepts
... Both halves are needed — encoding alone cannot neutralize... The scan
found the CDC gap above.
Migrations
0012_public_role_least_privilege— adds twoCHECKconstraints:
roles_public_least_privilegeonlumibase_rolesand
policies_public_least_privilegeonlumibase_policies. Both pin
admin_access/app_access(andenforce_tfafor policies) off for the
publicsystem key, so an elevation flag on the anonymous role cannot be set
even by hand-written SQL or an import. The predicates useis distinct from
rather than<>so they evaluate FALSE-or-TRUE — not NULL — for the many
rows with a NULLsystem_key/key. Additive and non-destructive; no
backfill. If an existing row already carries an elevatedpublicrole the
ALTER TABLEwill fail — clear those flags first, then re-run.