feat: vta only custom stack - #22
Merged
Merged
Conversation
Lets a vta_only session point at any full_stack this farm provisioned instead of only the platform stack. The owner mints a share code and hands out a connection bundle; the consumer pastes it on create. Specification only — no code yet. Decisions that shape it: - In-farm only. step_dids_grant_farm already enrols vtafarm-api as an admin in every full_stack's daemon ACL, so those stacks are valid targets for free. A stack we did not provision would 401 on the first DID upload, so it stays out of scope. - The bundle is evidence, not configuration. The three values a session is built from are read from the provider's own row; the pasted text only names it and proves authorisation. Nothing a user types becomes a URL this server connects to, which is what keeps SSRF and the self-asserted-audience relay unreachable. - No revoke and no delete guard. A provider teardown destroys nothing of the consumer's, so there is no data loss for a hard gate to protect — only a surprise, which a confirmation dialog handles. The two had to go together: a 409 without revoke pins a stack forever. - Mediation is open to every DID, so the share code is the only gate. - A validate route, because a confirmation card rendered from the pasted JSON would show a green tick for a bundle whose code is garbage. Signed-off-by: vthwang <vthwang.work@gmail.com>
Two hardening changes to the existing DID-hosting paths, ahead of letting a vta_only session point at a stack other than the platform one. Both touch flows that run today; neither adds a new concept. Publishing the DID log used to log and carry on. That produced the worst available outcome: a session marked `running`, a green badge in the portal, and an agent whose did:webvh does not resolve, so it silently delivers nothing. It was survivable while the only way to reach it was a platform stack whose ACL entry had gone missing — an operator's problem on a path operators watch. It stops being survivable once an ordinary user can aim a session at any stack in the farm. Every failure in that block now fails the session: no DID log parsed, no vta_did_url, no client for the control URL, or RegisterDid itself. The one exception is an unconfigured keypair, which stays a warning — that is a deployment-wide state rather than a property of the session, runProvision already treats it the same way, and failing on it would break every local environment that runs without one. The upload deliberately stays after the vta_setup_complete write. Resume only re-runs sessions in vta_setup_running, which is what stops the upload happening twice; registerAtomic sends force=false and errors on any non-2xx, so a replayed upload would fail and — with this change — kill an otherwise healthy session. Comment added, because moving that write later now breaks something non-obvious. The crash window it leaves is pre-existing and written up in design §9.1. Factory.For also takes an expected server DID and refuses a daemon that reports a different one. The daemon's self-reported DID becomes the `aud` of every id_token we sign with the farm's admin key, so a host claiming somebody else's DID would receive a token it could replay there. Nothing needed this while control URLs only ever named daemons we provisioned. Empty means "no expectation on record" and accepts anything, which is every vta_only row until phase 1 backfills did_hosting_did. Cache hits are checked too, or one unverified call would disarm the check for that URL permanently. Signed-off-by: vthwang <vthwang.work@gmail.com>
Groundwork for letting a vta_only session connect to a full_stack other
than the platform one. Nothing user-visible: no route reads or writes any
of it yet.
The three values a vta_only is actually wired to are already per-session
columns (000023), so the migration adds only the grant and the link:
share_code on the provider, connection_source + provider_session_id on
the consumer.
share_code is one nullable column rather than a boolean plus a code,
because the two would always have to agree and the code alone answers
both questions — minting enables sharing, clearing disables it, replacing
invalidates outstanding bundles, and none of the three touch a session
already connected. It is stored in plaintext on purpose: it is a
capability its owner displays to themselves and reads aloud, not a
password, and rotation covers what hashing would without making the value
unrecoverable to the person who has to share it.
connection_source admits 'platform' and 'in_farm' only. A stack this farm
did not provision has no ACL entry for our client DID and would 401 on
the first DID upload, so the constraint states the same scope the code
does; adding 'external' later is a one-line ALTER.
provider_session_id is ON DELETE SET NULL, and that is the whole orphan
mechanism rather than a fallback. Deleting a provider blocks on nothing,
so no handler writes to its dependents — Postgres nulls the link in the
same transaction and
connection_source = 'in_farm' AND provider_session_id IS NULL
is permanently "the stack this agent connected to is gone", with no event
needing to have fired and no reconciler to drift.
Also widens did_hosting_did from a full_stack output to "the DID of the
daemon at did_hosting_control_url", and backfills it for existing
vta_only rows. That is what arms Factory.For's audience check for
sessions built before it existed. The backfill joins on the server URL
rather than assuming the platform stack, so a row whose daemon no longer
has a row keeps '' — no expectation on record — instead of being handed a
DID that was never its daemon's.
Share codes are Crockford base32 with a check symbol: the format is meant
to survive being read aloud, and one without a checksum is half-designed.
Normalisation folds the glyphs people substitute anyway (I/L to 1, O to
0) so every transcription of a code reaches the same constant-time
comparison, and the check symbol makes "you mistyped this" a different
answer from "this code does not open anything here" — two problems
needing two different actions, which would otherwise collapse into the
vaguest message in the flow.
Verified against a throwaway Postgres 18: all 25 migrations apply, the FK
and CHECK land as intended, deleting a provider leaves its consumer
running with a nulled link, the backfill skips rows whose daemon is gone,
and down/up round-trips cleanly.
Signed-off-by: vthwang <vthwang.work@gmail.com>
Names the function after the role rather than after the platform stack, so a provider named by a share code can join later without a second, parallel path to the same three values. Only the platform lookup exists today; the ref parameter arrives with the bundle branch. The judgement is split out of the I/O. providerInfra decides whether a candidate row is usable and touches no database, which makes it testable directly — there were no tests here at all — and means a bundle-named provider will be held to the same readiness bar rather than a second copy of it that drifts out of step. Writing those tests turned up two pre-existing bugs in the path being refactored. Both are fixed here rather than left under new code: The fail-open on a database error reached POST /setup. resolveSharedInfra returned ready=true with a zero sharedInfra when the lookup itself failed, on the stated reasoning that create re-reads the row anyway. It did not re-read — it used those empty values, so a transient DB error could persist a session with no mediator DID and a vta_did_url of "/<name>-vta". A lookup failure is now its own reason, and the two callers part company on it: availability still fails open, because a blip must not blank the create screen, while create refuses, because there the choice is between waiting and provisioning an agent that can never deliver a message. The ServerURL == "" guard was unreachable. It tested the output of DidsURL(), which always prefixes https:// and so is never empty; a provider with no dids hostname yielded "https://.", passed, and got snapshotted onto the session permanently. It now tests the two components the hostname is built from. New sessions also snapshot the provider's daemon DID, which is what arms the audience check from phase 0 going forward — phase 1's migration only covered rows that already existed. It is deliberately not part of the readiness bar: a platform stack provisioned before that column carried this meaning would otherwise stop serving vta_only creation, and an empty value means "no expectation on record", which is exactly the old behaviour. Also repairs a doc comment orphaned by phase 1, which left the domain-type commentary sitting on top of the connection-source block. Signed-off-by: vthwang <vthwang.work@gmail.com>
The provider half of stack connections. A full_stack owner mints a share
code and hands out a bundle; nothing consumes it yet, so this is inert
until the create path lands.
PUT /setup/:id/sharing takes one action — enable, rotate or disable —
rather than an enabled bool plus a rotate bool, which would make
{"enabled": false, "rotate": true} mean nothing in particular. enable is
idempotent: turning on something already on returns the current code
instead of silently invalidating every bundle already handed out.
Replacing one is what rotate is for, and it asks explicitly.
All three gate joining, not membership. None of them disconnect anything,
and the handler deliberately writes to no other user's row: agents
already connected keep running, and the stronger lever is deleting the
stack, which stops everyone. The confirms in the UI have to say so.
enable and rotate require a running stack that has published its mediator
and DID-hosting identifiers, so "sharing is on" never means "on, but
every bundle from it is refused". The platform stack is refused outright:
it is already the default for every vta_only agent, reached by a path
that sends no bundle, and a second way to arrive at the same place would
be a share code nobody needs but anybody could leak.
The bundle carries the three display values alongside the two that
matter, and buildConnectionBundle returns nil rather than a hollow
struct when a stack is not shareable — the UI must not be able to offer
a "copy this" for something that would be refused on arrival.
Responses grow what the topology needs: full_stack rows report `shared`
plus the dependent list that the delete confirmation will name, vta_only
rows report where their mediator came from and whether that stack still
exists. The admin session list gets the same, batched over the page so
20 rows do not become 20 round trips — support's first question about a
broken agent is whose infrastructure it is on, and the answer was
otherwise a URL-to-URL comparison across two queries.
listConnections returns nil rather than an empty slice when its read
fails. "Nothing depends on this" is the one answer that would mislead
someone about to delete a stack.
Both routes are in openapi.yaml; the spec parses and has no dangling
refs.
Signed-off-by: vthwang <vthwang.work@gmail.com>
The consumer half. POST /setup takes an optional connection bundle and wires the agent to the stack it names instead of the platform one; the backend is end-to-end after this. Resolution runs in two tiers, and the split is the point. Everything decided before the share code is verified answers identically — no such stack, a stack that never shared, one that turned sharing off, a rotated code, and a code that is simply wrong all return invalid_bundle with one sentence and one status. Distinguishing them would turn this into a directory of which stacks exist and which are shared, which is exactly what the share code exists to prevent, and from the holder's side they are one fact with one next step: ask for a current bundle. A failed database read lands there too, for the same reason — the alternative leaks that a name exists whenever a query happens to fail. Once a current code is proved, specificity is free, so the remaining refusals say precisely what is wrong: not running, changed since it was copied, or at its connection limit. The bundle stays evidence rather than configuration. The stack is found in this farm's own database and every value comes off that row, so a pasted URL never becomes a socket — no SSRF surface, and the self-asserted-audience relay stays closed by construction rather than by a check someone has to remember. POST /setup/connection/validate runs the same checks and creates nothing. Without it the create form's confirmation card would be rendered from the pasted JSON — showing a confident "connecting to alice" for a bundle whose code is garbage, discovered only after naming the agent, choosing an image and pressing Create. It is explicitly not authoritative: create re-runs everything, because a stack can stop running, rotate or fill up in between. connection_source is keyed on whether a bundle was sent, not on whether resolveProvider returned a row — it returns one on the platform path too, and a platform session must keep provider_session_id NULL or model.IsOrphaned would eventually read it as a provider that had been deleted. Availability splits as decided: available still describes the default path, and custom_target_allowed survives every reason except at_capacity, because the platform stack is a default rather than a prerequisite. A farm with no platform stack can still create a vta_only agent against a customer stack. A connection on a full_stack request is refused rather than ignored — that mode provisions its own mediator and DID host, and silently dropping the field would let someone believe their stack was wired to somebody else's. Tests cover every refusal that happens before the database is touched, which also asserts that malformed input never reaches a query. The tiers that need real rows are exercised in phase 8. Signed-off-by: vthwang <vthwang.work@gmail.com>
GET /setup carries connection_source and provider_gone for vta_only rows and connection_count for full_stack ones, so the portal can mark an orphaned agent without opening it. Needed because an orphan's status stays `running` — it is running, and nothing of its own was touched when its provider went away. Nothing else on a list row would give it away. Signed-off-by: vthwang <vthwang.work@gmail.com>
Replaces the JSON connection bundle with a single code. The sharer sends one value; the recipient pastes one value. Nothing was lost, because nothing else in the bundle was doing work. The stack name is redundant now that the code is globally unique — a new index enforces that, which is what lets one lookup find the stack. The three DID/URL fields were only ever compared and never used; the row they came from has always been authoritative. The farm name only turned one generic refusal into a specific one. The kind/version tags were doing a job the code's own check character already does. It also removes a hazard rather than only weight. With a bundle, a client *can* parse it and render "connecting to alice, mediator did:webvh:…" the moment it is pasted — every value is right there — and that card would show a confident tick for a bundle whose code is garbage. The validate route existed to stop that. With a bare code there is nothing to render from except the server's answer, so presenting the sender's claims as facts about a stack is structurally impossible rather than merely discouraged. The staleness comparison goes with it and is not replaced. Rebuilding a stack mints a fresh code, so an old one already fails to resolve; the three compared values were belt to that braces. One real loss, stated plainly: a code pasted at the wrong farm now gets the generic "doesn't open anything here" instead of naming the farm. Rare, and the generic answer is true. A mistyped code is still caught locally by its check character, so it never lands in that message. Verified against a real Postgres and a running API: the index enforces uniqueness while still allowing many unshared stacks; a minted code resolves, including lowercased and with its dashes stripped; a one-character typo is refused as a typo; rotating invalidates the old code; disabling closes it; the connection cap refuses at the limit; sharing a vta_only and passing a code to full_stack are both refused; and deleting a provider leaves its consumers running and marked as orphaned on both the detail and list endpoints. Signed-off-by: vthwang <vthwang.work@gmail.com>
Crockford's check alphabet extends the 32 data symbols with `*~$=U` for remainders 32-36, so roughly one code in seven ended in punctuation -- `FDGE-K0G4-AWNF-CQS~`. Valid, and nothing downstream broke, but a code that cannot be read down a phone or typed on an arbitrary keyboard has given up the one property this format was chosen over raw base32 for. NewShareCode now rerolls until the check symbol lands inside the data alphabet: 37/32 ~ 1.16 attempts on average, 0.2 bits off the 75. ValidateShareCode is deliberately not narrowed to match. The reroll governs what we mint; codes handed out before it existed are live credentials in the database, and rejecting their check symbol would lock out their holders for a cosmetic reason. A test pins that both ways. Signed-off-by: vthwang <vthwang.work@gmail.com>
connections_max was only ever returned by the consumer's POST /setup/connection/validate, so the owner of a stack could not see how much of their own capacity was spoken for -- it is their storage and message volume being committed, and s6.3's cap is what bounds how many agents arrive before they notice. Adds it to PUT /setup/:id/sharing and to GET /setup/:id for a full_stack, both through one sharingResponse helper so the page renders identically whether it just acted or just loaded. That also collapses the four places that were assembling the same map by hand. Omitted when the cap is off, so a client renders "3 connected" rather than "3 of 0". Signed-off-by: vthwang <vthwang.work@gmail.com>
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.
No description provided.