Skip to content

feat(vercel): derive five deploy values from the linked stores - #238

Merged
jouwdan merged 10 commits into
mainfrom
claude/mei-129-derive-deploy-values
Aug 25, 2026
Merged

feat(vercel): derive five deploy values from the linked stores#238
jouwdan merged 10 commits into
mainfrom
claude/mei-129-derive-deploy-values

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Closes MEI-129.

The Deploy Button asked for seven values. Five of them the board can work out once Neon, Upstash and Blob are linked. It now asks for two: AUTH_SECRET and CRON_SECRET.

Why two and not zero

CRON_SECRET cannot be generated by the board — Vercel Cron sends Authorization: Bearer $CRON_SECRET read from the project's environment, so a value generated internally would be unknown to the caller.

AUTH_SECRET could be generated and persisted, but stays prompted by decision: it seals members' two-factor secrets and signs unsubscribe links, and keeping it environment-only means a database dump alone cannot forge either.

What is derived

Every derivation is gated on running on Vercel, fires only when the variable is unset, and requires a candidate that is both present and the right shape.

Variable From Basis
REDIS_URL KV_URL, then UPSTASH_REDIS_URL KV_URL confirmed on a real linked project — Upstash publishes no REDIS_URL at all, which is why the form asked for it
DIRECT_DATABASE_URL DATABASE_URL_UNPOOLED, then POSTGRES_URL_NON_POOLING both confirmed, both unpooled
CACHE_DRIVER redis, once a Redis URL resolves
FILESTORE_DRIVER blob, from BLOB_STORE_ID or BLOB_READ_WRITE_TOKEN see below
MAIL_FROM removed from the button mail cannot work until Resend is added after the deploy; it is now part of that step

KV_REST_API_URL is deliberately excluded: it is an HTTPS REST endpoint, and a Redis client given it fails at connect time as though the network were broken. POSTGRES_URL and POSTGRES_PRISMA_URL are excluded as pooled — a transaction-mode pooler cannot hold the session-level advisory lock migrations and the installer take.

The Blob driver now authenticates the way Vercel configures it

A linked Blob store publishes BLOB_STORE_ID and BLOB_WEBHOOK_PUBLIC_KEYand no read-write token. Confirmed against a real project's complete environment list.

Reading the installed SDK, that is deliberate. resolveBlobAuth resolves credentials as presigned → an explicit token option → OIDC (VERCEL_OIDC_TOKEN plus BLOB_STORE_ID) → the BLOB_READ_WRITE_TOKEN environment variable → throw. OIDC is tried before the static token, and the store id is exactly what it consumes.

The driver previously passed token: on every call, forcing the second branch and making OIDC unreachable, and refused to construct without the token — so it could not work against a store attached through the integration at all. It now carries two credential shapes: the OIDC path passes a store id and no token key, and the token path is unchanged.

When both are present, the store ids are compared. Same store, OIDC wins — a short-lived credential, and the SDK falls through to the token by itself if OIDC is unavailable, since getVercelOidcToken() returns undefined rather than throwing. Different stores, the token wins: it is typed by a person and a token naming another project's store is deliberate, so silently writing to the linked store would be a data error.

A token that parses as nothing is refused at boot, not on the first upload.

Self-host boards are untouched

Five environment shapes (compose-style, explicit drivers, redis cache, bare dev, build phase) each assert parseEnv({ ...shape, ...PUBLISHED }) deep-equals parseEnv(shape) on the full parsed environment, with every injected name present versus absent.

Refusals name what they looked for

A derivation that cannot resolve refuses to boot rather than falling back to local storage or the next cache — the silent degradations the prompts existed to prevent. Unresolved derivations are reported together, so one failed deploy names all of them.

Consequence worth knowing

A local community backup against a Blob store needs a token created by hand, because there is no deployment identity off-platform. This was always true of an OIDC-only project; what changed is that the documentation now says so instead of telling operators to copy a variable that does not exist.

Not established without a live deploy

Nobody has run this against real Blob storage. Reasoned from the SDK's shipped source, not observed:

  • whether VERCEL_OIDC_TOKEN is actually present at request time on a linked project — this is the premise of the whole path;
  • whether it exists during the build command (the code is careful not to need it: community migrate validates the environment and never reaches the file store);
  • whether OIDC can be disabled per project, which the board cannot see and so cannot refuse at boot for;
  • the SDK's error wording in whatever version Vercel resolves at deploy time.

Takes effect on a real deploy only once a release ships and meith-dev/vercel-template re-pins it.

claude added 10 commits August 25, 2026 19:45
The Deploy Button asked for seven values, five of which the board can work
out for itself once Neon, Upstash and Blob are linked: CACHE_DRIVER,
FILESTORE_DRIVER, REDIS_URL and DIRECT_DATABASE_URL now derive from what
those integrations publish, and MAIL_FROM moves to the Resend step that has
to happen after the deploy anyway. AUTH_SECRET and CRON_SECRET remain: the
cron caller reads CRON_SECRET from the project environment, and AUTH_SECRET
stays out of the database on purpose.

Every derivation is scoped to Vercel and fires only where the variable is
unset, so a self-hosted board boots exactly as it did before. A derivation
that cannot resolve refuses to boot and names every variable it looked at,
rather than falling back to local file storage or the per-instance cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
The refusal explained that a Redis client handed an HTTPS REST endpoint
fails at connect time as though the network were broken. That is a failure
the refusal exists to prevent the operator from ever reaching, so the
sentence described something they will not see. What they do need is the
fact that KV_REST_API_URL is not a candidate, which stays. The mechanism is
already in docs/vercel.md, where an explanation belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
packages/core/src/env.ts goes from 27 to 51. The raise is five refusal
messages: the ones the board emits when it is running on Vercel, a value it
derives is unset, and no candidate variable resolves — for the cache, the
cache URL, the object store, and the direct database URL.

