Skip to content

Tokens and the per-VM endpoint stub - #36

Merged
lex00 merged 1 commit into
mainfrom
feature/12-tokens-endpoint
Jul 31, 2026
Merged

lex00 merged 1 commit into
mainfrom
feature/12-tokens-endpoint

Conversation

@lex00

@lex00 lex00 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Stacked on #35, which is stacked on #34. Review those first.

Tokens

The recorded token is a JWE in compact serialization — five parts, empty encrypted-key segment because the header says alg: dir, header carrying a kid uuid and enc: A256GCM. m80 builds that shape from random bytes and validates by table lookup rather than by decrypting. A client that carries the token around cannot tell the difference; one that parses the header for a kid finds it where the service puts it. Nothing is encrypted in there and nothing should be read out of it past the header.

Request rules came from the vendored service model rather than guesswork: expirationInMinutes and allowedPorts both required, expiry documented at a maximum of 60, allowedPorts minimum length one, each member a union of exactly one of port/range/allPorts. The endpoint enforces the port grant — a token scoped to one port that opened another would pass requests the real service rejects.

Issuing against a SUSPENDED VM returns a full token (recorded). Against a TERMINATED one it is 400 ValidationException — unrecorded for this operation specifically, but the recorded rule for every other mutation.

CreateMicrovmShellAuthToken can only ever fail, and that is the honest implementation. SHELL_INGRESS is absent from the service model entirely, so no request exists that would make it succeed. Answering 501 would be worse: the operation is implemented, the recorded rejection is its one observable behavior, and a consumer that handles it is correctly exercised by it.

The endpoint stub

A VM's endpoint is a different host answered by the same process, and ServeMux host patterns cannot carry a wildcard, so api.Server grew an Intercept hook consulted before the route table. Routing is by hostname, or by a /_m80/vm/{id}/ path prefix for callers that cannot forge a Host header. The default body and an X-M80-State-Marker header both carry #11's state marker, so a caller who replaced the body with their own payload can still read it.

Almost none of the endpoint's answers are recorded, and none of them could have been — the runner signs and addresses control-plane requests, so it cannot call a host that is not the control plane. Every guess sits in one table in endpoint.go and is repeated in docs/api-surface.md, nowhere else:

Situation m80 Basis
unknown endpoint host 404 no VM to serve
no or malformed token header 401 guess
token unknown, expired, or another VM's 403 guess
port outside the token's allowedPorts 403 guess
VM TERMINATED 410 guess
VM PENDING 503 guess
VM SUSPENDED, autoResumeEnabled resume, then 200 inferred
VM SUSPENDED, no autoResumeEnabled 503 guess
VM RUNNING, token good 200 + stub body stub

Auto-resume is an inference rather than a guess: a suspended VM issues tokens, which is the order a client meaning to wake a VM by calling it has to work in, and autoResumeEnabled is the member that says whether it may. The 401/403 split is the guess most worth arguing with — one flat 403 would have been safer, but a missing credential and a rejected one are different failures to a client retrying with a fresh token.

Normalizer: a seventh instance of the recorded class

reSecretKey matched token|secret|credential|password against the key. TokenParts keys are header names — the model says so, because some schemes return several auth headers — and the recorded one is X-aws-proxy-auth, which contains none of those words. The value survived normalization, so the fixture held a literal JWE that no generated token could ever equal. auth is now in the rule. Compare normalizes the fixture too, so both sides collapse and nothing needed re-recording.

Result

58 pass, 0 fail, 24/29 operations exercised, up from 52 and 23. Both token fixtures match, including auth-token-while-suspended, which stops being the optional step that only ever reported unimplemented. go build, go vet, gofmt, go test -race clean.

Acceptance asked for a curl against a live VM, so:

--- valid token ---   200 {"microvmId":"microvm-3e02…","state":"RUNNING","stateMarker":2,…}
--- invalid token --- 403 {"error":"Forbidden","message":"Token is not valid for this MicroVM"}
--- no token ---      401 {"error":"Unauthorized","message":"Missing X-aws-proxy-auth"}
--- suspend, then call it (autoResumeEnabled) ---
state before call: SUSPENDED
                      200 {"state":"RUNNING","stateMarker":3,…}
state after call:  RUNNING
--- path-prefix form -- 200 {"stateMarker":4,…}

