merge: CLI through the console API — carlos ship/promote without seeing the infrastructure (#25)
CLI through the console API — carlos ship/promote without seeing the infrastructure
14 files changed,
+4651
−28
README.md+21 −0apimode.go+438 −0apimode_test.go+1243 −0appconfig_cli.go+8 −8auth.go+66 −0auth_e2e_test.go+298 −0cli.go+213 −19docs/superpowers/plans/2026-08-03-cli-console-api.md+315 −0docs/superpowers/specs/2026-08-03-cli-console-api-design.md+182 −0docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md+8 −0internal/console/apicli.go+679 −0internal/console/apicli_test.go+1134 −0internal/console/authcli.go+25 −1internal/console/server.go+21 −0
diff --git a/README.md b/README.md| index fabd0c7b9..e1aae8928 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -109,6 +109,27 @@ origin), expires in 90 days, and is listed with a revoke button at |
| carlos auth whoami # who this terminal is, per the console |
| carlos auth logout # revokes the token, then forgets it |
| +**Shipping from a logged-in laptop.** Once you're logged in, `ship`, |
| +`promote`, `rollback` and `channels` speak through the console instead of the |
| +bucket directly — no `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on a developer's |
| +machine at all: |
| + |
| + carlos ship -app blog -version $(git rev-parse --short HEAD) -target linux-arm64 blog-linux-arm64 |
| + carlos promote -app blog abc1234 canary/my-branch |
| + carlos channels -app blog |
| + |
| +Canary and edge promote exactly as before; **promoting onto `stable` is |
| +refused** — the console names itself in the refusal, because moving `stable` |
| +needs a passkey step-up only a browser can do. Finish it at the URL the |
| +refusal prints. |
| + |
| +CI and operators keep the existing path unchanged: set |
| +`CARLOS_DEPLOYMENT_BUCKET` (or `_DIR`) and every verb goes straight at the |
| +bucket, exactly as before — logging in is for a human at a laptop, never a box |
| +or a pipeline. Note the modes carry different authority: a scoped |
| +`CARLOS_RELEASE_KEY` constrains the direct path only — in API mode it is |
| +ignored and your console membership (plus the rung gates) is what decides. |
| + |
| **On a box, or in CI, don't log in.** There is no browser and no human, and a |
| long-lived token sitting on a box is a standing credential nobody is watching. |
| Name the owner outright instead — it needs no identity of its own and takes |
diff --git a/apimode.go b/apimode.go| new file mode 100644 |
| index 000000000..6145af533 |
| --- /dev/null |
| +++ b/apimode.go |
| @@ -0,0 +1,438 @@ |
| +// Which mode the CLI runs in, and the console API client for the mode that |
| +// doesn't touch the deployment bucket directly. |
| +// |
| +// Every verb that used to reach straight for CARLOS_DEPLOYMENT_BUCKET / |
| +// CARLOS_DEPLOYMENT_DIR now checks resolveMode first. Direct-mode wins |
| +// whenever either env var is set — that is unchanged behaviour, and it wins |
| +// unconditionally: a box configured for direct bucket access keeps working |
| +// exactly as it always has, whether or not the same machine also happens to |
| +// hold a console credential (`carlos auth login` is meant for a human's |
| +// laptop, not a box; see auth.go's HEADLESS AND BOX-SIDE USE note). Only when |
| +// neither env var is set does the CLI need a credential at all, and it is |
| +// credential-first from there: no local registry, no guessing an account by |
| +// name against a bucket the caller may not even be able to reach — the |
| +// console resolves the account (by sqid OR name) against the caller's own |
| +// memberships, which is also the authorization check. |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "mime/multipart" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "time" |
| +) |
| + |
| +// apiSession is the resolved API mode: which console, as whom. console and |
| +// address are carried for messages (Task 5's "logged in to X as Y" lines), |
| +// not used by the calls below. |
| +type apiSession struct { |
| + c *consoleClient |
| + console string // origin, for messages |
| + address string |
| +} |
| + |
| +// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode |
| +// — the store env is set, and the caller proceeds exactly as it does today, |
| +// straight against the bucket. A non-nil session means API mode: every call |
| +// goes through the console named in the credential loggedInCredential |
| +// resolved, as the address that credential belongs to. |
| +// |
| +// consoleFlag is the verb's -console value ("" meaning "the only one" — |
| +// pick's ordinary disambiguation rule, unchanged here). |
| +// |
| +// An explicit -console alongside either store env is refused outright |
| +// (design ruling, review round 2026-08-03) rather than silently winning as |
| +// direct mode: the spec's "the two modes never mix in one invocation" intent |
| +// (§1) would otherwise be violated invisibly — someone who typed -console |
| +// meaning it gets bucket access instead, with no signal that their flag was |
| +// ignored. A BARE env with no -console still wins silently, unchanged: that |
| +// half of §1 (operators, CI, box-side scripts) is deliberately untouched. |
| +func resolveMode(consoleFlag string) (*apiSession, error) { |
| + bucketSet := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" |
| + dirSet := os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" |
| + if consoleFlag != "" && (bucketSet || dirSet) { |
| + return nil, errors.New("-console conflicts with CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR " + |
| + "— unset the env to use the console, or drop -console") |
| + } |
| + if bucketSet || dirSet { |
| + return nil, nil |
| + } |
| + cred, err := loggedInCredential(consoleFlag) |
| + if err != nil { |
| + // loggedInCredential's own errors already name their own fix (log in |
| + // to the right console, re-log in, disambiguate with -console) — |
| + // except the plain "there are no credentials at all" case (pick's |
| + // exact, non-interpolated wording below), which never mentions |
| + // direct mode because pick() has no idea direct mode exists. That's |
| + // the one case worth widening here: a fresh checkout with neither a |
| + // bucket/dir nor a login has TWO ways to fix it, and the naked "not |
| + // logged in" message only offers one. |
| + if err.Error() == noCredentialsMsg { |
| + // But "not logged in" is also what pick() says when a credential |
| + // EXISTS but every entry on disk has expired — its live-credential |
| + // filter treats an expired entry as absent, so that case reaches |
| + // this same string. Those are different problems for a person to |
| + // fix ("configure a target" vs. "log back in"), and conflating |
| + // them here would tell someone who has in fact logged in before |
| + // that nothing is configured at all. |
| + if expired := expiredCredentialFirst(); expired != nil { |
| + return nil, fmt.Errorf("the credential for %s expired — run: carlos auth login -console %s", |
| + expired.Console, expired.Console) |
| + } |
| + return nil, fmt.Errorf("no deployment target configured: " + |
| + "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " + |
| + "or run carlos auth login -console <url> to work through a console") |
| + } |
| + return nil, err |
| + } |
| + return &apiSession{ |
| + c: newConsoleClient(cred.Console, cred.Token), |
| + console: cred.Console, |
| + address: cred.Address, |
| + }, nil |
| +} |
| + |
| +// noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for |
| +// "no -console given (and no CARLOS_CONSOLE either) and there is nothing |
| +// live to pick from" — the one pick() refusal that never mentions direct |
| +// mode, since pick has no idea direct mode exists. Matched by exact string |
| +// rather than a sentinel error because pick constructs a fresh error each |
| +// call; this is the one pick() message with no interpolated value, so an |
| +// exact match is precise, not fragile. |
| +// |
| +// It is ALSO the one case pick() reaches both when the credentials file is |
| +// genuinely empty and when it holds only expired entries (pick's "live" |
| +// filter excludes expired credentials before counting), which is why |
| +// resolveMode calls expiredCredentialFirst before treating it as "nothing |
| +// configured". |
| +const noCredentialsMsg = "not logged in — run: carlos auth login -console <url>" |
| + |
| +// expiredCredentialFirst reports whether the credentials file holds at |
| +// least one entry and every one of them is expired, returning one such |
| +// entry if so (and nil otherwise, including when the file is empty or |
| +// unreadable). Only ever consulted from the noCredentialsMsg branch above, |
| +// where consoleFlag and CARLOS_CONSOLE are already known to both be empty — |
| +// pick() would have taken its console!="" branch, and returned its OWN |
| +// (already specific) expired-credential message, otherwise. |
| +func expiredCredentialFirst() *credential { |
| + f, err := loadCredentials() |
| + if err != nil || len(f.Credentials) == 0 { |
| + return nil |
| + } |
| + now := time.Now() |
| + for i := range f.Credentials { |
| + if !f.Credentials[i].Expired(now) { |
| + return nil |
| + } |
| + } |
| + return &f.Credentials[0] |
| +} |
| + |
| +// apiChannel is one row of a channel ladder read back through the console |
| +// API, mirroring the runChannels per-row ERROR behaviour: a channel whose |
| +// pointer failed verification is a row with Err set, not a failed call. |
| +type apiChannel struct { |
| + Channel string |
| + Version string |
| + PromotedAt string |
| + Rung string |
| + Err string |
| +} |
| + |
| +// apiPromoteResult and apiShipResult carry the one fact Task 5 needs to |
| +// print a parity message: whether the console did nothing because the |
| +// request already matched what's live (a promote of a version already on |
| +// that channel; a re-ship of bytes already shipped under that version). |
| +// Every other field of the console's response (rung names, artifact |
| +// hashes) is deliberately not modelled here — the CLI already knows the |
| +// version/channel it asked for and prints those, never the server's echo of |
| +// them. |
| +type apiPromoteResult struct { |
| + NoOp bool |
| +} |
| + |
| +type apiShipResult struct { |
| + NoOp bool |
| +} |
| + |
| +// apiPath builds one /api/cli/apps/{account}/{app}/... path. account is |
| +// passed through verbatim (sqid or name, exactly what -account carried, |
| +// default "ops") — the console resolves it against the caller's own |
| +// memberships; this client must never resolve it against a local registry. |
| +func apiPath(account, app, tail string) string { |
| + return "/api/cli/apps/" + url.PathEscape(account) + "/" + url.PathEscape(app) + tail |
| +} |
| + |
| +// notFoundErr is what every call here returns for a 404 whose body carries |
| +// no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers |
| +// an unknown account, an unknown app, and someone else's account with the |
| +// SAME http.NotFound — a byte-identical text/plain body, not JSON, by design |
| +// (a token holder must not learn which of those three it was). This is the |
| +// one sane message for all three. A 404 that DOES carry an "error" field |
| +// (an unshipped version on promote, a channel with no history on rollback) |
| +// is a genuine server diagnosis, and apiErrorFromBody surfaces THAT text |
| +// instead — never rewritten into this one. |
| +func notFoundErr() error { |
| + return errors.New("not found: check -account and -app, and that your token's address is a member") |
| +} |
| + |
| +// apiErrorFromBody turns a non-2xx status and its raw body into an error. |
| +// The JSON {"error": "..."} shape is tried FIRST, whatever the status code — |
| +// several doors (promote's unshipped-version 404, rollback's no-history 409) |
| +// answer a real diagnosis worth showing verbatim. Only when the body has no |
| +// such field at all — exactly what apiAccountApp's gate sends, a text/plain |
| +// "404 page not found" — does a 404 fall back to the generic membership |
| +// message; every other unreadable body just reports its status. |
| +func apiErrorFromBody(status int, body []byte) error { |
| + var e struct { |
| + Error string `json:"error"` |
| + } |
| + if json.Unmarshal(body, &e) == nil && e.Error != "" { |
| + return errors.New(e.Error) |
| + } |
| + if status == http.StatusNotFound { |
| + return notFoundErr() |
| + } |
| + return fmt.Errorf("unexpected response (status %d)", status) |
| +} |
| + |
| +// getJSON runs one JSON GET and hands back the status and raw body without |
| +// interpreting a non-200 as an error itself, for the same reason postJSON |
| +// doesn't: a caller here needs the FULL body (to try apiErrorFromBody's |
| +// json-first decode), not just a pre-picked message. |
| +func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, error) { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.c.base+path, nil) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + if s.c.token != "" { |
| + req.Header.Set("Authorization", "Bearer "+s.c.token) |
| + } |
| + res, err := s.c.httpc.Do(req) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + defer res.Body.Close() |
| + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| + if err != nil { |
| + return res.StatusCode, nil, err |
| + } |
| + return res.StatusCode, body, nil |
| +} |
| + |
| +// postJSON runs one JSON POST and hands back the status and raw body |
| +// without interpreting a non-200 as an error itself. Unlike |
| +// (*consoleClient).do, which keeps only a bare "error" string, promote and |
| +// rollback need the FULL body on a stable refusal — its console_url would |
| +// otherwise be discarded before the caller ever sees it. |
| +func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []byte, error) { |
| + raw, err := json.Marshal(in) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.c.base+path, bytes.NewReader(raw)) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + if s.c.token != "" { |
| + req.Header.Set("Authorization", "Bearer "+s.c.token) |
| + } |
| + res, err := s.c.httpc.Do(req) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + defer res.Body.Close() |
| + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| + if err != nil { |
| + return res.StatusCode, nil, err |
| + } |
| + return res.StatusCode, body, nil |
| +} |
| + |
| +// stableRefusal decodes handleAPIPromote/handleAPIRollback's 403 shape |
| +// ({"error", "console_url"}) and, when it matches, returns the error the |
| +// caller should see: the console's own refusal text plus where to finish |
| +// the job — a passkey step-up has no bearer-token equivalent, so stable |
| +// promotion/rollback always ends here, never on this machine. |
| +func stableRefusal(status int, body []byte) (error, bool) { |
| + if status != http.StatusForbidden { |
| + return nil, false |
| + } |
| + var refusal struct { |
| + Error string `json:"error"` |
| + ConsoleURL string `json:"console_url"` |
| + } |
| + if json.Unmarshal(body, &refusal) != nil || refusal.Error == "" { |
| + return nil, false |
| + } |
| + return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true |
| +} |
| + |
| +// promote asks the console to promote version onto channel. The response's |
| +// "result" is the one field read here — "noop" when the ladder already had |
| +// this version on this channel — because everything else the response |
| +// carries (RUNG names in From/To) is not what the caller prints: the CLI |
| +// already knows the version/channel it asked for and prints those, never |
| +// res.From/res.To. |
| +func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) (apiPromoteResult, error) { |
| + in := struct { |
| + Version string `json:"version"` |
| + Channel string `json:"channel"` |
| + }{version, channel} |
| + status, body, err := s.postJSON(ctx, apiPath(account, app, "/promote"), in) |
| + if err != nil { |
| + return apiPromoteResult{}, err |
| + } |
| + if status == http.StatusOK { |
| + var res struct { |
| + Result string `json:"result"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err) |
| + } |
| + return apiPromoteResult{NoOp: res.Result == "noop"}, nil |
| + } |
| + if refErr, ok := stableRefusal(status, body); ok { |
| + return apiPromoteResult{}, refErr |
| + } |
| + return apiPromoteResult{}, apiErrorFromBody(status, body) |
| +} |
| + |
| +// rollback asks the console to roll channel back to its previous version. |
| +// There is no noop case for a rollback — it either has a previous version to |
| +// land on (409 if not) or it doesn't — so this stays error-only. |
| +func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error { |
| + in := struct { |
| + Channel string `json:"channel"` |
| + }{channel} |
| + status, body, err := s.postJSON(ctx, apiPath(account, app, "/rollback"), in) |
| + if err != nil { |
| + return err |
| + } |
| + if status == http.StatusOK { |
| + return nil |
| + } |
| + if refErr, ok := stableRefusal(status, body); ok { |
| + return refErr |
| + } |
| + return apiErrorFromBody(status, body) |
| +} |
| + |
| +// channels reads one app's channel ladder through the console. |
| +func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(account, app, "/channels")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Channels []struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt string `json:"promoted_at"` |
| + Rung string `json:"rung"` |
| + Error string `json:"error"` |
| + } `json:"channels"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("channels: unreadable response: %w", err) |
| + } |
| + out := make([]apiChannel, len(res.Channels)) |
| + for i, ch := range res.Channels { |
| + out[i] = apiChannel{ |
| + Channel: ch.Channel, Version: ch.Version, |
| + PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error, |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// shipTimeout bounds ship's own context — generous on purpose. The console |
| +// gives itself up to 10 minutes to read the request body and 2 minutes to |
| +// store it (internal/console/apicli.go), well past the 30s absolute Timeout |
| +// the JSON verbs' shared client carries; ship must never inherit that 30s |
| +// cap (doMultipart, auth.go, uses its own client for exactly this reason), |
| +// but it still needs SOME bound of its own rather than running forever. |
| +const shipTimeout = 15 * time.Minute |
| + |
| +// ship uploads one release's artifacts as a multipart POST: the meta part |
| +// ({"version","kind"}) first, then one file part per entry in files (partName |
| +// -> local path), in map-key-sorted order — sorted so the wire bytes are |
| +// deterministic, since Go's map iteration order is not. |
| +func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) (apiShipResult, error) { |
| + ctx, cancel := context.WithTimeout(ctx, shipTimeout) |
| + defer cancel() |
| + |
| + names := make([]string, 0, len(files)) |
| + for name := range files { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + |
| + status, body, err := s.c.doMultipart(ctx, apiPath(account, app, "/releases"), func(mw *multipart.Writer) error { |
| + metaJSON, err := json.Marshal(struct { |
| + Version string `json:"version"` |
| + Kind string `json:"kind"` |
| + }{version, kind}) |
| + if err != nil { |
| + return err |
| + } |
| + metaPart, err := mw.CreateFormField("meta") |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := metaPart.Write(metaJSON); err != nil { |
| + return err |
| + } |
| + for _, name := range names { |
| + if err := writeFilePart(mw, name, files[name]); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + return apiShipResult{}, err |
| + } |
| + // handleAPIShip (internal/console/apicli.go) sends "result": "shipped" |
| + // on BOTH paths — status is the only signal that distinguishes them: 201 |
| + // for bytes actually written, 200 when the request matched an identical |
| + // version already on record and nothing new was stored. |
| + switch status { |
| + case http.StatusCreated: |
| + return apiShipResult{NoOp: false}, nil |
| + case http.StatusOK: |
| + return apiShipResult{NoOp: true}, nil |
| + default: |
| + return apiShipResult{}, apiErrorFromBody(status, body) |
| + } |
| +} |
| + |
| +// writeFilePart streams one local file into a multipart part named name, so |
| +// a 30MiB artifact is read a chunk at a time rather than buffered whole |
| +// before it starts crossing the wire. |
| +func writeFilePart(mw *multipart.Writer, name, path string) error { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return err |
| + } |
| + defer f.Close() |
| + part, err := mw.CreateFormFile(name, filepath.Base(path)) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = io.Copy(part, f) |
| + return err |
| +} |
diff --git a/apimode_test.go b/apimode_test.go| new file mode 100644 |
| index 000000000..88d5c7cb0 |
| --- /dev/null |
| +++ b/apimode_test.go |
| @@ -0,0 +1,1243 @@ |
| +// Tests for API mode resolution and the console API client the other CLI |
| +// verbs (Task 5) build on: which mode the CLI is in (direct bucket access or |
| +// through a console it holds a credential for), and the four calls a session |
| +// in API mode can make. |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/edge" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// captureStdout swaps the package-level stdout var (cli.go — the seam the |
| +// four API-aware verbs print through) for a buffer, restoring it on cleanup. |
| +// log.Fatal paths are untouched by this seam on purpose — those still exit |
| +// the process, exactly as before. |
| +func captureStdout(t *testing.T) *bytes.Buffer { |
| + t.Helper() |
| + var buf bytes.Buffer |
| + orig := stdout |
| + stdout = &buf |
| + t.Cleanup(func() { stdout = orig }) |
| + return &buf |
| +} |
| + |
| +// clearDeploymentEnv ensures none of the three env vars resolveMode/pick |
| +// read leak in from the process running the test binary — resolveMode must |
| +// see a clean slate to make its own env check meaningful, and CARLOS_CONSOLE |
| +// left set would silently change which pick() branch a "" -console flag |
| +// takes. |
| +func clearDeploymentEnv(t *testing.T) { |
| + t.Helper() |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + t.Setenv("CARLOS_CONSOLE", "") |
| +} |
| + |
| +func TestResolveModeDirectWins(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-bucket") |
| + |
| + // A credential is present too — direct mode must win regardless, since a |
| + // box can be both logged in and configured for direct bucket access, and |
| + // existing direct-mode behaviour must not change out from under it. |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sess, err := resolveMode("") |
| + if err != nil || sess != nil { |
| + t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_BUCKET set = (%+v, %v), want (nil, nil)", sess, err) |
| + } |
| +} |
| + |
| +func TestResolveModeDirWins(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir()) |
| + |
| + sess, err := resolveMode("") |
| + if err != nil || sess != nil { |
| + t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_DIR set = (%+v, %v), want (nil, nil)", sess, err) |
| + } |
| +} |
| + |
| +func TestResolveModeAPI(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sess, err := resolveMode("") |
| + if err != nil { |
| + t.Fatalf("resolveMode: %v", err) |
| + } |
| + if sess == nil { |
| + t.Fatal("resolveMode with one credential = nil session, want one") |
| + } |
| + if sess.console != "https://a.example" { |
| + t.Fatalf("session console = %q, want https://a.example", sess.console) |
| + } |
| + if sess.address != "a@keymail.dev" { |
| + t.Fatalf("session address = %q, want a@keymail.dev", sess.address) |
| + } |
| + if sess.c == nil { |
| + t.Fatal("session has no console client") |
| + } |
| +} |
| + |
| +func TestResolveModeNothing(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + sess, err := resolveMode("") |
| + if sess != nil { |
| + t.Fatalf("resolveMode with nothing configured = %+v, want nil session", sess) |
| + } |
| + if err == nil { |
| + t.Fatal("resolveMode with nothing configured must error") |
| + } |
| + if !strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") { |
| + t.Fatalf("error %q does not mention CARLOS_DEPLOYMENT_BUCKET", err) |
| + } |
| + if !strings.Contains(err.Error(), "carlos auth login") { |
| + t.Fatalf("error %q does not mention carlos auth login", err) |
| + } |
| +} |
| + |
| +// TestResolveModeExpiredCredentialOnly pins the minor fix: a credential that |
| +// EXISTS but has expired is a different situation from nothing configured at |
| +// all, and deserves a message that says so instead of offering to set up |
| +// direct mode from scratch. |
| +func TestResolveModeExpiredCredentialOnly(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + f, _ := loadCredentials() |
| + f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + sess, err := resolveMode("") |
| + if sess != nil { |
| + t.Fatalf("resolveMode with only an expired credential = %+v, want nil session", sess) |
| + } |
| + if err == nil { |
| + t.Fatal("resolveMode with only an expired credential must error") |
| + } |
| + if !strings.Contains(err.Error(), "https://a.example") { |
| + t.Fatalf("error %q does not name the expired console", err) |
| + } |
| + if !strings.Contains(err.Error(), "expired") { |
| + t.Fatalf("error %q does not say the credential expired", err) |
| + } |
| + if !strings.Contains(err.Error(), "carlos auth login") { |
| + t.Fatalf("error %q does not name the fix", err) |
| + } |
| + if strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") { |
| + t.Fatalf("error %q wrongly offers direct mode for an expired (not absent) credential", err) |
| + } |
| +} |
| + |
| +// TestResolveModeConsoleFlagRefusedWithStoreEnv pins the design ruling from |
| +// the review round (2026-08-03): an explicit -console alongside a store env |
| +// is refused outright, rather than silently winning as direct mode with |
| +// -console quietly ignored — the spec's "the two modes never mix in one |
| +// invocation" intent (§1) would otherwise be violated invisibly. Both store |
| +// env vars are checked; a BARE env with no -console still wins silently, |
| +// unchanged (already pinned by TestResolveModeDirectWins/TestResolveModeDirWins, |
| +// both of which call resolveMode("")). |
| +func TestResolveModeConsoleFlagRefusedWithStoreEnv(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + envVar string |
| + }{ |
| + {"bucket", "CARLOS_DEPLOYMENT_BUCKET"}, |
| + {"dir", "CARLOS_DEPLOYMENT_DIR"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + t.Setenv(tc.envVar, "something") |
| + |
| + sess, err := resolveMode("https://a.example") |
| + if sess != nil { |
| + t.Fatalf("resolveMode with -console AND %s set = %+v, want nil session", tc.envVar, sess) |
| + } |
| + if err == nil { |
| + t.Fatalf("resolveMode with -console AND %s set must error", tc.envVar) |
| + } |
| + if !strings.Contains(err.Error(), "-console") { |
| + t.Fatalf("error %q does not mention -console", err) |
| + } |
| + if !strings.Contains(err.Error(), tc.envVar) { |
| + t.Fatalf("error %q does not mention %s", err, tc.envVar) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func newTestSession(base string) *apiSession { |
| + return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"} |
| +} |
| + |
| +// TestAPISessionShipMultipart pins the wire shape handleAPIShip |
| +// (internal/console/apicli.go) requires: bearer auth, the meta part first |
| +// with exact JSON, then artifact parts in map-key-sorted order (so the wire |
| +// bytes are deterministic instead of depending on map iteration order). |
| +func TestAPISessionShipMultipart(t *testing.T) { |
| + var gotAuth string |
| + var partNames []string |
| + var metaBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotAuth = r.Header.Get("Authorization") |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Errorf("request was not multipart: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Errorf("reading part: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + b, err := io.ReadAll(part) |
| + if err != nil { |
| + t.Errorf("reading part body: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + if part.FormName() == "meta" { |
| + metaBody = string(b) |
| + } |
| + partNames = append(partNames, part.FormName()) |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + dir := t.TempDir() |
| + fileA := filepath.Join(dir, "a.bin") |
| + fileB := filepath.Join(dir, "b.bin") |
| + if err := os.WriteFile(fileA, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(fileB, []byte("bbb"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{ |
| + "zeta-amd64": fileB, |
| + "alpha-amd64": fileA, |
| + }) |
| + if err != nil { |
| + t.Fatalf("ship: %v", err) |
| + } |
| + if res.NoOp { |
| + t.Fatal("a fresh (201) ship must not report NoOp") |
| + } |
| + if gotAuth != "Bearer cli-token" { |
| + t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer cli-token") |
| + } |
| + if want := `{"version":"1.2.3","kind":"binary"}`; metaBody != want { |
| + t.Fatalf("meta part = %q, want %q", metaBody, want) |
| + } |
| + wantOrder := []string{"meta", "alpha-amd64", "zeta-amd64"} |
| + if len(partNames) != len(wantOrder) { |
| + t.Fatalf("part names = %v, want %v", partNames, wantOrder) |
| + } |
| + for i := range wantOrder { |
| + if partNames[i] != wantOrder[i] { |
| + t.Fatalf("part order = %v, want %v", partNames, wantOrder) |
| + } |
| + } |
| +} |
| + |
| +// TestAPISessionShipNoOp pins handleAPIShip's identical-reship response |
| +// exactly as apicli.go sends it: still "result": "shipped" — 200 rather |
| +// than 201 is the ONLY signal that nothing new was written, so NoOp must be |
| +// keyed off status, not the result string. |
| +func TestAPISessionShipNoOp(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Errorf("request was not multipart: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + for { |
| + _, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Errorf("reading part: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + } |
| + w.WriteHeader(http.StatusOK) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + dir := t.TempDir() |
| + file := filepath.Join(dir, "a.bin") |
| + if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := newTestSession(ts.URL) |
| + res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + if err != nil { |
| + t.Fatalf("ship: %v", err) |
| + } |
| + if !res.NoOp { |
| + t.Fatalf("ship result = %+v, want NoOp true for a 200 identical re-ship", res) |
| + } |
| +} |
| + |
| +func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusConflict) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "version 1.2.3 was already shipped with different content", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + dir := t.TempDir() |
| + file := filepath.Join(dir, "a.bin") |
| + if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := newTestSession(ts.URL) |
| + _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + if err == nil || !strings.Contains(err.Error(), "already shipped with different content") { |
| + t.Fatalf("ship error = %v, want the server's conflict message", err) |
| + } |
| +} |
| + |
| +// TestAPISessionShipSurvivesSlowServer is the CRITICAL fix's proof: a slow |
| +// server holds the connection open longer than an old-style absolute client |
| +// Timeout would tolerate, but well within ship's own generous context. |
| +// shipHTTPClientTimeout (auth.go) is the injectable seam that lets both |
| +// halves of the point run fast, without an actual multi-minute sleep: |
| +// shrunk to a few milliseconds, the request fails exactly the way the |
| +// pre-fix code (which reused the JSON verbs' 30s-Timeout client) would have |
| +// on a real large upload; restored to its production zero value, the same |
| +// slow server succeeds because only the context bounds the request. |
| +func TestAPISessionShipSurvivesSlowServer(t *testing.T) { |
| + t.Run("an absolute client Timeout fails against a slow server", func(t *testing.T) { |
| + release := make(chan struct{}) |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + <-release |
| + io.Copy(io.Discard, r.Body) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}}) |
| + })) |
| + defer func() { |
| + close(release) |
| + ts.Close() |
| + }() |
| + |
| + dir := t.TempDir() |
| + file := filepath.Join(dir, "a.bin") |
| + if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := newTestSession(ts.URL) |
| + |
| + old := shipHTTPClientTimeout |
| + shipHTTPClientTimeout = 30 * time.Millisecond |
| + defer func() { shipHTTPClientTimeout = old }() |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + _, err := s.ship(ctx, "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + if err == nil { |
| + t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail") |
| + } |
| + }) |
| + |
| + t.Run("no absolute timeout survives the same slow server", func(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + time.Sleep(150 * time.Millisecond) // longer than the tiny timeout above; well inside ship's context |
| + io.Copy(io.Discard, r.Body) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + dir := t.TempDir() |
| + file := filepath.Join(dir, "a.bin") |
| + if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + s := newTestSession(ts.URL) |
| + // shipHTTPClientTimeout is back at its zero production default here |
| + // (the sibling subtest above restored it) — only the context bounds |
| + // this call. |
| + res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file}) |
| + if err != nil { |
| + t.Fatalf("ship against a slow-but-healthy server = %v, want success", err) |
| + } |
| + if res.NoOp { |
| + t.Fatal("a fresh (201) ship must not report NoOp") |
| + } |
| + }) |
| +} |
| + |
| +// TestAPISessionStableRefusal pins handleAPIPromote's 403 shape: the CLI |
| +// never resolves stable itself — it surfaces the console's own error AND the |
| +// console_url it hands back, because do() only keeps the bare "error" |
| +// string and a promote() caller needs the link too. |
| +func TestAPISessionStableRefusal(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": "http://x/a/b", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary") |
| + if err == nil { |
| + t.Fatal("promote against a stable refusal must error") |
| + } |
| + if !strings.Contains(err.Error(), "http://x/a/b") { |
| + t.Fatalf("promote error %q does not contain the console_url", err) |
| + } |
| + if !strings.Contains(err.Error(), "passkey") { |
| + t.Fatalf("promote error %q does not contain the console's refusal text", err) |
| + } |
| +} |
| + |
| +func TestAPISessionRollbackStableRefusal(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusForbidden) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": "http://x/a/b", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + err := s.rollback(context.Background(), "acct", "app", "stable") |
| + if err == nil || !strings.Contains(err.Error(), "http://x/a/b") { |
| + t.Fatalf("rollback error = %v, want it to contain the console_url", err) |
| + } |
| +} |
| + |
| +func TestAPISessionPromoteSuccess(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get("Authorization") != "Bearer cli-token" { |
| + w.WriteHeader(http.StatusUnauthorized) |
| + return |
| + } |
| + var req struct{ Version, Channel string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + if req.Version != "1.2.3" || req.Channel != "canary/a" { |
| + t.Errorf("promote body = %+v", req) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": "canary"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a") |
| + if err != nil { |
| + t.Fatalf("promote: %v", err) |
| + } |
| + if res.NoOp { |
| + t.Fatal(`promote result = NoOp true, want false for a "promoted" result`) |
| + } |
| +} |
| + |
| +// TestAPISessionPromoteNoOp pins the noop-parity fix: PromoteOnLadder |
| +// already found this version on this channel, and handleAPIPromote answers |
| +// {"result": "noop"} (still 200) — promote() must report that as NoOp true |
| +// rather than folding it into an ordinary success indistinguishable from an |
| +// actual promotion. |
| +func TestAPISessionPromoteNoOp(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"result": "noop"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a") |
| + if err != nil { |
| + t.Fatalf("promote: %v", err) |
| + } |
| + if !res.NoOp { |
| + t.Fatalf(`promote result = %+v, want NoOp true for a "noop" result`, res) |
| + } |
| +} |
| + |
| +// TestAPISessionPromoteUnshippedVersionSurfacesServerText pins the |
| +// IMPORTANT fix: a 404 with a real JSON diagnosis (an unshipped version, |
| +// handleAPIPromote's release.GetManifest check) must reach the caller |
| +// verbatim, not be swallowed into the generic membership message that only |
| +// belongs to apiAccountApp's gate. |
| +func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + json.NewEncoder(w).Encode(map[string]string{ |
| + "error": "Version 9.9.9 is not a shipped release of app.", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.promote(context.Background(), "acct", "app", "9.9.9", "canary/a") |
| + if err == nil || !strings.Contains(err.Error(), "not a shipped release") { |
| + t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err) |
| + } |
| + if strings.Contains(err.Error(), "check -account and -app") { |
| + t.Fatalf("promote 404 with a real diagnosis must not be replaced by the membership message: %v", err) |
| + } |
| +} |
| + |
| +// TestAPISessionPromoteAccountGateNotFound is the companion case: the SAME |
| +// 404 status, but apiAccountApp's gate (an unknown account/app or one the |
| +// caller doesn't belong to) — a text/plain http.NotFound body with no |
| +// "error" field at all — must still get the generic membership message. |
| +func TestAPISessionPromoteAccountGateNotFound(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.promote(context.Background(), "nope", "app", "1.2.3", "canary/a") |
| + if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err) |
| + } |
| +} |
| + |
| +// TestAPISessionChannelsNotFound pins the 404 case the parent task called |
| +// out specifically: apiAccountApp's gate answers http.NotFound, a |
| +// text/plain body, not JSON — the client must not choke on that and must |
| +// leave the caller with a sane, actionable message instead of the raw |
| +// "404 page not found" body. |
| +func TestAPISessionChannelsNotFound(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.NotFound(w, r) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + _, err := s.channels(context.Background(), "nope", "app") |
| + if err == nil { |
| + t.Fatal("channels against a 404 must error") |
| + } |
| + if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") { |
| + t.Fatalf("channels 404 error = %q, want a message naming -account/-app", err) |
| + } |
| +} |
| + |
| +func TestAPISessionChannels(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "channels": []map[string]any{ |
| + {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, |
| + {"channel": "broken", "error": "signature mismatch"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + chans, err := s.channels(context.Background(), "acct", "app") |
| + if err != nil { |
| + t.Fatalf("channels: %v", err) |
| + } |
| + if len(chans) != 2 { |
| + t.Fatalf("channels = %+v, want 2 rows", chans) |
| + } |
| + if chans[0].Channel != "canary/a" || chans[0].Version != "1.2.3" || chans[0].Rung != "canary" { |
| + t.Fatalf("row 0 = %+v", chans[0]) |
| + } |
| + if chans[1].Channel != "broken" || chans[1].Err != "signature mismatch" { |
| + t.Fatalf("row 1 = %+v", chans[1]) |
| + } |
| +} |
| + |
| +// ---- Task 5: the four verbs wired to resolveMode -------------------------- |
| + |
| +// loginTo saves one live credential for origin/address and returns it, so a |
| +// verb-level test can log in without going through `carlos auth login`. |
| +func loginTo(t *testing.T, origin, address string) { |
| + t.Helper() |
| + f, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f.put(credential{Console: origin, Address: address, Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := f.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// apiTestAccount is the -account value every API-mode verb test below uses. |
| +// Deliberately NOT sqid-shaped and not "ops": every prior round of these |
| +// tests happened to pass an -account value ("acct") that resolveAccount |
| +// would have resolved WITHOUT error had it wrongly been called (release. |
| +// ValidAccount("acct") happens to be true, so a stray resolveAccount call |
| +// would have silently "worked" and gone unnoticed). "vickys-team" fails |
| +// that same check, so a regression that starts calling acctOf()/ |
| +// resolveAccount in API mode again is loud (log.Fatal, or at minimum the |
| +// registry.db file requireNoRegistryTouch below watches for) rather than |
| +// silently passing. |
| +const apiTestAccount = "vickys-team" |
| + |
| +// requireNoRegistryTouch points CARLOS_DATA_DIR at a fresh, empty temp |
| +// directory and returns a func the caller runs AFTER the verb under test: |
| +// it fails the test if registry.db appeared there. accountFlags' resolver |
| +// (resolveAccount) is the only thing in this file that ever opens the |
| +// registry, and registry.Open creates the file on first open even if |
| +// resolution goes on to fail — so its presence is direct, mechanical proof |
| +// that API mode reached into the local registry, which it must never do |
| +// (there is no registry on a developer laptop to reach into). |
| +func requireNoRegistryTouch(t *testing.T) func() { |
| + t.Helper() |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DATA_DIR", dir) |
| + return func() { |
| + t.Helper() |
| + path := filepath.Join(dir, "registry.db") |
| + if _, err := os.Stat(path); err == nil { |
| + t.Fatalf("%s was created — API mode must never open the local registry", path) |
| + } else if !os.IsNotExist(err) { |
| + t.Fatalf("stat %s: %v", path, err) |
| + } |
| + } |
| +} |
| + |
| +// newShipFlagSet builds the -account/-registry flags runShipAPI's callers |
| +// register in production (accountFlags), parses account plus the given |
| +// positional file/dir args exactly as fs.Parse(os.Args) would, and returns |
| +// the FlagSet and the raw -account pointer runShipAPI expects — letting a |
| +// test drive runShipAPI directly (bypassing runShip's own flag set and, |
| +// crucially, its log.Fatal on error) so a refusal can be asserted as a |
| +// returned error instead of a process exit. |
| +func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string) { |
| + t.Helper() |
| + fs := flag.NewFlagSet("ship", flag.ContinueOnError) |
| + acctRaw, _ := accountFlags(fs) |
| + full := append([]string{"-account", account}, args...) |
| + if err := fs.Parse(full); err != nil { |
| + t.Fatalf("parse: %v", err) |
| + } |
| + return fs, acctRaw |
| +} |
| + |
| +// TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live |
| +// credential and neither store env set, it must ship through the console |
| +// (not openStore/release.Ship) and print the first-use "using <origin> as |
| +// <address>" line ahead of the ordinary "shipped" line — no artifact count, |
| +// per the noop-parity amendment, since the API-mode message is deliberately |
| +// its own shape. |
| +func TestShipVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /api/cli/apps/"+apiTestAccount+"/titogo/releases", func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Errorf("not multipart: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + for { |
| + _, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Errorf("part: %v", err) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + return |
| + } |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "shipped", "version": "abc1234", "artifacts": map[string]any{}, |
| + }) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + bin := filepath.Join(dir, "carlos") |
| + if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + |
| + out := buf.String() |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + if !strings.Contains(out, "shipped titogo abc1234") { |
| + t.Fatalf("stdout = %q, want the shipped line", out) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipVerbAPIModeNoOp pins the ship side of the noop-parity amendment: a |
| +// 200 (rather than 201) response is a re-ship of identical content, and the |
| +// verb must print the specified "already shipped ... nothing to do" line, not |
| +// the ordinary "shipped" line. |
| +func TestShipVerbAPIModeNoOp(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatalf("not multipart: %v", err) |
| + } |
| + for { |
| + if _, err := mr.NextPart(); err == io.EOF { |
| + break |
| + } else if err != nil { |
| + t.Fatalf("part: %v", err) |
| + } |
| + } |
| + w.WriteHeader(http.StatusOK) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "result": "shipped", "version": "abc1234", "artifacts": map[string]any{}, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + bin := filepath.Join(dir, "carlos") |
| + if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + |
| + out := buf.String() |
| + if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want %q", out, want) |
| + } |
| + if strings.Contains(out, "shipped titogo abc1234\n") { |
| + t.Fatalf("stdout = %q, must not ALSO print the ordinary shipped line", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPromoteVerbAPIMode pins runPromote's API-mode wiring: it must promote |
| +// through the console and print the SAME "promoted" line the direct path |
| +// prints (cli.go's runPromote default case) — the verb, not the transport, |
| +// owns that wording. |
| +func TestPromoteVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/promote" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + var req struct{ Version, Channel string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + if req.Version != "abc1234" || req.Channel != "canary/x" { |
| + t.Errorf("promote body = %+v", req) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"}) |
| + |
| + out := buf.String() |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want %q", out, want) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode |
| +// promote must print the byte-identical "already on X — nothing to do" shape |
| +// direct mode prints (runPromote's res.NoOp branch), not a differently-worded |
| +// API-mode-only message. |
| +func TestPromoteVerbAPIModeNoOp(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{"result": "noop"}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"}) |
| + |
| + out := buf.String() |
| + if want := "titogo abc1234 is already on canary/x — nothing to do"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want %q", out, want) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the |
| +// ladder through the console and print the same tabwriter table direct mode |
| +// prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row |
| +// ERROR behaviour, per apimode.go's doc comment). |
| +func TestChannelsVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/channels" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "channels": []map[string]any{ |
| + {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"}, |
| + {"channel": "broken", "error": "signature mismatch"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runChannels([]string{"-app", "titogo", "-account", apiTestAccount}) |
| + |
| + out := buf.String() |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") { |
| + t.Fatalf("stdout = %q, want the tabwriter header", out) |
| + } |
| + if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") { |
| + t.Fatalf("stdout = %q, want the canary/a row", out) |
| + } |
| + if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") { |
| + t.Fatalf("stdout = %q, want the broken row's ERROR text", out) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestRollbackVerbAPIMode pins runRollback's API-mode wiring. |
| +func TestRollbackVerbAPIMode(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/rollback" { |
| + t.Errorf("path = %s", r.URL.Path) |
| + } |
| + var req struct{ Channel string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + if req.Channel != "canary/x" { |
| + t.Errorf("rollback body = %+v", req) |
| + } |
| + json.NewEncoder(w).Encode(map[string]string{"result": "rolled back"}) |
| + })) |
| + defer ts.Close() |
| + |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + buf := captureStdout(t) |
| + runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "canary/x"}) |
| + |
| + out := buf.String() |
| + if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want it to contain %q", out, want) |
| + } |
| + if want := "rolled back titogo canary/x"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want %q", out, want) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestPromoteVerbDirectUnchanged pins the other half of resolveMode's |
| +// contract: with CARLOS_DEPLOYMENT_DIR set, runPromote must take the existing |
| +// direct-bucket path unchanged and never dial a console at all — even one it |
| +// holds a live (and, if it mistakenly used it, perfectly usable) credential |
| +// for. The httptest server's request counter is the proof: it must stay 0. |
| +func TestPromoteVerbDirectUnchanged(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + // A live credential pointed AT the counting server: direct mode must win |
| + // regardless (CARLOS_DEPLOYMENT_DIR set below), so this must never be |
| + // dialed — if it ever were, reqCount would catch it. |
| + loginTo(t, ts.URL, "vicky@keymail.dev") |
| + |
| + dir := t.TempDir() |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", dir) |
| + |
| + seed, pub, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Setenv("CARLOS_RELEASE_KEY", seed) |
| + t.Setenv("CARLOS_RELEASE_PUBKEY", pub) |
| + |
| + bin := filepath.Join(t.TempDir(), "carlos") |
| + if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runShip([]string{"-app", "demoapp", "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + runPromote([]string{"-app", "demoapp", "abc1234", "canary/x"}) |
| + |
| + out := buf.String() |
| + // Pins ship's direct-mode print format exactly (review round, MINOR 8): |
| + // unlike API mode's deliberately different "shipped <app> <version>" |
| + // (no count), direct mode still names the artifact count — this must |
| + // stay byte-identical to what it printed before Task 5 touched this verb. |
| + if want := "shipped demoapp abc1234 (1 artifacts)"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want the direct-mode shipped line", out) |
| + } |
| + if want := "promoted demoapp abc1234 -> canary/x"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout = %q, want the direct-mode promoted line", out) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 0 { |
| + t.Fatalf("server got %d requests, want 0 — direct mode must never dial the console", got) |
| + } |
| +} |
| + |
| +// ---- Task 5 review round: runShipAPI's static/multi-file/pre-flight paths - |
| + |
| +// TestShipAPIStaticNamesSiteTarGz pins runShipAPI's static branch end to end |
| +// (review round, IMPORTANT 2 — previously zero coverage): the packed tarball |
| +// must reach the server in a part named EXACTLY "site.tar.gz", carrying the |
| +// same bytes edge.PackDir itself produces for the same directory — proof the |
| +// scratch-temp-file plumbing (PackDir -> temp file -> multipart part) does |
| +// not mangle anything in between. |
| +func TestShipAPIStaticNamesSiteTarGz(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("<h1>hi</h1>"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + wantBytes, err := edge.PackDir(siteDir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var gotName string |
| + var gotBytes []byte |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatalf("not multipart: %v", err) |
| + } |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatalf("part: %v", err) |
| + } |
| + b, err := io.ReadAll(part) |
| + if err != nil { |
| + t.Fatalf("read part: %v", err) |
| + } |
| + if part.FormName() != "meta" { |
| + gotName = part.FormName() |
| + gotBytes = b |
| + } |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| + if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err != nil { |
| + t.Fatalf("runShipAPI: %v", err) |
| + } |
| + |
| + if gotName != "site.tar.gz" { |
| + t.Fatalf("server received artifact part named %q, want %q", gotName, "site.tar.gz") |
| + } |
| + if !bytes.Equal(gotBytes, wantBytes) { |
| + t.Fatalf("server received %d bytes, want the %d bytes edge.PackDir produced for the same directory", len(gotBytes), len(wantBytes)) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIStaticCleansUpTempFileOnServerError pins the temp-file-leak fix |
| +// (review round, MINOR 4): runShipAPI used to call log.Fatal on a failing |
| +// sess.ship, which calls os.Exit and skips every deferred function — |
| +// including the static kind's scratch temp file cleanup, up to 256MiB per |
| +// invocation. Returning an error instead (the caller log.Fatals) lets that |
| +// defer actually run. |
| +func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hi"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + io.Copy(io.Discard, r.Body) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + |
| + tmpParent := os.TempDir() |
| + before, err := os.ReadDir(tmpParent) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + beforeNames := map[string]bool{} |
| + for _, e := range before { |
| + beforeNames[e.Name()] = true |
| + } |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir) |
| + if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err == nil { |
| + t.Fatal("runShipAPI against a failing server must error") |
| + } |
| + |
| + after, err := os.ReadDir(tmpParent) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range after { |
| + if beforeNames[e.Name()] { |
| + continue |
| + } |
| + if strings.HasPrefix(e.Name(), "carlos-ship-") { |
| + t.Fatalf("temp file %s leaked after a failed ship", e.Name()) |
| + } |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIMultiFileGoodBasenames pins the multi-file binary ship path |
| +// (review round, IMPORTANT 2 + MINOR 7's good case): every basename shaped |
| +// like a GOOS-GOARCH target ships as its own part, named exactly its |
| +// basename. |
| +func TestShipAPIMultiFileGoodBasenames(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + dir := t.TempDir() |
| + fileArm := filepath.Join(dir, "linux-arm64") |
| + fileAmd := filepath.Join(dir, "linux-amd64") |
| + if err := os.WriteFile(fileArm, []byte("arm bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(fileAmd, []byte("amd bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var reqCount int32 |
| + gotParts := map[string]bool{} |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + t.Fatalf("not multipart: %v", err) |
| + } |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Fatalf("part: %v", err) |
| + } |
| + io.Copy(io.Discard, part) |
| + if part.FormName() != "meta" { |
| + gotParts[part.FormName()] = true |
| + } |
| + } |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}}) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd) |
| + if err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil { |
| + t.Fatalf("runShipAPI: %v", err) |
| + } |
| + |
| + if !gotParts["linux-arm64"] || !gotParts["linux-amd64"] || len(gotParts) != 2 { |
| + t.Fatalf("server received parts %v, want exactly linux-arm64 and linux-amd64", gotParts) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 1 { |
| + t.Fatalf("server got %d requests, want 1", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIMultiFileBadBasenameRefused pins the client-side name check |
| +// (review round, MINOR 7's bad case): a multi-file binary ship whose |
| +// basename doesn't fit the server's own GOOS-GOARCH shape (targetRe, |
| +// internal/console/apicli.go) must be refused before ever dialing the |
| +// console, naming the offending file. |
| +func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + dir := t.TempDir() |
| + fileGood := filepath.Join(dir, "linux-arm64") |
| + fileBad := filepath.Join(dir, "output.bin") |
| + if err := os.WriteFile(fileGood, []byte("arm bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(fileBad, []byte("bad bytes"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad) |
| + err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| + if err == nil { |
| + t.Fatal("runShipAPI with a bad basename must error") |
| + } |
| + if !strings.Contains(err.Error(), "output.bin") { |
| + t.Fatalf("error %q does not name the offending file", err) |
| + } |
| + if !strings.Contains(err.Error(), "invalid artifact name") { |
| + t.Fatalf("error %q does not explain the refusal", err) |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 0 { |
| + t.Fatalf("server got %d requests, want 0 — a bad basename must be refused before dialing the console", got) |
| + } |
| + checkNoRegistry() |
| +} |
| + |
| +// TestShipAPIMissingArtifactRefusedBeforeDialing pins the pre-flight stat |
| +// (review round, MINOR 6): a typo'd artifact path must fail clean before the |
| +// console is ever dialed, not after. |
| +func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + missing := filepath.Join(t.TempDir(), "linux-arm64") // never created |
| + |
| + var reqCount int32 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + atomic.AddInt32(&reqCount, 1) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + fs, acctRaw := newShipFlagSet(t, apiTestAccount, missing) |
| + err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64") |
| + if err == nil { |
| + t.Fatal("runShipAPI with a missing artifact path must error") |
| + } |
| + if got := atomic.LoadInt32(&reqCount); got != 0 { |
| + t.Fatalf("server got %d requests, want 0 — a missing path must be refused before dialing the console", got) |
| + } |
| + checkNoRegistry() |
| +} |
diff --git a/appconfig_cli.go b/appconfig_cli.go| index 1a4528ae1..749f32fc7 100644 |
| --- a/appconfig_cli.go |
| +++ b/appconfig_cli.go |
| @@ -177,7 +177,7 @@ func runEnvSet(args []string) { |
| fs := flag.NewFlagSet("env set", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]") |
| @@ -197,7 +197,7 @@ func runEnvUnset(args []string) { |
| fs := flag.NewFlagSet("env unset", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() == 0 { |
| log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]") |
| @@ -214,7 +214,7 @@ func runEnvList(args []string) { |
| fs := flag.NewFlagSet("env list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos env list -app <a> [-environment <name>]") |
| @@ -264,7 +264,7 @@ func runEnvExecDelivery(args []string) { |
| // below. There is no environment-scoped variant to route it to: |
| // appconfig.SetExecDelivery has no environment parameter at all. |
| environment := fs.String("environment", "", "refused: exec_delivery is app-level only (default bundle), never a named environment") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| log.Fatal("usage: carlos env exec-delivery -app <a> file|off") |
| @@ -299,7 +299,7 @@ func runEnvExecDelivery(args []string) { |
| func runEnvEnvironments(args []string) { |
| fs := flag.NewFlagSet("env environments", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos env environments -app <a>") |
| @@ -362,7 +362,7 @@ func runSecretsSet(args []string) { |
| fs := flag.NewFlagSet("secrets set", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]") |
| @@ -399,7 +399,7 @@ func runSecretsUnset(args []string) { |
| fs := flag.NewFlagSet("secrets unset", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" || fs.NArg() == 0 { |
| log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]") |
| @@ -416,7 +416,7 @@ func runSecretsList(args []string) { |
| fs := flag.NewFlagSet("secrets list", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)") |
| - acctOf := accountFlags(fs) |
| + _, acctOf := accountFlags(fs) |
| fs.Parse(args) |
| if *app == "" { |
| log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]") |
diff --git a/auth.go b/auth.go| index 6e3d42c37..fba2b8a39 100644 |
| --- a/auth.go |
| +++ b/auth.go |
| @@ -37,6 +37,7 @@ import ( |
| "flag" |
| "fmt" |
| "io" |
| + "mime/multipart" |
| "net" |
| "net/http" |
| "net/url" |
| @@ -394,6 +395,71 @@ func (c *consoleClient) do(ctx context.Context, method, path string, in, out any |
| return res.StatusCode, nil |
| } |
| +// shipHTTPClientTimeout is the ABSOLUTE per-request Timeout doMultipart's own |
| +// client carries — zero (the production default) means none: a ship relies |
| +// solely on its caller's context deadline (apimode.go's ship gives itself a |
| +// generous 15 minutes), because the console's own budget for a big upload — |
| +// up to 10 minutes to read the body, up to 2 more to store it |
| +// (internal/console/apicli.go) — already exceeds the 30s absolute Timeout |
| +// c.httpc carries for the JSON verbs. Reusing that client here would abort a |
| +// large, still-healthy upload CLIENT-side while the server was still working |
| +// on it, reporting a live ship as failed. |
| +// |
| +// A var only so a test can shrink it far below what any real upload takes — |
| +// set it small against a deliberately slow server and the request fails |
| +// fast, proving the failure mode the zero-Timeout client is built to avoid, |
| +// without an actual multi-minute sleep to demonstrate it. |
| +var shipHTTPClientTimeout time.Duration |
| + |
| +// doMultipart runs one multipart/form-data POST, streaming the body through |
| +// an io.Pipe rather than buffering it whole: write feeds the multipart.Writer |
| +// from a goroutine while http.Client reads the other end of the pipe as it |
| +// sends the request, so a 30MiB artifact is never held twice over (once in |
| +// caller memory, once again in an http.Client-owned buffer). |
| +// |
| +// Unlike do(), a non-200 status here is NOT turned into an error — the raw |
| +// body comes back alongside the status so a caller that needs more than a |
| +// bare {"error"} string (a stable-refusal's console_url, ship()'s per- |
| +// artifact result) can decode it itself. |
| +func (c *consoleClient) doMultipart(ctx context.Context, path string, write func(*multipart.Writer) error) (int, []byte, error) { |
| + pr, pw := io.Pipe() |
| + mw := multipart.NewWriter(pw) |
| + go func() { |
| + err := write(mw) |
| + if err == nil { |
| + err = mw.Close() |
| + } |
| + pw.CloseWithError(err) |
| + }() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, pr) |
| + if err != nil { |
| + // The write goroutine above is already running (or about to run) and |
| + // will otherwise block forever trying to write to a pipe nobody ever |
| + // reads: closing our end unblocks it with io.ErrClosedPipe instead of |
| + // leaking it. |
| + pr.Close() |
| + return 0, nil, err |
| + } |
| + req.Header.Set("Content-Type", mw.FormDataContentType()) |
| + if c.token != "" { |
| + req.Header.Set("Authorization", "Bearer "+c.token) |
| + } |
| + // A client of its own — see shipHTTPClientTimeout above — never c.httpc, |
| + // whose 30s absolute Timeout is sized for the JSON verbs, not a large |
| + // upload. |
| + client := &http.Client{Timeout: shipHTTPClientTimeout} |
| + res, err := client.Do(req) |
| + if err != nil { |
| + return 0, nil, err |
| + } |
| + defer res.Body.Close() |
| + body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20)) |
| + if err != nil { |
| + return res.StatusCode, nil, err |
| + } |
| + return res.StatusCode, body, nil |
| +} |
| + |
| type startResponse struct { |
| UserCode string `json:"user_code"` |
| DeviceCode string `json:"device_code"` |
diff --git a/auth_e2e_test.go b/auth_e2e_test.go| index 2fadb189f..967e34ef3 100644 |
| --- a/auth_e2e_test.go |
| +++ b/auth_e2e_test.go |
| @@ -1,7 +1,15 @@ |
| package main |
| import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| "os" |
| "os/exec" |
| "path/filepath" |
| @@ -9,6 +17,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/console" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -197,3 +206,292 @@ func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- Task 6: the whole chain, end to end ----------------------------------- |
| + |
| +// e2ePostJSON is a bare, unauthenticated JSON POST — the shape authLogin's |
| +// own start/poll calls make (auth.go). |
| +func e2ePostJSON(t *testing.T, u string, body any) map[string]any { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + var out map[string]any |
| + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +// e2eDeviceLogin drives `carlos auth login`'s three HTTP legs — POST |
| +// /auth/cli/start, POST /auth/cli/approve, POST /auth/cli/poll — against a |
| +// REAL console.Server, the exact wire protocol authLogin (auth.go) speaks. |
| +// |
| +// The one leg this does NOT reproduce is the human's own trip through |
| +// Keymail: internal/console/authcli_test.go's TestCLILoginEndToEnd already |
| +// drives that ceremony end to end (a fake Keymail, PKCE, the passkey |
| +// step-up redirect) inside package console, so redoing it here would only |
| +// be the same assertion through a slower path — this test's job is what |
| +// happens AFTER a token exists, not the ceremony that mints one. So this |
| +// mints an already-signed-in, already-stepped-up session straight through |
| +// the Store's own exported primitives — CreateSession + RecordStepUp are |
| +// exactly what a completed Keymail round trip leaves behind — and then |
| +// drives approve/poll for real, over real HTTP, against the real handlers. |
| +func e2eDeviceLogin(t *testing.T, consoleURL string, cst *console.Store, addr, label string) (token string, expiresAt time.Time) { |
| + t.Helper() |
| + start := e2ePostJSON(t, consoleURL+"/auth/cli/start", map[string]string{"label": label}) |
| + userCode, _ := start["user_code"].(string) |
| + deviceCode, _ := start["device_code"].(string) |
| + if userCode == "" || deviceCode == "" { |
| + t.Fatalf("auth/cli/start = %v, want both codes", start) |
| + } |
| + |
| + now := time.Now() |
| + raw, err := cst.CreateSession(addr, "", now) |
| + if err != nil { |
| + t.Fatalf("mint session: %v", err) |
| + } |
| + if err := cst.RecordStepUp(raw, addr, now, now); err != nil { |
| + t.Fatalf("record step-up: %v", err) |
| + } |
| + |
| + form := url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {raw}} |
| + req, err := http.NewRequest(http.MethodPost, consoleURL+"/auth/cli/approve", strings.NewReader(form.Encode())) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Cookie", "carlos_console_session="+raw) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("approve: %v", err) |
| + } |
| + body, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Approved") { |
| + t.Fatalf("approve = %d:\n%s", resp.StatusCode, body) |
| + } |
| + |
| + poll := e2ePostJSON(t, consoleURL+"/auth/cli/poll", map[string]string{"device_code": deviceCode}) |
| + if poll["status"] != "approved" { |
| + t.Fatalf("poll after approval = %v, want approved", poll) |
| + } |
| + token, _ = poll["token"].(string) |
| + if token == "" { |
| + t.Fatalf("poll approved with no token: %v", poll) |
| + } |
| + expiresAt = time.Now().Add(90 * 24 * time.Hour) |
| + if es, _ := poll["expires_at"].(string); es != "" { |
| + if parsed, err := time.Parse(time.RFC3339, es); err == nil { |
| + expiresAt = parsed |
| + } |
| + } |
| + return token, expiresAt |
| +} |
| + |
| +// TestE2ECLIThroughConsole is the acceptance test for the whole feature: a |
| +// REAL console.Server (a dir store, a real ed25519 signing key — no hand- |
| +// rolled httptest handlers standing in for it) fronted by an httptest |
| +// listener, a device-flow login against it landing a real bearer token in a |
| +// temp credential file, and then — with NO store env set at all, exactly a |
| +// developer's laptop — every API-mode verb driven through its real cli.go |
| +// entry point: ship, promote, channels, a refused stable promote, and a |
| +// rollback. |
| +func TestE2ECLIThroughConsole(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + tmp := t.TempDir() |
| + relStore := release.NewDirStore(filepath.Join(tmp, "store")) |
| + seed, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := release.ParseSigningKey(seed, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "e2e") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + cst, err := console.Open(filepath.Join(tmp, "console.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { cst.Close() }) |
| + |
| + // The origin must equal the listener's own URL, which only exists after |
| + // the listener starts — same indirection internal/console's own fixture |
| + // (server_test.go) uses. |
| + var srv *console.Server |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + srv.ServeHTTP(w, r) |
| + })) |
| + t.Cleanup(ts.Close) |
| + |
| + srv, err = console.NewServer(console.Config{ |
| + Origin: ts.URL, |
| + Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin |
| + Registry: filepath.Join(tmp, "registry.db"), |
| + Releases: relStore, |
| + ReleasePub: pub, |
| + Key: priv, |
| + AppScheme: "http", |
| + }, cst) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + const addr = "paul@keymail.dev" |
| + if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // --- device flow: start -> approve -> poll, to a real token on disk ---- |
| + token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // --- ship: a 1KiB linux-arm64 binary, through the console -------------- |
| + dir := t.TempDir() |
| + bin := filepath.Join(dir, "carlos") |
| + payload := bytes.Repeat([]byte{0x42}, 1024) |
| + if err := os.WriteFile(bin, payload, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + if !strings.Contains(buf.String(), "shipped titogo abc1234") { |
| + t.Fatalf("ship stdout = %q, want the shipped line", buf.String()) |
| + } |
| + |
| + sum := sha256.Sum256(payload) |
| + wantHash := hex.EncodeToString(sum[:]) |
| + m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo", "abc1234") |
| + if err != nil { |
| + t.Fatalf("GetManifest after ship: %v", err) |
| + } |
| + art, ok := m.Artifacts["linux-arm64"] |
| + if !ok || art.SHA256 != wantHash || art.Size != int64(len(payload)) { |
| + t.Fatalf("shipped manifest artifact = %+v, want sha256 %s size %d", art, wantHash, len(payload)) |
| + } |
| + |
| + // Identical re-ship: 200, not 201 — the noop-parity line, not the |
| + // ordinary "shipped" one. |
| + buf.Reset() |
| + runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin}) |
| + if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("re-ship stdout = %q, want %q", buf.String(), want) |
| + } |
| + |
| + // --- promote to canary/e2e: pointer moves, audit row names the CLI ----- |
| + buf.Reset() |
| + runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"}) |
| + if want := "promoted titogo abc1234 -> canary/e2e"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("promote stdout = %q, want %q", buf.String(), want) |
| + } |
| + cp, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e") |
| + if err != nil || cp.Version != "abc1234" { |
| + t.Fatalf("channel canary/e2e = %+v %v, want version abc1234", cp, err) |
| + } |
| + events, err := cst.Events(console.OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + foundAudit := false |
| + for _, e := range events { |
| + if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") { |
| + foundAudit = true |
| + } |
| + } |
| + if !foundAudit { |
| + t.Fatalf("audit events = %+v, want a promote row naming (cli)", events) |
| + } |
| + |
| + // A repeat promote of the same version onto the same channel is a |
| + // no-op — the same parity line the direct path prints (Task 5's |
| + // noop-parity amendment). |
| + buf.Reset() |
| + runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"}) |
| + if want := "titogo abc1234 is already on canary/e2e — nothing to do"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("repeat promote stdout = %q, want %q", buf.String(), want) |
| + } |
| + |
| + // --- channels: canary/e2e and its version show up ----------------------- |
| + buf.Reset() |
| + runChannels([]string{"-app", "titogo", "-account", "ops"}) |
| + if out := buf.String(); !strings.Contains(out, "canary/e2e") || !strings.Contains(out, "abc1234") { |
| + t.Fatalf("channels stdout = %q, want canary/e2e and abc1234", out) |
| + } |
| + |
| + // --- promote to stable: refused, naming the console; pointer untouched - |
| + // |
| + // runPromote itself log.Fatals on this error (os.Exit — nothing an |
| + // in-process test could recover from), so the refusal is asserted at the |
| + // apiSession level instead: the exact call runPromote's API-mode branch |
| + // makes, against the same real server, over the same credential. |
| + sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr} |
| + if _, err := sess.promote(context.Background(), "ops", "titogo", "abc1234", "stable"); err == nil { |
| + t.Fatal("promote to stable must refuse") |
| + } else { |
| + if !strings.Contains(err.Error(), ts.URL) { |
| + t.Fatalf("stable refusal %q does not name the console URL", err) |
| + } |
| + if !strings.Contains(err.Error(), "passkey") { |
| + t.Fatalf("stable refusal %q does not carry the console's refusal text", err) |
| + } |
| + } |
| + if _, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "stable"); err == nil { |
| + t.Fatal("stable has a pointer — the refused promote must not have created one") |
| + } |
| + |
| + // --- a second ship+promote, then roll back ------------------------------ |
| + payload2 := bytes.Repeat([]byte{0x43}, 1024) |
| + bin2 := filepath.Join(dir, "carlos-def5678") |
| + if err := os.WriteFile(bin2, payload2, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + buf.Reset() |
| + runShip([]string{"-app", "titogo", "-account", "ops", "-version", "def5678", "-target", "linux-arm64", bin2}) |
| + runPromote([]string{"-app", "titogo", "-account", "ops", "def5678", "canary/e2e"}) |
| + cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e") |
| + if err != nil || cp.Version != "def5678" { |
| + t.Fatalf("channel canary/e2e after second promote = %+v %v, want def5678", cp, err) |
| + } |
| + |
| + buf.Reset() |
| + runRollback([]string{"-app", "titogo", "-account", "ops", "canary/e2e"}) |
| + if want := "rolled back titogo canary/e2e"; !strings.Contains(buf.String(), want) { |
| + t.Fatalf("rollback stdout = %q, want %q", buf.String(), want) |
| + } |
| + cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e") |
| + if err != nil || cp.Version != "abc1234" { |
| + t.Fatalf("channel canary/e2e after rollback = %+v %v, want back to abc1234", cp, err) |
| + } |
| + |
| + checkNoRegistry() |
| +} |
diff --git a/cli.go b/cli.go| index 88872fe60..fdccb2676 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -9,11 +9,14 @@ import ( |
| "crypto/ed25519" |
| "crypto/sha256" |
| "encoding/hex" |
| + "errors" |
| "flag" |
| "fmt" |
| + "io" |
| "log" |
| "os" |
| "path/filepath" |
| + "regexp" |
| "strings" |
| "text/tabwriter" |
| "time" |
| @@ -26,6 +29,15 @@ import ( |
| "github.com/carlosframework/platform/internal/version" |
| ) |
| +// stdout is the print seam for the four release-pipeline verbs that now |
| +// speak the console API (ship/promote/channels/rollback): a package-level |
| +// var rather than an os.Stdout literal, so a test can swap it for a buffer |
| +// and assert on it. Every OTHER verb in this file still writes straight to |
| +// os.Stdout/fmt.Printf, unchanged — this seam exists only where Task 5 added |
| +// behaviour worth pinning with an assertion; log.Fatal paths are untouched by |
| +// it on purpose, exactly as they were before. |
| +var stdout io.Writer = os.Stdout |
| + |
| func dataDir() string { |
| if d := os.Getenv("CARLOS_DATA_DIR"); d != "" { |
| return d |
| @@ -710,13 +722,20 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub |
| // single-tenant deployment never types it. Reads fall back to the legacy |
| // unqualified keys either way, so an unmigrated app keeps working. |
| // |
| -// Returns a thunk rather than the value because resolution has to happen |
| -// AFTER fs.Parse — and it opens the registry, which a verb that never uses |
| -// the account should not pay for. |
| -func accountFlags(fs *flag.FlagSet) func() string { |
| +// Returns the raw flag value alongside a resolver thunk, rather than the |
| +// resolved value alone, because resolution has to happen AFTER fs.Parse — |
| +// and it opens the registry, which a verb that never uses the account should |
| +// not pay for. The raw pointer exists for API mode (cli.go's four |
| +// console-API verbs): they must send the -account value verbatim (a |
| +// developer laptop has no local registry to resolve it against, and the |
| +// console does its own resolution against the caller's memberships), and |
| +// reading it straight off this return value keeps that path from being |
| +// string-coupled to this function's internal flag name the way |
| +// fs.Lookup("account") would be. |
| +func accountFlags(fs *flag.FlagSet) (raw *string, resolve func() string) { |
| acct := fs.String("account", "ops", "account (sqid or name) the app belongs to") |
| regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)") |
| - return func() string { return resolveAccount(*regPath, *acct) } |
| + return acct, func() string { return resolveAccount(*regPath, *acct) } |
| } |
| func openStore() release.Store { |
| @@ -791,14 +810,27 @@ func publicKey() ed25519.PublicKey { |
| func runShip(args []string) { |
| fs := flag.NewFlagSet("ship", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctOf := accountFlags(fs) |
| + acctRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)") |
| kind := fs.String("kind", "binary", "binary | static") |
| ver := fs.String("version", "", "version (git short sha)") |
| target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)") |
| fs.Parse(args) |
| if *app == "" || *ver == "" || fs.NArg() == 0 { |
| - log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] <files-or-site-dir>") |
| + log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-console <origin>] <files-or-site-dir>") |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| } |
| + if sess != nil { |
| + if err := runShipAPI(sess, acctRaw, fs, *app, *ver, *kind, *target); err != nil { |
| + log.Fatal(err) |
| + } |
| + return |
| + } |
| + |
| files := map[string][]byte{} |
| arts := map[string]release.Artifact{} |
| addFile := func(name string, data []byte) { |
| @@ -844,7 +876,97 @@ func runShip(args []string) { |
| if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("shipped %s %s (%d artifacts)\n", *app, *ver, len(files)) |
| + fmt.Fprintf(stdout, "shipped %s %s (%d artifacts)\n", *app, *ver, len(files)) |
| +} |
| + |
| +// shipTargetRe validates a binary artifact's part name client-side, against |
| +// the exact shape the server enforces (targetRe, internal/console/apicli.go) |
| +// — checked here too so a malformed name (a build filename shipped without |
| +// -target, say) is refused before a single byte crosses the wire, rather |
| +// than after paying for the whole upload and hitting the server's own 400. |
| +var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| + |
| +// runShipAPI is runShip's API-mode body: the raw -account flag value goes |
| +// straight to the console (never acctOf/resolveAccount — there is no local |
| +// registry to resolve against on a developer laptop). Unlike the direct-mode |
| +// body above, artifacts are streamed from disk by path (apiSession.ship |
| +// reads them lazily) rather than buffered into memory here first — for a |
| +// static release, whose bytes only exist in memory (edge.PackDir's tarball), |
| +// that means writing them to a scratch temp file first so there is a path to |
| +// hand over at all. |
| +// |
| +// Returns an error rather than calling log.Fatal itself (the caller does |
| +// that): log.Fatal calls os.Exit immediately, which skips every deferred |
| +// function — including the static kind's scratch temp file cleanup below, |
| +// up to 256MiB per invocation. Returning lets that defer actually run on |
| +// every exit path, fatal or not. |
| +func runShipAPI(sess *apiSession, acctRaw *string, fs *flag.FlagSet, app, ver, kind, target string) error { |
| + acct := *acctRaw |
| + files := map[string]string{} |
| + if kind == "static" { |
| + if fs.NArg() != 1 { |
| + return errors.New("static ship takes exactly one site directory") |
| + } |
| + data, err := edge.PackDir(fs.Arg(0)) |
| + if err != nil { |
| + return err |
| + } |
| + tmp, err := os.CreateTemp("", "carlos-ship-*.tar.gz") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.Remove(tmp.Name()) |
| + if _, err := tmp.Write(data); err != nil { |
| + tmp.Close() |
| + return err |
| + } |
| + if err := tmp.Close(); err != nil { |
| + return err |
| + } |
| + files["site.tar.gz"] = tmp.Name() |
| + } else if fs.NArg() == 1 { |
| + files[target] = fs.Arg(0) |
| + } else { |
| + // Multiple files: pre-named per target (linux-arm64, linux-amd64, |
| + // ...) by basename, exactly like the direct-mode body above. |
| + for _, path := range fs.Args() { |
| + files[filepath.Base(path)] = path |
| + } |
| + } |
| + |
| + // Client-side name check, binary kind only (a static release's one part |
| + // is always named "site.tar.gz" above, never a caller-supplied name) — |
| + // same shape the server enforces, checked here so a bad basename is |
| + // named and refused before anything is stat'd or dialed. |
| + if kind != "static" { |
| + for name, path := range files { |
| + if !shipTargetRe.MatchString(name) { |
| + return fmt.Errorf("ship: invalid artifact name %q (from %s) — want a GOOS-GOARCH shape like linux-arm64 "+ |
| + "(pass -target to name a single file explicitly)", name, path) |
| + } |
| + } |
| + } |
| + |
| + // Pre-flight: every artifact path must exist before the console is ever |
| + // dialed or a byte moves — a typo'd path must fail clean here, not after |
| + // the "using ..." line already printed. |
| + for name, path := range files { |
| + if _, err := os.Stat(path); err != nil { |
| + return fmt.Errorf("ship: artifact %q: %w", name, err) |
| + } |
| + } |
| + |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + res, err := sess.ship(context.Background(), acct, app, ver, kind, files) |
| + if err != nil { |
| + return err |
| + } |
| + if res.NoOp { |
| + fmt.Fprintf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver) |
| + } else { |
| + fmt.Fprintf(stdout, "shipped %s %s\n", app, ver) |
| + } |
| + return nil |
| } |
| // runPromote points a channel at a shipped version, with the ladder enforced: |
| @@ -856,12 +978,42 @@ func runPromote(args []string) { |
| fs := flag.NewFlagSet("promote", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)") |
| - acctOf := accountFlags(fs) |
| + acctRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 2 { |
| - log.Fatal("usage: carlos promote -app <a> [-hotfix] <version> <channel>") |
| + log.Fatal("usage: carlos promote -app <a> [-hotfix] [-console <origin>] <version> <channel>") |
| } |
| version, channel := fs.Arg(0), fs.Arg(1) |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess != nil { |
| + if *hotfix { |
| + // A hotfix bypasses the signed ladder's ordering check entirely — |
| + // an operator act, recorded as such (spec §3). The console's own |
| + // promote door (handleAPIPromote) has no hotfix field at all, so |
| + // there is no way to express this through the API even in |
| + // principle; refuse before ever dialing out, rather than let the |
| + // flag silently do nothing. |
| + log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console") |
| + } |
| + acct := *acctRaw |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + res, err := sess.promote(context.Background(), acct, *app, version, channel) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if res.NoOp { |
| + fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| + } else { |
| + fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel) |
| + } |
| + return |
| + } |
| + |
| res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(), |
| acctOf(), *app, channel, version, |
| release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()}) |
| @@ -870,11 +1022,11 @@ func runPromote(args []string) { |
| } |
| switch { |
| case res.NoOp: |
| - fmt.Printf("%s %s is already on %s — nothing to do\n", *app, version, channel) |
| + fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel) |
| case res.Hotfix: |
| - fmt.Printf("promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel) |
| + fmt.Fprintf(stdout, "promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel) |
| default: |
| - fmt.Printf("promoted %s %s -> %s\n", *app, version, channel) |
| + fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel) |
| } |
| } |
| @@ -894,11 +1046,37 @@ func promoteActor() string { |
| func runChannels(args []string) { |
| fs := flag.NewFlagSet("channels", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctOf := accountFlags(fs) |
| + acctRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" { |
| - log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>]") |
| + log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>] [-console <origin>]") |
| + } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| } |
| + if sess != nil { |
| + acct := *acctRaw |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + chans, err := sess.channels(context.Background(), acct, *app) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| + fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| + for _, ch := range chans { |
| + if ch.Err != "" { |
| + fmt.Fprintf(w, "%s\tERROR: %s\t\n", ch.Channel, ch.Err) |
| + continue |
| + } |
| + fmt.Fprintf(w, "%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt) |
| + } |
| + w.Flush() |
| + return |
| + } |
| + |
| acct := acctOf() |
| s := openStore() |
| prefix := release.ChannelsPrefix(acct, *app) |
| @@ -907,7 +1085,7 @@ func runChannels(args []string) { |
| log.Fatal(err) |
| } |
| pub := publicKey() |
| - w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| + w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0) |
| fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED") |
| for _, k := range keys { |
| name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| @@ -924,15 +1102,31 @@ func runChannels(args []string) { |
| func runRollback(args []string) { |
| fs := flag.NewFlagSet("rollback", flag.ExitOnError) |
| app := fs.String("app", "", "app name") |
| - acctOf := accountFlags(fs) |
| + acctRaw, acctOf := accountFlags(fs) |
| + consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)") |
| fs.Parse(args) |
| if *app == "" || fs.NArg() != 1 { |
| - log.Fatal("usage: carlos rollback -app <a> <channel>") |
| + log.Fatal("usage: carlos rollback -app <a> [-console <origin>] <channel>") |
| } |
| + |
| + sess, err := resolveMode(*consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess != nil { |
| + acct := *acctRaw |
| + fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address) |
| + if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0)) |
| + return |
| + } |
| + |
| if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0)) |
| + fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0)) |
| } |
| // defaultGrantTTL is how long a scoped key lives when the operator does not |
diff --git a/docs/superpowers/plans/2026-08-03-cli-console-api.md b/docs/superpowers/plans/2026-08-03-cli-console-api.md| new file mode 100644 |
| index 000000000..7dee0d94c |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-03-cli-console-api.md |
| @@ -0,0 +1,315 @@ |
| +# CLI Through the Console API Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** `carlos ship/promote/rollback/channels` work with only a `carlos auth login` credential — the console does bucket writes and pointer signing server-side. |
| + |
| +**Architecture:** New bearer-gated JSON/multipart endpoints in `internal/console` (new file `apicli.go`) that share the browser doors' refusal order and call the same `release.*` functions. The CLI (root package) gains credential-first mode resolution: explicit store env → today's direct path, otherwise a stored credential → API path. Stable promotion is refused at the API with the console URL. |
| + |
| +**Tech Stack:** Go stdlib only (net/http, mime/multipart, crypto/sha256). No new dependencies. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-03-cli-console-api-design.md` — read it first. |
| + |
| +## Global Constraints |
| + |
| +- Worktree: `.claude/worktrees/cli-console-api` (branch `worktree-cli-console-api`). All work happens there. |
| +- Build/test env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; tests may need the sandbox disabled (unix sockets / "read-only file system"). |
| +- `go build ./... && go vet ./... && go test ./... -count=1` clean before every commit; `gofmt -l` clean for touched files. |
| +- Console security-gate ordering is load-bearing (CLAUDE.md): auth → membership/app (byte-identical 404) → cheap refusals → rung gate LAST. |
| +- Comment style: state constraints the code can't show; match the file's existing density and voice. |
| +- API paths live under `/api/cli/`; auth endpoints stay under `/auth/cli/` (do not move them). |
| +- The `{account}` path segment accepts sqid OR name; the server resolves it against the caller's own memberships (resolution and membership proof are the same query — see Task 1). |
| + |
| +--- |
| + |
| +### Task 1: `apicli.go` — auth/resolution helper + the two read endpoints |
| + |
| +**Files:** |
| +- Create: `internal/console/apicli.go` |
| +- Create: `internal/console/apicli_test.go` |
| +- Modify: `internal/console/authcli.go` (registerCLIRoutes — add 2 routes) |
| + |
| +**Interfaces:** |
| +- Consumes: `(*Server).cliUser(w, r) (string, bool)` (authcli.go:254), `(*Store).AccountsFor(address) ([]Account, error)`, `(*Store).AppsIn(accountID) ([]string, error)`, `(*Store).HasAppIn(accountID, app) (bool, error)`, `release.Channel(ctx, store, pub, acct, app, name) (*ChannelPointer, error)`, `release.ChannelsPrefix(acct, app) string`, `writeJSON/jsonError` (authcli.go:206,215). |
| +- Produces (later tasks rely on these exact names): |
| + - `func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (addr string, acct *Account, app string, ok bool)` — bearer auth, resolves `r.PathValue("account")` (sqid or name) against the caller's memberships, checks `HasAppIn`. Failure = `http.NotFound(w, r)` after `cliUser` succeeded, `ok=false`; `cliUser` failure already wrote its 401. |
| + - Route pattern style: `s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps)` etc. |
| + |
| +- [ ] **Step 1: Write failing tests** in `apicli_test.go` (package `console`, follow `authcli_test.go` fixtures for building a Server with a temp Store + `release.NewDirStore(t.TempDir())`): |
| + |
| +```go |
| +// TestAPIAppsListsMemberships: seed accounts bab(ops)+bac, member alice in |
| +// bab only, apps: titogo in bab. Bearer alice-token GET /api/cli/apps → |
| +// 200 {"accounts":[{"sqid":"bab","name":"ops","role":"member", |
| +// "apps":["titogo"]}]}. No bac in the body. |
| +// TestAPIAppsNoToken: no Authorization → 401 with the cliUser body. |
| +// TestAPIChannelsHappy: promote titogo abc1234 to canary/x in the dir |
| +// store (release.Ship a minimal manifest, then release.Promote with a test |
| +// key; ReleasePub pinned to its public half). GET |
| +// /api/cli/apps/bab/titogo/channels → 200, one entry |
| +// {"channel":"canary/x","version":"abc1234","rung":"canary", |
| +// "promoted_at":...}. Account segment "ops" (the NAME) must work |
| +// identically. |
| +// TestAPIChannelsRefusalMatrix: (a) member of nothing → 404; (b) member |
| +// of bab but app unclaimed → 404; (c) both 404 bodies byte-identical to |
| +// http.NotFound output. |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify failure** — `go test ./internal/console/ -run TestAPI -count=1` → FAIL (handlers undefined). |
| + |
| +- [ ] **Step 3: Implement** `apicli.go`: |
| + |
| +```go |
| +// apiAccountApp resolves and authorizes one CLI API request. The account |
| +// segment accepts a sqid or a name, resolved against the caller's OWN |
| +// memberships — resolution and membership proof are deliberately the same |
| +// lookup, so an unknown account and someone else's account are the same 404. |
| +func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return "", nil, "", false |
| + } |
| + seg := r.PathValue("account") |
| + app := r.PathValue("app") |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| + return "", nil, "", false |
| + } |
| + var acct *Account |
| + for i := range accts { |
| + if accts[i].Sqid == seg || accts[i].Name == seg { |
| + acct = &accts[i] |
| + break |
| + } |
| + } |
| + if acct == nil { |
| + http.NotFound(w, r) |
| + return "", nil, "", false |
| + } |
| + if has, err := s.store.HasAppIn(acct.ID, app); err != nil || !has { |
| + http.NotFound(w, r) |
| + return "", nil, "", false |
| + } |
| + return addr, acct, app, true |
| +} |
| +``` |
| + |
| +`handleAPIApps`: `cliUser`, `AccountsFor`, per account `AppsIn`, `writeJSON(w, 200, ...)` with the shape in Step 1. `handleAPIChannels`: `apiAccountApp`, then list `s.cfg.Releases.List(ctx, release.ChannelsPrefix(acct.Sqid, app))`, per key `release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name)`; entries carry `channel`, `version`, `promoted_at` (RFC3339), `rung` (`release.Rung(name)`, empty when not a rung); a pointer that fails verification becomes `{"channel":name,"error":"..."}` — mirror `runChannels`' per-row ERROR behavior (cli.go:856), never a whole-response failure. 15s context timeout like `handlePromote`. |
| + |
| +- [ ] **Step 4: Run to verify pass** — same command → PASS. |
| +- [ ] **Step 5: Commit** — `git add internal/console/apicli.go internal/console/apicli_test.go internal/console/authcli.go && git commit -m "console: CLI API — accounts/apps listing and channels reads"` |
| + |
| +--- |
| + |
| +### Task 2: promote + rollback endpoints |
| + |
| +**Files:** |
| +- Modify: `internal/console/apicli.go`, `internal/console/apicli_test.go`, `internal/console/authcli.go` (2 routes) |
| + |
| +**Interfaces:** |
| +- Consumes: `apiAccountApp` (Task 1), `release.ValidChannel`, `release.ValidVersion`, `release.GetManifest(ctx, store, sqid, app, version)`, `release.PromoteOnLadder(ctx, store, key, sqid, app, channel, version, release.PromoteOptions{Actor: addr}) (PromoteResult, error)`, `release.Rollback(ctx, store, key, sqid, app, channel) error`, `release.Rung(channel) (string, bool)`, `release.RungStable`, `s.audit(acct, app, addr, verb, detail)`. |
| +- Produces: `POST /api/cli/apps/{account}/{app}/promote` body `{"version":"...","channel":"..."}` → 200 `{"result":"promoted","from":"...","to":"...","noop":false}`; `POST .../rollback` body `{"channel":"..."}` → 200 `{"result":"rolled back"}`. Stable → 403 `{"error":"stable needs a passkey — finish in the console","console_url":"<Origin>/a/<sqid>/apps/<app>"}`. |
| + |
| +- [ ] **Step 1: Failing tests**: |
| + |
| +```go |
| +// TestAPIPromoteHappy: shipped abc1234 in dir store; POST promote |
| +// {version:"abc1234",channel:"canary/x"} → 200; release.Channel now |
| +// reports abc1234; store audit row exists with detail containing "(cli)". |
| +// TestAPIPromoteStableRefused: POST channel "stable" → 403 with |
| +// console_url "<origin>/a/bab/apps/titogo"; response bytes IDENTICAL for |
| +// a member token and an owner token (spec §3 — role must not leak). |
| +// TestAPIPromoteNoKey: Server built with Key nil → 503. |
| +// TestAPIPromoteBadVersion / BadChannel / UnshippedVersion → 400/400/404 |
| +// with jsonError bodies naming the problem. |
| +// TestAPIPromoteNoHotfix: body {"hotfix":true} decodes into a struct |
| +// WITHOUT a hotfix field — unknown fields are ignored by encoding/json, |
| +// and the handler never sets PromoteOptions.Hotfix; ladder-refused |
| +// promotion stays refused (ship abc1234, promote to beta/x directly → |
| +// PromoteOnLadder error surfaces as 409 {"error": ladder text}). |
| +// TestAPIRollbackHappy: two promotions then rollback → pointer back. |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** Door order copied from `handlePromote` (server.go:1170) minus CSRF; the rung gate is the API variant: |
| + |
| +```go |
| +if rung, _ := release.Rung(req.Channel); rung == release.RungStable { |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| + }) |
| + return |
| +} |
| +``` |
| + |
| +Refusal mapping: invalid channel/version → 400; manifest missing → 404 (`jsonError`, text from the browser door: `"Version X is not a shipped release of Y."`); `PromoteOnLadder` error → 409 with `err.Error()` (the ladder text names the rungs); `res.NoOp` → 200 `{"result":"noop"}`. Audit: `s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)")`, rollback likewise. |
| + |
| +- [ ] **Step 4: Verify pass.** Also `go test ./internal/console/ -count=1` (whole package — the browser-door tests must still pass). |
| +- [ ] **Step 5: Commit** — `"console: CLI API — promote and rollback through the browser doors, stable refused"` |
| + |
| +--- |
| + |
| +### Task 3: ship endpoint (multipart upload) |
| + |
| +**Files:** |
| +- Modify: `internal/console/apicli.go`, `internal/console/apicli_test.go`, `internal/console/authcli.go` (1 route) |
| + |
| +**Interfaces:** |
| +- Consumes: `apiAccountApp`, `release.Ship(ctx, store, sqid, m Manifest, files map[string][]byte) error`, `release.Manifest{Schema:1, App, Version, Kind, BuiltBy, BuiltAt, Artifacts map[string]release.Artifact}`, `release.Artifact{File, SHA256, Size}`, `release.ValidVersion`. |
| +- Produces: `POST /api/cli/apps/{account}/{app}/releases`, multipart/form-data; first part `meta` (JSON `{"version":"...","kind":"binary"|"static"}`), then file parts whose part NAME is the artifact key (`linux-arm64`, `linux-amd64`, or `site.tar.gz` for static). → 201 `{"result":"shipped","version":"...","artifacts":{"linux-arm64":{"sha256":"...","size":N}}}`. |
| + |
| +- [ ] **Step 1: Failing tests**: |
| + |
| +```go |
| +// TestAPIShipHappy: multipart with meta {version:"abc1234",kind:"binary"} |
| +// + part "linux-arm64" of 1KiB random bytes → 201; dir store now has |
| +// releases/abc1234/linux-arm64 with those bytes and manifest.json whose |
| +// sha256/size match what the SERVER computed; BuiltBy has prefix |
| +// "console-api ". |
| +// TestAPIShipStatic: kind static, part "site.tar.gz" → 201. |
| +// TestAPIShipRefusals: bad version chars → 400; kind "weird" → 400; |
| +// binary part named "not-a-target" → 400; static with a part that is not |
| +// site.tar.gz → 400; no file parts → 400; meta not first part → 400. |
| +// TestAPIShipTooBig: server built with apiShipMaxBytes lowered to 4KiB |
| +// (make it a var, default 256<<20) and an 8KiB part → 413. |
| +// TestAPIShipMemberGate: non-member token → the same 404 as Task 1's |
| +// matrix. |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** Key points the implementer must not improvise: |
| + |
| +```go |
| +var apiShipMaxBytes int64 = 256 << 20 // one ship, all artifacts |
| + |
| +// Part names become bucket key segments via the manifest, so the charset |
| +// checks below are path-traversal defense, not tidiness. |
| +var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| +``` |
| + |
| +`r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)` before parsing; `r.MultipartReader()` (streaming — never `ParseMultipartForm`, which buffers to disk with its own limits); require the first part be `meta`, decode with a 8KiB `io.LimitReader`; each following part: validate name (`targetRe` for binary, exactly `"site.tar.gz"` for static), `io.ReadAll` into memory (release.Ship takes `map[string][]byte` — same buffering the direct path's `os.ReadFile` does; MaxBytesReader is the cap), sha256 the bytes, build `release.Artifact{File: name, SHA256: hex, Size: int64(len(b))}`. Manifest: `Schema:1, App:app, Version:meta.Version, Kind:meta.Kind, BuiltBy:"console-api "+version.Build, BuiltAt:time.Now().UTC().Format(time.RFC3339)`. `release.Ship` error → 502 `jsonError` (the store failed, not the caller). Audit `"shipped "+version+" (cli)"`. A `MaxBytesError` (check with `errors.As(err, new(*http.MaxBytesError))`) → 413. |
| + |
| +- [ ] **Step 4: Verify pass** (whole package). |
| +- [ ] **Step 5: Commit** — `"console: CLI API — multipart ship, hashes computed server-side"` |
| + |
| +--- |
| + |
| +### Task 4: CLI mode resolution + API client methods |
| + |
| +**Files:** |
| +- Create: `apimode.go` (root package) |
| +- Create: `apimode_test.go` |
| +- Modify: `auth.go` (one addition: exported-in-package helper on consoleClient for multipart) |
| + |
| +**Interfaces:** |
| +- Consumes: `loggedInCredential(console string) (*credential, error)` (auth.go:659 — `pick` semantics: one credential or `-console` disambiguates), `newConsoleClient(base, token)` (auth.go:344), `(*consoleClient).do(ctx, method, path string, in, out any) (int, error)`. |
| +- Produces (Task 5 relies on these exact signatures): |
| + |
| +```go |
| +// apiSession is the resolved API mode: which console, as whom. |
| +type apiSession struct { |
| + c *consoleClient |
| + console string // origin, for messages |
| + address string |
| +} |
| + |
| +// resolveMode returns (nil, nil) when the store env is set — direct mode, |
| +// the caller proceeds exactly as today. Otherwise a credential is required |
| +// and its session returned; consoleFlag is the -console value ("" = only). |
| +func resolveMode(consoleFlag string) (*apiSession, error) |
| + |
| +func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) error |
| +func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error |
| +func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) // {Channel, Version, PromotedAt, Rung, Err string} |
| +func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) error // files: partName -> local path, streamed |
| +``` |
| + |
| +- [ ] **Step 1: Failing tests** in `apimode_test.go` (package main; `t.Setenv` for env, temp `HOME`/`CARLOS_CONFIG_DIR` per what `credentialsPath()` (auth.go:125) reads — check it and use the same override the auth tests use): |
| + |
| +```go |
| +// TestResolveModeDirectWins: CARLOS_DEPLOYMENT_BUCKET=x set + credential |
| +// file present → (nil, nil). |
| +// TestResolveModeDirWins: CARLOS_DEPLOYMENT_DIR set → (nil, nil). |
| +// TestResolveModeAPI: no env, one credential → session with its origin. |
| +// TestResolveModeNothing: no env, no credentials → error mentioning both |
| +// "CARLOS_DEPLOYMENT_BUCKET" and "carlos auth login". |
| +// TestAPISessionShipMultipart: httptest server asserting: bearer header, |
| +// multipart with meta first (exact JSON), then parts in map-key-sorted |
| +// order; respond 201. ship() with two temp files succeeds. |
| +// TestAPISessionStableRefusal: server responds 403 {"error":"stable |
| +// needs a passkey — finish in the console","console_url":"http://x/a/b"}; |
| +// promote() error text contains the console_url. |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** `resolveMode`: env check first (both vars), then `loggedInCredential(consoleFlag)` — its error already names the fixes; wrap the no-credential case with the two-option message from the test. Multipart ship streams via `io.Pipe` + `multipart.NewWriter` in a goroutine (30 MiB artifacts should not double-buffer); part order: `meta` first, then files sorted by name (deterministic tests). The 403 promote path decodes `{error, console_url}` and returns `fmt.Errorf("%s\n %s", body.Error, body.ConsoleURL)`. |
| +- [ ] **Step 4: Verify pass.** |
| +- [ ] **Step 5: Commit** — `"cli: credential-first mode resolution and console API client"` |
| + |
| +--- |
| + |
| +### Task 5: wire the four verbs |
| + |
| +**Files:** |
| +- Modify: `cli.go` (`runShip` :753, `runPromote` :817, `runChannels` :856, `runRollback` :886) |
| +- Modify: `apimode_test.go` (verb-level tests) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 4's `resolveMode` + `apiSession` methods. Note `accountFlags` (cli.go:716) resolves via the LOCAL registry — in API mode there is no registry, so the verbs must NOT call `acctOf()` when a session is returned; they pass the RAW `-account` flag value (sqid or name — the server resolves, Task 1). Add a `-console` string flag to each verb, "" default. |
| +- Produces: user-visible behavior only. |
| + |
| +- [ ] **Step 1: Failing tests** — run each verb function against an httptest console (as in Task 4) by setting the credential file + no store env; assert stdout via the existing capture pattern in `auth_test.go` (find `captureOutput` or equivalent; if none exists, route verb output through a package-level `var stdout io.Writer = os.Stdout` — smallest change, keep `log.Fatal` paths untouched): |
| + |
| +```go |
| +// TestShipVerbAPIMode: runShip([...]) with one artifact file → server got |
| +// the upload; stdout has "shipped titogo abc1234" and the first-use line |
| +// "using http://127.0.0.1:PORT as vicky@keymail.dev". |
| +// TestPromoteVerbAPIMode / TestChannelsVerbAPIMode / |
| +// TestRollbackVerbAPIMode: analogous. |
| +// TestPromoteVerbDirectUnchanged: CARLOS_DEPLOYMENT_DIR set → the verb |
| +// never dials the httptest server (server counts requests; expect 0). |
| +``` |
| + |
| +- [ ] **Step 2: Verify failure.** |
| +- [ ] **Step 3: Implement.** Each verb: parse flags (add `-console`), call `resolveMode(*consoleFlag)`; `nil, nil` → existing body unchanged; session → API call + spec §5 output (`using <origin> as <address>` once per invocation, then the verb line). `runPromote` in API mode with `-hotfix` set → `log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")`. `runChannels` API mode prints the same tabwriter table, ERROR rows included. |
| +- [ ] **Step 4: Verify pass** — `go test ./... -count=1` (all 14 packages). |
| +- [ ] **Step 5: Commit** — `"cli: ship/promote/rollback/channels speak the console API when logged in"` |
| + |
| +--- |
| + |
| +### Task 6: e2e — device flow to shipped release, README, format sweep |
| + |
| +**Files:** |
| +- Modify: `auth_e2e_test.go` (new test, reuse its device-flow helpers) |
| +- Modify: `README.md` (developer-flow section) |
| + |
| +**Interfaces:** |
| +- Consumes: everything above; `auth_e2e_test.go`'s existing console-server + device-approval fixtures. |
| + |
| +- [ ] **Step 1: Write the e2e test** (this one is the acceptance test — write it, watch it pass; every unit is already TDD'd): |
| + |
| +```go |
| +// TestE2ECLIThroughConsole: real console Server (dir store, test signing |
| +// key), device flow start→approve→poll to a real token in a temp |
| +// credential file; then, with NO store env: |
| +// runShip: 1KiB "linux-arm64" → 201, manifest hashes match |
| +// runPromote to "canary/e2e" → pointer moves, audit row "(cli)" |
| +// runChannels output contains "canary/e2e" and the version |
| +// runPromote to "stable" → refusal naming the console URL (assert the |
| +// verb surfaces it without promoting; pointer unchanged) |
| +// runRollback "canary/e2e" after a second ship+promote → pointer back |
| +``` |
| + |
| +- [ ] **Step 2: Run the full suite** — `go build ./... && go vet ./... && go test ./... -count=1` all green; `gofmt -l` on touched files → empty. |
| +- [ ] **Step 3: README** — add to the developer section: login → ship → promote in API mode, one paragraph + example; state plainly that stable finishes in the console and that CI/operators keep the env-var path. |
| +- [ ] **Step 4: Commit** — `"cli+console: e2e — a credential is enough to ship and promote"` |
| + |
| +--- |
| + |
| +## Self-Review (done at write time) |
| + |
| +- Spec coverage: §1→Task 4/5, §2→Tasks 1-3, §3→Tasks 1-2 (matrix tests), §4→Task 3, §5→Task 5, §6→Tasks 2-3 (audit asserts), §9→Tasks 1-6; §9's live tito PutObject probe is a deploy-time step, recorded for the PR body, not a code task. §4's "streams to a temp file" is implemented as capped in-memory buffering because `release.Ship` takes `map[string][]byte` — same buffering as the direct path; spec updated to match. |
| +- Placeholders: none; every handler's refusal mapping and every test's assertion target is named. |
| +- Type consistency: `apiAccountApp` (Tasks 1-3), `apiSession`/`resolveMode` (Tasks 4-5), `apiShipMaxBytes` (Task 3 test hook) — names match across tasks. |
diff --git a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md| new file mode 100644 |
| index 000000000..3e98633e2 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md |
| @@ -0,0 +1,182 @@ |
| +# CLI through the console API — ship without seeing the infrastructure |
| + |
| +**Date:** 2026-08-03 · **Status:** approved (Paul, in-session) · **Slice:** 1 |
| + |
| +## Problem |
| + |
| +`carlos auth login` (device flow, `cli_tokens`) shipped, but nothing consumes |
| +the credential. The verbs an app developer actually needs still reach around |
| +the platform's own control plane: |
| + |
| +- `carlos ship` opens the S3 store directly — needs AWS credentials, the |
| + bucket name, and a region in env. |
| +- `carlos promote` signs pointers locally — needs the release signing key. |
| +- `carlos channels` reads the bucket directly. |
| + |
| +So "give Vicky deploy access" today means giving her AWS credentials, which |
| +is exactly the infrastructure the platform exists to hide. carlos commands |
| +should talk to the carlos API; the deployment's console already holds bucket |
| +write access and the signing key, gates every mutation on membership, and |
| +audits it. |
| + |
| +## Decisions (made with Paul, 2026-08-03) |
| + |
| +1. **CLI refuses `stable`.** Stable promotion is passkey-step-up-gated in the |
| + browser (spec §7a) and a bearer token cannot do a passkey ceremony. The |
| + API returns the refusal with the console URL to finish in the browser. |
| + A pending-action browser-confirm handoff is future work, not this slice. |
| +2. **AWS-direct stays, for operators and CI.** Resolution is |
| + credential-first: a stored `auth login` credential and no explicit |
| + `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_DEPLOYMENT_DIR` in env → API mode. |
| + Explicit store env → direct mode, unchanged. Box-side scripts, pinfra, |
| + and the console shipping itself keep working; the two modes never mix in |
| + one invocation. |
| + |
| +## §1 Mode resolution (client) |
| + |
| +In `ship`, `promote`, `rollback`, `channels` (and nothing else this slice): |
| + |
| +- `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` set → **direct |
| + mode**, byte-identical to today. |
| +- Otherwise, if the credential file has exactly one console (or `-console |
| + <origin>` picks one, same `pick` semantics as `auth whoami`) → **API |
| + mode** against that origin. |
| +- Neither → the current "no deployment store configured" error, extended to |
| + mention `carlos auth login`. |
| + |
| +No silent fallback from API mode to direct mode or vice versa: a failed API |
| +call is a failed command. An explicit `-console` given alongside either store |
| +env is refused outright (not a silent direct-mode win), for the same reason: |
| +the two modes must never mix in one invocation, so a `-console` that would |
| +otherwise be silently ignored fails closed instead. The `-account` flag keeps |
| +its meaning in both modes; in API mode the server additionally verifies |
| +membership. |
| + |
| +## §2 API surface (server) |
| + |
| +All under the existing CLI route family (`registerCLIRoutes`), all bearer |
| +(`cliUser`), all JSON except the upload, all sharing the browser doors' |
| +semantics — a hand-written request meets exactly the checks a button does: |
| + |
| +- `GET /api/cli/apps` — accounts the address belongs to, each with its |
| + claimed apps and the caller's role. (Powers `carlos apps`, and lets ship |
| + fail fast on a bad account/app pair.) |
| +- `GET /api/cli/apps/{account}/{app}/channels` — channel pointers: |
| + channel, version, promoted_at, rung. Powers `carlos channels`. |
| +- `POST /api/cli/apps/{account}/{app}/releases` — ship. Multipart: |
| + `meta` part (JSON: version, kind binary|static) then one or more artifact |
| + parts, each part NAMED by its target (`linux-arm64`, …, or `site.tar.gz` |
| + for static) — the part name is the artifact key; meta carries no |
| + per-file fields. Server computes sha256 and sizes from the bytes it |
| + received — the client never states a hash it could lie about — builds the |
| + manifest exactly as `runShip` does (target-keyed artifacts; `BuiltBy: |
| + "console-api <console version>"`), and calls `release.Ship` against its |
| + own store. Idempotent per (account, app, version) the same way a re-run |
| + `carlos ship` is. |
| +- `POST /api/cli/apps/{account}/{app}/promote` — body {version, channel}. |
| + Mirrors `handlePromote` minus CSRF (bearer replaces the session): valid |
| + channel, valid version, manifest exists, rung gate, then |
| + `release.PromoteOnLadder` with `Actor: addr`. No `-hotfix` from the API |
| + this slice — a recorded ladder bypass is an operator act, and operators |
| + have direct mode. |
| +- `POST /api/cli/apps/{account}/{app}/rollback` — body {channel}. Mirrors |
| + the browser rollback door (rollback never bakes); stable is refused with |
| + the same 403 as promote (Paul's ruling 2026-08-03 — §8's no-stable blast |
| + radius governs). |
| + |
| +## §3 Authorization and refusal order |
| + |
| +Every endpoint: bearer → address; membership in `{account}`; app claimed in |
| +that account (`HasAppIn`). Any failure in that chain is the byte-identical |
| +404 the console's gate ordering rule demands — a token holder probing app |
| +names learns nothing a stranger wouldn't. Then, and only then, the cheap |
| +refusals (bad channel, bad version, unknown release), then the rung gate: |
| + |
| +- non-stable rung: membership already proved — allowed. |
| +- stable: `403` with body |
| + `{"error":"stable needs a passkey — finish in the console","console_url": |
| + "<origin>/a/<sqid>/apps/<app>"}`. Role is NOT consulted first — the 403 is |
| + identical for member and owner, because distinguishing them would leak |
| + role to a stolen token. (The browser's owner-404 ordering protects a |
| + different secret — account membership — which the 404 above already |
| + covers.) |
| + |
| +Rate limits: reuse the `cliLimits` family. Uploads get their own bucket-of- |
| +tokens (small: ships are rare) keyed by token hash, not IP — a shared NAT |
| +must not let one developer starve another, and a token is the natural |
| +principal here. |
| + |
| +## §4 Upload handling |
| + |
| +- `http.MaxBytesReader` at 256 MiB per request (largest current artifact is |
| + ~30 MiB; 256 leaves room for multi-target ships without inviting abuse). |
| +- Artifacts are read part-by-part from the streaming multipart reader and |
| + hashed as read; each artifact is held in memory once (the size cap is the |
| + guard) because `release.Ship` takes `map[string][]byte` — the same |
| + buffering the direct path's `os.ReadFile` does. |
| +- `version` and `target` validated against the same charsets ship enforces |
| + (`release.ValidVersion`; targets `GOOS-GOARCH` shaped) — they become |
| + bucket key segments, so the validation is path-traversal defense, stated |
| + as such in code. |
| +- The console's existing store credentials cover `releases/*` writes: it |
| + writes `apps/<sqid>/<app>/channels/*` today and the box role grants the |
| + whole bucket; §9 verifies this live on tito before merge. |
| + |
| +## §5 What the CLI prints |
| + |
| +API-mode output stays verb-for-verb compatible with direct mode where it |
| +can (`shipped <app> <version>`, `promoted <version> to <channel>`), plus the |
| +console origin on first use of a credential (`using https://carlos.tito.io |
| +as vicky@keymail.dev`) so a developer with two deployments always knows |
| +which one they just shipped to. Stable refusal prints the console URL from |
| +the 403 body. |
| + |
| +## §6 Audit |
| + |
| +API mutations audit through the same `s.audit` rows as the browser, actor = |
| +the token's address, detail suffixed `" (cli)"`. The issuance notification |
| +path (Keymail message on token creation) already exists; no new |
| +notification this slice. |
| + |
| +## §7 Out of scope (recorded, deliberate) |
| + |
| +- Claim/create-app via API — claim-proofing semantics stay in the browser |
| + and box-side (migrated names need operator judgment, as tito's titogo |
| + just demonstrated). |
| +- `-hotfix`, `carlos route/add/remove/instances`, env/secrets verbs — |
| + operator/box territory or future slices. |
| +- Stable via pending-action handoff. |
| +- `carlos canary` — uses ship+promote internals; follows automatically if |
| + those go through the client seam, but is not a named requirement and is |
| + not tested this slice. |
| + |
| +## §8 Security notes |
| + |
| +- Blast radius of a stolen token: non-stable promotes and ships on apps the |
| + address can already deploy. No key material, no stable, no AWS. |
| +- The signing key never moves: promotion signing stays in the console |
| + process. Ship needs no signature (pointers, not artifacts, are signed — |
| + the signed pointer names the version and the manifest carries the |
| + server-computed hashes). |
| +- Server-side hashing means a compromised client cannot make the manifest |
| + disagree with the artifact bytes. |
| +- Registry stays read-only to the console; nothing here touches routes. |
| + |
| +## §9 Testing |
| + |
| +- Unit: mode resolution matrix (env × credential file × -console flag); |
| + upload validation refusals; the 404/403 refusal order (a non-member and a |
| + member probing stable must be byte-distinguishable only where designed). |
| +- e2e (`auth_e2e_test.go` pattern): device flow → token → `GET apps` → |
| + multipart ship → promote to a canary channel → channels shows it → |
| + stable refused with console_url → rollback. Against `httptest` console |
| + with a dir-backed store. |
| +- Live before merge: on tito, confirm the box role accepts a |
| + `releases/*` PutObject from the console process (a 1-byte probe object, |
| + deleted after). |
| + |
| +## §10 Rollout |
| + |
| +Server first (console is backward-compatible: new routes only), then CLI. |
| +One PR, both halves; deploy console via the normal ship/promote/adopt; |
| +nothing changes for existing users until they run `carlos auth login`. |
diff --git a/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md| index 4b50a3dc9..8f45cbdf7 100644 |
| --- a/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md |
| @@ -107,6 +107,14 @@ holds. If that is a scoped seed, the accompanying `CARLOS_RELEASE_GRANT` |
| outside the grant fails LOCALLY with a clear error before any write — |
| matching the ladder's refusal style. |
| +That sentence is DIRECT-mode truth only. In the CLI's API mode (a stored |
| +`carlos auth login` credential and no store env — see the cli-console-api |
| +spec §1) a `CARLOS_RELEASE_KEY` in the environment is INERT: the console |
| +signs with the deployment's root key and authority is membership + the rung |
| +gate, so a "canary-only" scoped key on a laptop does not constrain what |
| +that laptop's console credential can promote. Scoped keys constrain the |
| +direct path; memberships constrain the API path. |
| + |
| ## 5. Verification surface |
| One function change: `verifyPointer` grows grant handling. Its callers — |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| new file mode 100644 |
| index 000000000..9ab77f6da |
| --- /dev/null |
| +++ b/internal/console/apicli.go |
| @@ -0,0 +1,679 @@ |
| +// The CLI's read-only console API: GET routes under /api/cli/ that let a |
| +// terminal list its own accounts/apps and read a channel ladder by talking |
| +// to the console over HTTP, rather than opening the deployment bucket |
| +// directly. Auth and account/app resolution are ONE helper (apiAccountApp) |
| +// for every route below that names an account and an app, because |
| +// resolution and membership proof are deliberately the same lookup: an |
| +// unknown account and an account you don't belong to must be the identical |
| +// 404, or a token holder learns something by probing names. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/version" |
| +) |
| + |
| +// apiAccountApp resolves and authorizes one CLI API request. The account |
| +// segment accepts a sqid or a name, resolved against the caller's OWN |
| +// memberships — resolution and membership proof are deliberately the same |
| +// lookup, so an unknown account and someone else's account are the same 404. |
| +// |
| +// Resolution follows the same doctrine as the CLI's own resolveAccount |
| +// (accounts.go): an exact SQID match wins outright, and only when none |
| +// matches does a NAME match apply, case-insensitively (EqualFold) — and an |
| +// ambiguous name (two of the caller's own accounts sharing it) is refused |
| +// rather than guessed at, because picking the wrong account here would list |
| +// or promote against the wrong tenant. |
| +func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return "", nil, "", false |
| + } |
| + seg := r.PathValue("account") |
| + app := r.PathValue("app") |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| + return "", nil, "", false |
| + } |
| + var acct *Account |
| + for i := range accts { |
| + if accts[i].Sqid == seg { |
| + acct = &accts[i] |
| + break |
| + } |
| + } |
| + if acct == nil { |
| + var matches []*Account |
| + for i := range accts { |
| + if strings.EqualFold(accts[i].Name, seg) { |
| + matches = append(matches, &accts[i]) |
| + } |
| + } |
| + switch len(matches) { |
| + case 1: |
| + acct = matches[0] |
| + case 0: |
| + // acct stays nil — the same 404 an unknown or someone-else's |
| + // account gets, below. |
| + default: |
| + sqids := make([]string, len(matches)) |
| + for i, m := range matches { |
| + sqids[i] = m.Sqid |
| + } |
| + jsonError(w, http.StatusConflict, |
| + fmt.Sprintf("account name %q is ambiguous between %s — use a sqid", seg, strings.Join(sqids, ", "))) |
| + return "", nil, "", false |
| + } |
| + } |
| + if acct == nil { |
| + http.NotFound(w, r) |
| + return "", nil, "", false |
| + } |
| + has, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err) |
| + } |
| + if err != nil || !has { |
| + http.NotFound(w, r) |
| + return "", nil, "", false |
| + } |
| + return addr, acct, app, true |
| +} |
| + |
| +// handleAPIApps answers `carlos apps`: every account the token's holder |
| +// belongs to, and the app names each one has claimed. Scoped to the caller's |
| +// OWN memberships — there is no "list every account" verb here, on the same |
| +// principle apiAccountApp enforces one call at a time. |
| +func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return |
| + } |
| + accts, err := s.store.AccountsFor(addr) |
| + if err != nil { |
| + log.Printf("console: api apps for %s: %v", addr, err) |
| + jsonError(w, http.StatusInternalServerError, "could not load accounts") |
| + return |
| + } |
| + out := make([]map[string]any, 0, len(accts)) |
| + for _, a := range accts { |
| + apps, err := s.store.AppsIn(a.ID) |
| + if err != nil { |
| + log.Printf("console: api apps in %s: %v", a.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, "could not load apps") |
| + return |
| + } |
| + if apps == nil { |
| + apps = []string{} |
| + } |
| + out = append(out, map[string]any{ |
| + "sqid": a.Sqid, "name": a.Name, "role": a.Role, "apps": apps, |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"accounts": out}) |
| +} |
| + |
| +// handleAPIChannels answers `carlos channels`: every channel pointer under |
| +// one app, verified the same way the CLI verifies them itself. A pointer |
| +// that fails verification becomes one erroring ROW rather than a failed |
| +// response — mirroring runChannels' per-row ERROR behaviour (cli.go), since |
| +// one bad pointer must not hide every other channel's answer from the |
| +// caller. |
| +func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + prefix := release.ChannelsPrefix(acct.Sqid, app) |
| + keys, err := s.cfg.Releases.List(ctx, prefix) |
| + if err != nil { |
| + log.Printf("console: api channels list %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not list channels") |
| + return |
| + } |
| + out := make([]map[string]any, 0, len(keys)) |
| + for _, k := range keys { |
| + name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| + cp, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name) |
| + if err != nil { |
| + out = append(out, map[string]any{"channel": name, "error": err.Error()}) |
| + continue |
| + } |
| + // rung is always present, "" when the channel name isn't one of the |
| + // ladder's rungs — a typed client (later tasks) can rely on the key |
| + // existing rather than testing for its absence. |
| + rung, _ := release.Rung(name) |
| + out = append(out, map[string]any{ |
| + "channel": name, |
| + "version": cp.Version, |
| + "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339), |
| + "rung": rung, |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"channels": out}) |
| +} |
| + |
| +// handleAPIPromote answers `carlos promote`: the CLI's write door onto the |
| +// SAME ladder the console's own promote button enforces (handlePromote, |
| +// server.go), doors in the same order minus two the browser needs and this |
| +// caller cannot present: CSRF (a bearer request carries no session cookie to |
| +// forge in the first place — readJSON's JSON-content-type insistence is |
| +// already this door's cross-site defence) and the passkey step-up (there is |
| +// no browser here to redirect into Keymail). |
| +// |
| +// Stable's gate does not soften into a step-up here — it simply refuses, |
| +// pointing at the console, which DOES have a passkey to ask for. Refusing |
| +// before ever consulting role is what keeps a member's 403 and an owner's 403 |
| +// byte-identical: a token holder must not learn their own role, let alone |
| +// anyone else's, from a door that isn't the one stable actually goes through. |
| +// |
| +// The request struct below has no hotfix field, and nowhere in this handler |
| +// is PromoteOptions.Hotfix ever set — encoding/json ignores an unknown |
| +// "hotfix" key rather than erroring on it, so there is no way to bypass the |
| +// ladder from this door at all. |
| +func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + // Keyed on the ADDRESS, not the token: the principal a write budget |
| + // belongs to is the PERSON, not the terminal they happen to be typing on |
| + // — the same key handleCLINewAccount already uses on this limiter. |
| + // Keying by token instead would give someone with several live tokens an |
| + // independent budget per token, which is the limit doing less than it |
| + // should, not more. |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no signing key on this host — the console is running read-only") |
| + return |
| + } |
| + var req struct { |
| + Version string `json:"version"` |
| + Channel string `json:"channel"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + channel := strings.TrimSpace(req.Channel) |
| + if !release.ValidChannel(channel) { |
| + jsonError(w, http.StatusBadRequest, "invalid channel name") |
| + return |
| + } |
| + version := strings.TrimSpace(req.Version) |
| + if !release.ValidVersion(version) { |
| + jsonError(w, http.StatusBadRequest, "invalid version") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil { |
| + jsonError(w, http.StatusNotFound, "Version "+version+" is not a shipped release of "+app+".") |
| + return |
| + } |
| + // The rung's own gate, LAST — after auth, after the account/app 404, after |
| + // the key check, and after every refusal that costs nothing to answer. |
| + // Same load-bearing order handlePromote's comment explains: a non-member |
| + // meets the byte-identical 404 in apiAccountApp before stable is ever |
| + // mentioned. Below stable, membership (already proved) is the gate; |
| + // PromoteOnLadder enforces the ladder itself. |
| + if rung, _ := release.Rung(channel); rung == release.RungStable { |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| + }) |
| + return |
| + } |
| + res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, |
| + release.PromoteOptions{Actor: addr}) |
| + if err != nil { |
| + log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err) |
| + // The ladder's own refusal text names the rungs — as useful over the |
| + // API as it is on the app page. |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| + if res.NoOp { |
| + writeJSON(w, http.StatusOK, map[string]any{"result": "noop"}) |
| + return |
| + } |
| + s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "result": "promoted", "from": res.From, "to": res.To, "noop": false, |
| + }) |
| +} |
| + |
| +// handleAPIRollback answers `carlos rollback`: the CLI's write door onto the |
| +// same swap-to-previous-version primitive as the console's own rollback |
| +// button (handleRollback, server.go). Below stable there is no rung gate, |
| +// matching the browser door — rolling back never climbs the ladder, so |
| +// nothing about maturity has a say over it. |
| +// |
| +// Stable is the one exception, and it is Paul's ruling (2026-08-03): a |
| +// rollback that lands on stable puts a build in front of every user of the |
| +// app exactly as a promotion onto it would (§8's no-stable blast radius |
| +// governs regardless of which verb got there), so it is refused with the |
| +// SAME byte-identical 403 handleAPIPromote gives stable — before |
| +// release.Rollback is ever called, and before the channel's current pointer |
| +// is even read, so the refusal cannot vary with what that pointer contains. |
| +func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + // Keyed on the ADDRESS, not the token: the principal a write budget |
| + // belongs to is the PERSON, not the terminal they happen to be typing on |
| + // — the same key handleCLINewAccount already uses on this limiter. |
| + // Keying by token instead would give someone with several live tokens an |
| + // independent budget per token, which is the limit doing less than it |
| + // should, not more. |
| + if !s.cli.write.allow(addr, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + jsonError(w, http.StatusServiceUnavailable, |
| + "no signing key on this host — the console is running read-only") |
| + return |
| + } |
| + var req struct { |
| + Channel string `json:"channel"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + channel := strings.TrimSpace(req.Channel) |
| + if !release.ValidChannel(channel) { |
| + jsonError(w, http.StatusBadRequest, "invalid channel name") |
| + return |
| + } |
| + if rung, _ := release.Rung(channel); rung == release.RungStable { |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| + }) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| + if err != nil { |
| + jsonError(w, http.StatusNotFound, "could not read channel "+channel) |
| + return |
| + } |
| + if len(cur.History) == 0 { |
| + jsonError(w, http.StatusConflict, "channel "+channel+" has no previous version to roll back to") |
| + return |
| + } |
| + target := cur.History[0] |
| + if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| + log.Printf("console: api rollback %s %s: %v", app, channel, err) |
| + jsonError(w, http.StatusConflict, err.Error()) |
| + return |
| + } |
| + s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target+" (cli)") |
| + writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"}) |
| +} |
| + |
| +// ---- ship (multipart upload) ------------------------------------------------ |
| + |
| +// apiShipMaxBytes bounds one ship request across every part TOGETHER — meta |
| +// plus all artifacts — not any single part on its own. A var, not a const, |
| +// so a test can lower it without rebuilding the fixture. 256MiB comfortably |
| +// covers today's binaries and a static site tarball in one request. |
| +var apiShipMaxBytes int64 = 256 << 20 |
| + |
| +// apiShipMaxPartsStatic and apiShipMaxPartsBinary bound the NUMBER of file |
| +// parts a ship request may carry: one for a static release (the site.tar.gz |
| +// name alone), eight for a binary release (comfortably above today's |
| +// platform count). r.MultipartReader() has no stdlib cap on part count at all — that |
| +// cap exists only on mime/multipart's ReadForm (1000 parts), which |
| +// streaming deliberately opts out of. Without a check of our own, a body |
| +// full of tiny, validly-named parts fits millions of them inside |
| +// apiShipMaxBytes: each one a map entry on the console's heap, and — had it |
| +// ever gotten that far — one sequential putImmutable call per part inside |
| +// release.Ship until the ship context timed out, orphaning however many |
| +// artifact objects had already landed (Ship writes the manifest LAST by |
| +// design, so a killed run leaves bytes with no manifest naming them). This |
| +// check IS the part-count cap; nothing upstream provides one. |
| +const ( |
| + apiShipMaxPartsStatic = 1 |
| + apiShipMaxPartsBinary = 8 |
| +) |
| + |
| +// targetRe validates a binary artifact's part NAME before it is used to |
| +// build a bucket key: release.ArtifactKey joins it straight into the object |
| +// path, so this check is path-traversal defense, not tidiness — a part |
| +// named "../../etc/passwd" must never reach release.Ship as a file name. |
| +var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`) |
| + |
| +// shipMaxBytesRefused answers 413 if err is the http.MaxBytesReader wrapped |
| +// around the request body cutting a read short, and reports whether it did |
| +// — the caller returns immediately when this is true, and falls through to |
| +// its own (more specific) 400 otherwise. |
| +func shipMaxBytesRefused(w http.ResponseWriter, err error) bool { |
| + var tooLarge *http.MaxBytesError |
| + if errors.As(err, &tooLarge) { |
| + jsonError(w, http.StatusRequestEntityTooLarge, "upload too large") |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// shipImmutableConflict reports whether err is release.Ship's refusal to |
| +// overwrite an existing version's object with DIFFERENT bytes — a |
| +// permanent conflict a retry can never fix, unlike every other error Ship |
| +// returns (a store/IO failure, which a retry might actually resolve). |
| +// |
| +// release.Ship/putImmutable (internal/release/release.go) expose no |
| +// sentinel or typed error for this specific case — the only signal |
| +// available is the literal wording putImmutable formats ("... already |
| +// exists with different content — refusing to overwrite an immutable |
| +// release object"), so this is a STRING match against release.go's current |
| +// wording, not a durable error-type check (errors.Is/As have nothing to |
| +// grab onto here). If that wording ever changes in release.go, this |
| +// silently stops firing and the conflict falls back to the 502 below — |
| +// worth revisiting if release.go ever grows a proper sentinel for it. |
| +// |
| +// validateManifest's errors also return from Ship along this same |
| +// unwrapped path, but none of its messages ("invalid app...", "invalid |
| +// kind...", "manifest has no artifacts", etc) contain this wording, so they |
| +// can never match here. They are unreachable in this branch for an |
| +// unrelated reason: every field validateManifest checks (app, version, |
| +// kind, artifact file names, sha256, size) was already validated by |
| +// handleAPIShip itself, using the same rules, before Ship was ever called. |
| +func shipImmutableConflict(err error) bool { |
| + return strings.Contains(err.Error(), "already exists with different content") |
| +} |
| + |
| +// shipArtifactsMatch reports whether two artifact maps describe the exact |
| +// same shipped CONTENT — same file set, same bytes (by sha256+size) for |
| +// each. BuiltAt/BuiltBy are deliberately excluded: they are metadata, not |
| +// content, and both handleAPIShip and the direct `carlos ship` CLI path |
| +// stamp a fresh BuiltAt into every manifest they build, so comparing |
| +// manifests wholesale (as release.Ship's own putImmutable does, |
| +// byte-for-byte) would call a genuinely identical re-ship a conflict for a |
| +// reason no caller could ever avoid. |
| +func shipArtifactsMatch(a, b map[string]release.Artifact) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for k, av := range a { |
| + bv, ok := b[k] |
| + if !ok || av.File != bv.File || av.SHA256 != bv.SHA256 || av.Size != bv.Size { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// handleAPIShip answers `carlos ship`: a multipart upload of one release's |
| +// artifacts. Hashes and sizes are computed HERE, from the bytes that |
| +// actually arrived on the wire, and handed to release.Ship as |
| +// already-filled Artifact{SHA256,Size} — Ship itself recomputes and |
| +// cross-checks them (release.go), so there is no way for a caller to lie |
| +// about either even in principle: the wire shape below has no field to lie |
| +// in (the meta part carries only version and kind). |
| +// |
| +// Streamed, never buffered to disk: r.MultipartReader() reads one part at a |
| +// time. ParseMultipartForm was ruled out on purpose — it spills whatever |
| +// doesn't fit its own in-memory threshold to a temp file under an |
| +// INDEPENDENT size limit, one that would sit alongside apiShipMaxBytes |
| +// rather than being governed by it. Each part is still read fully into |
| +// memory here, because release.Ship's own shape (map[string][]byte) leaves |
| +// no smaller option — the same buffering the CLI's own direct-ship path |
| +// already does with os.ReadFile — but the MaxBytesReader wrapped around the |
| +// whole request body below is what actually caps the total. |
| +func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + // Keyed on the TOKEN, not the address: ships are rare and heavy — bytes |
| + // on the wire, an object written per artifact — so a token is the |
| + // natural principal for an upload, unlike a write budget. Keying by |
| + // address instead would make a laptop and a CI runner sharing one |
| + // human's identity share one shipping budget between them, which is the |
| + // limit doing less than it should, not more. |
| + if !s.cli.ship.allow(hashToken(bearer(r)), time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many uploads — wait a while and try again") |
| + return |
| + } |
| + // The rate limiter bounds requests over TIME; it says nothing about how |
| + // many run AT ONCE. A ship holds a goroutine and a substantial buffer |
| + // open for as long as the client trickles bytes in, so a handful of |
| + // slow uploads in parallel is a resource-exhaustion path the counter |
| + // above does nothing to close. Non-blocking: a full semaphore means |
| + // "busy right now", answered immediately, rather than a request that |
| + // queues indefinitely behind someone else's slow upload. |
| + select { |
| + case s.shipSem <- struct{}{}: |
| + defer func() { <-s.shipSem }() |
| + default: |
| + jsonError(w, http.StatusTooManyRequests, "another ship is in progress — try again shortly") |
| + return |
| + } |
| + if s.shipStarted != nil { |
| + s.shipStarted <- struct{}{} |
| + } |
| + |
| + // Bounds the read side in TIME, not just in bytes: with no deadline, a |
| + // client sending one byte a second could hold this goroutine (and |
| + // whatever it has already buffered) open for hours — the semaphore |
| + // above only bounds how many can do that at once, not for how long. |
| + // http.ErrNotSupported is tolerated because some transports (test |
| + // servers among them, in principle) don't implement the underlying |
| + // deadline setter; the semaphore is still there as a backstop on those. |
| + rc := http.NewResponseController(w) |
| + if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) { |
| + log.Printf("console: api ship set read deadline: %v", err) |
| + } |
| + defer func() { |
| + if err := rc.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) { |
| + log.Printf("console: api ship clear read deadline: %v", err) |
| + } |
| + }() |
| + |
| + r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes) |
| + mr, err := r.MultipartReader() |
| + if err != nil { |
| + jsonError(w, http.StatusBadRequest, "expected a multipart/form-data body") |
| + return |
| + } |
| + |
| + first, err := mr.NextPart() |
| + if err != nil { |
| + if shipMaxBytesRefused(w, err) { |
| + return |
| + } |
| + jsonError(w, http.StatusBadRequest, "empty upload") |
| + return |
| + } |
| + if first.FormName() != "meta" { |
| + jsonError(w, http.StatusBadRequest, `the first part must be named "meta"`) |
| + return |
| + } |
| + var meta struct { |
| + Version string `json:"version"` |
| + Kind string `json:"kind"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(first, 8<<10)).Decode(&meta); err != nil { |
| + jsonError(w, http.StatusBadRequest, "malformed meta part") |
| + return |
| + } |
| + ver := strings.TrimSpace(meta.Version) |
| + if !release.ValidVersion(ver) || ver == "dev" { |
| + jsonError(w, http.StatusBadRequest, "invalid version") |
| + return |
| + } |
| + if meta.Kind != "binary" && meta.Kind != "static" { |
| + jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`) |
| + return |
| + } |
| + maxParts := apiShipMaxPartsBinary |
| + if meta.Kind == "static" { |
| + maxParts = apiShipMaxPartsStatic |
| + } |
| + |
| + files := make(map[string][]byte) |
| + artifacts := make(map[string]release.Artifact) |
| + for { |
| + part, err := mr.NextPart() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + if shipMaxBytesRefused(w, err) { |
| + return |
| + } |
| + jsonError(w, http.StatusBadRequest, "malformed multipart body") |
| + return |
| + } |
| + // Checked before anything else touches this part — including its |
| + // name, and certainly before io.ReadAll — because the cost this |
| + // guards against is per-PART, not per-byte: a body of nothing but |
| + // tiny, validly-named parts must be refused on the (maxParts+1)th |
| + // one, not after buffering it. |
| + if len(files) >= maxParts { |
| + jsonError(w, http.StatusBadRequest, |
| + fmt.Sprintf("too many file parts (max %d for a %s release)", maxParts, meta.Kind)) |
| + return |
| + } |
| + name := part.FormName() |
| + if meta.Kind == "static" { |
| + if name != "site.tar.gz" { |
| + jsonError(w, http.StatusBadRequest, `a static release ships exactly one file part, named "site.tar.gz"`) |
| + return |
| + } |
| + } else if !targetRe.MatchString(name) { |
| + jsonError(w, http.StatusBadRequest, "invalid artifact name "+name) |
| + return |
| + } |
| + // A second part claiming a name already seen is refused rather than |
| + // silently overwriting the first: both would otherwise be charged |
| + // against apiShipMaxBytes and apiShipMaxPartsBinary/Static, but only |
| + // the last one's bytes would ever reach the manifest — a caller |
| + // deserves an error, not a release that quietly ships less than it |
| + // paid to upload. |
| + if _, dup := files[name]; dup { |
| + jsonError(w, http.StatusBadRequest, "duplicate artifact part "+name) |
| + return |
| + } |
| + b, err := io.ReadAll(part) |
| + if err != nil { |
| + if shipMaxBytesRefused(w, err) { |
| + return |
| + } |
| + jsonError(w, http.StatusBadRequest, "could not read part "+name) |
| + return |
| + } |
| + sum := sha256.Sum256(b) |
| + files[name] = b |
| + artifacts[name] = release.Artifact{ |
| + File: name, |
| + SHA256: hex.EncodeToString(sum[:]), |
| + Size: int64(len(b)), |
| + } |
| + } |
| + if len(files) == 0 { |
| + jsonError(w, http.StatusBadRequest, "no artifact parts in the upload") |
| + return |
| + } |
| + |
| + m := release.Manifest{ |
| + Schema: 1, |
| + App: app, |
| + Version: ver, |
| + Kind: meta.Kind, |
| + BuiltBy: "console-api " + version.Build, |
| + BuiltAt: time.Now().UTC().Format(time.RFC3339), |
| + Artifacts: artifacts, |
| + } |
| + // Longer than the other CLI doors' 15-20s: this one moves real bytes — |
| + // up to apiShipMaxBytes of them — into the bucket, not a handful of |
| + // short strings. |
| + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) |
| + defer cancel() |
| + |
| + // A version's objects are immutable once shipped, but BOTH callers that |
| + // build a manifest like this one (this handler and the direct `carlos |
| + // ship` CLI path) stamp BuiltAt with a fresh time.Now() on every |
| + // invocation. Comparing the freshly-marshaled manifest byte-for-byte |
| + // against whatever release.Ship's own putImmutable finds already |
| + // stored — which is exactly what it does — would make a genuinely |
| + // identical re-ship register as a "different content" conflict for a |
| + // reason no caller could ever avoid: its OWN timestamp. Comparing |
| + // ARTIFACTS here first — content, not metadata — is what makes |
| + // re-shipping the exact same bytes twice actually succeed instead of |
| + // permanently wedging on its own BuiltAt. |
| + if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil { |
| + if !shipArtifactsMatch(existing.Artifacts, artifacts) { |
| + jsonError(w, http.StatusConflict, |
| + "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version") |
| + return |
| + } |
| + // Identical content already shipped: answer success without |
| + // writing anything — calling release.Ship again would only |
| + // rediscover this the slow way, and would in fact trip its OWN |
| + // manifest-level putImmutable conflict (see above), since BuiltAt |
| + // drifts every call even when the artifacts don't. 200, not 201: |
| + // nothing was created by this request. |
| + out := make(map[string]any, len(existing.Artifacts)) |
| + for name, a := range existing.Artifacts { |
| + out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size} |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "result": "shipped", "version": ver, "artifacts": out, |
| + }) |
| + return |
| + } |
| + |
| + if err := release.Ship(ctx, s.cfg.Releases, acct.Sqid, m, files); err != nil { |
| + log.Printf("console: api ship %s %s: %v", app, ver, err) |
| + if shipImmutableConflict(err) { |
| + // A permanent conflict, not a transient one: reachable when this |
| + // raced against another ship of the same version (the |
| + // GetManifest check above is not atomic with the Ship call |
| + // below), not through the common path above. Retrying this |
| + // exact request will never succeed, so it gets its own status |
| + // rather than the retry-flavored 502 below. |
| + jsonError(w, http.StatusConflict, |
| + "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version") |
| + return |
| + } |
| + // Anything else is the store failing, not the caller: a 502, |
| + // matching the other doors' convention that a bucket-side error is |
| + // never described to the caller in bucket-side terms. |
| + jsonError(w, http.StatusBadGateway, "could not store the release — try again") |
| + return |
| + } |
| + s.audit(acct, app, addr, "ship", "shipped "+ver+" (cli)") |
| + |
| + out := make(map[string]any, len(artifacts)) |
| + for name, a := range artifacts { |
| + out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size} |
| + } |
| + writeJSON(w, http.StatusCreated, map[string]any{ |
| + "result": "shipped", "version": ver, "artifacts": out, |
| + }) |
| +} |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| new file mode 100644 |
| index 000000000..60afc5b60 |
| --- /dev/null |
| +++ b/internal/console/apicli_test.go |
| @@ -0,0 +1,1134 @@ |
| +package console |
| + |
| +// The CLI's read-only API surface (apicli.go): `carlos apps` and `carlos |
| +// channels` reading through the console instead of the bucket directly. The |
| +// properties that matter, in the order they'd hurt if lost: a token only |
| +// ever sees ITS OWN memberships, account resolution never guesses between |
| +// candidates, an unknown account and one you don't belong to are the |
| +// identical 404, and a channel-ladder read mirrors the CLI's own per-row |
| +// ERROR behaviour rather than failing the whole response. |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "mime/multipart" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// getRaw is getJSON's plain-text cousin: some answers here (http.NotFound's |
| +// body) are not JSON, and asserting on them byte-for-byte is the point of |
| +// TestAPIChannelsRefusalMatrix. |
| +func getRaw(t *testing.T, u, bearer string) (int, string) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, u, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + body := make([]byte, 0, 256) |
| + buf := make([]byte, 256) |
| + for { |
| + n, err := res.Body.Read(buf) |
| + body = append(body, buf[:n]...) |
| + if err != nil { |
| + break |
| + } |
| + } |
| + return res.StatusCode, string(body) |
| +} |
| + |
| +// postRaw is postJSON's byte-exact cousin: TestAPIPromoteStableRefused needs |
| +// the raw response body, not a decoded map, to assert two responses are |
| +// IDENTICAL rather than merely equal key-by-key. |
| +func postRaw(t *testing.T, u string, body any, bearer string) (int, string) { |
| + t.Helper() |
| + raw, err := json.Marshal(body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "application/json") |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + out, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return res.StatusCode, string(out) |
| +} |
| + |
| +// TestAPIAppsListsMemberships: a token only ever learns about the accounts |
| +// IT belongs to, and only that account's apps — a second account's OWN app |
| +// (secretapp, claimed in bac) never leaks into bab's list. |
| +func TestAPIAppsListsMemberships(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + // bac: a second account alice is NOT a member of, with its own app. |
| + if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(2, "secretapp", "bob@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-apps-alice") |
| + |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("apps = %d %v", status, out) |
| + } |
| + accts, _ := out["accounts"].([]any) |
| + if len(accts) != 1 { |
| + t.Fatalf("accounts = %v, want exactly bab (bac must not appear)", out["accounts"]) |
| + } |
| + a, _ := accts[0].(map[string]any) |
| + if str(t, a, "sqid") != "bab" || str(t, a, "name") != "ops" || str(t, a, "role") != "member" { |
| + t.Fatalf("account = %v", a) |
| + } |
| + apps, _ := a["apps"].([]any) |
| + found := false |
| + for _, v := range apps { |
| + if v == "titogo" { |
| + found = true |
| + } |
| + if v == "secretapp" { |
| + t.Fatalf("bab's apps leaked bac's app: %v", apps) |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("apps = %v, want titogo among them", apps) |
| + } |
| +} |
| + |
| +// TestAPIAppsNoToken: the same 401 cliUser always gives. |
| +func TestAPIAppsNoToken(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", "") |
| + if status != http.StatusUnauthorized { |
| + t.Fatalf("apps with no token = %d %v, want 401", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "not signed in") { |
| + t.Fatalf("apps 401 body = %v, want the cliUser sentence", out) |
| + } |
| +} |
| + |
| +// TestAPIChannelsHappy: a promoted channel reads back through the API |
| +// exactly as the CLI's own `carlos channels` reads it — the account segment |
| +// works identically whether given as the sqid or the name, `rung` is always |
| +// present (empty for a channel outside the ladder's vocabulary), and |
| +// `promoted_at` is a real RFC3339 timestamp a typed client can parse. |
| +func TestAPIChannelsHappy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + seedChannel(t, fx, "titogo", "canary/x", "abc1234") |
| + // A second, non-rung channel — "adhoc" matches no ladder rung, so `rung` |
| + // must come back "" rather than being omitted. |
| + shipVersion(t, fx, "titogo", "def5678") |
| + seedChannel(t, fx, "titogo", "adhoc", "def5678") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-channels-paul") |
| + |
| + for _, seg := range []string{"bab", "ops"} { |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/"+seg+"/titogo/channels", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("channels (%s) = %d %v", seg, status, out) |
| + } |
| + chans, _ := out["channels"].([]any) |
| + if len(chans) != 2 { |
| + t.Fatalf("channels (%s) = %v, want exactly two entries", seg, out["channels"]) |
| + } |
| + byName := map[string]map[string]any{} |
| + for _, c := range chans { |
| + m, _ := c.(map[string]any) |
| + byName[str(t, m, "channel")] = m |
| + } |
| + canary, ok := byName["canary/x"] |
| + if !ok { |
| + t.Fatalf("channels (%s) missing canary/x: %v", seg, chans) |
| + } |
| + if str(t, canary, "version") != "abc1234" || str(t, canary, "rung") != "canary" { |
| + t.Fatalf("canary/x entry (%s) = %v", seg, canary) |
| + } |
| + if _, err := time.Parse(time.RFC3339, str(t, canary, "promoted_at")); err != nil { |
| + t.Fatalf("canary/x promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, canary, "promoted_at"), err) |
| + } |
| + adhoc, ok := byName["adhoc"] |
| + if !ok { |
| + t.Fatalf("channels (%s) missing adhoc: %v", seg, chans) |
| + } |
| + if str(t, adhoc, "version") != "def5678" { |
| + t.Fatalf("adhoc entry (%s) = %v", seg, adhoc) |
| + } |
| + if v, present := adhoc["rung"]; !present || v != "" { |
| + t.Fatalf("adhoc rung (%s) = %v (present=%v), want present and empty", seg, v, present) |
| + } |
| + if _, err := time.Parse(time.RFC3339, str(t, adhoc, "promoted_at")); err != nil { |
| + t.Fatalf("adhoc promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, adhoc, "promoted_at"), err) |
| + } |
| + } |
| +} |
| + |
| +// TestAPIChannelsErrorRow: a channel pointer that fails verification becomes |
| +// one erroring ROW, not a failed response — the good channel alongside it |
| +// must still read back intact. |
| +func TestAPIChannelsErrorRow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + seedChannel(t, fx, "titogo", "canary/x", "abc1234") |
| + // A garbage pointer at a second channel key — not signed, not even JSON — |
| + // planted directly in the bucket the way a corrupted or hand-edited |
| + // object would arrive. |
| + garbage := []byte("not a valid signed channel pointer") |
| + if _, err := fx.rel.Put(context.Background(), release.ChannelKey(release.OpsAccount, "titogo", "broken"), garbage, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-channels-error-row") |
| + |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("channels = %d %v, want 200 even with one bad pointer", status, out) |
| + } |
| + chans, _ := out["channels"].([]any) |
| + if len(chans) != 2 { |
| + t.Fatalf("channels = %v, want exactly two rows (one good, one broken)", out["channels"]) |
| + } |
| + byName := map[string]map[string]any{} |
| + for _, c := range chans { |
| + m, _ := c.(map[string]any) |
| + byName[str(t, m, "channel")] = m |
| + } |
| + broken, ok := byName["broken"] |
| + if !ok { |
| + t.Fatalf("channels missing the broken row: %v", chans) |
| + } |
| + if str(t, broken, "error") == "" { |
| + t.Fatalf("broken row = %v, want a non-empty error", broken) |
| + } |
| + if _, present := broken["version"]; present { |
| + t.Fatalf("broken row = %v, want no version alongside the error", broken) |
| + } |
| + good, ok := byName["canary/x"] |
| + if !ok { |
| + t.Fatalf("channels missing the good row: %v", chans) |
| + } |
| + if str(t, good, "version") != "abc1234" || str(t, good, "rung") != "canary" { |
| + t.Fatalf("good row = %v, want it unaffected by the broken one", good) |
| + } |
| +} |
| + |
| +// TestAPIChannelsRefusalMatrix: an unknown account, and an account you belong |
| +// to but an app it hasn't claimed, are the SAME 404 — byte-identical to |
| +// http.NotFound's own output, so a token holder learns nothing by probing. |
| +func TestAPIChannelsRefusalMatrix(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + |
| + // (a) member of nothing. |
| + eve := cliToken(t, fx, "eve@keymail.dev", "code-channels-eve") |
| + statusA, bodyA := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", eve) |
| + if statusA != http.StatusNotFound { |
| + t.Fatalf("member-of-nothing channels = %d, want 404", statusA) |
| + } |
| + |
| + // (b) member of bab, but the app is unclaimed there. |
| + if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + frank := cliToken(t, fx, "frank@keymail.dev", "code-channels-frank") |
| + statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/channels", frank) |
| + if statusB != http.StatusNotFound { |
| + t.Fatalf("unclaimed-app channels = %d, want 404", statusB) |
| + } |
| + |
| + if bodyA != bodyB { |
| + t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB) |
| + } |
| + // http.NotFound's own, well-known output — pinned so a future change to |
| + // either handler is caught even if it stays self-consistent. |
| + if bodyA != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA) |
| + } |
| +} |
| + |
| +// TestAPIAccountSqidBeatsName: a SQID match wins outright, even when the |
| +// segment given also happens to equal ANOTHER of the caller's accounts' |
| +// NAME. Picking that other account here would list (or later, act on) the |
| +// wrong tenant. |
| +func TestAPIAccountSqidBeatsName(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + // bac's NAME is literally "bab" — the sqid of the ops account. Alice |
| + // belongs to both. |
| + if err := fx.st.CreateAccount(2, "bac", "bab", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-beats-name") |
| + |
| + // "bab" as the account segment must resolve to the SQID match (the ops |
| + // account, which has titogo) and not the NAME match (bac, which doesn't). |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("channels = %d %v, want 200 (sqid match, not bac's name match)", status, out) |
| + } |
| +} |
| + |
| +// TestAPIAccountAmbiguousNameRefused: two of the caller's own accounts |
| +// sharing a name is refused with 409 and both candidate sqids named, never |
| +// silently resolved to one of them. |
| +func TestAPIAccountAmbiguousNameRefused(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + sqidA, sqidB := domains.Sqid(2), domains.Sqid(3) |
| + if err := fx.st.CreateAccount(2, sqidA, "widgets", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.CreateAccount(3, sqidB, "widgets", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-ambiguous-name") |
| + |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/anyapp/channels", token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("ambiguous-name channels = %d %v, want 409", status, out) |
| + } |
| + msg := str(t, out, "error") |
| + if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) { |
| + t.Fatalf("ambiguous-name error = %q, want both %q and %q named", msg, sqidA, sqidB) |
| + } |
| +} |
| + |
| +// TestAPIAccountNameMatchIsCaseInsensitive: a unique name match applies |
| +// EqualFold, matching resolveAccount's own doctrine (accounts.go). |
| +func TestAPIAccountNameMatchIsCaseInsensitive(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + sqid := domains.Sqid(2) |
| + if err := fx.st.CreateAccount(2, sqid, "Widgets", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.st.CreateAppIn(2, "gizmo", "alice@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "alice@keymail.dev", "code-case-insensitive-name") |
| + |
| + status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/gizmo/channels", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("case-insensitive name match = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// ---- promote + rollback (apicli.go) ----------------------------------------- |
| +// |
| +// The CLI's write doors: `carlos promote` and `carlos rollback` through the |
| +// same browser doors as the console UI (handlePromote/handleRollback, |
| +// server.go), minus CSRF (a JSON bearer request has no session cookie to |
| +// forge) and minus the passkey step-up (there is no browser here to redirect |
| +// into Keymail) — stable is refused outright instead. |
| + |
| +// TestAPIPromoteHappy: a shipped version reaches a fresh channel, the pointer |
| +// reads back the new version, and the audit trail names it a CLI action. |
| +func TestAPIPromoteHappy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-happy") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("promote = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "promoted" || str(t, out, "to") != "canary" { |
| + t.Fatalf("promote body = %v", out) |
| + } |
| + if v, present := out["noop"]; !present || v != false { |
| + t.Fatalf("promote noop = %v (present=%v), want false", v, present) |
| + } |
| + cp := fx.channelVersion(t, "titogo", "canary/x") |
| + if cp.Version != "abc1234" { |
| + t.Fatalf("channel after promote = %q, want abc1234", cp.Version) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a promote row whose detail names the CLI", events) |
| + } |
| +} |
| + |
| +// TestAPIPromoteStableRefused: stable cannot be reached over the API at all — |
| +// there is no passkey to step up to — and the refusal is byte-identical for a |
| +// member token and an owner token, so a token holder learns nothing about |
| +// their own role from this door (spec §3). |
| +func TestAPIPromoteStableRefused(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-stable-owner") |
| + member := cliToken(t, fx, "alice@keymail.dev", "code-stable-member") |
| + |
| + body := map[string]string{"version": "abc1234", "channel": "stable"} |
| + statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, owner) |
| + statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, member) |
| + if statusO != http.StatusForbidden || statusM != http.StatusForbidden { |
| + t.Fatalf("stable promote = owner %d, member %d, want both 403", statusO, statusM) |
| + } |
| + if outO != outM { |
| + t.Fatalf("stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM) |
| + } |
| + wantURL := fx.ts.URL + "/a/bab/apps/titogo" |
| + if !strings.Contains(outO, wantURL) { |
| + t.Fatalf("stable refusal = %q, want console_url %q", outO, wantURL) |
| + } |
| + if !strings.Contains(outO, "passkey") { |
| + t.Fatalf("stable refusal = %q, want it to name the passkey requirement", outO) |
| + } |
| + // The refusal must be true, not just spoken: neither call may have moved |
| + // the rung gate to a spot where PromoteOnLadder ran first and the 403 was |
| + // merely decorative. If a future edit reordered the gate below |
| + // PromoteOnLadder, this is what would catch it — the test above would |
| + // still pass on status and body alone. |
| + if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil { |
| + t.Fatalf("stable channel exists after two refused promotions, want it absent") |
| + } |
| +} |
| + |
| +// TestAPIPromoteNoKey: a read-only deployment (no signing key) refuses every |
| +// promotion with 503, the same as the browser door. |
| +func TestAPIPromoteNoKey(t *testing.T) { |
| + fx := newConsoleFixtureKeyed(t, false) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nokey") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("promote with no key = %d %v, want 503", status, out) |
| + } |
| +} |
| + |
| +// TestAPIPromoteBadVersion: a version that fails release.ValidVersion is a |
| +// 400 naming the problem, before anything touches the release store. |
| +func TestAPIPromoteBadVersion(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badversion") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "../etc/passwd", "channel": "canary/x"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("bad version promote = %d %v, want 400", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "version") { |
| + t.Fatalf("bad version error = %q, want it to name the version", str(t, out, "error")) |
| + } |
| +} |
| + |
| +// TestAPIPromoteBadChannel: an invalid channel name is a 400 naming the |
| +// problem. |
| +func TestAPIPromoteBadChannel(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badchannel") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "bad channel"}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("bad channel promote = %d %v, want 400", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "channel") { |
| + t.Fatalf("bad channel error = %q, want it to name the channel", str(t, out, "error")) |
| + } |
| +} |
| + |
| +// TestAPIPromoteUnshippedVersion: a well-formed version that was never |
| +// shipped is a 404 naming the app, the same sentence the browser door uses. |
| +func TestAPIPromoteUnshippedVersion(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-unshipped") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "ghost999", "channel": "canary/x"}, token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("unshipped version promote = %d %v, want 404", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "not a shipped release") { |
| + t.Fatalf("unshipped version error = %q, want the browser door's sentence", str(t, out, "error")) |
| + } |
| +} |
| + |
| +// TestAPIPromoteNoHotfix: the request struct has no hotfix field at all — |
| +// encoding/json silently ignores the unknown key, and the ladder gate still |
| +// refuses a skip exactly as it would with no such key present. There is no |
| +// way, from this door, to bypass the ladder. |
| +func TestAPIPromoteNoHotfix(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nohotfix") |
| + |
| + // abc1234 is on no rung yet (nothing has promoted it there), so promoting |
| + // straight to "beta" is the ladder-ORDERING refusal (beta is entered from |
| + // edge only) — not the "not a rung" arm a nested non-canary segment like |
| + // "beta/x" would hit instead. hotfix:true rides along and must be inert. |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]any{"version": "abc1234", "channel": "beta", "hotfix": true}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("ladder-skip promote with hotfix:true = %d %v, want 409 (hotfix must be inert)", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "beta is entered from edge only") { |
| + t.Fatalf("ladder-skip error = %q, want the ladder-ordering refusal text", str(t, out, "error")) |
| + } |
| + // And the pointer must not exist — the promotion truly never happened. |
| + if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "beta"); err == nil { |
| + t.Fatalf("beta channel exists after a refused promotion, want it absent") |
| + } |
| +} |
| + |
| +// TestAPIRollbackHappy: two promotions to the same channel, then a rollback, |
| +// return the pointer to the first version. |
| +func TestAPIRollbackHappy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + shipVersion(t, fx, "titogo", "def5678") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-happy") |
| + |
| + status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "abc1234", "channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("first promote = %d %v", status, out) |
| + } |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| + map[string]string{"version": "def5678", "channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("second promote = %d %v", status, out) |
| + } |
| + if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "def5678" { |
| + t.Fatalf("channel before rollback = %q, want def5678", cp.Version) |
| + } |
| + |
| + status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", |
| + map[string]string{"channel": "canary/x"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("rollback = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "rolled back" { |
| + t.Fatalf("rollback body = %v", out) |
| + } |
| + if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "abc1234" { |
| + t.Fatalf("channel after rollback = %q, want abc1234 (back to the first version)", cp.Version) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "rollback" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a rollback row whose detail names the CLI", events) |
| + } |
| +} |
| + |
| +// TestAPIRollbackStableRefused: Paul's ruling (2026-08-03) — a rollback that |
| +// would land on stable is refused with the SAME byte-identical 403 as a |
| +// stable promote, for a member token and an owner token alike, and the |
| +// pointer it would have touched is left exactly as it was. |
| +func TestAPIRollbackStableRefused(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + shipVersion(t, fx, "titogo", "def5678") |
| + // A stable channel with a real history to roll back to — so a refusal |
| + // that let the rollback through would be visible in what "stable" points |
| + // at afterwards, not just in a channel that never existed. |
| + seedChannel(t, fx, "titogo", "stable", "abc1234") |
| + seedChannel(t, fx, "titogo", "stable", "def5678") |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-rollback-stable-owner") |
| + member := cliToken(t, fx, "alice@keymail.dev", "code-rollback-stable-member") |
| + |
| + body := map[string]string{"channel": "stable"} |
| + statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, owner) |
| + statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, member) |
| + if statusO != http.StatusForbidden || statusM != http.StatusForbidden { |
| + t.Fatalf("stable rollback = owner %d, member %d, want both 403", statusO, statusM) |
| + } |
| + if outO != outM { |
| + t.Fatalf("stable rollback refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM) |
| + } |
| + wantURL := fx.ts.URL + "/a/bab/apps/titogo" |
| + if !strings.Contains(outO, wantURL) { |
| + t.Fatalf("stable rollback refusal = %q, want console_url %q", outO, wantURL) |
| + } |
| + if !strings.Contains(outO, "passkey") { |
| + t.Fatalf("stable rollback refusal = %q, want it to name the passkey requirement", outO) |
| + } |
| + if cp := fx.channelVersion(t, "titogo", "stable"); cp.Version != "def5678" { |
| + t.Fatalf("stable channel after two refused rollbacks = %q, want def5678 (unchanged)", cp.Version) |
| + } |
| +} |
| + |
| +// ---- ship (apicli.go) ------------------------------------------------------- |
| +// |
| +// `carlos ship`'s door: a multipart upload whose bytes are hashed and sized |
| +// HERE, server-side, from what actually arrived on the wire — never from a |
| +// field a caller could set, because there is no such field on this wire |
| +// shape (the meta part carries only version and kind). |
| + |
| +// shipPart is one part of a multipart ship request, in the order it should |
| +// be written — order matters, since the handler insists "meta" come first. |
| +type shipPart struct { |
| + name string |
| + body []byte |
| +} |
| + |
| +// metaPart builds the JSON "meta" part every ship request starts with. |
| +func metaPart(t *testing.T, version, kind string) shipPart { |
| + t.Helper() |
| + raw, err := json.Marshal(map[string]string{"version": version, "kind": kind}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return shipPart{name: "meta", body: raw} |
| +} |
| + |
| +// randomBytes is a stand-in artifact payload: real bytes, not a fixed |
| +// string, so a hash mismatch would actually be caught rather than a lucky |
| +// coincidence. |
| +func randomBytes(t *testing.T, n int) []byte { |
| + t.Helper() |
| + b := make([]byte, n) |
| + if _, err := rand.Read(b); err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// postShip POSTs a multipart/form-data ship request built from parts, in |
| +// the order given, and decodes whatever JSON comes back (a refusal that |
| +// never reaches the handler's own JSON responses — e.g. a body cut short by |
| +// MaxBytesReader — decodes to an empty map, which the caller only inspects |
| +// via the status code). |
| +func postShip(t *testing.T, u, bearer string, parts []shipPart) (int, map[string]any) { |
| + t.Helper() |
| + var buf bytes.Buffer |
| + mw := multipart.NewWriter(&buf) |
| + for _, p := range parts { |
| + fw, err := mw.CreateFormFile(p.name, p.name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fw.Write(p.body); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := mw.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + req, err := http.NewRequest(http.MethodPost, u, &buf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", mw.FormDataContentType()) |
| + if bearer != "" { |
| + req.Header.Set("Authorization", "Bearer "+bearer) |
| + } |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + raw, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]any{} |
| + _ = json.Unmarshal(raw, &out) |
| + return res.StatusCode, out |
| +} |
| + |
| +// TestAPIShipHappy: a fresh binary release ships through the multipart |
| +// door, lands at the exact bucket key the direct-to-bucket path would use, |
| +// and the manifest the server writes carries hashes/sizes it computed |
| +// itself — matching what the artifact bytes actually hash to, and BuiltBy |
| +// naming the console API as the shipper. |
| +func TestAPIShipHappy(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-happy") |
| + payload := randomBytes(t, 1024) |
| + |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship = %d %v", status, out) |
| + } |
| + if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" { |
| + t.Fatalf("ship body = %v", out) |
| + } |
| + sum := sha256.Sum256(payload) |
| + wantSHA := hex.EncodeToString(sum[:]) |
| + arts, _ := out["artifacts"].(map[string]any) |
| + arm64, _ := arts["linux-arm64"].(map[string]any) |
| + if arm64 == nil { |
| + t.Fatalf("ship artifacts = %v, want linux-arm64", arts) |
| + } |
| + if str(t, arm64, "sha256") != wantSHA { |
| + t.Fatalf("artifact sha256 = %v, want %s (computed from the bytes sent)", arm64["sha256"], wantSHA) |
| + } |
| + if sz, _ := arm64["size"].(float64); int64(sz) != int64(len(payload)) { |
| + t.Fatalf("artifact size = %v, want %d", arm64["size"], len(payload)) |
| + } |
| + |
| + ctx := context.Background() |
| + stored, _, err := fx.rel.Get(ctx, release.ArtifactKey(release.OpsAccount, "titogo", "abc1234", "linux-arm64")) |
| + if err != nil { |
| + t.Fatalf("stored artifact bytes: %v", err) |
| + } |
| + if !bytes.Equal(stored, payload) { |
| + t.Fatalf("stored artifact bytes differ from what was shipped") |
| + } |
| + m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "titogo", "abc1234") |
| + if err != nil { |
| + t.Fatalf("manifest: %v", err) |
| + } |
| + a, ok := m.Artifacts["linux-arm64"] |
| + if !ok { |
| + t.Fatalf("manifest artifacts = %v, want linux-arm64", m.Artifacts) |
| + } |
| + if a.SHA256 != wantSHA || a.Size != int64(len(payload)) { |
| + t.Fatalf("manifest artifact = %+v, want sha256=%s size=%d", a, wantSHA, len(payload)) |
| + } |
| + if !strings.HasPrefix(m.BuiltBy, "console-api ") { |
| + t.Fatalf("manifest built_by = %q, want prefix %q", m.BuiltBy, "console-api ") |
| + } |
| + |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range events { |
| + if e.Action == "ship" && strings.Contains(e.Detail, "(cli)") { |
| + found = true |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("events = %v, want a ship row whose detail names the CLI", events) |
| + } |
| +} |
| + |
| +// TestAPIShipStatic: a static site release ships under the single |
| +// "site.tar.gz" artifact name the static kind requires. |
| +func TestAPIShipStatic(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "website", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-static") |
| + payload := randomBytes(t, 512) |
| + |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/website/releases", token, |
| + []shipPart{metaPart(t, "def5678", "static"), {name: "site.tar.gz", body: payload}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("ship static = %d %v", status, out) |
| + } |
| + arts, _ := out["artifacts"].(map[string]any) |
| + if _, ok := arts["site.tar.gz"]; !ok { |
| + t.Fatalf("ship static artifacts = %v, want site.tar.gz", arts) |
| + } |
| +} |
| + |
| +// TestAPIShipRefusals: every malformed shape of a ship request is a 400, |
| +// and none of them reach release.Ship. |
| +func TestAPIShipRefusals(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-refusals") |
| + payload := randomBytes(t, 128) |
| + |
| + cases := []struct { |
| + name string |
| + parts []shipPart |
| + }{ |
| + {"bad version chars", []shipPart{ |
| + metaPart(t, "../etc/passwd", "binary"), {name: "linux-arm64", body: payload}}}, |
| + {"bad kind", []shipPart{ |
| + metaPart(t, "abc1234", "weird"), {name: "linux-arm64", body: payload}}}, |
| + {"binary part not a target name", []shipPart{ |
| + metaPart(t, "abc1234", "binary"), {name: "not-a-target", body: payload}}}, |
| + {"static part not site.tar.gz", []shipPart{ |
| + metaPart(t, "abc1234", "static"), {name: "linux-arm64", body: payload}}}, |
| + {"no file parts", []shipPart{ |
| + metaPart(t, "abc1234", "binary")}}, |
| + {"meta not first part", []shipPart{ |
| + {name: "linux-arm64", body: payload}, metaPart(t, "abc1234", "binary")}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, c.parts) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("%s = %d %v, want 400", c.name, status, out) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAPIShipTooBig: apiShipMaxBytes bounds the WHOLE request, not any one |
| +// part — lowered here to 4KiB, an 8KiB artifact part is refused with 413 |
| +// before it is ever handed to release.Ship. |
| +func TestAPIShipTooBig(t *testing.T) { |
| + old := apiShipMaxBytes |
| + apiShipMaxBytes = 4 << 10 |
| + defer func() { apiShipMaxBytes = old }() |
| + |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-toobig") |
| + payload := randomBytes(t, 8<<10) |
| + |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) |
| + if status != http.StatusRequestEntityTooLarge { |
| + t.Fatalf("oversized ship = %d %v, want 413", status, out) |
| + } |
| +} |
| + |
| +// TestAPIShipMemberGate: a non-member token gets the identical 404 every |
| +// other CLI-API door gives an unknown or someone-else's account — apiAccountApp |
| +// runs before any multipart parsing, so a garbage body never matters here. |
| +func TestAPIShipMemberGate(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + token := cliToken(t, fx, "eve@keymail.dev", "code-ship-gate") |
| + |
| + status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", map[string]string{}, token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("ship as non-member = %d, want 404", status) |
| + } |
| + if body != "404 page not found\n" { |
| + t.Fatalf("ship as non-member body = %q, want the stdlib http.NotFound output", body) |
| + } |
| +} |
| + |
| +// TestAPIShipTooManyParts: apiShipMaxBytes bounds bytes, not COUNT — a body |
| +// of nothing but tiny, validly-named parts would otherwise fit millions of |
| +// them in one small request. The ninth binary part (max is |
| +// apiShipMaxPartsBinary=8) is refused before it is ever read, and nothing |
| +// from this request reaches release.Ship: no manifest for this version |
| +// exists afterward. |
| +func TestAPIShipTooManyParts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-toomanyparts") |
| + |
| + parts := []shipPart{metaPart(t, "abc1234", "binary")} |
| + for i := 0; i < 9; i++ { |
| + parts = append(parts, shipPart{name: fmt.Sprintf("part-%d", i), body: []byte("x")}) |
| + } |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, parts) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("9-part ship = %d %v, want 400", status, out) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil { |
| + t.Fatalf("manifest exists after a refused too-many-parts ship, want none — nothing should reach release.Ship") |
| + } |
| +} |
| + |
| +// TestAPIShipDuplicatePart: two parts claiming the same artifact name are |
| +// refused rather than letting the second silently win — both would |
| +// otherwise be charged against the byte and part-count budgets while only |
| +// the last one's bytes ever reached the manifest. |
| +func TestAPIShipDuplicatePart(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-duplicate") |
| + |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{ |
| + metaPart(t, "abc1234", "binary"), |
| + {name: "linux-arm64", body: randomBytes(t, 32)}, |
| + {name: "linux-arm64", body: randomBytes(t, 32)}, |
| + }) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("duplicate-named ship = %d %v, want 400", status, out) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil { |
| + t.Fatalf("manifest exists after a refused duplicate-part ship, want none") |
| + } |
| +} |
| + |
| +// TestAPIShipTraversalRefusals: every part name that reads like an attempt |
| +// to escape the artifact's bucket-key segment is refused with 400, and — the |
| +// point of this test, not just its status code — none of them leave any |
| +// trace in the release store. Part names become bucket key segments via the |
| +// manifest (release.ArtifactKey joins them straight into the object path), |
| +// so this is path-traversal defense, not tidiness. |
| +func TestAPIShipTraversalRefusals(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-traversal") |
| + payload := randomBytes(t, 64) |
| + |
| + cases := []struct { |
| + name string |
| + version string |
| + kind string |
| + part string |
| + }{ |
| + {"dotdot slash etc passwd", "trv00001", "binary", "../../etc/passwd"}, |
| + {"nested traversal under a valid-looking prefix", "trv00002", "binary", "linux-arm64/../../x"}, |
| + {"bare dotdot", "trv00003", "binary", ".."}, |
| + {"empty part name", "trv00004", "binary", ""}, |
| + {"static traversal", "trv00005", "static", "../site.tar.gz"}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, c.version, c.kind), {name: c.part, body: payload}}) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("%s = %d %v, want 400", c.name, status, out) |
| + } |
| + if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil { |
| + t.Fatalf("%s: manifest exists for version %s, want none — release.Ship must never have been reached", |
| + c.name, c.version) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestAPIShipImmutableConflict: re-shipping an already-shipped version with |
| +// DIFFERENT bytes is a permanent 409, not the generic 502 every other |
| +// release.Ship failure gets — a caller retrying a 502 would re-upload |
| +// forever and never learn this can't succeed. Re-shipping the exact same |
| +// bytes, by contrast, must stay a success: it is the one case release.Ship |
| +// itself calls idempotent, and a caller retrying a dropped connection needs |
| +// that retry to actually land. |
| +func TestAPIShipImmutableConflict(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-immutable") |
| + payload := randomBytes(t, 256) |
| + |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) |
| + if status != http.StatusCreated { |
| + t.Fatalf("first ship = %d %v", status, out) |
| + } |
| + |
| + different := randomBytes(t, 256) |
| + status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: different}}) |
| + if status != http.StatusConflict { |
| + t.Fatalf("re-ship with different bytes = %d %v, want 409", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "immutable") { |
| + t.Fatalf("conflict error = %q, want it to name immutability", str(t, out, "error")) |
| + } |
| + |
| + status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}}) |
| + if status != http.StatusOK { |
| + t.Fatalf("re-ship with identical bytes = %d %v, want 200 (idempotent, nothing created)", status, out) |
| + } |
| + if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" { |
| + t.Fatalf("idempotent re-ship body = %v", out) |
| + } |
| +} |
| + |
| +// TestAPIShipConcurrencyLimit: the rate limiter bounds requests over TIME, |
| +// but says nothing about how many run AT ONCE. Two ships already in flight |
| +// (each holding the server's ship semaphore while its body trickles in, |
| +// via an io.Pipe under the test's control) leave a third concurrent |
| +// request no room — it must be refused immediately with 429, not queued |
| +// behind either of the first two. |
| +func TestAPIShipConcurrencyLimit(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-ship-concurrency") |
| + |
| + // A hook (same shape as cliNotified): signalled once a request has |
| + // actually acquired shipSem, so this test waits on a real fact instead |
| + // of racing the two background goroutines below with a sleep. |
| + fx.srv.shipStarted = make(chan struct{}, 2) |
| + |
| + var wg sync.WaitGroup |
| + statuses := make([]int, 2) |
| + var closers []func() error |
| + for i := 0; i < 2; i++ { |
| + pr, pw := io.Pipe() |
| + closers = append(closers, pw.Close) |
| + req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", pr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Content-Type", "multipart/form-data; boundary=xshipboundaryx") |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + wg.Add(1) |
| + idx := i |
| + go func() { |
| + defer wg.Done() |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + statuses[idx] = -1 |
| + return |
| + } |
| + defer res.Body.Close() |
| + io.Copy(io.Discard, res.Body) |
| + statuses[idx] = res.StatusCode |
| + }() |
| + } |
| + |
| + for i := 0; i < 2; i++ { |
| + select { |
| + case <-fx.srv.shipStarted: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("timed out waiting for the first two ships to start (acquire shipSem)") |
| + } |
| + } |
| + |
| + status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, |
| + []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}}) |
| + if status != http.StatusTooManyRequests { |
| + t.Fatalf("third concurrent ship = %d %v, want 429", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "in progress") { |
| + t.Fatalf("third concurrent ship error = %q, want it to name the concurrency limit", str(t, out, "error")) |
| + } |
| + |
| + // Unblock the two held-open requests (each now sees an immediately-EOF |
| + // body and is refused as an empty upload) so the test doesn't leak |
| + // goroutines, and confirm they actually finish. |
| + for _, c := range closers { |
| + c() |
| + } |
| + done := make(chan struct{}) |
| + go func() { wg.Wait(); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("timed out waiting for the held-open ships to finish after closing their bodies") |
| + } |
| + for i, st := range statuses { |
| + if st != http.StatusBadRequest { |
| + t.Fatalf("held-open ship %d finished with status %d, want 400 (empty upload, once unblocked)", i, st) |
| + } |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 6fdd51679..2ebb76b69 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -62,6 +62,18 @@ const ( |
| cliWriteBurst = 30 |
| // cliPollInterval is the wait the console asks the CLI to keep, seconds. |
| cliPollInterval = 2 |
| + |
| + // cliShipWindow is ship's own budget window — an hour, not the minute |
| + // every other CLI limiter uses: a ship is a deliberate release cut, not |
| + // a poll or a login guess, so the window matches how often a human (or |
| + // a CI job) actually does this. |
| + cliShipWindow = time.Hour |
| + // cliShipBurst bounds ship uploads per TOKEN (see handleAPIShip) within |
| + // that hour. Ships are rare: a build-and-ship is a deliberate act run a |
| + // handful of times a day at most, so ten in an hour is roomy for |
| + // legitimate use while making a stolen token's usefulness for flooding |
| + // storage negligible. |
| + cliShipBurst = 10 |
| ) |
| // rateLimiter is a fixed-window counter, keyed by whatever the caller thinks |
| @@ -126,7 +138,7 @@ func (l *rateLimiter) allow(key string, now time.Time) bool { |
| // cliLimits is the set of limiters the CLI endpoints share, hung off the |
| // Server so a test gets a fresh set per fixture. |
| type cliLimits struct { |
| - start, poll, pollIP, guessAddr, guessIP, write *rateLimiter |
| + start, poll, pollIP, guessAddr, guessIP, write, ship *rateLimiter |
| } |
| func newCLILimits() *cliLimits { |
| @@ -140,6 +152,10 @@ func newCLILimits() *cliLimits { |
| guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow), |
| guessIP: newRateLimiter(cliGuessBurst, cliRateWindow), |
| write: newRateLimiter(cliWriteBurst, cliRateWindow), |
| + // Ship gets its own budget, on its own window, keyed by token rather |
| + // than address (see handleAPIShip) — an upload is a different shape |
| + // of cost (bandwidth, storage) from the JSON mutations write covers. |
| + ship: newRateLimiter(cliShipBurst, cliShipWindow), |
| } |
| } |
| @@ -154,6 +170,14 @@ func (s *Server) registerCLIRoutes() { |
| s.mux.HandleFunc("GET /auth/cli/whoami", s.handleCLIWhoami) |
| s.mux.HandleFunc("POST /auth/cli/revoke", s.handleCLIRevoke) |
| s.mux.HandleFunc("POST /auth/cli/accounts", s.handleCLINewAccount) |
| + // The CLI's read-only API (apicli.go) — deliberately a different prefix |
| + // (/api/cli/) from the identity routes above (/auth/cli/), which must not |
| + // move now that CLIs depend on them. |
| + s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps) |
| + s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/channels", s.handleAPIChannels) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback) |
| + s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/server.go b/internal/console/server.go| index 53fb6adf1..abcf0babb 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -120,6 +120,22 @@ type Server struct { |
| // notification attempt. Tests only — the send is off the request |
| // goroutine, and a test that asserted on it without this would be racing. |
| cliNotified chan struct{} |
| + // shipSem bounds how many `carlos ship` uploads (apicli.go, |
| + // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which |
| + // bounds them over TIME. A rate limiter is a counter; it says nothing |
| + // about concurrency, and a ship holds a goroutine plus a substantial |
| + // buffer open for as long as the client trickles bytes in — a handful |
| + // of slow uploads run in parallel is a resource-exhaustion path the |
| + // counter alone does nothing to close. Always initialized (never nil): |
| + // unlike cliNotified below this is load-bearing in production, not a |
| + // test hook. |
| + shipSem chan struct{} |
| + // shipStarted, when non-nil, is signalled after a ship request |
| + // acquires shipSem, before it does any (possibly slow, possibly |
| + // blocking) reading of the request body. Tests only, same reasoning as |
| + // cliNotified: without this a test racing to fill shipSem would have no |
| + // way to know the earlier requests actually hold it yet. |
| + shipStarted chan struct{} |
| } |
| // NewServer wires a server over an opened Store, loading (or minting) the |
| @@ -140,6 +156,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme), |
| secure: strings.HasPrefix(cfg.Origin, "https://"), |
| cli: newCLILimits(), |
| + // Two concurrent ships at a time: roomy for one operator's CI plus a |
| + // human running `carlos ship` by hand, tight enough that a fleet of |
| + // slow-loris uploads against a stolen or shared token can't pile up |
| + // goroutines and buffers without bound. |
| + shipSem: make(chan struct{}, 2), |
| } |
| s.mux.HandleFunc("GET /{$}", s.handleHome) |
| s.mux.HandleFunc("POST /auth/continue", s.handleContinue) |