forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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 + }
+65 -2
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 ··· 578 585 return nil 579 586 }) 580 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 641 + }) 642 + 581 643 return &DB{db}, nil 582 644 } 583 645 ··· 653 715 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 654 716 if kind == reflect.Slice || kind == reflect.Array { 655 717 if rv.Len() == 0 { 656 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 718 + // always false 719 + return "1 = 0" 657 720 } 658 721 659 722 placeholders := make([]string, rv.Len()) ··· 672 735 kind := rv.Kind() 673 736 if kind == reflect.Slice || kind == reflect.Array { 674 737 if rv.Len() == 0 { 675 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 738 + return nil 676 739 } 677 740 678 741 out := make([]any, rv.Len())
+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
··· 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
··· 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
··· 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
··· 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
+1 -1
appview/pages/templates/knots/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Id }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addKnotMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+19 -30
appview/pages/templates/layouts/base.html
··· 14 14 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 15 15 {{ block "extrameta" . }}{{ end }} 16 16 </head> 17 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 - <div class="px-1"> 19 - {{ block "topbarLayout" . }} 20 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 21 - <header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 22 - {{ template "layouts/topbar" . }} 23 - </header> 24 - </div> 25 - {{ end }} 26 - </div> 17 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 + {{ block "topbarLayout" . }} 19 + <header class="px-1 col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 20 + {{ template "layouts/topbar" . }} 21 + </header> 22 + {{ end }} 27 23 28 - <div class="px-1 flex flex-col min-h-screen gap-4"> 29 - {{ block "contentLayout" . }} 30 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 24 + {{ block "mainLayout" . }} 25 + <div class="px-1 col-span-1 md:col-start-3 md:col-span-8 flex flex-col gap-4"> 26 + {{ block "contentLayout" . }} 31 27 <div class="col-span-1 md:col-span-2"> 32 28 {{ block "contentLeft" . }} {{ end }} 33 29 </div> ··· 37 33 <div class="col-span-1 md:col-span-2"> 38 34 {{ block "contentRight" . }} {{ end }} 39 35 </div> 40 - </div> 41 - {{ end }} 42 - 43 - {{ block "contentAfterLayout" . }} 44 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 36 + {{ end }} 37 + 38 + {{ block "contentAfterLayout" . }} 45 39 <div class="col-span-1 md:col-span-2"> 46 40 {{ block "contentAfterLeft" . }} {{ end }} 47 41 </div> ··· 51 45 <div class="col-span-1 md:col-span-2"> 52 46 {{ block "contentAfterRight" . }} {{ end }} 53 47 </div> 54 - </div> 55 - {{ end }} 56 - </div> 57 - 58 - <div class="px-1 mt-16"> 59 - {{ block "footerLayout" . }} 60 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 61 - <footer class="col-span-1 md:col-start-3 md:col-span-8"> 62 - {{ template "layouts/footer" . }} 63 - </footer> 48 + {{ end }} 64 49 </div> 65 - {{ end }} 66 - </div> 50 + {{ end }} 67 51 52 + {{ block "footerLayout" . }} 53 + <footer class="px-1 col-span-1 md:col-start-3 md:col-span-8 mt-12"> 54 + {{ template "layouts/footer" . }} 55 + </footer> 56 + {{ end }} 68 57 </body> 69 58 </html> 70 59 {{ end }}
+41 -3
appview/pages/templates/layouts/footer.html
··· 1 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 12 + <div class="flex flex-col gap-1"> 13 + <div class="font-medium text-xs uppercase tracking-wide mb-1">legal</div> 14 + <a href="/terms" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline flex gap-1 items-center">{{ i "file-text" "w-4 h-4 flex-shrink-0" }} terms of service</a> 15 + <a href="/privacy" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "shield" "w-4 h-4 flex-shrink-0" }} privacy policy</a> 16 + </div> 17 + 18 + <div class="flex flex-col gap-1"> 19 + <div class="font-medium text-xs uppercase tracking-wide mb-1">resources</div> 20 + <a href="https://blog.tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "book-open" "w-4 h-4 flex-shrink-0" }} blog</a> 21 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "book" "w-4 h-4 flex-shrink-0" }} docs</a> 22 + <a href="https://tangled.sh/@tangled.sh/core" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "code" "w-4 h-4 flex-shrink-0" }} source</a> 23 + </div> 24 + 25 + <div class="flex flex-col gap-1"> 26 + <div class="font-medium text-xs uppercase tracking-wide mb-1">social</div> 27 + <a href="https://chat.tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "message-circle" "w-4 h-4 flex-shrink-0" }} discord</a> 28 + <a href="https://web.libera.chat/#tangled" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ i "hash" "w-4 h-4 flex-shrink-0" }} irc</a> 29 + <a href="https://bsky.app/profile/tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" "w-4 h-4 flex-shrink-0 hover:text-gray-900 dark:hover:text-gray-200dark:text-white" }} bluesky</a> 30 + </div> 31 + 32 + <div class="flex flex-col gap-1"> 33 + <div class="font-medium text-xs uppercase tracking-wide mb-1">contact</div> 34 + <a href="mailto:team@tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 35 + <a href="mailto:security@tangled.sh" class="hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 36 + </div> 37 + </div> 38 + 39 + <div class="text-center lg:text-right flex-shrink-0"> 40 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 41 + </div> 42 + </div> 5 43 </div> 6 44 </div> 7 45 {{ end }}
+1 -13
appview/pages/templates/layouts/topbar.html
··· 1 1 {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 3 <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 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
··· 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
··· 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 }}
+20 -14
appview/pages/templates/repo/commit.html
··· 80 80 {{end}} 81 81 82 82 {{ define "topbarLayout" }} 83 - <header style="z-index: 20;"> 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 84 {{ template "layouts/topbar" . }} 85 85 </header> 86 86 {{ end }} 87 87 88 - {{ define "contentLayout" }} 89 - {{ block "content" . }}{{ end }} 90 - {{ end }} 88 + {{ define "mainLayout" }} 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 + {{ block "contentLayout" . }} 91 + {{ block "content" . }}{{ end }} 92 + {{ end }} 91 93 92 - {{ define "contentAfterLayout" }} 93 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 94 - <div class="col-span-1 md:col-span-2"> 95 - {{ block "contentAfterLeft" . }} {{ end }} 94 + {{ block "contentAfterLayout" . }} 95 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 96 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 97 + {{ block "contentAfterLeft" . }} {{ end }} 98 + </div> 99 + <main class="col-span-1 md:col-span-10"> 100 + {{ block "contentAfter" . }}{{ end }} 101 + </main> 96 102 </div> 97 - <main class="col-span-1 md:col-span-10"> 98 - {{ block "contentAfter" . }}{{ end }} 99 - </main> 103 + {{ end }} 100 104 </div> 101 105 {{ end }} 102 106 103 - {{ define "footerLayout" }} 104 - {{ template "layouts/footer" . }} 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/footer" . }} 110 + </footer> 105 111 {{ end }} 106 112 107 113 {{ define "contentAfter" }} ··· 112 118 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 113 119 {{ template "repo/fragments/diffOpts" .DiffOpts }} 114 120 </div> 115 - <div class="sticky top-0 mt-4"> 121 + <div class="sticky top-0 flex-grow max-h-screen"> 116 122 {{ template "repo/fragments/diffChangedFiles" .Diff }} 117 123 </div> 118 124 {{end}}
+22 -14
appview/pages/templates/repo/compare/compare.html
··· 11 11 {{ end }} 12 12 13 13 {{ define "topbarLayout" }} 14 - {{ template "layouts/topbar" . }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/topbar" . }} 16 + </header> 15 17 {{ end }} 16 18 17 - {{ define "contentLayout" }} 18 - {{ block "content" . }}{{ end }} 19 - {{ end }} 19 + {{ define "mainLayout" }} 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 + {{ block "contentLayout" . }} 22 + {{ block "content" . }}{{ end }} 23 + {{ end }} 20 24 21 - {{ define "contentAfterLayout" }} 22 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 23 - <div class="col-span-1 md:col-span-2"> 24 - {{ block "contentAfterLeft" . }} {{ end }} 25 + {{ block "contentAfterLayout" . }} 26 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 + {{ block "contentAfterLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-10"> 31 + {{ block "contentAfter" . }}{{ end }} 32 + </main> 25 33 </div> 26 - <main class="col-span-1 md:col-span-10"> 27 - {{ block "contentAfter" . }}{{ end }} 28 - </main> 34 + {{ end }} 29 35 </div> 30 36 {{ end }} 31 37 32 - {{ define "footerLayout" }} 33 - {{ template "layouts/footer" . }} 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/footer" . }} 41 + </footer> 34 42 {{ end }} 35 43 36 44 {{ define "contentAfter" }} ··· 41 49 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 42 50 {{ template "repo/fragments/diffOpts" .DiffOpts }} 43 51 </div> 44 - <div class="sticky top-0 mt-4"> 52 + <div class="sticky top-0 flex-grow max-h-screen"> 45 53 {{ template "repo/fragments/diffChangedFiles" .Diff }} 46 54 </div> 47 55 {{end}}
+1 -1
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 1 {{ define "repo/fragments/diffChangedFiles" }} 2 2 {{ $stat := .Stat }} 3 3 {{ $fileTree := fileTree .ChangedFiles }} 4 - <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 5 <div class="diff-stat"> 6 6 <div class="flex gap-2 items-center"> 7 7 <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong>
+1 -1
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 1 {{ define "repo/fragments/interdiffFiles" }} 2 2 {{ $fileTree := fileTree .AffectedFiles }} 3 - <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm md:min-h-screen text-sm"> 3 + <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 4 <div class="diff-stat"> 5 5 <div class="flex gap-2 items-center"> 6 6 <strong class="text-sm uppercase dark:text-gray-200">files</strong>
+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 -4
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 6 <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 7 ··· 9 9 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 10 {{ if $isIssueAuthor }} 11 11 <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 12 author 14 - </span> 15 13 {{ end }} 16 14 17 15 <span class="before:content-['ยท']"></span> 18 16 <a 19 17 href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 18 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 21 19 id="{{ .CommentId }}"> 22 20 {{ template "repo/fragments/time" .Created }} 23 21 </a>
+7 -8
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 5 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 6 {{ template "user/fragments/picHandleLink" $owner }} 7 7 8 + <!-- show user "hats" --> 9 + {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 + {{ if $isIssueAuthor }} 11 + <span class="before:content-['ยท']"></span> 12 + author 13 + {{ end }} 14 + 8 15 <span class="before:content-['ยท']"></span> 9 16 <a 10 17 href="#{{ .CommentId }}" ··· 18 25 {{ template "repo/fragments/time" .Created }} 19 26 {{ end }} 20 27 </a> 21 - 22 - <!-- show user "hats" --> 23 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 - {{ if $isIssueAuthor }} 25 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 26 - author 27 - </span> 28 - {{ end }} 29 28 30 29 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 30 {{ if and $isCommentOwner (not .Deleted) }}
+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>
+22 -14
appview/pages/templates/repo/pulls/interdiff.html
··· 29 29 {{ end }} 30 30 31 31 {{ define "topbarLayout" }} 32 - {{ template "layouts/topbar" . }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/topbar" . }} 34 + </header> 33 35 {{ end }} 34 36 35 - {{ define "contentLayout" }} 36 - {{ block "content" . }}{{ end }} 37 - {{ end }} 37 + {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 + {{ block "contentLayout" . }} 40 + {{ block "content" . }}{{ end }} 41 + {{ end }} 38 42 39 - {{ define "contentAfterLayout" }} 40 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 41 - <div class="col-span-1 md:col-span-2"> 42 - {{ block "contentAfterLeft" . }} {{ end }} 43 + {{ block "contentAfterLayout" . }} 44 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-10"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 43 51 </div> 44 - <main class="col-span-1 md:col-span-10"> 45 - {{ block "contentAfter" . }}{{ end }} 46 - </main> 52 + {{ end }} 47 53 </div> 48 54 {{ end }} 49 55 50 - {{ define "footerLayout" }} 51 - {{ template "layouts/footer" . }} 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/footer" . }} 59 + </footer> 52 60 {{ end }} 53 61 54 62 ··· 60 68 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 61 69 {{ template "repo/fragments/diffOpts" .DiffOpts }} 62 70 </div> 63 - <div class="sticky top-0 mt-4"> 71 + <div class="sticky top-0 flex-grow max-h-screen"> 64 72 {{ template "repo/fragments/interdiffFiles" .Interdiff }} 65 73 </div> 66 74 {{end}}
+22 -14
appview/pages/templates/repo/pulls/patch.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "topbarLayout" }} 38 - {{ template "layouts/topbar" . }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/topbar" . }} 40 + </header> 39 41 {{ end }} 40 42 41 - {{ define "contentLayout" }} 42 - {{ block "content" . }}{{ end }} 43 - {{ end }} 43 + {{ define "mainLayout" }} 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + {{ block "content" . }}{{ end }} 47 + {{ end }} 44 48 45 - {{ define "contentAfterLayout" }} 46 - <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 47 - <div class="col-span-1 md:col-span-2"> 48 - {{ block "contentAfterLeft" . }} {{ end }} 49 + {{ block "contentAfterLayout" . }} 50 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 51 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 52 + {{ block "contentAfterLeft" . }} {{ end }} 53 + </div> 54 + <main class="col-span-1 md:col-span-10"> 55 + {{ block "contentAfter" . }}{{ end }} 56 + </main> 49 57 </div> 50 - <main class="col-span-1 md:col-span-10"> 51 - {{ block "contentAfter" . }}{{ end }} 52 - </main> 58 + {{ end }} 53 59 </div> 54 60 {{ end }} 55 61 56 - {{ define "footerLayout" }} 57 - {{ template "layouts/footer" . }} 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/footer" . }} 65 + </footer> 58 66 {{ end }} 59 67 60 68 {{ define "contentAfter" }} ··· 65 73 <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 66 74 {{ template "repo/fragments/diffOpts" .DiffOpts }} 67 75 </div> 68 - <div class="sticky top-0 mt-4"> 76 + <div class="sticky top-0 flex-grow max-h-screen"> 69 77 {{ template "repo/fragments/diffChangedFiles" .Diff }} 70 78 </div> 71 79 {{end}}
+1 -1
appview/pages/templates/repo/pulls/pulls.html
··· 105 105 </span> 106 106 107 107 {{ $pipeline := index $.Pipelines .LatestSha }} 108 - {{ if $pipeline }} 108 + {{ if and $pipeline $pipeline.Id }} 109 109 <span class="before:content-['ยท']"></span> 110 110 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 111 111 {{ end }}
+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
+1 -1
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 13 13 <div 14 14 id="add-member-{{ .Instance }}" 15 15 popover 16 - class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 17 {{ block "addMemberPopover" . }} {{ end }} 18 18 </div> 19 19 {{ end }}
+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 &middot; 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
··· 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 &middot; tangled</title> 28 + <title>login or sign up &middot; 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 40 40 tangled.PublicKey{}, 41 41 tangled.Repo{}, 42 42 tangled.RepoArtifact{}, 43 + tangled.RepoCollaborator{}, 43 44 tangled.RepoIssue{}, 44 45 tangled.RepoIssueComment{}, 45 46 tangled.RepoIssueState{},
+12
docs/knot-hosting.md
··· 191 191 ``` 192 192 193 193 Make sure to restart your SSH server! 194 + 195 + #### MOTD (message of the day) 196 + 197 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 198 + `/home/git/motd` file: 199 + 200 + ``` 201 + printf "Hi from this knot!\n" > /home/git/motd 202 + ``` 203 + 204 + Note that you should add a newline at the end if setting a non-empty message 205 + since the knot won't do this for you.
+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
··· 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
··· 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=
+19 -3
guard/guard.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 6 8 "log/slog" 7 9 "net/http" 8 10 "net/url" ··· 43 45 Usage: "internal API endpoint", 44 46 Value: "http://localhost:5444", 45 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 46 53 }, 47 54 } 48 55 } ··· 54 61 gitDir := cmd.String("git-dir") 55 62 logPath := cmd.String("log-path") 56 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 57 65 58 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 67 if err != nil { ··· 149 157 "fullPath", fullPath, 150 158 "client", clientIP) 151 159 152 - if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 154 166 } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 167 + motdReader = reader 168 + } 169 + if gitCommand == "git-upload-pack" { 170 + io.WriteString(os.Stderr, "\x02") 156 171 } 172 + io.Copy(os.Stderr, motdReader) 157 173 158 174 gitCmd := exec.Command(gitCommand, fullPath) 159 175 gitCmd.Stdout = os.Stdout
+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
··· 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
··· 76 76 tangled.PublicKeyNSID, 77 77 tangled.KnotMemberNSID, 78 78 tangled.RepoPullNSID, 79 + tangled.RepoCollaboratorNSID, 79 80 }, nil, logger, db, true, c.Server.LogDids) 80 81 if err != nil { 81 82 logger.Error("failed to setup jetstream", "error", err)
-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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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="
+32 -1
nix/modules/knot.nix
··· 58 58 }; 59 59 }; 60 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 61 83 server = { 62 84 listenAddr = mkOption { 63 85 type = types.str; ··· 104 126 cfg.package 105 127 ]; 106 128 107 - system.activationScripts.gitConfig = '' 129 + system.activationScripts.gitConfig = let 130 + setMotd = 131 + if cfg.motdFile != null && cfg.motd != null 132 + then throw "motdFile and motd cannot be both set" 133 + else '' 134 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 135 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 136 + ''; 137 + in '' 108 138 mkdir -p "${cfg.repo.scanPath}" 109 139 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 110 140 ··· 116 146 [receive] 117 147 advertisePushOptions = true 118 148 EOF 149 + ${setMotd} 119 150 chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 120 151 ''; 121 152
+1
nix/vm.nix
··· 48 48 ]; 49 49 services.tangled-knot = { 50 50 enable = true; 51 + motd = "Welcome to the development knot!\n"; 51 52 server = { 52 53 secretFile = "/var/lib/knot/secret"; 53 54 hostname = "localhost:6000";
+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
··· 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
··· 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
··· 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
··· 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
··· 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 {