M .beads/issues.jsonl => .beads/issues.jsonl +1 -0
@@ 1,5 1,6 @@
{"_type":"issue","id":"spec-zqb","title":"Phase 3: write plane — agents propose","description":"The agent half of the loop, and the service's whole premise: an agent proposes a document change, gets back a link, a human approves in a browser. Until this ships, agents can only read.","design":"Scope: proposals (branch proposals/\u003cid\u003e + Postgres row, state open-\u003emerged|rejected); the If-Match tree-splice merge with the four staleness cases and the ancestry check gitx flagged; the single agent token + mandatory provenance trailers; spec_propose / spec_comment over REST + MCP; every write response returns {proposal, url} so the agent can hand over a link. Prereqs found during Phases 1-2: (1) service.ListProposals(space, state) — graph proposal listing is stubbed and db only has ListProposalsByState; (2) IsAncestor(proposalHead, H) check before Merge so an already-merged proposal reports 'already merged' not a confusing 409; (3) auto-merge policy evaluation (.spec.yml AutoMerges) which the reconciler's 'approval=policy' inference also wants. Dispatch like Phases 1-2: proposal orchestration in service/ first and committed, then surface tools fan out. This is where the merge model and reconciler first run under real proposals rather than on paper.","status":"closed","priority":1,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:08:58Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T05:29:29Z","started_at":"2026-07-23T04:52:59Z","closed_at":"2026-07-23T05:29:29Z","close_reason":"Write plane shipped: service.Propose/Merge/Reject/ListProposals with provenance trailers, auto-merge policy, If-Match staleness + already-merged ancestry check; surfaces graph (Proposals port), mcpsrv spec_propose, and new api/ REST PUT — every write returns {proposal, url}. Validated end-to-end against Postgres. spec_comment deliberately deferred to Phase 5 (spec-by6), which gates the comment schema on the Phase 4 review UI; the approve/reject browser UI is Phase 4 (spec-3vz).","dependencies":[{"issue_id":"spec-zqb","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-07-23T07:09:24Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"spec-ejq","title":"spec.sr.ht — reviewable document storage for humans and agents","description":"A third custom Go service on the self-hosted SourceHut instance: agents propose documents, a human reviews and curates, agents read the approved text. One loop — bot produces, human curates, bots consume. Runs at https://spec.srht.bigb.es, deployed on phoebe as srht-spec-1. Code: git.sourcecraft.dev/bigbes/sr-ht-spec. Design: docs/DESIGN.md.","design":"DONE and deployed (Phases 0-2): core domain, gitx (bare repos + id-keyed tree-splice merge), db (Postgres schema + global ID registry), authn (unified-login cookie + agent tokens + provenance), the three receive hooks + hook RPC + daemon, service layer + reconciler, doc (warren vault/render on git objects), search (one global bleve index, per-line ru/en routing), projects (saved filter, not container), web read UI, mcpsrv read tools, graph read schema, prosediff (Phase 0 gate PASSED), specsrht space create/list, push-\u003ereindex. Live corpus: ~bigbes/rfcs holds SPEC-0001 and NOTE-0001. Verified end-to-end: validating push path, fail-closed reads, id-addressing, bilingual search, GraphQL. NOT DONE: the agent half of the loop (write plane), review UI, comments.","status":"open","priority":1,"issue_type":"epic","owner":"bigbes@gmail.com","created_at":"2026-07-23T04:08:44Z","created_by":"Eugene Blikh","updated_at":"2026-07-23T04:08:44Z","dependency_count":0,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"spec-ejq.3","title":"Web: owner-only /tokens page for minting and revoking agent tokens","description":"Agent tokens can only be issued on the host (db.Store, and now specsrht token create). That makes the remote agent write plane unusable from anywhere without SSH: to hand an agent a credential the owner must be at the machine. Add an owner-only /tokens page — list, mint (plaintext shown once), revoke — going through service/, so the CLI and the page share one implementation of the ACL and the mint.","acceptance_criteria":"service.IssueAgentToken/ListAgentTokens/RevokeAgentToken are owner-only and refuse an agent with ErrForbidden; GET /tokens lists tokens for the owner and 403s everyone else; POST mints and shows the plaintext exactly once; POST revoke stamps it; both writes carry the same-origin CSRF guard as approve/reject; specsrht token create goes through the same service methods","status":"open","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-08-05T03:41:50Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T03:41:50Z","dependencies":[{"issue_id":"spec-ejq.3","depends_on_id":"spec-ejq","type":"parent-child","created_at":"2026-08-05T06:41:49Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-ovo","title":"CLI: specsrht doc propose — open a proposal from files on the host","description":"The two agent write surfaces (REST PUT, mcpsrv spec_propose) are remote and need a bearer token. When the operator and the documents are already on the host, that token is ceremony: the process can open Postgres and the bare repos directly. Add 'specsrht doc propose ~owner/space \u003cfile\u003e...' calling service.Propose with a constructed agent principal, so the shared layer still owns If-Match, provenance, the branch cut and the auto-merge gate.","acceptance_criteria":"doc propose opens a proposal from local files and prints proposal id, branch, base and URL; --as overrides the in-space path; --proposal adds to an open proposal; --base defaults to the approved head; provenance (--agent/--session) is recorded like a remote agent's; flags parse before, after and between positionals","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-08-05T03:23:01Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T03:23:07Z","started_at":"2026-08-05T03:23:07Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-rsb","title":"CLI: specsrht token create|list|revoke","description":"db.Store has the whole agent-token lifecycle (CreateAgentToken, ListAgentTokens, RevokeAgentToken, GenerateToken/HashToken) but nothing calls it: no CLI subcommand and no web page. A freshly deployed instance therefore has no way to issue the credential the agent write plane (REST PUT and mcpsrv spec_propose) requires, short of hand-writing an INSERT with a sha256 hash. Add a 'token' admin subcommand next to 'space', in the same shape: load config, open the pool, run, exit.","acceptance_criteria":"specsrht token create \u003cname\u003e mints a token, stores only its hash, and prints the plaintext once; specsrht token list shows id/name/created/revoked; specsrht token revoke \u003cid\u003e stamps it revoked; usage errors are one clear line; unit tests cover argument parsing and row formatting","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Eugene Blikh","owner":"bigbes@gmail.com","created_at":"2026-08-05T03:14:01Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T03:14:05Z","started_at":"2026-08-05T03:14:05Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"spec-by6.3.5","title":"Port review UI to a line-numbered unified prose diff","description":"Replace the block-card review renderer with a real unified diff: line-numbered gutter, drag-select a line range, comment composer inline. Owner reviewed the card version against a live proposal and rejected it — labels ('ADDED PARAGRAPH') outweighed content on every row, every block carried identical chrome, and on a new file the whole page is green so the cards add noise and no signal. Split view was considered and dropped: prose lines are long and unified reads better for reflowed text. Approved prototype: https://claude.ai/code/artifact/a45e45cb-16b5-42ec-8108-84767ef9f465","design":"SELECTION IS BY LINE, ANCHORING IS BY BLOCK. Lines are what the cursor lands on; block hashes are what survive a reflow. The web layer maps a selected line range to its enclosing prosediff block and stores the existing core.CommentAnchor unchanged. service/, db/ and core/ do not move. The composer states what it will anchor to, so the indirection is visible rather than magic.\n\nTHE BLOCKING PROBLEM — word marks cannot be placed on a line for free.\nprosediff.Span is {Op, Text, Space} with no source offsets, and Tokenize\ndeliberately drops whitespace ('\\n' and ' ' both collapse to Space=true) —\nwhich is exactly what makes a rewrap invisible to the differ. So for a\nChangeModify block there is no stored answer to 'which line did this word\nchange on'.\n\nRecoverable, not free: the span script consumes the old token sequence in order\n(Equal+Delete) and the new one (Equal+Insert). Re-tokenize each source line of\nBlock.Lines, walk the script, and split a span where it crosses a line\nboundary. Needs a mapper of roughly 100 lines plus tests. Equal/Insert/Delete\nblocks need none of this — their lines map 1:1 and carry exact numbers.\n\nFALLBACK IF THE MAPPER IS NOT WANTED: render a modified block as a paired\nold/new region labelled by line RANGE rather than per line, keeping the word\nmarks. Honest and much smaller, but does not match the approved prototype.\n\nGUTTER DETAILS SETTLED IN REVIEW: one shared rail ground for both number\ntracks with a single hairline against the content (a border per cell drew a\ncage); one --row-lh shared by gutter and prose so numerals sit on the text\nbaseline rather than floating; 34px tracks; the add/delete tint starts at the\nsign column so the gutter never looks part of the change.\n\nHEADING PATH: no per-hunk breadcrumb. It restated headings visible a few rows\nabove, always so on a new file. Replaced by one sticky section readout that\nappears only once its heading has scrolled past. The path stays in the composer\nand in each thread's anchor note, where the heading is usually off screen.","acceptance_criteria":"A reviewer drags across line numbers, comments, and the thread stores an anchor identical to the one the block-card UI would have stored. Word-level marks still appear inside a modified paragraph. Unchanged lines render as context and collapse. An outdated comment still appears and is never attached to a neighbouring block.","status":"closed","priority":2,"issue_type":"feature","owner":"bigbes@gmail.com","created_at":"2026-07-25T11:40:01Z","created_by":"Eugene Blikh","updated_at":"2026-08-05T01:11:47Z","closed_at":"2026-08-05T01:11:47Z","close_reason":"Closed","dependencies":[{"issue_id":"spec-by6.3.5","depends_on_id":"spec-by6.3","type":"parent-child","created_at":"2026-07-25T14:40:01Z","created_by":"Eugene Blikh","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
M cmd/specsrht/token.go => cmd/specsrht/token.go +20 -17
@@ 11,7 11,8 @@ import (
"sourcecraft.dev/bigbes/sr-ht-core/config"
- "sourcecraft.dev/bigbes/sr-ht-spec/db"
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
)
const tokenUsage = "usage: specsrht token create <name> | specsrht token list | specsrht token revoke <id>"
@@ 30,10 31,13 @@ const tokenUsage = "usage: specsrht token create <name> | specsrht token list |
// which is exactly the shape of mistake that ends with an unusable credential
// and no way to tell why.
//
-// It talks to [db.Store] directly rather than going through service.New. Tokens
-// touch neither git nor the index, so pulling in the repo root, the hooks and
-// bleve to mint a row would only add ways for the command to fail on an
-// instance whose daemon is otherwise fine.
+// It goes through service/ rather than db/ directly, and constructs the owner
+// principal to do so. The owner-only rule on minting is the one that makes
+// revocation mean anything — an agent that could mint would survive having its
+// credential revoked — so it is spelled once, in service/, and this command is
+// held to it exactly as the /tokens page is. A process on this host could of
+// course write the row itself; the point is not to confine it but to keep one
+// implementation of what issuing a token *is*.
func runToken(args []string) error {
if len(args) == 0 {
return errors.New(tokenUsage)
@@ 50,7 54,11 @@ func runToken(args []string) error {
}
defer pool.Close()
- store := db.NewStore(pool)
+ svc, err := service.New(cfg, pool)
+ if err != nil {
+ return err
+ }
+ owner := authn.Principal{Kind: authn.KindOwner, Owner: cfg.Instance.OwnerName}
ctx := context.Background()
switch args[0] {
@@ 60,14 68,9 @@ func runToken(args []string) error {
}
name := args[1]
- // The plaintext exists only here: it is generated, hashed, stored as a
- // hash, and printed once. Nothing writes it to the log, because a token
- // in a log file is a token in a backup.
- token, err := db.GenerateToken()
- if err != nil {
- return err
- }
- row, err := store.CreateAgentToken(ctx, name, db.HashToken(token))
+ // The plaintext is returned by the mint and printed once. Nothing writes
+ // it to the log, because a token in a log file is a token in a backup.
+ token, row, err := svc.IssueAgentToken(ctx, owner, name)
if err != nil {
return err
}
@@ 78,7 81,7 @@ func runToken(args []string) error {
return nil
case "list":
- tokens, err := store.ListAgentTokens(ctx)
+ tokens, err := svc.ListAgentTokens(ctx, owner)
if err != nil {
return err
}
@@ 97,7 100,7 @@ func runToken(args []string) error {
if err != nil {
return err
}
- if err := store.RevokeAgentToken(ctx, id); err != nil {
+ if err := svc.RevokeAgentToken(ctx, owner, id); err != nil {
return err
}
fmt.Printf("revoked agent token %d\n", id)
@@ 112,7 115,7 @@ func runToken(args []string) error {
// The hash is deliberately not shown: it identifies nothing an operator acts
// on, and printing a column of it would only invite treating it as the
// credential.
-func formatTokenRow(t *db.AgentToken) string {
+func formatTokenRow(t service.AgentToken) string {
state := "active"
if !t.Active() {
state = "revoked " + t.Revoked.Format(time.RFC3339)
M cmd/specsrht/token_test.go => cmd/specsrht/token_test.go +6 -20
@@ 5,7 5,7 @@ import (
"testing"
"time"
- "sourcecraft.dev/bigbes/sr-ht-spec/db"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
)
func TestParseTokenIDRejectsWhatIsNotAnID(t *testing.T) {
@@ 40,34 40,20 @@ func TestFormatTokenRow(t *testing.T) {
created := time.Date(2026, 8, 5, 9, 30, 0, 0, time.UTC)
revoked := created.Add(24 * time.Hour)
- active := formatTokenRow(&db.AgentToken{ID: 1, Name: "claude", Created: created})
+ active := formatTokenRow(service.AgentToken{ID: 1, Name: "claude", Created: created})
if want := "1\tclaude\t2026-08-05T09:30:00Z\tactive"; active != want {
t.Errorf("active row = %q want %q", active, want)
}
- dead := formatTokenRow(&db.AgentToken{ID: 2, Name: "old", Created: created, Revoked: &revoked})
+ dead := formatTokenRow(service.AgentToken{ID: 2, Name: "old", Created: created, Revoked: &revoked})
if want := "2\told\t2026-08-05T09:30:00Z\trevoked 2026-08-06T09:30:00Z"; dead != want {
t.Errorf("revoked row = %q want %q", dead, want)
}
}
-// TestFormatTokenRowKeepsTheHashOut guards the one thing this listing must not
-// leak into an operator's terminal or scrollback: the stored hash is not a
-// credential and printing it invites treating it as one.
-func TestFormatTokenRowKeepsTheHashOut(t *testing.T) {
- row := formatTokenRow(&db.AgentToken{
- ID: 3,
- Name: "agent",
- Hash: db.HashToken("s3cret"),
- Created: time.Date(2026, 8, 5, 9, 30, 0, 0, time.UTC),
- })
- if strings.Contains(row, "[") || strings.ContainsAny(row, "%") {
- t.Errorf("the row appears to render the hash bytes: %q", row)
- }
- if strings.Contains(row, string(db.HashToken("s3cret"))) {
- t.Errorf("the row carries the stored hash: %q", row)
- }
-}
+// There is no test that the listing keeps the stored hash out: service.AgentToken
+// has no hash field, so the type is the guarantee and a test would only assert
+// that Go's struct literals work.
// TestRunTokenRejectsBadInvocationsBeforeTheDatabase guards the ordering that
// makes this command usable on a workstation: a usage error must be reported
M service/token.go => service/token.go +122 -0
@@ 4,11 4,19 @@ import (
"context"
"errors"
"fmt"
+ "strings"
+ "time"
+ "unicode/utf8"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
)
+// MaxTokenNameLen bounds a token's label. It is a human-readable note about
+// which agent holds the credential, rendered in a table and in `token list`;
+// the cap keeps a pasted paragraph from becoming a row nobody can read.
+const MaxTokenNameLen = 128
+
// AgentTokenLookup is the one db/ method the token adapter needs. It is an
// interface rather than a *db.Store so the error mapping below — the part that
// actually carries a contract — can be tested against a fake instead of a
@@ 73,3 81,117 @@ func (t *TokenStore) LookupAgentToken(ctx context.Context, hash []byte) (authn.A
Revoked: row.Revoked,
}, nil
}
+
+// AgentToken is one credential as the surfaces above this layer need it: no
+// hash, because nothing above service/ has any use for it, and no plaintext,
+// because it exists only in the response to the call that minted it.
+type AgentToken struct {
+ ID int
+ Name string
+ Created time.Time
+ Revoked *time.Time
+}
+
+// Active reports whether the token may still authenticate.
+func (t AgentToken) Active() bool { return t.Revoked == nil }
+
+// IssueAgentToken mints a credential for the agent write plane and returns the
+// plaintext exactly once, alongside the stored row.
+//
+// Owner-only, and that is the interesting half of the ACL: an agent holding a
+// valid token may not mint another. Were it allowed to, revoking a compromised
+// credential would not end the compromise — the holder would simply have issued
+// itself a second one — and "revoke the token" is the entire incident response
+// this design has.
+//
+// The plaintext is returned rather than stored. There is no second chance to
+// read it, which is what makes a leaked database dump unreplayable, so a caller
+// that drops the value has to mint a new token.
+func (s *Service) IssueAgentToken(ctx context.Context, p authn.Principal, name string) (string, AgentToken, error) {
+ if !p.IsOwner() {
+ return "", AgentToken{}, fmt.Errorf("%w: %s may not issue agent tokens; only the instance owner may",
+ ErrForbidden, p)
+ }
+ name, err := validateTokenName(name)
+ if err != nil {
+ return "", AgentToken{}, err
+ }
+
+ token, err := db.GenerateToken()
+ if err != nil {
+ return "", AgentToken{}, fmt.Errorf("service: %w", err)
+ }
+ row, err := s.store.CreateAgentToken(ctx, name, db.HashToken(token))
+ if err != nil {
+ return "", AgentToken{}, fmt.Errorf("service: issue agent token %q: %w", name, err)
+ }
+ return token, tokenView(row), nil
+}
+
+// ListAgentTokens returns every token, newest first, so the owner can see what
+// exists and pick one to revoke. Owner-only for the same reason minting is: the
+// list is the inventory of who can write, and an agent has no business reading
+// it.
+func (s *Service) ListAgentTokens(ctx context.Context, p authn.Principal) ([]AgentToken, error) {
+ if !p.IsOwner() {
+ return nil, fmt.Errorf("%w: %s may not list agent tokens; only the instance owner may", ErrForbidden, p)
+ }
+ rows, err := s.store.ListAgentTokens(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("service: list agent tokens: %w", err)
+ }
+ out := make([]AgentToken, 0, len(rows))
+ for _, row := range rows {
+ out = append(out, tokenView(row))
+ }
+ return out, nil
+}
+
+// RevokeAgentToken stamps a token revoked. Owner-only. Revoking is a stamp
+// rather than a delete so the audit trail keeps naming the token that made past
+// proposals; re-revoking is a no-op, because an operator killing a credential
+// twice is not an error worth failing.
+func (s *Service) RevokeAgentToken(ctx context.Context, p authn.Principal, id int) error {
+ if !p.IsOwner() {
+ return fmt.Errorf("%w: %s may not revoke agent tokens; only the instance owner may", ErrForbidden, p)
+ }
+ if id <= 0 {
+ return fmt.Errorf("%w: %d is not an agent token id", ErrInvalid, id)
+ }
+ if err := s.store.RevokeAgentToken(ctx, id); err != nil {
+ if errors.Is(err, db.ErrNotFound) {
+ return fmt.Errorf("%w: agent token %d", ErrNotFound, id)
+ }
+ return fmt.Errorf("service: revoke agent token %d: %w", id, err)
+ }
+ return nil
+}
+
+// validateTokenName normalizes and checks a token label. The rules are the
+// weakest ones that keep the listing readable and unambiguous: trimmed,
+// non-empty, valid UTF-8, no control characters, and bounded. A name grants
+// nothing, so nothing stricter would be buying anything.
+func validateTokenName(name string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("%w: a token needs a name saying which agent holds it", ErrInvalid)
+ }
+ if len(name) > MaxTokenNameLen {
+ return "", fmt.Errorf("%w: token name is %d bytes, over the %d-byte limit",
+ ErrInvalid, len(name), MaxTokenNameLen)
+ }
+ if !utf8.ValidString(name) {
+ return "", fmt.Errorf("%w: token name is not valid UTF-8", ErrInvalid)
+ }
+ for _, r := range name {
+ if r < 0x20 || r == 0x7f {
+ return "", fmt.Errorf("%w: token name contains a control character %U", ErrInvalid, r)
+ }
+ }
+ return name, nil
+}
+
+// tokenView maps a stored token onto the surface shape, dropping the hash.
+func tokenView(row *db.AgentToken) AgentToken {
+ return AgentToken{ID: row.ID, Name: row.Name, Created: row.Created, Revoked: row.Revoked}
+}
A service/token_acl_test.go => service/token_acl_test.go +107 -0
@@ 0,0 1,107 @@
+package service
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+)
+
+// The three token calls are owner-only, and these tests run against a database
+// that cannot be reached: a refusal that needs a query is a refusal that would
+// have leaked the inventory, or minted the row, before deciding.
+
+func TestIssueAgentTokenIsOwnerOnly(t *testing.T) {
+ svc, _ := newService(t)
+
+ for name, p := range map[string]authn.Principal{
+ "an agent": {Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude", Session: "s1"},
+ "anonymous": authn.Anonymous(),
+ "a zero principal": {},
+ } {
+ token, row, err := svc.IssueAgentToken(t.Context(), p, "another")
+ if !errors.Is(err, ErrForbidden) {
+ t.Errorf("%s was not refused with ErrForbidden: %v", name, err)
+ }
+ if token != "" || row.ID != 0 {
+ t.Errorf("%s got a token back: %q %+v", name, token, row)
+ }
+ }
+}
+
+// TestIssueAgentTokenRefusesAnAgentBeforeMinting is the property revocation
+// depends on: an agent that could mint would survive having its own credential
+// revoked, so "revoke the token" would stop being incident response.
+func TestIssueAgentTokenRefusesAnAgentBeforeMinting(t *testing.T) {
+ svc, _ := newService(t)
+ agent := authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude", Session: "s1"}
+
+ _, _, err := svc.IssueAgentToken(t.Context(), agent, "self-issued")
+ if !errors.Is(err, ErrForbidden) {
+ t.Fatalf("an agent minting a token was not refused: %v", err)
+ }
+ if !strings.Contains(err.Error(), "only the instance owner") {
+ t.Errorf("the refusal does not say who may: %v", err)
+ }
+}
+
+func TestListAndRevokeAreOwnerOnly(t *testing.T) {
+ svc, _ := newService(t)
+ agent := authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude", Session: "s1"}
+
+ if _, err := svc.ListAgentTokens(t.Context(), agent); !errors.Is(err, ErrForbidden) {
+ t.Errorf("an agent listing tokens was not refused: %v", err)
+ }
+ if err := svc.RevokeAgentToken(t.Context(), agent, 1); !errors.Is(err, ErrForbidden) {
+ t.Errorf("an agent revoking a token was not refused: %v", err)
+ }
+}
+
+// TestRevokeAgentTokenChecksTheIDBeforeTheDatabase keeps a mistyped id from
+// becoming an UPDATE that matches nothing and reports "not found", which reads
+// like the token is already gone.
+func TestRevokeAgentTokenChecksTheIDBeforeTheDatabase(t *testing.T) {
+ svc, _ := newService(t)
+ owner := authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}
+
+ for _, id := range []int{0, -1} {
+ if err := svc.RevokeAgentToken(t.Context(), owner, id); !errors.Is(err, ErrInvalid) {
+ t.Errorf("revoking id %d was not refused as invalid: %v", id, err)
+ }
+ }
+}
+
+func TestValidateTokenName(t *testing.T) {
+ got, err := validateTokenName(" claude-code ")
+ if err != nil {
+ t.Fatalf("validateTokenName: %v", err)
+ }
+ if got != "claude-code" {
+ t.Errorf("name = %q want it trimmed", got)
+ }
+
+ for name, in := range map[string]string{
+ "empty": "",
+ "only whitespace": " ",
+ "a control character": "claude\x00code",
+ "a newline": "claude\ncode",
+ "longer than the caps": strings.Repeat("x", MaxTokenNameLen+1),
+ } {
+ if _, err := validateTokenName(in); !errors.Is(err, ErrInvalid) {
+ t.Errorf("%s was accepted as a token name", name)
+ }
+ }
+}
+
+// TestIssueAgentTokenValidatesTheNameBeforeMinting keeps a rejected name from
+// consuming entropy and, more importantly, from leaving a row whose label the
+// owner cannot read in the listing.
+func TestIssueAgentTokenValidatesTheNameBeforeMinting(t *testing.T) {
+ svc, _ := newService(t)
+ owner := authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}
+
+ if _, _, err := svc.IssueAgentToken(t.Context(), owner, " "); !errors.Is(err, ErrInvalid) {
+ t.Errorf("a blank token name was accepted: %v", err)
+ }
+}
M web/reader.go => web/reader.go +23 -0
@@ 83,6 83,17 @@ type Reader interface {
ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error)
ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error
+ // ListTokens, IssueToken and RevokeToken are the agent-credential surface
+ // behind /tokens. Like the comment writes they carry the principal
+ // explicitly, because the authority is not the read ACL: all three are
+ // owner-only, and an agent — authenticated though it is — may not read the
+ // inventory of who can write, let alone mint itself a second credential.
+ // IssueToken returns the plaintext exactly once; it is not stored and no
+ // later call can produce it again.
+ ListTokens(ctx context.Context, p authn.Principal) ([]service.AgentToken, error)
+ IssueToken(ctx context.Context, p authn.Principal, name string) (string, service.AgentToken, error)
+ RevokeToken(ctx context.Context, p authn.Principal, id int) error
+
// Inbox is every open proposal on the instance, newest first — the review
// queue. Digest is the recently policy-merged proposals, the firehose a human
// sees after the fact.
@@ 181,6 192,18 @@ func (r serviceReader) ResolveThread(ctx context.Context, p authn.Principal, thr
return r.svc.ResolveThread(ctx, p, threadID, resolved)
}
+func (r serviceReader) ListTokens(ctx context.Context, p authn.Principal) ([]service.AgentToken, error) {
+ return r.svc.ListAgentTokens(ctx, p)
+}
+
+func (r serviceReader) IssueToken(ctx context.Context, p authn.Principal, name string) (string, service.AgentToken, error) {
+ return r.svc.IssueAgentToken(ctx, p, name)
+}
+
+func (r serviceReader) RevokeToken(ctx context.Context, p authn.Principal, id int) error {
+ return r.svc.RevokeAgentToken(ctx, p, id)
+}
+
func (r serviceReader) Inbox(ctx context.Context) ([]service.Proposal, error) {
return r.svc.InboxProposals(ctx)
}
M web/router.go => web/router.go +6 -0
@@ 43,6 43,12 @@ func (s *Server) Register(r chi.Router) {
r.Get("/inbox", s.handleInbox)
r.Post("/inbox/seen", s.handleInboxSeen)
+ // The token routes are owner-only and live at the root, not under a space:
+ // there is one agent credential for the instance, not one per space.
+ r.Get("/tokens", s.handleTokens)
+ r.Post("/tokens", s.handleTokenIssue)
+ r.Post("/tokens/{id}/revoke", s.handleTokenRevoke)
+
// The proposal routes are registered before the document wildcard. chi gives
// the static "p" segment priority over the "*" catch-all regardless, but
// keeping them adjacent makes the "/p/ is the proposal namespace" decision
M web/server.go => web/server.go +5 -0
@@ 24,6 24,11 @@
// serving drafts by default would poison every downstream agent context with
// unreviewed text.
//
+// Two pages sit outside the space grammar: /inbox is the review queue, and
+// /tokens is the owner-only agent-credential page — mint (shown once), list,
+// revoke. There is one credential for the instance rather than one per space,
+// so it has no owner or space in its address.
+//
// # Who may read
//
// The instance has one human. There are no visibility levels, so the read ACL
M web/templates.go => web/templates.go +1 -1
@@ 46,7 46,7 @@ var funcMap = template.FuncMap{
}
// pageNames are the content templates; each is parsed with layout.html.
-var pageNames = []string{"index", "space", "document", "search", "error", "proposal", "inbox"}
+var pageNames = []string{"index", "space", "document", "search", "error", "proposal", "inbox", "tokens"}
// pages maps a page name to its parsed template set (layout + partials + that
// page). threads.html is parsed into every set rather than only into the
M web/templates/index.html => web/templates/index.html +4 -1
@@ 13,7 13,10 @@
{{if .Data.LoggedIn}}
<div class="row">
<div class="col-md-12">
- <p><a href="/inbox">Review queue</a> — proposals waiting on you.</p>
+ <p>
+ <a href="/inbox">Review queue</a> — proposals waiting on you.
+ <a href="/tokens" class="ml-2">Agent tokens</a> — credentials for the write plane.
+ </p>
<form method="GET" action="/search" class="form-inline">
<div class="form-group">
<label class="sr-only" for="q">Query</label>
A web/templates/tokens.html => web/templates/tokens.html +68 -0
@@ 0,0 1,68 @@
+{{define "content"}}
+<div class="row">
+ <div class="col-md-12">
+ <h2>Agent tokens</h2>
+ <p>
+ The credential an agent presents as
+ <code>Authorization: Bearer <token></code> to propose over the REST
+ and MCP write planes. Only the stored hash lives in the database, so a
+ token is shown exactly once — when it is minted.
+ </p>
+
+ {{if .Data.Minted}}
+ <div class="alert alert-success">
+ <p>
+ <strong>{{.Data.MintedName}}</strong> minted. Copy it now — this is
+ the only time it is shown.
+ </p>
+ <pre class="mb-0"><code>{{.Data.Minted}}</code></pre>
+ </div>
+ {{end}}
+
+ <form method="POST" action="/tokens" class="form-inline mb-4">
+ <div class="form-group">
+ <label class="sr-only" for="name">Name</label>
+ <input class="form-control" type="text" id="name" name="name"
+ placeholder="which agent holds it" autocomplete="off" required>
+ </div>
+ <button class="btn btn-primary" type="submit">Mint token</button>
+ </form>
+
+ {{if .Data.Tokens}}
+ <table class="table">
+ <thead>
+ <tr><th>Name</th><th>Created</th><th>State</th><th></th></tr>
+ </thead>
+ <tbody>
+ {{range .Data.Tokens}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td class="text-muted"><code>{{.Created}}</code></td>
+ <td>
+ {{if .Active}}
+ <span class="badge badge-success">active</span>
+ {{else}}
+ <span class="badge badge-danger">revoked</span>
+ <small class="text-muted"><code>{{.Revoked}}</code></small>
+ {{end}}
+ </td>
+ <td>
+ {{if .Active}}
+ <form method="POST" action="/tokens/{{.ID}}/revoke">
+ <button type="submit" class="btn btn-sm btn-outline-danger">Revoke</button>
+ </form>
+ {{end}}
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{else}}
+ <p class="text-muted">
+ No tokens yet. Without one the agent write plane refuses every caller as
+ anonymous, so nothing can propose.
+ </p>
+ {{end}}
+ </div>
+</div>
+{{end}}
A web/tokens.go => web/tokens.go +147 -0
@@ 0,0 1,147 @@
+package web
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// tokensData is the agent-token page: the inventory, and — on the one response
+// that follows a mint — the plaintext that will never be shown again.
+//
+// Minted is empty on every other render. It is a field on the page rather than
+// a flash cookie or a redirect parameter on purpose: a secret in a URL lands in
+// the browser history and in any proxy log between here and the operator, and a
+// secret in a cookie is a secret stored twice.
+type tokensData struct {
+ Tokens []tokenRow
+ Minted string
+ MintedName string
+}
+
+// tokenRow is one credential as a listing line. There is no hash column:
+// service.AgentToken carries no hash, which is the layer saying that nothing
+// above it has any business with the stored value.
+type tokenRow struct {
+ ID int
+ Name string
+ Created string
+ Revoked string // empty while the token is still active
+ Active bool
+}
+
+// handleTokens renders the token inventory.
+//
+// Owner-only, and the refusal is a 403 rather than the read plane's login
+// redirect: an agent reaching this page is authenticated already, so redirecting
+// it to log in would answer a question it did not ask. An anonymous browser is
+// sent to meta the usual way, because for a human the answer really is "log in".
+func (s *Server) handleTokens(w http.ResponseWriter, r *http.Request) {
+ p := authn.PrincipalFromContext(r.Context())
+ if p.IsAnonymous() {
+ s.loginRedirect(w, r)
+ return
+ }
+ if !p.IsOwner() {
+ s.renderError(w, r, http.StatusForbidden, "only the instance owner may manage agent tokens")
+ return
+ }
+ s.renderTokens(w, r, p, tokensData{})
+}
+
+// handleTokenIssue mints a token and renders the page with the plaintext shown
+// once.
+//
+// This is the one write in this package that does not end in a
+// post-redirect-get. A redirect would either drop the secret — the whole point
+// of the request — or carry it in a URL. So the POST renders, and the form's
+// name field is what a reload would re-submit: minting a second token by
+// accident is recoverable in one click on this very page, whereas a lost token
+// is not recoverable at all.
+func (s *Server) handleTokenIssue(w http.ResponseWriter, r *http.Request) {
+ p, ok := s.tokenWriter(w, r)
+ if !ok {
+ return
+ }
+ token, row, err := s.reader.IssueToken(r.Context(), p, r.FormValue("name"))
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ s.renderTokens(w, r, p, tokensData{Minted: token, MintedName: row.Name})
+}
+
+// handleTokenRevoke stamps a token revoked and redirects back to the listing.
+func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request) {
+ p, ok := s.tokenWriter(w, r)
+ if !ok {
+ return
+ }
+ id, err := strconv.Atoi(chi.URLParam(r, "id"))
+ if err != nil || id <= 0 {
+ s.renderError(w, r, http.StatusNotFound, "no such agent token")
+ return
+ }
+ if err := s.reader.RevokeToken(r.Context(), p, id); err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ http.Redirect(w, r, "/tokens", http.StatusSeeOther)
+}
+
+// tokenWriter is the shared gate on both token writes: owner-only, and the same
+// cross-site guard approve/reject use — the CSRF defense a form post needs when
+// the session cookie is meta's and this service cannot set its SameSite. It
+// answers the request itself when it refuses, so a caller only checks ok.
+func (s *Server) tokenWriter(w http.ResponseWriter, r *http.Request) (authn.Principal, bool) {
+ p := authn.PrincipalFromContext(r.Context())
+ if !p.IsOwner() {
+ s.renderError(w, r, http.StatusForbidden, "only the instance owner may manage agent tokens")
+ return authn.Principal{}, false
+ }
+ if !s.sameOrigin(r) {
+ s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
+ return authn.Principal{}, false
+ }
+ return p, true
+}
+
+// renderTokens reads the inventory and renders the page, carrying through
+// whatever the caller already has to show (a freshly minted token, or nothing).
+func (s *Server) renderTokens(w http.ResponseWriter, r *http.Request, p authn.Principal, data tokensData) {
+ tokens, err := s.reader.ListTokens(r.Context(), p)
+ if err != nil {
+ s.fail(w, r, err)
+ return
+ }
+ data.Tokens = tokenRows(tokens)
+
+ vd := s.chrome(r)
+ vd.Title = "Agent tokens"
+ vd.Data = data
+ s.render(w, http.StatusOK, "tokens", vd)
+}
+
+// tokenRows turns the service view onto listing lines, formatting the two
+// timestamps here so the template holds no date logic.
+func tokenRows(ts []service.AgentToken) []tokenRow {
+ rows := make([]tokenRow, 0, len(ts))
+ for _, t := range ts {
+ row := tokenRow{
+ ID: t.ID,
+ Name: t.Name,
+ Created: t.Created.UTC().Format(time.RFC3339),
+ Active: t.Active(),
+ }
+ if !row.Active {
+ row.Revoked = t.Revoked.UTC().Format(time.RFC3339)
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
A web/tokens_test.go => web/tokens_test.go +164 -0
@@ 0,0 1,164 @@
+package web
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/service"
+)
+
+// selfOrigin is this instance's origin as testServerWith configures it — the
+// value the cross-site guard accepts.
+const selfOrigin = "https://spec.example"
+
+func TestTokensPageListsWhatExists(t *testing.T) {
+ r := newFakeReader()
+ revoked := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ r.tokens = []service.AgentToken{
+ {ID: 2, Name: "claude-code", Created: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC)},
+ {ID: 1, Name: "old-runner", Created: time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC), Revoked: &revoked},
+ }
+ h, _, _ := testServerWith(t, r)
+
+ rec := get(t, h, "/tokens", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body:\n%s", rec.Code, rec.Body)
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"claude-code", "old-runner", "active", "revoked", "/tokens/2/revoke"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the page does not mention %q; body:\n%s", want, body)
+ }
+ }
+ // A revoked token has nothing left to revoke.
+ if strings.Contains(body, "/tokens/1/revoke") {
+ t.Errorf("the page offers to revoke an already-revoked token")
+ }
+}
+
+func TestTokensPageEmpty(t *testing.T) {
+ h, _, _ := testServerWith(t, newFakeReader())
+ rec := get(t, h, "/tokens", "bigbes")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "No tokens yet") {
+ t.Errorf("an empty inventory does not say so:\n%s", rec.Body)
+ }
+}
+
+// TestTokensPageIsOwnerOnly is the whole point of the page's ACL: an agent is
+// authenticated, and still may not see the inventory of who can write — nor be
+// bounced to a login page it has no way to use.
+func TestTokensPageIsOwnerOnly(t *testing.T) {
+ h, _, _ := testServerWith(t, newFakeReader())
+
+ if rec := getAgent(t, h, "/tokens", agentTk); rec.Code != http.StatusForbidden {
+ t.Errorf("an agent got %d for /tokens, want 403", rec.Code)
+ }
+ rec := get(t, h, "/tokens", "")
+ if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
+ t.Errorf("an anonymous browser got %d, want a login redirect", rec.Code)
+ }
+}
+
+// TestTokenMintShowsThePlaintextOnce proves the response to the mint carries the
+// value the service returned. It is shown here or nowhere: nothing stores it.
+func TestTokenMintShowsThePlaintextOnce(t *testing.T) {
+ r := newFakeReader()
+ h, _, _ := testServerWith(t, r)
+
+ rec := postForm(t, h, "/tokens", "bigbes", selfOrigin, url.Values{"name": {"claude-code"}})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body:\n%s", rec.Code, rec.Body)
+ }
+ if len(r.issued) != 1 {
+ t.Fatalf("the service minted %d tokens, want 1", len(r.issued))
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, r.issued[0]) {
+ t.Errorf("the minted token is not on the page; body:\n%s", body)
+ }
+ if !strings.Contains(body, "only time it is shown") {
+ t.Errorf("the page does not warn that the token is shown once")
+ }
+
+ // A later view of the page must not carry it: it exists only in the
+ // response to the request that minted it.
+ if again := get(t, h, "/tokens", "bigbes"); strings.Contains(again.Body.String(), r.issued[0]) {
+ t.Errorf("a later page view still shows the plaintext:\n%s", again.Body)
+ }
+}
+
+func TestTokenMintRefusesANamelessToken(t *testing.T) {
+ r := newFakeReader()
+ h, _, _ := testServerWith(t, r)
+
+ rec := postForm(t, h, "/tokens", "bigbes", selfOrigin, url.Values{"name": {" "}})
+ if rec.Code == http.StatusOK {
+ t.Errorf("a blank name was accepted: %d", rec.Code)
+ }
+ if len(r.issued) != 0 {
+ t.Errorf("a token was minted for a blank name: %v", r.issued)
+ }
+}
+
+func TestTokenWritesAreOwnerOnlyAndSameOrigin(t *testing.T) {
+ r := newFakeReader()
+ r.tokens = []service.AgentToken{{ID: 1, Name: "claude-code", Created: time.Now()}}
+ h, _, _ := testServerWith(t, r)
+
+ // An agent may not mint, even with a valid bearer token.
+ req := httptest.NewRequest(http.MethodPost, "/tokens", strings.NewReader("name=self"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Authorization", "Bearer "+agentTk)
+ req.Header.Set("Origin", selfOrigin)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("an agent minting got %d, want 403", rec.Code)
+ }
+
+ // The owner's own form post from somewhere else is a forgery.
+ if rec := postForm(t, h, "/tokens", "bigbes", "https://evil.example",
+ url.Values{"name": {"x"}}); rec.Code != http.StatusForbidden {
+ t.Errorf("a cross-origin mint got %d, want 403", rec.Code)
+ }
+ if rec := post(t, h, "/tokens/1/revoke", "bigbes", "https://evil.example"); rec.Code != http.StatusForbidden {
+ t.Errorf("a cross-origin revoke got %d, want 403", rec.Code)
+ }
+ if len(r.issued) != 0 {
+ t.Errorf("a refused request still minted: %v", r.issued)
+ }
+ if !r.tokens[0].Active() {
+ t.Errorf("a refused request still revoked the token")
+ }
+}
+
+func TestTokenRevokeStampsAndRedirects(t *testing.T) {
+ r := newFakeReader()
+ r.tokens = []service.AgentToken{{ID: 4, Name: "claude-code", Created: time.Now()}}
+ h, _, _ := testServerWith(t, r)
+
+ rec := post(t, h, "/tokens/4/revoke", "bigbes", selfOrigin)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body:\n%s", rec.Code, rec.Body)
+ }
+ if got := rec.Header().Get("Location"); got != "/tokens" {
+ t.Errorf("Location = %q want /tokens", got)
+ }
+ if r.tokens[0].Active() {
+ t.Errorf("the token is still active after a revoke")
+ }
+}
+
+func TestTokenRevokeRejectsAMalformedID(t *testing.T) {
+ h, _, _ := testServerWith(t, newFakeReader())
+ if rec := post(t, h, "/tokens/abc/revoke", "bigbes", selfOrigin); rec.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rec.Code)
+ }
+}
M web/web_test.go => web/web_test.go +45 -0
@@ 130,6 130,15 @@ type fakeReader struct {
// against a fake that accepts everything.
threads map[int][]*service.Thread
nextThread int
+
+ // tokens backs the /tokens page, with ids handed out by nextToken and
+ // minted plaintexts recorded in issued so a test can assert the page showed
+ // the value the mint returned. Like the thread methods, the three token
+ // methods restate service/'s owner-only rule so the fake refuses what the
+ // real service refuses.
+ tokens []service.AgentToken
+ nextToken int
+ issued []string
}
func newFakeReader() *fakeReader {
@@ 348,6 357,42 @@ func (f *fakeReader) thread(id int) *service.Thread {
return nil
}
+func (f *fakeReader) ListTokens(_ context.Context, p authn.Principal) ([]service.AgentToken, error) {
+ if !p.IsOwner() {
+ return nil, fmt.Errorf("%w: %s may not list agent tokens", service.ErrForbidden, p)
+ }
+ return append([]service.AgentToken(nil), f.tokens...), nil
+}
+
+func (f *fakeReader) IssueToken(_ context.Context, p authn.Principal, name string) (string, service.AgentToken, error) {
+ if !p.IsOwner() {
+ return "", service.AgentToken{}, fmt.Errorf("%w: %s may not issue agent tokens", service.ErrForbidden, p)
+ }
+ if strings.TrimSpace(name) == "" {
+ return "", service.AgentToken{}, fmt.Errorf("%w: a token needs a name", service.ErrInvalid)
+ }
+ f.nextToken++
+ tok := service.AgentToken{ID: f.nextToken, Name: strings.TrimSpace(name), Created: time.Now()}
+ f.tokens = append([]service.AgentToken{tok}, f.tokens...)
+ plaintext := fmt.Sprintf("plaintext-%d", tok.ID)
+ f.issued = append(f.issued, plaintext)
+ return plaintext, tok, nil
+}
+
+func (f *fakeReader) RevokeToken(_ context.Context, p authn.Principal, id int) error {
+ if !p.IsOwner() {
+ return fmt.Errorf("%w: %s may not revoke agent tokens", service.ErrForbidden, p)
+ }
+ for i := range f.tokens {
+ if f.tokens[i].ID == id {
+ now := time.Now()
+ f.tokens[i].Revoked = &now
+ return nil
+ }
+ }
+ return fmt.Errorf("%w: agent token %d", service.ErrNotFound, id)
+}
+
func (f *fakeReader) Inbox(_ context.Context) ([]service.Proposal, error) {
var out []service.Proposal
for _, p := range f.proposals {