diff --git a/src/server/admin.sx b/src/server/admin.sx new file mode 100644 index 0000000..988a516 --- /dev/null +++ b/src/server/admin.sx @@ -0,0 +1,555 @@ +// ===================================================================== +// admin.sx — the read-only admin console served by distd (subplan 06, +// P6.1): server-rendered HTML, no client framework, no build step — +// the same approach as the install pages, per PLAN.md "product code +// lives in sx". +// +// /admin apps overview: platform coverage, channels, +// latest release per app +// /admin/apps/ app detail: metadata, iOS install mode, +// channels (policy/rollout/retention), +// releases newest-first +// /admin/releases/ release detail: artifacts with validation +// status, channels pointing at it, audit +// timeline +// /admin/tokens tokens with lifecycle status — names, +// scopes, restrictions; NEVER a secret or hash +// /admin/audit the full audit log, newest first +// +// ACCESS: read-only and unauthenticated, like every distd GET in v0 — +// the console is a NAS-internal operator surface behind a reverse +// proxy. The tokens screen shows the same fields as `dist token list` +// (no secret material exists to leak; only hashes are at rest, and +// those are never rendered). Mutations stay on the token-gated POST +// /api endpoints; UI actions are a later slice. +// +// Self-contained on purpose: distd.sx imports this module, so nothing +// here may import distd.sx back; the few shared-shape helpers (loading, +// JSON errors, escaping) are local `adm_`-prefixed copies — top-level +// names resolve program-wide in sx, so the prefix also keeps them from +// colliding with distd's. +// ===================================================================== + +#import "modules/std.sx"; +#import "modules/std/json.sx"; +#import "../domain/platform.sx"; +#import "../domain/app.sx"; +#import "../domain/release.sx"; +#import "../domain/artifact.sx"; +#import "../domain/channel.sx"; +#import "../domain/token.sx"; +#import "../domain/audit.sx"; +#import "../domain/validate.sx"; +#import "../repo/repo.sx"; +http :: #import "http.sx"; +db :: #import "../repo/db.sx"; +jout :: #import "../json_out.sx"; +pl :: #import "../publish/publish.sx"; +tops :: #import "../token/ops.sx"; + +// ── small helpers (adm_-prefixed; see module header) ───────────────── + +adm_starts_with :: (s: string, prefix: string) -> bool { + if prefix.len > s.len { return false; } + i := 0; + while i < prefix.len { + if s[i] != prefix[i] { return false; } + i += 1; + } + return true; +} + +adm_tail :: (s: string, prefix: string) -> string { + return string.{ ptr = @s[prefix.len], len = s.len - prefix.len }; +} + +// Escape &, <, >, " for HTML text/attribute positions. +adm_esc :: (s: string) -> string { + acc := ""; + start := 0; + i := 0; + while i < s.len { + c := s[i]; + rep := ""; + if c == 38 { rep = "&"; } // & + if c == 60 { rep = "<"; } // < + if c == 62 { rep = ">"; } // > + if c == 34 { rep = """; } // " + if rep.len > 0 { + if i > start { acc = concat(acc, string.{ ptr = @s[start], len = i - start }); } + acc = concat(acc, rep); + start = i + 1; + } + i += 1; + } + if start == 0 { return s; } + if start < s.len { acc = concat(acc, string.{ ptr = @s[start], len = s.len - start }); } + return acc; +} + +adm_sha12 :: (sha: string) -> string { + if sha.len < 12 { return sha; } + return string.{ ptr = sha.ptr, len = 12 }; +} + +adm_pad2 :: (v: i64) -> string { + if v < 10 { return concat("0", int_to_string(v)); } + return int_to_string(v); +} + +// Epoch seconds as a compact UTC stamp ("2026-06-12 14:03Z"); 0 renders +// as a dash (the model's "never / not yet"). Civil-from-days conversion. +adm_time :: (secs: i64) -> string { + if secs <= 0 { return "—"; } + days := secs / 86400; + rem := secs % 86400; + z := days + 719468; + era := z / 146097; + doe := z - era * 146097; + yoe := (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + y := yoe + era * 400; + doy := doe - (365 * yoe + yoe / 4 - yoe / 100); + mp := (5 * doy + 2) / 153; + d := doy - (153 * mp + 2) / 5 + 1; + m := mp + 3; + if mp >= 10 { m = mp - 9; } + if m <= 2 { y += 1; } + s := concat(int_to_string(y), concat("-", adm_pad2(m))); + s = concat(s, concat("-", adm_pad2(d))); + s = concat(s, concat(" ", adm_pad2(rem / 3600))); + s = concat(s, concat(":", adm_pad2((rem % 3600) / 60))); + return concat(s, "Z"); +} + +// Byte count as a human size ("8 B", "12.4 KB", "3.1 MB"). +adm_size :: (bytes: i64) -> string { + if bytes < 1024 { return concat(int_to_string(bytes), " B"); } + unit := " KB"; + scaled := bytes * 10 / 1024; + if scaled >= 10240 { + unit = " MB"; + scaled = bytes * 10 / (1024 * 1024); + } + if scaled >= 10240 { + unit = " GB"; + scaled = bytes * 10 / (1024 * 1024 * 1024); + } + s := concat(int_to_string(scaled / 10), concat(".", int_to_string(scaled % 10))); + return concat(s, unit); +} + +adm_has_str :: (l: *List(string), s: string) -> bool { + i := 0; + while i < l.len { + if l.items[i] == s { return true; } + i += 1; + } + return false; +} + +// ── responses ───────────────────────────────────────────────────────── + +adm_error :: (client: i32, code: i64, fail_code: string, fail_message: string) { + f : jout.CliFailure = .{ code = fail_code, message = fail_message }; + raw : [4096]u8 = ---; + werr := false; + n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 }; + body := "{\"status\":\"error\"}"; + if !werr { body = string.{ ptr = @raw[0], len = n }; } + http.respond(client, code, "application/json", "", body); +} + +adm_load :: (client: i32, store_dir: string) -> ?Repo { + if !db.store_exists(store_dir) { + adm_error(client, 503, "store.load", + concat("no store database (nothing published yet): ", store_dir)); + return null; + } + loaded, le := db.load(store_dir); + if le { + adm_error(client, 503, "store.load", + concat("the store database could not be loaded: ", store_dir)); + return null; + } + return loaded; +} + +adm_html :: (client: i32, page: string) { + http.respond(client, 200, "text/html; charset=utf-8", "", page); +} + +// ── chrome ──────────────────────────────────────────────────────────── + +ADM_STYLE :: ""; + +// Page head + the quiet nav row; `active` marks the current section +// ("apps" | "tokens" | "audit"). +adm_head :: (title: string, active: string) -> string { + s := concat("", concat(adm_esc(title), "")); + s = concat(s, ADM_STYLE); + s = concat(s, concat("

