feat(cli): tapir invite <email> + TAPIR_PUBLIC_URL config

Mints a single-use invitation and prints the absolute claim URL for the
operator to send. The URL base is TAPIR_PUBLIC_URL (default
https://tapir.d-ma.be). runInvite is factored from config/store wiring so
it's unit-tested against a fake inviter — no Postgres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:15:24 +02:00
co-authored by Claude Opus 4.8
parent e44485df16
commit 893886a60a
4 changed files with 130 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
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
}
+59
View File
@@ -0,0 +1,59 @@
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
View File
@@ -53,6 +53,8 @@ func main() {
err = cmdRun(ctx, log)
case "serve":
err = cmdServe(ctx, log)
case "invite":
err = cmdInvite(ctx, os.Args[2:])
default:
usage()
os.Exit(2)
@@ -71,6 +73,7 @@ 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
+7
View File
@@ -68,6 +68,11 @@ type Config struct {
// HTTPAddr is the listen address for `tapir serve` (the Stage-0 web UI).
HTTPAddr string
// PublicURL is the externally-reachable base URL of the deployed service,
// e.g. "https://tapir.d-ma.be". Used to build absolute links handed to humans
// (the `tapir invite` URL). No trailing slash is assumed — callers trim it.
PublicURL string
// Dex OIDC (web login, ADR-011/012). When OIDCIssuer is empty, `serve` falls
// back to the allow-all StubAuth (local dev). When set, serve uses Dex: any
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
@@ -92,6 +97,7 @@ const (
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
defaultFetchBackoff = time.Hour
defaultPublicURL = "https://tapir.d-ma.be"
)
// Load reads the environment into a Config, applying defaults. It does not
@@ -112,6 +118,7 @@ func Load() (Config, error) {
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),