admin console: read-only /admin screens served by distd (P6.1)
Subplan 06, first slice. src/server/admin.sx server-renders the
operator console the same way the index and install pages are built —
sx string rendering, no client framework, no build step:
/admin apps overview (platform coverage, channel and
release counts, latest release)
/admin/apps/<slug> metadata + iOS install-mode chip, channels with
policy/rollout/retention, releases newest-first
/admin/releases/<id> artifacts with validation chips and download
links, channels serving the release, audit
timeline (by target, by metadata, by artifact
id prefix)
/admin/tokens lifecycle status via token_status; never a
secret or hash
/admin/audit the full log, newest first
Reads are public like every distd GET in v0; mutations stay on the
token-gated POST endpoints (UI actions are a later slice). Unknown
slug/release/route answer 404 JSON errors with stable codes
(admin.unknown_app / admin.unknown_release / http.not_found). The
module is self-contained (adm_-prefixed helpers) so distd can import it
without a cycle; timestamps render through a local civil-from-days
formatter. The public index footer links the console.
tests/server_admin.sx drives the built binary over curl and pins every
screen, the no-secret-material guarantee, and the 404 codes.
make test 24/24 green.
This commit is contained in:
555
src/server/admin.sx
Normal file
555
src/server/admin.sx
Normal file
@@ -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/<slug> app detail: metadata, iOS install mode,
|
||||
// channels (policy/rollout/retention),
|
||||
// releases newest-first
|
||||
// /admin/releases/<id> 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 :: "<style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:1.5rem auto;max-width:76rem;padding:0 1rem;font-size:14px}h1{font-size:1rem;letter-spacing:.04em;margin:0 0 .2rem}h2{font-size:.9rem;margin:1.6rem 0 .4rem;color:#aeb6c4}nav{margin:.2rem 0 1.2rem;color:#3a4150}nav a{margin-right:1rem}nav a.on{color:#d6dae1}table{border-collapse:collapse;width:100%;font-size:.85rem;white-space:nowrap}td,th{text-align:left;padding:.28rem .9rem .28rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}.twrap{overflow-x:auto}.chip{display:inline-block;padding:.02rem .45rem;border-radius:.6rem;font-size:.75rem;border:1px solid #2a2f3a;color:#8b93a3}.c-ok{color:#7dd17a;border-color:#2c4a2c}.c-warn{color:#e0c068;border-color:#4a432c}.c-bad{color:#e07a7a;border-color:#4a2c2c}td.wrap{white-space:normal}</style>";
|
||||
|
||||
// Page head + the quiet nav row; `active` marks the current section
|
||||
// ("apps" | "tokens" | "audit").
|
||||
adm_head :: (title: string, active: string) -> string {
|
||||
s := concat("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>", concat(adm_esc(title), "</title>"));
|
||||
s = concat(s, ADM_STYLE);
|
||||
s = concat(s, concat("</head><body><h1>", concat(adm_esc(title), "</h1><nav>")));
|
||||
apps_cls := ""; toks_cls := ""; audit_cls := "";
|
||||
if active == "apps" { apps_cls = " class=\"on\""; }
|
||||
if active == "tokens" { toks_cls = " class=\"on\""; }
|
||||
if active == "audit" { audit_cls = " class=\"on\""; }
|
||||
s = concat(s, concat(concat("<a href=\"/admin\"", apps_cls), ">apps</a>"));
|
||||
s = concat(s, concat(concat("<a href=\"/admin/tokens\"", toks_cls), ">tokens</a>"));
|
||||
s = concat(s, concat(concat("<a href=\"/admin/audit\"", audit_cls), ">audit</a>"));
|
||||
return concat(s, "<a href=\"/\">public index</a></nav>");
|
||||
}
|
||||
|
||||
ADM_FOOT :: "</body></html>";
|
||||
|
||||
adm_chip :: (label: string, cls: string) -> string {
|
||||
s := concat("<span class=\"chip", cls);
|
||||
return concat(s, concat("\">", concat(adm_esc(label), "</span>")));
|
||||
}
|
||||
|
||||
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("<a href=\"/admin/apps/", adm_esc(slug));
|
||||
return concat(s, concat("\">", concat(adm_esc(slug), "</a>")));
|
||||
}
|
||||
|
||||
adm_release_link :: (id: string, label: string) -> string {
|
||||
s := concat("<a href=\"/admin/releases/", adm_esc(id));
|
||||
return concat(s, concat("\">", concat(adm_esc(label), "</a>")));
|
||||
}
|
||||
|
||||
// ── /admin — apps overview ────────────────────────────────────────────
|
||||
|
||||
render_admin_apps :: (repo: *Repo) -> string {
|
||||
page := adm_head("dist admin — apps", "apps");
|
||||
if repo.apps.len == 0 {
|
||||
page = concat(page, "<p class=\"dim\">nothing published yet</p>");
|
||||
return concat(page, ADM_FOOT);
|
||||
}
|
||||
page = concat(page, "<div class=\"twrap\"><table><tr><th>app</th><th>name</th><th>visibility</th><th>ios mode</th><th>platforms</th><th>channels</th><th>releases</th><th>latest</th><th>published</th></tr>");
|
||||
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("<tr><td>", concat(adm_app_link(app.slug), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(app.display_name), "</td><td>"));
|
||||
row = concat(row, concat(db.visibility_str(app.visibility), "</td><td>"));
|
||||
row = concat(row, concat(adm_ios_chip(app.ios_mode), "</td><td>"));
|
||||
row = concat(row, concat(plist, "</td><td>"));
|
||||
row = concat(row, concat(int_to_string(nchan), "</td><td>"));
|
||||
row = concat(row, concat(int_to_string(nrel), "</td><td>"));
|
||||
if nrel > 0 {
|
||||
row = concat(row, concat(adm_release_link(latest.id, latest.version), "</td><td>"));
|
||||
row = concat(row, concat(adm_time(latest.published_at), "</td></tr>"));
|
||||
} else {
|
||||
row = concat(row, "—</td><td>—</td></tr>");
|
||||
}
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
return concat(page, ADM_FOOT);
|
||||
}
|
||||
|
||||
// ── /admin/apps/<slug> — app detail ───────────────────────────────────
|
||||
|
||||
render_admin_app :: (repo: *Repo, app: App) -> string {
|
||||
page := adm_head(concat("dist admin — ", app.slug), "apps");
|
||||
|
||||
page = concat(page, "<h2>app</h2><div class=\"twrap\"><table>");
|
||||
page = concat(page, concat("<tr><th>slug</th><td>", concat(adm_esc(app.slug), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>name</th><td>", concat(adm_esc(app.display_name), "</td></tr>")));
|
||||
if app.owner.len > 0 {
|
||||
page = concat(page, concat("<tr><th>owner</th><td>", concat(adm_esc(app.owner), "</td></tr>")));
|
||||
}
|
||||
page = concat(page, concat("<tr><th>visibility</th><td>", concat(db.visibility_str(app.visibility), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>ios install</th><td>", concat(adm_ios_chip(app.ios_mode), "</td></tr>")));
|
||||
if app.testflight_url.len > 0 {
|
||||
page = concat(page, concat("<tr><th>testflight</th><td>", concat(adm_esc(app.testflight_url), "</td></tr>")));
|
||||
}
|
||||
b := 0;
|
||||
while b < app.bundle_ids.len {
|
||||
bid := app.bundle_ids.items[b];
|
||||
page = concat(page, concat("<tr><th>bundle id</th><td>", concat(db.platform_str(bid.platform), concat(": ", concat(adm_esc(bid.value), "</td></tr>")))));
|
||||
b += 1;
|
||||
}
|
||||
page = concat(page, concat("<tr><th>created</th><td>", concat(adm_time(app.created_at), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>updated</th><td>", concat(adm_time(app.updated_at), "</td></tr>")));
|
||||
page = concat(page, "</table></div>");
|
||||
|
||||
page = concat(page, "<h2>channels</h2><div class=\"twrap\"><table><tr><th>channel</th><th>current release</th><th>policy</th><th>rollout</th><th>retention</th><th>install page</th></tr>");
|
||||
i := 0;
|
||||
while i < repo.channels.len {
|
||||
ch := repo.channels.items[i];
|
||||
i += 1;
|
||||
if ch.app_id != app.id { continue; }
|
||||
row := concat("<tr><td>", concat(adm_esc(ch.name), "</td><td>"));
|
||||
if ch.current_release_id.len > 0 {
|
||||
row = concat(row, concat(adm_release_link(ch.current_release_id, ch.current_release_id), "</td><td>"));
|
||||
} else {
|
||||
row = concat(row, "—</td><td>");
|
||||
}
|
||||
row = concat(row, concat(db.policy_str(ch.policy), "</td><td>"));
|
||||
row = concat(row, concat(int_to_string(ch.rollout_percent), "%</td><td>"));
|
||||
if ch.retention_keep > 0 {
|
||||
row = concat(row, concat("keep ", concat(int_to_string(ch.retention_keep), "</td><td>")));
|
||||
} else {
|
||||
row = concat(row, "keep all</td><td>");
|
||||
}
|
||||
row = concat(row, concat("<a href=\"/install/", concat(adm_esc(app.slug), concat("/", concat(adm_esc(ch.name), "\">install</a></td></tr>")))));
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
|
||||
page = concat(page, "<h2>releases <small>(newest first)</small></h2><div class=\"twrap\"><table><tr><th>version</th><th>build</th><th>channel</th><th>artifacts</th><th>created</th><th>published</th></tr>");
|
||||
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("<tr><td>", concat(adm_release_link(rel.id, rel.version), concat(" <small>", concat(adm_esc(rel.id), "</small></td><td>"))));
|
||||
row = concat(row, concat(int_to_string(rel.build), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(rel.channel), "</td><td>"));
|
||||
row = concat(row, concat(int_to_string(narts), "</td><td>"));
|
||||
row = concat(row, concat(adm_time(rel.created_at), "</td><td>"));
|
||||
row = concat(row, concat(adm_time(rel.published_at), "</td></tr>"));
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
return concat(page, ADM_FOOT);
|
||||
}
|
||||
|
||||
// ── /admin/releases/<id> — 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, "<h2>release</h2><div class=\"twrap\"><table>");
|
||||
page = concat(page, concat("<tr><th>app</th><td>", concat(adm_app_link(app_slug), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>version</th><td>", concat(adm_esc(rel.version), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>build</th><td>", concat(int_to_string(rel.build), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>channel</th><td>", concat(adm_esc(rel.channel), "</td></tr>")));
|
||||
if rel.notes.len > 0 {
|
||||
page = concat(page, concat("<tr><th>notes</th><td class=\"wrap\">", concat(adm_esc(rel.notes), "</td></tr>")));
|
||||
}
|
||||
page = concat(page, concat("<tr><th>created by</th><td>", concat(adm_esc(rel.created_by), "</td></tr>")));
|
||||
page = concat(page, concat("<tr><th>created</th><td>", concat(adm_time(rel.created_at), "</td></tr>")));
|
||||
if rel.published_at > 0 {
|
||||
page = concat(page, concat("<tr><th>published</th><td>", concat(adm_time(rel.published_at), "</td></tr>")));
|
||||
} else {
|
||||
page = concat(page, concat("<tr><th>published</th><td>", concat(adm_chip("draft", " c-warn"), "</td></tr>")));
|
||||
}
|
||||
|
||||
// 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("<tr><th>serving</th><td>", concat(pointed, "</td></tr>")));
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
|
||||
page = concat(page, "<h2>artifacts</h2><div class=\"twrap\"><table><tr><th>platform</th><th>file</th><th>type</th><th>size</th><th>sha256</th><th>validation</th></tr>");
|
||||
i = 0;
|
||||
while i < repo.artifacts.len {
|
||||
art := repo.artifacts.items[i];
|
||||
i += 1;
|
||||
if art.release_id != rel.id { continue; }
|
||||
row := concat("<tr><td>", concat(db.platform_str(art.platform), "</td><td>"));
|
||||
row = concat(row, concat("<a href=\"/download/", concat(art.sha256, concat("\">", concat(adm_esc(art.filename), "</a></td><td>")))));
|
||||
row = concat(row, concat(adm_esc(art.content_type), "</td><td>"));
|
||||
row = concat(row, concat(adm_size(art.size_bytes), "</td><td><small>"));
|
||||
row = concat(row, concat(adm_sha12(art.sha256), "…</small></td><td>"));
|
||||
row = concat(row, concat(adm_validation_chip(art.validation_status), "</td></tr>"));
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
|
||||
// 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, "<h2>timeline</h2><div class=\"twrap\"><table><tr><th>time</th><th>actor</th><th>action</th><th>target</th><th>detail</th></tr>");
|
||||
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("<tr><td>", concat(adm_time(e.created_at), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(e.actor), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(e.action), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(e.target_type), concat(": ", concat(adm_esc(e.target_id), "</td><td class=\"wrap\">"))));
|
||||
row = concat(row, concat(adm_esc(e.metadata), "</td></tr>"));
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
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, "<p class=\"dim\">no tokens (mint one: dist token create)</p>");
|
||||
return concat(page, ADM_FOOT);
|
||||
}
|
||||
now := pl.now_secs();
|
||||
page = concat(page, "<div class=\"twrap\"><table><tr><th>name</th><th>id</th><th>status</th><th>scopes</th><th>app</th><th>channel</th><th>created</th><th>expires</th><th>last used</th></tr>");
|
||||
i := 0;
|
||||
while i < repo.tokens.len {
|
||||
t := repo.tokens.items[i];
|
||||
i += 1;
|
||||
row := concat("<tr><td>", concat(adm_esc(t.name), "</td><td><small>"));
|
||||
row = concat(row, concat(adm_esc(t.id), "</small></td><td>"));
|
||||
row = concat(row, concat(adm_token_chip(tops.token_status(t, now)), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(t.scopes), "</td><td>"));
|
||||
row = concat(row, concat(if t.app_slug.len > 0 then adm_esc(t.app_slug) else "any", "</td><td>"));
|
||||
row = concat(row, concat(if t.channel.len > 0 then adm_esc(t.channel) else "any", "</td><td>"));
|
||||
row = concat(row, concat(adm_time(t.created_at), "</td><td>"));
|
||||
row = concat(row, concat(if t.expires_at > 0 then adm_time(t.expires_at) else "never", "</td><td>"));
|
||||
row = concat(row, concat(adm_time(t.last_used_at), "</td></tr>"));
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
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, "<p class=\"dim\">no audit events yet</p>");
|
||||
return concat(page, ADM_FOOT);
|
||||
}
|
||||
page = concat(page, "<div class=\"twrap\"><table><tr><th>time</th><th>actor</th><th>action</th><th>target</th><th>detail</th></tr>");
|
||||
i := repo.audit_events.len;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
e := repo.audit_events.items[i];
|
||||
row := concat("<tr><td>", concat(adm_time(e.created_at), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(e.actor), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(e.action), "</td><td>"));
|
||||
row = concat(row, concat(adm_esc(e.target_type), concat(": ", concat(adm_esc(e.target_id), "</td><td class=\"wrap\">"))));
|
||||
row = concat(row, concat(adm_esc(e.metadata), "</td></tr>"));
|
||||
page = concat(page, row);
|
||||
}
|
||||
page = concat(page, "</table></div>");
|
||||
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));
|
||||
}
|
||||
@@ -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 :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>dist</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:60rem;padding:0 1rem}h1{font-size:1.1rem;letter-spacing:.04em}h2{font-size:1rem;margin-top:2rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}table{border-collapse:collapse;width:100%;font-size:.85rem}td,th{text-align:left;padding:.3rem .8rem .3rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}</style></head><body><h1>dist — release console</h1>";
|
||||
|
||||
INDEX_FOOT :: "<p class=\"dim\"><small>API: <a href=\"/api/apps\">/api/apps</a> · <a href=\"/healthz\">/healthz</a></small></p></body></html>";
|
||||
INDEX_FOOT :: "<p class=\"dim\"><small><a href=\"/admin\">admin console</a> · API: <a href=\"/api/apps\">/api/apps</a> · <a href=\"/healthz\">/healthz</a></small></p></body></html>";
|
||||
|
||||
// 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;
|
||||
|
||||
217
tests/server_admin.sx
Normal file
217
tests/server_admin.sx
Normal file
@@ -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/<slug> channels with policy + retention
|
||||
// ("keep 2" / "keep all"), install links,
|
||||
// releases table with both releases
|
||||
// * /admin/releases/<id> 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/<slug> — 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/<id> — 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</span>") or contains(rd, ">pending</span>"), "artifact carries a validation chip");
|
||||
process.assert(contains(rd, "href=\"/download/"), "artifact links its download");
|
||||
process.assert(contains(rd, "<tr><th>serving</th><td>beta</td></tr>"), "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/<id> 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</span>"), "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</td>") and contains(au, ">ci</td>"), "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;
|
||||
}
|
||||
Reference in New Issue
Block a user