carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

File too large to show in full — download it above (showing a truncated preview below).

1# Temporary Static Sites Implementation Plan
2
3> **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.
4
5**Goal:** Let a member create a whole app that is static, platform-hosted, named `tmp-<label>`, expires into the Trash on its own and is purged 3 days later, with `carlos deploy --temporary`, `carlos apps ttl|keep|list`, and a console that alone decides every transition.
6
7**Architecture:** All lifecycle state lives in four new columns on the console's `apps` table (`expires_at`, `restoring`, `purge_after`, `purge_for`). Nothing new is written to the bucket beyond the existing trash marker, and boxes change nothing. Every transition runs under a per-app mutex in the console process and settles the row against the bucket marker first (`settleApp`). A 5-minute console pass (`expirypass.go`) expires due apps through the ordinary trash path and early-purges through a new `release.PurgeAppIfTrashedAt`, which only acts when the marker's clock matches the decision recorded in the row. The `tmp-` namespace is reserved at every door that can create a name, so a temporary app's expiry can only drop routes a temporary app created.
8
9**Tech Stack:** Go 1.26, `net/http` 1.22 mux patterns, SQLite via `modernc.org/sqlite`, `release.Store` (S3 or `DirStore`), html/template.
10
11**Spec:** `docs/superpowers/specs/2026-09-25-temporary-static-sites-design.md` (rev 9, approved 2026-09-26). §1 rulings are fixed. §§10-17 explain why each mechanism exists. Read the spec before each task; this plan argues from it.
12
13## Global Constraints
14
15- **Copy review.** Every user-facing string (CLI output, flag help, usage text, API error text, console HTML, flash messages, audit detail shown on the Activity tab, `docs/site/cli.md` prose) goes through the `copy-review` skill before it is written into code. Each such string is marked `// COPY-REVIEW <id>` in this plan (`<!-- COPY-REVIEW <id> -->` in templates, `(COPY-REVIEW <id>)` in prose for docs and usage text). Task 0 collects them all for one review before any task that ships them. Where the approved text differs from the draft shown here, the approved text wins, verbatim. The markers are plan annotations: do not carry them into the code, templates or docs. Tests assert on command names, app names and dates, never on a whole sentence, so an approved rewording does not break them. Operator log lines (`log.Printf`) are not copy and are not marked. Existing strings a task moves without changing are not new copy.
16- **House voice.** No em dashes in new copy. Use a full stop and a new sentence instead, lower case where the sentence continues a CLI line. Plain, direct sentences. Existing strings this plan does not touch keep their current wording.
17- **Testing in the sandbox.** Run Go with `GOCACHE=$TMPDIR/gocache` exported on the same command line, every time (the environment does not carry it between commands), e.g. `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestX' -count=1`. Tests that open unix sockets (the root package's `carlos` subprocess tests) also need `TMPDIR=/tmp/g` (create it first with `mkdir -p /tmp/g`), e.g. `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestX' -count=1`. If the toolchain reports "read-only file system", the command needs the sandbox disabled (AGENTS.md "Sandbox note").
18- **Cross-compile gate.** CI builds darwin, windows and linux-arm64 (`.amadan/ci.d/20-cross`). Nothing in this plan is linux-only; do not introduce a syscall or a linux-only type. Before the final commit run `GOOS=darwin GOCACHE=$TMPDIR/gocache go build ./... && GOOS=windows GOCACHE=$TMPDIR/gocache go build ./...`.
19- **Full gate before landing.** `GOCACHE=$TMPDIR/gocache go build ./...`, `GOCACHE=$TMPDIR/gocache go vet ./...`, `gofmt -l .` (must print nothing), and `GOCACHE=$TMPDIR/gocache go test ./... -count=1`.
20- **Branch and landing.** Work happens on a branch and lands through `amadan branch merge` on the amadan forge (`carlos/platform` on amadan.net), never a direct commit or merge to `main`, never a GitHub PR (AGENTS.md "Every change lands through a reviewed branch on amadan"). Squash to one commit before landing. Every commit message ends with:
21 `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`
22- **Wire-additive.** No change to box code and no change to any bucket object's shape. The only bucket object the feature writes is the existing trash marker (`apps/<sqid>/<app>/deleted.json`, `release.AppTombstone` schema 1), through the existing `release.TrashApp`. JSON responses only gain fields. Spec §2 depends on this: nothing in the bucket can make an app temporary.
23- **Fixed numbers (spec §1, §3, §5, §6).** Prefix `tmp-`. TTL default 7 days, minimum 1 hour, maximum 30 days. Cap 20 live temporary apps per account, where live means `expires_at > 0 AND (deleted_at = 0 OR restoring = 1)`. Early purge 3 days (`release.TemporaryCooloff = 72h`) after the marker's `deleted_at`. Restore sets `expires_at = now + 7 days` when `expires_at < now + 1 hour`. Expiry pass every 5 minutes. Feature switch `CARLOS_TEMPORARY_APPS=1` in the console unit's environment; off by default.
24- **Lock order.** Account lock before app lock, never the reverse. Only `/new-temporary` and `restoreApp` take the account lock.
25- **Security gate order** (AGENTS.md): session, CSRF, owner (byte-identical 404), `requireFreshAuth` last. New browser doors in this plan follow it; the temporary refusals sit after the membership gate so they never confirm an app exists to a stranger.
26
27## Review Focus
28
291. **TTL values at and past the bounds, including ones that overflow a `time.Duration`** (`ttl_seconds` of 3599, 3600, 2592000, 2592001, -1, 1<<62; CLI `--ttl 3D`, `1.5d`, `31d`, `90m`). Expected: the bounds are inclusive, everything else is refused with the 1h..30d sentence, nothing wraps, nothing is created. Tests: Task 5 Step 1 (`TestTemporaryTTLBounds`), Task 11 Step 1 (`TestParseTTL`).
302. **Temporary names at the edge of the grammar**: `TMP-Docs` (normalises to `tmp-docs`), bare `tmp-`, a trailing hyphen `tmp-abc-`, 33 characters, and a CLI `--app` that passes 32 characters only after the prefix is added. Expected: refused with the name rule and nothing claimed, except `TMP-Docs`, which creates `tmp-docs`. Tests: Task 5 Step 1 (`TestTemporaryCreateNameShapes`), Task 11 Step 1 (`TestTemporaryAppName`).
313. **An expiry exactly equal to the pass's clock.** Expected: `expires_at == now` expires (the rule is `<=`), `expires_at == now + 1s` does not. Test: Task 8 Step 1 (`TestExpiryBoundaryIsInclusive`).
324. **`ttl` and `keep` aimed at a permanent app, or at a temporary app in the Trash.** Expected: a 409 that names the command that helps (`carlos apps restore` for trashed), the row unchanged. A trashed app is not reachable through the live-app gate at all (404). Test: Task 9 Step 1 (`TestTTLAndKeepRefuseWhatTheyCannotChange`).
335. **An app-scoped service credential (a CI token bound to one app) calling `/new-temporary`.** Expected: the byte-identical 404 `/new` gives it, and no row, while an account-scoped Publish credential creates. Test: Task 5 Step 1 (`TestAppScopedServiceCannotClaimATemporaryApp`).
34
35---
36
37## File map
38
39| File | Task | Responsibility |
40|---|---|---|
41| `internal/release/temporary.go` (new) | 1 | `tmp-` prefix, `TemporaryCooloff`, `PurgeAppIfTrashedAt` |
42| `internal/release/trash.go` | 1 | `purgeReady`/`purgeApp` take an optional tombstone check |
43| `internal/console/store.go` | 2, 3, 6 | migration, `TrashAppRow`/`RestoreAppRow`/`AppRows`, name and temporary refusals in `GrantAt`, `BackfillOpsMemberships`, `RenameAppIn`, `MoveApp`, `SetAppControlPlane` |
44| `internal/console/tempstore.go` (new) | 2, 10 | `AppLife`, conditional updates, candidate queries, `effectivePurgeAt` |
45| `internal/console/server.go` | 3, 4, 6, 9 | `claimAppWith`, Server lock fields, production refusals, `page.ExpiresAt`, `appPage` |
46| `internal/console/applocks.go` (new) | 4 | per-key mutex, `lockAccount`, `lockApp` |
47| `internal/console/settle.go` (new) | 4 | tripwire helper, `settleApp` |
48| `internal/console/materialize.go` | 4 | shared tripwire, temporary window, restoring, restore catch-up |
49| `internal/console/apitemporary.go` (new) | 5, 9 | `/new-temporary`, cap, route check, ttl/keep/list, browser keep |
50| `internal/console/temprefusal.go` (new) | 6 | `temporaryRefusal`, `refuseTemporary` |
51| `internal/console/restore.go` (new) | 7 | `restoreApp` |
52| `internal/console/expirypass.go` (new) | 8 | the pass |
53| `internal/console/apicli.go`, `ownership.go`, `appsettings.go`, `apiinstances.go`, `instanceprivatestore.go`, `apistore.go`, `apistore_provision.go`, `apidomains.go`, `domainattach.go`, `apimail.go`, `apipipeline.go`, `controlplane.go`, `authcli.go`, `view.go` | 3-10 | door edits |
54| `internal/console/templates/appnav.html`, `trash.html` | 9, 10 | expiry header and Keep button, per-app Trash deadline |
55| `cmd/console/main.go` | 5, 8 | `CARLOS_TEMPORARY_APPS`, start the pass |
56| `cli.go`, `apps.go`, `apimode.go`, `deploy.go`, `temporary.go` (new, package main) | 3, 10, 11 | `carlos add` rail, `apps ttl|keep|list`, deploy flags, display |
57| `docs/site/cli.md` | 11 | the flags and the subcommands |
58
59---
60
61### Task 0: Copy review of every user-facing string
62
63Nothing in Tasks 3-11 is written until its strings have been through review. One review round covers the whole plan, because the strings share one voice and one vocabulary ("temporary app", "the Trash", "purged", "keep").
64
65**Files:**
66- Create: `docs/superpowers/plans/2026-09-26-temporary-static-sites-copy.md` (the approved-copy ledger this task produces; each later task reads it)
67
68**Interfaces:**
69- Consumes: this plan's `COPY-REVIEW` markers.
70- Produces: the ledger, keyed by the same ids (`C3.1`, `C5.2`, ...). Every later task uses the ledger's text wherever this plan shows a draft.
71
72- [ ] **Step 1: Collect the strings**
73
74Run: `grep -n "COPY-REVIEW" docs/superpowers/plans/2026-09-26-temporary-static-sites.md`
75Expected: one line per marked string; Appendix A at the end of this plan lists the same ids with their drafts and where each ships.
76
77- [ ] **Step 2: Review**
78
79Invoke the `copy-review` skill with Appendix A's table as the extracted strings. Say in the brief: house voice, no em dashes, full stop and a new sentence instead; `carlos ...` commands stay literal; dates print as `2006-01-02 15:04 UTC`; relative times print as `in 3d`, `in 5h`, `in 12m`. Wait for Paul's edits.
80
81- [ ] **Step 3: Record the approved text**
82
83Write the ledger file as a table `| id | approved text |`, verbatim from the review, and commit it:
84
85```bash
86git add docs/superpowers/plans/2026-09-26-temporary-static-sites-copy.md
87git commit -m "plan: approved copy for temporary static sites
88
89Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
90```
91
92---
93
94### Task 1: `release`: the `tmp-` namespace, the 3-day cooloff, and a purge bound to one trash
95
96**Files:**
97- Create: `internal/release/temporary.go`
98- Modify: `internal/release/trash.go:137-161` (`PurgeReady`), `internal/release/trash.go:182-266` (`PurgeApp`)
99- Test: `internal/release/temporary_test.go` (new)
100
101**Interfaces:**
102- Consumes: `ReadAppTombstone`, `TrashApp`, `ErrNotTrashed` (trash.go); test helpers `seedApp`, `listUnder` (trash_test.go:169, :185), `testKeys` (release_test.go:21), package `ctx` (trash_test.go:16).
103- Produces:
104 - `const TemporaryAppPrefix = "tmp-"`
105 - `func IsTemporaryName(app string) bool`
106 - `const TemporaryCooloff = 72 * time.Hour`
107 - `var ErrTrashClockMismatch error`
108 - `func PurgeAppIfTrashedAt(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, wantDeletedAt, now time.Time) error`
109
110- [ ] **Step 1: Write the failing tests**
111
112```go
113// internal/release/temporary_test.go
114package release
115
116import (
117 "errors"
118 "testing"
119 "time"
120)
121
122func TestTemporaryNamespaceAndCooloff(t *testing.T) {
123 for name, want := range map[string]bool{
124 "tmp-docs": true,
125 "tmp-a1b2c3": true,
126 "tmp": false,
127 "tmpdocs": false,
128 "docs-tmp-": false,
129 "": false,
130 } {
131 if got := IsTemporaryName(name); got != want {
132 t.Errorf("IsTemporaryName(%q) = %v, want %v", name, got, want)
133 }
134 }
135 if TemporaryCooloff != 72*time.Hour {
136 t.Fatalf("TemporaryCooloff = %v, want 72h (spec ruling 2)", TemporaryCooloff)
137 }
138}
139
140// The row stores whole seconds and the marker stores nanoseconds. The purge
141// decision is bound to the second (spec §5), so the two still match.
142func TestPurgeAppIfTrashedAtPurgesOnTheSameSecond(t *testing.T) {
143 s := NewDirStore(t.TempDir())
144 _, pub := testKeys(t)
145 seedApp(t, s, "bab", "tmp-docs")
146 trashedAt := time.Date(2026, 9, 26, 10, 0, 0, 750_000_000, time.UTC)
147 if err := TrashApp(ctx, s, "bab", "tmp-docs", "expiry", trashedAt); err != nil {
148 t.Fatal(err)
149 }
150 want := time.Unix(trashedAt.Unix(), 0)
151 if err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", want, trashedAt.Add(TemporaryCooloff)); err != nil {
152 t.Fatalf("PurgeAppIfTrashedAt on the matching clock: %v", err)
153 }
154 if got := listUnder(t, s, "apps/bab/tmp-docs/"); len(got) != 0 {
155 t.Fatalf("purge left objects behind: %v", got)
156 }
157}
158
159// A restore, a keep and a fresh trash write a marker with a new clock. The old
160// decision must not fire against it (spec §5, astra 4).
161func TestPurgeAppIfTrashedAtRefusesAnotherTrash(t *testing.T) {
162 s := NewDirStore(t.TempDir())
163 _, pub := testKeys(t)
164 keys := seedApp(t, s, "bab", "tmp-docs")
165 decided := time.Date(2026, 9, 26, 10, 0, 0, 0, time.UTC)
166 if err := TrashApp(ctx, s, "bab", "tmp-docs", "paul@keymail.dev", decided.Add(time.Hour)); err != nil {
167 t.Fatal(err)
168 }
169 err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", decided, decided.Add(TemporaryCooloff))
170 if !errors.Is(err, ErrTrashClockMismatch) {
171 t.Fatalf("PurgeAppIfTrashedAt against a newer trash = %v, want ErrTrashClockMismatch", err)
172 }
173 if got := listUnder(t, s, "apps/bab/tmp-docs/"); len(got) != len(keys)+1 {
174 t.Fatalf("a refused purge destroyed objects: %v", got)
175 }
176}
177
178// Everything PurgeApp refuses, this refuses too.
179func TestPurgeAppIfTrashedAtKeepsPurgeAppsGuards(t *testing.T) {
180 priv, pub := testKeys(t)
181 s := NewDirStore(t.TempDir())
182 seedApp(t, s, "bab", "tmp-docs")
183 at := time.Date(2026, 9, 26, 10, 0, 0, 0, time.UTC)
184 if err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", at, at); !errors.Is(err, ErrNotTrashed) {
185 t.Fatalf("untrashed app = %v, want ErrNotTrashed", err)
186 }
187 if err := SetProduction(ctx, s, priv, "bab", "tmp-docs", true, "paul@keymail.dev"); err != nil {
188 t.Fatal(err)
189 }
190 if err := TrashApp(ctx, s, "bab", "tmp-docs", "expiry", at); err != nil {
191 t.Fatal(err)
192 }
193 if err := PurgeAppIfTrashedAt(ctx, s, pub, "bab", "tmp-docs", at, at); err == nil {
194 t.Fatal("purged a production-flagged app")
195 }
196 if got := listUnder(t, s, "apps/bab/tmp-docs/"); len(got) == 0 {
197 t.Fatal("a refused purge destroyed objects")
198 }
199}
200```
201
202- [ ] **Step 2: Run the tests to verify they fail**
203
204Run: `GOCACHE=$TMPDIR/gocache go test ./internal/release/ -run 'TestTemporaryNamespaceAndCooloff|TestPurgeAppIfTrashedAt' -count=1`
205Expected: FAIL to compile: `undefined: IsTemporaryName`, `undefined: TemporaryCooloff`, `undefined: PurgeAppIfTrashedAt`, `undefined: ErrTrashClockMismatch`.
206
207- [ ] **Step 3: Implement**
208
209Create `internal/release/temporary.go`:
210
211```go
212package release
213
214import (
215 "context"
216 "crypto/ed25519"
217 "errors"
218 "fmt"
219 "strings"
220 "time"
221)
222
223// TemporaryAppPrefix is the reserved namespace every temporary app lives in
224// (temporary static sites spec, ruling 7). Nothing permanent may be CREATED
225// under it; an app made permanent with `carlos apps keep` keeps its name.
226// The reservation is what makes "brand new" provable: no permanent app, row
227// or route can hold a tmp- name, so a temporary app's expiry can only drop
228// routes a temporary app created (spec §3).
229const TemporaryAppPrefix = "tmp-"
230
231// IsTemporaryName reports whether app is in the reserved namespace. It says
232// nothing about whether the app IS temporary: a kept app keeps its tmp- name,
233// and only the console's row knows.
234func IsTemporaryName(app string) bool { return strings.HasPrefix(app, TemporaryAppPrefix) }
235
236// TemporaryCooloff is how long a trashed temporary app stays restorable
237// before the console purges it (spec ruling 2). The box's own 30-day
238// TrashCooloff still applies to every app as the backstop.
239const TemporaryCooloff = 72 * time.Hour
240
241// ErrTrashClockMismatch is PurgeAppIfTrashedAt's refusal: the app is in the
242// Trash, but not the trash the purge decision was made about.
243var ErrTrashClockMismatch = errors.New("release: the trash marker is not the one the purge was decided for")
244
245// PurgeAppIfTrashedAt is PurgeApp with one more precondition: the current
246// marker's DeletedAt, truncated to whole seconds, equals wantDeletedAt (the
247// console row's purge_for). A restore followed by a keep and a fresh trash
248// writes a marker with a new clock, so a decision taken about the old trash
249// can no longer fire (spec §5, astra 4).
250//
251// The check runs on the SAME tombstone read PurgeApp's own preconditions use
252// (purgeReady), so there is no second read and no window between two. Every
253// other guard (production flag, object-store estate) applies unchanged.
254func PurgeAppIfTrashedAt(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, wantDeletedAt, now time.Time) error {
255 return purgeApp(ctx, s, pub, acct, app, func(t *AppTombstone) error {
256 if t.DeletedAt.Unix() != wantDeletedAt.Unix() {
257 return fmt.Errorf("release: %s/%s was trashed at %s, not %s: %w", acct, app,
258 t.DeletedAt.UTC().Format(time.RFC3339), wantDeletedAt.UTC().Format(time.RFC3339), ErrTrashClockMismatch)
259 }
260 return nil
261 })
262}
263```
264
265In `internal/release/trash.go`, replace `PurgeReady` (lines 137-161) with a wrapper plus the checked form. Everything from the `len(pub)` check down is the existing body, unchanged:
266
267```go
268func PurgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) error {
269 return purgeReady(ctx, s, pub, acct, app, nil)
270}
271
272// purgeReady is PurgeReady with an optional extra precondition on the marker
273// it has just read. check sees the SAME tombstone the rest of this block
274// trusts, so a caller binding a purge to one particular trash
275// (PurgeAppIfTrashedAt) adds no second read.
276func purgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error {
277 tomb, err := ReadAppTombstone(ctx, s, acct, app)
278 if err != nil {
279 return err
280 }
281 if tomb == nil {
282 return ErrNotTrashed
283 }
284 if check != nil {
285 if err := check(tomb); err != nil {
286 return err
287 }
288 }
289 if len(pub) != ed25519.PublicKeySize {
290 return fmt.Errorf("release: no release public key — cannot prove %s/%s is not in production, refusing to purge", acct, app)
291 }
292 flag, err := GetProduction(ctx, s, pub, acct, app)
293 if err != nil {
294 return fmt.Errorf("release: cannot read the production flag for %s/%s — refusing to purge: %w", acct, app, err)
295 }
296 if flag != nil && flag.Production {
297 return fmt.Errorf("release: %s/%s is flagged production — a trashed production app should be impossible; refusing to purge", acct, app)
298 }
299 return nil
300}
301```
302
303Keep the existing comment block above `PurgeReady` and the one inside it about `(nil, nil)` (move it above the `GetProduction` call in `purgeReady`). Then replace `PurgeApp`'s first statement (lines 187-189) so the body becomes `purgeApp`:
304
305```go
306func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error {
307 return purgeApp(ctx, s, pub, acct, app, nil)
308}
309
310// purgeApp is PurgeApp's body with purgeReady's optional check threaded
311// through. Everything below the precondition call is PurgeApp as it was.
312func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error {
313 if err := purgeReady(ctx, s, pub, acct, app, check); err != nil {
314 return err
315 }
316 prefix := appPrefix(acct, app)
317 // ... lines 190-265 of the current PurgeApp, unchanged: the store-estate
318 // guard over store.json and stores/, then the sweep with the marker last.
319}
320```
321
322Move the existing doc comment of `PurgeApp` (lines 163-181) above the new `PurgeApp` wrapper, and the in-body comments (lines 183-186 and 191-223) into `purgeApp`, unchanged. The existing strings here are not user-facing copy this plan touches.
323
324- [ ] **Step 4: Run the tests to verify they pass, plus the box regression**
325
326Run: `GOCACHE=$TMPDIR/gocache go test ./internal/release/ -count=1`
327Expected: PASS, including every existing `TestPurgeApp*` and `TestTrash*`.
328
329Run: `GOCACHE=$TMPDIR/gocache go test ./internal/domains/ -run 'TestReconcilePurgesAppsPastTheCooloff|TestReconcileDoesNotPurgeInsideTheCooloff|TestReconcileNeverPurgesAProductionFlaggedApp' -count=1`
330Expected: PASS. This is spec §9's "Boxes: no change": a trashed app with no row knowledge still purges at 30 days through the refactored `PurgeApp`.
331
332- [ ] **Step 5: Commit**
333
334```bash
335gofmt -l internal/release
336git add internal/release/temporary.go internal/release/temporary_test.go internal/release/trash.go
337git commit -m "release: tmp- namespace, 3-day cooloff, purge bound to one trash
338
339Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
340```
341
342---
343
344### Task 2: Console store: the four columns, the temporary claim, conditional updates, one deadline
345
346**Files:**
347- Modify: `internal/console/store.go:386` (ALTER list, after `build_probe`), `:481-497` (`migrateAppsKey`'s `apps_new` and copy), `:1456-1476` (`TrashAppRow`, `RestoreAppRow`), `:1570-1606` (`AppRow`, `AppRows`)
348- Create: `internal/console/tempstore.go`
349- Test: `internal/console/tempstore_test.go` (new). Step 4 also runs the existing tests in `migrate_test.go`, `store_test.go` and `materialize_test.go`, which this task does not edit.
350
351**Interfaces:**
352- Consumes: `release.TemporaryCooloff`, `release.TrashCooloff` (Task 1, trash.go:33).
353- Produces:
354 - `type AppLife struct { AccountID int64; App string; CreatedAt, DeletedAt, ExpiresAt int64; Restoring bool; PurgeAfter, PurgeFor int64; ControlPlane bool }` with `func (l *AppLife) Temporary() bool` and `func (l *AppLife) Trashed() bool`. All times are Unix seconds.
355 - `func (s *Store) AppLife(accountID int64, app string) (*AppLife, error)` (nil, nil when there is no row)
356 - `func (s *Store) AppLivesIn(accountID int64) ([]AppLife, error)` (every row, trashed included, by name)
357 - `func (s *Store) LiveTemporaryApps(accountID int64) ([]AppLife, error)` (the cap's rows)
358 - `func (s *Store) CreateTemporaryAppIn(accountID int64, app, createdBy string, now, expiresAt time.Time) (bool, error)`
359 - `func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (bool, error)`
360 - `func (s *Store) KeepApp(accountID int64, app string) (bool, error)`
361 - `func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error)`
362 - `func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error)`
363 - `func (s *Store) SetRestoring(accountID int64, app string, on bool) (bool, error)`
364 - `func (s *Store) FinishRestoreRow(accountID int64, app string, now time.Time) error`
365 - `func (s *Store) TemporaryAppRows() ([]AppRow, error)` and `func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error)`
366 - `AppRow` gains `DeletedAt, ExpiresAt int64; Restoring bool; PurgeAfter, PurgeFor int64` (existing fields unchanged)
367 - `TrashAppRow` now also sets `purge_for`/`purge_after` when `expires_at > 0`; `RestoreAppRow` also clears `restoring`, `purge_after`, `purge_for`
368 - `func effectivePurgeAt(deletedAt, purgeAfter int64) time.Time`
369 - `const temporaryTTLDefault = 7 * 24 * time.Hour`, `const temporaryRestoreFloor = time.Hour`, `const temporaryCooloffSeconds = int64(release.TemporaryCooloff / time.Second)`
370
371- [ ] **Step 1: Write the failing tests**
372
373```go
374// internal/console/tempstore_test.go
375package console
376
377import (
378 "database/sql"
379 "path/filepath"
380 "testing"
381 "time"
382
383 "github.com/carlosframework/platform/internal/release"
384 _ "modernc.org/sqlite"
385)
386
387// tempStoreAccount gives a bare store an account row, which AppRows and the
388// candidate queries join against.
389func tempStoreAccount(t *testing.T, s *Store) int64 {
390 t.Helper()
391 id := testAccountID()
392 if _, err := s.EnsureAccount(id, "tst", "Test", time.Unix(1700000000, 0)); err != nil {
393 t.Fatal(err)
394 }
395 return id
396}
397
398func mustLife(t *testing.T, s *Store, acct int64, app string) *AppLife {
399 t.Helper()
400 l, err := s.AppLife(acct, app)
401 if err != nil || l == nil {
402 t.Fatalf("AppLife(%s) = %v, %v", app, l, err)
403 }
404 return l
405}
406
407// The pre-accounts table is rebuilt by migrateAppsKey on the FIRST open, after
408// the ALTER list has added the new columns to the old table. The rebuild must
409// carry them, or the first boot after upgrade runs without them.
410func TestTemporaryColumnsSurviveTheAppsRebuild(t *testing.T) {
411 path := filepath.Join(t.TempDir(), "console.db")
412 raw, err := sql.Open("sqlite", path)
413 if err != nil {
414 t.Fatal(err)
415 }
416 if _, err := raw.Exec(`CREATE TABLE apps (app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL);`); err != nil {
417 t.Fatal(err)
418 }
419 if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES ('kass', 'paul@keymail.dev', 1700000000)`); err != nil {
420 t.Fatal(err)
421 }
422 raw.Close()
423 st, err := Open(path)
424 if err != nil {
425 t.Fatal(err)
426 }
427 defer st.Close()
428 var exp, restoring, after, forAt int64
429 if err := st.db.QueryRow(`SELECT expires_at, restoring, purge_after, purge_for FROM apps WHERE app = 'kass'`).
430 Scan(&exp, &restoring, &after, &forAt); err != nil {
431 t.Fatalf("new columns after the first open: %v", err)
432 }
433 if exp != 0 || restoring != 0 || after != 0 || forAt != 0 {
434 t.Fatalf("defaults = %d %d %d %d, want all 0 (permanent, not restoring, no early purge)", exp, restoring, after, forAt)
435 }
436}
437
438func TestCreateTemporaryAppInClaimsWithTheExpiry(t *testing.T) {
439 s := openTestStore(t)
440 acct := tempStoreAccount(t, s)
441 now := time.Unix(1700000000, 0)
442 exp := now.Add(7 * 24 * time.Hour)
443 if ok, err := s.CreateTemporaryAppIn(acct, "tmp-docs", "paul@keymail.dev", now, exp); err != nil || !ok {
444 t.Fatalf("CreateTemporaryAppIn = %v %v", ok, err)
445 }
446 if l := mustLife(t, s, acct, "tmp-docs"); l.ExpiresAt != exp.Unix() || !l.Temporary() || l.Trashed() {
447 t.Fatalf("row = %+v, want expires_at %d, live", l, exp.Unix())
448 }
449 // A racing permanent create of the same name loses (ON CONFLICT DO NOTHING).
450 if ok, err := s.CreateAppIn(acct, "tmp-docs", "eve@keymail.dev", now); err != nil || ok {
451 t.Fatalf("permanent create over a temporary row = %v %v, want false", ok, err)
452 }
453 if ok, _ := s.CreateTemporaryAppIn(acct, "tmp-docs", "eve@keymail.dev", now, exp); ok {
454 t.Fatal("a second temporary claim of the same name succeeded")
455 }
456 if _, err := s.CreateTemporaryAppIn(acct, "tmp-zero", "paul@keymail.dev", now, time.Time{}); err == nil {
457 t.Fatal("a temporary claim with no expiry was accepted")
458 }
459}
460
461func TestTemporaryConditionalUpdatesLoseCleanly(t *testing.T) {
462 s := openTestStore(t)
463 acct := tempStoreAccount(t, s)
464 now := time.Unix(1700000000, 0)
465 if _, err := s.CreateAppIn(acct, "perm", "paul@keymail.dev", now); err != nil {
466 t.Fatal(err)
467 }
468 if _, err := s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour)); err != nil {
469 t.Fatal(err)
470 }
471 for name, fn := range map[string]func() (bool, error){
472 "keep permanent": func() (bool, error) { return s.KeepApp(acct, "perm") },
473 "ttl permanent": func() (bool, error) { return s.SetAppExpiry(acct, "perm", now.Add(2*time.Hour)) },
474 "expire permanent": func() (bool, error) { return s.ExpireAppRow(acct, "perm", now) },
475 "bump live": func() (bool, error) { return s.BumpTrashedExpiry(acct, "tmp-a", now) },
476 "restoring on live": func() (bool, error) { return s.SetRestoring(acct, "tmp-a", true) },
477 } {
478 if ok, err := fn(); err != nil || ok {
479 t.Errorf("%s = %v %v, want a lost update (false, nil)", name, ok, err)
480 }
481 }
482 // Expire wins once, and sets the 3-day window from the given clock.
483 at := now.Add(90 * time.Minute)
484 if ok, err := s.ExpireAppRow(acct, "tmp-a", at); err != nil || !ok {
485 t.Fatalf("ExpireAppRow = %v %v", ok, err)
486 }
487 l := mustLife(t, s, acct, "tmp-a")
488 if l.DeletedAt != at.Unix() || l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 {
489 t.Fatalf("expired row = %+v, want deleted_at = purge_for = %d, purge_after = +72h", l, at.Unix())
490 }
491 for name, fn := range map[string]func() (bool, error){
492 "expire twice": func() (bool, error) { return s.ExpireAppRow(acct, "tmp-a", at.Add(time.Hour)) },
493 "keep trashed": func() (bool, error) { return s.KeepApp(acct, "tmp-a") },
494 "ttl on trashed": func() (bool, error) { return s.SetAppExpiry(acct, "tmp-a", now.Add(9*time.Hour)) },
495 } {
496 if ok, err := fn(); err != nil || ok {
497 t.Errorf("%s = %v %v, want a lost update", name, ok, err)
498 }
499 }
500}
501
502func TestTrashAppRowSetsTheWindowOnlyForTemporaryRows(t *testing.T) {
503 s := openTestStore(t)
504 acct := tempStoreAccount(t, s)
505 now := time.Unix(1700000000, 0)
506 s.CreateAppIn(acct, "perm", "paul@keymail.dev", now)
507 s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now, now.Add(30*24*time.Hour))
508 at := now.Add(time.Minute)
509 for _, app := range []string{"perm", "tmp-a"} {
510 if err := s.TrashAppRow(acct, app, at); err != nil {
511 t.Fatal(err)
512 }
513 }
514 if l := mustLife(t, s, acct, "perm"); l.PurgeAfter != 0 || l.PurgeFor != 0 || l.DeletedAt != at.Unix() {
515 t.Fatalf("permanent trash = %+v, want no early purge", l)
516 }
517 if l := mustLife(t, s, acct, "tmp-a"); l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 {
518 t.Fatalf("temporary trash = %+v, want the 3-day window from %d", l, at.Unix())
519 }
520}
521
522func TestLiveTemporaryCountsARestoringRow(t *testing.T) {
523 s := openTestStore(t)
524 acct := tempStoreAccount(t, s)
525 now := time.Unix(1700000000, 0)
526 for _, app := range []string{"tmp-a", "tmp-b", "tmp-c"} {
527 s.CreateTemporaryAppIn(acct, app, "paul@keymail.dev", now, now.Add(time.Hour))
528 }
529 s.CreateAppIn(acct, "perm", "paul@keymail.dev", now)
530 s.TrashAppRow(acct, "tmp-b", now)
531 s.TrashAppRow(acct, "tmp-c", now)
532 if ok, err := s.SetRestoring(acct, "tmp-c", true); err != nil || !ok {
533 t.Fatalf("SetRestoring = %v %v", ok, err)
534 }
535 live, err := s.LiveTemporaryApps(acct)
536 if err != nil {
537 t.Fatal(err)
538 }
539 var names []string
540 for _, l := range live {
541 names = append(names, l.App)
542 }
543 if len(names) != 2 || names[0] != "tmp-a" || names[1] != "tmp-c" {
544 t.Fatalf("LiveTemporaryApps = %v, want [tmp-a tmp-c]: live, plus the one mid-restore", names)
545 }
546}
547
548func TestRestoreStepsOnTheRow(t *testing.T) {
549 s := openTestStore(t)
550 acct := tempStoreAccount(t, s)
551 now := time.Unix(1700000000, 0)
552 s.CreateTemporaryAppIn(acct, "tmp-a", "paul@keymail.dev", now.Add(-10*24*time.Hour), now.Add(-time.Minute))
553 s.TrashAppRow(acct, "tmp-a", now.Add(-time.Minute))
554 // Step 2: only while trashed, only when the expiry is under an hour away.
555 if ok, err := s.BumpTrashedExpiry(acct, "tmp-a", now); err != nil || !ok {
556 t.Fatalf("BumpTrashedExpiry = %v %v", ok, err)
557 }
558 if l := mustLife(t, s, acct, "tmp-a"); l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !l.Trashed() || l.PurgeAfter == 0 {
559 t.Fatalf("after step 2 = %+v, want expiry now+7d, still trashed, deadline kept", l)
560 }
561 if ok, _ := s.BumpTrashedExpiry(acct, "tmp-a", now); ok {
562 t.Fatal("step 2 fired again on an expiry a week out")
563 }
564 // Step 5.
565 if err := s.FinishRestoreRow(acct, "tmp-a", now); err != nil {
566 t.Fatal(err)
567 }
568 if l := mustLife(t, s, acct, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 {
569 t.Fatalf("after step 5 = %+v, want live with no deadline", l)
570 }
571}
572
573func TestEffectivePurgeAt(t *testing.T) {
574 del := int64(1700000000)
575 if got, want := effectivePurgeAt(del, 0), time.Unix(del, 0).UTC().Add(release.TrashCooloff); !got.Equal(want) {
576 t.Errorf("permanent = %v, want %v", got, want)
577 }
578 if got, want := effectivePurgeAt(del, del+72*3600), time.Unix(del+72*3600, 0).UTC(); !got.Equal(want) {
579 t.Errorf("temporary = %v, want %v", got, want)
580 }
581}
582
583func TestPurgeDueAndTemporaryRows(t *testing.T) {
584 s := openTestStore(t)
585 acct := tempStoreAccount(t, s)
586 now := time.Unix(1700000000, 0)
587 s.CreateAppIn(acct, "perm", "paul@keymail.dev", now)
588 s.CreateTemporaryAppIn(acct, "tmp-due", "paul@keymail.dev", now, now.Add(time.Hour))
589 s.CreateTemporaryAppIn(acct, "tmp-later", "paul@keymail.dev", now, now.Add(time.Hour))
590 s.TrashAppRow(acct, "perm", now.Add(-100*24*time.Hour))
591 s.TrashAppRow(acct, "tmp-due", now.Add(-72*time.Hour))
592 s.TrashAppRow(acct, "tmp-later", now.Add(-71*time.Hour))
593 due, err := s.PurgeDueApps(now)
594 if err != nil {
595 t.Fatal(err)
596 }
597 if len(due) != 1 || due[0].App != "tmp-due" || due[0].Sqid != "tst" || due[0].PurgeFor != now.Add(-72*time.Hour).Unix() {
598 t.Fatalf("PurgeDueApps = %+v, want only tmp-due (a permanent row never has purge_after)", due)
599 }
600 rows, err := s.TemporaryAppRows()
601 if err != nil {
602 t.Fatal(err)
603 }
604 if len(rows) != 2 {
605 t.Fatalf("TemporaryAppRows = %+v, want the two temporary rows", rows)
606 }
607}
608```
609
610- [ ] **Step 2: Run the tests to verify they fail**
611
612Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryColumnsSurviveTheAppsRebuild|TestCreateTemporaryAppInClaimsWithTheExpiry|TestTemporaryConditionalUpdatesLoseCleanly|TestTrashAppRowSetsTheWindowOnlyForTemporaryRows|TestLiveTemporaryCountsARestoringRow|TestRestoreStepsOnTheRow|TestEffectivePurgeAt|TestPurgeDueAndTemporaryRows' -count=1`
613Expected: FAIL to compile: `undefined: AppLife`, `s.CreateTemporaryAppIn undefined`, and so on.
614
615- [ ] **Step 3: Implement**
616
617In `internal/console/store.go`, append to the ALTER list after line 386 (`build_probe`):
618
619```go
620 // Temporary apps (temporary static sites spec §2). The lifecycle lives
621 // HERE, never in the bucket, so bucket write access cannot make an app
622 // temporary. expires_at 0 = permanent. restoring 1 = between a
623 // restore's "about to delete the marker" and "row is live" (§6), and
624 // counts against the cap. purge_after/purge_for are fixed at trash
625 // time from whether the row was temporary then; nothing re-derives
626 // them.
627 `ALTER TABLE apps ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;`,
628 `ALTER TABLE apps ADD COLUMN restoring INTEGER NOT NULL DEFAULT 0;`,
629 `ALTER TABLE apps ADD COLUMN purge_after INTEGER NOT NULL DEFAULT 0;`,
630 `ALTER TABLE apps ADD COLUMN purge_for INTEGER NOT NULL DEFAULT 0;`,
631```
632
633In `migrateAppsKey` (store.go:481-497), add the four columns to `apps_new` and to the copy. The ALTERs above run first, so the old table has them:
634
635```go
636 `CREATE TABLE apps_new (
637 app TEXT NOT NULL,
638 account_id INTEGER NOT NULL DEFAULT 1,
639 created_by TEXT NOT NULL,
640 created_at INTEGER NOT NULL,
641 deleted_at INTEGER NOT NULL DEFAULT 0,
642 placement TEXT NOT NULL DEFAULT '',
643 control_plane INTEGER NOT NULL DEFAULT 0,
644 build_probe TEXT NOT NULL DEFAULT '',
645 expires_at INTEGER NOT NULL DEFAULT 0,
646 restoring INTEGER NOT NULL DEFAULT 0,
647 purge_after INTEGER NOT NULL DEFAULT 0,
648 purge_for INTEGER NOT NULL DEFAULT 0,
649 PRIMARY KEY (account_id, app)
650);`,
651 `INSERT INTO apps_new (app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, expires_at, restoring, purge_after, purge_for)
652 SELECT app, account_id, created_by, created_at, deleted_at, placement, control_plane, build_probe, expires_at, restoring, purge_after, purge_for FROM apps;`,
653```
654
655Replace `TrashAppRow` and `RestoreAppRow` (store.go:1456-1476):
656
657```go
658// TrashAppRow marks an app trashed. Trashing twice keeps the FIRST timestamp
659// (guarded by `deleted_at = 0`) — the cooloff clock starts once, and a second
660// trash call (racing UI, a retried request) must not push the purge deadline
661// out again.
662//
663// now is the MARKER's clock at every caller. A temporary row (expires_at > 0)
664// also gets its early-purge window in the same statement: purge_for is the
665// marker's deleted_at and purge_after is 3 days later (temporary static sites
666// spec §5). The window is bound to the marker's clock and to whether the row
667// was temporary when the marker was found; a permanent row gets 0 for both.
668func (s *Store) TrashAppRow(accountID int64, app string, now time.Time) error {
669 at := now.Unix()
670 _, err := s.db.Exec(`UPDATE apps SET deleted_at = ?,
671 purge_for = CASE WHEN expires_at > 0 THEN ? ELSE 0 END,
672 purge_after = CASE WHEN expires_at > 0 THEN ? ELSE 0 END
673WHERE account_id = ? AND app = ? AND deleted_at = 0`,
674 at, at, at+temporaryCooloffSeconds, accountID, app)
675 return err
676}
677
678// RestoreAppRow un-trashes an app and clears everything that only means
679// something while trashed (restoring, the early-purge window). Restoring
680// something that was never trashed is harmless.
681func (s *Store) RestoreAppRow(accountID int64, app string) error {
682 _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0
683WHERE account_id = ? AND app = ?`, accountID, app)
684 return err
685}
686```
687
688Replace `AppRow` and `AppRows` (store.go:1570-1606):
689
690```go
691type AppRow struct {
692 AccountID int64
693 Sqid string
694 App string
695 Deleted bool // trashed here (deleted_at != 0), not purged
696 // The lifecycle columns (temporary static sites spec §2), Unix seconds.
697 DeletedAt int64
698 ExpiresAt int64 // 0 = permanent
699 Restoring bool
700 PurgeAfter int64 // 0 = no early purge
701 PurgeFor int64
702}
703
704// appRowCols is the SELECT list every AppRow query uses, in scanAppRows'
705// order: p is apps, a is accounts.
706const appRowCols = `p.account_id, a.sqid, p.app, p.deleted_at, p.expires_at, p.restoring, p.purge_after, p.purge_for`
707
708func scanAppRows(rows *sql.Rows) ([]AppRow, error) {
709 defer rows.Close()
710 var out []AppRow
711 for rows.Next() {
712 var r AppRow
713 if err := rows.Scan(&r.AccountID, &r.Sqid, &r.App, &r.DeletedAt, &r.ExpiresAt, &r.Restoring, &r.PurgeAfter, &r.PurgeFor); err != nil {
714 return nil, err
715 }
716 r.Deleted = r.DeletedAt != 0
717 out = append(out, r)
718 }
719 return out, rows.Err()
720}
721
722func (s *Store) AppRows() ([]AppRow, error) {
723 rows, err := s.db.Query(`SELECT ` + appRowCols + `
724FROM apps p JOIN accounts a ON a.id = p.account_id
725WHERE a.deleted_at = 0 ORDER BY a.sqid, p.app`)
726 if err != nil {
727 return nil, err
728 }
729 return scanAppRows(rows)
730}
731```
732
733(Keep `AppRows`' existing doc comment.)
734
735Create `internal/console/tempstore.go`:
736
737```go
738package console
739
740import (
741 "database/sql"
742 "errors"
743 "time"
744
745 "github.com/carlosframework/platform/internal/release"
746)
747
748const (
749 // temporaryTTLDefault is a temporary app's life when nobody says
750 // otherwise, and what a restore gives back to one whose expiry is under
751 // temporaryRestoreFloor away (spec §1 ruling 3, §6 step 2).
752 temporaryTTLDefault = 7 * 24 * time.Hour
753 temporaryRestoreFloor = time.Hour
754 // temporaryCooloffSeconds is release.TemporaryCooloff as the Unix-second
755 // offset the row stores.
756 temporaryCooloffSeconds = int64(release.TemporaryCooloff / time.Second)
757)
758
759// AppLife is one apps row's lifecycle columns, read together because every
760// temporary-app decision needs them together. Times are Unix seconds.
761type AppLife struct {
762 AccountID int64
763 App string
764 CreatedAt int64
765 DeletedAt int64 // 0 = live
766 ExpiresAt int64 // 0 = permanent
767 Restoring bool
768 PurgeAfter int64 // 0 = no early purge
769 PurgeFor int64 // the marker deleted_at purge_after was computed from
770 ControlPlane bool
771}
772
773func (l *AppLife) Temporary() bool { return l.ExpiresAt > 0 }
774func (l *AppLife) Trashed() bool { return l.DeletedAt != 0 }
775
776const appLifeCols = `account_id, app, created_at, deleted_at, expires_at, restoring, purge_after, purge_for, control_plane`
777
778func scanAppLife(sc interface{ Scan(...any) error }) (AppLife, error) {
779 var l AppLife
780 err := sc.Scan(&l.AccountID, &l.App, &l.CreatedAt, &l.DeletedAt, &l.ExpiresAt, &l.Restoring,
781 &l.PurgeAfter, &l.PurgeFor, &l.ControlPlane)
782 return l, err
783}
784
785// AppLife reads one row's lifecycle. (nil, nil) when there is no row.
786func (s *Store) AppLife(accountID int64, app string) (*AppLife, error) {
787 l, err := scanAppLife(s.db.QueryRow(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND app = ?`, accountID, app))
788 if errors.Is(err, sql.ErrNoRows) {
789 return nil, nil
790 }
791 if err != nil {
792 return nil, err
793 }
794 return &l, nil
795}
796
797func (s *Store) appLives(query string, args ...any) ([]AppLife, error) {
798 rows, err := s.db.Query(query, args...)
799 if err != nil {
800 return nil, err
801 }
802 defer rows.Close()
803 var out []AppLife
804 for rows.Next() {
805 l, err := scanAppLife(rows)
806 if err != nil {
807 return nil, err
808 }
809 out = append(out, l)
810 }
811 return out, rows.Err()
812}
813
814// AppLivesIn is every row in the account, trashed included, by name: the
815// input to GET /api/cli/apps/{account}/list (spec §7).
816func (s *Store) AppLivesIn(accountID int64) ([]AppLife, error) {
817 return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? ORDER BY app`, accountID)
818}
819
820// liveTemporaryWhere is the cap's definition of a live temporary app (spec
821// §6): a row mid-restore holds its slot, so a new app cannot take it.
822const liveTemporaryWhere = `expires_at > 0 AND (deleted_at = 0 OR restoring = 1)`
823
824// LiveTemporaryApps lists the rows the cap counts, soonest expiry first, so
825// the refusal can say what each one is and when it goes.
826func (s *Store) LiveTemporaryApps(accountID int64) ([]AppLife, error) {
827 return s.appLives(`SELECT `+appLifeCols+` FROM apps WHERE account_id = ? AND `+liveTemporaryWhere+` ORDER BY expires_at, app`, accountID)
828}
829
830// CreateTemporaryAppIn claims a name with its expiry in the SAME insert
831// (spec §3): there is no moment where the row exists permanent, and a racing
832// permanent create of the same name loses the ON CONFLICT.
833func (s *Store) CreateTemporaryAppIn(accountID int64, app, createdBy string, now, expiresAt time.Time) (bool, error) {
834 if expiresAt.Unix() <= 0 {
835 return false, errors.New("console: a temporary app needs an expiry")
836 }
837 res, err := s.db.Exec(`INSERT INTO apps (app, account_id, created_by, created_at, expires_at)
838VALUES (?, ?, ?, ?, ?) ON CONFLICT(account_id, app) DO NOTHING`,
839 app, accountID, createdBy, now.Unix(), expiresAt.Unix())
840 if err != nil {
841 return false, err
842 }
843 n, err := res.RowsAffected()
844 return n > 0, err
845}
846
847// changed runs one conditional UPDATE and reports whether it won. Every
848// transition below is one of these: a guard in the WHERE clause, and false
849// when a concurrent transition got there first.
850func (s *Store) changed(query string, args ...any) (bool, error) {
851 res, err := s.db.Exec(query, args...)
852 if err != nil {
853 return false, err
854 }
855 n, err := res.RowsAffected()
856 return n > 0, err
857}
858
859// ExpireAppRow is the expiry pass's step 4 (spec §5): the marker's clock
860// becomes deleted_at and purge_for, and purge_after is 3 days later, guarded
861// by expires_at > 0 AND deleted_at = 0 so a keep that won the lock first
862// (expires_at = 0) makes this a no-op.
863func (s *Store) ExpireAppRow(accountID int64, app string, deletedAt time.Time) (bool, error) {
864 at := deletedAt.Unix()
865 return s.changed(`UPDATE apps SET deleted_at = ?, purge_for = ?, purge_after = ?
866WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0`,
867 at, at, at+temporaryCooloffSeconds, accountID, app)
868}
869
870// KeepApp makes a live temporary app permanent (`carlos apps keep`).
871func (s *Store) KeepApp(accountID int64, app string) (bool, error) {
872 return s.changed(`UPDATE apps SET expires_at = 0
873WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`, accountID, app)
874}
875
876// SetAppExpiry moves a live temporary app's expiry (`carlos apps ttl`). It
877// only ever moves a non-zero expiry: there is no door that makes an existing
878// app temporary (spec §3).
879func (s *Store) SetAppExpiry(accountID int64, app string, expiresAt time.Time) (bool, error) {
880 return s.changed(`UPDATE apps SET expires_at = ?
881WHERE account_id = ? AND app = ? AND expires_at > 0 AND deleted_at = 0 AND restoring = 0`,
882 expiresAt.Unix(), accountID, app)
883}
884
885// BumpTrashedExpiry is restore step 2 (spec §6): while the row is still
886// trashed, an expiry under an hour away becomes a week away, so the expiry
887// pass never sees an untrashed row with a stale expiry.
888func (s *Store) BumpTrashedExpiry(accountID int64, app string, now time.Time) (bool, error) {
889 return s.changed(`UPDATE apps SET expires_at = ?
890WHERE account_id = ? AND app = ? AND expires_at > 0 AND expires_at < ? AND deleted_at != 0`,
891 now.Add(temporaryTTLDefault).Unix(), accountID, app, now.Add(temporaryRestoreFloor).Unix())
892}
893
894// SetRestoring sets restore step 3's flag (only on a trashed row) or clears
895// it (settleApp, and a failed step 4).
896func (s *Store) SetRestoring(accountID int64, app string, on bool) (bool, error) {
897 if on {
898 return s.changed(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = ? AND deleted_at != 0`, accountID, app)
899 }
900 return s.changed(`UPDATE apps SET restoring = 0 WHERE account_id = ? AND app = ?`, accountID, app)
901}
902
903// FinishRestoreRow is restore step 5 in one statement, with step 2 folded in
904// for the paths that reach it without having done step 2 (a direct-mode
905// restore caught up by materialize or by the expiry pass, spec §5, §6).
906func (s *Store) FinishRestoreRow(accountID int64, app string, now time.Time) error {
907 _, err := s.db.Exec(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0,
908 expires_at = CASE WHEN expires_at > 0 AND expires_at < ? THEN ? ELSE expires_at END
909WHERE account_id = ? AND app = ?`,
910 now.Add(temporaryRestoreFloor).Unix(), now.Add(temporaryTTLDefault).Unix(), accountID, app)
911 return err
912}
913
914// TemporaryAppRows is every temporary row of every live account: the expiry
915// pass's settle sweep and expire candidates. A list taken before the lock is
916// only a hint (spec §2).
917func (s *Store) TemporaryAppRows() ([]AppRow, error) {
918 rows, err := s.db.Query(`SELECT ` + appRowCols + `
919FROM apps p JOIN accounts a ON a.id = p.account_id
920WHERE a.deleted_at = 0 AND p.expires_at > 0 ORDER BY a.sqid, p.app`)
921 if err != nil {
922 return nil, err
923 }
924 return scanAppRows(rows)
925}
926
927// PurgeDueApps is the early-purge candidates: trashed rows whose window has
928// passed. Keyed on purge_after alone, because the decision was fixed at trash
929// time (spec §2).
930func (s *Store) PurgeDueApps(now time.Time) ([]AppRow, error) {
931 rows, err := s.db.Query(`SELECT `+appRowCols+`
932FROM apps p JOIN accounts a ON a.id = p.account_id
933WHERE a.deleted_at = 0 AND p.deleted_at != 0 AND p.purge_after > 0 AND p.purge_after <= ?
934ORDER BY a.sqid, p.app`, now.Unix())
935 if err != nil {
936 return nil, err
937 }
938 return scanAppRows(rows)
939}
940
941// effectivePurgeAt is the one purge deadline every display uses (spec §5,
942// astra 4.2): purge_after when set, else deleted_at + TrashCooloff. Nothing
943// that shows or returns a purge date computes DeletedAt + TrashCooloff itself.
944func effectivePurgeAt(deletedAt, purgeAfter int64) time.Time {
945 if purgeAfter > 0 {
946 return time.Unix(purgeAfter, 0).UTC()
947 }
948 return time.Unix(deletedAt, 0).UTC().Add(release.TrashCooloff)
949}
950```
951
952- [ ] **Step 4: Run the tests to verify they pass**
953
954Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryColumnsSurviveTheAppsRebuild|TestCreateTemporaryAppInClaimsWithTheExpiry|TestTemporaryConditionalUpdatesLoseCleanly|TestTrashAppRowSetsTheWindowOnlyForTemporaryRows|TestLiveTemporaryCountsARestoringRow|TestRestoreStepsOnTheRow|TestEffectivePurgeAt|TestPurgeDueAndTemporaryRows|TestAppsRebuild|TestReopenMigrationIdempotent|TestTrashAppRowRoundTrip|TestPurgeAppRowRefusesLiveRow|TestMaterializeApps' -count=1`
955Expected: PASS.
956
957- [ ] **Step 5: Commit**
958
959```bash
960gofmt -l internal/console
961git add internal/console/store.go internal/console/tempstore.go internal/console/tempstore_test.go
962git commit -m "console: temporary-app columns, claim with expiry, conditional updates
963
964Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
965```
966
967---
968
969### Task 3: Reserve the `tmp-` namespace at every door that creates a name
970
971**Files:**
972- Modify: `internal/console/server.go:3352-3361` (claim outcomes), `:3460-3591` (`claimApp`), `:3621-3640` (`handleNewApp` switch)
973- Modify: `internal/console/apicli.go:415-470` (`handleAPICreateApp` switch)
974- Modify: `internal/console/appsettings.go:766-770` (rename destination), `:852` (rename undo), `:867-884` (`renameAdvice`)
975- Modify: `internal/console/store.go:515-527` (`Grant`/`GrantAt`), `:658-663` (`BackfillOpsMemberships` insert), `:1751-1806` (`RenameAppIn`)
976- Modify: `cli.go:90-92` (`runAdd`), `cli.go:1286` (next to `addRail`), `apps.go:469-471` (`runAppsCreate`)
977- Test: `internal/console/tempnames_test.go` (new), `cli_test.go`
978
979**Interfaces:**
980- Consumes: `release.IsTemporaryName`, `release.TemporaryAppPrefix` (Task 1); `Store.CreateTemporaryAppIn` (Task 2).
981- Produces:
982 - `claimTemporaryReserved` (new `claimOutcome`)
983 - `func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, expiresAt time.Time) (outcome claimOutcome, placed string, held *apexHold)`; `claimApp` is now `claimAppWith(..., time.Time{})`
984 - `var ErrTemporaryNameReserved error` (console)
985 - `func (s *Store) UndoRenameAppIn(accountID int64, renamed, original string) error`
986 - `func appRail(app string) error` (package main)
987
988- [ ] **Step 1: Write the failing tests**
989
990```go
991// internal/console/tempnames_test.go
992package console
993
994import (
995 "bytes"
996 "context"
997 "errors"
998 "log"
999 "net/http"
1000 "net/url"
1001 "os"
1002 "strings"
1003 "testing"
1004 "time"
1005)
1006
1007func TestPermanentClaimsRefuseTheTmpNamespace(t *testing.T) {
1008 fx := newConsoleFixture(t)
1009 acct := opsAccount(t, fx)
1010 if got, _, _ := fx.srv.claimApp(context.Background(), acct, "tmp-docs", "paul@keymail.dev", "", true); got != claimTemporaryReserved {
1011 t.Fatalf("claimApp(tmp-docs) = %v, want claimTemporaryReserved", got)
1012 }
1013 // And a temporary claim refuses anything outside it.
1014 if got, _, _ := fx.srv.claimAppWith(context.Background(), acct, "docs", "paul@keymail.dev", "", false, time.Now().Add(time.Hour)); got != claimInvalid {
1015 t.Fatalf("temporary claimAppWith(docs) = %v, want claimInvalid", got)
1016 }
1017
1018 // The CLI door.
1019 token := cliToken(t, fx, "paul@keymail.dev", "code-tmp-new")
1020 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "tmp-docs"}, token)
1021 if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "tmp-") {
1022 t.Fatalf("/new tmp-docs = %d %v, want 409 naming the tmp- reservation", status, out)
1023 }
1024
1025 // The browser door.
1026 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-tmp-form")
1027 status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"tmp-docs"}})
1028 if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
1029 t.Fatalf("browser create tmp-docs = %d -> %q, want 303 back to the form", status, loc)
1030 }
1031 if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "tmp-") {
1032 t.Fatal("browser create tmp-docs: the flash does not name the tmp- reservation")
1033 }
1034 if accts, _ := fx.st.AppAccounts("tmp-docs"); len(accts) != 0 {
1035 t.Fatalf("a permanent door created tmp-docs in %v", accts)
1036 }
1037}
1038
1039func TestRenameRefusesATmpDestination(t *testing.T) {
1040 fx := newConsoleFixture(t)
1041 if _, err := fx.st.CreateAppIn(OpsAccountID, "draft", "paul@keymail.dev", time.Now()); err != nil {
1042 t.Fatal(err)
1043 }
1044 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-tmp-rename")
1045 status, _, body := postForm(t, c, fx.u("/apps/draft/rename"), url.Values{"csrf": {csrf}, "to": {"tmp-draft"}})
1046 if status != http.StatusUnprocessableEntity || !strings.Contains(body, "tmp-") {
1047 t.Fatalf("rename to tmp-draft = %d, want 422 naming the reservation", status)
1048 }
1049 if err := fx.st.RenameAppIn(OpsAccountID, "draft", "tmp-draft"); !errors.Is(err, ErrTemporaryNameReserved) {
1050 t.Fatalf("RenameAppIn onto tmp- = %v, want ErrTemporaryNameReserved", err)
1051 }
1052 // The undo of a kept tmp- app's rename is the one way back into the
1053 // namespace, and only onto the name it came from.
1054 if _, err := fx.st.CreateAppIn(OpsAccountID, "tmp-kept", "paul@keymail.dev", time.Now()); err != nil {
1055 t.Fatal(err)
1056 }
1057 if err := fx.st.RenameAppIn(OpsAccountID, "tmp-kept", "kept"); err != nil {
1058 t.Fatal(err)
1059 }
1060 if err := fx.st.UndoRenameAppIn(OpsAccountID, "kept", "tmp-kept"); err != nil {
1061 t.Fatalf("UndoRenameAppIn = %v, want the undo to land", err)
1062 }
1063}
1064
1065func TestGrantAndBackfillRespectTheTmpNamespace(t *testing.T) {
1066 st := openTestStore(t)
1067 if err := st.Grant("paul@keymail.dev", "tmp-x"); !errors.Is(err, ErrTemporaryNameReserved) {
1068 t.Fatalf("Grant(tmp-x) = %v, want ErrTemporaryNameReserved", err)
1069 }
1070 // A grant written before the reservation existed.
1071 if _, err := st.db.Exec(`INSERT INTO owners (address, app, created_at) VALUES ('paul@keymail.dev', 'tmp-old', 1)`); err != nil {
1072 t.Fatal(err)
1073 }
1074 var logged bytes.Buffer
1075 log.SetOutput(&logged)
1076 t.Cleanup(func() { log.SetOutput(os.Stderr) })
1077 if _, err := st.BackfillOpsMemberships(time.Unix(1700000000, 0)); err != nil {
1078 t.Fatal(err)
1079 }
1080 if has, _ := st.HasApp("tmp-old"); has {
1081 t.Fatal("backfill turned a tmp- grant into a permanent row")
1082 }
1083 if !strings.Contains(logged.String(), "tmp-old") {
1084 t.Fatalf("backfill skipped tmp-old silently; log:\n%s", logged.String())
1085 }
1086}
1087```
1088
1089In `cli_test.go`, next to `TestAddRefusesSidecarSuffixAndKind` (cli_test.go:1151):
1090
1091```go
1092func TestAddRefusesTheTmpNamespace(t *testing.T) {
1093 if err := appRail("tmp-docs"); err == nil {
1094 t.Fatal("carlos add must not route a tmp- app")
1095 }
1096 for _, app := range []string{"", "docs", "tmpdocs"} {
1097 if err := appRail(app); err != nil {
1098 t.Fatalf("appRail(%q) = %v, want nil", app, err)
1099 }
1100 }
1101}
1102
1103// TestAddRefusesTheTmpNamespaceWiredIntoRunAdd proves runAdd calls appRail
1104// before any registry write.
1105func TestAddRefusesTheTmpNamespaceWiredIntoRunAdd(t *testing.T) {
1106 bin := buildCarlos(t)
1107 regPath := filepath.Join(t.TempDir(), "registry.db")
1108 cmd := exec.Command(bin, "add", "-registry", regPath, "-app", "tmp-docs", "-kind", "service", "-addr", "127.0.0.1:9", "svc.example.com")
1109 cmd.Env = carlosTestEnv(t)
1110 cmd.Dir = t.TempDir()
1111 out, err := cmd.CombinedOutput()
1112 if err == nil {
1113 t.Fatalf("carlos add --app tmp-docs: want non-zero exit, got 0\n%s", out)
1114 }
1115 if !strings.Contains(string(out), "tmp-") {
1116 t.Fatalf("refusal does not name the tmp- reservation:\n%s", out)
1117 }
1118 reg, rerr := registry.Open(regPath)
1119 if rerr != nil {
1120 t.Fatal(rerr)
1121 }
1122 defer reg.Close()
1123 if rt, gerr := reg.Get("svc.example.com"); gerr != nil || rt != nil {
1124 t.Fatalf("a refused add still wrote a row: %+v, %v", rt, gerr)
1125 }
1126}
1127```
1128
1129- [ ] **Step 2: Run the tests to verify they fail**
1130
1131Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestPermanentClaimsRefuseTheTmpNamespace|TestRenameRefusesATmpDestination|TestGrantAndBackfillRespectTheTmpNamespace' -count=1`
1132Expected: FAIL to compile: `undefined: claimTemporaryReserved`, `undefined: ErrTemporaryNameReserved`, `fx.srv.claimAppWith undefined`, `fx.st.UndoRenameAppIn undefined`.
1133
1134Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestAddRefusesTheTmpNamespace' -count=1`
1135Expected: FAIL to compile: `undefined: appRail`.
1136
1137- [ ] **Step 3: Implement**
1138
1139`internal/console/server.go`, append to the `claimOutcome` constants (after `claimApexHeld`, line 3360):
1140
1141```go
1142 claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7)
1143```
1144
1145Rename the body of `claimApp` (server.go:3460) to `claimAppWith` and make `claimApp` the permanent wrapper. Only the lines shown change; the rest of the body is as it is today:
1146
1147```go
1148func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool) (outcome claimOutcome, placed string, held *apexHold) {
1149 return s.claimAppWith(ctx, acct, name, addr, placement, ownerClaim, time.Time{})
1150}
1151
1152// claimAppWith is claimApp with a temporary claim's expiry. A zero expiresAt
1153// is the permanent claim every existing door makes, and it refuses a tmp-
1154// name: nothing permanent may be created in that namespace (spec ruling 7).
1155// A non-zero one is /new-temporary's claim: it refuses any other name, runs
1156// with ownerClaim false whatever the caller's role (spec §3, so any object
1157// under the prefix refuses), and inserts the row with its expiry in the same
1158// statement that claims the name.
1159func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, expiresAt time.Time) (outcome claimOutcome, placed string, held *apexHold) {
1160 if !release.ValidApp(name) {
1161 return claimInvalid, "", nil
1162 }
1163 if name == "new" {
1164 return claimReserved, "", nil
1165 }
1166 temporary := !expiresAt.IsZero()
1167 if release.IsTemporaryName(name) != temporary {
1168 if temporary {
1169 return claimInvalid, "", nil
1170 }
1171 return claimTemporaryReserved, "", nil
1172 }
1173 if temporary {
1174 ownerClaim = false
1175 }
1176 // ... unchanged from "Taken means taken IN THIS ACCOUNT" (line 3471)
1177 // down to the insert, which becomes:
1178 var created bool
1179 var err error
1180 if temporary {
1181 created, err = s.store.CreateTemporaryAppIn(acct.ID, name, addr, time.Now(), expiresAt)
1182 } else {
1183 created, err = s.store.CreateAppIn(acct.ID, name, addr, time.Now())
1184 }
1185 if err != nil {
1186 return claimCreateFailed, "", nil
1187 }
1188 // ... unchanged from "if !created" to the end.
1189}
1190```
1191
1192(Move `claimApp`'s existing doc comment onto `claimAppWith` and keep a one-line comment on the wrapper.)
1193
1194`handleNewApp` switch (server.go, after `case claimReserved:` at line 3625):
1195
1196```go
1197 case claimTemporaryReserved:
1198 fail("Names starting with tmp- are kept for temporary sites. Choose another name.") // COPY-REVIEW C3.1
1199```
1200
1201`handleAPICreateApp` switch (apicli.go, after `case claimReserved:` at line 420):
1202
1203```go
1204 case claimTemporaryReserved:
1205 jsonError(w, http.StatusConflict,
1206 "names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") // COPY-REVIEW C3.2
1207```
1208
1209`handleAppRename` (appsettings.go), after the `to == "new"` block at lines 766-770:
1210
1211```go
1212 if release.IsTemporaryName(to) {
1213 s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
1214 "Names starting with tmp- are kept for temporary sites. Choose another name.", // COPY-REVIEW C3.1
1215 map[string]string{"rename": r.FormValue("to")})
1216 return
1217 }
1218```
1219
1220and the undo at line 852 becomes `s.store.UndoRenameAppIn(acct.ID, to, app)`. `renameAdvice` (line 867) gains a first case:
1221
1222```go
1223 case errors.Is(err, ErrTemporaryNameReserved):
1224 return "Names starting with tmp- are kept for temporary sites. Choose another name." // COPY-REVIEW C3.1
1225```
1226
1227`internal/console/store.go`: add near the apps section (above `CreateApp`, line 1336):
1228
1229```go
1230// ErrTemporaryNameReserved refuses a permanent name in the tmp- namespace
1231// (temporary static sites spec ruling 7). `console grant` prints it to an
1232// operator, so it is written as a sentence.
1233var ErrTemporaryNameReserved = errors.New("names starting with tmp- belong to temporary apps. A grant, a rename or a permanent claim cannot create one") // COPY-REVIEW C3.3
1234```
1235
1236`GrantAt` (store.go:522) gains a first statement (`Grant` calls it):
1237
1238```go
1239 if release.IsTemporaryName(app) {
1240 return ErrTemporaryNameReserved
1241 }
1242```
1243
1244`BackfillOpsMemberships`: the claiming insert (lines 658-663) gets `AND o.app NOT LIKE 'tmp-%'` in its `WHERE`, and just before `tx.Commit()` (line 698) the skipped grants are reported:
1245
1246```go
1247 if _, err := tx.Exec(`INSERT OR IGNORE INTO apps (app, account_id, created_by, created_at)
1248SELECT o.app, ?, MIN(o.address), ? FROM owners o
1249WHERE o.app NOT IN (SELECT app FROM apps) AND o.app NOT LIKE 'tmp-%'
1250GROUP BY o.app`, OpsAccountID, now.Unix()); err != nil {
1251 return nil, err
1252 }
1253```
1254
1255```go
1256 // A tmp- grant never becomes a row: the namespace is reserved for
1257 // temporary apps, and only /new-temporary may create one (spec §3,
1258 // astra 7.2). Said at every start, because only an operator can revoke it.
1259 skipped, err := tx.Query(`SELECT DISTINCT app FROM owners WHERE app LIKE 'tmp-%' AND app NOT IN (SELECT app FROM apps) ORDER BY app`)
1260 if err != nil {
1261 return nil, err
1262 }
1263 for skipped.Next() {
1264 var app string
1265 if err := skipped.Scan(&app); err != nil {
1266 skipped.Close()
1267 return nil, err
1268 }
1269 log.Printf("console: not claiming %s for ops: names starting with tmp- belong to temporary apps. Revoke the grant with: console revoke <address> %s", app, app)
1270 }
1271 skipped.Close()
1272 if err := skipped.Err(); err != nil {
1273 return nil, err
1274 }
1275```
1276
1277Add `"log"` and `"github.com/carlosframework/platform/internal/release"` to store.go's imports.
1278
1279`RenameAppIn` (store.go:1751) becomes a wrapper; the body moves to `renameAppIn` with one new first check:
1280
1281```go
1282func (s *Store) RenameAppIn(accountID int64, from, to string) error {
1283 return s.renameAppIn(accountID, from, to, false)
1284}
1285
1286// UndoRenameAppIn reverses a rename that just landed (handleAppRename's
1287// post-commit re-check). It is the one path that may rename INTO the tmp-
1288// namespace, because it only ever puts back the name the app already had: a
1289// kept temporary app keeps its tmp- name, and renaming it away and then
1290// undoing must be able to land.
1291func (s *Store) UndoRenameAppIn(accountID int64, renamed, original string) error {
1292 return s.renameAppIn(accountID, renamed, original, true)
1293}
1294
1295func (s *Store) renameAppIn(accountID int64, from, to string, undo bool) error {
1296 if !undo && release.IsTemporaryName(to) {
1297 return ErrTemporaryNameReserved
1298 }
1299 // ... the current RenameAppIn body, unchanged.
1300}
1301```
1302
1303`cli.go`: after the `addRail` call in `runAdd` (lines 90-92):
1304
1305```go
1306 if err := appRail(*app); err != nil {
1307 log.Fatalf("add %s: %v", host, err)
1308 }
1309```
1310
1311and next to `addRail` (cli.go:1286):
1312
1313```go
1314// appRail is `carlos add`'s side of the tmp- reservation (temporary static
1315// sites spec §3): a temporary app's routes come only from its instance
1316// records, minted through the console, so a hand-added route may never wear
1317// a tmp- name. Upgrading a box's binary upgrades this writer; there is no
1318// other copy of it.
1319func appRail(app string) error {
1320 if release.IsTemporaryName(app) {
1321 return fmt.Errorf("--app %s: names starting with tmp- belong to temporary apps, which get their routes from the console. carlos add cannot route one", app) // COPY-REVIEW C3.4
1322 }
1323 return nil
1324}
1325```
1326
1327`apps.go`, in `runAppsCreate` after `validAppName` (line 469-471), so a refused name costs no create budget:
1328
1329```go
1330 if release.IsTemporaryName(strings.TrimSpace(*app)) {
1331 log.Fatal("apps create: names starting with tmp- are kept for temporary sites. Use carlos deploy --temporary, or choose another name") // COPY-REVIEW C3.2
1332 }
1333```
1334
1335- [ ] **Step 4: Run the tests to verify they pass**
1336
1337Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestPermanentClaimsRefuseTheTmpNamespace|TestRenameRefusesATmpDestination|TestGrantAndBackfillRespectTheTmpNamespace|TestOwnerClaim|TestBrowserDoor|TestCreateApp|TestAppRename|TestRename|TestBackfill|TestOwnersRoundTrip' -count=1`
1338Expected: PASS.
1339
1340Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestAddRefuses' -count=1`
1341Expected: PASS.
1342
1343- [ ] **Step 5: Commit**
1344
1345```bash
1346gofmt -l .
1347git add internal/console/server.go internal/console/apicli.go internal/console/appsettings.go internal/console/store.go internal/console/tempnames_test.go cli.go cli_test.go apps.go
1348git commit -m "console, cli: reserve the tmp- namespace at every door that creates a name
1349
1350Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1351```
1352
1353---
1354### Task 4: Per-app locks, the shared tripwire, `settleApp`, and materialize's temporary window
1355
1356**Files:**
1357- Create: `internal/console/applocks.go`, `internal/console/settle.go`
1358- Modify: `internal/console/server.go:879` (two Server fields after `relayAppLocks`)
1359- Modify: `internal/console/materialize.go:311-325` (tripwire), `:352-381` (marker and restore cases)
1360- Test: `internal/console/settle_test.go` (new; its materialize tests sit there beside the helpers they use). Step 4 also runs the existing `materialize_test.go`, unedited.
1361
1362**Interfaces:**
1363- Consumes: `Store.AppLife`, `Store.TrashAppRow` (window), `Store.SetRestoring`, `Store.FinishRestoreRow`, `Store.PurgeAppRow`, `Store.CreateTemporaryAppIn`, `AppRow.Restoring` (Task 2); `release.ReadAppTombstone`, `release.AppTombstoneKey`, `release.AppPrefix`.
1364- Produces:
1365 - `type keyedLocks struct` (zero value ready) with `func (l *keyedLocks) lock(key string) func()`
1366 - Server fields `appLocks keyedLocks`, `acctLocks keyedLocks`
1367 - `func (s *Server) lockAccount(sqid string) func()` and `func (s *Server) lockApp(sqid, app string) func()`
1368 - `type evidenceFunc func(ctx context.Context) bool`
1369 - `func isTrashMarkerKey(key string) bool`, `func bucketEvidence(keys []string) bool`
1370 - `func (s *Server) listEvidence(ctx context.Context) (keys []string, listed, evidence bool)`
1371 - `func (s *Server) doorEvidence(ctx context.Context) bool`
1372 - `func (s *Server) settleApp(ctx context.Context, accountID int64, sqid, app string, now time.Time, evidence evidenceFunc) error`
1373 - test helpers (settle_test.go, reused by Tasks 5-10): `tempApp(t, fx, app string, expires time.Time)`, `lifeOf(t, fx, app string) *AppLife`, `markerOf(t, fx, app string) *release.AppTombstone`, `always(bool) evidenceFunc`
1374
1375- [ ] **Step 1: Write the failing tests**
1376
1377```go
1378// internal/console/settle_test.go
1379package console
1380
1381import (
1382 "bytes"
1383 "context"
1384 "log"
1385 "os"
1386 "strings"
1387 "sync"
1388 "testing"
1389 "time"
1390
1391 "github.com/carlosframework/platform/internal/domains"
1392 "github.com/carlosframework/platform/internal/release"
1393)
1394
1395// tempApp seeds a temporary row in the fixture's ops account and one object
1396// under its prefix (a shipped site, as far as any listing can tell).
1397func tempApp(t *testing.T, fx *consoleFixture, app string, expires time.Time) {
1398 t.Helper()
1399 if ok, err := fx.st.CreateTemporaryAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now(), expires); err != nil || !ok {
1400 t.Fatalf("CreateTemporaryAppIn(%s) = %v %v", app, ok, err)
1401 }
1402 if _, err := fx.rel.Put(context.Background(), "apps/"+fx.sqid+"/"+app+"/channels/edge.json", []byte(`{}`), ""); err != nil {
1403 t.Fatal(err)
1404 }
1405}
1406
1407func lifeOf(t *testing.T, fx *consoleFixture, app string) *AppLife {
1408 t.Helper()
1409 l, err := fx.st.AppLife(OpsAccountID, app)
1410 if err != nil {
1411 t.Fatal(err)
1412 }
1413 return l
1414}
1415
1416func markerOf(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone {
1417 t.Helper()
1418 tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app)
1419 if err != nil {
1420 t.Fatal(err)
1421 }
1422 return tomb
1423}
1424
1425func always(v bool) evidenceFunc { return func(context.Context) bool { return v } }
1426
1427func TestKeyedLocksSerialiseOneKeyOnly(t *testing.T) {
1428 var l keyedLocks
1429 unlockA := l.lock("bab/tmp-a")
1430 other := make(chan struct{})
1431 go func() { l.lock("bab/tmp-b")(); close(other) }()
1432 select {
1433 case <-other:
1434 case <-time.After(time.Second):
1435 t.Fatal("a different key waited on tmp-a's lock")
1436 }
1437 var wg sync.WaitGroup
1438 got := make(chan struct{})
1439 wg.Add(1)
1440 go func() { defer wg.Done(); l.lock("bab/tmp-a")(); close(got) }()
1441 select {
1442 case <-got:
1443 t.Fatal("a second holder of tmp-a's lock did not wait")
1444 case <-time.After(50 * time.Millisecond):
1445 }
1446 unlockA()
1447 wg.Wait()
1448 if len(l.m) != 0 {
1449 t.Fatalf("lock entries left behind: %v", l.m)
1450 }
1451}
1452
1453func TestBucketEvidenceIgnoresTrashMarkers(t *testing.T) {
1454 if bucketEvidence(nil) {
1455 t.Error("an empty listing is not evidence")
1456 }
1457 if bucketEvidence([]string{"apps/bab/tmp-a/deleted.json", "apps/bab/x/deleted.json"}) {
1458 t.Error("a listing of trash markers alone is not evidence (spec §2, astra 5.2)")
1459 }
1460 if !bucketEvidence([]string{"apps/bab/tmp-a/deleted.json", "apps/bab/kass/channels/stable.json"}) {
1461 t.Error("one real key is evidence")
1462 }
1463 if isTrashMarkerKey("apps/bab/tmp-a/sub/deleted.json") {
1464 t.Error("a deleted.json below the app prefix is not the marker")
1465 }
1466}
1467
1468// Spec §9: settleApp, each marker/row/restoring combination.
1469func TestSettleAppEachCombination(t *testing.T) {
1470 ctx := context.Background()
1471 now := time.Now().Truncate(time.Second)
1472 at := now.Add(-time.Hour)
1473
1474 t.Run("live row, marker present: trashed with the marker's clock and the 3-day window", func(t *testing.T) {
1475 fx := newConsoleFixture(t)
1476 tempApp(t, fx, "tmp-a", now.Add(29*24*time.Hour))
1477 release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "paul@keymail.dev", at)
1478 if err := fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err != nil {
1479 t.Fatal(err)
1480 }
1481 l := lifeOf(t, fx, "tmp-a")
1482 if l.DeletedAt != at.Unix() || l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 {
1483 t.Fatalf("row = %+v", l)
1484 }
1485 })
1486 t.Run("live permanent row, marker present: trashed, no early purge", func(t *testing.T) {
1487 fx := newConsoleFixture(t)
1488 release.TrashApp(ctx, fx.rel, fx.sqid, "kass", "paul@keymail.dev", at)
1489 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "kass", now, always(true))
1490 if l := lifeOf(t, fx, "kass"); l.DeletedAt != at.Unix() || l.PurgeAfter != 0 {
1491 t.Fatalf("row = %+v", l)
1492 }
1493 })
1494 t.Run("restoring, marker present: step 4 failed, slot released, deadline kept", func(t *testing.T) {
1495 fx := newConsoleFixture(t)
1496 tempApp(t, fx, "tmp-a", now.Add(time.Hour))
1497 release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "expiry", at)
1498 fx.st.TrashAppRow(OpsAccountID, "tmp-a", at)
1499 fx.st.SetRestoring(OpsAccountID, "tmp-a", true)
1500 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true))
1501 if l := lifeOf(t, fx, "tmp-a"); l.Restoring || !l.Trashed() || l.PurgeAfter != at.Unix()+72*3600 {
1502 t.Fatalf("row = %+v", l)
1503 }
1504 })
1505 t.Run("restoring, marker gone, objects present: step 5 finished", func(t *testing.T) {
1506 fx := newConsoleFixture(t)
1507 tempApp(t, fx, "tmp-a", now.Add(time.Hour))
1508 fx.st.TrashAppRow(OpsAccountID, "tmp-a", at)
1509 fx.st.SetRestoring(OpsAccountID, "tmp-a", true)
1510 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true))
1511 if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 {
1512 t.Fatalf("row = %+v", l)
1513 }
1514 })
1515 t.Run("trashed, marker gone, objects present: restored elsewhere, expiry bumped", func(t *testing.T) {
1516 fx := newConsoleFixture(t)
1517 tempApp(t, fx, "tmp-a", now.Add(-time.Minute))
1518 fx.st.TrashAppRow(OpsAccountID, "tmp-a", at)
1519 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true))
1520 l := lifeOf(t, fx, "tmp-a")
1521 if l.Trashed() || l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() {
1522 t.Fatalf("row = %+v, want live with expiry now+7d", l)
1523 }
1524 })
1525 t.Run("trashed, no marker, no objects, evidence: purged elsewhere, row freed", func(t *testing.T) {
1526 fx := newConsoleFixture(t)
1527 fx.st.CreateTemporaryAppIn(OpsAccountID, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour))
1528 fx.st.TrashAppRow(OpsAccountID, "tmp-a", at)
1529 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true))
1530 if l := lifeOf(t, fx, "tmp-a"); l != nil {
1531 t.Fatalf("row = %+v, want it gone", l)
1532 }
1533 })
1534 t.Run("trashed, no marker, no objects, no evidence: left exactly as it is", func(t *testing.T) {
1535 fx := newConsoleFixture(t)
1536 fx.st.CreateTemporaryAppIn(OpsAccountID, "tmp-a", "paul@keymail.dev", now, now.Add(time.Hour))
1537 fx.st.TrashAppRow(OpsAccountID, "tmp-a", at)
1538 before := *lifeOf(t, fx, "tmp-a")
1539 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(false))
1540 if l := lifeOf(t, fx, "tmp-a"); l == nil || *l != before {
1541 t.Fatalf("row = %+v, want unchanged %+v", l, before)
1542 }
1543 })
1544 t.Run("live, no marker: nothing to settle", func(t *testing.T) {
1545 fx := newConsoleFixture(t)
1546 tempApp(t, fx, "tmp-a", now.Add(time.Hour))
1547 before := *lifeOf(t, fx, "tmp-a")
1548 fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true))
1549 if l := lifeOf(t, fx, "tmp-a"); *l != before {
1550 t.Fatalf("row = %+v, want unchanged", l)
1551 }
1552 })
1553 t.Run("unreadable marker: an error, and nothing changes", func(t *testing.T) {
1554 fx := newConsoleFixture(t)
1555 tempApp(t, fx, "tmp-a", now.Add(time.Hour))
1556 fx.rel.Put(ctx, release.AppTombstoneKey(fx.sqid, "tmp-a"), []byte("{"), "")
1557 before := *lifeOf(t, fx, "tmp-a")
1558 if err := fx.srv.settleApp(ctx, OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err == nil {
1559 t.Fatal("settleApp believed an unparseable marker")
1560 }
1561 if l := lifeOf(t, fx, "tmp-a"); *l != before {
1562 t.Fatalf("row = %+v, want unchanged", l)
1563 }
1564 })
1565}
1566
1567// Spec §9 "Materialize": a marker found for a temporary row sets the 3-day
1568// window from the marker's clock; for a permanent row, leaves it 0.
1569func TestMaterializeSetsTheTemporaryWindowFromTheMarker(t *testing.T) {
1570 st := openTestStore(t)
1571 rel := release.NewDirStore(t.TempDir())
1572 ctx := t.Context()
1573 now := time.Unix(1700000000, 0).UTC()
1574 id, sqid, err := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now)
1575 if err != nil {
1576 t.Fatal(err)
1577 }
1578 MaterializeAccounts(ctx, rel, st)
1579 st.CreateTemporaryAppIn(id, "tmp-a", "paul@keymail.dev", now, now.Add(30*24*time.Hour))
1580 st.CreateAppIn(id, "perm", "paul@keymail.dev", now)
1581 at := now.Add(time.Hour)
1582 for _, app := range []string{"tmp-a", "perm"} {
1583 rel.Put(ctx, "apps/"+sqid+"/"+app+"/channels/edge.json", []byte(`{}`), "")
1584 if err := release.TrashApp(ctx, rel, sqid, app, "paul@keymail.dev", at); err != nil {
1585 t.Fatal(err)
1586 }
1587 }
1588 if err := MaterializeApps(ctx, rel, st); err != nil {
1589 t.Fatal(err)
1590 }
1591 if l, _ := st.AppLife(id, "tmp-a"); l.PurgeFor != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 {
1592 t.Fatalf("temporary row = %+v, want the window from the marker's clock", l)
1593 }
1594 if l, _ := st.AppLife(id, "perm"); l.PurgeAfter != 0 || l.PurgeFor != 0 {
1595 t.Fatalf("permanent row = %+v, want no window", l)
1596 }
1597}
1598
1599// The tripwire ignores trash markers: a bucket holding nothing else is not
1600// evidence that anything was purged.
1601func TestMaterializeHoldsPurgesOnABucketOfMarkers(t *testing.T) {
1602 st := openTestStore(t)
1603 rel := release.NewDirStore(t.TempDir())
1604 ctx := t.Context()
1605 now := time.Unix(1700000000, 0).UTC()
1606 id, sqid, _ := domains.MintAccount(ctx, rel, "Testco", "paul@keymail.dev", now)
1607 MaterializeAccounts(ctx, rel, st)
1608 st.CreateAppIn(id, "gamma", "paul@keymail.dev", now)
1609 st.TrashAppRow(id, "gamma", now)
1610 release.TrashApp(ctx, rel, sqid, "somebody-else", "paul@keymail.dev", now)
1611 var logged bytes.Buffer
1612 log.SetOutput(&logged)
1613 t.Cleanup(func() { log.SetOutput(os.Stderr) })
1614 if err := MaterializeApps(ctx, rel, st); err != nil {
1615 t.Fatal(err)
1616 }
1617 if trashed, _ := st.HasTrashedAppIn(id, "gamma"); !trashed {
1618 t.Fatal("a bucket holding only trash markers released a held name")
1619 }
1620 if !strings.Contains(logged.String(), "CARLOS_DEPLOYMENT_BUCKET") {
1621 t.Fatalf("the hold was silent:\n%s", logged.String())
1622 }
1623}
1624```
1625
1626- [ ] **Step 2: Run the tests to verify they fail**
1627
1628Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeyedLocksSerialiseOneKeyOnly|TestBucketEvidenceIgnoresTrashMarkers|TestSettleAppEachCombination|TestMaterializeSetsTheTemporaryWindowFromTheMarker|TestMaterializeHoldsPurgesOnABucketOfMarkers' -count=1`
1629Expected: FAIL to compile: `undefined: keyedLocks`, `undefined: evidenceFunc`, `undefined: bucketEvidence`, `fx.srv.settleApp undefined`.
1630
1631- [ ] **Step 3: Implement**
1632
1633Create `internal/console/applocks.go`:
1634
1635```go
1636package console
1637
1638import "sync"
1639
1640// keyedLocks is a refcounted mutex per string key: storelock.go's shape with
1641// the key built by the caller. Entries drop at zero references, so an app
1642// locked once does not hold a mutex for the life of the process. The zero
1643// value is ready, which keeps &Server{} literals in tests safe.
1644//
1645// IN-PROCESS ONLY, like storeWalkLocks: temporary static sites spec §2 assumes
1646// one console per deployment bucket, which is how every deployment runs.
1647type keyedLocks struct {
1648 mu sync.Mutex
1649 m map[string]*keyedLock
1650}
1651
1652type keyedLock struct {
1653 mu sync.Mutex
1654 refs int
1655}
1656
1657// lock blocks until key's mutex is held and returns the release:
1658//
1659// defer s.lockApp(acct.Sqid, app)()
1660func (l *keyedLocks) lock(key string) func() {
1661 l.mu.Lock()
1662 if l.m == nil {
1663 l.m = make(map[string]*keyedLock)
1664 }
1665 e := l.m[key]
1666 if e == nil {
1667 e = &keyedLock{}
1668 l.m[key] = e
1669 }
1670 e.refs++
1671 l.mu.Unlock()
1672
1673 e.mu.Lock()
1674 return func() {
1675 e.mu.Unlock()
1676 l.mu.Lock()
1677 e.refs--
1678 if e.refs == 0 {
1679 delete(l.m, key)
1680 }
1681 l.mu.Unlock()
1682 }
1683}
1684
1685// lockAccount serialises counting temporary apps against creating or
1686// restoring one, which is what makes the cap exact (spec §3, §6).
1687//
1688// LOCK ORDER: account before app, never the other way round. Only
1689// /new-temporary and restoreApp take this lock.
1690func (s *Server) lockAccount(sqid string) func() { return s.acctLocks.lock(sqid) }
1691
1692// lockApp is the per-app mutex every lifecycle transition takes: keep, ttl,
1693// restore, trash, the purge door and each step of the expiry pass (spec §5).
1694func (s *Server) lockApp(sqid, app string) func() { return s.appLocks.lock(sqid + "/" + app) }
1695```
1696
1697In `internal/console/server.go`, after `relayAppLocks sync.Map` (line 879):
1698
1699```go
1700 // appLocks and acctLocks are the temporary-app lifecycle locks
1701 // (applocks.go). Zero values are ready.
1702 appLocks keyedLocks
1703 acctLocks keyedLocks
1704```
1705
1706Create `internal/console/settle.go`:
1707
1708```go
1709package console
1710
1711import (
1712 "context"
1713 "log"
1714 "strings"
1715 "time"
1716
1717 "github.com/carlosframework/platform/internal/release"
1718)
1719
1720// evidenceFunc answers the empty-bucket tripwire (temporary static sites spec
1721// §2): does the apps/ listing hold at least one key that is not a trash
1722// marker? Every branch that reasons from absence to "the purge finished" asks
1723// it first, and an empty or failed listing is "no evidence".
1724type evidenceFunc func(ctx context.Context) bool
1725
1726// isTrashMarkerKey reports whether key is some app's trash marker,
1727// apps/<sqid>/<app>/deleted.json exactly.
1728func isTrashMarkerKey(key string) bool {
1729 parts := strings.SplitN(key, "/", 4)
1730 return len(parts) == 4 && parts[0] == "apps" && key == release.AppTombstoneKey(parts[1], parts[2])
1731}
1732
1733// bucketEvidence is the tripwire over a listing already taken. Markers do not
1734// count: they are the one thing this feature writes into a bucket that holds
1735// nothing else, so a wrong, empty bucket would otherwise vouch for itself
1736// with the pass's own markers (astra 5.2).
1737func bucketEvidence(keys []string) bool {
1738 for _, key := range keys {
1739 if !isTrashMarkerKey(key) {
1740 return true
1741 }
1742 }
1743 return false
1744}
1745
1746// listEvidence lists apps/ once. listed is false when the listing failed;
1747// evidence is the tripwire's answer, false for a failed listing too.
1748func (s *Server) listEvidence(ctx context.Context) (keys []string, listed, evidence bool) {
1749 if s.cfg.Releases == nil {
1750 return nil, false, false
1751 }
1752 keys, err := s.cfg.Releases.List(ctx, "apps/")
1753 if err != nil {
1754 log.Printf("console: list apps/ for the empty-bucket tripwire: %v", err)
1755 return nil, false, false
1756 }
1757 return keys, true, bucketEvidence(keys)
1758}
1759
1760// doorEvidence is the evidenceFunc a door passes to settleApp: it lists only
1761// if settleApp actually reaches an absence branch.
1762func (s *Server) doorEvidence(ctx context.Context) bool {
1763 _, _, ev := s.listEvidence(ctx)
1764 return ev
1765}
1766
1767// settleApp brings one app's row into line with its trash marker before any
1768// transition acts (spec §2 "Every transition settles first", §6). It is
1769// MaterializeApps for one app, plus the restoring cases. The CALLER holds
1770// s.lockApp(sqid, app), and re-reads the row afterwards: what it listed
1771// before the lock is only a hint.
1772//
1773// marker, row live -> trashed with the marker's clock (and the
1774// 3-day window if temporary, TrashAppRow)
1775// marker, row restoring -> restore step 4 failed: restoring = 0, the
1776// app stays trashed with its deadline
1777// no marker, row trashed, keys -> restored elsewhere, or step 5 failed:
1778// FinishRestoreRow
1779// no marker, row trashed, none -> purged elsewhere: PurgeAppRow, only if
1780// the tripwire finds evidence
1781// no marker, row live -> nothing
1782//
1783// An unreadable marker is an error and changes nothing.
1784func (s *Server) settleApp(ctx context.Context, accountID int64, sqid, app string, now time.Time, evidence evidenceFunc) error {
1785 if s.cfg.Releases == nil {
1786 return nil
1787 }
1788 life, err := s.store.AppLife(accountID, app)
1789 if err != nil || life == nil {
1790 return err
1791 }
1792 tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, sqid, app)
1793 if err != nil {
1794 return err
1795 }
1796 if tomb != nil {
1797 switch {
1798 case !life.Trashed():
1799 if err := s.store.TrashAppRow(accountID, app, tomb.DeletedAt); err != nil {
1800 return err
1801 }
1802 log.Printf("console: %s/%s is in the trash in the bucket; marking it trashed here", sqid, app)
1803 case life.Restoring:
1804 if _, err := s.store.SetRestoring(accountID, app, false); err != nil {
1805 return err
1806 }
1807 log.Printf("console: %s/%s: a restore did not remove the trash marker; it stays in the trash", sqid, app)
1808 }
1809 return nil
1810 }
1811 if !life.Trashed() {
1812 return nil
1813 }
1814 keys, err := s.cfg.Releases.List(ctx, release.AppPrefix(sqid, app))
1815 if err != nil {
1816 return err
1817 }
1818 if len(keys) > 0 {
1819 if err := s.store.FinishRestoreRow(accountID, app, now); err != nil {
1820 return err
1821 }
1822 log.Printf("console: %s/%s has no trash marker and still has objects; restoring it here", sqid, app)
1823 return nil
1824 }
1825 if !evidence(ctx) {
1826 log.Printf("console: %s/%s has no trash marker and no objects, but the apps/ listing holds nothing else either; "+
1827 "leaving the row. Is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?", sqid, app)
1828 return nil
1829 }
1830 if err := s.store.PurgeAppRow(accountID, app); err != nil {
1831 return err
1832 }
1833 log.Printf("console: %s/%s is gone from the bucket; releasing the name here", sqid, app)
1834 return nil
1835}
1836```
1837
1838In `internal/console/materialize.go`, the tripwire (line 311) becomes the shared helper:
1839
1840```go
1841 purgesHeld := !bucketEvidence(keys)
1842```
1843
1844and its log line (line 322-323) keeps its text but its comment gains: "Trash markers do not count as evidence (bucketEvidence, settle.go): they are the one thing the temporary-app pass writes into a bucket." The marker case (lines 352-371) handles a restoring row, and the restore case (lines 376-381) uses `FinishRestoreRow`:
1845
1846```go
1847 case bs.marker:
1848 tomb, err := release.ReadAppTombstone(ctx, s, r.Sqid, r.App)
1849 if err != nil {
1850 log.Printf("console: leaving %s/%s alone: %v", r.Sqid, r.App, err)
1851 continue
1852 }
1853 if tomb == nil {
1854 continue // restored between the listing and the read
1855 }
1856 if r.Deleted {
1857 // A restore that crashed before deleting the marker: release
1858 // its cap slot; the app stays in the Trash (spec §6).
1859 if r.Restoring {
1860 if _, err := st.SetRestoring(r.AccountID, r.App, false); err != nil {
1861 log.Printf("console: clear restoring on %s/%s: %v", r.Sqid, r.App, err)
1862 }
1863 }
1864 continue
1865 }
1866 // The clock comes from the MARKER. TrashAppRow also sets the
1867 // 3-day window when the row is temporary (spec §5 "Materialize").
1868 if err := st.TrashAppRow(r.AccountID, r.App, tomb.DeletedAt); err != nil {
1869 log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err)
1870 continue
1871 }
1872 log.Printf("console: %s/%s is in the trash in the bucket — marking it trashed here", r.Sqid, r.App)
1873
1874 case !r.Deleted:
1875 continue // (d), and the ordinary live app: no marker, nothing to do
1876
1877 case bs.anyKeys:
1878 // Restored elsewhere (a headless restore), or a console restore
1879 // that failed its last step. A temporary row gets restore step 2
1880 // here too (spec §6).
1881 if err := st.FinishRestoreRow(r.AccountID, r.App, time.Now()); err != nil {
1882 log.Printf("console: restore %s/%s: %v", r.Sqid, r.App, err)
1883 continue
1884 }
1885 log.Printf("console: %s/%s has no trash marker and still has objects — restoring it here", r.Sqid, r.App)
1886```
1887
1888(The two existing log lines keep their current text; they are operator logs this plan does not rewrite.)
1889
1890- [ ] **Step 4: Run the tests to verify they pass**
1891
1892Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeyedLocksSerialiseOneKeyOnly|TestBucketEvidenceIgnoresTrashMarkers|TestSettleAppEachCombination|TestMaterialize' -count=1`
1893Expected: PASS, including the existing `TestMaterializeAppsConverges` and `TestMaterializeAppsRefusesAnEmptyBucket`.
1894
1895- [ ] **Step 5: Commit**
1896
1897```bash
1898gofmt -l internal/console
1899git add internal/console/applocks.go internal/console/settle.go internal/console/server.go internal/console/materialize.go internal/console/settle_test.go
1900git commit -m "console: per-app locks, shared tripwire, settleApp, temporary window in materialize
1901
1902Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1903```
1904
1905---
1906
1907### Task 5: `POST /api/cli/apps/{account}/new-temporary`
1908
1909**Files:**
1910- Create: `internal/console/apitemporary.go`
1911- Modify: `internal/console/server.go:456` (`Config`: add `TemporaryApps`)
1912- Modify: `internal/console/authcli.go:482` (register the route after `/new`)
1913- Modify: `internal/console/authcli.go:111-135` (budget constants), `:325-331` (`cliRates` fields), `:366-368` (constructors); `internal/console/svcprincipal.go:387` (add `allowTemporaryCreate` beside `allowCreate`)
1914- Modify: `cmd/console/main.go:267-300` (Config literal), `:28-68` (doc comment)
1915- Test: `internal/console/apitemporary_test.go` (new)
1916
1917**Interfaces:**
1918- Consumes: `claimAppWith`, `claimTemporaryReserved` (Task 3); `lockAccount` (Task 4); `Store.LiveTemporaryApps`, `Store.AppLife`, `temporaryTTLDefault` (Task 2); `release.IsTemporaryName`, `release.TemporaryAppPrefix`, `release.RouteAccount` (adopt.go:862); `provision.ValidHostShape`; `apiPrincipalAccount`, `auditActor`, `readJSONStrictLimit`, `normalizeAppName`, `apexHeldSentence`, `openRegistry`.
1919- Produces:
1920 - `Config.TemporaryApps bool`
1921 - `const temporaryTTLMin = time.Hour`, `const temporaryTTLMax = 30 * 24 * time.Hour`, `const temporaryAppCap = 20`
1922 - `func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error)` and `var errTTLBounds error`
1923 - `func validTemporaryName(name string) bool`
1924 - `type temporaryCapError struct{ Live []AppLife }` with `Error() string`
1925 - `func (s *Server) temporaryCapCheck(accountID int64) error` (caller holds `lockAccount`)
1926 - `func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error)`
1927 - `func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request)`
1928 - `const cliTemporaryCreateBurst = 50`; `cliRates.tempCreate *rateLimiter`; `func (s *Server) allowTemporaryCreate(p Principal, now time.Time) bool`
1929 - Wire: request `{"name": string, "ttl_seconds": int}` (unknown fields refused); 200 `{"account", "app", "url", "expires_at"}` (RFC 3339); 409 bodies carry `"code": "name_taken"` or `"code": "cap"`; 404 while `CARLOS_TEMPORARY_APPS` is not `1`.
1930
1931**Separate create budget (spec ruling 8).** `/new-temporary` does **not**
1932spend `allowCreate`'s 10-a-day budget (`authcli.go:119`). It has its own
1933budget of 50 per principal per `cliCreateWindow`. It is keyed on
1934`principalKey(p)` for persons and service principals alike, so a key
1935rotation gets no fresh budget, and it lives in a separate limiter, so
1936temporary creates and permanent creates never spend each other's
1937budget. The code for Step 3:
1938
1939```go
1940// authcli.go, beside cliCreateBurst:
1941
1942 // cliTemporaryCreateBurst bounds temporary-app claim ATTEMPTS per
1943 // principal per day (spec ruling 8, Paul 2026-09-26). Its own budget,
1944 // not cliCreateBurst's: the scratchpad use is an agent in a loop, and
1945 // ten a day would refuse it by lunchtime. The live cap (20) bounds
1946 // storage while apps live; this bounds churn, since a trashed
1947 // temporary app still holds storage for its 3-day window, so the
1948 // worst case is about 150 trashed-but-unpurged apps per principal.
1949 cliTemporaryCreateBurst = 50
1950
1951// cliRates, beside svcCreate:
1952 tempCreate *rateLimiter
1953
1954// the constructor at :366, beside create:
1955 tempCreate: newRateLimiter(cliTemporaryCreateBurst, cliCreateWindow),
1956
1957// svcprincipal.go, after allowCreate:
1958
1959// allowTemporaryCreate is /new-temporary's budget: one limiter for both
1960// principal kinds, keyed like allowCreate (principalKey), separate from it
1961// so neither kind of create spends the other's day.
1962func (s *Server) allowTemporaryCreate(p Principal, now time.Time) bool {
1963 return s.cli.tempCreate.allow(principalKey(p), now)
1964}
1965```
1966
1967Tests, added to Step 1's file:
1968
1969```go
1970func TestTemporaryCreateHasOwnBudget(t *testing.T) {
1971 fx, token := temporaryFixture(t)
1972 // Attempts, not successes, spend the budget (cliCreateBurst's rule):
1973 // past the live cap of 20 these are 409s, and they still count.
1974 for i := 0; i < cliTemporaryCreateBurst; i++ {
1975 status, out := createTemporary(t, fx, token, fmt.Sprintf("tmp-b%d", i), 0)
1976 if status == http.StatusTooManyRequests {
1977 t.Fatalf("temporary attempt %d refused by the rate limit: %v", i, out)
1978 }
1979 }
1980 if status, out := createTemporary(t, fx, token, "tmp-over", 0); status != http.StatusTooManyRequests {
1981 t.Fatalf("attempt %d = %d %v, want 429", cliTemporaryCreateBurst+1, status, out)
1982 }
1983 // The permanent budget is untouched by all of that.
1984 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new",
1985 map[string]any{"name": "permanent-one"}, token)
1986 if status == http.StatusTooManyRequests {
1987 t.Fatalf("/new refused after temporary creates: %v; the budgets must be separate", out)
1988 }
1989}
1990```
1991
1992Add `"fmt"` to the test file's imports if Step 1's list lacks it.
1993
1994- [ ] **Step 1: Write the failing tests**
1995
1996```go
1997// internal/console/apitemporary_test.go
1998package console
1999
2000import (
2001 "fmt"
2002 "net/http"
2003 "os"
2004 "path/filepath"
2005 "strings"
2006 "sync"
2007 "testing"
2008 "time"
2009
2010 "github.com/carlosframework/platform/internal/registry"
2011 "github.com/carlosframework/platform/internal/release"
2012 "github.com/carlosframework/platform/internal/svcrole"
2013)
2014
2015func temporaryFixture(t *testing.T) (*consoleFixture, string) {
2016 t.Helper()
2017 fx := newConsoleFixture(t)
2018 fx.srv.cfg.TemporaryApps = true
2019 return fx, cliToken(t, fx, "paul@keymail.dev", "code-"+strings.ReplaceAll(t.Name(), "/", "-"))
2020}
2021
2022func createTemporary(t *testing.T, fx *consoleFixture, token, name string, ttl int64) (int, map[string]any) {
2023 t.Helper()
2024 return postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary",
2025 map[string]any{"name": name, "ttl_seconds": ttl}, token)
2026}
2027
2028func TestTemporaryRouteIsOffUntilSwitchedOn(t *testing.T) {
2029 fx := newConsoleFixture(t)
2030 token := cliToken(t, fx, "paul@keymail.dev", "code-temp-off")
2031 status, h, body := rawReq(t, http.MethodPost, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", token, `{"name":"tmp-docs"}`)
2032 assertNotFound(t, "feature off", canonicalNotFound(t), status, h, body)
2033 if l := lifeOf(t, fx, "tmp-docs"); l != nil {
2034 t.Fatal("the switched-off route created a row")
2035 }
2036}
2037
2038func TestTemporaryCreateSetsTheExpiryInTheClaim(t *testing.T) {
2039 fx, token := temporaryFixture(t)
2040 before := time.Now()
2041 status, out := createTemporary(t, fx, token, "tmp-docs", 0)
2042 if status != http.StatusOK {
2043 t.Fatalf("create = %d %v", status, out)
2044 }
2045 exp, err := time.Parse(time.RFC3339, str(t, out, "expires_at"))
2046 if err != nil {
2047 t.Fatalf("expires_at %q: %v", out["expires_at"], err)
2048 }
2049 if d := exp.Sub(before); d < temporaryTTLDefault-time.Minute || d > temporaryTTLDefault+time.Minute {
2050 t.Fatalf("default expiry is %v away, want 7 days", d)
2051 }
2052 if l := lifeOf(t, fx, "tmp-docs"); l == nil || l.ExpiresAt != exp.Unix() {
2053 t.Fatalf("row = %+v, want expires_at %d", l, exp.Unix())
2054 }
2055 if p, err := release.GetPipeline(t.Context(), fx.rel, fx.pub, fx.sqid, "tmp-docs"); err != nil || p == nil {
2056 t.Fatalf("a new temporary app gets the default pipeline like any new app: %v %v", p, err)
2057 }
2058}
2059
2060// Review Focus 1.
2061func TestTemporaryTTLBounds(t *testing.T) {
2062 for secs, ok := range map[int64]bool{
2063 0: true, 3599: false, 3600: true, 2592000: true, 2592001: false, -1: false, 1 << 62: false,
2064 } {
2065 if _, err := temporaryTTL(secs, true); (err == nil) != ok {
2066 t.Errorf("temporaryTTL(%d) = %v, want ok=%v", secs, err, ok)
2067 }
2068 }
2069 if _, err := temporaryTTL(0, false); err == nil {
2070 t.Error("ttl 0 is only the default at create, never for carlos apps ttl")
2071 }
2072 fx, token := temporaryFixture(t)
2073 if status, _ := createTemporary(t, fx, token, "tmp-short", 3599); status != http.StatusBadRequest {
2074 t.Fatalf("ttl 3599 = %d, want 400", status)
2075 }
2076 if status, _ := createTemporary(t, fx, token, "tmp-huge", 1<<62); status != http.StatusBadRequest {
2077 t.Fatalf("ttl 1<<62 = %d, want 400", status)
2078 }
2079 if l := lifeOf(t, fx, "tmp-huge"); l != nil {
2080 t.Fatal("a refused ttl created a row")
2081 }
2082}
2083
2084// Review Focus 2.
2085func TestTemporaryCreateNameShapes(t *testing.T) {
2086 fx, token := temporaryFixture(t)
2087 for _, name := range []string{"docs", "tmp-", "tmp-abc-", "tmp-" + strings.Repeat("a", 29), "tmp_docs"} {
2088 if status, out := createTemporary(t, fx, token, name, 0); status != http.StatusBadRequest {
2089 t.Errorf("name %q = %d %v, want 400", name, status, out)
2090 }
2091 }
2092 if status, out := createTemporary(t, fx, token, " TMP-Docs ", 0); status != http.StatusOK || out["app"] != "tmp-docs" {
2093 t.Fatalf("TMP-Docs = %d %v, want 200 creating tmp-docs", status, out)
2094 }
2095}
2096
2097func TestTemporaryCreateRefusesUnknownFields(t *testing.T) {
2098 fx, token := temporaryFixture(t)
2099 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary",
2100 map[string]any{"name": "tmp-cp", "control_plane": true}, token)
2101 if status != http.StatusBadRequest || !strings.Contains(str(t, out, "error"), "control_plane") {
2102 t.Fatalf("control_plane on /new-temporary = %d %v, want 400 naming the field", status, out)
2103 }
2104}
2105
2106// Spec §9: any key under the prefix refuses, including a manifest with no
2107// channel pointer, and the owner role does not exempt it (paul owns ops).
2108func TestTemporaryCreateRefusesAnyKeyUnderThePrefix(t *testing.T) {
2109 fx, token := temporaryFixture(t)
2110 if _, err := fx.rel.Put(t.Context(), "apps/"+fx.sqid+"/tmp-shipped/releases/v1/manifest.json", []byte(`{}`), ""); err != nil {
2111 t.Fatal(err)
2112 }
2113 status, out := createTemporary(t, fx, token, "tmp-shipped", 0)
2114 if status != http.StatusConflict || out["code"] != "name_taken" {
2115 t.Fatalf("create over an unpromoted release = %d %v, want 409 name_taken", status, out)
2116 }
2117 if l := lifeOf(t, fx, "tmp-shipped"); l != nil {
2118 t.Fatal("the owner adopted release history into a temporary app")
2119 }
2120}
2121
2122// Spec §3 (astra 5.1): a route-only service of the same account and app, with
2123// no row and no bucket objects, refuses. Another account's route does not.
2124func TestTemporaryCreateRefusesARouteOfTheSameAccount(t *testing.T) {
2125 fx, token := temporaryFixture(t)
2126 reg, err := registry.Open(fx.regPath)
2127 if err != nil {
2128 t.Fatal(err)
2129 }
2130 for _, rt := range []registry.Route{
2131 {Host: "svc.example.com", Kind: "service", Status: "ready", App: "tmp-svc", Account: fx.sqid, Addr: "127.0.0.1:9"},
2132 {Host: "other.example.com", Kind: "service", Status: "ready", App: "tmp-other", Account: "bzz", Addr: "127.0.0.1:9"},
2133 } {
2134 if err := reg.Upsert(rt); err != nil {
2135 t.Fatal(err)
2136 }
2137 }
2138 if status, out := createTemporary(t, fx, token, "tmp-svc", 0); status != http.StatusConflict {
2139 t.Fatalf("same-account route = %d %v, want 409", status, out)
2140 }
2141 if status, out := createTemporary(t, fx, token, "tmp-other", 0); status != http.StatusOK {
2142 t.Fatalf("another account's route = %d %v, want 200", status, out)
2143 }
2144}
2145
2146// "Could not check" is not "no routes".
2147func TestTemporaryCreateRefusesWhenTheRegistryCannotBeRead(t *testing.T) {
2148 fx, token := temporaryFixture(t)
2149 notADir := filepath.Join(t.TempDir(), "file")
2150 if err := os.WriteFile(notADir, []byte("x"), 0o600); err != nil {
2151 t.Fatal(err)
2152 }
2153 fx.srv.cfg.Registry = filepath.Join(notADir, "registry.db")
2154 if status, out := createTemporary(t, fx, token, "tmp-blind", 0); status != http.StatusBadGateway {
2155 t.Fatalf("unreadable registry = %d %v, want 502", status, out)
2156 }
2157 if l := lifeOf(t, fx, "tmp-blind"); l != nil {
2158 t.Fatal("created without checking the routes")
2159 }
2160}
2161
2162// Spec §9: the cap refuses the 21st and is exact under concurrent creates.
2163func TestTemporaryCapIsExactUnderConcurrentCreates(t *testing.T) {
2164 fx, token := temporaryFixture(t)
2165 now := time.Now()
2166 for i := 0; i < 18; i++ {
2167 if ok, err := fx.st.CreateTemporaryAppIn(OpsAccountID, fmt.Sprintf("tmp-seed%02d", i), "paul@keymail.dev", now, now.Add(time.Hour)); err != nil || !ok {
2168 t.Fatal(ok, err)
2169 }
2170 }
2171 var wg sync.WaitGroup
2172 statuses := make([]int, 5)
2173 for i := range statuses {
2174 wg.Add(1)
2175 go func(i int) {
2176 defer wg.Done()
2177 statuses[i], _ = createTemporary(t, fx, token, fmt.Sprintf("tmp-race%d", i), 0)
2178 }(i)
2179 }
2180 wg.Wait()
2181 won := 0
2182 for _, s := range statuses {
2183 if s == http.StatusOK {
2184 won++
2185 }
2186 }
2187 if won != 2 {
2188 t.Fatalf("statuses = %v, want exactly 2 of 5 to fit under the cap of 20", statuses)
2189 }
2190 live, _ := fx.st.LiveTemporaryApps(OpsAccountID)
2191 if len(live) != temporaryAppCap {
2192 t.Fatalf("%d live temporary apps, want %d", len(live), temporaryAppCap)
2193 }
2194 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new-temporary", map[string]any{"name": "tmp-21st"}, token)
2195 msg := str(t, out, "error")
2196 if status != http.StatusConflict || out["code"] != "cap" || !strings.Contains(msg, "tmp-seed00") {
2197 t.Fatalf("21st = %d %v, want 409 cap listing each app with its expiry", status, out)
2198 }
2199}
2200
2201// Review Focus 5.
2202func TestAppScopedServiceCannotClaimATemporaryApp(t *testing.T) {
2203 fx := newConsoleFixture(t)
2204 fx.srv.cfg.TemporaryApps = true
2205 u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new-temporary"
2206 appTok := serviceToken(t, fx.st, OpsAccountID, "kass-ci", "kass", svcrole.Publish)
2207 status, h, body := rawReq(t, http.MethodPost, u, appTok, `{"name":"tmp-ci"}`)
2208 assertNotFound(t, "app-scoped credential claiming a temporary app", canonicalNotFound(t), status, h, body)
2209 if l := lifeOf(t, fx, "tmp-ci"); l != nil {
2210 t.Fatal("an app-scoped credential claimed a temporary app")
2211 }
2212 acctTok := serviceToken(t, fx.st, OpsAccountID, "pages", "", svcrole.Publish)
2213 if status, _, body := rawReq(t, http.MethodPost, u, acctTok, `{"name":"tmp-ci"}`); status != http.StatusOK {
2214 t.Fatalf("account-scoped Publish credential = %d %s, want 200", status, body)
2215 }
2216}
2217```
2218
2219`rawReq(t, method, u, bearer, body) (int, http.Header, string)`, `canonicalNotFound(t) notFound404` and `assertNotFound(t, what, want, status, h, body)` are the existing helpers at `internal/console/apiinstances_test.go:42-96`.
2220
2221- [ ] **Step 2: Run the tests to verify they fail**
2222
2223Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRouteIsOffUntilSwitchedOn|TestTemporaryCreate|TestTemporaryTTLBounds|TestTemporaryCapIsExactUnderConcurrentCreates|TestAppScopedServiceCannotClaimATemporaryApp' -count=1`
2224Expected: FAIL to compile: `fx.srv.cfg.TemporaryApps undefined`, `undefined: temporaryTTL`, `undefined: temporaryAppCap`.
2225
2226- [ ] **Step 3: Implement**
2227
2228`internal/console/server.go`, in `Config` (after `StoreMaxPerApp`, line 605):
2229
2230```go
2231 // TemporaryApps turns on POST /api/cli/apps/{account}/new-temporary
2232 // (temporary static sites spec §3). Off by default, and only switched on
2233 // after every box runs a binary that carries the tmp- reservation and
2234 // every registry has been scanned clean (the rollout steps). The expiry
2235 // pass runs either way, so apps created while it was on still expire.
2236 TemporaryApps bool
2237```
2238
2239`cmd/console/main.go`, in the `console.Config` literal (after `StoreOperators`):
2240
2241```go
2242 TemporaryApps: os.Getenv("CARLOS_TEMPORARY_APPS") == "1",
2243```
2244
2245and add to the header doc comment (lines 28-68): `CARLOS_TEMPORARY_APPS=1 turns on temporary apps (carlos deploy --temporary); off by default, see the temporary static sites rollout.`
2246
2247`internal/console/authcli.go`, after line 482:
2248
2249```go
2250 // Temporary apps (temporary static sites spec §3). A separate route, not a
2251 // field on /new: /new's decoder accepts unknown fields, so an older
2252 // console would take "temporary" silently and create a permanent app. An
2253 // older console answers this route 404 and the CLI stops there.
2254 s.cliRoute("POST /api/cli/apps/{account}/new-temporary", svcrole.Allow(svcrole.Publish), s.handleAPICreateTemporaryApp)
2255```
2256
2257Create `internal/console/apitemporary.go`:
2258
2259```go
2260package console
2261
2262import (
2263 "errors"
2264 "fmt"
2265 "log"
2266 "net/http"
2267 "net/url"
2268 "strings"
2269 "time"
2270
2271 "github.com/carlosframework/platform/internal/provision"
2272 "github.com/carlosframework/platform/internal/release"
2273)
2274
2275// The bounds are spec ruling 3: default 7 days (temporaryTTLDefault,
2276// tempstore.go), at least an hour, at most 30 days, and at most 20 live
2277// temporary apps in an account.
2278const (
2279 temporaryTTLMin = time.Hour
2280 temporaryTTLMax = 30 * 24 * time.Hour
2281 temporaryAppCap = 20
2282)
2283
2284var errTTLBounds = errors.New("ttl_seconds must be between 1 hour (3600) and 30 days (2592000)") // COPY-REVIEW C5.1
2285
2286// temporaryTTL reads a request's ttl_seconds. Compared as seconds before any
2287// multiplication, so no value can overflow a Duration on its way to being
2288// refused. 0 is the default only where allowDefault says so (create).
2289func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error) {
2290 if seconds == 0 && allowDefault {
2291 return temporaryTTLDefault, nil
2292 }
2293 if seconds < int64(temporaryTTLMin/time.Second) || seconds > int64(temporaryTTLMax/time.Second) {
2294 return 0, errTTLBounds
2295 }
2296 return time.Duration(seconds) * time.Second, nil
2297}
2298
2299// validTemporaryName is the name rule on top of release.ValidApp: in the tmp-
2300// namespace, something after the prefix, and a DNS label, because the name
2301// becomes the first label of the app's platform host.
2302func validTemporaryName(name string) bool {
2303 return release.IsTemporaryName(name) && name != release.TemporaryAppPrefix &&
2304 release.ValidApp(name) && provision.ValidHostShape(name)
2305}
2306
2307// temporaryCapError is the cap's refusal. It lists each live temporary app
2308// with its expiry, so the person refused can see what to keep or delete.
2309type temporaryCapError struct{ Live []AppLife }
2310
2311func (e *temporaryCapError) Error() string {
2312 var b strings.Builder
2313 fmt.Fprintf(&b, "this account already has %d temporary apps, the most it can hold. "+
2314 "Make one permanent with carlos apps keep, or delete one with carlos apps delete. They are:", len(e.Live)) // COPY-REVIEW C5.2
2315 for _, l := range e.Live {
2316 fmt.Fprintf(&b, "\n %s expires %s", l.App, time.Unix(l.ExpiresAt, 0).UTC().Format("2006-01-02 15:04 UTC")) // COPY-REVIEW C5.3
2317 }
2318 return b.String()
2319}
2320
2321// temporaryCapCheck refuses when the account is at the cap. The CALLER holds
2322// s.lockAccount(sqid), which is what makes count-then-insert exact (spec §3).
2323func (s *Server) temporaryCapCheck(accountID int64) error {
2324 live, err := s.store.LiveTemporaryApps(accountID)
2325 if err != nil {
2326 return err
2327 }
2328 if len(live) >= temporaryAppCap {
2329 return &temporaryCapError{Live: live}
2330 }
2331 return nil
2332}
2333
2334// accountAppHasRoutes is appHasRoutes (appsettings.go:711) scoped to one
2335// account: any route in THIS console's registry whose account
2336// (release.RouteAccount) is sqid and whose app is app, platform apex
2337// included. A registry that cannot be opened or listed is an error, never
2338// "no routes" (spec §3, astra 5.1). It is a second line behind the tmp-
2339// namespace, not the proof: routes hand-added on other boxes are invisible
2340// here.
2341func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error) {
2342 if s.cfg.Registry == "" {
2343 return false, errors.New("console: no registry configured")
2344 }
2345 reg, err := openRegistry(s.cfg.Registry)
2346 if err != nil {
2347 return false, err
2348 }
2349 routes, err := reg.List()
2350 if err != nil {
2351 return false, err
2352 }
2353 for _, rt := range routes {
2354 if rt.App == app && release.RouteAccount(rt) == sqid {
2355 return true, nil
2356 }
2357 }
2358 return false, nil
2359}
2360
2361// handleAPICreateTemporaryApp is POST /api/cli/apps/{account}/new-temporary
2362// (spec §3): a FRESH claim of a tmp- name, never an adoption, with the expiry
2363// in the same insert. It shares claimApp's checks through claimAppWith and
2364// takes no control_plane, place or build_probe: its strict decoder refuses
2365// them by name.
2366func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request) {
2367 if !s.cfg.TemporaryApps {
2368 http.NotFound(w, r)
2369 return
2370 }
2371 p, acct, ok := s.apiPrincipalAccount(w, r)
2372 if !ok {
2373 return
2374 }
2375 // An app-scoped credential claiming a new name is account authority it
2376 // was not given: the byte-identical 404, exactly as /new (apicli.go:345).
2377 if p.Kind == PrincipalService && p.App != "" {
2378 http.NotFound(w, r)
2379 return
2380 }
2381 addr := auditActor(p, acct)
2382 now := time.Now()
2383 if !s.allowTemporaryCreate(p, now) {
2384 jsonError(w, http.StatusTooManyRequests,
2385 "temporary app limit reached for today. Try again tomorrow, or deploy to a temporary app you already have") // COPY-REVIEW C5.4
2386 return
2387 }
2388 var req struct {
2389 Name string `json:"name"`
2390 TTLSeconds int64 `json:"ttl_seconds"`
2391 }
2392 if !readJSONStrictLimit(w, r, &req, 8<<10) {
2393 return
2394 }
2395 name := normalizeAppName(req.Name)
2396 if !validTemporaryName(name) {
2397 jsonError(w, http.StatusBadRequest,
2398 "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") // COPY-REVIEW C5.5
2399 return
2400 }
2401 ttl, err := temporaryTTL(req.TTLSeconds, true)
2402 if err != nil {
2403 jsonError(w, http.StatusBadRequest, err.Error())
2404 return
2405 }
2406 if s.cfg.Releases == nil {
2407 jsonError(w, http.StatusServiceUnavailable,
2408 "this console has no deployment bucket, so it cannot create a temporary app") // COPY-REVIEW C5.6
2409 return
2410 }
2411 defer s.lockAccount(acct.Sqid)()
2412 if err := s.temporaryCapCheck(acct.ID); err != nil {
2413 var capErr *temporaryCapError
2414 if errors.As(err, &capErr) {
2415 writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"})
2416 return
2417 }
2418 log.Printf("console: temporary create %s: count: %v", acct.Sqid, err)
2419 jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") // COPY-REVIEW C5.7
2420 return
2421 }
2422 switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); {
2423 case err != nil:
2424 log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err)
2425 jsonError(w, http.StatusBadGateway, "could not check the routes for that name, so nothing was created. Try again") // COPY-REVIEW C5.8
2426 return
2427 case wired:
2428 writeJSON(w, http.StatusConflict, map[string]any{
2429 "error": "a route in this account already serves " + name + ", so it cannot be a temporary app. Choose another name", // COPY-REVIEW C5.9
2430 "code": "name_taken",
2431 })
2432 return
2433 }
2434 outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, now.Add(ttl))
2435 switch outcome {
2436 case claimOK:
2437 case claimInvalid:
2438 jsonError(w, http.StatusBadRequest,
2439 "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") // COPY-REVIEW C5.5
2440 return
2441 case claimReserved:
2442 writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is reserved", "code": "name_taken"})
2443 return
2444 case claimTaken:
2445 writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is taken", "code": "name_taken"})
2446 return
2447 case claimApexHeld:
2448 writeJSON(w, http.StatusConflict, map[string]any{
2449 "error": "that name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held),
2450 "code": "name_taken",
2451 })
2452 return
2453 case claimStoreFailed:
2454 jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again") // COPY-REVIEW C5.10
2455 return
2456 default:
2457 jsonError(w, http.StatusInternalServerError, "could not create the app. Try again") // COPY-REVIEW C5.11
2458 return
2459 }
2460 life, err := s.store.AppLife(acct.ID, name)
2461 if err != nil || life == nil || !life.Temporary() {
2462 log.Printf("console: temporary create %s/%s: read back expiry: %v %+v", acct.Sqid, name, err, life)
2463 jsonError(w, http.StatusInternalServerError,
2464 "the app was created but its expiry could not be read back. Check carlos apps list before you ship to it") // COPY-REVIEW C5.12
2465 return
2466 }
2467 expires := time.Unix(life.ExpiresAt, 0).UTC()
2468 s.audit(acct, name, addr, "create", "created temporary app "+name+", expiring "+expires.Format("2006-01-02 15:04 UTC")+" (cli)") // COPY-REVIEW C5.13
2469 writeJSON(w, http.StatusOK, map[string]any{
2470 "account": acct.Sqid,
2471 "app": name,
2472 "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
2473 "expires_at": expires.Format(time.RFC3339),
2474 })
2475}
2476```
2477
2478- [ ] **Step 4: Run the tests to verify they pass**
2479
2480Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRouteIsOffUntilSwitchedOn|TestTemporaryCreate|TestTemporaryTTLBounds|TestTemporaryCapIsExactUnderConcurrentCreates|TestAppScopedServiceCannotClaimATemporaryApp|TestEveryCLIDoorNamesItsRoles|TestAppScopedServiceCannotClaimANewApp' -count=1`
2481Expected: PASS.
2482
2483Run: `GOCACHE=$TMPDIR/gocache go build ./cmd/console/`
2484Expected: builds.
2485
2486- [ ] **Step 5: Commit**
2487
2488```bash
2489gofmt -l internal/console cmd/console
2490git add internal/console/apitemporary.go internal/console/apitemporary_test.go internal/console/server.go internal/console/authcli.go internal/console/svcprincipal.go cmd/console/main.go
2491git commit -m "console: /new-temporary behind CARLOS_TEMPORARY_APPS, exact cap, route check
2492
2493Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
2494```
2495
2496---
2497### Task 6: Temporary apps stay static, platform-hosted, and unprotected
2498
2499Spec §4: on a row with `expires_at > 0`, every server mutation path in the table refuses, each refusal names `carlos apps keep`, and keep lifts them all. Rename, move and the control-plane setter refuse in the shared store operation too, under the per-app mutex.
2500
2501**Files:**
2502- Create: `internal/console/temprefusal.go`
2503- Modify: `internal/console/apicli.go:1653-1656` (`handleAPIShip`, after the kind check)
2504- Modify: `internal/console/apiinstances.go:693-697` (`handleAPIInstanceCreate`, after `ValidHost`), `:1751` (`handleAPIInstanceEnable`, before the private-storage block)
2505- Modify: `internal/console/instanceprivatestore.go:176` (`instanceStoreProvision`, first statement)
2506- Modify: `internal/console/apistore.go:363-367` (`handleAPIStoreCreate`), `:764-768` (`handleAPIStoreGrant`); `internal/console/apistore_provision.go:15-19` (`handleAPIStoreProvision`)
2507- Modify: `internal/console/apidomains.go:258-262` (`handleAPIDomainAttach`); `internal/console/domainattach.go:102-104` (`attachDomain`, after `bucketRefusal`)
2508- Modify: `internal/console/apimail.go:994-998` (`handleAPIEmailDomainEnsure`), `:1239-1243` (`handleAPIEmailEnable`), `:1374-1378` (`handleAPIEmailCredentialsCreate`)
2509- Modify: `internal/console/server.go:3819-3838` (`handlePromote`'s `enable_bake`), `:4041-4085` (`handleProduction`)
2510- Modify: `internal/console/apipipeline.go:249-251` (`ensureProductionForBakes`)
2511- Modify: `internal/console/appsettings.go:750-753` (`handleAppRename`), `:867` (`renameAdvice`), `:1266-1271` (`handleAppMoveAccount`), `:1364` (`moveAdvice`)
2512- Modify: `internal/console/controlplane.go:261-292` (`handleAppControlPlaneSet`)
2513- Modify: `internal/console/store.go` (`renameAppIn` from Task 3, `MoveApp` at :1633, `SetAppControlPlane` at :1424)
2514- Test: `internal/console/temprefusal_test.go` (new)
2515
2516**Interfaces:**
2517- Consumes: `Store.AppLife`, `Store.KeepApp`, `AppLife.Temporary` (Task 2); `lockApp` (Task 4); `s.apexHost` (apex.go:28).
2518- Produces:
2519 - `var ErrTemporaryApp error` (console; returned by `RenameAppIn`, `MoveApp`, `SetAppControlPlane(…, true)`)
2520 - `func (s *Server) temporaryRefusal(acct *Account, app, action string) (msg string, status int)`: msg "" when the app is not temporary; status 409, or 503 when the row cannot be read (fails closed)
2521 - `func (s *Server) refuseTemporary(w http.ResponseWriter, acct *Account, app, action string) bool`
2522
2523- [ ] **Step 1: Write the failing tests**
2524
2525```go
2526// internal/console/temprefusal_test.go
2527package console
2528
2529import (
2530 "context"
2531 "errors"
2532 "net/http"
2533 "net/http/httptest"
2534 "net/url"
2535 "strings"
2536 "testing"
2537 "time"
2538
2539 "github.com/carlosframework/platform/internal/provision"
2540)
2541
2542const keepHint = "carlos apps keep"
2543
2544// refusalFixture is a console with one live temporary app, tmp-site, and a
2545// person token and browser for paul, who owns the account.
2546func refusalFixture(t *testing.T) (*consoleFixture, string, *http.Client, string) {
2547 t.Helper()
2548 fx := newConsoleFixture(t)
2549 fx.srv.cfg.AppsDomain = "carlosapp.test"
2550 tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour))
2551 token := cliToken(t, fx, "paul@keymail.dev", "code-refuse-cli")
2552 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-refuse-web")
2553 return fx, token, c, csrf
2554}
2555
2556func keep(t *testing.T, fx *consoleFixture) {
2557 t.Helper()
2558 if ok, err := fx.st.KeepApp(OpsAccountID, "tmp-site"); err != nil || !ok {
2559 t.Fatalf("KeepApp = %v %v", ok, err)
2560 }
2561}
2562
2563// flashAfter follows a browser door's 303 and returns the page it lands on.
2564func flashAfter(t *testing.T, fx *consoleFixture, c *http.Client, status int, loc, body string) string {
2565 t.Helper()
2566 if status != http.StatusSeeOther {
2567 return body
2568 }
2569 _, _, page := get(t, c, fx.ts.URL+loc)
2570 return page
2571}
2572
2573// Spec §9: the server paths in §4 each refuse on a temporary row and pass
2574// after keep. "Pass" means the temporary refusal no longer fires; the door
2575// may still refuse for its own reasons (no mail backend, no store backend).
2576func TestTemporaryRefusalsAtTheAPIDoors(t *testing.T) {
2577 fx, token, _, _ := refusalFixture(t)
2578 base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site"
2579 doors := []struct {
2580 name string
2581 call func() (int, string)
2582 }{
2583 {"ship a binary", func() (int, string) {
2584 st, out := postShip(t, base+"/releases", token, []shipPart{metaPart(t, "v1", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
2585 return st, str(t, out, "error")
2586 }},
2587 {"store create", func() (int, string) {
2588 st, out := postJSON(t, base+"/store", map[string]string{"env_prefix": "S3"}, token)
2589 return st, str(t, out, "error")
2590 }},
2591 {"store provision", func() (int, string) {
2592 st, out := postJSON(t, base+"/store/provision", map[string]string{}, token)
2593 return st, str(t, out, "error")
2594 }},
2595 {"domain attach", func() (int, string) {
2596 st, out := postJSON(t, base+"/domains", map[string]string{"host": "www.example.com"}, token)
2597 return st, str(t, out, "error")
2598 }},
2599 {"email domains add", func() (int, string) {
2600 st, out := postJSON(t, base+"/email/domains", map[string]string{"domain": "example.com"}, token)
2601 return st, str(t, out, "error")
2602 }},
2603 {"email enable", func() (int, string) {
2604 st, out := postJSON(t, base+"/email/enable", map[string]string{}, token)
2605 return st, str(t, out, "error")
2606 }},
2607 {"email credentials", func() (int, string) {
2608 st, out := postJSON(t, base+"/email/credentials", map[string]string{"name": "ci"}, token)
2609 return st, str(t, out, "error")
2610 }},
2611 {"private storage", func() (int, string) {
2612 st, out := putJSON(t, base+"/instances-manifest", map[string]any{"private_storage": true}, token)
2613 return st, str(t, out, "error")
2614 }},
2615 }
2616 for _, d := range doors {
2617 if st, msg := d.call(); st != http.StatusConflict || !strings.Contains(msg, keepHint) {
2618 t.Errorf("%s on a temporary app = %d %q, want 409 naming %s", d.name, st, msg, keepHint)
2619 }
2620 }
2621 keep(t, fx)
2622 for _, d := range doors {
2623 if _, msg := d.call(); strings.Contains(msg, keepHint) {
2624 t.Errorf("%s after keep still refuses as temporary: %q", d.name, msg)
2625 }
2626 }
2627}
2628
2629// Instance create: static at the platform host only.
2630func TestTemporaryInstanceCreateIsStaticAtThePlatformHost(t *testing.T) {
2631 fx, token, _, _ := refusalFixture(t)
2632 base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site"
2633 if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK {
2634 t.Fatalf("enable = %d %v", st, out)
2635 }
2636 apex := "tmp-site." + fx.sqid + ".carlosapp.test"
2637 for _, body := range []map[string]any{
2638 {"host": apex, "kind": "instance"},
2639 {"host": apex},
2640 {"host": "www." + fx.sqid + ".carlosapp.test", "kind": "static"},
2641 } {
2642 if st, out := postJSON(t, base+"/instances", body, token); st != http.StatusConflict || !strings.Contains(str(t, out, "error"), keepHint) {
2643 t.Errorf("instance %v on a temporary app = %d %v, want 409 naming %s", body, st, out, keepHint)
2644 }
2645 }
2646 // The one shape a temporary app may have must actually be created, not
2647 // merely escape the temporary refusal: 201, and a record in the bucket at
2648 // the platform host, for this account and app, kind static. The shape is
2649 // TestAPIInstanceCreateStaticAtOwnApex's (apiinstances_test.go:2044).
2650 status, _, out := rawReq(t, http.MethodPost, base+"/instances", token,
2651 `{"host":"`+apex+`","kind":"static","channel":"edge"}`)
2652 if status != http.StatusCreated {
2653 t.Fatalf("static instance at the platform host = %d %s, want 201", status, out)
2654 }
2655 stored, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex)
2656 if err != nil {
2657 t.Fatalf("read back the record: %v", err)
2658 }
2659 if stored.Account != fx.sqid || stored.App != "tmp-site" || stored.Host != apex || stored.Kind != "static" {
2660 t.Fatalf("stored record = %+v, want account %s, app tmp-site, host %s, kind static", stored, fx.sqid, apex)
2661 }
2662}
2663
2664func TestTemporaryRefusalsAtTheBrowserDoors(t *testing.T) {
2665 fx, _, c, csrf := refusalFixture(t)
2666 doors := map[string]url.Values{
2667 "/apps/tmp-site/production": {"csrf": {csrf}, "production": {"on"}},
2668 "/apps/tmp-site/rename": {"csrf": {csrf}, "to": {"site"}},
2669 "/apps/tmp-site/move": {"csrf": {csrf}},
2670 "/apps/tmp-site/control-plane/set": {"csrf": {csrf}},
2671 "/apps/tmp-site/domains/attach": {"csrf": {csrf}, "host": {"www.example.com"}},
2672 }
2673 for path, form := range doors {
2674 st, loc, body := postForm(t, c, fx.u(path), form)
2675 if page := flashAfter(t, fx, c, st, loc, body); !strings.Contains(page, keepHint) {
2676 t.Errorf("%s on a temporary app: the page does not name %s", path, keepHint)
2677 }
2678 }
2679 keep(t, fx)
2680 for path, form := range doors {
2681 st, loc, body := postForm(t, c, fx.u(path), form)
2682 if page := flashAfter(t, fx, c, st, loc, body); strings.Contains(page, keepHint) {
2683 t.Errorf("%s after keep still refuses as temporary", path)
2684 }
2685 }
2686}
2687
2688// Spec §4: enforced at the store operation as well as the handler.
2689func TestTemporaryRefusalsAtTheStore(t *testing.T) {
2690 fx, _, _, _ := refusalFixture(t)
2691 if _, err := fx.st.EnsureAccount(2000, "bdz", "Other", time.Now()); err != nil {
2692 t.Fatal(err)
2693 }
2694 if err := fx.st.RenameAppIn(OpsAccountID, "tmp-site", "site"); !errors.Is(err, ErrTemporaryApp) {
2695 t.Errorf("RenameAppIn = %v, want ErrTemporaryApp", err)
2696 }
2697 if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); !errors.Is(err, ErrTemporaryApp) {
2698 t.Errorf("MoveApp = %v, want ErrTemporaryApp", err)
2699 }
2700 if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); !errors.Is(err, ErrTemporaryApp) {
2701 t.Errorf("SetAppControlPlane(true) = %v, want ErrTemporaryApp", err)
2702 }
2703 if l := lifeOf(t, fx, "tmp-site"); l.ControlPlane || l.AccountID != OpsAccountID {
2704 t.Fatalf("a refused store operation changed the row: %+v", l)
2705 }
2706 keep(t, fx)
2707 if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", true); err != nil {
2708 t.Errorf("SetAppControlPlane after keep = %v", err)
2709 }
2710 if err := fx.st.SetAppControlPlane(OpsAccountID, "tmp-site", false); err != nil {
2711 t.Fatal(err)
2712 }
2713 if err := fx.st.MoveApp("tmp-site", OpsAccountID, 2000); err != nil {
2714 t.Errorf("MoveApp after keep = %v", err)
2715 }
2716}
2717
2718// The shared provisioning path instance create reaches refuses on its own.
2719func TestInstanceStoreProvisionRefusesATemporaryApp(t *testing.T) {
2720 fx, _, _, _ := refusalFixture(t)
2721 acct := opsAccount(t, fx)
2722 rec := httptest.NewRecorder()
2723 if _, ok := fx.srv.instanceStoreProvision(context.Background(), rec, acct, "tmp-site", "", "paul@keymail.dev"); ok {
2724 t.Fatal("instanceStoreProvision provisioned for a temporary app")
2725 }
2726 if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), keepHint) {
2727 t.Fatalf("refusal = %d %q", rec.Code, rec.Body.String())
2728 }
2729}
2730```
2731
2732- [ ] **Step 2: Run the tests to verify they fail**
2733
2734Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRefusals|TestTemporaryInstanceCreateIsStaticAtThePlatformHost|TestInstanceStoreProvisionRefusesATemporaryApp' -count=1`
2735Expected: FAIL to compile: `undefined: ErrTemporaryApp`. With that stubbed, FAIL on every door: status is not 409 / message lacks "carlos apps keep".
2736
2737- [ ] **Step 3: Implement**
2738
2739Create `internal/console/temprefusal.go`:
2740
2741```go
2742package console
2743
2744import (
2745 "errors"
2746 "log"
2747 "net/http"
2748)
2749
2750// ErrTemporaryApp is the store operations' refusal (RenameAppIn, MoveApp,
2751// SetAppControlPlane(…, true)) for a row with expires_at > 0. A temporary
2752// row cannot change its name, its account, or its protection class
2753// (temporary static sites spec §4): rename and move would point it at bucket
2754// state it did not create, and the control-plane flag would take it out of
2755// automatic destruction's reach.
2756var ErrTemporaryApp = errors.New("the app is temporary. Make it permanent first with carlos apps keep") // COPY-REVIEW C6.16
2757
2758// temporaryRefusal answers "may this door proceed?" for the §4 table. msg is
2759// "" when the app is not temporary; otherwise the sentence to show, naming
2760// `carlos apps keep`, and the status a JSON door sends. A row that cannot be
2761// read refuses with 503: "could not check" is not "permanent".
2762//
2763// Callers MUST be past their own membership gate first, for the same reason
2764// controlPlaneRefusal gives: consulted earlier, this is an oracle.
2765//
2766// action is a whole clause ("ship a binary release to it"), so the sentence
2767// reads as English for every door.
2768func (s *Server) temporaryRefusal(acct *Account, app, action string) (msg string, status int) {
2769 life, err := s.store.AppLife(acct.ID, app)
2770 if err != nil {
2771 log.Printf("console: temporary check %s/%s: %v", acct.Sqid, app, err)
2772 return "Could not check whether " + app + " is temporary. Nothing was changed. Try again.", // COPY-REVIEW C6.1
2773 http.StatusServiceUnavailable
2774 }
2775 if life == nil || !life.Temporary() {
2776 return "", 0
2777 }
2778 return app + " is temporary, so you cannot " + action + ". " +
2779 "Make it permanent first with: carlos apps keep --app " + app, // COPY-REVIEW C6.2
2780 http.StatusConflict
2781}
2782
2783// refuseTemporary is temporaryRefusal for the JSON doors: it writes the
2784// refusal and returns true when the caller must stop.
2785func (s *Server) refuseTemporary(w http.ResponseWriter, acct *Account, app, action string) bool {
2786 msg, status := s.temporaryRefusal(acct, app, action)
2787 if msg == "" {
2788 return false
2789 }
2790 jsonError(w, status, msg)
2791 return true
2792}
2793```
2794
2795The door edits. Each `action` clause is marked with its own id, C6.3 through C6.13.
2796
2797`handleAPIShip` (apicli.go), directly after the `meta.Kind` validity check at lines 1653-1656:
2798
2799```go
2800 if meta.Kind != "static" && s.refuseTemporary(w, acct, app, "ship a binary release to it") { // COPY-REVIEW C6.3
2801 return
2802 }
2803```
2804
2805`handleAPIInstanceCreate` (apiinstances.go), directly after the `provision.ValidHost` refusal (lines 693-697):
2806
2807```go
2808 // A temporary app serves one static site at its own platform address
2809 // (spec §4, ruling 5): anything else is estate an automatic purge would
2810 // have to tear down, or a host outside what its expiry drops.
2811 if apex := s.apexHost(acct.Sqid, app); strings.TrimSpace(req.Kind) != "static" || apex == "" || host != apex {
2812 if s.refuseTemporary(w, acct, app, "serve anything but a static site at "+app+"'s own platform address") { // COPY-REVIEW C6.4
2813 return
2814 }
2815 }
2816```
2817
2818`handleAPIInstanceEnable` (apiinstances.go), immediately before the block at line 1751:
2819
2820```go
2821 if req.PrivateStorage != nil && *req.PrivateStorage &&
2822 s.refuseTemporary(w, acct, app, "give its instances private storage") { // COPY-REVIEW C6.5
2823 return
2824 }
2825```
2826
2827`instanceStoreProvision` (instanceprivatestore.go:176), first statement, as the backstop behind the manifest door:
2828
2829```go
2830 if s.refuseTemporary(w, acct, app, "give it object storage") { // COPY-REVIEW C6.6
2831 return nil, false
2832 }
2833```
2834
2835`handleAPIStoreCreate` (apistore.go:363), `handleAPIStoreGrant` (apistore.go:764) and `handleAPIStoreProvision` (apistore_provision.go:15), each directly after its `apiAccountApp` gate:
2836
2837```go
2838 if s.refuseTemporary(w, acct, app, "give it object storage") { // COPY-REVIEW C6.6
2839 return
2840 }
2841```
2842
2843`handleAPIDomainAttach` (apidomains.go:258), directly after `domainsGate`, before `refuseServiceControlPlane`:
2844
2845```go
2846 if s.refuseTemporary(w, acct, app, "attach a domain to it") { // COPY-REVIEW C6.7
2847 return
2848 }
2849```
2850
2851`attachDomain` (domainattach.go), after the `bucketRefusal` check (lines 102-104), which covers the browser form (appdomains.go:47) as well:
2852
2853```go
2854 if msg, status := s.temporaryRefusal(acct, app, "attach a domain to it"); msg != "" { // COPY-REVIEW C6.7
2855 return attachResult{}, &doorRefusal{Status: status, Msg: msg}
2856 }
2857```
2858
2859`handleAPIEmailDomainEnsure` (apimail.go:994), `handleAPIEmailEnable` (:1239), `handleAPIEmailCredentialsCreate` (:1374), each directly after `apiAccountApp`, before `mailWriteGates`:
2860
2861```go
2862 if s.refuseTemporary(w, acct, app, "give it email") { // COPY-REVIEW C6.8
2863 return
2864 }
2865```
2866
2867`handleProduction` (server.go:4041), directly after the production value is parsed (after `default: back("err", "Invalid production value.")`):
2868
2869```go
2870 if on {
2871 defer s.lockApp(acct.Sqid, app)()
2872 if msg, _ := s.temporaryRefusal(acct, app, "mark it as production"); msg != "" { // COPY-REVIEW C6.9
2873 back("err", msg)
2874 return
2875 }
2876 }
2877```
2878
2879`handlePromote`'s `enable_bake` (server.go:3819-3838): the `default:` arm becomes
2880
2881```go
2882 default:
2883 if tmsg, _ := s.temporaryRefusal(acct, app, "turn its safety delay on"); tmsg != "" { // COPY-REVIEW C6.10
2884 msg += " " + tmsg
2885 break
2886 }
2887 if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr); err != nil {
2888 // ... unchanged
2889```
2890
2891`ensureProductionForBakes` (apipipeline.go), after `if !bakes { return }` (line 249-251):
2892
2893```go
2894 // A temporary app is never flagged production (spec §4): the flag would
2895 // take it out of automatic destruction's reach. Its pipeline bakes on
2896 // boxes new enough to read the pipeline; the old-binary fallback is
2897 // skipped, and said so in the log.
2898 if life, err := s.store.AppLife(acct.ID, app); err != nil || (life != nil && life.Temporary()) {
2899 log.Printf("console: pipeline %s/%s: not setting production for the bake fallback (temporary app, or unreadable row: %v)", acct.Sqid, app, err)
2900 return
2901 }
2902```
2903
2904`handleAppRename` (appsettings.go), directly after the control-plane refusal (lines 750-753):
2905
2906```go
2907 defer s.lockApp(acct.Sqid, app)()
2908 if msg, _ := s.temporaryRefusal(acct, app, "rename it"); msg != "" { // COPY-REVIEW C6.11
2909 back("err", msg)
2910 return
2911 }
2912```
2913
2914and `renameAdvice` gains:
2915
2916```go
2917 case errors.Is(err, ErrTemporaryApp):
2918 return "This app is temporary, so it cannot be renamed. Make it permanent first with carlos apps keep." // COPY-REVIEW C6.14
2919```
2920
2921`handleAppMoveAccount` (appsettings.go), directly after the control-plane refusal (lines 1266-1271):
2922
2923```go
2924 defer s.lockApp(acct.Sqid, app)()
2925 if msg, _ := s.temporaryRefusal(acct, app, "move it to another account"); msg != "" { // COPY-REVIEW C6.12
2926 back("err", msg)
2927 return
2928 }
2929```
2930
2931and `moveAdvice` (line 1364) gains a first check:
2932
2933```go
2934 if errors.Is(err, ErrTemporaryApp) {
2935 return "This app is temporary, so it cannot move. Make it permanent first with carlos apps keep." // COPY-REVIEW C6.15
2936 }
2937```
2938
2939`handleAppControlPlaneSet` (controlplane.go), after the owner check and `back` definition:
2940
2941```go
2942 defer s.lockApp(acct.Sqid, app)()
2943 if msg, _ := s.temporaryRefusal(acct, app, "mark it as the control plane"); msg != "" { // COPY-REVIEW C6.13
2944 back("err", msg)
2945 return
2946 }
2947```
2948
2949and its `SetAppControlPlane` failure branch maps `ErrTemporaryApp` to the same `temporaryRefusal` message (a keep cannot race it: under the lock, nothing makes a permanent app temporary).
2950
2951Store operations (`internal/console/store.go`):
2952
2953`renameAppIn` (Task 3): the source lookup reads `expires_at` too:
2954
2955```go
2956 var createdAt, expiresAt int64
2957 err = tx.QueryRow(`SELECT created_at, expires_at FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`,
2958 accountID, from).Scan(&createdAt, &expiresAt)
2959 if err == sql.ErrNoRows {
2960 return fmt.Errorf("console: this account does not claim %q", from)
2961 } else if err != nil {
2962 return err
2963 }
2964 if expiresAt > 0 {
2965 return ErrTemporaryApp
2966 }
2967```
2968
2969`MoveApp`: after the `atSrc == 0` check:
2970
2971```go
2972 var srcExpires int64
2973 if err := tx.QueryRow(`SELECT expires_at FROM apps WHERE app = ? AND account_id = ?`, app, from).Scan(&srcExpires); err != nil {
2974 return err
2975 }
2976 if srcExpires > 0 {
2977 return ErrTemporaryApp
2978 }
2979```
2980
2981(`console migrate-app` in cmd/console/main.go:225 reaches this and prints the error.)
2982
2983`SetAppControlPlane` (store.go:1424):
2984
2985```go
2986// SetAppControlPlane marks (or unmarks) an app as the control plane. Marking
2987// refuses a temporary row (ErrTemporaryApp): the flag refuses destruction,
2988// and a temporary app exists to be destroyed (temporary static sites §4).
2989func (s *Store) SetAppControlPlane(accountID int64, app string, on bool) error {
2990 if !on {
2991 _, err := s.db.Exec(`UPDATE apps SET control_plane = 0 WHERE account_id = ? AND app = ?`, accountID, app)
2992 return err
2993 }
2994 res, err := s.db.Exec(`UPDATE apps SET control_plane = 1 WHERE account_id = ? AND app = ? AND expires_at = 0`, accountID, app)
2995 if err != nil {
2996 return err
2997 }
2998 if n, _ := res.RowsAffected(); n == 0 {
2999 if l, err := s.AppLife(accountID, app); err == nil && l != nil && l.Temporary() {
3000 return ErrTemporaryApp
3001 }
3002 }
3003 return nil
3004}
3005```
3006
3007- [ ] **Step 4: Run the tests to verify they pass**
3008
3009Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTemporaryRefusals|TestTemporaryInstanceCreateIsStaticAtThePlatformHost|TestInstanceStoreProvisionRefusesATemporaryApp|TestAPIShip|TestAPIInstance|TestAPIStore|TestAPIDomain|TestAPIEmail|TestAppRename|TestAppMove|TestControlPlane|TestProduction|TestPipeline' -count=1`
3010Expected: PASS. If a browser door's page renders the refusal without following a redirect (a 4xx page), `flashAfter` already returns the body.
3011
3012- [ ] **Step 5: Commit**
3013
3014```bash
3015gofmt -l internal/console
3016git add internal/console/temprefusal.go internal/console/temprefusal_test.go internal/console/apicli.go internal/console/apiinstances.go internal/console/instanceprivatestore.go internal/console/apistore.go internal/console/apistore_provision.go internal/console/apidomains.go internal/console/domainattach.go internal/console/apimail.go internal/console/server.go internal/console/apipipeline.go internal/console/appsettings.go internal/console/controlplane.go internal/console/store.go
3017git commit -m "console: temporary apps refuse every door that would outlive their expiry
3018
3019Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
3020```
3021
3022---
3023
3024### Task 7: `restoreApp`, and destructive doors that lock, settle and re-check
3025
3026**Files:**
3027- Create: `internal/console/restore.go`
3028- Modify: `internal/console/apicli.go:1196-1296` (`handleAPITrashApp`), `:1313-1356` (`handleAPIRestoreApp`)
3029- Modify: `internal/console/ownership.go:226-297` (`handleRestoreApp`), `:313-480` (`handlePurgeApp`: lock, settle, re-check after `requireFreshAuth` at :340 and before `PurgeReady` at :357)
3030- Modify: `internal/console/appsettings.go:972-1060` (`handleAppDelete`: lock, settle, re-check after `ctx` at :1008, before `GetProduction` at :1013)
3031- Test: `internal/console/restore_test.go` (new), `internal/console/temphook_test.go` (new)
3032
3033**Interfaces:**
3034- Consumes: `lockAccount`, `lockApp`, `settleApp`, `doorEvidence` (Task 4); `temporaryCapCheck`, `temporaryCapError` (Task 5); `Store.AppLife`, `BumpTrashedExpiry`, `SetRestoring`, `FinishRestoreRow` (Task 2); `release.RestoreApp`.
3035- Produces:
3036 - `var errNotInTrash error`
3037 - `type restoreFailure struct { Marker bool; Err error }` (`Marker` true: the bucket half failed and nothing was restored)
3038 - `func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now time.Time) error` (returns nil, `errNotInTrash`, `*temporaryCapError` or `*restoreFailure`)
3039 - `type lockedRefusalKind int` (`lockedUnreadable`, `lockedGone`, `lockedWrongState`, `lockedControlPlane`), `type lockedRefusal struct { Kind lockedRefusalKind; Err error; Msg string; Status int }` and `func (s *Server) recheckUnderLock(ctx context.Context, acct *Account, app, verb string, purge bool, now time.Time) (*AppLife, *lockedRefusal)`: the settle-and-re-validate every destructive door runs after `lockApp`; each door maps the kind onto its own existing wording
3040 - `func restoredTemporaryNote(app string) string`: the restore note and flash for a `tmp-` app
3041 - test double `hookStore` (temphook_test.go, with `failPut`, `failDelete`, `onDelete`, `onGet`), used by Tasks 7 and 8; test helper `waitForWaiter(t, l *keyedLocks, key string)`
3042
3043- [ ] **Step 1: Write the failing tests**
3044
3045```go
3046// internal/console/temphook_test.go
3047package console
3048
3049import (
3050 "context"
3051 "errors"
3052 "sync"
3053
3054 "github.com/carlosframework/platform/internal/release"
3055)
3056
3057// hookStore is the recording store the lifecycle tests drive failures
3058// through. failPut and failDelete map a key to how many more times it fails
3059// (a negative count fails forever); onDelete runs before each Delete, which is
3060// how a test observes the row at the moment a marker is removed; onGet runs
3061// before each Get.
3062type hookStore struct {
3063 release.Store
3064 mu sync.Mutex
3065 failPut map[string]int
3066 failDelete map[string]int
3067 onDelete func(key string)
3068 // onGet runs before each Get: how a test changes the bucket between two
3069 // reads of one request (Task 8's ErrNotTrashed test).
3070 onGet func(key string)
3071 deletes []string
3072}
3073
3074func (h *hookStore) Get(ctx context.Context, key string) ([]byte, string, error) {
3075 if h.onGet != nil {
3076 h.onGet(key)
3077 }
3078 return h.Store.Get(ctx, key)
3079}
3080
3081func (h *hookStore) fails(m map[string]int, key string) bool {
3082 h.mu.Lock()
3083 defer h.mu.Unlock()
3084 n, ok := m[key]
3085 if !ok || n == 0 {
3086 return false
3087 }
3088 if n > 0 {
3089 m[key] = n - 1
3090 }
3091 return true
3092}
3093
3094func (h *hookStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
3095 if h.fails(h.failPut, key) {
3096 return "", errors.New("hookStore: put refused")
3097 }
3098 return h.Store.Put(ctx, key, body, ifMatch)
3099}
3100
3101func (h *hookStore) Delete(ctx context.Context, key string) error {
3102 if h.onDelete != nil {
3103 h.onDelete(key)
3104 }
3105 if h.fails(h.failDelete, key) {
3106 return errors.New("hookStore: delete refused")
3107 }
3108 h.mu.Lock()
3109 h.deletes = append(h.deletes, key)
3110 h.mu.Unlock()
3111 return h.Store.Delete(ctx, key)
3112}
3113```
3114
3115```go
3116// internal/console/restore_test.go
3117package console
3118
3119import (
3120 "context"
3121 "errors"
3122 "fmt"
3123 "net/http"
3124 "net/url"
3125 "strings"
3126 "testing"
3127 "time"
3128
3129 "github.com/carlosframework/platform/internal/provision"
3130 "github.com/carlosframework/platform/internal/release"
3131)
3132
3133// trashedTemp seeds tmp-a, expired, in the Trash with its 3-day window, the
3134// state the expiry pass leaves.
3135func trashedTemp(t *testing.T, fx *consoleFixture, app string, at time.Time) {
3136 t.Helper()
3137 tempApp(t, fx, app, at)
3138 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, app, "expiry", at); err != nil {
3139 t.Fatal(err)
3140 }
3141 if ok, err := fx.st.ExpireAppRow(OpsAccountID, app, at); err != nil || !ok {
3142 t.Fatal(ok, err)
3143 }
3144}
3145
3146// Spec §6 and §9: rewrites expiry before deleting the tombstone, and clears
3147// the deadline only after. The order is asserted at the moment of the delete.
3148func TestRestoreRewritesExpiryBeforeTheMarkerGoes(t *testing.T) {
3149 fx := newConsoleFixture(t)
3150 now := time.Now().Truncate(time.Second)
3151 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
3152 h := &hookStore{Store: fx.rel}
3153 marker := release.AppTombstoneKey(fx.sqid, "tmp-a")
3154 var atDelete *AppLife
3155 h.onDelete = func(key string) {
3156 if key == marker {
3157 atDelete = lifeOf(t, fx, "tmp-a")
3158 }
3159 }
3160 fx.srv.cfg.Releases = h
3161 if err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now); err != nil {
3162 t.Fatal(err)
3163 }
3164 if atDelete == nil {
3165 t.Fatal("the marker was never deleted")
3166 }
3167 if atDelete.ExpiresAt != now.Add(temporaryTTLDefault).Unix() || !atDelete.Restoring || atDelete.PurgeAfter == 0 {
3168 t.Fatalf("row at the marker delete = %+v, want expiry now+7d, restoring, deadline still set", atDelete)
3169 }
3170 if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.PurgeFor != 0 {
3171 t.Fatalf("row after = %+v, want live, no deadline", l)
3172 }
3173}
3174
3175// Spec §9: a failed tombstone delete keeps the deadline and releases restoring.
3176func TestRestoreWhoseMarkerDeleteFailsKeepsTheDeadline(t *testing.T) {
3177 fx := newConsoleFixture(t)
3178 now := time.Now().Truncate(time.Second)
3179 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
3180 before := *lifeOf(t, fx, "tmp-a")
3181 fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failDelete: map[string]int{release.AppTombstoneKey(fx.sqid, "tmp-a"): 1}}
3182 err := fx.srv.restoreApp(context.Background(), opsAccount(t, fx), "tmp-a", now)
3183 var rf *restoreFailure
3184 if !errors.As(err, &rf) || !rf.Marker {
3185 t.Fatalf("restoreApp = %v, want a marker-half restoreFailure", err)
3186 }
3187 l := lifeOf(t, fx, "tmp-a")
3188 if l.Restoring || !l.Trashed() || l.PurgeAfter != before.PurgeAfter || l.PurgeFor != before.PurgeFor {
3189 t.Fatalf("row = %+v, want trashed with the same deadline and restoring released", l)
3190 }
3191}
3192
3193// Spec §9: a failed step 5 holds the cap slot until settled.
3194func TestRestoreThatStopsBeforeStepFiveHoldsItsSlot(t *testing.T) {
3195 fx := newConsoleFixture(t)
3196 now := time.Now().Truncate(time.Second)
3197 for i := 0; i < temporaryAppCap-1; i++ {
3198 tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour))
3199 }
3200 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
3201 // Steps 3 and 4 done, step 5 never ran.
3202 fx.st.SetRestoring(OpsAccountID, "tmp-a", true)
3203 release.RestoreApp(context.Background(), fx.rel, fx.sqid, "tmp-a")
3204 if err := fx.srv.temporaryCapCheck(OpsAccountID); err == nil {
3205 t.Fatal("the half-restored app's slot was free for a new app to take")
3206 }
3207 if err := fx.srv.settleApp(context.Background(), OpsAccountID, fx.sqid, "tmp-a", now, always(true)); err != nil {
3208 t.Fatal(err)
3209 }
3210 if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.Restoring {
3211 t.Fatalf("settled row = %+v, want live", l)
3212 }
3213}
3214
3215// Spec §9: the cap is enforced on every console restore door.
3216func TestRestoreDoorsEnforceTheCap(t *testing.T) {
3217 fx := newConsoleFixture(t)
3218 now := time.Now().Truncate(time.Second)
3219 for i := 0; i < temporaryAppCap; i++ {
3220 tempApp(t, fx, fmt.Sprintf("tmp-live%02d", i), now.Add(time.Hour))
3221 }
3222 trashedTemp(t, fx, "tmp-a", now.Add(-time.Hour))
3223 token := cliToken(t, fx, "paul@keymail.dev", "code-restore-cap")
3224 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/tmp-a/restore", map[string]string{}, token)
3225 if status != http.StatusConflict || out["code"] != "cap" {
3226 t.Fatalf("API restore over the cap = %d %v, want 409 cap", status, out)
3227 }
3228 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-cap-web")
3229 st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-a"}})
3230 if _, _, page := get(t, c, fx.ts.URL+loc); st != http.StatusSeeOther || !strings.Contains(page, "tmp-live00") {
3231 t.Fatalf("browser restore over the cap = %d, want the refusal listing the live apps", st)
3232 }
3233 if markerOf(t, fx, "tmp-a") == nil {
3234 t.Fatal("a refused restore removed the marker")
3235 }
3236}
3237
3238// Spec §5 "Trash by hand": the doors set the window for a temporary row only.
3239func TestTrashDoorsSetTheWindowForTemporaryRows(t *testing.T) {
3240 fx := newConsoleFixture(t)
3241 tempApp(t, fx, "tmp-a", time.Now().Add(30*24*time.Hour))
3242 token := cliToken(t, fx, "paul@keymail.dev", "code-trash-window")
3243 for _, app := range []string{"tmp-a", "kass"} {
3244 if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token); status != http.StatusOK {
3245 t.Fatalf("delete %s = %d %v", app, status, out)
3246 }
3247 }
3248 tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass")
3249 if tmp.PurgeFor != tmp.DeletedAt || tmp.PurgeAfter != tmp.DeletedAt+72*3600 {
3250 t.Fatalf("temporary trash = %+v, want the 3-day window", tmp)
3251 }
3252 if kass.PurgeAfter != 0 || kass.PurgeFor != 0 {
3253 t.Fatalf("permanent trash = %+v, want none", kass)
3254 }
3255}
3256```
3257
3258Append to `internal/console/restore_test.go` (the helpers `postGated` (stepup_test.go:56), `postRaw` (apicli_test.go:67) and `appKeys` (deletion_test.go:179) already exist):
3259
3260```go
3261// waitForWaiter returns once a second holder is queued on key: the request
3262// under test has passed every check it makes before the lock and is blocked
3263// on it. Deterministic: it waits for the queue, not for a clock.
3264func waitForWaiter(t *testing.T, l *keyedLocks, key string) {
3265 t.Helper()
3266 deadline := time.Now().Add(10 * time.Second)
3267 for time.Now().Before(deadline) {
3268 l.mu.Lock()
3269 n := 0
3270 if e := l.m[key]; e != nil {
3271 n = e.refs
3272 }
3273 l.mu.Unlock()
3274 if n >= 2 {
3275 return
3276 }
3277 time.Sleep(time.Millisecond)
3278 }
3279 t.Fatalf("no request queued on %s", key)
3280}
3281
3282// Astra plan R1 finding 1: a trash door wrote the marker and failed its row
3283// update, so the row is live. The manual purge must settle first: without it
3284// PurgeApp sweeps the objects and PurgeAppRow (guarded deleted_at != 0)
3285// silently keeps a live row that nothing ever repairs.
3286func TestManualPurgeAfterAFailedTrashRowUpdate(t *testing.T) {
3287 fx := newConsoleFixture(t)
3288 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", time.Now()); err != nil {
3289 t.Fatal(err)
3290 }
3291 if l := lifeOf(t, fx, "kass"); l.Trashed() {
3292 t.Fatal("setup: the row must still be live")
3293 }
3294 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-purge-unsettled")
3295 postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
3296 if keys := appKeys(t, fx, "kass"); len(keys) != 0 {
3297 t.Fatalf("purge left objects: %v", keys)
3298 }
3299 if l := lifeOf(t, fx, "kass"); l != nil {
3300 t.Fatalf("row = %+v after a purge, want it gone with the objects", l)
3301 }
3302}
3303
3304// Astra plan round 4: restoring a provisioned temporary static app must not
3305// send anyone to carlos add, which refuses every tmp- name. Its instance
3306// record survives trash and restore, and with the marker gone provision's
3307// reconcile mints the route again (box side: TestReconcileDeclinesTrashedApp,
3308// run in Step 4). Both restore doors are checked.
3309func TestRestoreOfAProvisionedTemporaryStaticApp(t *testing.T) {
3310 fx := newConsoleFixture(t)
3311 fx.srv.cfg.AppsDomain = "carlosapp.test"
3312 tempApp(t, fx, "tmp-site", time.Now().Add(24*time.Hour))
3313 token := cliToken(t, fx, "paul@keymail.dev", "code-restore-provisioned")
3314 base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/tmp-site"
3315 if st, out := putJSON(t, base+"/instances-manifest", map[string]any{}, token); st != http.StatusOK {
3316 t.Fatalf("enable = %d %v", st, out)
3317 }
3318 apex := "tmp-site." + fx.sqid + ".carlosapp.test"
3319 if st, _, out := rawReq(t, http.MethodPost, base+"/instances", token, `{"host":"`+apex+`","kind":"static","channel":"edge"}`); st != http.StatusCreated {
3320 t.Fatalf("instance = %d %s", st, out)
3321 }
3322 recordKept := func(when string) {
3323 t.Helper()
3324 rec, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "tmp-site", apex)
3325 if err != nil || rec == nil || rec.Kind != "static" || rec.Host != apex {
3326 t.Fatalf("%s: instance record = %+v %v, want the static record at %s kept", when, rec, err, apex)
3327 }
3328 }
3329
3330 // API: delete, then restore.
3331 if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK {
3332 t.Fatalf("delete = %d %v", st, out)
3333 }
3334 recordKept("after trash")
3335 st, out := postJSON(t, base+"/restore", map[string]string{}, token)
3336 if st != http.StatusOK {
3337 t.Fatalf("restore = %d %v", st, out)
3338 }
3339 if note := str(t, out, "note"); strings.Contains(note, "carlos add") || !strings.Contains(note, "tmp-site") {
3340 t.Fatalf("restore note = %q, want the instance-record guidance, never carlos add", note)
3341 }
3342 recordKept("after API restore")
3343 if markerOf(t, fx, "tmp-site") != nil || lifeOf(t, fx, "tmp-site").Trashed() {
3344 t.Fatal("after restore the marker must be gone and the row live, so reconcile mints the route again")
3345 }
3346
3347 // Browser: delete through the API again, restore from the Trash page.
3348 if st, out := postJSON(t, base+"/delete", map[string]string{}, token); st != http.StatusOK {
3349 t.Fatalf("second delete = %d %v", st, out)
3350 }
3351 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-restore-provisioned-web")
3352 st, loc, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"tmp-site"}})
3353 if st != http.StatusSeeOther {
3354 t.Fatalf("browser restore = %d", st)
3355 }
3356 if _, _, page := get(t, c, fx.ts.URL+loc); strings.Contains(page, "carlos add") || !strings.Contains(page, "tmp-site") {
3357 t.Fatal("the browser restore flash sends a temporary app to carlos add, or does not name it")
3358 }
3359 recordKept("after browser restore")
3360}
3361
3362// Astra plan round 2: settling under the lock must not turn a trash retry into
3363// a refusal. A marker already in the bucket (a headless trash) and a live row:
3364// the API trash settles the row onto the marker's clock and then answers 200
3365// through its ordinary path, with the marker untouched, the existing deadline,
3366// and a serial bump.
3367func TestTrashAfterAHeadlessTrashIsIdempotent(t *testing.T) {
3368 fx := newConsoleFixture(t)
3369 ctx := context.Background()
3370 old := time.Now().Add(-5 * 24 * time.Hour).Truncate(time.Second)
3371 if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", "headless", old); err != nil {
3372 t.Fatal(err)
3373 }
3374 serialBefore, _, _ := fx.rel.Get(ctx, release.SerialKey)
3375 token := cliToken(t, fx, "paul@keymail.dev", "code-trash-idempotent")
3376 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token)
3377 if status != http.StatusOK {
3378 t.Fatalf("delete after a headless trash = %d %v, want 200", status, out)
3379 }
3380 until, err := time.Parse(time.RFC3339, str(t, out, "restorable_until"))
3381 if err != nil || !until.Equal(old.Add(release.TrashCooloff)) {
3382 t.Fatalf("restorable_until = %v %v, want the headless marker's %v", until, err, old.Add(release.TrashCooloff))
3383 }
3384 if tomb := markerOf(t, fx, "kass"); tomb == nil || !tomb.DeletedAt.Equal(old) || tomb.Actor != "headless" {
3385 t.Fatalf("marker = %+v, want the headless one, untouched", tomb)
3386 }
3387 if serialAfter, _, _ := fx.rel.Get(ctx, release.SerialKey); string(serialAfter) == string(serialBefore) {
3388 t.Fatal("the idempotent success skipped the serial bump")
3389 }
3390}
3391
3392// Astra plan R1 finding 2 (spec §2: a check made before the lock is a hint).
3393// Each case holds the app's lock, lets the destructive request queue on it,
3394// changes a precondition, and releases. The door must refuse on what is true
3395// once it holds the lock.
3396func TestDestructiveDoorsRecheckAfterTheLock(t *testing.T) {
3397 run := func(t *testing.T, fx *consoleFixture, app string, request func() int, change func()) int {
3398 t.Helper()
3399 unlock := fx.srv.lockApp(fx.sqid, app)
3400 done := make(chan int, 1)
3401 go func() { done <- request() }()
3402 waitForWaiter(t, &fx.srv.appLocks, fx.sqid+"/"+app)
3403 change()
3404 unlock()
3405 return <-done
3406 }
3407
3408 t.Run("API trash: marked control plane while waiting", func(t *testing.T) {
3409 fx := newConsoleFixture(t)
3410 token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-cp")
3411 status := run(t, fx, "kass", func() int {
3412 st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token)
3413 return st
3414 }, func() {
3415 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
3416 t.Error(err)
3417 }
3418 })
3419 if status != http.StatusForbidden || markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() {
3420 t.Fatalf("status %d: the control plane was trashed by a request that checked the flag before waiting", status)
3421 }
3422 })
3423
3424 t.Run("API trash: row purged while waiting", func(t *testing.T) {
3425 fx := newConsoleFixture(t)
3426 token := cliToken(t, fx, "paul@keymail.dev", "code-recheck-api-gone")
3427 status := run(t, fx, "kass", func() int {
3428 st, _ := postRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token)
3429 return st
3430 }, func() {
3431 fx.st.TrashAppRow(OpsAccountID, "kass", time.Now())
3432 fx.st.PurgeAppRow(OpsAccountID, "kass")
3433 })
3434 if status != http.StatusNotFound || markerOf(t, fx, "kass") != nil {
3435 t.Fatalf("status %d: a delayed trash wrote a marker for a row that no longer exists", status)
3436 }
3437 })
3438
3439 t.Run("browser delete: marked control plane while waiting", func(t *testing.T) {
3440 fx := newConsoleFixture(t)
3441 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-web-cp")
3442 run(t, fx, "kass", func() int {
3443 st, _, _ := postGated(t, fx, c, fx.u("/apps/kass/delete"), url.Values{"csrf": {csrf}, "confirm": {"kass"}})
3444 return st
3445 }, func() {
3446 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
3447 t.Error(err)
3448 }
3449 })
3450 if markerOf(t, fx, "kass") != nil || lifeOf(t, fx, "kass").Trashed() {
3451 t.Fatal("the browser delete trashed the control plane after waiting on the lock")
3452 }
3453 })
3454
3455 t.Run("purge door: marked control plane while waiting", func(t *testing.T) {
3456 fx := newConsoleFixture(t)
3457 now := time.Now()
3458 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "kass", "paul@keymail.dev", now); err != nil {
3459 t.Fatal(err)
3460 }
3461 fx.st.TrashAppRow(OpsAccountID, "kass", now)
3462 before := len(appKeys(t, fx, "kass"))
3463 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-recheck-purge-cp")
3464 run(t, fx, "kass", func() int {
3465 st, _, _ := postGated(t, fx, c, fx.u("/trash/delete"), url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}})
3466 return st
3467 }, func() {
3468 if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
3469 t.Error(err)
3470 }
3471 })
3472 if got := len(appKeys(t, fx, "kass")); got != before || lifeOf(t, fx, "kass") == nil {
3473 t.Fatalf("the purge door destroyed the control plane after waiting on the lock (%d of %d keys left)", got, before)
3474 }
3475 })
3476}
3477```
3478
3479- [ ] **Step 2: Run the tests to verify they fail**
3480
3481Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestRestore|TestTrashDoorsSetTheWindowForTemporaryRows|TestManualPurgeAfterAFailedTrashRowUpdate|TestDestructiveDoorsRecheckAfterTheLock|TestTrashAfterAHeadlessTrashIsIdempotent' -count=1`
3482Expected: FAIL to compile: `fx.srv.restoreApp undefined`, `undefined: restoreFailure`. (`TestTrashAfterAHeadlessTrashIsIdempotent` passes before and after; it pins that the re-check keeps the idempotent path.) Once those compile, `TestManualPurgeAfterAFailedTrashRowUpdate` fails with the row still live after the purge, and every `TestDestructiveDoorsRecheckAfterTheLock` case fails: the doors have no lock to queue on (`no request queued`) or act on the pre-lock answer. (`TestTrashDoorsSetTheWindowForTemporaryRows` already passes through Task 2's `TrashAppRow`; it stays as the door-level pin.)
3483
3484- [ ] **Step 3: Implement**
3485
3486Create `internal/console/restore.go`:
3487
3488```go
3489package console
3490
3491import (
3492 "context"
3493 "errors"
3494 "log"
3495 "time"
3496
3497 "github.com/carlosframework/platform/internal/release"
3498)
3499
3500// errNotInTrash: after settling, the app is not in the Trash (restored or
3501// purged elsewhere since the door looked).
3502var errNotInTrash = errors.New("console: the app is not in the trash")
3503
3504// restoreFailure says which half of a restore failed. Marker true means the
3505// bucket half (or anything before it) failed and nothing was restored; false
3506// means the marker is gone and only this console's row did not follow, which
3507// the next settle repairs.
3508type restoreFailure struct {
3509 Marker bool
3510 Err error
3511}
3512
3513func (e *restoreFailure) Error() string { return e.Err.Error() }
3514func (e *restoreFailure) Unwrap() error { return e.Err }
3515
3516// restoredTemporaryNote is what a restore says about serving a tmp- app again
3517// (Astra plan round 4). The usual advice, `carlos add <host>`, cannot help:
3518// appRail (cli.go, Task 3) refuses every tmp- name, kept ones included. It
3519// does not need to. Trash drops a temporary app's routes but keeps its
3520// instance records, restore leaves them alone, and once the marker is gone
3521// provision's reconcile mints the routes again from those records
3522// (internal/provision/reconcile.go:903, pinned box-side by
3523// TestReconcileDeclinesTrashedApp). The serial bump makes that seconds. Keyed
3524// on the name, not the row, because appRail is: a kept tmp- app is told the
3525// same thing.
3526func restoredTemporaryNote(app string) string {
3527 return app + " is restored with its releases and channels intact. Its instance records were kept, " +
3528 "so hosts bring its routes back on their next pass." // COPY-REVIEW C7.3
3529}
3530
3531// restoreApp is the one restore every console door runs (temporary static
3532// sites spec §6): the CLI door, the browser door, and nothing else. The
3533// expiry pass's catch-up is settleApp's restore branch. For a temporary row:
3534//
3535// 1. settleApp; cap check counting this app as a new live one
3536// 2. expires_at < now+1h: expires_at = now+7d (still trashed, so harmless)
3537// 3. restoring = 1: from here the app holds a cap slot
3538// 4. release.RestoreApp (delete the marker)
3539// 5. one statement: deleted_at = restoring = purge_after = purge_for = 0
3540//
3541// A permanent row takes the same path without steps 1's cap check and 2.
3542// Locks: account then app, the plan's one lock order.
3543func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now time.Time) error {
3544 if s.cfg.Releases == nil {
3545 return &restoreFailure{Marker: true, Err: errors.New("console: no deployment bucket on this host")}
3546 }
3547 defer s.lockAccount(acct.Sqid)()
3548 defer s.lockApp(acct.Sqid, app)()
3549 if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil {
3550 return &restoreFailure{Marker: true, Err: err}
3551 }
3552 life, err := s.store.AppLife(acct.ID, app)
3553 if err != nil {
3554 return &restoreFailure{Marker: true, Err: err}
3555 }
3556 if life == nil || !life.Trashed() {
3557 return errNotInTrash
3558 }
3559 if life.Temporary() {
3560 if err := s.temporaryCapCheck(acct.ID); err != nil {
3561 var capErr *temporaryCapError
3562 if errors.As(err, &capErr) {
3563 return capErr
3564 }
3565 return &restoreFailure{Marker: true, Err: err}
3566 }
3567 if _, err := s.store.BumpTrashedExpiry(acct.ID, app, now); err != nil {
3568 return &restoreFailure{Marker: true, Err: err}
3569 }
3570 }
3571 if _, err := s.store.SetRestoring(acct.ID, app, true); err != nil {
3572 return &restoreFailure{Marker: true, Err: err}
3573 }
3574 if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
3575 if _, cerr := s.store.SetRestoring(acct.ID, app, false); cerr != nil {
3576 log.Printf("console: restore %s/%s: release restoring after a failed marker delete: %v", acct.Sqid, app, cerr)
3577 }
3578 return &restoreFailure{Marker: true, Err: err}
3579 }
3580 if err := s.store.FinishRestoreRow(acct.ID, app, now); err != nil {
3581 return &restoreFailure{Marker: false, Err: err}
3582 }
3583 return nil
3584}
3585```
3586
3587`handleAPIRestoreApp` (apicli.go:1313): replace the `release.RestoreApp` call and the `RestoreAppRow` call (lines 1331-1345) with:
3588
3589```go
3590 var capErr *temporaryCapError
3591 var rf *restoreFailure
3592 switch err := s.restoreApp(ctx, acct, app, time.Now()); {
3593 case err == nil:
3594 case errors.Is(err, errNotInTrash):
3595 http.NotFound(w, r)
3596 return
3597 case errors.As(err, &capErr):
3598 writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"})
3599 return
3600 case errors.As(err, &rf) && rf.Marker:
3601 log.Printf("console: api restore %s/%s: %v", acct.Sqid, app, err)
3602 jsonError(w, http.StatusBadGateway, "could not remove the trash marker — nothing was restored; try again")
3603 return
3604 default:
3605 log.Printf("console: api restore row %s/%s: %v", acct.Sqid, app, err)
3606 jsonError(w, http.StatusInternalServerError,
3607 "the trash marker was removed, but this console's own record did not update —"+
3608 " it will catch up on the next materialize pass")
3609 return
3610 }
3611```
3612
3613(The two existing error strings are kept byte for byte: they are not new copy.)
3614
3615The success response's `note` (apicli.go:1352-1355) stays as it is for every other app, and names the right way back for a `tmp-` one:
3616
3617```go
3618 note := app + " is restored with its releases and channels intact, and with NO routes:" +
3619 " run `carlos add <host>` to serve it again."
3620 if release.IsTemporaryName(app) {
3621 note = restoredTemporaryNote(app)
3622 }
3623 writeJSON(w, http.StatusOK, map[string]any{"result": "restored", "note": note})
3624```
3625
3626`handleRestoreApp` (ownership.go:226): replace lines 267-280 (the `release.RestoreApp` and `RestoreAppRow` calls) with:
3627
3628```go
3629 var capErr *temporaryCapError
3630 var rf *restoreFailure
3631 switch err := s.restoreApp(r.Context(), acct, app, time.Now()); {
3632 case err == nil:
3633 case errors.Is(err, errNotInTrash):
3634 fail("There is nothing called “" + app + "” in this account's trash.")
3635 return
3636 case errors.As(err, &capErr):
3637 fail(capErr.Error())
3638 return
3639 case errors.As(err, &rf) && rf.Marker:
3640 log.Printf("console: restore %s/%s: %v", acct.Sqid, app, err)
3641 fail("Could not remove the trash marker, so nothing was restored. Try again once the bucket is reachable.")
3642 return
3643 default:
3644 log.Printf("console: restore row %s/%s: %v", acct.Sqid, app, err)
3645 fail("The trash marker was removed, but this console's own record did not update — " +
3646 "it will catch up on the next restart.")
3647 return
3648 }
3649```
3650
3651Keep the `s.cfg.Releases == nil` check above it. Add `"errors"` to ownership.go's imports if missing. The success flash (ownership.go:274-275) keeps its wording for every other app and uses the same note for a `tmp-` one:
3652
3653```go
3654 if release.IsTemporaryName(app) {
3655 s.setFlash(w, "ok", restoredTemporaryNote(app))
3656 } else {
3657 s.setFlash(w, "ok", "Restored “"+app+"”, with its releases and channels intact and with NO routes: "+
3658 "run `carlos add <host>` to serve it again.")
3659 }
3660```
3661
3662The destructive doors: every check a door makes before `lockApp` is a hint (spec §2). After it takes the lock, it settles, re-reads the row, and asks again whether the row exists, is in a state the door may act on, and is the control plane.
3663
3664Two rules keep the doors' existing contracts (Astra plan round 2):
3665
3666- **A trash that finds the app already trashed is an idempotent success.** If settling finds a marker (a headless trash, or an earlier request whose row update failed), the row is now trashed with the marker's clock. The trash door then runs its ordinary success path: `TrashApp` is create-only and leaves the marker alone, the re-read returns the marker's clock, `TrashAppRow` is a no-op, and the response carries the existing deadline, with the audit row and the serial bump. `TestAPITrashTwiceKeepsTheClock` (apicli_test.go:2155) and the retry in `TestAPITrashUnconfirmedWindowIsRefused` (:2226) depend on this and must pass unchanged.
3667- **Every refusal reuses the door's existing wording.** `recheckUnderLock` returns a kind, not a sentence, and each door maps it onto strings it already has: the API trash door's `could not confirm the trash window — try again` (apicli.go:1275; `TestAPITrashUnconfirmedWindowIsRefused` asserts its lowercase "try again" at :2208) and the purge door's `purgeAdvice` (ownership.go:498; `TestPurgeRefusesARestoredAppBeforeTouchingItsStore` asserts its "not in the trash" at deletion_test.go:770). These are existing, already-approved copy, not new strings. Only the browser delete door has no existing sentence for these cases, and gets C7.1 and C7.2.
3668
3669Add to `internal/console/restore.go`:
3670
3671```go
3672// lockedRefusalKind is why a destructive door must stop after taking the
3673// lock. The door, not this helper, chooses the words: each maps these onto
3674// sentences it already uses, so settling under the lock changes no existing
3675// copy.
3676type lockedRefusalKind int
3677
3678const (
3679 lockedUnreadable lockedRefusalKind = iota + 1 // settle or the re-read failed: nothing was changed
3680 lockedGone // the row no longer exists
3681 lockedWrongState // purge: not in the Trash (restored while waiting); trash: trashed with no marker to confirm its clock
3682 lockedControlPlane // marked as the control plane while waiting
3683)
3684
3685// lockedRefusal is recheckUnderLock's answer when a door must stop. Err is
3686// the settle error for lockedUnreadable (logged, and handed to purgeAdvice by
3687// the purge door); Msg and Status are controlPlaneRefusal's, for
3688// lockedControlPlane.
3689type lockedRefusal struct {
3690 Kind lockedRefusalKind
3691 Err error
3692 Msg string
3693 Status int
3694}
3695
3696// recheckUnderLock is what every destructive door (API trash, browser trash,
3697// the purge door) runs after it takes s.lockApp(acct.Sqid, app) and before it
3698// writes or tears down anything. Everything the door checked before the lock
3699// is only a hint (temporary static sites spec §2): the control-plane setter
3700// takes the same lock, and a row can be trashed, restored or purged while the
3701// request waits. So: settle the row against the marker, re-read it, and ask
3702// again.
3703//
3704// purge is true for the purge door, which acts only on a trashed row. Settling
3705// is what makes it correct after a trash whose row update failed: the marker
3706// is there and the row is live, and without the settle PurgeApp would sweep
3707// the objects while PurgeAppRow (guarded deleted_at != 0) kept the live row
3708// for ever.
3709//
3710// For the trash doors (purge false) a trashed row is NOT a refusal when its
3711// marker is there: that is a trash already done, and the door's ordinary
3712// path answers it idempotently with the marker's own deadline. A trashed row
3713// with no marker can only survive settling when the tripwire held it (spec
3714// §2); writing a fresh marker there would give the app a second clock, so it
3715// refuses as unconfirmed.
3716//
3717// The production flag and the purge's own preconditions (PurgeReady, the
3718// email and store estate) are not re-asked here because each door already
3719// reads them after this call, under the lock. Nothing about a temporary row
3720// stops a trash or a purge.
3721func (s *Server) recheckUnderLock(ctx context.Context, acct *Account, app, verb string, purge bool, now time.Time) (*AppLife, *lockedRefusal) {
3722 if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil {
3723 log.Printf("console: %s %s/%s: settle: %v", verb, acct.Sqid, app, err)
3724 return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err}
3725 }
3726 life, err := s.store.AppLife(acct.ID, app)
3727 if err != nil {
3728 log.Printf("console: %s %s/%s: re-read: %v", verb, acct.Sqid, app, err)
3729 return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err}
3730 }
3731 if life == nil {
3732 return nil, &lockedRefusal{Kind: lockedGone}
3733 }
3734 switch {
3735 case purge && (!life.Trashed() || life.Restoring):
3736 return nil, &lockedRefusal{Kind: lockedWrongState, Err: release.ErrNotTrashed}
3737 case !purge && life.Restoring:
3738 return nil, &lockedRefusal{Kind: lockedWrongState}
3739 case !purge && life.Trashed():
3740 tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app)
3741 if err != nil {
3742 log.Printf("console: %s %s/%s: re-read marker: %v", verb, acct.Sqid, app, err)
3743 return nil, &lockedRefusal{Kind: lockedUnreadable, Err: err}
3744 }
3745 if tomb == nil {
3746 return nil, &lockedRefusal{Kind: lockedWrongState}
3747 }
3748 // Already trashed, marker and all: the door's success path answers.
3749 }
3750 if msg, status := s.controlPlaneRefusal(acct, app, verb); msg != "" {
3751 return nil, &lockedRefusal{Kind: lockedControlPlane, Msg: msg, Status: status}
3752 }
3753 return life, nil
3754}
3755```
3756
3757restore.go already imports `release`, `log`, `context` and `time`; nothing else is needed.
3758
3759`handleAPITrashApp` (apicli.go:1196): keep the pre-lock `refuseControlPlane` at :1209 as an early hint. After the `s.cfg.Releases == nil` check and the `ctx` line (lines 1219-1220), before `GetProduction` (so the production flag is read under the lock):
3760
3761```go
3762 // Every transition takes the app's lock and settles first (spec §2, §5).
3763 // The checks above ran before the lock: ask again. An app already in the
3764 // Trash with its marker falls through to the idempotent path below.
3765 defer s.lockApp(acct.Sqid, app)()
3766 if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil {
3767 switch ref.Kind {
3768 case lockedGone:
3769 http.NotFound(w, r) // what apiAccountApp's gate answers for this app now
3770 case lockedControlPlane:
3771 jsonError(w, ref.Status, ref.Msg)
3772 default:
3773 // Existing copy (apicli.go:1275): the same answer an unreadable
3774 // marker already gets from this door, and the retry is safe.
3775 jsonError(w, http.StatusBadGateway, "could not confirm the trash window — try again")
3776 }
3777 return
3778 }
3779```
3780
3781`handleAppDelete` (appsettings.go:972): keep the pre-lock `controlPlaneRefusal` at :991 as a hint. After `requireFreshAuth` and the `ctx` line (:1008-1009), before `GetProduction` (:1013):
3782
3783```go
3784 defer s.lockApp(acct.Sqid, app)()
3785 if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil {
3786 switch ref.Kind {
3787 case lockedGone:
3788 back("err", app+" no longer exists.") // COPY-REVIEW C7.2
3789 case lockedControlPlane:
3790 back("err", ref.Msg)
3791 default:
3792 back("err", "Could not confirm the trash marker. Nothing was changed. Try again.") // COPY-REVIEW C7.1
3793 }
3794 return
3795 }
3796```
3797
3798`handlePurgeApp` (ownership.go:313): keep the pre-lock `controlPlaneRefusal` at :335 as a hint. After `requireFreshAuth` (:340) and the `s.cfg.Releases == nil` check (:343-346), before `PurgeReady` (:357), so the settle, the re-check, `PurgeReady`, the email and store checks, every teardown and the sweep all run under the one lock:
3799
3800```go
3801 defer s.lockApp(acct.Sqid, app)()
3802 if _, ref := s.recheckUnderLock(r.Context(), acct, app, "deleted", true, time.Now()); ref != nil {
3803 switch ref.Kind {
3804 case lockedControlPlane:
3805 fail(ref.Msg)
3806 case lockedGone:
3807 // Existing copy: purgeAdvice's "not in the trash ... may have been
3808 // restored, or already deleted" (ownership.go:500).
3809 fail(purgeAdvice(release.ErrNotTrashed, app))
3810 default:
3811 // lockedWrongState carries ErrNotTrashed; lockedUnreadable carries
3812 // the read error, which purgeAdvice answers with its existing
3813 // "still in the trash, try again" sentence.
3814 fail(purgeAdvice(ref.Err, app))
3815 }
3816 return
3817 }
3818```
3819
3820`TestPurgeRefusesARestoredAppBeforeTouchingItsStore` (deletion_test.go:745) now reaches its refusal one step earlier: the settle finds no marker and objects present, finishes the restore on the row, and `lockedWrongState` maps to the same `purgeAdvice(release.ErrNotTrashed, ...)` sentence the old `PurgeReady` refusal produced. Its store, variables, audit and object assertions are unchanged, and so is its "not in the trash" check.
3821
3822- [ ] **Step 4: Run the tests to verify they pass**
3823
3824Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestRestore|TestTrashDoorsSetTheWindowForTemporaryRows|TestManualPurgeAfterAFailedTrashRowUpdate|TestDestructiveDoorsRecheckAfterTheLock|TestTrashAfterAHeadlessTrashIsIdempotent|TestTrash|TestDeleteNow|TestPurge|TestAPITrash|TestAPIRestore|TestAppDelete|TestControlPlane' -count=1`
3825
3826Run: `GOCACHE=$TMPDIR/gocache go test ./internal/provision/ -run 'TestReconcileDeclinesTrashedApp' -count=1`
3827Expected: PASS, unchanged. This is the box-side half of `TestRestoreOfAProvisionedTemporaryStaticApp`: a record whose app's marker is gone is minted again. No box code changes.
3828Expected: PASS, with `TestAPITrashTwiceKeepsTheClock`, `TestAPITrashUnconfirmedWindowIsRefused` and `TestPurgeRefusesARestoredAppBeforeTouchingItsStore` passing unchanged: this task edits none of their assertions.
3829
3830- [ ] **Step 5: Commit**
3831
3832```bash
3833gofmt -l internal/console
3834git add internal/console/restore.go internal/console/restore_test.go internal/console/temphook_test.go internal/console/apicli.go internal/console/ownership.go internal/console/appsettings.go
3835git commit -m "console: one restore for every door, trash doors lock and settle
3836
3837Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
3838```
3839
3840---
3841### Task 8: The expiry pass
3842
3843**Files:**
3844- Create: `internal/console/expirypass.go`
3845- Modify: `cmd/console/main.go:372` (start the pass after `StartScanPass`)
3846- Test: `internal/console/expirypass_test.go` (new)
3847
3848**Interfaces:**
3849- Consumes: `Store.TemporaryAppRows`, `Store.PurgeDueApps`, `Store.AppLife`, `Store.ExpireAppRow`, `Store.PurgeAppRow`, `AppRow` (Task 2); `lockApp`, `settleApp`, `listEvidence`, `isTrashMarkerKey`, `evidenceFunc` (Task 4); `release.TrashApp`, `release.ReadAppTombstone`, `release.PurgeAppIfTrashedAt`, `release.ErrNotTrashed` (Task 1); `mail.Get`, `s.mailAppDomains` (apimail.go:308); `s.audit`, `s.bumpSerial`; test helpers `tempApp`, `lifeOf`, `markerOf`, `trashedTemp`, `hookStore`, `deletableStore` (view_test.go:20), `putMailRecord` (appsettings_test.go:1164).
3850- Produces:
3851 - `const expiryPassInterval = 5 * time.Minute`, `const expiryActor = "expiry"`
3852 - `func (s *Server) StartExpiryPass(ctx context.Context)`
3853 - `func (s *Server) expiryPass(ctx context.Context, now time.Time)` (the clock is a parameter: tests drive it directly)
3854 - `func (s *Server) settleSweep(ctx context.Context, rows []AppRow, keys []string, now time.Time, ev evidenceFunc)`
3855 - `func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc)`
3856 - `func (s *Server) purgeOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc)`
3857 - `func (s *Server) mailEstate(ctx context.Context, acct *Account, app string) (bool, error)`
3858
3859- [ ] **Step 1: Write the failing tests**
3860
3861```go
3862// internal/console/expirypass_test.go
3863package console
3864
3865import (
3866 "bytes"
3867 "context"
3868 "log"
3869 "os"
3870 "path/filepath"
3871 "strings"
3872 "testing"
3873 "time"
3874
3875 "github.com/carlosframework/platform/internal/mail"
3876 "github.com/carlosframework/platform/internal/release"
3877)
3878
3879// Spec §9: expire -> trash -> purge at 3 days, on a fake clock.
3880func TestExpiryPassExpiresThenPurgesAtThreeDays(t *testing.T) {
3881 fx := newConsoleFixture(t)
3882 ctx := context.Background()
3883 now := time.Now().Truncate(time.Second).Add(time.Hour)
3884 tempApp(t, fx, "tmp-a", now.Add(-time.Minute))
3885 fx.srv.expiryPass(ctx, now)
3886 tomb := markerOf(t, fx, "tmp-a")
3887 if tomb == nil || !tomb.DeletedAt.Equal(now) || tomb.Actor != expiryActor {
3888 t.Fatalf("marker = %+v, want one written by the pass at %v", tomb, now)
3889 }
3890 if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != now.Unix() || l.PurgeAfter != now.Unix()+72*3600 {
3891 t.Fatalf("row = %+v, want trashed with the 3-day window", l)
3892 }
3893 fx.srv.expiryPass(ctx, now.Add(72*time.Hour-time.Second))
3894 if markerOf(t, fx, "tmp-a") == nil {
3895 t.Fatal("purged before the 3 days were up")
3896 }
3897 fx.srv.expiryPass(ctx, now.Add(72*time.Hour))
3898 if keys, _ := fx.rel.List(ctx, "apps/"+fx.sqid+"/tmp-a/"); len(keys) != 0 {
3899 t.Fatalf("objects left after the early purge: %v", keys)
3900 }
3901 if l := lifeOf(t, fx, "tmp-a"); l != nil {
3902 t.Fatalf("row = %+v, want it gone with the name", l)
3903 }
3904}
3905
3906// Review Focus 3.
3907func TestExpiryBoundaryIsInclusive(t *testing.T) {
3908 fx := newConsoleFixture(t)
3909 now := time.Now().Truncate(time.Second)
3910 tempApp(t, fx, "tmp-now", now)
3911 tempApp(t, fx, "tmp-later", now.Add(time.Second))
3912 fx.srv.expiryPass(context.Background(), now)
3913 if l := lifeOf(t, fx, "tmp-now"); !l.Trashed() {
3914 t.Error("expires_at == now did not expire")
3915 }
3916 if l := lifeOf(t, fx, "tmp-later"); l.Trashed() {
3917 t.Error("expires_at == now+1s expired early")
3918 }
3919}
3920
3921// Spec §9: a keep between candidate listing and lock acquisition; the pass
3922// must skip.
3923func TestExpirySkipsAKeepThatLandedAfterTheListing(t *testing.T) {
3924 fx := newConsoleFixture(t)
3925 now := time.Now().Truncate(time.Second)
3926 tempApp(t, fx, "tmp-a", now.Add(-time.Minute))
3927 rows, _ := fx.st.TemporaryAppRows()
3928 fx.st.KeepApp(OpsAccountID, "tmp-a")
3929 for _, r := range rows {
3930 fx.srv.expireOne(context.Background(), r, now, always(true))
3931 }
3932 if markerOf(t, fx, "tmp-a") != nil {
3933 t.Fatal("the pass trashed an app a keep had made permanent")
3934 }
3935 if l := lifeOf(t, fx, "tmp-a"); l.Trashed() || l.ExpiresAt != 0 {
3936 t.Fatalf("row = %+v, want live and permanent", l)
3937 }
3938}
3939
3940// Spec §9: failure at step 3 retries.
3941func TestExpiryRetriesAFailedTrash(t *testing.T) {
3942 fx := newConsoleFixture(t)
3943 now := time.Now().Truncate(time.Second)
3944 tempApp(t, fx, "tmp-a", now.Add(-time.Minute))
3945 fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failPut: map[string]int{release.AppTombstoneKey(fx.sqid, "tmp-a"): 1}}
3946 fx.srv.expiryPass(context.Background(), now)
3947 if markerOf(t, fx, "tmp-a") != nil || lifeOf(t, fx, "tmp-a").Trashed() {
3948 t.Fatal("a failed marker write changed something")
3949 }
3950 fx.srv.expiryPass(context.Background(), now.Add(5*time.Minute))
3951 if l := lifeOf(t, fx, "tmp-a"); !l.Trashed() || l.DeletedAt != now.Add(5*time.Minute).Unix() {
3952 t.Fatalf("row after the retry = %+v", l)
3953 }
3954}
3955
3956// Spec §9: restore -> keep -> trash between decision and purge; the stale
3957// decision refuses through PurgeAppIfTrashedAt.
3958func TestEarlyPurgeRefusesAMarkerFromAnotherTrash(t *testing.T) {
3959 fx := newConsoleFixture(t)
3960 ctx := context.Background()
3961 now := time.Now().Truncate(time.Second)
3962 decided := now.Add(-72 * time.Hour)
3963 trashedTemp(t, fx, "tmp-a", decided)
3964 // The bucket moved on: restored, kept and trashed again by hand.
3965 release.RestoreApp(ctx, fx.rel, fx.sqid, "tmp-a")
3966 release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "paul@keymail.dev", now.Add(-time.Hour))
3967 rows, _ := fx.st.PurgeDueApps(now)
3968 for _, r := range rows {
3969 fx.srv.purgeOne(ctx, r, now, always(true))
3970 }
3971 if keys, _ := fx.rel.List(ctx, "apps/"+fx.sqid+"/tmp-a/"); len(keys) < 2 {
3972 t.Fatalf("a stale decision destroyed objects: %v", keys)
3973 }
3974 if lifeOf(t, fx, "tmp-a") == nil {
3975 t.Fatal("a stale decision freed the name")
3976 }
3977}
3978
3979// Spec §9: an interrupted purge resumes.
3980func TestEarlyPurgeResumesAfterAnInterruption(t *testing.T) {
3981 fx := newConsoleFixture(t)
3982 ctx := context.Background()
3983 now := time.Now().Truncate(time.Second)
3984 trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour))
3985 fx.rel.Put(ctx, "apps/"+fx.sqid+"/tmp-a/releases/v1/site.tar.gz", []byte("x"), "")
3986 fx.srv.cfg.Releases = &hookStore{Store: fx.rel, failDelete: map[string]int{"apps/" + fx.sqid + "/tmp-a/releases/v1/site.tar.gz": 1}}
3987 fx.srv.expiryPass(ctx, now)
3988 if lifeOf(t, fx, "tmp-a") == nil || markerOf(t, fx, "tmp-a") == nil {
3989 t.Fatal("an interrupted purge lost its marker or its row")
3990 }
3991 fx.srv.expiryPass(ctx, now.Add(5*time.Minute))
3992 if l := lifeOf(t, fx, "tmp-a"); l != nil {
3993 t.Fatalf("row = %+v after the resumed purge, want gone", l)
3994 }
3995}
3996
3997// Spec §9: ErrNotTrashed with an empty prefix finalises; with objects, restores.
3998//
3999// The branch is only reached when the marker disappears AFTER purgeOne's
4000// settle read it and BEFORE PurgeAppIfTrashedAt reads it: a marker gone
4001// before the pass is repaired by the settle sweep and never gets that far.
4002// So the hook deletes the marker on its second read. Reads of the marker key
4003// in one pass over a due, consistent row: purgeOne's settleApp (1), then
4004// PurgeAppIfTrashedAt's purgeReady (2). The sweep reads nothing for a row
4005// whose trash state already agrees with the listing.
4006func TestEarlyPurgeNotTrashedBranches(t *testing.T) {
4007 for _, objects := range []bool{false, true} {
4008 t.Run(map[bool]string{false: "no objects finalises", true: "objects restore"}[objects], func(t *testing.T) {
4009 fx := newConsoleFixture(t)
4010 ctx := context.Background()
4011 now := time.Now().Truncate(time.Second)
4012 trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour))
4013 if !objects {
4014 // The marker is the only key left under the prefix. kass's
4015 // objects keep the tripwire satisfied.
4016 if err := fx.rel.Delete(ctx, "apps/"+fx.sqid+"/tmp-a/channels/edge.json"); err != nil {
4017 t.Fatal(err)
4018 }
4019 }
4020 marker := release.AppTombstoneKey(fx.sqid, "tmp-a")
4021 reads := 0
4022 fx.srv.cfg.Releases = &hookStore{Store: fx.rel, onGet: func(key string) {
4023 if key != marker {
4024 return
4025 }
4026 reads++
4027 if reads == 2 {
4028 fx.rel.Delete(ctx, marker)
4029 }
4030 }}
4031 var logged bytes.Buffer
4032 log.SetOutput(&logged)
4033 t.Cleanup(func() { log.SetOutput(os.Stderr) })
4034
4035 fx.srv.expiryPass(ctx, now)
4036
4037 if reads < 2 {
4038 t.Fatalf("the marker was read %d time(s); the purge never reached its own precondition read", reads)
4039 }
4040 if !strings.Contains(logged.String(), "left the trash before its purge") {
4041 t.Fatalf("the ErrNotTrashed branch did not run; log:\n%s", logged.String())
4042 }
4043 l := lifeOf(t, fx, "tmp-a")
4044 if !objects {
4045 if l != nil {
4046 t.Fatalf("row = %+v, want finalised (no marker, no objects, evidence)", l)
4047 }
4048 return
4049 }
4050 if l == nil || l.Trashed() || l.Restoring || l.PurgeAfter != 0 || l.ExpiresAt != now.Add(temporaryTTLDefault).Unix() {
4051 t.Fatalf("row = %+v, want restored: live, no deadline, expiry now+7d", l)
4052 }
4053 if keys, _ := fx.rel.List(ctx, "apps/"+fx.sqid+"/tmp-a/"); len(keys) == 0 {
4054 t.Fatal("the restore branch destroyed objects")
4055 }
4056 })
4057 }
4058}
4059
4060// Spec §9: control-plane and email-estate backstops refuse without tearing down.
4061func TestEarlyPurgeBackstops(t *testing.T) {
4062 ctx := context.Background()
4063 now := time.Now().Truncate(time.Second)
4064 t.Run("control plane", func(t *testing.T) {
4065 fx := newConsoleFixture(t)
4066 trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour))
4067 // Unreachable through any door (Task 6); set by hand to prove the backstop.
4068 fx.st.db.Exec(`UPDATE apps SET control_plane = 1 WHERE app = 'tmp-a'`)
4069 fx.srv.expiryPass(ctx, now)
4070 if markerOf(t, fx, "tmp-a") == nil || lifeOf(t, fx, "tmp-a") == nil {
4071 t.Fatal("the pass purged the control plane")
4072 }
4073 })
4074 t.Run("email estate", func(t *testing.T) {
4075 fx := newConsoleFixture(t)
4076 trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour))
4077 putMailRecord(t, fx, "tmp-a", &mail.Record{
4078 Declared: &mail.Declared{EnvPrefix: "CARLOS_SMTP", From: "hi@example.com", At: now},
4079 Provisioned: &mail.Provisioned{KeyID: "AKIA1", Active: true, At: now},
4080 })
4081 fx.srv.expiryPass(ctx, now)
4082 if markerOf(t, fx, "tmp-a") == nil || lifeOf(t, fx, "tmp-a") == nil {
4083 t.Fatal("the pass purged an app with email estate")
4084 }
4085 })
4086}
4087
4088// Spec §9 "Empty bucket": startup materialize and several passes against an
4089// empty replacement bucket, then one holding only trash markers. No marker is
4090// written and both rows survive unchanged.
4091func TestExpiryPassAgainstTheWrongBucket(t *testing.T) {
4092 for _, markersOnly := range []bool{false, true} {
4093 fx := newConsoleFixture(t)
4094 ctx := context.Background()
4095 now := time.Now().Truncate(time.Second)
4096 trashedTemp(t, fx, "tmp-due", now.Add(-72*time.Hour))
4097 tempApp(t, fx, "tmp-expired", now.Add(-time.Hour))
4098 before := map[string]AppLife{"tmp-due": *lifeOf(t, fx, "tmp-due"), "tmp-expired": *lifeOf(t, fx, "tmp-expired")}
4099 dir := filepath.Join(t.TempDir(), "wrong")
4100 wrong := &deletableStore{Store: release.NewDirStore(dir), root: dir}
4101 if markersOnly {
4102 release.TrashApp(ctx, wrong, fx.sqid, "someone-else", "paul@keymail.dev", now)
4103 }
4104 fx.srv.cfg.Releases = wrong
4105 materializeQuietly(ctx, wrong, fx.st)
4106 for i := 0; i < 3; i++ {
4107 fx.srv.expiryPass(ctx, now.Add(time.Duration(i)*5*time.Minute))
4108 }
4109 keys, _ := wrong.List(ctx, "apps/")
4110 if want := map[bool]int{false: 0, true: 1}[markersOnly]; len(keys) != want {
4111 t.Fatalf("markersOnly=%v: the wrong bucket now holds %v", markersOnly, keys)
4112 }
4113 for app, b := range before {
4114 if l := lifeOf(t, fx, app); l == nil || *l != b {
4115 t.Fatalf("markersOnly=%v: %s = %+v, want unchanged %+v", markersOnly, app, l, b)
4116 }
4117 }
4118 }
4119}
4120
4121// Spec §9 "Settle sweep": a manual trash of a temporary app with 30 days left
4122// whose row update failed. No restart, no user action: the next pass settles
4123// it, and the early purge runs at 3 days.
4124func TestSettleSweepRepairsAFailedManualTrash(t *testing.T) {
4125 fx := newConsoleFixture(t)
4126 ctx := context.Background()
4127 now := time.Now().Truncate(time.Second)
4128 tempApp(t, fx, "tmp-a", now.Add(30*24*time.Hour))
4129 release.TrashApp(ctx, fx.rel, fx.sqid, "tmp-a", "paul@keymail.dev", now)
4130 fx.srv.expiryPass(ctx, now.Add(time.Minute))
4131 if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != now.Unix() || l.PurgeAfter != now.Unix()+72*3600 {
4132 t.Fatalf("row after the sweep = %+v, want trashed at the marker's clock with the 3-day window", l)
4133 }
4134 fx.srv.expiryPass(ctx, now.Add(72*time.Hour))
4135 if l := lifeOf(t, fx, "tmp-a"); l != nil {
4136 t.Fatalf("row = %+v, want purged at 3 days", l)
4137 }
4138}
4139
4140// Spec §9: an early purge whose final marker delete fails, with no other keys
4141// in the bucket. The next pass trips the tripwire, still retries the purge,
4142// finishes, and removes the row.
4143func TestEarlyPurgeOfTheLastAppRetriesUnderTheTripwire(t *testing.T) {
4144 fx := newConsoleFixture(t)
4145 ctx := context.Background()
4146 now := time.Now().Truncate(time.Second)
4147 dir := filepath.Join(t.TempDir(), "only")
4148 only := &deletableStore{Store: release.NewDirStore(dir), root: dir}
4149 fx.srv.cfg.Releases = only
4150 fx.rel = only
4151 trashedTemp(t, fx, "tmp-a", now.Add(-72*time.Hour))
4152 marker := release.AppTombstoneKey(fx.sqid, "tmp-a")
4153 fx.srv.cfg.Releases = &hookStore{Store: only, failDelete: map[string]int{marker: 1}}
4154 fx.srv.expiryPass(ctx, now)
4155 if keys, _ := only.List(ctx, "apps/"); len(keys) != 1 || keys[0] != marker {
4156 t.Fatalf("after the first pass the bucket holds %v, want only the marker", keys)
4157 }
4158 if lifeOf(t, fx, "tmp-a") == nil {
4159 t.Fatal("the row went before the marker")
4160 }
4161 fx.srv.expiryPass(ctx, now.Add(5*time.Minute))
4162 if keys, _ := only.List(ctx, "apps/"); len(keys) != 0 {
4163 t.Fatalf("the retry left %v", keys)
4164 }
4165 if l := lifeOf(t, fx, "tmp-a"); l != nil {
4166 t.Fatalf("row = %+v, want gone", l)
4167 }
4168}
4169```
4170
4171`fx.rel = only` in the last test repoints `tempApp`/`markerOf` at the one-app bucket; `trashedTemp` seeds through `fx.rel`. The fixture's `kass` row has no objects in that bucket and stays live: a live row with no marker is never touched.
4172
4173- [ ] **Step 2: Run the tests to verify they fail**
4174
4175Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestExpiry|TestEarlyPurge|TestSettleSweep' -count=1`
4176Expected: FAIL to compile: `fx.srv.expiryPass undefined`, `undefined: expiryActor`.
4177
4178- [ ] **Step 3: Implement**
4179
4180Create `internal/console/expirypass.go`:
4181
4182```go
4183// The expiry pass (temporary static sites spec §5): the only actor that
4184// expires or early-purges anything. Its own ticker, beside economicspass.go
4185// and scanpass.go. Every step takes the per-app mutex keep, ttl, restore,
4186// trash and the purge door take, settles first, and re-reads the row: the
4187// candidate lists taken before the lock are only hints.
4188package console
4189
4190import (
4191 "context"
4192 "errors"
4193 "log"
4194 "time"
4195
4196 "github.com/carlosframework/platform/internal/mail"
4197 "github.com/carlosframework/platform/internal/release"
4198)
4199
4200const (
4201 expiryPassInterval = 5 * time.Minute
4202 // expiryActor is who the pass's trash markers and audit rows name.
4203 expiryActor = "expiry"
4204)
4205
4206// StartExpiryPass runs the pass now and then every five minutes until ctx is
4207// done, StartScanPass's shape. It runs whether or not CARLOS_TEMPORARY_APPS is
4208// set: apps created while it was on must still expire after it is turned off.
4209func (s *Server) StartExpiryPass(ctx context.Context) {
4210 if s.cfg.Releases == nil {
4211 return
4212 }
4213 t := time.NewTicker(expiryPassInterval)
4214 defer t.Stop()
4215 for {
4216 s.expiryPass(ctx, time.Now())
4217 select {
4218 case <-ctx.Done():
4219 return
4220 case <-t.C:
4221 }
4222 }
4223}
4224
4225// expiryPass is one pass at the given clock.
4226//
4227// It lists apps/ once, for the tripwire and the settle sweep. With no
4228// evidence (spec §2, a tripped pass) it expires nothing and finalises nothing
4229// from absence, but it still runs the sweep's presence branch and still
4230// retries early purges: PurgeAppIfTrashedAt acts only on a marker whose clock
4231// matches the row, and a wrong, empty bucket has no such marker.
4232func (s *Server) expiryPass(ctx context.Context, now time.Time) {
4233 rows, err := s.store.TemporaryAppRows()
4234 if err != nil {
4235 log.Printf("console: expiry pass: list temporary apps: %v", err)
4236 return
4237 }
4238 due, err := s.store.PurgeDueApps(now)
4239 if err != nil {
4240 log.Printf("console: expiry pass: list purges due: %v", err)
4241 return
4242 }
4243 if len(rows) == 0 && len(due) == 0 {
4244 return
4245 }
4246 keys, listed, evidence := s.listEvidence(ctx)
4247 ev := func(context.Context) bool { return evidence }
4248 if !evidence {
4249 log.Printf("console: expiry pass: the apps/ listing holds nothing but trash markers, or could not be read; " +
4250 "expiring nothing and finalising nothing this pass. Is CARLOS_DEPLOYMENT_BUCKET pointing at the right bucket?")
4251 }
4252 if listed {
4253 s.settleSweep(ctx, rows, keys, now, ev)
4254 }
4255 if evidence {
4256 for _, r := range rows {
4257 if !r.Deleted && r.ExpiresAt > 0 && r.ExpiresAt <= now.Unix() {
4258 s.expireOne(ctx, r, now, ev)
4259 }
4260 }
4261 }
4262 for _, r := range due {
4263 s.purgeOne(ctx, r, now, ev)
4264 }
4265}
4266
4267// settleSweep (spec §5, astra 8.1) compares every temporary row with the
4268// listing already taken: a row whose trash state disagrees with its marker,
4269// or that is mid-restore, is settled under its lock. This repairs a trash door
4270// that wrote the marker and failed its row update while the expiry is still
4271// far off. No extra listing; a GET only for the rows that disagree.
4272func (s *Server) settleSweep(ctx context.Context, rows []AppRow, keys []string, now time.Time, ev evidenceFunc) {
4273 markers := make(map[string]bool)
4274 for _, k := range keys {
4275 if isTrashMarkerKey(k) {
4276 markers[k] = true
4277 }
4278 }
4279 for _, r := range rows {
4280 if markers[release.AppTombstoneKey(r.Sqid, r.App)] == r.Deleted && !r.Restoring {
4281 continue
4282 }
4283 func() {
4284 defer s.lockApp(r.Sqid, r.App)()
4285 if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil {
4286 log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err)
4287 }
4288 }()
4289 }
4290}
4291
4292// expireOne is spec §5 "Expire", steps 1-4, for one candidate.
4293func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) {
4294 defer s.lockApp(r.Sqid, r.App)()
4295 if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil {
4296 log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err)
4297 return
4298 }
4299 life, err := s.store.AppLife(r.AccountID, r.App)
4300 if err != nil {
4301 log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err)
4302 return
4303 }
4304 // A keep that ran after the listing set expires_at = 0, which fails > 0.
4305 if life == nil || life.ExpiresAt <= 0 || life.ExpiresAt > now.Unix() || life.Trashed() || life.Restoring {
4306 return
4307 }
4308 acct := &Account{ID: r.AccountID, Sqid: r.Sqid}
4309 if life.ControlPlane {
4310 log.Printf("console: expiry pass: %s/%s is marked as the control plane; not expiring it", r.Sqid, r.App)
4311 s.audit(acct, r.App, expiryActor, "expire-refused", "did not move "+r.App+" to the trash: it is marked as the control plane") // COPY-REVIEW C8.1
4312 return
4313 }
4314 if err := release.TrashApp(ctx, s.cfg.Releases, r.Sqid, r.App, expiryActor, now); err != nil {
4315 log.Printf("console: expiry pass: trash %s/%s: %v", r.Sqid, r.App, err)
4316 return // step 3 wrote nothing; the next pass tries again
4317 }
4318 // Create-only: a marker that already existed keeps its clock, the same
4319 // re-read handleAPITrashApp does.
4320 tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, r.Sqid, r.App)
4321 if err != nil || tomb == nil {
4322 log.Printf("console: expiry pass: re-read marker %s/%s: %v", r.Sqid, r.App, err)
4323 return
4324 }
4325 if ok, err := s.store.ExpireAppRow(r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok {
4326 // The marker stands and the row is live. The next door or pass to
4327 // touch the app settles it, with the temporary window (spec §5).
4328 log.Printf("console: expiry pass: mark %s/%s trashed: changed=%v err=%v", r.Sqid, r.App, ok, err)
4329 return
4330 }
4331 s.audit(acct, r.App, expiryActor, "expire", "moved "+r.App+" to the trash: it reached its expiry") // COPY-REVIEW C8.2
4332 s.bumpSerial(ctx)
4333}
4334
4335// purgeOne is spec §5 "Early purge" for one candidate.
4336func (s *Server) purgeOne(ctx context.Context, r AppRow, now time.Time, ev evidenceFunc) {
4337 defer s.lockApp(r.Sqid, r.App)()
4338 if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil {
4339 log.Printf("console: expiry pass: settle %s/%s: %v", r.Sqid, r.App, err)
4340 return
4341 }
4342 life, err := s.store.AppLife(r.AccountID, r.App)
4343 if err != nil {
4344 log.Printf("console: expiry pass: read %s/%s: %v", r.Sqid, r.App, err)
4345 return
4346 }
4347 if life == nil || !life.Trashed() || life.PurgeAfter <= 0 || life.PurgeAfter > now.Unix() || life.PurgeFor <= 0 {
4348 return // restored, purged, or never due: settling answered it
4349 }
4350 acct := &Account{ID: r.AccountID, Sqid: r.Sqid}
4351 // The backstops (spec §5). §4 makes both unreachable; if either is ever
4352 // reached, the app waits for a person at the console's purge door.
4353 if life.ControlPlane {
4354 log.Printf("console: expiry pass: %s/%s is marked as the control plane; not purging it", r.Sqid, r.App)
4355 s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it is marked as the control plane") // COPY-REVIEW C8.3
4356 return
4357 }
4358 switch estate, err := s.mailEstate(ctx, acct, r.App); {
4359 case err != nil:
4360 log.Printf("console: expiry pass: read email estate %s/%s: %v; not purging", r.Sqid, r.App, err)
4361 return
4362 case estate:
4363 log.Printf("console: expiry pass: %s/%s has email estate; not purging it", r.Sqid, r.App)
4364 s.audit(acct, r.App, expiryActor, "purge-refused", "did not delete "+r.App+": it has email set up. Delete it from the Trash page") // COPY-REVIEW C8.4
4365 return
4366 }
4367 err = release.PurgeAppIfTrashedAt(ctx, s.cfg.Releases, s.cfg.ReleasePub, r.Sqid, r.App, time.Unix(life.PurgeFor, 0), now)
4368 switch {
4369 case err == nil:
4370 if err := s.store.PurgeAppRow(r.AccountID, r.App); err != nil {
4371 log.Printf("console: expiry pass: release the name %s/%s: %v", r.Sqid, r.App, err)
4372 return
4373 }
4374 s.audit(acct, r.App, expiryActor, "purge", "deleted "+r.App+" permanently, 3 days after it went to the trash") // COPY-REVIEW C8.5
4375 case errors.Is(err, release.ErrNotTrashed):
4376 // Not "restored" (spec §5, astra 2.4): a finished purge whose row
4377 // write failed, a box purge, a headless restore and a crashed
4378 // restore all look the same at the marker. settleApp tells them
4379 // apart by listing the prefix, under the tripwire.
4380 log.Printf("console: expiry pass: %s/%s left the trash before its purge; settling it", r.Sqid, r.App)
4381 if err := s.settleApp(ctx, r.AccountID, r.Sqid, r.App, now, ev); err != nil {
4382 log.Printf("console: expiry pass: settle %s/%s after ErrNotTrashed: %v", r.Sqid, r.App, err)
4383 }
4384 default:
4385 // Interrupted, a clock mismatch, a production flag, store estate:
4386 // purge_after stays, and the next pass resumes or refuses again.
4387 log.Printf("console: expiry pass: early purge %s/%s: %v", r.Sqid, r.App, err)
4388 }
4389}
4390
4391// mailEstate is the purge door's email test (ownership.go, handlePurgeApp):
4392// a provisioned sender, a standalone credential, or any domain claim. Unlike
4393// the door, an unreadable domain list is an error here: the pass acts on its
4394// own authority and fails toward keeping.
4395func (s *Server) mailEstate(ctx context.Context, acct *Account, app string) (bool, error) {
4396 rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app)
4397 if err != nil {
4398 return false, err
4399 }
4400 if rec != nil && (rec.Provisioned != nil || len(rec.Standalone) > 0) {
4401 return true, nil
4402 }
4403 doms, err := s.mailAppDomains(ctx, acct, app)
4404 if err != nil {
4405 return false, err
4406 }
4407 return len(doms) > 0, nil
4408}
4409```
4410
4411`cmd/console/main.go`, after `go srv.StartScanPass(...)` (line 372):
4412
4413```go
4414 // The temporary-app expiry pass (internal/console/expirypass.go). Same
4415 // placement rule as every pass above: started here, never in NewServer.
4416 go srv.StartExpiryPass(context.Background())
4417```
4418
4419- [ ] **Step 4: Run the tests to verify they pass**
4420
4421Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestExpiry|TestEarlyPurge|TestSettleSweep|TestSettleApp|TestMaterialize' -count=1`
4422Expected: PASS.
4423
4424Run: `GOCACHE=$TMPDIR/gocache go build ./cmd/console/`
4425Expected: builds.
4426
4427- [ ] **Step 5: Commit**
4428
4429```bash
4430gofmt -l internal/console cmd/console
4431git add internal/console/expirypass.go internal/console/expirypass_test.go cmd/console/main.go
4432git commit -m "console: the expiry pass expires, sweeps, and purges temporary apps at 3 days
4433
4434Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
4435```
4436
4437---
4438### Task 9: `ttl`, `keep` and `list` doors, and the Keep button
4439
4440**Files:**
4441- Modify: `internal/console/apitemporary.go` (Task 5's file: helpers and handlers)
4442- Modify: `internal/console/authcli.go` (three routes after `/new-temporary`)
4443- Modify: `internal/console/server.go:1202` (browser route after `/rename`), `:1441` (`page`: `ExpiresAt`), `:2685-2702` (`appPage`)
4444- Modify: `internal/console/templates/appnav.html:2-5`
4445- Test: `internal/console/apitemporary_test.go`
4446
4447**Interfaces:**
4448- Consumes: `lockApp`, `settleApp`, `doorEvidence` (Task 4); `temporaryTTL` (Task 5); `Store.AppLife`, `Store.KeepApp`, `Store.SetAppExpiry`, `Store.AppLivesIn`, `effectivePurgeAt` (Task 2); `apiAccountApp`, `apiPrincipalAccount`, `allowWrite`, `accountApp`.
4449- Produces:
4450 - `var errAppGone, errTemporaryInTrash, errNotTemporary error`
4451 - `func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error`
4452 - `func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error)`
4453 - `func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request)`, `handleAPIAppTTL`, `handleAPIAppList`, `handleAppKeep` (browser)
4454 - `page.ExpiresAt time.Time` (zero for a permanent app)
4455 - Wire: `POST /api/cli/apps/{account}/{app}/ttl {"ttl_seconds": N}` → 200 `{"app", "expires_at"}`; `POST /api/cli/apps/{account}/{app}/keep` → 200 `{"app", "expires_at": null}`; `GET /api/cli/apps/{account}/list` → 200 JSON array of `{"app", "deleted_at", "expires_at", "purge_after"}`, RFC 3339 or null, trashed apps included, `purge_after` = `effectivePurgeAt` for trashed rows; `POST /a/{sqid}/apps/{app}/keep` (browser, any member, CSRF, no passkey)
4456
4457- [ ] **Step 1: Write the failing tests**
4458
4459Append to `internal/console/apitemporary_test.go` (add `"context"`, `"encoding/json"`, `"net/url"` and `release` to its imports if missing). `postJSON`/`getJSON` are only used where the door answers JSON; a door expected to answer `http.NotFound`'s plain text is driven with `rawReq` and checked with `assertNotFound` (apiinstances_test.go:42-96).
4460
4461```go
4462func appURL(fx *consoleFixture, app, tail string) string {
4463 return fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/" + app + tail
4464}
4465
4466func TestKeepMakesATemporaryAppPermanent(t *testing.T) {
4467 fx, token := temporaryFixture(t)
4468 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
4469 status, out := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token)
4470 if status != http.StatusOK || out["app"] != "tmp-a" || out["expires_at"] != nil {
4471 t.Fatalf("keep = %d %v, want 200 with expires_at null", status, out)
4472 }
4473 if l := lifeOf(t, fx, "tmp-a"); l.Temporary() {
4474 t.Fatalf("row = %+v, want permanent", l)
4475 }
4476}
4477
4478func TestTTLMovesTheExpiry(t *testing.T) {
4479 fx, token := temporaryFixture(t)
4480 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
4481 before := time.Now()
4482 status, out := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{"ttl_seconds": 5 * 86400}, token)
4483 if status != http.StatusOK {
4484 t.Fatalf("ttl = %d %v", status, out)
4485 }
4486 exp, _ := time.Parse(time.RFC3339, str(t, out, "expires_at"))
4487 if d := exp.Sub(before); d < 5*24*time.Hour-time.Minute || d > 5*24*time.Hour+time.Minute {
4488 t.Fatalf("expiry is %v away, want 5 days", d)
4489 }
4490 if l := lifeOf(t, fx, "tmp-a"); l.ExpiresAt != exp.Unix() {
4491 t.Fatalf("row = %+v", l)
4492 }
4493}
4494
4495// Review Focus 4.
4496func TestTTLAndKeepRefuseWhatTheyCannotChange(t *testing.T) {
4497 fx, token := temporaryFixture(t)
4498 for _, tail := range []string{"/keep", "/ttl"} {
4499 status, out := postJSON(t, appURL(fx, "kass", tail), map[string]int64{"ttl_seconds": 86400}, token)
4500 if status != http.StatusConflict {
4501 t.Errorf("%s on a permanent app = %d %v, want 409", tail, status, out)
4502 }
4503 }
4504 if l := lifeOf(t, fx, "kass"); l.Temporary() {
4505 t.Fatal("a door made a permanent app temporary")
4506 }
4507 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
4508 if status, _ := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{"ttl_seconds": 3599}, token); status != http.StatusBadRequest {
4509 t.Errorf("ttl 3599 = %d, want 400", status)
4510 }
4511 if status, _ := postJSON(t, appURL(fx, "tmp-a", "/ttl"), map[string]int64{}, token); status != http.StatusBadRequest {
4512 t.Errorf("ttl with no ttl_seconds = %d, want 400", status)
4513 }
4514 fx.st.TrashAppRow(OpsAccountID, "tmp-a", time.Now())
4515 trashed := *lifeOf(t, fx, "tmp-a")
4516 // The live-app gate's answer is http.NotFound's plain text, not JSON, so
4517 // these go through rawReq (apiinstances_test.go:42): postJSON's doJSON
4518 // fails the test on any body that does not decode (authcli_test.go:62).
4519 for _, tail := range []string{"/keep", "/ttl"} {
4520 status, h, body := rawReq(t, http.MethodPost, appURL(fx, "tmp-a", tail), token, `{"ttl_seconds":86400}`)
4521 assertNotFound(t, tail+" on a trashed app", canonicalNotFound(t), status, h, body)
4522 }
4523 if l := lifeOf(t, fx, "tmp-a"); l == nil || *l != trashed {
4524 t.Fatalf("row = %+v after refused keep/ttl, want unchanged %+v", l, trashed)
4525 }
4526}
4527
4528// Spec §9 "failure at step 4 followed by a keep": the marker is written and
4529// the row is live and expired. Keep settles first, finds the app in the
4530// Trash, and refuses; the row is left trashed with the 3-day window.
4531func TestKeepAfterAFailedExpiryStepFourIsRefused(t *testing.T) {
4532 fx, token := temporaryFixture(t)
4533 at := time.Now().Truncate(time.Second).Add(-time.Minute)
4534 tempApp(t, fx, "tmp-a", at)
4535 if err := release.TrashApp(context.Background(), fx.rel, fx.sqid, "tmp-a", expiryActor, at); err != nil {
4536 t.Fatal(err)
4537 }
4538 status, out := postJSON(t, appURL(fx, "tmp-a", "/keep"), map[string]string{}, token)
4539 if status != http.StatusConflict || !strings.Contains(str(t, out, "error"), "carlos apps restore") {
4540 t.Fatalf("keep = %d %v, want 409 pointing at carlos apps restore", status, out)
4541 }
4542 if l := lifeOf(t, fx, "tmp-a"); l.DeletedAt != at.Unix() || l.PurgeAfter != at.Unix()+72*3600 || !l.Temporary() {
4543 t.Fatalf("row = %+v, want trashed at the marker's clock with the 3-day window, still temporary", l)
4544 }
4545}
4546
4547// Spec §9 "/list": trashed apps shown; the accounts listing's []string
4548// unchanged; service-principal filtering matches.
4549func TestListShowsTheTrashAndFiltersServices(t *testing.T) {
4550 fx, token := temporaryFixture(t)
4551 now := time.Now().Truncate(time.Second)
4552 tempApp(t, fx, "tmp-live", now.Add(3*24*time.Hour))
4553 tempApp(t, fx, "tmp-gone", now.Add(time.Hour))
4554 fx.st.TrashAppRow(OpsAccountID, "tmp-gone", now)
4555 fx.st.TrashAppRow(OpsAccountID, "kass", now)
4556
4557 list := func(tok string) map[string]map[string]any {
4558 t.Helper()
4559 status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/list", tok)
4560 if status != http.StatusOK {
4561 t.Fatalf("list = %d %s", status, body)
4562 }
4563 var rows []map[string]any
4564 if err := json.Unmarshal([]byte(body), &rows); err != nil {
4565 t.Fatalf("list is not a JSON array: %v\n%s", err, body)
4566 }
4567 out := map[string]map[string]any{}
4568 for _, r := range rows {
4569 out[r["app"].(string)] = r
4570 }
4571 return out
4572 }
4573 rows := list(token)
4574 if len(rows) != 3 {
4575 t.Fatalf("list = %v, want tmp-live, tmp-gone and kass", rows)
4576 }
4577 if r := rows["tmp-live"]; r["deleted_at"] != nil || r["purge_after"] != nil || r["expires_at"] != now.Add(3*24*time.Hour).UTC().Format(time.RFC3339) {
4578 t.Errorf("tmp-live = %v", r)
4579 }
4580 if r := rows["tmp-gone"]; r["purge_after"] != now.Add(72*time.Hour).UTC().Format(time.RFC3339) {
4581 t.Errorf("tmp-gone = %v, want purge_after 3 days after the trash", r)
4582 }
4583 if r := rows["kass"]; r["purge_after"] != now.Add(30*24*time.Hour).UTC().Format(time.RFC3339) || r["expires_at"] != nil {
4584 t.Errorf("kass = %v, want purge_after 30 days after the trash", r)
4585 }
4586 // The accounts listing keeps its []string of LIVE names.
4587 _, accts := getJSON(t, fx.ts.URL+"/api/cli/apps", token)
4588 for _, a := range accts["accounts"].([]any) {
4589 for _, name := range a.(map[string]any)["apps"].([]any) {
4590 if _, ok := name.(string); !ok || name == "kass" || name == "tmp-gone" {
4591 t.Errorf("accounts listing apps = %v, want live names only, as strings", a)
4592 }
4593 }
4594 }
4595 // An app-scoped service sees only its app.
4596 svc := serviceToken(t, fx.st, OpsAccountID, "live-ci", "tmp-live", svcrole.Publish)
4597 if rows := list(svc); len(rows) != 1 || rows["tmp-live"] == nil {
4598 t.Errorf("app-scoped list = %v, want only tmp-live", rows)
4599 }
4600}
4601
4602func TestBrowserKeepButton(t *testing.T) {
4603 fx := newConsoleFixture(t)
4604 tempApp(t, fx, "tmp-a", time.Now().Add(time.Hour))
4605 c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-keep-button")
4606 if _, _, page := get(t, c, fx.u("/apps/tmp-a")); !strings.Contains(page, fx.path("/apps/tmp-a/keep")) {
4607 t.Fatal("the app page of a temporary app has no Keep form")
4608 }
4609 if _, _, page := get(t, c, fx.u("/apps/kass")); strings.Contains(page, "/apps/kass/keep") {
4610 t.Fatal("a permanent app shows a Keep form")
4611 }
4612 status, _, _ := postForm(t, c, fx.u("/apps/tmp-a/keep"), url.Values{"csrf": {csrf}})
4613 if status != http.StatusSeeOther {
4614 t.Fatalf("keep = %d, want 303", status)
4615 }
4616 if l := lifeOf(t, fx, "tmp-a"); l.Temporary() {
4617 t.Fatal("the Keep button did not keep")
4618 }
4619}
4620```
4621
4622- [ ] **Step 2: Run the tests to verify they fail**
4623
4624Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeep|TestTTL|TestListShowsTheTrashAndFiltersServices|TestBrowserKeepButton' -count=1`
4625Expected: FAIL: the `/keep`, `/ttl` and `/list` requests answer 404 or 405, and the app page has no Keep form.
4626
4627- [ ] **Step 3: Implement**
4628
4629Append to `internal/console/apitemporary.go` (add `"context"` and `"crypto/subtle"` to its imports):
4630
4631```go
4632var (
4633 errAppGone = errors.New("console: no such app")
4634 errTemporaryInTrash = errors.New("console: the app is in the trash")
4635 errNotTemporary = errors.New("console: the app is permanent")
4636)
4637
4638// temporaryLive takes the app's lock, settles, and returns the row if it is
4639// a live temporary app. The caller runs its update under the same lock.
4640// Settling first is what makes a keep after a failed expiry step 4 find the
4641// app in the Trash and refuse ("restore it first"), rather than succeed on a
4642// row the bucket has already trashed (spec §5).
4643func (s *Server) temporaryLive(ctx context.Context, acct *Account, app string, now time.Time) (*AppLife, error) {
4644 if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil {
4645 return nil, err
4646 }
4647 life, err := s.store.AppLife(acct.ID, app)
4648 switch {
4649 case err != nil:
4650 return nil, err
4651 case life == nil:
4652 return nil, errAppGone
4653 case life.Trashed() || life.Restoring:
4654 return nil, errTemporaryInTrash
4655 case !life.Temporary():
4656 return nil, errNotTemporary
4657 }
4658 return life, nil
4659}
4660
4661// keepApp is `carlos apps keep` and the Keep button: expires_at = 0.
4662func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error {
4663 defer s.lockApp(acct.Sqid, app)()
4664 if _, err := s.temporaryLive(ctx, acct, app, now); err != nil {
4665 return err
4666 }
4667 ok, err := s.store.KeepApp(acct.ID, app)
4668 if err != nil {
4669 return err
4670 }
4671 if !ok {
4672 return errTemporaryInTrash
4673 }
4674 return nil
4675}
4676
4677// setAppTTL is `carlos apps ttl`: expires_at = now + ttl, live temporary
4678// apps only (spec §7). It never sets an expiry on a permanent row.
4679func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error) {
4680 defer s.lockApp(acct.Sqid, app)()
4681 if _, err := s.temporaryLive(ctx, acct, app, now); err != nil {
4682 return time.Time{}, err
4683 }
4684 exp := now.Add(ttl).Truncate(time.Second)
4685 ok, err := s.store.SetAppExpiry(acct.ID, app, exp)
4686 if err != nil {
4687 return time.Time{}, err
4688 }
4689 if !ok {
4690 return time.Time{}, errTemporaryInTrash
4691 }
4692 return exp, nil
4693}
4694
4695// lifecycleRefusal maps keepApp/setAppTTL's errors onto the JSON answer.
4696// It reports whether it wrote one.
4697func lifecycleRefusal(w http.ResponseWriter, r *http.Request, app string, err error) bool {
4698 switch {
4699 case err == nil:
4700 return false
4701 case errors.Is(err, errAppGone):
4702 http.NotFound(w, r)
4703 case errors.Is(err, errTemporaryInTrash):
4704 jsonError(w, http.StatusConflict, app+" is in the Trash. Restore it first with: carlos apps restore "+app) // COPY-REVIEW C9.1
4705 case errors.Is(err, errNotTemporary):
4706 jsonError(w, http.StatusConflict, app+" is permanent. It has no expiry to move or keep") // COPY-REVIEW C9.2
4707 default:
4708 log.Printf("console: lifecycle %s: %v", app, err)
4709 jsonError(w, http.StatusInternalServerError, "could not change "+app+". Nothing was changed. Try again") // COPY-REVIEW C9.3
4710 }
4711 return true
4712}
4713
4714func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request) {
4715 addr, acct, app, ok := s.apiAccountApp(w, r)
4716 if !ok {
4717 return
4718 }
4719 p, _ := principalFrom(r.Context())
4720 if !s.allowWrite(p, time.Now()) {
4721 jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") // COPY-REVIEW C9.4
4722 return
4723 }
4724 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
4725 defer cancel()
4726 if lifecycleRefusal(w, r, app, s.keepApp(ctx, acct, app, time.Now())) {
4727 return
4728 }
4729 s.audit(acct, app, addr, "keep", "made "+app+" permanent (cli)") // COPY-REVIEW C9.5
4730 writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": nil})
4731}
4732
4733func (s *Server) handleAPIAppTTL(w http.ResponseWriter, r *http.Request) {
4734 addr, acct, app, ok := s.apiAccountApp(w, r)
4735 if !ok {
4736 return
4737 }
4738 p, _ := principalFrom(r.Context())
4739 if !s.allowWrite(p, time.Now()) {
4740 jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") // COPY-REVIEW C9.4
4741 return
4742 }
4743 var req struct {
4744 TTLSeconds int64 `json:"ttl_seconds"`
4745 }
4746 if !readJSONStrictLimit(w, r, &req, 8<<10) {
4747 return
4748 }
4749 ttl, err := temporaryTTL(req.TTLSeconds, false)
4750 if err != nil {
4751 jsonError(w, http.StatusBadRequest, err.Error())
4752 return
4753 }
4754 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
4755 defer cancel()
4756 exp, err := s.setAppTTL(ctx, acct, app, ttl, time.Now())
4757 if lifecycleRefusal(w, r, app, err) {
4758 return
4759 }
4760 s.audit(acct, app, addr, "ttl", "set "+app+" to expire "+exp.UTC().Format("2006-01-02 15:04 UTC")+" (cli)") // COPY-REVIEW C9.6
4761 writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": exp.UTC().Format(time.RFC3339)})
4762}
4763
4764// handleAPIAppList is GET /api/cli/apps/{account}/list (spec §7): every app
4765// the caller can see in the account, trashed included, with its lifecycle
4766// times. New rather than a change to GET /api/cli/apps, whose "apps" field is
4767// a []string that clients decode as such. An app-scoped service sees its own
4768// app only, the filter handleAPIApps applies (apicli.go:532).
4769func (s *Server) handleAPIAppList(w http.ResponseWriter, r *http.Request) {
4770 p, acct, ok := s.apiPrincipalAccount(w, r)
4771 if !ok {
4772 return
4773 }
4774 lives, err := s.store.AppLivesIn(acct.ID)
4775 if err != nil {
4776 log.Printf("console: api list %s: %v", acct.Sqid, err)
4777 jsonError(w, http.StatusInternalServerError, "could not load apps") // COPY-REVIEW C9.7
4778 return
4779 }
4780 stamp := func(unix int64) any {
4781 if unix == 0 {
4782 return nil
4783 }
4784 return time.Unix(unix, 0).UTC().Format(time.RFC3339)
4785 }
4786 out := make([]map[string]any, 0, len(lives))
4787 for _, l := range lives {
4788 if p.Kind == PrincipalService && p.App != "" && l.App != p.App {
4789 continue
4790 }
4791 entry := map[string]any{"app": l.App, "deleted_at": stamp(l.DeletedAt), "expires_at": stamp(l.ExpiresAt), "purge_after": nil}
4792 if l.Trashed() {
4793 entry["purge_after"] = effectivePurgeAt(l.DeletedAt, l.PurgeAfter).Format(time.RFC3339)
4794 }
4795 out = append(out, entry)
4796 }
4797 writeJSON(w, http.StatusOK, out)
4798}
4799
4800// handleAppKeep is the app page's Keep button (spec §7): any member, CSRF,
4801// no passkey, because keep only makes the app safer.
4802func (s *Server) handleAppKeep(w http.ResponseWriter, r *http.Request) {
4803 addr, raw, acct, _, app, ok := s.accountApp(w, r)
4804 if !ok {
4805 return
4806 }
4807 if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
4808 s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
4809 return
4810 }
4811 back := func(kind, msg string) {
4812 s.setFlash(w, kind, msg)
4813 http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
4814 }
4815 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
4816 defer cancel()
4817 switch err := s.keepApp(ctx, acct, app, time.Now()); {
4818 case err == nil:
4819 case errors.Is(err, errNotTemporary):
4820 back("ok", app+" is already permanent.") // COPY-REVIEW C9.8
4821 return
4822 case errors.Is(err, errTemporaryInTrash), errors.Is(err, errAppGone):
4823 back("err", app+" is in the Trash. Restore it from the Trash page first.") // COPY-REVIEW C9.9
4824 return
4825 default:
4826 log.Printf("console: keep %s/%s: %v", acct.Sqid, app, err)
4827 back("err", "Could not keep "+app+". Nothing was changed. Try again.") // COPY-REVIEW C9.10
4828 return
4829 }
4830 s.audit(acct, app, addr, "keep", "made "+app+" permanent") // COPY-REVIEW C9.5
4831 back("ok", app+" is permanent now. It will not expire.") // COPY-REVIEW C9.11
4832}
4833```
4834
4835`internal/console/authcli.go`, after the `/new-temporary` route:
4836
4837```go
4838 s.cliRoute("GET /api/cli/apps/{account}/list", svcrole.Allow(svcrole.Publish), s.handleAPIAppList)
4839 s.cliRoute("POST /api/cli/apps/{account}/{app}/ttl", svcrole.Allow(svcrole.Publish), s.handleAPIAppTTL)
4840 s.cliRoute("POST /api/cli/apps/{account}/{app}/keep", svcrole.Allow(svcrole.Publish), s.handleAPIAppKeep)
4841```
4842
4843`internal/console/server.go`, after `POST /a/{sqid}/apps/{app}/rename` (line 1201):
4844
4845```go
4846 s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/keep", s.handleAppKeep)
4847```
4848
4849`page` struct, next to `Trashed` (line 1441):
4850
4851```go
4852 // ExpiresAt is when a temporary app moves to the Trash; zero for a
4853 // permanent one. appnav shows it with the Keep button.
4854 ExpiresAt time.Time
4855```
4856
4857`appPage` (server.go), after `p.Detail = &views[0]` (line 2685):
4858
4859```go
4860 if life, err := s.store.AppLife(acct.ID, app); err != nil {
4861 log.Printf("console: app lifecycle %s/%s: %v", acct.Sqid, app, err)
4862 } else if life != nil && life.Temporary() {
4863 p.ExpiresAt = time.Unix(life.ExpiresAt, 0).UTC()
4864 }
4865```
4866
4867`internal/console/templates/appnav.html`, the `pagehead` block becomes:
4868
4869```html
4870<div class="pagehead">
4871 <p class="eyebrow">App · {{.Account.Name}}</p>
4872 <h1>{{.AppName}}</h1>
4873 {{if not .ExpiresAt.IsZero}}
4874 <!-- COPY-REVIEW C9.12 -->
4875 <p class="small">Temporary. It moves to the Trash on {{fmtTime .ExpiresAt}}, and is deleted 3 days after that.</p>
4876 <form method="post" action="{{.Base}}/apps/{{.AppName}}/keep">
4877 <input type="hidden" name="csrf" value="{{.CSRF}}">
4878 <!-- COPY-REVIEW C9.13 -->
4879 <button type="submit">Keep this app</button>
4880 </form>
4881 {{end}}
4882</div>
4883```
4884
4885(`.Base` is what every form posts under, server.go:1432; `.CSRF` is on every account page.)
4886
4887- [ ] **Step 4: Run the tests to verify they pass**
4888
4889Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestKeep|TestTTL|TestListShowsTheTrashAndFiltersServices|TestBrowserKeepButton|TestEveryCLIDoorNamesItsRoles|TestAPIApps|TestAppsListForAService' -count=1`
4890Expected: PASS.
4891
4892- [ ] **Step 5: Commit**
4893
4894```bash
4895gofmt -l internal/console
4896git add internal/console/apitemporary.go internal/console/apitemporary_test.go internal/console/authcli.go internal/console/server.go internal/console/templates/appnav.html
4897git commit -m "console: ttl, keep and list doors, and a Keep button on temporary apps
4898
4899Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
4900```
4901
4902---
4903
4904### Task 10: One deadline, everywhere it is shown
4905
4906Spec §5 "One deadline, everywhere": the Trash view, the trash API's `restorable_until`, the browser delete confirmation and the CLI all show `effectivePurgeAt`, and direct-mode delete stops promising a window it cannot know.
4907
4908**Files:**
4909- Modify: `internal/console/store.go:1509-1540` (`TrashedApp`, `TrashedAppsIn`)
4910- Modify: `internal/console/view.go:475-494` (`TrashedApps`)
4911- Modify: `internal/console/templates/trash.html:10-11, :35-39, :51-52`
4912- Modify: `internal/console/templates/app_settings.html:488-493` (the delete confirmation's restore window), `internal/console/templates/apps.html:24-25` (the account page's Trash link)
4913- Modify: `internal/console/server.go:71-73` (template funcs: add `temporaryCooloffDays` beside `trashCooloffDays`)
4914- Modify: `internal/console/apicli.go:1279-1295` (`handleAPITrashApp` response)
4915- Modify: `internal/console/appsettings.go:1052-1054` (`handleAppDelete` flash)
4916- Modify: `apps.go:84-120` (`appsUsage`: lines 88, 94 and 120 promise 30 days), `:222-243` (`runAppsDelete`), `:262-294` (`headlessTrash`), `:302-308` (`printTrashed`), `:384-385` (`runAppsRestore`'s headless line)
4917- Create: `temporary.go` (package main: `inDuration`)
4918- Test: `internal/console/deletion_test.go`, `apps_test.go`
4919
4920**Interfaces:**
4921- Consumes: `effectivePurgeAt`, `Store.AppLife` (Task 2); `release.TemporaryCooloff` (Task 1).
4922- Produces:
4923 - `TrashedApp.PurgeAfter int64`
4924 - `func inDuration(d time.Duration) string` (package main): `in 3d`, `in 5h`, `in 12m`, `soon` for d <= 0
4925 - `func restoreHint(app string) string` (package main): delete's line about getting the app back, which never names `carlos add` for a `tmp-` app
4926 - `func printTrashed(app, restorableUntil string, now time.Time)` (was `printTrashed(app, restorableUntil string)`)
4927 - `func headlessTrash(ctx context.Context, store release.Store, pub ed25519.PublicKey, acct, app string, now time.Time) (time.Time, error)` now returns the marker's `DeletedAt` (was `DeletedAt + TrashCooloff`)
4928 - `func headlessTrashLines(app string, deletedAt, now time.Time) []string`
4929 - template func `temporaryCooloffDays` (3, from `release.TemporaryCooloff`); the delete confirmation picks it when `.ExpiresAt` (Task 9's `page` field, set by `appPage`) is non-zero
4930
4931Every surface that promised 30 days was found with `grep -rn "trashCooloffDays\|TrashCooloff\|30 days\|30-day\|thirty" internal/console/*.go internal/console/templates/ *.go docs/site/cli.md` (excluding tests and the unrelated mail, analytics and session hits). The user-facing ones and where each is handled: `trash.html:10, :35, :52` (this task), `app_settings.html:492` (this task), `apps.html:25` (this task), `appsettings.go:1058` browser delete flash (this task), `apicli.go:1295` `restorable_until` (this task), `view.go:492` Trash view (this task), `apps.go:88, :94, :120` usage and `apps.go:307` headless (this task), `docs/site/cli.md:130` (Task 11). The rest are code comments.
4932
4933- [ ] **Step 1: Write the failing tests**
4934
4935Append to `internal/console/deletion_test.go`:
4936
4937```go
4938// Spec §9 "Deadline display": a temporary and a permanent app trashed
4939// together show 3 and 30 days, on the Trash page and in the trash API.
4940func TestTrashShowsEachAppsOwnDeadline(t *testing.T) {
4941 fx := newConsoleFixture(t)
4942 tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour))
4943 token := cliToken(t, fx, "paul@keymail.dev", "code-deadlines")
4944 until := map[string]time.Time{}
4945 for _, app := range []string{"tmp-a", "kass"} {
4946 status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/"+app+"/delete", map[string]string{}, token)
4947 if status != http.StatusOK {
4948 t.Fatalf("delete %s = %d %v", app, status, out)
4949 }
4950 u, err := time.Parse(time.RFC3339, str(t, out, "restorable_until"))
4951 if err != nil {
4952 t.Fatal(err)
4953 }
4954 until[app] = u
4955 }
4956 tmp, kass := lifeOf(t, fx, "tmp-a"), lifeOf(t, fx, "kass")
4957 if want := time.Unix(tmp.DeletedAt, 0).Add(72 * time.Hour); !until["tmp-a"].Equal(want) {
4958 t.Errorf("tmp-a restorable_until = %v, want %v", until["tmp-a"], want)
4959 }
4960 if want := time.Unix(kass.DeletedAt, 0).Add(30 * 24 * time.Hour); !until["kass"].Equal(want) {
4961 t.Errorf("kass restorable_until = %v, want %v", until["kass"], want)
4962 }
4963 c, _ := signInAs(t, fx, "paul@keymail.dev", "code-deadlines-web")
4964 _, _, page := get(t, c, fx.u("/trash"))
4965 for app, u := range until {
4966 if !strings.Contains(page, u.UTC().Format("2006-01-02 15:04 UTC")) {
4967 t.Errorf("the Trash page does not show %s's deadline %v", app, u)
4968 }
4969 }
4970}
4971```
4972
4973Also append to `internal/console/deletion_test.go`:
4974
4975```go
4976// Astra plan round 3: the delete confirmation on Settings promises the window
4977// the app will actually get, before anything is deleted: 3 days for a
4978// temporary app, 30 for a permanent one. The number sits in its own element
4979// so the assertion survives any rewording of the sentence around it.
4980func TestDeleteConfirmationNamesEachAppsWindow(t *testing.T) {
4981 fx := newConsoleFixture(t)
4982 tempApp(t, fx, "tmp-a", time.Now().Add(24*time.Hour))
4983 c, _ := signInAs(t, fx, "paul@keymail.dev", "code-delete-window")
4984 for app, days := range map[string]string{"tmp-a": "3", "kass": "30"} {
4985 status, _, page := get(t, c, fx.u("/apps/"+app+"/settings/delete"))
4986 if status != http.StatusOK {
4987 t.Fatalf("%s delete settings = %d", app, status)
4988 }
4989 if want := `class="restore-days">` + days + ` days<`; !strings.Contains(page, want) {
4990 t.Errorf("%s: the delete confirmation does not promise %s days (want %q in the page)", app, days, want)
4991 }
4992 }
4993}
4994```
4995
4996Replace `TestHeadlessTrashHappyPathWritesMarkerAndReturnsItsWindow` (apps_test.go:426) and add the display tests:
4997
4998```go
4999func TestHeadlessTrashReturnsTheMarkersClock(t *testing.T) {
5000 store, _, pub := newHeadlessTestStore(t)
5001 ctx := context.Background()
5002 now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
5003 deletedAt, err := headlessTrash(ctx, store, pub, "bab", "myapp", now)
5004 if err != nil {
5005 t.Fatalf("headlessTrash: %v", err)
5006 }
5007 if !deletedAt.Equal(now) {
5008 t.Fatalf("deletedAt = %v, want the marker's %v", deletedAt, now)
5009 }
5010 // A retry two days in re-reads the marker: the clock never restarts.
5011 again, err := headlessTrash(ctx, store, pub, "bab", "myapp", now.Add(48*time.Hour))
5012 if err != nil || !again.Equal(now) {
5013 t.Fatalf("retry = %v %v, want the first trash's clock %v", again, err, now)
5014 }
5015}
5016
5017// Spec §5 "Direct mode" and §9: both dates from the marker, and a date
5018// already passed is said as such.
5019func TestHeadlessTrashLinesNameBothWindows(t *testing.T) {
5020 at := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
5021 tmp, perm := "2026-08-07 12:00 UTC", "2026-09-03 12:00 UTC"
5022 for _, now := range []time.Time{at, at.Add(48 * time.Hour)} {
5023 out := strings.Join(headlessTrashLines("myapp", at, now), "\n")
5024 if !strings.Contains(out, tmp) || !strings.Contains(out, perm) || !strings.Contains(out, "carlos apps list") {
5025 t.Fatalf("at %v:\n%s\nwant both %s and %s, and carlos apps list", now, out, tmp, perm)
5026 }
5027 }
5028 out := strings.Join(headlessTrashLines("myapp", at, at.Add(4*24*time.Hour)), "\n")
5029 if strings.Contains(out, tmp) || !strings.Contains(out, perm) {
5030 t.Fatalf("after 3 days:\n%s\nwant the temporary date replaced by 'may be purged at any time'", out)
5031 }
5032}
5033
5034func TestPrintTrashedSaysHowLong(t *testing.T) {
5035 buf := captureStdout(t)
5036 now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
5037 printTrashed("tmp-a", now.Add(72*time.Hour).Format(time.RFC3339), now)
5038 if out := buf.String(); !strings.Contains(out, "in 3d") || !strings.Contains(out, "2026-09-29 12:00 UTC") || !strings.Contains(out, "carlos apps restore tmp-a") {
5039 t.Fatalf("printTrashed:\n%s", out)
5040 }
5041}
5042
5043func TestRestoreHintNeverSendsATmpAppToCarlosAdd(t *testing.T) {
5044 if h := restoreHint("tmp-a"); strings.Contains(h, "carlos add") {
5045 t.Errorf("restoreHint(tmp-a) = %q: carlos add refuses every tmp- name", h)
5046 }
5047 if h := restoreHint("docs"); !strings.Contains(h, "carlos add") {
5048 t.Errorf("restoreHint(docs) = %q, want the carlos add advice kept for ordinary apps", h)
5049 }
5050 buf := captureStdout(t)
5051 printTrashed("tmp-a", "2026-09-29T12:00:00Z", time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC))
5052 if strings.Contains(buf.String(), "carlos add") {
5053 t.Errorf("delete of a temporary app points at carlos add:\n%s", buf.String())
5054 }
5055}
5056
5057func TestInDuration(t *testing.T) {
5058 for d, want := range map[time.Duration]string{
5059 72 * time.Hour: "in 3d", 71 * time.Hour: "in 2d", 5 * time.Hour: "in 5h",
5060 12 * time.Minute: "in 12m", 10 * time.Second: "in 1m", 0: "soon", -time.Hour: "soon",
5061 } {
5062 if got := inDuration(d); got != want {
5063 t.Errorf("inDuration(%v) = %q, want %q", d, got, want)
5064 }
5065 }
5066}
5067```
5068
5069`captureStdout(t)` (apimode_test.go:35) swaps the package's `stdout` (cli.go:45) for a buffer and restores it on cleanup.
5070
5071- [ ] **Step 2: Run the tests to verify they fail**
5072
5073Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTrashShowsEachAppsOwnDeadline|TestDeleteConfirmationNamesEachAppsWindow' -count=1`
5074Expected: FAIL: `tmp-a restorable_until = <30 days>, want <3 days>`, the Trash page lacks the 3-day date, and neither delete confirmation has a `restore-days` element.
5075
5076Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestHeadlessTrash|TestPrintTrashedSaysHowLong|TestInDuration|TestRestoreHintNeverSendsATmpAppToCarlosAdd' -count=1`
5077Expected: FAIL to compile: `undefined: headlessTrashLines`, `undefined: inDuration`, `printTrashed` has too many arguments.
5078
5079- [ ] **Step 3: Implement**
5080
5081`internal/console/store.go`, `TrashedApp` and `TrashedAppsIn` (lines 1509-1540):
5082
5083```go
5084type TrashedApp struct {
5085 App string
5086 DeletedAt time.Time
5087 PurgeAfter int64 // Unix seconds; 0 = the ordinary 30 days (effectivePurgeAt)
5088}
5089
5090func (s *Store) TrashedAppsIn(accountID int64) ([]TrashedApp, error) {
5091 rows, err := s.db.Query(`SELECT app, deleted_at, purge_after FROM apps WHERE account_id = ? AND deleted_at != 0 ORDER BY app`,
5092 accountID)
5093 if err != nil {
5094 return nil, err
5095 }
5096 defer rows.Close()
5097 var out []TrashedApp
5098 for rows.Next() {
5099 var t TrashedApp
5100 var deletedAt int64
5101 if err := rows.Scan(&t.App, &deletedAt, &t.PurgeAfter); err != nil {
5102 return nil, err
5103 }
5104 t.DeletedAt = time.Unix(deletedAt, 0).UTC()
5105 out = append(out, t)
5106 }
5107 return out, rows.Err()
5108}
5109```
5110
5111`internal/console/view.go`, in `TrashedApps` (line 491), replace `v.PurgesAt = v.DeletedAt.Add(release.TrashCooloff)` with:
5112
5113```go
5114 // One deadline, everywhere (temporary static sites spec §5): the
5115 // row's purge_after when the app was temporary at trash time, else
5116 // the marker's clock plus the 30-day cooloff.
5117 v.PurgesAt = effectivePurgeAt(v.DeletedAt.Unix(), row.PurgeAfter)
5118```
5119
5120`internal/console/templates/trash.html`: the opening paragraph (lines 6-9) keeps its text, which is true for an ordinary app, and is followed by one new sentence for temporary ones, which carlos add refuses:
5121
5122```html
5123<!-- COPY-REVIEW C10.17 -->
5124<p class="small">A temporary app (named tmp-...) needs no carlos add: its instance records are kept
5125 in the Trash, so hosts serve it again on their own once it is restored.</p>
5126```
5127
5128Then the line 10-11 paragraph, the line 35 sentence, and the empty-state paragraph at 51-52 become:
5129
5130```html
5131<!-- COPY-REVIEW C10.1 -->
5132<p class="small">Left alone, the platform destroys a trashed app on its own on the date in the last column:
5133 30 days after it was deleted, or 3 days for a temporary app. That is permanent. The objects go, and the
5134 name is free for anyone in this account to claim again.</p>
5135```
5136
5137```html
5138<!-- COPY-REVIEW C10.2 -->
5139<p class="small">Deleting now does what the wait would do on its own, immediately and
5140 permanently: every object the app owns is destroyed and its name is freed. There is no undo
5141 and no copy anywhere. Type the app's name to confirm, then confirm with your passkey. A
5142 signed-in browser on its own is not enough. An app still marked as production cannot be
5143 permanently deleted, whatever you type. Turn its safety delay off first.</p>
5144```
5145
5146```html
5147<!-- COPY-REVIEW C10.3 -->
5148<p>Nothing here. An app deleted with <code>carlos apps delete</code> waits in this Trash,
5149 restorable the whole time, until the platform destroys it: 30 days for most apps, 3 days for a
5150 temporary one.</p>
5151```
5152
5153`internal/console/server.go`, template funcs, beside `trashCooloffDays` (line 73):
5154
5155```go
5156 // temporaryCooloffDays is a temporary app's restore window, in days: the
5157 // delete confirmation shows it for an app with an expiry (temporary static
5158 // sites spec §5, "the delete confirmation in the browser").
5159 "temporaryCooloffDays": func() int { return int(release.TemporaryCooloff.Hours() / 24) },
5160```
5161
5162`internal/console/templates/app_settings.html:488-493`, the delete paragraph becomes (`.ExpiresAt` is non-zero exactly when the app is temporary, set by `appPage` in Task 9):
5163
5164```html
5165<!-- COPY-REVIEW C10.14 -->
5166<p class="small">Deleting puts {{.Detail.App}} in the account's
5167<a href="{{.Nav}}/trash">trash</a>: hosts stop serving it on their next pass
5168(custom domains included), and it is restorable for
5169<strong class="restore-days">{{if .ExpiresAt.IsZero}}{{trashCooloffDays}}{{else}}{{temporaryCooloffDays}}{{end}} days</strong>
5170before the platform destroys its releases for good.{{if not .ExpiresAt.IsZero}} It is a temporary app, so its
5171window is shorter than usual.{{end}}{{if .Detail.Production.On}} It is marked as production right now, which
5172blocks deletion — turn the safety delay off on the <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{.DeploymentsCopy.History}}</a> tab first.{{end}}</p>
5173```
5174
5175(The production clause is existing copy, kept byte for byte.)
5176
5177`internal/console/templates/apps.html:24-25`, the account page's Trash link, which cannot know a single window for a mixed Trash:
5178
5179```html
5180<!-- COPY-REVIEW C10.15 -->
5181{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a>: deleted apps you can still
5182 restore, each until the date shown there.</p>{{end}}
5183```
5184
5185`handleAPITrashApp` (apicli.go), after `TrashAppRow` succeeds (line 1285), compute the deadline from the row and use it in the response:
5186
5187```go
5188 // One deadline, everywhere (spec §5). If the row cannot be read back,
5189 // promise the SHORTER window: under-promising a restore deadline is
5190 // harmless, over-promising is the one outcome a deletion must not produce.
5191 until := deletedAt.Add(release.TemporaryCooloff)
5192 if life, lerr := s.store.AppLife(acct.ID, app); lerr == nil && life != nil {
5193 until = effectivePurgeAt(life.DeletedAt, life.PurgeAfter)
5194 } else if lerr != nil {
5195 log.Printf("console: api trash %s/%s: read back the deadline: %v", acct.Sqid, app, lerr)
5196 }
5197```
5198
5199and the response's `"restorable_until"` becomes `until.UTC().Format(time.RFC3339)`.
5200
5201`handleAppDelete` (appsettings.go:1052-1054): the same `until` computation after `TrashAppRow`, and the flash becomes:
5202
5203```go
5204 s.setFlash(w, "ok", "Moved "+app+" to the trash. You can restore it there until "+
5205 until.UTC().Format("2006-01-02 15:04 UTC")+". Hosts stop serving it on their next pass.") // COPY-REVIEW C10.4
5206```
5207
5208Create `temporary.go` (package main) with the relative-time helper the CLI shares and delete's restore hint (Task 11 adds to this file):
5209
5210```go
5211// temporary.go: the CLI side of temporary apps (temporary static sites spec
5212// §7) and the one relative-time format every lifecycle line prints.
5213package main
5214
5215import (
5216 "fmt"
5217 "time"
5218
5219 "github.com/carlosframework/platform/internal/release"
5220)
5221
5222// restoreHint is what `carlos apps delete` says about getting the app back.
5223// carlos add refuses every tmp- name (appRail, cli.go), kept ones included,
5224// so a tmp- app is told what actually happens: its instance records are kept
5225// through the trash, and hosts mint its routes again once it is restored
5226// (internal/provision/reconcile.go:903).
5227func restoreHint(app string) string {
5228 if release.IsTemporaryName(app) {
5229 return "restoring brings back its releases and channels, and hosts bring its routes back from its instance records" // COPY-REVIEW C10.16
5230 }
5231 return "restoring brings back its releases and channels, but no routes. carlos add <host> serves it again" // COPY-REVIEW C10.7
5232}
5233
5234// inDuration says how far away d is, in the largest whole unit: "in 3d",
5235// "in 5h", "in 12m". Floors, so it never promises more time than there is.
5236// Anything already due is "soon".
5237func inDuration(d time.Duration) string {
5238 switch {
5239 case d <= 0:
5240 return "soon" // COPY-REVIEW C10.5
5241 case d >= 24*time.Hour:
5242 return fmt.Sprintf("in %dd", int(d/(24*time.Hour)))
5243 case d >= time.Hour:
5244 return fmt.Sprintf("in %dh", int(d/time.Hour))
5245 default:
5246 return fmt.Sprintf("in %dm", max(int(d/time.Minute), 1))
5247 }
5248}
5249```
5250
5251`apps.go`:
5252
5253`headlessTrash`'s last line (line 293) returns the marker's clock:
5254
5255```go
5256 return tomb.DeletedAt, nil
5257```
5258
5259(and its doc comment's last bullet now says it returns the marker's own `DeletedAt`, so a retry never restarts the clock).
5260
5261`printTrashed` (line 302) and the headless lines:
5262
5263```go
5264// printTrashed is the signed-in delete's output: the console's own deadline
5265// (effectivePurgeAt), how far off it is, and the restore command.
5266func printTrashed(app, restorableUntil string, now time.Time) {
5267 when, rel := restorableUntil, ""
5268 if t, err := time.Parse(time.RFC3339, restorableUntil); err == nil {
5269 when, rel = t.UTC().Format("2006-01-02 15:04 UTC"), inDuration(t.Sub(now))
5270 }
5271 fmt.Fprintf(stdout, "%s %s. It is purged %s, at %s. Until then, carlos apps restore %s brings it back.\n", // COPY-REVIEW C10.6
5272 colorFor(stdout).ok("trashed"), app, rel, when, app)
5273 mutedf(stdout, "%s", restoreHint(app))
5274}
5275
5276// headlessTrashLines is direct mode's delete output (spec §5, astra 7.4 and
5277// 8.2). There is no console row here, so it cannot know whether the app is
5278// temporary, and the tmp- prefix does not say (a kept app keeps it). It names
5279// both windows from the marker's own clock, and a date already passed as
5280// "may be purged at any time".
5281func headlessTrashLines(app string, deletedAt, now time.Time) []string {
5282 when := func(t time.Time) string {
5283 if !t.After(now) {
5284 return "may be purged at any time" // COPY-REVIEW C10.8
5285 }
5286 return "is purged at " + t.UTC().Format("2006-01-02 15:04 UTC") // COPY-REVIEW C10.9
5287 }
5288 return []string{
5289 fmt.Sprintf("%s has been in the Trash since %s.", app, deletedAt.UTC().Format("2006-01-02 15:04 UTC")), // COPY-REVIEW C10.10
5290 fmt.Sprintf("If it is a temporary app it %s. If not, it %s.", // COPY-REVIEW C10.11
5291 when(deletedAt.Add(release.TemporaryCooloff)), when(deletedAt.Add(release.TrashCooloff))),
5292 "carlos apps list against the console shows which.", // COPY-REVIEW C10.12
5293 }
5294}
5295```
5296
5297`runAppsDelete`: the signed-in branch calls `printTrashed(app, res.RestorableUntil, time.Now())`; the headless branch (lines 238-242) becomes:
5298
5299```go
5300 deletedAt, err := headlessTrash(context.Background(), store, pub, acctID, app, time.Now())
5301 if err != nil {
5302 log.Fatalf("apps delete: %v", err)
5303 }
5304 fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("trashed"), app)
5305 for _, line := range headlessTrashLines(app, deletedAt, time.Now()) {
5306 fmt.Fprintln(stdout, line)
5307 }
5308 mutedf(stdout, "%s", restoreHint(app))
5309```
5310
5311`runAppsRestore`'s headless branch (apps.go:384-385) keeps its line for every other app and says the true thing for a `tmp-` one, placed after the `restored` line and before the existing `mutedf`:
5312
5313```go
5314 if release.IsTemporaryName(app) {
5315 mutedf(stdout, "%s is restored with its releases and channels intact. Its instance records were kept, "+
5316 "so hosts bring its routes back on their next pass", app) // COPY-REVIEW C10.18
5317 return
5318 }
5319```
5320
5321The signed-in branch prints the console's own note, which Task 7 already makes right.
5322
5323`appsUsage`'s `delete` and `restore` paragraphs (apps.go:88, :94) say "restorable for 30 days" and "within the 30-day window", and its last paragraph (apps.go:120) says "Headless deployments wait out the 30 days". Change the first two to "restorable for 30 days, or 3 for a temporary app" and the third to "Headless deployments wait out the window: 30 days, or 3 for a temporary app" (`COPY-REVIEW C10.13`; the usage text is a const, so the marker is a plan annotation only).
5324
5325The `restore` paragraph (apps.go:94-97) also tells every app to run `carlos add <host>` afterwards, which Task 3 refuses for every `tmp-` name, kept ones included. Append to that paragraph, after "to serve it again.", this sentence (also `COPY-REVIEW C10.13`):
5326
5327```text
5328 A tmp- app needs none of that: its instance records
5329 are kept in the Trash, so hosts bring its routes back
5330 on their next pass.
5331```
5332
5333Regression, added to Step 1's test file (`apps_test.go`):
5334
5335```go
5336func TestAppsUsageNeverSendsATmpAppToCarlosAdd(t *testing.T) {
5337 i := strings.Index(appsUsage, "restore [flags] <app>")
5338 if i < 0 {
5339 t.Fatal("appsUsage has no restore paragraph")
5340 }
5341 para := appsUsage[i:]
5342 if j := strings.Index(para, "\n\n"); j >= 0 {
5343 para = para[:j]
5344 }
5345 if !strings.Contains(para, "tmp- app needs none of that") {
5346 t.Errorf("restore usage does not tell a tmp- app its routes come back on their own:\n%s", para)
5347 }
5348}
5349```
5350
5351Add `TestAppsUsageNeverSendsATmpAppToCarlosAdd` to Step 4's `-run` pattern for package `.`.
5352
5353- [ ] **Step 4: Run the tests to verify they pass**
5354
5355Run: `GOCACHE=$TMPDIR/gocache go test ./internal/console/ -run 'TestTrashShowsEachAppsOwnDeadline|TestDeleteConfirmationNamesEachAppsWindow|TestTrashPage|TestAPITrash|TestDeleteNow|TestAppDelete|TestAppSettings' -count=1`
5356Expected: PASS.
5357
5358Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestHeadlessTrash|TestPrintTrashedSaysHowLong|TestInDuration|TestRestoreHintNeverSendsATmpAppToCarlosAdd|TestAppsUsageNeverSendsATmpAppToCarlosAdd|TestAPISessionTrashApp' -count=1`
5359Expected: PASS.
5360
5361- [ ] **Step 5: Commit**
5362
5363```bash
5364gofmt -l .
5365git add internal/console/store.go internal/console/view.go internal/console/server.go internal/console/templates/trash.html internal/console/templates/app_settings.html internal/console/templates/apps.html internal/console/apicli.go internal/console/appsettings.go internal/console/deletion_test.go apps.go apps_test.go temporary.go
5366git commit -m "console, cli: one purge deadline everywhere; direct mode names both windows
5367
5368Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
5369```
5370
5371---
5372### Task 11: The CLI: `carlos deploy --temporary`, `carlos apps ttl|keep|list`, and the docs
5373
5374**Files:**
5375- Modify: `temporary.go` (package main, created in Task 10)
5376- Modify: `apimode.go` (after `restoreApp`, line 1622)
5377- Modify: `deploy.go:37-66` (flags, usage), `:84-93` (create before app resolution), `:121` (expiry line for an existing temporary app), imports
5378- Modify: `apps.go:36-106` (`appsUsage`), `:109-128` (`RunApps`)
5379- Modify: `docs/site/cli.md:112-160` (`### carlos apps`), `:211-245` (`### carlos deploy`)
5380- Test: `temporary_test.go` (new, package main)
5381
5382**Interfaces:**
5383- Consumes: `inDuration` (Task 10); `release.IsTemporaryName`, `release.TemporaryAppPrefix`, `release.ValidApp` (Task 1); the four console doors and their wire shapes (Tasks 5, 9); `apiSession.postJSON`, `getJSON`, `apiPath`, `apiErrorFromBody`, `resolveMode`, `resolvedAccount`, `printUsing`, `appFlag`, `consoleOption`, `flagUsage`, `flagWasSet`, `mutedf`, `colorFor`, `stdout`; test helper `newTestSession` (apimode_test.go:209).
5384- Produces:
5385 - `const ttlFloor = time.Hour`, `const ttlCeiling = 30 * 24 * time.Hour`, `const ttlDefault = 7 * 24 * time.Hour`, `const temporaryNameAttempts = 5`
5386 - `func parseTTL(s string) (time.Duration, error)`
5387 - `func temporaryAppName(app string, random func() string) (string, error)`, `func randomTemporaryLabel() string`
5388 - `func temporaryDeployFlags(temporary, ttlSet bool, ttlRaw, host string, kindSet bool, kind string, nargs int) (bool, time.Duration, error)` (`ttlSet` is `flagWasSet(fs, "ttl")`, flags.go:98)
5389 - `func createTemporary(ctx context.Context, sess *apiSession, acct apiAcct, appFlag string, ttl time.Duration, random func() string) (apiCreateTemporaryResult, error)`
5390 - `func printTemporaryExpiry(ctx context.Context, sess *apiSession, acct apiAcct, app string)`
5391 - `func appLifeLine(l apiAppLife, now time.Time) string`
5392 - `func requireSignedInApps(verb, consoleFlag string) *apiSession`
5393 - `func runAppsTTL(args []string)`, `func runAppsKeep(args []string)`, `func runAppsList(args []string)`
5394 - apimode.go: `type apiCreateTemporaryResult struct { Account, App, URL string; ExpiresAt time.Time }`, `type apiAppLife struct { App string; DeletedAt, ExpiresAt, PurgeAfter *time.Time }`, `var errTemporaryUnavailable, errTemporaryNameTaken, errTemporaryNotConfirmed, errListUnavailable error`, `func (s *apiSession) createTemporaryApp(ctx context.Context, a apiAcct, app string, ttl time.Duration) (apiCreateTemporaryResult, error)`, `func (s *apiSession) appTTL(ctx context.Context, a apiAcct, app string, ttl time.Duration) (time.Time, error)`, `func (s *apiSession) appKeep(ctx context.Context, a apiAcct, app string) error`, `func (s *apiSession) appList(ctx context.Context, a apiAcct) ([]apiAppLife, error)`
5395
5396- [ ] **Step 1: Write the failing tests**
5397
5398```go
5399// temporary_test.go
5400package main
5401
5402import (
5403 "context"
5404 "encoding/json"
5405 "errors"
5406 "net/http"
5407 "net/http/httptest"
5408 "os/exec"
5409 "strings"
5410 "sync"
5411 "sync/atomic"
5412 "testing"
5413 "time"
5414)
5415
5416// Review Focus 1.
5417func TestParseTTL(t *testing.T) {
5418 for in, want := range map[string]time.Duration{
5419 "3d": 72 * time.Hour, "3D": 72 * time.Hour, " 30d ": 30 * 24 * time.Hour,
5420 "1h": time.Hour, "90m": 90 * time.Minute, "72h30m": 72*time.Hour + 30*time.Minute,
5421 } {
5422 if got, err := parseTTL(in); err != nil || got != want {
5423 t.Errorf("parseTTL(%q) = %v %v, want %v", in, got, err, want)
5424 }
5425 }
5426 for _, in := range []string{"", "d", "1.5d", "-1d", "31d", "59m", "0", "7", "9999999999999d", "3 days"} {
5427 if _, err := parseTTL(in); err == nil {
5428 t.Errorf("parseTTL(%q) accepted", in)
5429 }
5430 }
5431}
5432
5433// Review Focus 2.
5434func TestTemporaryAppName(t *testing.T) {
5435 gen := func() string { return "a1b2c3" }
5436 for in, want := range map[string]string{
5437 "": "tmp-a1b2c3", "acme-docs": "tmp-acme-docs", "tmp-acme": "tmp-acme", " TMP-Acme ": "tmp-acme",
5438 } {
5439 if got, err := temporaryAppName(in, gen); err != nil || got != want {
5440 t.Errorf("temporaryAppName(%q) = %q %v, want %q", in, got, err, want)
5441 }
5442 }
5443 for _, in := range []string{strings.Repeat("a", 29), "acme_docs", "acme-", "tmp-", "tmp-abc-"} {
5444 if got, err := temporaryAppName(in, gen); err == nil {
5445 t.Errorf("temporaryAppName(%q) = %q, want a refusal", in, got)
5446 }
5447 }
5448 for i := 0; i < 50; i++ {
5449 label := randomTemporaryLabel()
5450 if len(label) != 6 || strings.Trim(label, "abcdefghijklmnopqrstuvwxyz0123456789") != "" {
5451 t.Fatalf("randomTemporaryLabel() = %q, want 6 lowercase letters and digits", label)
5452 }
5453 }
5454}
5455
5456// Spec §9 "CLI": --ttl implies --temporary; local refusals.
5457func TestTemporaryDeployFlags(t *testing.T) {
5458 if temp, ttl, err := temporaryDeployFlags(false, true, "3d", "", false, "binary", 1); err != nil || !temp || ttl != 72*time.Hour {
5459 t.Errorf("--ttl 3d alone = %v %v %v, want temporary for 3d", temp, ttl, err)
5460 }
5461 if temp, ttl, err := temporaryDeployFlags(true, false, "", "", false, "binary", 1); err != nil || !temp || ttl != ttlDefault {
5462 t.Errorf("--temporary alone = %v %v %v, want 7d", temp, ttl, err)
5463 }
5464 if temp, _, err := temporaryDeployFlags(false, false, "", "www.example.com", true, "binary", 1); err != nil || temp {
5465 t.Errorf("an ordinary deploy = %v %v, want untouched", temp, err)
5466 }
5467 // Astra plan R1 finding 5: --ttl given is --ttl given, whatever its value.
5468 // An empty or blank value is an invalid TTL, never an ordinary deploy.
5469 for _, raw := range []string{"", " ", "\t"} {
5470 if temp, _, err := temporaryDeployFlags(false, true, raw, "", false, "binary", 1); err == nil || temp {
5471 t.Errorf("--ttl %q = %v %v, want a refusal", raw, temp, err)
5472 }
5473 }
5474 for name, c := range map[string]struct {
5475 host string
5476 kindSet bool
5477 kind string
5478 nargs int
5479 }{
5480 "--host": {"www.example.com", false, "binary", 1},
5481 "--kind binary": {"", true, "binary", 1},
5482 "no site dir": {"", false, "binary", 0},
5483 } {
5484 if _, _, err := temporaryDeployFlags(true, false, "", c.host, c.kindSet, c.kind, c.nargs); err == nil {
5485 t.Errorf("--temporary with %s was accepted", name)
5486 }
5487 }
5488}
5489
5490// TestDeployRefusesABlankTTL drives the real entry point: flag parsing, the
5491// flagWasSet presence check and the refusal, before any session is resolved
5492// or anything is created. The binary runs with carlosTestEnv (no login) in a
5493// scratch dir, so reaching the ordinary deploy path would fail differently
5494// (no console) and not name --ttl.
5495func TestDeployRefusesABlankTTL(t *testing.T) {
5496 bin := buildCarlos(t)
5497 for _, args := range [][]string{
5498 {"deploy", "--ttl", "", "./site"},
5499 {"deploy", "--ttl=", "./site"},
5500 {"deploy", "--ttl", " ", "./site"},
5501 } {
5502 cmd := exec.Command(bin, args...)
5503 cmd.Env = carlosTestEnv(t)
5504 cmd.Dir = t.TempDir()
5505 out, err := cmd.CombinedOutput()
5506 if err == nil {
5507 t.Fatalf("carlos %q: want a non-zero exit, got 0\n%s", args, out)
5508 }
5509 if !strings.Contains(string(out), "--ttl") {
5510 t.Fatalf("carlos %q: the refusal does not name --ttl:\n%s", args, out)
5511 }
5512 }
5513}
5514
5515// fakeTemporaryConsole answers /new-temporary with the statuses given, in
5516// order, and counts every request it sees.
5517func fakeTemporaryConsole(t *testing.T, statuses ...int) (*apiSession, *atomic.Int32, *[]string) {
5518 t.Helper()
5519 var n atomic.Int32
5520 var mu sync.Mutex
5521 var names []string
5522 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5523 i := int(n.Add(1)) - 1
5524 var in struct {
5525 Name string `json:"name"`
5526 }
5527 json.NewDecoder(r.Body).Decode(&in)
5528 mu.Lock()
5529 names = append(names, in.Name)
5530 mu.Unlock()
5531 st := statuses[min(i, len(statuses)-1)]
5532 switch st {
5533 case http.StatusOK:
5534 json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": in.Name, "expires_at": "2026-10-03T12:00:00Z"})
5535 case http.StatusConflict:
5536 w.WriteHeader(st)
5537 json.NewEncoder(w).Encode(map[string]string{"error": "that name is taken", "code": "name_taken"})
5538 default:
5539 http.NotFound(w, r)
5540 }
5541 }))
5542 t.Cleanup(ts.Close)
5543 return newTestSession(ts.URL), &n, &names
5544}
5545
5546// Spec §9 "CLI": generated name shape and retry.
5547func TestCreateTemporaryRetriesGeneratedNames(t *testing.T) {
5548 labels := []string{"aaaaaa", "bbbbbb", "cccccc"}
5549 i := 0
5550 gen := func() string { l := labels[i]; i++; return l }
5551 sess, n, names := fakeTemporaryConsole(t, http.StatusConflict, http.StatusConflict, http.StatusOK)
5552 res, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, gen)
5553 if err != nil || res.App != "tmp-cccccc" || n.Load() != 3 {
5554 t.Fatalf("createTemporary = %+v %v after %d requests (%v), want tmp-cccccc on the third", res, err, n.Load(), *names)
5555 }
5556 // A name the caller chose is never retried under another name.
5557 sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict)
5558 if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "acme-docs", ttlDefault, gen); !errors.Is(err, errTemporaryNameTaken) || n.Load() != 1 {
5559 t.Fatalf("taken --app = %v after %d requests, want errTemporaryNameTaken after 1", err, n.Load())
5560 }
5561 // Generated names give up after temporaryNameAttempts.
5562 sess, n, _ = fakeTemporaryConsole(t, http.StatusConflict)
5563 if _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, func() string { return "zzzzzz" }); err == nil || n.Load() != temporaryNameAttempts {
5564 t.Fatalf("all taken = %v after %d requests, want an error after %d", err, n.Load(), temporaryNameAttempts)
5565 }
5566}
5567
5568// Spec §9: the CLI against a console without the route creates nothing.
5569func TestCreateTemporaryAgainstAConsoleWithoutTheRoute(t *testing.T) {
5570 sess, n, _ := fakeTemporaryConsole(t, http.StatusNotFound)
5571 _, err := createTemporary(context.Background(), sess, apiAcct{Seg: "bab"}, "", ttlDefault, func() string { return "aaaaaa" })
5572 if !errors.Is(err, errTemporaryUnavailable) || n.Load() != 1 {
5573 t.Fatalf("older console = %v after %d requests, want errTemporaryUnavailable after exactly 1", err, n.Load())
5574 }
5575}
5576
5577// Spec §3: the CLI checks the response carries a non-zero expires_at before
5578// it ships.
5579func TestCreateTemporaryRequiresTheExpiryEcho(t *testing.T) {
5580 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5581 json.NewEncoder(w).Encode(map[string]string{"account": "bab", "app": "tmp-docs"})
5582 }))
5583 defer ts.Close()
5584 _, err := newTestSession(ts.URL).createTemporaryApp(context.Background(), apiAcct{Seg: "bab"}, "tmp-docs", ttlDefault)
5585 if !errors.Is(err, errTemporaryNotConfirmed) || !strings.Contains(err.Error(), "carlos apps delete tmp-docs") {
5586 t.Fatalf("no expires_at = %v, want errTemporaryNotConfirmed naming the cleanup", err)
5587 }
5588}
5589
5590func TestAPISessionLifecycleDoors(t *testing.T) {
5591 var got []string
5592 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
5593 got = append(got, r.Method+" "+r.URL.Path)
5594 switch {
5595 case strings.HasSuffix(r.URL.Path, "/ttl"):
5596 var in struct {
5597 TTLSeconds int64 `json:"ttl_seconds"`
5598 }
5599 json.NewDecoder(r.Body).Decode(&in)
5600 if in.TTLSeconds != 5*86400 {
5601 t.Errorf("ttl_seconds = %d, want %d", in.TTLSeconds, 5*86400)
5602 }
5603 json.NewEncoder(w).Encode(map[string]string{"app": "tmp-a", "expires_at": "2026-10-01T12:00:00Z"})
5604 case strings.HasSuffix(r.URL.Path, "/keep"):
5605 json.NewEncoder(w).Encode(map[string]any{"app": "tmp-a", "expires_at": nil})
5606 default:
5607 w.Write([]byte(`[{"app":"tmp-a","deleted_at":null,"expires_at":"2026-10-01T12:00:00Z","purge_after":null},` +
5608 `{"app":"kass","deleted_at":"2026-09-26T12:00:00Z","expires_at":null,"purge_after":"2026-10-26T12:00:00Z"}]`))
5609 }
5610 }))
5611 defer ts.Close()
5612 s := newTestSession(ts.URL)
5613 ctx, acct := context.Background(), apiAcct{Seg: "bab"}
5614 if exp, err := s.appTTL(ctx, acct, "tmp-a", 5*24*time.Hour); err != nil || exp.Format(time.RFC3339) != "2026-10-01T12:00:00Z" {
5615 t.Fatalf("appTTL = %v %v", exp, err)
5616 }
5617 if err := s.appKeep(ctx, acct, "tmp-a"); err != nil {
5618 t.Fatalf("appKeep = %v", err)
5619 }
5620 lives, err := s.appList(ctx, acct)
5621 if err != nil || len(lives) != 2 || lives[0].ExpiresAt == nil || lives[1].DeletedAt == nil || lives[1].PurgeAfter == nil {
5622 t.Fatalf("appList = %+v %v", lives, err)
5623 }
5624 want := []string{"POST /api/cli/apps/bab/tmp-a/ttl", "POST /api/cli/apps/bab/tmp-a/keep", "GET /api/cli/apps/bab/list"}
5625 if strings.Join(got, "|") != strings.Join(want, "|") {
5626 t.Fatalf("requests = %v, want %v", got, want)
5627 }
5628}
5629
5630func TestAppLifeLine(t *testing.T) {
5631 now := time.Date(2026, 9, 26, 12, 0, 0, 0, time.UTC)
5632 at := func(d time.Duration) *time.Time { v := now.Add(d); return &v }
5633 if line := appLifeLine(apiAppLife{App: "tmp-a", ExpiresAt: at(72 * time.Hour)}, now); !strings.Contains(line, "live") || !strings.Contains(line, "expires in 3d") {
5634 t.Errorf("live temporary = %q", line)
5635 }
5636 if line := appLifeLine(apiAppLife{App: "tmp-b", ExpiresAt: at(-time.Hour), DeletedAt: at(-time.Hour), PurgeAfter: at(49 * time.Hour)}, now); !strings.Contains(line, "trashed") || !strings.Contains(line, "purged in 2d") {
5637 t.Errorf("trashed temporary = %q", line)
5638 }
5639 if line := appLifeLine(apiAppLife{App: "kass"}, now); strings.Contains(line, "expires") || strings.Contains(line, "purged") {
5640 t.Errorf("live permanent = %q, want no time", line)
5641 }
5642}
5643```
5644
5645- [ ] **Step 2: Run the tests to verify they fail**
5646
5647Run: `mkdir -p /tmp/g && TMPDIR=/tmp/g GOCACHE=/tmp/g/gocache go test . -run 'TestParseTTL|TestTemporaryAppName|TestTemporaryDeployFlags|TestDeployRefusesABlankTTL|TestCreateTemporary|TestAPISessionLifecycleDoors|TestAppLifeLine' -count=1`
5648Expected: FAIL to compile: `undefined: parseTTL`, `undefined: temporaryAppName`, `undefined: createTemporary`, `s.appTTL undefined`.
5649
5650- [ ] **Step 3: Implement**
5651
5652Append to `temporary.go` (its imports become `context`, `crypto/rand`, `errors`, `flag`, `fmt`, `log`, `strconv`, `strings`, `time`, and `internal/release`):
5653
5654```go
5655// The bounds match the console's (temporary static sites spec ruling 3).
5656// Checked here too so a typo costs no create budget.
5657const (
5658 ttlFloor = time.Hour
5659 ttlCeiling = 30 * 24 * time.Hour
5660 ttlDefault = 7 * 24 * time.Hour
5661 // temporaryNameAttempts bounds retries of a GENERATED name on "taken".
5662 temporaryNameAttempts = 5
5663)
5664
5665var errTTL = errors.New("a temporary app lives from 1h to 30d. Give a whole number of days like 3d, or a duration like 36h or 90m") // COPY-REVIEW C11.1
5666
5667// parseTTL reads --ttl and `carlos apps ttl`'s argument: Go durations plus
5668// whole days ("3d"). Days are bounded before they are multiplied, so no
5669// input overflows.
5670func parseTTL(s string) (time.Duration, error) {
5671 s = strings.ToLower(strings.TrimSpace(s))
5672 var d time.Duration
5673 if n, ok := strings.CutSuffix(s, "d"); ok {
5674 days, err := strconv.Atoi(n)
5675 if err != nil || days < 0 || days > int(ttlCeiling/(24*time.Hour)) {
5676 return 0, errTTL
5677 }
5678 d = time.Duration(days) * 24 * time.Hour
5679 } else {
5680 var err error
5681 if d, err = time.ParseDuration(s); err != nil {
5682 return 0, errTTL
5683 }
5684 }
5685 if d < ttlFloor || d > ttlCeiling {
5686 return 0, errTTL
5687 }
5688 return d, nil
5689}
5690
5691const temporaryLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
5692
5693// randomTemporaryLabel is six random lowercase letters and digits (spec §7).
5694func randomTemporaryLabel() string {
5695 b := make([]byte, 6)
5696 if _, err := rand.Read(b); err != nil {
5697 log.Fatalf("deploy: no randomness for a temporary app name: %v", err)
5698 }
5699 for i := range b {
5700 b[i] = temporaryLabelAlphabet[int(b[i])%len(temporaryLabelAlphabet)]
5701 }
5702 return string(b)
5703}
5704
5705// temporaryAppName is spec §7's naming: no --app gives tmp- plus a random
5706// label; --app acme-docs gives tmp-acme-docs; an --app already starting tmp-
5707// is used as given. The result must be a valid app name and a DNS label (it
5708// becomes the first label of the platform host), so a trailing hyphen or a
5709// name past 32 characters is refused here, before any request.
5710func temporaryAppName(app string, random func() string) (string, error) {
5711 name := strings.ToLower(strings.TrimSpace(app))
5712 switch {
5713 case name == "":
5714 name = release.TemporaryAppPrefix + random()
5715 case !release.IsTemporaryName(name):
5716 name = release.TemporaryAppPrefix + name
5717 }
5718 if name == release.TemporaryAppPrefix || !release.ValidApp(name) || strings.HasSuffix(name, "-") {
5719 return "", fmt.Errorf("%q cannot name a temporary app: it would be %s, and a temporary app's name is tmp- followed by "+
5720 "lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit", app, name) // COPY-REVIEW C11.2
5721 }
5722 return name, nil
5723}
5724
5725// temporaryDeployFlags applies spec §7's rules to deploy's flags: --ttl alone
5726// implies --temporary, and --host, --kind binary and a missing site
5727// directory are refused locally, before anything is created.
5728//
5729// ttlSet is whether --ttl was GIVEN (flagWasSet), not whether its value is
5730// blank: `--ttl ""` is a TTL the caller asked for and got wrong, and must be
5731// refused, never read as "no --ttl" and turned into an ordinary deploy.
5732func temporaryDeployFlags(temporary, ttlSet bool, ttlRaw, host string, kindSet bool, kind string, nargs int) (bool, time.Duration, error) {
5733 if ttlSet {
5734 temporary = true
5735 }
5736 if !temporary {
5737 return false, 0, nil
5738 }
5739 ttl := ttlDefault
5740 if ttlSet {
5741 d, err := parseTTL(ttlRaw)
5742 if err != nil {
5743 return false, 0, fmt.Errorf("--ttl %q: %w", ttlRaw, err)
5744 }
5745 ttl = d
5746 }
5747 switch {
5748 case host != "":
5749 return false, 0, errors.New("--temporary cannot be used with --host. A temporary app is served only at its own platform address") // COPY-REVIEW C11.3
5750 case kindSet && kind != "static":
5751 return false, 0, errors.New("--temporary deploys a static site, so it cannot be used with --kind binary") // COPY-REVIEW C11.4
5752 case nargs == 0:
5753 return false, 0, errors.New("--temporary needs the site directory to deploy, for example: carlos deploy --temporary ./dist") // COPY-REVIEW C11.5
5754 }
5755 return true, ttl, nil
5756}
5757
5758// createTemporary creates the app through /new-temporary, retrying a
5759// GENERATED name that is taken. A name the caller chose is tried once.
5760func createTemporary(ctx context.Context, sess *apiSession, acct apiAcct, appFlag string, ttl time.Duration, random func() string) (apiCreateTemporaryResult, error) {
5761 attempts := 1
5762 if strings.TrimSpace(appFlag) == "" {
5763 attempts = temporaryNameAttempts
5764 }
5765 var last error
5766 for i := 0; i < attempts; i++ {
5767 name, err := temporaryAppName(appFlag, random)
5768 if err != nil {
5769 return apiCreateTemporaryResult{}, err
5770 }
5771 res, err := sess.createTemporaryApp(ctx, acct, name, ttl)
5772 if errors.Is(err, errTemporaryNameTaken) && attempts > 1 {
5773 last = err
5774 continue
5775 }
5776 return res, err
5777 }
5778 return apiCreateTemporaryResult{}, fmt.Errorf("%d generated names in a row were taken. Try again, or name the app with --app: %w", attempts, last) // COPY-REVIEW C11.6
5779}
5780
5781// printTemporaryExpiry is spec §7's "carlos deploy to an existing temporary
5782// app: expiry unchanged, printed". Best effort: a console without /list says
5783// nothing rather than something it cannot back up.
5784func printTemporaryExpiry(ctx context.Context, sess *apiSession, acct apiAcct, app string) {
5785 lives, err := sess.appList(ctx, acct)
5786 if err != nil {
5787 return
5788 }
5789 for _, l := range lives {
5790 if l.App == app && l.ExpiresAt != nil && l.DeletedAt == nil {
5791 mutedf(stdout, "%s is temporary. It moves to the Trash %s, at %s. This deploy does not change that", // COPY-REVIEW C11.7
5792 app, inDuration(time.Until(*l.ExpiresAt)), l.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC"))
5793 }
5794 }
5795}
5796
5797// appLifeLine is one line of `carlos apps list` (spec §7): name, live or
5798// trashed, and "expires in 3d", "purged in 2d" or nothing.
5799func appLifeLine(l apiAppLife, now time.Time) string {
5800 state, note := "live", "" // COPY-REVIEW C11.8
5801 switch {
5802 case l.DeletedAt != nil:
5803 state = "trashed" // COPY-REVIEW C11.8
5804 if l.PurgeAfter != nil {
5805 note = "purged " + inDuration(l.PurgeAfter.Sub(now)) // COPY-REVIEW C11.8
5806 }
5807 case l.ExpiresAt != nil:
5808 note = "expires " + inDuration(l.ExpiresAt.Sub(now)) // COPY-REVIEW C11.8
5809 }
5810 return strings.TrimRight(fmt.Sprintf("%-32s %-7s %s", l.App, state, note), " ")
5811}
5812
5813// requireSignedInApps refuses direct mode by name for the apps verbs that
5814// only the console can answer.
5815func requireSignedInApps(verb, consoleFlag string) *apiSession {
5816 sess, err := resolveMode(consoleFlag)
5817 if err != nil {
5818 log.Fatal(err)
5819 }
5820 if sess == nil {
5821 log.Fatalf("carlos apps %s works through a console, and this shell is in direct bucket mode "+
5822 "(CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR is set). Temporary apps are recorded by the console, "+
5823 "which direct mode never reads. Unset the store variables and run: carlos auth login --console <url>", verb) // COPY-REVIEW C11.9
5824 }
5825 return sess
5826}
5827
5828// appsLifecycleFlags is the flag set the three verbs share. Not accountFlags:
5829// that also registers --registry for direct mode, which these never use.
5830func appsLifecycleFlags(name string) (fs *flag.FlagSet, app, acctRaw, acctIDRaw, consoleFlag *string) {
5831 fs = flag.NewFlagSet(name, flag.ExitOnError)
5832 app = appFlag(fs, "app name")
5833 acctRaw = fs.String("account", "", "account (sqid or name) the app is in (default: your only account, or the configured/chosen one)")
5834 acctIDRaw = fs.String("account-id", "", "account sqid, matched as a sqid only; supersedes --account")
5835 consoleFlag = consoleOption(fs, "console to use (default: the only logged-in one)")
5836 fs.Usage = flagUsage(fs, "")
5837 return fs, app, acctRaw, acctIDRaw, consoleFlag
5838}
5839
5840func runAppsTTL(args []string) {
5841 fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps ttl")
5842 fs.Parse(args)
5843 if strings.TrimSpace(*app) == "" || fs.NArg() != 1 {
5844 log.Fatal("usage: carlos apps ttl --app <name> <1h to 30d, e.g. 5d> [--account <sqid|name>] [--console <origin>]") // COPY-REVIEW C11.10
5845 }
5846 ttl, err := parseTTL(fs.Arg(0))
5847 if err != nil {
5848 log.Fatalf("apps ttl: %v", err)
5849 }
5850 sess := requireSignedInApps("ttl", *consoleFlag)
5851 printUsing(stdout, sess)
5852 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
5853 exp, err := sess.appTTL(context.Background(), acct, *app, ttl)
5854 if err != nil {
5855 log.Fatalf("apps ttl: %v", err)
5856 }
5857 fmt.Fprintf(stdout, "%s %s moves to the Trash %s, at %s\n", colorFor(stdout).ok("expiry set"), // COPY-REVIEW C11.11
5858 *app, inDuration(time.Until(exp)), exp.UTC().Format("2006-01-02 15:04 UTC"))
5859}
5860
5861func runAppsKeep(args []string) {
5862 fs, app, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps keep")
5863 fs.Parse(args)
5864 if strings.TrimSpace(*app) == "" || fs.NArg() != 0 {
5865 log.Fatal("usage: carlos apps keep --app <name> [--account <sqid|name>] [--console <origin>]") // COPY-REVIEW C11.12
5866 }
5867 sess := requireSignedInApps("keep", *consoleFlag)
5868 printUsing(stdout, sess)
5869 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
5870 if err := sess.appKeep(context.Background(), acct, *app); err != nil {
5871 log.Fatalf("apps keep: %v", err)
5872 }
5873 fmt.Fprintf(stdout, "%s %s is permanent now. It keeps its name\n", colorFor(stdout).ok("kept"), *app) // COPY-REVIEW C11.13
5874}
5875
5876func runAppsList(args []string) {
5877 fs, _, acctRaw, acctIDRaw, consoleFlag := appsLifecycleFlags("apps list")
5878 fs.Parse(args)
5879 if fs.NArg() != 0 {
5880 log.Fatal("usage: carlos apps list [--account <sqid|name>] [--console <origin>]") // COPY-REVIEW C11.14
5881 }
5882 sess := requireSignedInApps("list", *consoleFlag)
5883 printUsing(stdout, sess)
5884 acct := resolvedAccount(sess, acctRaw, acctIDRaw)
5885 lives, err := sess.appList(context.Background(), acct)
5886 if err != nil {
5887 log.Fatalf("apps list: %v", err)
5888 }
5889 if len(lives) == 0 {
5890 mutedf(stdout, "no apps in this account") // COPY-REVIEW C11.15
5891 return
5892 }
5893 now := time.Now()
5894 for _, l := range lives {
5895 fmt.Fprintln(stdout, appLifeLine(l, now))
5896 }
5897}
5898```
5899
5900Append to `apimode.go` after `restoreApp` (line 1622):
5901
5902```go
5903// ---- temporary apps (temporary static sites spec §3, §7) --------------------
5904
5905// apiCreateTemporaryResult is handleAPICreateTemporaryApp's success shape,
5906// with expires_at parsed. It is only returned once the expiry is confirmed.
5907type apiCreateTemporaryResult struct {
5908 Account string
5909 App string
5910 URL string
5911 ExpiresAt time.Time
5912}
5913
5914// apiAppLife is one row of GET /api/cli/apps/{account}/list.
5915type apiAppLife struct {
5916 App string `json:"app"`
5917 DeletedAt *time.Time `json:"deleted_at"`
5918 ExpiresAt *time.Time `json:"expires_at"`
5919 PurgeAfter *time.Time `json:"purge_after"`
5920}
5921
5922var (
5923 errTemporaryUnavailable = errors.New("this console does not offer temporary apps, or the account was not found (it answered 404). Nothing was created") // COPY-REVIEW C11.16
5924 errTemporaryNameTaken = errors.New("that name is taken") // COPY-REVIEW C11.17
5925 errTemporaryNotConfirmed = errors.New("the console did not confirm an expiry")
5926 errListUnavailable = errors.New("this console does not offer carlos apps list yet (it answered 404). The console needs upgrading first") // COPY-REVIEW C11.18
5927)
5928
5929// createTemporaryApp is POST /new-temporary. A 404 is an older console (or a
5930// switched-off one, or a wrong account) and nothing was created: the caller
5931// stops there (spec §3, astra 2.7). A 409 with code name_taken is
5932// errTemporaryNameTaken, which a generated name retries. A 200 without a
5933// usable expires_at is a console that did not make the app temporary, and
5934// the caller must not ship.
5935func (s *apiSession) createTemporaryApp(ctx context.Context, a apiAcct, app string, ttl time.Duration) (apiCreateTemporaryResult, error) {
5936 in := struct {
5937 Name string `json:"name"`
5938 TTLSeconds int64 `json:"ttl_seconds"`
5939 }{app, int64(ttl / time.Second)}
5940 status, body, err := s.postJSON(ctx, apiPath(a, "new-temporary", ""), in)
5941 if err != nil {
5942 return apiCreateTemporaryResult{}, err
5943 }
5944 switch status {
5945 case http.StatusOK:
5946 case http.StatusNotFound:
5947 return apiCreateTemporaryResult{}, errTemporaryUnavailable
5948 case http.StatusConflict:
5949 var e struct {
5950 Code string `json:"code"`
5951 }
5952 if json.Unmarshal(body, &e) == nil && e.Code == "name_taken" {
5953 return apiCreateTemporaryResult{}, fmt.Errorf("%w: %s", errTemporaryNameTaken, app)
5954 }
5955 return apiCreateTemporaryResult{}, apiErrorFromBody(status, body)
5956 default:
5957 return apiCreateTemporaryResult{}, apiErrorFromBody(status, body)
5958 }
5959 var res struct {
5960 Account string `json:"account"`
5961 App string `json:"app"`
5962 URL string `json:"url"`
5963 ExpiresAt string `json:"expires_at"`
5964 }
5965 if err := json.Unmarshal(body, &res); err != nil {
5966 return apiCreateTemporaryResult{}, fmt.Errorf("deploy: unreadable response from the console: %w", err)
5967 }
5968 if res.App == "" {
5969 res.App = app
5970 }
5971 exp, perr := time.Parse(time.RFC3339, res.ExpiresAt)
5972 if perr != nil || exp.Unix() <= 0 {
5973 return apiCreateTemporaryResult{App: res.App}, fmt.Errorf("the console created %s but did not confirm it is temporary, so nothing was shipped. "+
5974 "If you do not want it, delete it with: carlos apps delete %s (%w)", res.App, res.App, errTemporaryNotConfirmed) // COPY-REVIEW C11.19
5975 }
5976 return apiCreateTemporaryResult{Account: res.Account, App: res.App, URL: res.URL, ExpiresAt: exp}, nil
5977}
5978
5979// appTTL is POST {app}/ttl: the new expiry, as the console set it.
5980func (s *apiSession) appTTL(ctx context.Context, a apiAcct, app string, ttl time.Duration) (time.Time, error) {
5981 in := struct {
5982 TTLSeconds int64 `json:"ttl_seconds"`
5983 }{int64(ttl / time.Second)}
5984 status, body, err := s.postJSON(ctx, apiPath(a, app, "/ttl"), in)
5985 if err != nil {
5986 return time.Time{}, err
5987 }
5988 if status != http.StatusOK {
5989 return time.Time{}, apiErrorFromBody(status, body)
5990 }
5991 var res struct {
5992 ExpiresAt string `json:"expires_at"`
5993 }
5994 if err := json.Unmarshal(body, &res); err != nil {
5995 return time.Time{}, fmt.Errorf("apps ttl: unreadable response: %w", err)
5996 }
5997 return time.Parse(time.RFC3339, res.ExpiresAt)
5998}
5999
6000// appKeep is POST {app}/keep.
6001func (s *apiSession) appKeep(ctx context.Context, a apiAcct, app string) error {
6002 status, body, err := s.postJSON(ctx, apiPath(a, app, "/keep"), struct{}{})
6003 if err != nil {
6004 return err
6005 }
6006 if status != http.StatusOK {
6007 return apiErrorFromBody(status, body)
6008 }
6009 return nil
6010}
6011
6012// appList is GET /api/cli/apps/{account}/list.
6013func (s *apiSession) appList(ctx context.Context, a apiAcct) ([]apiAppLife, error) {
6014 status, body, err := s.getJSON(ctx, apiPath(a, "list", ""))
6015 if err != nil {
6016 return nil, err
6017 }
6018 if status == http.StatusNotFound {
6019 return nil, errListUnavailable
6020 }
6021 if status != http.StatusOK {
6022 return nil, apiErrorFromBody(status, body)
6023 }
6024 var out []apiAppLife
6025 if err := json.Unmarshal(body, &out); err != nil {
6026 return nil, fmt.Errorf("apps list: unreadable response: %w", err)
6027 }
6028 return out, nil
6029}
6030```
6031
6032`apps.go`, `RunApps` (lines 115-126) gains three cases:
6033
6034```go
6035 case "ttl":
6036 runAppsTTL(args[1:])
6037 case "keep":
6038 runA