", concat(adm_esc(title), "

"); +} + +ADM_FOOT :: ""; + +adm_chip :: (label: string, cls: string) -> string { + s := concat("", concat(adm_esc(label), ""))); +} + +adm_validation_chip :: (s: ValidationStatus) -> string { + if s == .valid { return adm_chip("valid", " c-ok"); } + if s == .invalid { return adm_chip("invalid", " c-bad"); } + return adm_chip("pending", " c-warn"); +} + +// The three iOS install modes must be visually distinct (PLAN.md "iOS +// Install Policy"): artifact-only is the quiet default, the other two +// carry their install-flow claim in the label. +adm_ios_chip :: (m: IosMode) -> string { + if m == .testflight { return adm_chip("ios: testflight", " c-ok"); } + if m == .enterprise { return adm_chip("ios: enterprise/MDM", " c-warn"); } + return adm_chip("ios: artifact-only", ""); +} + +adm_token_chip :: (status: string) -> string { + if status == "active" { return adm_chip("active", " c-ok"); } + if status == "revoked" { return adm_chip("revoked", " c-bad"); } + return adm_chip("expired", " c-warn"); +} + +adm_app_link :: (slug: string) -> string { + s := concat("", concat(adm_esc(slug), ""))); +} + +adm_release_link :: (id: string, label: string) -> string { + s := concat("", concat(adm_esc(label), ""))); +} + +// ── /admin — apps overview ──────────────────────────────────────────── + +render_admin_apps :: (repo: *Repo) -> string { + page := adm_head("dist admin — apps", "apps"); + if repo.apps.len == 0 { + page = concat(page, "

