From 63af6d250d2d41e794cf9cf2530ca321352fdd72 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Sun, 26 Jul 2026 10:08:43 +0530 Subject: [PATCH 1/3] feat(handler): gate finalize on root index.html (422 missing_index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs §T T2 --- internal/handler/deploy.go | 54 ++++++ internal/handler/deploy_missing_index_test.go | 160 ++++++++++++++++++ internal/handler/handler.go | 11 ++ 3 files changed, 225 insertions(+) create mode 100644 internal/handler/deploy_missing_index_test.go diff --git a/internal/handler/deploy.go b/internal/handler/deploy.go index 2a56bc7..0756975 100644 --- a/internal/handler/deploy.go +++ b/internal/handler/deploy.go @@ -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 { @@ -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 { diff --git a/internal/handler/deploy_missing_index_test.go b/internal/handler/deploy_missing_index_test.go new file mode 100644 index 0000000..1ce9a20 --- /dev/null +++ b/internal/handler/deploy_missing_index_test.go @@ -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")) +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 0e1d381..f9477af 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -247,6 +247,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 From 0799b324cf7a67c45a983017139d0c73c3fe3c57 Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Sun, 26 Jul 2026 10:25:00 +0530 Subject: [PATCH 2/3] feat(handler): gate promote/rollback on target index.html (422 missing_index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs §T T3 --- internal/handler/audit_wiring_test.go | 1 + internal/handler/breadcrumb_test.go | 1 + internal/handler/destructive_span_test.go | 1 + internal/handler/handler.go | 1 + internal/handler/outbox_emit_test.go | 1 + internal/handler/pg_writethrough_test.go | 1 + internal/handler/site.go | 21 ++++++ internal/handler/site_logaction_test.go | 1 + internal/handler/site_missing_index_test.go | 79 +++++++++++++++++++++ internal/handler/site_test.go | 4 ++ internal/handler/sitelock_test.go | 2 + internal/handler/test_helpers_test.go | 10 +++ internal/r2/r2.go | 18 +++++ 13 files changed, 141 insertions(+) create mode 100644 internal/handler/site_missing_index_test.go diff --git a/internal/handler/audit_wiring_test.go b/internal/handler/audit_wiring_test.go index 89adf4e..98c0e2c 100644 --- a/internal/handler/audit_wiring_test.go +++ b/internal/handler/audit_wiring_test.go @@ -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"}, diff --git a/internal/handler/breadcrumb_test.go b/internal/handler/breadcrumb_test.go index 677c621..a3f951d 100644 --- a/internal/handler/breadcrumb_test.go +++ b/internal/handler/breadcrumb_test.go @@ -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"}, diff --git a/internal/handler/destructive_span_test.go b/internal/handler/destructive_span_test.go index d65afb8..cc68217 100644 --- a/internal/handler/destructive_span_test.go +++ b/internal/handler/destructive_span_test.go @@ -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"}}}, diff --git a/internal/handler/handler.go b/internal/handler/handler.go index f9477af..c38ee39 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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) diff --git a/internal/handler/outbox_emit_test.go b/internal/handler/outbox_emit_test.go index 562b905..c43dd3f 100644 --- a/internal/handler/outbox_emit_test.go +++ b/internal/handler/outbox_emit_test.go @@ -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 diff --git a/internal/handler/pg_writethrough_test.go b/internal/handler/pg_writethrough_test.go index 48d45a0..1704565 100644 --- a/internal/handler/pg_writethrough_test.go +++ b/internal/handler/pg_writethrough_test.go @@ -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", diff --git a/internal/handler/site.go b/internal/handler/site.go index 1928bbb..92ed551 100644 --- a/internal/handler/site.go +++ b/internal/handler/site.go @@ -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) @@ -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 diff --git a/internal/handler/site_logaction_test.go b/internal/handler/site_logaction_test.go index 5c49fd0..650af23 100644 --- a/internal/handler/site_logaction_test.go +++ b/internal/handler/site_logaction_test.go @@ -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"}, diff --git a/internal/handler/site_missing_index_test.go b/internal/handler/site_missing_index_test.go new file mode 100644 index 0000000..61bb096 --- /dev/null +++ b/internal/handler/site_missing_index_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func promoteGH() *fakeGH { + return &fakeGH{ + tokenLogins: map[string]string{"tok": "alice"}, + userTeams: map[string]map[string]bool{"alice": {"team-eng": true}}, + } +} + +func TestSitePromote_MissingIndex_Rejects(t *testing.T) { + store := newFakeR2() + store.aliases["www/preview"] = "20260420-141522-abc1234" + h, _ := newTestHandlers(t, promoteGH(), standardSites(), store) + + w := withSiteRoute(http.MethodPost, "/api/site/{site}/promote", + "/api/site/www/promote", nil, + contextWithLogin(context.Background(), "alice", "tok"), + h.SitePromote, + ) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "missing_index") + + store.mu.Lock() + _, hasProd := store.aliases["www/production"] + store.mu.Unlock() + assert.False(t, hasProd, "production alias must not be written when the promoted deploy lacks index.html") +} + +func TestSitePromote_DirectWrite_MissingIndex_Rejects(t *testing.T) { + store := newFakeR2() + h, _ := newTestHandlers(t, promoteGH(), standardSites(), store) + + body, _ := json.Marshal(SitePromoteRequest{DeployID: "20260513-101010-cas9999"}) + w := withSiteRoute(http.MethodPost, "/api/site/{site}/promote", + "/api/site/www/promote", body, + contextWithLogin(context.Background(), "alice", "tok"), + h.SitePromote, + ) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "missing_index") + + store.mu.Lock() + _, hasProd := store.aliases["www/production"] + store.mu.Unlock() + assert.False(t, hasProd, "direct-write promote must not write prod when target lacks index.html") +} + +func TestSiteRollback_MissingIndex_Rejects(t *testing.T) { + store := newFakeR2() + store.objects["www/deploys/20260420-141522-old/page.html"] = []byte("no root index") + h, _ := newTestHandlers(t, promoteGH(), standardSites(), store) + + body, _ := json.Marshal(SiteRollbackRequest{To: "20260420-141522-old"}) + w := withSiteRoute(http.MethodPost, "/api/site/{site}/rollback", + "/api/site/www/rollback", body, + contextWithLogin(context.Background(), "alice", "tok"), + h.SiteRollback, + ) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code, w.Body.String()) + assert.Contains(t, w.Body.String(), "missing_index") + + store.mu.Lock() + _, hasProd := store.aliases["www/production"] + store.mu.Unlock() + assert.False(t, hasProd, "rollback to a deploy that exists but has no root index.html must not write prod") +} diff --git a/internal/handler/site_test.go b/internal/handler/site_test.go index 37bc82b..a114e32 100644 --- a/internal/handler/site_test.go +++ b/internal/handler/site_test.go @@ -42,6 +42,7 @@ func TestSitePromote_Atomic(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, gh, standardSites(), store) @@ -297,6 +298,7 @@ func TestSitePromote_DirectWriteSkipsPreviewRead(t *testing.T) { // Pre-seed a preview alias that would be promoted under the // legacy path. It must remain untouched here. store.aliases["www/preview"] = "20260420-141522-pre1234" + store.objects["www/deploys/20260513-101010-cas9999/index.html"] = []byte("hi") h, _ := newTestHandlers(t, gh, standardSites(), store) body, _ := json.Marshal(SitePromoteRequest{DeployID: "20260513-101010-cas9999"}) @@ -402,6 +404,7 @@ func TestSitePromote_CAS_HappyPath(t *testing.T) { store := newFakeR2() store.aliases["www/preview"] = "20260420-141522-newer1" store.aliases["www/production"] = "20260101-101010-older1" + store.objects["www/deploys/20260420-141522-newer1/index.html"] = []byte("hi") h, _ := newTestHandlers(t, gh, standardSites(), store) body, _ := json.Marshal(SitePromoteRequest{ExpectedCurrent: "20260101-101010-older1"}) @@ -461,6 +464,7 @@ func TestSitePromote_CAS_AndDeployID_AtomicSwap(t *testing.T) { store := newFakeR2() store.aliases["www/preview"] = "20260420-141522-pre1234" store.aliases["www/production"] = "20260101-101010-current" + store.objects["www/deploys/20260513-101010-cas9999/index.html"] = []byte("hi") h, _ := newTestHandlers(t, gh, standardSites(), store) body, _ := json.Marshal(SitePromoteRequest{ diff --git a/internal/handler/sitelock_test.go b/internal/handler/sitelock_test.go index cf4d43b..39007cf 100644 --- a/internal/handler/sitelock_test.go +++ b/internal/handler/sitelock_test.go @@ -128,6 +128,7 @@ func TestSitePromote_AliasWriteUnderSiteLock(t *testing.T) { h.Locker = &fakeLocker{log: log} 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", "/api/site/www/promote", body, @@ -164,6 +165,7 @@ func TestSitePromote_CASReadInsideLock(t *testing.T) { log := &eventLog{} store := &loggingR2{fakeR2: newFakeR2(), log: log} store.aliases["www/production"] = "20260101-000000-old0001" + store.objects["www.freecode.camp/deploys/20260420-141522-abc1234/index.html"] = []byte("hi") h, _ := newTestHandlers(t, authedGH(), standardSites(), store) h.DeployPrefix = mustDeployPrefixTemplate(prodShapedFormat) diff --git a/internal/handler/test_helpers_test.go b/internal/handler/test_helpers_test.go index 66827c6..31d3b99 100644 --- a/internal/handler/test_helpers_test.go +++ b/internal/handler/test_helpers_test.go @@ -396,6 +396,16 @@ func (f *fakeR2) HasPrefix(_ context.Context, prefix string) (bool, error) { return false, nil } +func (f *fakeR2) HasObject(_ context.Context, key string) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.listErr != nil { + return false, f.listErr + } + _, ok := f.objects[key] + return ok, nil +} + func (f *fakeR2) MovePrefix(ctx context.Context, src, dst string) (int, error) { if err := ctx.Err(); err != nil { return 0, err diff --git a/internal/r2/r2.go b/internal/r2/r2.go index 1b2b303..8f6d6a3 100644 --- a/internal/r2/r2.go +++ b/internal/r2/r2.go @@ -153,6 +153,24 @@ func (c *Client) HasPrefix(ctx context.Context, prefix string) (bool, error) { return len(page.Contents) > 0, nil } +func (c *Client) HasObject(ctx context.Context, key string) (bool, error) { + _, err := c.s3.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: awsv2.String(c.bucket), + Key: awsv2.String(key), + }) + if err != nil { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + switch apiErr.ErrorCode() { + case "NoSuchKey", "NotFound": + return false, nil + } + } + return false, fmt.Errorf("r2 head %s: %w", key, err) + } + return true, nil +} + // ListPrefix returns all keys under the given prefix. func (c *Client) ListPrefix(ctx context.Context, prefix string) ([]string, error) { var out []string From 9d05d0adb6a72132d885b8c191571b7ef6a11fbf Mon Sep 17 00:00:00 2001 From: Mrugesh Mohapatra Date: Mon, 27 Jul 2026 18:06:25 +0530 Subject: [PATCH 3/3] docs(readme): document missing_index 422 on finalize/promote/rollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs §T T4 --- docs/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index e6d0a32..e423abc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) @@ -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`):