···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 }}
···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 }}
+19-6
appview/pages/templates/repo/blob.html
···56 {{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8-9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10-11{{ end }}
1213{{ define "repoContent" }}
···44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45 {{ if .RenderToggle }}
46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47- <a
48- href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49 hx-boost="true"
50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51 {{ end }}
52 </div>
53 </div>
54 </div>
55- {{ if .IsBinary }}
56 <p class="text-center text-gray-400 dark:text-gray-500">
57- This is a binary file and will not be displayed.
58 </p>
000000000000059 {{ else }}
60 <div class="overflow-auto relative">
61 {{ if .ShowRendered }}
···56 {{ $title := printf "%s at %s · %s" .Path .Ref .RepoInfo.FullName }}
7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }}
8+9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }}
10+11{{ end }}
1213{{ define "repoContent" }}
···44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a>
45 {{ if .RenderToggle }}
46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
47+ <a
48+ href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}"
49 hx-boost="true"
50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a>
51 {{ end }}
52 </div>
53 </div>
54 </div>
55+ {{ if and .IsBinary .Unsupported }}
56 <p class="text-center text-gray-400 dark:text-gray-500">
57+ Previews are not supported for this file type.
58 </p>
59+ {{ else if .IsBinary }}
60+ <div class="text-center">
61+ {{ if .IsImage }}
62+ <img src="{{ .ContentSrc }}"
63+ alt="{{ .Path }}"
64+ class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" />
65+ {{ else if .IsVideo }}
66+ <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded">
67+ <source src="{{ .ContentSrc }}">
68+ Your browser does not support the video tag.
69+ </video>
70+ {{ end }}
71+ </div>
72 {{ else }}
73 <div class="overflow-auto relative">
74 {{ if .ShowRendered }}
···44 r.Get("/", s.ResubmitPull)
45 r.Post("/", s.ResubmitPull)
46 })
47+ // permissions here require us to know pull author
48+ // it is handled within the route
49 r.Post("/close", s.ClosePull)
50 r.Post("/reopen", s.ReopenPull)
51 // collaborators only
···17 "github.com/go-chi/chi/v5"
18 "tangled.sh/tangled.sh/core/appview/config"
19 "tangled.sh/tangled.sh/core/appview/db"
20- "tangled.sh/tangled.sh/core/appview/idresolver"
21 "tangled.sh/tangled.sh/core/appview/oauth"
22 "tangled.sh/tangled.sh/core/appview/pages"
23 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
024 "tangled.sh/tangled.sh/core/knotclient"
25 "tangled.sh/tangled.sh/core/rbac"
26)
···149 for _, item := range repoCollaborators {
150 // currently only two roles: owner and member
151 var role string
152- if item[3] == "repo:owner" {
0153 role = "owner"
154- } else if item[3] == "repo:collaborator" {
155 role = "collaborator"
156- } else {
157 continue
158 }
159
···17 "github.com/go-chi/chi/v5"
18 "tangled.sh/tangled.sh/core/appview/config"
19 "tangled.sh/tangled.sh/core/appview/db"
020 "tangled.sh/tangled.sh/core/appview/oauth"
21 "tangled.sh/tangled.sh/core/appview/pages"
22 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
23+ "tangled.sh/tangled.sh/core/idresolver"
24 "tangled.sh/tangled.sh/core/knotclient"
25 "tangled.sh/tangled.sh/core/rbac"
26)
···149 for _, item := range repoCollaborators {
150 // currently only two roles: owner and member
151 var role string
152+ switch item[3] {
153+ case "repo:owner":
154 role = "owner"
155+ case "repo:collaborator":
156 role = "collaborator"
157+ default:
158 continue
159 }
160
···32nix run .#watch-tailwind
33```
34000000000000000035## running a knot
3637An end-to-end knot setup requires setting up a machine with
···39quite cumbersome. So the nix flake provides a
40`nixosConfiguration` to do so.
4142-To begin, head to `http://localhost:3000` in the browser and
43-generate a knot secret. Replace the existing secret in
44-`flake.nix` with the newly generated secret.
04546You can now start a lightweight NixOS VM using
47`nixos-shell` like so:
···71git remote add local-dev git@nixos-shell:user/repo
72git push local-dev main
73```
00000000000000000000000000
···32nix run .#watch-tailwind
33```
3435+To authenticate with the appview, you will need redis and
36+OAUTH JWKs to be setup:
37+38+```
39+# oauth jwks should already be setup by the nix devshell:
40+echo $TANGLED_OAUTH_JWKS
41+{"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"}
42+43+# if not, you can set it up yourself:
44+go build -o genjwks.out ./cmd/genjwks
45+export TANGLED_OAUTH_JWKS="$(./genjwks.out)"
46+47+# run redis in at a new shell to store oauth sessions
48+redis-server
49+```
50+51## running a knot
5253An end-to-end knot setup requires setting up a machine with
···55quite cumbersome. So the nix flake provides a
56`nixosConfiguration` to do so.
5758+To begin, head to `http://localhost:3000/knots` in the browser
59+and generate a knot secret. Replace the existing secret in
60+`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61+secret.
6263You can now start a lightweight NixOS VM using
64`nixos-shell` like so:
···88git remote add local-dev git@nixos-shell:user/repo
89git push local-dev main
90```
91+92+## running a spindle
93+94+Be sure to change the `owner` field for the spindle in
95+`nix/vm.nix` to your own DID. The above VM should already
96+be running a spindle on `localhost:6555`. You can head to
97+the spindle dashboard on `http://localhost:3000/spindles`,
98+and register a spindle with hostname `localhost:6555`. It
99+should instantly be verified. You can then configure each
100+repository to use this spindle and run CI jobs.
101+102+Of interest when debugging spindles:
103+104+```
105+# service logs from journald:
106+journalctl -xeu spindle
107+108+# CI job logs from disk:
109+ls /var/log/spindle
110+111+# debugging spindle db:
112+sqlite3 /var/lib/spindle/spindle.db
113+114+# litecli has a nicer REPL interface:
115+litecli /var/lib/spindle/spindle.db
116+```
+12
docs/knot-hosting.md
···191```
192193Make sure to restart your SSH server!
000000000000
···191```
192193Make 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.
+4-3
docs/spindle/architecture.md
···1314### the engine
1516-At present, the only supported backend is Docker. Spindle executes each step in
17-the pipeline in a fresh container, with state persisted across steps within the
18-`/tangled/workspace` directory.
01920The base image for the container is constructed on the fly using
21[Nixery](https://nixery.dev), which is handy for caching layers for frequently
···1314### the engine
1516+At present, the only supported backend is Docker (and Podman, if Docker
17+compatibility is enabled, so that `/run/docker.sock` is created). Spindle
18+executes each step in the pipeline in a fresh container, with state persisted
19+across steps within the `/tangled/workspace` directory.
2021The base image for the container is constructed on the fly using
22[Nixery](https://nixery.dev), which is handy for caching layers for frequently
···1+# spindle secrets with openbao
2+3+This document covers setting up Spindle to use OpenBao for secrets
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.
14+15+## installation
16+17+Install OpenBao from nixpkgs:
18+19+```bash
20+nix shell nixpkgs#openbao # for a local server
21+```
22+23+## setup
24+25+The setup process can is documented for both local development and production.
26+27+### local development
28+29+Start OpenBao in dev mode:
30+31+```bash
32+bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201
33+```
34+35+This starts OpenBao on `http://localhost:8201` with a root token.
36+37+Set up environment for bao CLI:
38+39+```bash
40+export BAO_ADDR=http://localhost:8200
41+export BAO_TOKEN=root
42+```
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+64+Create the spindle KV mount:
65+66+```bash
67+bao secrets enable -path=spindle -version=2 kv
68+```
69+70+Set up AppRole authentication and policy:
71+72+Create a policy file `spindle-policy.hcl`:
73+74+```hcl
75+# Full access to spindle KV v2 data
76+path "spindle/data/*" {
77+ capabilities = ["create", "read", "update", "delete"]
78+}
79+80+# Access to metadata for listing and management
81+path "spindle/metadata/*" {
82+ capabilities = ["list", "read", "delete", "update"]
83+}
84+85+# Allow listing at root level
86+path "spindle/" {
87+ capabilities = ["list"]
88+}
89+90+# Required for connection testing and health checks
91+path "auth/token/lookup-self" {
92+ capabilities = ["read"]
93+}
94+```
95+96+Apply the policy and create an AppRole:
97+98+```bash
99+bao policy write spindle-policy spindle-policy.hcl
100+bao auth enable approle
101+bao write auth/approle/role/spindle \
102+ token_policies="spindle-policy" \
103+ token_ttl=1h \
104+ token_max_ttl=4h \
105+ bind_secret_id=true \
106+ secret_id_ttl=0 \
107+ secret_id_num_uses=0
108+```
109+110+Get the credentials:
111+112+```bash
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"
182+```
183+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
196+197+Set these environment variables for Spindle:
198+199+```bash
200+export SPINDLE_SERVER_SECRETS_PROVIDER=openbao
201+export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201
202+export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle
203+```
204+205+Start Spindle:
206+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.
216+217+## verifying setup
218+219+Test the proxy directly:
220+221+```bash
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
227+```
228+229+Test OpenBao operations through the server:
230+231+```bash
232+# List all secrets
233+bao kv list spindle/
234+235+# Add a test secret via Spindle API, then check it exists
236+bao kv list spindle/repos/
237+238+# Get a specific secret
239+bao kv get spindle/repos/your_repo_path/SECRET_NAME
240+```
241+242+## how it works
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
247+- Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}`
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
251+252+## troubleshooting
253+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.
259+260+**404 route errors**: The spindle KV mount probably doesn't exist - run
261+the mount creation step again.
262+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+```
···57 depth: 50
58 submodules: true
59```
60+61+## git push options
62+63+These are push options that can be used with the `--push-option (-o)` flag of git push:
64+65+- `verbose-ci`, `ci-verbose`: enables diagnostics reporting for the CI pipeline, allowing you to see any issues when you push.
66+- `skip-ci`, `ci-skip`: skips triggering the CI pipeline.
···8 "runtime/debug"
910 "github.com/go-chi/chi/v5"
011 "tangled.sh/tangled.sh/core/jetstream"
12 "tangled.sh/tangled.sh/core/knotserver/config"
13 "tangled.sh/tangled.sh/core/knotserver/db"
0014 "tangled.sh/tangled.sh/core/notifier"
15 "tangled.sh/tangled.sh/core/rbac"
16-)
17-18-const (
19- ThisServer = "thisserver" // resource identifier for rbac enforcement
20)
2122type Handle struct {
23- c *config.Config
24- db *db.DB
25- jc *jetstream.JetstreamClient
26- e *rbac.Enforcer
27- l *slog.Logger
28- n *notifier.Notifier
02930 // init is a channel that is closed when the knot has been initailized
31 // i.e. when the first user (knot owner) has been added.
···37 r := chi.NewRouter()
3839 h := Handle{
40- c: c,
41- db: db,
42- e: e,
43- l: l,
44- jc: jc,
45- n: n,
46- init: make(chan struct{}),
047 }
4849- err := e.AddKnot(ThisServer)
50 if err != nil {
51 return nil, fmt.Errorf("failed to setup enforcer: %w", err)
52 }
···131 })
132 })
133000134 // Create a new repository.
135 r.Route("/repo", func(r chi.Router) {
136 r.Use(h.VerifySignature)
···161 r.Get("/keys", h.Keys)
162163 return r, nil
000000000000000164}
165166// version is set during build time.
···8 "runtime/debug"
910 "github.com/go-chi/chi/v5"
11+ "tangled.sh/tangled.sh/core/idresolver"
12 "tangled.sh/tangled.sh/core/jetstream"
13 "tangled.sh/tangled.sh/core/knotserver/config"
14 "tangled.sh/tangled.sh/core/knotserver/db"
15+ "tangled.sh/tangled.sh/core/knotserver/xrpc"
16+ tlog "tangled.sh/tangled.sh/core/log"
17 "tangled.sh/tangled.sh/core/notifier"
18 "tangled.sh/tangled.sh/core/rbac"
000019)
2021type Handle struct {
22+ c *config.Config
23+ db *db.DB
24+ jc *jetstream.JetstreamClient
25+ e *rbac.Enforcer
26+ l *slog.Logger
27+ n *notifier.Notifier
28+ resolver *idresolver.Resolver
2930 // init is a channel that is closed when the knot has been initailized
31 // i.e. when the first user (knot owner) has been added.
···37 r := chi.NewRouter()
3839 h := Handle{
40+ c: c,
41+ db: db,
42+ e: e,
43+ l: l,
44+ jc: jc,
45+ n: n,
46+ resolver: idresolver.DefaultResolver(),
47+ init: make(chan struct{}),
48 }
4950+ err := e.AddKnot(rbac.ThisServer)
51 if err != nil {
52 return nil, fmt.Errorf("failed to setup enforcer: %w", err)
53 }
···132 })
133 })
134135+ // xrpc apis
136+ r.Mount("/xrpc", h.XrpcRouter())
137+138 // Create a new repository.
139 r.Route("/repo", func(r chi.Router) {
140 r.Use(h.VerifySignature)
···165 r.Get("/keys", h.Keys)
166167 return r, nil
168+}
169+170+func (h *Handle) XrpcRouter() http.Handler {
171+ logger := tlog.New("knots")
172+173+ xrpc := &xrpc.Xrpc{
174+ Config: h.c,
175+ Db: h.db,
176+ Ingester: h.jc,
177+ Enforcer: h.e,
178+ Logger: logger,
179+ Notifier: h.n,
180+ Resolver: h.resolver,
181+ }
182+ return xrpc.Router()
183}
184185// version is set during build time.
+65-4
knotserver/ingester.go
···17 "github.com/bluesky-social/jetstream/pkg/models"
18 securejoin "github.com/cyphar/filepath-securejoin"
19 "tangled.sh/tangled.sh/core/api/tangled"
20- "tangled.sh/tangled.sh/core/appview/idresolver"
21 "tangled.sh/tangled.sh/core/knotserver/db"
22 "tangled.sh/tangled.sh/core/knotserver/git"
23 "tangled.sh/tangled.sh/core/log"
024 "tangled.sh/tangled.sh/core/workflow"
25)
26···46 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
47 }
4849- ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite")
50 if err != nil || !ok {
51 l.Error("failed to add member", "did", did)
52 return fmt.Errorf("failed to enforce permissions: %w", err)
53 }
5455- if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil {
56 l.Error("failed to add member", "error", err)
57 return fmt.Errorf("failed to add member: %w", err)
58 }
···212 return h.db.InsertEvent(event, h.n)
213}
21400000000000000000000000000000000000000000000000000215func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
216 l := log.FromContext(ctx)
217···265 defer func() {
266 eventTime := event.TimeUS
267 lastTimeUs := eventTime + 1
268- fmt.Println("lastTimeUs", lastTimeUs)
269 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
270 err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
271 }
···291 if err := h.processKnotMember(ctx, did, record); err != nil {
292 return fmt.Errorf("failed to process knot member: %w", err)
293 }
0294 case tangled.RepoPullNSID:
295 var record tangled.RepoPull
296 if err := json.Unmarshal(raw, &record); err != nil {
···299 if err := h.processPull(ctx, did, record); err != nil {
300 return fmt.Errorf("failed to process knot member: %w", err)
301 }
0000000000302 }
303304 return err
···17 "github.com/bluesky-social/jetstream/pkg/models"
18 securejoin "github.com/cyphar/filepath-securejoin"
19 "tangled.sh/tangled.sh/core/api/tangled"
20+ "tangled.sh/tangled.sh/core/idresolver"
21 "tangled.sh/tangled.sh/core/knotserver/db"
22 "tangled.sh/tangled.sh/core/knotserver/git"
23 "tangled.sh/tangled.sh/core/log"
24+ "tangled.sh/tangled.sh/core/rbac"
25 "tangled.sh/tangled.sh/core/workflow"
26)
27···47 return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname)
48 }
4950+ ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite")
51 if err != nil || !ok {
52 l.Error("failed to add member", "did", did)
53 return fmt.Errorf("failed to enforce permissions: %w", err)
54 }
5556+ if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil {
57 l.Error("failed to add member", "error", err)
58 return fmt.Errorf("failed to add member: %w", err)
59 }
···213 return h.db.InsertEvent(event, h.n)
214}
215216+// 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+266func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error {
267 l := log.FromContext(ctx)
268···316 defer func() {
317 eventTime := event.TimeUS
318 lastTimeUs := eventTime + 1
0319 if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil {
320 err = fmt.Errorf("(deferred) failed to save last time us: %w", err)
321 }
···341 if err := h.processKnotMember(ctx, did, record); err != nil {
342 return fmt.Errorf("failed to process knot member: %w", err)
343 }
344+345 case tangled.RepoPullNSID:
346 var record tangled.RepoPull
347 if err := json.Unmarshal(raw, &record); err != nil {
···350 if err := h.processPull(ctx, did, record); err != nil {
351 return fmt.Errorf("failed to process knot member: %w", err)
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+363 }
364365 return err
+62-5
knotserver/internal.go
···13 "github.com/go-chi/chi/v5"
14 "github.com/go-chi/chi/v5/middleware"
15 "tangled.sh/tangled.sh/core/api/tangled"
016 "tangled.sh/tangled.sh/core/knotserver/config"
17 "tangled.sh/tangled.sh/core/knotserver/db"
18 "tangled.sh/tangled.sh/core/knotserver/git"
···38 return
39 }
4041- ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
42 if err != nil || !ok {
43 w.WriteHeader(http.StatusForbidden)
44 return
···64 return
65}
660000067func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
68 l := h.l.With("handler", "PostReceiveHook")
69···90 // non-fatal
91 }
92000000000000000093 for _, line := range lines {
94 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
95 if err != nil {
···97 // non-fatal
98 }
99100- err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
101 if err != nil {
102 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
103 // non-fatal
104 }
105 }
00106}
107108func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
···148 return h.db.InsertEvent(event, h.n)
149}
150151-func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
0000152 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
153 if err != nil {
154 return err
···169 return err
170 }
17100172 var pipeline workflow.Pipeline
173 for _, e := range workflowDir {
174 if !e.IsFile {
···183184 wf, err := workflow.FromFile(e.Name, contents)
185 if err != nil {
186- // TODO: log here, respond to client that is pushing
187 h.l.Error("failed to parse workflow", "err", err, "path", fpath)
0188 continue
189 }
190···209 },
210 }
211212- // TODO: send the diagnostics back to the user here via stderr
213 cp := compiler.Compile(pipeline)
214 eventJson, err := json.Marshal(cp)
215 if err != nil {
216 return err
0000000000000000000000000000217 }
218219 // do not run empty pipelines