Ready for twist
What a project must satisfy before wbsp deploy --destination twist succeeds
and the application signs people in at https://<app>.twist-hosting.com. The
build applies this list on entry to the production phase.
How to read an item. Each begins with a six-character identifier — quote
that, never a position in the list, because items are reordered, regrouped and
retired and the identifier is the only thing that stays put. Each ends with
where it comes from: G is the client guidelines index (with the section), R
the manifest reference, D the credential-less deploy page, H HAP's own
guidelines (named), X a behaviour the platform verified in its own code,
Phil a direct instruction (dated), P a catalogue project by number, and C
the conductor's field record (list, item, date) from which the item was vetted.
Owner. The platform writes this page. Regenerating the guidelines digest never touches it. A retired item stays on the page under Retired so a quoted identifier always resolves.
A. The destination block
- gbpppu
wbsp.yamldeclaresdestination.twistwithtype: aws,mode: normal,tenant: twist,cluster: main,enclave: twist.tenant:is per destination; a root-leveltenant:is rejected. (R;Ctwist 1;P41, 42, 126, 169) - exhadz
access: publicat top level and aroutes.twistentry naming the public domain in the form<app>.twist-hosting.com.routes:is required wheneveraccess: public. (R;Ctwist 2) - agy3ba
port: 8080at top level. Onawsthe container is routed to 8080 whateverport:says unlessuse_declared_port: true, which also injectsPORT. A project listening elsewhere declares that, or fixes its listener. (RThe port contract;Ctwist 3) - 8ez2ya The reserved
universal: {}destination is declared, and thetwistblock is an ordinary destination carrying its identity keys;demo,sandboxanduniversalcarry none of them. (RReserved destinations;Ctwist 4) - cvn8pd
variant: <uuid>sits immediately beforename:;projectNo:is declared;source:anddockerfile:name a Dockerfile that buildslinux/amd64. (G§1;R;Ctwist 5)
B. Data stores
- j2tr63
database.enabled: true, and all three store keys stated explicitly —redis.enabled,redis.durable,memorydb.enabled— each with its one-line comment, withredis.durableandmemorydb.enabledeach matching a user-decidedredis/memorydbline inDECISIONS.md; a manifest that declares either without that decision fails here. Nothing underenv:orsecrets:names aDATABASE_*,REDIS_*orMEMORYDB_*variable — the platform injects them and a declaration fights it. (R;G§4 Requirement — warn your user and get permission before declaring either;Ctwist 6) - 3fs25x On the
twisttenant bothredis.durableandmemorydb.enabledarefalseunless Phil has agreed otherwise for the project. The application treats Redis as a cache that can be emptied at any time: every job idempotent, nothing held only in Redis, the source of truth in PostgreSQL. (Phil2026-09-08, 2026-09-14;G§4 Treat cache-mode Redis as ephemeral;Ctwist 6) - u33ht5 Every PostgreSQL extension the schema needs is under
database.extensions; the platform creates them before the release runs. (R;Ctwist 7) - 5khe6m The application reads the discrete
DATABASE_HOST/PORT/NAME/USER/PASSWORDand honoursDATABASE_SSL=true, which the platform sets on RDS. Redis clients honourREDIS_NAMESPACEandREDIS_TLS. No source file reads aDATABASE_URL. (G§4;R;Ctwist 8) - 2znuum
REDIS_*always describes plain Redis and never needs a cluster client: an application declaringdurable: trueis attached to a separate durable Redis instance with the same protocol, and no other application's declaration changes what this one receives. The application builds an ordinary (non-cluster) Redis client fromREDIS_*and declares noREDIS_CLUSTERflag.MEMORYDB_*, injected only when the application itself declaresmemorydb.enabled: true, is the only cluster-mode store; that client is cluster-aware, with credentials in the node options since nodes are discovered rather than listed. (G§4 MemoryDB is a different product;R;Ctwist 44;P123) - 8f6q6v The application reads
REDIS_USERNAMEand passes it. The platform provisions a named ACL user; a password-only AUTH against it fails withWRONGPASS, which reads like a wrong password, and never shows on a laptop where the default user authenticates by password alone. Omit the username when absent rather than sending an empty one. (Ctwist 45;P123) - ch7xmn Hash tags apply to
MEMORYDB_NAMESPACEkeys and are never required forREDIS_*. An application on MemoryDB putsMEMORYDB_NAMESPACEinside{…}in its key prefix so every key of the deployment shares a slot and multi-key commands work, keeps the namespace leading the key literally so a prefix-matching ACL still sees it, and tags the local stand-in too so a laptop behaves like the cloud. (G§4 MemoryDB is a different product;Ctwist 46;P123) - 7kzcax If the application uses S3,
s3.enabled: trueand no access keys anywhere: onawsthe pod reaches the bucket through the role on its service account. (Ctwist 9;P126) - qp4pyd If the application relies on row-level security: the cloud role is a plain user with no bypass and, under
FORCE ROW LEVEL SECURITY, no owner exemption, so policies are live on twist though inert ondev, whose role is a superuser. Every connection path sets the tenant context the policies read — request middleware, workers, health checks, migration-time data fixes, pooled connections — or a query that works ondevreturns zero rows on twist. Enforcement is tested against a non-superuser role before the deploy. (Xmeasured 2026-09-08;Ctwist 37;P61)
C. Release and migrations
-
kc53m2 A
release:command exists, is idempotent, runs inside the application's own image with the discreteDATABASE_*only, and brings an empty database to the checked-in schema plus whatever row a first user needs, before the new version serves. (G§4.4;R;Ctwist 10) -
aegvkq The image carries whatever
release:needs: the migration toolchain and its dependency closure (a copied Prisma directory without its engines is the known failure),psqlif the command uses it, and a script that runs undershwithout an exec bit. Verified by building the image locally and running the release command inside it against a local database. (RRelease;Ctwist 11;P123) -
3db2ma Migrations are expand/contract: the previous version may still be serving while
release:runs. (Ctwist 12;P42) -
wyugth
release_timeoutis set when migrations can exceed the default 600 seconds. (R;Ctwist 13) -
pkzsac
health_check.pathis declared when the application 404s at/, sowbsp list --healthandinfoprobe a real endpoint; the path answers 200 with a body. (R;Ctwist 14) -
3mexnw The emptiness guard on any script that can wipe data must be able to see everything. Under row-level security a plain count runs through the policies, and with no tenant context it returns zero for a fully populated production database — so the guard reports "empty, safe to wipe" about a live system. Count with a path that bypasses the filter (a role with
BYPASSRLS, orSET LOCAL row_security = offwhere the role may); if the check cannot be made to see past the policies it must fail closed and refuse, never report empty. An emptiness test that can be filtered is not a guard. Two minutes to check an existing script: run its own count as the application role, with no tenant context, against a database you know has rows. (Gpipeline instruction corrected 2026-09-13;Ctwist 54–55;P61) -
69kpfm
wbsp db dumpcannot back up a database whose tablesFORCE ROW LEVEL SECURITY.pg_dumpsetsrow_security = offso it can never write a silently partial dump, and with that set any query a policy would filter raises instead of returning less. Where the application's own role owns the tables (it ran the migrations) FORCE removes the owner's exemption and the dump fails outright — so the backup you were told to take before a destructive step is not available to you. That ispg_dumpbeing careful. A workable backup lifts FORCE, copies every table, restores FORCE and commits only at the end, inside one transaction, so no other session sees the tables unforced and any failure rolls back. (Ctwist 54;P61)
D. Environment and secrets
-
q2eh8v Per-destination
destination.twist.envcarries the placement configuration, not the top-levelenv:: the issuerhttps://hap.wbsp.ai/t/twist, the publichttps://application URL under the app's own name, any "trust the proxy host" flag the framework needs (AUTH_TRUST_HOST=truefor Auth.js), and a destination marker so the login bypass is refused. (G§5, §6;Ctwist 15;P126, 169) -
8uqf6h Only
env:andsecrets:reach the container. A key that sits in.env.twistand is named by neither is read and dropped; a value already carried byenv:is not demanded again as a secret. (R;Ctwist 16) -
694ktr Secrets are declared by reference, and per destination where a value exists only for twist. Per-destination
secrets:fold into the top-level map (destination wins) but never subtract from it, so a secret declared underdevdoes not reachtwistunless repeated, and a secret declared at top level also bindsdemo, which fails closed on values the platform supplies itself. (R;Ctwist 17;P42) -
hfxvkg Every declared secret has a value the platform will hold for this installation: in the git-ignored
.env.twistbesidewbsp.yaml, or set once withwbsp config set --destination twist.wbsp config status --destination twistreports the verdict the deploy will use. A declared secret with no value fails the deploy closed; avalue_specsentry withrequired: falseis omitted instead — entirely absent, never present-and-empty. (R;G§5;Ctwist 18) -
f9pwnv A deploy records a placeholder as a real value, and every status command calls it complete:
wbsp config statusreports a secret "set" whether the value is real or the template'sREPLACE_WITH_…string. The failure surfaces only at the token exchange, as401 invalid_client, and only when somebody signs in. Where a secret is transcribed by hand, check the file before the deploy. (Ctwist 49;P123) -
2ag7nu A committed
.env.twist.exampleexists with placeholders only — no shared test-HAP values, since that client accepts localhost redirects only — and says which values are placement configuration living inwbsp.yamlrather than in this file. (G§5;Ctwist 19) -
52r4ng No
WBSP_RDS_*,WBSP_REDIS_*,WBSP_ACME_*orAWS_PROFILEis needed in.env.twistfor the credential-less flow; they belong to the operator's--backdoorpath. Present ones are ignored, not harmful. (D;Ctwist 20) -
enrrzu No login-bypass flag is on for twist — absent, or stated off — and the code refuses it on a cloud destination regardless. (
G§6;Ctwist 21; see the ready-to-test page) -
u7c5ef
DATABASE_SSLis injected on cloud destinations only, so the branch that reads it has never run when you first deploy — and the spelling is per driver. The platform injects a boolean; drivers want a libpq word under different parameter names (sslmode=requirefor psycopg2,ssl=requirefor asyncpg, which rejectsssl=trueoutright). A project that builds one connection string for both drivers and interpolates the boolean cannot open a single connection on its first cloud destination, which presents as a deployment that comes up and serves nothing. Test the value against the driver's own parser, not against a literal —assert "?ssl=true" in dsnholds precisely while the application cannot connect. Userequire, notverify-full: the provider CA is not in the default trust store and a tunnelled connection presents a certificate naming the real database host. (RDATABASE_SSLonaws;Ctwist 51;P61) -
qfsq66 A value arriving is not the same as the mechanism that consumes it working — test the consumer, not the file. A test asserting the file holds the right value passes throughout. Two shapes worth checking by name: an env file folded into the environment after the argument parser was constructed can never reach a default, so a required value reads as missing and an optional one silently falls back to a synthesised default; and a "this is a cloud destination" guard derived from a list of cloud names lets an unrecognised destination fall through to the local path — derive such a guard from the list of local names so anything unrecognised fails safe as cloud. (
Ctwist 52;P61) -
abdxev A committed
.env.<destination>.exampleis an instruction, not a placeholder list, and whatever it leaves unsaid is copied literally into the one file nobody else reviews. Wherever a value is not free-form it states: what each sentinel means and when it stops applying (a "set this tonone" that is correct for one configuration is a defect in every other); the shape of the value where shape matters (a host root with no path, when the code appends one); and whether a value is used verbatim — an identifier that falls back to a default only when empty passes a non-empty typo straight through to the provider. A declared secret with no value at least fails the deploy closed; a plausible wrong value does not. (G§5;Ctwist 53;P61) -
5vgpqq If the application calls an AI model, it reads exactly
AI_PROVIDER(anthropic|openai|ollama|none),AI_API_KEY,AI_BASE_URLandAI_MODEL, withAI_MODEL_<ROLE>for per-role overrides andAI_EMBEDDING_*only where embeddings use a different provider — not anLLM_*spelling, and not a key per provider besideAI_PROVIDER. One factory selects the client; nothing else branches on the provider. Availability is the selected provider's own requirements, never "a key is present":ollamaneedsAI_BASE_URLand no key. WithAI_PROVIDERabsent ornonethe model features are off and say so, and the health endpoint reports it; the application does not crash. (G§5 The AI provider contract;Phil2026-09-15;Cnote 2026-09-15;P1, 61, 169, 301, 439) -
9avsyc
AI_API_KEYis declared with a value specsource: operator, required: false, and is in nogroup. Without the spec a deployment that correctly has no key —ollama, ornone— is refused, because a declared secret with no spec is required; inside a group with the otherAI_*values, a keylessollamaconfiguration reads as half-supplied and is refused the same way.AI_PROVIDERandAI_MODELsit indestination.twist.env:, not the top-levelenv:, whichdemoalso reads. (G§5 The AI provider contract;Xvalue resolution: required-without-spec, all-or-nothing groups;Cnote 2026-09-15)
E. Identity
- 22amdb A HAP client exists in tenant
twistfor this application, registered before the first sign-in, carrying this destination's callback verbatim —https://<app>.twist-hosting.com/<framework-standard path>— with scopesopenid profile emailplusoffline_accessif the library asks for a refresh token. A repository deploy registers nothing; HAP matchesredirect_urias an exact string; the shared test client is localhost-only; another project's twist client is a different host and fails at the callback. (G§6;Hhap-registering-an-application;Ctwist 22) - 4m6t37 The project cannot register its own client: registration needs a tenant administrator of
twistor an admin key bound to that tenant, and an ordinary account in the tenant confers nothing. The shape is: the project asks, the tenant administrator registers, the credentials come back. Afterwards the project may be made application administrator of that one client — able to edit, rotate, disable and delete it — without gaining the power to create another. (HHAP's answer of 2026-09-08;Ctwist 38) - cwhbmr The registration request the project sends, one line each so it can be registered verbatim:
name(permanent, shown in audit rows);application_type: web;confidential: true(this, not the type, is what mints a secret);redirect_urisexact;scopes(requested must be a subset of registered or authorize refuses withinvalid_scope);home_urlhttps://<app>.twist-hosting.com/(without it the app is an unopenable row on everyone's menu);access_modeopenunless a per-person allow-list is wanted. Scopes cannot be changed through the API afterwards, only in the console; post-logout redirect URIs cannot be set at creation, only by a later edit. (H;Ctwist 39) - 5spzja The secret is shown once, at creation, and can never be re-shown. A lost secret is replaced by rotation, never recovered. Replacing a working secret: rotate with an overlap so both work while the project redeploys. Replacing a lost one: rotate with no overlap, which also revokes that application's tokens and sessions. Local token validation checks the issuer
https://hap.wbsp.ai/t/twist, not the audience alone, because aclient_idis unique only within a tenant. (H;Ctwist 40) - j6shsa Nothing reads
nameoremailoff the verified ACCESS token. It carriessub,aud,iss,expandscopeand no profile claims, on every destination and for every user, whatever scopes were requested — so an API holding no ID token reads them from{AUTH_ISSUER}/oauth2/userinfo, where a row is about to be written rather than on every authenticated request, and treats a failure there as a missing name rather than a failed sign-in. Checked on two live deployments that were storing an invented name and a null respectively. (G§6;Ctwist 63) - fdfpvt If the application provisions a user row on first sight, names already written are checked, not assumed: that path writes the display name once and then looks the user up by subject, so every row created before an identity fix keeps whatever was wrong — an invented name, an empty string, a null. Refresh name and email when they differ from what the provider reports; never refresh the role that way. (
G§6;Ctwist 64) - x5pzxx Changing a redirect URI later is an edit on the existing client, never delete-and-recreate; an edit revokes nothing. Register the new URI before the application starts sending it, keep both during a path change, then drop the old. (
H;Ctwist 41) - ead6f7 The client id and issuer live with the secret in the twist configuration — per-destination
env:or.env.twist— never inherited from a top-levelenv:that another destination also reads. (Ctwist 23;P42) - ww7beb The callback is built as
https://, nothttp://: the platform terminates TLS and forwards plain HTTP, the ingress answers plain http with a 301, and an http callback never matches. Frameworks that derive the callback from the request host pin the public URL and trust the proxy. (G§6 Build the callback as https;Ctwist 24) - h9wkvb
auth.callbackPath(andauth.scopeswhen non-default) is declared in the manifest, so the redirect URI to register is stated rather than guessed, andwbsp deploy --dry-runprints the scope set that will be registered. (G§5 The identity contract, §6;Ctwist 25) - abea94 Outside demo and sandbox, the user menu's
Exitforwards tohttps://hap.wbsp.ai/t/twist/exit, built from the configured issuer with/exitappended, after the application has ended its own session. (G§6 Show who is signed in;Ctwist 26) - 7wztq8 Leaving never returns to the application. A post-logout return to the application's own root — RP-initiated logout as the OIDC specification describes it — delivers the person, unauthenticated, to an application that correctly starts a sign-in, and presents as a sign-in fault (
invalid-stateon the next attempt) rather than a sign-out one. Checked from outside with no session, by following every hop of theExitroute: the chain reacheshttps://hap.wbsp.ai/t/twist/exitand no hop names<app>.twist-hosting.com, including inside an encodedpost_logout_redirect_uri. A test that greps the source for the logout call proves nothing about where the person ends up. (G§6 Leaving never returns to your application;Cnote 2026-09-15;P439) - wgmpr9 The
redirect_uriis sent twice — on the authorize request and on the code exchange — and HAP compares the two as exact strings. Both are built from the same pinned value. A client library handed the incoming callback URL derives the exchange's value from that URL instead of from configuration, and inside a Next.js standalone container that URL has been observed as the server's bind address (http://0.0.0.0:8080/…), so every sign-in is refused withinvalid_grantwhile the readiness endpoint reports the right callback. Rebuild the URL from configuration and take only the query string from the request. (G§6 The redirect_uri is sent twice;Cnote 2026-09-15;P5) - ps8hch A callback whose pending sign-in state is missing restarts the sign-in once, carrying a short-lived marker, and renders a page on a second miss in a row — never a problem page at the end of a provider redirect, where nothing starts a sign-in and the back button is the only way on. (
G§6 Make identity resolution resilient;Cnote 2026-09-15;P439) - bz52ys Know what omitting
consent=granteddoes; sending it is a choice, not a requirement. It asserts first-party consent — correct for your own application in your own tenant, wrong for a broker. Omitting it on a client with no consent on record asks the person rather than looping (the loop was fixed in 2026-08), which on a first sign-in is a screen the visitor did not expect and on an established client is invisible. Send it if a visible consent prompt on a newly registered client would be unacceptable; do not send it because a checklist said so. (Hhap-hosted-login-guidelines,hap-sso-client-guidelines;Ctwist 48 as corrected byP45, 2026-09-12)
F. Account and authority
- t8c869 The deployer holds a signed-in session for tenant
twist:wbsp login --tenant twistcreates it, cached per tenant, and a deploy to a destination declaringtenant: twistpresents that session rather than the website's.wbsp whoamireports one session — the tenant the project's destinations agree on, else the website tenant — so inside a project with several tenants itstenants:line says nothing about twist;sessions:lists which tenants have a cached login. The deployer role in tenanttwistis granted by that tenant's administrator. (D;Xtoken cache per tenant;Ctwist 27, corrected 2026-09-08) - jafxu5 The variant is owned by, or shared with, the signed-in account: the one-writer check refuses a deploy it cannot verify, and
--allow-unverified-owneris the operator override, not the fix. (DTroubleshooting;Ctwist 28) - ddubz2 The installation is not operator-managed by accident: nothing has been stored with
wbsp config setfor this installation unless that was the intent. If it was, the deploy path is refused andwbsp config remove-installationis the only way back. (Ctwist 29; digest, production phase) - ejche6
wbspis current (wbsp --version).wbsp deployno longer takes--sandbox,--universalor--publish-only; the destination name says what is published. (D;Ctwist 30) - w3reft Before spending a deploy on a rotated secret, test it: a token request carrying a deliberately invalid code answers
401 invalid_clientwhile the secret is wrong and400 invalid_grantonce it is right, because HAP rejects the client before it looks at the code. A successful deploy, a healthy pod and a "complete" config status are together still consistent with nobody being able to sign in. (Ctwist 50;P123)
G. Runtime shape on one container
- t2eueq The application runs as one container: a worker, scheduler or queue consumer runs inside the same process or is declared as a component. A "dedicated worker" mode with no second container queues forever. (
R;Ctwist 31;P169) - 282gv8 Logs go to stdout and stderr as structured lines; the platform collects them. (
G§7;Ctwist 32) - 2uymvp Behind the path-prefix router
Locationheaders are not rewritten: redirects are relative, or built from the pinned public URL. (G§2 viapath-prefixes.md;Ctwist 33) - vkeeze A Next.js standalone image sets
HOSTNAME=0.0.0.0in its runtime stage, for the reason given on the ready-for-sandbox page (item as7xfn): the fault hides onaws, where the pod is probed by address, and surfaces on every local type. (Ctwist 47;P45)
H. Publishing and after the deploy
-
yx6hdb
wbsp deploybuilds from the working tree. Before any publish the project readsgit statusand records it, and publishes from a clean tree or a checkout of the intended commit, stating the commit. The-dirtysuffix is honest fromwbspv0.2.45; on v0.2.44 and earlier it appears on every demo or sandbox publish regardless (see the ready-for-sandbox page, c4a442). (X;Ctwist 43;P123) -
86c5d9
wbsp deploy --destination twistends with the placement reported;wbsp list --provider aws --tenant twistshows the apprunningwith its route;https://<app>.twist-hosting.comanswers 200 (or a redirect to sign-in that answers 200) and the health path answers. (D;Ctwist 34) -
yvegw3 One real sign-in through HAP on tenant
twistcompletes — nothing before the deploy can prove the redirect URI is registered, and the only proof that a client secret is right is one completed sign-in. The tester is a member of tenanttwist; the first arrival is provisioned with the ordinary default role; admin follows HAP's claims. (G§6;Ctwist 35, 49;P42) -
pjs7et The project has told nobody outside its repository to do anything: the HAP client registration, tenant membership and deployer role are requests to the tenant's administrator, carried by the operator, never instructions to a core project. (
G§9;Ctwist 36) -
pjdnma A deploy can report success and change nothing that is running, and a failed deploy can already have changed it. Where the workload references a moving tag, moving the tag does not tell the cluster to pull it again — the command completes, prints a normal status and the pods go on serving what they were serving. The inverse also happens: a deploy that fails at the route step has already rolled the new image out. The exit status describes the command, not what is serving. Verify by looking at what is served, through something that changes between builds: a versioned asset's hash, a version string the build stamps in, a value the health endpoint reports. Give it long enough that you are not reporting a race, then believe what you see. (
Ctwist 56;P45, 61)
Retired
None yet.