Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ DELETE /api/site/{site}/deploys/{deployId} → 200 { site,
POST /api/site/{site}/deploys/{deployId}/restore → 200 { site, deployId, status: "restored", moved, bytes } · 410 site_gone/already_purged
GET /api/site/{site}/trash → [{ deployId, trashedAt, expiresAt, bytes }]
GET /api/site/{site}/alias/{mode} → { site, mode, deployId, url }
POST /api/site/{site}/promote → { url }
POST /api/site/{site}/rollback { to } → { url }
POST /api/site/{site}/promote → { url } · 422 missing_index
POST /api/site/{site}/rollback { to } → { url } · 422 missing_index

POST /api/repo { name, visibility?, description?, template? } → 201 RepoRow (feature-gated)
GET /api/repos [?status=&mine=] → [RepoRow] (feature-gated)
Expand All @@ -35,11 +35,13 @@ DELETE /api/repo/{id} → 204
GET /api/audit [?site=&actor=&action=&since=&limit=&offset=] → [AuditRow] (durable trail, newest-first)

PUT /api/deploy/{deployId}/upload multipart stream → { received }
POST /api/deploy/{deployId}/finalize { mode } → { url }
POST /api/deploy/{deployId}/finalize { mode } → { url } · 422 missing_index
```

`/api/repo*` is mounted only when `RepoEnabled()` is true (Apollo-11 App credentials configured — see Configuration). `DELETE /api/site/{slug}?purge=true` additionally moves the site's R2 prefix to `_trash/` and records a tombstone (gated the same as the plain delete); the bare `DELETE` only removes the registry row. `POST /api/site/{site}/deploys/{deployId}/restore` reverses a `DELETE .../deploys/{deployId}` tombstone, moving the bytes back from `_trash/` and re-marking the deploy active; `GET /api/site/{site}/trash` lists the site's tombstoned deploys with their purge-eligibility `expiresAt` (`CLEANUP_RECOVERY_DAYS` out from `trashedAt`).

A deploy becomes live only if it is servable at `/`: `finalize`, `promote`, and `rollback` reject with `422 missing_index` (alias untouched, previous deploy keeps serving) when the target deploy has no root `index.html` — the one object the serve plane requires for `/`. On `finalize` the `422` body additionally carries an advisory `hint` when the upload looks like a framework build directory (e.g. a raw `.next` server build) rather than a static export. See ADR-016 §2026-07-26.

`GET /api/audit` reads the durable, append-only `audit_log` — every privileged action attributed to an actor: staff/CI lifecycle (deploy, site, repo) plus system-driven GC rows (`gc.purge` under `actor=system:gc`, reconcile under `actor=system:reconcile`). Filter by `site` / `actor` / `action` / `since` (RFC3339), paginated (`limit` default 100, max 500 — `limit=0` clamps to the default 100, it does not return zero rows; `offset`), newest-first. It replaces the raw-`psql`-on-prod path for reading the trail. Because the trail is cross-tenant, the endpoint is team-gated: the caller must be on the Universe-org staff team (`AUDIT_READ_AUTHZ_TEAM`, default `staff`) — not merely any authenticated GitHub bearer. From the CLI: `universe audit ls [--actor --action --site --since --limit] [--json]` (universe-cli release follows artemis v1.5.0, since it depends on the deployed endpoint).

Auth headers (`/api/*` except `/healthz`, `/readyz`):
Expand Down
1 change: 1 addition & 0 deletions internal/handler/audit_wiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestSitePromote_RecordsExactlyOneAudit(t *testing.T) {
fa := &fakeAudit{}
store := newFakeR2()
store.aliases["www/preview"] = "20260420-141522-abc1234"
store.objects["www/deploys/20260420-141522-abc1234/index.html"] = []byte("hi")
h, _ := newTestHandlers(t,
&fakeGH{
tokenLogins: map[string]string{"good": "alice"},
Expand Down
1 change: 1 addition & 0 deletions internal/handler/breadcrumb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func TestSitePromote_AddsBreadcrumbs(t *testing.T) {
hub, ft := newHubWithTransport(t)
store := newFakeR2()
store.aliases["www/preview"] = "20260420-141522-abc1234"
store.objects["www/deploys/20260420-141522-abc1234/index.html"] = []byte("hi")
h, _ := newTestHandlers(t,
&fakeGH{
tokenLogins: map[string]string{"good": "alice"},
Expand Down
54 changes: 54 additions & 0 deletions internal/handler/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,15 @@ func (h *Handlers) DeployFinalize(w http.ResponseWriter, r *http.Request) {
"files manifest is required and must list at least one path")
return
}
if !hasIndexHTML(req.Files) {
var extra map[string]any
if hint := frameworkBuildHint(req.Files); hint != "" {
extra = map[string]any{"hint": hint}
}
writeErrorDetail(w, http.StatusUnprocessableEntity, "missing_index",
"deploy has no root index.html; the site cannot be served at /", extra)
return
}

prefix := h.deployPrefix(claims.Site, deployID)
if err := telemetry.WithSpan(r.Context(), "r2.list.verify", func(ctx context.Context) error {
Expand Down Expand Up @@ -301,6 +310,51 @@ func (h *Handlers) DeployFinalize(w http.ResponseWriter, r *http.Request) {
})
}

const rootIndexKey = "index.html"

const staticExportHint = "This looks like a framework build directory, not a static export. " +
"Configure a static export (e.g. Next.js output: 'export', Nuxt nuxi generate, SvelteKit adapter-static) " +
"and point platform.yaml build.output at the export directory (e.g. out/, dist/, build/)."

func hasIndexHTML(files []string) bool {
for _, f := range files {
if f == rootIndexKey {
return true
}
}
return false
}

func looksLikeFrameworkBuild(files []string) bool {
var hasBuildID, hasBuildManifest bool
for _, f := range files {
p := strings.ReplaceAll(f, `\`, "/")
switch p {
case "BUILD_ID":
hasBuildID = true
case "build-manifest.json":
hasBuildManifest = true
case "nitro.json":
return true
}
if strings.HasPrefix(p, "_app/immutable/") ||
strings.HasPrefix(p, ".next/") ||
strings.HasPrefix(p, ".nuxt/") ||
strings.HasPrefix(p, ".svelte-kit/") ||
strings.HasPrefix(p, ".output/") {
return true
}
}
return hasBuildID && hasBuildManifest
}

func frameworkBuildHint(files []string) string {
if looksLikeFrameworkBuild(files) {
return staticExportHint
}
return ""
}

// deployPrefix returns the R2 key prefix for one deploy, e.g.
// "www/deploys/20260420-141522-abc1234/".
func (h *Handlers) deployPrefix(site, deployID string) string {
Expand Down
160 changes: 160 additions & 0 deletions internal/handler/deploy_missing_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package handler

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/freeCodeCamp/artemis/internal/gc"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHasIndexHTML(t *testing.T) {
cases := []struct {
name string
files []string
want bool
}{
{"bare root", []string{"index.html"}, true},
{"root among others", []string{"index.html", "assets/app.js"}, true},
{"root with framework markers still served", []string{"index.html", ".next/BUILD_ID", "BUILD_ID"}, true},
{"nested only", []string{"assets/index.html", "app.js"}, false},
{"dot-slash prefix is a different key", []string{"./index.html"}, false},
{"leading slash is a different key", []string{"/index.html"}, false},
{"uppercase is a different key", []string{"INDEX.HTML"}, false},
{"no index at all", []string{"app.js", "style.css"}, false},
{"empty", nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, hasIndexHTML(c.files))
})
}
}

func TestLooksLikeFrameworkBuild(t *testing.T) {
cases := []struct {
name string
files []string
want bool
}{
{"next bare-root (the incident)", []string{"BUILD_ID", "build-manifest.json", "server/app.js"}, true},
{"next prefixed", []string{".next/BUILD_ID", "index.html"}, true},
{"nitro output", []string{"nitro.json", "server/index.mjs"}, true},
{"sveltekit client assets", []string{"_app/immutable/chunks/x.js"}, true},
{"nitro prefixed", []string{".output/server/index.mjs"}, true},
{"nuxt cache prefixed", []string{".nuxt/dist/x.js"}, true},
{"windows backslash prefixed", []string{`.next\BUILD_ID`, `.next\build-manifest.json`}, true},
{"single next marker is not enough", []string{"BUILD_ID"}, false},
{"plain static site", []string{"index.html", "app.js", "style.css"}, false},
{"empty", nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, looksLikeFrameworkBuild(c.files))
})
}
}

func finalizeMissingIndex(t *testing.T, store *fakeR2, files []string) *httptest.ResponseRecorder {
t.Helper()
h, jwt := newTestHandlers(t, &fakeGH{}, standardSites(), store)
deployID := "20260420-141522-abc1234"
tok, _, err := jwt.Sign("alice", "www", deployID)
require.NoError(t, err)
body, _ := json.Marshal(DeployFinalizeRequest{Mode: "preview", Files: files})
return withChiRoute(http.MethodPost, "/api/deploy/{deployId}/finalize",
"/api/deploy/"+deployID+"/finalize",
body,
map[string]string{"Authorization": "Bearer " + tok},
h.RequireDeployJWT(http.HandlerFunc(h.DeployFinalize)).ServeHTTP,
context.Background(),
)
}

func TestDeployFinalize_MissingIndexHTML(t *testing.T) {
store := newFakeR2()
w := finalizeMissingIndex(t, store, []string{"assets/app.js", "style.css"})

require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "missing_index")

store.mu.Lock()
_, hasAlias := store.aliases["www/preview"]
store.mu.Unlock()
assert.False(t, hasAlias, "alias must NOT be written when root index.html is missing")
}

func TestDeployFinalize_MissingIndexHTML_NestedIndexNotSufficient(t *testing.T) {
store := newFakeR2()
w := finalizeMissingIndex(t, store, []string{"assets/index.html", "app.js"})

require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "missing_index")

store.mu.Lock()
_, hasAlias := store.aliases["www/preview"]
store.mu.Unlock()
assert.False(t, hasAlias, "nested assets/index.html must not satisfy the root index gate")
}

func TestDeployFinalize_MissingIndexHTML_NoMarkerWritten(t *testing.T) {
store := newFakeR2()
deployID := "20260420-141522-abc1234"
prefix := "www/deploys/" + deployID + "/"

w := finalizeMissingIndex(t, store, []string{"app.js"})
require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())

store.mu.Lock()
_, hasMarker := store.objects[prefix+gc.MarkerObjectName]
store.mu.Unlock()
assert.False(t, hasMarker, "no deploy marker may be written on a missing_index rejection")
}

func TestDeployFinalize_MissingIndexHTML_FrameworkHint(t *testing.T) {
store := newFakeR2()
w := finalizeMissingIndex(t, store, []string{"BUILD_ID", "build-manifest.json", "server/app.js"})

require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
body := w.Body.String()
assert.Contains(t, body, "missing_index")
assert.Contains(t, body, "static export", "framework-build rejections must carry the static-export hint")
}

func TestDeployFinalize_MissingIndexHTML_NoHintForPlainStatic(t *testing.T) {
store := newFakeR2()
w := finalizeMissingIndex(t, store, []string{"styles.css", "logo.png"})

require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
assert.NotContains(t, w.Body.String(), "static export")
}

func TestDeployFinalize_MissingIndexHTML_ErrCodeRecorded(t *testing.T) {
cap := captureAccessLog(t)
store := newFakeR2()
h, jwt := newTestHandlers(t, &fakeGH{}, standardSites(), store)
deployID := "20260420-141522-abc1234"
tok, _, err := jwt.Sign("alice", "www", deployID)
require.NoError(t, err)
body, _ := json.Marshal(DeployFinalizeRequest{Mode: "preview", Files: []string{"app.js"}})

router := chi.NewRouter()
router.Use(RequestID)
router.Use(AccessLog)
router.Post("/api/deploy/{deployId}/finalize",
h.RequireDeployJWT(http.HandlerFunc(h.DeployFinalize)).ServeHTTP)

req := httptest.NewRequest(http.MethodPost, "/api/deploy/"+deployID+"/finalize", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String())
assert.Equal(t, "missing_index", cap.httpAttr(t, "errCode"))
}
1 change: 1 addition & 0 deletions internal/handler/destructive_span_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func TestDestructiveFlow_BreadcrumbsAndSpans(t *testing.T) {

store := newFakeR2()
store.aliases["www/preview"] = "20260420-141522-abc1234"
store.objects["www/deploys/20260420-141522-abc1234/index.html"] = []byte("hi")
h, _ := newTestHandlers(t,
&fakeGH{tokenLogins: map[string]string{"good": "alice"}, userTeams: map[string]map[string]bool{"alice": {"team-a": true}}},
&fakeSites{bySite: map[string][]string{"www": {"team-a"}}},
Expand Down
12 changes: 12 additions & 0 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type R2Store interface {
GetAlias(ctx context.Context, aliasKey string) (string, error)
ListPrefix(ctx context.Context, prefix string) ([]string, error)
HasPrefix(ctx context.Context, prefix string) (bool, error)
HasObject(ctx context.Context, key string) (bool, error)
VerifyDeployComplete(ctx context.Context, prefix string, expected []string) error
MovePrefix(ctx context.Context, src, dst string) (int, error)
PrefixBytes(ctx context.Context, prefix string) (int64, error)
Expand Down Expand Up @@ -247,6 +248,17 @@ func writeError(w http.ResponseWriter, status int, code, message string) {
})
}

func writeErrorDetail(w http.ResponseWriter, status int, code, message string, extra map[string]any) {
if sw, ok := w.(*statusWriter); ok {
sw.errCode = code
}
errObj := map[string]any{"code": code, "message": message}
for k, v := range extra {
errObj[k] = v
}
writeJSON(w, status, map[string]any{"error": errObj})
}

// writeUpstreamError logs err with full context and writes an opaque
// generic message to the client. Use whenever err comes from a
// transitive dependency (R2 SDK, go-redis, GitHub API) whose strings
Expand Down
1 change: 1 addition & 0 deletions internal/handler/outbox_emit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ func TestFinalize_EmitsSiteChanged(t *testing.T) {
func TestPromote_EmitsSiteChanged(t *testing.T) {
store := newFakeR2()
store.aliases["www/preview"] = "20260420-141522-abc1234"
store.objects["www/deploys/20260420-141522-abc1234/index.html"] = []byte("hi")
h, _ := newTestHandlers(t, authedGH(), standardSites(), store)
ob := &fakeOutbox{}
h.Outbox = ob
Expand Down
1 change: 1 addition & 0 deletions internal/handler/pg_writethrough_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func TestSitePromote_PGWriteThrough(t *testing.T) {
h.Outbox = ob

deployID := "20260420-141522-abc1234"
store.objects["www.freecode.camp/deploys/"+deployID+"/index.html"] = []byte("hi")
body, _ := json.Marshal(SitePromoteRequest{DeployID: deployID})

w := withSiteRoute(http.MethodPost, "/api/site/{site}/promote",
Expand Down
21 changes: 21 additions & 0 deletions internal/handler/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,17 @@ func (h *Handlers) SitePromote(w http.ResponseWriter, r *http.Request) {
}
}

hasIndex, err := h.R2.HasObject(r.Context(), h.deployPrefix(site, deployID)+rootIndexKey)
if err != nil {
writeUpstreamError(w, r, http.StatusBadGateway, "r2_head_failed", "r2.head.index.promote", err)
return errAliasWriteHandled
}
if !hasIndex {
writeError(w, http.StatusUnprocessableEntity, "missing_index",
"target deploy has no root index.html; it cannot be served at /")
return errAliasWriteHandled
}

telemetry.Breadcrumb(r.Context(), "promote", "production alias write")
if err := telemetry.WithSpan(r.Context(), "r2.put.alias.promote", func(ctx context.Context) error {
return h.R2.PutAlias(ctx, prodKey, deployID)
Expand Down Expand Up @@ -211,6 +222,16 @@ func (h *Handlers) SiteRollback(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnprocessableEntity, "deploy_missing", "target deploy no longer exists in r2")
return errAliasWriteHandled
}
hasIndex, err := h.R2.HasObject(r.Context(), prefix+rootIndexKey)
if err != nil {
writeUpstreamError(w, r, http.StatusBadGateway, "r2_head_failed", "r2.head.index.rollback", err)
return errAliasWriteHandled
}
if !hasIndex {
writeError(w, http.StatusUnprocessableEntity, "missing_index",
"target deploy has no root index.html; it cannot be served at /")
return errAliasWriteHandled
}

// CAS guard: read current prod alias and bail on mismatch. Missing
// alias normalises to empty-string — symmetric with SitePromote so
Expand Down
1 change: 1 addition & 0 deletions internal/handler/site_logaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestSitePromote_LogsActionWithActor(t *testing.T) {
cap := captureAccessLog(t)
store := newFakeR2()
store.aliases["www/preview"] = "20260420-141522-abc1234"
store.objects["www/deploys/20260420-141522-abc1234/index.html"] = []byte("hi")
h, _ := newTestHandlers(t,
&fakeGH{
tokenLogins: map[string]string{"good": "alice"},
Expand Down
Loading
Loading