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)
|
err = cmdRun(ctx, log)
|
||||||
case "serve":
|
case "serve":
|
||||||
err = cmdServe(ctx, log)
|
err = cmdServe(ctx, log)
|
||||||
|
case "invite":
|
||||||
|
err = cmdInvite(ctx, os.Args[2:])
|
||||||
default:
|
default:
|
||||||
usage()
|
usage()
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
@@ -71,6 +73,7 @@ usage:
|
|||||||
tapir auth one-time: authorize YouTube and store a refresh token
|
tapir auth one-time: authorize YouTube and store a refresh token
|
||||||
tapir run detect new videos, summarize, deliver to your store
|
tapir run detect new videos, summarize, deliver to your store
|
||||||
tapir serve run the web UI (read summaries, record watch/skip/save)
|
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 list [-limit N] list stored summaries, recent first
|
||||||
tapir show <video-id> show one summary in full
|
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 is the listen address for `tapir serve` (the Stage-0 web UI).
|
||||||
HTTPAddr string
|
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
|
// 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
|
// 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).
|
// Dex-authenticated subject may sign in, then registers a tapir user (ADR-012).
|
||||||
@@ -92,6 +97,7 @@ const (
|
|||||||
defaultOAuthRedirectAddr = "localhost:8080"
|
defaultOAuthRedirectAddr = "localhost:8080"
|
||||||
defaultHTTPAddr = ":8080"
|
defaultHTTPAddr = ":8080"
|
||||||
defaultFetchBackoff = time.Hour
|
defaultFetchBackoff = time.Hour
|
||||||
|
defaultPublicURL = "https://tapir.d-ma.be"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the environment into a Config, applying defaults. It does not
|
// 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()),
|
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||||
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||||
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
||||||
|
PublicURL: envOr("TAPIR_PUBLIC_URL", defaultPublicURL),
|
||||||
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
||||||
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
||||||
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
||||||
|
|||||||
Reference in New Issue
Block a user