gitmote

gitmote  ·  history  ·  branches

feat: CI stage 4 precursor — expiring, repo-scoped, read-only tokens

47553979de02e74aac76a31ca25b1b17d492c3da
Atanas Minev <atmin@protonmail.com> · 2026-07-03 19:14

diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
index 0f1b71e..b729754 100644
--- a/internal/auth/auth_test.go
+++ b/internal/auth/auth_test.go
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"path/filepath"
"testing"
+ "time"
"github.com/atmin/gitmote/internal/meta"
)
@@ -199,6 +200,107 @@ func TestBasicAuthCredentials(t *testing.T) {
}
}
+func TestTokenExpiry(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+ repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ u, _ := m.CreateUser(ctx, "atmin")
+ if err := m.SetACL(ctx, repo.ID, u.ID, meta.PermRead); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+
+ expiry := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
+ raw, err := g.MintScoped(ctx, u.ID, "ci", nil, false, expiry)
+ if err != nil {
+ t.Fatalf("MintScoped: %v", err)
+ }
+
+ // Before expiry: authorizes.
+ g.now = func() time.Time { return expiry.Add(-time.Minute) }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); err != nil {
+ t.Errorf("before expiry: Authorize = %v, want ok", err)
+ }
+
+ // At/after expiry: rejected as unauthorized.
+ g.now = func() time.Time { return expiry }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("at expiry: Authorize = %v, want ErrUnauthorized", err)
+ }
+ g.now = func() time.Time { return expiry.Add(time.Hour) }
+ if _, err := g.VerifyToken(ctx, raw); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("after expiry: VerifyToken = %v, want ErrUnauthorized", err)
+ }
+}
+
+func TestRepoScope(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+ repoA, _ := m.CreateRepo(ctx, "atmin/a", "main")
+ repoB, _ := m.CreateRepo(ctx, "atmin/b", "main")
+ u, _ := m.CreateUser(ctx, "atmin")
+ // The owner has read on BOTH repos.
+ if err := m.SetACL(ctx, repoA.ID, u.ID, meta.PermRead); err != nil {
+ t.Fatalf("SetACL a: %v", err)
+ }
+ if err := m.SetACL(ctx, repoB.ID, u.ID, meta.PermRead); err != nil {
+ t.Fatalf("SetACL b: %v", err)
+ }
+
+ // A token scoped to A authorizes A but is denied B despite the ACL on B.
+ raw, err := g.MintScoped(ctx, u.ID, "scoped-a", &repoA.ID, false, time.Time{})
+ if err != nil {
+ t.Fatalf("MintScoped: %v", err)
+ }
+ if _, err := g.Authorize(bearer(raw), "atmin/a", meta.PermRead); err != nil {
+ t.Errorf("scoped repo: Authorize = %v, want ok", err)
+ }
+ if _, err := g.Authorize(bearer(raw), "atmin/b", meta.PermRead); !errors.Is(err, ErrForbidden) {
+ t.Errorf("other repo: Authorize = %v, want ErrForbidden", err)
+ }
+}
+
+func TestReadOnlyToken(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+ repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ u, _ := m.CreateUser(ctx, "atmin")
+ // The owner can write, but the token is read-only.
+ if err := m.SetACL(ctx, repo.ID, u.ID, meta.PermWrite); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+
+ raw, err := g.MintScoped(ctx, u.ID, "ro", nil, true, time.Time{})
+ if err != nil {
+ t.Fatalf("MintScoped: %v", err)
+ }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermRead); err != nil {
+ t.Errorf("read with read-only token: Authorize = %v, want ok", err)
+ }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermWrite); !errors.Is(err, ErrForbidden) {
+ t.Errorf("write with read-only token: Authorize = %v, want ErrForbidden", err)
+ }
+}
+
+func TestUnscopedTokenUnaffected(t *testing.T) {
+ ctx := context.Background()
+ m := openMeta(t)
+ g := NewGuard(m)
+ repo, _ := m.CreateRepo(ctx, "atmin/repo", "main")
+ raw, userID := seedToken(t, m) // ordinary CreateToken PAT
+ if err := m.SetACL(ctx, repo.ID, userID, meta.PermWrite); err != nil {
+ t.Fatalf("SetACL: %v", err)
+ }
+
+ // No expiry, any owned repo, read + write per ACL — even far in the future.
+ g.now = func() time.Time { return time.Date(3000, 1, 1, 0, 0, 0, 0, time.UTC) }
+ if _, err := g.Authorize(bearer(raw), "atmin/repo", meta.PermWrite); err != nil {
+ t.Errorf("unscoped token write: Authorize = %v, want ok", err)
+ }
+}
+
func TestAllows(t *testing.T) {
cases := []struct {
granted, required meta.Perm
diff --git a/internal/auth/guard.go b/internal/auth/guard.go
index 46800bc..5733e9e 100644
--- a/internal/auth/guard.go
+++ b/internal/auth/guard.go
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"strings"
+ "time"
"github.com/atmin/gitmote/internal/meta"
)
@@ -13,11 +14,12 @@ import (
// Guard authenticates PATs and authorizes requests per-repo against the ACL
// table. It is the request guard the transport layer consults before serving.
type Guard struct {
- md *meta.Metadata
+ md *meta.Metadata
+ now func() time.Time // injectable clock for token expiry (time.Now in prod)
}
// NewGuard returns a Guard backed by the metadata layer.
-func NewGuard(md *meta.Metadata) *Guard { return &Guard{md: md} }
+func NewGuard(md *meta.Metadata) *Guard { return &Guard{md: md, now: time.Now} }
// Authorize verifies the request's token and checks the resulting user holds at
// least perm on repoName. The returned error selects the response:
@@ -31,7 +33,11 @@ func NewGuard(md *meta.Metadata) *Guard { return &Guard{md: md} }
func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*meta.User, error) {
ctx := r.Context()
- user, err := g.authenticate(ctx, r)
+ raw, ok := tokenFromRequest(r)
+ if !ok {
+ return nil, ErrUnauthorized
+ }
+ vt, err := g.verify(ctx, raw)
if err != nil {
return nil, err
}
@@ -41,7 +47,17 @@ func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*me
return nil, err // meta.ErrNotFound flows through to a 404
}
- granted, err := g.md.GetACL(ctx, repo.ID, user.ID)
+ // Token constraints gate before the ACL: a repo-scoped token reaches only its
+ // one repo, and a read-only token cannot perform a write/admin operation —
+ // even where the owner's ACL would otherwise allow it.
+ if vt.repoScope != nil && *vt.repoScope != repo.ID {
+ return nil, ErrForbidden
+ }
+ if vt.readOnly && permRank[perm] > permRank[meta.PermRead] {
+ return nil, ErrForbidden
+ }
+
+ granted, err := g.md.GetACL(ctx, repo.ID, vt.user.ID)
if errors.Is(err, meta.ErrNotFound) {
return nil, ErrForbidden // no ACL row means no access
}
@@ -51,18 +67,15 @@ func (g *Guard) Authorize(r *http.Request, repoName string, perm meta.Perm) (*me
if !allows(granted, perm) {
return nil, ErrForbidden
}
- return user, nil
+ return &vt.user, nil
}
-// authenticate resolves the request's PAT to its owner, or ErrUnauthorized. All
-// failures collapse to ErrUnauthorized so a client cannot distinguish "unknown
-// token" from "wrong secret".
-func (g *Guard) authenticate(ctx context.Context, r *http.Request) (*meta.User, error) {
- raw, ok := tokenFromRequest(r)
- if !ok {
- return nil, ErrUnauthorized
- }
- return g.VerifyToken(ctx, raw)
+// verifiedToken is a successfully authenticated token: its owner plus the
+// constraints Authorize enforces.
+type verifiedToken struct {
+ user meta.User
+ repoScope *int64
+ readOnly bool
}
// VerifyToken resolves a raw PAT string to its owner, or ErrUnauthorized. It is
@@ -70,6 +83,18 @@ func (g *Guard) authenticate(ctx context.Context, r *http.Request) (*meta.User,
// token from the Authorization header, and the web UI's login form calls it with
// a pasted token. All failures collapse to ErrUnauthorized.
func (g *Guard) VerifyToken(ctx context.Context, raw string) (*meta.User, error) {
+ vt, err := g.verify(ctx, raw)
+ if err != nil {
+ return nil, err
+ }
+ return &vt.user, nil
+}
+
+// verify authenticates a raw token and returns its owner and constraints. It
+// checks the verifier in constant time and rejects an expired token. All
+// failures collapse to ErrUnauthorized so a client cannot distinguish "unknown
+// token" from "wrong secret" from "expired".
+func (g *Guard) verify(ctx context.Context, raw string) (*verifiedToken, error) {
selector, secret, ok := split(raw)
if !ok {
return nil, ErrUnauthorized
@@ -84,10 +109,28 @@ func (g *Guard) VerifyToken(ctx context.Context, raw string) (*meta.User, error)
if subtle.ConstantTimeCompare([]byte(ta.Verifier), []byte(hashSecret(secret))) != 1 {
return nil, ErrUnauthorized
}
+ if ta.ExpiresAt != nil && !ta.ExpiresAt.After(g.now()) {
+ return nil, ErrUnauthorized
+ }
if err := g.md.TouchToken(ctx, ta.TokenID); err != nil {
return nil, err
}
- return &ta.User, nil
+ return &verifiedToken{user: ta.User, repoScope: ta.RepoScope, readOnly: ta.ReadOnly}, nil
+}
+
+// MintScoped creates and persists a token with optional constraints — repoScope
+// (nil = all the owner's repos), readOnly (deny push), expiresAt (zero = never) —
+// and returns the raw token string, shown exactly once. It is the CI clone
+// credential mint (task 21) and any expiring/scoped PAT.
+func (g *Guard) MintScoped(ctx context.Context, userID int64, label string, repoScope *int64, readOnly bool, expiresAt time.Time) (string, error) {
+ raw, selector, verifier, err := Mint()
+ if err != nil {
+ return "", err
+ }
+ if _, err := g.md.CreateScopedToken(ctx, userID, selector, verifier, label, repoScope, readOnly, expiresAt); err != nil {
+ return "", err
+ }
+ return raw, nil
}
// tokenFromRequest extracts a PAT from the Authorization header. It accepts a
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
index 71bbc3f..7f04ef4 100644
--- a/internal/meta/meta.go
+++ b/internal/meta/meta.go
@@ -75,7 +75,66 @@ func Open(ctx context.Context, cfg Config) (*Metadata, error) {
if err != nil {
return nil, err
}
- return &Metadata{db: db}, nil
+ m := &Metadata{db: db}
+ if err := m.migrate(ctx); err != nil {
+ _ = db.Close()
+ return nil, err
+ }
+ return m, nil
+}
+
+// migrate applies additive schema changes that a CREATE TABLE IF NOT EXISTS in
+// schema.sql cannot express against an already-populated table. s3lite has no
+// version table, so every migration must be idempotent: this checks
+// pragma_table_info and only ALTERs columns that are missing. On a fresh DB the
+// columns already exist (schema.sql), so this is a no-op. This is the
+// established pattern for adding a column to an existing table.
+func (m *Metadata) migrate(ctx context.Context) error {
+ return m.addColumns(ctx, "tokens", []columnDef{
+ {"expires_at", "TEXT"},
+ {"repo_scope", "INTEGER REFERENCES repos(id)"},
+ {"read_only", "INTEGER NOT NULL DEFAULT 0"},
+ })
+}
+
+// columnDef is one column to add: its name and the type/constraints DDL that
+// follows "ADD COLUMN <name>". An added NOT NULL column must carry a DEFAULT.
+type columnDef struct {
+ name string
+ ddl string
+}
+
+// addColumns adds each column absent from table. table and the column defs are
+// internal constants (never user input), so the string-built DDL is safe.
+func (m *Metadata) addColumns(ctx context.Context, table string, cols []columnDef) error {
+ have := make(map[string]bool)
+ rows, err := m.db.QueryContext(ctx, `SELECT name FROM pragma_table_info(?)`, table)
+ if err != nil {
+ return err
+ }
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ have[name] = true
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return err
+ }
+ _ = rows.Close()
+
+ for _, c := range cols {
+ if have[c.name] {
+ continue
+ }
+ if _, err := m.db.ExecContext(ctx, `ALTER TABLE `+table+` ADD COLUMN `+c.name+` `+c.ddl); err != nil {
+ return err
+ }
+ }
+ return nil
}
// Close durably flushes pending replication and closes the database; the flush
diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go
index 35a82bd..a6e45bb 100644
--- a/internal/meta/meta_test.go
+++ b/internal/meta/meta_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"path/filepath"
"testing"
+ "time"
)
// open returns a fresh, local-only Metadata at a temp path (no replication).
@@ -61,6 +62,62 @@ func TestMigrationsIdempotentAndFreshSchema(t *testing.T) {
}
}
+func TestScopedTokenColumnsMigrateAndPersist(t *testing.T) {
+ ctx := context.Background()
+ path := filepath.Join(t.TempDir(), "meta.sqlite3")
+
+ // A DB whose tokens table predates the scoped columns: create it by hand
+ // without them, plus a legacy row, then let Open's guarded migration add them.
+ m := openAt(t, path)
+ u, err := m.CreateUser(ctx, "atmin")
+ if err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if _, err := m.CreateToken(ctx, u.ID, "legacy-sel", "legacy-ver", "old"); err != nil {
+ t.Fatalf("CreateToken: %v", err)
+ }
+ if _, err := m.db.ExecContext(ctx, `ALTER TABLE tokens DROP COLUMN expires_at`); err != nil {
+ t.Fatalf("drop expires_at: %v", err)
+ }
+ if _, err := m.db.ExecContext(ctx, `ALTER TABLE tokens DROP COLUMN repo_scope`); err != nil {
+ t.Fatalf("drop repo_scope: %v", err)
+ }
+ if _, err := m.db.ExecContext(ctx, `ALTER TABLE tokens DROP COLUMN read_only`); err != nil {
+ t.Fatalf("drop read_only: %v", err)
+ }
+ if err := m.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+
+ // Re-open: migrate adds the columns. Open again: idempotent (no error).
+ m2 := openAt(t, path)
+ // The legacy row reads back with NULL/0 defaults.
+ ta, err := m2.TokenBySelector(ctx, "legacy-sel")
+ if err != nil {
+ t.Fatalf("TokenBySelector(legacy): %v", err)
+ }
+ if ta.ExpiresAt != nil || ta.RepoScope != nil || ta.ReadOnly {
+ t.Errorf("legacy token = %+v, want no expiry/scope, not read-only", ta)
+ }
+ if err := m2.Close(); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+ m3 := openAt(t, path) // second migrate is a no-op
+ r := seedRepo(t, m3, "atmin/repo")
+ exp := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
+ if _, err := m3.CreateScopedToken(ctx, u.ID, "sel2", "ver2", "scoped", &r.ID, true, exp); err != nil {
+ t.Fatalf("CreateScopedToken: %v", err)
+ }
+ ta2, err := m3.TokenBySelector(ctx, "sel2")
+ if err != nil {
+ t.Fatalf("TokenBySelector(sel2): %v", err)
+ }
+ if ta2.RepoScope == nil || *ta2.RepoScope != r.ID || !ta2.ReadOnly ||
+ ta2.ExpiresAt == nil || !ta2.ExpiresAt.Equal(exp) {
+ t.Errorf("scoped token = %+v, want scope %d, read-only, expiry %v", ta2, r.ID, exp)
+ }
+}
+
func TestRepoCRUD(t *testing.T) {
ctx := context.Background()
m := open(t)
diff --git a/internal/meta/schema.sql b/internal/meta/schema.sql
index 6bb05bf..314471d 100644
--- a/internal/meta/schema.sql
+++ b/internal/meta/schema.sql
@@ -32,8 +32,15 @@ CREATE TABLE IF NOT EXISTS tokens ( -- HTTP personal access tokens
verifier TEXT NOT NULL, -- SHA-256 of the token's secret half, never the raw token
label TEXT,
created_at TEXT NOT NULL,
- last_used TEXT
+ last_used TEXT,
+ expires_at TEXT, -- NULL = never expires
+ repo_scope INTEGER REFERENCES repos(id), -- NULL = all the owner's repos; else the only allowed repo
+ read_only INTEGER NOT NULL DEFAULT 0 -- 1 = clone/fetch only, no push
);
+-- NOTE: the three columns above are also added to existing DBs by the guarded
+-- migration in meta.go (Metadata.migrate) — s3lite has no version table, so a
+-- bare ALTER can't live here. That guarded ALTER is the pattern for future
+-- additive columns.
CREATE TABLE IF NOT EXISTS ssh_keys ( -- deferred transport, schema ready
id INTEGER PRIMARY KEY,
diff --git a/internal/meta/users.go b/internal/meta/users.go
index c591a80..ccc27e2 100644
--- a/internal/meta/users.go
+++ b/internal/meta/users.go
@@ -25,12 +25,16 @@ type Token struct {
LastUsed *time.Time
}
-// TokenAuth is a token's stored verifier plus its owner, returned to the auth
-// layer for constant-time verification (see internal/auth).
+// TokenAuth is a token's stored verifier plus its owner and constraints,
+// returned to the auth layer for constant-time verification and the scope/expiry
+// checks (see internal/auth). ExpiresAt/RepoScope are nil when unconstrained.
type TokenAuth struct {
- TokenID int64
- Verifier string
- User User
+ TokenID int64
+ Verifier string
+ User User
+ ExpiresAt *time.Time
+ RepoScope *int64
+ ReadOnly bool
}
// CreateUser inserts a regular (non-admin) user.
@@ -158,26 +162,64 @@ func (m *Metadata) CreateToken(ctx context.Context, userID int64, selector, veri
return &Token{ID: id, UserID: userID, Label: label, CreatedAt: parseTime(created)}, nil
}
-// TokenBySelector returns the verifier and owner of the token with the given
-// selector, or ErrNotFound. The auth layer compares the verifier in constant
-// time; this method deliberately does not touch last_used (see TouchToken).
+// CreateScopedToken stores a token carrying optional constraints: repoScope (nil
+// = all the owner's repos), readOnly (deny push), and expiresAt (zero = never).
+// It backs the CI clone credential and any expiring/scoped PAT.
+func (m *Metadata) CreateScopedToken(ctx context.Context, userID int64, selector, verifier, label string, repoScope *int64, readOnly bool, expiresAt time.Time) (*Token, error) {
+ created := now()
+ var exp *string
+ if !expiresAt.IsZero() {
+ s := expiresAt.UTC().Format(time.RFC3339Nano)
+ exp = &s
+ }
+ res, err := m.db.ExecContext(ctx,
+ `INSERT INTO tokens (user_id, selector, verifier, label, created_at, expires_at, repo_scope, read_only)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ userID, selector, verifier, label, created, exp, repoScope, boolToInt(readOnly))
+ if err != nil {
+ return nil, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return nil, err
+ }
+ return &Token{ID: id, UserID: userID, Label: label, CreatedAt: parseTime(created)}, nil
+}
+
+// TokenBySelector returns the verifier, owner, and constraints of the token with
+// the given selector, or ErrNotFound. The auth layer compares the verifier in
+// constant time and enforces the constraints; this method deliberately does not
+// touch last_used (see TouchToken).
func (m *Metadata) TokenBySelector(ctx context.Context, selector string) (*TokenAuth, error) {
var (
- ta TokenAuth
- isAdmin int64
- ts string
+ ta TokenAuth
+ isAdmin int64
+ ts string
+ expiresAt sql.NullString
+ repoScope sql.NullInt64
+ readOnly int64
)
err := m.db.QueryRowContext(ctx,
- `SELECT t.id, t.verifier, u.id, u.handle, u.is_admin, u.created_at
+ `SELECT t.id, t.verifier, t.expires_at, t.repo_scope, t.read_only,
+ u.id, u.handle, u.is_admin, u.created_at
FROM tokens t JOIN users u ON u.id = t.user_id
WHERE t.selector = ?`, selector).
- Scan(&ta.TokenID, &ta.Verifier, &ta.User.ID, &ta.User.Handle, &isAdmin, &ts)
+ Scan(&ta.TokenID, &ta.Verifier, &expiresAt, &repoScope, &readOnly,
+ &ta.User.ID, &ta.User.Handle, &isAdmin, &ts)
if isNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
+ if expiresAt.Valid {
+ exp := parseTime(expiresAt.String)
+ ta.ExpiresAt = &exp
+ }
+ if repoScope.Valid {
+ ta.RepoScope = &repoScope.Int64
+ }
+ ta.ReadOnly = readOnly != 0
ta.User.IsAdmin = isAdmin != 0
ta.User.CreatedAt = parseTime(ts)
return &ta, nil
diff --git a/tasks/20-ci-scoped-tokens.md b/tasks/20-ci-scoped-tokens.md
deleted file mode 100644
index 4618f7a..0000000
--- a/tasks/20-ci-scoped-tokens.md
+++ /dev/null
@@ -1,82 +0,0 @@
-# CI stage 4 (precursor) — scoped, expiring access tokens
-
-Part of the CI epic ([16-ci.md](16-ci.md), decision §Stage 0 #4). A small,
-self-contained extension to the token model, valuable on its own (expiring scoped
-tokens are generally useful), and the prerequisite for the runner's **per-run,
-read-only, repo-scoped clone credential** in stage 4 ([21](21-ci-runner.md)). No
-CI wiring here — just the auth capability + tests.
-
-## Spec
-
-A personal access token can carry three new, optional constraints, all enforced
-at auth time:
-
-- **expiry** — past `expires_at` → the token is rejected.
-- **repo scope** — a scoped token authorizes only its one repo; any other repo is
- denied even if the owner has ACLs there.
-- **read-only** — a read-only token is denied for write/admin operations
- (push), allowed for read (clone/fetch).
-
-Existing unscoped PATs are unaffected: absent = no expiry, all the owner's repos,
-not read-only.
-
-## Current
-
-- The `tokens` table ([schema.sql](../internal/meta/schema.sql#L28)) has
- `id, user_id, selector, verifier, label, created_at, last_used` — **no** expiry
- or scope.
-- [`auth.Guard.VerifyToken`](../internal/auth/guard.go#L72) looks the row up by
- selector (`meta.TokenBySelector`), compares the verifier in constant time, and
- returns the owner; [`Authorize`](../internal/auth/guard.go#L31) then checks the
- ACL perm via `allows`. Neither consults expiry or scope.
-- `Mint()` ([auth/token.go](../internal/auth/token.go#L42)) makes the
- `selector.secret` pair; `meta.CreateToken` persists it.
-- The schema is applied on every `Open` and **must stay idempotent**, and s3lite
- has **no version table**. SQLite has no `ADD COLUMN IF NOT EXISTS`, so a bare
- `ALTER TABLE … ADD COLUMN` would fail on the second `Open` of an existing DB —
- this is the one fiddly part.
-
-## Change
-
-**1. Schema migration (idempotent, mind the existing prod DB).** Add nullable
-columns `expires_at TEXT`, `repo_scope INTEGER REFERENCES repos(id)`, and
-`read_only INTEGER NOT NULL DEFAULT 0` to `tokens`. Because the columns must be
-added to an already-populated table without a version table, do it as a small
-**guarded Go migration** in [meta.go](../internal/meta/meta.go): query
-`pragma_table_info('tokens')` and `ALTER TABLE tokens ADD COLUMN …` only for
-columns not already present. Fresh DBs can get them straight in the `CREATE TABLE`
-for clarity; the guarded ALTER covers the existing prod DB. (Document this as the
-established pattern for future additive columns.)
-
-**2. `meta`** — `TokenBySelector` returns the new fields on the token record; add
-`CreateScopedToken(ctx, userID, selector, verifier, label string, repoScope
-*int64, readOnly bool, expiresAt time.Time) (Token, error)`. `DeleteToken`
-(revoke) already exists.
-
-**3. `auth.Guard`** — inject a clock (`now func() time.Time`, default
-`time.Now`, override in tests):
-- `VerifyToken`: after the verifier check, reject with `ErrUnauthorized` when
- `expires_at` is set and in the past. Optionally bump `last_used`.
-- `Authorize`: if the token is `repo_scope`d and the requested `repoName`'s id ≠
- the scope → deny; if `read_only` and the required perm is write/admin → deny.
- This means `Authorize` must thread the token's constraints from verification
- into the perm decision (return them from a shared internal `authenticate` step
- rather than re-fetching).
-- Add `MintScoped(...)` helper (or expose via the guard) that mints + persists a
- scoped token and returns the raw string, for stage 4's dispatcher to call.
-
-## Verify
-
-- **auth unit tests** (golden + failure, cf. existing guard tests):
- - a not-yet-expired token authorizes; an **expired** one is rejected (inject a
- clock).
- - a token scoped to repo A authorizes A, is **denied** on repo B (even with an
- ACL on B).
- - a **read-only** token allows a read perm, is **denied** a write perm.
- - an ordinary unscoped PAT is unaffected (no expiry, any owned repo, read+write
- per ACL).
-- **migration idempotency:** open an existing DB (with a pre-populated `tokens`
- table lacking the columns) twice — no error, columns present, old rows read
- back with NULL/0 defaults. A fresh DB works too.
-- `gofmt`/`golangci-lint`/`go test ./...` clean; `make e2e-local` +
- `make e2e-restore` green (existing push/clone auth unchanged).
diff --git a/tasks/README.md b/tasks/README.md
index b673d0d..c1a99ff 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -17,8 +17,6 @@ index [16-ci.md](16-ci.md) (the *why* is in
[../docs/evolution/ci-runner.md](../docs/evolution/ci-runner.md)). Split into
dependency-ordered stages, each implementable from fresh context:
-- [20-ci-scoped-tokens.md](20-ci-scoped-tokens.md) — expiring, repo-scoped,
- read-only tokens (precursor: the runner's clone credential).
- [21-ci-runner.md](21-ci-runner.md) — runner image + internal claim/complete API
(the execution stage; has an infra spike to resolve first).
- [22-ci-secrets.md](22-ci-secrets.md) — encrypted per-repo secrets store