P4.3: token security at rest + dist token CLI

Subplan 02 Slice 5: Token domain entity (scopes, app/channel scoping,
expiry, revocation, last-used) with boundary validation; secrets are
dist_<64 hex> drawn from arc4random_buf and only their SHA-256 is
persisted. check_token gates revocation > expiry > scope > app/channel;
mark_token_used stamps usage for the P4.4 server auth.

CLI: dist token create (raw secret shown exactly once; works on a fresh
store so CI tokens can predate the first publish), list (lifecycle
status, never the secret), revoke (unknown id and double-revoke are
distinct errors). Every mutation appends an audit event; tokens joins
db.json's persisted arrays, with an absent member loading as empty so
older db.json files stay readable.

make test 16/16 (new: token_check.sx unit suite, token_ops.sx pinned
CLI acceptance).
This commit is contained in:
agra
2026-06-12 10:52:08 +03:00
parent 6c19f1073f
commit d8b7a7bfb3
8 changed files with 1217 additions and 2 deletions

View File

@@ -7,7 +7,8 @@
// thing that fixes db.json's layout is the ORDER these functions `put`.
// Every entity is emitted in its struct's declaration-field order, and the
// top-level object is emitted as apps, releases, artifacts, channels,
// audit_events. Re-saving an unchanged model yields byte-identical output.
// tokens, audit_events. Re-saving an unchanged model yields byte-identical
// output.
//
// Enums serialize as their lowercase variant NAME (e.g. "android_apk",
// "percentage"), never an ordinal — readable and reorder-proof.
@@ -31,6 +32,7 @@ jsonp :: #import "modules/std/json.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.sx";
@@ -156,6 +158,21 @@ channel_to_json :: (c: Channel, alloc: Allocator) -> Value {
return .object(o);
}
token_to_json :: (t: Token, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(t.id), alloc);
o.put("name", .str(t.name), alloc);
o.put("token_hash", .str(t.token_hash), alloc);
o.put("scopes", .str(t.scopes), alloc);
o.put("app_slug", .str(t.app_slug), alloc);
o.put("channel", .str(t.channel), alloc);
o.put("created_at", .int_(t.created_at), alloc);
o.put("expires_at", .int_(t.expires_at), alloc);
o.put("last_used_at", .int_(t.last_used_at), alloc);
o.put("revoked_at", .int_(t.revoked_at), alloc);
return .object(o);
}
audit_to_json :: (e: AuditEvent, alloc: Allocator) -> Value {
o : Object = .{};
o.put("id", .str(e.id), alloc);
@@ -193,6 +210,11 @@ model_to_json :: (self: *Repo, alloc: Allocator) -> Value {
while i < self.channels.len { chans.add(channel_to_json(self.channels.items[i], alloc), alloc); i += 1; }
root.put("channels", .array(chans), alloc);
toks : Array = .{};
i = 0;
while i < self.tokens.len { toks.add(token_to_json(self.tokens.items[i], alloc), alloc); i += 1; }
root.put("tokens", .array(toks), alloc);
evs : Array = .{};
i = 0;
while i < self.audit_events.len { evs.add(audit_to_json(self.audit_events.items[i], alloc), alloc); i += 1; }
@@ -354,6 +376,21 @@ channel_from_json :: (o: Object, alloc: Allocator) -> (Channel, !LoadErr) {
return c;
}
token_from_json :: (o: Object, alloc: Allocator) -> (Token, !LoadErr) {
t : Token = .{};
t.id = try req_str(o, "id", alloc);
t.name = try req_str(o, "name", alloc);
t.token_hash = try req_str(o, "token_hash", alloc);
t.scopes = try req_str(o, "scopes", alloc);
t.app_slug = try req_str(o, "app_slug", alloc);
t.channel = try req_str(o, "channel", alloc);
t.created_at = try req_int(o, "created_at");
t.expires_at = try req_int(o, "expires_at");
t.last_used_at = try req_int(o, "last_used_at");
t.revoked_at = try req_int(o, "revoked_at");
return t;
}
audit_from_json :: (o: Object, alloc: Allocator) -> (AuditEvent, !LoadErr) {
e : AuditEvent = .{};
e.id = try req_str(o, "id", alloc);
@@ -407,6 +444,22 @@ load_into :: (repo: *Repo, bytes: string, scratch: Allocator) -> !LoadErr {
i += 1;
}
// OPTIONAL member: an absent `tokens` array loads as zero tokens, so
// db.json files from layouts without tokens stay readable. A PRESENT
// member is held to the same strictness as everything else.
tokq := db_obj_find(ro, "tokens");
if tokq != null {
tokv := tokq!;
if tokv != .array { raise error.BadShape; }
tok_arr := tokv.array;
i = 0;
while i < tok_arr.len {
o := try db_req_obj(tok_arr.items[i]);
repo.create_token(try token_from_json(o, oa));
i += 1;
}
}
ev_arr := try db_req_arr(ro, "audit_events");
i = 0;
while i < ev_arr.len {