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:
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user