Commit c40a116572

c40a1165727c27b0de99f6143dd1399e4c1895a4

parent: 298ac0b456

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:57 UTC

store: push tokens for receive-pack

Ref #282

Layout: unified · split

internal/store/migrations/0063_push_tokens.down.sql added +1
@@ -0,0 +1 @@
1DROP TABLE push_tokens;
internal/store/migrations/0063_push_tokens.up.sql added +10
@@ -0,0 +1,10 @@
1-- One row per receive-pack in flight. The hook names its push by the
2-- token; hookd answers only a live one. Only the SHA-256 is stored.
3CREATE TABLE push_tokens (
4 token_hash TEXT PRIMARY KEY,
5 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
6 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 scope TEXT NOT NULL,
8 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
9 expires_at TEXT NOT NULL
10);
internal/store/pushtokens.go added +52
@@ -0,0 +1,52 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "time"
7)
8
9// PushToken is the receive-pack a hook request speaks for.
10type PushToken struct {
11 RepoID int64
12 UserID int64
13 Scope string
14}
15
16// pushTokenTTL bounds a row whose receive-pack died before deleting it.
17const pushTokenTTL = 24 * time.Hour
18
19// CreatePushToken records a token for one receive-pack and returns it.
20func (s *Store) CreatePushToken(repoID, userID int64, scope string) (string, error) {
21 token, hash, err := NewToken()
22 if err != nil {
23 return "", err
24 }
25 _, err = s.DB.Exec(
26 "INSERT INTO push_tokens (token_hash, repo_id, user_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
27 hash, repoID, userID, scope, fmtTime(time.Now().Add(pushTokenTTL)))
28 if err != nil {
29 return "", err
30 }
31 return token, nil
32}
33
34// PushTokenByHash looks up a live token by its stored hash. ErrNotFound
35// covers both an absent row and one that has expired.
36func (s *Store) PushTokenByHash(hash string) (PushToken, error) {
37 var t PushToken
38 err := s.DB.QueryRow(
39 "SELECT repo_id, user_id, scope FROM push_tokens WHERE token_hash = ? AND expires_at > ?",
40 hash, fmtTime(time.Now())).Scan(&t.RepoID, &t.UserID, &t.Scope)
41 if errors.Is(err, sql.ErrNoRows) {
42 return PushToken{}, ErrNotFound
43 }
44 return t, err
45}
46
47// DeletePushToken removes a token by its raw value, once its receive-pack
48// is done with it.
49func (s *Store) DeletePushToken(token string) error {
50 _, err := s.DB.Exec("DELETE FROM push_tokens WHERE token_hash = ?", HashToken(token))
51 return err
52}
internal/store/pushtokens_test.go added +49
@@ -0,0 +1,49 @@
1package store
2
3import (
4 "errors"
5 "testing"
6 "time"
7)
8
9func TestPushTokens(t *testing.T) {
10 s := open(t)
11 if err := s.MigrateUp(); err != nil {
12 t.Fatal(err)
13 }
14 uid, err := s.CreateUser("alice", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 repoID, err := s.CreateRepo("user", uid, "app", "public")
19 if err != nil {
20 t.Fatal(err)
21 }
22 token, err := s.CreatePushToken(repoID, uid, "full")
23 if err != nil {
24 t.Fatal(err)
25 }
26 got, err := s.PushTokenByHash(HashToken(token))
27 if err != nil || got != (PushToken{RepoID: repoID, UserID: uid, Scope: "full"}) {
28 t.Fatalf("lookup = %+v, %v", got, err)
29 }
30 if err := s.DeletePushToken(token); err != nil {
31 t.Fatal(err)
32 }
33 if _, err := s.PushTokenByHash(HashToken(token)); !errors.Is(err, ErrNotFound) {
34 t.Fatalf("after delete: %v", err)
35 }
36
37 // A token whose receive-pack never cleaned up is swept after a day.
38 stale, err := s.CreatePushToken(repoID, uid, "full")
39 if err != nil {
40 t.Fatal(err)
41 }
42 swept, err := s.Sweep(Retention{}, time.Now().Add(25*time.Hour))
43 if err != nil || swept["push_tokens"] != 1 {
44 t.Fatalf("sweep = %v, %v", swept, err)
45 }
46 if _, err := s.PushTokenByHash(HashToken(stale)); !errors.Is(err, ErrNotFound) {
47 t.Fatalf("after sweep: %v", err)
48 }
49}
internal/store/retention.go +1
@@ -51,6 +51,7 @@ func (s *Store) Sweep(r Retention, now time.Time) (Swept, error) {
5151 {"web_sessions", "expires_at <= ?"},
5252 {"login_tokens", "expires_at <= ?"},
5353 {"email_tokens", "expires_at <= ?"},
54 {"push_tokens", "expires_at <= ?"},
5455 }
5556 for _, e := range expired {
5657 n, err := s.deleteBy("DELETE FROM "+e.table+" WHERE "+e.where, fmtTime(now))