nothing published yet

"); + return concat(page, ADM_FOOT); + } + page = concat(page, "
"); + a := 0; + while a < repo.apps.len { + app := repo.apps.items[a]; + a += 1; + + // Platform coverage: distinct platforms across the app's artifacts. + plats : List(string) = .{}; + nrel := 0; + latest : Release = .{}; + i := 0; + while i < repo.artifacts.len { + art := repo.artifacts.items[i]; + if art.app_id == app.id { + ps := db.platform_str(art.platform); + if !adm_has_str(@plats, ps) { plats.append(ps, context.allocator); } + } + i += 1; + } + i = 0; + while i < repo.releases.len { + r := repo.releases.items[i]; + if r.app_id == app.id { nrel += 1; latest = r; } + i += 1; + } + nchan := 0; + i = 0; + while i < repo.channels.len { + if repo.channels.items[i].app_id == app.id { nchan += 1; } + i += 1; + } + plist := ""; + i = 0; + while i < plats.len { + if i > 0 { plist = concat(plist, " "); } + plist = concat(plist, plats.items[i]); + i += 1; + } + if plist.len == 0 { plist = "—"; } + + row := concat("")); + } else { + row = concat(row, "—"); + } + page = concat(page, row); + } + page = concat(page, "
appnamevisibilityios modeplatformschannelsreleaseslatestpublished
", concat(adm_app_link(app.slug), "")); + row = concat(row, concat(adm_esc(app.display_name), "")); + row = concat(row, concat(db.visibility_str(app.visibility), "")); + row = concat(row, concat(adm_ios_chip(app.ios_mode), "")); + row = concat(row, concat(plist, "")); + row = concat(row, concat(int_to_string(nchan), "")); + row = concat(row, concat(int_to_string(nrel), "")); + if nrel > 0 { + row = concat(row, concat(adm_release_link(latest.id, latest.version), "")); + row = concat(row, concat(adm_time(latest.published_at), "
"); + return concat(page, ADM_FOOT); +} + +// ── /admin/apps/ — app detail ─────────────────────────────────── + +render_admin_app :: (repo: *Repo, app: App) -> string { + page := adm_head(concat("dist admin — ", app.slug), "apps"); + + page = concat(page, "

app

"); + page = concat(page, concat(""))); + page = concat(page, concat(""))); + if app.owner.len > 0 { + page = concat(page, concat(""))); + } + page = concat(page, concat(""))); + page = concat(page, concat(""))); + if app.testflight_url.len > 0 { + page = concat(page, concat(""))); + } + b := 0; + while b < app.bundle_ids.len { + bid := app.bundle_ids.items[b]; + page = concat(page, concat(""))))); + b += 1; + } + page = concat(page, concat(""))); + page = concat(page, concat(""))); + page = concat(page, "
slug", concat(adm_esc(app.slug), "
name", concat(adm_esc(app.display_name), "
owner", concat(adm_esc(app.owner), "
visibility", concat(db.visibility_str(app.visibility), "
ios install", concat(adm_ios_chip(app.ios_mode), "
testflight", concat(adm_esc(app.testflight_url), "
bundle id", concat(db.platform_str(bid.platform), concat(": ", concat(adm_esc(bid.value), "
created", concat(adm_time(app.created_at), "
updated", concat(adm_time(app.updated_at), "
"); + + page = concat(page, "

channels

