more labeler improvements. standardize did work between labeler and hold. improve sql race conditions on local-only db

This commit is contained in:
Evan Jarrett
2026-05-02 22:13:53 -05:00
parent 410183f13b
commit ab66028151
48 changed files with 3735 additions and 1553 deletions
+25
View File
@@ -0,0 +1,25 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -buildvcs=false -o ./tmp/atcr-labeler ./cmd/labeler"
entrypoint = ["./tmp/atcr-labeler", "serve", "--config", "config-labeler.example.yaml"]
include_ext = ["go", "html", "css", "js"]
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview", "pkg/hold", "node_modules"]
exclude_regex = ["_test\\.go$", "cbor_gen\\.go$", "\\.min\\.js$", "public/css/style\\.css$", "public/icons\\.svg$"]
delay = 3000
stop_on_error = true
send_interrupt = true
kill_delay = 500
[log]
time = false
[color]
main = "cyan"
watcher = "magenta"
build = "yellow"
runner = "green"
[misc]
clean_on_exit = true
+168 -103
View File
@@ -5,12 +5,11 @@ import (
"fmt"
"log/slog"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/hold"
"atcr.io/pkg/hold/pds"
"github.com/bluesky-social/indigo/atproto/atcrypto"
didplc "github.com/did-method-plc/go-didplc"
"github.com/spf13/cobra"
)
@@ -21,144 +20,210 @@ var plcCmd = &cobra.Command{
var plcConfigFile string
var (
plcAddRotationKeyFirst bool
plcAddRotationKeyLast bool
)
var plcAddRotationKeyCmd = &cobra.Command{
Use: "add-rotation-key <multibase-key>",
Use: "add-rotation-key [multibase-key]",
Short: "Add a rotation key to this hold's PLC identity",
Long: `Add an additional rotation key to the hold's did:plc document.
The key must be a multibase-encoded private key (K-256 or P-256, starting with 'z').
If a multibase-encoded private key (K-256 or P-256, starting with 'z') is supplied as
the positional argument, that key is added. If no argument is given, a fresh K-256
keypair is generated and the private half is printed to stdout. Save it offline as
your recovery key, since it will not be shown again.
By default the new key is inserted at the highest priority position (--first), which
allows it to override ops signed by lower-priority keys within PLC's 72-hour recovery
window. Pass --last to append at the lowest priority instead.
The hold's configured rotation key is used to sign the PLC update.
atcr-hold plc add-rotation-key --config config.yaml z...`,
Args: cobra.ExactArgs(1),
atcr-hold plc add-rotation-key --config config.yaml # generate + print
atcr-hold plc add-rotation-key --config config.yaml --last # append, low priority
atcr-hold plc add-rotation-key --config config.yaml z... # use supplied key`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
firstSet := cmd.Flags().Changed("first")
lastSet := cmd.Flags().Changed("last")
if firstSet && lastSet {
return fmt.Errorf("--first and --last are mutually exclusive")
}
prepend := !plcAddRotationKeyLast
cfg, err := hold.LoadConfig(plcConfigFile)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if cfg.Database.DIDMethod != "plc" {
return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod)
}
ctx := context.Background()
// Resolve the hold's DID
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
DID: cfg.Database.DID,
DIDMethod: cfg.Database.DIDMethod,
PublicURL: cfg.Server.PublicURL,
DBPath: cfg.Database.Path,
SigningKeyPath: cfg.Database.KeyPath,
RotationKey: cfg.Database.RotationKey,
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
})
holdDID, rotationKey, signingKey, err := loadHoldPLCIdentity(ctx, cfg)
if err != nil {
return fmt.Errorf("failed to resolve hold DID: %w", err)
return err
}
// Parse the rotation key from config (required for signing PLC updates)
if cfg.Database.RotationKey == "" {
return fmt.Errorf("database.rotation_key must be set to sign PLC updates")
}
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
if err != nil {
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
}
// Parse the new key to add (K-256 or P-256)
newKey, err := atcrypto.ParsePrivateMultibase(args[0])
if err != nil {
return fmt.Errorf("failed to parse key argument: %w", err)
}
newKeyPub, err := newKey.PublicKey()
if err != nil {
return fmt.Errorf("failed to get public key from argument: %w", err)
}
newKeyDIDKey := newKeyPub.DIDKey()
// Load signing key for verification methods
keyPath := cfg.Database.KeyPath
if keyPath == "" {
keyPath = cfg.Database.Path + "/signing.key"
}
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
if err != nil {
return fmt.Errorf("failed to load signing key: %w", err)
}
// Fetch current PLC state
plcDirectoryURL := cfg.Database.PLCDirectoryURL
if plcDirectoryURL == "" {
plcDirectoryURL = "https://plc.directory"
}
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
opLog, err := client.OpLog(ctx, holdDID)
if err != nil {
return fmt.Errorf("failed to fetch PLC op log: %w", err)
}
if len(opLog) == 0 {
return fmt.Errorf("empty op log for %s", holdDID)
}
lastEntry := opLog[len(opLog)-1]
lastOp := lastEntry.Regular
if lastOp == nil {
return fmt.Errorf("last PLC operation is not a regular op")
}
// Check if key already present
for _, k := range lastOp.RotationKeys {
if k == newKeyDIDKey {
fmt.Printf("Key %s is already a rotation key for %s\n", newKeyDIDKey, holdDID)
return nil
var newKey atcrypto.PrivateKeyExportable
if len(args) == 1 {
newKey, err = atcrypto.ParsePrivateMultibase(args[0])
if err != nil {
return fmt.Errorf("failed to parse key argument: %w", err)
}
}
// Build updated rotation keys: keep existing, append new
rotationKeys := make([]string, len(lastOp.RotationKeys))
copy(rotationKeys, lastOp.RotationKeys)
rotationKeys = append(rotationKeys, newKeyDIDKey)
// Build update: preserve everything else from current state
sigPub, err := signingKey.PublicKey()
res, err := did.AddRotationKey(ctx, did.AddRotationKeyOptions{
DID: holdDID,
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
RotationKey: rotationKey,
SigningKey: signingKey,
VerificationKeyName: "atproto",
NewKey: newKey,
Prepend: prepend,
})
if err != nil {
return fmt.Errorf("failed to get signing public key: %w", err)
return err
}
prevCID := lastEntry.AsOperation().CID().String()
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: rotationKeys,
VerificationMethods: map[string]string{
"atproto": sigPub.DIDKey(),
},
AlsoKnownAs: lastOp.AlsoKnownAs,
Services: lastOp.Services,
Prev: &prevCID,
if res.AlreadyPresent {
fmt.Printf("Key %s is already a rotation key for %s (priority %d of %d)\n",
res.NewKeyDIDKey, holdDID, res.ExistingAt, res.TotalKeys)
return nil
}
if err := op.Sign(rotationKey); err != nil {
return fmt.Errorf("failed to sign PLC update: %w", err)
}
if err := client.Submit(ctx, holdDID, op); err != nil {
return fmt.Errorf("failed to submit PLC update: %w", err)
if res.Generated {
fmt.Println("=========================================================================")
fmt.Println("GENERATED NEW ROTATION KEY. SAVE THIS NOW. IT WILL NOT BE SHOWN AGAIN.")
fmt.Println("Store it offline (password manager, paper, hardware token).")
fmt.Println()
fmt.Printf("Private key (multibase): %s\n", res.NewKey.Multibase())
fmt.Printf("Public key (did:key): %s\n", res.NewKeyDIDKey)
fmt.Println("=========================================================================")
}
slog.Info("Added rotation key to PLC identity",
"did", holdDID,
"new_key", newKeyDIDKey,
"total_rotation_keys", len(rotationKeys),
"new_key", res.NewKeyDIDKey,
"priority", res.InsertedAt,
"total_rotation_keys", res.TotalKeys,
"generated", res.Generated,
)
fmt.Printf("Added rotation key %s to %s\n", newKeyDIDKey, holdDID)
fmt.Printf("Added rotation key %s to %s (priority %d of %d)\n",
res.NewKeyDIDKey, holdDID, res.InsertedAt, res.TotalKeys)
return nil
},
}
var plcListRotationKeysCmd = &cobra.Command{
Use: "list-rotation-keys",
Short: "List rotation keys in this hold's PLC document",
Long: `Fetch the hold's did:plc document from the PLC directory and print its
rotation keys in priority order (index 0 is highest priority and can override
ops signed by lower-priority keys within PLC's 72-hour recovery window).
The key matching the local database.rotation_key is marked as LOCAL.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := hold.LoadConfig(plcConfigFile)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if cfg.Database.DIDMethod != "plc" {
return fmt.Errorf("this command only works with did:plc (database.did_method is %q)", cfg.Database.DIDMethod)
}
ctx := context.Background()
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
if err != nil {
return fmt.Errorf("failed to resolve hold DID: %w", err)
}
var localRotationKey atcrypto.PrivateKey
if cfg.Database.RotationKey != "" {
localRotationKey, err = atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
if err != nil {
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
}
}
res, err := did.ListRotationKeys(ctx, did.ListRotationKeysOptions{
DID: holdDID,
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
LocalRotationKey: localRotationKey,
})
if err != nil {
return err
}
printRotationKeys(res)
return nil
},
}
// loadHoldPLCIdentity is the shared "load DID + rotation key + signing key" helper used
// by every PLC command. It enforces that database.rotation_key is set since every PLC
// command needs a rotation key to either sign updates or verify the LOCAL marker.
func loadHoldPLCIdentity(ctx context.Context, cfg *hold.Config) (string, atcrypto.PrivateKey, *atcrypto.PrivateKeyK256, error) {
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
if err != nil {
return "", nil, nil, fmt.Errorf("failed to resolve hold DID: %w", err)
}
if cfg.Database.RotationKey == "" {
return "", nil, nil, fmt.Errorf("database.rotation_key must be set to sign PLC updates")
}
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Database.RotationKey)
if err != nil {
return "", nil, nil, fmt.Errorf("failed to parse rotation_key from config: %w", err)
}
keyPath := cfg.Database.KeyPath
if keyPath == "" {
keyPath = cfg.Database.Path + "/signing.key"
}
signingKey, err := oauth.GenerateOrLoadPDSKey(keyPath)
if err != nil {
return "", nil, nil, fmt.Errorf("failed to load signing key: %w", err)
}
return holdDID, rotationKey, signingKey, nil
}
// printRotationKeys is the shared CLI output for `list-rotation-keys`.
func printRotationKeys(res *did.ListRotationKeysResult) {
fmt.Printf("DID: %s\n", res.DID)
fmt.Printf("PLC directory: %s\n", res.Directory)
fmt.Printf("Rotation keys (%d):\n", len(res.Keys))
for i, k := range res.Keys {
marker := ""
switch {
case len(res.Keys) == 1:
marker = "(only key)"
case i == 0:
marker = "(highest priority)"
case i == len(res.Keys)-1:
marker = "(lowest priority)"
}
localTag := ""
if res.LocalDIDKey != "" && k == res.LocalDIDKey {
localTag = " [LOCAL — database.rotation_key]"
}
fmt.Printf(" [%d] %s %s%s\n", i, k, marker, localTag)
}
if res.LocalDIDKey != "" && !res.LocalPresent {
fmt.Printf("\nWARNING: local rotation_key (%s) is NOT present in the PLC document.\n", res.LocalDIDKey)
fmt.Println("This service cannot sign PLC updates. Possible compromise or out-of-band rotation.")
}
}
func init() {
plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file")
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyFirst, "first", true, "insert at highest priority (default)")
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyLast, "last", false, "insert at lowest priority")
plcCmd.AddCommand(plcAddRotationKeyCmd)
plcCmd.AddCommand(plcListRotationKeysCmd)
}
+3 -10
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/hold"
holddb "atcr.io/pkg/hold/db"
"atcr.io/pkg/hold/pds"
@@ -39,7 +40,7 @@ The CAR is written to stdout, so redirect to a file:
}
defer cleanup()
if err := holdPDS.ExportToCAR(ctx, os.Stdout); err != nil {
if err := holdPDS.RepomgrRef().ReadRepo(ctx, holdPDS.UID(), "", os.Stdout); err != nil {
return fmt.Errorf("failed to export: %w", err)
}
@@ -105,15 +106,7 @@ func init() {
// openHoldPDS creates a HoldPDS from config for offline CLI operations.
// Returns the PDS and a cleanup function that must be deferred.
func openHoldPDS(ctx context.Context, cfg *hold.Config) (*pds.HoldPDS, func(), error) {
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
DID: cfg.Database.DID,
DIDMethod: cfg.Database.DIDMethod,
PublicURL: cfg.Server.PublicURL,
DBPath: cfg.Database.Path,
SigningKeyPath: cfg.Database.KeyPath,
RotationKey: cfg.Database.RotationKey,
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
})
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
if err != nil {
return nil, nil, fmt.Errorf("failed to resolve hold DID: %w", err)
}
+1
View File
@@ -73,6 +73,7 @@ func init() {
rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(plcCmd)
}
func main() {
+225
View File
@@ -0,0 +1,225 @@
package main
import (
"context"
"fmt"
"log/slog"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/labeler"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/spf13/cobra"
)
var plcCmd = &cobra.Command{
Use: "plc",
Short: "PLC directory management commands",
}
var plcConfigFile string
var (
plcAddRotationKeyFirst bool
plcAddRotationKeyLast bool
)
var plcAddRotationKeyCmd = &cobra.Command{
Use: "add-rotation-key [multibase-key]",
Short: "Add a rotation key to this labeler's PLC identity",
Long: `Add an additional rotation key to the labeler's did:plc document.
If a multibase-encoded private key (K-256 or P-256, starting with 'z') is supplied as
the positional argument, that key is added. If no argument is given, a fresh K-256
keypair is generated and the private half is printed to stdout. Save it offline as
your recovery key, since it will not be shown again.
By default the new key is inserted at the highest priority position (--first), which
allows it to override ops signed by lower-priority keys within PLC's 72-hour recovery
window. Pass --last to append at the lowest priority instead.
The labeler's configured rotation key is used to sign the PLC update.
atcr-labeler plc add-rotation-key --config config.yaml # generate + print
atcr-labeler plc add-rotation-key --config config.yaml --last # append, low priority
atcr-labeler plc add-rotation-key --config config.yaml z... # use supplied key`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
firstSet := cmd.Flags().Changed("first")
lastSet := cmd.Flags().Changed("last")
if firstSet && lastSet {
return fmt.Errorf("--first and --last are mutually exclusive")
}
prepend := !plcAddRotationKeyLast
cfg, err := labeler.LoadConfig(plcConfigFile)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if cfg.Labeler.DIDMethod != "plc" {
return fmt.Errorf("this command only works with did:plc (labeler.did_method is %q)", cfg.Labeler.DIDMethod)
}
ctx := context.Background()
labelerDID, rotationKey, signingKey, err := loadLabelerPLCIdentity(ctx, cfg)
if err != nil {
return err
}
var newKey atcrypto.PrivateKeyExportable
if len(args) == 1 {
newKey, err = atcrypto.ParsePrivateMultibase(args[0])
if err != nil {
return fmt.Errorf("failed to parse key argument: %w", err)
}
}
res, err := did.AddRotationKey(ctx, did.AddRotationKeyOptions{
DID: labelerDID,
PLCDirectoryURL: cfg.PLCDirectoryURL(),
RotationKey: rotationKey,
SigningKey: signingKey,
VerificationKeyName: "atproto_label",
NewKey: newKey,
Prepend: prepend,
})
if err != nil {
return err
}
if res.AlreadyPresent {
fmt.Printf("Key %s is already a rotation key for %s (priority %d of %d)\n",
res.NewKeyDIDKey, labelerDID, res.ExistingAt, res.TotalKeys)
return nil
}
if res.Generated {
fmt.Println("=========================================================================")
fmt.Println("GENERATED NEW ROTATION KEY. SAVE THIS NOW. IT WILL NOT BE SHOWN AGAIN.")
fmt.Println("Store it offline (password manager, paper, hardware token).")
fmt.Println()
fmt.Printf("Private key (multibase): %s\n", res.NewKey.Multibase())
fmt.Printf("Public key (did:key): %s\n", res.NewKeyDIDKey)
fmt.Println("=========================================================================")
}
slog.Info("Added rotation key to PLC identity",
"did", labelerDID,
"new_key", res.NewKeyDIDKey,
"priority", res.InsertedAt,
"total_rotation_keys", res.TotalKeys,
"generated", res.Generated,
)
fmt.Printf("Added rotation key %s to %s (priority %d of %d)\n",
res.NewKeyDIDKey, labelerDID, res.InsertedAt, res.TotalKeys)
return nil
},
}
var plcListRotationKeysCmd = &cobra.Command{
Use: "list-rotation-keys",
Short: "List rotation keys in this labeler's PLC document",
Long: `Fetch the labeler's did:plc document from the PLC directory and print its
rotation keys in priority order (index 0 is highest priority and can override
ops signed by lower-priority keys within PLC's 72-hour recovery window).
The key matching the local labeler.rotation_key is marked as LOCAL.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := labeler.LoadConfig(plcConfigFile)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if cfg.Labeler.DIDMethod != "plc" {
return fmt.Errorf("this command only works with did:plc (labeler.did_method is %q)", cfg.Labeler.DIDMethod)
}
ctx := context.Background()
labelerDID, _, _, err := loadLabelerPLCIdentity(ctx, cfg)
if err != nil {
return err
}
var localRotationKey atcrypto.PrivateKey
if cfg.Labeler.RotationKey != "" {
localRotationKey, err = atcrypto.ParsePrivateMultibase(cfg.Labeler.RotationKey)
if err != nil {
return fmt.Errorf("failed to parse rotation_key from config: %w", err)
}
}
res, err := did.ListRotationKeys(ctx, did.ListRotationKeysOptions{
DID: labelerDID,
PLCDirectoryURL: cfg.PLCDirectoryURL(),
LocalRotationKey: localRotationKey,
})
if err != nil {
return err
}
printRotationKeys(res)
return nil
},
}
// loadLabelerPLCIdentity is the shared "load DID + rotation key + signing key" helper
// used by every PLC command. Mirrors loadHoldPLCIdentity over in cmd/hold/plc.go.
func loadLabelerPLCIdentity(ctx context.Context, cfg *labeler.Config) (string, atcrypto.PrivateKey, *atcrypto.PrivateKeyK256, error) {
labelerDID, _, err := labeler.LoadIdentity(ctx, cfg)
if err != nil {
return "", nil, nil, err
}
if cfg.Labeler.RotationKey == "" {
return "", nil, nil, fmt.Errorf("labeler.rotation_key must be set to sign PLC updates")
}
rotationKey, err := atcrypto.ParsePrivateMultibase(cfg.Labeler.RotationKey)
if err != nil {
return "", nil, nil, fmt.Errorf("failed to parse rotation_key from config: %w", err)
}
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath())
if err != nil {
return "", nil, nil, fmt.Errorf("failed to load signing key: %w", err)
}
return labelerDID, rotationKey, signingKey, nil
}
// printRotationKeys is the shared CLI output for list-rotation-keys, kept identical to
// the hold version since the formatting is service-agnostic.
func printRotationKeys(res *did.ListRotationKeysResult) {
fmt.Printf("DID: %s\n", res.DID)
fmt.Printf("PLC directory: %s\n", res.Directory)
fmt.Printf("Rotation keys (%d):\n", len(res.Keys))
for i, k := range res.Keys {
marker := ""
switch {
case len(res.Keys) == 1:
marker = "(only key)"
case i == 0:
marker = "(highest priority)"
case i == len(res.Keys)-1:
marker = "(lowest priority)"
}
localTag := ""
if res.LocalDIDKey != "" && k == res.LocalDIDKey {
localTag = " [LOCAL — labeler.rotation_key]"
}
fmt.Printf(" [%d] %s %s%s\n", i, k, marker, localTag)
}
if res.LocalDIDKey != "" && !res.LocalPresent {
fmt.Printf("\nWARNING: local rotation_key (%s) is NOT present in the PLC document.\n", res.LocalDIDKey)
fmt.Println("This service cannot sign PLC updates. Possible compromise or out-of-band rotation.")
}
}
func init() {
plcCmd.PersistentFlags().StringVarP(&plcConfigFile, "config", "c", "", "path to YAML configuration file")
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyFirst, "first", true, "insert at highest priority (default)")
plcAddRotationKeyCmd.Flags().BoolVar(&plcAddRotationKeyLast, "last", false, "insert at lowest priority")
plcCmd.AddCommand(plcAddRotationKeyCmd)
plcCmd.AddCommand(plcListRotationKeysCmd)
}
+55
View File
@@ -0,0 +1,55 @@
# ATCR Labeler Configuration
# Generated with defaults — edit as needed.
# Configuration format version.
version: "0.1"
# Log level: debug, info, warn, error.
log_level: info
# Labeler service settings.
labeler:
# Enable the labeler service.
enabled: true
# Listen address for labeler (e.g., :5002).
addr: :5002
# Externally reachable labeler URL. Empty = derive from server.base_url.
public_url: ""
# DID of the labeler admin. Only this DID can log into the admin panel.
owner_did: did:plc:your-did-here
# Directory for labeler state (database, signing key, did.txt).
data_dir: /var/lib/atcr-labeler
# DID method: "plc" (recommended) or "web".
did_method: plc
# Explicit did:plc identifier for adoption/recovery (optional).
did: ""
# Path to K-256 signing key (defaults to <data_dir>/signing.key).
key_path: ""
# Multibase-encoded rotation key (K-256 or P-256). Required to update the PLC document.
rotation_key: ""
# PLC directory URL (default https://plc.directory).
plc_directory_url: https://plc.directory
# Optional libSQL/Bunny remote sync URL. Empty = local-only.
libsql_sync_url: ""
# Auth token for libsql_sync_url.
libsql_auth_token: ""
# Embedded-replica pull interval (e.g. 30s). 0 = manual sync only.
libsql_sync_interval: 0s
# AppView server settings (shared config).
server:
base_url: https://atcr.io
client_name: AT Container Registry
client_short_name: ATCR
test_mode: false
# Remote log shipping settings.
log_shipper:
# Log shipping backend: "victoria", "opensearch", or "loki". Empty disables shipping.
backend: ""
# Remote log service endpoint, e.g. "http://victorialogs:9428".
url: ""
# Number of log entries to buffer before flushing to the remote service.
batch_size: 0
# Maximum time between flushes, even if batch is not full.
flush_interval: 0s
# Basic auth username for the log service (optional).
username: ""
# Basic auth password for the log service (optional).
password: ""
+6 -1
View File
@@ -11,7 +11,12 @@ labeler:
enabled: true
addr: :5002
owner_did: ""
db_path: "{{.BasePath}}/labeler/labeler.db"
data_dir: "{{.BasePath}}/labeler"
did_method: plc
did: ""
key_path: ""
rotation_key: ""
plc_directory_url: https://plc.directory
server:
base_url: "https://seamark.dev"
client_name: Seamark
+50
View File
@@ -19,6 +19,9 @@ services:
# ATCR_SERVER_CLIENT_SHORT_NAME: "Seamark"
ATCR_SERVER_MANAGED_HOLDS: did:web:172.28.0.3%3A8080
ATCR_SERVER_DEFAULT_HOLD_DID: did:web:172.28.0.3%3A8080
# Labeler URL (HTTP for dev — ParseLabelerURL accepts it directly so we don't
# have to round-trip through did:web → https:// resolution).
ATCR_LABELER_DID: http://172.28.0.4:5002
ATCR_SERVER_TEST_MODE: true
ATCR_LOG_LEVEL: debug
LOG_SHIPPER_BACKEND: victoria
@@ -97,6 +100,52 @@ services:
atcr-network:
ipv4_address: 172.28.0.3
atcr-labeler:
# Base config: config-labeler.example.yaml (passed via Air entrypoint).
# Env vars below override config file values for local dev.
#
# Why did:web for dev: did:plc would submit a real PLC operation to plc.directory
# for every fresh dev environment, polluting production with throwaway DIDs that
# point at 172.28.0.x. did:web is purely self-served via /.well-known/did.json so
# nothing leaks. Switch to plc + a real public_url for production.
environment:
LABELER_LABELER_DID_METHOD: web
LABELER_LABELER_PUBLIC_URL: http://172.28.0.4:5002
LABELER_LABELER_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg
LABELER_LABELER_DATA_DIR: /var/lib/atcr-labeler
LABELER_SERVER_TEST_MODE: true
LABELER_LOG_LEVEL: debug
LOG_SHIPPER_BACKEND: victoria
LOG_SHIPPER_URL: http://172.28.0.10:9428
logging:
driver: json-file
options:
max-size: "10m"
max-file: "1"
build:
context: .
dockerfile: Dockerfile.dev
args:
AIR_CONFIG: .air.labeler.toml
image: atcr-labeler-dev:latest
container_name: atcr-labeler
ports:
- "5002:5002"
volumes:
# Mount source code for Air hot reload
- .:/app:z
- go-mod-cache:/go/pkg/mod
# Persist signing key + did.txt + label database across container restarts so
# dev signatures stay verifiable. Wipe with `docker compose down -v` to reset.
- atcr-labeler:/var/lib/atcr-labeler
restart: unless-stopped
dns:
- 8.8.8.8
- 1.1.1.1
networks:
atcr-network:
ipv4_address: 172.28.0.4
# Victoria Logs for centralized log storage
# Uncomment to enable, then set LOG_SHIPPER_* env vars above
victorialogs:
@@ -123,6 +172,7 @@ networks:
volumes:
atcr-hold:
atcr-labeler:
atcr-auth:
atcr-ui:
go-mod-cache:
+28 -15
View File
@@ -16,6 +16,31 @@ func BlobCDNURL(did, cid string) string {
return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
}
// activeTakedownClause returns a SQL fragment ready to drop into a `WHERE NOT
// EXISTS (...)` filter for excluding rows whose `(did, repository)` pair is currently
// taken down. The `alias` argument is the outer table alias (e.g. "m" for manifests,
// "lm" for latest_manifests) and must already be in scope at the use site.
//
// Mirrors the semantics of `IsTakenDown` (defined in labels.go) so listings stay
// consistent with the per-repo page check: a label only counts as active when it has
// neg=0, no newer neg=1 row with the same (src, uri, val), and a non-expired `exp`.
// Without these clauses listings hide a repo forever once you've ever taken it down,
// even after a reversal.
func activeTakedownClause(alias string) string {
return `NOT EXISTS (
SELECT 1 FROM labels l1
WHERE l1.subject_did = ` + alias + `.did
AND (l1.subject_repo = ` + alias + `.repository OR l1.subject_repo = '')
AND l1.val = '!takedown' AND l1.neg = 0
AND NOT EXISTS (
SELECT 1 FROM labels l2
WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
AND l2.neg = 1 AND l2.id > l1.id
)
AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)
)`
}
// accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs
// the viewer is allowed to see in listings. Requires the viewerDID to be
// passed twice as query arguments (once for the owner_did check and once
@@ -107,11 +132,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID
WHERE ra.did = lm.did AND ra.repository = lm.repository
AND ra.value LIKE ? ESCAPE '\'
))
AND NOT EXISTS (
SELECT 1 FROM labels
WHERE (subject_did = lm.did AND (subject_repo = lm.repository OR subject_repo = ''))
AND val = '!takedown' AND neg = 0
)
AND ` + activeTakedownClause("lm") + `
),
repo_stats AS (
SELECT
@@ -2122,11 +2143,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS
JOIN users u ON m.did = u.did
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
WHERE NOT EXISTS (
SELECT 1 FROM labels
WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = ''))
AND val = '!takedown' AND neg = 0
)
WHERE ` + activeTakedownClause("m") + `
ORDER BY ` + orderBy + `
LIMIT ?
`
@@ -2205,11 +2222,7 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar
JOIN users u ON m.did = u.did
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
WHERE NOT EXISTS (
SELECT 1 FROM labels
WHERE (subject_did = m.did AND (subject_repo = m.repository OR subject_repo = ''))
AND val = '!takedown' AND neg = 0
)
WHERE ` + activeTakedownClause("m") + `
ORDER BY MAX(rs.last_push, m.created_at) DESC
`
+8 -9
View File
@@ -39,18 +39,17 @@ func InitializeDatabase(dbPath string, cfg LibsqlConfig) (*sql.DB, *sql.DB, *Ses
} else {
roDSN += "?mode=ro"
}
readOnlyDB, err := sql.Open("libsql", roDSN)
// Wrap with busyTimeoutConnector so every pooled read-only connection
// gets PRAGMA busy_timeout. Without this, reads return SQLITE_BUSY
// immediately when a write is in progress on the read-write connection
// (busy_timeout is per-connection, so a one-shot PRAGMA only configures
// whichever conn served it).
roBase, err := openLibsqlLocalConnector(roDSN)
if err != nil {
slog.Warn("Failed to open read-only database connection", "error", err)
slog.Warn("Failed to open read-only database connector", "error", err)
return nil, nil, nil
}
// busy_timeout is per-connection — without this, reads return SQLITE_BUSY
// immediately when a write is in progress on the read-write connection.
var busyTimeout int
if err := readOnlyDB.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
slog.Warn("Failed to set busy_timeout on read-only connection", "error", err)
}
readOnlyDB := sql.OpenDB(&busyTimeoutConnector{base: roBase, timeoutMs: 5000})
slog.Info("UI database initialized", "mode", "readonly", "path", dbPath)
+71 -15
View File
@@ -5,7 +5,9 @@
package db
import (
"context"
"database/sql"
"database/sql/driver"
"embed"
"fmt"
"io/fs"
@@ -55,36 +57,34 @@ func InitDB(path string, cfg LibsqlConfig) (*sql.DB, error) {
db = sql.OpenDB(connector)
slog.Info("Database opened in embedded replica mode", "path", path, "sync_url", cfg.SyncURL)
} else {
// Local-only mode: plain file via libsql driver
// Paths starting with "file:" or ":memory:" are already valid libsql URIs
// Local-only mode: plain file via libsql driver, wrapped so every new
// connection gets PRAGMA busy_timeout. SQLite's busy_timeout is
// per-connection, so a one-shot db.Exec only configures whichever
// pooled conn served the call — leaving the rest to fail SQLITE_BUSY
// instantly on any write contention with the jetstream/backfill workers.
// Paths starting with "file:" or ":memory:" are already valid libsql URIs.
dsn := path
if !strings.HasPrefix(path, "file:") && !strings.HasPrefix(path, ":memory:") {
dsn = "file:" + path
}
var err error
db, err = sql.Open("libsql", dsn)
baseConnector, err := openLibsqlLocalConnector(dsn)
if err != nil {
return nil, err
}
db = sql.OpenDB(&busyTimeoutConnector{base: baseConnector, timeoutMs: 5000})
slog.Info("Database opened in local-only mode", "path", path)
}
// In local-only mode, configure WAL and busy_timeout locally.
// In embedded replica mode, the remote server manages these settings
// and PRAGMA assignments are rejected as "unsupported statement"
// (observed with Bunny Database; Turso may behave similarly).
// In local-only mode, set WAL mode (database-wide setting, persists
// across connections — single call is sufficient unlike busy_timeout).
// In embedded replica mode, the remote server manages this and the
// PRAGMA is rejected as "unsupported statement" (observed with Bunny;
// Turso may behave similarly).
if cfg.SyncURL == "" {
// Enable WAL mode for concurrent read/write access
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
return nil, err
}
// Retry on lock instead of failing immediately (5s timeout)
var busyTimeout int
if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
return nil, err
}
}
// Enable foreign keys
@@ -377,3 +377,59 @@ func parseMigrationFilename(filename string) (int, string, error) {
return version, name, nil
}
// openLibsqlLocalConnector returns a driver.Connector for a local libsql DSN.
// go-libsql exports NewEmbeddedReplicaConnector for replica mode but no public
// constructor for local files, so we obtain the driver via a probe sql.Open
// (which is lazy and opens no connection) and ask it for a Connector.
func openLibsqlLocalConnector(dsn string) (driver.Connector, error) {
probe, err := sql.Open("libsql", dsn)
if err != nil {
return nil, fmt.Errorf("probe libsql driver: %w", err)
}
drv := probe.Driver()
_ = probe.Close()
dctx, ok := drv.(driver.DriverContext)
if !ok {
return nil, fmt.Errorf("libsql driver does not implement driver.DriverContext")
}
return dctx.OpenConnector(dsn)
}
// busyTimeoutConnector wraps a driver.Connector and runs PRAGMA busy_timeout
// on every newly opened connection. SQLite's busy_timeout is per-connection,
// so this is the only way to ensure every conn in the pool waits on lock
// contention instead of returning SQLITE_BUSY immediately.
type busyTimeoutConnector struct {
base driver.Connector
timeoutMs int
}
func (c *busyTimeoutConnector) Connect(ctx context.Context) (driver.Conn, error) {
conn, err := c.base.Connect(ctx)
if err != nil {
return nil, err
}
// libsql treats PRAGMA assignments as queries that return a row, so we
// must use QueryerContext rather than ExecerContext.
queryer, ok := conn.(driver.QueryerContext)
if !ok {
_ = conn.Close()
return nil, fmt.Errorf("libsql conn does not support QueryerContext")
}
rows, err := queryer.QueryContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", c.timeoutMs), nil)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("set busy_timeout on new conn: %w", err)
}
_ = rows.Close()
return conn, nil
}
func (c *busyTimeoutConnector) Driver() driver.Driver {
return c.base.Driver()
}
+1 -1
View File
@@ -234,7 +234,7 @@ func (h *ManifestDiffHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
+1 -1
View File
@@ -105,7 +105,7 @@ func (h *DigestDetailHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
+1 -1
View File
@@ -63,7 +63,7 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
// Opportunistically update cached handle if it changed
if owner.Handle != resolvedHandle {
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
owner.Handle = resolvedHandle
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
} else if viewedUser.Handle != resolvedHandle {
// Opportunistically update cached handle if it changed
_ = db.UpdateUserHandle(h.ReadOnlyDB, did, resolvedHandle)
_ = db.UpdateUserHandle(h.DB, did, resolvedHandle)
viewedUser.Handle = resolvedHandle
}
+83 -38
View File
@@ -3,8 +3,9 @@
package labeler
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
@@ -13,26 +14,11 @@ import (
"atcr.io/pkg/appview/db"
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/events"
"github.com/gorilla/websocket"
)
// LabelsMessage is the wire format for subscribeLabels events.
type LabelsMessage struct {
Seq int64 `json:"seq"`
Labels []LabelEvent `json:"labels"`
}
// LabelEvent is a single label from the labeler.
type LabelEvent struct {
Src string `json:"src"`
URI string `json:"uri"`
CID string `json:"cid,omitempty"`
Val string `json:"val"`
Neg bool `json:"neg"`
Cts string `json:"cts"`
Exp string `json:"exp,omitempty"`
}
// Subscriber connects to a labeler's subscribeLabels endpoint
// and mirrors labels into the appview database.
type Subscriber struct {
@@ -121,35 +107,55 @@ func (s *Subscriber) connect() error {
default:
}
var msg LabelsMessage
if err := conn.ReadJSON(&msg); err != nil {
mt, payload, err := conn.ReadMessage()
if err != nil {
return fmt.Errorf("read error: %w", err)
}
// Per the ATProto event-stream spec each frame is a binary message; reject text.
if mt != websocket.BinaryMessage {
slog.Warn("Ignoring non-binary frame from labeler", "type", mt)
continue
}
for _, le := range msg.Labels {
seq, labels, err := decodeFrame(payload)
if err != nil {
if errors.Is(err, errInfoFrame) {
continue // already logged inside decodeFrame
}
return fmt.Errorf("decode frame: %w", err)
}
for _, le := range labels {
cts, _ := time.Parse(time.RFC3339, le.Cts)
did, repo := extractSubjectFromURI(le.URI)
did, repo := extractSubjectFromURI(le.Uri)
label := &db.Label{
Src: le.Src,
URI: le.URI,
URI: le.Uri,
Val: le.Val,
Neg: le.Neg,
Neg: le.Neg != nil && *le.Neg,
Cts: cts,
SubjectDID: did,
SubjectRepo: repo,
Seq: msg.Seq,
Seq: seq,
}
if err := db.UpsertLabel(s.database, label); err != nil {
slog.Warn("Failed to upsert label", "uri", le.URI, "error", err)
slog.Warn("Failed to upsert label", "uri", le.Uri, "error", err)
continue
}
slog.Info("Mirrored label",
"uri", le.URI,
// "Mirrored label X" reads as an apply; reversals are a different action
// from the operator's POV (and a different SQL effect — the NOT EXISTS
// negation clause kicks in), so log them distinctly.
msg := "Mirrored label"
if label.Neg {
msg = "Mirrored label reversal"
}
slog.Info(msg,
"uri", le.Uri,
"val", le.Val,
"neg", le.Neg,
"neg", label.Neg,
"subject_did", did,
"subject_repo", repo,
)
@@ -157,6 +163,54 @@ func (s *Subscriber) connect() error {
}
}
// errInfoFrame is returned by decodeFrame when the frame is informational and the
// caller should just continue to the next message.
var errInfoFrame = errors.New("labeler: info frame")
// decodeFrame parses a single subscribeLabels binary frame. ATProto event-stream framing
// is two concatenated CBOR objects: a {op,t} header and a body. We dispatch on the
// header op/t pair and return the labels body for op=1, t="#labels". For #info frames
// we log and signal errInfoFrame so the caller skips. Error frames (op=-1) become Go
// errors so the run loop reconnects with backoff.
func decodeFrame(payload []byte) (int64, []*comatproto.LabelDefs_Label, error) {
r := bytes.NewReader(payload)
var header events.EventHeader
if err := header.UnmarshalCBOR(r); err != nil {
return 0, nil, fmt.Errorf("unmarshal header: %w", err)
}
switch {
case header.Op == events.EvtKindErrorFrame:
var ef events.ErrorFrame
if err := ef.UnmarshalCBOR(r); err != nil {
return 0, nil, fmt.Errorf("unmarshal error frame: %w", err)
}
return 0, nil, fmt.Errorf("labeler error frame: %s — %s", ef.Error, ef.Message)
case header.Op == events.EvtKindMessage && header.MsgType == "#labels":
var body comatproto.LabelSubscribeLabels_Labels
if err := body.UnmarshalCBOR(r); err != nil {
return 0, nil, fmt.Errorf("unmarshal labels body: %w", err)
}
return body.Seq, body.Labels, nil
case header.Op == events.EvtKindMessage && header.MsgType == "#info":
var info comatproto.LabelSubscribeLabels_Info
if err := info.UnmarshalCBOR(r); err != nil {
return 0, nil, fmt.Errorf("unmarshal info body: %w", err)
}
message := ""
if info.Message != nil {
message = *info.Message
}
slog.Info("Labeler info frame", "name", info.Name, "message", message)
return 0, nil, errInfoFrame
default:
return 0, nil, fmt.Errorf("unexpected frame op=%d t=%q", header.Op, header.MsgType)
}
}
// extractSubjectFromURI extracts the DID and repository from an AT URI.
// Examples:
//
@@ -228,12 +282,3 @@ func SubscriberFromConfig(labelerDIDOrURL string, database *sql.DB) *Subscriber
labelerURL := ParseLabelerURL(labelerDIDOrURL)
return NewSubscriber(labelerURL, database)
}
// DecodeLabelsFromJSON decodes a JSON-encoded labels message.
func DecodeLabelsFromJSON(data []byte) (*LabelsMessage, error) {
var msg LabelsMessage
if err := json.Unmarshal(data, &msg); err != nil {
return nil, err
}
return &msg, nil
}
+208
View File
@@ -0,0 +1,208 @@
package did
import (
"context"
"fmt"
"github.com/bluesky-social/indigo/atproto/atcrypto"
didplc "github.com/did-method-plc/go-didplc"
)
// AddRotationKeyOptions configures a rotation-key insert operation.
type AddRotationKeyOptions struct {
// DID is the resolved did:plc identifier of the service.
DID string
// PLCDirectoryURL is the PLC directory endpoint (defaults to https://plc.directory if empty).
PLCDirectoryURL string
// RotationKey is the currently-authorized rotation key used to sign the update op.
RotationKey atcrypto.PrivateKey
// SigningKey is the local k256 verification key — its public part goes into the
// new op's VerificationMethods so we don't accidentally drop it during the update.
SigningKey *atcrypto.PrivateKeyK256
// VerificationKeyName is the fragment under which SigningKey is registered
// (e.g. "atproto" for a PDS, "atproto_label" for a labeler).
VerificationKeyName string
// NewKey is the rotation key to add. If nil, a fresh K-256 key is generated and
// returned in the result so the caller can print/persist it.
NewKey atcrypto.PrivateKeyExportable
// Prepend places the new key at the highest priority position. When false the key
// is appended at the lowest priority — only set false when the operator explicitly
// asks for it.
Prepend bool
}
// AddRotationKeyResult describes the outcome of an AddRotationKey call.
type AddRotationKeyResult struct {
NewKey atcrypto.PrivateKeyExportable
NewKeyDIDKey string
Generated bool
AlreadyPresent bool
ExistingAt int
InsertedAt int
TotalKeys int
}
// AddRotationKey fetches the current PLC op log, inserts NewKey (generating one if nil),
// signs the update with RotationKey, and submits it. Caller is responsible for printing
// the generated key material — this function returns it on the result so prints can
// happen in the binary's own format.
func AddRotationKey(ctx context.Context, opt AddRotationKeyOptions) (*AddRotationKeyResult, error) {
if opt.DID == "" {
return nil, fmt.Errorf("plc: DID is required")
}
if opt.RotationKey == nil {
return nil, fmt.Errorf("plc: rotation key is required to sign updates")
}
if opt.SigningKey == nil {
return nil, fmt.Errorf("plc: signing key is required (becomes verificationMethods.%s)", opt.VerificationKeyName)
}
if opt.VerificationKeyName == "" {
return nil, fmt.Errorf("plc: VerificationKeyName is required")
}
directory := opt.PLCDirectoryURL
if directory == "" {
directory = "https://plc.directory"
}
client := &didplc.Client{DirectoryURL: directory}
res := &AddRotationKeyResult{NewKey: opt.NewKey}
if res.NewKey == nil {
raw, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
return nil, fmt.Errorf("plc: failed to generate rotation key: %w", err)
}
res.NewKey = raw
res.Generated = true
}
newPub, err := res.NewKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("plc: failed to derive new public key: %w", err)
}
res.NewKeyDIDKey = newPub.DIDKey()
opLog, err := client.OpLog(ctx, opt.DID)
if err != nil {
return nil, fmt.Errorf("plc: failed to fetch op log for %s: %w", opt.DID, err)
}
if len(opLog) == 0 {
return nil, fmt.Errorf("plc: empty op log for %s", opt.DID)
}
lastEntry := opLog[len(opLog)-1]
lastOp := lastEntry.Regular
if lastOp == nil {
return nil, fmt.Errorf("plc: last operation is not a regular op")
}
for i, k := range lastOp.RotationKeys {
if k == res.NewKeyDIDKey {
res.AlreadyPresent = true
res.ExistingAt = i
res.TotalKeys = len(lastOp.RotationKeys)
return res, nil
}
}
rotationKeys := make([]string, 0, len(lastOp.RotationKeys)+1)
if opt.Prepend {
rotationKeys = append(rotationKeys, res.NewKeyDIDKey)
rotationKeys = append(rotationKeys, lastOp.RotationKeys...)
res.InsertedAt = 0
} else {
rotationKeys = append(rotationKeys, lastOp.RotationKeys...)
rotationKeys = append(rotationKeys, res.NewKeyDIDKey)
res.InsertedAt = len(rotationKeys) - 1
}
res.TotalKeys = len(rotationKeys)
sigPub, err := opt.SigningKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("plc: failed to derive signing public key: %w", err)
}
prevCID := lastEntry.AsOperation().CID().String()
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: rotationKeys,
VerificationMethods: map[string]string{
opt.VerificationKeyName: sigPub.DIDKey(),
},
AlsoKnownAs: lastOp.AlsoKnownAs,
Services: lastOp.Services,
Prev: &prevCID,
}
if err := op.Sign(opt.RotationKey); err != nil {
return nil, fmt.Errorf("plc: failed to sign update: %w", err)
}
if err := client.Submit(ctx, opt.DID, op); err != nil {
return nil, fmt.Errorf("plc: failed to submit update: %w", err)
}
return res, nil
}
// ListRotationKeysOptions configures a list-rotation-keys read.
type ListRotationKeysOptions struct {
DID string
PLCDirectoryURL string
LocalRotationKey atcrypto.PrivateKey // optional — used to compute the LOCAL marker
}
// ListRotationKeysResult holds the priority-ordered rotation keys plus the local
// rotation key's did:key form (if provided), so callers can mark and warn appropriately.
type ListRotationKeysResult struct {
DID string
Directory string
Keys []string
LocalDIDKey string
LocalPresent bool
}
// ListRotationKeys fetches the current PLC op and returns its rotation keys in priority order.
func ListRotationKeys(ctx context.Context, opt ListRotationKeysOptions) (*ListRotationKeysResult, error) {
if opt.DID == "" {
return nil, fmt.Errorf("plc: DID is required")
}
directory := opt.PLCDirectoryURL
if directory == "" {
directory = "https://plc.directory"
}
client := &didplc.Client{DirectoryURL: directory}
opLog, err := client.OpLog(ctx, opt.DID)
if err != nil {
return nil, fmt.Errorf("plc: failed to fetch op log for %s: %w", opt.DID, err)
}
if len(opLog) == 0 {
return nil, fmt.Errorf("plc: empty op log for %s", opt.DID)
}
lastOp := opLog[len(opLog)-1].Regular
if lastOp == nil {
return nil, fmt.Errorf("plc: last operation is not a regular op")
}
res := &ListRotationKeysResult{
DID: opt.DID,
Directory: directory,
Keys: append([]string(nil), lastOp.RotationKeys...),
}
if opt.LocalRotationKey != nil {
pub, err := opt.LocalRotationKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("plc: failed to derive local rotation public key: %w", err)
}
res.LocalDIDKey = pub.DIDKey()
for _, k := range res.Keys {
if k == res.LocalDIDKey {
res.LocalPresent = true
break
}
}
}
return res, nil
}
+304
View File
@@ -0,0 +1,304 @@
package did
import (
"context"
"strings"
"testing"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// TestAddRotationKey_AppendNew confirms a fresh key is appended at the lowest priority
// when Prepend is false.
func TestAddRotationKey_AppendNew(t *testing.T) {
ctx := context.Background()
serverRot := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
newKey := generateK256(t)
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
RotationKey: serverRot,
SigningKey: signing,
VerificationKeyName: "atproto",
NewKey: newKey,
Prepend: false,
})
if err != nil {
t.Fatalf("AddRotationKey: %v", err)
}
if res.AlreadyPresent {
t.Fatal("AlreadyPresent should be false")
}
if res.Generated {
t.Error("Generated should be false when NewKey provided")
}
if res.TotalKeys != 2 {
t.Errorf("TotalKeys: got %d want 2", res.TotalKeys)
}
if res.InsertedAt != 1 {
t.Errorf("InsertedAt: got %d want 1 (appended)", res.InsertedAt)
}
if len(fake.submitted) != 1 {
t.Fatalf("expected one update submission, got %d", len(fake.submitted))
}
got := fake.submitted[0]
newPub, _ := newKey.PublicKey()
if got.RotationKeys[len(got.RotationKeys)-1] != newPub.DIDKey() {
t.Errorf("appended key not at last position: %v", got.RotationKeys)
}
}
// TestAddRotationKey_Prepend confirms Prepend=true puts the new key at index 0
// (highest priority position).
func TestAddRotationKey_Prepend(t *testing.T) {
ctx := context.Background()
serverRot := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
newKey := generateK256(t)
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
RotationKey: serverRot,
SigningKey: signing,
VerificationKeyName: "atproto",
NewKey: newKey,
Prepend: true,
})
if err != nil {
t.Fatalf("AddRotationKey: %v", err)
}
if res.InsertedAt != 0 {
t.Errorf("InsertedAt: got %d want 0", res.InsertedAt)
}
if len(fake.submitted) != 1 {
t.Fatalf("expected one update, got %d", len(fake.submitted))
}
newPub, _ := newKey.PublicKey()
if fake.submitted[0].RotationKeys[0] != newPub.DIDKey() {
t.Errorf("prepended key not at first position: %v", fake.submitted[0].RotationKeys)
}
}
// TestAddRotationKey_GeneratesWhenNil confirms the helper generates a fresh key
// and reports it via Result.
func TestAddRotationKey_GeneratesWhenNil(t *testing.T) {
ctx := context.Background()
serverRot := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
RotationKey: serverRot,
SigningKey: signing,
VerificationKeyName: "atproto",
NewKey: nil,
})
if err != nil {
t.Fatalf("AddRotationKey: %v", err)
}
if !res.Generated {
t.Error("Generated should be true when NewKey is nil")
}
if res.NewKey == nil {
t.Fatal("NewKey on result should not be nil")
}
if res.NewKeyDIDKey == "" {
t.Error("NewKeyDIDKey should be populated")
}
}
// TestAddRotationKey_AlreadyPresent confirms a no-op when the key is already in the list.
func TestAddRotationKey_AlreadyPresent(t *testing.T) {
ctx := context.Background()
serverRot := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
res, err := AddRotationKey(ctx, AddRotationKeyOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
RotationKey: serverRot,
SigningKey: signing,
VerificationKeyName: "atproto",
NewKey: serverRot,
})
if err != nil {
t.Fatalf("AddRotationKey: %v", err)
}
if !res.AlreadyPresent {
t.Error("AlreadyPresent should be true")
}
if res.ExistingAt != 0 {
t.Errorf("ExistingAt: got %d want 0", res.ExistingAt)
}
if len(fake.submitted) != 0 {
t.Errorf("no submission expected when key already present, got %d", len(fake.submitted))
}
}
// TestAddRotationKey_ValidationErrors covers the early-return guard clauses in AddRotationKey.
func TestAddRotationKey_ValidationErrors(t *testing.T) {
ctx := context.Background()
signing := generateK256(t)
rot := generateK256(t)
cases := []struct {
name string
opt AddRotationKeyOptions
wantSub string
}{
{
name: "missing DID",
opt: AddRotationKeyOptions{RotationKey: rot, SigningKey: signing, VerificationKeyName: "atproto"},
wantSub: "DID is required",
},
{
name: "missing rotation key",
opt: AddRotationKeyOptions{DID: "did:plc:abc", SigningKey: signing, VerificationKeyName: "atproto"},
wantSub: "rotation key is required",
},
{
name: "missing signing key",
opt: AddRotationKeyOptions{DID: "did:plc:abc", RotationKey: rot, VerificationKeyName: "atproto"},
wantSub: "signing key is required",
},
{
name: "missing verification key name",
opt: AddRotationKeyOptions{DID: "did:plc:abc", RotationKey: rot, SigningKey: signing},
wantSub: "VerificationKeyName is required",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := AddRotationKey(ctx, tc.opt)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tc.wantSub) {
t.Errorf("error: got %q want substring %q", err.Error(), tc.wantSub)
}
})
}
}
// TestListRotationKeys returns the priority-ordered keys from the latest op.
func TestListRotationKeys(t *testing.T) {
ctx := context.Background()
rot1 := generateK256(t)
rot2 := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{rot1, rot2}, rot1, signing)
defer fake.Close()
res, err := ListRotationKeys(ctx, ListRotationKeysOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
})
if err != nil {
t.Fatalf("ListRotationKeys: %v", err)
}
if res.DID != fake.did {
t.Errorf("DID: got %s want %s", res.DID, fake.did)
}
if res.Directory != fake.URL() {
t.Errorf("Directory: got %s want %s", res.Directory, fake.URL())
}
if len(res.Keys) != 2 {
t.Fatalf("Keys length: got %d want 2", len(res.Keys))
}
pub1, _ := rot1.PublicKey()
pub2, _ := rot2.PublicKey()
if res.Keys[0] != pub1.DIDKey() {
t.Errorf("Keys[0]: got %s want %s", res.Keys[0], pub1.DIDKey())
}
if res.Keys[1] != pub2.DIDKey() {
t.Errorf("Keys[1]: got %s want %s", res.Keys[1], pub2.DIDKey())
}
if res.LocalDIDKey != "" {
t.Errorf("LocalDIDKey should be empty when no LocalRotationKey provided, got %s", res.LocalDIDKey)
}
if res.LocalPresent {
t.Error("LocalPresent should be false when no LocalRotationKey provided")
}
}
// TestListRotationKeys_LocalPresent confirms LocalPresent flips to true when the local
// key matches one in the published list.
func TestListRotationKeys_LocalPresent(t *testing.T) {
ctx := context.Background()
serverRot := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
res, err := ListRotationKeys(ctx, ListRotationKeysOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
LocalRotationKey: serverRot,
})
if err != nil {
t.Fatalf("ListRotationKeys: %v", err)
}
if !res.LocalPresent {
t.Error("LocalPresent should be true")
}
pub, _ := serverRot.PublicKey()
if res.LocalDIDKey != pub.DIDKey() {
t.Errorf("LocalDIDKey: got %s want %s", res.LocalDIDKey, pub.DIDKey())
}
}
// TestListRotationKeys_LocalNotPresent flags a rotated-out local key.
func TestListRotationKeys_LocalNotPresent(t *testing.T) {
ctx := context.Background()
serverRot := generateK256(t)
signing := generateK256(t)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
stranger := generateK256(t)
res, err := ListRotationKeys(ctx, ListRotationKeysOptions{
DID: fake.did,
PLCDirectoryURL: fake.URL(),
LocalRotationKey: stranger,
})
if err != nil {
t.Fatalf("ListRotationKeys: %v", err)
}
if res.LocalPresent {
t.Error("LocalPresent should be false for a stranger key")
}
if res.LocalDIDKey == "" {
t.Error("LocalDIDKey should still be populated even when not present")
}
}
// TestListRotationKeys_MissingDID surfaces the early-return validation.
func TestListRotationKeys_MissingDID(t *testing.T) {
_, err := ListRotationKeys(context.Background(), ListRotationKeysOptions{})
if err == nil {
t.Fatal("expected error for missing DID")
}
if !strings.Contains(err.Error(), "DID is required") {
t.Errorf("error: got %q", err.Error())
}
}
+243
View File
@@ -0,0 +1,243 @@
// Package did provides shared did:web and did:plc identity management for ATCR services.
//
// Both the hold and labeler services declare an ATProto identity with a signing key
// and one or more service endpoints. This package generalizes the genesis/update/load
// flow so callers only have to specify their verification key fragment name and the
// service entries they want to register.
package did
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// Service is a service entry in a DID document or PLC operation.
type Service struct {
Type string
Endpoint string
}
// Config configures DID identity loading or creation.
type Config struct {
// Method is "web" or "plc".
Method string
// PublicURL is the externally reachable URL of the service.
PublicURL string
// DBPath is a directory used to persist did.txt for did:plc identities.
DBPath string
// SigningKeyPath is the on-disk path for the K-256 signing key (will be generated if missing).
SigningKeyPath string
// RotationKey is a multibase-encoded private key used to sign PLC operations (optional).
// If empty for did:plc, a new rotation key is generated and logged once for the operator.
RotationKey string
// PLCDirectoryURL is the PLC directory endpoint.
PLCDirectoryURL string
// DID overrides the persisted DID (used for adoption/recovery of an existing did:plc).
DID string
// VerificationKeyName is the fragment used in the DID document and PLC operation
// for the signing key (e.g. "atproto" for a PDS, "atproto_label" for a labeler).
VerificationKeyName string
// Services lists service entries keyed by service id (e.g. "atproto_pds", "atproto_labeler").
Services map[string]Service
}
// LoadOrCreate returns the service's DID. did:web is derived deterministically from
// PublicURL; did:plc is loaded from disk or created and registered with the PLC directory.
func LoadOrCreate(ctx context.Context, cfg Config) (string, error) {
if cfg.Method != "plc" {
return GenerateDIDFromURL(cfg.PublicURL), nil
}
if cfg.VerificationKeyName == "" {
return "", fmt.Errorf("did: VerificationKeyName is required for did:plc")
}
if len(cfg.Services) == 0 {
return "", fmt.Errorf("did: at least one service entry is required for did:plc")
}
didPath := filepath.Join(cfg.DBPath, "did.txt")
var d string
if cfg.DID != "" {
if !strings.HasPrefix(cfg.DID, "did:plc:") {
return "", fmt.Errorf("did: DID must be a did:plc identifier, got %q", cfg.DID)
}
d = cfg.DID
slog.Info("Using DID from config (adoption/recovery)", "did", d)
} else if data, err := os.ReadFile(didPath); err == nil {
val := strings.TrimSpace(string(data))
if strings.HasPrefix(val, "did:plc:") {
d = val
slog.Info("Loaded existing did:plc identity", "did", d)
}
}
if d != "" {
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
return "", fmt.Errorf("did: failed to create did.txt directory: %w", err)
}
if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil {
return "", fmt.Errorf("did: failed to write did.txt: %w", err)
}
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
if err != nil {
return "", fmt.Errorf("did: failed to load signing key: %w", err)
}
rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey)
if err := EnsureCurrent(ctx, d, rotationKey, signingKey, cfg); err != nil {
slog.Warn("Failed to verify PLC identity is current (will retry on next restart)",
"did", d, "error", err)
}
return d, nil
}
slog.Info("Creating new did:plc identity")
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
if err != nil {
return "", fmt.Errorf("did: failed to load signing key: %w", err)
}
var rotationKey atcrypto.PrivateKeyExportable
if cfg.RotationKey != "" {
rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey)
if err != nil {
return "", fmt.Errorf("did: failed to parse rotation_key: %w", err)
}
} else {
rawKey, genErr := atcrypto.GeneratePrivateKeyK256()
if genErr != nil {
return "", fmt.Errorf("did: failed to generate rotation key: %w", genErr)
}
rotationKey = rawKey
slog.Warn("Generated new rotation key — save this in your config as rotation_key",
"rotation_key", rawKey.Multibase())
}
d, err = CreateIdentity(ctx, rotationKey, signingKey, cfg)
if err != nil {
return "", fmt.Errorf("did: failed to create PLC identity: %w", err)
}
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
return "", fmt.Errorf("did: failed to create did.txt directory: %w", err)
}
if err := os.WriteFile(didPath, []byte(d+"\n"), 0600); err != nil {
return "", fmt.Errorf("did: failed to write did.txt: %w", err)
}
slog.Info("Created did:plc identity", "did", d, "plc_directory", cfg.PLCDirectoryURL)
slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).")
return d, nil
}
// DIDDocument is the JSON shape we serve for did:web identities.
type DIDDocument struct {
Context []string `json:"@context"`
ID string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
VerificationMethod []VerificationMethod `json:"verificationMethod"`
Authentication []string `json:"authentication,omitempty"`
AssertionMethod []string `json:"assertionMethod,omitempty"`
Service []DIDService `json:"service,omitempty"`
}
// VerificationMethod is a public key entry in a DID document.
type VerificationMethod struct {
ID string `json:"id"`
Type string `json:"type"`
Controller string `json:"controller"`
PublicKeyMultibase string `json:"publicKeyMultibase"`
}
// DIDService is a service entry in a DID document.
type DIDService struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
}
// BuildDIDDocument constructs a DID document for a did:web identity. The verification
// method fragment matches verificationKeyName (e.g. "#atproto" or "#atproto_label");
// pass "" to default to "atproto". Authentication is only added for the standard
// "atproto" key per the bsky/PDS pattern.
func BuildDIDDocument(did, publicURL string, signingKey *atcrypto.PrivateKeyK256, verificationKeyName string, services map[string]Service) (*DIDDocument, error) {
host, err := hostWithPort(publicURL)
if err != nil {
return nil, err
}
pub, err := signingKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("did: failed to get public key: %w", err)
}
keyName := verificationKeyName
if keyName == "" {
keyName = "atproto"
}
doc := &DIDDocument{
Context: []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
},
ID: did,
AlsoKnownAs: []string{"at://" + host},
VerificationMethod: []VerificationMethod{
{
ID: fmt.Sprintf("%s#%s", did, keyName),
Type: "Multikey",
Controller: did,
PublicKeyMultibase: pub.Multibase(),
},
},
}
if keyName == "atproto" {
doc.Authentication = []string{fmt.Sprintf("%s#atproto", did)}
}
for id, svc := range services {
doc.Service = append(doc.Service, DIDService{
ID: "#" + id,
Type: svc.Type,
ServiceEndpoint: svc.Endpoint,
})
}
return doc, nil
}
// MarshalDIDDocument is a convenience for serving a DID doc as indented JSON.
func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) {
return json.MarshalIndent(doc, "", " ")
}
func hostWithPort(publicURL string) (string, error) {
u, err := url.Parse(publicURL)
if err != nil {
return "", fmt.Errorf("did: failed to parse public URL: %w", err)
}
host := u.Hostname()
if port := u.Port(); port != "" && port != "80" && port != "443" {
host = host + ":" + port
}
return host, nil
}
+356
View File
@@ -0,0 +1,356 @@
package did
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func testServices(publicURL string) map[string]Service {
return map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
}
}
// TestBuildDIDDocument verifies the standard atproto DID document layout for a did:web service.
func TestBuildDIDDocument(t *testing.T) {
publicURL := "https://hold.example.com"
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:hold.example.com", publicURL, signingKey, "atproto", testServices(publicURL))
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.ID != "did:web:hold.example.com" {
t.Errorf("ID: got %s want did:web:hold.example.com", doc.ID)
}
expectedContexts := []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
}
if len(doc.Context) != len(expectedContexts) {
t.Errorf("Context length: got %d want %d", len(doc.Context), len(expectedContexts))
}
for i, expected := range expectedContexts {
if doc.Context[i] != expected {
t.Errorf("Context[%d]: got %s want %s", i, doc.Context[i], expected)
}
}
if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
t.Errorf("AlsoKnownAs: got %v want [at://hold.example.com]", doc.AlsoKnownAs)
}
if len(doc.VerificationMethod) != 1 {
t.Fatalf("VerificationMethod length: got %d want 1", len(doc.VerificationMethod))
}
vm := doc.VerificationMethod[0]
if vm.ID != "did:web:hold.example.com#atproto" {
t.Errorf("VerificationMethod.ID: got %s", vm.ID)
}
if vm.Type != "Multikey" {
t.Errorf("VerificationMethod.Type: got %s want Multikey", vm.Type)
}
if vm.Controller != "did:web:hold.example.com" {
t.Errorf("VerificationMethod.Controller: got %s", vm.Controller)
}
if vm.PublicKeyMultibase == "" {
t.Error("VerificationMethod.PublicKeyMultibase is empty")
}
pub, _ := signingKey.PublicKey()
if vm.PublicKeyMultibase != pub.Multibase() {
t.Errorf("VerificationMethod.PublicKeyMultibase: got %s want %s", vm.PublicKeyMultibase, pub.Multibase())
}
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
t.Errorf("Authentication: got %v", doc.Authentication)
}
if len(doc.Service) != 2 {
t.Fatalf("Service length: got %d want 2", len(doc.Service))
}
svcByID := map[string]DIDService{}
for _, s := range doc.Service {
svcByID[s.ID] = s
}
pdsService, ok := svcByID["#atproto_pds"]
if !ok {
t.Fatalf("missing #atproto_pds service in %v", svcByID)
}
if pdsService.Type != "AtprotoPersonalDataServer" {
t.Errorf("#atproto_pds Type: got %s", pdsService.Type)
}
if pdsService.ServiceEndpoint != publicURL {
t.Errorf("#atproto_pds Endpoint: got %s want %s", pdsService.ServiceEndpoint, publicURL)
}
holdService, ok := svcByID["#atcr_hold"]
if !ok {
t.Fatalf("missing #atcr_hold service in %v", svcByID)
}
if holdService.Type != "AtcrHoldService" {
t.Errorf("#atcr_hold Type: got %s", holdService.Type)
}
if holdService.ServiceEndpoint != publicURL {
t.Errorf("#atcr_hold Endpoint: got %s want %s", holdService.ServiceEndpoint, publicURL)
}
}
// TestBuildDIDDocument_WithPort confirms non-standard ports flow into AlsoKnownAs.
func TestBuildDIDDocument_WithPort(t *testing.T) {
publicURL := "https://hold.example.com:8443"
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:hold.example.com%3A8443", publicURL, signingKey, "atproto", testServices(publicURL))
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.ID != "did:web:hold.example.com%3A8443" {
t.Errorf("ID: got %s", doc.ID)
}
if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
t.Errorf("AlsoKnownAs: got %s want at://hold.example.com:8443", doc.AlsoKnownAs[0])
}
}
// TestBuildDIDDocument_StandardPortsStripped verifies port 80/443 are not appended to alsoKnownAs.
func TestBuildDIDDocument_StandardPortsStripped(t *testing.T) {
signingKey := generateK256(t)
cases := []struct {
name string
publicURL string
wantAKA string
}{
{"http port 80", "http://hold.example.com:80", "at://hold.example.com"},
{"https port 443", "https://hold.example.com:443", "at://hold.example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
doc, err := BuildDIDDocument("did:web:hold.example.com", tc.publicURL, signingKey, "atproto", nil)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.AlsoKnownAs[0] != tc.wantAKA {
t.Errorf("AlsoKnownAs: got %s want %s", doc.AlsoKnownAs[0], tc.wantAKA)
}
})
}
}
// TestBuildDIDDocument_InvalidURL confirms malformed URLs surface as errors.
func TestBuildDIDDocument_InvalidURL(t *testing.T) {
signingKey := generateK256(t)
_, err := BuildDIDDocument("did:web:bogus", "ht!tp://invalid url", signingKey, "atproto", nil)
if err == nil {
t.Fatal("expected error for invalid URL, got nil")
}
}
// TestBuildDIDDocument_DefaultVerificationKeyName confirms the empty fragment defaults to "atproto"
// and adds Authentication.
func TestBuildDIDDocument_DefaultVerificationKeyName(t *testing.T) {
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "", nil)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.VerificationMethod[0].ID != "did:web:example.com#atproto" {
t.Errorf("VerificationMethod.ID: got %s want did:web:example.com#atproto", doc.VerificationMethod[0].ID)
}
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:example.com#atproto" {
t.Errorf("Authentication: got %v", doc.Authentication)
}
}
// TestBuildDIDDocument_LabelerKey confirms a non-"atproto" verification key (e.g. labeler)
// does not add Authentication, mirroring the bsky labeler pattern.
func TestBuildDIDDocument_LabelerKey(t *testing.T) {
signingKey := generateK256(t)
services := map[string]Service{
"atproto_labeler": {Type: "AtprotoLabeler", Endpoint: "https://labeler.example.com"},
}
doc, err := BuildDIDDocument("did:web:labeler.example.com", "https://labeler.example.com", signingKey, "atproto_label", services)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if doc.VerificationMethod[0].ID != "did:web:labeler.example.com#atproto_label" {
t.Errorf("VerificationMethod.ID: got %s", doc.VerificationMethod[0].ID)
}
if len(doc.Authentication) != 0 {
t.Errorf("Authentication should be empty for non-atproto key, got %v", doc.Authentication)
}
if len(doc.Service) != 1 || doc.Service[0].ID != "#atproto_labeler" {
t.Errorf("Service: got %v", doc.Service)
}
}
// TestBuildDIDDocument_NoServices confirms a DID document can be built without any service entries.
func TestBuildDIDDocument_NoServices(t *testing.T) {
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", nil)
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
if len(doc.Service) != 0 {
t.Errorf("Service should be empty, got %v", doc.Service)
}
}
// TestMarshalDIDDocument confirms marshaling produces parseable, indented JSON.
func TestMarshalDIDDocument(t *testing.T) {
signingKey := generateK256(t)
doc, err := BuildDIDDocument("did:web:example.com", "https://example.com", signingKey, "atproto", testServices("https://example.com"))
if err != nil {
t.Fatalf("BuildDIDDocument: %v", err)
}
data, err := MarshalDIDDocument(doc)
if err != nil {
t.Fatalf("MarshalDIDDocument: %v", err)
}
if !strings.Contains(string(data), " ") {
t.Error("expected indented JSON output")
}
var parsed DIDDocument
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if parsed.ID != doc.ID {
t.Errorf("ID round-trip: got %s want %s", parsed.ID, doc.ID)
}
if len(parsed.Service) != len(doc.Service) {
t.Errorf("Service length round-trip: got %d want %d", len(parsed.Service), len(doc.Service))
}
}
// TestLoadOrCreate_DIDWeb confirms did:web mode returns a deterministic identifier
// without touching disk or any external service.
func TestLoadOrCreate_DIDWeb(t *testing.T) {
cfg := Config{
Method: "web",
PublicURL: "https://hold.example.com",
}
d, err := LoadOrCreate(context.Background(), cfg)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if d != "did:web:hold.example.com" {
t.Errorf("DID: got %s want did:web:hold.example.com", d)
}
}
// TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty confirms an empty method behaves like did:web.
func TestLoadOrCreate_DIDWebDefaultsToWebWhenMethodEmpty(t *testing.T) {
cfg := Config{
PublicURL: "https://example.com:8443",
}
d, err := LoadOrCreate(context.Background(), cfg)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if d != "did:web:example.com%3A8443" {
t.Errorf("DID: got %s want did:web:example.com%%3A8443", d)
}
}
// TestLoadOrCreate_PLCRequiresVerificationKeyName confirms missing required PLC fields error early.
func TestLoadOrCreate_PLCRequiresVerificationKeyName(t *testing.T) {
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
},
}
_, err := LoadOrCreate(context.Background(), cfg)
if err == nil {
t.Fatal("expected error when VerificationKeyName is empty")
}
if !strings.Contains(err.Error(), "VerificationKeyName") {
t.Errorf("expected error about VerificationKeyName, got: %v", err)
}
}
// TestLoadOrCreate_PLCRequiresServices confirms PLC mode demands at least one service entry.
func TestLoadOrCreate_PLCRequiresServices(t *testing.T) {
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
VerificationKeyName: "atproto",
}
_, err := LoadOrCreate(context.Background(), cfg)
if err == nil {
t.Fatal("expected error when Services is empty")
}
if !strings.Contains(err.Error(), "service") {
t.Errorf("expected error about services, got: %v", err)
}
}
// TestLoadOrCreate_PLCRejectsNonPLCAdoption confirms a configured DID must be a did:plc identifier.
func TestLoadOrCreate_PLCRejectsNonPLCAdoption(t *testing.T) {
tmp := t.TempDir()
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
DBPath: tmp,
SigningKeyPath: filepath.Join(tmp, "signing.key"),
VerificationKeyName: "atproto",
DID: "did:web:example.com",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
},
}
_, err := LoadOrCreate(context.Background(), cfg)
if err == nil {
t.Fatal("expected error for non-did:plc adoption")
}
if !strings.Contains(err.Error(), "did:plc") {
t.Errorf("expected error about did:plc, got: %v", err)
}
}
// TestLoadOrCreate_PLCAdoptionPersistsDID confirms a configured did:plc is written to did.txt
// even when the PLC directory call fails (the failure is logged, not returned).
func TestLoadOrCreate_PLCAdoptionPersistsDID(t *testing.T) {
tmp := t.TempDir()
cfg := Config{
Method: "plc",
PublicURL: "https://example.com",
DBPath: tmp,
SigningKeyPath: filepath.Join(tmp, "signing.key"),
VerificationKeyName: "atproto",
DID: "did:plc:abcdefghijklmnopqrstuvwx",
PLCDirectoryURL: "http://127.0.0.1:1", // unreachable; EnsureCurrent failure is non-fatal
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.com"},
},
}
d, err := LoadOrCreate(context.Background(), cfg)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
if d != "did:plc:abcdefghijklmnopqrstuvwx" {
t.Errorf("DID: got %s", d)
}
got, err := os.ReadFile(filepath.Join(tmp, "did.txt"))
if err != nil {
t.Fatalf("read did.txt: %v", err)
}
if strings.TrimSpace(string(got)) != "did:plc:abcdefghijklmnopqrstuvwx" {
t.Errorf("did.txt contents: got %q", string(got))
}
}
+172
View File
@@ -0,0 +1,172 @@
package did
import (
"context"
"fmt"
"log/slog"
"github.com/bluesky-social/indigo/atproto/atcrypto"
didplc "github.com/did-method-plc/go-didplc"
)
// CreateIdentity builds a genesis PLC operation with the configured verification key and
// services, signs it with the rotation key, and submits it to the PLC directory.
func CreateIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, cfg Config) (string, error) {
rotPub, err := rotationKey.PublicKey()
if err != nil {
return "", fmt.Errorf("did: failed to get rotation public key: %w", err)
}
sigPub, err := signingKey.PublicKey()
if err != nil {
return "", fmt.Errorf("did: failed to get signing public key: %w", err)
}
host, err := hostWithPort(cfg.PublicURL)
if err != nil {
return "", err
}
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: []string{rotPub.DIDKey()},
VerificationMethods: map[string]string{
cfg.VerificationKeyName: sigPub.DIDKey(),
},
AlsoKnownAs: []string{"at://" + host},
Services: toOpServices(cfg.Services),
Prev: nil,
}
if err := op.Sign(rotationKey); err != nil {
return "", fmt.Errorf("did: failed to sign genesis operation: %w", err)
}
d, err := op.DID()
if err != nil {
return "", fmt.Errorf("did: failed to compute DID from genesis: %w", err)
}
client := &didplc.Client{DirectoryURL: cfg.PLCDirectoryURL}
if err := client.Submit(ctx, d, op); err != nil {
return "", fmt.Errorf("did: failed to submit genesis operation: %w", err)
}
return d, nil
}
// EnsureCurrent reconciles the published DID document with the local config; if the local
// signing key, public URL, or service set differs, an update operation is signed and submitted.
// Without a rotation key, mismatches log a warning but are not fatal.
func EnsureCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, cfg Config) error {
client := &didplc.Client{DirectoryURL: cfg.PLCDirectoryURL}
opLog, err := client.OpLog(ctx, did)
if err != nil {
return fmt.Errorf("did: failed to fetch op log for %s: %w", did, err)
}
if len(opLog) == 0 {
return fmt.Errorf("did: empty op log for %s", did)
}
lastEntry := opLog[len(opLog)-1]
lastOp := lastEntry.Regular
if lastOp == nil {
slog.Warn("Last PLC operation is not a regular op, skipping auto-update", "did", did)
return nil
}
sigPub, err := signingKey.PublicKey()
if err != nil {
return fmt.Errorf("did: failed to get signing public key: %w", err)
}
localKey := sigPub.DIDKey()
plcKey := lastOp.VerificationMethods[cfg.VerificationKeyName]
keyMatch := localKey == plcKey
servicesMatch := true
for name, svc := range cfg.Services {
plcSvc, ok := lastOp.Services[name]
if !ok || plcSvc.Type != svc.Type || plcSvc.Endpoint != svc.Endpoint {
servicesMatch = false
break
}
}
if keyMatch && servicesMatch {
slog.Info("PLC identity is current", "did", did)
return nil
}
slog.Info("PLC identity needs update",
"did", did, "signing_key_changed", !keyMatch, "services_changed", !servicesMatch)
if rotationKey == nil {
slog.Warn("PLC document doesn't match local state but no rotation key available. Provide rotation key to auto-update.",
"did", did, "signing_key_changed", !keyMatch, "services_changed", !servicesMatch)
return nil
}
rotPub, err := rotationKey.PublicKey()
if err != nil {
return fmt.Errorf("did: failed to get rotation public key: %w", err)
}
localRotKey := rotPub.DIDKey()
// Verify the local rotation key still has authority on the PLC document.
// If it's been rotated out (possibly maliciously), refuse to submit — PLC would
// reject anyway, and silent failure here would be confusing.
localRotKeyPresent := false
for _, k := range lastOp.RotationKeys {
if k == localRotKey {
localRotKeyPresent = true
break
}
}
if !localRotKeyPresent {
slog.Warn("Local rotation key not present in PLC document — refusing to update. Possible compromise or out-of-band rotation. Recover with offline key if available.",
"did", did, "local_rotation_key", localRotKey, "plc_rotation_keys", lastOp.RotationKeys)
return nil
}
host, err := hostWithPort(cfg.PublicURL)
if err != nil {
return err
}
prevCID := lastEntry.AsOperation().CID().String()
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: lastOp.RotationKeys,
VerificationMethods: map[string]string{
cfg.VerificationKeyName: localKey,
},
AlsoKnownAs: []string{"at://" + host},
Services: toOpServices(cfg.Services),
Prev: &prevCID,
}
if err := op.Sign(rotationKey); err != nil {
return fmt.Errorf("did: failed to sign update operation: %w", err)
}
if err := client.Submit(ctx, did, op); err != nil {
return fmt.Errorf("did: failed to submit update: %w", err)
}
slog.Info("Updated PLC identity",
"did", did, "signing_key_rotated", !keyMatch, "services_changed", !servicesMatch)
return nil
}
func toOpServices(in map[string]Service) map[string]didplc.OpService {
out := make(map[string]didplc.OpService, len(in))
for name, svc := range in {
out[name] = didplc.OpService{Type: svc.Type, Endpoint: svc.Endpoint}
}
return out
}
func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) {
if encoded == "" {
return nil, nil
}
key, err := atcrypto.ParsePrivateMultibase(encoded)
if err != nil {
return nil, fmt.Errorf("did: failed to parse multibase key: %w", err)
}
return key, nil
}
+243
View File
@@ -0,0 +1,243 @@
package did
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/bluesky-social/indigo/atproto/atcrypto"
didplc "github.com/did-method-plc/go-didplc"
)
// fakePLC stands up an httptest server that serves a single op log entry and
// captures any submitted update op for inspection.
type fakePLC struct {
server *httptest.Server
did string
logEntries []didplc.OpEnum
submitted []didplc.RegularOp
}
func (f *fakePLC) URL() string { return f.server.URL }
func (f *fakePLC) Close() { f.server.Close() }
// newFakePLC creates a fake PLC directory pre-loaded with a single signed
// genesis op containing the given rotation keys (in priority order). Returns
// the fake server and the resulting did:plc DID derived from the genesis op.
func newFakePLC(t *testing.T, rotationKeys []*atcrypto.PrivateKeyK256, signer atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256) *fakePLC {
t.Helper()
rotationDIDKeys := make([]string, 0, len(rotationKeys))
for _, k := range rotationKeys {
pub, err := k.PublicKey()
if err != nil {
t.Fatalf("rotation key public: %v", err)
}
rotationDIDKeys = append(rotationDIDKeys, pub.DIDKey())
}
sigPub, err := signingKey.PublicKey()
if err != nil {
t.Fatalf("signing key public: %v", err)
}
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: rotationDIDKeys,
VerificationMethods: map[string]string{
"atproto": sigPub.DIDKey(),
},
AlsoKnownAs: []string{"at://example.test"},
Services: map[string]didplc.OpService{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
},
Prev: nil,
}
if err := op.Sign(signer); err != nil {
t.Fatalf("sign genesis: %v", err)
}
did, err := op.DID()
if err != nil {
t.Fatalf("compute DID: %v", err)
}
f := &fakePLC{
did: did,
logEntries: []didplc.OpEnum{{Regular: op}},
}
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/log") {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(f.logEntries)
return
}
if r.Method == http.MethodPost && r.URL.Path == "/"+did {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var op didplc.RegularOp
if err := json.Unmarshal(body, &op); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f.submitted = append(f.submitted, op)
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
})
f.server = httptest.NewServer(mux)
return f
}
// generateK256 returns a fresh K-256 keypair, failing the test on error.
func generateK256(t *testing.T) *atcrypto.PrivateKeyK256 {
t.Helper()
k, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
t.Fatalf("generate K-256: %v", err)
}
return k
}
// writeSigningKey persists a signing key to a temp file and returns its path.
// Used so EnsureCurrent can load it via oauth.GenerateOrLoadPDSKey.
func writeSigningKey(t *testing.T, dir string, key *atcrypto.PrivateKeyK256) string {
t.Helper()
path := filepath.Join(dir, "signing.key")
if err := os.WriteFile(path, key.Bytes(), 0600); err != nil {
t.Fatalf("write signing key: %v", err)
}
return path
}
func TestEnsureCurrent_PreservesRotationKeys(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
// Server-side rotation key (the one stored in database.rotation_key) and an
// "offline" recovery key that lives only in PLC. The genesis op lists offline
// FIRST (highest priority).
serverRot := generateK256(t)
offlineRot := generateK256(t)
// Original signing key used to build genesis; the local signing key on disk
// will be different to force EnsureCurrent into the update path.
originalSigning := generateK256(t)
localSigning := generateK256(t)
writeSigningKey(t, tmp, localSigning)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{offlineRot, serverRot}, serverRot, originalSigning)
defer fake.Close()
cfg := Config{
PublicURL: "https://example.test",
PLCDirectoryURL: fake.URL(),
VerificationKeyName: "atproto",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
},
}
if err := EnsureCurrent(ctx, fake.did, serverRot, localSigning, cfg); err != nil {
t.Fatalf("EnsureCurrent: %v", err)
}
if len(fake.submitted) != 1 {
t.Fatalf("expected exactly one update op submitted, got %d", len(fake.submitted))
}
got := fake.submitted[0]
offlinePub, _ := offlineRot.PublicKey()
serverPub, _ := serverRot.PublicKey()
want := []string{offlinePub.DIDKey(), serverPub.DIDKey()}
if len(got.RotationKeys) != len(want) {
t.Fatalf("rotation keys length: got %d want %d", len(got.RotationKeys), len(want))
}
for i := range want {
if got.RotationKeys[i] != want[i] {
t.Errorf("rotation key [%d]: got %s want %s", i, got.RotationKeys[i], want[i])
}
}
// Verify signing key was actually rotated (sanity check we hit the update path).
localPub, _ := localSigning.PublicKey()
if got.VerificationMethods["atproto"] != localPub.DIDKey() {
t.Errorf("expected signing key to update to local key %s, got %s",
localPub.DIDKey(), got.VerificationMethods["atproto"])
}
}
func TestEnsureCurrent_RefusesUpdateWhenLocalKeyMissing(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
// Genesis lists only the offline key. The local server has been rotated out.
offlineRot := generateK256(t)
localRot := generateK256(t) // not in PLC
originalSigning := generateK256(t)
localSigning := generateK256(t)
writeSigningKey(t, tmp, localSigning)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{offlineRot}, offlineRot, originalSigning)
defer fake.Close()
cfg := Config{
PublicURL: "https://example.test",
PLCDirectoryURL: fake.URL(),
VerificationKeyName: "atproto",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
},
}
// Local signing key has drifted, which would normally trigger an update.
if err := EnsureCurrent(ctx, fake.did, localRot, localSigning, cfg); err != nil {
t.Fatalf("EnsureCurrent returned error: %v", err)
}
if len(fake.submitted) != 0 {
t.Fatalf("expected no update submission when local rotation key isn't in PLC list, got %d", len(fake.submitted))
}
}
func TestEnsureCurrent_NoOpWhenCurrent(t *testing.T) {
ctx := context.Background()
tmp := t.TempDir()
serverRot := generateK256(t)
signing := generateK256(t)
writeSigningKey(t, tmp, signing)
fake := newFakePLC(t, []*atcrypto.PrivateKeyK256{serverRot}, serverRot, signing)
defer fake.Close()
cfg := Config{
PublicURL: "https://example.test",
PLCDirectoryURL: fake.URL(),
VerificationKeyName: "atproto",
Services: map[string]Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: "https://example.test"},
},
}
if err := EnsureCurrent(ctx, fake.did, serverRot, signing, cfg); err != nil {
t.Fatalf("EnsureCurrent: %v", err)
}
if len(fake.submitted) != 0 {
t.Fatalf("expected no update when state is current, got %d submitted", len(fake.submitted))
}
}
+24
View File
@@ -0,0 +1,24 @@
package did
import (
"fmt"
"net/url"
)
// GenerateDIDFromURL computes a did:web identifier from a public URL.
// Per the did:web spec, ports are percent-encoded (`:` → `%3A`).
func GenerateDIDFromURL(publicURL string) string {
u, err := url.Parse(publicURL)
if err != nil {
return fmt.Sprintf("did:web:%s", publicURL)
}
hostname := u.Hostname()
if hostname == "" {
hostname = "localhost"
}
port := u.Port()
if port != "" && port != "80" && port != "443" {
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
}
return fmt.Sprintf("did:web:%s", hostname)
}
+38
View File
@@ -0,0 +1,38 @@
package did
import "testing"
// TestGenerateDIDFromURL covers host extraction and port encoding for did:web.
func TestGenerateDIDFromURL(t *testing.T) {
cases := []struct {
name string
publicURL string
want string
}{
{"https no port", "https://hold.example.com", "did:web:hold.example.com"},
{"http no port", "http://hold.example.com", "did:web:hold.example.com"},
{"https port 443 stripped", "https://hold.example.com:443", "did:web:hold.example.com"},
{"http port 80 stripped", "http://hold.example.com:80", "did:web:hold.example.com"},
{"non-standard port encoded", "https://hold.example.com:8443", "did:web:hold.example.com%3A8443"},
{"localhost with port", "http://localhost:3000", "did:web:localhost%3A3000"},
{"trailing path ignored", "https://hold.example.com/foo/bar", "did:web:hold.example.com"},
{"subdomain preserved", "https://api.hold.example.com", "did:web:api.hold.example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := GenerateDIDFromURL(tc.publicURL)
if got != tc.want {
t.Errorf("GenerateDIDFromURL(%q): got %s want %s", tc.publicURL, got, tc.want)
}
})
}
}
// TestGenerateDIDFromURL_EmptyHost confirms a URL without a hostname falls back to localhost.
// (url.Parse on a bare path does not error, so the fallback is the only signal we have.)
func TestGenerateDIDFromURL_EmptyHost(t *testing.T) {
got := GenerateDIDFromURL("")
if got != "did:web:localhost" {
t.Errorf("empty URL: got %s want did:web:localhost", got)
}
}
+4 -4
View File
@@ -644,10 +644,10 @@ func (m *Manager) aggregateHoldFeatures(rank int) []string {
}
var (
minQuota int64 = -1
maxQuota int64
scanCount int
totalHolds int
minQuota int64 = -1
maxQuota int64
scanCount int
totalHolds int
)
for _, cached := range m.holdTierCache {
+19
View File
@@ -15,8 +15,10 @@ import (
"github.com/spf13/viper"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/config"
"atcr.io/pkg/hold/gc"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/hold/quota"
)
@@ -57,6 +59,23 @@ type Config struct {
// Subsystems (e.g. billing) use this to re-read the same file for extended fields.
func (c *Config) ConfigPath() string { return c.configPath }
// DIDConfig builds the did.Config used to load or create the hold's identity.
// The verification key fragment and service set are hold-specific (atproto PDS +
// AtcrHoldService), so they're filled in here rather than at every callsite.
func (c *Config) DIDConfig() did.Config {
return did.Config{
DID: c.Database.DID,
Method: c.Database.DIDMethod,
PublicURL: c.Server.PublicURL,
DBPath: c.Database.Path,
SigningKeyPath: c.Database.KeyPath,
RotationKey: c.Database.RotationKey,
PLCDirectoryURL: c.Database.PLCDirectoryURL,
VerificationKeyName: "atproto",
Services: pds.HoldServices(c.Server.PublicURL),
}
}
// AdminConfig defines admin panel settings
type AdminConfig struct {
// Enable the web-based admin panel.
-435
View File
@@ -1,435 +0,0 @@
package pds
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
didplc "github.com/did-method-plc/go-didplc"
)
// DIDDocument represents a did:web document
type DIDDocument struct {
Context []string `json:"@context"`
ID string `json:"id"`
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
VerificationMethod []VerificationMethod `json:"verificationMethod"`
Authentication []string `json:"authentication,omitempty"`
AssertionMethod []string `json:"assertionMethod,omitempty"`
Service []Service `json:"service,omitempty"`
}
// VerificationMethod represents a public key in a DID document
type VerificationMethod struct {
ID string `json:"id"`
Type string `json:"type"`
Controller string `json:"controller"`
PublicKeyMultibase string `json:"publicKeyMultibase"`
}
// Service represents a service endpoint in a DID document
type Service struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
}
// GenerateDIDDocument creates a DID document for the hold's identity.
// It uses the hold's stored DID (which may be did:web or did:plc).
func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
did := p.did
// Parse URL for alsoKnownAs
u, err := url.Parse(publicURL)
if err != nil {
return nil, fmt.Errorf("failed to parse public URL: %w", err)
}
host := u.Hostname()
if port := u.Port(); port != "" && port != "80" && port != "443" {
host = fmt.Sprintf("%s:%s", host, port)
}
// Get public key in multibase format using indigo's crypto
pubKey, err := p.signingKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
publicKeyMultibase := pubKey.Multibase()
doc := &DIDDocument{
Context: []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
},
ID: did,
AlsoKnownAs: []string{
fmt.Sprintf("at://%s", host),
},
VerificationMethod: []VerificationMethod{
{
ID: fmt.Sprintf("%s#atproto", did),
Type: "Multikey",
Controller: did,
PublicKeyMultibase: publicKeyMultibase,
},
},
Authentication: []string{
fmt.Sprintf("%s#atproto", did),
},
Service: []Service{
{
ID: "#atproto_pds",
Type: "AtprotoPersonalDataServer",
ServiceEndpoint: publicURL,
},
{
ID: "#atcr_hold",
Type: "AtcrHoldService",
ServiceEndpoint: publicURL,
},
},
}
return doc, nil
}
// MarshalDIDDocument converts a DID document to JSON using the stored public URL
func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) {
doc, err := p.GenerateDIDDocument(p.PublicURL)
if err != nil {
return nil, err
}
return json.MarshalIndent(doc, "", " ")
}
// DIDConfig holds parameters for DID creation/loading.
type DIDConfig struct {
DID string // Explicit DID for adoption/recovery (optional)
DIDMethod string // "web" or "plc"
PublicURL string
DBPath string
SigningKeyPath string
RotationKey string // Multibase-encoded private key, K-256 or P-256 (optional)
PLCDirectoryURL string
}
// LoadOrCreateDID returns the hold's DID, either by deriving it from the URL (did:web)
// or by loading/creating a did:plc identity registered with the PLC directory.
//
// For did:plc, the priority is: config DID > did.txt > create new.
// When an existing DID is found (config or did.txt), EnsurePLCCurrent is called
// to auto-update the PLC directory if the signing key or URL has changed.
func LoadOrCreateDID(ctx context.Context, cfg DIDConfig) (string, error) {
if cfg.DIDMethod != "plc" {
return GenerateDIDFromURL(cfg.PublicURL), nil
}
didPath := filepath.Join(cfg.DBPath, "did.txt")
// Priority: config DID > did.txt > create new
var did string
if cfg.DID != "" {
if !strings.HasPrefix(cfg.DID, "did:plc:") {
return "", fmt.Errorf("database.did must be a did:plc identifier, got %q", cfg.DID)
}
did = cfg.DID
slog.Info("Using DID from config (adoption/recovery)", "did", did)
} else if data, err := os.ReadFile(didPath); err == nil {
d := strings.TrimSpace(string(data))
if strings.HasPrefix(d, "did:plc:") {
did = d
slog.Info("Loaded existing did:plc identity", "did", did)
}
}
if did != "" {
// Persist to did.txt (may be from config on first adoption)
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
return "", fmt.Errorf("failed to create directory for did.txt: %w", err)
}
if err := os.WriteFile(didPath, []byte(did+"\n"), 0600); err != nil {
return "", fmt.Errorf("failed to write did.txt: %w", err)
}
// Load signing key (generate if missing — recovery case)
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
if err != nil {
return "", fmt.Errorf("failed to load signing key: %w", err)
}
// Try to parse rotation key (optional — may not be configured)
rotationKey, _ := parseOptionalMultibaseKey(cfg.RotationKey)
if err := EnsurePLCCurrent(ctx, did, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL); err != nil {
slog.Warn("Failed to verify PLC identity is current (will retry on next restart)",
"did", did,
"error", err,
)
}
return did, nil
}
// No existing DID — create new genesis operation
slog.Info("Creating new did:plc identity")
// Load or generate signing key
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath)
if err != nil {
return "", fmt.Errorf("failed to load signing key: %w", err)
}
// Parse or generate rotation key
var rotationKey atcrypto.PrivateKeyExportable
if cfg.RotationKey != "" {
rotationKey, err = parseOptionalMultibaseKey(cfg.RotationKey)
if err != nil {
return "", fmt.Errorf("failed to parse rotation_key: %w", err)
}
} else {
// Generate a new rotation key — user must save the multibase output
rawKey, genErr := atcrypto.GeneratePrivateKeyK256()
if genErr != nil {
return "", fmt.Errorf("failed to generate rotation key: %w", genErr)
}
rotationKey = rawKey
slog.Warn("Generated new rotation key — save this in your config as database.rotation_key",
"rotation_key", rawKey.Multibase(),
)
}
did, err = CreatePLCIdentity(ctx, rotationKey, signingKey, cfg.PublicURL, cfg.PLCDirectoryURL)
if err != nil {
return "", fmt.Errorf("failed to create PLC identity: %w", err)
}
// Persist DID
if err := os.MkdirAll(filepath.Dir(didPath), 0755); err != nil {
return "", fmt.Errorf("failed to create directory for did.txt: %w", err)
}
if err := os.WriteFile(didPath, []byte(did+"\n"), 0600); err != nil {
return "", fmt.Errorf("failed to write did.txt: %w", err)
}
slog.Info("Created did:plc identity",
"did", did,
"plc_directory", cfg.PLCDirectoryURL,
)
slog.Warn("Back up your rotation_key. It is only needed for DID updates (URL changes, key rotation).")
return did, nil
}
// parseOptionalMultibaseKey parses a multibase-encoded private key string (K-256 or P-256).
// Returns nil, nil if the input is empty (key not configured).
func parseOptionalMultibaseKey(encoded string) (atcrypto.PrivateKeyExportable, error) {
if encoded == "" {
return nil, nil
}
key, err := atcrypto.ParsePrivateMultibase(encoded)
if err != nil {
return nil, fmt.Errorf("failed to parse rotation key multibase string: %w", err)
}
return key, nil
}
// EnsurePLCCurrent checks the PLC directory for the given DID and updates it
// if the local signing key or public URL doesn't match what's registered.
// If rotationKey is nil, mismatches are logged as warnings but not fatal.
func EnsurePLCCurrent(ctx context.Context, did string, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) error {
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
// Fetch current op log
opLog, err := client.OpLog(ctx, did)
if err != nil {
return fmt.Errorf("failed to fetch PLC op log for %s: %w", did, err)
}
if len(opLog) == 0 {
return fmt.Errorf("empty op log for %s", did)
}
lastEntry := opLog[len(opLog)-1]
lastOp := lastEntry.Regular
if lastOp == nil {
// Last op is not a regular op (could be legacy or tombstone) — skip update
slog.Warn("Last PLC operation is not a regular op, skipping auto-update", "did", did)
return nil
}
// Compare local state vs PLC state
sigPub, err := signingKey.PublicKey()
if err != nil {
return fmt.Errorf("failed to get signing public key: %w", err)
}
localVerificationKey := sigPub.DIDKey()
plcVerificationKey := lastOp.VerificationMethods["atproto"]
localEndpoint := publicURL
var plcEndpoint string
if svc, ok := lastOp.Services["atproto_pds"]; ok {
plcEndpoint = svc.Endpoint
}
keyMatch := localVerificationKey == plcVerificationKey
endpointMatch := localEndpoint == plcEndpoint
if keyMatch && endpointMatch {
slog.Info("PLC identity is current", "did", did)
return nil
}
slog.Info("PLC identity needs update",
"did", did,
"signing_key_changed", !keyMatch,
"endpoint_changed", !endpointMatch,
)
if rotationKey == nil {
slog.Warn("PLC document doesn't match local state but no rotation key available. Provide rotation key to auto-update PLC directory.",
"did", did,
"signing_key_changed", !keyMatch,
"endpoint_changed", !endpointMatch,
)
return nil
}
// Build update operation
rotPub, err := rotationKey.PublicKey()
if err != nil {
return fmt.Errorf("failed to get rotation public key: %w", err)
}
// Extract hostname for alsoKnownAs
u, err := url.Parse(publicURL)
if err != nil {
return fmt.Errorf("failed to parse public URL: %w", err)
}
host := u.Hostname()
if port := u.Port(); port != "" && port != "80" && port != "443" {
host = host + ":" + port
}
prevCID := lastEntry.AsOperation().CID().String()
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: []string{rotPub.DIDKey()},
VerificationMethods: map[string]string{
"atproto": localVerificationKey,
},
AlsoKnownAs: []string{"at://" + host},
Services: map[string]didplc.OpService{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
},
Prev: &prevCID,
}
if err := op.Sign(rotationKey); err != nil {
return fmt.Errorf("failed to sign PLC update operation: %w", err)
}
if err := client.Submit(ctx, did, op); err != nil {
return fmt.Errorf("failed to submit PLC update: %w", err)
}
slog.Info("Updated PLC identity",
"did", did,
"signing_key_rotated", !keyMatch,
"endpoint_changed", !endpointMatch,
)
return nil
}
// CreatePLCIdentity creates a new did:plc identity by building a genesis operation,
// signing it with the rotation key, and submitting it to the PLC directory.
func CreatePLCIdentity(ctx context.Context, rotationKey atcrypto.PrivateKey, signingKey *atcrypto.PrivateKeyK256, publicURL, plcDirectoryURL string) (string, error) {
rotPub, err := rotationKey.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to get rotation public key: %w", err)
}
sigPub, err := signingKey.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to get signing public key: %w", err)
}
// Extract hostname for alsoKnownAs
u, err := url.Parse(publicURL)
if err != nil {
return "", fmt.Errorf("failed to parse public URL: %w", err)
}
host := u.Hostname()
if port := u.Port(); port != "" && port != "80" && port != "443" {
host = host + ":" + port
}
op := &didplc.RegularOp{
Type: "plc_operation",
RotationKeys: []string{rotPub.DIDKey()},
VerificationMethods: map[string]string{
"atproto": sigPub.DIDKey(),
},
AlsoKnownAs: []string{"at://" + host},
Services: map[string]didplc.OpService{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
},
Prev: nil,
}
if err := op.Sign(rotationKey); err != nil {
return "", fmt.Errorf("failed to sign PLC genesis operation: %w", err)
}
did, err := op.DID()
if err != nil {
return "", fmt.Errorf("failed to compute DID from genesis operation: %w", err)
}
client := &didplc.Client{DirectoryURL: plcDirectoryURL}
if err := client.Submit(ctx, did, op); err != nil {
return "", fmt.Errorf("failed to submit genesis operation to PLC directory: %w", err)
}
return did, nil
}
// GenerateDIDFromURL creates a did:web identifier from a public URL.
// Per the did:web spec, ports are percent-encoded: the colon becomes %3A.
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com%3A8080"
func GenerateDIDFromURL(publicURL string) string {
// Parse URL
u, err := url.Parse(publicURL)
if err != nil {
// Fallback: assume it's just a hostname
return fmt.Sprintf("did:web:%s", publicURL)
}
// Get hostname
hostname := u.Hostname()
if hostname == "" {
hostname = "localhost"
}
// Get port
port := u.Port()
// Include port in DID if it's non-standard (not 80 for http, not 443 for https)
// Per did:web spec, the colon is percent-encoded as %3A
if port != "" && port != "80" && port != "443" {
return fmt.Sprintf("did:web:%s%%3A%s", hostname, port)
}
return fmt.Sprintf("did:web:%s", hostname)
}
-274
View File
@@ -1,274 +0,0 @@
package pds
import (
"context"
"encoding/json"
"path/filepath"
"testing"
)
// TestGenerateDIDFromURL tests DID generation from various URL formats
func TestGenerateDIDFromURL(t *testing.T) {
tests := []struct {
name string
publicURL string
expectedDID string
}{
{
name: "standard HTTP with standard port",
publicURL: "http://hold.example.com",
expectedDID: "did:web:hold.example.com",
},
{
name: "standard HTTPS with standard port",
publicURL: "https://hold.example.com",
expectedDID: "did:web:hold.example.com",
},
{
name: "HTTP with non-standard port",
publicURL: "http://hold.example.com:8080",
expectedDID: "did:web:hold.example.com%3A8080",
},
{
name: "HTTPS with non-standard port",
publicURL: "https://hold.example.com:8443",
expectedDID: "did:web:hold.example.com%3A8443",
},
{
name: "localhost with port",
publicURL: "http://localhost:8080",
expectedDID: "did:web:localhost%3A8080",
},
{
name: "HTTP with explicit port 80",
publicURL: "http://hold.example.com:80",
expectedDID: "did:web:hold.example.com",
},
{
name: "HTTPS with explicit port 443",
publicURL: "https://hold.example.com:443",
expectedDID: "did:web:hold.example.com",
},
{
name: "subdomain",
publicURL: "https://hold1.atcr.io",
expectedDID: "did:web:hold1.atcr.io",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
did := GenerateDIDFromURL(tt.publicURL)
if did != tt.expectedDID {
t.Errorf("Expected DID %s, got %s", tt.expectedDID, did)
}
})
}
}
// TestGenerateDIDFromURL_InvalidURL tests handling of invalid URLs
func TestGenerateDIDFromURL_InvalidURL(t *testing.T) {
// Invalid URLs get parsed with empty hostname, which defaults to localhost
did := GenerateDIDFromURL("not a url")
if did != "did:web:localhost" {
t.Errorf("Expected did:web:localhost for invalid URL, got %s", did)
}
}
// TestGenerateDIDDocument tests DID document generation
func TestGenerateDIDDocument(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
doc, err := pds.GenerateDIDDocument(publicURL)
if err != nil {
t.Fatalf("Failed to generate DID document: %v", err)
}
// Verify required fields
if doc.ID != "did:web:hold.example.com" {
t.Errorf("Expected DID did:web:hold.example.com, got %s", doc.ID)
}
// Verify context
if len(doc.Context) != 3 {
t.Errorf("Expected 3 context entries, got %d", len(doc.Context))
}
expectedContexts := []string{
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
}
for i, expected := range expectedContexts {
if doc.Context[i] != expected {
t.Errorf("Expected context[%d] = %s, got %s", i, expected, doc.Context[i])
}
}
// Verify alsoKnownAs
if len(doc.AlsoKnownAs) != 1 || doc.AlsoKnownAs[0] != "at://hold.example.com" {
t.Errorf("Expected alsoKnownAs=['at://hold.example.com'], got %v", doc.AlsoKnownAs)
}
// Verify verification method
if len(doc.VerificationMethod) != 1 {
t.Fatalf("Expected 1 verification method, got %d", len(doc.VerificationMethod))
}
vm := doc.VerificationMethod[0]
if vm.ID != "did:web:hold.example.com#atproto" {
t.Errorf("Expected verification method ID did:web:hold.example.com#atproto, got %s", vm.ID)
}
if vm.Type != "Multikey" {
t.Errorf("Expected type Multikey, got %s", vm.Type)
}
if vm.Controller != "did:web:hold.example.com" {
t.Errorf("Expected controller did:web:hold.example.com, got %s", vm.Controller)
}
if vm.PublicKeyMultibase == "" {
t.Error("Expected non-empty publicKeyMultibase")
}
// Verify authentication
if len(doc.Authentication) != 1 || doc.Authentication[0] != "did:web:hold.example.com#atproto" {
t.Errorf("Expected authentication=['did:web:hold.example.com#atproto'], got %v", doc.Authentication)
}
// Verify services
if len(doc.Service) != 2 {
t.Fatalf("Expected 2 services, got %d", len(doc.Service))
}
// Check PDS service
pdsService := doc.Service[0]
if pdsService.ID != "#atproto_pds" {
t.Errorf("Expected service ID #atproto_pds, got %s", pdsService.ID)
}
if pdsService.Type != "AtprotoPersonalDataServer" {
t.Errorf("Expected service type AtprotoPersonalDataServer, got %s", pdsService.Type)
}
if pdsService.ServiceEndpoint != publicURL {
t.Errorf("Expected service endpoint %s, got %s", publicURL, pdsService.ServiceEndpoint)
}
// Check hold service
holdService := doc.Service[1]
if holdService.ID != "#atcr_hold" {
t.Errorf("Expected service ID #atcr_hold, got %s", holdService.ID)
}
if holdService.Type != "AtcrHoldService" {
t.Errorf("Expected service type AtcrHoldService, got %s", holdService.Type)
}
if holdService.ServiceEndpoint != publicURL {
t.Errorf("Expected service endpoint %s, got %s", publicURL, holdService.ServiceEndpoint)
}
}
// TestGenerateDIDDocument_WithPort tests DID document with non-standard port
func TestGenerateDIDDocument_WithPort(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com:8443"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com%3A8443", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
doc, err := pds.GenerateDIDDocument(publicURL)
if err != nil {
t.Fatalf("Failed to generate DID document: %v", err)
}
// Verify DID includes percent-encoded port
if doc.ID != "did:web:hold.example.com%3A8443" {
t.Errorf("Expected DID did:web:hold.example.com%%3A8443, got %s", doc.ID)
}
// Verify alsoKnownAs includes port
if doc.AlsoKnownAs[0] != "at://hold.example.com:8443" {
t.Errorf("Expected alsoKnownAs with port, got %s", doc.AlsoKnownAs[0])
}
}
// TestMarshalDIDDocument tests DID document JSON marshaling
func TestMarshalDIDDocument(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
jsonBytes, err := pds.MarshalDIDDocument()
if err != nil {
t.Fatalf("Failed to marshal DID document: %v", err)
}
// Verify it's valid JSON
var doc map[string]any
if err := json.Unmarshal(jsonBytes, &doc); err != nil {
t.Fatalf("Failed to unmarshal DID document JSON: %v", err)
}
// Verify required fields
if id, ok := doc["id"].(string); !ok || id != "did:web:hold.example.com" {
t.Errorf("Expected id='did:web:hold.example.com', got %v", doc["id"])
}
if _, ok := doc["@context"]; !ok {
t.Error("Expected @context field in JSON")
}
if _, ok := doc["verificationMethod"]; !ok {
t.Error("Expected verificationMethod field in JSON")
}
if _, ok := doc["service"]; !ok {
t.Error("Expected service field in JSON")
}
// Verify pretty-printed (has indentation)
if len(jsonBytes) < 100 {
t.Error("Expected pretty-printed JSON to be reasonably sized")
}
}
// TestGenerateDIDDocument_InvalidURL tests error handling
func TestGenerateDIDDocument_InvalidURL(t *testing.T) {
ctx := context.Background()
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "pds.db")
keyPath := filepath.Join(tmpDir, "signing-key")
publicURL := "https://hold.example.com"
pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", publicURL, "https://atcr.io", dbPath, keyPath, false)
if err != nil {
t.Fatalf("Failed to create PDS: %v", err)
}
// Try to generate DID document with invalid URL
_, err = pds.GenerateDIDDocument("ht!tp://invalid url")
if err == nil {
t.Error("Expected error for invalid URL, got nil")
}
}
-11
View File
@@ -1,11 +0,0 @@
package pds
import (
"context"
"io"
)
// ExportToCAR streams the hold's repo as a CAR file to the writer.
func (p *HoldPDS) ExportToCAR(ctx context.Context, w io.Writer) error {
return p.repomgr.ReadRepo(ctx, p.uid, "", w)
}
@@ -11,6 +11,7 @@ import (
"strings"
"atcr.io/pkg/atproto"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/auth/oauth"
holddb "atcr.io/pkg/hold/db"
"atcr.io/pkg/s3"
@@ -36,6 +37,17 @@ func init() {
lexutil.RegisterType(atproto.ImageConfigCollection, &atproto.ImageConfigRecord{})
}
// HoldServices returns the service entries the hold publishes in its DID document
// and PLC operations: an atproto PDS endpoint plus the ATCR hold service endpoint.
// Single source of truth, used by both boot-time identity loading and DID-document
// serving.
func HoldServices(publicURL string) map[string]did.Service {
return map[string]did.Service{
"atproto_pds": {Type: "AtprotoPersonalDataServer", Endpoint: publicURL},
"atcr_hold": {Type: "AtcrHoldService", Endpoint: publicURL},
}
}
// HoldPDS is a minimal ATProto PDS implementation for a hold service
type HoldPDS struct {
did string
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"atcr.io/pkg/atproto"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/hold/quota"
"atcr.io/pkg/s3"
"github.com/bluesky-social/indigo/api/bsky"
@@ -425,7 +426,7 @@ func (h *XRPCHandler) HandleDescribeRepo(w http.ResponseWriter, r *http.Request)
}
// Generate DID document
didDoc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL)
didDoc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL))
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
@@ -1387,7 +1388,7 @@ func (h *XRPCHandler) HandleGetLatestCommit(w http.ResponseWriter, r *http.Reque
// HandleDIDDocument returns the DID document
func (h *XRPCHandler) HandleDIDDocument(w http.ResponseWriter, r *http.Request) {
doc, err := h.pds.GenerateDIDDocument(h.pds.PublicURL)
doc, err := did.BuildDIDDocument(h.pds.DID(), h.pds.PublicURL, h.pds.SigningKey(), "atproto", HoldServices(h.pds.PublicURL))
if err != nil {
http.Error(w, fmt.Sprintf("failed to generate DID document: %v", err), http.StatusInternalServerError)
return
+2 -9
View File
@@ -11,6 +11,7 @@ import (
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/hold/admin"
holddb "atcr.io/pkg/hold/db"
"atcr.io/pkg/hold/gc"
@@ -76,15 +77,7 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) {
if cfg.Database.Path != "" {
ctx := context.Background()
holdDID, err := pds.LoadOrCreateDID(ctx, pds.DIDConfig{
DID: cfg.Database.DID,
DIDMethod: cfg.Database.DIDMethod,
PublicURL: cfg.Server.PublicURL,
DBPath: cfg.Database.Path,
SigningKeyPath: cfg.Database.KeyPath,
RotationKey: cfg.Database.RotationKey,
PLCDirectoryURL: cfg.Database.PLCDirectoryURL,
})
holdDID, err := did.LoadOrCreate(ctx, cfg.DIDConfig())
if err != nil {
return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
}
+188 -21
View File
@@ -1,26 +1,53 @@
package labeler
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"fmt"
"html/template"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
)
// Session represents an authenticated admin session.
const (
sessionCookieName = "labeler_session"
sessionTTL = 24 * time.Hour
csrfHeaderName = "X-CSRF-Token"
csrfFormField = "csrf_token"
)
// Session represents an authenticated admin session. Restart wipes the in-memory
// map so any stolen cookie token becomes useless after a restart, by design.
//
// UserAgent and IPPrefix are captured at login and rechecked on every request —
// a stolen token replayed from a different browser or network prefix is rejected
// and the session is torn down. Empty bound values (Unix sockets, tests, unusual
// proxies) opt out rather than locking users out. Binding at /24 (IPv4) / /64
// (IPv6) tolerates DHCP renewals within a prefix without inviting cross-network
// replay.
type Session struct {
DID string
Handle string
DID string
Handle string
CSRFToken string
CreatedAt time.Time
UserAgent string
IPPrefix string
}
// Auth manages admin authentication.
// Auth manages in-memory admin sessions for the labeler.
type Auth struct {
ownerDID string
sessions map[string]*Session
sessionsMu sync.RWMutex
}
// NewAuth creates a new Auth manager.
// NewAuth wires a fresh in-memory session store keyed to the configured owner DID.
func NewAuth(ownerDID string) *Auth {
return &Auth{
ownerDID: ownerDID,
@@ -28,41 +55,70 @@ func NewAuth(ownerDID string) *Auth {
}
}
func (a *Auth) createSession(did, handle string) (string, error) {
func randToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
return "", fmt.Errorf("generate token: %w", err)
}
token := base64.URLEncoding.EncodeToString(b)
return base64.URLEncoding.EncodeToString(b), nil
}
// CreateSession installs a new in-memory session and returns its cookie token
// alongside the embedded CSRF token (for echoing into forms).
func (a *Auth) CreateSession(did, handle, userAgent, ipPrefix string) (string, *Session, error) {
token, err := randToken()
if err != nil {
return "", nil, err
}
csrfToken, err := randToken()
if err != nil {
return "", nil, err
}
s := &Session{
DID: did,
Handle: handle,
CSRFToken: csrfToken,
CreatedAt: time.Now(),
UserAgent: userAgent,
IPPrefix: ipPrefix,
}
a.sessionsMu.Lock()
a.sessions[token] = &Session{DID: did, Handle: handle}
a.sessions[token] = s
a.sessionsMu.Unlock()
return token, nil
return token, s, nil
}
func (a *Auth) getSession(token string) *Session {
// GetSession returns the session for the cookie token, evicting expired entries on access.
func (a *Auth) GetSession(token string) *Session {
a.sessionsMu.RLock()
defer a.sessionsMu.RUnlock()
return a.sessions[token]
s := a.sessions[token]
a.sessionsMu.RUnlock()
if s == nil {
return nil
}
if !s.CreatedAt.IsZero() && time.Since(s.CreatedAt) > sessionTTL {
a.sessionsMu.Lock()
delete(a.sessions, token)
a.sessionsMu.Unlock()
return nil
}
return s
}
func (a *Auth) deleteSession(token string) {
// DeleteSession removes a session by cookie token (logout).
func (a *Auth) DeleteSession(token string) {
a.sessionsMu.Lock()
delete(a.sessions, token)
a.sessionsMu.Unlock()
}
const sessionCookieName = "labeler_session"
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: 86400, // 24 hours
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
@@ -88,7 +144,18 @@ func getSessionCookie(r *http.Request) (string, bool) {
return cookie.Value, true
}
// RequireOwner is middleware that checks the session belongs to the owner DID.
type sessionContextKeyT struct{}
var sessionContextKey = sessionContextKeyT{}
// SessionFromContext returns the session attached to the request context, if any.
func SessionFromContext(ctx context.Context) *Session {
s, _ := ctx.Value(sessionContextKey).(*Session)
return s
}
// RequireOwner enforces a valid session bound to the owner DID, with UA / IP-prefix
// replay defense. State-mutating methods then go through the CSRF check below.
func (a *Auth) RequireOwner(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := getSessionCookie(r)
@@ -96,11 +163,111 @@ func (a *Auth) RequireOwner(next http.Handler) http.Handler {
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
session := a.getSession(token)
if session == nil || session.DID != a.ownerDID {
session := a.GetSession(token)
if session == nil {
clearSessionCookie(w)
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
if session.DID != a.ownerDID {
a.DeleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
return
}
if session.UserAgent != "" && session.UserAgent != r.UserAgent() {
slog.Warn("Admin session UA mismatch — suspected token replay", "did", session.DID)
a.DeleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
return
}
if session.IPPrefix != "" {
if now := clientIPPrefix(r); now != "" && now != session.IPPrefix {
slog.Warn("Admin session IP-prefix mismatch — suspected token replay",
"did", session.DID, "session", session.IPPrefix, "request", now)
a.DeleteSession(token)
clearSessionCookie(w)
http.Redirect(w, r, "/auth/login?error=access+denied", http.StatusFound)
return
}
}
ctx := context.WithValue(r.Context(), sessionContextKey, session)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireCSRF validates a per-session CSRF token on state-mutating requests. Safe
// methods pass through. Token comes from X-CSRF-Token header or the csrf_token form
// field for application/x-www-form-urlencoded bodies. Must run after RequireOwner.
func (a *Auth) RequireCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
session := SessionFromContext(r.Context())
if session == nil || session.CSRFToken == "" {
http.Error(w, "Forbidden: missing session", http.StatusForbidden)
return
}
got := r.Header.Get(csrfHeaderName)
if got == "" {
contentType := r.Header.Get("Content-Type")
if idx := strings.IndexByte(contentType, ';'); idx >= 0 {
contentType = contentType[:idx]
}
contentType = strings.TrimSpace(strings.ToLower(contentType))
if contentType == "application/x-www-form-urlencoded" {
if err := r.ParseForm(); err == nil {
got = r.PostFormValue(csrfFormField)
}
}
}
if subtle.ConstantTimeCompare([]byte(got), []byte(session.CSRFToken)) != 1 {
slog.Warn("Labeler CSRF mismatch", "path", r.URL.Path, "did", session.DID)
http.Error(w, "Forbidden: CSRF token mismatch — reload the page and try again.", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// csrfInputHTML emits a hidden form input carrying the per-session CSRF token.
func csrfInputHTML(token string) template.HTML {
escaped := template.HTMLEscapeString(token)
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + escaped + `">`)
}
// clientIPPrefix returns a stable prefix key for the request's client IP — /24 for
// IPv4, /64 for IPv6. Empty string means "don't bind" (avoids locking users behind
// unusual proxies / Unix sockets / tests).
func clientIPPrefix(r *http.Request) string {
var host string
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
if comma := strings.IndexByte(fwd, ','); comma >= 0 {
host = strings.TrimSpace(fwd[:comma])
} else {
host = strings.TrimSpace(fwd)
}
} else {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
host = h
} else {
host = r.RemoteAddr
}
}
ip := net.ParseIP(host)
if ip == nil {
return ""
}
if v4 := ip.To4(); v4 != nil {
return fmt.Sprintf("v4:%d.%d.%d", v4[0], v4[1], v4[2])
}
v6 := ip.To16()
return fmt.Sprintf("v6:%02x%02x%02x%02x%02x%02x%02x%02x",
v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7])
}
+78 -18
View File
@@ -5,7 +5,9 @@ package labeler
import (
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
@@ -30,11 +32,41 @@ type LabelerConfig struct {
// Listen address for the labeler HTTP server.
Addr string `yaml:"addr" comment:"Listen address for labeler (e.g., :5002)."`
// PublicURL is the externally reachable URL of the labeler. When empty the URL is
// derived from server.base_url by prefixing "labeler." (so https://atcr.io →
// https://labeler.atcr.io). Set explicitly for IP-based dev environments.
PublicURL string `yaml:"public_url" comment:"Externally reachable labeler URL. Empty = derive from server.base_url."`
// DID of the labeler admin. Only this DID can log into the admin panel.
OwnerDID string `yaml:"owner_did" comment:"DID of the labeler admin. Only this DID can log into the admin panel."`
// Path to labeler SQLite database.
DBPath string `yaml:"db_path" comment:"Path to labeler SQLite database."`
// Directory for labeler state: SQLite database, signing key, did.txt.
DataDir string `yaml:"data_dir" comment:"Directory for labeler state (database, signing key, did.txt)."`
// DID method: "plc" (recommended, portable) or "web" (hostname-bound).
DIDMethod string `yaml:"did_method" comment:"DID method: \"plc\" (recommended) or \"web\"."`
// Explicit DID for did:plc adoption/recovery (optional).
DID string `yaml:"did" comment:"Explicit did:plc identifier for adoption/recovery (optional)."`
// Signing key path (defaults to <DataDir>/signing.key).
KeyPath string `yaml:"key_path" comment:"Path to K-256 signing key (defaults to <data_dir>/signing.key)."`
// Rotation key multibase (K-256 or P-256). Required to update the PLC document.
RotationKey string `yaml:"rotation_key" comment:"Multibase-encoded rotation key (K-256 or P-256). Required to update the PLC document."`
// PLC directory URL (default https://plc.directory).
PLCDirectoryURL string `yaml:"plc_directory_url" comment:"PLC directory URL (default https://plc.directory)."`
// LibsqlSyncURL enables embedded-replica sync to a remote libSQL/Bunny database when set.
// Empty = local-only mode (default).
LibsqlSyncURL string `yaml:"libsql_sync_url" comment:"Optional libSQL/Bunny remote sync URL. Empty = local-only."`
// LibsqlAuthToken is the auth token for the remote libSQL database.
LibsqlAuthToken string `yaml:"libsql_auth_token" comment:"Auth token for libsql_sync_url."`
// LibsqlSyncInterval is how often the embedded replica pulls from the remote.
LibsqlSyncInterval time.Duration `yaml:"libsql_sync_interval" comment:"Embedded-replica pull interval (e.g. 30s). 0 = manual sync only."`
}
// AppviewServerConfig is a subset of the appview ServerConfig that the labeler needs.
@@ -45,9 +77,13 @@ type AppviewServerConfig struct {
TestMode bool `yaml:"test_mode"`
}
// PublicURL returns the labeler's public URL derived from the appview base URL.
// If appview is https://atcr.io, labeler is https://labeler.atcr.io.
// PublicURL returns the labeler's externally reachable URL. When labeler.public_url
// is set explicitly it wins; otherwise it's derived from server.base_url by prefixing
// "labeler." (so https://atcr.io → https://labeler.atcr.io).
func (c *Config) PublicURL() string {
if c.Labeler.PublicURL != "" {
return c.Labeler.PublicURL
}
u, err := url.Parse(c.Server.BaseURL)
if err != nil {
return ""
@@ -56,17 +92,25 @@ func (c *Config) PublicURL() string {
return u.String()
}
// DID returns the labeler's did:web identity derived from its public URL.
func (c *Config) DID() string {
u, err := url.Parse(c.PublicURL())
if err != nil {
return ""
// DBPath returns the path to the SQLite database file inside the data dir.
func (c *Config) DBPath() string {
return filepath.Join(c.Labeler.DataDir, "labeler.db")
}
// SigningKeyPath returns the configured signing key path or the default inside DataDir.
func (c *Config) SigningKeyPath() string {
if c.Labeler.KeyPath != "" {
return c.Labeler.KeyPath
}
host := u.Hostname()
if port := u.Port(); port != "" {
host += "%3A" + port
return filepath.Join(c.Labeler.DataDir, "signing.key")
}
// PLCDirectoryURL returns the configured PLC directory URL or the canonical default.
func (c *Config) PLCDirectoryURL() string {
if c.Labeler.PLCDirectoryURL != "" {
return c.Labeler.PLCDirectoryURL
}
return "did:web:" + host
return "https://plc.directory"
}
func setDefaults(v *viper.Viper) {
@@ -76,8 +120,17 @@ func setDefaults(v *viper.Viper) {
// Labeler defaults
v.SetDefault("labeler.enabled", false)
v.SetDefault("labeler.addr", ":5002")
v.SetDefault("labeler.public_url", "")
v.SetDefault("labeler.owner_did", "")
v.SetDefault("labeler.db_path", "/var/lib/atcr-labeler/labeler.db")
v.SetDefault("labeler.data_dir", "/var/lib/atcr-labeler")
v.SetDefault("labeler.did_method", "plc")
v.SetDefault("labeler.did", "")
v.SetDefault("labeler.key_path", "")
v.SetDefault("labeler.rotation_key", "")
v.SetDefault("labeler.plc_directory_url", "https://plc.directory")
v.SetDefault("labeler.libsql_sync_url", "")
v.SetDefault("labeler.libsql_auth_token", "")
v.SetDefault("labeler.libsql_sync_interval", 0)
// Server defaults (read from shared appview config)
v.SetDefault("server.base_url", "")
@@ -121,6 +174,11 @@ func LoadConfig(yamlPath string) (*Config, error) {
if !strings.HasPrefix(cfg.Labeler.OwnerDID, "did:") {
return nil, fmt.Errorf("labeler.owner_did must be a DID (got %q)", cfg.Labeler.OwnerDID)
}
switch cfg.Labeler.DIDMethod {
case "plc", "web":
default:
return nil, fmt.Errorf("labeler.did_method must be \"plc\" or \"web\" (got %q)", cfg.Labeler.DIDMethod)
}
return cfg, nil
}
@@ -136,10 +194,12 @@ func ExampleYAML() ([]byte, error) {
ClientShortName: "ATCR",
},
Labeler: LabelerConfig{
Enabled: true,
Addr: ":5002",
OwnerDID: "did:plc:your-did-here",
DBPath: "/var/lib/atcr-labeler/labeler.db",
Enabled: true,
Addr: ":5002",
OwnerDID: "did:plc:your-did-here",
DataDir: "/var/lib/atcr-labeler",
DIDMethod: "plc",
PLCDirectoryURL: "https://plc.directory",
},
}
return config.MarshalCommentedYAML("ATCR Labeler Configuration", cfg)
-24
View File
@@ -25,27 +25,3 @@ func TestConfig_PublicURL(t *testing.T) {
})
}
}
func TestConfig_DID(t *testing.T) {
tests := []struct {
name string
baseURL string
want string
}{
{"standard", "https://atcr.io", "did:web:labeler.atcr.io"},
{"with port", "https://atcr.io:8080", "did:web:labeler.atcr.io%3A8080"},
{"localhost", "http://localhost:5000", "did:web:labeler.localhost%3A5000"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{
Server: AppviewServerConfig{BaseURL: tt.baseURL},
}
got := cfg.DID()
if got != tt.want {
t.Errorf("DID() = %q, want %q", got, tt.want)
}
})
}
}
+240 -86
View File
@@ -3,13 +3,21 @@ package labeler
import (
"database/sql"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/tursodatabase/go-libsql"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/bluesky-social/indigo/atproto/labeling"
"github.com/tursodatabase/go-libsql"
)
// LabelVersion is the ATProto label format version (currently 1).
const LabelVersion int64 = labeling.ATPROTO_LABEL_VERSION
const schema = `
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -20,15 +28,19 @@ CREATE TABLE IF NOT EXISTS labels (
neg BOOLEAN NOT NULL DEFAULT 0,
cts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
exp TIMESTAMP,
ver INTEGER NOT NULL DEFAULT 1,
sig BLOB NOT NULL,
subject_did TEXT NOT NULL,
subject_repo TEXT NOT NULL DEFAULT '',
UNIQUE(src, uri, val, neg)
subject_repo TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo);
CREATE INDEX IF NOT EXISTS idx_labels_cts ON labels(cts DESC);
CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri);
`
// Label represents an ATProto label (com.atproto.label.defs#label).
// Label represents an ATProto label record stored locally. Its on-the-wire representation
// is produced by ToLabeling() which round-trips through indigo's labeling package so the
// signature stays valid byte-for-byte.
type Label struct {
ID int64
Src string
@@ -38,100 +50,218 @@ type Label struct {
Neg bool
Cts time.Time
Exp *time.Time
Ver int64
Sig []byte
SubjectDID string
SubjectRepo string
}
// OpenDB opens or creates the labeler database.
func OpenDB(dbPath string) (*sql.DB, error) {
// LibsqlSync configures optional embedded-replica sync to a remote libSQL database.
// SyncURL empty means local-only mode.
type LibsqlSync struct {
SyncURL string
AuthToken string
SyncInterval time.Duration
}
// LabelerDB wraps the *sql.DB plus its libsql connector (when in embedded-replica mode)
// so the caller can release file locks on shutdown.
type LabelerDB struct {
DB *sql.DB
connector io.Closer
}
// Close closes the database and the libsql connector (if any). The connector close is
// what releases file locks; without it a subsequent local-only open errors with
// "database is locked" — the same gotcha the hold ran into.
func (l *LabelerDB) Close() error {
var dbErr, connErr error
if l.DB != nil {
dbErr = l.DB.Close()
}
if l.connector != nil {
connErr = l.connector.Close()
}
if dbErr != nil {
return dbErr
}
return connErr
}
// OpenDB opens or creates the labeler database. When sync.SyncURL is set, the DB runs
// in embedded-replica mode (writes go to the remote, frames replicate to the local
// file); otherwise it's a plain local libSQL file. Schema is applied either way.
func OpenDB(dbPath string, sync LibsqlSync) (*LabelerDB, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("failed to create db directory: %w", err)
}
db, err := sql.Open("libsql", "file:"+dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
var (
db *sql.DB
connector io.Closer
)
if sync.SyncURL != "" {
opts := []libsql.Option{libsql.WithAuthToken(sync.AuthToken)}
if sync.SyncInterval > 0 {
opts = append(opts, libsql.WithSyncInterval(sync.SyncInterval))
}
conn, err := libsql.NewEmbeddedReplicaConnector(dbPath, sync.SyncURL, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create libsql embedded replica connector: %w", err)
}
db = sql.OpenDB(conn)
connector = conn
slog.Info("Labeler database opened in embedded replica mode", "path", dbPath, "sync_url", sync.SyncURL)
} else {
dsn := dbPath
if !strings.HasPrefix(dsn, "file:") && !strings.HasPrefix(dsn, ":memory:") {
dsn = "file:" + dsn
}
var err error
db, err = sql.Open("libsql", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
slog.Info("Labeler database opened in local-only mode", "path", dbPath)
}
// Local PRAGMAs only — Bunny rejects PRAGMA assignments forwarded over the
// replication protocol (same caveat as pkg/hold/db).
if sync.SyncURL == "" {
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
_ = closeIfNonNil(db, connector)
return nil, fmt.Errorf("failed to set journal mode: %w", err)
}
var busyTimeout int
if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
_ = closeIfNonNil(db, connector)
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
}
}
// Apply schema
for _, stmt := range splitStatements(schema) {
if _, err := db.Exec(stmt); err != nil {
_ = closeIfNonNil(db, connector)
return nil, fmt.Errorf("failed to apply schema: %w", err)
}
}
return &LabelerDB{DB: db, connector: connector}, nil
}
return db, nil
// closeIfNonNil is the defensive cleanup for the failure path on OpenDB so we don't
// leave file locks dangling if schema application fails.
func closeIfNonNil(db *sql.DB, connector io.Closer) error {
if db != nil {
_ = db.Close()
}
if connector != nil {
return connector.Close()
}
return nil
}
// splitStatements splits SQL by semicolons (go-libsql doesn't support multi-statement exec).
func splitStatements(sql string) []string {
var stmts []string
for _, s := range splitOnSemicolon(sql) {
s = trimSpace(s)
parts := strings.Split(sql, ";")
out := make([]string, 0, len(parts))
for _, s := range parts {
s = strings.TrimSpace(s)
if s != "" {
stmts = append(stmts, s)
out = append(out, s)
}
}
return stmts
return out
}
func splitOnSemicolon(s string) []string {
var parts []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ';' {
parts = append(parts, s[start:i])
start = i + 1
}
// ToLabeling converts the row into indigo's label struct (deterministic CBOR shape).
func (l *Label) ToLabeling() labeling.Label {
out := labeling.Label{
CreatedAt: l.Cts.UTC().Format(time.RFC3339),
SourceDID: l.Src,
URI: l.URI,
Val: l.Val,
Version: l.Ver,
}
if start < len(s) {
parts = append(parts, s[start:])
if l.CID != "" {
s := l.CID
out.CID = &s
}
return parts
if l.Exp != nil {
s := l.Exp.UTC().Format(time.RFC3339)
out.ExpiresAt = &s
}
if l.Neg {
t := true
out.Negated = &t
}
if len(l.Sig) > 0 {
out.Sig = l.Sig
}
return out
}
func trimSpace(s string) string {
// Simple trim that handles newlines and spaces
i := 0
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
i++
// Sign computes a k256 signature over the deterministic CBOR encoding of the label
// (without the sig field) and stores it on the row.
func (l *Label) Sign(key *atcrypto.PrivateKeyK256) error {
if l.Ver == 0 {
l.Ver = LabelVersion
}
j := len(s)
for j > i && (s[j-1] == ' ' || s[j-1] == '\t' || s[j-1] == '\n' || s[j-1] == '\r') {
j--
if l.Cts.IsZero() {
l.Cts = time.Now().UTC()
}
return s[i:j]
pre := l.ToLabeling()
pre.Sig = nil
if err := pre.Sign(key); err != nil {
return fmt.Errorf("failed to sign label: %w", err)
}
l.Sig = pre.Sig
return nil
}
// CreateLabel inserts a new label into the database.
// CreateLabel inserts a freshly signed label and returns its sequence id.
// Caller must Sign() first — CreateLabel rejects rows missing a signature.
func CreateLabel(db *sql.DB, l *Label) (int64, error) {
if len(l.Sig) == 0 {
return 0, fmt.Errorf("refusing to insert unsigned label")
}
if l.Ver == 0 {
l.Ver = LabelVersion
}
var expStr *string
if l.Exp != nil {
s := l.Exp.UTC().Format(time.RFC3339)
expStr = &s
}
result, err := db.Exec(
`INSERT INTO labels (src, uri, cid, val, neg, cts, exp, subject_did, subject_repo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(src, uri, val, neg) DO UPDATE SET cts = excluded.cts`,
l.Src, l.URI, l.CID, l.Val, l.Neg, l.Cts.UTC().Format(time.RFC3339), l.Exp,
`INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.Src, l.URI, nullableString(l.CID), l.Val, l.Neg,
l.Cts.UTC().Format(time.RFC3339), expStr, l.Ver, l.Sig,
l.SubjectDID, l.SubjectRepo,
)
if err != nil {
return 0, fmt.Errorf("failed to create label: %w", err)
return 0, fmt.Errorf("failed to insert label: %w", err)
}
return result.LastInsertId()
id, err := result.LastInsertId()
if err != nil {
return 0, err
}
l.ID = id
return id, nil
}
// NegateLabel creates a negation label to reverse a previous label.
func NegateLabel(db *sql.DB, src, uri, val string, subjectDID, subjectRepo string) error {
_, err := db.Exec(
`INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo)
VALUES (?, ?, ?, 1, ?, ?, ?)`,
src, uri, val, time.Now().UTC().Format(time.RFC3339), subjectDID, subjectRepo,
)
return err
func nullableString(s string) any {
if s == "" {
return nil
}
return s
}
// GetLabelsSince returns labels with id > cursor, ordered by id ascending.
func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) {
rows, err := db.Query(
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, subject_did, subject_repo
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
FROM labels WHERE id > ? ORDER BY id ASC LIMIT ?`,
cursor, limit,
)
@@ -139,10 +269,21 @@ func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) {
return nil, err
}
defer rows.Close()
return scanLabels(rows)
}
// LatestSeq returns the highest sequence id in the database, or 0 if empty.
func LatestSeq(db *sql.DB) (int64, error) {
var seq sql.NullInt64
if err := db.QueryRow(`SELECT MAX(id) FROM labels`).Scan(&seq); err != nil {
return 0, err
}
if !seq.Valid {
return 0, nil
}
return seq.Int64, nil
}
// ListActiveTakedowns returns active (non-negated) takedown labels.
func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
var total int
@@ -161,7 +302,7 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
}
rows, err := db.Query(
`SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.subject_did, l1.subject_repo
`SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.ver, l1.sig, l1.subject_did, l1.subject_repo
FROM labels l1
WHERE l1.val = '!takedown' AND l1.neg = 0
AND NOT EXISTS (
@@ -182,10 +323,10 @@ func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
return labels, total, err
}
// GetLabelsForRepo returns all active labels for a specific DID + repository.
// GetLabelsForRepo returns all labels for a specific DID + repository.
func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
rows, err := db.Query(
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, subject_did, subject_repo
`SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
FROM labels
WHERE subject_did = ? AND subject_repo = ?
ORDER BY cts DESC`,
@@ -198,55 +339,67 @@ func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
return scanLabels(rows)
}
// NegateRepoLabels creates negation labels for all active takedown labels on a (DID, repo) pair.
func NegateRepoLabels(db *sql.DB, src, did, repo string) error {
// newNegationLabel constructs an unsigned negation label awaiting Sign().
func newNegationLabel(src, uri, val, did, repo string) *Label {
return &Label{
Src: src,
URI: uri,
Val: val,
Neg: true,
Cts: time.Now().UTC(),
SubjectDID: did,
SubjectRepo: repo,
}
}
// NegateRepoLabels signs+inserts negation labels for all active takedown labels on (DID, repo).
func NegateRepoLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did, repo string) ([]Label, error) {
rows, err := db.Query(
`SELECT uri FROM labels
WHERE subject_did = ? AND subject_repo = ? AND val = '!takedown' AND neg = 0`,
did, repo,
)
if err != nil {
return err
return nil, err
}
var uris []string
for rows.Next() {
var uri string
if err := rows.Scan(&uri); err != nil {
rows.Close()
return err
return nil, err
}
uris = append(uris, uri)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
return nil, err
}
now := time.Now().UTC().Format(time.RFC3339)
out := make([]Label, 0, len(uris))
for _, uri := range uris {
if _, err := db.Exec(
`INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo)
VALUES (?, ?, '!takedown', 1, ?, ?, ?)`,
src, uri, now, did, repo,
); err != nil {
return err
neg := newNegationLabel(src, uri, "!takedown", did, repo)
if err := neg.Sign(key); err != nil {
return out, err
}
if _, err := CreateLabel(db, neg); err != nil {
return out, err
}
out = append(out, *neg)
}
return nil
return out, nil
}
// NegateUserLabels creates negation labels for all active takedown labels on a DID (user-level).
func NegateUserLabels(db *sql.DB, src, did string) error {
// NegateUserLabels signs+inserts negation labels for all active takedown labels on a DID.
func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string) ([]Label, error) {
rows, err := db.Query(
`SELECT uri, subject_repo FROM labels
WHERE subject_did = ? AND val = '!takedown' AND neg = 0`,
did,
)
if err != nil {
return err
return nil, err
}
type uriRepo struct {
uri string
repo string
@@ -256,26 +409,27 @@ func NegateUserLabels(db *sql.DB, src, did string) error {
var e uriRepo
if err := rows.Scan(&e.uri, &e.repo); err != nil {
rows.Close()
return err
return nil, err
}
entries = append(entries, e)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
return nil, err
}
now := time.Now().UTC().Format(time.RFC3339)
out := make([]Label, 0, len(entries))
for _, e := range entries {
if _, err := db.Exec(
`INSERT INTO labels (src, uri, val, neg, cts, subject_did, subject_repo)
VALUES (?, ?, '!takedown', 1, ?, ?, ?)`,
src, e.uri, now, did, e.repo,
); err != nil {
return err
neg := newNegationLabel(src, e.uri, "!takedown", did, e.repo)
if err := neg.Sign(key); err != nil {
return out, err
}
if _, err := CreateLabel(db, neg); err != nil {
return out, err
}
out = append(out, *neg)
}
return nil
return out, nil
}
func scanLabels(rows *sql.Rows) ([]Label, error) {
@@ -284,7 +438,7 @@ func scanLabels(rows *sql.Rows) ([]Label, error) {
var l Label
var cts string
var exp *string
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.SubjectDID, &l.SubjectRepo); err != nil {
if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo); err != nil {
return nil, err
}
if t, err := time.Parse(time.RFC3339, cts); err == nil {
+141 -180
View File
@@ -1,31 +1,66 @@
package labeler
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
func newTestKey(t *testing.T) *atcrypto.PrivateKeyK256 {
t.Helper()
k, err := atcrypto.GeneratePrivateKeyK256()
if err != nil {
t.Fatalf("generate key: %v", err)
}
return k
}
// openTestDB opens a fresh local-only labeler DB and registers cleanup. Returns the
// raw *sql.DB so existing tests can keep using it; the wrapper lifecycle is handled
// here so tests don't have to know about the embedded-replica machinery.
func openTestDB(t *testing.T, path string) *sql.DB {
t.Helper()
storage, err := OpenDB(path, LibsqlSync{})
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = storage.Close() })
return storage.DB
}
// signAndCreate is a helper that signs the label and inserts it; it returns the row id.
func signAndCreate(t *testing.T, db *sql.DB, key *atcrypto.PrivateKeyK256, l *Label) int64 {
t.Helper()
if err := l.Sign(key); err != nil {
t.Fatalf("sign: %v", err)
}
id, err := CreateLabel(db, l)
if err != nil {
t.Fatalf("create: %v", err)
}
return id
}
func TestOpenDB(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "subdir", "test.db")
db, err := OpenDB(dbPath)
storage, err := OpenDB(dbPath, LibsqlSync{})
if err != nil {
t.Fatalf("OpenDB failed: %v", err)
}
defer db.Close()
defer storage.Close()
// Verify directory was created
if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
t.Error("expected directory to be created")
}
// Verify tables exist
var count int
err = db.QueryRow("SELECT COUNT(*) FROM labels").Scan(&count)
if err != nil {
if err := storage.DB.QueryRow("SELECT COUNT(*) FROM labels").Scan(&count); err != nil {
t.Fatalf("failed to query labels table: %v", err)
}
if count != 0 {
@@ -35,31 +70,26 @@ func TestOpenDB(t *testing.T) {
func TestCreateLabel(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
now := time.Now().UTC().Truncate(time.Second)
label := &Label{
Src: "did:web:labeler.atcr.io",
Src: "did:plc:labeler-1",
URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
Val: "!takedown",
Cts: now,
SubjectDID: "did:plc:abc",
SubjectRepo: "myimage",
}
id, err := CreateLabel(db, label)
if err != nil {
t.Fatalf("CreateLabel failed: %v", err)
}
id := signAndCreate(t, db, key, label)
if id <= 0 {
t.Errorf("expected positive id, got %d", id)
}
if len(label.Sig) == 0 {
t.Error("expected signature populated by Sign()")
}
// Verify it was stored
labels, err := GetLabelsSince(db, 0, 10)
if err != nil {
t.Fatal(err)
@@ -67,146 +97,88 @@ func TestCreateLabel(t *testing.T) {
if len(labels) != 1 {
t.Fatalf("expected 1 label, got %d", len(labels))
}
if labels[0].Src != "did:web:labeler.atcr.io" {
t.Errorf("expected src did:web:labeler.atcr.io, got %s", labels[0].Src)
}
if labels[0].Val != "!takedown" {
t.Errorf("expected val !takedown, got %s", labels[0].Val)
if labels[0].Src != label.Src {
t.Errorf("Src = %s, want %s", labels[0].Src, label.Src)
}
if labels[0].SubjectDID != "did:plc:abc" {
t.Errorf("expected subject_did did:plc:abc, got %s", labels[0].SubjectDID)
t.Errorf("SubjectDID = %s", labels[0].SubjectDID)
}
if labels[0].SubjectRepo != "myimage" {
t.Errorf("expected subject_repo myimage, got %s", labels[0].SubjectRepo)
t.Errorf("SubjectRepo = %s", labels[0].SubjectRepo)
}
if labels[0].Ver != LabelVersion {
t.Errorf("Ver = %d, want %d", labels[0].Ver, LabelVersion)
}
if len(labels[0].Sig) == 0 {
t.Error("expected stored sig to be populated")
}
}
func TestCreateLabel_Upsert(t *testing.T) {
func TestCreateLabel_RejectsUnsigned(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db := openTestDB(t, filepath.Join(dir, "test.db"))
now := time.Now().UTC()
label := &Label{
Src: "did:web:labeler.atcr.io",
URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
Val: "!takedown",
Cts: now,
SubjectDID: "did:plc:abc",
SubjectRepo: "myimage",
Src: "did:plc:labeler-1", URI: "at://did:plc:abc",
Val: "!takedown", Cts: time.Now().UTC(),
SubjectDID: "did:plc:abc",
}
// First insert
_, err = CreateLabel(db, label)
if err != nil {
t.Fatal(err)
}
// Same (src, uri, val) - should upsert, not error
label.Cts = now.Add(time.Hour)
_, err = CreateLabel(db, label)
if err != nil {
t.Fatalf("upsert should not fail: %v", err)
}
// Should still be 1 label
labels, err := GetLabelsSince(db, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(labels) != 1 {
t.Errorf("expected 1 label after upsert, got %d", len(labels))
if _, err := CreateLabel(db, label); err == nil {
t.Fatal("expected CreateLabel to reject an unsigned label")
}
}
func TestNegateLabel(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
func TestSignAndVerify(t *testing.T) {
key := newTestKey(t)
label := &Label{
Src: "did:plc:labeler-1",
URI: "at://did:plc:abc",
Val: "!takedown",
Cts: time.Now().UTC(),
Ver: LabelVersion,
}
defer db.Close()
src := "did:web:labeler.atcr.io"
now := time.Now().UTC()
// Create a label
_, err = CreateLabel(db, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
Val: "!takedown", Cts: now,
SubjectDID: "did:plc:abc", SubjectRepo: "myimage",
})
if err != nil {
if err := label.Sign(key); err != nil {
t.Fatal(err)
}
// Negate it
err = NegateLabel(db, src, "at://did:plc:abc/io.atcr.manifest/sha256-123", "!takedown", "did:plc:abc", "myimage")
if err != nil {
t.Fatalf("NegateLabel failed: %v", err)
}
// Should have 2 labels now (original + negation)
labels, err := GetLabelsSince(db, 0, 10)
pub, err := key.PublicKey()
if err != nil {
t.Fatal(err)
}
if len(labels) != 2 {
t.Fatalf("expected 2 labels, got %d", len(labels))
}
// The negation label should have neg=true
negLabel := labels[1]
if !negLabel.Neg {
t.Error("expected negation label to have neg=true")
wire := label.ToLabeling()
if err := wire.VerifySignature(pub); err != nil {
t.Fatalf("signature did not verify: %v", err)
}
}
func TestListActiveTakedowns(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
src := "did:web:labeler.atcr.io"
src := "did:plc:labeler-1"
now := time.Now().UTC()
// Create 3 labels
for i, repo := range []string{"repo1", "repo2", "repo3"} {
_, err = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/" + repo,
Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute),
SubjectDID: "did:plc:abc", SubjectRepo: repo,
})
if err != nil {
t.Fatal(err)
}
}
// All 3 should be active
labels, total, err := ListActiveTakedowns(db, 10, 0)
if err != nil {
t.Fatal(err)
}
if total != 3 {
t.Errorf("expected 3 active takedowns, got %d", total)
}
if len(labels) != 3 {
t.Errorf("expected 3 labels returned, got %d", len(labels))
if total != 3 || len(labels) != 3 {
t.Errorf("expected 3 active takedowns, got total=%d returned=%d", total, len(labels))
}
// Negate one
err = NegateLabel(db, src, "at://did:plc:abc/io.atcr.repo/repo2", "!takedown", "did:plc:abc", "repo2")
if err != nil {
if _, err := NegateRepoLabels(db, key, src, "did:plc:abc", "repo2"); err != nil {
t.Fatal(err)
}
// Should be 2 active
_, total, err = ListActiveTakedowns(db, 10, 0)
if err != nil {
t.Fatal(err)
@@ -218,39 +190,33 @@ func TestListActiveTakedowns(t *testing.T) {
func TestNegateRepoLabels(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
src := "did:web:labeler.atcr.io"
src := "did:plc:labeler-1"
now := time.Now().UTC()
did := "did:plc:abc"
// Create multiple labels for same repo
uris := []string{
"at://did:plc:abc/io.atcr.manifest/sha256-111",
"at://did:plc:abc/io.atcr.manifest/sha256-222",
"at://did:plc:abc/io.atcr.tag/myimage-latest",
}
for _, uri := range uris {
_, err = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: uri, Val: "!takedown", Cts: now,
SubjectDID: did, SubjectRepo: "myimage",
})
if err != nil {
t.Fatal(err)
}
}
// Negate all labels for the repo
err = NegateRepoLabels(db, src, did, "myimage")
negs, err := NegateRepoLabels(db, key, src, did, "myimage")
if err != nil {
t.Fatal(err)
}
if len(negs) != len(uris) {
t.Errorf("expected %d negation labels, got %d", len(uris), len(negs))
}
// Should have 0 active takedowns
_, total, err := ListActiveTakedowns(db, 10, 0)
if err != nil {
t.Fatal(err)
@@ -262,39 +228,30 @@ func TestNegateRepoLabels(t *testing.T) {
func TestNegateUserLabels(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
src := "did:web:labeler.atcr.io"
src := "did:plc:labeler-1"
now := time.Now().UTC()
did := "did:plc:abc"
// Create labels for different repos + a user-level label
_, err = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc", Val: "!takedown", Cts: now,
SubjectDID: did, SubjectRepo: "",
SubjectDID: did,
})
if err != nil {
t.Fatal(err)
}
_, err = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1", Val: "!takedown", Cts: now,
SubjectDID: did, SubjectRepo: "repo1",
})
negs, err := NegateUserLabels(db, key, src, did)
if err != nil {
t.Fatal(err)
}
// Negate all labels for the user
err = NegateUserLabels(db, src, did)
if err != nil {
t.Fatal(err)
if len(negs) != 2 {
t.Errorf("expected 2 negation labels, got %d", len(negs))
}
// Should have 0 active
_, total, err := ListActiveTakedowns(db, 10, 0)
if err != nil {
t.Fatal(err)
@@ -306,28 +263,20 @@ func TestNegateUserLabels(t *testing.T) {
func TestGetLabelsSince(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
src := "did:web:labeler.atcr.io"
src := "did:plc:labeler-1"
now := time.Now().UTC()
// Create 5 labels
for i := 0; i < 5; i++ {
_, err = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.manifest/" + string(rune('a'+i)),
Val: "!takedown", Cts: now.Add(time.Duration(i) * time.Minute),
SubjectDID: "did:plc:abc", SubjectRepo: "repo",
})
if err != nil {
t.Fatal(err)
}
}
// Get all since 0
labels, err := GetLabelsSince(db, 0, 10)
if err != nil {
t.Fatal(err)
@@ -336,19 +285,15 @@ func TestGetLabelsSince(t *testing.T) {
t.Errorf("expected 5 labels, got %d", len(labels))
}
// Get since cursor (skip first 3)
if len(labels) >= 3 {
cursor := labels[2].ID
after, err := GetLabelsSince(db, cursor, 10)
if err != nil {
t.Fatal(err)
}
if len(after) != 2 {
t.Errorf("expected 2 labels after cursor %d, got %d", cursor, len(after))
}
cursor := labels[2].ID
after, err := GetLabelsSince(db, cursor, 10)
if err != nil {
t.Fatal(err)
}
if len(after) != 2 {
t.Errorf("expected 2 labels after cursor %d, got %d", cursor, len(after))
}
// Get with limit
limited, err := GetLabelsSince(db, 0, 2)
if err != nil {
t.Fatal(err)
@@ -358,32 +303,50 @@ func TestGetLabelsSince(t *testing.T) {
}
}
func TestGetLabelsForRepo(t *testing.T) {
func TestLatestSeq(t *testing.T) {
dir := t.TempDir()
db, err := OpenDB(filepath.Join(dir, "test.db"))
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
if seq, err := LatestSeq(db); err != nil || seq != 0 {
t.Fatalf("expected empty seq=0, got %d (err=%v)", seq, err)
}
id := signAndCreate(t, db, key, &Label{
Src: "did:plc:labeler-1", URI: "at://did:plc:abc",
Val: "!takedown", Cts: time.Now().UTC(),
SubjectDID: "did:plc:abc",
})
seq, err := LatestSeq(db)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if seq != id {
t.Errorf("LatestSeq = %d, want %d", seq, id)
}
}
src := "did:web:labeler.atcr.io"
func TestGetLabelsForRepo(t *testing.T) {
dir := t.TempDir()
db := openTestDB(t, filepath.Join(dir, "test.db"))
key := newTestKey(t)
src := "did:plc:labeler-1"
now := time.Now().UTC()
// Labels for different repos
_, _ = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo1",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo1",
})
_, _ = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:abc/io.atcr.repo/repo2",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:abc", SubjectRepo: "repo2",
})
_, _ = CreateLabel(db, &Label{
signAndCreate(t, db, key, &Label{
Src: src, URI: "at://did:plc:def/io.atcr.repo/repo1",
Val: "!takedown", Cts: now, SubjectDID: "did:plc:def", SubjectRepo: "repo1",
})
// Get labels for specific did+repo
labels, err := GetLabelsForRepo(db, "did:plc:abc", "repo1")
if err != nil {
t.Fatal(err)
@@ -392,7 +355,6 @@ func TestGetLabelsForRepo(t *testing.T) {
t.Errorf("expected 1 label for did:plc:abc/repo1, got %d", len(labels))
}
// Different user same repo
labels, err = GetLabelsForRepo(db, "did:plc:def", "repo1")
if err != nil {
t.Fatal(err)
@@ -401,7 +363,6 @@ func TestGetLabelsForRepo(t *testing.T) {
t.Errorf("expected 1 label for did:plc:def/repo1, got %d", len(labels))
}
// No labels
labels, err = GetLabelsForRepo(db, "did:plc:xyz", "repo1")
if err != nil {
t.Fatal(err)
+3 -4
View File
@@ -13,9 +13,8 @@ import (
// Auth handlers
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// If already logged in, redirect to dashboard
if token, ok := getSessionCookie(r); ok {
if session := s.auth.getSession(token); session != nil && session.DID == s.config.Labeler.OwnerDID {
if session := s.auth.GetSession(token); session != nil && session.DID == s.config.Labeler.OwnerDID {
http.Redirect(w, r, "/", http.StatusFound)
return
}
@@ -99,7 +98,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
return
}
token, err := s.auth.createSession(did, handle)
token, _, err := s.auth.CreateSession(did, handle, r.UserAgent(), clientIPPrefix(r))
if err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
@@ -111,7 +110,7 @@ func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if token, ok := getSessionCookie(r); ok {
s.auth.deleteSession(token)
s.auth.DeleteSession(token)
}
clearSessionCookie(w)
http.Redirect(w, r, "/auth/login", http.StatusFound)
+75
View File
@@ -0,0 +1,75 @@
package labeler
import (
"sync"
)
// hubSubscriber is one connected subscribeLabels client. The hub fans out new labels
// to each subscriber's bounded channel; if a slow client fills the buffer, the hub
// drops them rather than blocking the writer.
type hubSubscriber struct {
ch chan *Label
closed bool
}
// Hub broadcasts newly-inserted labels to all live subscribeLabels clients.
type Hub struct {
mu sync.Mutex
subs map[*hubSubscriber]struct{}
}
// NewHub returns an empty hub ready to accept subscribers.
func NewHub() *Hub {
return &Hub{subs: make(map[*hubSubscriber]struct{})}
}
// subscribe registers a new subscriber and returns its event channel + a cancel func.
// The buffer size bounds backpressure tolerance per client.
func (h *Hub) subscribe(buffer int) (*hubSubscriber, func()) {
s := &hubSubscriber{ch: make(chan *Label, buffer)}
h.mu.Lock()
h.subs[s] = struct{}{}
h.mu.Unlock()
return s, func() { h.unsubscribe(s) }
}
func (h *Hub) unsubscribe(s *hubSubscriber) {
h.mu.Lock()
defer h.mu.Unlock()
if _, ok := h.subs[s]; !ok {
return
}
delete(h.subs, s)
if !s.closed {
s.closed = true
close(s.ch)
}
}
// Broadcast sends a copy of the label to every live subscriber. Subscribers whose
// buffer is full are evicted on the spot rather than slowing down the writer.
func (h *Hub) Broadcast(l *Label) {
if l == nil {
return
}
h.mu.Lock()
dead := make([]*hubSubscriber, 0)
for s := range h.subs {
select {
case s.ch <- l:
default:
dead = append(dead, s)
}
}
h.mu.Unlock()
for _, s := range dead {
h.unsubscribe(s)
}
}
// Len returns the number of live subscribers (mostly for tests / metrics).
func (h *Hub) Len() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs)
}
+38 -21
View File
@@ -1,38 +1,55 @@
package labeler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"atcr.io/pkg/atproto/did"
"atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/atcrypto"
)
// DIDDocument represents a did:web DID document.
type DIDDocument struct {
Context []string `json:"@context"`
ID string `json:"id"`
Service []DIDService `json:"service,omitempty"`
// labelerServices returns the service entries the labeler publishes in its DID document
// and PLC operations: a single AtprotoLabeler endpoint at #atproto_labeler.
func labelerServices(publicURL string) map[string]did.Service {
return map[string]did.Service{
"atproto_labeler": {Type: "AtprotoLabeler", Endpoint: publicURL},
}
}
// DIDService represents a service entry in a DID document.
type DIDService struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
// LoadIdentity resolves the labeler's DID and loads its k256 signing key.
// For did:plc this calls into the shared PLC package (loading or creating); for did:web
// the DID is derived from PublicURL and the signing key is generated on disk if missing.
func LoadIdentity(ctx context.Context, cfg *Config) (string, *atcrypto.PrivateKeyK256, error) {
labelerDID, err := did.LoadOrCreate(ctx, did.Config{
Method: cfg.Labeler.DIDMethod,
PublicURL: cfg.PublicURL(),
DBPath: cfg.Labeler.DataDir,
SigningKeyPath: cfg.SigningKeyPath(),
RotationKey: cfg.Labeler.RotationKey,
PLCDirectoryURL: cfg.PLCDirectoryURL(),
DID: cfg.Labeler.DID,
VerificationKeyName: "atproto_label",
Services: labelerServices(cfg.PublicURL()),
})
if err != nil {
return "", nil, fmt.Errorf("labeler: failed to resolve DID: %w", err)
}
signingKey, err := oauth.GenerateOrLoadPDSKey(cfg.SigningKeyPath())
if err != nil {
return "", nil, fmt.Errorf("labeler: failed to load signing key: %w", err)
}
return labelerDID, signingKey, nil
}
func (s *Server) handleDIDDocument(w http.ResponseWriter, r *http.Request) {
doc := DIDDocument{
Context: []string{"https://www.w3.org/ns/did/v1"},
ID: s.config.DID(),
Service: []DIDService{
{
ID: "#atproto_labeler",
Type: "AtprotoLabeler",
ServiceEndpoint: s.config.PublicURL(),
},
},
doc, err := did.BuildDIDDocument(s.did, s.config.PublicURL(), s.signingKey, "atproto_label", labelerServices(s.config.PublicURL()))
if err != nil {
http.Error(w, "failed to build DID document", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
}
+50 -16
View File
@@ -5,34 +5,51 @@ import (
"database/sql"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/atcrypto"
indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/go-chi/chi/v5"
)
// Server is the labeler HTTP server.
type Server struct {
config *Config
db *sql.DB
router chi.Router
clientApp *indigooauth.ClientApp
auth *Auth
config *Config
storage *LabelerDB
db *sql.DB
router chi.Router
clientApp *indigooauth.ClientApp
auth *Auth
did string
signingKey *atcrypto.PrivateKeyK256
hub *Hub
}
// NewServer creates a new labeler server.
func NewServer(cfg *Config) (*Server, error) {
db, err := OpenDB(cfg.Labeler.DBPath)
storage, err := OpenDB(cfg.DBPath(), LibsqlSync{
SyncURL: cfg.Labeler.LibsqlSyncURL,
AuthToken: cfg.Labeler.LibsqlAuthToken,
SyncInterval: cfg.Labeler.LibsqlSyncInterval,
})
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
ctx := context.Background()
did, signingKey, err := LoadIdentity(ctx, cfg)
if err != nil {
_ = storage.Close()
return nil, err
}
publicURL := cfg.PublicURL()
// Set up OAuth client for admin login
@@ -68,10 +85,14 @@ func NewServer(cfg *Config) (*Server, error) {
auth := NewAuth(cfg.Labeler.OwnerDID)
s := &Server{
config: cfg,
db: db,
clientApp: clientApp,
auth: auth,
config: cfg,
storage: storage,
db: storage.DB,
clientApp: clientApp,
auth: auth,
did: did,
signingKey: signingKey,
hub: NewHub(),
}
s.setupRoutes()
@@ -97,9 +118,11 @@ func (s *Server) setupRoutes() {
r.Get("/xrpc/com.atproto.label.subscribeLabels", s.handleSubscribeLabels)
r.Get("/xrpc/com.atproto.label.queryLabels", s.handleQueryLabels)
// Protected routes (require owner)
// Protected routes (require owner). CSRF is enforced for state-mutating
// methods inside the same group, so it sees the session on the context.
r.Group(func(r chi.Router) {
r.Use(s.auth.RequireOwner)
r.Use(s.auth.RequireCSRF)
r.Get("/", s.handleDashboard)
r.Get("/takedown", s.handleTakedownForm)
@@ -115,7 +138,7 @@ func (s *Server) Serve() error {
slog.Info("Starting labeler service",
"addr", s.config.Labeler.Addr,
"public_url", s.config.PublicURL(),
"did", s.config.DID(),
"did", s.did,
"owner", s.config.Labeler.OwnerDID,
)
@@ -140,17 +163,28 @@ func (s *Server) Serve() error {
}
case <-ctx.Done():
slog.Info("Shutting down labeler service")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5000000000) // 5s
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown error: %w", err)
}
}
s.db.Close()
if err := s.storage.Close(); err != nil {
slog.Warn("Error closing labeler database", "error", err)
}
return nil
}
// isLocalhost returns true when the host is reachable only from the local machine /
// docker host — anything that an external PDS can't reach. Matches the hold's policy:
// any IP literal counts (covers 127.0.0.1, 192.168.*, 172.16-31.*, 10.*, ::1, etc.) plus
// the literal "localhost". When this is true, OAuth uses indigo's `NewLocalhostConfig`
// which sets a `http://localhost`-form client_id that PDSes accept under the loopback
// exception — so the PDS never has to fetch the client metadata URL we publish.
func isLocalhost(host string) bool {
return host == "localhost" || host == "127.0.0.1" || strings.HasPrefix(host, "192.168.")
if host == "localhost" {
return true
}
return net.ParseIP(host) != nil
}
+30
View File
@@ -0,0 +1,30 @@
package labeler
import "testing"
// TestIsLocalhost covers the OAuth-mode decision: any IP-literal host (including
// docker-compose private addresses like 172.28.0.x and the standard 127.0.0.1) plus
// the literal "localhost" routes through the loopback OAuth path so PDSes don't have
// to fetch our published client metadata. Domain names go through the public-client
// path and require the metadata endpoint to be reachable from the PDS.
func TestIsLocalhost(t *testing.T) {
tests := []struct {
host string
want bool
}{
{"localhost", true},
{"127.0.0.1", true},
{"::1", true},
{"192.168.1.10", true},
{"172.28.0.4", true}, // docker-compose private network
{"10.0.0.5", true}, // RFC 1918
{"labeler.atcr.io", false},
{"labeler.example.com", false},
{"", false},
}
for _, tt := range tests {
if got := isLocalhost(tt.host); got != tt.want {
t.Errorf("isLocalhost(%q) = %v, want %v", tt.host, got, tt.want)
}
}
}
+249 -99
View File
@@ -1,62 +1,107 @@
package labeler
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"strings"
comatproto "github.com/bluesky-social/indigo/api/atproto"
"github.com/bluesky-social/indigo/events"
"github.com/gorilla/websocket"
cbg "github.com/whyrusleeping/cbor-gen"
)
const (
subscriberBuffer = 64
backfillPageLimit = 200
)
var upgrader = websocket.Upgrader{
// CheckOrigin is permissive: the firehose is a public stream by design and ATProto
// consumers are not browsers, so the same-origin policy doesn't apply to them anyway.
CheckOrigin: func(r *http.Request) bool { return true },
}
// LabelsMessage is the ATProto subscribeLabels wire format.
type LabelsMessage struct {
Seq int64 `json:"seq"`
Labels []LabelOutput `json:"labels"`
}
// frameLabels builds the binary frame for a labels event: CBOR-encoded
// {op:1, t:"#labels"} header concatenated with CBOR-encoded {seq, labels:[...]} body.
func frameLabels(seq int64, labels []*comatproto.LabelDefs_Label) ([]byte, error) {
var buf bytes.Buffer
w := cbg.NewCborWriter(&buf)
// LabelOutput is the ATProto label format for subscribeLabels/queryLabels output.
type LabelOutput struct {
Src string `json:"src"`
URI string `json:"uri"`
CID string `json:"cid,omitempty"`
Val string `json:"val"`
Neg bool `json:"neg"`
Cts string `json:"cts"`
Exp string `json:"exp,omitempty"`
}
func labelToOutput(l Label) LabelOutput {
out := LabelOutput{
Src: l.Src,
URI: l.URI,
CID: l.CID,
Val: l.Val,
Neg: l.Neg,
Cts: l.Cts.UTC().Format(time.RFC3339),
header := events.EventHeader{Op: events.EvtKindMessage, MsgType: "#labels"}
if err := header.MarshalCBOR(w); err != nil {
return nil, fmt.Errorf("marshal header: %w", err)
}
if l.Exp != nil {
out.Exp = l.Exp.UTC().Format(time.RFC3339)
body := comatproto.LabelSubscribeLabels_Labels{Seq: seq, Labels: labels}
if err := body.MarshalCBOR(w); err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
return out
return buf.Bytes(), nil
}
// handleSubscribeLabels implements com.atproto.label.subscribeLabels (WebSocket).
// frameInfo builds the binary frame for an info event: header {op:1, t:"#info"} plus body.
func frameInfo(name, message string) ([]byte, error) {
var buf bytes.Buffer
w := cbg.NewCborWriter(&buf)
header := events.EventHeader{Op: events.EvtKindMessage, MsgType: "#info"}
if err := header.MarshalCBOR(w); err != nil {
return nil, err
}
body := comatproto.LabelSubscribeLabels_Info{Name: name}
if message != "" {
body.Message = &message
}
if err := body.MarshalCBOR(w); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// frameError builds an error frame: header {op:-1} plus {error, message}.
func frameError(name, message string) ([]byte, error) {
var buf bytes.Buffer
w := cbg.NewCborWriter(&buf)
header := events.EventHeader{Op: events.EvtKindErrorFrame}
if err := header.MarshalCBOR(w); err != nil {
return nil, err
}
body := events.ErrorFrame{Error: name, Message: message}
if err := body.MarshalCBOR(w); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// labelToLexicon converts a stored row into the indigo lexicon type used in the wire format.
func labelToLexicon(l *Label) *comatproto.LabelDefs_Label {
tmp := l.ToLabeling()
lex := tmp.ToLexicon()
return &lex
}
// handleSubscribeLabels implements com.atproto.label.subscribeLabels.
//
// Wire format: each WebSocket binary message is two concatenated CBOR objects (header
// + body) matching the firehose convention. Backfill pages historical labels since the
// cursor, then the connection joins the broadcast hub for live deliveries.
func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
cursorStr := r.URL.Query().Get("cursor")
var cursor int64
if cursorStr != "" {
var err error
cursor, err = strconv.ParseInt(cursorStr, 10, 64)
v, err := strconv.ParseInt(cursorStr, 10, 64)
if err != nil {
http.Error(w, "invalid cursor", http.StatusBadRequest)
return
}
cursor = v
}
conn, err := upgrader.Upgrade(w, r, nil)
@@ -68,34 +113,64 @@ func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
slog.Info("subscribeLabels client connected", "cursor", cursor)
// Send historical labels since cursor
labels, err := GetLabelsSince(s.db, cursor, 1000)
latest, err := LatestSeq(s.db)
if err != nil {
slog.Error("Failed to get labels", "error", err)
slog.Error("Failed to read latest seq", "error", err)
return
}
if cursor > latest {
if frame, ferr := frameError("FutureCursor", "cursor is in the future"); ferr == nil {
_ = conn.WriteMessage(websocket.BinaryMessage, frame)
}
return
}
for _, l := range labels {
msg := LabelsMessage{
Seq: l.ID,
Labels: []LabelOutput{labelToOutput(l)},
// Subscribe to the broadcast hub BEFORE backfilling so we don't lose events
// that arrive while we're streaming the historical tail.
sub, cancel := s.hub.subscribe(subscriberBuffer)
defer cancel()
if cursor > 0 {
if frame, ferr := frameInfo("OutdatedCursor", "starting backfill from cursor"); ferr == nil {
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
}
if err := conn.WriteJSON(msg); err != nil {
return
}
cursor = l.ID
}
// Poll for new labels
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
// Backfill historical labels in pages until we catch up.
for {
labels, err := GetLabelsSince(s.db, cursor, backfillPageLimit)
if err != nil {
slog.Error("Failed to read labels for backfill", "error", err)
return
}
if len(labels) == 0 {
break
}
for i := range labels {
frame, ferr := frameLabels(labels[i].ID, []*comatproto.LabelDefs_Label{labelToLexicon(&labels[i])})
if ferr != nil {
slog.Error("Failed to encode label frame", "error", ferr)
return
}
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
cursor = labels[i].ID
}
if len(labels) < backfillPageLimit {
break
}
}
// Read pump (detect client disconnect)
// Live delivery: a goroutine monitors the read side so we notice client disconnects;
// the main loop pulls from the hub and writes frames until either side closes.
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
if _, _, rerr := conn.ReadMessage(); rerr != nil {
return
}
}
@@ -105,83 +180,158 @@ func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) {
select {
case <-done:
return
case <-ticker.C:
labels, err := GetLabelsSince(s.db, cursor, 100)
if err != nil {
slog.Error("Failed to poll labels", "error", err)
continue
case lbl, ok := <-sub.ch:
if !ok {
return
}
for _, l := range labels {
msg := LabelsMessage{
Seq: l.ID,
Labels: []LabelOutput{labelToOutput(l)},
}
if err := conn.WriteJSON(msg); err != nil {
return
}
cursor = l.ID
if lbl.ID <= cursor {
continue // already delivered during backfill
}
frame, ferr := frameLabels(lbl.ID, []*comatproto.LabelDefs_Label{labelToLexicon(lbl)})
if ferr != nil {
slog.Error("Failed to encode label frame", "error", ferr)
return
}
if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil {
return
}
cursor = lbl.ID
}
}
}
// handleQueryLabels implements com.atproto.label.queryLabels (HTTP GET).
// queryLabelsResponse mirrors the lexicon JSON shape for queryLabels.
type queryLabelsResponse struct {
Cursor string `json:"cursor,omitempty"`
Labels []*comatproto.LabelDefs_Label `json:"labels"`
}
// handleQueryLabels implements com.atproto.label.queryLabels.
//
// Filters (uriPatterns, sources) are applied in SQL so the LIMIT cap operates on the
// filtered result, not the raw scan. URI patterns support a single trailing `*` glob
// (LIKE), with `%` and `_` escaped to remain literal.
func (s *Server) handleQueryLabels(w http.ResponseWriter, r *http.Request) {
uriPatterns := r.URL.Query()["uriPatterns"]
cursorStr := r.URL.Query().Get("cursor")
limitStr := r.URL.Query().Get("limit")
q := r.URL.Query()
patterns := q["uriPatterns"]
sources := q["sources"]
var cursor int64
if cursorStr != "" {
cursor, _ = strconv.ParseInt(cursorStr, 10, 64)
if cs := q.Get("cursor"); cs != "" {
v, err := strconv.ParseInt(cs, 10, 64)
if err != nil {
http.Error(w, "invalid cursor", http.StatusBadRequest)
return
}
cursor = v
}
limit := 50
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 250 {
if ls := q.Get("limit"); ls != "" {
if l, err := strconv.Atoi(ls); err == nil && l > 0 && l <= 250 {
limit = l
}
}
labels, err := GetLabelsSince(s.db, cursor, limit)
rows, err := queryLabelsSQL(s.db, patterns, sources, cursor, limit)
if err != nil {
http.Error(w, "failed to query labels", http.StatusInternalServerError)
if errors.Is(err, errInvalidPattern) {
http.Error(w, "invalid uriPattern: wildcard '*' must be at the end", http.StatusBadRequest)
return
}
slog.Error("queryLabels failed", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Filter by URI patterns if provided
var filtered []LabelOutput
for _, l := range labels {
if len(uriPatterns) == 0 || matchesAnyPattern(l.URI, uriPatterns) {
filtered = append(filtered, labelToOutput(l))
}
out := &queryLabelsResponse{Labels: make([]*comatproto.LabelDefs_Label, 0, len(rows))}
for i := range rows {
out.Labels = append(out.Labels, labelToLexicon(&rows[i]))
}
var nextCursor string
if len(labels) > 0 {
nextCursor = strconv.FormatInt(labels[len(labels)-1].ID, 10)
}
resp := struct {
Cursor string `json:"cursor,omitempty"`
Labels []LabelOutput `json:"labels"`
}{
Cursor: nextCursor,
Labels: filtered,
}
if resp.Labels == nil {
resp.Labels = []LabelOutput{}
if len(rows) > 0 {
out.Cursor = strconv.FormatInt(rows[len(rows)-1].ID, 10)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
_ = json.NewEncoder(w).Encode(out)
}
func matchesAnyPattern(uri string, patterns []string) bool {
for _, p := range patterns {
// Simple prefix matching (ATProto spec allows glob-like patterns)
if p == uri || (len(p) > 0 && p[len(p)-1] == '*' && len(uri) >= len(p)-1 && uri[:len(p)-1] == p[:len(p)-1]) {
return true
var errInvalidPattern = errors.New("invalid uriPattern")
// queryLabelsSQL builds the WHERE clause from filter args and runs the query. All
// filtering happens in SQL so LIMIT operates on already-filtered rows.
func queryLabelsSQL(db *sql.DB, patterns, sources []string, cursor int64, limit int) ([]Label, error) {
var (
where []string
args []any
)
where = append(where, "id > ?")
args = append(args, cursor)
if len(patterns) > 0 {
var ors []string
var matchAll bool
for _, p := range patterns {
if p == "" {
continue
}
if p == "*" {
matchAll = true
break
}
like, err := patternToLike(p)
if err != nil {
return nil, err
}
if strings.ContainsAny(like, `%_\`) {
ors = append(ors, "uri LIKE ? ESCAPE '\\'")
} else {
ors = append(ors, "uri = ?")
}
args = append(args, like)
}
if !matchAll && len(ors) > 0 {
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
}
return false
if len(sources) > 0 {
placeholders := strings.Repeat("?,", len(sources))
placeholders = placeholders[:len(placeholders)-1]
where = append(where, "src IN ("+placeholders+")")
for _, s := range sources {
args = append(args, s)
}
}
args = append(args, limit)
q := `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
FROM labels WHERE ` + strings.Join(where, " AND ") +
` ORDER BY id ASC LIMIT ?`
rows, err := db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLabels(rows)
}
// patternToLike converts a uriPattern into a SQLite LIKE expression. The only
// wildcard supported is a trailing `*`, which becomes `%`. Literal `%`, `_`, and `\`
// in the input are escaped via the LIKE ESCAPE clause used at query time.
func patternToLike(p string) (string, error) {
if idx := strings.Index(p, "*"); idx >= 0 && idx != len(p)-1 {
return "", errInvalidPattern
}
literal := p
suffix := ""
if strings.HasSuffix(p, "*") {
literal = p[:len(p)-1]
suffix = "%"
}
literal = strings.ReplaceAll(literal, `\`, `\\`)
literal = strings.ReplaceAll(literal, `%`, `\%`)
literal = strings.ReplaceAll(literal, `_`, `\_`)
return literal + suffix, nil
}
+70 -47
View File
@@ -1,86 +1,109 @@
package labeler
import (
"strings"
"testing"
"time"
comatproto "github.com/bluesky-social/indigo/api/atproto"
)
func TestLabelToOutput(t *testing.T) {
// TestLabelToLexicon checks the wire-format conversion populates the indigo lexicon
// fields (which is what's serialized into both the WS frame and the queryLabels JSON).
func TestLabelToLexicon(t *testing.T) {
now := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
exp := time.Date(2026, 4, 22, 10, 0, 0, 0, time.UTC)
label := Label{
ID: 1,
Src: "did:web:labeler.atcr.io",
Src: "did:plc:abc",
URI: "at://did:plc:abc/io.atcr.manifest/sha256-123",
CID: "bafyabc",
Val: "!takedown",
Neg: false,
Cts: now,
Exp: &exp,
Ver: LabelVersion,
Sig: []byte{0x01, 0x02, 0x03},
SubjectDID: "did:plc:abc",
SubjectRepo: "myimage",
}
lex := labelToLexicon(&label)
out := labelToOutput(label)
if out.Src != "did:web:labeler.atcr.io" {
t.Errorf("Src = %q, want did:web:labeler.atcr.io", out.Src)
if lex.Src != label.Src {
t.Errorf("Src = %q", lex.Src)
}
if out.URI != "at://did:plc:abc/io.atcr.manifest/sha256-123" {
t.Errorf("URI = %q", out.URI)
if lex.Uri != label.URI {
t.Errorf("Uri = %q", lex.Uri)
}
if out.CID != "bafyabc" {
t.Errorf("CID = %q, want bafyabc", out.CID)
if lex.Cid == nil || *lex.Cid != "bafyabc" {
t.Errorf("Cid = %v", lex.Cid)
}
if out.Val != "!takedown" {
t.Errorf("Val = %q", out.Val)
if lex.Cts != "2026-03-22T10:00:00Z" {
t.Errorf("Cts = %q", lex.Cts)
}
if out.Neg {
t.Error("expected Neg=false")
if lex.Exp == nil || *lex.Exp != "2026-04-22T10:00:00Z" {
t.Errorf("Exp = %v", lex.Exp)
}
if out.Cts != "2026-03-22T10:00:00Z" {
t.Errorf("Cts = %q", out.Cts)
}
if out.Exp != "2026-04-22T10:00:00Z" {
t.Errorf("Exp = %q", out.Exp)
if len(lex.Sig) != 3 {
t.Errorf("Sig length = %d, want 3", len(lex.Sig))
}
}
func TestLabelToOutput_NoExpiration(t *testing.T) {
label := Label{
Src: "did:web:labeler.atcr.io",
URI: "at://did:plc:abc",
Val: "!takedown",
Cts: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
}
out := labelToOutput(label)
if out.Exp != "" {
t.Errorf("expected empty Exp, got %q", out.Exp)
}
}
func TestMatchesAnyPattern(t *testing.T) {
func TestPatternToLike(t *testing.T) {
tests := []struct {
name string
uri string
patterns []string
want bool
in string
want string
wantErr bool
}{
{"exact match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:abc/io.atcr.manifest/sha256-123"}, true},
{"no match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/io.atcr.manifest/sha256-123"}, false},
{"wildcard match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:abc/*"}, true},
{"wildcard no match", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/*"}, false},
{"empty patterns", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{}, false},
{"multiple patterns", "at://did:plc:abc/io.atcr.manifest/sha256-123", []string{"at://did:plc:def/*", "at://did:plc:abc/*"}, true},
{"at://did:plc:abc/foo", "at://did:plc:abc/foo", false},
{"at://did:plc:abc/*", "at://did:plc:abc/%", false},
{"at://did:plc:abc%/foo", `at://did:plc:abc\%/foo`, false},
{"at://did:plc:abc_/foo", `at://did:plc:abc\_/foo`, false},
{"at://*/foo", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := matchesAnyPattern(tt.uri, tt.patterns)
t.Run(tt.in, func(t *testing.T) {
got, err := patternToLike(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Errorf("matchesAnyPattern(%q, %v) = %v, want %v", tt.uri, tt.patterns, got, tt.want)
t.Errorf("patternToLike(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
// TestFrameLabels exercises the CBOR framing — produces non-empty bytes whose first
// byte is a CBOR map header and whose body contains the labels payload keys.
func TestFrameLabels(t *testing.T) {
now := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
l := &Label{
Src: "did:plc:abc",
URI: "at://did:plc:abc",
Val: "!takedown",
Cts: now,
Ver: LabelVersion,
Sig: []byte("sig-bytes"),
}
frame, err := frameLabels(42, []*comatproto.LabelDefs_Label{labelToLexicon(l)})
if err != nil {
t.Fatalf("frame: %v", err)
}
if len(frame) < 2 {
t.Fatalf("frame too short: %d bytes", len(frame))
}
if frame[0]&0xe0 != 0xa0 {
t.Errorf("first byte %#x is not a CBOR map header", frame[0])
}
if !strings.Contains(string(frame), "labels") || !strings.Contains(string(frame), "seq") {
t.Errorf("frame missing expected keys")
}
}
+151 -71
View File
@@ -7,6 +7,7 @@ import (
"html/template"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
@@ -21,88 +22,137 @@ type TakedownInput struct {
}
// ParseTakedownInput parses various input formats into a TakedownInput.
// Supported formats:
// - atcr.io/r/handle/repo
// - handle/repo
// - at://did:plc:xyz/io.atcr.repo.page/repo
// - at://did:plc:xyz (user-level)
// - handle (user-level)
// - did:plc:xyz (user-level)
//
// Supported shapes (dispatched in order):
//
// - at://<did-or-handle>[/collection/rkey] — ATProto AT URI
// - did:plc:..., did:web:... — bare DID, user-level takedown
// - URL with /u/<handle> or /r/<handle>/<repo> — appview routes (with or without scheme)
// - <handle> — bare handle, user-level takedown
//
// Anything else is rejected. The appview's /r/ route uses the repo name as a single
// path segment so any trailing path (digest pages, tag tabs) is discarded; URL
// fragments and query strings are dropped in all cases.
func ParseTakedownInput(ctx context.Context, input string) (*TakedownInput, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, fmt.Errorf("empty takedown input")
}
// AT URI format
if strings.HasPrefix(input, "at://") {
return parseATURI(ctx, input)
}
// Strip URL prefix if present
input = strings.TrimPrefix(input, "https://")
input = strings.TrimPrefix(input, "http://")
// Remove atcr.io/r/ or similar prefix
for _, prefix := range []string{"atcr.io/r/", "localhost/r/"} {
if strings.HasPrefix(input, prefix) {
input = strings.TrimPrefix(input, prefix)
break
}
}
// Also handle custom domains: anything ending in /r/
if idx := strings.Index(input, "/r/"); idx >= 0 {
input = input[idx+3:]
// Bare DID — no slashes, no scheme. did:plc:..., did:web:..., did:web:host%3Aport.
if strings.HasPrefix(input, "did:") && !strings.Contains(input, "/") {
return resolveBareIdentifier(ctx, input)
}
// Now input should be "handle/repo" or "handle" or "did:xxx"
parts := strings.SplitN(input, "/", 2)
identifier := parts[0]
var repo string
if len(parts) > 1 {
repo = parts[1]
repo = strings.TrimSuffix(repo, "/")
// URL-shaped: contains a scheme or a slash. Parse and dispatch on the path.
if hasURLShape(input) {
return parseTakedownURL(ctx, input)
}
did, handle, err := resolveIdentifier(ctx, identifier)
if err != nil {
return nil, err
}
return &TakedownInput{
DID: did,
Handle: handle,
Repository: repo,
}, nil
// Otherwise: bare handle.
return resolveBareIdentifier(ctx, input)
}
func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) {
// at://did:plc:xyz/collection/rkey
trimmed := strings.TrimPrefix(uri, "at://")
parts := strings.SplitN(trimmed, "/", 3)
// hasURLShape reports whether the input looks like a URL or a path. A bare handle like
// "alice.bsky.social" is not URL-shaped (no slashes, no scheme).
func hasURLShape(s string) bool {
return strings.Contains(s, "://") || strings.Contains(s, "/")
}
did := parts[0]
if !strings.HasPrefix(did, "did:") {
// It's a handle
resolvedDID, handle, err := resolveIdentifier(ctx, did)
// parseTakedownURL parses a URL whose path is one of the appview's takedown-relevant
// routes: /u/<handle> for user-level, /r/<handle>/<repo> for repo-level. The host part
// is irrelevant — we only use the path — so this also accepts schemeless input like
// "atcr.io/r/handle/repo" by prepending https:// before parsing.
func parseTakedownURL(ctx context.Context, input string) (*TakedownInput, error) {
if !strings.Contains(input, "://") {
input = "https://" + input
}
u, err := url.Parse(input)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) == 0 || parts[0] == "" {
// No path — treat the host as the identifier (e.g. "alice.bsky.social/").
return resolveBareIdentifier(ctx, u.Host)
}
switch parts[0] {
case "u":
if len(parts) < 2 || parts[1] == "" {
return nil, fmt.Errorf("missing handle in /u/<handle>")
}
return resolveBareIdentifier(ctx, parts[1])
case "r":
if len(parts) < 3 || parts[1] == "" || parts[2] == "" {
return nil, fmt.Errorf("missing handle or repo in /r/<handle>/<repo>")
}
base, err := resolveBareIdentifier(ctx, parts[1])
if err != nil {
return nil, err
}
did = resolvedDID
if len(parts) >= 3 {
return &TakedownInput{DID: did, Handle: handle, Repository: parts[2]}, nil
// parts[2] only — discard any /digest/..., /tags/..., etc. trailing path.
base.Repository = parts[2]
return base, nil
default:
return nil, fmt.Errorf("unsupported URL path %q (expected /u/<handle> or /r/<handle>/<repo>)", u.Path)
}
}
// parseATURI parses an at:// URI. The authority is a DID or handle; the path's third
// segment (rkey) becomes the repo for repo-level takedowns. Fragment and query are
// stripped first since paste-of-browser-AT-URI may include them.
func parseATURI(ctx context.Context, uri string) (*TakedownInput, error) {
trimmed := strings.TrimPrefix(uri, "at://")
if idx := strings.IndexAny(trimmed, "#?"); idx >= 0 {
trimmed = trimmed[:idx]
}
parts := strings.SplitN(trimmed, "/", 3)
authority := parts[0]
if authority == "" {
return nil, fmt.Errorf("at:// URI missing authority")
}
var (
did, handle string
err error
)
if strings.HasPrefix(authority, "did:") {
did = authority
_, handle, _, _ = atproto.ResolveIdentity(ctx, did)
} else {
did, handle, err = resolveIdentifier(ctx, authority)
if err != nil {
return nil, err
}
return &TakedownInput{DID: did, Handle: handle}, nil
}
// Resolve handle from DID
_, handle, _, _ := atproto.ResolveIdentity(ctx, did)
if len(parts) < 3 {
// User-level takedown
return &TakedownInput{DID: did, Handle: handle}, nil
out := &TakedownInput{DID: did, Handle: handle}
if len(parts) >= 3 {
out.Repository = parts[2]
}
return out, nil
}
// Extract repository from rkey (third part)
repo := parts[2]
return &TakedownInput{DID: did, Handle: handle, Repository: repo}, nil
// resolveBareIdentifier resolves a handle or DID to a user-level TakedownInput. When
// the input is already a DID, the resolve is best-effort (DID is the source of truth;
// handle is just for display) and we don't fail if PLC/web resolution is unreachable.
// For a handle, resolution is required since we need a DID to label.
func resolveBareIdentifier(ctx context.Context, id string) (*TakedownInput, error) {
if strings.HasPrefix(id, "did:") {
_, handle, _, _ := atproto.ResolveIdentity(ctx, id)
return &TakedownInput{DID: id, Handle: handle}, nil
}
did, handle, err := resolveIdentifier(ctx, id)
if err != nil {
return nil, err
}
return &TakedownInput{DID: did, Handle: handle}, nil
}
func resolveIdentifier(ctx context.Context, identifier string) (did, handle string, err error) {
@@ -124,7 +174,7 @@ type TakedownResult struct {
// ExecuteTakedown creates takedown labels for a repo or user.
func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*TakedownResult, error) {
src := s.config.DID()
src := s.did
now := time.Now().UTC()
result := &TakedownResult{
DID: input.DID,
@@ -143,9 +193,13 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
SubjectDID: input.DID,
SubjectRepo: "",
}
if err := label.Sign(s.signingKey); err != nil {
return nil, fmt.Errorf("failed to sign user-level label: %w", err)
}
if _, err := CreateLabel(s.db, label); err != nil {
return nil, fmt.Errorf("failed to create user-level label: %w", err)
}
s.hub.Broadcast(label)
result.Labels = append(result.Labels, *label)
slog.Info("Created user-level takedown", "did", input.DID, "handle", input.Handle)
return result, nil
@@ -168,9 +222,13 @@ func (s *Server) ExecuteTakedown(ctx context.Context, input *TakedownInput) (*Ta
SubjectDID: input.DID,
SubjectRepo: input.Repository,
}
if err := summaryLabel.Sign(s.signingKey); err != nil {
return nil, fmt.Errorf("failed to sign summary label: %w", err)
}
if _, err := CreateLabel(s.db, summaryLabel); err != nil {
return nil, fmt.Errorf("failed to create summary label: %w", err)
}
s.hub.Broadcast(summaryLabel)
result.Labels = append(result.Labels, *summaryLabel)
slog.Info("Created repo-level takedown",
@@ -225,10 +283,15 @@ func (s *Server) discoverAndLabelRecords(ctx context.Context, did, repo, src str
SubjectDID: did,
SubjectRepo: repo,
}
if err := label.Sign(s.signingKey); err != nil {
slog.Warn("Failed to sign label", "uri", uri, "error", err)
continue
}
if _, err := CreateLabel(s.db, label); err != nil {
slog.Warn("Failed to create label", "uri", uri, "error", err)
continue
}
s.hub.Broadcast(label)
labels = append(labels, *label)
}
}
@@ -256,6 +319,10 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to list takedowns", http.StatusInternalServerError)
return
}
csrf := ""
if session := SessionFromContext(r.Context()); session != nil {
csrf = session.CSRFToken
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
@@ -301,12 +368,13 @@ form{display:inline}
<td>%s</td>
<td><code>%s</code></td>
<td>%s</td>
<td><form method="POST" action="/reverse"><input type="hidden" name="did" value="%s"><input type="hidden" name="repo" value="%s"><button type="submit" class="btn btn-danger" onclick="return confirm('Reverse this takedown?')">Reverse</button></form></td>
<td><form method="POST" action="/reverse">%s<input type="hidden" name="did" value="%s"><input type="hidden" name="repo" value="%s"><button type="submit" class="btn btn-danger" onclick="return confirm('Reverse this takedown?')">Reverse</button></form></td>
</tr>`,
template.HTMLEscapeString(l.SubjectDID),
repoDisplay,
template.HTMLEscapeString(l.URI),
l.Cts.Format("2006-01-02 15:04"),
csrfInputHTML(csrf),
template.HTMLEscapeString(l.SubjectDID),
template.HTMLEscapeString(l.SubjectRepo),
)
@@ -320,6 +388,10 @@ form{display:inline}
func (s *Server) handleTakedownForm(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("msg")
errorMsg := r.URL.Query().Get("error")
csrf := ""
if session := SessionFromContext(r.Context()); session != nil {
csrf = session.CSRFToken
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
@@ -353,15 +425,16 @@ nav{display:flex;gap:16px;margin-bottom:24px}
fmt.Fprintf(w, `<div class="error">%s</div>`, template.HTMLEscapeString(errorMsg))
}
fmt.Fprint(w, `
fmt.Fprintf(w, `
<form method="POST" action="/takedown">
%s
<label for="target"><strong>Target</strong></label>
<input type="text" id="target" name="target" placeholder="atcr.io/r/handle/repo, at://did/collection/rkey, or handle" required>
<p class="help">Accepts repo URLs, AT URIs, handles, or DIDs. Omit the repo for a user-level takedown.</p>
<input type="text" id="target" name="target" placeholder="/r/handle/repo, /u/handle, at://did/collection/rkey, handle, or did:..." required>
<p class="help">Repo: <code>/r/handle/repo</code> (or full atcr.io URL). User-level: <code>/u/handle</code>, a bare handle, or a DID. AT URIs (<code>at://...</code>) also work.</p>
<br>
<button type="submit" class="btn" onclick="return confirm('Issue takedown? This will suppress the content immediately.')">Issue Takedown</button>
</form>
</body></html>`)
</body></html>`, csrfInputHTML(csrf))
}
func (s *Server) handleTakedownSubmit(w http.ResponseWriter, r *http.Request) {
@@ -399,12 +472,15 @@ func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) {
return
}
src := s.config.DID()
var err error
src := s.did
var (
negs []Label
err error
)
if repo == "" {
err = NegateUserLabels(s.db, src, did)
negs, err = NegateUserLabels(s.db, s.signingKey, src, did)
} else {
err = NegateRepoLabels(s.db, src, did, repo)
negs, err = NegateRepoLabels(s.db, s.signingKey, src, did, repo)
}
if err != nil {
@@ -413,6 +489,10 @@ func (s *Server) handleReverse(w http.ResponseWriter, r *http.Request) {
return
}
slog.Info("Reversed takedown", "did", did, "repo", repo)
for i := range negs {
s.hub.Broadcast(&negs[i])
}
slog.Info("Reversed takedown", "did", did, "repo", repo, "negations", len(negs))
http.Redirect(w, r, "/", http.StatusFound)
}
+67 -36
View File
@@ -5,37 +5,73 @@ import (
"testing"
)
func TestParseTakedownInput_RepoURL(t *testing.T) {
// These tests only exercise parsing logic, not PDS resolution.
// ResolveIdentity calls are tested with mock server below.
// TestParseTakedownURL exercises the URL-shaped parsing path offline by feeding only
// inputs whose identifier is a DID — DIDs short-circuit ResolveIdentity entirely, so
// this whole table runs without network. Every case here came up while debugging
// browser-paste bugs (URL fragments, query strings, trailing /digest/... segments).
func TestParseTakedownURL(t *testing.T) {
tests := []struct {
name string
input string
wantDID string
wantRepo string
wantErr bool
}{
{"full URL", "https://atcr.io/r/handle/myimage", "myimage"},
{"no scheme", "atcr.io/r/handle/myimage", "myimage"},
{"handle/repo", "handle/myimage", "myimage"},
{"trailing slash", "atcr.io/r/handle/myimage/", "myimage"},
{"custom domain", "https://registry.example.com/r/handle/myimage", "myimage"},
}
// /r/<handle>/<repo>
{"r path with scheme", "https://atcr.io/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false},
{"r path no scheme", "atcr.io/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false},
{"r path trailing slash", "atcr.io/r/did:plc:abc/myimage/", "did:plc:abc", "myimage", false},
{"r path custom domain", "https://seamark.dev/r/did:plc:abc/myimage", "did:plc:abc", "myimage", false},
{"r path subroute (digest)", "https://atcr.io/r/did:plc:abc/myimage/digest/sha256-deadbeef", "did:plc:abc", "myimage", false},
{"r path with hash fragment", "https://atcr.io/r/did:plc:abc/myimage#overview", "did:plc:abc", "myimage", false},
{"r path with query string", "https://atcr.io/r/did:plc:abc/myimage?tag=latest", "did:plc:abc", "myimage", false},
{"r path missing repo", "https://atcr.io/r/did:plc:abc", "", "", true},
// /u/<handle>
{"u path with scheme", "https://atcr.io/u/did:plc:abc", "did:plc:abc", "", false},
{"u path no scheme", "atcr.io/u/did:plc:abc", "did:plc:abc", "", false},
{"u path with hash", "https://atcr.io/u/did:plc:abc#tab", "did:plc:abc", "", false},
{"u path missing handle", "https://atcr.io/u", "", "", true},
// Unknown route
{"unsupported path", "https://atcr.io/foo/did:plc:abc", "", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// These will fail on ResolveIdentity since there's no real PDS,
// but we can at least verify the parsing doesn't panic
_, err := ParseTakedownInput(context.Background(), tt.input)
if err == nil {
t.Skip("ResolveIdentity succeeded unexpectedly (network available)")
got, err := parseTakedownURL(context.Background(), tt.input)
if tt.wantErr {
if err == nil {
t.Errorf("expected error, got %+v", got)
}
return
}
// The error should be from resolution, not parsing
if err != nil {
t.Logf("Expected resolution error: %v", err)
t.Fatalf("unexpected error: %v", err)
}
if got.DID != tt.wantDID {
t.Errorf("DID = %q, want %q", got.DID, tt.wantDID)
}
if got.Repository != tt.wantRepo {
t.Errorf("Repository = %q, want %q", got.Repository, tt.wantRepo)
}
})
}
}
func TestParseTakedownInput_BareDID(t *testing.T) {
// Bare DIDs short-circuit network resolution.
got, err := ParseTakedownInput(context.Background(), "did:plc:abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.DID != "did:plc:abc123" {
t.Errorf("DID = %q, want did:plc:abc123", got.DID)
}
if got.Repository != "" {
t.Errorf("Repository = %q, want empty (user-level)", got.Repository)
}
}
func TestParseTakedownInput_ATURI(t *testing.T) {
tests := []struct {
name string
@@ -61,37 +97,32 @@ func TestParseTakedownInput_ATURI(t *testing.T) {
"did:plc:xyz",
"sha256-deadbeef",
},
{
"AT URI with hash fragment",
"at://did:plc:abc#frag",
"did:plc:abc",
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input, err := ParseTakedownInput(context.Background(), tt.input)
got, err := ParseTakedownInput(context.Background(), tt.input)
if err != nil {
// Resolution may fail for handle-based AT URIs
t.Logf("Parse error (may be expected): %v", err)
return
t.Fatalf("unexpected error: %v", err)
}
if input.DID != tt.wantDID {
t.Errorf("DID = %q, want %q", input.DID, tt.wantDID)
if got.DID != tt.wantDID {
t.Errorf("DID = %q, want %q", got.DID, tt.wantDID)
}
if input.Repository != tt.wantRepo {
t.Errorf("Repository = %q, want %q", input.Repository, tt.wantRepo)
if got.Repository != tt.wantRepo {
t.Errorf("Repository = %q, want %q", got.Repository, tt.wantRepo)
}
})
}
}
func TestParseTakedownInput_DID(t *testing.T) {
// Direct DID input (user-level takedown)
input, err := ParseTakedownInput(context.Background(), "at://did:plc:abc123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if input.DID != "did:plc:abc123" {
t.Errorf("DID = %q, want did:plc:abc123", input.DID)
}
if input.Repository != "" {
t.Errorf("Repository = %q, want empty (user-level)", input.Repository)
func TestParseTakedownInput_Empty(t *testing.T) {
if _, err := ParseTakedownInput(context.Background(), ""); err == nil {
t.Error("expected error for empty input")
}
}