···11+{{ define "title" }} privacy policy {{ end }}
22+{{ define "content" }}
33+<div class="max-w-4xl mx-auto px-4 py-8">
44+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
55+ <div class="prose prose-gray dark:prose-invert max-w-none">
66+ <h1>Privacy Policy</h1>
77+88+ <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
99+1010+ <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>
1111+1212+ <h2>1. Information We Collect</h2>
1313+1414+ <h3>Account Information</h3>
1515+ <p>When you create an account, we collect:</p>
1616+ <ul>
1717+ <li>Your chosen username</li>
1818+ <li>Email address</li>
1919+ <li>Profile information you choose to provide</li>
2020+ <li>Authentication data</li>
2121+ </ul>
2222+2323+ <h3>Content and Activity</h3>
2424+ <p>We store:</p>
2525+ <ul>
2626+ <li>Code repositories and associated metadata</li>
2727+ <li>Issues, pull requests, and comments</li>
2828+ <li>Activity logs and usage patterns</li>
2929+ <li>Public keys for authentication</li>
3030+ </ul>
3131+3232+ <h2>2. Data Location and Hosting</h2>
3333+ <div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 my-6">
3434+ <h3 class="text-blue-800 dark:text-blue-200 font-semibold mb-2">EU Data Hosting</h3>
3535+ <p class="text-blue-700 dark:text-blue-300">
3636+ <strong>All Tangled service data is hosted within the European Union.</strong> Specifically:
3737+ </p>
3838+ <ul class="text-blue-700 dark:text-blue-300 mt-2">
3939+ <li><strong>Personal Data Servers (PDS):</strong> Accounts hosted on Tangled PDS (*.tngl.sh) are located in Finland</li>
4040+ <li><strong>Application Data:</strong> All other service data is stored on EU-based servers</li>
4141+ <li><strong>Data Processing:</strong> All data processing occurs within EU jurisdiction</li>
4242+ </ul>
4343+ </div>
4444+4545+ <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 my-6">
4646+ <h3 class="text-yellow-800 dark:text-yellow-200 font-semibold mb-2">External PDS Notice</h3>
4747+ <p class="text-yellow-700 dark:text-yellow-300">
4848+ <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.
4949+ </p>
5050+ </div>
5151+5252+ <h2>3. Third-Party Data Processors</h2>
5353+ <p>We only share your data with the following third-party processors:</p>
5454+5555+ <h3>Resend (Email Services)</h3>
5656+ <ul>
5757+ <li><strong>Purpose:</strong> Sending transactional emails (account verification, notifications)</li>
5858+ <li><strong>Data Shared:</strong> Email address and necessary message content</li>
5959+ <li><strong>Location:</strong> EU-compliant email delivery service</li>
6060+ </ul>
6161+6262+ <h3>Cloudflare (Image Caching)</h3>
6363+ <ul>
6464+ <li><strong>Purpose:</strong> Caching and optimizing image delivery</li>
6565+ <li><strong>Data Shared:</strong> Public images and associated metadata for caching purposes</li>
6666+ <li><strong>Location:</strong> Global CDN with EU data protection compliance</li>
6767+ </ul>
6868+6969+ <h2>4. How We Use Your Information</h2>
7070+ <p>We use your information to:</p>
7171+ <ul>
7272+ <li>Provide and maintain the Service</li>
7373+ <li>Process your transactions and requests</li>
7474+ <li>Send you technical notices and support messages</li>
7575+ <li>Improve and develop new features</li>
7676+ <li>Ensure security and prevent fraud</li>
7777+ <li>Comply with legal obligations</li>
7878+ </ul>
7979+8080+ <h2>5. Data Sharing and Disclosure</h2>
8181+ <p>We do not sell, trade, or rent your personal information. We may share your information only in the following circumstances:</p>
8282+ <ul>
8383+ <li>With the third-party processors listed above</li>
8484+ <li>When required by law or legal process</li>
8585+ <li>To protect our rights, property, or safety, or that of our users</li>
8686+ <li>In connection with a merger, acquisition, or sale of assets (with appropriate protections)</li>
8787+ </ul>
8888+8989+ <h2>6. Data Security</h2>
9090+ <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>
9191+9292+ <h2>7. Data Retention</h2>
9393+ <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>
9494+9595+ <h2>8. Your Rights</h2>
9696+ <p>Under applicable data protection laws, you have the right to:</p>
9797+ <ul>
9898+ <li>Access your personal information</li>
9999+ <li>Correct inaccurate information</li>
100100+ <li>Request deletion of your information</li>
101101+ <li>Object to processing of your information</li>
102102+ <li>Data portability</li>
103103+ <li>Withdraw consent (where applicable)</li>
104104+ </ul>
105105+106106+ <h2>9. Cookies and Tracking</h2>
107107+ <p>We use cookies and similar technologies to:</p>
108108+ <ul>
109109+ <li>Maintain your login session</li>
110110+ <li>Remember your preferences</li>
111111+ <li>Analyze usage patterns to improve the Service</li>
112112+ </ul>
113113+ <p>You can control cookie settings through your browser preferences.</p>
114114+115115+ <h2>10. Children's Privacy</h2>
116116+ <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>
117117+118118+ <h2>11. International Data Transfers</h2>
119119+ <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>
120120+121121+ <h2>12. Changes to This Privacy Policy</h2>
122122+ <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>
123123+124124+ <h2>13. Contact Information</h2>
125125+ <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>
126126+127127+ <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
128128+ <p>This Privacy Policy complies with the EU General Data Protection Regulation (GDPR) and other applicable data protection laws.</p>
129129+ </div>
130130+ </div>
131131+ </div>
132132+</div>
133133+{{ end }}
+71
appview/pages/templates/legal/terms.html
···11+{{ define "title" }}terms of service{{ end }}
22+33+{{ define "content" }}
44+<div class="max-w-4xl mx-auto px-4 py-8">
55+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8">
66+ <div class="prose prose-gray dark:prose-invert max-w-none">
77+ <h1>Terms of Service</h1>
88+99+ <p><strong>Last updated:</strong> {{ now.Format "January 2, 2006" }}</p>
1010+1111+ <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>
1212+1313+ <h2>1. Acceptance of Terms</h2>
1414+ <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>
1515+1616+ <h2>2. Account Registration</h2>
1717+ <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>
1818+1919+ <h2>3. Account Termination</h2>
2020+ <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 my-6">
2121+ <h3 class="text-red-800 dark:text-red-200 font-semibold mb-2">Important Notice</h3>
2222+ <p class="text-red-700 dark:text-red-300">
2323+ <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.
2424+ </p>
2525+ <p class="text-red-700 dark:text-red-300 mt-2">
2626+ 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.
2727+ </p>
2828+ </div>
2929+3030+ <h2>4. Acceptable Use</h2>
3131+ <p>You agree not to use the Service to:</p>
3232+ <ul>
3333+ <li>Violate any applicable laws or regulations</li>
3434+ <li>Infringe upon the rights of others</li>
3535+ <li>Upload, store, or share content that is illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable</li>
3636+ <li>Engage in spam, phishing, or other deceptive practices</li>
3737+ <li>Attempt to gain unauthorized access to the Service or other users' accounts</li>
3838+ <li>Interfere with or disrupt the Service or servers connected to the Service</li>
3939+ </ul>
4040+4141+ <h2>5. Content and Intellectual Property</h2>
4242+ <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>
4343+4444+ <h2>6. Privacy</h2>
4545+ <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>
4646+4747+ <h2>7. Disclaimers</h2>
4848+ <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>
4949+5050+ <h2>8. Limitation of Liability</h2>
5151+ <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>
5252+5353+ <h2>9. Indemnification</h2>
5454+ <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>
5555+5656+ <h2>10. Governing Law</h2>
5757+ <p>These Terms shall be interpreted and governed by the laws of Finland, without regard to its conflict of law provisions.</p>
5858+5959+ <h2>11. Changes to Terms</h2>
6060+ <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>
6161+6262+ <h2>12. Contact Information</h2>
6363+ <p>If you have any questions about these Terms of Service, please contact us through our platform or via email.</p>
6464+6565+ <div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-600 dark:text-gray-400">
6666+ <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>
6767+ </div>
6868+ </div>
6969+ </div>
7070+</div>
7171+{{ end }}
+19-6
appview/pages/templates/repo/blob.html
···5566 {{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
77 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
88-88+99 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
1010-1010+1111{{ end }}
12121313{{ define "repoContent" }}
···4444 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
4545 {{ if .RenderToggle }}
4646 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
4747- <a
4848- href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
4747+ <a
4848+ href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
4949 hx-boost="true"
5050 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
5151 {{ end }}
5252 </div>
5353 </div>
5454 </div>
5555- {{ if .IsBinary }}
5555+ {{ if and .IsBinary .Unsupported }}
5656 <p class="text-center text-gray-400 dark:text-gray-500">
5757- This is a binary file and will not be displayed.
5757+ Previews are not supported for this file type.
5858 </p>
5959+ {{ else if .IsBinary }}
6060+ <div class="text-center">
6161+ {{ if .IsImage }}
6262+ <img src="{{ .ContentSrc }}"
6363+ alt="{{ .Path }}"
6464+ class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
6565+ {{ else if .IsVideo }}
6666+ <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
6767+ <source src="{{ .ContentSrc }}">
6868+ Your browser does not support the video tag.
6969+ </video>
7070+ {{ end }}
7171+ </div>
5972 {{ else }}
6073 <div class="overflow-auto relative">
6174 {{ if .ShowRendered }}
···1414 "time"
15151616 "tangled.sh/tangled.sh/core/api/tangled"
1717- "tangled.sh/tangled.sh/core/appview"
1817 "tangled.sh/tangled.sh/core/appview/config"
1918 "tangled.sh/tangled.sh/core/appview/db"
2020- "tangled.sh/tangled.sh/core/appview/idresolver"
1919+ "tangled.sh/tangled.sh/core/appview/notify"
2120 "tangled.sh/tangled.sh/core/appview/oauth"
2221 "tangled.sh/tangled.sh/core/appview/pages"
2322 "tangled.sh/tangled.sh/core/appview/reporesolver"
2323+ "tangled.sh/tangled.sh/core/idresolver"
2424 "tangled.sh/tangled.sh/core/knotclient"
2525 "tangled.sh/tangled.sh/core/patchutil"
2626+ "tangled.sh/tangled.sh/core/tid"
2627 "tangled.sh/tangled.sh/core/types"
27282829 "github.com/bluekeyes/go-gitdiff/gitdiff"
···3132 lexutil "github.com/bluesky-social/indigo/lex/util"
3233 "github.com/go-chi/chi/v5"
3334 "github.com/google/uuid"
3434- "github.com/posthog/posthog-go"
3535)
36363737type Pulls struct {
···4141 idResolver *idresolver.Resolver
4242 db *db.DB
4343 config *config.Config
4444- posthog posthog.Client
4444+ notifier notify.Notifier
4545}
46464747func New(
···5151 resolver *idresolver.Resolver,
5252 db *db.DB,
5353 config *config.Config,
5454- posthog posthog.Client,
5454+ notifier notify.Notifier,
5555) *Pulls {
5656 return &Pulls{
5757 oauth: oauth,
···6060 idResolver: resolver,
6161 db: db,
6262 config: config,
6363- posthog: posthog,
6363+ notifier: notifier,
6464 }
6565}
6666···355355 return
356356 }
357357358358+ var diffOpts types.DiffOpts
359359+ if d := r.URL.Query().Get("diff"); d == "split" {
360360+ diffOpts.Split = true
361361+ }
362362+358363 pull, ok := r.Context().Value("pull").(*db.Pull)
359364 if !ok {
360365 log.Println("failed to get pull")
···395400 Round: roundIdInt,
396401 Submission: pull.Submissions[roundIdInt],
397402 Diff: &diff,
403403+ DiffOpts: diffOpts,
398404 })
399405400406}
···408414 return
409415 }
410416417417+ var diffOpts types.DiffOpts
418418+ if d := r.URL.Query().Get("diff"); d == "split" {
419419+ diffOpts.Split = true
420420+ }
421421+411422 pull, ok := r.Context().Value("pull").(*db.Pull)
412423 if !ok {
413424 log.Println("failed to get pull")
···463474 Round: roundIdInt,
464475 DidHandleMap: didHandleMap,
465476 Interdiff: interdiff,
477477+ DiffOpts: diffOpts,
466478 })
467467- return
468479}
469480470481func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
···544555545556 // we want to group all stacked PRs into just one list
546557 stacks := make(map[string]db.Stack)
558558+ var shas []string
547559 n := 0
548560 for _, p := range pulls {
561561+ // store the sha for later
562562+ shas = append(shas, p.LatestSha())
549563 // this PR is stacked
550564 if p.StackId != "" {
551565 // we have already seen this PR stack
···564578 }
565579 pulls = pulls[:n]
566580581581+ repoInfo := f.RepoInfo(user)
582582+ ps, err := db.GetPipelineStatuses(
583583+ s.db,
584584+ db.FilterEq("repo_owner", repoInfo.OwnerDid),
585585+ db.FilterEq("repo_name", repoInfo.Name),
586586+ db.FilterEq("knot", repoInfo.Knot),
587587+ db.FilterIn("sha", shas),
588588+ )
589589+ if err != nil {
590590+ log.Printf("failed to fetch pipeline statuses: %s", err)
591591+ // non-fatal
592592+ }
593593+ m := make(map[string]db.Pipeline)
594594+ for _, p := range ps {
595595+ m[p.Sha] = p
596596+ }
597597+567598 identsToResolve := make([]string, len(pulls))
568599 for i, pull := range pulls {
569600 identsToResolve[i] = pull.OwnerDid
···585616 DidHandleMap: didHandleMap,
586617 FilteringBy: state,
587618 Stacks: stacks,
619619+ Pipelines: m,
588620 })
589589- return
590621}
591622592623func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
···657688 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
658689 Collection: tangled.RepoPullCommentNSID,
659690 Repo: user.Did,
660660- Rkey: appview.TID(),
691691+ Rkey: tid.TID(),
661692 Record: &lexutil.LexiconTypeDecoder{
662693 Val: &tangled.RepoPullComment{
663694 Repo: &atUri,
···674705 return
675706 }
676707677677- // Create the pull comment in the database with the commentAt field
678678- commentId, err := db.NewPullComment(tx, &db.PullComment{
708708+ comment := &db.PullComment{
679709 OwnerDid: user.Did,
680710 RepoAt: f.RepoAt.String(),
681711 PullId: pull.PullId,
682712 Body: body,
683713 CommentAt: atResp.Uri,
684714 SubmissionId: pull.Submissions[roundNumber].ID,
685685- })
715715+ }
716716+717717+ // Create the pull comment in the database with the commentAt field
718718+ commentId, err := db.NewPullComment(tx, comment)
686719 if err != nil {
687720 log.Println("failed to create pull comment", err)
688721 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
···696729 return
697730 }
698731699699- if !s.config.Core.Dev {
700700- err = s.posthog.Enqueue(posthog.Capture{
701701- DistinctId: user.Did,
702702- Event: "new_pull_comment",
703703- Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
704704- })
705705- if err != nil {
706706- log.Println("failed to enqueue posthog event:", err)
707707- }
708708- }
732732+ s.notifier.NewPullComment(r.Context(), comment)
709733710734 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
711735 return
···10341058 body = formatPatches[0].Body
10351059 }
1036106010371037- rkey := appview.TID()
10611061+ rkey := tid.TID()
10381062 initialSubmission := db.PullSubmission{
10391063 Patch: patch,
10401064 SourceRev: sourceRev,
10411065 }
10421042- err = db.NewPull(tx, &db.Pull{
10661066+ pull := &db.Pull{
10431067 Title: title,
10441068 Body: body,
10451069 TargetBranch: targetBranch,
···10501074 &initialSubmission,
10511075 },
10521076 PullSource: pullSource,
10531053- })
10771077+ }
10781078+ err = db.NewPull(tx, pull)
10541079 if err != nil {
10551080 log.Println("failed to create pull request", err)
10561081 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
···10901115 return
10911116 }
1092111710931093- if !s.config.Core.Dev {
10941094- err = s.posthog.Enqueue(posthog.Capture{
10951095- DistinctId: user.Did,
10961096- Event: "new_pull",
10971097- Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
10981098- })
10991099- if err != nil {
11001100- log.Println("failed to enqueue posthog event:", err)
11011101- }
11021102- }
11181118+ s.notifier.NewPull(r.Context(), pull)
1103111911041120 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
11051121}
···16621678 }
1663167916641680 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
16651665- return
16661681}
1667168216681683func (s *Pulls) resubmitStackedPullHelper(
···19061921 }
1907192219081923 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
19091909- return
19101924}
1911192519121926func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
···2030204420312045 // auth filter: only owner or collaborators can close
20322046 roles := f.RolesInRepo(user)
20472047+ isOwner := roles.IsOwner()
20332048 isCollaborator := roles.IsCollaborator()
20342049 isPullAuthor := user.Did == pull.OwnerDid
20352035- isCloseAllowed := isCollaborator || isPullAuthor
20502050+ isCloseAllowed := isOwner || isCollaborator || isPullAuthor
20362051 if !isCloseAllowed {
20372052 log.Println("failed to close pull")
20382053 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···20762091 }
2077209220782093 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
20792079- return
20802094}
2081209520822096func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
···2098211220992113 // auth filter: only owner or collaborators can close
21002114 roles := f.RolesInRepo(user)
21152115+ isOwner := roles.IsOwner()
21012116 isCollaborator := roles.IsCollaborator()
21022117 isPullAuthor := user.Did == pull.OwnerDid
21032103- isCloseAllowed := isCollaborator || isPullAuthor
21182118+ isCloseAllowed := isOwner || isCollaborator || isPullAuthor
21042119 if !isCloseAllowed {
21052120 log.Println("failed to close pull")
21062121 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
···21442159 }
2145216021462161 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
21472147- return
21482162}
2149216321502164func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
···2170218421712185 title := fp.Title
21722186 body := fp.Body
21732173- rkey := appview.TID()
21872187+ rkey := tid.TID()
2174218821752189 initialSubmission := db.PullSubmission{
21762190 Patch: fp.Raw,
+2
appview/pulls/router.go
···4444 r.Get("/", s.ResubmitPull)
4545 r.Post("/", s.ResubmitPull)
4646 })
4747+ // permissions here require us to know pull author
4848+ // it is handled within the route
4749 r.Post("/close", s.ClosePull)
4850 r.Post("/reopen", s.ReopenPull)
4951 // collaborators only
···3232nix run .#watch-tailwind
3333```
34343535+To authenticate with the appview, you will need redis and
3636+OAUTH JWKs to be setup:
3737+3838+```
3939+# oauth jwks should already be setup by the nix devshell:
4040+echo $TANGLED_OAUTH_JWKS
4141+{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
4242+4343+# if not, you can set it up yourself:
4444+go build -o genjwks.out ./cmd/genjwks
4545+export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
4646+4747+# run redis in at a new shell to store oauth sessions
4848+redis-server
4949+```
5050+3551## running a knot
36523753An end-to-end knot setup requires setting up a machine with
···3955quite cumbersome. So the nix flake provides a
4056`nixosConfiguration` to do so.
41574242-To begin, head to `http://localhost:3000` in the browser and
4343-generate a knot secret. Replace the existing secret in
4444-`flake.nix` with the newly generated secret.
5858+To begin, head to `http://localhost:3000/knots` in the browser
5959+and generate a knot secret. Replace the existing secret in
6060+`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
6161+secret.
45624663You can now start a lightweight NixOS VM using
4764`nixos-shell` like so:
···7188git remote add local-dev git@nixos-shell:user/repo
7289git push local-dev main
7390```
9191+9292+## running a spindle
9393+9494+Be sure to change the `owner` field for the spindle in
9595+`nix/vm.nix` to your own DID. The above VM should already
9696+be running a spindle on `localhost:6555`. You can head to
9797+the spindle dashboard on `http://localhost:3000/spindles`,
9898+and register a spindle with hostname `localhost:6555`. It
9999+should instantly be verified. You can then configure each
100100+repository to use this spindle and run CI jobs.
101101+102102+Of interest when debugging spindles:
103103+104104+```
105105+# service logs from journald:
106106+journalctl -xeu spindle
107107+108108+# CI job logs from disk:
109109+ls /var/log/spindle
110110+111111+# debugging spindle db:
112112+sqlite3 /var/lib/spindle/spindle.db
113113+114114+# litecli has a nicer REPL interface:
115115+litecli /var/lib/spindle/spindle.db
116116+```
+12
docs/knot-hosting.md
···191191```
192192193193Make sure to restart your SSH server!
194194+195195+#### MOTD (message of the day)
196196+197197+To configure the MOTD used ("Welcome to this knot!" by default), edit the
198198+`/home/git/motd` file:
199199+200200+```
201201+printf "Hi from this knot!\n" > /home/git/motd
202202+```
203203+204204+Note that you should add a newline at the end if setting a non-empty message
205205+since the knot won't do this for you.
+4-3
docs/spindle/architecture.md
···13131414### the engine
15151616-At present, the only supported backend is Docker. Spindle executes each step in
1717-the pipeline in a fresh container, with state persisted across steps within the
1818-`/tangled/workspace` directory.
1616+At present, the only supported backend is Docker (and Podman, if Docker
1717+compatibility is enabled, so that `/run/docker.sock` is created). Spindle
1818+executes each step in the pipeline in a fresh container, with state persisted
1919+across steps within the `/tangled/workspace` directory.
19202021The base image for the container is constructed on the fly using
2122[Nixery](https://nixery.dev), which is handy for caching layers for frequently
+285
docs/spindle/openbao.md
···11+# spindle secrets with openbao
22+33+This document covers setting up Spindle to use OpenBao for secrets
44+management via OpenBao Proxy instead of the default SQLite backend.
55+66+## overview
77+88+Spindle now uses OpenBao Proxy for secrets management. The proxy handles
99+authentication automatically using AppRole credentials, while Spindle
1010+connects to the local proxy instead of directly to the OpenBao server.
1111+1212+This approach provides better security, automatic token renewal, and
1313+simplified application code.
1414+1515+## installation
1616+1717+Install OpenBao from nixpkgs:
1818+1919+```bash
2020+nix shell nixpkgs#openbao # for a local server
2121+```
2222+2323+## setup
2424+2525+The setup process can is documented for both local development and production.
2626+2727+### local development
2828+2929+Start OpenBao in dev mode:
3030+3131+```bash
3232+bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
3333+```
3434+3535+This starts OpenBao on `http://localhost:8201` with a root token.
3636+3737+Set up environment for bao CLI:
3838+3939+```bash
4040+export BAO_ADDR=http://localhost:8200
4141+export BAO_TOKEN=root
4242+```
4343+4444+### production
4545+4646+You would typically use a systemd service with a configuration file. Refer to
4747+[@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be
4848+achieved using Nix.
4949+5050+Then, initialize the bao server:
5151+```bash
5252+bao operator init -key-shares=1 -key-threshold=1
5353+```
5454+5555+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:
5656+```bash
5757+bao operator unseal <unseal_key>
5858+```
5959+6060+All steps below remain the same across both dev and production setups.
6161+6262+### configure openbao server
6363+6464+Create the spindle KV mount:
6565+6666+```bash
6767+bao secrets enable -path=spindle -version=2 kv
6868+```
6969+7070+Set up AppRole authentication and policy:
7171+7272+Create a policy file `spindle-policy.hcl`:
7373+7474+```hcl
7575+# Full access to spindle KV v2 data
7676+path "spindle/data/*" {
7777+ capabilities = ["create", "read", "update", "delete"]
7878+}
7979+8080+# Access to metadata for listing and management
8181+path "spindle/metadata/*" {
8282+ capabilities = ["list", "read", "delete", "update"]
8383+}
8484+8585+# Allow listing at root level
8686+path "spindle/" {
8787+ capabilities = ["list"]
8888+}
8989+9090+# Required for connection testing and health checks
9191+path "auth/token/lookup-self" {
9292+ capabilities = ["read"]
9393+}
9494+```
9595+9696+Apply the policy and create an AppRole:
9797+9898+```bash
9999+bao policy write spindle-policy spindle-policy.hcl
100100+bao auth enable approle
101101+bao write auth/approle/role/spindle \
102102+ token_policies="spindle-policy" \
103103+ token_ttl=1h \
104104+ token_max_ttl=4h \
105105+ bind_secret_id=true \
106106+ secret_id_ttl=0 \
107107+ secret_id_num_uses=0
108108+```
109109+110110+Get the credentials:
111111+112112+```bash
113113+# Get role ID (static)
114114+ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id)
115115+116116+# Generate secret ID
117117+SECRET_ID=$(bao write -field=secret_id auth/approle/role/spindle/secret-id)
118118+119119+echo "Role ID: $ROLE_ID"
120120+echo "Secret ID: $SECRET_ID"
121121+```
122122+123123+### create proxy configuration
124124+125125+Create the credential files:
126126+127127+```bash
128128+# Create directory for OpenBao files
129129+mkdir -p /tmp/openbao
130130+131131+# Save credentials
132132+echo "$ROLE_ID" > /tmp/openbao/role-id
133133+echo "$SECRET_ID" > /tmp/openbao/secret-id
134134+chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id
135135+```
136136+137137+Create a proxy configuration file `/tmp/openbao/proxy.hcl`:
138138+139139+```hcl
140140+# OpenBao server connection
141141+vault {
142142+ address = "http://localhost:8200"
143143+}
144144+145145+# Auto-Auth using AppRole
146146+auto_auth {
147147+ method "approle" {
148148+ mount_path = "auth/approle"
149149+ config = {
150150+ role_id_file_path = "/tmp/openbao/role-id"
151151+ secret_id_file_path = "/tmp/openbao/secret-id"
152152+ }
153153+ }
154154+155155+ # Optional: write token to file for debugging
156156+ sink "file" {
157157+ config = {
158158+ path = "/tmp/openbao/token"
159159+ mode = 0640
160160+ }
161161+ }
162162+}
163163+164164+# Proxy listener for Spindle
165165+listener "tcp" {
166166+ address = "127.0.0.1:8201"
167167+ tls_disable = true
168168+}
169169+170170+# Enable API proxy with auto-auth token
171171+api_proxy {
172172+ use_auto_auth_token = true
173173+}
174174+175175+# Enable response caching
176176+cache {
177177+ use_auto_auth_token = true
178178+}
179179+180180+# Logging
181181+log_level = "info"
182182+```
183183+184184+### start the proxy
185185+186186+Start OpenBao Proxy:
187187+188188+```bash
189189+bao proxy -config=/tmp/openbao/proxy.hcl
190190+```
191191+192192+The proxy will authenticate with OpenBao and start listening on
193193+`127.0.0.1:8201`.
194194+195195+### configure spindle
196196+197197+Set these environment variables for Spindle:
198198+199199+```bash
200200+export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201201+export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202202+export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203203+```
204204+205205+Start Spindle:
206206+207207+Spindle will now connect to the local proxy, which handles all
208208+authentication automatically.
209209+210210+## production setup for proxy
211211+212212+For production, you'll want to run the proxy as a service:
213213+214214+Place your production configuration in `/etc/openbao/proxy.hcl` with
215215+proper TLS settings for the vault connection.
216216+217217+## verifying setup
218218+219219+Test the proxy directly:
220220+221221+```bash
222222+# Check proxy health
223223+curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health
224224+225225+# Test token lookup through proxy
226226+curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self
227227+```
228228+229229+Test OpenBao operations through the server:
230230+231231+```bash
232232+# List all secrets
233233+bao kv list spindle/
234234+235235+# Add a test secret via Spindle API, then check it exists
236236+bao kv list spindle/repos/
237237+238238+# Get a specific secret
239239+bao kv get spindle/repos/your_repo_path/SECRET_NAME
240240+```
241241+242242+## how it works
243243+244244+- Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201)
245245+- The proxy authenticates with OpenBao using AppRole credentials
246246+- All Spindle requests go through the proxy, which injects authentication tokens
247247+- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
248248+- Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo`
249249+- The proxy handles all token renewal automatically
250250+- Spindle no longer manages tokens or authentication directly
251251+252252+## troubleshooting
253253+254254+**Connection refused**: Check that the OpenBao Proxy is running and
255255+listening on the configured address.
256256+257257+**403 errors**: Verify the AppRole credentials are correct and the policy
258258+has the necessary permissions.
259259+260260+**404 route errors**: The spindle KV mount probably doesn't exist - run
261261+the mount creation step again.
262262+263263+**Proxy authentication failures**: Check the proxy logs and verify the
264264+role-id and secret-id files are readable and contain valid credentials.
265265+266266+**Secret not found after writing**: This can indicate policy permission
267267+issues. Verify the policy includes both `spindle/data/*` and
268268+`spindle/metadata/*` paths with appropriate capabilities.
269269+270270+Check proxy logs:
271271+272272+```bash
273273+# If running as systemd service
274274+journalctl -u openbao-proxy -f
275275+276276+# If running directly, check the console output
277277+```
278278+279279+Test AppRole authentication manually:
280280+281281+```bash
282282+bao write auth/approle/login \
283283+ role_id="$(cat /tmp/openbao/role-id)" \
284284+ secret_id="$(cat /tmp/openbao/secret-id)"
285285+```
+7
docs/spindle/pipeline.md
···5757 depth: 50
5858 submodules: true
5959```
6060+6161+## git push options
6262+6363+These are push options that can be used with the `--push-option (-o)` flag of git push:
6464+6565+- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
6666+- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.