The marker running 2 → 3 → 4 straight through the suspend is the evidence #12 exists to produce.

Closes #12

lex00 added a commit that referenced this pull request Jul 31, 2026
main gained the mkdocs site and the strict docs gate (#27) and the floci
architecture findings (#28) after this branch was cut, and the two sides
had both reformatted 60-connectors.json.

The conflict is a genuine union rather than a pick. main added the
subset:floci tag, correcting which cases the floci subset covers. This
branch added the live-recorded request members the connector create and
update actually need — AssociatedComputeResourceTypes, NetworkProtocol,
ClientToken and OperatorRole, with the operatorRoleArn param behind them.
Both are kept; dropping either would lose a real finding.

This also unblocks CI on the PR. A conflicting pull request has no
mergeable ref for pull_request workflows to run against, which is why #34
showed no checks at all while #35 and #36 were green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
@lex00
lex00 force-pushed the feature/11-suspend-resume branch from 6802911 to f2076c2 Compare July 31, 2026 04:52
CreateMicrovmAuthToken mints the recorded token shape: a JWE in compact
serialization, five parts with an empty encrypted-key segment because the
header says alg "dir", the header carrying a kid uuid and enc A256GCM.
m80 builds that shape from random bytes and validates by table lookup
rather than by decrypting. A client that carries the token around cannot
tell the difference; one that parses the header for a kid finds it where
the service puts it. Nothing is encrypted in there and nothing should be
read out of it past the header.

The request rules come from the vendored model rather than guesswork.
expirationInMinutes and allowedPorts are both required, expiry is
documented at a maximum of 60, allowedPorts has a minimum length of one,
and each member is a union — exactly one of port, range or allPorts. The
endpoint enforces the port grant, because a token scoped to one port that
opened another would pass requests the real service rejects.

Issuing against a SUSPENDED VM returns a full token, recorded, and that
is the order a client meaning to wake a VM by calling it has to work in.
Against a TERMINATED one it is the recorded 400 ValidationException —
unrecorded for this operation specifically, but it is the recorded rule
for every other mutation and a token against a VM that no longer exists
could only mislead.

CreateMicrovmShellAuthToken can only ever fail, and that is the honest
implementation. SHELL_INGRESS is absent from the service model entirely,
so no request exists that would make it succeed. 501 would be worse: the
operation is implemented, the recorded rejection is its one observable
behavior, and a consumer that handles it is correctly exercised.

The per-VM endpoint is answered from the same process. A VM's endpoint is
a different host, and ServeMux host patterns cannot carry a wildcard, so
api.Server grew an Intercept hook consulted before the route table. It
routes by hostname, or by a /_m80/vm/{id}/ path prefix for callers that
cannot forge a Host header. The default body and an X-M80-State-Marker
header both carry #11's state marker, so a caller that replaced the body
with its own payload can still read it.

Almost none of the endpoint's answers are recorded and none of them could
have been: the runner signs and addresses control-plane requests, so it
cannot call a host that is not the control plane. Every guess is in one
table in endpoint.go and repeated in docs/api-surface.md, nowhere else.
The auto-resume row is an inference rather than a guess — a suspended VM
issues tokens, which is the order waking a VM by calling it requires, and
autoResumeEnabled is the member saying whether it may. The 401/403 split
is the guess most worth arguing with.

Also widens the normalizer's credential rule to cover "auth". TokenParts
keys are header names — the model says so, because some schemes return
several auth headers — and the recorded one is X-aws-proxy-auth, which
contains none of token/secret/credential/password. The value therefore
survived normalization and the fixture held a literal JWE that no
generated token could ever equal. Compare normalizes the fixture too, so
both sides now collapse and no re-recording is needed.

Conformance: 58 pass, 0 fail, 24 of 29 operations exercised, up from 52
and 23. Both token fixtures match, including auth-token-while-suspended,
which stops being the optional step that only ever reported unimplemented.

Closes #12

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
@lex00
lex00 changed the base branch from feature/11-suspend-resume to main July 31, 2026 04:58
@lex00
lex00 force-pushed the feature/12-tokens-endpoint branch from 4cea72c to 6547908 Compare July 31, 2026 04:58
@lex00
lex00 merged commit 0831406 into main Jul 31, 2026
2 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.

Tokens and the per-VM endpoint stub

1 participant