refactor: remove Dex local-password invite provisioning (ADR-019)
Authentik owns invites now (infra ADR-0001). Delete adapters/dex, the /invite set-password UI, the tapir invite CLI, the InvitationStore/ DexPasswordCreator ports + App wiring, the invite Templ pages, and the invite Taskfile target. New users are invited via Authentik, log in via OIDC, and hit the existing /register gate. invitations table (mig 009) left in place (append-only; harmless). task check green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,61 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/config"
|
||||
)
|
||||
|
||||
// inviteTTL is how long a minted invite stays claimable. A week is generous for a
|
||||
// human to act on an emailed link without leaving a stale capability around.
|
||||
const inviteTTL = 7 * 24 * time.Hour
|
||||
|
||||
// inviter is the narrow store capability cmdInvite needs — minting an invitation.
|
||||
// Defined here (not store) so runInvite is testable with a fake, no Postgres.
|
||||
type inviter interface {
|
||||
CreateInvitation(ctx context.Context, email string, ttl time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// cmdInvite mints an invitation for an email and prints the claim URL. Host-side
|
||||
// only (no Dex session): the operator runs it, copies the link, and sends it.
|
||||
// Usage: tapir invite <email>.
|
||||
func cmdInvite(ctx context.Context, args []string) error {
|
||||
if len(args) < 1 || strings.TrimSpace(args[0]) == "" {
|
||||
return fmt.Errorf("usage: tapir invite <email>")
|
||||
}
|
||||
email := strings.TrimSpace(args[0])
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.DBDSN) == "" {
|
||||
return fmt.Errorf("missing required config: TAPIR_DB_DSN")
|
||||
}
|
||||
|
||||
st, err := store.New(ctx, cfg.DBDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
return runInvite(ctx, st, os.Stdout, cfg.PublicURL, email)
|
||||
}
|
||||
|
||||
// runInvite is the testable core: mint the token and print the absolute claim URL
|
||||
// to w. Pure of config/store construction so a fake inviter exercises it.
|
||||
func runInvite(ctx context.Context, inv inviter, w io.Writer, publicURL, email string) error {
|
||||
token, err := inv.CreateInvitation(ctx, email, inviteTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create invitation: %w", err)
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(publicURL), "/")
|
||||
_, err = fmt.Fprintf(w, "Invite URL (valid 7 days):\n%s/invite/%s\n", base, token)
|
||||
return err
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeInviter records the mint call and returns a canned token.
|
||||
type fakeInviter struct {
|
||||
token string
|
||||
err error
|
||||
gotEmail string
|
||||
gotTTL time.Duration
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (f *fakeInviter) CreateInvitation(_ context.Context, email string, ttl time.Duration) (string, error) {
|
||||
f.callCount++
|
||||
f.gotEmail, f.gotTTL = email, ttl
|
||||
return f.token, f.err
|
||||
}
|
||||
|
||||
func TestRunInvitePrintsURL(t *testing.T) {
|
||||
inv := &fakeInviter{token: "deadbeefcafe"}
|
||||
var out strings.Builder
|
||||
|
||||
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "new@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "new@example.com", inv.gotEmail)
|
||||
require.Equal(t, inviteTTL, inv.gotTTL)
|
||||
got := out.String()
|
||||
require.Contains(t, got, "https://tapir.d-ma.be/invite/deadbeefcafe")
|
||||
require.Contains(t, got, "valid 7 days")
|
||||
}
|
||||
|
||||
func TestRunInviteTrimsTrailingSlash(t *testing.T) {
|
||||
inv := &fakeInviter{token: "tok"}
|
||||
var out strings.Builder
|
||||
|
||||
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be/", "x@example.com")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, out.String(), "https://tapir.d-ma.be/invite/tok")
|
||||
require.NotContains(t, out.String(), "//invite")
|
||||
}
|
||||
|
||||
func TestRunInvitePropagatesError(t *testing.T) {
|
||||
inv := &fakeInviter{err: errors.New("db down")}
|
||||
var out strings.Builder
|
||||
|
||||
err := runInvite(context.Background(), inv, &out, "https://tapir.d-ma.be", "x@example.com")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, out.String())
|
||||
}
|
||||
+3
-17
@@ -22,7 +22,6 @@ import (
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/dex"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
|
||||
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
|
||||
@@ -55,8 +54,6 @@ func main() {
|
||||
err = cmdRun(ctx, log)
|
||||
case "serve":
|
||||
err = cmdServe(ctx, log)
|
||||
case "invite":
|
||||
err = cmdInvite(ctx, os.Args[2:])
|
||||
case "report":
|
||||
err = runReport(ctx, os.Args[2:])
|
||||
default:
|
||||
@@ -77,7 +74,6 @@ usage:
|
||||
tapir auth one-time: authorize YouTube and store a refresh token
|
||||
tapir run detect new videos, summarize, deliver to your store
|
||||
tapir serve run the web UI (read summaries, record watch/skip/save)
|
||||
tapir invite <email> mint an invitation link for a new user (host-side)
|
||||
tapir list [-limit N] list stored summaries, recent first
|
||||
tapir show <video-id> show one summary in full
|
||||
tapir report Stage-0 usage gate: per-user distinct active weeks
|
||||
@@ -197,19 +193,9 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
|
||||
secretStore := secrets.NewFileStore(cfg.SecretsFile)
|
||||
app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log}
|
||||
|
||||
// Email-invite onboarding (public /invite/{token}). The store validates and
|
||||
// consumes tokens; the Dex client creates the local-password account. In-cluster
|
||||
// the SA token mount is present and account creation works; off-cluster (dev) it
|
||||
// is nil and the submit handler degrades to a clear "deployed-only" message.
|
||||
app.Invitations = st
|
||||
if dexClient, err := dex.NewPasswordClient(); err == nil {
|
||||
app.Dex = dexClient
|
||||
log.Info("invite account creation enabled (in-cluster dex password client)")
|
||||
} else if errors.Is(err, dex.ErrNotInCluster) {
|
||||
log.Warn("invite account creation disabled: not in-cluster — /invite is deployed-only")
|
||||
} else {
|
||||
return fmt.Errorf("dex password client: %w", err)
|
||||
}
|
||||
// User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
|
||||
// the Dex local-password provisioning path was removed (ADR-019). An
|
||||
// authenticated subject with no Tapir user is routed to /register.
|
||||
|
||||
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
|
||||
// credentials are present; the refresh token persists through the SecretStore
|
||||
|
||||
Reference in New Issue
Block a user