"); + i := 0; + while i < repo.channels.len { + ch := repo.channels.items[i]; + i += 1; + if ch.app_id != app.id { continue; } + row := concat(""))))); + page = concat(page, row); + } + page = concat(page, "
channelcurrent releasepolicyrolloutretentioninstall page
", concat(adm_esc(ch.name), "")); + if ch.current_release_id.len > 0 { + row = concat(row, concat(adm_release_link(ch.current_release_id, ch.current_release_id), "")); + } else { + row = concat(row, "—"); + } + row = concat(row, concat(db.policy_str(ch.policy), "")); + row = concat(row, concat(int_to_string(ch.rollout_percent), "%")); + if ch.retention_keep > 0 { + row = concat(row, concat("keep ", concat(int_to_string(ch.retention_keep), ""))); + } else { + row = concat(row, "keep all"); + } + row = concat(row, concat("install
"); + + page = concat(page, "

releases (newest first)

"); + r := repo.releases.len; + while r > 0 { + r -= 1; + rel := repo.releases.items[r]; + if rel.app_id != app.id { continue; } + narts := 0; + k := 0; + while k < repo.artifacts.len { + if repo.artifacts.items[k].release_id == rel.id { narts += 1; } + k += 1; + } + row := concat("")); + page = concat(page, row); + } + page = concat(page, "
versionbuildchannelartifactscreatedpublished
", concat(adm_release_link(rel.id, rel.version), concat(" ", concat(adm_esc(rel.id), "")))); + row = concat(row, concat(int_to_string(rel.build), "")); + row = concat(row, concat(adm_esc(rel.channel), "")); + row = concat(row, concat(int_to_string(narts), "")); + row = concat(row, concat(adm_time(rel.created_at), "")); + row = concat(row, concat(adm_time(rel.published_at), "
"); + return concat(page, ADM_FOOT); +} + +// ── /admin/releases/ — release detail ───────────────────────────── + +render_admin_release :: (repo: *Repo, rel: Release) -> string { + page := adm_head(concat("dist admin — ", rel.id), "apps"); + + app_slug := rel.app_id; + aq := repo.get_app(rel.app_id); + if aq != null { app_slug = aq!.slug; } + + page = concat(page, "

release

"); + page = concat(page, concat(""))); + page = concat(page, concat(""))); + page = concat(page, concat(""))); + page = concat(page, concat(""))); + if rel.notes.len > 0 { + page = concat(page, concat(""))); + } + page = concat(page, concat(""))); + page = concat(page, concat(""))); + if rel.published_at > 0 { + page = concat(page, concat(""))); + } else { + page = concat(page, concat(""))); + } + + // Channels currently serving this release. + pointed := ""; + i := 0; + while i < repo.channels.len { + ch := repo.channels.items[i]; + if ch.app_id == rel.app_id and ch.current_release_id == rel.id { + if pointed.len > 0 { pointed = concat(pointed, " "); } + pointed = concat(pointed, adm_esc(ch.name)); + } + i += 1; + } + if pointed.len > 0 { + page = concat(page, concat(""))); + } + page = concat(page, "
app", concat(adm_app_link(app_slug), "
version", concat(adm_esc(rel.version), "
build", concat(int_to_string(rel.build), "
channel", concat(adm_esc(rel.channel), "
notes", concat(adm_esc(rel.notes), "
created by", concat(adm_esc(rel.created_by), "
created", concat(adm_time(rel.created_at), "
published", concat(adm_time(rel.published_at), "
published", concat(adm_chip("draft", " c-warn"), "
serving", concat(pointed, "
"); + + page = concat(page, "

artifacts

"); + i = 0; + while i < repo.artifacts.len { + art := repo.artifacts.items[i]; + i += 1; + if art.release_id != rel.id { continue; } + row := concat("")); + page = concat(page, row); + } + page = concat(page, "
platformfiletypesizesha256validation
", concat(db.platform_str(art.platform), "")); + row = concat(row, concat("", concat(adm_esc(art.filename), ""))))); + row = concat(row, concat(adm_esc(art.content_type), "")); + row = concat(row, concat(adm_size(art.size_bytes), "")); + row = concat(row, concat(adm_sha12(art.sha256), "…")); + row = concat(row, concat(adm_validation_chip(art.validation_status), "
"); + + // Timeline: audit events that touch this release — by target, by + // metadata (promote/rollback carry the release id there), or an + // artifact event whose target id is namespaced under the release id. + page = concat(page, "

