···9090 redirectURI := cfg.Server.PublicURL + "/auth/oauth/callback"
9191 clientID := cfg.Server.PublicURL + "/client-metadata.json"
92929393- // Define scopes needed for hold registration
9393+ // Define scopes needed for hold registration and crew management
9494+ // Omit action parameter to allow all actions (create, update, delete)
9495 scopes := []string{
9596 "atproto",
9696- fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
9797- fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
9898- fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
9999- fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
100100- fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection),
101101- fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection),
9797+ fmt.Sprintf("repo:%s", atproto.HoldCollection),
9898+ fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
9999+ fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
102100 }
103101104102 config := indigooauth.NewPublicConfig(clientID, redirectURI, scopes)
···147145 log.Printf("You can register manually later using the /register endpoint")
148146 } else {
149147 log.Printf("Successfully registered hold service in PDS")
148148+ }
149149+150150+ // Reconcile allow-all crew state
151151+ if err := service.ReconcileAllowAllCrew(&oauthCallbackHandler); err != nil {
152152+ log.Printf("WARNING: Failed to reconcile allow-all crew state: %v", err)
150153 }
151154 }
152155
+28
deploy/.env.prod.template
···3535# Default: false (private)
3636HOLD_PUBLIC=false
37373838+# Allow all authenticated users to write to this hold
3939+# This setting controls write permissions for authenticated ATCR users
4040+#
4141+# - true: Any authenticated ATCR user can push images (treat all as crew)
4242+# Useful for shared/community holds where you want to allow
4343+# multiple users to push without explicit crew membership.
4444+# Users must still authenticate via ATProto OAuth.
4545+#
4646+# - false: Only hold owner and explicit crew members can push (default)
4747+# Write access requires io.atcr.hold.crew record in owner's PDS.
4848+# Most secure option for production holds.
4949+#
5050+# Read permissions are controlled by HOLD_PUBLIC (above).
5151+#
5252+# Security model:
5353+# Read: HOLD_PUBLIC=true → anonymous + authenticated users
5454+# HOLD_PUBLIC=false → authenticated users only
5555+# Write: HOLD_ALLOW_ALL_CREW=true → all authenticated users
5656+# HOLD_ALLOW_ALL_CREW=false → owner + crew only (verified via PDS)
5757+#
5858+# Use cases:
5959+# - Public registry: HOLD_PUBLIC=true, HOLD_ALLOW_ALL_CREW=true
6060+# - ATProto users only: HOLD_PUBLIC=false, HOLD_ALLOW_ALL_CREW=true
6161+# - Private hold (default): HOLD_PUBLIC=false, HOLD_ALLOW_ALL_CREW=false
6262+#
6363+# Default: false
6464+HOLD_ALLOW_ALL_CREW=false
6565+3866# ==============================================================================
3967# S3/UpCloud Object Storage Configuration
4068# ==============================================================================
···11+# Hold Crew Access Control
22+33+## Overview
44+55+ATCR uses a crew-based access control system for hold (storage) services. Hold owners can grant write access to other users by creating crew records in their PDS. This document describes the scalable access control system that supports:
66+77+- **Individual access** - Explicit DID-based crew membership
88+- **Wildcard access** - Allow all authenticated users
99+- **Pattern-based access** - Match users by handle patterns (e.g., `*.example.com`)
1010+- **Access revocation** - Bar (ban) specific users or patterns
1111+1212+## Problem Statement
1313+1414+The original crew system required one `io.atcr.hold.crew` record per user. This doesn't scale for:
1515+1616+1. **Public/shared holds** - Thousands of users would need individual crew records
1717+2. **Community holds** - PDS operators want to allow all their users
1818+3. **Default registries** - AppView operators want to allow all authenticated users
1919+4. **Access revocation** - No way to selectively remove access from wildcard/pattern grants
2020+2121+## Design Goals
2222+2323+1. **Preserve ATProto semantics** - Keep `member` as DID type for backlinks
2424+2. **Scalable** - Support thousands of users with minimal records
2525+3. **Flexible patterns** - Support wildcards, handle globs, future regex
2626+4. **Clear semantics** - Separate allow/deny (crew vs barred)
2727+5. **Backward compatible** - Existing crew records work unchanged
2828+6. **Performance** - Minimize PDS queries, enable caching
2929+3030+## Record Schemas
3131+3232+### io.atcr.hold.crew (Updated)
3333+3434+Crew membership grants write access to a hold. Stored in the **hold owner's PDS**.
3535+3636+```json
3737+{
3838+ "$type": "io.atcr.hold.crew",
3939+ "hold": "at://did:plc:owner/io.atcr.hold/shared",
4040+ "member": "did:plc:alice123", // Optional: Explicit DID (for backlinks)
4141+ "memberPattern": "*.bsky.social", // Optional: Pattern matching
4242+ "role": "write",
4343+ "createdAt": "2025-10-13T12:00:00Z"
4444+}
4545+```
4646+4747+**Fields:**
4848+4949+- `hold` (string, at-uri, required) - AT-URI of the hold record
5050+- `member` (string, did, optional) - Explicit DID for individual access (enables backlinks)
5151+- `memberPattern` (string, optional) - Pattern for matching multiple users
5252+- `role` (string, required) - Role: `"owner"` or `"write"`
5353+- `expiresAt` (string, datetime, optional) - Optional expiration
5454+- `createdAt` (string, datetime, required) - Creation timestamp
5555+5656+**Validation:** Exactly one of `member` or `memberPattern` must be set.
5757+5858+**Pattern syntax:**
5959+6060+- `"*"` - Matches all authenticated users
6161+- `"*.domain.com"` - Matches handles ending with `.domain.com`
6262+- `"subdomain.*"` - Matches handles starting with `subdomain.`
6363+- `"*.bsky.*"` - Matches handles containing `.bsky.`
6464+6565+**Examples:**
6666+6767+```json
6868+// Explicit DID (current behavior, preserved)
6969+{
7070+ "$type": "io.atcr.hold.crew",
7171+ "hold": "at://did:plc:owner/io.atcr.hold/team",
7272+ "member": "did:plc:alice123",
7373+ "role": "write",
7474+ "createdAt": "2025-10-13T12:00:00Z"
7575+}
7676+7777+// Allow all authenticated users (public hold)
7878+{
7979+ "$type": "io.atcr.hold.crew",
8080+ "hold": "at://did:plc:owner/io.atcr.hold/shared",
8181+ "memberPattern": "*",
8282+ "role": "write",
8383+ "createdAt": "2025-10-13T12:00:00Z"
8484+}
8585+8686+// Allow all users from a community
8787+{
8888+ "$type": "io.atcr.hold.crew",
8989+ "hold": "at://did:plc:owner/io.atcr.hold/community",
9090+ "memberPattern": "*.my-community.social",
9191+ "role": "write",
9292+ "createdAt": "2025-10-13T12:00:00Z"
9393+}
9494+9595+// Allow specific subdomain
9696+{
9797+ "$type": "io.atcr.hold.crew",
9898+ "hold": "at://did:plc:owner/io.atcr.hold/corp",
9999+ "memberPattern": "*.eng.company.com",
100100+ "role": "write",
101101+ "createdAt": "2025-10-13T12:00:00Z"
102102+}
103103+```
104104+105105+### io.atcr.hold.crew.barred (New)
106106+107107+Barred list revokes access for specific users or patterns. Overrides crew membership. Stored in the **hold owner's PDS**.
108108+109109+```json
110110+{
111111+ "$type": "io.atcr.hold.crew.barred",
112112+ "hold": "at://did:plc:owner/io.atcr.hold/shared",
113113+ "member": "did:plc:spammer", // Optional: Explicit DID
114114+ "memberPattern": "*.spam-instance.com", // Optional: Pattern matching
115115+ "reason": "spam/abuse/policy violation",
116116+ "barredAt": "2025-10-13T12:00:00Z"
117117+}
118118+```
119119+120120+**Fields:**
121121+122122+- `hold` (string, at-uri, required) - AT-URI of the hold record
123123+- `member` (string, did, optional) - Explicit DID to bar
124124+- `memberPattern` (string, optional) - Pattern for barring multiple users
125125+- `reason` (string, optional) - Human-readable reason for access revocation
126126+- `barredAt` (string, datetime, required) - When user was barred
127127+128128+**Validation:** Exactly one of `member` or `memberPattern` must be set.
129129+130130+**Pattern syntax:** Same as crew patterns (wildcards, handle globs).
131131+132132+**Limitations:** Handle-based barring can be circumvented by users changing their handle or acquiring a new domain. However, this requires significant effort (purchasing domains, changing identity), making it an acceptable deterrent for most abuse cases. DID-based barring is permanent (until user creates new DID).
133133+134134+**Examples:**
135135+136136+```json
137137+// Bar specific user
138138+{
139139+ "$type": "io.atcr.hold.crew.barred",
140140+ "hold": "at://did:plc:owner/io.atcr.hold/shared",
141141+ "member": "did:plc:badactor",
142142+ "reason": "Terms of service violation",
143143+ "barredAt": "2025-10-13T12:00:00Z"
144144+}
145145+146146+// Bar all users from a spam PDS
147147+{
148148+ "$type": "io.atcr.hold.crew.barred",
149149+ "hold": "at://did:plc:owner/io.atcr.hold/shared",
150150+ "memberPattern": "*.spam-pds.com",
151151+ "reason": "Spam instance",
152152+ "barredAt": "2025-10-13T14:30:00Z"
153153+}
154154+155155+// Bar pattern of suspicious accounts
156156+{
157157+ "$type": "io.atcr.hold.crew.barred",
158158+ "hold": "at://did:plc:owner/io.atcr.hold/shared",
159159+ "memberPattern": "bot*",
160160+ "reason": "Automated account abuse",
161161+ "barredAt": "2025-10-13T15:00:00Z"
162162+}
163163+```
164164+165165+## Authorization Logic
166166+167167+Write authorization follows this priority order:
168168+169169+```
170170+isAuthorizedWrite(did, handle):
171171+ 1. If DID is hold owner → ALLOW
172172+ 2. If DID or handle matches barred list → DENY
173173+ 3. If DID explicitly in crew list → ALLOW
174174+ 4. If handle matches crew pattern → ALLOW
175175+ 5. Default → DENY
176176+```
177177+178178+**Detailed algorithm:**
179179+180180+```go
181181+func (s *HoldService) isAuthorizedWrite(did string) bool {
182182+ // 1. Check if owner
183183+ if did == s.config.Registration.OwnerDID {
184184+ return true // Owner always has access
185185+ }
186186+187187+ // 2. Resolve handle from DID
188188+ handle, err := resolveHandle(did)
189189+ if err != nil {
190190+ log.Printf("Failed to resolve handle for DID %s: %v", did, err)
191191+ handle = "" // Continue without handle matching
192192+ }
193193+194194+ // 3. Check barred list (explicit deny overrides everything)
195195+ barred, err := s.isBarred(did, handle)
196196+ if err != nil {
197197+ log.Printf("Error checking barred status: %v", err)
198198+ return false // Fail secure
199199+ }
200200+ if barred {
201201+ return false // Explicitly barred
202202+ }
203203+204204+ // 4. Check crew list (explicit allow)
205205+ crew, err := s.isCrewMember(did, handle)
206206+ if err != nil {
207207+ log.Printf("Error checking crew status: %v", err)
208208+ return false // Fail secure
209209+ }
210210+211211+ return crew // Allow if crew member, deny otherwise
212212+}
213213+214214+func (s *HoldService) isBarred(did, handle string) (bool, error) {
215215+ records := listBarredRecords()
216216+217217+ for _, record := range records {
218218+ // Check explicit DID match
219219+ if record.Member != "" && record.Member == did {
220220+ return true, nil
221221+ }
222222+223223+ // Check pattern match (if handle available)
224224+ if record.MemberPattern != "" && handle != "" {
225225+ if matchPattern(record.MemberPattern, handle) {
226226+ return true, nil
227227+ }
228228+ }
229229+ }
230230+231231+ return false, nil
232232+}
233233+234234+func (s *HoldService) isCrewMember(did, handle string) (bool, error) {
235235+ records := listCrewRecords()
236236+237237+ for _, record := range records {
238238+ // Check explicit DID match
239239+ if record.Member != "" && record.Member == did {
240240+ return true, nil
241241+ }
242242+243243+ // Check pattern match (if handle available)
244244+ if record.MemberPattern != "" && handle != "" {
245245+ if matchPattern(record.MemberPattern, handle) {
246246+ return true, nil
247247+ }
248248+ }
249249+ }
250250+251251+ return false, nil
252252+}
253253+```
254254+255255+**Pattern matching:**
256256+257257+```go
258258+func matchPattern(pattern, handle string) bool {
259259+ if pattern == "*" {
260260+ return true // Wildcard matches all
261261+ }
262262+263263+ // Convert glob pattern to regex
264264+ // *.example.com → ^.*\.example\.com$
265265+ // subdomain.* → ^subdomain\..*$
266266+ // *.bsky.* → ^.*\.bsky\..*$
267267+268268+ regex := globToRegex(pattern)
269269+ matched, _ := regexp.MatchString(regex, handle)
270270+ return matched
271271+}
272272+```
273273+274274+## Use Cases
275275+276276+### 1. Public Hold (Allow All Users)
277277+278278+**Goal:** Shared storage for any authenticated ATCR user.
279279+280280+**Setup:**
281281+```bash
282282+# Create crew record with wildcard
283283+atproto put-record \
284284+ --collection io.atcr.hold.crew \
285285+ --rkey "all-users" \
286286+ --value '{
287287+ "$type": "io.atcr.hold.crew",
288288+ "hold": "at://did:plc:owner/io.atcr.hold/public",
289289+ "memberPattern": "*",
290290+ "role": "write"
291291+ }'
292292+```
293293+294294+**Result:** All authenticated users can push. Owner can selectively bar bad actors.
295295+296296+### 2. Community Hold (PDS-Specific)
297297+298298+**Goal:** Storage for all users from a specific community/PDS.
299299+300300+**Setup:**
301301+```bash
302302+# Allow all community members
303303+atproto put-record \
304304+ --collection io.atcr.hold.crew \
305305+ --rkey "community-hold" \
306306+ --value '{
307307+ "$type": "io.atcr.hold.crew",
308308+ "hold": "at://did:plc:owner/io.atcr.hold/community",
309309+ "memberPattern": "*.my-community.social",
310310+ "role": "write"
311311+ }'
312312+```
313313+314314+**Result:** Anyone with a `@someone.my-community.social` handle can push.
315315+316316+### 3. Team Hold with Selective Banning
317317+318318+**Goal:** Shared team storage, but remove access from former employees.
319319+320320+**Setup:**
321321+```bash
322322+# Allow team domain
323323+atproto put-record \
324324+ --collection io.atcr.hold.crew \
325325+ --rkey "team-hold" \
326326+ --value '{
327327+ "$type": "io.atcr.hold.crew",
328328+ "hold": "at://did:plc:owner/io.atcr.hold/team",
329329+ "memberPattern": "*.company.com",
330330+ "role": "write"
331331+ }'
332332+333333+# Bar former employee
334334+atproto put-record \
335335+ --collection io.atcr.hold.crew.barred \
336336+ --rkey "bar-former-employee" \
337337+ --value '{
338338+ "$type": "io.atcr.hold.crew.barred",
339339+ "hold": "at://did:plc:owner/io.atcr.hold/team",
340340+ "member": "did:plc:former-employee",
341341+ "reason": "No longer with company"
342342+ }'
343343+```
344344+345345+**Result:** All `@*.company.com` users can push, except the explicitly barred DID.
346346+347347+### 4. Anti-Spam with Barred Patterns
348348+349349+**Goal:** Public hold with protection against known spam instances.
350350+351351+**Setup:**
352352+```bash
353353+# Allow all users
354354+atproto put-record \
355355+ --collection io.atcr.hold.crew \
356356+ --rkey "public-hold" \
357357+ --value '{
358358+ "$type": "io.atcr.hold.crew",
359359+ "hold": "at://did:plc:owner/io.atcr.hold/public",
360360+ "memberPattern": "*",
361361+ "role": "write"
362362+ }'
363363+364364+# Bar spam instance
365365+atproto put-record \
366366+ --collection io.atcr.hold.crew.barred \
367367+ --rkey "bar-spam-pds" \
368368+ --value '{
369369+ "$type": "io.atcr.hold.crew.barred",
370370+ "hold": "at://did:plc:owner/io.atcr.hold/public",
371371+ "memberPattern": "*.known-spam.com",
372372+ "reason": "Spam source"
373373+ }'
374374+```
375375+376376+**Result:** Everyone can push except users from `*.known-spam.com`.
377377+378378+### 5. Mixed Access (Explicit + Patterns)
379379+380380+**Goal:** Team pattern plus individual guests.
381381+382382+**Setup:**
383383+```bash
384384+# Team pattern
385385+atproto put-record \
386386+ --collection io.atcr.hold.crew \
387387+ --rkey "team-pattern" \
388388+ --value '{
389389+ "$type": "io.atcr.hold.crew",
390390+ "hold": "at://did:plc:owner/io.atcr.hold/team",
391391+ "memberPattern": "*.company.com",
392392+ "role": "write"
393393+ }'
394394+395395+# Individual contractor
396396+atproto put-record \
397397+ --collection io.atcr.hold.crew \
398398+ --rkey "contractor-alice" \
399399+ --value '{
400400+ "$type": "io.atcr.hold.crew",
401401+ "hold": "at://did:plc:owner/io.atcr.hold/team",
402402+ "member": "did:plc:alice-contractor",
403403+ "role": "write"
404404+ }'
405405+```
406406+407407+**Result:** Team members + specific contractor all have access.
408408+409409+## Implementation Details
410410+411411+### Code Changes Required
412412+413413+**Files to modify:**
414414+415415+1. **`lexicons/io/atcr/hold/crew.json`**
416416+ - Make `member` optional (remove from `required`)
417417+ - Add `memberPattern` field (string, optional)
418418+ - Update description
419419+420420+2. **`lexicons/io/atcr/hold/crew/barred.json`** (new file)
421421+ - Define new lexicon for barred records
422422+ - Same structure as crew (member + memberPattern)
423423+ - Add `reason` field
424424+425425+3. **`pkg/atproto/lexicon.go`**
426426+ - Update `HoldCrewRecord` struct (add `MemberPattern` field, make `Member` pointer for optional)
427427+ - Add `BarredRecord` struct
428428+ - Add `NewBarredRecord()` constructor
429429+ - Add `BarredCollection` constant
430430+431431+4. **`pkg/hold/authorization.go`**
432432+ - Update `isCrewMember()` to check patterns
433433+ - Add `isBarred()` function
434434+ - Add `resolveHandle()` helper (DID → handle lookup)
435435+ - Add `matchPattern()` helper (glob matching)
436436+ - Update `isAuthorizedWrite()` to check barred first
437437+438438+5. **`pkg/hold/registration.go`**
439439+ - Add `HOLD_ALLOW_ALL_CREW` env var handling
440440+ - Check env var on every startup (not just first registration)
441441+ - Reconcile desired state (env) vs actual state (PDS)
442442+ - Create/delete wildcard crew record as needed
443443+444444+### Pattern Matching Implementation
445445+446446+```go
447447+// pkg/hold/patterns.go (new file)
448448+449449+package hold
450450+451451+import (
452452+ "regexp"
453453+ "strings"
454454+)
455455+456456+// matchPattern checks if a handle matches a pattern
457457+func matchPattern(pattern, handle string) bool {
458458+ if pattern == "*" {
459459+ return true
460460+ }
461461+462462+ // Convert glob to regex
463463+ regex := globToRegex(pattern)
464464+ matched, err := regexp.MatchString(regex, handle)
465465+ if err != nil {
466466+ return false
467467+ }
468468+ return matched
469469+}
470470+471471+// globToRegex converts a glob pattern to a regex
472472+// *.example.com → ^.*\.example\.com$
473473+// subdomain.* → ^subdomain\..*$
474474+// *.bsky.* → ^.*\.bsky\..*$
475475+func globToRegex(pattern string) string {
476476+ // Escape special regex characters except *
477477+ escaped := regexp.QuoteMeta(pattern)
478478+479479+ // Replace escaped \* with .*
480480+ regex := strings.ReplaceAll(escaped, "\\*", ".*")
481481+482482+ // Anchor to start and end
483483+ return "^" + regex + "$"
484484+}
485485+```
486486+487487+### Handle Resolution
488488+489489+```go
490490+// pkg/hold/resolve.go
491491+492492+package hold
493493+494494+import (
495495+ "context"
496496+ "github.com/bluesky-social/indigo/atproto/identity"
497497+ "github.com/bluesky-social/indigo/atproto/syntax"
498498+)
499499+500500+// resolveHandle resolves a DID to its current handle
501501+func resolveHandle(did string) (string, error) {
502502+ ctx := context.Background()
503503+ directory := identity.DefaultDirectory()
504504+505505+ didParsed, err := syntax.ParseDID(did)
506506+ if err != nil {
507507+ return "", err
508508+ }
509509+510510+ ident, err := directory.LookupDID(ctx, didParsed)
511511+ if err != nil {
512512+ return "", err
513513+ }
514514+515515+ return ident.Handle.String(), nil
516516+}
517517+```
518518+519519+### Caching Considerations
520520+521521+**Problem:** Pattern matching requires handle resolution, which adds latency.
522522+523523+**Solution:** Cache handle lookups with TTL.
524524+525525+```go
526526+type handleCache struct {
527527+ mu sync.RWMutex
528528+ cache map[string]cacheEntry // did → handle
529529+}
530530+531531+type cacheEntry struct {
532532+ handle string
533533+ expiresAt time.Time
534534+}
535535+536536+const handleCacheTTL = 10 * time.Minute
537537+538538+func (c *handleCache) get(did string) (string, bool) {
539539+ c.mu.RLock()
540540+ defer c.mu.RUnlock()
541541+542542+ entry, ok := c.cache[did]
543543+ if !ok || time.Now().After(entry.expiresAt) {
544544+ return "", false
545545+ }
546546+ return entry.handle, true
547547+}
548548+549549+func (c *handleCache) set(did, handle string) {
550550+ c.mu.Lock()
551551+ defer c.mu.Unlock()
552552+553553+ c.cache[did] = cacheEntry{
554554+ handle: handle,
555555+ expiresAt: time.Now().Add(handleCacheTTL),
556556+ }
557557+}
558558+```
559559+560560+**Trade-offs:**
561561+- **Cache hit:** Authorization instant
562562+- **Cache miss:** One additional PDS lookup (acceptable for writes)
563563+- **TTL:** 10 minutes balances freshness vs performance
564564+565565+### HOLD_ALLOW_ALL_CREW Environment Variable
566566+567567+**Purpose:** Automatically manage wildcard crew access via environment variable.
568568+569569+**Behavior:** Checked on **every startup** (not just first registration):
570570+571571+1. **Read env var:** `HOLD_ALLOW_ALL_CREW` (true/false)
572572+2. **Query PDS:** Check for crew record with rkey `"allow-all"` and `memberPattern: "*"`
573573+3. **Reconcile state:**
574574+ - If env=`true` and record missing → **Create wildcard crew record** (requires OAuth)
575575+ - If env=`false` (or unset) and record exists → **Delete wildcard crew record** (requires OAuth)
576576+ - Otherwise → No action needed
577577+578578+**Well-known record key:** `"allow-all"` (used exclusively for the managed wildcard record)
579579+580580+**Implementation:**
581581+582582+```go
583583+// pkg/hold/config.go
584584+type Config struct {
585585+ Registration struct {
586586+ OwnerDID string
587587+ AllowAllCrew bool // HOLD_ALLOW_ALL_CREW
588588+ }
589589+ // ...
590590+}
591591+592592+// pkg/hold/registration.go
593593+func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
594594+ desiredState := s.config.Registration.AllowAllCrew
595595+596596+ // Query PDS for "allow-all" crew record
597597+ actualState, err := s.hasAllowAllCrewRecord()
598598+ if err != nil {
599599+ return fmt.Errorf("failed to check allow-all crew record: %w", err)
600600+ }
601601+602602+ // States match - nothing to do
603603+ if desiredState == actualState {
604604+ log.Printf("Allow-all crew state matches desired state: %v", desiredState)
605605+ return nil
606606+ }
607607+608608+ // State mismatch - need to reconcile
609609+ if desiredState && !actualState {
610610+ // Need to create wildcard crew record
611611+ log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
612612+ return s.createAllowAllCrewRecord(callbackHandler)
613613+ }
614614+615615+ if !desiredState && actualState {
616616+ // Need to delete wildcard crew record
617617+ log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW removed/false)")
618618+ return s.deleteAllowAllCrewRecord(callbackHandler)
619619+ }
620620+621621+ return nil
622622+}
623623+624624+func (s *HoldService) hasAllowAllCrewRecord() (bool, error) {
625625+ ownerDID := s.config.Registration.OwnerDID
626626+ if ownerDID == "" {
627627+ return false, fmt.Errorf("hold owner DID not configured")
628628+ }
629629+630630+ ctx := context.Background()
631631+632632+ // Resolve owner's PDS
633633+ pdsEndpoint, err := s.resolveOwnerPDS(ownerDID)
634634+ if err != nil {
635635+ return false, err
636636+ }
637637+638638+ // Query for specific rkey
639639+ client := atproto.NewClient(pdsEndpoint, ownerDID, "")
640640+ record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all")
641641+642642+ if err != nil {
643643+ // Record doesn't exist
644644+ return false, nil
645645+ }
646646+647647+ // Verify it's the wildcard record (memberPattern: "*")
648648+ var crewRecord atproto.HoldCrewRecord
649649+ if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
650650+ return false, err
651651+ }
652652+653653+ // Check if it's the exact wildcard pattern
654654+ return crewRecord.MemberPattern == "*", nil
655655+}
656656+657657+func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
658658+ // This requires OAuth - reuse registration OAuth flow
659659+ // Need authenticated client to create record
660660+661661+ ownerDID := s.config.Registration.OwnerDID
662662+ pdsEndpoint, err := s.resolveOwnerPDS(ownerDID)
663663+ if err != nil {
664664+ return err
665665+ }
666666+667667+ // Get handle for OAuth
668668+ handle, err := resolveHandleFromDID(ownerDID)
669669+ if err != nil {
670670+ return err
671671+ }
672672+673673+ // Run OAuth flow (similar to registration)
674674+ ctx := context.Background()
675675+ result, err := oauth.InteractiveFlowWithCallback(
676676+ ctx,
677677+ s.config.Server.PublicURL,
678678+ handle,
679679+ s.getCrewManagementScopes(),
680680+ func(handler http.HandlerFunc) error {
681681+ *callbackHandler = handler
682682+ return nil
683683+ },
684684+ func(authURL string) error {
685685+ log.Printf("\n%s", strings.Repeat("=", 80))
686686+ log.Printf("OAUTH REQUIRED: Creating allow-all crew record")
687687+ log.Printf("%s", strings.Repeat("=", 80))
688688+ log.Printf("\nVisit: %s\n", authURL)
689689+ log.Printf("Waiting for authorization...")
690690+ log.Printf("%s\n", strings.Repeat("=", 80))
691691+ return nil
692692+ },
693693+ )
694694+ if err != nil {
695695+ return err
696696+ }
697697+698698+ // Create authenticated client
699699+ apiClient := result.Session.APIClient()
700700+ client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient)
701701+702702+ // Get hold URI (need to know which hold to grant access to)
703703+ holdURI, err := s.getHoldURI()
704704+ if err != nil {
705705+ return err
706706+ }
707707+708708+ // Create wildcard crew record
709709+ crewRecord := atproto.HoldCrewRecord{
710710+ Type: atproto.HoldCrewCollection,
711711+ Hold: holdURI,
712712+ MemberPattern: ptr("*"), // Wildcard - allow all
713713+ Role: "write",
714714+ CreatedAt: time.Now(),
715715+ }
716716+717717+ _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", &crewRecord)
718718+ if err != nil {
719719+ return fmt.Errorf("failed to create allow-all crew record: %w", err)
720720+ }
721721+722722+ log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
723723+ return nil
724724+}
725725+726726+func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
727727+ // Similar OAuth flow for deletion
728728+ // Only delete if it's the exact wildcard pattern (safety check)
729729+730730+ isWildcard, err := s.hasAllowAllCrewRecord()
731731+ if err != nil {
732732+ return err
733733+ }
734734+735735+ if !isWildcard {
736736+ log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion")
737737+ return nil
738738+ }
739739+740740+ // OAuth flow (same as create)
741741+ ownerDID := s.config.Registration.OwnerDID
742742+ pdsEndpoint, err := s.resolveOwnerPDS(ownerDID)
743743+ if err != nil {
744744+ return err
745745+ }
746746+747747+ handle, err := resolveHandleFromDID(ownerDID)
748748+ if err != nil {
749749+ return err
750750+ }
751751+752752+ ctx := context.Background()
753753+ result, err := oauth.InteractiveFlowWithCallback(
754754+ ctx,
755755+ s.config.Server.PublicURL,
756756+ handle,
757757+ s.getCrewManagementScopes(),
758758+ func(handler http.HandlerFunc) error {
759759+ *callbackHandler = handler
760760+ return nil
761761+ },
762762+ func(authURL string) error {
763763+ log.Printf("\n%s", strings.Repeat("=", 80))
764764+ log.Printf("OAUTH REQUIRED: Deleting allow-all crew record")
765765+ log.Printf("%s", strings.Repeat("=", 80))
766766+ log.Printf("\nVisit: %s\n", authURL)
767767+ log.Printf("Waiting for authorization...")
768768+ log.Printf("%s\n", strings.Repeat("=", 80))
769769+ return nil
770770+ },
771771+ )
772772+ if err != nil {
773773+ return err
774774+ }
775775+776776+ // Create authenticated client
777777+ apiClient := result.Session.APIClient()
778778+ client := atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient)
779779+780780+ // Delete the record
781781+ err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all")
782782+ if err != nil {
783783+ return fmt.Errorf("failed to delete allow-all crew record: %w", err)
784784+ }
785785+786786+ log.Printf("✓ Deleted allow-all crew record")
787787+ return nil
788788+}
789789+790790+func (s *HoldService) getCrewManagementScopes() []string {
791791+ return []string{
792792+ "atproto",
793793+ fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
794794+ fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
795795+ fmt.Sprintf("repo:%s?action=delete", atproto.HoldCrewCollection),
796796+ }
797797+}
798798+799799+// Helper for pointer
800800+func ptr(s string) *string {
801801+ return &s
802802+}
803803+```
804804+805805+**Startup sequence:**
806806+807807+```go
808808+// cmd/hold/main.go
809809+func main() {
810810+ // ... load config ...
811811+812812+ holdService := hold.NewHoldService(config)
813813+814814+ // Register HTTP routes
815815+ var oauthCallbackHandler http.HandlerFunc
816816+ http.HandleFunc("/auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
817817+ if oauthCallbackHandler != nil {
818818+ oauthCallbackHandler(w, r)
819819+ } else {
820820+ http.Error(w, "OAuth callback not initialized", http.StatusInternalServerError)
821821+ }
822822+ })
823823+824824+ // Auto-register hold (if HOLD_OWNER set)
825825+ if config.Registration.OwnerDID != "" {
826826+ err := holdService.AutoRegister(&oauthCallbackHandler)
827827+ if err != nil {
828828+ log.Fatalf("Failed to register hold: %v", err)
829829+ }
830830+831831+ // Reconcile allow-all crew record
832832+ err = holdService.ReconcileAllowAllCrew(&oauthCallbackHandler)
833833+ if err != nil {
834834+ log.Fatalf("Failed to reconcile allow-all crew: %v", err)
835835+ }
836836+ }
837837+838838+ // Start server...
839839+}
840840+```
841841+842842+**Key properties:**
843843+844844+1. **Idempotent:** Safe to run on every startup
845845+2. **Well-known rkey:** Uses `"allow-all"` exclusively for managed record
846846+3. **Safety:** Only deletes if `memberPattern` is exactly `"*"` (won't touch custom patterns like `*.example.com`)
847847+4. **OAuth required:** Both create and delete operations need authentication
848848+5. **Reuses infrastructure:** Same OAuth flow as registration
849849+850850+**Example configurations:**
851851+852852+```bash
853853+# Public hold - allow all users
854854+HOLD_ALLOW_ALL_CREW=true
855855+856856+# Private hold - explicit crew only
857857+HOLD_ALLOW_ALL_CREW=false
858858+# (or omit the variable entirely)
859859+```
860860+861861+**Edge cases handled:**
862862+863863+- Record exists with different pattern → Won't delete (safety)
864864+- OAuth fails → Service won't start (explicit failure)
865865+- PDS unreachable → Startup fails (can't verify state)
866866+- Record exists but env unset → Deletes wildcard (opt-in behavior)
867867+868868+**Custom patterns preserved:**
869869+870870+Hold owners can still manually create pattern-based crew records with different rkeys:
871871+872872+```bash
873873+# Manually created pattern (rkey: "community")
874874+atproto put-record \
875875+ --collection io.atcr.hold.crew \
876876+ --rkey "community" \
877877+ --value '{
878878+ "memberPattern": "*.my-community.social",
879879+ "role": "write"
880880+ }'
881881+```
882882+883883+The `HOLD_ALLOW_ALL_CREW` management **only touches** the `"allow-all"` rkey with exact `memberPattern: "*"`.
884884+885885+## Migration Path
886886+887887+**Backward Compatibility:** Fully compatible with existing deployments.
888888+889889+1. **Existing crew records work unchanged**
890890+ - Records with `member` (DID) continue to work
891891+ - No changes needed to existing records
892892+893893+2. **Opt-in patterns**
894894+ - Hold owners can add pattern-based crew records
895895+ - Mix explicit DIDs and patterns freely
896896+897897+3. **Barred list is optional**
898898+ - Only needed for selective access revocation
899899+ - Empty barred list = no blocking
900900+901901+4. **Lexicon evolution**
902902+ - Making `member` optional is backward compatible (existing records still have it)
903903+ - Adding `memberPattern` is additive (old clients ignore it)
904904+905905+## Future Enhancements
906906+907907+### 1. PDS-Based Access Control
908908+909909+**Goal:** Allow/bar users based on their PDS (not handle).
910910+911911+**Challenge:** ATProto doesn't give PDSes stable identifiers. PDS endpoints are mutable URLs.
912912+913913+**Potential Solutions:**
914914+915915+#### Option A: PDS DID Standard (if ATProto adds it)
916916+917917+If ATProto introduces PDS DIDs:
918918+919919+```json
920920+{
921921+ "$type": "io.atcr.hold.crew",
922922+ "hold": "at://did:plc:owner/io.atcr.hold/community",
923923+ "memberPattern": "pds:did:plc:pds-id",
924924+ "role": "write"
925925+}
926926+```
927927+928928+#### Option B: Accept PDS URL Mutability
929929+930930+Store PDS URLs with understanding they can change:
931931+932932+```json
933933+{
934934+ "$type": "io.atcr.hold.crew",
935935+ "hold": "at://did:plc:owner/io.atcr.hold/community",
936936+ "memberPattern": "pds:https://my-community.social",
937937+ "role": "write"
938938+}
939939+```
940940+941941+**Trade-off:** User migration bypasses access control, but this requires effort.
942942+943943+#### Option C: PDS Trust Lists (Federated Model)
944944+945945+Reference curated lists of trusted PDSes:
946946+947947+```json
948948+{
949949+ "$type": "io.atcr.hold.crew",
950950+ "hold": "at://did:plc:owner/io.atcr.hold/community",
951951+ "memberPattern": "trust-list:at://did:plc:curator/trust.list/vetted-pds",
952952+ "role": "write"
953953+}
954954+```
955955+956956+**Status:** Experimental. Requires additional standards.
957957+958958+### 2. Advanced Pattern Matching
959959+960960+**Goal:** Support more sophisticated patterns.
961961+962962+**Potential patterns:**
963963+964964+- **Regex:** `memberPattern: "regex:^eng-.*@company.com$"`
965965+- **Multiple patterns:** `memberPattern: ["*.example.com", "*.other.com"]`
966966+- **NOT patterns:** `memberPattern: "!*.spam.com"` (everything except)
967967+968968+**Implementation:** Extend `matchPattern()` function with pattern type detection.
969969+970970+### 3. Temporary Access
971971+972972+**Goal:** Time-limited crew membership.
973973+974974+**Current support:** `expiresAt` field already in schema (optional).
975975+976976+**Enhancement:** Hold service automatically checks expiration during authorization:
977977+978978+```go
979979+if record.ExpiresAt != nil && time.Now().After(*record.ExpiresAt) {
980980+ continue // Skip expired crew record
981981+}
982982+```
983983+984984+### 4. Role-Based Access Control (RBAC)
985985+986986+**Goal:** Fine-grained permissions beyond read/write.
987987+988988+**Potential roles:**
989989+- `"read"` - Pull only
990990+- `"write"` - Push + pull
991991+- `"admin"` - Manage crew records
992992+- `"owner"` - Full control
993993+994994+**Current status:** `role` field exists but only `"owner"` and `"write"` are used.
995995+996996+### 5. Audit Logging
997997+998998+**Goal:** Track access grants/denials for compliance.
999999+10001000+**Implementation:**
10011001+- Log crew checks to structured log
10021002+- Include: DID, handle, result (allow/deny), reason
10031003+- Optional: Write to ATProto audit log record
10041004+10051005+## Security Considerations
10061006+10071007+### 1. Public Records
10081008+10091009+**Consideration:** Crew and barred records are public ATProto records.
10101010+10111011+**Implications:**
10121012+- Anyone can see who has access to a hold
10131013+- Anyone can see who is barred (and why)
10141014+- Similar to Bluesky block lists being public
10151015+10161016+**Mitigation:** This is intentional transparency. Hold owners should use generic reasons in barred records if privacy is a concern.
10171017+10181018+### 2. Handle Changes
10191019+10201020+**Consideration:** Handles can change, but DIDs are permanent.
10211021+10221022+**Implications:**
10231023+- Pattern matching based on handles can be bypassed by changing handle
10241024+- DID-based rules are more stable
10251025+- However, changing handles or acquiring new domains requires significant effort:
10261026+ - Purchasing new domain names ($10-100+/year)
10271027+ - Updating identity across platforms
10281028+ - Loss of established reputation/identity
10291029+10301030+**Recommendation:**
10311031+- Use DID-based crew/barred records for critical access control (permanent)
10321032+- Use pattern-based rules for convenience and community management
10331033+- The effort required to bypass handle patterns makes them an acceptable deterrent
10341034+- Combine both approaches for defense in depth
10351035+10361036+### 3. PDS Migration
10371037+10381038+**Consideration:** Users can migrate to different PDSes.
10391039+10401040+**Implications:**
10411041+- PDS-based patterns (future) can be bypassed by migration
10421042+- Handle patterns persist across PDS migration (if handle stays same)
10431043+10441044+**Recommendation:** Accept this as inherent trade-off. Migration requires user effort and is acceptable "escape hatch."
10451045+10461046+### 4. Pattern Matching Performance
10471047+10481048+**Consideration:** Complex patterns could cause ReDoS (regex denial of service).
10491049+10501050+**Mitigation:**
10511051+- Limit pattern complexity (only basic globs in v1)
10521052+- Cache handle lookups to minimize repeated work
10531053+- Set timeout on pattern matching operations
10541054+10551055+### 5. Barred List Circumvention
10561056+10571057+**Consideration:** Barred users might create new DIDs.
10581058+10591059+**Mitigation:**
10601060+- This is fundamental to decentralized identity (users control DIDs)
10611061+- Hold owners can add new DIDs to barred list as discovered
10621062+- Pattern-based barring (handle/PDS patterns) provides broader coverage
10631063+10641064+## Testing Strategy
10651065+10661066+### Unit Tests
10671067+10681068+**Pattern matching:**
10691069+```go
10701070+func TestMatchPattern(t *testing.T) {
10711071+ tests := []struct{
10721072+ pattern string
10731073+ handle string
10741074+ want bool
10751075+ }{
10761076+ {"*", "anything.com", true},
10771077+ {"*.example.com", "alice.example.com", true},
10781078+ {"*.example.com", "bob.other.com", false},
10791079+ {"eng.*", "eng.company.com", true},
10801080+ {"eng.*", "sales.company.com", false},
10811081+ }
10821082+ // ...
10831083+}
10841084+```
10851085+10861086+**Authorization logic:**
10871087+```go
10881088+func TestIsAuthorizedWrite(t *testing.T) {
10891089+ // Test: owner always allowed
10901090+ // Test: explicit crew member allowed
10911091+ // Test: pattern match allowed
10921092+ // Test: barred user denied
10931093+ // Test: barred pattern denied
10941094+ // Test: barred overrides crew
10951095+}
10961096+```
10971097+10981098+### Integration Tests
10991099+11001100+1. **Create hold with wildcard crew** → verify any user can write
11011101+2. **Add barred record** → verify barred user rejected
11021102+3. **Pattern-based crew** → verify matching handles allowed
11031103+4. **Mixed access** → verify explicit + pattern both work
11041104+5. **Handle resolution failure** → verify fallback to DID-only matching
11051105+11061106+### Performance Tests
11071107+11081108+1. **Large crew list** (1000+ records) → measure query time
11091109+2. **Complex patterns** → measure pattern matching time
11101110+3. **Handle cache** → verify cache hit rate
11111111+4. **Concurrent requests** → verify no race conditions
11121112+11131113+## References
11141114+11151115+- [ATProto Lexicon Spec](https://atproto.com/specs/lexicon)
11161116+- [Bluesky Block Lists](https://bsky.app/profile/bsky.app/post/3l7wzyc6i622o) (analogous public records)
11171117+- [Go Glob Matching](https://pkg.go.dev/path/filepath#Match)
11181118+- [OAuth Scopes](https://atproto.com/specs/oauth#scopes) (for crew management permissions)
11191119+11201120+## Appendix: Lexicon Definitions
11211121+11221122+### lexicons/io/atcr/hold/crew.json (Updated)
11231123+11241124+```json
11251125+{
11261126+ "lexicon": 1,
11271127+ "id": "io.atcr.hold.crew",
11281128+ "defs": {
11291129+ "main": {
11301130+ "type": "record",
11311131+ "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns.",
11321132+ "key": "any",
11331133+ "record": {
11341134+ "type": "object",
11351135+ "required": ["hold", "role", "createdAt"],
11361136+ "properties": {
11371137+ "hold": {
11381138+ "type": "string",
11391139+ "format": "at-uri",
11401140+ "description": "AT-URI of the hold record (e.g., 'at://did:plc:owner/io.atcr.hold/hold1')"
11411141+ },
11421142+ "member": {
11431143+ "type": "string",
11441144+ "format": "did",
11451145+ "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set."
11461146+ },
11471147+ "memberPattern": {
11481148+ "type": "string",
11491149+ "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set."
11501150+ },
11511151+ "role": {
11521152+ "type": "string",
11531153+ "description": "Member's role/permissions. 'owner' = hold owner, 'write' = can push blobs.",
11541154+ "knownValues": ["owner", "write"]
11551155+ },
11561156+ "expiresAt": {
11571157+ "type": "string",
11581158+ "format": "datetime",
11591159+ "description": "Optional expiration for this membership"
11601160+ },
11611161+ "createdAt": {
11621162+ "type": "string",
11631163+ "format": "datetime",
11641164+ "description": "Membership creation timestamp"
11651165+ }
11661166+ }
11671167+ }
11681168+ }
11691169+ }
11701170+}
11711171+```
11721172+11731173+### lexicons/io/atcr/hold/crew/barred.json (New)
11741174+11751175+```json
11761176+{
11771177+ "lexicon": 1,
11781178+ "id": "io.atcr.hold.crew.barred",
11791179+ "defs": {
11801180+ "main": {
11811181+ "type": "record",
11821182+ "description": "Barred (banned) list for a storage hold. Users/patterns in this list are denied write access, overriding crew membership. Stored in the hold owner's PDS.",
11831183+ "key": "any",
11841184+ "record": {
11851185+ "type": "object",
11861186+ "required": ["hold", "barredAt"],
11871187+ "properties": {
11881188+ "hold": {
11891189+ "type": "string",
11901190+ "format": "at-uri",
11911191+ "description": "AT-URI of the hold record"
11921192+ },
11931193+ "member": {
11941194+ "type": "string",
11951195+ "format": "did",
11961196+ "description": "DID of user to bar. Exactly one of 'member' or 'memberPattern' must be set."
11971197+ },
11981198+ "memberPattern": {
11991199+ "type": "string",
12001200+ "description": "Pattern for barring multiple users. Supports wildcards: '*.spam.com', 'bot*', etc. Exactly one of 'member' or 'memberPattern' must be set."
12011201+ },
12021202+ "reason": {
12031203+ "type": "string",
12041204+ "maxLength": 300,
12051205+ "description": "Optional human-readable reason for barring (e.g., 'spam', 'abuse', 'policy violation')"
12061206+ },
12071207+ "barredAt": {
12081208+ "type": "string",
12091209+ "format": "datetime",
12101210+ "description": "When the user/pattern was barred"
12111211+ }
12121212+ }
12131213+ }
12141214+ }
12151215+ }
12161216+}
12171217+```
12181218+12191219+## Summary
12201220+12211221+This design enables scalable, flexible access control for ATCR holds while:
12221222+12231223+- **Preserving ATProto semantics** (DID backlinks, public records)
12241224+- **Supporting massive scale** (one record for thousands of users)
12251225+- **Enabling selective revocation** (barred list)
12261226+- **Maintaining backward compatibility** (existing records work unchanged)
12271227+- **Planning for future enhancements** (PDS-based filtering when possible)
12281228+12291229+---
12301230+12311231+**Note on terminology:** "Barred" is an ironic reversal of the idiom "no holds barred" (meaning "without restrictions"). In wrestling, when all holds are allowed, it's unrestricted. In ATCR, being "barred from a hold" means you're restricted from access. The pun works in reverse! 🥁
+7-3
lexicons/io/atcr/hold/crew.json
···44 "defs": {
55 "main": {
66 "type": "record",
77- "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
77+ "description": "Crew membership for a storage hold. Stored in the hold owner's PDS to maintain control over write access. Supports explicit DIDs (with backlinks), wildcard access, and handle patterns. Crew members can push blobs to the hold. Read access is controlled by the hold's public flag, not crew membership.",
88 "key": "any",
99 "record": {
1010 "type": "object",
1111- "required": ["hold", "member", "role", "createdAt"],
1111+ "required": ["hold", "role", "createdAt"],
1212 "properties": {
1313 "hold": {
1414 "type": "string",
···1818 "member": {
1919 "type": "string",
2020 "format": "did",
2121- "description": "DID of the crew member who can use this hold"
2121+ "description": "DID of crew member (for individual access with backlinks). Exactly one of 'member' or 'memberPattern' must be set."
2222+ },
2323+ "memberPattern": {
2424+ "type": "string",
2525+ "description": "Pattern for matching multiple users. Supports wildcards: '*' (all users), '*.domain.com' (handle glob). Exactly one of 'member' or 'memberPattern' must be set."
2226 },
2327 "role": {
2428 "type": "string",
+25-4
pkg/atproto/lexicon.go
···205205// HoldCrewRecord represents membership in a storage hold
206206// Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control
207207// Owner can add/remove crew members by creating/deleting these records in their own PDS
208208+// Supports both explicit DIDs (with backlinks) and pattern-based matching (wildcards, handle globs)
208209type HoldCrewRecord struct {
209210 // Type should be "io.atcr.hold.crew"
210211 Type string `json:"$type"`
···213214 // e.g., "at://did:plc:owner/io.atcr.hold/hold1"
214215 Hold string `json:"hold"`
215216216216- // Member is the DID of the crew member
217217- Member string `json:"member"`
217217+ // Member is the DID of the crew member (optional, for explicit access)
218218+ // Exactly one of Member or MemberPattern must be set
219219+ Member *string `json:"member,omitempty"`
220220+221221+ // MemberPattern is a pattern for matching multiple users (optional, for pattern-based access)
222222+ // Supports wildcards: "*" (all users), "*.domain.com" (handle glob)
223223+ // Exactly one of Member or MemberPattern must be set
224224+ MemberPattern *string `json:"memberPattern,omitempty"`
218225219226 // Role defines permissions: "owner", "write", "read"
220227 Role string `json:"role"`
221228229229+ // ExpiresAt is optional expiration for this membership
230230+ ExpiresAt *time.Time `json:"expiresAt,omitempty"`
231231+222232 // AddedAt timestamp
223233 AddedAt time.Time `json:"createdAt"`
224234}
225235226226-// NewHoldCrewRecord creates a new hold crew record
236236+// NewHoldCrewRecord creates a new hold crew record with explicit DID
227237func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
228238 return &HoldCrewRecord{
229239 Type: HoldCrewCollection,
230240 Hold: hold,
231231- Member: member,
241241+ Member: &member,
232242 Role: role,
233243 AddedAt: time.Now(),
244244+ }
245245+}
246246+247247+// NewHoldCrewRecordWithPattern creates a new hold crew record with pattern matching
248248+func NewHoldCrewRecordWithPattern(hold, pattern, role string) *HoldCrewRecord {
249249+ return &HoldCrewRecord{
250250+ Type: HoldCrewCollection,
251251+ Hold: hold,
252252+ MemberPattern: &pattern,
253253+ Role: role,
254254+ AddedAt: time.Now(),
234255 }
235256}
236257
+37-3
pkg/hold/authorization.go
···55 "encoding/json"
66 "fmt"
77 "log"
88+ "time"
89910 "atcr.io/pkg/atproto"
1011 "github.com/bluesky-social/indigo/atproto/identity"
···7980}
80818182// isCrewMember checks if a DID is a crew member of this hold
8383+// Supports both explicit DID matching and pattern-based matching (wildcards, handle globs)
8284func (s *HoldService) isCrewMember(did string) (bool, error) {
8385 ownerDID := s.config.Registration.OwnerDID
8486 if ownerDID == "" {
···114116 return false, fmt.Errorf("failed to list crew records: %w", err)
115117 }
116118117117- // Check if DID is in crew list
119119+ // Resolve handle once for pattern matching (lazily, only if needed)
120120+ var handle string
121121+ var handleResolved bool
122122+123123+ // Check crew records for both explicit DID and pattern matches
118124 for _, record := range records {
119125 var crewRecord atproto.HoldCrewRecord
120126 if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
121127 continue
122128 }
123129124124- if crewRecord.Member == did {
125125- // Found crew membership
130130+ // Check expiration (if set)
131131+ if crewRecord.ExpiresAt != nil && time.Now().After(*crewRecord.ExpiresAt) {
132132+ continue // Skip expired membership
133133+ }
134134+135135+ // Check explicit DID match
136136+ if crewRecord.Member != nil && *crewRecord.Member == did {
137137+ // Found explicit crew membership
126138 return true, nil
139139+ }
140140+141141+ // Check pattern match (if pattern is set)
142142+ if crewRecord.MemberPattern != nil && *crewRecord.MemberPattern != "" {
143143+ // Lazy handle resolution - only resolve if we encounter a pattern
144144+ if !handleResolved {
145145+ handle, err = resolveHandle(did)
146146+ if err != nil {
147147+ log.Printf("Warning: failed to resolve handle for DID %s: %v", did, err)
148148+ // Continue checking explicit DIDs even if handle resolution fails
149149+ handleResolved = true // Mark as attempted (don't retry)
150150+ handle = "" // Empty handle won't match patterns
151151+ } else {
152152+ handleResolved = true
153153+ }
154154+ }
155155+156156+ // If we have a handle, check pattern match
157157+ if handle != "" && matchPattern(*crewRecord.MemberPattern, handle) {
158158+ // Found pattern-based crew membership
159159+ return true, nil
160160+ }
127161 }
128162 }
129163
+6
pkg/hold/config.go
···2121 // OwnerDID is the owner's ATProto DID (from env: HOLD_OWNER)
2222 // If set, auto-registration is enabled
2323 OwnerDID string `yaml:"owner_did"`
2424+2525+ // AllowAllCrew controls whether to create a wildcard crew record (from env: HOLD_ALLOW_ALL_CREW)
2626+ // If true, creates/maintains a crew record with memberPattern: "*" (allows all authenticated users)
2727+ // If false, deletes the wildcard crew record if it exists
2828+ AllowAllCrew bool `yaml:"allow_all_crew"`
2429}
25302631// StorageConfig wraps distribution's storage configuration
···72777378 // Registration configuration (optional)
7479 cfg.Registration.OwnerDID = os.Getenv("HOLD_OWNER")
8080+ cfg.Registration.AllowAllCrew = os.Getenv("HOLD_ALLOW_ALL_CREW") == "true"
75817682 // Storage configuration - build from env vars based on storage type
7783 storageType := getEnvOrDefault("STORAGE_DRIVER", "s3")
+40
pkg/hold/patterns.go
···11+package hold
22+33+import (
44+ "regexp"
55+ "strings"
66+)
77+88+// matchPattern checks if a handle matches a pattern
99+// Supports wildcards: "*" (all), "*.domain.com" (suffix), "prefix.*" (prefix), "*.mid.*" (contains)
1010+func matchPattern(pattern, handle string) bool {
1111+ if pattern == "*" {
1212+ // Wildcard matches all
1313+ return true
1414+ }
1515+1616+ // Convert glob to regex and match
1717+ regex := globToRegex(pattern)
1818+ matched, err := regexp.MatchString(regex, handle)
1919+ if err != nil {
2020+ // Log error but fail closed (don't grant access on regex error)
2121+ return false
2222+ }
2323+ return matched
2424+}
2525+2626+// globToRegex converts a glob pattern to a regex pattern
2727+// Examples:
2828+// - "*.example.com" → "^.*\.example\.com$"
2929+// - "subdomain.*" → "^subdomain\..*$"
3030+// - "*.bsky.*" → "^.*\.bsky\..*$"
3131+func globToRegex(pattern string) string {
3232+ // Escape special regex characters (except *)
3333+ escaped := regexp.QuoteMeta(pattern)
3434+3535+ // Replace escaped \* with .*
3636+ regex := strings.ReplaceAll(escaped, "\\*", ".*")
3737+3838+ // Anchor to start and end
3939+ return "^" + regex + "$"
4040+}
+247-60
pkg/hold/registration.go
···115115116116// registerWithOAuth performs OAuth flow and registers the hold
117117func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string, callbackHandler *http.HandlerFunc) error {
118118- // Define the scopes we need for hold registration
119119- holdScopes := []string{
120120- "atproto",
121121- fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
122122- fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
123123- fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
124124- fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
125125- fmt.Sprintf("repo:%s?action=create", atproto.SailorProfileCollection),
126126- fmt.Sprintf("repo:%s?action=update", atproto.SailorProfileCollection),
127127- }
128128-129129- // Determine base URL based on mode
130130- // Callback path standardized to /auth/oauth/callback across ATCR
131131- var baseURL string
132132-133133- if s.config.Server.TestMode {
134134- // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
135135- // Extract port from publicURL (e.g., "http://172.28.0.3:8080" -> ":8080")
136136- parsedURL, err := url.Parse(publicURL)
137137- if err != nil {
138138- return fmt.Errorf("failed to parse public URL: %w", err)
139139- }
140140- port := parsedURL.Port()
141141- if port == "" {
142142- port = "8080" // default
143143- }
144144- baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
145145- } else {
146146- baseURL = publicURL
147147- }
148148-149149- // Run interactive OAuth flow with persistent server
150150- ctx := context.Background()
151151-152152- result, err := oauth.InteractiveFlowWithCallback(
153153- ctx,
154154- baseURL,
155155- handle,
156156- holdScopes, // Pass hold-specific scopes
157157- func(handler http.HandlerFunc) error {
158158- // Populate the pre-registered callback handler
159159- *callbackHandler = handler
160160- return nil
161161- },
162162- func(authURL string) error {
163163- // Display OAuth URL for user to visit
164164- log.Print("\n" + strings.Repeat("=", 80))
165165- log.Printf("OAUTH AUTHORIZATION REQUIRED")
166166- log.Print(strings.Repeat("=", 80))
167167- log.Printf("\nPlease visit this URL to authorize the hold service:\n")
168168- log.Printf(" %s\n", authURL)
169169- log.Printf("Waiting for authorization...")
170170- log.Print(strings.Repeat("=", 80) + "\n")
171171- return nil
172172- },
173173- )
118118+ // Run OAuth flow to get authenticated client
119119+ client, err := s.runOAuthFlow(callbackHandler, "Hold service registration")
174120 if err != nil {
175121 return err
176122 }
···179125 log.Printf("OAuth session obtained successfully")
180126 log.Printf("DID: %s", did)
181127 log.Printf("PDS: %s", pdsEndpoint)
182182-183183- // Create ATProto client with indigo's API client (handles DPoP automatically)
184184- apiClient := result.Session.APIClient()
185185- client := atproto.NewClientWithIndigoClient(pdsEndpoint, did, apiClient)
186128187129 return s.registerWithClient(publicURL, did, client)
188130}
···265207 }
266208 return hostname, nil
267209}
210210+211211+// ReconcileAllowAllCrew reconciles the allow-all crew record state with the environment variable
212212+// Called on every startup to ensure the PDS record matches the desired configuration
213213+func (s *HoldService) ReconcileAllowAllCrew(callbackHandler *http.HandlerFunc) error {
214214+ ownerDID := s.config.Registration.OwnerDID
215215+ if ownerDID == "" {
216216+ // No owner DID configured, skip reconciliation
217217+ return nil
218218+ }
219219+220220+ desiredState := s.config.Registration.AllowAllCrew
221221+222222+ log.Printf("Checking allow-all crew state (desired: %v)", desiredState)
223223+224224+ // Query PDS for current state
225225+ actualState, err := s.hasAllowAllCrewRecord()
226226+ if err != nil {
227227+ return fmt.Errorf("failed to check allow-all crew record: %w", err)
228228+ }
229229+230230+ log.Printf("Allow-all crew record exists: %v", actualState)
231231+232232+ // States match - nothing to do
233233+ if desiredState == actualState {
234234+ if desiredState {
235235+ log.Printf("✓ Allow-all crew enabled (all authenticated users can push)")
236236+ } else {
237237+ log.Printf("✓ Allow-all crew disabled (explicit crew membership required)")
238238+ }
239239+ return nil
240240+ }
241241+242242+ // State mismatch - need to reconcile
243243+ if desiredState && !actualState {
244244+ // Need to create wildcard crew record
245245+ log.Printf("Creating allow-all crew record (HOLD_ALLOW_ALL_CREW=true)")
246246+ return s.createAllowAllCrewRecord(callbackHandler)
247247+ }
248248+249249+ if !desiredState && actualState {
250250+ // Need to delete wildcard crew record
251251+ log.Printf("Deleting allow-all crew record (HOLD_ALLOW_ALL_CREW=false)")
252252+ return s.deleteAllowAllCrewRecord(callbackHandler)
253253+ }
254254+255255+ return nil
256256+}
257257+258258+// hasAllowAllCrewRecord checks if the allow-all crew record exists in the PDS
259259+func (s *HoldService) hasAllowAllCrewRecord() (bool, error) {
260260+ ownerDID := s.config.Registration.OwnerDID
261261+ if ownerDID == "" {
262262+ return false, fmt.Errorf("hold owner DID not configured")
263263+ }
264264+265265+ ctx := context.Background()
266266+267267+ // Resolve owner's PDS endpoint
268268+ directory := identity.DefaultDirectory()
269269+ ownerDIDParsed, err := syntax.ParseDID(ownerDID)
270270+ if err != nil {
271271+ return false, fmt.Errorf("invalid owner DID: %w", err)
272272+ }
273273+274274+ ident, err := directory.LookupDID(ctx, ownerDIDParsed)
275275+ if err != nil {
276276+ return false, fmt.Errorf("failed to resolve owner PDS: %w", err)
277277+ }
278278+279279+ pdsEndpoint := ident.PDSEndpoint()
280280+ if pdsEndpoint == "" {
281281+ return false, fmt.Errorf("no PDS endpoint found for owner")
282282+ }
283283+284284+ // Create unauthenticated client to read public records
285285+ client := atproto.NewClient(pdsEndpoint, ownerDID, "")
286286+287287+ // Query for specific rkey "allow-all"
288288+ record, err := client.GetRecord(ctx, atproto.HoldCrewCollection, "allow-all")
289289+ if err != nil {
290290+ // Record doesn't exist
291291+ if strings.Contains(err.Error(), "not found") {
292292+ return false, nil
293293+ }
294294+ return false, fmt.Errorf("failed to get crew record: %w", err)
295295+ }
296296+297297+ // Verify it's the wildcard record (memberPattern: "*")
298298+ var crewRecord atproto.HoldCrewRecord
299299+ if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
300300+ return false, fmt.Errorf("failed to unmarshal crew record: %w", err)
301301+ }
302302+303303+ // Check if it's the exact wildcard pattern
304304+ return crewRecord.MemberPattern != nil && *crewRecord.MemberPattern == "*", nil
305305+}
306306+307307+// createAllowAllCrewRecord creates a wildcard crew record allowing all authenticated users
308308+func (s *HoldService) createAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
309309+ ownerDID := s.config.Registration.OwnerDID
310310+ publicURL := s.config.Server.PublicURL
311311+312312+ // Run OAuth flow to get authenticated client
313313+ client, err := s.runOAuthFlow(callbackHandler, "Creating allow-all crew record")
314314+ if err != nil {
315315+ return err
316316+ }
317317+318318+ ctx := context.Background()
319319+320320+ // Get hold URI
321321+ holdName, err := extractHostname(publicURL)
322322+ if err != nil {
323323+ return fmt.Errorf("failed to extract hostname: %w", err)
324324+ }
325325+326326+ holdURI := fmt.Sprintf("at://%s/%s/%s", ownerDID, atproto.HoldCollection, holdName)
327327+328328+ // Create wildcard crew record
329329+ crewRecord := atproto.NewHoldCrewRecordWithPattern(holdURI, "*", "write")
330330+331331+ _, err = client.PutRecord(ctx, atproto.HoldCrewCollection, "allow-all", crewRecord)
332332+ if err != nil {
333333+ return fmt.Errorf("failed to create allow-all crew record: %w", err)
334334+ }
335335+336336+ log.Printf("✓ Created allow-all crew record (allows all authenticated users)")
337337+ return nil
338338+}
339339+340340+// deleteAllowAllCrewRecord deletes the wildcard crew record
341341+func (s *HoldService) deleteAllowAllCrewRecord(callbackHandler *http.HandlerFunc) error {
342342+ // Safety check: only delete if it's the exact wildcard pattern
343343+ isWildcard, err := s.hasAllowAllCrewRecord()
344344+ if err != nil {
345345+ return fmt.Errorf("failed to check allow-all crew record: %w", err)
346346+ }
347347+348348+ if !isWildcard {
349349+ log.Printf("Warning: 'allow-all' crew record exists but is not wildcard - skipping deletion")
350350+ return nil
351351+ }
352352+353353+ // Run OAuth flow to get authenticated client
354354+ client, err := s.runOAuthFlow(callbackHandler, "Deleting allow-all crew record")
355355+ if err != nil {
356356+ return err
357357+ }
358358+359359+ ctx := context.Background()
360360+361361+ // Delete the record
362362+ err = client.DeleteRecord(ctx, atproto.HoldCrewCollection, "allow-all")
363363+ if err != nil {
364364+ return fmt.Errorf("failed to delete allow-all crew record: %w", err)
365365+ }
366366+367367+ log.Printf("✓ Deleted allow-all crew record")
368368+ return nil
369369+}
370370+371371+// getHoldRegistrationScopes returns the OAuth scopes needed for hold registration and crew management
372372+func getHoldRegistrationScopes() []string {
373373+ return []string{
374374+ "atproto",
375375+ fmt.Sprintf("repo:%s", atproto.HoldCollection),
376376+ fmt.Sprintf("repo:%s", atproto.HoldCrewCollection),
377377+ fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
378378+ }
379379+}
380380+381381+// runOAuthFlow performs OAuth flow and returns an authenticated client
382382+// Reusable helper to avoid code duplication across registration and reconciliation
383383+func (s *HoldService) runOAuthFlow(callbackHandler *http.HandlerFunc, purpose string) (*atproto.Client, error) {
384384+ ownerDID := s.config.Registration.OwnerDID
385385+ publicURL := s.config.Server.PublicURL
386386+387387+ ctx := context.Background()
388388+389389+ // Resolve owner's PDS endpoint
390390+ directory := identity.DefaultDirectory()
391391+ ownerDIDParsed, err := syntax.ParseDID(ownerDID)
392392+ if err != nil {
393393+ return nil, fmt.Errorf("invalid owner DID: %w", err)
394394+ }
395395+396396+ ident, err := directory.LookupDID(ctx, ownerDIDParsed)
397397+ if err != nil {
398398+ return nil, fmt.Errorf("failed to resolve owner PDS: %w", err)
399399+ }
400400+401401+ pdsEndpoint := ident.PDSEndpoint()
402402+ if pdsEndpoint == "" {
403403+ return nil, fmt.Errorf("no PDS endpoint found for owner")
404404+ }
405405+406406+ handle := ident.Handle.String()
407407+ if handle == "" || handle == "handle.invalid" {
408408+ return nil, fmt.Errorf("no valid handle found for DID")
409409+ }
410410+411411+ // Determine base URL for OAuth
412412+ var baseURL string
413413+ if s.config.Server.TestMode {
414414+ parsedURL, err := url.Parse(publicURL)
415415+ if err != nil {
416416+ return nil, fmt.Errorf("failed to parse public URL: %w", err)
417417+ }
418418+ port := parsedURL.Port()
419419+ if port == "" {
420420+ port = "8080"
421421+ }
422422+ baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
423423+ } else {
424424+ baseURL = publicURL
425425+ }
426426+427427+ // Run OAuth flow
428428+ result, err := oauth.InteractiveFlowWithCallback(
429429+ ctx,
430430+ baseURL,
431431+ handle,
432432+ getHoldRegistrationScopes(),
433433+ func(handler http.HandlerFunc) error {
434434+ *callbackHandler = handler
435435+ return nil
436436+ },
437437+ func(authURL string) error {
438438+ log.Print("\n" + strings.Repeat("=", 80))
439439+ log.Printf("OAUTH REQUIRED: %s", purpose)
440440+ log.Print(strings.Repeat("=", 80))
441441+ log.Printf("\nVisit: %s\n", authURL)
442442+ log.Printf("Waiting for authorization...")
443443+ log.Print(strings.Repeat("=", 80) + "\n")
444444+ return nil
445445+ },
446446+ )
447447+ if err != nil {
448448+ return nil, fmt.Errorf("OAuth flow failed: %w", err)
449449+ }
450450+451451+ // Create authenticated client
452452+ apiClient := result.Session.APIClient()
453453+ return atproto.NewClientWithIndigoClient(pdsEndpoint, ownerDID, apiClient), nil
454454+}
+88
pkg/hold/resolve.go
···11+package hold
22+33+import (
44+ "context"
55+ "fmt"
66+ "sync"
77+ "time"
88+99+ "github.com/bluesky-social/indigo/atproto/identity"
1010+ "github.com/bluesky-social/indigo/atproto/syntax"
1111+)
1212+1313+// handleCache provides caching for DID → handle resolution
1414+// This reduces latency for pattern matching authorization checks
1515+type handleCache struct {
1616+ mu sync.RWMutex
1717+ cache map[string]cacheEntry // did → handle
1818+}
1919+2020+type cacheEntry struct {
2121+ handle string
2222+ expiresAt time.Time
2323+}
2424+2525+const handleCacheTTL = 10 * time.Minute
2626+2727+var (
2828+ // Global handle cache instance
2929+ globalHandleCache = &handleCache{
3030+ cache: make(map[string]cacheEntry),
3131+ }
3232+)
3333+3434+// get retrieves a cached handle for a DID
3535+func (c *handleCache) get(did string) (string, bool) {
3636+ c.mu.RLock()
3737+ defer c.mu.RUnlock()
3838+3939+ entry, ok := c.cache[did]
4040+ if !ok || time.Now().After(entry.expiresAt) {
4141+ return "", false
4242+ }
4343+ return entry.handle, true
4444+}
4545+4646+// set stores a handle in the cache
4747+func (c *handleCache) set(did, handle string) {
4848+ c.mu.Lock()
4949+ defer c.mu.Unlock()
5050+5151+ c.cache[did] = cacheEntry{
5252+ handle: handle,
5353+ expiresAt: time.Now().Add(handleCacheTTL),
5454+ }
5555+}
5656+5757+// resolveHandle resolves a DID to its current handle using ATProto identity resolution
5858+// Results are cached for 10 minutes to reduce latency
5959+func resolveHandle(did string) (string, error) {
6060+ // Check cache first
6161+ if handle, ok := globalHandleCache.get(did); ok {
6262+ return handle, nil
6363+ }
6464+6565+ // Cache miss - resolve from network
6666+ ctx := context.Background()
6767+ directory := identity.DefaultDirectory()
6868+6969+ didParsed, err := syntax.ParseDID(did)
7070+ if err != nil {
7171+ return "", fmt.Errorf("invalid DID: %w", err)
7272+ }
7373+7474+ ident, err := directory.LookupDID(ctx, didParsed)
7575+ if err != nil {
7676+ return "", fmt.Errorf("failed to resolve DID: %w", err)
7777+ }
7878+7979+ handle := ident.Handle.String()
8080+ if handle == "" || handle == "handle.invalid" {
8181+ return "", fmt.Errorf("no valid handle found for DID")
8282+ }
8383+8484+ // Cache the result
8585+ globalHandleCache.set(did, handle)
8686+8787+ return handle, nil
8888+}