diff --git a/appview/config/config.go b/appview/config/config.go index 0c422989..36a66a68 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -8,6 +8,8 @@ import ( "time" "github.com/sethvargo/go-envconfig" + + "tangled.org/core/consts" ) type CoreConfig struct { @@ -104,6 +106,11 @@ func (p *PdsConfig) IsTnglShUser(pdsHost string) bool { return strings.TrimRight(pdsHost, "/") == strings.TrimRight(p.Host, "/") } +type KnotConfig struct { + Default string `env:"DEFAULT"` + AdminSecret string `env:"ADMIN_SECRET"` +} + type R2Config struct { AccessKeyID string `env:"ACCESS_KEY_ID"` SecretAccessKey string `env:"SECRET_ACCESS_KEY"` @@ -183,6 +190,7 @@ type Config struct { Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` Plc PlcConfig `env:",prefix=TANGLED_PLC_"` Pds PdsConfig `env:",prefix=TANGLED_PDS_"` + Knot KnotConfig `env:",prefix=TANGLED_KNOT_"` Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` Label LabelConfig `env:",prefix=TANGLED_LABEL_"` Bluesky BlueskyConfig `env:",prefix=TANGLED_BLUESKY_"` @@ -199,5 +207,9 @@ func LoadConfig(ctx context.Context) (*Config, error) { return nil, err } + if cfg.Knot.Default == "" { + cfg.Knot.Default = consts.DefaultKnot + } + return &cfg, nil } diff --git a/appview/config/config_test.go b/appview/config/config_test.go new file mode 100644 index 00000000..2a25edba --- /dev/null +++ b/appview/config/config_test.go @@ -0,0 +1,32 @@ +package config + +import ( + "context" + "testing" + + "tangled.org/core/consts" +) + +func TestLoadConfig_DefaultKnotFallsBackToConst(t *testing.T) { + t.Setenv("TANGLED_KNOT_DEFAULT", "") + + cfg, err := LoadConfig(context.Background()) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Knot.Default != consts.DefaultKnot { + t.Fatalf("unset TANGLED_KNOT_DEFAULT = %q, want fallback %q", cfg.Knot.Default, consts.DefaultKnot) + } +} + +func TestLoadConfig_DefaultKnotHonorsOverride(t *testing.T) { + t.Setenv("TANGLED_KNOT_DEFAULT", "kt.tngl.oyster.cafe") + + cfg, err := LoadConfig(context.Background()) + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Knot.Default != "kt.tngl.oyster.cafe" { + t.Fatalf("TANGLED_KNOT_DEFAULT override = %q, want kt.tngl.oyster.cafe", cfg.Knot.Default) + } +} diff --git a/appview/db/db.go b/appview/db/db.go index eef1e8fc..f2c5f8c4 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -2158,6 +2158,17 @@ func Make(ctx context.Context, dbPath string) (*DB, error) { `) return err }) + + orm.RunMigration(conn, logger, "add-knot-acl-native", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table if not exists knot_acl_native ( + domain text primary key, + since text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + `) + return err + }) + return &DB{ db, logger, diff --git a/appview/db/knot_acl_native.go b/appview/db/knot_acl_native.go new file mode 100644 index 00000000..d6b06770 --- /dev/null +++ b/appview/db/knot_acl_native.go @@ -0,0 +1,28 @@ +package db + +import ( + "context" + "database/sql" + "errors" +) + +func MarkKnotAclNative(ctx context.Context, e Execer, domain string) error { + _, err := e.ExecContext( + ctx, + `insert into knot_acl_native (domain) values (?) on conflict (domain) do nothing`, + domain, + ) + return err +} + +func IsKnotAclNative(ctx context.Context, e Execer, domain string) (bool, error) { + var one int + err := e.QueryRowContext(ctx, `select 1 from knot_acl_native where domain = ?`, domain).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} diff --git a/appview/db/knot_acl_native_test.go b/appview/db/knot_acl_native_test.go new file mode 100644 index 00000000..3c7227c1 --- /dev/null +++ b/appview/db/knot_acl_native_test.go @@ -0,0 +1,49 @@ +package db + +import ( + "context" + "testing" +) + +func TestKnotAclNativeDefaultsFalse(t *testing.T) { + d := newTestDB(t) + + native, err := IsKnotAclNative(context.Background(), d, "clam.nel.pet") + if err != nil { + t.Fatalf("IsKnotAclNative: %v", err) + } + if native { + t.Fatal("an unseen knot must default to not native") + } +} + +func TestKnotAclNativeMarkLatches(t *testing.T) { + d := newTestDB(t) + + if err := MarkKnotAclNative(context.Background(), d, "whelk.nel.pet"); err != nil { + t.Fatalf("MarkKnotAclNative: %v", err) + } + + native, err := IsKnotAclNative(context.Background(), d, "whelk.nel.pet") + if err != nil { + t.Fatalf("IsKnotAclNative: %v", err) + } + if !native { + t.Fatal("a marked knot must read back native") + } +} + +func TestKnotAclNativeMarkIsIdempotent(t *testing.T) { + d := newTestDB(t) + + if err := MarkKnotAclNative(context.Background(), d, "limpet.nel.pet"); err != nil { + t.Fatalf("first mark: %v", err) + } + if err := MarkKnotAclNative(context.Background(), d, "limpet.nel.pet"); err != nil { + t.Fatalf("second mark must be a no-op, got: %v", err) + } + + if n := countRows(t, d, `select count(*) from knot_acl_native where domain = ?`, "limpet.nel.pet"); n != 1 { + t.Fatalf("rows = %d, want exactly 1 after a repeated mark", n) + } +}