+31
.github/workflows/sync-internal.yaml
+31
.github/workflows/sync-internal.yaml
···
1
+
name: Sync to internal repo
2
+
3
+
on:
4
+
push:
5
+
branches: [main]
6
+
7
+
jobs:
8
+
sync:
9
+
runs-on: ubuntu-latest
10
+
if: github.repository == 'bluesky-social/indigo'
11
+
steps:
12
+
- name: Checkout public repo
13
+
uses: actions/checkout@v4
14
+
with:
15
+
fetch-depth: 0
16
+
- name: Generate GitHub App Token
17
+
id: app-token
18
+
uses: actions/create-github-app-token@v1
19
+
with:
20
+
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
21
+
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
22
+
repositories: indigo-internal
23
+
- name: Push to internal repo
24
+
env:
25
+
TOKEN: ${{ steps.app-token.outputs.token }}
26
+
run: |
27
+
git config user.name "github-actions"
28
+
git config user.email "test@users.noreply.github.com"
29
+
git config --unset-all http.https://github.com/.extraheader
30
+
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/indigo-internal.git
31
+
git push internal main --force
+2
atproto/label/label.go
+2
atproto/label/label.go
···
140
140
Cid: l.CID,
141
141
Cts: l.CreatedAt,
142
142
Exp: l.ExpiresAt,
143
+
Neg: l.Negated,
143
144
Sig: []byte(l.Sig),
144
145
Src: l.SourceDID,
145
146
Uri: l.URI,
···
157
158
CID: l.Cid,
158
159
CreatedAt: l.Cts,
159
160
ExpiresAt: l.Exp,
161
+
Negated: l.Neg,
160
162
Sig: []byte(l.Sig),
161
163
SourceDID: l.Src,
162
164
URI: l.Uri,
+62
atproto/label/label_test.go
+62
atproto/label/label_test.go
···
4
4
"encoding/json"
5
5
"testing"
6
6
7
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
7
8
"github.com/bluesky-social/indigo/atproto/crypto"
8
9
9
10
"github.com/stretchr/testify/assert"
···
89
90
assert.NoError(l.Sign(priv))
90
91
assert.NoError(l.VerifySignature(pub))
91
92
}
93
+
94
+
func TestToLexicon(t *testing.T) {
95
+
assert := assert.New(t)
96
+
97
+
expiresAt := "2025-07-28T23:53:19.804Z"
98
+
negated := true
99
+
cid := "bafyreifxykqhed72s26cr4i64rxvrtofeqrly3j4vjzbkvo3ckkjbxjqtq"
100
+
101
+
l := Label{
102
+
CID: &cid,
103
+
CreatedAt: "2024-10-23T17:51:19.128Z",
104
+
ExpiresAt: &expiresAt,
105
+
Negated: &negated,
106
+
SourceDID: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
107
+
URI: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.actor.profile/self",
108
+
Val: "good",
109
+
Version: ATPROTO_LABEL_VERSION,
110
+
Sig: []byte("sig"), // invalid, but we only care about the conversion
111
+
}
112
+
113
+
lex := l.ToLexicon()
114
+
assert.Equal(l.Version, *lex.Ver)
115
+
assert.Equal(l.CreatedAt, lex.Cts)
116
+
assert.Equal(l.URI, lex.Uri)
117
+
assert.Equal(l.Val, lex.Val)
118
+
assert.Equal(l.CID, lex.Cid)
119
+
assert.Equal(l.ExpiresAt, lex.Exp)
120
+
assert.Equal(l.Negated, lex.Neg)
121
+
assert.Equal(l.SourceDID, lex.Src)
122
+
}
123
+
124
+
func TestFromLexicon(t *testing.T) {
125
+
assert := assert.New(t)
126
+
127
+
expiresAt := "2025-07-28T23:53:19.804Z"
128
+
negated := true
129
+
cid := "bafyreifxykqhed72s26cr4i64rxvrtofeqrly3j4vjzbkvo3ckkjbxjqtq"
130
+
version := int64(1)
131
+
132
+
lex := &comatproto.LabelDefs_Label{
133
+
Cid: &cid,
134
+
Cts: "2024-10-23T17:51:19.128Z",
135
+
Exp: &expiresAt,
136
+
Neg: &negated,
137
+
Src: "did:plc:ewvi7nxzyoun6zhxrhs64oiz",
138
+
Uri: "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.actor.profile/self",
139
+
Val: "good",
140
+
Ver: &version,
141
+
Sig: []byte("sig"), // invalid, but we only care about the conversion
142
+
}
143
+
144
+
l := FromLexicon(lex)
145
+
assert.Equal(lex.Ver, &l.Version)
146
+
assert.Equal(lex.Cts, l.CreatedAt)
147
+
assert.Equal(lex.Uri, l.URI)
148
+
assert.Equal(lex.Val, l.Val)
149
+
assert.Equal(lex.Cid, l.CID)
150
+
assert.Equal(lex.Exp, l.ExpiresAt)
151
+
assert.Equal(lex.Neg, l.Negated)
152
+
assert.Equal(lex.Src, l.SourceDID)
153
+
}
+84
-1
cmd/goat/account.go
+84
-1
cmd/goat/account.go
···
8
8
"time"
9
9
10
10
comatproto "github.com/bluesky-social/indigo/api/atproto"
11
+
"github.com/bluesky-social/indigo/atproto/auth"
12
+
"github.com/bluesky-social/indigo/atproto/crypto"
11
13
"github.com/bluesky-social/indigo/atproto/syntax"
12
14
"github.com/bluesky-social/indigo/xrpc"
13
15
···
89
91
},
90
92
&cli.Command{
91
93
Name: "service-auth",
92
-
Usage: "create service auth token",
94
+
Usage: "ask the PDS to create a service auth token",
93
95
Flags: []cli.Flag{
94
96
&cli.StringFlag{
95
97
Name: "endpoint",
···
109
111
},
110
112
},
111
113
Action: runAccountServiceAuth,
114
+
},
115
+
&cli.Command{
116
+
Name: "service-auth-offline",
117
+
Usage: "create service auth token via locally-held signing key",
118
+
Flags: []cli.Flag{
119
+
&cli.StringFlag{
120
+
Name: "atproto-signing-key",
121
+
Required: true,
122
+
Usage: "private key used to sign the token (multibase syntax)",
123
+
EnvVars: []string{"ATPROTO_SIGNING_KEY"},
124
+
},
125
+
&cli.StringFlag{
126
+
Name: "iss",
127
+
Required: true,
128
+
Usage: "the DID of the account issuing the token",
129
+
},
130
+
&cli.StringFlag{
131
+
Name: "endpoint",
132
+
Aliases: []string{"lxm"},
133
+
Usage: "restrict token to API endpoint (NSID, optional)",
134
+
},
135
+
&cli.StringFlag{
136
+
Name: "audience",
137
+
Aliases: []string{"aud"},
138
+
Required: true,
139
+
Usage: "DID of service that will receive and validate token",
140
+
},
141
+
&cli.IntFlag{
142
+
Name: "duration-sec",
143
+
Value: 60,
144
+
Usage: "validity time window of token (seconds)",
145
+
},
146
+
},
147
+
Action: runAccountServiceAuthOffline,
112
148
},
113
149
&cli.Command{
114
150
Name: "create",
···
365
401
}
366
402
367
403
fmt.Println(resp.Token)
404
+
405
+
return nil
406
+
}
407
+
408
+
func runAccountServiceAuthOffline(cctx *cli.Context) error {
409
+
privStr := cctx.String("atproto-signing-key")
410
+
if privStr == "" {
411
+
return fmt.Errorf("private key must be provided")
412
+
}
413
+
privkey, err := crypto.ParsePrivateMultibase(privStr)
414
+
if err != nil {
415
+
return fmt.Errorf("failed parsing private key: %w", err)
416
+
}
417
+
418
+
issString := cctx.String("iss")
419
+
// TODO: support fragment identifiers
420
+
iss, err := syntax.ParseDID(issString)
421
+
if err != nil {
422
+
return fmt.Errorf("iss argument must be a valid DID: %w", err)
423
+
}
424
+
425
+
lxmString := cctx.String("endpoint")
426
+
var lxm *syntax.NSID = nil
427
+
if lxmString != "" {
428
+
lxmTmp, err := syntax.ParseNSID(lxmString)
429
+
if err != nil {
430
+
return fmt.Errorf("lxm argument must be a valid NSID: %w", err)
431
+
}
432
+
lxm = &lxmTmp
433
+
}
434
+
435
+
aud := cctx.String("audience")
436
+
// TODO: can aud DID have a fragment?
437
+
_, err = syntax.ParseDID(aud)
438
+
if err != nil {
439
+
return fmt.Errorf("aud argument must be a valid DID: %w", err)
440
+
}
441
+
442
+
durSec := cctx.Int("duration-sec")
443
+
duration := time.Duration(durSec * int(time.Second))
444
+
445
+
token, err := auth.SignServiceAuth(iss, aud, duration, lxm, privkey)
446
+
if err != nil {
447
+
return fmt.Errorf("failed signing token: %w", err)
448
+
}
449
+
450
+
fmt.Println(token)
368
451
369
452
return nil
370
453
}
+441
cmd/goat/plc.go
+441
cmd/goat/plc.go
···
6
6
"fmt"
7
7
"io"
8
8
"net/http"
9
+
"net/url"
9
10
"strings"
10
11
"time"
11
12
13
+
"github.com/bluesky-social/indigo/atproto/crypto"
12
14
"github.com/bluesky-social/indigo/atproto/identity"
13
15
"github.com/bluesky-social/indigo/atproto/syntax"
14
16
"github.com/bluesky-social/indigo/util"
17
+
18
+
"github.com/did-method-plc/go-didplc"
15
19
16
20
"github.com/urfave/cli/v2"
17
21
)
···
70
74
},
71
75
},
72
76
Action: runPLCDump,
77
+
},
78
+
&cli.Command{
79
+
Name: "genesis",
80
+
Usage: "produce an unsigned genesis operation",
81
+
Flags: []cli.Flag{
82
+
&cli.StringFlag{
83
+
Name: "handle",
84
+
Usage: "atproto handle",
85
+
},
86
+
&cli.StringSliceFlag{
87
+
Name: "rotation-key",
88
+
Usage: "rotation public key, in did:key format",
89
+
},
90
+
&cli.StringFlag{
91
+
Name: "atproto-key",
92
+
Usage: "atproto repo signing public key, in did:key format",
93
+
},
94
+
&cli.StringFlag{
95
+
Name: "pds",
96
+
Usage: "atproto PDS service URL",
97
+
},
98
+
},
99
+
Action: runPLCGenesis,
100
+
},
101
+
&cli.Command{
102
+
Name: "calc-did",
103
+
Usage: "calculate the DID corresponding to a signed PLC operation",
104
+
ArgsUsage: `<signed_genesis.json>`,
105
+
Flags: []cli.Flag{},
106
+
Action: runPLCCalcDID,
107
+
},
108
+
&cli.Command{
109
+
Name: "sign",
110
+
Usage: "sign an operation, ready to be submitted",
111
+
ArgsUsage: `<operation.json>`,
112
+
Flags: []cli.Flag{
113
+
&cli.StringFlag{
114
+
Name: "plc-signing-key",
115
+
Usage: "private key used to sign operation (multibase syntax)",
116
+
EnvVars: []string{"PLC_SIGNING_KEY"},
117
+
},
118
+
},
119
+
Action: runPLCSign,
120
+
},
121
+
&cli.Command{
122
+
Name: "submit",
123
+
Usage: "submit a signed operation to the PLC directory",
124
+
ArgsUsage: `<signed_operation.json>`,
125
+
Flags: []cli.Flag{
126
+
&cli.BoolFlag{
127
+
Name: "genesis",
128
+
Usage: "the operation is a genesis operation",
129
+
},
130
+
&cli.StringFlag{
131
+
Name: "did",
132
+
Usage: "the DID of the identity to update",
133
+
},
134
+
},
135
+
Action: runPLCSubmit,
136
+
},
137
+
&cli.Command{
138
+
Name: "update",
139
+
Usage: "apply updates to a previous operation to produce a new one (but don't sign or submit it, yet)",
140
+
ArgsUsage: `<DID>`,
141
+
Flags: []cli.Flag{
142
+
&cli.StringFlag{
143
+
Name: "prev",
144
+
Usage: "the CID of the operation to use as a base (uses most recent op if not specified)",
145
+
},
146
+
&cli.StringFlag{
147
+
Name: "handle",
148
+
Usage: "atproto handle",
149
+
},
150
+
&cli.StringSliceFlag{
151
+
Name: "add-rotation-key",
152
+
Usage: "rotation public key, in did:key format (added to front of rotationKey list)",
153
+
},
154
+
&cli.StringSliceFlag{
155
+
Name: "remove-rotation-key",
156
+
Usage: "rotation public key, in did:key format",
157
+
},
158
+
&cli.StringFlag{
159
+
Name: "atproto-key",
160
+
Usage: "atproto repo signing public key, in did:key format",
161
+
},
162
+
&cli.StringFlag{
163
+
Name: "pds",
164
+
Usage: "atproto PDS service URL",
165
+
},
166
+
},
167
+
Action: runPLCUpdate,
73
168
},
74
169
},
75
170
}
···
320
415
}
321
416
return &d, nil
322
417
}
418
+
419
+
func runPLCGenesis(cctx *cli.Context) error {
420
+
// TODO: helper function in didplc to make an empty op like this?
421
+
services := make(map[string]didplc.OpService)
422
+
verifMethods := make(map[string]string)
423
+
op := didplc.RegularOp{
424
+
Type: "plc_operation",
425
+
RotationKeys: []string{},
426
+
VerificationMethods: verifMethods,
427
+
AlsoKnownAs: []string{},
428
+
Services: services,
429
+
}
430
+
431
+
for _, rotationKey := range cctx.StringSlice("rotation-key") {
432
+
if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil {
433
+
return err
434
+
}
435
+
op.RotationKeys = append(op.RotationKeys, rotationKey)
436
+
}
437
+
438
+
handle := cctx.String("handle")
439
+
if handle != "" {
440
+
parsedHandle, err := syntax.ParseHandle(strings.TrimPrefix(handle, "at://"))
441
+
if err != nil {
442
+
return err
443
+
}
444
+
parsedHandle = parsedHandle.Normalize()
445
+
op.AlsoKnownAs = append(op.AlsoKnownAs, "at://"+string(parsedHandle))
446
+
}
447
+
448
+
atprotoKey := cctx.String("atproto-key")
449
+
if atprotoKey != "" {
450
+
if _, err := crypto.ParsePublicDIDKey(atprotoKey); err != nil {
451
+
return err
452
+
}
453
+
op.VerificationMethods["atproto"] = atprotoKey
454
+
}
455
+
456
+
pds := cctx.String("pds")
457
+
if pds != "" {
458
+
parsedUrl, err := url.Parse(pds)
459
+
if err != nil {
460
+
return err
461
+
}
462
+
if !parsedUrl.IsAbs() {
463
+
return fmt.Errorf("invalid PDS URL: must be absolute")
464
+
}
465
+
op.Services["atproto_pds"] = didplc.OpService{
466
+
Type: "AtprotoPersonalDataServer",
467
+
Endpoint: pds,
468
+
}
469
+
}
470
+
471
+
res, err := json.MarshalIndent(op, "", " ")
472
+
if err != nil {
473
+
return err
474
+
}
475
+
fmt.Println(string(res))
476
+
477
+
return nil
478
+
}
479
+
480
+
func runPLCCalcDID(cctx *cli.Context) error {
481
+
s := cctx.Args().First()
482
+
if s == "" {
483
+
return fmt.Errorf("need to provide genesis json path as input")
484
+
}
485
+
486
+
inputReader, err := getFileOrStdin(s)
487
+
if err != nil {
488
+
return err
489
+
}
490
+
491
+
inBytes, err := io.ReadAll(inputReader)
492
+
if err != nil {
493
+
return err
494
+
}
495
+
496
+
var enum didplc.OpEnum
497
+
if err := json.Unmarshal(inBytes, &enum); err != nil {
498
+
return err
499
+
}
500
+
op := enum.AsOperation()
501
+
502
+
did, err := op.DID() // errors if op is not a signed genesis op
503
+
if err != nil {
504
+
return err
505
+
}
506
+
507
+
fmt.Println(did)
508
+
509
+
return nil
510
+
}
511
+
512
+
func runPLCSign(cctx *cli.Context) error {
513
+
s := cctx.Args().First()
514
+
if s == "" {
515
+
return fmt.Errorf("need to provide PLC operation json path as input")
516
+
}
517
+
518
+
privStr := cctx.String("plc-signing-key")
519
+
if privStr == "" {
520
+
return fmt.Errorf("private key must be provided")
521
+
}
522
+
523
+
inputReader, err := getFileOrStdin(s)
524
+
if err != nil {
525
+
return err
526
+
}
527
+
528
+
inBytes, err := io.ReadAll(inputReader)
529
+
if err != nil {
530
+
return err
531
+
}
532
+
533
+
var enum didplc.OpEnum
534
+
if err := json.Unmarshal(inBytes, &enum); err != nil {
535
+
return err
536
+
}
537
+
op := enum.AsOperation()
538
+
539
+
// Note: we do not require that the op is currently unsigned.
540
+
// If it's already signed, we'll re-sign it.
541
+
542
+
privkey, err := crypto.ParsePrivateMultibase(privStr)
543
+
if err != nil {
544
+
return err
545
+
}
546
+
547
+
if err := op.Sign(privkey); err != nil {
548
+
return err
549
+
}
550
+
551
+
res, err := json.MarshalIndent(op, "", " ")
552
+
if err != nil {
553
+
return err
554
+
}
555
+
fmt.Println(string(res))
556
+
557
+
return nil
558
+
}
559
+
560
+
func runPLCSubmit(cctx *cli.Context) error {
561
+
ctx := context.Background()
562
+
expectGenesis := cctx.Bool("genesis")
563
+
didString := cctx.String("did")
564
+
565
+
if !expectGenesis && didString == "" {
566
+
return fmt.Errorf("exactly one of either --genesis or --did must be specified")
567
+
}
568
+
569
+
if expectGenesis && didString != "" {
570
+
return fmt.Errorf("exactly one of either --genesis or --did must be specified")
571
+
}
572
+
573
+
s := cctx.Args().First()
574
+
if s == "" {
575
+
return fmt.Errorf("need to provide PLC operation json path as input")
576
+
}
577
+
578
+
inputReader, err := getFileOrStdin(s)
579
+
if err != nil {
580
+
return err
581
+
}
582
+
583
+
inBytes, err := io.ReadAll(inputReader)
584
+
if err != nil {
585
+
return err
586
+
}
587
+
588
+
var enum didplc.OpEnum
589
+
if err := json.Unmarshal(inBytes, &enum); err != nil {
590
+
return err
591
+
}
592
+
op := enum.AsOperation()
593
+
594
+
if op.IsGenesis() != expectGenesis {
595
+
if expectGenesis {
596
+
return fmt.Errorf("expected genesis operation, but a non-genesis operation was provided")
597
+
} else {
598
+
return fmt.Errorf("expected non-genesis operation, but a genesis operation was provided")
599
+
}
600
+
}
601
+
602
+
if op.IsGenesis() {
603
+
didString, err = op.DID()
604
+
if err != nil {
605
+
return err
606
+
}
607
+
}
608
+
609
+
if !op.IsSigned() {
610
+
return fmt.Errorf("operation must be signed")
611
+
}
612
+
613
+
c := didplc.Client{
614
+
DirectoryURL: cctx.String("plc-host"),
615
+
UserAgent: *userAgent(),
616
+
}
617
+
618
+
if err = c.Submit(ctx, didString, op); err != nil {
619
+
return err
620
+
}
621
+
622
+
fmt.Println("success")
623
+
624
+
return nil
625
+
}
626
+
627
+
// fetch logs from /log/audit, select according to base_cid ("" means use latest), and
628
+
// prepare it for updates:
629
+
// - convert from legacy op format if needed (and reject tombstone ops)
630
+
// - strip signature
631
+
// - set `prev` to appropriate value
632
+
func fetchOpForUpdate(ctx context.Context, c didplc.Client, did string, base_cid string) (*didplc.RegularOp, error) {
633
+
auditlog, err := c.AuditLog(ctx, did)
634
+
if err != nil {
635
+
return nil, err
636
+
}
637
+
638
+
if err = didplc.VerifyOpLog(auditlog); err != nil {
639
+
return nil, err
640
+
}
641
+
642
+
var baseLogEntry *didplc.LogEntry
643
+
if base_cid == "" {
644
+
// use most recent entry
645
+
baseLogEntry = &auditlog[len(auditlog)-1]
646
+
} else {
647
+
// scan for the specified entry
648
+
for _, entry := range auditlog {
649
+
if entry.CID == base_cid {
650
+
baseLogEntry = &entry
651
+
break
652
+
}
653
+
}
654
+
if baseLogEntry == nil {
655
+
return nil, fmt.Errorf("no operation found matching CID %s", base_cid)
656
+
}
657
+
}
658
+
var op didplc.RegularOp
659
+
switch baseOp := baseLogEntry.Operation.AsOperation().(type) {
660
+
case *didplc.RegularOp:
661
+
op = *baseOp
662
+
op.Sig = nil
663
+
case *didplc.LegacyOp:
664
+
op = baseOp.RegularOp() // also strips sig
665
+
case *didplc.TombstoneOp:
666
+
return nil, fmt.Errorf("cannot update from a tombstone op")
667
+
}
668
+
op.Prev = &baseLogEntry.CID
669
+
return &op, nil
670
+
}
671
+
672
+
func runPLCUpdate(cctx *cli.Context) error {
673
+
ctx := context.Background()
674
+
prevCID := cctx.String("prev")
675
+
676
+
didString := cctx.Args().First()
677
+
if didString == "" {
678
+
return fmt.Errorf("please specify a DID to update")
679
+
}
680
+
681
+
c := didplc.Client{
682
+
DirectoryURL: cctx.String("plc-host"),
683
+
UserAgent: *userAgent(),
684
+
}
685
+
op, err := fetchOpForUpdate(ctx, c, didString, prevCID)
686
+
if err != nil {
687
+
return err
688
+
}
689
+
690
+
for _, rotationKey := range cctx.StringSlice("remove-rotation-key") {
691
+
if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil {
692
+
return err
693
+
}
694
+
removeSuccess := false
695
+
for idx, existingRotationKey := range op.RotationKeys {
696
+
if existingRotationKey == rotationKey {
697
+
op.RotationKeys = append(op.RotationKeys[:idx], op.RotationKeys[idx+1:]...)
698
+
removeSuccess = true
699
+
}
700
+
}
701
+
if !removeSuccess {
702
+
return fmt.Errorf("failed remove rotation key %s, not found in array", rotationKey)
703
+
}
704
+
}
705
+
706
+
for _, rotationKey := range cctx.StringSlice("add-rotation-key") {
707
+
if _, err := crypto.ParsePublicDIDKey(rotationKey); err != nil {
708
+
return err
709
+
}
710
+
// prepend (Note: if adding multiple rotation keys at once, they'll end up in reverse order)
711
+
op.RotationKeys = append([]string{rotationKey}, op.RotationKeys...)
712
+
}
713
+
714
+
handle := cctx.String("handle")
715
+
if handle != "" {
716
+
parsedHandle, err := syntax.ParseHandle(strings.TrimPrefix(handle, "at://"))
717
+
if err != nil {
718
+
return err
719
+
}
720
+
721
+
// strip any existing at:// akas
722
+
// (someone might have some non-atproto akas, we will leave them untouched,
723
+
// they can manually manage those or use some other tool if needed)
724
+
var akas []string
725
+
for _, aka := range op.AlsoKnownAs {
726
+
if !strings.HasPrefix(aka, "at://") {
727
+
akas = append(akas, aka)
728
+
}
729
+
}
730
+
op.AlsoKnownAs = append(akas, "at://"+string(parsedHandle))
731
+
}
732
+
733
+
atprotoKey := cctx.String("atproto-key")
734
+
if atprotoKey != "" {
735
+
if _, err := crypto.ParsePublicDIDKey(atprotoKey); err != nil {
736
+
return err
737
+
}
738
+
op.VerificationMethods["atproto"] = atprotoKey
739
+
}
740
+
741
+
pds := cctx.String("pds")
742
+
if pds != "" {
743
+
parsedUrl, err := url.Parse(pds)
744
+
if err != nil {
745
+
return err
746
+
}
747
+
if !parsedUrl.IsAbs() {
748
+
return fmt.Errorf("invalid PDS URL: must be absolute")
749
+
}
750
+
op.Services["atproto_pds"] = didplc.OpService{
751
+
Type: "AtprotoPersonalDataServer",
752
+
Endpoint: pds,
753
+
}
754
+
}
755
+
756
+
res, err := json.MarshalIndent(op, "", " ")
757
+
if err != nil {
758
+
return err
759
+
}
760
+
fmt.Println(string(res))
761
+
762
+
return nil
763
+
}
-26
docs/auth.md
-26
docs/auth.md
···
1
-
# Auth
2
-
3
-
The auth system uses two tokens, an access token and a refresh token.
4
-
5
-
The access token is a jwt with the following values:
6
-
```
7
-
scope: "com.atproto.access"
8
-
sub: <the users DID>
9
-
iat: the current time, in unix epoch seconds
10
-
exp: the expiry date, usually around an hour, but at least 15 minutes
11
-
```
12
-
13
-
The refresh token is a jwt with the following values:
14
-
```
15
-
scope: "com.atproto.refresh"
16
-
sub: <the users DID>
17
-
iat: the current time, in unix epoch seconds
18
-
exp: the expiry date, usually around a week, must be significantly longer than the access token
19
-
jti: a unique identifier for this token
20
-
```
21
-
22
-
The access token is what is used for all requests, however since it expires
23
-
quickly, it must be refreshed periodically using the refresh token.
24
-
When the refresh token is used, it must be marked as deleted, and the new token then replaces it.
25
-
Note: The old access token is not necessarily disabled at that point of refreshing.
26
-
-37
docs/feed-proposal.md
-37
docs/feed-proposal.md
···
1
-
# Feed Structuring Proposal
2
-
3
-
Some thoughts on a new format for feeds.
4
-
5
-
## Motivation
6
-
The interface for requesting and getting back feeds is something that I feel is really at the core of what bluesky offers. The user should be able to choose what feeds they subscribe to, feeds should be first class objects, they should be able to be efficiently generated and consumed, and they should be able to trustlessly come from anywhere.
7
-
There are a lot of changes we *could* make to the current structure, but I don't want to stray too far from where we are at right now.
8
-
9
-
10
-
```go
11
-
type Feed struct {
12
-
Items []FeedItem
13
-
Values map[Cid]Record
14
-
ItemInfos map[Uri]ItemInfo
15
-
ActorInfos map[Did]ActorInfo
16
-
}
17
-
18
-
type FeedItem struct {
19
-
Uri string
20
-
Replies []Uri
21
-
ReplyTo Uri
22
-
RepostedBy Did
23
-
}
24
-
25
-
type ItemInfo struct {
26
-
Cid Cid
27
-
Upvotes int
28
-
Reposts int
29
-
Replies int
30
-
Author Did
31
-
}
32
-
```
33
-
34
-
The main idea here is not repeating ourselves, while still providing all the information the client might need.
35
-
With this structure too, the user could easily request *less* data, asking to
36
-
skip the inclusion of records older than X, or saying they are okay with stale
37
-
information in certain places for the sake of efficiency.
+1
-1
go.mod
+1
-1
go.mod
···
25
25
github.com/hashicorp/golang-lru/v2 v2.0.7
26
26
github.com/icrowley/fake v0.0.0-20221112152111-d7b7e2276db2
27
27
github.com/ipfs/go-block-format v0.2.0
28
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d
29
28
github.com/ipfs/go-cid v0.4.1
30
29
github.com/ipfs/go-datastore v0.6.0
31
30
github.com/ipfs/go-ds-flatfs v0.5.1
···
89
88
github.com/cockroachdb/redact v1.1.5 // indirect
90
89
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
91
90
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
91
+
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 // indirect
92
92
github.com/getsentry/sentry-go v0.27.0 // indirect
93
93
github.com/go-redis/redis v6.15.9+incompatible // indirect
94
94
github.com/goccy/go-json v0.10.2 // indirect
+4
-2
go.sum
+4
-2
go.sum
···
81
81
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
82
82
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
83
83
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
84
+
github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797 h1:yYj4PNkUnWSh0Fhsl/pUoxMvBVaVeY6ZebkWMyGzW9k=
85
+
github.com/did-method-plc/go-didplc v0.0.0-20250716162123-d0c3eba68797/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM=
86
+
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038 h1:AGh+Vn9fXhf9eo8erG1CK4+LACduPo64P1OICQLDv88=
87
+
github.com/did-method-plc/go-didplc v0.0.0-20250716171643-635da8b4e038/go.mod h1:ddIXqTTSXWtj5kMsHAPj8SvbIx2GZdAkBFgFa6e6+CM=
84
88
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2 h1:S6Dco8FtAhEI/qkg/00H6RdEGC+MCy5GPiQ+xweNRFE=
85
89
github.com/dustinkirkland/golang-petname v0.0.0-20231002161417-6a283f1aaaf2/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
86
90
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
···
184
188
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
185
189
github.com/ipfs/go-blockservice v0.5.2 h1:in9Bc+QcXwd1apOVM7Un9t8tixPKdaHQFdLSUM1Xgk8=
186
190
github.com/ipfs/go-blockservice v0.5.2/go.mod h1:VpMblFEqG67A/H2sHKAemeH9vlURVavlysbdUI632yk=
187
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d h1:9V+GGXCuOfDiFpdAHz58q9mKLg447xp0cQKvqQrAwYE=
188
-
github.com/ipfs/go-bs-sqlite3 v0.0.0-20221122195556-bfcee1be620d/go.mod h1:pMbnFyNAGjryYCLCe59YDLRv/ujdN+zGJBT1umlvYRM=
189
191
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
190
192
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
191
193
github.com/ipfs/go-datastore v0.5.0/go.mod h1:9zhEApYMTl17C8YDp7JmU7sQZi2/wqiYh73hakZ90Bk=
+1
-1
mst/mst_test.go
+1
-1
mst/mst_test.go
···
532
532
for i := 0; i < 256; i++ {
533
533
f.Add([]byte{byte(i)})
534
534
}
535
-
rx := regexp.MustCompile("^[a-zA-Z0-9_:.-]+$")
535
+
rx := regexp.MustCompile("^[a-zA-Z0-9_:.~-]+$")
536
536
f.Fuzz(func(t *testing.T, in []byte) {
537
537
s := string(in)
538
538
if a, b := rx.MatchString(s), keyHasAllValidChars(s); a != b {
+2
-2
mst/mst_util.go
+2
-2
mst/mst_util.go
···
197
197
}
198
198
199
199
// keyHasAllValidChars reports whether s matches
200
-
// the regexp /^[a-zA-Z0-9_:.-]+$/ without using regexp,
200
+
// the regexp /^[a-zA-Z0-9_:.~-]+$/ without using regexp,
201
201
// which is slower.
202
202
func keyHasAllValidChars(s string) bool {
203
203
if len(s) == 0 {
···
211
211
continue
212
212
}
213
213
switch b {
214
-
case '_', ':', '.', '-':
214
+
case '_', ':', '.', '~', '-':
215
215
continue
216
216
default:
217
217
return false
+135
pkg/robusthttp/client.go
+135
pkg/robusthttp/client.go
···
1
+
package robusthttp
2
+
3
+
import (
4
+
"context"
5
+
"log/slog"
6
+
"net/http"
7
+
"time"
8
+
9
+
"github.com/hashicorp/go-cleanhttp"
10
+
"github.com/hashicorp/go-retryablehttp"
11
+
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
12
+
)
13
+
14
+
type LeveledSlog struct {
15
+
inner *slog.Logger
16
+
}
17
+
18
+
// re-writes HTTP client ERROR to WARN level (because of retries)
19
+
func (l LeveledSlog) Error(msg string, keysAndValues ...any) {
20
+
l.inner.Warn(msg, keysAndValues...)
21
+
}
22
+
23
+
func (l LeveledSlog) Warn(msg string, keysAndValues ...any) {
24
+
l.inner.Warn(msg, keysAndValues...)
25
+
}
26
+
27
+
func (l LeveledSlog) Info(msg string, keysAndValues ...any) {
28
+
l.inner.Info(msg, keysAndValues...)
29
+
}
30
+
31
+
func (l LeveledSlog) Debug(msg string, keysAndValues ...any) {
32
+
l.inner.Debug(msg, keysAndValues...)
33
+
}
34
+
35
+
type Option func(*retryablehttp.Client)
36
+
37
+
// WithMaxRetries sets the maximum number of retries for the HTTP client.
38
+
func WithMaxRetries(maxRetries int) Option {
39
+
return func(client *retryablehttp.Client) {
40
+
client.RetryMax = maxRetries
41
+
}
42
+
}
43
+
44
+
// WithRetryWaitMin sets the minimum wait time between retries.
45
+
func WithRetryWaitMin(waitMin time.Duration) Option {
46
+
return func(client *retryablehttp.Client) {
47
+
client.RetryWaitMin = waitMin
48
+
}
49
+
}
50
+
51
+
// WithRetryWaitMax sets the maximum wait time between retries.
52
+
func WithRetryWaitMax(waitMax time.Duration) Option {
53
+
return func(client *retryablehttp.Client) {
54
+
client.RetryWaitMax = waitMax
55
+
}
56
+
}
57
+
58
+
// WithLogger sets a custom logger for the HTTP client.
59
+
func WithLogger(logger *slog.Logger) Option {
60
+
return func(client *retryablehttp.Client) {
61
+
client.Logger = retryablehttp.LeveledLogger(LeveledSlog{inner: logger})
62
+
}
63
+
}
64
+
65
+
// WithTransport sets a custom transport for the HTTP client.
66
+
func WithTransport(transport http.RoundTripper) Option {
67
+
return func(client *retryablehttp.Client) {
68
+
client.HTTPClient.Transport = transport
69
+
}
70
+
}
71
+
72
+
// WithRetryPolicy sets a custom retry policy for the HTTP client.
73
+
func WithRetryPolicy(policy retryablehttp.CheckRetry) Option {
74
+
return func(client *retryablehttp.Client) {
75
+
client.CheckRetry = policy
76
+
}
77
+
}
78
+
79
+
// Generates an HTTP client with decent general-purpose defaults around
80
+
// timeouts and retries. The returned client has the stdlib http.Client
81
+
// interface, but has Hashicorp retryablehttp logic internally.
82
+
//
83
+
// This client will retry on connection errors, 5xx status (except 501).
84
+
// It will log intermediate failures with WARN level. This does not start from
85
+
// http.DefaultClient.
86
+
//
87
+
// This should be usable for XRPC clients, and other general inter-service
88
+
// client needs. CLI tools might want shorter timeouts and fewer retries by
89
+
// default.
90
+
func NewClient(options ...Option) *http.Client {
91
+
logger := LeveledSlog{inner: slog.Default().With("subsystem", "RobustHTTPClient")}
92
+
retryClient := retryablehttp.NewClient()
93
+
retryClient.HTTPClient.Transport = otelhttp.NewTransport(cleanhttp.DefaultPooledTransport())
94
+
retryClient.RetryMax = 3
95
+
retryClient.RetryWaitMin = 1 * time.Second
96
+
retryClient.RetryWaitMax = 10 * time.Second
97
+
retryClient.Logger = retryablehttp.LeveledLogger(logger)
98
+
retryClient.CheckRetry = DefaultRetryPolicy
99
+
100
+
for _, option := range options {
101
+
option(retryClient)
102
+
}
103
+
104
+
client := retryClient.StandardClient()
105
+
client.Timeout = 30 * time.Second
106
+
return client
107
+
}
108
+
109
+
// For use in local integration tests. Short timeouts, no retries, etc
110
+
func TestingHTTPClient() *http.Client {
111
+
112
+
client := http.DefaultClient
113
+
client.Timeout = 1 * time.Second
114
+
return client
115
+
}
116
+
117
+
// DefaultRetryPolicy is a custom wrapper around retryablehttp.DefaultRetryPolicy.
118
+
// It treats `429 Too Many Requests` as non-retryable, so the application can decide
119
+
// how to deal with rate-limiting.
120
+
func DefaultRetryPolicy(ctx context.Context, resp *http.Response, err error) (bool, error) {
121
+
if err == nil && resp.StatusCode == http.StatusTooManyRequests {
122
+
return false, nil
123
+
}
124
+
return retryablehttp.DefaultRetryPolicy(ctx, resp, err)
125
+
}
126
+
127
+
func NoInternalServerErrorPolicy(ctx context.Context, resp *http.Response, err error) (bool, error) {
128
+
if err == nil && resp.StatusCode == http.StatusTooManyRequests {
129
+
return false, nil
130
+
}
131
+
if err == nil && resp.StatusCode == http.StatusInternalServerError {
132
+
return false, nil
133
+
}
134
+
return retryablehttp.DefaultRetryPolicy(ctx, resp, err)
135
+
}