Files
pocket-id/backend/internal/cmds/one_time_access_token.go
T
ItalyPaleAleandClaude Opus 5 cea1267f37 feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When it is
empty or set to "embedded" (the default), Pocket ID starts the runtime inside its own process, backed by its own database.

Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime.

Note: connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT, and optionally (but recommended) FRANCIS_CA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMgoTZtznSjP4SHTaHbRen
2026-09-15 16:02:05 +00:00

118 lines
3.9 KiB
Go

package cmds
import (
"context"
"errors"
"fmt"
"time"
francishost "github.com/italypaleale/francis/host"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
)
var oneTimeAccessTokenCmd = &cobra.Command{
Use: "one-time-access-token [username or email]",
Short: "Generates a one-time access token for the given user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Get the username or email of the user
userArg := args[0]
// Connect to the database
db, pg, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer queryCancel()
err = db.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return errors.New("user not found")
case err != nil:
return fmt.Errorf("failed to query for user: %w", err)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
}
instanceID, err := instanceid.Load(cmd.Context(), db)
if err != nil {
return err
}
// One-time access tokens live in the actor state store, which is reached differently depending on where the actor runtime runs
// The CLI never runs the full actor host: with an embedded runtime it writes to Pocket ID's database directly, and with a standalone one it joins the cluster as a client for just long enough to write the token
token, err := storeOneTimeAccessToken(cmd.Context(), db, pg, instanceID, user.ID)
if err != nil {
return fmt.Errorf("failed to create access token: %w", err)
}
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, token)
return nil
},
}
// storeOneTimeAccessToken persists a one-time access token valid for one hour, through whichever actor runtime this deployment uses, and returns the token
func storeOneTimeAccessToken(ctx context.Context, db *gorm.DB, pg *pgxpool.Pool, instanceID string, userID string) (string, error) {
// A standalone Francis runtime owns the actor state, so the token is written through a short-lived client connection to it
if !common.EnvConfig.HasEmbeddedFrancisRuntime() {
var token string
err := bootstrap.WithActorClient(ctx, &common.EnvConfig, func(clientCtx context.Context, client francishost.Host) error {
tokenCtx, tokenCancel := context.WithTimeout(clientCtx, 10*time.Second)
defer tokenCancel()
var storeErr error
token, _, storeErr = onetimeaccess.StoreToken(tokenCtx, client, userID, time.Hour, false)
return storeErr
})
if err != nil {
return "", err
}
return token, nil
}
// With the embedded runtime the actor state lives in Pocket ID's own database, which a minimal state store writes to without running an actor host
actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{
DB: db,
Postgres: pg,
EnvConfig: &common.EnvConfig,
InstanceID: instanceID,
})
if err != nil {
return "", fmt.Errorf("failed to initialize the actor state store: %w", err)
}
tokenCtx, tokenCancel := context.WithTimeout(ctx, 10*time.Second)
defer tokenCancel()
token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, userID, time.Hour, false)
if err != nil {
return "", err
}
return token, nil
}
func init() {
rootCmd.AddCommand(oneTimeAccessTokenCmd)
}