This is the hosted door for Vivijure Studio. It is the software that lets someone sign up, agree to the rules, and get their own working film studio on our shared render pool a few minutes later, without ever installing anything or pasting a RunPod key.
It is a single Cloudflare Worker. It runs separately from the studio itself, and it is released and deployed on its own schedule.
Vivijure Studio is free software you run yourself. That is the main way to use it, and it always will be. This repository exists so that the other way, where someone runs the control panel for you, is built in the open too.
The hosted tier sells convenience, never capability. There is no community edition, no pay-gated feature, and no hosted-only trick. Hosted and self-hosted ship the same things at the same time.
That is not a promise bolted on top of the code. It is a property of how the code works: this control plane installs the published studio release, unmodified. There is no separate hosted version of the studio that could quietly drift away from the one you can download. Anyone may run a competing hosted Vivijure from exactly this source, and that is fine by us.
It owns accounts, sign-in, the rules gate, tenant records, provisioning, and the admin switches.
It owns no film data at all. Your projects, storyboards, cast, renders, and finished films
live in your own studio, on your own database and storage. They never pass through here and
are never stored here. That boundary is enforced by a test (schema-guard), not just by a
sentence in a document: the build fails if a studio table ever appears in this repo's migrations.
When you sign up, you do not get an account inside one shared studio. You get your own complete studio: your own Worker, your own database, your own storage bucket, your own secrets.
We did it this way on purpose. The studio is built to be run by one operator, and that assumption makes a lot of the code simpler and safer. If we had bolted a "which user is this?" column onto every table instead, the hosted version would behave differently from the self-hosted version, and the two would drift apart forever.
Instead, every tenant simply is the operator of their own studio. Every assumption stays true. Nothing forks.
flowchart TD
V["A visitor<br/>studio.example.com"]
subgraph CPBOX["Control Plane Worker (this repo)"]
CP["Front door + onboarding UI<br/>public/"]
AUTH["Sign-in<br/>magic link, Google, GitHub"]
GATE["AUP gate<br/>versioned + blocking"]
PROV["Tenant provisioner"]
ADMIN["Admin switches<br/>suspend, signups on/off"]
end
CPDB[("Control-plane D1<br/>accounts, tenants,<br/>AUP acceptances, jobs")]
subgraph BUNDLE["Studio bundle contract (arms length)"]
R2M[("R2 release mirror<br/>studio-releases/tag/")]
PIN["Pinned release<br/>tag + manifest_sha256"]
MAN["The manifest carries:<br/>worker + assets + modules<br/>+ migrations + required_vars"]
end
subgraph NS1["Dispatch namespace: tenant studios"]
T1["tenant-alice-studio"]
T2["tenant-bob-studio"]
end
subgraph NS2["Dispatch namespace: tenant modules"]
M1["alice: keyframe, own-gpu,<br/>finish-upscale,<br/>finish-rife"]
end
subgraph RP["Alice's own RunPod account"]
E1["4 GPU endpoints<br/>she pays for, she owns"]
end
D1T[("Alice's own D1")]
R2T[("Alice's own R2")]
U["Alice<br/>alice.studio.example.com"]
V --> CP
CP --> AUTH
AUTH --> GATE
GATE --> PROV
ADMIN -.-> PROV
CP --- CPDB
R2M --- PIN
PIN --- MAN
PROV -->|"reads the pinned,<br/>integrity-checked bundle"| R2M
PROV -->|"uploads the studio"| T1
PROV -->|"uploads module workers"| M1
T1 -->|"MODULE_DISPATCH"| M1
T1 --- D1T
T1 --- R2T
M1 -->|"invoke key B,<br/>scoped to these 4"| E1
U --> T1
Read it in one line: a visitor signs up at the front door, the provisioner builds them a studio out of a published release artifact, and that studio talks to GPUs on the customer's own RunPod account.
This is the flow the onboarding pages in public/ walk a person through.
- Sign in. Magic link by email, or Google, or GitHub.
- Agree to the rules. The Acceptable Use Policy. This blocks everything else until it is done, and the acceptance is recorded against a known account.
- Pick a name. This becomes both your web address and the internal name of your studio.
- Paste key A. A RunPod key that can create things. We use it once and never store it.
- We build your studio. Database, storage, the studio Worker, four GPU endpoints, and five render modules. You can watch the progress.
- Mint key B in the RunPod console. A weaker key that can only run the four endpoints we just made for you.
- Paste key B. We check it, store it on your studio, and you are live.
This is the heart of the security story, so it is worth being plain about it.
| Key A (provisioning) | Key B (stored) | |
|---|---|---|
| What it can do | Create things across your whole RunPod account | Run your four endpoints, and nothing else |
| How long we hold it | Used once, in memory, never written down | Stored as a secret on your own studio |
| If it leaked | Your whole RunPod account | Four endpoints you already own |
Why two steps and not one? RunPod keys can only be made by hand in their console, and a key cannot be limited to endpoints that do not exist yet. So key B is physically impossible to make until key A has already created the endpoints. The two-step flow is not us being fussy; it is the only order that works.
Key B is checked before it is ever stored. If it turns out to be powerful enough to reach RunPod's admin interface, we refuse it outright and tell you exactly why, because storing a powerful key would throw away the whole point.
The honest cost. Because we never keep key A, a build that fails on the RunPod side cannot
restart itself. It asks you to paste key A again (409 runpod_key_required). Steps on the
Cloudflare side resume with no key at all. We think never holding the powerful key is worth that.
| Method | How it works |
|---|---|
| Magic link | Sent through postern. The sender identity is tied to our token, so we never choose a "From" address |
| OIDC with PKCE, hand-rolled | |
| GitHub | OAuth, hand-rolled |
| Apple | A seam only. It appears the day the Apple credentials are configured. No code change needed |
The front door does not hardcode a list of buttons. It asks GET /api/platform/config which
methods are actually configured (both the public id and the secret present) and renders that
list. A half-configured provider is simply absent, never a broken button.
The one rule that matters most: a sign-in provider can only reach an account when it says the email address is verified. Without that rule, anyone who could set an unverified email anywhere could take over the matching Vivijure account.
Sessions use __Host- cookies, HttpOnly and Secure. __Host- matters here specifically because
tenant studios are neighbouring subdomains.
Versioned, blocking, and logged, and it sits in front of provisioning from day one. No studio can exist without a recorded acceptance by a known account.
The gate asks "has this account accepted the current version?", never "has this account
accepted anything?". So raising AUP_VERSION re-asks everyone the next time they show up, with
no migration and no backfill. A simple yes/no flag would silently wave every existing account
through changed rules, which would make the record worthless.
Acceptance records store a hash of the IP address, not the address itself. The record needs to prove who agreed to what and when. It does not need to be a location database.
Your chosen name is both a web address label and an internal script name, so it is checked once against the letters both of those allow, plus a reserved list so nobody can register a name that impersonates one of our own pages.
A tenant has a lifecycle: pending, provisioning, awaiting_go_live, live, failed,
deleting, deleted. Suspension is a separate flag, not one of those values. Reads still
accept the dead name awaiting_invoke_key; writes never produce it.
That separation is load-bearing and we learned it the hard way. Storing "suspended" in the lifecycle column destroys the value it overwrites, so resuming has to guess where to go back to, and guessing "live" once promoted a studio that had never actually been built. Two independent facts need two independent columns.
A freshly built studio with four GPU endpoints still cannot render anything, because the endpoint ids are read by module workers, and nothing creates those. So the provisioner creates them, the same way a self-hosted studio does.
Hosted module workers (keyframe, own-gpu, finish-upscale, finish-rife, and the rest of
the catalog) are uploaded into one shared dispatch namespace, named with the tenant id in front so
cleanup is a simple sweep. Each one carries only its own endpoint id. Hosted has never provisioned
finish-lipsync, and as of cp#517 that module is retired outright rather than left to self-host:
MuseTalk is ruled out as a lip-sync provider. Lip-sync itself is not retired -- infinitetalk serves
it as an audio-driven motion door. Which module maps to which endpoint is
plain data in the catalog, so extending the tier is a row in a table, not new code.
The studio then installs them through its own install route, running its own real conformance check. No install logic is copied into the control plane.
This works even though key B does not exist yet, because the conformance check never triggers real GPU work. Key B is added afterwards, in place, to the studio and to every module.
Protected by a bearer token, compared in constant time. If the token is unset there is no admin surface at all, rather than an open one. Every action is recorded, and a suspension without a stated reason is refused, because a kill switch has to stay attributable.
- Suspend or resume a tenant. Pulls their routing immediately, no matter what their own studio thinks.
- Turn signups on or off. Stored in the database, not in config, so it flips without a deploy. It closes the door to new accounts only. It never locks out someone who already has an account, and by ruling it does not block provisioning for a person already partway through setup. The toggle aims at the front door, not at people already inside it.
There is no cap on the number of tenants. Storage spend is the meter that matters.
Everything below is declared in wrangler.toml.example and mirrored by hand in the Worker's
Env interface. The account id is never hardcoded.
You do not edit those values into a file by hand for a real deploy. The rendered wrangler.toml is
built at deploy time from repository variables and secrets, so the live configuration lives in one
place instead of in somebody's working copy. See docs/deploy.md for which name
is a variable, which is a secret, and which are allowed to be empty.
| Setting | Kind | What it is |
|---|---|---|
CONTROL_PLANE_HOST |
var | The single hostname fact. The front-door address and the tenant address suffix are both derived from it, so they cannot drift apart |
AUP_VERSION |
var | Current rules version. Raising it re-asks everyone |
AUP_URL |
var | Where the rules text is served from. Point this at an immutable ref, never a moving branch |
POSTERN_SEND_URL |
var | The email door for magic links. A URL, not a secret |
GOOGLE_OAUTH_CLIENT_ID |
var | Public half of Google sign-in |
GITHUB_OAUTH_CLIENT_ID |
var | Public half of GitHub sign-in |
APPLE_TEAM_ID, APPLE_SERVICES_ID |
var | The parked Apple seam. Empty means Apple is not offered |
PAYPAL_CLIENT_ID |
var | Public half of the PayPal checkout rail. Empty means the rail is not offered |
PAYPAL_WEBHOOK_ID |
var | PayPal webhook endpoint id for POST /api/webhooks/paypal |
PAYPAL_ENV |
var | live or empty/sandbox. Empty is sandbox |
PAYPAL_CLIENT_SECRET |
secret | Private half of the PayPal rail. wrangler secret put from a chmod 600 file |
CF_ACCOUNT_ID |
var | Needed at runtime, because a Worker cannot read its own account id |
DISPATCH_NAMESPACE |
var | Name of the tenant-studio namespace. Must match the binding |
TENANT_MODULE_NAMESPACE |
var | Name of the shared tenant-module namespace. Created if missing, but required |
STUDIO_RELEASE |
var | The pinned studio release every new tenant gets. Floor is v1.3.1; earlier artifacts are refused (see below) |
CP_DB |
D1 | Platform data only |
TENANT_DISPATCH |
dispatch namespace | Where tenant studios live |
STUDIO_RELEASES |
R2 | The release-artifact mirror |
CP_RATE_LIMIT |
rate limit | Throttles the outbound email path |
POSTERN_SEND_TOKEN |
secret | Bearer for the email door |
GOOGLE_OAUTH_CLIENT_SECRET |
secret | Private half of Google sign-in |
GITHUB_OAUTH_CLIENT_SECRET |
secret | Private half of GitHub sign-in |
APPLE_PRIVATE_KEY |
secret | The Apple .p8, parked with the rest of the seam |
CONTROL_PLANE_ADMIN_TOKEN |
secret | The admin gate. Unset means no admin surface |
CF_PROVISIONER_TOKEN |
secret | Mints tenant databases, storage, and scoped credentials |
STUDIO_TOKEN_KEK |
secret | Encrypts every tenant's stored studio token at rest |
STUDIO_TOKEN_KEK_NEXT |
secret | The INCOMING key during a rotation window (cp#95). Absent = no window open |
STUDIO_TOKEN_KEK_ENCRYPT_SLOT |
var | Which installed KEK new ciphertext is written under: primary (default) or next |
Provisioning refuses politely when it is not fully wired. If any of CF_PROVISIONER_TOKEN,
CF_ACCOUNT_ID, DISPATCH_NAMESPACE, TENANT_MODULE_NAMESPACE, STUDIO_RELEASE, or the
STUDIO_RELEASES binding is missing, the provision routes answer 503 provisioner_unconfigured.
Parking someone on a job that nothing will ever run is a lie with a progress bar.
This is the seam between the two repositories, and it is deliberately arms length. There is no shared source code.
- Vivijure Studio (
vivijure-cf) builds a release artifact and publishes it into an R2 mirror atstudio-releases/<tag>/, along with the module bundles atstudio-releases/<tag>/modules/<name>/. - This control plane pins one exact release: a tag plus a
manifest_sha256. - At provision time it reads that pinned bundle out of the mirror and checks the hash before using it.
The top-level studio manifest.json is the contract for the studio artifact (and the pin
manifest_sha256 covers only that file). Module bundles are co-published under the same tag tree
at modules/<name>/ but are self-anchored: each module has its own manifest.json with
worker.sha256, which this plane re-checks at provision (cf#147). A tenant may also pin modules to
a different release than the studio (cf#103), so the top-level digest deliberately does not claim
to cover module bytes.
| What ships under the tag | Where integrity is anchored |
|---|---|
| the studio worker | top-level manifest.json → worker.sha256 (in the pin) |
| the studio assets | top-level manifest asset hashes (in the pin) |
| the modules | per-module modules/<name>/manifest.json → worker.sha256 (not in the studio pin) |
| the studio migrations | top-level manifest (in the pin) |
required_vars |
top-level manifest (in the pin); we read that list rather than keeping our own copy |
The pin has a floor: v1.3.1. Migrations and required_vars only started riding the manifest
at that release, so an earlier artifact cannot describe a tenant fully enough to build one. The
control plane refuses a pre-v1.3.1 artifact outright rather than provisioning a studio it cannot
finish. Code floor: MANIFEST_PIN_FLOOR in src/bundle-r2.ts. The live pin is the operator-set
STUDIO_RELEASE env var and must be at or above that floor (CHANGELOG records pin advances; recent
narrative includes studio v1.12.0 for storage quota and plan-enhance).
That last row is the important one. If this repo kept its own idea of what a tenant studio needs, the two lists would drift apart the first time the studio gained a setting, and the failure would show up as a broken tenant rather than a failed build. Reading the declaration means a studio that needs something new says so, and provisioning refuses honestly when it cannot supply it.
A prerequisite worth stating plainly: the mirror bucket must exist and the pinned tag's artifact must already have been published into it before any provision can succeed. If it has not, the provision fails honestly at the upload step rather than half-working.
studio.example.com -> this Worker -> the front door (signup, account, status)
alice.studio.example.com -> this Worker -> dispatched to alice's studio
The front door is a Custom Domain. The tenant leg is a wildcard route, which needs two things live on the zone or it does not serve at all: a proxied wildcard DNS record, and a certificate covering the wildcard. A second-level wildcard is not covered by Universal SSL, so it needs an Advanced Certificate Manager pack.
Tenant hostnames are always children of the front-door hostname. That is the ruled shape and it holds for any self-host too.
One warning about serving assets: run_worker_first = true is load-bearing on this Worker.
Without it, static files are served straight from the edge and skip the Worker entirely, which
meant a suspended tenant's home page served the front door instead of a refusal, and a live
tenant's studio root served the front door instead of their studio. Both were found on a real
deploy. No unit test can see this, because the test suite never touches the asset layer.
Each repository versions and deploys its own product. The tags do not have to agree and are not meant to:
- a
v*tag here deploys the control plane - a
v*tag invivijure-cfdeploys the Studio panel
That split is deliberate. Before the extraction one tag did both jobs, so a Studio release and a control-plane release could not happen independently even when they had nothing to do with each other.
The control plane deploys from a SemVer tag, and only from a tag. There is no deploy-from-main path, and no deploy-from-a-laptop path.
# bump BOTH; the pipeline refuses if they disagree
# package.json "version"
# src/version.ts CONTROL_PLANE_VERSION
git commit -am "chore(release): v1.2.3"
git tag -a v1.2.3 -m "control plane v1.2.3"
git push origin main --follow-tags.github/workflows/deploy.yml then runs. It is two jobs with a gate between them, which is the
point: everything that can refuse the release happens before anything writes.
preflight (reads and checks only, writes nothing):
- install, typecheck, tests, config-render guards
- tag and version must agree, or it refuses
- render
wrangler.tomlfromwrangler.toml.example - report pending migrations
- a dry run stops here
release (the writes):
- apply the D1 migrations
- verify nothing is still pending, and fail the deploy if it is
- deploy the Worker
The config is rendered in both jobs rather than passed between them. A rendered wrangler.toml
contains live configuration, and handing it between jobs as an artifact would put that on the
workflow's storage for anyone with read access to the run.
Deploying first leaves a window where the new Worker code is running against the old schema, which is exactly how this system broke before. Migrating first is safe here because control-plane migrations are additive: old code tolerates a column it does not know about, whereas new code cannot tolerate a column that is missing.
That reasoning is the whole justification, so it has a limit worth stating. If a migration is ever non-additive (a drop, a rename, a narrowing), this ordering becomes wrong and the change needs a two-tag expand-then-contract instead. Change the migration, not the ordering.
Step 6 exists because step 5 exiting cleanly only proves it did not error; it does not prove the ledger now matches. A partial apply must never reach the deploy wearing a green checkmark.
No hand-applied schema, ever. Schema reaches the live database through step 5 or it does not reach it at all.
This is not a style preference. The previous control-plane database was built by hand, so one
migration went in raw, one was skipped, one was applied after the fact, and there was no ledger to
notice any of it. Two live provisioning failures came out of that single gap. The repository's own
schema-guard test cannot catch this class, because it compares code against migrations/ and never
against the deployed database. Only the deploy job can.
On a release with no schema change, steps 4 to 6 print No migrations to apply. That line is not
noise; it is the evidence that the ledger and migrations/ agree.
Dispatch the workflow manually with dry_run: true (the default) to render the config and report
pending migrations, then stop. Nothing is migrated and nothing is deployed. Use it to confirm the
repository variables and secrets are populated before cutting a real tag, because a tag is a
public act and a failed one is awkward to take back.
Typecheck will not catch any of these. Only a real deploy will.
- the tenant dispatch namespace must already exist, or the binding is dangling and the deploy fails outright
- the studio-releases R2 bucket must exist and hold the artifact for the pinned
STUDIO_RELEASE, or provisioning later fails at the upload step - the wildcard tenant leg needs a proxied wildcard DNS record and a certificate pack covering
*.<CONTROL_PLANE_HOST>
Full mechanics, including every required variable and secret and why exactly four of them are
allowed to be empty: docs/deploy.md.
npm run typecheck # the CI gate. Run it before pushing
npm test # unit tests
npm run guard:resolve # checks every function the pages call actually exists
npm run dev # live, against a real local databaseThe front end here is vanilla JavaScript, HTML, and CSS. No framework, no build step, no CSS
preprocessor. That is deliberate. Check your JavaScript with node --check.
On testing, one rule we paid for. The in-memory store proves decision paths only. It encodes our own assumptions and will happily agree with a bug in our own SQL. The real database seam is verified against a real D1. Both halves are required, and the live pass is what caught the suspension bug described above.
The Acceptable Use Policy, the privacy details, and the rest of the hosted legal scaffolding live
in docs/legal/hosted/.
The one red line is absolute: Vivijure is not used to create child sexual abuse material or nonconsensual intimate imagery. Synthetic and AI-generated content is explicitly included, with no "it is not real" exception. This is the single exception to an otherwise hands-off privacy posture.
Vivijure is not one program. It is a small group of programs that work together.
- vivijure is the hub, and the full map lives there.
- vivijure-cf is the Studio itself on Cloudflare. Self-host it and you never need this repo at all.
- vivijure-control-plane (this repo) is the hosted door in front of it.
- vivijure-backend is the GPU render engine on RunPod.
It is called the control plane, never the "platform". The Studio already has a src/platform/
that means something else entirely, and colliding with it would trap the next person to read this.
zone-security/ holds the vivijure.com zone protections (WAF and friends) as code. Nothing there
is configured by hand in a dashboard: the script in that directory is both the record and the
mechanism. It rides with this repo because the hosted door is what those protections are actually
protecting.
Extracted from vivijure-cf per issue #85, so
that people running the Studio themselves are not carrying hosted-service machinery they will never
switch on. See NOTICE for provenance.
AGPL-3.0-only, like everything else in the constellation. See LICENSE.