A coreauth/coreauth.go => coreauth/coreauth.go +38 -0
@@ 0,0 1,38 @@
+// Package coreauth bridges spec.sr.ht's own principal model (authn.Principal)
+// to core-go's auth.AuthContext, which the core-go webhook engine requires in
+// context. spec keeps authn as its real authorization; this is a compatibility
+// shim, nothing more.
+//
+// The owner (and an agent, which acts for the owner) map to AUTH_INTERNAL, not
+// AUTH_COOKIE, for two reasons: INTERNAL bypasses core-go's @access scope
+// checks (AuthContext.Access short-circuits for INTERNAL), and core-go's
+// webhooks.NewAuthConfig REFUSES cookie auth outright but accepts INTERNAL — so
+// mapping the owner to INTERNAL is what lets the single owner create webhooks
+// at all. The agent identity that triggers an event is not represented here; it
+// rides in the webhook payload (the proposal), and webhook management is
+// owner-gated in the resolvers regardless.
+package coreauth
+
+import (
+ "context"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+)
+
+// Derive maps a spec principal to a core-go AuthContext. See the package doc
+// for why owner and agent both become AUTH_INTERNAL.
+func Derive(p authn.Principal, ownerUserID int) *auth.AuthContext {
+ switch {
+ case p.IsOwner() || p.IsAgent():
+ return &auth.AuthContext{AuthMethod: auth.AUTH_INTERNAL, UserID: ownerUserID, Username: p.Owner}
+ default:
+ return &auth.AuthContext{AuthMethod: auth.AUTH_ANON_INTERNAL}
+ }
+}
+
+// Context installs a derived AuthContext for principal p onto ctx.
+func Context(ctx context.Context, p authn.Principal, ownerUserID int) context.Context {
+ return auth.Context(ctx, Derive(p, ownerUserID))
+}
A coreauth/coreauth_test.go => coreauth/coreauth_test.go +78 -0
@@ 0,0 1,78 @@
+package coreauth
+
+import (
+ "context"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/auth"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/authn"
+)
+
+func TestDerive(t *testing.T) {
+ const ownerUserID = 42
+
+ tests := []struct {
+ name string
+ principal authn.Principal
+ wantMethod string
+ wantUserID int
+ wantUser string
+ }{
+ {
+ name: "owner",
+ principal: authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"},
+ wantMethod: auth.AUTH_INTERNAL,
+ wantUserID: ownerUserID,
+ wantUser: "bigbes",
+ },
+ {
+ name: "agent",
+ principal: authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer"},
+ wantMethod: auth.AUTH_INTERNAL,
+ wantUserID: ownerUserID,
+ wantUser: "bigbes",
+ },
+ {
+ name: "anonymous",
+ principal: authn.Anonymous(),
+ wantMethod: auth.AUTH_ANON_INTERNAL,
+ wantUserID: 0,
+ wantUser: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ac := Derive(tt.principal, ownerUserID)
+ if ac.AuthMethod != tt.wantMethod {
+ t.Errorf("AuthMethod = %q, want %q", ac.AuthMethod, tt.wantMethod)
+ }
+ if ac.UserID != tt.wantUserID {
+ t.Errorf("UserID = %d, want %d", ac.UserID, tt.wantUserID)
+ }
+ if ac.Username != tt.wantUser {
+ t.Errorf("Username = %q, want %q", ac.Username, tt.wantUser)
+ }
+ })
+ }
+}
+
+func TestContext(t *testing.T) {
+ const ownerUserID = 7
+ ctx := Context(context.Background(), authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}, ownerUserID)
+
+ ac := auth.ForContext(ctx)
+ if ac == nil {
+ t.Fatal("auth.ForContext returned nil; expected an AuthContext")
+ }
+ if ac.AuthMethod != auth.AUTH_INTERNAL {
+ t.Errorf("AuthMethod = %q, want %q", ac.AuthMethod, auth.AUTH_INTERNAL)
+ }
+ if ac.UserID != ownerUserID {
+ t.Errorf("UserID = %d, want %d", ac.UserID, ownerUserID)
+ }
+ if ac.Username != "bigbes" {
+ t.Errorf("Username = %q, want %q", ac.Username, "bigbes")
+ }
+}
A db/user.go => db/user.go +22 -0
@@ 0,0 1,22 @@
+package db
+
+import (
+ "context"
+ "fmt"
+)
+
+// EnsureUser upserts a user row by username and returns its id. spec.sr.ht is
+// single-owner; this seeds the one row core-go's user-scoped webhook model
+// references (user_id FK). Idempotent — the daemon calls it every startup.
+func (s *Store) EnsureUser(ctx context.Context, username string) (int, error) {
+ const q = `
+INSERT INTO "user" (created, updated, username, email, user_type)
+VALUES (NOW() at time zone 'utc', NOW() at time zone 'utc', $1, '', 'USER')
+ON CONFLICT (username) DO UPDATE SET updated = NOW() at time zone 'utc'
+RETURNING id`
+ var id int
+ if err := s.q.QueryRowContext(ctx, q, username).Scan(&id); err != nil {
+ return 0, fmt.Errorf("ensure user %q: %w", username, err)
+ }
+ return id, nil
+}
A db/user_test.go => db/user_test.go +38 -0
@@ 0,0 1,38 @@
+package db
+
+import (
+ "context"
+ "testing"
+)
+
+func TestEnsureUser(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ id, err := s.EnsureUser(ctx, "bigbes")
+ if err != nil {
+ t.Fatalf("ensure user (insert): %v", err)
+ }
+ if id == 0 {
+ t.Fatal("expected a non-zero user id")
+ }
+
+ // Idempotent: a second call for the same username returns the same id.
+ id2, err := s.EnsureUser(ctx, "bigbes")
+ if err != nil {
+ t.Fatalf("ensure user (upsert): %v", err)
+ }
+ if id2 != id {
+ t.Fatalf("ensure user id changed: got %d, want %d", id2, id)
+ }
+
+ // A different username gets its own row.
+ other, err := s.EnsureUser(ctx, "someone")
+ if err != nil {
+ t.Fatalf("ensure other user: %v", err)
+ }
+ if other == id {
+ t.Fatalf("distinct username reused id %d", id)
+ }
+}
M service/service.go => service/service.go +6 -0
@@ 218,6 218,12 @@ type Service struct {
tokens *TokenStore
resolver *authn.Resolver
+ // ownerUserID caches the id of the owner's "user" row, seeded by
+ // EnsureOwnerUser at startup. Zero until then. It is the user_id the
+ // core-go webhook engine's user-scoped subscriptions FK against and the
+ // UserID the coreauth bridge stamps on the owner's AuthContext.
+ ownerUserID int
+
// grace is how long a proposal row with no branch is left alone before the
// reconciler deletes it. See DefaultReconcileGrace.
grace time.Duration
A service/user.go => service/user.go +21 -0
@@ 0,0 1,21 @@
+package service
+
+import (
+ "context"
+ "fmt"
+)
+
+// EnsureOwnerUser seeds the owner's user row (username = the configured
+// owner-name) and caches its id for the webhook auth bridge. Call once at
+// startup.
+func (s *Service) EnsureOwnerUser(ctx context.Context) error {
+ id, err := s.store.EnsureUser(ctx, s.cfg.Instance.OwnerName)
+ if err != nil {
+ return fmt.Errorf("service: ensure owner user: %w", err)
+ }
+ s.ownerUserID = id
+ return nil
+}
+
+// OwnerUserID is the owner user row id, 0 until EnsureOwnerUser has run.
+func (s *Service) OwnerUserID() int { return s.ownerUserID }