P4.4: bearer auth + write endpoints on distd
distd stops being read-only. http.sx learns the write-side request surface: header capture with case-insensitive lookup, a Content-Length- bounded body read loop (8K header cap, 512 MiB body cap -> 413, 411 for length-less POST/PUT), and the matching status texts. Auth (server/auth.sx): Authorization: Bearer is re-hashed and resolved via find_token_by_hash, then gated through check_token — 401 for missing/malformed/unknown credentials, 403 with a refusal-specific code (auth.revoked/expired/missing_scope/app_forbidden/channel_forbidden); successful auth stamps last_used_at. check_token's app gate now treats an empty REQUEST app like an empty request channel (uploads are app-agnostic until a release references them). Write routes (POST, publish scope): /api/upload content-addresses the body; /api/apps/<slug>/releases publishes over already-uploaded objects through commit_publish — the back half extracted from run_publish so CLI and HTTP publishes share one find/create-app -> transaction -> audit -> persist pipeline; channels/<name>/promote|rollback delegate to the P3.5 CLI pipelines. Reads stay public. make test 17/17 (new: server_write.sx pinned acceptance over curl).
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
// dist ci publish local publish pipeline (P3.4a/b) — see publish.sx
|
||||
// dist release promote point a channel at a release (P3.5) — see release/ops.sx
|
||||
// dist release rollback channel pointer to the previous release (P3.5)
|
||||
// dist server run read-only HTTP API over the store (P4.1) — see server/distd.sx
|
||||
// dist server run HTTP API over the store: public reads (P4.1),
|
||||
// token-gated writes (P4.4) — see server/distd.sx
|
||||
// dist token create mint a scoped CI token, secret shown once (P4.3) — see token/ops.sx
|
||||
// dist token list tokens with lifecycle status, never the secret (P4.3)
|
||||
// dist token revoke revoke a token by id (P4.3)
|
||||
@@ -49,7 +50,7 @@ emit_human :: (s: string, json_mode: bool) {
|
||||
if json_mode { eputs(s); } else { out(s); }
|
||||
}
|
||||
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||
|
||||
// True if `name` appears as a token in `args`.
|
||||
has_flag :: (args: []string, name: string) -> bool {
|
||||
|
||||
@@ -51,13 +51,14 @@ has_scope :: (scopes: string, word: string) -> bool {
|
||||
// Decide whether `t` authorizes `scope` against (`app_slug`, `channel`) at
|
||||
// time `now`. Checks run in refusal-severity order: a revoked token reports
|
||||
// Revoked even if it is also expired. An empty `t.app_slug` / `t.channel`
|
||||
// matches anything; an empty REQUEST channel also passes the channel gate
|
||||
// (the operation has no channel to constrain, e.g. a read).
|
||||
// matches anything; an empty REQUEST app or channel also passes its gate —
|
||||
// the operation has nothing to constrain there (an upload is app-agnostic
|
||||
// until a release references it; a read has no channel).
|
||||
check_token :: (t: Token, scope: string, app_slug: string, channel: string, now: i64) -> !TokenCheckErr {
|
||||
if t.revoked_at > 0 { raise error.Revoked; }
|
||||
if t.expires_at > 0 and now >= t.expires_at { raise error.Expired; }
|
||||
if !has_scope(t.scopes, scope) { raise error.ScopeMissing; }
|
||||
if t.app_slug.len > 0 and t.app_slug != app_slug { raise error.AppMismatch; }
|
||||
if t.app_slug.len > 0 and app_slug.len > 0 and t.app_slug != app_slug { raise error.AppMismatch; }
|
||||
if t.channel.len > 0 and channel.len > 0 and t.channel != channel { raise error.ChannelMismatch; }
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,19 @@ PublishedArtifact :: struct {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// One artifact whose bytes are ALREADY content-addressed in the store,
|
||||
// carrying everything `commit_publish` needs to build the Artifact entity.
|
||||
// Both publish fronts produce these: the CLI from manifest paths it stored
|
||||
// itself, the HTTP API from digests of previously-uploaded objects.
|
||||
ResolvedArtifact :: struct {
|
||||
platform: Platform;
|
||||
key: string; // sha256 == storage key
|
||||
size_bytes: i64;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
metadata: string;
|
||||
}
|
||||
|
||||
// The machine-readable result of a successful publish: the release identity
|
||||
// and the artifacts that were stored under it.
|
||||
PublishOutcome :: struct {
|
||||
@@ -184,55 +197,10 @@ run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFai
|
||||
|
||||
base_dir := dirname(manifest_path);
|
||||
abs := abs_store(store_dir);
|
||||
now := now_secs();
|
||||
|
||||
// Seed the Repo from any prior state so separate CLI invocations SHARE
|
||||
// state through the store: a pre-existing `<store>/db.json` is loaded so
|
||||
// find-or-create sees earlier apps and the integrity transaction sees
|
||||
// earlier releases. A new version then ACCUMULATES (the app is found, not
|
||||
// duplicated); re-publishing the SAME release id is rejected as a
|
||||
// duplicate by the transaction. An absent db.json starts empty. The loaded
|
||||
// model grows through its own owning allocator (`context.allocator`, the
|
||||
// process-lifetime default), per the long-lived-container rule.
|
||||
repo := Repo.init();
|
||||
if exists(path_join(store_dir, "db.json")) {
|
||||
loaded, le := db.load(store_dir);
|
||||
if le {
|
||||
fail_out.code = "persist.load";
|
||||
fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir);
|
||||
raise error.Persist;
|
||||
}
|
||||
repo = loaded;
|
||||
}
|
||||
st := Store.init(store_dir);
|
||||
|
||||
// 2. Find or create the app (keyed by slug).
|
||||
slug := m.app;
|
||||
app_id := "";
|
||||
existing := repo.find_app_by_slug(slug);
|
||||
if existing == null {
|
||||
app_id = concat("app-", slug);
|
||||
repo.create_app(App.{
|
||||
id = app_id, slug = slug, display_name = slug,
|
||||
owner = "ci", visibility = .private,
|
||||
created_at = now, updated_at = now,
|
||||
});
|
||||
} else {
|
||||
found := existing!;
|
||||
app_id = found.id;
|
||||
}
|
||||
|
||||
// 3. Draft the release for this version/channel.
|
||||
release_id := concat(concat(concat("rel-", slug), "-"), m.version);
|
||||
rel := Release.{
|
||||
id = release_id, app_id = app_id, version = m.version, build = 1,
|
||||
channel = m.channel, notes = "", created_by = "ci",
|
||||
created_at = now, published_at = now,
|
||||
};
|
||||
|
||||
// 4. Per artifact: store by digest -> validate -> build the entity.
|
||||
arts : List(Artifact) = .{};
|
||||
out_arts : List(PublishedArtifact) = .{};
|
||||
// 2. Per artifact: store by digest -> validate -> resolve.
|
||||
resolved : List(ResolvedArtifact) = .{};
|
||||
|
||||
i := 0;
|
||||
while i < m.artifacts.len {
|
||||
@@ -273,31 +241,102 @@ run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFai
|
||||
raise error.Validation;
|
||||
}
|
||||
|
||||
fname := if ma.filename.len > 0 then ma.filename else basename(src);
|
||||
pname := db.platform_str(ma.platform);
|
||||
|
||||
art := Artifact.{
|
||||
id = concat(concat(release_id, "-"), pname),
|
||||
app_id = app_id, release_id = release_id,
|
||||
platform = ma.platform, filename = fname, content_type = ct,
|
||||
size_bytes = actual_size, sha256 = key, storage_key = key,
|
||||
metadata = ma.metadata, validation_status = .valid,
|
||||
};
|
||||
arts.append(art, alloc);
|
||||
|
||||
url := concat(concat(concat("file://", abs), "/objects/"), key);
|
||||
out_arts.append(PublishedArtifact.{
|
||||
id = art.id, platform_name = pname, size_bytes = actual_size,
|
||||
sha256 = key, url = url,
|
||||
resolved.append(ResolvedArtifact.{
|
||||
platform = ma.platform, key = key, size_bytes = actual_size,
|
||||
filename = if ma.filename.len > 0 then ma.filename else basename(src),
|
||||
content_type = ct, metadata = ma.metadata,
|
||||
}, alloc);
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// 5. Publish via the integrity-checked transaction (channel promotion
|
||||
// included). Rollback on failure is left intact for P3.4b.
|
||||
// 3. Commit through the shared pipeline; the CLI's download URLs are
|
||||
// file:// paths into the absolutized local store.
|
||||
url_prefix := concat(concat("file://", abs), "/objects/");
|
||||
arts_view : []ResolvedArtifact = .{ ptr = resolved.items, len = resolved.len };
|
||||
o := try commit_publish(store_dir, m.app, m.version, m.channel, "ci", url_prefix, arts_view, fail_out);
|
||||
return o;
|
||||
}
|
||||
|
||||
// The shared back half of every publish: load prior state, find/create the
|
||||
// app, draft the release, build the Artifact entities from ALREADY-STORED
|
||||
// objects, run the integrity-checked transaction (channel promotion
|
||||
// included), write the audit trail, persist, and shape the outcome. Both
|
||||
// the CLI (`run_publish`) and the HTTP write API commit through here, so
|
||||
// publish semantics cannot drift between the two fronts.
|
||||
commit_publish :: (store_dir: string, slug: string, version: string, channel_name: string, actor: string, url_prefix: string, arts_in: []ResolvedArtifact, fail_out: *jout.CliFailure) -> (PublishOutcome, !PublishError) {
|
||||
alloc := context.allocator;
|
||||
now := now_secs();
|
||||
|
||||
// Seed the Repo from any prior state so separate invocations SHARE
|
||||
// state through the store: a pre-existing `<store>/db.json` is loaded so
|
||||
// find-or-create sees earlier apps and the integrity transaction sees
|
||||
// earlier releases. A new version then ACCUMULATES (the app is found, not
|
||||
// duplicated); re-publishing the SAME release id is rejected as a
|
||||
// duplicate by the transaction. An absent db.json starts empty. The loaded
|
||||
// model grows through its own owning allocator (`context.allocator`, the
|
||||
// process-lifetime default), per the long-lived-container rule.
|
||||
repo := Repo.init();
|
||||
if exists(path_join(store_dir, "db.json")) {
|
||||
loaded, le := db.load(store_dir);
|
||||
if le {
|
||||
fail_out.code = "persist.load";
|
||||
fail_out.message = concat("existing db.json under the store could not be loaded: ", store_dir);
|
||||
raise error.Persist;
|
||||
}
|
||||
repo = loaded;
|
||||
}
|
||||
|
||||
// Find or create the app (keyed by slug).
|
||||
app_id := "";
|
||||
existing := repo.find_app_by_slug(slug);
|
||||
if existing == null {
|
||||
app_id = concat("app-", slug);
|
||||
repo.create_app(App.{
|
||||
id = app_id, slug = slug, display_name = slug,
|
||||
owner = actor, visibility = .private,
|
||||
created_at = now, updated_at = now,
|
||||
});
|
||||
} else {
|
||||
found := existing!;
|
||||
app_id = found.id;
|
||||
}
|
||||
|
||||
// Draft the release for this version/channel.
|
||||
release_id := concat(concat(concat("rel-", slug), "-"), version);
|
||||
rel := Release.{
|
||||
id = release_id, app_id = app_id, version = version, build = 1,
|
||||
channel = channel_name, notes = "", created_by = actor,
|
||||
created_at = now, published_at = now,
|
||||
};
|
||||
|
||||
// Build the entities off the resolved inputs.
|
||||
arts : List(Artifact) = .{};
|
||||
out_arts : List(PublishedArtifact) = .{};
|
||||
i := 0;
|
||||
while i < arts_in.len {
|
||||
ra := arts_in[i];
|
||||
pname := db.platform_str(ra.platform);
|
||||
art := Artifact.{
|
||||
id = concat(concat(release_id, "-"), pname),
|
||||
app_id = app_id, release_id = release_id,
|
||||
platform = ra.platform, filename = ra.filename,
|
||||
content_type = ra.content_type,
|
||||
size_bytes = ra.size_bytes, sha256 = ra.key, storage_key = ra.key,
|
||||
metadata = ra.metadata, validation_status = .valid,
|
||||
};
|
||||
arts.append(art, alloc);
|
||||
out_arts.append(PublishedArtifact.{
|
||||
id = art.id, platform_name = pname, size_bytes = ra.size_bytes,
|
||||
sha256 = ra.key, url = concat(url_prefix, ra.key),
|
||||
}, alloc);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Publish via the integrity-checked transaction (channel promotion
|
||||
// included). Rollback on failure is left intact for P3.4b.
|
||||
chan := Channel.{
|
||||
app_id = app_id, name = m.channel, current_release_id = "",
|
||||
app_id = app_id, name = channel_name, current_release_id = "",
|
||||
policy = .manual, rollout_percent = 100,
|
||||
};
|
||||
pe := false;
|
||||
@@ -320,24 +359,24 @@ run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFai
|
||||
while j < arts.len {
|
||||
aid := arts.items[j].id;
|
||||
repo.create_audit_event(AuditEvent.{
|
||||
id = concat("evt-upload-", aid), actor = "ci",
|
||||
id = concat("evt-upload-", aid), actor = actor,
|
||||
action = "artifact.upload", target_type = "artifact",
|
||||
target_id = aid, metadata = "", created_at = now,
|
||||
});
|
||||
j += 1;
|
||||
}
|
||||
repo.create_audit_event(AuditEvent.{
|
||||
id = concat("evt-publish-", release_id), actor = "ci",
|
||||
id = concat("evt-publish-", release_id), actor = actor,
|
||||
action = "release.publish", target_type = "release",
|
||||
target_id = release_id, metadata = "", created_at = now,
|
||||
});
|
||||
repo.create_audit_event(AuditEvent.{
|
||||
id = concat(concat(concat("evt-promote-", app_id), "-"), m.channel),
|
||||
actor = "ci", action = "channel.promote", target_type = "channel",
|
||||
target_id = m.channel, metadata = "", created_at = now,
|
||||
id = concat(concat(concat("evt-promote-", app_id), "-"), channel_name),
|
||||
actor = actor, action = "channel.promote", target_type = "channel",
|
||||
target_id = channel_name, metadata = "", created_at = now,
|
||||
});
|
||||
|
||||
// 6. Persist the whole model under the store.
|
||||
// Persist the whole model under the store.
|
||||
persist_err := false;
|
||||
db.save(repo, store_dir) catch { persist_err = true; };
|
||||
if persist_err {
|
||||
@@ -348,7 +387,7 @@ run_publish :: (manifest_path: string, store_dir: string, fail_out: *jout.CliFai
|
||||
|
||||
return PublishOutcome.{
|
||||
release_id = release_id, app_id = app_id,
|
||||
version = m.version, channel = m.channel,
|
||||
version = version, channel = channel_name,
|
||||
artifacts = out_arts,
|
||||
};
|
||||
}
|
||||
|
||||
136
src/server/auth.sx
Normal file
136
src/server/auth.sx
Normal file
@@ -0,0 +1,136 @@
|
||||
// =====================================================================
|
||||
// auth.sx — bearer-token authentication for distd's write surface
|
||||
// (subplan 04, Slice 2).
|
||||
//
|
||||
// `authenticate` takes the raw header block plus the (scope, app, channel)
|
||||
// the operation demands, and either returns the live Token or refuses:
|
||||
//
|
||||
// 401 Unauthorized — no `Authorization` header, a non-Bearer scheme, or
|
||||
// a secret whose hash matches no stored token. The response never
|
||||
// distinguishes "no such token" from "wrong secret": both are
|
||||
// `auth.unknown_token`.
|
||||
// 403 Forbidden — the token exists but `check_token` refuses it; the
|
||||
// code names the refusal (auth.revoked / auth.expired /
|
||||
// auth.missing_scope / auth.app_forbidden / auth.channel_forbidden).
|
||||
// 503 Unavailable — db.json exists but could not be loaded.
|
||||
//
|
||||
// The presented secret is re-hashed (`digest_of_bytes`, the store's
|
||||
// SHA-256) and matched against hashes at rest — the secret itself is never
|
||||
// stored or logged. Successful auth stamps `last_used_at` and persists;
|
||||
// the stamp is best-effort (a failed save must not fail an authorized
|
||||
// request — the operation's own save is the authoritative write).
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/fs.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 "../repo/repo.sx";
|
||||
#import "../store/store.sx";
|
||||
db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
pl :: #import "../publish/publish.sx";
|
||||
tops :: #import "../token/ops.sx";
|
||||
http :: #import "http.sx";
|
||||
|
||||
AuthErr :: error {
|
||||
Unauthorized, // -> 401
|
||||
Forbidden, // -> 403
|
||||
Unavailable, // -> 503
|
||||
}
|
||||
|
||||
// The secret inside `Authorization: Bearer <secret>`, or null when the
|
||||
// value does not carry the Bearer scheme (case-insensitive, per RFC 7235)
|
||||
// or carries an empty credential.
|
||||
bearer_secret :: (header: string) -> ?string {
|
||||
SCHEME :: "bearer ";
|
||||
if header.len <= 7 { return null; }
|
||||
i := 0;
|
||||
while i < 7 {
|
||||
c := header[i];
|
||||
if c >= 65 and c <= 90 { c += 32; } // ASCII lower
|
||||
if c != SCHEME[i] { return null; }
|
||||
i += 1;
|
||||
}
|
||||
return string.{ ptr = @header[7], len = header.len - 7 };
|
||||
}
|
||||
|
||||
check_code :: (e: TokenCheckErr) -> string {
|
||||
if e == error.Revoked { return "auth.revoked"; }
|
||||
if e == error.Expired { return "auth.expired"; }
|
||||
if e == error.ScopeMissing { return "auth.missing_scope"; }
|
||||
if e == error.AppMismatch { return "auth.app_forbidden"; }
|
||||
return "auth.channel_forbidden";
|
||||
}
|
||||
|
||||
check_message :: (e: TokenCheckErr, scope: string, app_slug: string, channel: string) -> string {
|
||||
if e == error.Revoked { return "token has been revoked"; }
|
||||
if e == error.Expired { return "token has expired"; }
|
||||
if e == error.ScopeMissing { return concat("token lacks the required scope: ", scope); }
|
||||
if e == error.AppMismatch { return concat("token is not scoped to this app: ", app_slug); }
|
||||
return concat("token is not scoped to this channel: ", channel);
|
||||
}
|
||||
|
||||
// Authenticate the request these headers came with, demanding `scope`
|
||||
// against (`app_slug`, `channel`) — "" where the operation has no app or
|
||||
// channel to constrain.
|
||||
authenticate :: (store_dir: string, headers: string, scope: string, app_slug: string, channel: string, fail_out: *jout.CliFailure) -> (Token, !AuthErr) {
|
||||
hq := http.header_value(headers, "authorization");
|
||||
if hq == null {
|
||||
fail_out.code = "auth.missing";
|
||||
fail_out.message = "this route requires Authorization: Bearer <token>";
|
||||
raise error.Unauthorized;
|
||||
}
|
||||
sq := bearer_secret(hq!);
|
||||
if sq == null {
|
||||
fail_out.code = "auth.malformed";
|
||||
fail_out.message = "Authorization header is not a Bearer credential";
|
||||
raise error.Unauthorized;
|
||||
}
|
||||
presented_hash := digest_of_bytes(sq!);
|
||||
|
||||
if !exists(path_join(store_dir, "db.json")) {
|
||||
fail_out.code = "auth.unknown_token";
|
||||
fail_out.message = "unknown token";
|
||||
raise error.Unauthorized;
|
||||
}
|
||||
repo, le := db.load(store_dir);
|
||||
if le {
|
||||
fail_out.code = "store.load";
|
||||
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
|
||||
raise error.Unavailable;
|
||||
}
|
||||
|
||||
tq := repo.find_token_by_hash(presented_hash);
|
||||
if tq == null {
|
||||
fail_out.code = "auth.unknown_token";
|
||||
fail_out.message = "unknown token";
|
||||
raise error.Unauthorized;
|
||||
}
|
||||
t := tq!;
|
||||
|
||||
now := pl.now_secs();
|
||||
cerr := false;
|
||||
ccode := "";
|
||||
cmsg := "";
|
||||
check_token(t, scope, app_slug, channel, now) catch (e) {
|
||||
cerr = true;
|
||||
ccode = check_code(e);
|
||||
cmsg = check_message(e, scope, app_slug, channel);
|
||||
};
|
||||
if cerr {
|
||||
fail_out.code = ccode;
|
||||
fail_out.message = cmsg;
|
||||
raise error.Forbidden;
|
||||
}
|
||||
|
||||
tops.mark_token_used(@repo, t.id, now);
|
||||
db.save(@repo, store_dir) catch {}; // best-effort stamp (see header)
|
||||
|
||||
return t;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// =====================================================================
|
||||
// distd.sx — the read-only distribution server over the local store
|
||||
// (subplan 04, Slices 1 + the read half of 3/4), run as `dist server run`.
|
||||
// distd.sx — the distribution server over the local store (subplan 04,
|
||||
// Slices 1-4), run as `dist server run`.
|
||||
//
|
||||
// Serves the state the CLI publishes — db.json metadata and the
|
||||
// content-addressed objects — over HTTP (src/server/http.sx):
|
||||
// content-addressed objects — over HTTP (src/server/http.sx). Reads are
|
||||
// public:
|
||||
//
|
||||
// GET / HTML index: apps, channels, releases, links
|
||||
// GET /healthz {"status":"ok"} — no store access
|
||||
@@ -12,6 +13,21 @@
|
||||
// GET /download/<sha256> the object's bytes (application/octet-stream,
|
||||
// X-Checksum-SHA256 header)
|
||||
//
|
||||
// Writes require `Authorization: Bearer <token>` with the `publish` scope
|
||||
// (src/server/auth.sx; tokens are minted with `dist token create`):
|
||||
//
|
||||
// POST /api/upload raw bytes -> content-
|
||||
// addressed object; responds {"status":"stored","sha256":..}
|
||||
// POST /api/apps/<slug>/releases JSON body {version,
|
||||
// channel, artifacts:[{platform, sha256, ...}]} over ALREADY-
|
||||
// uploaded objects -> the same commit pipeline as `dist ci publish`
|
||||
// POST /api/apps/<slug>/channels/<name>/promote {"release_id":..}
|
||||
// POST /api/apps/<slug>/channels/<name>/rollback (empty body)
|
||||
//
|
||||
// The channel operations delegate to the P3.5 CLI pipelines and the
|
||||
// release POST commits through publish.sx's `commit_publish`, so HTTP and
|
||||
// CLI semantics cannot drift.
|
||||
//
|
||||
// Anything else is a JSON error in the CLI's error shape
|
||||
// (`{"status":"error","error":{code,message}}`) with the matching HTTP
|
||||
// status — the API and the CLI report failures identically.
|
||||
@@ -21,10 +37,6 @@
|
||||
// the store on disk stays the single source of truth (no cache to
|
||||
// invalidate, LAN-scale traffic).
|
||||
//
|
||||
// MUTATION is the CLI's job for now: every route is GET; writes arrive
|
||||
// with token auth (subplan 04 Slice 2, deferred with the rest of the
|
||||
// upload/auth surface).
|
||||
//
|
||||
// RESPONSE BUFFERS are heap slices from the per-request arena, never big
|
||||
// stack arrays: a stack array of 64K+ in one frame crashes the sx LLVM
|
||||
// backend (DAGCombiner segfault). Small fixed buffers (4K) are fine.
|
||||
@@ -38,12 +50,25 @@
|
||||
#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";
|
||||
#import "../store/store.sx";
|
||||
#import "../validation/artifact_file.sx";
|
||||
#import "../publish/publish.sx";
|
||||
sock :: #import "modules/std/socket.sx";
|
||||
http :: #import "http.sx";
|
||||
au :: #import "auth.sx";
|
||||
db :: #import "../repo/db.sx";
|
||||
jout :: #import "../json_out.sx";
|
||||
// Also reached through an alias so publish helpers read as `pl.…` at the
|
||||
// call sites that mirror dist.sx's.
|
||||
pl :: #import "../publish/publish.sx";
|
||||
ops :: #import "../release/ops.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";
|
||||
|
||||
// Response-body capacity for the /api JSON renders (heap, per-request).
|
||||
RENDER_CAP :: 262144;
|
||||
@@ -326,36 +351,371 @@ handle_download :: (client: i32, store_dir: string, sha: string) {
|
||||
http.respond(client, 200, "application/octet-stream", extra, bq!);
|
||||
}
|
||||
|
||||
// Route one parsed request. GET only; the path decides the handler.
|
||||
route :: (client: i32, store_dir: string, method: string, path: string) -> i64 {
|
||||
if method != "GET" {
|
||||
respond_error(client, 405, "http.method_not_allowed",
|
||||
"every distd route is GET for now (writes go through the dist CLI)");
|
||||
return 405;
|
||||
// ── write surface (POST, token-gated) ─────────────────────────────────
|
||||
|
||||
// `s` split at its first '/': head before it, rest after it ("" when no
|
||||
// slash exists).
|
||||
Seg :: struct {
|
||||
head: string;
|
||||
rest: string;
|
||||
}
|
||||
|
||||
seg_split :: (s: string) -> Seg {
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
if s[i] == 47 { // '/'
|
||||
return Seg.{
|
||||
head = string.{ ptr = s.ptr, len = i },
|
||||
rest = string.{ ptr = @s[i + 1], len = s.len - i - 1 },
|
||||
};
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if path == "/" {
|
||||
handle_index(client, store_dir);
|
||||
return 200;
|
||||
return Seg.{ head = s, rest = "" };
|
||||
}
|
||||
|
||||
// Authenticate a write request or answer it. Null means the refusal
|
||||
// response has already been sent.
|
||||
auth_or_respond :: (client: i32, store_dir: string, req: *http.Request, app_slug: string, channel: string) -> ?Token {
|
||||
fail : jout.CliFailure = .{};
|
||||
t, ae := au.authenticate(store_dir, req.headers, "publish", app_slug, channel, @fail);
|
||||
if ae {
|
||||
status : i64 = 401;
|
||||
if ae == error.Forbidden { status = 403; }
|
||||
if ae == error.Unavailable { status = 503; }
|
||||
respond_error(client, status, fail.code, fail.message);
|
||||
return null;
|
||||
}
|
||||
if path == "/healthz" {
|
||||
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
|
||||
return 200;
|
||||
return t;
|
||||
}
|
||||
|
||||
// JSON-object body, or null after answering 400.
|
||||
body_object :: (client: i32, body: string) -> ?Object {
|
||||
v, pe := jsrv.parse(body, context.allocator);
|
||||
if pe {
|
||||
respond_error(client, 400, "api.bad_json", "request body is not valid JSON");
|
||||
return null;
|
||||
}
|
||||
if path == "/api/apps" {
|
||||
handle_apps_index(client, store_dir);
|
||||
return 200;
|
||||
if v != .object {
|
||||
respond_error(client, 400, "api.bad_json", "request body must be a JSON object");
|
||||
return null;
|
||||
}
|
||||
return v.object;
|
||||
}
|
||||
|
||||
wb_find :: (o: Object, key: string) -> ?Value {
|
||||
i := 0;
|
||||
while i < o.len {
|
||||
if o.items[i].key == key { return o.items[i].val; }
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Required string member, or null after answering 400.
|
||||
wb_req_str :: (client: i32, o: Object, key: string) -> ?string {
|
||||
vq := wb_find(o, key);
|
||||
if vq != null {
|
||||
v := vq!;
|
||||
if v == .str {
|
||||
if v.str.len > 0 { return v.str; }
|
||||
}
|
||||
}
|
||||
respond_error(client, 400, "api.missing_field",
|
||||
concat("body requires a non-empty string member: ", key));
|
||||
return null;
|
||||
}
|
||||
|
||||
wb_opt_str :: (o: Object, key: string) -> string {
|
||||
vq := wb_find(o, key);
|
||||
if vq == null { return ""; }
|
||||
v := vq!;
|
||||
if v != .str { return ""; }
|
||||
return v.str;
|
||||
}
|
||||
|
||||
wb_opt_int :: (o: Object, key: string, default_: i64) -> i64 {
|
||||
vq := wb_find(o, key);
|
||||
if vq == null { return default_; }
|
||||
v := vq!;
|
||||
if v != .int_ { return default_; }
|
||||
return v.int_;
|
||||
}
|
||||
|
||||
// POST /api/upload — content-address the raw body into the store. The
|
||||
// upload is app-agnostic (the bytes are inert until a release references
|
||||
// them), so auth demands only the publish scope.
|
||||
handle_upload :: (client: i32, store_dir: string, req: *http.Request) {
|
||||
if auth_or_respond(client, store_dir, req, "", "") == null { return; }
|
||||
if req.body.len == 0 {
|
||||
respond_error(client, 400, "upload.empty", "upload body is empty");
|
||||
return;
|
||||
}
|
||||
st := Store.init(store_dir);
|
||||
werr := false;
|
||||
key := "";
|
||||
k, se := st.put_bytes(req.body);
|
||||
if se { werr = true; }
|
||||
if !se { key = k; }
|
||||
if werr {
|
||||
respond_error(client, 500, "store.write",
|
||||
"upload bytes could not be content-addressed into the store");
|
||||
return;
|
||||
}
|
||||
body := concat("{\"status\":\"stored\",\"sha256\":\"", concat(key, concat("\",\"size_bytes\":", concat(int_to_string(req.body.len), "}"))));
|
||||
http.respond(client, 200, "application/json", "", body);
|
||||
}
|
||||
|
||||
// POST /api/apps/<slug>/releases — publish a release whose artifacts name
|
||||
// already-uploaded objects by digest. Mirrors the CLI manifest checks
|
||||
// (platform, digest, size, content type, filename extension) against the
|
||||
// STORE rather than local files, then commits through the shared pipeline.
|
||||
handle_release_create :: (client: i32, store_dir: string, req: *http.Request, slug: string) {
|
||||
oq := body_object(client, req.body);
|
||||
if oq == null { return; }
|
||||
o := oq!;
|
||||
|
||||
versionq := wb_req_str(client, o, "version");
|
||||
if versionq == null { return; }
|
||||
version := versionq!;
|
||||
channelq := wb_req_str(client, o, "channel");
|
||||
if channelq == null { return; }
|
||||
channel := channelq!;
|
||||
|
||||
tokq := auth_or_respond(client, store_dir, req, slug, channel);
|
||||
if tokq == null { return; }
|
||||
tok := tokq!;
|
||||
|
||||
artsq := wb_find(o, "artifacts");
|
||||
arts_ok := false;
|
||||
arts_arr : Array = .{};
|
||||
if artsq != null {
|
||||
av := artsq!;
|
||||
if av == .array {
|
||||
if av.array.len > 0 { arts_ok = true; arts_arr = av.array; }
|
||||
}
|
||||
}
|
||||
if !arts_ok {
|
||||
respond_error(client, 400, "api.missing_field",
|
||||
"body requires a non-empty artifacts array");
|
||||
return;
|
||||
}
|
||||
|
||||
alloc := context.allocator;
|
||||
resolved : List(ResolvedArtifact) = .{};
|
||||
i := 0;
|
||||
while i < arts_arr.len {
|
||||
if arts_arr.items[i] != .object {
|
||||
respond_error(client, 400, "api.bad_json", "each artifact must be a JSON object");
|
||||
return;
|
||||
}
|
||||
ao := arts_arr.items[i].object;
|
||||
|
||||
pidq := wb_req_str(client, ao, "platform");
|
||||
if pidq == null { return; }
|
||||
platform, perr := parse_platform(pidq!);
|
||||
if perr {
|
||||
respond_error(client, 400, "api.unknown_platform",
|
||||
concat("unknown platform id: ", pidq!));
|
||||
return;
|
||||
}
|
||||
|
||||
shaq := wb_req_str(client, ao, "sha256");
|
||||
if shaq == null { return; }
|
||||
sha := shaq!;
|
||||
if !is_hex64(sha) {
|
||||
respond_error(client, 400, "api.bad_digest",
|
||||
"artifact sha256 must be a 64-char lowercase-hex digest");
|
||||
return;
|
||||
}
|
||||
|
||||
// The named object must already live in the store (uploaded via
|
||||
// /api/upload or an earlier publish).
|
||||
opath := path_join(store_dir, concat("objects/", sha));
|
||||
ob := read_file(opath);
|
||||
if ob == null {
|
||||
respond_error(client, 404, "api.unknown_object",
|
||||
concat("no object with that digest in the store (upload it first): ", sha));
|
||||
return;
|
||||
}
|
||||
actual_size := ob!.len;
|
||||
declared := wb_opt_int(ao, "size_bytes", -1);
|
||||
if declared >= 0 and declared != actual_size {
|
||||
respond_error(client, 400, "api.size_mismatch",
|
||||
concat("declared size_bytes does not match the stored object: ", sha));
|
||||
return;
|
||||
}
|
||||
|
||||
ct := wb_opt_str(ao, "content_type");
|
||||
if ct.len == 0 { ct = pl.default_content_type(platform); }
|
||||
if !is_allowed_content_type(ct) {
|
||||
respond_error(client, 400, "api.content_type_denied",
|
||||
concat("artifact content type is not on the allow-list: ", ct));
|
||||
return;
|
||||
}
|
||||
|
||||
fname := wb_opt_str(ao, "filename");
|
||||
if fname.len == 0 {
|
||||
fname = concat(slug, concat("-", concat(version, concat(".", expected_ext(platform)))));
|
||||
}
|
||||
if !ext_matches_platform(fname, platform) {
|
||||
respond_error(client, 400, "api.extension_mismatch",
|
||||
concat("artifact filename extension does not match its platform: ", fname));
|
||||
return;
|
||||
}
|
||||
|
||||
resolved.append(ResolvedArtifact.{
|
||||
platform = platform, key = sha, size_bytes = actual_size,
|
||||
filename = fname, content_type = ct,
|
||||
metadata = wb_opt_str(ao, "metadata"),
|
||||
}, alloc);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
fail : jout.CliFailure = .{};
|
||||
actor := concat("token:", tok.name);
|
||||
arts_view : []ResolvedArtifact = .{ ptr = resolved.items, len = resolved.len };
|
||||
outcome, ce := pl.commit_publish(store_dir, slug, version, channel, actor, "/download/", arts_view, @fail);
|
||||
if ce {
|
||||
status : i64 = 500;
|
||||
if ce == error.Transaction {
|
||||
status = 400;
|
||||
if fail.code == "transaction.integrity" { status = 409; }
|
||||
}
|
||||
if ce == error.Persist {
|
||||
if fail.code == "persist.load" { status = 503; }
|
||||
}
|
||||
respond_error(client, status, fail.code, fail.message);
|
||||
return;
|
||||
}
|
||||
if !ce {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := pl.write_json(@outcome, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP status for a failed P3.5 channel operation.
|
||||
op_http_status :: (e: ops.OpError) -> i64 {
|
||||
if e == error.Load { return 503; }
|
||||
if e == error.NotFound { return 404; }
|
||||
if e == error.Invalid { return 409; }
|
||||
return 500;
|
||||
}
|
||||
|
||||
// POST /api/apps/<slug>/channels/<name>/promote — body {"release_id":..};
|
||||
// delegates to the CLI's promote pipeline.
|
||||
handle_promote :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
oq := body_object(client, req.body);
|
||||
if oq == null { return; }
|
||||
relq := wb_req_str(client, oq!, "release_id");
|
||||
if relq == null { return; }
|
||||
|
||||
if auth_or_respond(client, store_dir, req, slug, chan_name) == null { return; }
|
||||
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := ops.run_promote(store_dir, slug, chan_name, relq!, @fail);
|
||||
if e {
|
||||
respond_error(client, op_http_status(e), fail.code, fail.message);
|
||||
return;
|
||||
}
|
||||
if !e {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := ops.write_promote_json(@o, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/apps/<slug>/channels/<name>/rollback — empty body; delegates
|
||||
// to the CLI's rollback pipeline.
|
||||
handle_rollback :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
||||
if auth_or_respond(client, store_dir, req, slug, chan_name) == null { return; }
|
||||
|
||||
fail : jout.CliFailure = .{};
|
||||
o, e := ops.run_rollback(store_dir, slug, chan_name, @fail);
|
||||
if e {
|
||||
respond_error(client, op_http_status(e), fail.code, fail.message);
|
||||
return;
|
||||
}
|
||||
if !e {
|
||||
buf := render_buf();
|
||||
werr := false;
|
||||
n := ops.write_rollback_json(@o, buf) catch { werr = true; 0 };
|
||||
respond_render(client, buf, n, werr);
|
||||
}
|
||||
}
|
||||
|
||||
// Route a write request under POST /api/. Returns false when no write
|
||||
// route matches (the caller 404s).
|
||||
route_post :: (client: i32, store_dir: string, req: *http.Request) -> bool {
|
||||
path := req.path;
|
||||
if path == "/api/upload" {
|
||||
handle_upload(client, store_dir, req);
|
||||
return true;
|
||||
}
|
||||
if starts_with(path, "/api/apps/") {
|
||||
handle_app_detail(client, store_dir, tail_after(path, "/api/apps/"));
|
||||
return 200;
|
||||
s1 := seg_split(tail_after(path, "/api/apps/"));
|
||||
if s1.head.len == 0 { return false; }
|
||||
if s1.rest == "releases" {
|
||||
handle_release_create(client, store_dir, req, s1.head);
|
||||
return true;
|
||||
}
|
||||
if starts_with(s1.rest, "channels/") {
|
||||
s2 := seg_split(tail_after(s1.rest, "channels/"));
|
||||
if s2.head.len == 0 { return false; }
|
||||
if s2.rest == "promote" {
|
||||
handle_promote(client, store_dir, req, s1.head, s2.head);
|
||||
return true;
|
||||
}
|
||||
if s2.rest == "rollback" {
|
||||
handle_rollback(client, store_dir, req, s1.head, s2.head);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if starts_with(path, "/download/") {
|
||||
handle_download(client, store_dir, tail_after(path, "/download/"));
|
||||
return 200;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Route one parsed request: GET reads, POST writes.
|
||||
route :: (client: i32, store_dir: string, req: *http.Request) -> i64 {
|
||||
method := req.method;
|
||||
path := req.path;
|
||||
if method == "GET" {
|
||||
if path == "/" {
|
||||
handle_index(client, store_dir);
|
||||
return 200;
|
||||
}
|
||||
if path == "/healthz" {
|
||||
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
|
||||
return 200;
|
||||
}
|
||||
if path == "/api/apps" {
|
||||
handle_apps_index(client, store_dir);
|
||||
return 200;
|
||||
}
|
||||
if starts_with(path, "/api/apps/") {
|
||||
handle_app_detail(client, store_dir, tail_after(path, "/api/apps/"));
|
||||
return 200;
|
||||
}
|
||||
if starts_with(path, "/download/") {
|
||||
handle_download(client, store_dir, tail_after(path, "/download/"));
|
||||
return 200;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no route for ", path));
|
||||
return 404;
|
||||
}
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no route for ", path));
|
||||
return 404;
|
||||
if method == "POST" {
|
||||
if route_post(client, store_dir, req) { return 200; }
|
||||
respond_error(client, 404, "http.not_found",
|
||||
concat("no write route for ", path));
|
||||
return 404;
|
||||
}
|
||||
respond_error(client, 405, "http.method_not_allowed",
|
||||
"distd routes are GET (reads) or POST (token-gated writes)");
|
||||
return 405;
|
||||
}
|
||||
|
||||
// ── server loop ───────────────────────────────────────────────────────
|
||||
@@ -363,18 +723,38 @@ route :: (client: i32, store_dir: string, method: string, path: string) -> i64 {
|
||||
// Read one request off `client`, route it, log the result line. All
|
||||
// allocations land in the pushed per-request context allocator.
|
||||
serve_one :: (client: i32, store_dir: string) {
|
||||
buf : [8192]u8 = ---;
|
||||
n := sock.read(client, @buf[0], 8192);
|
||||
if n <= 0 { return; }
|
||||
|
||||
raw := string.{ ptr = @buf[0], len = xx n };
|
||||
req : http.Request = .{};
|
||||
if !http.parse_request(raw, @req) {
|
||||
respond_error(client, 400, "http.bad_request",
|
||||
"request line did not parse as HTTP");
|
||||
closed := false;
|
||||
rstatus : i64 = 0;
|
||||
rcode := "";
|
||||
rmsg := "";
|
||||
http.read_request(client, @req) catch (e) {
|
||||
if e == error.Closed { closed = true; }
|
||||
if !closed {
|
||||
rstatus = 400; rcode = "http.bad_request";
|
||||
rmsg = "request could not be read as HTTP (bad request line or truncated body)";
|
||||
if e == error.LengthRequired {
|
||||
rstatus = 411; rcode = "http.length_required";
|
||||
rmsg = "POST/PUT requires a Content-Length header";
|
||||
}
|
||||
if e == error.BodyTooLarge {
|
||||
rstatus = 413; rcode = "http.body_too_large";
|
||||
rmsg = "request body exceeds the server's size cap";
|
||||
}
|
||||
if e == error.HeadersTooLarge {
|
||||
rstatus = 400; rcode = "http.headers_too_large";
|
||||
rmsg = "request header block exceeds 8K";
|
||||
}
|
||||
}
|
||||
};
|
||||
if closed { return; }
|
||||
if rstatus > 0 {
|
||||
respond_error(client, rstatus, rcode, rmsg);
|
||||
slog(concat("distd: unreadable request -> ", concat(int_to_string(rstatus), "\n")));
|
||||
return;
|
||||
}
|
||||
code := route(client, store_dir, req.method, req.path);
|
||||
|
||||
code := route(client, store_dir, @req);
|
||||
line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(code), "\n"))))));
|
||||
slog(line);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
// =====================================================================
|
||||
// http.sx — minimal HTTP/1.1 over `std.socket` (subplan 04, Slice 1).
|
||||
// http.sx — minimal HTTP/1.1 over `std.socket` (subplan 04, Slices 1+2).
|
||||
//
|
||||
// The temporary in-repo boundary for the missing `std.http`, written so it
|
||||
// can be lifted into the sx stdlib later. Deliberately minimal — exactly
|
||||
// what a read-only JSON API + artifact download server needs:
|
||||
// what a JSON API + artifact upload/download server needs:
|
||||
//
|
||||
// * `listen_on(port)` — a listening TCP socket on 0.0.0.0:<port>
|
||||
// (INADDR_ANY, so the server is reachable from the LAN);
|
||||
// * `parse_request` — method + path views off the request line;
|
||||
// * `read_request` — one full request: request line, header block, and a
|
||||
// Content-Length-bounded body, with typed failures for every refusal;
|
||||
// * `header_value` — case-insensitive header lookup;
|
||||
// * `respond` — one full response: status line, Content-Type/-Length,
|
||||
// `Connection: close`, optional extra header lines, body.
|
||||
//
|
||||
// NOT handled (v0, documented): request headers and bodies (the routes are
|
||||
// GET-only and need neither), keep-alive (every response closes), chunked
|
||||
// transfer, TLS (the deployment plan terminates TLS at a reverse proxy).
|
||||
// A request is read in ONE `read` — request lines of interest fit the
|
||||
// first segment; anything that doesn't parse is a 400.
|
||||
// REQUEST MEMORY: `read_request` allocates its header and body buffers
|
||||
// from `context.allocator` — the caller's per-request arena — so every
|
||||
// view in a `Request` lives exactly as long as the request being served.
|
||||
//
|
||||
// LIMITS: the header block must fit HDR_CAP (8K); a body must declare
|
||||
// Content-Length (411 otherwise) and fit MAX_BODY (413 otherwise). Bodies
|
||||
// are read whole into memory — streaming uploads are a later slice.
|
||||
//
|
||||
// NOT handled (v0, documented): keep-alive (every response closes),
|
||||
// chunked transfer, TLS (the deployment plan terminates TLS at a reverse
|
||||
// proxy).
|
||||
// =====================================================================
|
||||
|
||||
#import "modules/std.sx";
|
||||
@@ -27,10 +35,27 @@ HttpError :: error {
|
||||
Listen,
|
||||
}
|
||||
|
||||
// Method + path of one request, as VIEWS into the caller's read buffer.
|
||||
// Why a request could not be read. `Closed` = the peer sent nothing
|
||||
// (speculative preconnect or a dropped connection) — not worth a response.
|
||||
// Every other tag maps to a 4xx the caller sends.
|
||||
ReadErr :: error {
|
||||
Closed, // no bytes arrived (idle preconnect / disconnect)
|
||||
BadRequest, // request line unparseable, or the body never finished
|
||||
HeadersTooLarge, // header block exceeds HDR_CAP
|
||||
LengthRequired, // a method with a body arrived without Content-Length
|
||||
BodyTooLarge, // declared Content-Length exceeds MAX_BODY
|
||||
}
|
||||
|
||||
HDR_CAP :: 8192;
|
||||
MAX_BODY :: 536870912; // 512 MiB — bodies are held in memory whole
|
||||
|
||||
// One parsed request. All fields are views into per-request arena memory
|
||||
// owned by `read_request`.
|
||||
Request :: struct {
|
||||
method: string = "";
|
||||
path: string = "";
|
||||
headers: string = ""; // raw header block, between request line and the blank line
|
||||
body: string = "";
|
||||
}
|
||||
|
||||
// Open a listening socket on 0.0.0.0:<port>. SO_REUSEADDR so a restarted
|
||||
@@ -81,11 +106,149 @@ parse_request :: (raw: string, req: *Request) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case-insensitive ASCII equality for header names.
|
||||
hname_eq :: (a: string, b: string) -> bool {
|
||||
if a.len != b.len { return false; }
|
||||
i := 0;
|
||||
while i < a.len {
|
||||
ca := a[i];
|
||||
cb := b[i];
|
||||
if ca >= 65 and ca <= 90 { ca += 32; } // 'A'..'Z' -> lower
|
||||
if cb >= 65 and cb <= 90 { cb += 32; }
|
||||
if ca != cb { return false; }
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The value of header `name` (case-insensitive) in a raw header block —
|
||||
// leading spaces/tabs trimmed, null when absent.
|
||||
header_value :: (headers: string, name: string) -> ?string {
|
||||
i := 0;
|
||||
while i < headers.len {
|
||||
// the current line: [i, eol)
|
||||
eol := i;
|
||||
while eol < headers.len and headers[eol] != 13 { eol += 1; } // 13 = '\r'
|
||||
// split at ':'
|
||||
c := i;
|
||||
while c < eol and headers[c] != 58 { c += 1; } // 58 = ':'
|
||||
if c < eol {
|
||||
nm := string.{ ptr = @headers[i], len = c - i };
|
||||
if hname_eq(nm, name) {
|
||||
v := c + 1;
|
||||
while v < eol and (headers[v] == 32 or headers[v] == 9) { v += 1; }
|
||||
return string.{ ptr = @headers[v], len = eol - v };
|
||||
}
|
||||
}
|
||||
// skip "\r\n"
|
||||
i = eol + 2;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Non-negative decimal, or null (empty/garbage/overflow-length input).
|
||||
parse_content_length :: (s: string) -> ?i64 {
|
||||
if s.len == 0 or s.len > 12 { return null; }
|
||||
v : i64 = 0;
|
||||
i := 0;
|
||||
while i < s.len {
|
||||
c := s[i];
|
||||
if c < 48 or c > 57 { return null; } // '0'..'9'
|
||||
v = v * 10 + (c - 48);
|
||||
i += 1;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Find "\r\n\r\n" in buf[0..len); -1 when absent.
|
||||
find_blank_line :: (buf: [*]u8, len: i64) -> i64 {
|
||||
i : i64 = 0;
|
||||
while i + 3 < len {
|
||||
if buf[i] == 13 and buf[i+1] == 10 and buf[i+2] == 13 and buf[i+3] == 10 {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Read one full request off `client`: request line + headers (up to
|
||||
// HDR_CAP), then — when Content-Length says so — the whole body. GETs
|
||||
// carry no body; any request DECLARING a body gets it read regardless of
|
||||
// method, so the router can 405 with the connection drained.
|
||||
read_request :: (client: i32, req: *Request) -> !ReadErr {
|
||||
hbuf : [*]u8 = xx context.allocator.alloc_bytes(HDR_CAP);
|
||||
filled : i64 = 0;
|
||||
hdr_end : i64 = -1;
|
||||
|
||||
while hdr_end < 0 {
|
||||
if filled >= HDR_CAP { raise error.HeadersTooLarge; }
|
||||
n := sock.read(client, @hbuf[filled], xx (HDR_CAP - filled));
|
||||
if n <= 0 {
|
||||
if filled == 0 { raise error.Closed; }
|
||||
raise error.BadRequest;
|
||||
}
|
||||
filled += n;
|
||||
hdr_end = find_blank_line(hbuf, filled);
|
||||
}
|
||||
|
||||
head := string.{ ptr = hbuf, len = hdr_end };
|
||||
if !parse_request(head, req) { raise error.BadRequest; }
|
||||
|
||||
// header block = after the request line, before the blank line
|
||||
line_end := 0;
|
||||
while line_end < head.len and head[line_end] != 13 { line_end += 1; }
|
||||
hstart := line_end + 2;
|
||||
if hstart < hdr_end {
|
||||
req.headers = string.{ ptr = @hbuf[hstart], len = hdr_end - hstart };
|
||||
} else {
|
||||
req.headers = "";
|
||||
}
|
||||
|
||||
// body: Content-Length governs; absent means none expected — except
|
||||
// for POST/PUT, which must declare one (411) so an upload can never
|
||||
// be silently truncated.
|
||||
clq := header_value(req.headers, "content-length");
|
||||
if clq == null {
|
||||
if req.method == "POST" or req.method == "PUT" { raise error.LengthRequired; }
|
||||
req.body = "";
|
||||
return;
|
||||
}
|
||||
clv := parse_content_length(clq!);
|
||||
if clv == null { raise error.BadRequest; }
|
||||
body_len := clv!;
|
||||
if body_len == 0 { req.body = ""; return; }
|
||||
if body_len > MAX_BODY { raise error.BodyTooLarge; }
|
||||
|
||||
bbuf : [*]u8 = xx context.allocator.alloc_bytes(xx body_len);
|
||||
have : i64 = 0;
|
||||
|
||||
// bytes that arrived in the header read belong to the body
|
||||
spill := filled - (hdr_end + 4);
|
||||
if spill > 0 {
|
||||
take := if spill > body_len then body_len else spill;
|
||||
memcpy(bbuf, @hbuf[hdr_end + 4], xx take);
|
||||
have = take;
|
||||
}
|
||||
while have < body_len {
|
||||
n := sock.read(client, @bbuf[have], xx (body_len - have));
|
||||
if n <= 0 { raise error.BadRequest; } // peer quit mid-body
|
||||
have += n;
|
||||
}
|
||||
req.body = string.{ ptr = bbuf, len = body_len };
|
||||
return;
|
||||
}
|
||||
|
||||
status_text :: (code: i64) -> string {
|
||||
if code == 200 { return "OK"; }
|
||||
if code == 400 { return "Bad Request"; }
|
||||
if code == 401 { return "Unauthorized"; }
|
||||
if code == 403 { return "Forbidden"; }
|
||||
if code == 404 { return "Not Found"; }
|
||||
if code == 405 { return "Method Not Allowed"; }
|
||||
if code == 409 { return "Conflict"; }
|
||||
if code == 411 { return "Length Required"; }
|
||||
if code == 413 { return "Payload Too Large"; }
|
||||
if code == 503 { return "Service Unavailable"; }
|
||||
return "Internal Server Error";
|
||||
}
|
||||
|
||||
282
tests/server_write.sx
Normal file
282
tests/server_write.sx
Normal file
@@ -0,0 +1,282 @@
|
||||
// Pinned acceptance for P4.4 — distd's token-gated write surface.
|
||||
//
|
||||
// Mints tokens via the CLI (a publish-scoped one, a read-only one, an
|
||||
// app-scoped one for the WRONG app, and a revoked one), starts the BUILT
|
||||
// `build/dist server run`, and asserts over curl:
|
||||
//
|
||||
// * auth: 401 auth.missing / auth.unknown_token without a usable
|
||||
// bearer; 403 auth.missing_scope (read token),
|
||||
// auth.revoked (revoked token), auth.app_forbidden
|
||||
// (token scoped to another app); only tokens that PASSED
|
||||
// auth get last_used_at stamped.
|
||||
// * upload: POST /api/upload stores the body content-addressed — the
|
||||
// returned sha256 equals an independently computed digest of
|
||||
// the fixture, the object lands under objects/<sha>, and a
|
||||
// re-upload answers the same key (dedup). A bodyless POST is
|
||||
// 411.
|
||||
// * release: POST /api/apps/<slug>/releases over the uploaded digest
|
||||
// publishes through the shared pipeline — visible on the
|
||||
// next GET (per-request reload), channel moved; an unknown
|
||||
// digest is 404 api.unknown_object and leaves no channel
|
||||
// behind; re-publishing the same version is 409.
|
||||
// * channel: promote / rollback move the pointer exactly like their
|
||||
// CLI twins; unknown release id is 404.
|
||||
// * methods: anything but GET/POST is 405.
|
||||
#import "modules/std.sx";
|
||||
#import "modules/std/json.sx";
|
||||
process :: #import "modules/std/process.sx";
|
||||
fs :: #import "modules/std/fs.sx";
|
||||
hash :: #import "modules/std/hash.sx";
|
||||
|
||||
STORE :: ".sx-tmp/server_write";
|
||||
PORT :: "18793";
|
||||
BASE :: "http://127.0.0.1:18793";
|
||||
FIXTURE :: "examples/fixtures/acme-1.2.3-android.apk";
|
||||
|
||||
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_int :: (o: Object, key: string) -> i64 { return get(o, key).int_; }
|
||||
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
|
||||
get_arr :: (o: Object, key: string) -> Array { return get(o, key).array; }
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Mint a token via the CLI and return its raw secret.
|
||||
mint :: (flags: string, scratch: Allocator) -> string {
|
||||
cmd := concat("build/dist token create --local-store .sx-tmp/server_write ", flags);
|
||||
cmd = concat(cmd, " --json 2>/dev/null");
|
||||
r := process.run(cmd);
|
||||
process.assert(r != null and r!.exit_code == 0, concat("token create must exit 0: ", flags));
|
||||
o := parse_body(r!.stdout, "token create", scratch);
|
||||
return get_str(get_obj(o, "token"), "secret");
|
||||
}
|
||||
|
||||
// POST `path` with optional bearer + data flags; returns the body.
|
||||
post :: (path: string, auth: string, dataflags: string) -> string {
|
||||
cmd := "curl -s -m 4 -X POST ";
|
||||
if auth.len > 0 {
|
||||
cmd = concat(cmd, concat("-H 'Authorization: Bearer ", concat(auth, "' ")));
|
||||
}
|
||||
cmd = concat(cmd, concat(dataflags, concat(" ", concat(BASE, path))));
|
||||
r := process.run(cmd);
|
||||
process.assert(r != null, concat("curl spawn failed: ", path));
|
||||
return r!.stdout;
|
||||
}
|
||||
|
||||
// POST and return only the HTTP status code as text.
|
||||
post_code :: (path: string, auth: string, dataflags: string) -> string {
|
||||
cmd := "curl -s -m 4 -o /dev/null -w '%{http_code}' -X POST ";
|
||||
if auth.len > 0 {
|
||||
cmd = concat(cmd, concat("-H 'Authorization: Bearer ", concat(auth, "' ")));
|
||||
}
|
||||
cmd = concat(cmd, concat(dataflags, concat(" ", concat(BASE, path))));
|
||||
r := process.run(cmd);
|
||||
process.assert(r != null, concat("curl spawn failed: ", path));
|
||||
return r!.stdout;
|
||||
}
|
||||
|
||||
fetch :: (path: string) -> string {
|
||||
r := process.run(concat(concat("curl -s -m 4 ", BASE), path));
|
||||
process.assert(r != null, concat("curl spawn failed: ", path));
|
||||
return r!.stdout;
|
||||
}
|
||||
|
||||
// The error.code member of a JSON error response.
|
||||
err_code :: (body: string, what: string, scratch: Allocator) -> string {
|
||||
return get_str(get_obj(parse_body(body, what, scratch), "error"), "code");
|
||||
}
|
||||
|
||||
load_db :: (scratch: Allocator) -> Object {
|
||||
b := fs.read_file(path_join(STORE, "db.json"));
|
||||
process.assert(b != null, "db.json must exist under the store");
|
||||
return parse_body(b!, "db.json", scratch);
|
||||
}
|
||||
|
||||
// current_release_id of channel `name`, "" when the channel doesn't exist.
|
||||
channel_pointer :: (name: string, scratch: Allocator) -> string {
|
||||
chans := get_arr(load_db(scratch), "channels");
|
||||
i := 0;
|
||||
while i < chans.len {
|
||||
co := chans.items[i].object;
|
||||
if get_str(co, "name") == name { return get_str(co, "current_release_id"); }
|
||||
i += 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// last_used_at of the token named `name`.
|
||||
token_last_used :: (name: string, scratch: Allocator) -> i64 {
|
||||
toks := get_arr(load_db(scratch), "tokens");
|
||||
i := 0;
|
||||
while i < toks.len {
|
||||
to := toks.items[i].object;
|
||||
if get_str(to, "name") == name { return get_int(to, "last_used_at"); }
|
||||
i += 1;
|
||||
}
|
||||
process.assert(false, concat("token not in db.json: ", name));
|
||||
return -1;
|
||||
}
|
||||
|
||||
// JSON body for a release POST referencing `sha` (single android artifact).
|
||||
release_body :: (version: string, channel: string, sha: string) -> string {
|
||||
b := concat("-d '{\"version\":\"", version);
|
||||
b = concat(b, concat("\",\"channel\":\"", channel));
|
||||
b = concat(b, "\",\"artifacts\":[{\"platform\":\"android_apk\",\"sha256\":\"");
|
||||
b = concat(b, sha);
|
||||
return concat(b, "\"}]}'");
|
||||
}
|
||||
|
||||
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_write' 2>/dev/null");
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
|
||||
// ── tokens (this also creates db.json on the fresh store) ─────────
|
||||
publisher := mint("--name publisher", xx arena);
|
||||
reader := mint("--name reader --scope read", xx arena);
|
||||
wrong_app := mint("--name wrong-app --app other-app", xx arena);
|
||||
revoked := mint("--name doomed", xx arena);
|
||||
// revoke "doomed" by id, looked up via token list
|
||||
tl := process.run("build/dist token list --local-store .sx-tmp/server_write --json 2>/dev/null");
|
||||
process.assert(tl != null and tl!.exit_code == 0, "token list must exit 0");
|
||||
toks := get_arr(parse_body(tl!.stdout, "token list", xx arena), "tokens");
|
||||
doomed_id := "";
|
||||
ti := 0;
|
||||
while ti < toks.len {
|
||||
to := toks.items[ti].object;
|
||||
if get_str(to, "name") == "doomed" { doomed_id = get_str(to, "id"); }
|
||||
ti += 1;
|
||||
}
|
||||
process.assert(doomed_id.len > 0, "doomed token must be listed");
|
||||
rv := process.run(concat(concat("build/dist token revoke --id ", doomed_id), " --local-store .sx-tmp/server_write --json 2>/dev/null"));
|
||||
process.assert(rv != null and rv!.exit_code == 0, "token revoke must exit 0");
|
||||
|
||||
// ── server up ──────────────────────────────────────────────────────
|
||||
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 := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), "/healthz"));
|
||||
if c != null {
|
||||
if c!.stdout == "200" { ready = true; break; }
|
||||
}
|
||||
process.run("sleep 0.2");
|
||||
tries += 1;
|
||||
}
|
||||
process.assert(ready, "server must answer /healthz within 10s");
|
||||
print(" server up\n");
|
||||
|
||||
upload_data := concat("--data-binary @", FIXTURE);
|
||||
|
||||
// ── auth refusals ──────────────────────────────────────────────────
|
||||
process.assert(post_code("/api/upload", "", upload_data) == "401", "no auth is 401");
|
||||
process.assert(err_code(post("/api/upload", "", upload_data), "no-auth body", xx arena) == "auth.missing",
|
||||
"no auth names auth.missing");
|
||||
process.assert(err_code(post("/api/upload", "dist_bogus", upload_data), "bad-token body", xx arena) == "auth.unknown_token",
|
||||
"unknown secret names auth.unknown_token");
|
||||
process.assert(post_code("/api/upload", "dist_bogus", upload_data) == "401", "unknown secret is 401");
|
||||
process.assert(post_code("/api/upload", reader, upload_data) == "403", "read scope is 403");
|
||||
process.assert(err_code(post("/api/upload", reader, upload_data), "reader body", xx arena) == "auth.missing_scope",
|
||||
"read scope names auth.missing_scope");
|
||||
process.assert(post_code("/api/upload", revoked, upload_data) == "403", "revoked token is 403");
|
||||
process.assert(err_code(post("/api/upload", revoked, upload_data), "revoked body", xx arena) == "auth.revoked",
|
||||
"revoked token names auth.revoked");
|
||||
print(" auth refusals: 401/403 with precise codes\n");
|
||||
|
||||
// ── upload ─────────────────────────────────────────────────────────
|
||||
fixture_bytes := fs.read_file(FIXTURE);
|
||||
process.assert(fixture_bytes != null, "fixture must be readable");
|
||||
d := hash.sha256_hex(fixture_bytes!);
|
||||
expect_sha := string.{ ptr = @d[0], len = 64 };
|
||||
|
||||
up := parse_body(post("/api/upload", publisher, upload_data), "upload", xx arena);
|
||||
process.assert(get_str(up, "status") == "stored", "upload json status stored");
|
||||
sha := get_str(up, "sha256");
|
||||
process.assert(sha == expect_sha, "upload sha256 equals an independent digest of the bytes");
|
||||
process.assert(fs.exists(path_join(STORE, concat("objects/", sha))), "object lands under objects/<sha>");
|
||||
|
||||
up2 := parse_body(post("/api/upload", publisher, upload_data), "re-upload", xx arena);
|
||||
process.assert(get_str(up2, "sha256") == sha, "re-upload answers the same key (dedup)");
|
||||
|
||||
process.assert(post_code("/api/upload", publisher, "") == "411", "bodyless POST is 411");
|
||||
print(" upload: content-addressed, dedup, 411 without a body\n");
|
||||
|
||||
// ── release publish over the uploaded object ───────────────────────
|
||||
rb := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)), "release", xx arena);
|
||||
process.assert(get_str(rb, "status") == "published", "release json status published");
|
||||
process.assert(get_str(get_obj(rb, "release"), "id") == "rel-acme-app-1.2.3", "release id");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "beta points at the new release");
|
||||
|
||||
det := parse_body(fetch("/api/apps/acme-app"), "detail", xx arena);
|
||||
process.assert(get_arr(det, "releases").len == 1, "GET reflects the POSTed release (per-request reload)");
|
||||
|
||||
process.assert(post_code("/api/apps/acme-app/releases", wrong_app, release_body("1.2.9", "beta", sha)) == "403",
|
||||
"token scoped to another app is 403");
|
||||
process.assert(err_code(post("/api/apps/acme-app/releases", wrong_app, release_body("1.2.9", "beta", sha)), "wrong-app body", xx arena) == "auth.app_forbidden",
|
||||
"wrong-app scope names auth.app_forbidden");
|
||||
|
||||
UNKNOWN :: "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
process.assert(post_code("/api/apps/acme-app/releases", publisher, release_body("9.9.9", "nightly", UNKNOWN)) == "404",
|
||||
"unknown object is 404");
|
||||
process.assert(err_code(post("/api/apps/acme-app/releases", publisher, release_body("9.9.9", "nightly", UNKNOWN)), "unknown-object body", xx arena) == "api.unknown_object",
|
||||
"unknown object names api.unknown_object");
|
||||
process.assert(channel_pointer("nightly", xx arena) == "", "aborted publish leaves no channel behind");
|
||||
|
||||
process.assert(post_code("/api/apps/acme-app/releases", publisher, release_body("1.2.3", "beta", sha)) == "409",
|
||||
"duplicate release id is 409");
|
||||
print(" release: publish ok, scope enforced, aborts leave no trace\n");
|
||||
|
||||
// ── channel ops mirror the CLI ─────────────────────────────────────
|
||||
p2 := parse_body(post("/api/apps/acme-app/releases", publisher, release_body("1.2.4", "beta", sha)), "release B", xx arena);
|
||||
process.assert(get_str(p2, "status") == "published", "second release published");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "beta -> 1.2.4");
|
||||
|
||||
rbk := parse_body(post("/api/apps/acme-app/channels/beta/rollback", publisher, "-d ''"), "rollback", xx arena);
|
||||
process.assert(get_str(rbk, "status") == "rolled_back", "rollback json status");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.3", "rollback moved beta back");
|
||||
|
||||
pm := parse_body(post("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-acme-app-1.2.4\"}'"), "promote", xx arena);
|
||||
process.assert(get_str(pm, "status") == "promoted", "promote json status");
|
||||
process.assert(channel_pointer("beta", xx arena) == "rel-acme-app-1.2.4", "promote moved beta forward");
|
||||
|
||||
process.assert(post_code("/api/apps/acme-app/channels/beta/promote", publisher, "-d '{\"release_id\":\"rel-nope\"}'") == "404",
|
||||
"promoting an unknown release is 404");
|
||||
print(" channel ops: promote/rollback mirror the CLI\n");
|
||||
|
||||
// ── last-used stamping + method gate ───────────────────────────────
|
||||
process.assert(token_last_used("publisher", xx arena) > 0, "publisher token got last_used_at stamped");
|
||||
process.assert(token_last_used("reader", xx arena) == 0, "refused token is never stamped");
|
||||
|
||||
mc := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' -X DELETE ", BASE), "/healthz"));
|
||||
process.assert(mc != null and mc!.stdout == "405", "non-GET/POST method is 405");
|
||||
print(" last-used stamped for authed tokens only; 405 method gate\n");
|
||||
|
||||
// ── teardown ───────────────────────────────────────────────────────
|
||||
process.run(concat("kill ", pid));
|
||||
process.run(concat("rm -rf ", STORE));
|
||||
print("server_write: ALL CASES PASS\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -118,6 +118,12 @@ check_empty_request_channel :: () -> bool {
|
||||
return refusal(scoped_token(), "publish", "acme-app", "", 500) == "";
|
||||
}
|
||||
|
||||
// An empty REQUEST app passes the app gate likewise (an upload is
|
||||
// app-agnostic until a release references it).
|
||||
check_empty_request_app :: () -> bool {
|
||||
return refusal(scoped_token(), "publish", "", "", 500) == "";
|
||||
}
|
||||
|
||||
// An unscoped token (empty app_slug/channel) authorizes any app/channel.
|
||||
check_unscoped_matches_all :: () -> bool {
|
||||
t := scoped_token();
|
||||
@@ -276,6 +282,7 @@ main :: () -> i32 {
|
||||
failures += run_case("check: app scope mismatch refused", check_app_mismatch());
|
||||
failures += run_case("check: channel scope mismatch refused", check_channel_mismatch());
|
||||
failures += run_case("check: empty request channel passes", check_empty_request_channel());
|
||||
failures += run_case("check: empty request app passes", check_empty_request_app());
|
||||
failures += run_case("check: unscoped token matches all", check_unscoped_matches_all());
|
||||
failures += run_case("validate: accepts good tokens", check_validate_accepts());
|
||||
failures += run_case("validate: rejects each bad form", check_validate_rejects());
|
||||
|
||||
Reference in New Issue
Block a user