// Command tmplatformctl is the admin surface (P-8): credit an account, read a balance, look at // sign-ins, end sessions. // // A CLI rather than a protected HTTP route, deliberately. An admin endpoint needs a second // authorisation model — roles, an escalation path, a way to lose the admin cookie — for four // operations. The trust boundary for these is already "can open a shell on the box and read the // DSN", and that boundary is enforced by the machine rather than by code we would have to write // and get right. If a browser-facing admin panel is ever wanted, it wraps these same store calls. package main import ( "context" "crypto/rand" "encoding/hex" "errors" "flag" "fmt" "io" "os" "os/signal" "syscall" "text/tabwriter" "time" "textmachine/platform/internal/config" "textmachine/platform/internal/money" "textmachine/platform/internal/pgstore" ) func main() { if err := run(os.Args[1:], os.Stdout); err != nil { if errors.Is(err, errUsage) { _, _ = fmt.Fprintln(os.Stderr, usage) os.Exit(2) } _, _ = fmt.Fprintln(os.Stderr, "tmplatformctl:", err) os.Exit(1) } } // errUsage asks main to print the usage text; every other error is a message on its own. var errUsage = errors.New("usage") const usage = `usage: tmplatformctl [flags] grant --user --usd [--note ] [--key ] adjust --user --usd --note [--key ] balance --user logins --user [--limit ] revoke --user book add --user --workdir --title --source-lang --target-lang --chapters [--characters ] (development intake) books [--migratable] [--abandoned] (books, which are safe to migrate, which lost their surface) book refresh --book (ask again for a reading surface that was given up on) runs [--stalled] (live runs and what the reconciler cannot finish) run abandon --run --reason [--release-hold] (operator's terminal verdict on a stalled run) run unquarantine --run (materialize a quarantined attempt's journal again) seed [--url ] [--subject ] [--usd ] [--source ] (development stand: account, credit, a book) exit-marker (called by systemd, no DSN) The DSN comes from TM_PLATFORM_DSN or TM_PLATFORM_DSN_FILE.` func run(args []string, out io.Writer) error { if len(args) == 0 { return errUsage } // Before the DSN: this one runs INSIDE a run's transient unit, as ExecStopPost, where there is // no database handle and no credential to read one with — and where failing would lose the only // record of how the run ended. if args[0] == "exit-marker" { return exitMarker(args[1:]) } dsn, err := config.Secret("TM_PLATFORM_DSN") if err != nil { return err } if dsn == "" { return errors.New("TM_PLATFORM_DSN (or TM_PLATFORM_DSN_FILE) is not set") } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() store, err := pgstore.Open(ctx, dsn) if err != nil { return err } defer store.Close() cmd, rest := args[0], args[1:] switch cmd { case "grant": return grant(ctx, store, rest, out) case "adjust": return adjust(ctx, store, rest, out) case "balance": return balance(ctx, store, rest, out) case "logins": return logins(ctx, store, rest, out) case "revoke": return revoke(ctx, store, rest, out) case "book": if len(rest) == 0 { return fmt.Errorf("book takes a subcommand, add or refresh: %w", errUsage) } switch rest[0] { case "add": return addBook(ctx, store, rest[1:], out) case "refresh": return refreshBook(ctx, store, rest[1:], out) } return fmt.Errorf("book takes a subcommand, add or refresh: %w", errUsage) case "books": return listBooks(ctx, store, rest, out) case "runs": return listRuns(ctx, store, rest, out) case "run": if len(rest) == 0 { return fmt.Errorf("run takes a subcommand, abandon or unquarantine: %w", errUsage) } switch rest[0] { case "abandon": return abandonRun(ctx, store, rest[1:], out) case "unquarantine": return unquarantineRun(ctx, store, rest[1:], out) } return fmt.Errorf("run takes a subcommand, abandon or unquarantine: %w", errUsage) case "seed": return seed(ctx, store, rest, out) default: return fmt.Errorf("unknown command %q: %w", cmd, errUsage) } } // grant writes one ledger row: that is the whole of the free tier. func grant(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { fs := flag.NewFlagSet("grant", flag.ContinueOnError) user := fs.String("user", "", "account id") amount := fs.String("usd", "", "amount in dollars, e.g. 5 or 2.50") note := fs.String("note", "", "why") // Idempotency is OPT-IN. A default key derived from the account and the day looked safe and was // not: two legitimate grants on one day collapse into the first, and the second reports success // while crediting nothing. Each invocation is its own intent unless the operator says otherwise. key := fs.String("key", "", "idempotency key: repeating the command with the same one is a no-op") if err := fs.Parse(args); err != nil { return err } if *user == "" || *amount == "" { return errors.New("grant needs --user and --usd") } micro, err := money.ParseUSD(*amount) if err != nil { return err } return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) { return store.Grant(ctx, *user, micro, "admin", id, *note, now) }, "granted "+micro.USD()+" to "+*user) } // adjust corrects a balance with a second row: ledger rows are never edited. func adjust(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { fs := flag.NewFlagSet("adjust", flag.ContinueOnError) user := fs.String("user", "", "account id") amount := fs.String("usd", "", "signed amount in dollars, e.g. -2.50") note := fs.String("note", "", "why (required: an unexplained correction is unauditable)") key := fs.String("key", "", "idempotency key") if err := fs.Parse(args); err != nil { return err } if *user == "" || *amount == "" || *note == "" { return errors.New("adjust needs --user, --usd and --note") } micro, err := money.ParseUSD(*amount) if err != nil { return err } return write(ctx, store, out, *user, *key, func(id string, now time.Time) (bool, error) { return store.Adjust(ctx, *user, micro, "admin", id, *note, now) }, "adjusted "+*user+" by "+micro.USD()) } // balanceReader is the read-back half of write. One method, and its only reason to exist is that the // rule below — a committed write never reports failure — cannot be tested against a store that // always works. type balanceReader interface { Balance(ctx context.Context, userID string) (money.MicroUSD, error) } // write runs one ledger operation and reports what actually happened. "Applied" and "the key was // already spent" are different outcomes and the operator is told which one they got. func write(ctx context.Context, store balanceReader, out io.Writer, user, key string, op func(id string, now time.Time) (bool, error), what string) error { now := time.Now().UTC() id := key if id == "" { id = newKey() } applied, err := op(id, now) if err != nil { // The key travels WITH the error, because the error is where the operator decides what to do // next. A write refused on the wire may still have committed — the ambiguous break is ON the // commit — and the natural reading of a failure is "run it again", which without --key mints // a fresh key and credits a second time (PD-89). Named here, the same key makes the retry a // no-op if the first run did commit and a first write if it did not: one credit either way. // The sentence holds for every caller: grant and adjust take the key as --key, and seed spends // a key fixed by the account, so its plain repeat is already under the same one. It rides the // error to stderr rather than `out`, so a script reading the outcome stream never takes a // refusal for an outcome. return fmt.Errorf("%w (key %s: if the write did commit it is spent under this key, so a repeat under the same key — for grant and adjust, --key %s — is applied at most once)", err, id, id) } // Past this point the write is committed and NOTHING may report failure. An operator who reads // an error retries, and a retry without --key mints a fresh idempotency key, so the second run // credits again — a failed BALANCE READ would have bought a double credit. The balance is a // courtesy; its failure is a note on the same line. Found by review. shown := "balance unavailable: " + user if after, err := store.Balance(ctx, user); err == nil { shown = "balance is " + after.USD() } else { _, _ = fmt.Fprintf(out, "warning: could not read the balance back: %v\n", err) } if !applied { _, _ = fmt.Fprintf(out, "no-op: key %s was already used on %s; %s\n", id, user, shown) return nil } _, _ = fmt.Fprintf(out, "%s (key %s); %s\n", what, id, shown) return nil } // newKey mints a key for a one-off command, so that two deliberate grants on the same day are two // grants. func newKey() string { var b [8]byte rand.Read(b[:]) // never fails return "cli-" + hex.EncodeToString(b[:]) } func balance(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { fs := flag.NewFlagSet("balance", flag.ContinueOnError) user := fs.String("user", "", "account id") if err := fs.Parse(args); err != nil { return err } if *user == "" { return errors.New("balance needs --user") } // One snapshot: reading the cache and the ledger in two queries reports drift that a concurrent // grant caused between them. a, err := store.ReadAccount(ctx, *user) if err != nil { return err } _, _ = fmt.Fprintf(out, "balance %s\n", a.Balance.USD()) if a.Reserved != 0 { _, _ = fmt.Fprintf(out, "reserved %s (open holds, already deducted)\n", a.Reserved.USD()) } if a.Balance != a.LedgerSum { _, _ = fmt.Fprintf(out, "⚠ ledger sums to %s: the cached balance has drifted\n", a.LedgerSum.USD()) } open, err := store.OpenReservations(ctx, *user) if err != nil { return err } for _, r := range open { _, _ = fmt.Fprintf(out, " hold %s on book %s since %s (run %s)\n", r.Amount.USD(), r.BookID, r.OpenedAt.UTC().Format(time.RFC3339), r.EngineRunID) } return nil } func logins(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { fs := flag.NewFlagSet("logins", flag.ContinueOnError) user := fs.String("user", "", "account id") limit := fs.Int("limit", 20, "how many") if err := fs.Parse(args); err != nil { return err } if *user == "" { return errors.New("logins needs --user") } entries, err := store.RecentLogins(ctx, *user, *limit) if err != nil { return err } w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintln(w, "WHEN\tPROVIDER\tOUTCOME\tCLIENT\tFROM\tREASON") for _, e := range entries { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", e.At.UTC().Format(time.RFC3339), e.Provider, e.Outcome, e.Client, e.IPPrefix, e.Reason) } return w.Flush() } func revoke(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error { fs := flag.NewFlagSet("revoke", flag.ContinueOnError) user := fs.String("user", "", "account id") if err := fs.Parse(args); err != nil { return err } if *user == "" { return errors.New("revoke needs --user") } n, err := store.RevokeUserSessions(ctx, *user, time.Now().UTC()) if err != nil { return err } _, _ = fmt.Fprintf(out, "revoked %d sessions of %s\n", n, *user) return nil }