+198
api/tangled/cbor_gen.go
+198
api/tangled/cbor_gen.go
···
5854
5854
5855
5855
return nil
5856
5856
}
5857
+
func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error {
5858
+
if t == nil {
5859
+
_, err := w.Write(cbg.CborNull)
5860
+
return err
5861
+
}
5862
+
5863
+
cw := cbg.NewCborWriter(w)
5864
+
5865
+
if _, err := cw.Write([]byte{164}); err != nil {
5866
+
return err
5867
+
}
5868
+
5869
+
// t.Repo (string) (string)
5870
+
if len("repo") > 1000000 {
5871
+
return xerrors.Errorf("Value in field \"repo\" was too long")
5872
+
}
5873
+
5874
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil {
5875
+
return err
5876
+
}
5877
+
if _, err := cw.WriteString(string("repo")); err != nil {
5878
+
return err
5879
+
}
5880
+
5881
+
if len(t.Repo) > 1000000 {
5882
+
return xerrors.Errorf("Value in field t.Repo was too long")
5883
+
}
5884
+
5885
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil {
5886
+
return err
5887
+
}
5888
+
if _, err := cw.WriteString(string(t.Repo)); err != nil {
5889
+
return err
5890
+
}
5891
+
5892
+
// t.LexiconTypeID (string) (string)
5893
+
if len("$type") > 1000000 {
5894
+
return xerrors.Errorf("Value in field \"$type\" was too long")
5895
+
}
5896
+
5897
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil {
5898
+
return err
5899
+
}
5900
+
if _, err := cw.WriteString(string("$type")); err != nil {
5901
+
return err
5902
+
}
5903
+
5904
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil {
5905
+
return err
5906
+
}
5907
+
if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil {
5908
+
return err
5909
+
}
5910
+
5911
+
// t.Subject (string) (string)
5912
+
if len("subject") > 1000000 {
5913
+
return xerrors.Errorf("Value in field \"subject\" was too long")
5914
+
}
5915
+
5916
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil {
5917
+
return err
5918
+
}
5919
+
if _, err := cw.WriteString(string("subject")); err != nil {
5920
+
return err
5921
+
}
5922
+
5923
+
if len(t.Subject) > 1000000 {
5924
+
return xerrors.Errorf("Value in field t.Subject was too long")
5925
+
}
5926
+
5927
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil {
5928
+
return err
5929
+
}
5930
+
if _, err := cw.WriteString(string(t.Subject)); err != nil {
5931
+
return err
5932
+
}
5933
+
5934
+
// t.CreatedAt (string) (string)
5935
+
if len("createdAt") > 1000000 {
5936
+
return xerrors.Errorf("Value in field \"createdAt\" was too long")
5937
+
}
5938
+
5939
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil {
5940
+
return err
5941
+
}
5942
+
if _, err := cw.WriteString(string("createdAt")); err != nil {
5943
+
return err
5944
+
}
5945
+
5946
+
if len(t.CreatedAt) > 1000000 {
5947
+
return xerrors.Errorf("Value in field t.CreatedAt was too long")
5948
+
}
5949
+
5950
+
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil {
5951
+
return err
5952
+
}
5953
+
if _, err := cw.WriteString(string(t.CreatedAt)); err != nil {
5954
+
return err
5955
+
}
5956
+
return nil
5957
+
}
5958
+
5959
+
func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) {
5960
+
*t = RepoCollaborator{}
5961
+
5962
+
cr := cbg.NewCborReader(r)
5963
+
5964
+
maj, extra, err := cr.ReadHeader()
5965
+
if err != nil {
5966
+
return err
5967
+
}
5968
+
defer func() {
5969
+
if err == io.EOF {
5970
+
err = io.ErrUnexpectedEOF
5971
+
}
5972
+
}()
5973
+
5974
+
if maj != cbg.MajMap {
5975
+
return fmt.Errorf("cbor input should be of type map")
5976
+
}
5977
+
5978
+
if extra > cbg.MaxLength {
5979
+
return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra)
5980
+
}
5981
+
5982
+
n := extra
5983
+
5984
+
nameBuf := make([]byte, 9)
5985
+
for i := uint64(0); i < n; i++ {
5986
+
nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000)
5987
+
if err != nil {
5988
+
return err
5989
+
}
5990
+
5991
+
if !ok {
5992
+
// Field doesn't exist on this type, so ignore it
5993
+
if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil {
5994
+
return err
5995
+
}
5996
+
continue
5997
+
}
5998
+
5999
+
switch string(nameBuf[:nameLen]) {
6000
+
// t.Repo (string) (string)
6001
+
case "repo":
6002
+
6003
+
{
6004
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6005
+
if err != nil {
6006
+
return err
6007
+
}
6008
+
6009
+
t.Repo = string(sval)
6010
+
}
6011
+
// t.LexiconTypeID (string) (string)
6012
+
case "$type":
6013
+
6014
+
{
6015
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6016
+
if err != nil {
6017
+
return err
6018
+
}
6019
+
6020
+
t.LexiconTypeID = string(sval)
6021
+
}
6022
+
// t.Subject (string) (string)
6023
+
case "subject":
6024
+
6025
+
{
6026
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6027
+
if err != nil {
6028
+
return err
6029
+
}
6030
+
6031
+
t.Subject = string(sval)
6032
+
}
6033
+
// t.CreatedAt (string) (string)
6034
+
case "createdAt":
6035
+
6036
+
{
6037
+
sval, err := cbg.ReadStringWithMax(cr, 1000000)
6038
+
if err != nil {
6039
+
return err
6040
+
}
6041
+
6042
+
t.CreatedAt = string(sval)
6043
+
}
6044
+
6045
+
default:
6046
+
// Field doesn't exist on this type, so ignore it
6047
+
if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil {
6048
+
return err
6049
+
}
6050
+
}
6051
+
}
6052
+
6053
+
return nil
6054
+
}
5857
6055
func (t *RepoIssue) MarshalCBOR(w io.Writer) error {
5858
6056
if t == nil {
5859
6057
_, err := w.Write(cbg.CborNull)
+25
api/tangled/repocollaborator.go
+25
api/tangled/repocollaborator.go
···
1
+
// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2
+
3
+
package tangled
4
+
5
+
// schema: sh.tangled.repo.collaborator
6
+
7
+
import (
8
+
"github.com/bluesky-social/indigo/lex/util"
9
+
)
10
+
11
+
const (
12
+
RepoCollaboratorNSID = "sh.tangled.repo.collaborator"
13
+
)
14
+
15
+
func init() {
16
+
util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{})
17
+
} //
18
+
// RECORDTYPE: RepoCollaborator
19
+
type RepoCollaborator struct {
20
+
LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"`
21
+
CreatedAt string `json:"createdAt" cborgen:"createdAt"`
22
+
// repo: repo to add this user to
23
+
Repo string `json:"repo" cborgen:"repo"`
24
+
Subject string `json:"subject" cborgen:"subject"`
25
+
}
+18
-5
appview/config/config.go
+18
-5
appview/config/config.go
···
10
10
)
11
11
12
12
type CoreConfig struct {
13
-
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
-
DbPath string `env:"DB_PATH, default=appview.db"`
15
-
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
-
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
-
Dev bool `env:"DEV, default=false"`
13
+
CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14
+
DbPath string `env:"DB_PATH, default=appview.db"`
15
+
ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16
+
AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"`
17
+
Dev bool `env:"DEV, default=false"`
18
+
DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
18
19
}
19
20
20
21
type OAuthConfig struct {
···
59
60
DB int `env:"DB, default=0"`
60
61
}
61
62
63
+
type PdsConfig struct {
64
+
Host string `env:"HOST, default=https://tngl.sh"`
65
+
AdminSecret string `env:"ADMIN_SECRET"`
66
+
}
67
+
68
+
type Cloudflare struct {
69
+
ApiToken string `env:"API_TOKEN"`
70
+
ZoneId string `env:"ZONE_ID"`
71
+
}
72
+
62
73
func (cfg RedisConfig) ToURL() string {
63
74
u := &url.URL{
64
75
Scheme: "redis",
···
84
95
Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"`
85
96
OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"`
86
97
Redis RedisConfig `env:",prefix=TANGLED_REDIS_"`
98
+
Pds PdsConfig `env:",prefix=TANGLED_PDS_"`
99
+
Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"`
87
100
}
88
101
89
102
func LoadConfig(ctx context.Context) (*Config, error) {
+76
appview/db/collaborators.go
+76
appview/db/collaborators.go
···
1
+
package db
2
+
3
+
import (
4
+
"fmt"
5
+
"strings"
6
+
"time"
7
+
8
+
"github.com/bluesky-social/indigo/atproto/syntax"
9
+
)
10
+
11
+
type Collaborator struct {
12
+
// identifiers for the record
13
+
Id int64
14
+
Did syntax.DID
15
+
Rkey string
16
+
17
+
// content
18
+
SubjectDid syntax.DID
19
+
RepoAt syntax.ATURI
20
+
21
+
// meta
22
+
Created time.Time
23
+
}
24
+
25
+
func AddCollaborator(e Execer, c Collaborator) error {
26
+
_, err := e.Exec(
27
+
`insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`,
28
+
c.Did, c.Rkey, c.SubjectDid, c.RepoAt,
29
+
)
30
+
return err
31
+
}
32
+
33
+
func DeleteCollaborator(e Execer, filters ...filter) error {
34
+
var conditions []string
35
+
var args []any
36
+
for _, filter := range filters {
37
+
conditions = append(conditions, filter.Condition())
38
+
args = append(args, filter.Arg()...)
39
+
}
40
+
41
+
whereClause := ""
42
+
if conditions != nil {
43
+
whereClause = " where " + strings.Join(conditions, " and ")
44
+
}
45
+
46
+
query := fmt.Sprintf(`delete from collaborators %s`, whereClause)
47
+
48
+
_, err := e.Exec(query, args...)
49
+
return err
50
+
}
51
+
52
+
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
53
+
rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator)
54
+
if err != nil {
55
+
return nil, err
56
+
}
57
+
defer rows.Close()
58
+
59
+
var repoAts []string
60
+
for rows.Next() {
61
+
var aturi string
62
+
err := rows.Scan(&aturi)
63
+
if err != nil {
64
+
return nil, err
65
+
}
66
+
repoAts = append(repoAts, aturi)
67
+
}
68
+
if err := rows.Err(); err != nil {
69
+
return nil, err
70
+
}
71
+
if repoAts == nil {
72
+
return nil, nil
73
+
}
74
+
75
+
return GetRepos(e, 0, FilterIn("at_uri", repoAts))
76
+
}
+62
appview/db/db.go
+62
appview/db/db.go
···
436
436
unique(repo_at, ref, language)
437
437
);
438
438
439
+
create table if not exists signups_inflight (
440
+
id integer primary key autoincrement,
441
+
email text not null unique,
442
+
invite_code text not null,
443
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
444
+
);
445
+
439
446
create table if not exists migrations (
440
447
id integer primary key autoincrement,
441
448
name text unique
···
576
583
alter table repos add column spindle text;
577
584
`)
578
585
return nil
586
+
})
587
+
588
+
// recreate and add rkey + created columns with default constraint
589
+
runMigration(db, "rework-collaborators-table", func(tx *sql.Tx) error {
590
+
// create new table
591
+
// - repo_at instead of repo integer
592
+
// - rkey field
593
+
// - created field
594
+
_, err := tx.Exec(`
595
+
create table collaborators_new (
596
+
-- identifiers for the record
597
+
id integer primary key autoincrement,
598
+
did text not null,
599
+
rkey text,
600
+
601
+
-- content
602
+
subject_did text not null,
603
+
repo_at text not null,
604
+
605
+
-- meta
606
+
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
607
+
608
+
-- constraints
609
+
foreign key (repo_at) references repos(at_uri) on delete cascade
610
+
)
611
+
`)
612
+
if err != nil {
613
+
return err
614
+
}
615
+
616
+
// copy data
617
+
_, err = tx.Exec(`
618
+
insert into collaborators_new (id, did, rkey, subject_did, repo_at)
619
+
select
620
+
c.id,
621
+
r.did,
622
+
'',
623
+
c.did,
624
+
r.at_uri
625
+
from collaborators c
626
+
join repos r on c.repo = r.id
627
+
`)
628
+
if err != nil {
629
+
return err
630
+
}
631
+
632
+
// drop old table
633
+
_, err = tx.Exec(`drop table collaborators`)
634
+
if err != nil {
635
+
return err
636
+
}
637
+
638
+
// rename new table
639
+
_, err = tx.Exec(`alter table collaborators_new rename to collaborators`)
640
+
return err
579
641
})
580
642
581
643
return &DB{db}, nil
+16
-2
appview/db/email.go
+16
-2
appview/db/email.go
···
103
103
query := `
104
104
select email, did
105
105
from emails
106
-
where
107
-
verified = ?
106
+
where
107
+
verified = ?
108
108
and email in (` + strings.Join(placeholders, ",") + `)
109
109
`
110
110
···
153
153
`
154
154
var count int
155
155
err := e.QueryRow(query, did, email).Scan(&count)
156
+
if err != nil {
157
+
return false, err
158
+
}
159
+
return count > 0, nil
160
+
}
161
+
162
+
func CheckEmailExistsAtAll(e Execer, email string) (bool, error) {
163
+
query := `
164
+
select count(*)
165
+
from emails
166
+
where email = ?
167
+
`
168
+
var count int
169
+
err := e.QueryRow(query, email).Scan(&count)
156
170
if err != nil {
157
171
return false, err
158
172
}
-34
appview/db/repos.go
-34
appview/db/repos.go
···
550
550
return &repo, nil
551
551
}
552
552
553
-
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
554
-
_, err := e.Exec(
555
-
`insert into collaborators (did, repo)
556
-
values (?, (select id from repos where did = ? and name = ? and knot = ?));`,
557
-
collaborator, repoOwnerDid, repoName, repoKnot)
558
-
return err
559
-
}
560
-
561
553
func UpdateDescription(e Execer, repoAt, newDescription string) error {
562
554
_, err := e.Exec(
563
555
`update repos set description = ? where at_uri = ?`, newDescription, repoAt)
···
568
560
_, err := e.Exec(
569
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
570
562
return err
571
-
}
572
-
573
-
func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) {
574
-
rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator)
575
-
if err != nil {
576
-
return nil, err
577
-
}
578
-
defer rows.Close()
579
-
580
-
var repoIds []int
581
-
for rows.Next() {
582
-
var id int
583
-
err := rows.Scan(&id)
584
-
if err != nil {
585
-
return nil, err
586
-
}
587
-
repoIds = append(repoIds, id)
588
-
}
589
-
if err := rows.Err(); err != nil {
590
-
return nil, err
591
-
}
592
-
if repoIds == nil {
593
-
return nil, nil
594
-
}
595
-
596
-
return GetRepos(e, 0, FilterIn("id", repoIds))
597
563
}
598
564
599
565
type RepoStats struct {
+29
appview/db/signup.go
+29
appview/db/signup.go
···
1
+
package db
2
+
3
+
import "time"
4
+
5
+
type InflightSignup struct {
6
+
Id int64
7
+
Email string
8
+
InviteCode string
9
+
Created time.Time
10
+
}
11
+
12
+
func AddInflightSignup(e Execer, signup InflightSignup) error {
13
+
query := `insert into signups_inflight (email, invite_code) values (?, ?)`
14
+
_, err := e.Exec(query, signup.Email, signup.InviteCode)
15
+
return err
16
+
}
17
+
18
+
func DeleteInflightSignup(e Execer, email string) error {
19
+
query := `delete from signups_inflight where email = ?`
20
+
_, err := e.Exec(query, email)
21
+
return err
22
+
}
23
+
24
+
func GetEmailForCode(e Execer, inviteCode string) (string, error) {
25
+
query := `select email from signups_inflight where invite_code = ?`
26
+
var email string
27
+
err := e.QueryRow(query, inviteCode).Scan(&email)
28
+
return email, err
29
+
}
+53
appview/dns/cloudflare.go
+53
appview/dns/cloudflare.go
···
1
+
package dns
2
+
3
+
import (
4
+
"context"
5
+
"fmt"
6
+
7
+
"github.com/cloudflare/cloudflare-go"
8
+
"tangled.sh/tangled.sh/core/appview/config"
9
+
)
10
+
11
+
type Record struct {
12
+
Type string
13
+
Name string
14
+
Content string
15
+
TTL int
16
+
Proxied bool
17
+
}
18
+
19
+
type Cloudflare struct {
20
+
api *cloudflare.API
21
+
zone string
22
+
}
23
+
24
+
func NewCloudflare(c *config.Config) (*Cloudflare, error) {
25
+
apiToken := c.Cloudflare.ApiToken
26
+
api, err := cloudflare.NewWithAPIToken(apiToken)
27
+
if err != nil {
28
+
return nil, err
29
+
}
30
+
return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil
31
+
}
32
+
33
+
func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error {
34
+
_, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{
35
+
Type: record.Type,
36
+
Name: record.Name,
37
+
Content: record.Content,
38
+
TTL: record.TTL,
39
+
Proxied: &record.Proxied,
40
+
})
41
+
if err != nil {
42
+
return fmt.Errorf("failed to create DNS record: %w", err)
43
+
}
44
+
return nil
45
+
}
46
+
47
+
func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error {
48
+
err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID)
49
+
if err != nil {
50
+
return fmt.Errorf("failed to delete DNS record: %w", err)
51
+
}
52
+
return nil
53
+
}
+22
appview/pages/pages.go
+22
appview/pages/pages.go
···
262
262
return p.executePlain("user/login", w, params)
263
263
}
264
264
265
+
type SignupParams struct{}
266
+
267
+
func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error {
268
+
return p.executePlain("user/completeSignup", w, params)
269
+
}
270
+
271
+
type TermsOfServiceParams struct {
272
+
LoggedInUser *oauth.User
273
+
}
274
+
275
+
func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
276
+
return p.execute("legal/terms", w, params)
277
+
}
278
+
279
+
type PrivacyPolicyParams struct {
280
+
LoggedInUser *oauth.User
281
+
}
282
+
283
+
func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
284
+
return p.execute("legal/privacy", w, params)
285
+
}
286
+
265
287
type TimelineParams struct {
266
288
LoggedInUser *oauth.User
267
289
Timeline []db.TimelineEvent
-12
appview/pages/templates/layouts/topbar.html
-12
appview/pages/templates/layouts/topbar.html
···
6
6
tangled<sub>alpha</sub>
7
7
</a>
8
8
</div>
9
-
<div class="hidden md:flex gap-4 items-center">
10
-
<a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center">
11
-
{{ i "message-circle" "size-4" }} discord
12
-
</a>
13
9
14
-
<a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center">
15
-
{{ i "hash" "size-4" }} irc
16
-
</a>
17
-
18
-
<a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center">
19
-
{{ i "code" "size-4" }} source
20
-
</a>
21
-
</div>
22
10
<div id="right-items" class="flex items-center gap-4">
23
11
{{ with .LoggedInUser }}
24
12
<a href="/repo/new" hx-boost="true" class="btn-create hover:no-underline hover:text-white">
+133
appview/pages/templates/legal/privacy.html
+133
appview/pages/templates/legal/privacy.html
···
1
+
{{ define "title" }} privacy policy {{ end }}
2
+
{{ define "content" }}
3
+
<div class="max-w-4xl mx-auto px-4 py-8">
4
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
5
+
<div class="prose prose-gray dark:prose-invert max-w-none">
6
+
<h1>Privacy Policy</h1>
7
+
8
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
9
+
10
+
<p>This Privacy Policy describes how Tangled ("we," "us," or "our") collects, uses, and shares your personal information when you use our platform and services (the "Service").</p>
11
+
12
+
<h2>1. Information We Collect</h2>
13
+
14
+
<h3>Account Information</h3>
15
+
<p>When you create an account, we collect:</p>
16
+
<ul>
17
+
<li>Your chosen username</li>
18
+
<li>Email address</li>
19
+
<li>Profile information you choose to provide</li>
20
+
<li>Authentication data</li>
21
+
</ul>
22
+
23
+
<h3>Content and Activity</h3>
24
+
<p>We store:</p>
25
+
<ul>
26
+
<li>Code repositories and associated metadata</li>
27
+
<li>Issues, pull requests, and comments</li>
28
+
<li>Activity logs and usage patterns</li>
29
+
<li>Public keys for authentication</li>
30
+
</ul>
31
+
32
+
<h2>2. Data Location and Hosting</h2>
33
+
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
34
+
<h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
35
+
<p class="text-blue-700 dark:text-blue-300">
36
+
<strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
37
+
</p>
38
+
<ul class="text-blue-700 dark:text-blue-300 mt-2">
39
+
<li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
40
+
<li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
41
+
<li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
42
+
</ul>
43
+
</div>
44
+
45
+
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
46
+
<h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
47
+
<p class="text-yellow-700 dark:text-yellow-300">
48
+
<strong>Important:</strong> If your account is hosted on Bluesky's PDS or other self-hosted Personal Data Servers (not *.tngl.sh), we do not control that data. The data protection, storage location, and privacy practices for such accounts are governed by the respective PDS provider's policies, not this Privacy Policy. We only control data processing within our own services and infrastructure.
49
+
</p>
50
+
</div>
51
+
52
+
<h2>3. Third-Party Data Processors</h2>
53
+
<p>We only share your data with the following third-party processors:</p>
54
+
55
+
<h3>Resend (Email Services)</h3>
56
+
<ul>
57
+
<li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
58
+
<li><strong>Data Shared:</strong> Email address and necessary message content</li>
59
+
<li><strong>Location:</strong> EU-compliant email delivery service</li>
60
+
</ul>
61
+
62
+
<h3>Cloudflare (Image Caching)</h3>
63
+
<ul>
64
+
<li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
65
+
<li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
66
+
<li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
67
+
</ul>
68
+
69
+
<h2>4. How We Use Your Information</h2>
70
+
<p>We use your information to:</p>
71
+
<ul>
72
+
<li>Provide and maintain the Service</li>
73
+
<li>Process your transactions and requests</li>
74
+
<li>Send you technical notices and support messages</li>
75
+
<li>Improve and develop new features</li>
76
+
<li>Ensure security and prevent fraud</li>
77
+
<li>Comply with legal obligations</li>
78
+
</ul>
79
+
80
+
<h2>5. Data Sharing and Disclosure</h2>
81
+
<p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
82
+
<ul>
83
+
<li>With the third-party processors listed above</li>
84
+
<li>When required by law or legal process</li>
85
+
<li>To protect our rights, property, or safety, or that of our users</li>
86
+
<li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
87
+
</ul>
88
+
89
+
<h2>6. Data Security</h2>
90
+
<p>We implement appropriate technical and organizational measures to protect your personal information against unauthorized access, alteration, disclosure, or destruction. However, no method of transmission over the Internet is 100% secure.</p>
91
+
92
+
<h2>7. Data Retention</h2>
93
+
<p>We retain your personal information for as long as necessary to provide the Service and fulfill the purposes outlined in this Privacy Policy, unless a longer retention period is required by law.</p>
94
+
95
+
<h2>8. Your Rights</h2>
96
+
<p>Under applicable data protection laws, you have the right to:</p>
97
+
<ul>
98
+
<li>Access your personal information</li>
99
+
<li>Correct inaccurate information</li>
100
+
<li>Request deletion of your information</li>
101
+
<li>Object to processing of your information</li>
102
+
<li>Data portability</li>
103
+
<li>Withdraw consent (where applicable)</li>
104
+
</ul>
105
+
106
+
<h2>9. Cookies and Tracking</h2>
107
+
<p>We use cookies and similar technologies to:</p>
108
+
<ul>
109
+
<li>Maintain your login session</li>
110
+
<li>Remember your preferences</li>
111
+
<li>Analyze usage patterns to improve the Service</li>
112
+
</ul>
113
+
<p>You can control cookie settings through your browser preferences.</p>
114
+
115
+
<h2>10. Children's Privacy</h2>
116
+
<p>The Service is not intended for children under 16 years of age. We do not knowingly collect personal information from children under 16. If we become aware that we have collected such information, we will take steps to delete it.</p>
117
+
118
+
<h2>11. International Data Transfers</h2>
119
+
<p>While all our primary data processing occurs within the EU, some of our third-party processors may process data outside the EU. When this occurs, we ensure appropriate safeguards are in place, such as Standard Contractual Clauses or adequacy decisions.</p>
120
+
121
+
<h2>12. Changes to This Privacy Policy</h2>
122
+
<p>We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last updated" date.</p>
123
+
124
+
<h2>13. Contact Information</h2>
125
+
<p>If you have any questions about this Privacy Policy or wish to exercise your rights, please contact us through our platform or via email.</p>
126
+
127
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128
+
<p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129
+
</div>
130
+
</div>
131
+
</div>
132
+
</div>
133
+
{{ end }}
+71
appview/pages/templates/legal/terms.html
+71
appview/pages/templates/legal/terms.html
···
1
+
{{ define "title" }}terms of service{{ end }}
2
+
3
+
{{ define "content" }}
4
+
<div class="max-w-4xl mx-auto px-4 py-8">
5
+
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
6
+
<div class="prose prose-gray dark:prose-invert max-w-none">
7
+
<h1>Terms of Service</h1>
8
+
9
+
<p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
10
+
11
+
<p>Welcome to Tangled. These Terms of Service ("Terms") govern your access to and use of the Tangled platform and services (the "Service") operated by us ("Tangled," "we," "us," or "our").</p>
12
+
13
+
<h2>1. Acceptance of Terms</h2>
14
+
<p>By accessing or using our Service, you agree to be bound by these Terms. If you disagree with any part of these terms, then you may not access the Service.</p>
15
+
16
+
<h2>2. Account Registration</h2>
17
+
<p>To use certain features of the Service, you must register for an account. You agree to provide accurate, current, and complete information during the registration process and to update such information to keep it accurate, current, and complete.</p>
18
+
19
+
<h2>3. Account Termination</h2>
20
+
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
21
+
<h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
22
+
<p class="text-red-700 dark:text-red-300">
23
+
<strong>We reserve the right to terminate, suspend, or restrict access to your account at any time, for any reason, or for no reason at all, at our sole discretion.</strong> This includes, but is not limited to, termination for violation of these Terms, inappropriate conduct, spam, abuse, or any other behavior we deem harmful to the Service or other users.
24
+
</p>
25
+
<p class="text-red-700 dark:text-red-300 mt-2">
26
+
Account termination may result in the loss of access to your repositories, data, and other content associated with your account. We are not obligated to provide advance notice of termination, though we may do so in our discretion.
27
+
</p>
28
+
</div>
29
+
30
+
<h2>4. Acceptable Use</h2>
31
+
<p>You agree not to use the Service to:</p>
32
+
<ul>
33
+
<li>Violate any applicable laws or regulations</li>
34
+
<li>Infringe upon the rights of others</li>
35
+
<li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
36
+
<li>Engage in spam, phishing, or other deceptive practices</li>
37
+
<li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
38
+
<li>Interfere with or disrupt the Service or servers connected to the Service</li>
39
+
</ul>
40
+
41
+
<h2>5. Content and Intellectual Property</h2>
42
+
<p>You retain ownership of the content you upload to the Service. By uploading content, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the Service.</p>
43
+
44
+
<h2>6. Privacy</h2>
45
+
<p>Your privacy is important to us. Please review our <a href="/privacy" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300">Privacy Policy</a>, which also governs your use of the Service.</p>
46
+
47
+
<h2>7. Disclaimers</h2>
48
+
<p>The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make no warranties, expressed or implied, and hereby disclaim and negate all other warranties including without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.</p>
49
+
50
+
<h2>8. Limitation of Liability</h2>
51
+
<p>In no event shall Tangled, nor its directors, employees, partners, agents, suppliers, or affiliates, be liable for any indirect, incidental, special, consequential, or punitive damages, including without limitation, loss of profits, data, use, goodwill, or other intangible losses, resulting from your use of the Service.</p>
52
+
53
+
<h2>9. Indemnification</h2>
54
+
<p>You agree to defend, indemnify, and hold harmless Tangled and its affiliates, officers, directors, employees, and agents from and against any and all claims, damages, obligations, losses, liabilities, costs, or debt, and expenses (including attorney's fees).</p>
55
+
56
+
<h2>10. Governing Law</h2>
57
+
<p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
58
+
59
+
<h2>11. Changes to Terms</h2>
60
+
<p>We reserve the right to modify or replace these Terms at any time. If a revision is material, we will try to provide at least 30 days notice prior to any new terms taking effect.</p>
61
+
62
+
<h2>12. Contact Information</h2>
63
+
<p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
64
+
65
+
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
66
+
<p>These terms are effective as of the last updated date shown above and will remain in effect except with respect to any changes in their provisions in the future, which will be in effect immediately after being posted on this page.</p>
67
+
</div>
68
+
</div>
69
+
</div>
70
+
</div>
71
+
{{ end }}
+9
-21
appview/pages/templates/repo/index.html
+9
-21
appview/pages/templates/repo/index.html
···
170
170
{{ define "commitLog" }}
171
171
<div id="commit-log" class="md:col-span-1 px-2 pb-4">
172
172
<div class="flex justify-between items-center">
173
-
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
174
-
<div class="flex gap-2 items-center font-bold">
175
-
{{ i "logs" "w-4 h-4" }} commits
176
-
</div>
177
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
178
-
view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }}
179
-
</span>
173
+
<a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
174
+
{{ i "logs" "w-4 h-4" }} commits
175
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span>
180
176
</a>
181
177
</div>
182
178
<div class="flex flex-col gap-6">
···
278
274
{{ define "branchList" }}
279
275
{{ if gt (len .BranchesTrunc) 0 }}
280
276
<div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
281
-
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
282
-
<div class="flex gap-2 items-center font-bold">
283
-
{{ i "git-branch" "w-4 h-4" }} branches
284
-
</div>
285
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
286
-
view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }}
287
-
</span>
277
+
<a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
278
+
{{ i "git-branch" "w-4 h-4" }} branches
279
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span>
288
280
</a>
289
281
<div class="flex flex-col gap-1">
290
282
{{ range .BranchesTrunc }}
···
321
313
{{ if gt (len .TagsTrunc) 0 }}
322
314
<div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700">
323
315
<div class="flex justify-between items-center">
324
-
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group">
325
-
<div class="flex gap-2 items-center font-bold">
326
-
{{ i "tags" "w-4 h-4" }} tags
327
-
</div>
328
-
<span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 ">
329
-
view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }}
330
-
</span>
316
+
<a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline">
317
+
{{ i "tags" "w-4 h-4" }} tags
318
+
<span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span>
331
319
</a>
332
320
</div>
333
321
<div class="flex flex-col gap-1">
+2
-2
appview/pages/templates/repo/log.html
+2
-2
appview/pages/templates/repo/log.html
···
21
21
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div>
22
22
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div>
23
23
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div>
24
-
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Date</div>
24
+
<div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div>
25
25
</div>
26
26
{{ range $index, $commit := .Commits }}
27
27
{{ $messageParts := splitN $commit.Message "\n\n" 2 }}
···
85
85
{{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }}
86
86
{{ end }}
87
87
</div>
88
-
<div class="align-top text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
88
+
<div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div>
89
89
</div>
90
90
{{ end }}
91
91
</div>
+27
-22
appview/pages/templates/repo/settings/pipelines.html
+27
-22
appview/pages/templates/repo/settings/pipelines.html
···
20
20
<div class="col-span-1 md:col-span-2">
21
21
<h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2>
22
22
<p class="text-gray-500 dark:text-gray-400">
23
-
Choose a spindle to execute your workflows on. Spindles can be
24
-
selfhosted,
23
+
Choose a spindle to execute your workflows on. Only repository owners
24
+
can configure spindles. Spindles can be selfhosted,
25
25
<a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">
26
26
click to learn more.
27
27
</a>
28
28
</p>
29
29
</div>
30
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
31
-
<select
32
-
id="spindle"
33
-
name="spindle"
34
-
required
35
-
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"
36
-
{{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
37
-
<option value="" disabled selected >
38
-
Choose a spindle
39
-
</option>
40
-
{{ range $.Spindles }}
41
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
42
-
{{ . }}
30
+
{{ if not $.RepoInfo.Roles.IsOwner }}
31
+
<div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
32
+
{{ or $.CurrentSpindle "No spindle configured" }}
33
+
</div>
34
+
{{ else }}
35
+
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
+
<select
37
+
id="spindle"
38
+
name="spindle"
39
+
required
40
+
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
+
<option value="" disabled>
42
+
Choose a spindle
43
43
</option>
44
-
{{ end }}
45
-
</select>
46
-
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
47
-
{{ i "check" "size-4" }}
48
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
49
-
</button>
50
-
</form>
44
+
{{ range $.Spindles }}
45
+
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
46
+
{{ . }}
47
+
</option>
48
+
{{ end }}
49
+
</select>
50
+
<button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}>
51
+
{{ i "check" "size-4" }}
52
+
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
53
+
</button>
54
+
</form>
55
+
{{ end }}
51
56
</div>
52
57
{{ end }}
53
58
+104
appview/pages/templates/user/completeSignup.html
+104
appview/pages/templates/user/completeSignup.html
···
1
+
{{ define "user/completeSignup" }}
2
+
<!doctype html>
3
+
<html lang="en" class="dark:bg-gray-900">
4
+
<head>
5
+
<meta charset="UTF-8" />
6
+
<meta
7
+
name="viewport"
8
+
content="width=device-width, initial-scale=1.0"
9
+
/>
10
+
<meta
11
+
property="og:title"
12
+
content="complete signup ยท tangled"
13
+
/>
14
+
<meta
15
+
property="og:url"
16
+
content="https://tangled.sh/complete-signup"
17
+
/>
18
+
<meta
19
+
property="og:description"
20
+
content="complete your signup for tangled"
21
+
/>
22
+
<script src="/static/htmx.min.js"></script>
23
+
<link
24
+
rel="stylesheet"
25
+
href="/static/tw.css?{{ cssContentHash }}"
26
+
type="text/css"
27
+
/>
28
+
<title>complete signup · tangled</title>
29
+
</head>
30
+
<body class="flex items-center justify-center min-h-screen">
31
+
<main class="max-w-md px-6 -mt-4">
32
+
<h1
33
+
class="text-center text-2xl font-semibold italic dark:text-white"
34
+
>
35
+
tangled
36
+
</h1>
37
+
<h2 class="text-center text-xl italic dark:text-white">
38
+
tightly-knit social coding.
39
+
</h2>
40
+
<form
41
+
class="mt-4 max-w-sm mx-auto"
42
+
hx-post="/signup/complete"
43
+
hx-swap="none"
44
+
hx-disabled-elt="#complete-signup-button"
45
+
>
46
+
<div class="flex flex-col">
47
+
<label for="code">verification code</label>
48
+
<input
49
+
type="text"
50
+
id="code"
51
+
name="code"
52
+
tabindex="1"
53
+
required
54
+
placeholder="tngl-sh-foo-bar"
55
+
/>
56
+
<span class="text-sm text-gray-500 mt-1">
57
+
Enter the code sent to your email.
58
+
</span>
59
+
</div>
60
+
61
+
<div class="flex flex-col mt-4">
62
+
<label for="username">desired username</label>
63
+
<input
64
+
type="text"
65
+
id="username"
66
+
name="username"
67
+
tabindex="2"
68
+
required
69
+
placeholder="jason"
70
+
/>
71
+
<span class="text-sm text-gray-500 mt-1">
72
+
Your complete handle will be of the form <code>user.tngl.sh</code>.
73
+
</span>
74
+
</div>
75
+
76
+
<div class="flex flex-col mt-4">
77
+
<label for="password">password</label>
78
+
<input
79
+
type="password"
80
+
id="password"
81
+
name="password"
82
+
tabindex="3"
83
+
required
84
+
/>
85
+
<span class="text-sm text-gray-500 mt-1">
86
+
Choose a strong password for your account.
87
+
</span>
88
+
</div>
89
+
90
+
<button
91
+
class="btn-create w-full my-2 mt-6"
92
+
type="submit"
93
+
id="complete-signup-button"
94
+
tabindex="4"
95
+
>
96
+
<span>complete signup</span>
97
+
</button>
98
+
</form>
99
+
<p id="signup-error" class="error w-full"></p>
100
+
<p id="signup-msg" class="dark:text-white w-full"></p>
101
+
</main>
102
+
</body>
103
+
</html>
104
+
{{ end }}
+54
-7
appview/pages/templates/user/login.html
+54
-7
appview/pages/templates/user/login.html
···
17
17
/>
18
18
<meta
19
19
property="og:description"
20
-
content="login to tangled"
20
+
content="login to or sign up for tangled"
21
21
/>
22
22
<script src="/static/htmx.min.js"></script>
23
23
<link
···
25
25
href="/static/tw.css?{{ cssContentHash }}"
26
26
type="text/css"
27
27
/>
28
-
<title>login · tangled</title>
28
+
<title>login or sign up · tangled</title>
29
29
</head>
30
30
<body class="flex items-center justify-center min-h-screen">
31
31
<main class="max-w-md px-6 -mt-4">
···
51
51
name="handle"
52
52
tabindex="1"
53
53
required
54
+
placeholder="foo.tngl.sh"
54
55
/>
55
56
<span class="text-sm text-gray-500 mt-1">
56
-
Use your
57
-
<a href="https://bsky.app">Bluesky</a> handle to log
58
-
in. You will then be redirected to your PDS to
59
-
complete authentication.
57
+
Use your <a href="https://atproto.com">ATProto</a>
58
+
handle to log in. If you're unsure, this is likely
59
+
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
60
60
</span>
61
61
</div>
62
62
···
69
69
<span>login</span>
70
70
</button>
71
71
</form>
72
-
<p class="text-sm text-gray-500">
72
+
<hr class="my-4">
73
+
<p class="text-sm text-gray-500 mt-4">
74
+
Alternatively, you may create an account on Tangled below. You will
75
+
get a <code>user.tngl.sh</code> handle.
76
+
</p>
77
+
78
+
<details class="group">
79
+
80
+
<summary
81
+
class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2"
82
+
>
83
+
create an account
84
+
85
+
<div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div>
86
+
<div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div>
87
+
</summary>
88
+
<form
89
+
class="mt-4 max-w-sm mx-auto"
90
+
hx-post="/signup"
91
+
hx-swap="none"
92
+
hx-disabled-elt="#signup-button"
93
+
>
94
+
<div class="flex flex-col mt-2">
95
+
<label for="email">email</label>
96
+
<input
97
+
type="email"
98
+
id="email"
99
+
name="email"
100
+
tabindex="4"
101
+
required
102
+
placeholder="jason@bourne.co"
103
+
/>
104
+
</div>
105
+
<span class="text-sm text-gray-500 mt-1">
106
+
You will receive an email with a code. Enter that, along with your
107
+
desired username and password in the next page to complete your registration.
108
+
</span>
109
+
<button
110
+
class="btn w-full my-2 mt-6"
111
+
type="submit"
112
+
id="signup-button"
113
+
tabindex="7"
114
+
>
115
+
<span>sign up</span>
116
+
</button>
117
+
</form>
118
+
</details>
119
+
<p class="text-sm text-gray-500 mt-6">
73
120
Join our <a href="https://chat.tangled.sh">Discord</a> or
74
121
IRC channel:
75
122
<a href="https://web.libera.chat/#tangled"
+44
-5
appview/repo/repo.go
+44
-5
appview/repo/repo.go
···
39
39
"github.com/go-git/go-git/v5/plumbing"
40
40
41
41
comatproto "github.com/bluesky-social/indigo/api/atproto"
42
+
"github.com/bluesky-social/indigo/atproto/syntax"
42
43
lexutil "github.com/bluesky-social/indigo/lex/util"
43
44
)
44
45
···
741
742
return
742
743
}
743
744
745
+
// remove a single leading `@`, to make @handle work with ResolveIdent
746
+
collaborator = strings.TrimPrefix(collaborator, "@")
747
+
744
748
collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
745
749
if err != nil {
746
750
fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err)
···
751
755
fail("You seem to be adding yourself as a collaborator.", nil)
752
756
return
753
757
}
754
-
755
758
l = l.With("collaborator", collaboratorIdent.Handle)
756
759
l = l.With("knot", f.Knot)
757
-
l.Info("adding to knot")
758
760
761
+
// announce this relation into the firehose, store into owners' pds
762
+
client, err := rp.oauth.AuthorizedClient(r)
763
+
if err != nil {
764
+
fail("Failed to write to PDS.", err)
765
+
return
766
+
}
767
+
768
+
// emit a record
769
+
currentUser := rp.oauth.GetUser(r)
770
+
rkey := tid.TID()
771
+
createdAt := time.Now()
772
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
773
+
Collection: tangled.RepoCollaboratorNSID,
774
+
Repo: currentUser.Did,
775
+
Rkey: rkey,
776
+
Record: &lexutil.LexiconTypeDecoder{
777
+
Val: &tangled.RepoCollaborator{
778
+
Subject: collaboratorIdent.DID.String(),
779
+
Repo: string(f.RepoAt),
780
+
CreatedAt: createdAt.Format(time.RFC3339),
781
+
}},
782
+
})
783
+
// invalid record
784
+
if err != nil {
785
+
fail("Failed to write record to PDS.", err)
786
+
return
787
+
}
788
+
l = l.With("at-uri", resp.Uri)
789
+
l.Info("wrote record to PDS")
790
+
791
+
l.Info("adding to knot")
759
792
secret, err := db.GetRegistrationKey(rp.db, f.Knot)
760
793
if err != nil {
761
794
fail("Failed to add to knot.", err)
···
798
831
return
799
832
}
800
833
801
-
err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
834
+
err = db.AddCollaborator(rp.db, db.Collaborator{
835
+
Did: syntax.DID(currentUser.Did),
836
+
Rkey: rkey,
837
+
SubjectDid: collaboratorIdent.DID,
838
+
RepoAt: f.RepoAt,
839
+
Created: createdAt,
840
+
})
802
841
if err != nil {
803
842
fail("Failed to add collaborator.", err)
804
843
return
···
1189
1228
f, err := rp.repoResolver.Resolve(r)
1190
1229
user := rp.oauth.GetUser(r)
1191
1230
1192
-
// all spindles that this user is a member of
1193
-
spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1231
+
// all spindles that the repo owner is a member of
1232
+
spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid())
1194
1233
if err != nil {
1195
1234
log.Println("failed to fetch spindles", err)
1196
1235
return
+104
appview/signup/requests.go
+104
appview/signup/requests.go
···
1
+
package signup
2
+
3
+
// We have this extra code here for now since the xrpcclient package
4
+
// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth.
5
+
6
+
import (
7
+
"bytes"
8
+
"encoding/json"
9
+
"fmt"
10
+
"io"
11
+
"net/http"
12
+
"net/url"
13
+
)
14
+
15
+
// makePdsRequest is a helper method to make requests to the PDS service
16
+
func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) {
17
+
jsonData, err := json.Marshal(body)
18
+
if err != nil {
19
+
return nil, err
20
+
}
21
+
22
+
url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint)
23
+
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData))
24
+
if err != nil {
25
+
return nil, err
26
+
}
27
+
28
+
req.Header.Set("Content-Type", "application/json")
29
+
30
+
if useAuth {
31
+
req.SetBasicAuth("admin", s.config.Pds.AdminSecret)
32
+
}
33
+
34
+
return http.DefaultClient.Do(req)
35
+
}
36
+
37
+
// handlePdsError processes error responses from the PDS service
38
+
func (s *Signup) handlePdsError(resp *http.Response, action string) error {
39
+
var errorResp struct {
40
+
Error string `json:"error"`
41
+
Message string `json:"message"`
42
+
}
43
+
44
+
respBody, _ := io.ReadAll(resp.Body)
45
+
if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" {
46
+
return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message)
47
+
}
48
+
49
+
// Fallback if we couldn't parse the error
50
+
return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode)
51
+
}
52
+
53
+
func (s *Signup) inviteCodeRequest() (string, error) {
54
+
body := map[string]any{"useCount": 1}
55
+
56
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true)
57
+
if err != nil {
58
+
return "", err
59
+
}
60
+
defer resp.Body.Close()
61
+
62
+
if resp.StatusCode != http.StatusOK {
63
+
return "", s.handlePdsError(resp, "create invite code")
64
+
}
65
+
66
+
var result map[string]string
67
+
json.NewDecoder(resp.Body).Decode(&result)
68
+
return result["code"], nil
69
+
}
70
+
71
+
func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) {
72
+
parsedURL, err := url.Parse(s.config.Pds.Host)
73
+
if err != nil {
74
+
return "", fmt.Errorf("invalid PDS host URL: %w", err)
75
+
}
76
+
77
+
pdsDomain := parsedURL.Hostname()
78
+
79
+
body := map[string]string{
80
+
"email": email,
81
+
"handle": fmt.Sprintf("%s.%s", username, pdsDomain),
82
+
"password": password,
83
+
"inviteCode": code,
84
+
}
85
+
86
+
resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false)
87
+
if err != nil {
88
+
return "", err
89
+
}
90
+
defer resp.Body.Close()
91
+
92
+
if resp.StatusCode != http.StatusOK {
93
+
return "", s.handlePdsError(resp, "create account")
94
+
}
95
+
96
+
var result struct {
97
+
DID string `json:"did"`
98
+
}
99
+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
100
+
return "", fmt.Errorf("failed to decode create account response: %w", err)
101
+
}
102
+
103
+
return result.DID, nil
104
+
}
+249
appview/signup/signup.go
+249
appview/signup/signup.go
···
1
+
package signup
2
+
3
+
import (
4
+
"bufio"
5
+
"fmt"
6
+
"log/slog"
7
+
"net/http"
8
+
"os"
9
+
"strings"
10
+
11
+
"github.com/go-chi/chi/v5"
12
+
"github.com/posthog/posthog-go"
13
+
"tangled.sh/tangled.sh/core/appview/config"
14
+
"tangled.sh/tangled.sh/core/appview/db"
15
+
"tangled.sh/tangled.sh/core/appview/dns"
16
+
"tangled.sh/tangled.sh/core/appview/email"
17
+
"tangled.sh/tangled.sh/core/appview/pages"
18
+
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
+
"tangled.sh/tangled.sh/core/appview/xrpcclient"
20
+
"tangled.sh/tangled.sh/core/idresolver"
21
+
)
22
+
23
+
type Signup struct {
24
+
config *config.Config
25
+
db *db.DB
26
+
cf *dns.Cloudflare
27
+
posthog posthog.Client
28
+
xrpc *xrpcclient.Client
29
+
idResolver *idresolver.Resolver
30
+
pages *pages.Pages
31
+
l *slog.Logger
32
+
disallowedNicknames map[string]bool
33
+
}
34
+
35
+
func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
36
+
var cf *dns.Cloudflare
37
+
if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
38
+
var err error
39
+
cf, err = dns.NewCloudflare(cfg)
40
+
if err != nil {
41
+
l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
42
+
}
43
+
}
44
+
45
+
disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
46
+
47
+
return &Signup{
48
+
config: cfg,
49
+
db: database,
50
+
posthog: pc,
51
+
idResolver: idResolver,
52
+
cf: cf,
53
+
pages: pages,
54
+
l: l,
55
+
disallowedNicknames: disallowedNicknames,
56
+
}
57
+
}
58
+
59
+
func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
60
+
disallowed := make(map[string]bool)
61
+
62
+
if filepath == "" {
63
+
logger.Debug("no disallowed nicknames file configured")
64
+
return disallowed
65
+
}
66
+
67
+
file, err := os.Open(filepath)
68
+
if err != nil {
69
+
logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
70
+
return disallowed
71
+
}
72
+
defer file.Close()
73
+
74
+
scanner := bufio.NewScanner(file)
75
+
lineNum := 0
76
+
for scanner.Scan() {
77
+
lineNum++
78
+
line := strings.TrimSpace(scanner.Text())
79
+
if line == "" || strings.HasPrefix(line, "#") {
80
+
continue // skip empty lines and comments
81
+
}
82
+
83
+
nickname := strings.ToLower(line)
84
+
if userutil.IsValidSubdomain(nickname) {
85
+
disallowed[nickname] = true
86
+
} else {
87
+
logger.Warn("invalid nickname format in disallowed nicknames file",
88
+
"file", filepath, "line", lineNum, "nickname", nickname)
89
+
}
90
+
}
91
+
92
+
if err := scanner.Err(); err != nil {
93
+
logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
94
+
}
95
+
96
+
logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
97
+
return disallowed
98
+
}
99
+
100
+
// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
101
+
func (s *Signup) isNicknameAllowed(nickname string) bool {
102
+
return !s.disallowedNicknames[strings.ToLower(nickname)]
103
+
}
104
+
105
+
func (s *Signup) Router() http.Handler {
106
+
r := chi.NewRouter()
107
+
r.Post("/", s.signup)
108
+
r.Get("/complete", s.complete)
109
+
r.Post("/complete", s.complete)
110
+
111
+
return r
112
+
}
113
+
114
+
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
115
+
if s.cf == nil {
116
+
http.Error(w, "signup is disabled", http.StatusFailedDependency)
117
+
}
118
+
emailId := r.FormValue("email")
119
+
120
+
if !email.IsValidEmail(emailId) {
121
+
s.pages.Notice(w, "login-msg", "Invalid email address.")
122
+
return
123
+
}
124
+
125
+
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
126
+
if err != nil {
127
+
s.l.Error("failed to check email existence", "error", err)
128
+
s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
129
+
return
130
+
}
131
+
if exists {
132
+
s.pages.Notice(w, "login-msg", "Email already exists.")
133
+
return
134
+
}
135
+
136
+
code, err := s.inviteCodeRequest()
137
+
if err != nil {
138
+
s.l.Error("failed to create invite code", "error", err)
139
+
s.pages.Notice(w, "login-msg", "Failed to create invite code.")
140
+
return
141
+
}
142
+
143
+
em := email.Email{
144
+
APIKey: s.config.Resend.ApiKey,
145
+
From: s.config.Resend.SentFrom,
146
+
To: emailId,
147
+
Subject: "Verify your Tangled account",
148
+
Text: `Copy and paste this code below to verify your account on Tangled.
149
+
` + code,
150
+
Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
151
+
<p><code>` + code + `</code></p>`,
152
+
}
153
+
154
+
err = email.SendEmail(em)
155
+
if err != nil {
156
+
s.l.Error("failed to send email", "error", err)
157
+
s.pages.Notice(w, "login-msg", "Failed to send email.")
158
+
return
159
+
}
160
+
err = db.AddInflightSignup(s.db, db.InflightSignup{
161
+
Email: emailId,
162
+
InviteCode: code,
163
+
})
164
+
if err != nil {
165
+
s.l.Error("failed to add inflight signup", "error", err)
166
+
s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
167
+
return
168
+
}
169
+
170
+
s.pages.HxRedirect(w, "/signup/complete")
171
+
}
172
+
173
+
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
174
+
switch r.Method {
175
+
case http.MethodGet:
176
+
s.pages.CompleteSignup(w, pages.SignupParams{})
177
+
case http.MethodPost:
178
+
username := r.FormValue("username")
179
+
password := r.FormValue("password")
180
+
code := r.FormValue("code")
181
+
182
+
if !userutil.IsValidSubdomain(username) {
183
+
s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")
184
+
return
185
+
}
186
+
187
+
if !s.isNicknameAllowed(username) {
188
+
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
189
+
return
190
+
}
191
+
192
+
email, err := db.GetEmailForCode(s.db, code)
193
+
if err != nil {
194
+
s.l.Error("failed to get email for code", "error", err)
195
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
196
+
return
197
+
}
198
+
199
+
did, err := s.createAccountRequest(username, password, email, code)
200
+
if err != nil {
201
+
s.l.Error("failed to create account", "error", err)
202
+
s.pages.Notice(w, "signup-error", err.Error())
203
+
return
204
+
}
205
+
206
+
if s.cf == nil {
207
+
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
208
+
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
209
+
return
210
+
}
211
+
212
+
err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
213
+
Type: "TXT",
214
+
Name: "_atproto." + username,
215
+
Content: "did=" + did,
216
+
TTL: 6400,
217
+
Proxied: false,
218
+
})
219
+
if err != nil {
220
+
s.l.Error("failed to create DNS record", "error", err)
221
+
s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
222
+
return
223
+
}
224
+
225
+
err = db.AddEmail(s.db, db.Email{
226
+
Did: did,
227
+
Address: email,
228
+
Verified: true,
229
+
Primary: true,
230
+
})
231
+
if err != nil {
232
+
s.l.Error("failed to add email", "error", err)
233
+
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
234
+
return
235
+
}
236
+
237
+
s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
238
+
<a class="underline text-black dark:text-white" href="/login">login</a>
239
+
with <code>%s.tngl.sh</code>.`, username))
240
+
241
+
go func() {
242
+
err := db.DeleteInflightSignup(s.db, email)
243
+
if err != nil {
244
+
s.l.Error("failed to delete inflight signup", "error", err)
245
+
}
246
+
}()
247
+
return
248
+
}
249
+
}
+11
appview/state/router.go
+11
appview/state/router.go
···
14
14
"tangled.sh/tangled.sh/core/appview/pulls"
15
15
"tangled.sh/tangled.sh/core/appview/repo"
16
16
"tangled.sh/tangled.sh/core/appview/settings"
17
+
"tangled.sh/tangled.sh/core/appview/signup"
17
18
"tangled.sh/tangled.sh/core/appview/spindles"
18
19
"tangled.sh/tangled.sh/core/appview/state/userutil"
19
20
"tangled.sh/tangled.sh/core/log"
···
137
138
r.Mount("/settings", s.SettingsRouter())
138
139
r.Mount("/knots", s.KnotsRouter(mw))
139
140
r.Mount("/spindles", s.SpindlesRouter())
141
+
r.Mount("/signup", s.SignupRouter())
140
142
r.Mount("/", s.OAuthRouter())
141
143
142
144
r.Get("/keys/{user}", s.Keys)
145
+
r.Get("/terms", s.TermsOfService)
146
+
r.Get("/privacy", s.PrivacyPolicy)
143
147
144
148
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
145
149
s.pages.Error404(w)
···
217
221
pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer)
218
222
return pipes.Router(mw)
219
223
}
224
+
225
+
func (s *State) SignupRouter() http.Handler {
226
+
logger := log.New("signup")
227
+
228
+
sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger)
229
+
return sig.Router()
230
+
}
+16
-2
appview/state/state.go
+16
-2
appview/state/state.go
···
23
23
"tangled.sh/tangled.sh/core/appview/notify"
24
24
"tangled.sh/tangled.sh/core/appview/oauth"
25
25
"tangled.sh/tangled.sh/core/appview/pages"
26
-
posthog_service "tangled.sh/tangled.sh/core/appview/posthog"
26
+
posthogService "tangled.sh/tangled.sh/core/appview/posthog"
27
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
28
28
"tangled.sh/tangled.sh/core/eventconsumer"
29
29
"tangled.sh/tangled.sh/core/idresolver"
···
133
133
134
134
var notifiers []notify.Notifier
135
135
if !config.Core.Dev {
136
-
notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog))
136
+
notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog))
137
137
}
138
138
notifier := notify.NewMergedNotifier(notifiers...)
139
139
···
154
154
}
155
155
156
156
return state, nil
157
+
}
158
+
159
+
func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
160
+
user := s.oauth.GetUser(r)
161
+
s.pages.TermsOfService(w, pages.TermsOfServiceParams{
162
+
LoggedInUser: user,
163
+
})
164
+
}
165
+
166
+
func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
167
+
user := s.oauth.GetUser(r)
168
+
s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
169
+
LoggedInUser: user,
170
+
})
157
171
}
158
172
159
173
func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
+6
appview/state/userutil/userutil.go
+6
appview/state/userutil/userutil.go
···
51
51
func IsDid(s string) bool {
52
52
return didRegex.MatchString(s)
53
53
}
54
+
55
+
var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`)
56
+
57
+
func IsValidSubdomain(name string) bool {
58
+
return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name)
59
+
}
+1
cmd/gen.go
+1
cmd/gen.go
+193
-38
docs/spindle/openbao.md
+193
-38
docs/spindle/openbao.md
···
1
1
# spindle secrets with openbao
2
2
3
3
This document covers setting up Spindle to use OpenBao for secrets
4
-
management instead of the default SQLite backend.
4
+
management via OpenBao Proxy instead of the default SQLite backend.
5
+
6
+
## overview
7
+
8
+
Spindle now uses OpenBao Proxy for secrets management. The proxy handles
9
+
authentication automatically using AppRole credentials, while Spindle
10
+
connects to the local proxy instead of directly to the OpenBao server.
11
+
12
+
This approach provides better security, automatic token renewal, and
13
+
simplified application code.
5
14
6
15
## installation
7
16
8
17
Install OpenBao from nixpkgs:
9
18
10
19
```bash
11
-
nix-env -iA nixpkgs.openbao
20
+
nix shell nixpkgs#openbao # for a local server
12
21
```
13
22
14
-
## local development setup
23
+
## setup
24
+
25
+
The setup process can is documented for both local development and production.
26
+
27
+
### local development
15
28
16
29
Start OpenBao in dev mode:
17
30
18
31
```bash
19
-
bao server -dev
32
+
bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
20
33
```
21
34
22
-
This starts OpenBao on `http://localhost:8200` with a root token. Save
23
-
the root token from the output -- you'll need it.
35
+
This starts OpenBao on `http://localhost:8201` with a root token.
24
36
25
37
Set up environment for bao CLI:
26
38
27
39
```bash
28
40
export BAO_ADDR=http://localhost:8200
29
-
export BAO_TOKEN=hvs.your-root-token-here
41
+
export BAO_TOKEN=root
30
42
```
31
43
44
+
### production
45
+
46
+
You would typically use a systemd service with a configuration file. Refer to
47
+
[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
48
+
achieved using Nix.
49
+
50
+
Then, initialize the bao server:
51
+
```bash
52
+
bao operator init -key-shares=1 -key-threshold=1
53
+
```
54
+
55
+
This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up:
56
+
```bash
57
+
bao operator unseal <unseal_key>
58
+
```
59
+
60
+
All steps below remain the same across both dev and production setups.
61
+
62
+
### configure openbao server
63
+
32
64
Create the spindle KV mount:
33
65
34
66
```bash
35
67
bao secrets enable -path=spindle -version=2 kv
36
68
```
37
69
38
-
Set up AppRole authentication:
70
+
Set up AppRole authentication and policy:
39
71
40
72
Create a policy file `spindle-policy.hcl`:
41
73
42
74
```hcl
75
+
# Full access to spindle KV v2 data
43
76
path "spindle/data/*" {
44
-
capabilities = ["create", "read", "update", "delete", "list"]
77
+
capabilities = ["create", "read", "update", "delete"]
45
78
}
46
79
80
+
# Access to metadata for listing and management
47
81
path "spindle/metadata/*" {
48
-
capabilities = ["list", "read", "delete"]
82
+
capabilities = ["list", "read", "delete", "update"]
49
83
}
50
84
51
-
path "spindle/*" {
85
+
# Allow listing at root level
86
+
path "spindle/" {
52
87
capabilities = ["list"]
53
88
}
89
+
90
+
# Required for connection testing and health checks
91
+
path "auth/token/lookup-self" {
92
+
capabilities = ["read"]
93
+
}
54
94
```
55
95
56
96
Apply the policy and create an AppRole:
···
61
101
bao write auth/approle/role/spindle \
62
102
token_policies="spindle-policy" \
63
103
token_ttl=1h \
64
-
token_max_ttl=4h
104
+
token_max_ttl=4h \
105
+
bind_secret_id=true \
106
+
secret_id_ttl=0 \
107
+
secret_id_num_uses=0
65
108
```
66
109
67
110
Get the credentials:
68
111
69
112
```bash
70
-
bao read auth/approle/role/spindle/role-id
71
-
bao write -f auth/approle/role/spindle/secret-id
113
+
# Get role ID (static)
114
+
ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115
+
116
+
# Generate secret ID
117
+
SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
118
+
119
+
echo "Role ID: $ROLE_ID"
120
+
echo "Secret ID: $SECRET_ID"
121
+
```
122
+
123
+
### create proxy configuration
124
+
125
+
Create the credential files:
126
+
127
+
```bash
128
+
# Create directory for OpenBao files
129
+
mkdir -p /tmp/openbao
130
+
131
+
# Save credentials
132
+
echo "$ROLE_ID" > /tmp/openbao/role-id
133
+
echo "$SECRET_ID" > /tmp/openbao/secret-id
134
+
chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135
+
```
136
+
137
+
Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138
+
139
+
```hcl
140
+
# OpenBao server connection
141
+
vault {
142
+
address = "http://localhost:8200"
143
+
}
144
+
145
+
# Auto-Auth using AppRole
146
+
auto_auth {
147
+
method "approle" {
148
+
mount_path = "auth/approle"
149
+
config = {
150
+
role_id_file_path = "/tmp/openbao/role-id"
151
+
secret_id_file_path = "/tmp/openbao/secret-id"
152
+
}
153
+
}
154
+
155
+
# Optional: write token to file for debugging
156
+
sink "file" {
157
+
config = {
158
+
path = "/tmp/openbao/token"
159
+
mode = 0640
160
+
}
161
+
}
162
+
}
163
+
164
+
# Proxy listener for Spindle
165
+
listener "tcp" {
166
+
address = "127.0.0.1:8201"
167
+
tls_disable = true
168
+
}
169
+
170
+
# Enable API proxy with auto-auth token
171
+
api_proxy {
172
+
use_auto_auth_token = true
173
+
}
174
+
175
+
# Enable response caching
176
+
cache {
177
+
use_auto_auth_token = true
178
+
}
179
+
180
+
# Logging
181
+
log_level = "info"
72
182
```
73
183
74
-
Configure Spindle:
184
+
### start the proxy
185
+
186
+
Start OpenBao Proxy:
187
+
188
+
```bash
189
+
bao proxy -config=/tmp/openbao/proxy.hcl
190
+
```
191
+
192
+
The proxy will authenticate with OpenBao and start listening on
193
+
`127.0.0.1:8201`.
194
+
195
+
### configure spindle
75
196
76
197
Set these environment variables for Spindle:
77
198
78
199
```bash
79
200
export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
80
-
export SPINDLE_SERVER_SECRETS_OPENBAO_ADDR=http://localhost:8200
81
-
export SPINDLE_SERVER_SECRETS_OPENBAO_ROLE_ID=your-role-id-from-above
82
-
export SPINDLE_SERVER_SECRETS_OPENBAO_SECRET_ID=your-secret-id-from-above
201
+
export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
83
202
export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
84
203
```
85
204
86
205
Start Spindle:
87
206
88
-
Spindle will now use OpenBao for secrets storage with automatic token
89
-
renewal.
207
+
Spindle will now connect to the local proxy, which handles all
208
+
authentication automatically.
209
+
210
+
## production setup for proxy
211
+
212
+
For production, you'll want to run the proxy as a service:
213
+
214
+
Place your production configuration in `/etc/openbao/proxy.hcl` with
215
+
proper TLS settings for the vault connection.
90
216
91
217
## verifying setup
92
218
93
-
List all secrets:
219
+
Test the proxy directly:
94
220
95
221
```bash
96
-
bao kv list spindle/
222
+
# Check proxy health
223
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224
+
225
+
# Test token lookup through proxy
226
+
curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
97
227
```
98
228
99
-
Add a test secret via Spindle API, then check it exists:
229
+
Test OpenBao operations through the server:
100
230
101
231
```bash
232
+
# List all secrets
233
+
bao kv list spindle/
234
+
235
+
# Add a test secret via Spindle API, then check it exists
102
236
bao kv list spindle/repos/
103
-
```
104
237
105
-
Get a specific secret:
106
-
107
-
```bash
238
+
# Get a specific secret
108
239
bao kv get spindle/repos/your_repo_path/SECRET_NAME
109
240
```
110
241
111
242
## how it works
112
243
244
+
- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245
+
- The proxy authenticates with OpenBao using AppRole credentials
246
+
- All Spindle requests go through the proxy, which injects authentication tokens
113
247
- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
114
-
- Each repository gets its own namespace
115
-
- Repository paths like `at://did:plc:alice/myrepo` become
116
-
`at_did_plc_alice_myrepo`
117
-
- The system automatically handles token renewal using AppRole
118
-
authentication
119
-
- On shutdown, Spindle cleanly stops the token renewal process
248
+
- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249
+
- The proxy handles all token renewal automatically
250
+
- Spindle no longer manages tokens or authentication directly
120
251
121
252
## troubleshooting
122
253
123
-
**403 errors**: Check that your BAO_TOKEN is set and the spindle mount
124
-
exists
254
+
**Connection refused**: Check that the OpenBao Proxy is running and
255
+
listening on the configured address.
256
+
257
+
**403 errors**: Verify the AppRole credentials are correct and the policy
258
+
has the necessary permissions.
125
259
126
260
**404 route errors**: The spindle KV mount probably doesn't exist - run
127
-
the mount creation step again
261
+
the mount creation step again.
128
262
129
-
**Token expired**: The AppRole system should handle this automatically,
130
-
but you can check token status with `bao token lookup`
263
+
**Proxy authentication failures**: Check the proxy logs and verify the
264
+
role-id and secret-id files are readable and contain valid credentials.
265
+
266
+
**Secret not found after writing**: This can indicate policy permission
267
+
issues. Verify the policy includes both `spindle/data/*` and
268
+
`spindle/metadata/*` paths with appropriate capabilities.
269
+
270
+
Check proxy logs:
271
+
272
+
```bash
273
+
# If running as systemd service
274
+
journalctl -u openbao-proxy -f
275
+
276
+
# If running directly, check the console output
277
+
```
278
+
279
+
Test AppRole authentication manually:
280
+
281
+
```bash
282
+
bao write auth/approle/login \
283
+
role_id="$(cat /tmp/openbao/role-id)" \
284
+
secret_id="$(cat /tmp/openbao/secret-id)"
285
+
```
+2
go.mod
+2
go.mod
···
12
12
github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1
13
13
github.com/carlmjohnson/versioninfo v0.22.5
14
14
github.com/casbin/casbin/v2 v2.103.0
15
+
github.com/cloudflare/cloudflare-go v0.115.0
15
16
github.com/cyphar/filepath-securejoin v0.4.1
16
17
github.com/dgraph-io/ristretto v0.2.0
17
18
github.com/docker/docker v28.2.2+incompatible
···
85
86
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
86
87
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
87
88
github.com/golang/mock v1.6.0 // indirect
89
+
github.com/google/go-querystring v1.1.0 // indirect
88
90
github.com/gorilla/css v1.0.1 // indirect
89
91
github.com/gorilla/securecookie v1.1.2 // indirect
90
92
github.com/hashicorp/errwrap v1.1.0 // indirect
+5
go.sum
+5
go.sum
···
53
53
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
54
54
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4=
55
55
github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
56
+
github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
57
+
github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
56
58
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
57
59
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
58
60
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
···
152
154
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
153
155
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
154
156
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
157
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
155
158
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
156
159
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
157
160
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
158
161
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
159
162
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
163
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
164
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
160
165
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
161
166
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
162
167
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+13
jetstream/jetstream.go
+13
jetstream/jetstream.go
···
52
52
j.mu.Unlock()
53
53
}
54
54
55
+
func (j *JetstreamClient) RemoveDid(did string) {
56
+
if did == "" {
57
+
return
58
+
}
59
+
60
+
if j.logDids {
61
+
j.l.Info("removing did from in-memory filter", "did", did)
62
+
}
63
+
j.mu.Lock()
64
+
delete(j.wantedDids, did)
65
+
j.mu.Unlock()
66
+
}
67
+
55
68
type processor func(context.Context, *models.Event) error
56
69
57
70
func (j *JetstreamClient) withDidFilter(processFunc processor) processor {
+61
-1
knotserver/ingester.go
+61
-1
knotserver/ingester.go
···
213
213
return h.db.InsertEvent(event, h.n)
214
214
}
215
215
216
+
// duplicated from add collaborator
217
+
func (h *Handle) processCollaborator(ctx context.Context, did string, record tangled.RepoCollaborator) error {
218
+
repoAt, err := syntax.ParseATURI(record.Repo)
219
+
if err != nil {
220
+
return err
221
+
}
222
+
223
+
resolver := idresolver.DefaultResolver()
224
+
225
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
226
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
227
+
return err
228
+
}
229
+
230
+
// TODO: fix this for good, we need to fetch the record here unfortunately
231
+
// resolve this aturi to extract the repo record
232
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
233
+
if err != nil || owner.Handle.IsInvalidHandle() {
234
+
return fmt.Errorf("failed to resolve handle: %w", err)
235
+
}
236
+
237
+
xrpcc := xrpc.Client{
238
+
Host: owner.PDSEndpoint(),
239
+
}
240
+
241
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
242
+
if err != nil {
243
+
return err
244
+
}
245
+
246
+
repo := resp.Value.Val.(*tangled.Repo)
247
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
248
+
249
+
// check perms for this user
250
+
if ok, err := h.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
251
+
return fmt.Errorf("insufficient permissions: %w", err)
252
+
}
253
+
254
+
if err := h.db.AddDid(subjectId.DID.String()); err != nil {
255
+
return err
256
+
}
257
+
h.jc.AddDid(subjectId.DID.String())
258
+
259
+
if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil {
260
+
return err
261
+
}
262
+
263
+
return h.fetchAndAddKeys(ctx, subjectId.DID.String())
264
+
}
265
+
216
266
func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
217
267
l := log.FromContext(ctx)
218
268
···
266
316
defer func() {
267
317
eventTime := event.TimeUS
268
318
lastTimeUs := eventTime + 1
269
-
fmt.Println("lastTimeUs", lastTimeUs)
270
319
if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
271
320
err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
272
321
}
···
292
341
if err := h.processKnotMember(ctx, did, record); err != nil {
293
342
return fmt.Errorf("failed to process knot member: %w", err)
294
343
}
344
+
295
345
case tangled.RepoPullNSID:
296
346
var record tangled.RepoPull
297
347
if err := json.Unmarshal(raw, &record); err != nil {
···
300
350
if err := h.processPull(ctx, did, record); err != nil {
301
351
return fmt.Errorf("failed to process knot member: %w", err)
302
352
}
353
+
354
+
case tangled.RepoCollaboratorNSID:
355
+
var record tangled.RepoCollaborator
356
+
if err := json.Unmarshal(raw, &record); err != nil {
357
+
return fmt.Errorf("failed to unmarshal record: %w", err)
358
+
}
359
+
if err := h.processCollaborator(ctx, did, record); err != nil {
360
+
return fmt.Errorf("failed to process knot member: %w", err)
361
+
}
362
+
303
363
}
304
364
305
365
return err
+1
knotserver/server.go
+1
knotserver/server.go
-37
lexicons/addSecret.json
-37
lexicons/addSecret.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.addSecret",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Add a CI secret",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"key",
15
-
"value"
16
-
],
17
-
"properties": {
18
-
"repo": {
19
-
"type": "string",
20
-
"format": "at-uri"
21
-
},
22
-
"key": {
23
-
"type": "string",
24
-
"maxLength": 50,
25
-
"minLength": 1
26
-
},
27
-
"value": {
28
-
"type": "string",
29
-
"maxLength": 200,
30
-
"minLength": 1
31
-
}
32
-
}
33
-
}
34
-
}
35
-
}
36
-
}
37
-
}
-52
lexicons/artifact.json
-52
lexicons/artifact.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.artifact",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"repo",
15
-
"tag",
16
-
"createdAt",
17
-
"artifact"
18
-
],
19
-
"properties": {
20
-
"name": {
21
-
"type": "string",
22
-
"description": "name of the artifact"
23
-
},
24
-
"repo": {
25
-
"type": "string",
26
-
"format": "at-uri",
27
-
"description": "repo that this artifact is being uploaded to"
28
-
},
29
-
"tag": {
30
-
"type": "bytes",
31
-
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
-
"minLength": 20,
33
-
"maxLength": 20
34
-
},
35
-
"createdAt": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"description": "time of creation of this artifact"
39
-
},
40
-
"artifact": {
41
-
"type": "blob",
42
-
"description": "the artifact",
43
-
"accept": [
44
-
"*/*"
45
-
],
46
-
"maxSize": 52428800
47
-
}
48
-
}
49
-
}
50
-
}
51
-
}
52
-
}
-29
lexicons/defaultBranch.json
-29
lexicons/defaultBranch.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.setDefaultBranch",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Set the default branch for a repository",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"defaultBranch"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"defaultBranch": {
22
-
"type": "string"
23
-
}
24
-
}
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
-67
lexicons/listSecrets.json
-67
lexicons/listSecrets.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.listSecrets",
4
-
"defs": {
5
-
"main": {
6
-
"type": "query",
7
-
"parameters": {
8
-
"type": "params",
9
-
"required": [
10
-
"repo"
11
-
],
12
-
"properties": {
13
-
"repo": {
14
-
"type": "string",
15
-
"format": "at-uri"
16
-
}
17
-
}
18
-
},
19
-
"output": {
20
-
"encoding": "application/json",
21
-
"schema": {
22
-
"type": "object",
23
-
"required": [
24
-
"secrets"
25
-
],
26
-
"properties": {
27
-
"secrets": {
28
-
"type": "array",
29
-
"items": {
30
-
"type": "ref",
31
-
"ref": "#secret"
32
-
}
33
-
}
34
-
}
35
-
}
36
-
}
37
-
},
38
-
"secret": {
39
-
"type": "object",
40
-
"required": [
41
-
"repo",
42
-
"key",
43
-
"createdAt",
44
-
"createdBy"
45
-
],
46
-
"properties": {
47
-
"repo": {
48
-
"type": "string",
49
-
"format": "at-uri"
50
-
},
51
-
"key": {
52
-
"type": "string",
53
-
"maxLength": 50,
54
-
"minLength": 1
55
-
},
56
-
"createdAt": {
57
-
"type": "string",
58
-
"format": "datetime"
59
-
},
60
-
"createdBy": {
61
-
"type": "string",
62
-
"format": "did"
63
-
}
64
-
}
65
-
}
66
-
}
67
-
}
+263
lexicons/pipeline/pipeline.json
+263
lexicons/pipeline/pipeline.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.pipeline",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"triggerMetadata",
14
+
"workflows"
15
+
],
16
+
"properties": {
17
+
"triggerMetadata": {
18
+
"type": "ref",
19
+
"ref": "#triggerMetadata"
20
+
},
21
+
"workflows": {
22
+
"type": "array",
23
+
"items": {
24
+
"type": "ref",
25
+
"ref": "#workflow"
26
+
}
27
+
}
28
+
}
29
+
}
30
+
},
31
+
"triggerMetadata": {
32
+
"type": "object",
33
+
"required": [
34
+
"kind",
35
+
"repo"
36
+
],
37
+
"properties": {
38
+
"kind": {
39
+
"type": "string",
40
+
"enum": [
41
+
"push",
42
+
"pull_request",
43
+
"manual"
44
+
]
45
+
},
46
+
"repo": {
47
+
"type": "ref",
48
+
"ref": "#triggerRepo"
49
+
},
50
+
"push": {
51
+
"type": "ref",
52
+
"ref": "#pushTriggerData"
53
+
},
54
+
"pullRequest": {
55
+
"type": "ref",
56
+
"ref": "#pullRequestTriggerData"
57
+
},
58
+
"manual": {
59
+
"type": "ref",
60
+
"ref": "#manualTriggerData"
61
+
}
62
+
}
63
+
},
64
+
"triggerRepo": {
65
+
"type": "object",
66
+
"required": [
67
+
"knot",
68
+
"did",
69
+
"repo",
70
+
"defaultBranch"
71
+
],
72
+
"properties": {
73
+
"knot": {
74
+
"type": "string"
75
+
},
76
+
"did": {
77
+
"type": "string",
78
+
"format": "did"
79
+
},
80
+
"repo": {
81
+
"type": "string"
82
+
},
83
+
"defaultBranch": {
84
+
"type": "string"
85
+
}
86
+
}
87
+
},
88
+
"pushTriggerData": {
89
+
"type": "object",
90
+
"required": [
91
+
"ref",
92
+
"newSha",
93
+
"oldSha"
94
+
],
95
+
"properties": {
96
+
"ref": {
97
+
"type": "string"
98
+
},
99
+
"newSha": {
100
+
"type": "string",
101
+
"minLength": 40,
102
+
"maxLength": 40
103
+
},
104
+
"oldSha": {
105
+
"type": "string",
106
+
"minLength": 40,
107
+
"maxLength": 40
108
+
}
109
+
}
110
+
},
111
+
"pullRequestTriggerData": {
112
+
"type": "object",
113
+
"required": [
114
+
"sourceBranch",
115
+
"targetBranch",
116
+
"sourceSha",
117
+
"action"
118
+
],
119
+
"properties": {
120
+
"sourceBranch": {
121
+
"type": "string"
122
+
},
123
+
"targetBranch": {
124
+
"type": "string"
125
+
},
126
+
"sourceSha": {
127
+
"type": "string",
128
+
"minLength": 40,
129
+
"maxLength": 40
130
+
},
131
+
"action": {
132
+
"type": "string"
133
+
}
134
+
}
135
+
},
136
+
"manualTriggerData": {
137
+
"type": "object",
138
+
"properties": {
139
+
"inputs": {
140
+
"type": "array",
141
+
"items": {
142
+
"type": "ref",
143
+
"ref": "#pair"
144
+
}
145
+
}
146
+
}
147
+
},
148
+
"workflow": {
149
+
"type": "object",
150
+
"required": [
151
+
"name",
152
+
"dependencies",
153
+
"steps",
154
+
"environment",
155
+
"clone"
156
+
],
157
+
"properties": {
158
+
"name": {
159
+
"type": "string"
160
+
},
161
+
"dependencies": {
162
+
"type": "array",
163
+
"items": {
164
+
"type": "ref",
165
+
"ref": "#dependency"
166
+
}
167
+
},
168
+
"steps": {
169
+
"type": "array",
170
+
"items": {
171
+
"type": "ref",
172
+
"ref": "#step"
173
+
}
174
+
},
175
+
"environment": {
176
+
"type": "array",
177
+
"items": {
178
+
"type": "ref",
179
+
"ref": "#pair"
180
+
}
181
+
},
182
+
"clone": {
183
+
"type": "ref",
184
+
"ref": "#cloneOpts"
185
+
}
186
+
}
187
+
},
188
+
"dependency": {
189
+
"type": "object",
190
+
"required": [
191
+
"registry",
192
+
"packages"
193
+
],
194
+
"properties": {
195
+
"registry": {
196
+
"type": "string"
197
+
},
198
+
"packages": {
199
+
"type": "array",
200
+
"items": {
201
+
"type": "string"
202
+
}
203
+
}
204
+
}
205
+
},
206
+
"cloneOpts": {
207
+
"type": "object",
208
+
"required": [
209
+
"skip",
210
+
"depth",
211
+
"submodules"
212
+
],
213
+
"properties": {
214
+
"skip": {
215
+
"type": "boolean"
216
+
},
217
+
"depth": {
218
+
"type": "integer"
219
+
},
220
+
"submodules": {
221
+
"type": "boolean"
222
+
}
223
+
}
224
+
},
225
+
"step": {
226
+
"type": "object",
227
+
"required": [
228
+
"name",
229
+
"command"
230
+
],
231
+
"properties": {
232
+
"name": {
233
+
"type": "string"
234
+
},
235
+
"command": {
236
+
"type": "string"
237
+
},
238
+
"environment": {
239
+
"type": "array",
240
+
"items": {
241
+
"type": "ref",
242
+
"ref": "#pair"
243
+
}
244
+
}
245
+
}
246
+
},
247
+
"pair": {
248
+
"type": "object",
249
+
"required": [
250
+
"key",
251
+
"value"
252
+
],
253
+
"properties": {
254
+
"key": {
255
+
"type": "string"
256
+
},
257
+
"value": {
258
+
"type": "string"
259
+
}
260
+
}
261
+
}
262
+
}
263
+
}
-263
lexicons/pipeline.json
-263
lexicons/pipeline.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.pipeline",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"triggerMetadata",
14
-
"workflows"
15
-
],
16
-
"properties": {
17
-
"triggerMetadata": {
18
-
"type": "ref",
19
-
"ref": "#triggerMetadata"
20
-
},
21
-
"workflows": {
22
-
"type": "array",
23
-
"items": {
24
-
"type": "ref",
25
-
"ref": "#workflow"
26
-
}
27
-
}
28
-
}
29
-
}
30
-
},
31
-
"triggerMetadata": {
32
-
"type": "object",
33
-
"required": [
34
-
"kind",
35
-
"repo"
36
-
],
37
-
"properties": {
38
-
"kind": {
39
-
"type": "string",
40
-
"enum": [
41
-
"push",
42
-
"pull_request",
43
-
"manual"
44
-
]
45
-
},
46
-
"repo": {
47
-
"type": "ref",
48
-
"ref": "#triggerRepo"
49
-
},
50
-
"push": {
51
-
"type": "ref",
52
-
"ref": "#pushTriggerData"
53
-
},
54
-
"pullRequest": {
55
-
"type": "ref",
56
-
"ref": "#pullRequestTriggerData"
57
-
},
58
-
"manual": {
59
-
"type": "ref",
60
-
"ref": "#manualTriggerData"
61
-
}
62
-
}
63
-
},
64
-
"triggerRepo": {
65
-
"type": "object",
66
-
"required": [
67
-
"knot",
68
-
"did",
69
-
"repo",
70
-
"defaultBranch"
71
-
],
72
-
"properties": {
73
-
"knot": {
74
-
"type": "string"
75
-
},
76
-
"did": {
77
-
"type": "string",
78
-
"format": "did"
79
-
},
80
-
"repo": {
81
-
"type": "string"
82
-
},
83
-
"defaultBranch": {
84
-
"type": "string"
85
-
}
86
-
}
87
-
},
88
-
"pushTriggerData": {
89
-
"type": "object",
90
-
"required": [
91
-
"ref",
92
-
"newSha",
93
-
"oldSha"
94
-
],
95
-
"properties": {
96
-
"ref": {
97
-
"type": "string"
98
-
},
99
-
"newSha": {
100
-
"type": "string",
101
-
"minLength": 40,
102
-
"maxLength": 40
103
-
},
104
-
"oldSha": {
105
-
"type": "string",
106
-
"minLength": 40,
107
-
"maxLength": 40
108
-
}
109
-
}
110
-
},
111
-
"pullRequestTriggerData": {
112
-
"type": "object",
113
-
"required": [
114
-
"sourceBranch",
115
-
"targetBranch",
116
-
"sourceSha",
117
-
"action"
118
-
],
119
-
"properties": {
120
-
"sourceBranch": {
121
-
"type": "string"
122
-
},
123
-
"targetBranch": {
124
-
"type": "string"
125
-
},
126
-
"sourceSha": {
127
-
"type": "string",
128
-
"minLength": 40,
129
-
"maxLength": 40
130
-
},
131
-
"action": {
132
-
"type": "string"
133
-
}
134
-
}
135
-
},
136
-
"manualTriggerData": {
137
-
"type": "object",
138
-
"properties": {
139
-
"inputs": {
140
-
"type": "array",
141
-
"items": {
142
-
"type": "ref",
143
-
"ref": "#pair"
144
-
}
145
-
}
146
-
}
147
-
},
148
-
"workflow": {
149
-
"type": "object",
150
-
"required": [
151
-
"name",
152
-
"dependencies",
153
-
"steps",
154
-
"environment",
155
-
"clone"
156
-
],
157
-
"properties": {
158
-
"name": {
159
-
"type": "string"
160
-
},
161
-
"dependencies": {
162
-
"type": "array",
163
-
"items": {
164
-
"type": "ref",
165
-
"ref": "#dependency"
166
-
}
167
-
},
168
-
"steps": {
169
-
"type": "array",
170
-
"items": {
171
-
"type": "ref",
172
-
"ref": "#step"
173
-
}
174
-
},
175
-
"environment": {
176
-
"type": "array",
177
-
"items": {
178
-
"type": "ref",
179
-
"ref": "#pair"
180
-
}
181
-
},
182
-
"clone": {
183
-
"type": "ref",
184
-
"ref": "#cloneOpts"
185
-
}
186
-
}
187
-
},
188
-
"dependency": {
189
-
"type": "object",
190
-
"required": [
191
-
"registry",
192
-
"packages"
193
-
],
194
-
"properties": {
195
-
"registry": {
196
-
"type": "string"
197
-
},
198
-
"packages": {
199
-
"type": "array",
200
-
"items": {
201
-
"type": "string"
202
-
}
203
-
}
204
-
}
205
-
},
206
-
"cloneOpts": {
207
-
"type": "object",
208
-
"required": [
209
-
"skip",
210
-
"depth",
211
-
"submodules"
212
-
],
213
-
"properties": {
214
-
"skip": {
215
-
"type": "boolean"
216
-
},
217
-
"depth": {
218
-
"type": "integer"
219
-
},
220
-
"submodules": {
221
-
"type": "boolean"
222
-
}
223
-
}
224
-
},
225
-
"step": {
226
-
"type": "object",
227
-
"required": [
228
-
"name",
229
-
"command"
230
-
],
231
-
"properties": {
232
-
"name": {
233
-
"type": "string"
234
-
},
235
-
"command": {
236
-
"type": "string"
237
-
},
238
-
"environment": {
239
-
"type": "array",
240
-
"items": {
241
-
"type": "ref",
242
-
"ref": "#pair"
243
-
}
244
-
}
245
-
}
246
-
},
247
-
"pair": {
248
-
"type": "object",
249
-
"required": [
250
-
"key",
251
-
"value"
252
-
],
253
-
"properties": {
254
-
"key": {
255
-
"type": "string"
256
-
},
257
-
"value": {
258
-
"type": "string"
259
-
}
260
-
}
261
-
}
262
-
}
263
-
}
-31
lexicons/removeSecret.json
-31
lexicons/removeSecret.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo.removeSecret",
4
-
"defs": {
5
-
"main": {
6
-
"type": "procedure",
7
-
"description": "Remove a CI secret",
8
-
"input": {
9
-
"encoding": "application/json",
10
-
"schema": {
11
-
"type": "object",
12
-
"required": [
13
-
"repo",
14
-
"key"
15
-
],
16
-
"properties": {
17
-
"repo": {
18
-
"type": "string",
19
-
"format": "at-uri"
20
-
},
21
-
"key": {
22
-
"type": "string",
23
-
"maxLength": 50,
24
-
"minLength": 1
25
-
}
26
-
}
27
-
}
28
-
}
29
-
}
30
-
}
31
-
}
+37
lexicons/repo/addSecret.json
+37
lexicons/repo/addSecret.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.addSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Add a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key",
15
+
"value"
16
+
],
17
+
"properties": {
18
+
"repo": {
19
+
"type": "string",
20
+
"format": "at-uri"
21
+
},
22
+
"key": {
23
+
"type": "string",
24
+
"maxLength": 50,
25
+
"minLength": 1
26
+
},
27
+
"value": {
28
+
"type": "string",
29
+
"maxLength": 200,
30
+
"minLength": 1
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
}
+52
lexicons/repo/artifact.json
+52
lexicons/repo/artifact.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.artifact",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"repo",
15
+
"tag",
16
+
"createdAt",
17
+
"artifact"
18
+
],
19
+
"properties": {
20
+
"name": {
21
+
"type": "string",
22
+
"description": "name of the artifact"
23
+
},
24
+
"repo": {
25
+
"type": "string",
26
+
"format": "at-uri",
27
+
"description": "repo that this artifact is being uploaded to"
28
+
},
29
+
"tag": {
30
+
"type": "bytes",
31
+
"description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)",
32
+
"minLength": 20,
33
+
"maxLength": 20
34
+
},
35
+
"createdAt": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"description": "time of creation of this artifact"
39
+
},
40
+
"artifact": {
41
+
"type": "blob",
42
+
"description": "the artifact",
43
+
"accept": [
44
+
"*/*"
45
+
],
46
+
"maxSize": 52428800
47
+
}
48
+
}
49
+
}
50
+
}
51
+
}
52
+
}
+36
lexicons/repo/collaborator.json
+36
lexicons/repo/collaborator.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.collaborator",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"subject",
14
+
"repo",
15
+
"createdAt"
16
+
],
17
+
"properties": {
18
+
"subject": {
19
+
"type": "string",
20
+
"format": "did"
21
+
},
22
+
"repo": {
23
+
"type": "string",
24
+
"description": "repo to add this user to",
25
+
"format": "at-uri"
26
+
},
27
+
"createdAt": {
28
+
"type": "string",
29
+
"format": "datetime"
30
+
}
31
+
}
32
+
}
33
+
}
34
+
}
35
+
}
36
+
+29
lexicons/repo/defaultBranch.json
+29
lexicons/repo/defaultBranch.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.setDefaultBranch",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Set the default branch for a repository",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"defaultBranch"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"defaultBranch": {
22
+
"type": "string"
23
+
}
24
+
}
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
+67
lexicons/repo/listSecrets.json
+67
lexicons/repo/listSecrets.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.listSecrets",
4
+
"defs": {
5
+
"main": {
6
+
"type": "query",
7
+
"parameters": {
8
+
"type": "params",
9
+
"required": [
10
+
"repo"
11
+
],
12
+
"properties": {
13
+
"repo": {
14
+
"type": "string",
15
+
"format": "at-uri"
16
+
}
17
+
}
18
+
},
19
+
"output": {
20
+
"encoding": "application/json",
21
+
"schema": {
22
+
"type": "object",
23
+
"required": [
24
+
"secrets"
25
+
],
26
+
"properties": {
27
+
"secrets": {
28
+
"type": "array",
29
+
"items": {
30
+
"type": "ref",
31
+
"ref": "#secret"
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
},
38
+
"secret": {
39
+
"type": "object",
40
+
"required": [
41
+
"repo",
42
+
"key",
43
+
"createdAt",
44
+
"createdBy"
45
+
],
46
+
"properties": {
47
+
"repo": {
48
+
"type": "string",
49
+
"format": "at-uri"
50
+
},
51
+
"key": {
52
+
"type": "string",
53
+
"maxLength": 50,
54
+
"minLength": 1
55
+
},
56
+
"createdAt": {
57
+
"type": "string",
58
+
"format": "datetime"
59
+
},
60
+
"createdBy": {
61
+
"type": "string",
62
+
"format": "did"
63
+
}
64
+
}
65
+
}
66
+
}
67
+
}
+31
lexicons/repo/removeSecret.json
+31
lexicons/repo/removeSecret.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo.removeSecret",
4
+
"defs": {
5
+
"main": {
6
+
"type": "procedure",
7
+
"description": "Remove a CI secret",
8
+
"input": {
9
+
"encoding": "application/json",
10
+
"schema": {
11
+
"type": "object",
12
+
"required": [
13
+
"repo",
14
+
"key"
15
+
],
16
+
"properties": {
17
+
"repo": {
18
+
"type": "string",
19
+
"format": "at-uri"
20
+
},
21
+
"key": {
22
+
"type": "string",
23
+
"maxLength": 50,
24
+
"minLength": 1
25
+
}
26
+
}
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
+54
lexicons/repo/repo.json
+54
lexicons/repo/repo.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.repo",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "tid",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"name",
14
+
"knot",
15
+
"owner",
16
+
"createdAt"
17
+
],
18
+
"properties": {
19
+
"name": {
20
+
"type": "string",
21
+
"description": "name of the repo"
22
+
},
23
+
"owner": {
24
+
"type": "string",
25
+
"format": "did"
26
+
},
27
+
"knot": {
28
+
"type": "string",
29
+
"description": "knot where the repo was created"
30
+
},
31
+
"spindle": {
32
+
"type": "string",
33
+
"description": "CI runner to send jobs to and receive results from"
34
+
},
35
+
"description": {
36
+
"type": "string",
37
+
"format": "datetime",
38
+
"minGraphemes": 1,
39
+
"maxGraphemes": 140
40
+
},
41
+
"source": {
42
+
"type": "string",
43
+
"format": "uri",
44
+
"description": "source of the repo"
45
+
},
46
+
"createdAt": {
47
+
"type": "string",
48
+
"format": "datetime"
49
+
}
50
+
}
51
+
}
52
+
}
53
+
}
54
+
}
-54
lexicons/repo.json
-54
lexicons/repo.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.repo",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "tid",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"name",
14
-
"knot",
15
-
"owner",
16
-
"createdAt"
17
-
],
18
-
"properties": {
19
-
"name": {
20
-
"type": "string",
21
-
"description": "name of the repo"
22
-
},
23
-
"owner": {
24
-
"type": "string",
25
-
"format": "did"
26
-
},
27
-
"knot": {
28
-
"type": "string",
29
-
"description": "knot where the repo was created"
30
-
},
31
-
"spindle": {
32
-
"type": "string",
33
-
"description": "CI runner to send jobs to and receive results from"
34
-
},
35
-
"description": {
36
-
"type": "string",
37
-
"format": "datetime",
38
-
"minGraphemes": 1,
39
-
"maxGraphemes": 140
40
-
},
41
-
"source": {
42
-
"type": "string",
43
-
"format": "uri",
44
-
"description": "source of the repo"
45
-
},
46
-
"createdAt": {
47
-
"type": "string",
48
-
"format": "datetime"
49
-
}
50
-
}
51
-
}
52
-
}
53
-
}
54
-
}
+25
lexicons/spindle/spindle.json
+25
lexicons/spindle/spindle.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "sh.tangled.spindle",
4
+
"needsCbor": true,
5
+
"needsType": true,
6
+
"defs": {
7
+
"main": {
8
+
"type": "record",
9
+
"key": "any",
10
+
"record": {
11
+
"type": "object",
12
+
"required": [
13
+
"createdAt"
14
+
],
15
+
"properties": {
16
+
"createdAt": {
17
+
"type": "string",
18
+
"format": "datetime"
19
+
}
20
+
}
21
+
}
22
+
}
23
+
}
24
+
}
25
+
-25
lexicons/spindle.json
-25
lexicons/spindle.json
···
1
-
{
2
-
"lexicon": 1,
3
-
"id": "sh.tangled.spindle",
4
-
"needsCbor": true,
5
-
"needsType": true,
6
-
"defs": {
7
-
"main": {
8
-
"type": "record",
9
-
"key": "any",
10
-
"record": {
11
-
"type": "object",
12
-
"required": [
13
-
"createdAt"
14
-
],
15
-
"properties": {
16
-
"createdAt": {
17
-
"type": "string",
18
-
"format": "datetime"
19
-
}
20
-
}
21
-
}
22
-
}
23
-
}
24
-
}
25
-
+79
-19
nix/gomod2nix.toml
+79
-19
nix/gomod2nix.toml
···
11
11
version = "v0.6.2"
12
12
hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU="
13
13
[mod."github.com/ProtonMail/go-crypto"]
14
-
version = "v1.2.0"
15
-
hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo="
14
+
version = "v1.3.0"
15
+
hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI="
16
+
[mod."github.com/alecthomas/assert/v2"]
17
+
version = "v2.11.0"
18
+
hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU="
16
19
[mod."github.com/alecthomas/chroma/v2"]
17
20
version = "v2.19.0"
18
21
hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM="
19
22
replaced = "github.com/oppiliappan/chroma/v2"
23
+
[mod."github.com/alecthomas/repr"]
24
+
version = "v0.4.0"
25
+
hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU="
20
26
[mod."github.com/anmitsu/go-shlex"]
21
27
version = "v0.0.0-20200514113438-38f4b401e2be"
22
28
hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54="
···
51
57
[mod."github.com/casbin/govaluate"]
52
58
version = "v1.3.0"
53
59
hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA="
60
+
[mod."github.com/cenkalti/backoff/v4"]
61
+
version = "v4.3.0"
62
+
hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8="
54
63
[mod."github.com/cespare/xxhash/v2"]
55
64
version = "v2.3.0"
56
65
hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY="
57
66
[mod."github.com/cloudflare/circl"]
58
-
version = "v1.6.0"
59
-
hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0="
67
+
version = "v1.6.2-0.20250618153321-aa837fd1539d"
68
+
hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y="
60
69
[mod."github.com/containerd/errdefs"]
61
70
version = "v1.0.0"
62
71
hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI="
···
105
114
[mod."github.com/felixge/httpsnoop"]
106
115
version = "v1.0.4"
107
116
hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c="
117
+
[mod."github.com/fsnotify/fsnotify"]
118
+
version = "v1.6.0"
119
+
hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0="
108
120
[mod."github.com/gliderlabs/ssh"]
109
121
version = "v0.3.8"
110
122
hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc="
···
127
139
version = "v5.17.0"
128
140
hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ="
129
141
replaced = "github.com/oppiliappan/go-git/v5"
142
+
[mod."github.com/go-jose/go-jose/v3"]
143
+
version = "v3.0.4"
144
+
hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ="
130
145
[mod."github.com/go-logr/logr"]
131
146
version = "v1.4.3"
132
147
hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA="
···
136
151
[mod."github.com/go-redis/cache/v9"]
137
152
version = "v9.0.0"
138
153
hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY="
154
+
[mod."github.com/go-test/deep"]
155
+
version = "v1.1.1"
156
+
hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8="
139
157
[mod."github.com/goccy/go-json"]
140
158
version = "v0.10.5"
141
159
hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw="
···
148
166
[mod."github.com/golang/groupcache"]
149
167
version = "v0.0.0-20241129210726-2c02b8208cf8"
150
168
hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74="
169
+
[mod."github.com/golang/mock"]
170
+
version = "v1.6.0"
171
+
hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno="
151
172
[mod."github.com/google/uuid"]
152
173
version = "v1.6.0"
153
174
hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw="
···
161
182
version = "v1.4.0"
162
183
hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g="
163
184
[mod."github.com/gorilla/websocket"]
164
-
version = "v1.5.3"
165
-
hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0="
185
+
version = "v1.5.4-0.20250319132907-e064f32e3674"
186
+
hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to="
187
+
[mod."github.com/hashicorp/errwrap"]
188
+
version = "v1.1.0"
189
+
hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw="
166
190
[mod."github.com/hashicorp/go-cleanhttp"]
167
191
version = "v0.5.2"
168
192
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
193
+
[mod."github.com/hashicorp/go-multierror"]
194
+
version = "v1.1.1"
195
+
hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA="
169
196
[mod."github.com/hashicorp/go-retryablehttp"]
170
197
version = "v0.7.8"
171
198
hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80="
199
+
[mod."github.com/hashicorp/go-secure-stdlib/parseutil"]
200
+
version = "v0.2.0"
201
+
hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8="
202
+
[mod."github.com/hashicorp/go-secure-stdlib/strutil"]
203
+
version = "v0.1.2"
204
+
hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A="
205
+
[mod."github.com/hashicorp/go-sockaddr"]
206
+
version = "v1.0.7"
207
+
hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs="
172
208
[mod."github.com/hashicorp/golang-lru"]
173
209
version = "v1.0.2"
174
210
hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
175
211
[mod."github.com/hashicorp/golang-lru/v2"]
176
212
version = "v2.0.7"
177
213
hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g="
214
+
[mod."github.com/hashicorp/hcl"]
215
+
version = "v1.0.1-vault-7"
216
+
hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM="
217
+
[mod."github.com/hexops/gotextdiff"]
218
+
version = "v1.0.3"
219
+
hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0="
178
220
[mod."github.com/hiddeco/sshsig"]
179
221
version = "v0.2.0"
180
222
hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU="
···
256
298
[mod."github.com/minio/sha256-simd"]
257
299
version = "v1.0.1"
258
300
hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA="
301
+
[mod."github.com/mitchellh/mapstructure"]
302
+
version = "v1.5.0"
303
+
hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE="
259
304
[mod."github.com/moby/docker-image-spec"]
260
305
version = "v1.3.1"
261
306
hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs="
···
289
334
[mod."github.com/munnerz/goautoneg"]
290
335
version = "v0.0.0-20191010083416-a7dc8b61c822"
291
336
hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q="
337
+
[mod."github.com/onsi/gomega"]
338
+
version = "v1.37.0"
339
+
hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o="
340
+
[mod."github.com/openbao/openbao/api/v2"]
341
+
version = "v2.3.0"
342
+
hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM="
292
343
[mod."github.com/opencontainers/go-digest"]
293
344
version = "v1.0.0"
294
345
hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ="
···
296
347
version = "v1.1.1"
297
348
hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8="
298
349
[mod."github.com/opentracing/opentracing-go"]
299
-
version = "v1.2.0"
300
-
hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM="
350
+
version = "v1.2.1-0.20220228012449-10b1cf09e00b"
351
+
hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw="
301
352
[mod."github.com/pjbgf/sha1cd"]
302
353
version = "v0.3.2"
303
354
hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk="
···
326
377
version = "v0.16.1"
327
378
hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo="
328
379
[mod."github.com/redis/go-redis/v9"]
329
-
version = "v9.3.0"
330
-
hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w="
380
+
version = "v9.7.3"
381
+
hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo="
331
382
[mod."github.com/resend/resend-go/v2"]
332
383
version = "v2.15.0"
333
384
hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg="
385
+
[mod."github.com/ryanuber/go-glob"]
386
+
version = "v1.0.0"
387
+
hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY="
334
388
[mod."github.com/segmentio/asm"]
335
389
version = "v1.2.0"
336
390
hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs="
···
380
434
[mod."go.opentelemetry.io/otel"]
381
435
version = "v1.37.0"
382
436
hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo="
437
+
[mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"]
438
+
version = "v1.33.0"
439
+
hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I="
383
440
[mod."go.opentelemetry.io/otel/metric"]
384
441
version = "v1.37.0"
385
442
hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg="
···
405
462
version = "v0.0.0-20250620022241-b7579e27df2b"
406
463
hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig="
407
464
[mod."golang.org/x/net"]
408
-
version = "v0.41.0"
409
-
hash = "sha256-6/pi8rNmGvBFzkJQXkXkMfL1Bjydhg3BgAMYDyQ/Uvg="
465
+
version = "v0.42.0"
466
+
hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s="
410
467
[mod."golang.org/x/sync"]
411
-
version = "v0.15.0"
412
-
hash = "sha256-Jf4ehm8H8YAWY6mM151RI5CbG7JcOFtmN0AZx4bE3UE="
468
+
version = "v0.16.0"
469
+
hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks="
413
470
[mod."golang.org/x/sys"]
414
471
version = "v0.34.0"
415
472
hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50="
473
+
[mod."golang.org/x/text"]
474
+
version = "v0.27.0"
475
+
hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8="
416
476
[mod."golang.org/x/time"]
417
477
version = "v0.12.0"
418
478
hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw="
···
420
480
version = "v0.0.0-20240903120638-7835f813f4da"
421
481
hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo="
422
482
[mod."google.golang.org/genproto/googleapis/api"]
423
-
version = "v0.0.0-20250519155744-55703ea1f237"
424
-
hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ="
483
+
version = "v0.0.0-20250603155806-513f23925822"
484
+
hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU="
425
485
[mod."google.golang.org/genproto/googleapis/rpc"]
426
-
version = "v0.0.0-20250519155744-55703ea1f237"
486
+
version = "v0.0.0-20250603155806-513f23925822"
427
487
hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM="
428
488
[mod."google.golang.org/grpc"]
429
-
version = "v1.72.1"
430
-
hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs="
489
+
version = "v1.73.0"
490
+
hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c="
431
491
[mod."google.golang.org/protobuf"]
432
492
version = "v1.36.6"
433
493
hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc="
+2
-2
nix/modules/knot.nix
+2
-2
nix/modules/knot.nix
···
128
128
129
129
system.activationScripts.gitConfig = let
130
130
setMotd =
131
-
if cfg.motdFile != null && cfg.motd != null then
132
-
throw "motdFile and motd cannot be both set"
131
+
if cfg.motdFile != null && cfg.motd != null
132
+
then throw "motdFile and motd cannot be both set"
133
133
else ''
134
134
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
135
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
+2
-4
spindle/config/config.go
+2
-4
spindle/config/config.go
···
28
28
}
29
29
30
30
type OpenBaoConfig struct {
31
-
Addr string `env:"ADDR"`
32
-
RoleID string `env:"ROLE_ID"`
33
-
SecretID string `env:"SECRET_ID"`
34
-
Mount string `env:"MOUNT, default=spindle"`
31
+
ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"`
32
+
Mount string `env:"MOUNT, default=spindle"`
35
33
}
36
34
37
35
type Pipelines struct {
+122
-3
spindle/ingester.go
+122
-3
spindle/ingester.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
-
"path/filepath"
8
8
9
9
"tangled.sh/tangled.sh/core/api/tangled"
10
10
"tangled.sh/tangled.sh/core/eventconsumer"
11
+
"tangled.sh/tangled.sh/core/idresolver"
11
12
"tangled.sh/tangled.sh/core/rbac"
12
13
14
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
15
+
"github.com/bluesky-social/indigo/atproto/identity"
16
+
"github.com/bluesky-social/indigo/atproto/syntax"
17
+
"github.com/bluesky-social/indigo/xrpc"
13
18
"github.com/bluesky-social/jetstream/pkg/models"
19
+
securejoin "github.com/cyphar/filepath-securejoin"
14
20
)
15
21
16
22
type Ingester func(ctx context.Context, e *models.Event) error
···
35
41
s.ingestMember(ctx, e)
36
42
case tangled.RepoNSID:
37
43
s.ingestRepo(ctx, e)
44
+
case tangled.RepoCollaboratorNSID:
45
+
s.ingestCollaborator(ctx, e)
38
46
}
39
47
40
48
return err
···
92
100
return nil
93
101
}
94
102
95
-
func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error {
103
+
func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error {
96
104
var err error
105
+
did := e.Did
106
+
resolver := idresolver.DefaultResolver()
97
107
98
108
l := s.l.With("component", "ingester", "record", tangled.RepoNSID)
99
109
···
129
139
return fmt.Errorf("failed to add repo: %w", err)
130
140
}
131
141
142
+
didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name)
143
+
if err != nil {
144
+
return err
145
+
}
146
+
132
147
// add repo to rbac
133
-
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, filepath.Join(record.Owner, record.Name)); err != nil {
148
+
if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil {
134
149
l.Error("failed to add repo to enforcer", "error", err)
135
150
return fmt.Errorf("failed to add repo: %w", err)
136
151
}
137
152
153
+
// add collaborators to rbac
154
+
owner, err := resolver.ResolveIdent(ctx, did)
155
+
if err != nil || owner.Handle.IsInvalidHandle() {
156
+
return err
157
+
}
158
+
if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil {
159
+
return err
160
+
}
161
+
138
162
// add this knot to the event consumer
139
163
src := eventconsumer.NewKnotSource(record.Knot)
140
164
s.ks.AddSource(context.Background(), src)
···
144
168
}
145
169
return nil
146
170
}
171
+
172
+
func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error {
173
+
var err error
174
+
175
+
l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did)
176
+
177
+
l.Info("ingesting collaborator record")
178
+
179
+
switch e.Commit.Operation {
180
+
case models.CommitOperationCreate, models.CommitOperationUpdate:
181
+
raw := e.Commit.Record
182
+
record := tangled.RepoCollaborator{}
183
+
err = json.Unmarshal(raw, &record)
184
+
if err != nil {
185
+
l.Error("invalid record", "error", err)
186
+
return err
187
+
}
188
+
189
+
resolver := idresolver.DefaultResolver()
190
+
191
+
subjectId, err := resolver.ResolveIdent(ctx, record.Subject)
192
+
if err != nil || subjectId.Handle.IsInvalidHandle() {
193
+
return err
194
+
}
195
+
196
+
repoAt, err := syntax.ParseATURI(record.Repo)
197
+
if err != nil {
198
+
l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo)
199
+
return nil
200
+
}
201
+
202
+
// TODO: get rid of this entirely
203
+
// resolve this aturi to extract the repo record
204
+
owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String())
205
+
if err != nil || owner.Handle.IsInvalidHandle() {
206
+
return fmt.Errorf("failed to resolve handle: %w", err)
207
+
}
208
+
209
+
xrpcc := xrpc.Client{
210
+
Host: owner.PDSEndpoint(),
211
+
}
212
+
213
+
resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String())
214
+
if err != nil {
215
+
return err
216
+
}
217
+
218
+
repo := resp.Value.Val.(*tangled.Repo)
219
+
didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name)
220
+
221
+
// check perms for this user
222
+
if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil {
223
+
return fmt.Errorf("insufficient permissions: %w", err)
224
+
}
225
+
226
+
// add collaborator to rbac
227
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
228
+
l.Error("failed to add repo to enforcer", "error", err)
229
+
return fmt.Errorf("failed to add repo: %w", err)
230
+
}
231
+
232
+
return nil
233
+
}
234
+
return nil
235
+
}
236
+
237
+
func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error {
238
+
l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators")
239
+
240
+
l.Info("fetching and adding existing collaborators")
241
+
242
+
xrpcc := xrpc.Client{
243
+
Host: owner.PDSEndpoint(),
244
+
}
245
+
246
+
resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false)
247
+
if err != nil {
248
+
return err
249
+
}
250
+
251
+
var errs error
252
+
for _, r := range resp.Records {
253
+
if r == nil {
254
+
continue
255
+
}
256
+
record := r.Value.Val.(*tangled.RepoCollaborator)
257
+
258
+
if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil {
259
+
l.Error("failed to add repo to enforcer", "error", err)
260
+
errors.Join(errs, fmt.Errorf("failed to add repo: %w", err))
261
+
}
262
+
}
263
+
264
+
return errs
265
+
}
+55
-149
spindle/secrets/openbao.go
+55
-149
spindle/secrets/openbao.go
···
6
6
"log/slog"
7
7
"path"
8
8
"strings"
9
-
"sync"
10
9
"time"
11
10
12
11
"github.com/bluesky-social/indigo/atproto/syntax"
···
16
15
type OpenBaoManager struct {
17
16
client *vault.Client
18
17
mountPath string
19
-
roleID string
20
-
secretID string
21
-
stopCh chan struct{}
22
-
tokenMu sync.RWMutex
23
18
logger *slog.Logger
24
19
}
25
20
···
31
26
}
32
27
}
33
28
34
-
func NewOpenBaoManager(address, roleID, secretID string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
35
-
if address == "" {
36
-
return nil, fmt.Errorf("address cannot be empty")
37
-
}
38
-
if roleID == "" {
39
-
return nil, fmt.Errorf("role_id cannot be empty")
40
-
}
41
-
if secretID == "" {
42
-
return nil, fmt.Errorf("secret_id cannot be empty")
29
+
// NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy
30
+
// The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200")
31
+
// The proxy handles all authentication automatically via Auto-Auth
32
+
func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) {
33
+
if proxyAddress == "" {
34
+
return nil, fmt.Errorf("proxy address cannot be empty")
43
35
}
44
36
45
37
config := vault.DefaultConfig()
46
-
config.Address = address
38
+
config.Address = proxyAddress
47
39
48
40
client, err := vault.NewClient(config)
49
41
if err != nil {
50
42
return nil, fmt.Errorf("failed to create openbao client: %w", err)
51
43
}
52
44
53
-
// Authenticate using AppRole
54
-
err = authenticateAppRole(client, roleID, secretID)
55
-
if err != nil {
56
-
return nil, fmt.Errorf("failed to authenticate with AppRole: %w", err)
57
-
}
58
-
59
45
manager := &OpenBaoManager{
60
46
client: client,
61
47
mountPath: "spindle", // default KV v2 mount path
62
-
roleID: roleID,
63
-
secretID: secretID,
64
-
stopCh: make(chan struct{}),
65
48
logger: logger,
66
49
}
67
50
···
69
52
opt(manager)
70
53
}
71
54
72
-
go manager.tokenRenewalLoop()
73
-
74
-
return manager, nil
75
-
}
76
-
77
-
// authenticateAppRole authenticates the client using AppRole method
78
-
func authenticateAppRole(client *vault.Client, roleID, secretID string) error {
79
-
authData := map[string]interface{}{
80
-
"role_id": roleID,
81
-
"secret_id": secretID,
82
-
}
83
-
84
-
resp, err := client.Logical().Write("auth/approle/login", authData)
85
-
if err != nil {
86
-
return fmt.Errorf("failed to login with AppRole: %w", err)
87
-
}
88
-
89
-
if resp == nil || resp.Auth == nil {
90
-
return fmt.Errorf("no auth info returned from AppRole login")
91
-
}
92
-
93
-
client.SetToken(resp.Auth.ClientToken)
94
-
return nil
95
-
}
96
-
97
-
// stop stops the token renewal goroutine
98
-
func (v *OpenBaoManager) Stop() {
99
-
close(v.stopCh)
100
-
}
101
-
102
-
// tokenRenewalLoop runs in a background goroutine to automatically renew or re-authenticate tokens
103
-
func (v *OpenBaoManager) tokenRenewalLoop() {
104
-
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
105
-
defer ticker.Stop()
106
-
107
-
for {
108
-
select {
109
-
case <-v.stopCh:
110
-
return
111
-
case <-ticker.C:
112
-
ctx := context.Background()
113
-
if err := v.ensureValidToken(ctx); err != nil {
114
-
v.logger.Error("openbao token renewal failed", "error", err)
115
-
}
116
-
}
117
-
}
118
-
}
119
-
120
-
// ensureValidToken checks if the current token is valid and renews or re-authenticates if needed
121
-
func (v *OpenBaoManager) ensureValidToken(ctx context.Context) error {
122
-
v.tokenMu.Lock()
123
-
defer v.tokenMu.Unlock()
124
-
125
-
// check current token info
126
-
tokenInfo, err := v.client.Auth().Token().LookupSelf()
127
-
if err != nil {
128
-
// token is invalid, need to re-authenticate
129
-
v.logger.Warn("token lookup failed, re-authenticating", "error", err)
130
-
return v.reAuthenticate()
131
-
}
132
-
133
-
if tokenInfo == nil || tokenInfo.Data == nil {
134
-
return v.reAuthenticate()
135
-
}
136
-
137
-
// check TTL
138
-
ttlRaw, ok := tokenInfo.Data["ttl"]
139
-
if !ok {
140
-
return v.reAuthenticate()
141
-
}
142
-
143
-
var ttl int64
144
-
switch t := ttlRaw.(type) {
145
-
case int64:
146
-
ttl = t
147
-
case float64:
148
-
ttl = int64(t)
149
-
case int:
150
-
ttl = int64(t)
151
-
default:
152
-
return v.reAuthenticate()
55
+
if err := manager.testConnection(); err != nil {
56
+
return nil, fmt.Errorf("failed to connect to bao proxy: %w", err)
153
57
}
154
58
155
-
// if TTL is less than 5 minutes, try to renew
156
-
if ttl < 300 {
157
-
v.logger.Info("token ttl low, attempting renewal", "ttl_seconds", ttl)
158
-
159
-
renewResp, err := v.client.Auth().Token().RenewSelf(3600) // 1h
160
-
if err != nil {
161
-
v.logger.Warn("token renewal failed, re-authenticating", "error", err)
162
-
return v.reAuthenticate()
163
-
}
164
-
165
-
if renewResp == nil || renewResp.Auth == nil {
166
-
v.logger.Warn("token renewal returned no auth info, re-authenticating")
167
-
return v.reAuthenticate()
168
-
}
169
-
170
-
v.logger.Info("token renewed successfully", "new_ttl_seconds", renewResp.Auth.LeaseDuration)
171
-
}
172
-
173
-
return nil
59
+
logger.Info("successfully connected to bao proxy", "address", proxyAddress)
60
+
return manager, nil
174
61
}
175
62
176
-
// reAuthenticate performs a fresh authentication using AppRole
177
-
func (v *OpenBaoManager) reAuthenticate() error {
178
-
v.logger.Info("re-authenticating with approle")
63
+
// testConnection verifies that we can connect to the proxy
64
+
func (v *OpenBaoManager) testConnection() error {
65
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
66
+
defer cancel()
179
67
180
-
err := authenticateAppRole(v.client, v.roleID, v.secretID)
68
+
// try token self-lookup as a quick way to verify proxy works
69
+
// and is authenticated
70
+
_, err := v.client.Auth().Token().LookupSelfWithContext(ctx)
181
71
if err != nil {
182
-
return fmt.Errorf("re-authentication failed: %w", err)
72
+
return fmt.Errorf("proxy connection test failed: %w", err)
183
73
}
184
74
185
-
v.logger.Info("re-authentication successful")
186
75
return nil
187
76
}
188
77
189
78
func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error {
190
-
v.tokenMu.RLock()
191
-
defer v.tokenMu.RUnlock()
192
79
if err := ValidateKey(secret.Key); err != nil {
193
80
return err
194
81
}
195
82
196
83
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
197
-
198
-
fmt.Println(v.mountPath, secretPath)
84
+
v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath)
199
85
86
+
// Check if secret already exists
200
87
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
201
88
if err == nil && existing != nil {
89
+
v.logger.Debug("secret already exists", "path", secretPath)
202
90
return ErrKeyAlreadyPresent
203
91
}
204
92
···
210
98
"created_by": secret.CreatedBy.String(),
211
99
}
212
100
213
-
_, err = v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
101
+
v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath)
102
+
resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData)
214
103
if err != nil {
104
+
v.logger.Error("failed to write secret", "path", secretPath, "error", err)
215
105
return fmt.Errorf("failed to store secret in openbao: %w", err)
216
106
}
217
107
108
+
v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime)
109
+
110
+
v.logger.Debug("verifying secret was written", "path", secretPath)
111
+
readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
112
+
if err != nil {
113
+
v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err)
114
+
return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err)
115
+
}
116
+
117
+
if readBack == nil || readBack.Data == nil {
118
+
v.logger.Error("secret verification returned empty data", "path", secretPath)
119
+
return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath)
120
+
}
121
+
122
+
v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version)
218
123
return nil
219
124
}
220
125
221
126
func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error {
222
-
v.tokenMu.RLock()
223
-
defer v.tokenMu.RUnlock()
224
127
secretPath := v.buildSecretPath(secret.Repo, secret.Key)
225
128
129
+
// check if secret exists
226
130
existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
227
131
if err != nil || existing == nil {
228
132
return ErrKeyNotFound
···
233
137
return fmt.Errorf("failed to delete secret from openbao: %w", err)
234
138
}
235
139
140
+
v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key)
236
141
return nil
237
142
}
238
143
239
144
func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) {
240
-
v.tokenMu.RLock()
241
-
defer v.tokenMu.RUnlock()
242
145
repoPath := v.buildRepoPath(repo)
243
146
244
-
secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
147
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
245
148
if err != nil {
246
149
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
247
150
return []LockedSecret{}, nil
···
266
169
continue
267
170
}
268
171
269
-
secretPath := path.Join(repoPath, key)
172
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
270
173
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
271
174
if err != nil {
272
-
continue // Skip secrets we can't read
175
+
v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err)
176
+
continue
273
177
}
274
178
275
179
if secretData == nil || secretData.Data == nil {
···
308
212
secrets = append(secrets, secret)
309
213
}
310
214
215
+
v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets))
311
216
return secrets, nil
312
217
}
313
218
314
219
func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) {
315
-
v.tokenMu.RLock()
316
-
defer v.tokenMu.RUnlock()
317
220
repoPath := v.buildRepoPath(repo)
318
221
319
-
secretsList, err := v.client.Logical().List(fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
222
+
secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath))
320
223
if err != nil {
321
224
if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") {
322
225
return []UnlockedSecret{}, nil
···
341
244
continue
342
245
}
343
246
344
-
secretPath := path.Join(repoPath, key)
247
+
secretPath := fmt.Sprintf("%s/%s", repoPath, key)
345
248
secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath)
346
249
if err != nil {
250
+
v.logger.Warn("failed to read secret", "path", secretPath, "error", err)
347
251
continue
348
252
}
349
253
···
355
259
356
260
valueStr, ok := data["value"].(string)
357
261
if !ok {
358
-
continue // skip secrets without values
262
+
v.logger.Warn("secret missing value", "path", secretPath)
263
+
continue
359
264
}
360
265
361
266
createdAtStr, ok := data["created_at"].(string)
···
389
294
secrets = append(secrets, secret)
390
295
}
391
296
297
+
v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets))
392
298
return secrets, nil
393
299
}
394
300
395
-
// buildRepoPath creates an OpenBao path for a repository
301
+
// buildRepoPath creates a safe path for a repository
396
302
func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string {
397
303
// convert DidSlashRepo to a safe path by replacing special characters
398
304
repoPath := strings.ReplaceAll(string(repo), "/", "_")
···
401
307
return fmt.Sprintf("repos/%s", repoPath)
402
308
}
403
309
404
-
// buildSecretPath creates an OpenBao path for a specific secret
310
+
// buildSecretPath creates a path for a specific secret
405
311
func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string {
406
312
return path.Join(v.buildRepoPath(repo), key)
407
313
}
+59
-84
spindle/secrets/openbao_test.go
+59
-84
spindle/secrets/openbao_test.go
···
16
16
secrets map[string]UnlockedSecret // key: repo_key format
17
17
shouldError bool
18
18
errorToReturn error
19
-
stopped bool
20
19
}
21
20
22
21
func NewMockOpenBaoManager() *MockOpenBaoManager {
···
31
30
func (m *MockOpenBaoManager) ClearError() {
32
31
m.shouldError = false
33
32
m.errorToReturn = nil
34
-
}
35
-
36
-
func (m *MockOpenBaoManager) Stop() {
37
-
m.stopped = true
38
-
}
39
-
40
-
func (m *MockOpenBaoManager) IsStopped() bool {
41
-
return m.stopped
42
33
}
43
34
44
35
func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string {
···
118
109
}
119
110
}
120
111
112
+
// Test MockOpenBaoManager interface compliance
113
+
func TestMockOpenBaoManagerInterface(t *testing.T) {
114
+
var _ Manager = (*MockOpenBaoManager)(nil)
115
+
}
116
+
121
117
func TestOpenBaoManagerInterface(t *testing.T) {
122
118
var _ Manager = (*OpenBaoManager)(nil)
123
119
}
···
125
121
func TestNewOpenBaoManager(t *testing.T) {
126
122
tests := []struct {
127
123
name string
128
-
address string
129
-
roleID string
130
-
secretID string
124
+
proxyAddr string
131
125
opts []OpenBaoManagerOpt
132
126
expectError bool
133
127
errorContains string
134
128
}{
135
129
{
136
-
name: "empty address",
137
-
address: "",
138
-
roleID: "test-role-id",
139
-
secretID: "test-secret-id",
130
+
name: "empty proxy address",
131
+
proxyAddr: "",
140
132
opts: nil,
141
133
expectError: true,
142
-
errorContains: "address cannot be empty",
134
+
errorContains: "proxy address cannot be empty",
143
135
},
144
136
{
145
-
name: "empty role_id",
146
-
address: "http://localhost:8200",
147
-
roleID: "",
148
-
secretID: "test-secret-id",
137
+
name: "valid proxy address",
138
+
proxyAddr: "http://localhost:8200",
149
139
opts: nil,
150
-
expectError: true,
151
-
errorContains: "role_id cannot be empty",
140
+
expectError: true, // Will fail because no real proxy is running
141
+
errorContains: "failed to connect to bao proxy",
152
142
},
153
143
{
154
-
name: "empty secret_id",
155
-
address: "http://localhost:8200",
156
-
roleID: "test-role-id",
157
-
secretID: "",
158
-
opts: nil,
159
-
expectError: true,
160
-
errorContains: "secret_id cannot be empty",
144
+
name: "with mount path option",
145
+
proxyAddr: "http://localhost:8200",
146
+
opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")},
147
+
expectError: true, // Will fail because no real proxy is running
148
+
errorContains: "failed to connect to bao proxy",
161
149
},
162
150
}
163
151
164
152
for _, tt := range tests {
165
153
t.Run(tt.name, func(t *testing.T) {
166
154
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
167
-
manager, err := NewOpenBaoManager(tt.address, tt.roleID, tt.secretID, logger, tt.opts...)
155
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...)
168
156
169
157
if tt.expectError {
170
158
assert.Error(t, err)
171
159
assert.Nil(t, manager)
172
160
assert.Contains(t, err.Error(), tt.errorContains)
173
161
} else {
174
-
// For valid configurations, we expect an error during authentication
175
-
// since we're not connecting to a real OpenBao server
176
-
assert.Error(t, err)
177
-
assert.Nil(t, manager)
162
+
assert.NoError(t, err)
163
+
assert.NotNil(t, manager)
178
164
}
179
165
})
180
166
}
···
253
239
assert.Equal(t, "custom-mount", manager.mountPath)
254
240
}
255
241
256
-
func TestOpenBaoManager_Stop(t *testing.T) {
257
-
// Create a manager with minimal setup
258
-
manager := &OpenBaoManager{
259
-
mountPath: "test",
260
-
stopCh: make(chan struct{}),
261
-
}
262
-
263
-
// Verify the manager implements Stopper interface
264
-
var stopper Stopper = manager
265
-
assert.NotNil(t, stopper)
266
-
267
-
// Call Stop and verify it doesn't panic
268
-
assert.NotPanics(t, func() {
269
-
manager.Stop()
270
-
})
271
-
272
-
// Verify the channel was closed
273
-
select {
274
-
case <-manager.stopCh:
275
-
// Channel was closed as expected
276
-
default:
277
-
t.Error("Expected stop channel to be closed after Stop()")
278
-
}
279
-
}
280
-
281
-
func TestOpenBaoManager_StopperInterface(t *testing.T) {
282
-
manager := &OpenBaoManager{}
283
-
284
-
// Verify that OpenBaoManager implements the Stopper interface
285
-
_, ok := interface{}(manager).(Stopper)
286
-
assert.True(t, ok, "OpenBaoManager should implement Stopper interface")
287
-
}
288
-
289
-
// Test MockOpenBaoManager interface compliance
290
-
func TestMockOpenBaoManagerInterface(t *testing.T) {
291
-
var _ Manager = (*MockOpenBaoManager)(nil)
292
-
var _ Stopper = (*MockOpenBaoManager)(nil)
293
-
}
294
-
295
242
func TestMockOpenBaoManager_AddSecret(t *testing.T) {
296
243
tests := []struct {
297
244
name string
···
563
510
assert.NoError(t, err)
564
511
}
565
512
566
-
func TestMockOpenBaoManager_Stop(t *testing.T) {
567
-
mock := NewMockOpenBaoManager()
568
-
569
-
assert.False(t, mock.IsStopped())
570
-
571
-
mock.Stop()
572
-
573
-
assert.True(t, mock.IsStopped())
574
-
}
575
-
576
513
func TestMockOpenBaoManager_Integration(t *testing.T) {
577
514
tests := []struct {
578
515
name string
···
628
565
})
629
566
}
630
567
}
568
+
569
+
func TestOpenBaoManager_ProxyConfiguration(t *testing.T) {
570
+
tests := []struct {
571
+
name string
572
+
proxyAddr string
573
+
description string
574
+
}{
575
+
{
576
+
name: "default_localhost",
577
+
proxyAddr: "http://127.0.0.1:8200",
578
+
description: "Should connect to default localhost proxy",
579
+
},
580
+
{
581
+
name: "custom_host",
582
+
proxyAddr: "http://bao-proxy:8200",
583
+
description: "Should connect to custom proxy host",
584
+
},
585
+
{
586
+
name: "https_proxy",
587
+
proxyAddr: "https://127.0.0.1:8200",
588
+
description: "Should connect to HTTPS proxy",
589
+
},
590
+
}
591
+
592
+
for _, tt := range tests {
593
+
t.Run(tt.name, func(t *testing.T) {
594
+
t.Log("Testing scenario:", tt.description)
595
+
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
596
+
597
+
// All these will fail because no real proxy is running
598
+
// but we can test that the configuration is properly accepted
599
+
manager, err := NewOpenBaoManager(tt.proxyAddr, logger)
600
+
assert.Error(t, err) // Expected because no real proxy
601
+
assert.Nil(t, manager)
602
+
assert.Contains(t, err.Error(), "failed to connect to bao proxy")
603
+
})
604
+
}
605
+
}
+13
-6
spindle/secrets/policy.hcl
+13
-6
spindle/secrets/policy.hcl
···
1
-
# KV v2 data operations
2
-
path "spindle/data/*" {
1
+
# Allow full access to the spindle KV mount
2
+
path "spindle/*" {
3
3
capabilities = ["create", "read", "update", "delete", "list"]
4
4
}
5
5
6
-
# KV v2 metadata operations (needed for listing)
6
+
path "spindle/data/*" {
7
+
capabilities = ["create", "read", "update", "delete"]
8
+
}
9
+
7
10
path "spindle/metadata/*" {
8
11
capabilities = ["list", "read", "delete"]
9
12
}
10
13
11
-
# Root path access (needed for mount-level operations)
12
-
path "spindle/*" {
13
-
capabilities = ["list"]
14
+
# Allow listing mounts (for connection testing)
15
+
path "sys/mounts" {
16
+
capabilities = ["read"]
14
17
}
15
18
19
+
# Allow token self-lookup (for health checks)
20
+
path "auth/token/lookup-self" {
21
+
capabilities = ["read"]
22
+
}
+5
-12
spindle/server.go
+5
-12
spindle/server.go
···
71
71
var vault secrets.Manager
72
72
switch cfg.Server.Secrets.Provider {
73
73
case "openbao":
74
-
if cfg.Server.Secrets.OpenBao.Addr == "" {
75
-
return fmt.Errorf("openbao address is required when using openbao secrets provider")
76
-
}
77
-
if cfg.Server.Secrets.OpenBao.RoleID == "" {
78
-
return fmt.Errorf("openbao role_id is required when using openbao secrets provider")
79
-
}
80
-
if cfg.Server.Secrets.OpenBao.SecretID == "" {
81
-
return fmt.Errorf("openbao secret_id is required when using openbao secrets provider")
74
+
if cfg.Server.Secrets.OpenBao.ProxyAddr == "" {
75
+
return fmt.Errorf("openbao proxy address is required when using openbao secrets provider")
82
76
}
83
77
vault, err = secrets.NewOpenBaoManager(
84
-
cfg.Server.Secrets.OpenBao.Addr,
85
-
cfg.Server.Secrets.OpenBao.RoleID,
86
-
cfg.Server.Secrets.OpenBao.SecretID,
78
+
cfg.Server.Secrets.OpenBao.ProxyAddr,
87
79
logger,
88
80
secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount),
89
81
)
90
82
if err != nil {
91
83
return fmt.Errorf("failed to setup openbao secrets provider: %w", err)
92
84
}
93
-
logger.Info("using openbao secrets provider", "address", cfg.Server.Secrets.OpenBao.Addr, "mount", cfg.Server.Secrets.OpenBao.Mount)
85
+
logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount)
94
86
case "sqlite", "":
95
87
vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets"))
96
88
if err != nil {
···
111
103
collections := []string{
112
104
tangled.SpindleMemberNSID,
113
105
tangled.RepoNSID,
106
+
tangled.RepoCollaboratorNSID,
114
107
}
115
108
jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true)
116
109
if err != nil {