timeline

"); + artifact_prefix := concat(rel.id, "-"); + i = 0; + while i < repo.audit_events.len { + e := repo.audit_events.items[i]; + i += 1; + hit := e.target_id == rel.id or e.metadata == rel.id; + if !hit and e.target_type == "artifact" and adm_starts_with(e.target_id, artifact_prefix) { hit = true; } + if !hit { continue; } + row := concat("")); + page = concat(page, row); + } + page = concat(page, "
timeactoractiontargetdetail
", concat(adm_time(e.created_at), "")); + row = concat(row, concat(adm_esc(e.actor), "")); + row = concat(row, concat(adm_esc(e.action), "")); + row = concat(row, concat(adm_esc(e.target_type), concat(": ", concat(adm_esc(e.target_id), "")))); + row = concat(row, concat(adm_esc(e.metadata), "
"); + return concat(page, ADM_FOOT); +} + +// ── /admin/tokens ───────────────────────────────────────────────────── + +render_admin_tokens :: (repo: *Repo) -> string { + page := adm_head("dist admin — tokens", "tokens"); + if repo.tokens.len == 0 { + page = concat(page, "

no tokens (mint one: dist token create)

"); + return concat(page, ADM_FOOT); + } + now := pl.now_secs(); + page = concat(page, "
"); + i := 0; + while i < repo.tokens.len { + t := repo.tokens.items[i]; + i += 1; + row := concat("")); + page = concat(page, row); + } + page = concat(page, "
nameidstatusscopesappchannelcreatedexpireslast used
", concat(adm_esc(t.name), "")); + row = concat(row, concat(adm_esc(t.id), "")); + row = concat(row, concat(adm_token_chip(tops.token_status(t, now)), "")); + row = concat(row, concat(adm_esc(t.scopes), "")); + row = concat(row, concat(if t.app_slug.len > 0 then adm_esc(t.app_slug) else "any", "")); + row = concat(row, concat(if t.channel.len > 0 then adm_esc(t.channel) else "any", "")); + row = concat(row, concat(adm_time(t.created_at), "")); + row = concat(row, concat(if t.expires_at > 0 then adm_time(t.expires_at) else "never", "")); + row = concat(row, concat(adm_time(t.last_used_at), "
"); + return concat(page, ADM_FOOT); +} + +// ── /admin/audit ────────────────────────────────────────────────────── + +render_admin_audit :: (repo: *Repo) -> string { + page := adm_head("dist admin — audit", "audit"); + if repo.audit_events.len == 0 { + page = concat(page, "

no audit events yet

"); + return concat(page, ADM_FOOT); + } + page = concat(page, "
"); + i := repo.audit_events.len; + while i > 0 { + i -= 1; + e := repo.audit_events.items[i]; + row := concat("")); + page = concat(page, row); + } + page = concat(page, "
timeactoractiontargetdetail
", concat(adm_time(e.created_at), "")); + row = concat(row, concat(adm_esc(e.actor), "")); + row = concat(row, concat(adm_esc(e.action), "")); + row = concat(row, concat(adm_esc(e.target_type), concat(": ", concat(adm_esc(e.target_id), "")))); + row = concat(row, concat(adm_esc(e.metadata), "
"); + return concat(page, ADM_FOOT); +} + +// ── routing (distd delegates every /admin path here) ───────────────── + +handle_admin :: (client: i32, store_dir: string, path: string) { + rq := adm_load(client, store_dir); + if rq == null { return; } + repo := rq!; + + if path == "/admin" or path == "/admin/" { + adm_html(client, render_admin_apps(@repo)); + return; + } + if path == "/admin/tokens" { + adm_html(client, render_admin_tokens(@repo)); + return; + } + if path == "/admin/audit" { + adm_html(client, render_admin_audit(@repo)); + return; + } + if adm_starts_with(path, "/admin/apps/") { + slug := adm_tail(path, "/admin/apps/"); + aq := repo.find_app_by_slug(slug); + if aq == null { + adm_error(client, 404, "admin.unknown_app", + concat("no app with that slug in the store: ", slug)); + return; + } + adm_html(client, render_admin_app(@repo, aq!)); + return; + } + if adm_starts_with(path, "/admin/releases/") { + id := adm_tail(path, "/admin/releases/"); + relq := repo.get_release(id); + if relq == null { + adm_error(client, 404, "admin.unknown_release", + concat("no release with that id in the store: ", id)); + return; + } + adm_html(client, render_admin_release(@repo, relq!)); + return; + } + adm_error(client, 404, "http.not_found", concat("no admin route for ", path)); +} diff --git a/src/server/distd.sx b/src/server/distd.sx index 809ac6c..50842d0 100644 --- a/src/server/distd.sx +++ b/src/server/distd.sx @@ -70,6 +70,7 @@ jout :: #import "../json_out.sx"; // call sites that mirror dist.sx's. pl :: #import "../publish/publish.sx"; ops :: #import "../release/ops.sx"; +adm :: #import "admin.sx"; // Aliased so the json reader is called as `jsrv.parse` — a bare `parse` // would bind to `std.cli`'s once both modules share the `dist` program. jsrv :: #import "modules/std/json.sx"; @@ -245,7 +246,7 @@ short_sha :: (sha: string) -> string { INDEX_HEAD :: "dist