They cannot go through the catalog. assertEnv() runs during the build and at
boot, before there is a request, a board, a member or a locale to pick, and
so before there is a Translator to take one — the same reason the 27 already
banked in this file are banked. Their reader is an operator looking at a
deploy log, in the same language as the log around them, and their whole
purpose is to name every variable that was searched: that list is the only
clue anyone has when an integration publishes a name this board does not
know.

The count is per quoted fragment rather than per message, so a refusal
wrapped over eight lines is eight strings. Five messages, twenty-four
strings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
MEI-122 replaced the s3-file-store entry with blob-file-store and keys.ts;
the prose kept the old name and the old count of raised entries.
apps/community's tsconfig augments NodeJS.ProcessEnv to require NODE_ENV,
so a partial fixture cannot satisfy it.
A Blob store attached through the Vercel integration publishes BLOB_STORE_ID
and BLOB_WEBHOOK_PUBLIC_KEY, and no read-write token. @vercel/blob resolves
a credential in the order: passed token, then OIDC — the deployment's own
identity token with a store id from BLOB_STORE_ID — then
BLOB_READ_WRITE_TOKEN from the environment. This driver passed token on
every call, which took the first branch and made OIDC unreachable, and
fromEnv refused to construct without a token at all. So the driver could not
be used against a store attached the way the platform attaches one.

The driver now takes either shape. Given a store id it passes the id and no
token, letting the SDK reach OIDC; given a token it behaves as before. With
both, the store id wins — matching what the SDK would do if handed nothing —
unless the token names a different store, where the token wins, because
naming another store is deliberate and writing to the linked one instead
would be a data error. url() takes the store id from whichever shape built
it, normalising the store_ prefix the way the SDK does.

FILESTORE_DRIVER=blob now derives from either name, and requires either. A
store id without a token is the normal shape, not a misconfiguration; what
is broken is neither being present. Off the platform there is no identity to
borrow, so a local `community backup` still needs a read-write token created
on the store — and a first call that finds no credential now says so, naming
the store and both causes, instead of passing the SDK's error through.

Nothing asks for VERCEL_OIDC_TOKEN, so a build cannot fail on a credential
that only exists at request time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
packages/drivers/src/files/blob-file-store.ts goes from 5 to 13. The eight
are the two configuration errors the two credential shapes made necessary:
the one naming BLOB_STORE_ID and BLOB_READ_WRITE_TOKEN when a blob store is
selected with neither, and the one explaining a first upload that found no
OIDC identity to authenticate with.

They cannot go through the catalog for the same reason the boot-time strings
in packages/core/src/env.ts cannot. A file store is constructed from the
environment before there is a request, a board or a member, so there is no
locale to pick and no Translator to take one. Their reader is whoever ran
the deploy, or the backup, looking at a log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
BLOB_READ_WRITE_TOKEN was typed as any non-empty string, so a truncated,
stale or wrong-kind value passed validation, satisfied the FILESTORE_DRIVER
derivation, and then threw from storeIdFrom on the first request that
touched an upload — a 500 on a live board, long after the deploy went green.
That is the boot-then-fail-on-first-upload shape this branch exists to
remove, reintroduced through the token path, and on a linked project it
turns a board that BLOB_STORE_ID alone would have served into a broken one.

The shape check the driver already applied now lives in the schema, so it
fires at boot next to every other configuration error and `community
env:check` reports it. Nothing new is being judged: storeIdFrom rejected
exactly these tokens already, and both callers now share one parser so the
two cannot drift.

Treating an unparseable token as absent whenever a store id is present was
the alternative. It loses on two counts: it cannot help a board with only a
bad token, where nothing else would ever catch it, and where it does apply
it silently ignores a credential the operator typed — while a stale token
for the right store and a garbage one are indistinguishable to us, so it
would quietly write to a store the operator may not have meant.

Four smaller things from the same review. A store id is trimmed before the
SDK or url() sees it, so a pasted value with whitespace cannot produce a
malformed host or fall to the token path on a mismatched comparison. The
intercepted no-credentials message is raised only on the store-id path,
since the token path never asked OIDC for anything. del hands the SDK a copy
of the auth options rather than the driver's own object. And the sentence
the driver matches on is now one exported constant, with a test asserting
the installed @vercel/blob still contains it, so a reword upstream fails
rather than silently costing a good error message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
packages/core/src/env.ts goes from 51 to 55: the four fragments of the
message the schema now raises for a BLOB_READ_WRITE_TOKEN that names no
store. It is emitted by assertEnv() before there is a board, a member or a
Translator, like the boot-time strings already banked in this file.

Also records what one of the strings already banked in
blob-file-store.ts actually is: BLOB_NO_CREDENTIALS holds the sentence
@vercel/blob throws, matched on so the driver can replace that error with
one naming the store. It is a sentinel for somebody else's string, not copy,
and rephrasing it would stop the match — worth saying where the count is
explained, since it looks like a message and is not one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015WBLHKvRatuUufybYpgER1
@jouwdan
jouwdan merged commit 14272a2 into main Aug 25, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants