~bigbes/sr-ht-spec

64e4ac89d9d5529643c64e187dc2d6b277992363 — Eugene Blikh 25 days ago 3697671
feat(coreauth): owner user seed + authn→core-go AuthContext bridge (Phase 5a)

The compatibility shim that lets core-go's webhook engine run inside this
single-owner, agent-aware service — spec keeps authn as its real auth.

- db.EnsureUser + service.EnsureOwnerUser: seed and cache the owner's
  "user" row (the FK target core-go's user-scoped webhook model needs).
  Idempotent; run at startup.
- coreauth.Derive/Context: map authn.Principal → auth.AuthContext. Owner
  and agent both become AUTH_INTERNAL (not COOKIE) deliberately —
  INTERNAL bypasses core-go's @access scope checks AND is accepted by
  webhooks.NewAuthConfig (which refuses cookie auth), which is what lets
  the single owner create webhooks. The agent identity rides in the
  payload, not the auth context; webhook management stays owner-gated in
  the resolvers.

Factored as its own package so it lifts cleanly into a shared sr-ht-ext
module later (deferred). Build + tests green.
6 files changed, 203 insertions(+), 0 deletions(-)

A coreauth/coreauth.go
A coreauth/coreauth_test.go
A db/user.go
A db/user_test.go
M service/service.go
A service/user.go
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 }