Config files for my server. Except not my secrets

completely overhaul landing + add knot landing

if this breaks im blowing myself up lol

vielle.dev e49bb441 b8e04109

verified
+14 -1
caddy/Caddyfile
··· 123 123 format console 124 124 } 125 125 126 - @landing path / /css 126 + rewrite / /pds 127 + @landing path /pds /styles.css 127 128 reverse_proxy @landing landing:8000 129 + 130 + # disable age assurance 131 + handle /xrpc/app.bsky.ageassurance.getState { 132 + header content-type "application/json" 133 + header access-control-allow-headers "authorization,dpop,atproto-accept-labelers,atproto-proxy" 134 + header access-control-allow-origin "*" 135 + respond `{"state":{"lastInitiatedAt":"2025-07-14T14:22:43.912Z","status":"assured","access":"full"},"metadata":{"accountCreatedAt":"2022-11-17T00:35:16.391Z"}}` 200 136 + } 128 137 129 138 reverse_proxy {$PI_ADDRESS:pi}:8000 { 130 139 transport http { ··· 159 168 output stdout 160 169 format console 161 170 } 171 + 172 + rewrite / /knot 173 + @landing path /knot /styles.css 174 + reverse_proxy @landing landing:8000 162 175 163 176 reverse_proxy {$PI_ADDRESS:pi}:5555 164 177 }
+11
compose.yaml
··· 12 12 build: ./landing 13 13 environment: 14 14 - PORT=8000 15 + - PDS=http://100.84.64.24:8000 16 + - KNOT_HOST=http://100.84.64.24:5555 17 + # tangled uses a plain domain name for knots in lexicons. imo this is bad but it is what it is rn 18 + # allows me to have a diff HOST to the public one which may improve speeds ig? 19 + - KNOT_NAME=knot.vielle.dev 15 20 restart: unless-stopped 16 21 17 22 caddy: ··· 29 34 - caddy_config:/config 30 35 environment: 31 36 HOST: vielle.dev 37 + DONG_HOST: dongs.zip 38 + PDS_ADMIN_EMAIL: admin@vielle.dev 39 + PI_ADDRESS: "100.84.64.24" 40 + PI_PORT_PDS: 8000 41 + PI_PORT_KNOT: 5555 42 + PI_PORT_PIPER: 8010 32 43 depends_on: 33 44 - prs 34 45 - landing
+2 -2
landing/Dockerfile
··· 3 3 4 4 COPY ./ /app 5 5 RUN deno install 6 - RUN deno bundle --unstable-raw-imports --output bundle.js /app/index.ts 6 + RUN deno bundle --unstable-raw-imports --output bundle.js /app/src/index.ts 7 7 8 - CMD deno --allow-net --unstable-temporal bundle.js 8 + CMD deno --allow-net --unstable-temporal --allow-env bundle.js
+3
landing/deno.json
··· 1 1 { 2 + "tasks": { 3 + "dev": "deno run --allow-net --unstable-temporal --unstable-raw-imports --allow-env --watch ./src/index.ts" 4 + }, 2 5 "imports": { 3 6 "zod": "npm:zod@^4.1.13" 4 7 }
landing/heading.txt landing/src/pds/pds-heading.txt
+18 -50
landing/index.ts landing/src/pds/pds.ts
··· 1 - import css from "./styles.css" with { type: "text" }; 2 - import heading from "./heading.txt" with { type: "text" }; 1 + import heading from "./pds-heading.txt" with { type: "text" }; 3 2 import { 4 3 didDocSchema, 5 4 getRecordSchema, ··· 8 7 profileSelfSchema, 9 8 statusphereSchema, 10 9 tealFmStatusSchema, 11 - } from "./schemas.ts"; 10 + } from "../schemas.ts"; 11 + import { stagger } from "../utils.ts"; 12 12 13 - const PORT = Number(Deno.env.get("PORT")); 14 13 const PDS = Deno.env.get("PDS") ?? "http://pi:8000"; 14 + const PLC_DIRECTORY = Deno.env.get("PLC_DIRECTORY") ?? "https://plc.directory"; 15 15 16 16 async function renderUser(did: string): Promise<string> { 17 17 const handle = fetch( 18 18 did.startsWith("did:plc") 19 - ? "https://plc.directory/" + did 19 + ? `${PLC_DIRECTORY}/${did}` 20 20 : `https://${did.replace("did:web:", "")}/.well-known/did.json` 21 21 ) 22 22 .then((res) => res.json()) ··· 25 25 (doc) => 26 26 doc.alsoKnownAs 27 27 .filter((x) => x.startsWith("at://"))[0] 28 - ?.replace("at://", "") ?? "invalid.handle" 29 - ); 28 + ?.replace("at://", "") ?? "handle.invalid" 29 + ) 30 + .catch(() => "handle.invalid"); 30 31 31 - const displayName = fetch( 32 + const displayName: Promise<string | undefined> = fetch( 32 33 `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=app.bsky.actor.profile&rkey=self` 33 34 ) 34 35 .then((res) => res.json()) 35 36 .then((data) => getRecordSchema(profileSelfSchema).safeParse(data).data) 36 - .then((data) => (data ? data.value.displayName : undefined)); 37 + .then((data) => (data ? data.value.displayName : undefined)) 38 + .catch(() => undefined); 37 39 38 - const statusphere = fetch( 40 + const statusphere: Promise<string | undefined> = fetch( 39 41 `${PDS}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=xyz.statusphere.status` 40 42 ) 41 43 .then((res) => res.json()) 42 44 .then((data) => listRecordsSchema(statusphereSchema).safeParse(data).data) 43 45 .then((data) => 44 46 data && data.records.length > 0 ? data.records[0].value.status : undefined 45 - ); 47 + ) 48 + .catch(() => undefined); 46 49 47 50 const nowPlaying = fetch( 48 51 `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=fm.teal.alpha.actor.status&rkey=self` ··· 59 62 ? `${data.value.item.artists.length - 1} more artist${data.value.item.artists.length > 1 ? "s" : ""}` 60 63 : "")) 61 64 : undefined 62 - ); 65 + ) 66 + .catch(() => undefined); 63 67 64 68 return ( 65 69 "- " + ··· 77 81 ); 78 82 } 79 83 80 - function landingPage(): Response { 84 + export default function (): Response { 81 85 // get upstream pds status 82 86 const status = fetch(`${PDS}/xrpc/_health`).then( 83 87 async (res) => `${res.status} ${res.statusText} ${await res.text()}` ··· 107 111 }, 108 112 }); 109 113 110 - // buffer the output just to be easier to handle 111 - const stagger = new TransformStream({ 112 - async transform( 113 - chunk: string, 114 - controller: TransformStreamDefaultController<string> 115 - ) { 116 - for (const part of chunk) { 117 - controller.enqueue(part); 118 - await new Promise((res) => setTimeout(res, 10)); 119 - } 120 - }, 121 - }); 122 - 123 114 return new Response( 124 - body.pipeThrough(stagger).pipeThrough(new TextEncoderStream()), 115 + body.pipeThrough(stagger()).pipeThrough(new TextEncoderStream()), 125 116 { 126 117 headers: { 127 118 "Content-Type": "text/text; charset=utf-8", ··· 131 122 } 132 123 ); 133 124 } 134 - 135 - Deno.serve( 136 - { port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 }, 137 - (req) => { 138 - const path = new URL(req.url).pathname; 139 - switch (path) { 140 - case "/": 141 - return landingPage(); 142 - case "/styles.css": 143 - return new Response(css, { 144 - headers: { 145 - "Content-Type": "text/css; charset=utf-8", 146 - "Access-Control-Allow-Origin": "*", 147 - }, 148 - }); 149 - default: 150 - return new Response(`404 Not Found: ${path} does not exist`, { 151 - status: 400, 152 - statusText: "Not Found", 153 - }); 154 - } 155 - } 156 - );
+18
landing/schemas.ts landing/src/schemas.ts
··· 30 30 }), 31 31 }); 32 32 33 + export const tangledKnotOwnerSchema = z.object({ 34 + owner: z.string(), 35 + }); 36 + 37 + export const tangledRepoSchema = z.object({ 38 + knot: z.string(), 39 + name: z.string(), 40 + description: z.string().optional(), 41 + website: z.url().optional(), 42 + }); 43 + 44 + export const slingshotMiniDocSchema = z.object({ 45 + pds: z.string(), 46 + handle: z.string(), 47 + did: z.string(), 48 + }); 49 + 33 50 export function getRecordSchema<T>(record: T) { 34 51 return z.object({ 35 52 value: record, ··· 45 62 value: record, 46 63 }) 47 64 ), 65 + cursor: z.string().optional(), 48 66 }); 49 67 }
+36
landing/src/index.ts
··· 1 + import css from "./styles.css" with { type: "text" }; 2 + import pds from "./pds/pds.ts"; 3 + import knot from "./knot/knot.ts"; 4 + 5 + const PORT = Number(Deno.env.get("PORT")); 6 + 7 + Deno.serve( 8 + { port: !Number.isNaN(PORT) && PORT <= 65535 && PORT > 0 ? PORT : 8000 }, 9 + (req) => { 10 + const path = new URL(req.url).pathname; 11 + console.log("PATH", path); 12 + switch (path) { 13 + case "/styles.css": 14 + return new Response(css, { 15 + headers: { 16 + "Content-Type": "text/css; charset=utf-8", 17 + "Access-Control-Allow-Origin": "*", 18 + }, 19 + }); 20 + 21 + case "/pds": 22 + case "/pds/": 23 + return pds(); 24 + 25 + case "/knot": 26 + case "/knot/": 27 + return knot(); 28 + } 29 + 30 + console.warn(`Request for ${path} returned 404`); 31 + return new Response("404 Not Found", { 32 + status: 404, 33 + statusText: "Not Found", 34 + }); 35 + } 36 + );
+10
landing/src/knot/knot-heading.txt
··· 1 + __ ___ _ _ ___ __ 2 + | \ / \| \| |/ __|/__| 3 + _ | - || - || || | |\__\ 4 + |_||__/ \___/|_|\_|\___||__/ 5 + 6 + 7 + Knot Source: https://tangled.org/tangled.org/core/tree/master/knotserver 8 + Docs: https://tangled.org/tangled.org/core/tree/master/docs 9 + .dong file: https://dongs.zip/ 10 + health:
+162
landing/src/knot/knot.ts
··· 1 + import z from "zod"; 2 + import heading from "./knot-heading.txt" with { type: "text" }; 3 + import { 4 + listRecordsSchema, 5 + slingshotMiniDocSchema, 6 + tangledKnotOwnerSchema, 7 + tangledRepoSchema, 8 + } from "../schemas.ts"; 9 + import { stagger } from "../utils.ts"; 10 + 11 + const KNOT_HOST = Deno.env.get("KNOT_HOST") ?? "http://pi:5555"; 12 + const KNOT_NAME = Deno.env.get("KNOT_NAME") ?? "knot.vielle.dev"; 13 + const SLINGSHOT_INSTANCE = 14 + Deno.env.get("SLINGSHOT_INSTANCE") ?? "https://slingshot.microcosm.blue"; 15 + 16 + function getAllRecords<T>( 17 + owner: string, 18 + pds: string, 19 + collection: string, 20 + validator: z.ZodType, 21 + cursor?: string 22 + ): Promise<{ uri: string; cid: string; value: T }[]> { 23 + return fetch( 24 + `${pds}/xrpc/com.atproto.repo.listRecords?repo=${owner}&collection=${collection}&cursor=${cursor}` 25 + ) 26 + .then((res) => res.json()) 27 + 28 + .then( 29 + (res) => 30 + // check that it matches the schema and assume that the schema matches T 31 + listRecordsSchema(validator).safeParse(res).data as { 32 + records: { 33 + uri: string; 34 + cid: string; 35 + value: T; 36 + }[]; 37 + cursor?: string; 38 + } 39 + ) 40 + .then(async (res) => [ 41 + ...res.records, 42 + ...(res.cursor 43 + ? await getAllRecords<T>(owner, pds, collection, validator, res.cursor) 44 + : []), 45 + ]); 46 + } 47 + 48 + export default function (): Response { 49 + // get upstream knot owner/status 50 + const owner = fetch(`${KNOT_HOST}/xrpc/sh.tangled.owner`); 51 + const status = owner.then( 52 + async (res) => `${res.status} ${res.statusText} ${await res.clone().text()}` 53 + ); 54 + 55 + const ownerDid: Promise<string | undefined> = owner 56 + .then((res) => res.json()) 57 + .then((x) => tangledKnotOwnerSchema.safeParse(x).data?.owner) 58 + .catch(() => undefined); 59 + 60 + const ownerPds = ownerDid 61 + .then((owner) => 62 + fetch( 63 + `${SLINGSHOT_INSTANCE}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${owner}` 64 + ) 65 + ) 66 + .then((res) => res.json()) 67 + .then((x) => slingshotMiniDocSchema.safeParse(x).data?.pds); 68 + 69 + const members = ownerPds 70 + .then((pds) => 71 + ownerDid.then(async (owner) => [ 72 + owner, 73 + ...(await getAllRecords<{ subject: string; domain: string }>( 74 + owner, 75 + pds, 76 + "sh.tangled.knot.member", 77 + z.object({ 78 + subject: z.string(), 79 + domain: z.string(), 80 + }) 81 + ).then((x) => 82 + x 83 + .filter((x) => x.value.domain === KNOT_NAME) 84 + .map((x) => x.value.subject) 85 + )), 86 + ]) 87 + ) 88 + .then((dids) => 89 + Promise.all( 90 + dids.map((did) => 91 + fetch( 92 + `${SLINGSHOT_INSTANCE}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${did}` 93 + ) 94 + .then((res) => res.json()) 95 + .then((res) => slingshotMiniDocSchema.safeParse(res).data) 96 + ) 97 + ) 98 + ); 99 + 100 + const membersRepr = members.then( 101 + (x) => 102 + " - " + 103 + x 104 + .map((x) => (x.handle !== "handle.invalid" ? x.handle : x.did)) 105 + .join("\n - ") 106 + ); 107 + 108 + const repos = members.then((members) => 109 + Promise.all( 110 + members.map((member) => 111 + getAllRecords<z.infer<typeof tangledRepoSchema>>( 112 + member.did, 113 + member.pds, 114 + "sh.tangled.repo", 115 + tangledRepoSchema 116 + ).then((x) => 117 + x.map((x) => ({ 118 + ...x.value, 119 + user: 120 + member.handle !== "handle.invalid" ? member.handle : member.did, 121 + })) 122 + ) 123 + ) 124 + ).then((x) => x.flat().filter((x) => x.knot === KNOT_NAME)) 125 + ); 126 + 127 + const reposRepr = repos.then( 128 + (x) => 129 + " - " + 130 + x 131 + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) 132 + .map( 133 + (x) => 134 + `${x.user}/${x.name}${x.description ? `\n ${x.description}` : ""}${x.website ? `\n ${x.website}` : ""}` 135 + ) 136 + .join("\n - ") + 137 + "\n" 138 + ); 139 + 140 + const body = new ReadableStream({ 141 + async start(controller) { 142 + controller.enqueue(heading); 143 + controller.enqueue(await status); 144 + controller.enqueue("\n\nMembers:\n"); 145 + controller.enqueue(await membersRepr); 146 + controller.enqueue("\n\nRepos:\n"); 147 + controller.enqueue(await reposRepr); 148 + controller.close(); 149 + }, 150 + }); 151 + 152 + return new Response( 153 + body.pipeThrough(stagger()).pipeThrough(new TextEncoderStream()), 154 + { 155 + headers: { 156 + "Content-Type": "text/text; charset=utf-8", 157 + "Access-Control-Allow-Origin": "*", 158 + Link: "</styles.css>; rel=stylesheet", 159 + }, 160 + } 161 + ); 162 + }
+16
landing/src/utils.ts
··· 1 + /** 2 + * Delays the output of each character in the input stream by 10ms 3 + * @returns TransformStream 4 + */ 5 + export const stagger = () => 6 + new TransformStream({ 7 + async transform( 8 + chunk: string, 9 + controller: TransformStreamDefaultController<string> 10 + ) { 11 + for (const part of chunk) { 12 + controller.enqueue(part); 13 + await new Promise((res) => setTimeout(res, 10)); 14 + } 15 + }, 16 + });
landing/styles.css landing/src/styles.css