dist — release console

"; -INDEX_FOOT :: "

API: /api/apps · /healthz

"; +INDEX_FOOT :: "

admin console · API: /api/apps · /healthz

"; // Server-rendered index: every app with its channels and its releases' // artifacts as direct /download links. Dense and read-only, per the @@ -978,6 +979,10 @@ route :: (client: i32, store_dir: string, req: *http.Request) -> i64 { handle_install_route(client, store_dir, req, tail_after(path, "/install/")); return 200; } + if path == "/admin" or starts_with(path, "/admin/") { + adm.handle_admin(client, store_dir, path); + return 200; + } respond_error(client, 404, "http.not_found", concat("no route for ", path)); return 404; diff --git a/tests/server_admin.sx b/tests/server_admin.sx new file mode 100644 index 0000000..241e249 --- /dev/null +++ b/tests/server_admin.sx @@ -0,0 +1,217 @@ +// Pinned acceptance for P6.1 (subplan 06) — the read-only admin console +// served by distd at /admin. +// +// Publishes two releases into a fresh store, mints a token, sets a +// retention policy, cross-promotes onto a second channel, then starts +// the BUILT `build/dist server run` and asserts every admin screen over +// curl: +// +// * /admin apps overview: app link, iOS mode chip, +// platform coverage, latest release +// * /admin/apps/ channels with policy + retention +// ("keep 2" / "keep all"), install links, +// releases table with both releases +// * /admin/releases/ artifact row (file, size, sha prefix, +// validation chip), serving channels, +// audit timeline (artifact.upload + +// release.publish + the cli promote) +// * /admin/tokens token name + lifecycle status; the +// page NEVER contains the secret or any +// hash material +// * /admin/audit newest-first log naming every actor +// and action recorded so far +// * unknown slug / release / route → 404 JSON errors with stable codes +// * / (public index) links the admin console +#import "modules/std.sx"; +#import "modules/std/json.sx"; +process :: #import "modules/std/process.sx"; + +STORE :: ".sx-tmp/server_admin"; +MDIR :: ".sx-tmp/server_admin_m"; +PORT :: "18799"; +BASE :: "http://127.0.0.1:18799"; + +MANIFEST_A :: "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; +MANIFEST_B :: "{\"app\":\"acme-app\",\"version\":\"1.2.4\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"android_apk\",\"path\":\"../../examples/fixtures/acme-1.2.3-android.apk\"}]}"; + +REL_A :: "rel-acme-app-1.2.3"; +REL_B :: "rel-acme-app-1.2.4"; + +get :: (o: Object, key: string) -> Value { + i := 0; + while i < o.len { + if o.items[i].key == key { return o.items[i].val; } + i += 1; + } + process.assert(false, concat("missing json key: ", key)); + dummy : Value = .null_; + return dummy; +} +get_str :: (o: Object, key: string) -> string { return get(o, key).str; } +get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; } + +write_file :: (path: string, body: string) { + cmd := concat(concat(concat("printf '%s' '", body), "' > "), path); + process.run(cmd); +} + +// True iff `needle` occurs in `hay` (plain scan; bodies are small). +contains :: (hay: string, needle: string) -> bool { + if needle.len == 0 { return true; } + if needle.len > hay.len { return false; } + i := 0; + while i + needle.len <= hay.len { + j := 0; + ok := true; + while j < needle.len { + if hay[i + j] != needle[j] { ok = false; break; } + j += 1; + } + if ok { return true; } + i += 1; + } + return false; +} + +fetch :: (path: string) -> string { + r := process.run(concat(concat("curl -s -m 2 ", BASE), path)); + process.assert(r != null, concat("curl spawn failed: ", path)); + return r!.stdout; +} + +fetch_code :: (path: string) -> string { + r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), path)); + process.assert(r != null, concat("curl spawn failed: ", path)); + return r!.stdout; +} + +parse_body :: (body: string, what: string, scratch: Allocator) -> Object { + v, e := parse(body, scratch); + if e { + process.assert(false, concat("response must be valid JSON: ", what)); + dummy : Object = .{}; + return dummy; + } + return v.object; +} + +publish_cmd :: (mpath: string) -> string { + c := concat("build/dist ci publish --manifest ", mpath); + c = concat(c, concat(" --local-store ", STORE)); + return concat(c, " --json 2>/dev/null >/dev/null"); +} + +main :: () -> i32 { + gpa := GPA.init(); + arena := Arena.init(xx gpa, 1 << 20); + defer arena.deinit(); + + process.run("pkill -f 'dist server run --local-store .sx-tmp/server_admin' 2>/dev/null"); + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + process.run(concat("mkdir -p ", MDIR)); + write_file(path_join(MDIR, "a.json"), MANIFEST_A); + write_file(path_join(MDIR, "b.json"), MANIFEST_B); + + // ── seed: two releases, a token, a retention policy, a cross-promote ─ + ra := process.run(publish_cmd(path_join(MDIR, "a.json"))); + process.assert(ra != null and ra!.exit_code == 0, "publish A must exit 0"); + rb := process.run(publish_cmd(path_join(MDIR, "b.json"))); + process.assert(rb != null and rb!.exit_code == 0, "publish B must exit 0"); + + tc := process.run(concat(concat("build/dist token create --name ci-main --local-store ", STORE), " --json 2>/dev/null")); + process.assert(tc != null and tc!.exit_code == 0, "token create must exit 0"); + tco := parse_body(tc!.stdout, "token create", xx arena); + secret := get_str(get_obj(tco, "token"), "secret"); + process.assert(secret.len > 0, "token create returned the secret"); + + cs := process.run(concat(concat("build/dist channel set --app acme-app --channel stable --retention-keep 2 --local-store ", STORE), " --json 2>/dev/null")); + process.assert(cs != null and cs!.exit_code == 0, "channel set must exit 0"); + + pr := process.run(concat(concat(concat("build/dist release promote --app acme-app --channel beta --release ", REL_A), concat(" --local-store ", STORE)), " --json 2>/dev/null")); + process.assert(pr != null and pr!.exit_code == 0, "promote A onto beta must exit 0"); + + // ── start the server, poll /healthz ────────────────────────────── + sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), "")); + process.assert(sp != null, "server spawn failed"); + pid := sp!.stdout; + + ready := false; + tries := 0; + while tries < 50 { + c := fetch_code("/healthz"); + if c == "200" { ready = true; break; } + process.run("sleep 0.2"); + tries += 1; + } + process.assert(ready, "server must answer /healthz within 10s"); + print(" server up\n"); + + // ── /admin — apps overview ──────────────────────────────────────── + r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code} %{content_type}' ", BASE), "/admin")); + process.assert(r != null and contains(r!.stdout, "200 text/html"), "/admin is a 200 HTML page"); + ov := fetch("/admin"); + process.assert(contains(ov, "href=\"/admin/apps/acme-app\""), "overview links the app detail"); + process.assert(contains(ov, "ios: artifact-only"), "overview shows the iOS mode chip"); + process.assert(contains(ov, "android_apk"), "overview shows platform coverage"); + process.assert(contains(ov, "1.2.4"), "overview shows the latest release version"); + print(" /admin overview ok\n"); + + // ── /admin/apps/ — channels + retention + releases ───────── + ad := fetch("/admin/apps/acme-app"); + process.assert(contains(ad, "keep 2"), "stable shows its retention policy"); + process.assert(contains(ad, "keep all"), "beta shows the keep-everything default"); + process.assert(contains(ad, "href=\"/install/acme-app/stable\""), "channels link their install pages"); + process.assert(contains(ad, REL_A) and contains(ad, REL_B), "releases table lists both releases"); + process.assert(contains(ad, "manual"), "channels show their rollout policy"); + print(" /admin/apps/acme-app ok\n"); + + // ── /admin/releases/ — artifacts, serving, timeline ────────── + rd := fetch(concat("/admin/releases/", REL_A)); + process.assert(contains(rd, "acme-1.2.3-android.apk"), "release detail names the artifact file"); + process.assert(contains(rd, ">valid") or contains(rd, ">pending"), "artifact carries a validation chip"); + process.assert(contains(rd, "href=\"/download/"), "artifact links its download"); + process.assert(contains(rd, "servingbeta"), "release detail names the channel serving it"); + process.assert(contains(rd, "artifact.upload"), "timeline shows the upload event"); + process.assert(contains(rd, "release.publish"), "timeline shows the publish event"); + process.assert(contains(rd, "channel.promote"), "timeline shows the cli promote (metadata names this release)"); + print(" /admin/releases/ ok\n"); + + // ── /admin/tokens — status without secret material ──────────────── + tk := fetch("/admin/tokens"); + process.assert(contains(tk, "ci-main"), "tokens screen names the token"); + process.assert(contains(tk, ">active"), "tokens screen shows lifecycle status"); + process.assert(!contains(tk, secret), "tokens screen NEVER contains the secret"); + process.assert(!contains(tk, "token_hash"), "tokens screen never mentions hash material"); + print(" /admin/tokens ok\n"); + + // ── /admin/audit — every recorded actor/action ──────────────────── + au := fetch("/admin/audit"); + process.assert(contains(au, "token.create"), "audit shows token.create"); + process.assert(contains(au, "channel.update"), "audit shows the retention change"); + process.assert(contains(au, "channel.promote"), "audit shows the promote"); + process.assert(contains(au, "release.publish"), "audit shows the publishes"); + process.assert(contains(au, ">cli") and contains(au, ">ci"), "audit names both actors"); + print(" /admin/audit ok\n"); + + // ── 404s with stable codes ──────────────────────────────────────── + process.assert(fetch_code("/admin/apps/nope") == "404", "unknown slug is 404"); + na := parse_body(fetch("/admin/apps/nope"), "unknown slug body", xx arena); + process.assert(get_str(get_obj(na, "error"), "code") == "admin.unknown_app", "unknown slug names admin.unknown_app"); + process.assert(fetch_code("/admin/releases/nope") == "404", "unknown release is 404"); + nr := parse_body(fetch("/admin/releases/nope"), "unknown release body", xx arena); + process.assert(get_str(get_obj(nr, "error"), "code") == "admin.unknown_release", "unknown release names admin.unknown_release"); + process.assert(fetch_code("/admin/bogus") == "404", "unknown admin route is 404"); + print(" admin 404s ok\n"); + + // ── the public index links the console ──────────────────────────── + idx := fetch("/"); + process.assert(contains(idx, "href=\"/admin\""), "public index links /admin"); + + // ── teardown ────────────────────────────────────────────────────── + process.run(concat("kill ", pid)); + process.run(concat("rm -rf ", STORE)); + process.run(concat("rm -rf ", MDIR)); + print("server_admin: ALL CASES PASS\n"); + return 0; +}