mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-23 21:36:39 +00:00
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) nothing changes: 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. Connecting to a standalone runtime also needs FRANCIS_HOST_PSK, the host bootstrap pre-shared key the runtime is configured with, and optionally FRANCIS_CA, the PEM-encoded cluster CA to pin before the first connection. Without a pinned CA Francis trusts the certificate it is served on first use, and warns about it. The actor host is now held as the topology-agnostic francis host.Host interface, since the concrete type depends on the configuration. The commands that reach the actor data through Pocket ID's own database (export, import, and one-time-access-token) fail with an explicit error when a standalone runtime owns that data instead, rather than silently operating on the wrong store. The Francis dependency is temporarily pinned to a commit that adds the host.Host interface.
This commit is contained in:
+3
-1
@@ -24,7 +24,7 @@ require (
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/italypaleale/francis v0.1.0-beta.23
|
||||
github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd
|
||||
github.com/italypaleale/go-kit v1.0.0
|
||||
github.com/italypaleale/go-sql-utils v0.3.5
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
@@ -65,6 +65,8 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/MicahParks/jwkset v0.11.3 // indirect
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.1 // indirect
|
||||
github.com/alphadose/haxmap v1.4.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
|
||||
|
||||
+6
-2
@@ -2,6 +2,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/MicahParks/jwkset v0.11.3 h1:Phli4RdTDdIdLXZpuO7abkwZyzIk0RDTUPVVBHPRdkQ=
|
||||
github.com/MicahParks/jwkset v0.11.3/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.1 h1:VR3jlEs2wz1xGjvUUwUHeoB+eJ6gUnBrpnIByRrtj6g=
|
||||
github.com/MicahParks/keyfunc/v3 v3.8.1/go.mod h1:LcorJ0sz2tZGvgZqIfaeyLkJmM+kxIfRDu8dFY0TAas=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
@@ -243,8 +247,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.23 h1:Yu0K/LE7NwQWVC1yOH6/uVE/+1JZ966aAftPnDTUDmw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.23/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM=
|
||||
github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd h1:GVUawGFf4RwwXIUPX+TKgBqi60lTHCpP6n5roIthPZU=
|
||||
github.com/italypaleale/francis v0.1.0-beta.23.0.20260817061021-17e8f38e50cd/go.mod h1:qB+0OgLCTWw3/AmdHe30bN81TTo5BcI95kAwAJdLPuM=
|
||||
github.com/italypaleale/go-kit v1.0.0 h1:c+SaYHTaoZzTgbPXhSbNVdNRkNdNhE0YljBWWoPTdKI=
|
||||
github.com/italypaleale/go-kit v1.0.0/go.mod h1:wg4UsIbsbtDiVqUjJdo/tO9lXo0OXBuwPOpSJ+u+1jI=
|
||||
github.com/italypaleale/go-sql-utils v0.3.5 h1:kkrhIo1tVJvcsjRc1kePWE9Cqdm26+Et5XZYSNmv764=
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
StaticApiKey string
|
||||
AppConfig appconfig.AppConfigResolver
|
||||
EmailSender APIKeyExpiryEmailSender
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
@@ -26,7 +26,7 @@ type AppConfigService struct {
|
||||
envConfig *AppConfigModel
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
func NewService(ctx context.Context, actors francishost.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{}
|
||||
|
||||
// If the UI config is disabled, we do not need to init the config actor
|
||||
@@ -56,8 +56,8 @@ func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *
|
||||
}
|
||||
err = actors.RegisterSingletonActor(
|
||||
AppConfigActorType, NewAppConfigActor,
|
||||
local.WithBootstrapData(bootstrapData),
|
||||
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
francishost.WithBootstrapData(bootstrapData),
|
||||
francishost.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/italypaleale/francis/components/postgres"
|
||||
"github.com/italypaleale/francis/components/sqlite"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/italypaleale/francis/host/remote"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -24,6 +26,10 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||
)
|
||||
|
||||
// ErrRemoteFrancisRuntime is returned by the helpers that reach the actor data through Pocket ID's own database when FRANCIS_HOST points to a standalone Francis runtime
|
||||
// That runtime owns the actor data instead, so it can only be reached through the runtime itself
|
||||
var ErrRemoteFrancisRuntime = errors.New("the actor data is owned by the standalone Francis runtime configured in FRANCIS_HOST, and is not stored in Pocket ID's database")
|
||||
|
||||
type NewActorsOpts struct {
|
||||
Postgres *pgxpool.Pool
|
||||
|
||||
@@ -34,57 +40,25 @@ type NewActorsOpts struct {
|
||||
FileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) {
|
||||
log := slog.Default()
|
||||
func NewActors(o NewActorsOpts) (francishost.Host, map[string]*ratelimit.RateLimitService, error) {
|
||||
log := slog.Default().With("scope", "actor-host")
|
||||
|
||||
// Derive a PSK from the global encryption key
|
||||
// The runtime PSK derives the cluster CA used for host-to-host mTLS
|
||||
psk, err := o.getPSK()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to derive PSK: %w", err)
|
||||
// Create the actor host for the configured topology
|
||||
// The embedded runtime keeps the actor data in Pocket ID's own database, while a standalone Francis runtime owns it instead and coordinates every host that connects to it
|
||||
var (
|
||||
h francishost.Host
|
||||
err error
|
||||
)
|
||||
if o.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
log.Debug("Starting the embedded Francis runtime")
|
||||
h, err = o.newEmbeddedHost(log)
|
||||
} else {
|
||||
log.Info("Connecting to a standalone Francis runtime", slog.Any("addresses", o.EnvConfig.FrancisAddresses))
|
||||
h, err = o.newRemoteHost(log)
|
||||
}
|
||||
|
||||
// Derive the cluster host limit from the HA setting
|
||||
// With HA disabled the cluster is capped at a single replica
|
||||
maxHosts := 1
|
||||
if o.EnvConfig.HAEnabled {
|
||||
// 0 = no cap
|
||||
maxHosts = 0
|
||||
}
|
||||
|
||||
// Options for the host
|
||||
opts := []local.HostOption{
|
||||
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
local.WithLogger(log.With("scope", "actor-host")),
|
||||
local.WithRuntimePSKs(psk),
|
||||
local.WithShutdownGracePeriod(10 * time.Second),
|
||||
local.WithMaxHosts(maxHosts),
|
||||
local.WithHostHealthCheckDeadline(ActorsHostHealthCheckDeadline(o.EnvConfig.HAEnabled)),
|
||||
}
|
||||
|
||||
// With a single active host the relaxed alarm intervals reduce database load
|
||||
// The longer lease duration also means fewer lease renewals, since Francis renews a lease 10s before it expires (no other host can claim the alarm anyways)
|
||||
// When HA is enabled these are dropped so Francis uses its tighter defaults, which distribute alarm work and fail over faster across multiple hosts
|
||||
if !o.EnvConfig.HAEnabled {
|
||||
opts = append(opts,
|
||||
local.WithAlarmsPollInterval(5*time.Minute),
|
||||
local.WithAlarmsFetchAheadInterval(5*time.Minute),
|
||||
local.WithAlarmsLeaseDuration(180*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
// Add the database connection
|
||||
providerOpt, err := o.getProviderOption()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
opts = append(opts, providerOpt)
|
||||
|
||||
// Create a new actor host
|
||||
h, err := local.NewHost(opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create actor host: %w", err)
|
||||
}
|
||||
|
||||
// Add all cron jobs
|
||||
err = o.registerCronJobs(h)
|
||||
@@ -107,6 +81,88 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
|
||||
return h, rateLimitServices, nil
|
||||
}
|
||||
|
||||
// newEmbeddedHost creates the actor host that runs the Francis runtime inside the Pocket ID process, backed by Pocket ID's own database
|
||||
func (o *NewActorsOpts) newEmbeddedHost(log *slog.Logger) (*local.Host, error) {
|
||||
// Derive a PSK from the global encryption key
|
||||
// The runtime PSK derives the cluster CA used for host-to-host mTLS
|
||||
psk, err := o.getPSK()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive PSK: %w", err)
|
||||
}
|
||||
|
||||
// Derive the cluster host limit from the HA setting
|
||||
// With HA disabled the cluster is capped at a single replica
|
||||
maxHosts := 1
|
||||
if o.EnvConfig.HAEnabled {
|
||||
// 0 = no cap
|
||||
maxHosts = 0
|
||||
}
|
||||
|
||||
// Options for the host
|
||||
opts := []local.HostOption{
|
||||
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
local.WithLogger(log),
|
||||
local.WithRuntimePSKs(psk),
|
||||
local.WithShutdownGracePeriod(10 * time.Second),
|
||||
local.WithMaxHosts(maxHosts),
|
||||
local.WithHostHealthCheckDeadline(ActorsHostHealthCheckDeadline(o.EnvConfig.HAEnabled)),
|
||||
}
|
||||
|
||||
// With a single active host the relaxed alarm intervals reduce database load
|
||||
// The longer lease duration also means fewer lease renewals, since Francis renews a lease 10s before it expires (no other host can claim the alarm anyways)
|
||||
// When HA is enabled these are dropped so Francis uses its tighter defaults, which distribute alarm work and fail over faster across multiple hosts
|
||||
if !o.EnvConfig.HAEnabled {
|
||||
opts = append(opts,
|
||||
local.WithAlarmsPollInterval(5*time.Minute),
|
||||
local.WithAlarmsFetchAheadInterval(5*time.Minute),
|
||||
local.WithAlarmsLeaseDuration(180*time.Second),
|
||||
)
|
||||
}
|
||||
|
||||
// Add the database connection
|
||||
providerOpt, err := o.getProviderOption()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, providerOpt)
|
||||
|
||||
h, err := local.NewHost(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create actor host: %w", err)
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// newRemoteHost creates the actor host that connects to a standalone Francis runtime
|
||||
// The runtime owns the actor state, placement, and alarms, so none of the embedded runtime's database and clustering options apply here
|
||||
// That includes the cap on the number of hosts in the cluster, which the runtime enforces through its own "maxHosts" setting: Pocket ID cannot limit itself to a single replica from this side
|
||||
func (o *NewActorsOpts) newRemoteHost(log *slog.Logger) (*remote.Host, error) {
|
||||
opts := []remote.HostOption{
|
||||
// Actors placed on this host are invoked by its peers at this address, which is also the one it advertises to the runtime
|
||||
remote.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
remote.WithLogger(log),
|
||||
remote.WithRuntimeAddresses(o.EnvConfig.FrancisAddresses...),
|
||||
remote.WithHostBootstrapPSK(o.EnvConfig.FrancisHostPSK),
|
||||
remote.WithShutdownGracePeriod(10 * time.Second),
|
||||
}
|
||||
|
||||
// Pinning the cluster CA lets Pocket ID verify the runtime on its very first connection
|
||||
// Francis requires the trust decision to be explicit, so without a pinned CA we have to opt into trusting the certificate served on first use, which it warns about
|
||||
if len(o.EnvConfig.FrancisCA) > 0 {
|
||||
opts = append(opts, remote.WithPinnedCA(o.EnvConfig.FrancisCA))
|
||||
} else {
|
||||
opts = append(opts, remote.WithUnsafeNoPinnedCA())
|
||||
}
|
||||
|
||||
h, err := remote.NewHost(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create remote actor host: %w", err)
|
||||
}
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Derive a PSK from the global encryption key
|
||||
func (o *NewActorsOpts) getPSK() ([]byte, error) {
|
||||
// This is tied to the instance ID of the Pocket ID deployment/cluster
|
||||
@@ -117,7 +173,12 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) {
|
||||
// NewActorStateStore creates a minimal actor host that can read and write actor state directly, without joining the cluster or binding a network port.
|
||||
// It's meant for short-lived contexts such as CLI commands that need to persist actor state (for example, one-time access tokens) without running the full actor host.
|
||||
// The returned host must NOT be Run(): only direct state operations (Get/Set/Delete on state) are supported, and they require the actor state tables to already exist, which is the case whenever the server has run at least once against this database.
|
||||
// It only works with the embedded runtime, since the actor state then lives in Pocket ID's own database: with a standalone Francis runtime it returns ErrRemoteFrancisRuntime.
|
||||
func NewActorStateStore(o NewActorsOpts) (*local.Host, error) {
|
||||
if !o.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
return nil, ErrRemoteFrancisRuntime
|
||||
}
|
||||
|
||||
providerOpt, err := o.getProviderOption()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -234,7 +295,7 @@ func (o *NewActorsOpts) getProviderOption() (local.HostOption, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
|
||||
func (o *NewActorsOpts) registerCronJobs(host francishost.Host) (err error) {
|
||||
// In test mode, we do not register anything
|
||||
if common.EnvConfig.AppEnv == "test" {
|
||||
return nil
|
||||
@@ -271,7 +332,7 @@ func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
|
||||
|
||||
// registerRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
|
||||
// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment
|
||||
func (o *NewActorsOpts) registerRateLimiters(host *local.Host) (actors map[string]*ratelimit.RateLimit, err error) {
|
||||
func (o *NewActorsOpts) registerRateLimiters(host francishost.Host) (actors map[string]*ratelimit.RateLimit, err error) {
|
||||
policies := middleware.RateLimitPolicies()
|
||||
actors = make(map[string]*ratelimit.RateLimit, len(policies))
|
||||
for _, p := range policies {
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/italypaleale/francis/host/remote"
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
@@ -31,6 +33,85 @@ func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) {
|
||||
require.Equalf(t, expected, actual, "actual result: %s", actual)
|
||||
}
|
||||
|
||||
// TestNewActorsSelectsTopology covers the branch that FRANCIS_HOST drives: with no standalone runtime configured Pocket ID starts an embedded one, and otherwise it connects to the addresses it was given.
|
||||
func TestNewActorsSelectsTopology(t *testing.T) {
|
||||
// The actor host is created but never run, so the database only has to exist
|
||||
newDB := func(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "pocket-id.db")
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbPath+"?_pragma=foreign_keys(1)"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// registerCronJobs reads the app environment off the global config and skips every job in test mode, which keeps each case down to the host itself and the rate limiters
|
||||
baseConfig := func(t *testing.T) *common.EnvConfigSchema {
|
||||
t.Helper()
|
||||
|
||||
originalAppEnv := common.EnvConfig.AppEnv
|
||||
common.EnvConfig.AppEnv = common.AppEnvTest
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig.AppEnv = originalAppEnv
|
||||
})
|
||||
|
||||
return &common.EnvConfigSchema{
|
||||
AppEnv: common.AppEnvTest,
|
||||
EncryptionKey: []byte("test-encryption-key"),
|
||||
ActorsHost: "127.0.0.1",
|
||||
ActorsPort: "1414",
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("embedded runtime by default", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
|
||||
h, rateLimitServices, err := NewActors(NewActorsOpts{
|
||||
EnvConfig: cfg,
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
DB: newDB(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &local.Host{}, h)
|
||||
require.NotEmpty(t, rateLimitServices)
|
||||
})
|
||||
|
||||
t.Run("remote runtime when addresses are configured", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443", "runtime-2.example.com:8443"}
|
||||
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
|
||||
|
||||
// No database is passed, since a standalone runtime owns the actor data and the remote host must not reach for Pocket ID's own database
|
||||
h, rateLimitServices, err := NewActors(NewActorsOpts{
|
||||
EnvConfig: cfg,
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &remote.Host{}, h)
|
||||
require.NotEmpty(t, rateLimitServices)
|
||||
})
|
||||
|
||||
t.Run("state store is unavailable with a remote runtime", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
|
||||
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
|
||||
|
||||
_, err := NewActorStateStore(NewActorsOpts{
|
||||
EnvConfig: cfg,
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
DB: newDB(t),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrRemoteFrancisRuntime)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewActorsBackupProvider covers the provider the export and import use to back up and restore the actor host's data.
|
||||
// It builds the provider from the same options the actor host uses, so a mismatch between those options and the concrete provider would otherwise only surface at runtime, when an export or import is attempted.
|
||||
func TestNewActorsBackupProvider(t *testing.T) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -152,7 +152,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// actorsRunServiceFn wraps the actor host's Run method in a background service and returns a "ready" signal that other services can wait on
|
||||
func actorsRunServiceFn(actors *local.Host) (servicerunner.Service, *servicerunner.Ready) {
|
||||
func actorsRunServiceFn(actors francishost.Host) (servicerunner.Service, *servicerunner.Ready) {
|
||||
actorsReady := servicerunner.NewReady()
|
||||
fn := func(ctx context.Context) error {
|
||||
runErrCh := make(chan error, 1)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -49,7 +49,7 @@ type services struct {
|
||||
oneTimeAccessModule *onetimeaccess.Module
|
||||
emailVerificationModule *emailverification.Module
|
||||
apiModule *api.Module
|
||||
actors *local.Host
|
||||
actors francishost.Host
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -57,7 +57,7 @@ func initServices(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
instanceID string,
|
||||
actors *local.Host,
|
||||
actors francishost.Host,
|
||||
httpClient *http.Client,
|
||||
imageExtensions map[string]string,
|
||||
fileStorage storage.FileStorage,
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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/service"
|
||||
)
|
||||
|
||||
@@ -34,6 +36,12 @@ func init() {
|
||||
|
||||
// runExport orchestrates the export flow
|
||||
func runExport(ctx context.Context, flags exportFlags) error {
|
||||
// The export includes the actor data, which a standalone Francis runtime keeps in its own store rather than in Pocket ID's database
|
||||
// Exporting anyway would silently produce an archive missing that data, so refuse instead
|
||||
if !common.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
return errors.New("exporting is not supported when FRANCIS_HOST points to a standalone Francis runtime: export Pocket ID's data and the runtime's data separately, using the runtime's own backup command for the latter")
|
||||
}
|
||||
|
||||
db, pg, err := bootstrap.NewDatabase(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
|
||||
@@ -47,6 +47,12 @@ func init() {
|
||||
|
||||
// runImport handles the high-level orchestration of the import process
|
||||
func runImport(ctx context.Context, flags importFlags) error {
|
||||
// The archive carries the actor data, which is restored into Pocket ID's own database and so is only reachable with the embedded runtime
|
||||
// A standalone Francis runtime owns that data instead, and it has to be restored through the runtime itself
|
||||
if !common.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
return errors.New("importing is not supported when FRANCIS_HOST points to a standalone Francis runtime: import Pocket ID's data and the runtime's data separately, using the runtime's own restore command for the latter")
|
||||
}
|
||||
|
||||
if !flags.Yes {
|
||||
ok, err := askForConfirmation()
|
||||
if err != nil {
|
||||
|
||||
@@ -61,7 +61,10 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
EnvConfig: &common.EnvConfig,
|
||||
InstanceID: instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, bootstrap.ErrRemoteFrancisRuntime) {
|
||||
// Writing the token through a standalone runtime would mean joining the cluster as a full actor host, which this short-lived command does not do
|
||||
return errors.New("generating a one-time access token from the CLI is not supported when FRANCIS_HOST points to a standalone Francis runtime: request the token from a running Pocket ID instance instead")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to initialize the actor state store: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -37,6 +38,11 @@ const (
|
||||
defaultSqliteConnString string = "data/pocket-id.db"
|
||||
defaultFsUploadPath string = "data/uploads"
|
||||
AppUrl string = "http://localhost:1411"
|
||||
|
||||
// FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process
|
||||
FrancisHostEmbedded string = "embedded"
|
||||
// francisHostPSKMinLength is the shortest host bootstrap pre-shared key Francis accepts
|
||||
francisHostPSKMinLength int = 16
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
@@ -88,6 +94,20 @@ type EnvConfigSchema struct {
|
||||
ActorsPort string `env:"ACTORS_PORT"`
|
||||
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
|
||||
|
||||
// FrancisHost selects where the Francis actor runtime lives
|
||||
// When empty or set to "embedded", Pocket ID runs the runtime inside its own process, which is the default
|
||||
// Any other value is the address (or a comma-separated list of addresses) of a standalone Francis runtime to connect to, and in that case Pocket ID does not start an embedded runtime
|
||||
FrancisHost string `env:"FRANCIS_HOST" options:"toLower"`
|
||||
// FrancisHostPSK is the pre-shared key Pocket ID presents to a standalone Francis runtime when joining the cluster
|
||||
// It must match the "bootstrap.hostPSK" value in the runtime's own configuration, and it is required whenever FrancisHost points to a standalone runtime
|
||||
FrancisHostPSK []byte `env:"FRANCIS_HOST_PSK" options:"file"`
|
||||
// FrancisCA is the PEM-encoded cluster CA of a standalone Francis runtime, which Pocket ID pins before its first connection
|
||||
// Leaving it empty makes Pocket ID trust the certificate the runtime presents on the first connection, which is vulnerable to an attacker intercepting that connection
|
||||
FrancisCA []byte `env:"FRANCIS_CA" options:"file"`
|
||||
// FrancisAddresses contains the runtime addresses parsed out of FrancisHost, and is empty when the actor runtime is embedded
|
||||
// It is derived from FrancisHost and is not bound to an environment variable of its own
|
||||
FrancisAddresses []string
|
||||
|
||||
// HAEnabled turns on high-availability mode, allowing more than one replica of Pocket ID to run against the same database at once
|
||||
// It is intentionally not bound to an environment variable while HA support is still being completed
|
||||
// TODO: Add env var when HA mode is ready
|
||||
@@ -124,6 +144,7 @@ func defaultConfig() EnvConfigSchema {
|
||||
Host: "0.0.0.0",
|
||||
ActorsPort: "1414",
|
||||
ActorsHost: "0.0.0.0",
|
||||
FrancisHost: FrancisHostEmbedded,
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
}
|
||||
@@ -186,6 +207,12 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
// Prepare the DB config
|
||||
prepareDbConfig(config)
|
||||
|
||||
// Resolve where the Francis actor runtime lives, which decides whether Pocket ID starts an embedded one
|
||||
err = prepareFrancisConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate other required options
|
||||
err = validateAppURLs(config)
|
||||
if err != nil {
|
||||
@@ -222,6 +249,60 @@ func prepareDbConfig(config *EnvConfigSchema) {
|
||||
}
|
||||
}
|
||||
|
||||
// prepareFrancisConfig resolves FRANCIS_HOST into the list of standalone runtime addresses Pocket ID connects to
|
||||
// An empty value or the "embedded" constant keeps the actor runtime inside the Pocket ID process, and leaves the address list empty
|
||||
func prepareFrancisConfig(config *EnvConfigSchema) error {
|
||||
config.FrancisAddresses = nil
|
||||
|
||||
value := strings.TrimSpace(config.FrancisHost)
|
||||
if value == "" || value == FrancisHostEmbedded {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Any other value is one address, or a comma-separated list of addresses, of the standalone runtime replicas
|
||||
// Pocket ID dials them directly rather than resolving a service record, so each one must carry an explicit port
|
||||
parts := strings.Split(value, ",")
|
||||
addresses := make([]string, 0, len(parts))
|
||||
for _, address := range parts {
|
||||
address = strings.TrimSpace(address)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil || port == "" {
|
||||
return fmt.Errorf("invalid address '%s' in FRANCIS_HOST: addresses must be in the 'host:port' format", address)
|
||||
}
|
||||
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
|
||||
if len(addresses) == 0 {
|
||||
return errors.New("FRANCIS_HOST does not contain any address")
|
||||
}
|
||||
|
||||
// The key has to match the runtime's byte-for-byte, and reading it from a file (including a container secret) usually leaves a trailing newline behind, so surrounding whitespace is never meaningful here
|
||||
config.FrancisHostPSK = bytes.TrimSpace(config.FrancisHostPSK)
|
||||
|
||||
// A standalone runtime only admits hosts that present the bootstrap pre-shared key it is configured with
|
||||
// Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup
|
||||
switch {
|
||||
case len(config.FrancisHostPSK) == 0:
|
||||
return errors.New("FRANCIS_HOST_PSK is required when FRANCIS_HOST points to a standalone Francis runtime")
|
||||
case len(config.FrancisHostPSK) < francisHostPSKMinLength:
|
||||
return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength)
|
||||
}
|
||||
|
||||
config.FrancisAddresses = addresses
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasEmbeddedFrancisRuntime returns true when Pocket ID runs the Francis actor runtime inside its own process, which is the case unless FRANCIS_HOST points to a standalone runtime
|
||||
func (config *EnvConfigSchema) HasEmbeddedFrancisRuntime() bool {
|
||||
return len(config.FrancisAddresses) == 0
|
||||
}
|
||||
|
||||
func validateAppURLs(config *EnvConfigSchema) error {
|
||||
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -353,6 +353,134 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrancisHostConfig(t *testing.T) {
|
||||
originalConfig := EnvConfig
|
||||
t.Cleanup(func() {
|
||||
EnvConfig = originalConfig
|
||||
})
|
||||
|
||||
// setBaseEnv sets the variables every valid configuration needs, so each subtest only sets what it is about
|
||||
setBaseEnv := func(t *testing.T) {
|
||||
t.Helper()
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
}
|
||||
|
||||
t.Run("should default to the embedded runtime", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, FrancisHostEmbedded, EnvConfig.FrancisHost)
|
||||
assert.Empty(t, EnvConfig.FrancisAddresses)
|
||||
assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should keep the embedded runtime when FRANCIS_HOST is empty", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, EnvConfig.FrancisAddresses)
|
||||
assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should keep the embedded runtime when FRANCIS_HOST is 'embedded'", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "EMBEDDED")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, FrancisHostEmbedded, EnvConfig.FrancisHost) // lowercased
|
||||
assert.Empty(t, EnvConfig.FrancisAddresses)
|
||||
assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should parse a single remote runtime address", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"francis.example.com:8443"}, EnvConfig.FrancisAddresses)
|
||||
assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should parse a comma-separated list of remote runtime addresses", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "one.example.com:8443, two.example.com:8443 ,[2001:db8::1]:8443")
|
||||
t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"one.example.com:8443", "two.example.com:8443", "[2001:db8::1]:8443"}, EnvConfig.FrancisAddresses)
|
||||
assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should fail when a remote runtime address has no port", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "francis.example.com")
|
||||
t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid address 'francis.example.com' in FRANCIS_HOST")
|
||||
})
|
||||
|
||||
t.Run("should fail when FRANCIS_HOST only contains separators", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", " , ")
|
||||
t.Setenv("FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "FRANCIS_HOST does not contain any address")
|
||||
})
|
||||
|
||||
t.Run("should fail when the bootstrap PSK is missing", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "francis.example.com:8443")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "FRANCIS_HOST_PSK is required")
|
||||
})
|
||||
|
||||
t.Run("should fail when the bootstrap PSK is too short", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("FRANCIS_HOST_PSK", "too-short")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "FRANCIS_HOST_PSK must be at least 16 bytes long")
|
||||
})
|
||||
|
||||
t.Run("should read the bootstrap PSK and the CA from files", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
pskFile := tempDir + "/psk"
|
||||
caFile := tempDir + "/ca.pem"
|
||||
caContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
||||
// The trailing newline is what writing a secret to a file usually leaves behind, and it must not become part of the key
|
||||
require.NoError(t, os.WriteFile(pskFile, []byte("bootstrap-psk-that-is-long-enough\n"), 0600))
|
||||
require.NoError(t, os.WriteFile(caFile, []byte(caContent), 0600))
|
||||
|
||||
t.Setenv("FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("FRANCIS_HOST_PSK_FILE", pskFile)
|
||||
t.Setenv("FRANCIS_CA_FILE", caFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("bootstrap-psk-that-is-long-enough"), EnvConfig.FrancisHostPSK)
|
||||
assert.Equal(t, []byte(caContent), EnvConfig.FrancisCA)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -33,7 +33,7 @@ type IPLocationResolver interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
BaseURL string
|
||||
|
||||
Signer TokenService
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
Users UserProvider
|
||||
EmailSender EmailSender
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -38,7 +38,7 @@ type GroupSyncer interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
HTTPClient *http.Client
|
||||
FileStorage storage.FileStorage
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -33,7 +33,7 @@ type UserProvider interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/google/uuid"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
@@ -51,7 +51,7 @@ type LdapSyncer interface {
|
||||
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
actors *local.Host
|
||||
actors francishost.Host
|
||||
jwtService *JwtService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
ldapSyncer LdapSyncer
|
||||
@@ -69,7 +69,7 @@ const (
|
||||
e2eEmailVerificationToken = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup"
|
||||
)
|
||||
|
||||
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
func NewTestService(db *gorm.DB, actors francishost.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
actors: actors,
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -29,7 +29,7 @@ type UserCreator interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
|
||||
Reference in New Issue
Block a user