mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
clean up old migration code. minor bug fixes with appview ui
This commit is contained in:
@@ -29,8 +29,6 @@ server:
|
|||||||
default_hold_did: ""
|
default_hold_did: ""
|
||||||
# Allows HTTP (not HTTPS) for DID resolution and uses transition:generic OAuth scope.
|
# Allows HTTP (not HTTPS) for DID resolution and uses transition:generic OAuth scope.
|
||||||
test_mode: false
|
test_mode: false
|
||||||
# Path to P-256 private key for OAuth client authentication. Auto-generated on first run.
|
|
||||||
oauth_key_path: /var/lib/atcr/oauth/client.key
|
|
||||||
# Display name shown on OAuth authorization screens.
|
# Display name shown on OAuth authorization screens.
|
||||||
client_name: AT Container Registry
|
client_name: AT Container Registry
|
||||||
# Short name used in page titles and browser tabs.
|
# Short name used in page titles and browser tabs.
|
||||||
@@ -45,7 +43,7 @@ ui:
|
|||||||
# SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals.
|
# SQLite/libSQL database for OAuth sessions, stars, pull counts, and device approvals.
|
||||||
database_path: /var/lib/atcr/ui.db
|
database_path: /var/lib/atcr/ui.db
|
||||||
# Visual theme name (e.g. "seamark"). Empty uses default atcr.io branding.
|
# Visual theme name (e.g. "seamark"). Empty uses default atcr.io branding.
|
||||||
theme: "seamark"
|
theme: ""
|
||||||
# libSQL sync URL (libsql://...). Works with Turso cloud or self-hosted libsql-server. Leave empty for local-only SQLite.
|
# libSQL sync URL (libsql://...). Works with Turso cloud or self-hosted libsql-server. Leave empty for local-only SQLite.
|
||||||
libsql_sync_url: ""
|
libsql_sync_url: ""
|
||||||
# Auth token for libSQL sync. Required if libsql_sync_url is set.
|
# Auth token for libSQL sync. Required if libsql_sync_url is set.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ server:
|
|||||||
addr: :5000
|
addr: :5000
|
||||||
base_url: "https://seamark.dev"
|
base_url: "https://seamark.dev"
|
||||||
default_hold_did: "{{.HoldDid}}"
|
default_hold_did: "{{.HoldDid}}"
|
||||||
oauth_key_path: "{{.BasePath}}/oauth/client.key"
|
|
||||||
client_name: Seamark
|
client_name: Seamark
|
||||||
test_mode: false
|
test_mode: false
|
||||||
client_short_name: Seamark
|
client_short_name: Seamark
|
||||||
@@ -40,7 +39,6 @@ jetstream:
|
|||||||
- https://relay1.us-east.bsky.network
|
- https://relay1.us-east.bsky.network
|
||||||
- https://relay1.us-west.bsky.network
|
- https://relay1.us-west.bsky.network
|
||||||
auth:
|
auth:
|
||||||
key_path: "{{.BasePath}}/auth/private-key.pem"
|
|
||||||
cert_path: "{{.BasePath}}/auth/private-key.crt"
|
cert_path: "{{.BasePath}}/auth/private-key.crt"
|
||||||
legal:
|
legal:
|
||||||
company_name: Seamark
|
company_name: Seamark
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ type ServerConfig struct {
|
|||||||
// Allows HTTP (not HTTPS) for DID resolution.
|
// Allows HTTP (not HTTPS) for DID resolution.
|
||||||
TestMode bool `yaml:"test_mode" comment:"Allows HTTP (not HTTPS) for DID resolution and uses transition:generic OAuth scope."`
|
TestMode bool `yaml:"test_mode" comment:"Allows HTTP (not HTTPS) for DID resolution and uses transition:generic OAuth scope."`
|
||||||
|
|
||||||
// Path to P-256 private key for OAuth client authentication.
|
|
||||||
OAuthKeyPath string `yaml:"oauth_key_path" comment:"Path to P-256 private key for OAuth client authentication. Auto-generated on first run."`
|
|
||||||
|
|
||||||
// Display name shown on OAuth authorization screens.
|
// Display name shown on OAuth authorization screens.
|
||||||
ClientName string `yaml:"client_name" comment:"Display name shown on OAuth authorization screens."`
|
ClientName string `yaml:"client_name" comment:"Display name shown on OAuth authorization screens."`
|
||||||
|
|
||||||
@@ -116,11 +113,8 @@ type JetstreamConfig struct {
|
|||||||
|
|
||||||
// AuthConfig defines authentication settings
|
// AuthConfig defines authentication settings
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
// RSA private key for signing registry JWTs.
|
|
||||||
KeyPath string `yaml:"key_path" comment:"RSA private key for signing registry JWTs issued to Docker clients."`
|
|
||||||
|
|
||||||
// X.509 certificate matching the JWT signing key.
|
// X.509 certificate matching the JWT signing key.
|
||||||
CertPath string `yaml:"cert_path" comment:"X.509 certificate matching the JWT signing key."`
|
CertPath string `yaml:"cert_path" comment:"X.509 certificate matching the JWT signing key (auto-generated on each boot from the JWT key in the database)."`
|
||||||
|
|
||||||
// TokenExpiration is the JWT expiration duration (5 minutes, not configurable)
|
// TokenExpiration is the JWT expiration duration (5 minutes, not configurable)
|
||||||
TokenExpiration time.Duration `yaml:"-"`
|
TokenExpiration time.Duration `yaml:"-"`
|
||||||
@@ -169,7 +163,6 @@ func setDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("server.test_mode", false)
|
v.SetDefault("server.test_mode", false)
|
||||||
v.SetDefault("server.client_name", "AT Container Registry")
|
v.SetDefault("server.client_name", "AT Container Registry")
|
||||||
v.SetDefault("server.client_short_name", "ATCR")
|
v.SetDefault("server.client_short_name", "ATCR")
|
||||||
v.SetDefault("server.oauth_key_path", "/var/lib/atcr/oauth/client.key")
|
|
||||||
v.SetDefault("server.registry_domains", []string{})
|
v.SetDefault("server.registry_domains", []string{})
|
||||||
v.SetDefault("server.managed_holds", []string{})
|
v.SetDefault("server.managed_holds", []string{})
|
||||||
|
|
||||||
@@ -200,7 +193,6 @@ func setDefaults(v *viper.Viper) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Auth defaults
|
// Auth defaults
|
||||||
v.SetDefault("auth.key_path", "/var/lib/atcr/auth/private-key.pem")
|
|
||||||
v.SetDefault("auth.cert_path", "/var/lib/atcr/auth/private-key.crt")
|
v.SetDefault("auth.cert_path", "/var/lib/atcr/auth/private-key.crt")
|
||||||
|
|
||||||
// Log shipper defaults
|
// Log shipper defaults
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import (
|
|||||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadOAuthKey loads the OAuth P-256 key with priority: DB → file → generate.
|
// loadOAuthKey loads the OAuth P-256 key from the DB, generating one if absent.
|
||||||
// Keys loaded from file or newly generated are stored in the DB.
|
func loadOAuthKey(database *sql.DB) (*atcrypto.PrivateKeyP256, error) {
|
||||||
func loadOAuthKey(database *sql.DB, keyPath string) (*atcrypto.PrivateKeyP256, error) {
|
|
||||||
// Try database first
|
|
||||||
data, err := db.GetCryptoKey(database, "oauth_p256")
|
data, err := db.GetCryptoKey(database, "oauth_p256")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query crypto_keys: %w", err)
|
return nil, fmt.Errorf("failed to query crypto_keys: %w", err)
|
||||||
@@ -35,23 +33,6 @@ func loadOAuthKey(database *sql.DB, keyPath string) (*atcrypto.PrivateKeyP256, e
|
|||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try file fallback
|
|
||||||
if keyPath != "" {
|
|
||||||
if fileData, err := os.ReadFile(keyPath); err == nil {
|
|
||||||
key, err := atcrypto.ParsePrivateBytesP256(fileData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse OAuth key from file %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
// Migrate to database
|
|
||||||
if err := db.PutCryptoKey(database, "oauth_p256", fileData); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to store OAuth key in database: %w", err)
|
|
||||||
}
|
|
||||||
slog.Info("Migrated OAuth P-256 key from file to database", "path", keyPath)
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new key
|
|
||||||
p256Key, err := atcrypto.GeneratePrivateKeyP256()
|
p256Key, err := atcrypto.GeneratePrivateKeyP256()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate OAuth P-256 key: %w", err)
|
return nil, fmt.Errorf("failed to generate OAuth P-256 key: %w", err)
|
||||||
@@ -66,16 +47,15 @@ func loadOAuthKey(database *sql.DB, keyPath string) (*atcrypto.PrivateKeyP256, e
|
|||||||
return p256Key, nil
|
return p256Key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadJWTKeyAndCert loads the JWT RSA key from DB (with file fallback) and generates
|
// loadJWTKeyAndCert loads the JWT RSA key from the DB and generates a self-signed
|
||||||
// a self-signed certificate. The cert is always regenerated and written to certPath
|
// certificate. The cert is always regenerated and written to certPath on disk
|
||||||
// on disk because the distribution library reads it via os.Open().
|
// because the distribution library reads it via os.Open().
|
||||||
func loadJWTKeyAndCert(database *sql.DB, keyPath, certPath string) (*rsa.PrivateKey, []byte, error) {
|
func loadJWTKeyAndCert(database *sql.DB, certPath string) (*rsa.PrivateKey, []byte, error) {
|
||||||
rsaKey, err := loadRSAKey(database, keyPath)
|
rsaKey, err := loadRSAKey(database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate cert and write to disk for distribution library
|
|
||||||
certDER, err := generateAndWriteCert(rsaKey, certPath)
|
certDER, err := generateAndWriteCert(rsaKey, certPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -84,9 +64,8 @@ func loadJWTKeyAndCert(database *sql.DB, keyPath, certPath string) (*rsa.Private
|
|||||||
return rsaKey, certDER, nil
|
return rsaKey, certDER, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadRSAKey loads the RSA private key with priority: DB → file → generate.
|
// loadRSAKey loads the RSA private key from the DB, generating one if absent.
|
||||||
func loadRSAKey(database *sql.DB, keyPath string) (*rsa.PrivateKey, error) {
|
func loadRSAKey(database *sql.DB) (*rsa.PrivateKey, error) {
|
||||||
// Try database first
|
|
||||||
data, err := db.GetCryptoKey(database, "jwt_rsa")
|
data, err := db.GetCryptoKey(database, "jwt_rsa")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query crypto_keys: %w", err)
|
return nil, fmt.Errorf("failed to query crypto_keys: %w", err)
|
||||||
@@ -100,23 +79,6 @@ func loadRSAKey(database *sql.DB, keyPath string) (*rsa.PrivateKey, error) {
|
|||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try file fallback
|
|
||||||
if keyPath != "" {
|
|
||||||
if fileData, err := os.ReadFile(keyPath); err == nil {
|
|
||||||
key, err := parseRSAKeyPEM(fileData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse RSA key from file %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
// Migrate to database
|
|
||||||
if err := db.PutCryptoKey(database, "jwt_rsa", fileData); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to store RSA key in database: %w", err)
|
|
||||||
}
|
|
||||||
slog.Info("Migrated JWT RSA key from file to database", "path", keyPath)
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new key
|
|
||||||
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
|
return nil, fmt.Errorf("failed to generate RSA key: %w", err)
|
||||||
|
|||||||
@@ -489,43 +489,6 @@ func TestProcessStar(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessStar_OldFormat(t *testing.T) {
|
|
||||||
database := setupTestDB(t)
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
// Insert test users
|
|
||||||
for _, did := range []string{"did:plc:starrer456", "did:plc:owner456"} {
|
|
||||||
_, err := database.Exec(
|
|
||||||
`INSERT INTO users (did, handle, pds_endpoint, last_seen) VALUES (?, ?, ?, ?)`,
|
|
||||||
did, did+".test", "https://pds.example.com", time.Now())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to insert test user %s: %v", did, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p := NewProcessor(database, false, nil)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Old format JSON (object subject with did + repository)
|
|
||||||
oldFormatJSON := `{"$type":"io.atcr.sailor.star","subject":{"did":"did:plc:owner456","repository":"legacy-app"},"createdAt":"2025-06-01T00:00:00Z"}`
|
|
||||||
|
|
||||||
err := p.ProcessStar(ctx, "did:plc:starrer456", []byte(oldFormatJSON))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ProcessStar (old format) failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify star was inserted with correct owner/repo
|
|
||||||
var count int
|
|
||||||
err = database.QueryRow("SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = ? AND repository = ?",
|
|
||||||
"did:plc:starrer456", "did:plc:owner456", "legacy-app").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to query stars: %v", err)
|
|
||||||
}
|
|
||||||
if count != 1 {
|
|
||||||
t.Errorf("Expected 1 star from old format, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcessManifest_Duplicate(t *testing.T) {
|
func TestProcessManifest_Duplicate(t *testing.T) {
|
||||||
database := setupTestDB(t)
|
database := setupTestDB(t)
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|||||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+2
-14
@@ -202,8 +202,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
|||||||
atproto.SetTestMode(true)
|
atproto.SetTestMode(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load crypto keys from database (with file fallback and migration)
|
oauthKey, err := loadOAuthKey(s.Database)
|
||||||
oauthKey, err := loadOAuthKey(s.Database, cfg.Server.OAuthKeyPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load OAuth key: %w", err)
|
return nil, fmt.Errorf("failed to load OAuth key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -455,17 +454,6 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
|||||||
}(client, s.Refresher, holdDID, s.HoldAuthorizer)
|
}(client, s.Refresher, holdDID, s.HoldAuthorizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate old-format star records to AT URI format in background
|
|
||||||
go func(client *atproto.Client, did string) {
|
|
||||||
ctx := context.Background()
|
|
||||||
migrated, err := atproto.MigrateStarRecords(ctx, client)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("Star record migration failed", "component", "appview/callback", "did", did, "error", err, "migrated", migrated)
|
|
||||||
} else if migrated > 0 {
|
|
||||||
slog.Info("Migrated star records to AT URI format", "component", "appview/callback", "did", did, "count", migrated)
|
|
||||||
}
|
|
||||||
}(client, did)
|
|
||||||
|
|
||||||
// Drain manifests from old hold to successor in background
|
// Drain manifests from old hold to successor in background
|
||||||
go func(client *atproto.Client, did string) {
|
go func(client *atproto.Client, did string) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
@@ -485,7 +473,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
|
|||||||
|
|
||||||
// Create token issuer
|
// Create token issuer
|
||||||
if cfg.Distribution.Auth["token"] != nil {
|
if cfg.Distribution.Auth["token"] != nil {
|
||||||
rsaKey, certDER, err := loadJWTKeyAndCert(s.Database, cfg.Auth.KeyPath, cfg.Auth.CertPath)
|
rsaKey, certDER, err := loadJWTKeyAndCert(s.Database, cfg.Auth.CertPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to load JWT key material: %w", err)
|
return nil, fmt.Errorf("failed to load JWT key material: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
wrapper.classList.add('expanded');
|
setSearchExpanded(wrapper, true);
|
||||||
input.focus();
|
input.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,18 @@ func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Short-circuit if we have a recent cached approval. The hold's requestCrew
|
||||||
|
// would just return success for an established crew member, so the round-trip
|
||||||
|
// is pure waste. Cache lookup uses the same key (holdDID, userDID) that
|
||||||
|
// CheckWriteAccess will hit later in the request.
|
||||||
|
if authorizer != nil {
|
||||||
|
if cached, err := authorizer.IsCachedCrewMember(ctx, holdDID, client.DID()); err == nil && cached {
|
||||||
|
slog.Debug("crew membership cached, skipping requestCrew",
|
||||||
|
"holdDID", holdDID, "userDID", client.DID())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve hold DID to HTTP endpoint
|
// Resolve hold DID to HTTP endpoint
|
||||||
holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID)
|
holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -61,8 +73,17 @@ func EnsureCrewMembership(ctx context.Context, client *atproto.Client, refresher
|
|||||||
|
|
||||||
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID())
|
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", client.DID())
|
||||||
|
|
||||||
// Clear any cached denial to ensure immediate access
|
|
||||||
if authorizer != nil {
|
if authorizer != nil {
|
||||||
|
// Warm the approval cache so subsequent CheckWriteAccess calls within this
|
||||||
|
// request (e.g. each layer in a multi-layer push) skip the XRPC getRecord.
|
||||||
|
if err := authorizer.RecordCrewApproval(ctx, holdDID, client.DID()); err != nil {
|
||||||
|
slog.Warn("failed to record crew approval after crew registration",
|
||||||
|
"holdDID", holdDID,
|
||||||
|
"userDID", client.DID(),
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any cached denial to ensure immediate access
|
||||||
if err := authorizer.ClearCrewDenial(ctx, holdDID, client.DID()); err != nil {
|
if err := authorizer.ClearCrewDenial(ctx, holdDID, client.DID()); err != nil {
|
||||||
slog.Warn("failed to clear denial cache after crew registration",
|
slog.Warn("failed to clear denial cache after crew registration",
|
||||||
"holdDID", holdDID,
|
"holdDID", holdDID,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"atcr.io/pkg/atproto"
|
||||||
|
"atcr.io/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnsureCrewMembership_EmptyHoldDID(t *testing.T) {
|
func TestEnsureCrewMembership_EmptyHoldDID(t *testing.T) {
|
||||||
@@ -11,4 +15,83 @@ func TestEnsureCrewMembership_EmptyHoldDID(t *testing.T) {
|
|||||||
// If we get here without panic, test passes
|
// If we get here without panic, test passes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Add comprehensive tests with HTTP client mocking
|
// fakeAuthorizer records cache calls for use in EnsureCrewMembership tests.
|
||||||
|
type fakeAuthorizer struct {
|
||||||
|
cachedReturn bool
|
||||||
|
isCachedCalls atomic.Int32
|
||||||
|
recordApprovalCalls atomic.Int32
|
||||||
|
clearDenialCalls atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAuthorizer) GetCaptainRecord(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAuthorizer) CheckWriteAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAuthorizer) IsCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAuthorizer) ClearCrewDenial(ctx context.Context, holdDID, userDID string) error {
|
||||||
|
f.clearDenialCalls.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeAuthorizer) IsCachedCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
|
f.isCachedCalls.Add(1)
|
||||||
|
return f.cachedReturn, nil
|
||||||
|
}
|
||||||
|
func (f *fakeAuthorizer) RecordCrewApproval(ctx context.Context, holdDID, userDID string) error {
|
||||||
|
f.recordApprovalCalls.Add(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ auth.HoldAuthorizer = (*fakeAuthorizer)(nil)
|
||||||
|
|
||||||
|
// TestEnsureCrewMembership_SkipsRequestCrewWhenCached verifies the cache short-circuit:
|
||||||
|
// when IsCachedCrewMember returns true, the function returns before attempting the
|
||||||
|
// requestCrew POST (and before fetching a service token, which would otherwise fail
|
||||||
|
// with a nil refresher).
|
||||||
|
func TestEnsureCrewMembership_SkipsRequestCrewWhenCached(t *testing.T) {
|
||||||
|
authz := &fakeAuthorizer{cachedReturn: true}
|
||||||
|
client := atproto.NewClient("https://pds.example", "did:plc:user123", "")
|
||||||
|
holdDID := "did:web:hold01.atcr.io"
|
||||||
|
|
||||||
|
// Pass nil refresher: if the cache check did NOT short-circuit, the function
|
||||||
|
// would log "skipping crew registration" and still return without panic — but
|
||||||
|
// it also would NOT have called IsCachedCrewMember, which is what we assert.
|
||||||
|
EnsureCrewMembership(context.Background(), client, nil, holdDID, authz)
|
||||||
|
|
||||||
|
if authz.isCachedCalls.Load() != 1 {
|
||||||
|
t.Errorf("Expected IsCachedCrewMember to be called once, got %d", authz.isCachedCalls.Load())
|
||||||
|
}
|
||||||
|
if authz.recordApprovalCalls.Load() != 0 {
|
||||||
|
t.Errorf("Expected RecordCrewApproval not to be called on cache hit, got %d", authz.recordApprovalCalls.Load())
|
||||||
|
}
|
||||||
|
if authz.clearDenialCalls.Load() != 0 {
|
||||||
|
t.Errorf("Expected ClearCrewDenial not to be called on cache hit, got %d", authz.clearDenialCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureCrewMembership_NoRefresherFallsThroughCacheCheck verifies that a cache
|
||||||
|
// miss with a nil refresher takes the existing app-password skip path, and does not
|
||||||
|
// call RecordCrewApproval (since requestCrew never ran).
|
||||||
|
func TestEnsureCrewMembership_NoRefresherFallsThroughCacheCheck(t *testing.T) {
|
||||||
|
authz := &fakeAuthorizer{cachedReturn: false}
|
||||||
|
client := atproto.NewClient("https://pds.example", "did:plc:user123", "")
|
||||||
|
holdDID := "did:web:hold01.atcr.io"
|
||||||
|
|
||||||
|
EnsureCrewMembership(context.Background(), client, nil, holdDID, authz)
|
||||||
|
|
||||||
|
if authz.isCachedCalls.Load() != 1 {
|
||||||
|
t.Errorf("Expected IsCachedCrewMember to be called once, got %d", authz.isCachedCalls.Load())
|
||||||
|
}
|
||||||
|
if authz.recordApprovalCalls.Load() != 0 {
|
||||||
|
t.Errorf("Expected RecordCrewApproval not to be called when requestCrew did not run, got %d", authz.recordApprovalCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add comprehensive tests with HTTP client mocking for the requestCrew success
|
||||||
|
// path (requires a working oauth.Refresher to obtain a service token).
|
||||||
|
|||||||
@@ -19,43 +19,65 @@ const ProfileRKey = "self"
|
|||||||
// Used to prevent duplicate migration goroutines
|
// Used to prevent duplicate migration goroutines
|
||||||
var migrationLocks sync.Map
|
var migrationLocks sync.Map
|
||||||
|
|
||||||
// EnsureProfile checks if a user's profile exists and creates it if needed
|
// EnsureProfile checks if a user's profile exists and creates it if needed.
|
||||||
// This should be called during authentication (OAuth exchange or token service)
|
// If the profile already exists, missing fields are reconciled against the
|
||||||
// If defaultHoldDID is provided, creates profile with that default (or empty if not provided)
|
// AppView's defaults so downstream logic (e.g. successor migration) has a
|
||||||
// Expected format: "did:web:hold01.atcr.io"
|
// concrete defaultHold to anchor on instead of relying on the empty fallback.
|
||||||
// Normalizes URLs to DIDs for consistency (for backward compatibility)
|
// Currently only defaultHold is reconciled; other zero-valued fields have
|
||||||
|
// their own runtime defaults and shouldn't be written unprompted.
|
||||||
|
// This should be called during authentication (OAuth exchange or token service).
|
||||||
|
// Expected format for defaultHoldDID: "did:web:hold01.atcr.io"
|
||||||
func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID string) error {
|
func EnsureProfile(ctx context.Context, client *atproto.Client, defaultHoldDID string) error {
|
||||||
// Check if profile already exists
|
// Resolve the AppView default to a DID up-front; used both for create and reconcile paths.
|
||||||
profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey)
|
|
||||||
if err == nil && profile != nil {
|
|
||||||
// Profile exists, nothing to do
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize to DID if it's a URL (or pass through if already a DID)
|
|
||||||
// This ensures we store DIDs consistently in new profiles
|
|
||||||
normalizedDID := ""
|
normalizedDID := ""
|
||||||
if defaultHoldDID != "" {
|
if defaultHoldDID != "" {
|
||||||
resolved, err := atproto.ResolveHoldDID(ctx, defaultHoldDID)
|
resolved, err := atproto.ResolveHoldDID(ctx, defaultHoldDID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Failed to resolve hold DID for new profile", "component", "profile", "defaultHold", defaultHoldDID, "error", err)
|
slog.Warn("Failed to resolve hold DID", "component", "profile", "defaultHold", defaultHoldDID, "error", err)
|
||||||
} else {
|
} else {
|
||||||
normalizedDID = resolved
|
normalizedDID = resolved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile doesn't exist - create it
|
record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, ProfileRKey)
|
||||||
newProfile := atproto.NewSailorProfileRecord(normalizedDID)
|
if err != nil && !errors.Is(err, atproto.ErrRecordNotFound) {
|
||||||
|
// Preserve previous best-effort behavior: log and treat as missing.
|
||||||
_, err = client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, newProfile)
|
slog.Warn("Failed to fetch existing profile", "component", "profile", "did", client.DID(), "error", err)
|
||||||
if err != nil {
|
record = nil
|
||||||
return fmt.Errorf("failed to create sailor profile: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if record == nil {
|
||||||
|
newProfile := atproto.NewSailorProfileRecord(normalizedDID)
|
||||||
|
if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, newProfile); err != nil {
|
||||||
|
return fmt.Errorf("failed to create sailor profile: %w", err)
|
||||||
|
}
|
||||||
slog.Debug("Created sailor profile", "component", "profile", "default_hold", normalizedDID)
|
slog.Debug("Created sailor profile", "component", "profile", "default_hold", normalizedDID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var profile atproto.SailorProfileRecord
|
||||||
|
if err := json.Unmarshal(record.Value, &profile); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse existing profile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
if profile.DefaultHold == "" && normalizedDID != "" {
|
||||||
|
profile.DefaultHold = normalizedDID
|
||||||
|
changed = true
|
||||||
|
slog.Info("Reconciling empty defaultHold to AppView default", "component", "profile", "did", client.DID(), "default_hold", normalizedDID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.UpdatedAt = time.Now()
|
||||||
|
if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, ProfileRKey, &profile); err != nil {
|
||||||
|
return fmt.Errorf("failed to reconcile sailor profile: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetProfile retrieves the user's profile from their PDS
|
// GetProfile retrieves the user's profile from their PDS
|
||||||
// Returns nil if profile doesn't exist
|
// Returns nil if profile doesn't exist
|
||||||
// Automatically migrates old URL-based defaultHold values to DIDs
|
// Automatically migrates old URL-based defaultHold values to DIDs
|
||||||
|
|||||||
@@ -203,6 +203,99 @@ func TestEnsureProfile_Exists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEnsureProfile_ReconcilesEmptyDefaultHold tests that an existing profile
|
||||||
|
// with empty defaultHold gets backfilled with the AppView default. This anchors
|
||||||
|
// users (typically app-password-only ones who never log in interactively) to a
|
||||||
|
// concrete hold so successor migration / drain has a candidate to work with.
|
||||||
|
func TestEnsureProfile_ReconcilesEmptyDefaultHold(t *testing.T) {
|
||||||
|
var sentProfile map[string]any
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// GetRecord: profile exists, defaultHold is empty
|
||||||
|
if r.Method == "GET" {
|
||||||
|
response := `{
|
||||||
|
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||||
|
"cid": "bafytest",
|
||||||
|
"value": {
|
||||||
|
"$type": "io.atcr.sailor.profile",
|
||||||
|
"createdAt": "2025-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(response))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutRecord: reconciliation should write
|
||||||
|
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||||
|
var body map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
mu.Lock()
|
||||||
|
sentProfile = body
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"uri":"at://did:plc:test123/io.atcr.sailor.profile/self","cid":"bafytest"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||||
|
if err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io"); err != nil {
|
||||||
|
t.Fatalf("EnsureProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if sentProfile == nil {
|
||||||
|
t.Fatal("expected reconciliation PutRecord call")
|
||||||
|
}
|
||||||
|
recordData := sentProfile["record"].(map[string]any)
|
||||||
|
if got := recordData["defaultHold"]; got != "did:web:hold01.atcr.io" {
|
||||||
|
t.Errorf("defaultHold after reconcile = %v, want did:web:hold01.atcr.io", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnsureProfile_NoReconcileWhenSet asserts we don't churn existing profiles
|
||||||
|
// that already have a defaultHold (regardless of whether it matches the AppView
|
||||||
|
// default — that's the user's explicit choice).
|
||||||
|
func TestEnsureProfile_NoReconcileWhenSet(t *testing.T) {
|
||||||
|
putRecordCalled := false
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == "GET" {
|
||||||
|
response := `{
|
||||||
|
"uri": "at://did:plc:test123/io.atcr.sailor.profile/self",
|
||||||
|
"cid": "bafytest",
|
||||||
|
"value": {
|
||||||
|
"$type": "io.atcr.sailor.profile",
|
||||||
|
"defaultHold": "did:plc:userpicked",
|
||||||
|
"createdAt": "2025-01-01T00:00:00Z",
|
||||||
|
"updatedAt": "2025-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(response))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == "POST" && strings.Contains(r.URL.Path, "putRecord") {
|
||||||
|
putRecordCalled = true
|
||||||
|
t.Error("PutRecord should not be called when defaultHold is already set")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := atproto.NewClient(server.URL, "did:plc:test123", "test-token")
|
||||||
|
if err := EnsureProfile(context.Background(), client, "did:web:hold01.atcr.io"); err != nil {
|
||||||
|
t.Fatalf("EnsureProfile() error = %v", err)
|
||||||
|
}
|
||||||
|
if putRecordCalled {
|
||||||
|
t.Error("unexpected PutRecord call")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGetProfile tests retrieving a user's profile
|
// TestGetProfile tests retrieving a user's profile
|
||||||
func TestGetProfile(t *testing.T) {
|
func TestGetProfile(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
{{ define "nav-brand" }}
|
{{ define "nav-brand" }}
|
||||||
<a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-base-content no-underline tracking-tight focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded-sm">
|
<a href="/" class="flex items-center gap-2 text-2xl font-display font-bold text-base-content no-underline tracking-tight focus-visible:outline-2 focus-visible:outline-primary focus-visible:outline-offset-2 rounded-sm">
|
||||||
<svg class="nav-brand-logo h-12 w-auto" viewBox="-77.86658 0 736.12415 736.12415" width="48" height="48" aria-hidden="true" focusable="false">
|
<img src="/favicon.svg" alt="" class="nav-brand-logo h-12 w-auto" aria-hidden="true" />
|
||||||
<g transform="translate(-221.72255,-136.52091)">
|
|
||||||
<path fill="#fdfdfd" d="M 617.5495,871.8604 C 578.11386,869.03851 541.82227,858.4385 500.12778,837.56397 465.36235,820.15851 436.76514,811.07059 403.5,806.85656 c -12.84516,-1.62722 -49.07301,-1.60244 -62,0.0424 -33.91367,4.31524 -65.77468,15.2698 -95.98413,33.00161 -9.12922,5.35851 -13.94133,5.5727 -19.08341,0.84942 -5.98616,-5.4986 -6.2808,-13.15645 -0.72998,-18.97261 3.25811,-3.41385 18.13488,-11.95914 32.64892,-18.7537 20.1575,-9.43648 48.25209,-17.77664 72.6486,-21.56648 16.14087,-2.50738 56.00489,-3.09183 73.35439,-1.07546 38.90127,4.52113 66.90782,13.35774 110.65324,34.91324 32.03372,15.78457 54.63895,23.156 85.328,27.82494 17.10618,2.60249 48.21475,3.55343 64.17738,1.9618 39.21587,-3.91019 79.89818,-17.74495 111.98699,-38.0832 9.49605,-6.01871 11.46031,-6.6266 16.5169,-5.11161 7.93123,2.37625 11.12157,9.92854 7.78731,18.43441 -2.64662,6.75168 -37.55875,25.98896 -64.92934,35.77736 -37.43027,13.386 -77.3022,18.6972 -118.32537,15.76171 z M 618,805.41223 c -1.925,-0.22171 -7.775,-0.8899 -13,-1.48485 -21.24321,-2.41892 -50.05582,-9.3996 -70.2951,-17.03102 C 530.1922,785.1948 515.025,778.25587 501,771.47651 471.8442,757.38326 456.97198,751.58624 437.3313,746.65917 411.89585,740.27843 404.77,739.49571 372.5,739.538 c -22.47927,0.0295 -31.33872,0.42211 -39.40113,1.74626 -29.96332,4.92111 -60.43175,15.82046 -84.68974,30.29572 -10.43562,6.22716 -14.40931,7.02265 -20.17816,4.03946 -5.94688,-3.07525 -8.46008,-12.56844 -4.80438,-18.14775 3.66647,-5.59574 29.31775,-19.52383 51.07341,-27.73175 24.58467,-9.27525 43.08892,-13.51256 71,-16.25837 31.52697,-3.10152 63.72313,-0.73488 96.09111,7.06336 13.26193,3.19513 29.91602,8.61443 39.90889,12.98649 1.65,0.72191 5.25,2.20206 8,3.28923 2.75,1.08718 14.45,6.64838 26,12.35823 11.55,5.70985 23.25,11.12029 26,12.02321 2.75,0.90292 8.01923,2.7551 11.70941,4.11596 30.31402,11.17918 74.29895,16.77162 107.36691,13.65112 43.37883,-4.09349 80.37395,-16.50011 117.3777,-39.36357 8.46646,-5.23116 13.48207,-5.95296 18.47855,-2.65926 3.98502,2.62694 5.48994,5.68112 5.53199,11.22693 0.0474,6.25411 -2.48022,9.11099 -14.79473,16.72177 -30.30214,18.72775 -68.52877,32.58103 -105.50546,38.23503 -12.567,1.92158 -53.92342,3.40408 -63.66437,2.28216 z"/>
|
|
||||||
<path fill="#f9d911" d="m 617.5,778.92769 c -22.78112,-2.29598 -48.20714,-7.6784 -64.29059,-13.60964 -3.69018,-1.36086 -8.95941,-3.21304 -11.70941,-4.11596 -2.75,-0.90292 -14.45,-6.31336 -26,-12.02321 -11.55,-5.70985 -23.25,-11.27105 -26,-12.35823 -2.75,-1.08717 -6.35,-2.56732 -8,-3.28923 -9.99287,-4.37206 -26.64696,-9.79136 -39.90889,-12.98649 -32.36798,-7.79824 -64.56414,-10.16488 -96.09111,-7.06336 -14.17395,1.39439 -19.56653,2.1545 -30.75,4.33436 l -4.75,0.92586 0.008,-21.6209 c 0.004,-11.89149 0.4721,-24.48375 1.03986,-27.98279 1.90217,-11.72272 9.10364,-22.18111 18.9719,-27.55209 2.80287,-1.52551 14.63265,-6.51166 26.2884,-11.08032 17.63653,-6.91292 21.32151,-8.70088 21.96242,-10.65618 0.42358,-1.29223 3.70527,-19.67451 7.29266,-40.84951 10.93432,-64.54115 36.65053,-214.58556 47.9037,-279.5 1.90689,-11 4.40006,-25.625 5.54037,-32.5 1.1403,-6.875 2.31057,-13.2875 2.6006,-14.25 0.47966,-1.59334 -0.4683,-1.75 -10.59018,-1.75 C 416.06978,251 412,248.85761 412,240.98882 c 0,-2.14014 0.93248,-4.07514 2.92308,-6.06574 L 417.84615,232 h 94.62238 94.62238 l 2.45454,2.45455 c 3.63107,3.63106 3.42121,9.73263 -0.46853,13.62237 C 606.18281,250.97104 606.03914,251 594.57692,251 583.42789,251 583,251.0788 583,253.13192 c 0,1.17255 1.38641,10.06005 3.0809,19.75 4.02848,23.03676 22.95628,133.28638 39.93618,232.61808 3.76071,22 8.66683,50.58443 10.90248,63.52095 2.23566,12.93652 5.15946,29.98903 6.49734,37.89445 1.36905,8.08956 2.98675,14.71603 3.70023,15.15699 0.69723,0.43091 11.19361,4.71484 23.32528,9.51983 12.13168,4.80499 23.63509,9.72363 25.56316,10.93031 8.44769,5.28698 15.21532,16.07918 17.02955,27.1567 0.52431,3.20142 0.9559,26.82634 0.95909,52.49981 l 0.006,46.67904 -5.26963,1.53193 c -26.98949,7.84605 -63.85821,11.29637 -91.23037,8.53768 z M 616.00462,561.75 c 0.0111,-3.02045 -11.42676,-68.33901 -12.1049,-69.12748 -0.41508,-0.48262 -8.32482,1.69447 -17.5772,4.83798 -9.25239,3.1435 -52.73341,17.81377 -96.6245,32.60059 -43.89109,14.78682 -80.22859,27.14879 -80.75,27.47104 C 408.42661,557.85438 408,559.21648 408,560.55902 V 563 h 104 c 82.53087,0 104.00095,-0.25804 104.00462,-1.25 z M 503.64748,483.89936 c 47.21888,-16.00535 87.3399,-29.53337 89.1578,-30.06227 1.8179,-0.52889 3.55299,-1.60715 3.85575,-2.39613 0.4452,-1.16018 -3.19945,-25.14729 -5.18715,-34.13899 -0.46799,-2.11701 -2.89118,-1.38503 -65.72989,19.85499 -35.88419,12.12917 -73.34399,24.763 -83.24399,28.07518 -9.9,3.31219 -18.31011,6.30244 -18.68913,6.64501 -0.37902,0.34257 -2.07159,8.72285 -3.76128,18.62285 -1.68968,9.9 -3.30065,19.0125 -3.57992,20.25 -0.27928,1.2375 -0.0953,2.25 0.40876,2.25 0.50409,0 39.55016,-13.09529 86.76905,-29.10064 z m -12.00479,-76.80553 c 32.37152,-10.9484 66.24488,-22.4234 75.27412,-25.5 9.02925,-3.07661 16.56675,-5.59383 16.75,-5.59383 0.78989,0 0.2413,-6.30318 -0.70199,-8.06574 C 581.95652,366.05023 580.11419,366 512.02383,366 c -44.20346,0 -70.12676,0.35753 -70.50679,0.97242 -0.50686,0.82012 -10.47609,57.06549 -10.50667,59.27758 -0.006,0.4125 0.39134,0.75 0.88232,0.75 0.49098,0 27.37848,-8.95778 59.75,-19.90617 z M 464.69411,214.25 c 1.35047,-3.74335 4.751,-7.53803 7.95455,-8.87656 L 476,203.97316 v -17.27648 c 0,-19.53223 1.2134,-26.23741 6.13635,-33.90916 3.94773,-6.15201 8.94216,-10.46656 15.81802,-13.66476 4.57517,-2.12807 7.03676,-2.58325 14.06666,-2.60111 7.61634,-0.0194 9.22192,0.3234 15.12258,3.22828 7.83094,3.85516 13.97677,10.13512 17.68889,18.07492 2.52613,5.4031 2.67763,6.64616 3.16041,25.93123 l 0.50709,20.25609 3.48007,1.74391 C 555.13579,207.33747 560,213.03373 560,215.14788 560,215.61655 538.41412,216 512.03138,216 c -45.25823,0 -47.93294,-0.0989 -47.33727,-1.75 z"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<span class="min-w-0 max-w-56 truncate sm:max-w-none">{{ .ClientName }}</span>
|
<span class="min-w-0 max-w-56 truncate sm:max-w-none">{{ .ClientName }}</span>
|
||||||
</a>
|
</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
+1
-95
@@ -3,7 +3,6 @@ package atproto
|
|||||||
//go:generate go run generate.go
|
//go:generate go run generate.go
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base32"
|
"encoding/base32"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -434,45 +433,8 @@ type StarRecord struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON handles both old format (object subject) and new format (AT URI string subject)
|
|
||||||
func (s *StarRecord) UnmarshalJSON(data []byte) error {
|
|
||||||
// Use a raw type to inspect the subject field
|
|
||||||
type starRecordRaw struct {
|
|
||||||
Type string `json:"$type"`
|
|
||||||
Subject json.RawMessage `json:"subject"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
}
|
|
||||||
var raw starRecordRaw
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return fmt.Errorf("failed to unmarshal star record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Type = raw.Type
|
|
||||||
s.CreatedAt = raw.CreatedAt
|
|
||||||
|
|
||||||
// Try new format: subject is a string AT URI
|
|
||||||
var subjectStr string
|
|
||||||
if err := json.Unmarshal(raw.Subject, &subjectStr); err == nil && strings.HasPrefix(subjectStr, "at://") {
|
|
||||||
s.Subject = subjectStr
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to old format: subject is an object with did + repository
|
|
||||||
var oldSubject struct {
|
|
||||||
DID string `json:"did"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw.Subject, &oldSubject); err != nil {
|
|
||||||
return fmt.Errorf("failed to unmarshal star subject (neither AT URI string nor {did, repository} object): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Subject = BuildRepoPageURI(oldSubject.DID, oldSubject.Repository)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSubjectDIDAndRepository extracts the owner DID and repository name
|
// GetSubjectDIDAndRepository extracts the owner DID and repository name
|
||||||
// from the star record's subject AT URI. UnmarshalJSON normalizes old format
|
// from the star record's subject AT URI.
|
||||||
// to AT URI, so this always works with ParseRepoPageURI.
|
|
||||||
func (s *StarRecord) GetSubjectDIDAndRepository() (ownerDID, repository string, err error) {
|
func (s *StarRecord) GetSubjectDIDAndRepository() (ownerDID, repository string, err error) {
|
||||||
return ParseRepoPageURI(s.Subject)
|
return ParseRepoPageURI(s.Subject)
|
||||||
}
|
}
|
||||||
@@ -532,62 +494,6 @@ func ParseStarRecordKey(rkey string) (ownerDID, repository string, err error) {
|
|||||||
return parts[0], parts[1], nil
|
return parts[0], parts[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MigrateStarRecords lists the user's star records and rewrites any old-format
|
|
||||||
// records (object subject with did/repository) to the new AT URI format.
|
|
||||||
// Returns the number of records migrated.
|
|
||||||
func MigrateStarRecords(ctx context.Context, client *Client) (int, error) {
|
|
||||||
migrated := 0
|
|
||||||
cursor := ""
|
|
||||||
|
|
||||||
for {
|
|
||||||
records, nextCursor, err := client.ListRecordsWithCursor(ctx, StarCollection, 100, cursor)
|
|
||||||
if err != nil {
|
|
||||||
return migrated, fmt.Errorf("failed to list star records: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rec := range records {
|
|
||||||
// Try to unmarshal as new format (string subject) — skip if already migrated
|
|
||||||
var subjectStr string
|
|
||||||
if err := json.Unmarshal(rec.Value, &struct {
|
|
||||||
Subject *string `json:"subject"`
|
|
||||||
}{Subject: &subjectStr}); err == nil && strings.HasPrefix(subjectStr, "at://") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old format — unmarshal via StarRecord (which normalizes to AT URI)
|
|
||||||
var starRecord StarRecord
|
|
||||||
if err := json.Unmarshal(rec.Value, &starRecord); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract rkey from the record URI (at://did/collection/rkey)
|
|
||||||
uriParts := strings.Split(rec.URI, "/")
|
|
||||||
if len(uriParts) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rkey := uriParts[len(uriParts)-1]
|
|
||||||
|
|
||||||
// Rewrite with new format at the same rkey
|
|
||||||
newRecord := &StarRecord{
|
|
||||||
Type: StarCollection,
|
|
||||||
Subject: starRecord.Subject, // Already normalized to AT URI by UnmarshalJSON
|
|
||||||
CreatedAt: starRecord.CreatedAt,
|
|
||||||
}
|
|
||||||
if _, err := client.PutRecord(ctx, StarCollection, rkey, newRecord); err != nil {
|
|
||||||
return migrated, fmt.Errorf("failed to migrate star record %s: %w", rec.URI, err)
|
|
||||||
}
|
|
||||||
migrated++
|
|
||||||
}
|
|
||||||
|
|
||||||
if nextCursor == "" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
cursor = nextCursor
|
|
||||||
}
|
|
||||||
|
|
||||||
return migrated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDID checks if a string is a DID (starts with "did:")
|
// IsDID checks if a string is a DID (starts with "did:")
|
||||||
func IsDID(s string) bool {
|
func IsDID(s string) bool {
|
||||||
return len(s) > 4 && s[:4] == "did:"
|
return len(s) > 4 && s[:4] == "did:"
|
||||||
|
|||||||
@@ -812,52 +812,27 @@ func TestBlobReference_JSONSerialization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStarRecord_DualFormatDeserialization(t *testing.T) {
|
func TestStarRecord_Deserialization(t *testing.T) {
|
||||||
tests := []struct {
|
const jsonData = `{"$type":"io.atcr.sailor.star","subject":"at://did:plc:alice123/io.atcr.repo.page/myapp","createdAt":"2025-01-01T00:00:00Z"}`
|
||||||
name string
|
|
||||||
jsonData string
|
|
||||||
wantSubject string
|
|
||||||
wantOwnerDID string
|
|
||||||
wantRepo string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "new format - AT URI string",
|
|
||||||
jsonData: `{"$type":"io.atcr.sailor.star","subject":"at://did:plc:alice123/io.atcr.repo.page/myapp","createdAt":"2025-01-01T00:00:00Z"}`,
|
|
||||||
wantSubject: "at://did:plc:alice123/io.atcr.repo.page/myapp",
|
|
||||||
wantOwnerDID: "did:plc:alice123",
|
|
||||||
wantRepo: "myapp",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "old format - object subject",
|
|
||||||
jsonData: `{"$type":"io.atcr.sailor.star","subject":{"did":"did:plc:alice123","repository":"myapp"},"createdAt":"2025-01-01T00:00:00Z"}`,
|
|
||||||
wantSubject: "at://did:plc:alice123/io.atcr.repo.page/myapp",
|
|
||||||
wantOwnerDID: "did:plc:alice123",
|
|
||||||
wantRepo: "myapp",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
var record StarRecord
|
var record StarRecord
|
||||||
if err := json.Unmarshal([]byte(tt.jsonData), &record); err != nil {
|
if err := json.Unmarshal([]byte(jsonData), &record); err != nil {
|
||||||
t.Fatalf("Unmarshal error: %v", err)
|
t.Fatalf("Unmarshal error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if record.Subject != tt.wantSubject {
|
if record.Subject != "at://did:plc:alice123/io.atcr.repo.page/myapp" {
|
||||||
t.Errorf("Subject = %v, want %v", record.Subject, tt.wantSubject)
|
t.Errorf("Subject = %v", record.Subject)
|
||||||
}
|
}
|
||||||
|
|
||||||
ownerDID, repo, err := record.GetSubjectDIDAndRepository()
|
ownerDID, repo, err := record.GetSubjectDIDAndRepository()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSubjectDIDAndRepository error: %v", err)
|
t.Fatalf("GetSubjectDIDAndRepository error: %v", err)
|
||||||
}
|
}
|
||||||
if ownerDID != tt.wantOwnerDID {
|
if ownerDID != "did:plc:alice123" {
|
||||||
t.Errorf("ownerDID = %v, want %v", ownerDID, tt.wantOwnerDID)
|
t.Errorf("ownerDID = %v", ownerDID)
|
||||||
}
|
}
|
||||||
if repo != tt.wantRepo {
|
if repo != "myapp" {
|
||||||
t.Errorf("repository = %v, want %v", repo, tt.wantRepo)
|
t.Errorf("repository = %v", repo)
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ type HoldAuthorizer interface {
|
|||||||
// Called when user successfully becomes a crew member to ensure immediate access
|
// Called when user successfully becomes a crew member to ensure immediate access
|
||||||
// Returns nil if no denial cache exists or invalidation succeeds
|
// Returns nil if no denial cache exists or invalidation succeeds
|
||||||
ClearCrewDenial(ctx context.Context, holdDID, userDID string) error
|
ClearCrewDenial(ctx context.Context, holdDID, userDID string) error
|
||||||
|
|
||||||
|
// IsCachedCrewMember returns true only if there is a non-expired approval
|
||||||
|
// in the cache. It MUST NOT make any network calls. Cache miss returns (false, nil).
|
||||||
|
IsCachedCrewMember(ctx context.Context, holdDID, userDID string) (bool, error)
|
||||||
|
|
||||||
|
// RecordCrewApproval writes an approval to the cache with the implementation's
|
||||||
|
// standard TTL. Used to warm the cache after an out-of-band confirmation of crew
|
||||||
|
// membership (e.g. a successful requestCrew POST). No-op for implementations
|
||||||
|
// without a cache.
|
||||||
|
RecordCrewApproval(ctx context.Context, holdDID, userDID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckReadAccessWithCaptain implements the standard read authorization logic
|
// CheckReadAccessWithCaptain implements the standard read authorization logic
|
||||||
|
|||||||
@@ -401,6 +401,24 @@ func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID,
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsCachedCrewMember returns true if there is a non-expired approval row.
|
||||||
|
// Never makes network calls. Cache miss or no DB returns (false, nil).
|
||||||
|
func (a *RemoteHoldAuthorizer) IsCachedCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
|
if a.db == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return a.getCachedApproval(holdDID, userDID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordCrewApproval writes an approval to the cache with the standard 15-min TTL.
|
||||||
|
// No-op if there is no DB.
|
||||||
|
func (a *RemoteHoldAuthorizer) RecordCrewApproval(ctx context.Context, holdDID, userDID string) error {
|
||||||
|
if a.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return a.cacheApproval(holdDID, userDID, 15*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
// CheckReadAccess implements read authorization using shared logic
|
// CheckReadAccess implements read authorization using shared logic
|
||||||
func (a *RemoteHoldAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
|
func (a *RemoteHoldAuthorizer) CheckReadAccess(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
captain, err := a.GetCaptainRecord(ctx, holdDID)
|
captain, err := a.GetCaptainRecord(ctx, holdDID)
|
||||||
|
|||||||
@@ -446,3 +446,129 @@ func TestClearAllDenials_OnStartup(t *testing.T) {
|
|||||||
t.Error("Expected all denials to be cleared after ClearAllDenials")
|
t.Error("Expected all denials to be cleared after ClearAllDenials")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsCachedCrewMember_Hit(t *testing.T) {
|
||||||
|
testDB := setupTestDB(t)
|
||||||
|
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||||
|
defer close(remote.stopCleanup)
|
||||||
|
|
||||||
|
holdDID := "did:web:hold01.atcr.io"
|
||||||
|
userDID := "did:plc:user123"
|
||||||
|
|
||||||
|
if err := remote.cacheApproval(holdDID, userDID, 15*time.Minute); err != nil {
|
||||||
|
t.Fatalf("cacheApproval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err := remote.IsCachedCrewMember(context.Background(), holdDID, userDID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsCachedCrewMember returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !cached {
|
||||||
|
t.Error("Expected cache hit, got miss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCachedCrewMember_Miss(t *testing.T) {
|
||||||
|
testDB := setupTestDB(t)
|
||||||
|
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||||
|
defer close(remote.stopCleanup)
|
||||||
|
|
||||||
|
cached, err := remote.IsCachedCrewMember(context.Background(),
|
||||||
|
"did:web:hold01.atcr.io", "did:plc:nobody")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsCachedCrewMember returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cached {
|
||||||
|
t.Error("Expected cache miss, got hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCachedCrewMember_Expired(t *testing.T) {
|
||||||
|
testDB := setupTestDB(t)
|
||||||
|
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||||
|
defer close(remote.stopCleanup)
|
||||||
|
|
||||||
|
holdDID := "did:web:hold01.atcr.io"
|
||||||
|
userDID := "did:plc:user123"
|
||||||
|
|
||||||
|
// Insert a row that already expired one minute ago.
|
||||||
|
now := time.Now()
|
||||||
|
_, err := testDB.Exec(`
|
||||||
|
INSERT INTO hold_crew_approvals (hold_did, user_did, approved_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, holdDID, userDID, now.Add(-2*time.Minute), now.Add(-1*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed insert failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err := remote.IsCachedCrewMember(context.Background(), holdDID, userDID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsCachedCrewMember returned error: %v", err)
|
||||||
|
}
|
||||||
|
if cached {
|
||||||
|
t.Error("Expected expired entry to be treated as miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired entry should have been cleaned up by getCachedApproval.
|
||||||
|
var count int
|
||||||
|
if err := testDB.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM hold_crew_approvals WHERE hold_did = ? AND user_did = ?
|
||||||
|
`, holdDID, userDID).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count query failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("Expected expired row to be deleted, found %d", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordCrewApproval_WritesAndReadsBack(t *testing.T) {
|
||||||
|
testDB := setupTestDB(t)
|
||||||
|
remote := NewRemoteHoldAuthorizer(testDB, false).(*RemoteHoldAuthorizer)
|
||||||
|
defer close(remote.stopCleanup)
|
||||||
|
|
||||||
|
holdDID := "did:web:hold01.atcr.io"
|
||||||
|
userDID := "did:plc:user123"
|
||||||
|
|
||||||
|
if err := remote.RecordCrewApproval(context.Background(), holdDID, userDID); err != nil {
|
||||||
|
t.Fatalf("RecordCrewApproval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err := remote.IsCachedCrewMember(context.Background(), holdDID, userDID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IsCachedCrewMember returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !cached {
|
||||||
|
t.Error("Expected RecordCrewApproval to populate the cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify TTL is roughly 15 minutes from now.
|
||||||
|
var expiresAt time.Time
|
||||||
|
if err := testDB.QueryRow(`
|
||||||
|
SELECT expires_at FROM hold_crew_approvals WHERE hold_did = ? AND user_did = ?
|
||||||
|
`, holdDID, userDID).Scan(&expiresAt); err != nil {
|
||||||
|
t.Fatalf("expires_at query failed: %v", err)
|
||||||
|
}
|
||||||
|
ttl := time.Until(expiresAt)
|
||||||
|
if ttl < 14*time.Minute || ttl > 16*time.Minute {
|
||||||
|
t.Errorf("Expected TTL ~15min, got %v", ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsCachedCrewMember_NoDB(t *testing.T) {
|
||||||
|
remote := NewRemoteHoldAuthorizer(nil, false).(*RemoteHoldAuthorizer)
|
||||||
|
defer close(remote.stopCleanup)
|
||||||
|
|
||||||
|
cached, err := remote.IsCachedCrewMember(context.Background(),
|
||||||
|
"did:web:hold01.atcr.io", "did:plc:user123")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected nil error with nil DB, got %v", err)
|
||||||
|
}
|
||||||
|
if cached {
|
||||||
|
t.Error("Expected false with nil DB")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := remote.RecordCrewApproval(context.Background(),
|
||||||
|
"did:web:hold01.atcr.io", "did:plc:user123"); err != nil {
|
||||||
|
t.Errorf("Expected nil error from RecordCrewApproval with nil DB, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,3 +102,14 @@ func (a *Authorizer) CheckWriteAccess(ctx context.Context, holdDID, userDID stri
|
|||||||
func (a *Authorizer) ClearCrewDenial(ctx context.Context, holdDID, userDID string) error {
|
func (a *Authorizer) ClearCrewDenial(ctx context.Context, holdDID, userDID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsCachedCrewMember always returns (false, nil) for the local authorizer.
|
||||||
|
// There is no cache; callers fall through to the direct PDS lookup.
|
||||||
|
func (a *Authorizer) IsCachedCrewMember(ctx context.Context, holdDID, userDID string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordCrewApproval is a no-op for the local authorizer (no cache to warm).
|
||||||
|
func (a *Authorizer) RecordCrewApproval(ctx context.Context, holdDID, userDID string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -202,95 +201,3 @@ func (p *HoldPDS) UpdateCrewMemberTier(ctx context.Context, memberDID, tier stri
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(crew-migration): Remove this migration code after all holds have been upgraded (added 2026-01-06)
|
|
||||||
// This migrates TID-based crew records to hash-based rkeys for O(1) lookups
|
|
||||||
|
|
||||||
// MigrateCrewRecordsToHashRkeys migrates old TID-based crew records to hash-based rkeys
|
|
||||||
// This is idempotent - records that already have hash-based rkeys are skipped
|
|
||||||
// Returns the number of records migrated
|
|
||||||
func (p *HoldPDS) MigrateCrewRecordsToHashRkeys(ctx context.Context) (int, error) {
|
|
||||||
// List all crew members (includes both TID and hash-based rkeys)
|
|
||||||
members, err := p.ListCrewMembers(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to list crew members: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("Starting crew record migration", "totalRecords", len(members))
|
|
||||||
|
|
||||||
migrated := 0
|
|
||||||
duplicatesDeleted := 0
|
|
||||||
alreadyHashBased := 0
|
|
||||||
seen := make(map[string]bool) // Track seen member DIDs to handle duplicates
|
|
||||||
|
|
||||||
for _, m := range members {
|
|
||||||
memberDID := m.Record.Member
|
|
||||||
expectedRkey := atproto.CrewRecordKey(memberDID)
|
|
||||||
|
|
||||||
// Skip if already using hash-based rkey
|
|
||||||
if m.Rkey == expectedRkey {
|
|
||||||
seen[memberDID] = true
|
|
||||||
alreadyHashBased++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a TID-based record that needs migration
|
|
||||||
slog.Info("Migrating crew record to hash-based rkey",
|
|
||||||
"memberDID", memberDID,
|
|
||||||
"oldRkey", m.Rkey,
|
|
||||||
"newRkey", expectedRkey)
|
|
||||||
|
|
||||||
// Check if we already have a hash-based record for this DID (duplicate handling)
|
|
||||||
if seen[memberDID] {
|
|
||||||
// Already migrated this DID, just delete the old TID record
|
|
||||||
slog.Info("Deleting duplicate TID-based crew record",
|
|
||||||
"memberDID", memberDID,
|
|
||||||
"rkey", m.Rkey)
|
|
||||||
if err := p.RemoveCrewMember(ctx, m.Rkey); err != nil {
|
|
||||||
slog.Warn("Failed to delete duplicate crew record",
|
|
||||||
"rkey", m.Rkey,
|
|
||||||
"error", err)
|
|
||||||
} else {
|
|
||||||
duplicatesDeleted++
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new record with hash-based rkey (PutRecord handles upsert)
|
|
||||||
newRecord := &atproto.CrewRecord{
|
|
||||||
Type: atproto.CrewCollection,
|
|
||||||
Member: m.Record.Member,
|
|
||||||
Role: m.Record.Role,
|
|
||||||
Permissions: m.Record.Permissions,
|
|
||||||
Tier: m.Record.Tier,
|
|
||||||
AddedAt: m.Record.AddedAt,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.CrewCollection, expectedRkey, newRecord)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Failed to create hash-based crew record",
|
|
||||||
"memberDID", memberDID,
|
|
||||||
"error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the old TID-based record
|
|
||||||
if err := p.RemoveCrewMember(ctx, m.Rkey); err != nil {
|
|
||||||
slog.Warn("Failed to delete old TID-based crew record",
|
|
||||||
"rkey", m.Rkey,
|
|
||||||
"error", err)
|
|
||||||
// Continue anyway - the new record is created
|
|
||||||
}
|
|
||||||
|
|
||||||
seen[memberDID] = true
|
|
||||||
migrated++
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("Crew record migration complete",
|
|
||||||
"migrated", migrated,
|
|
||||||
"duplicatesDeleted", duplicatesDeleted,
|
|
||||||
"alreadyHashBased", alreadyHashBased,
|
|
||||||
"totalRecords", len(members))
|
|
||||||
|
|
||||||
return migrated, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -353,14 +353,6 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, s3svc *s3.S3Service, cfg Bootst
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(crew-migration): Remove this call after all holds have been upgraded (added 2026-01-06)
|
|
||||||
// Migrate TID-based crew records to hash-based rkeys for O(1) lookups
|
|
||||||
if migrated, err := p.MigrateCrewRecordsToHashRkeys(ctx); err != nil {
|
|
||||||
slog.Warn("Crew record migration failed", "error", err)
|
|
||||||
} else if migrated > 0 {
|
|
||||||
slog.Info("Migrated crew records to hash-based rkeys", "count", migrated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create or sync Bluesky profile record from config
|
// Create or sync Bluesky profile record from config
|
||||||
// This runs even if captain exists (for existing holds being upgraded)
|
// This runs even if captain exists (for existing holds being upgraded)
|
||||||
// Skip if no S3 service (e.g., in tests)
|
// Skip if no S3 service (e.g., in tests)
|
||||||
|
|||||||
Reference in New Issue
Block a user