feat(vercel): derive five deploy values from the linked stores - #238
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_SECRETandCRON_SECRET.Why two and not zero
CRON_SECRETcannot be generated by the board — Vercel Cron sendsAuthorization: Bearer $CRON_SECRETread from the project's environment, so a value generated internally would be unknown to the caller.AUTH_SECRETcould 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.
REDIS_URLKV_URL, thenUPSTASH_REDIS_URLKV_URLconfirmed on a real linked project — Upstash publishes noREDIS_URLat all, which is why the form asked for itDIRECT_DATABASE_URLDATABASE_URL_UNPOOLED, thenPOSTGRES_URL_NON_POOLINGCACHE_DRIVERredis, once a Redis URL resolvesFILESTORE_DRIVERblob, fromBLOB_STORE_IDorBLOB_READ_WRITE_TOKENMAIL_FROMKV_REST_API_URLis 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_URLandPOSTGRES_PRISMA_URLare 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_IDandBLOB_WEBHOOK_PUBLIC_KEY— and no read-write token. Confirmed against a real project's complete environment list.Reading the installed SDK, that is deliberate.
resolveBlobAuthresolves credentials as presigned → an explicittokenoption → OIDC (VERCEL_OIDC_TOKENplusBLOB_STORE_ID) → theBLOB_READ_WRITE_TOKENenvironment 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 notokenkey, 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()returnsundefinedrather 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-equalsparseEnv(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
localstorage or thenextcache — 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 backupagainst 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:
VERCEL_OIDC_TOKENis actually present at request time on a linked project — this is the premise of the whole path;community migratevalidates the environment and never reaches the file store);Takes effect on a real deploy only once a release ships and
meith-dev/vercel-templatere-pins it.