This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

getting ready

author
pds.dad
date (Jul 2, 2026, 5:14 PM -0500) commit 185d4fe6 parent 0c94e614 change-id momouurp
+300 -22
+12
.dockerignore
··· 1 + .git 2 + .env 3 + .claude 4 + client/ 5 + chess-puzzles/ 6 + data/ 7 + tmp/ 8 + puzzles.db 9 + puzzles.db-shm 10 + puzzles.db-wal 11 + puzzleimport 12 + *.md
+38
Dockerfile
··· 1 + # syntax=docker/dockerfile:1 2 + # Go XRPC service (cmd/puzzlemethis). The SvelteKit client has its own 3 + # Dockerfile in client/; docker-compose.yml runs both. 4 + # 5 + # Example: 6 + # docker build -t puzzlemethis-api . 7 + # docker run -p 8080:8080 -v ./data:/data \ 8 + # -e SERVICE_DID=did:web:api.example.com \ 9 + # -e SERVICE_HOST=api.example.com \ 10 + # -e SERVICE_PRIVATE_KEY=... puzzlemethis-api 11 + 12 + FROM golang:1.26-bookworm AS build 13 + WORKDIR /src 14 + COPY go.mod go.sum ./ 15 + RUN go mod download 16 + COPY . . 17 + # mattn/go-sqlite3 compiles SQLite from C source: CGO is required, and the 18 + # runtime image only needs a matching glibc (hence bookworm on both stages), 19 + # no libsqlite3 package. themes.xml and board-image sprites are go:embed'd. 20 + RUN CGO_ENABLED=1 go build -trimpath -o /out/puzzlemethis ./cmd/puzzlemethis 21 + 22 + FROM debian:bookworm-slim 23 + # curl is for the compose healthcheck; ca-certificates for HTTPS to PLC/slingshot. 24 + RUN apt-get update \ 25 + && apt-get install -y --no-install-recommends ca-certificates curl \ 26 + && rm -rf /var/lib/apt/lists/* \ 27 + && useradd --uid 1000 --create-home app 28 + COPY --from=build /out/puzzlemethis /usr/local/bin/ 29 + # /data holds the SQLite db plus its WAL sidecars — mount a directory there 30 + # that uid 1000 can write. 31 + ENV SERVER_HOST=0.0.0.0 \ 32 + DATABASE_URL=/data/puzzles.db \ 33 + LOG_FORMAT=json 34 + USER app 35 + EXPOSE 8080 36 + # Exec form so SIGTERM reaches the process directly — the server checkpoints 37 + # the WAL on graceful shutdown. 38 + ENTRYPOINT ["/usr/local/bin/puzzlemethis"]
+4 -2
client/Dockerfile
··· 16 16 # --build-arg PUBLIC_SERVICE_DID=did:web:api.example.com ./client 17 17 # docker run -p 3000:3000 -e ORIGIN=https://puzzles.example.com puzzlemethis-client 18 18 19 - FROM node:22-slim AS build 19 + # node 24 (npm 11): npm 10 rejects this lockfile — @emnapi/* resolve as 20 + # bundled deps of @tailwindcss/oxide-wasm32-wasi, which npm 10 can't verify. 21 + FROM node:24-slim AS build 20 22 WORKDIR /app 21 23 22 24 # Install deps against the lockfile first for better layer caching. ··· 34 36 RUN npm run build:docker 35 37 36 38 # --- runtime image ----------------------------------------------------------- 37 - FROM node:22-slim 39 + FROM node:24-slim 38 40 WORKDIR /app 39 41 ENV NODE_ENV=production 40 42
+38 -4
client/README.md
··· 1 1 # PuzzleMeThis client 2 2 3 - SvelteKit SPA (Svelte 5 + Tailwind v4 + daisyUI) for the PuzzleMeThis XRPC service. 3 + SvelteKit app (adapter-node, Svelte 5 + Tailwind v4 + daisyUI) for the PuzzleMeThis XRPC service. 4 4 5 5 - **Casual** (`/`): random puzzles with theme/rating filters, direct calls to the Go service. 6 6 - **Ranked** (`/ranked`): sign in with atproto OAuth; ranked calls are **proxied through your PDS** (`atproto-proxy: <SERVICE_DID>#lichess_puzzles` header), which mints the service-auth JWT and forwards to the service. ··· 17 17 | Variable | Meaning | 18 18 | --- | --- | 19 19 | `PUBLIC_API_URL` | Go service base URL, used only for the public endpoints (`http://localhost:8080` in dev). | 20 - | `PUBLIC_SERVICE_DID` | Must equal the Go service's `SERVICE_DID` verbatim (`did:web:<host>`). | 21 - | `PUBLIC_HANDLE_RESOLVER` | Handle resolver for OAuth sign-in (`https://bsky.social`). | 20 + | `PUBLIC_SERVICE_DID` | Must equal the Go service's `SERVICE_DID` verbatim (`did:web:<host>`). Also baked into the OAuth scope's `aud`. | 21 + 22 + (Handle resolution for OAuth sign-in is hardcoded to Cloudflare DNS-over-HTTPS in `src/lib/auth/auth.svelte.ts`.) 23 + 24 + ## OAuth client 25 + 26 + Scopes are granular atproto permissions (no `transition:generic`), built in 27 + `src/lib/auth/clientMetadata.ts`: 28 + 29 + - `atproto` 30 + - `repo:org.lichess.puzzle.attempt?action=create` — create-only attempt records 31 + - `rpc:org.lichess.puzzle.getNextRanked?aud=<did>%23lichess_puzzles` 32 + - `rpc:org.lichess.puzzle.submitAttempt?aud=<did>%23lichess_puzzles` 33 + 34 + Two client modes, picked automatically by hostname in `auth.svelte.ts`: 35 + 36 + - **Loopback (dev)** on `127.0.0.1`/`localhost`/`[::1]`: the atproto loopback 37 + client, scope embedded in the `http://localhost?...` client_id. 38 + - **Discoverable (deployed)** everywhere else: client metadata served 39 + dynamically at `/client-metadata.json` (a `+server.ts` endpoint generated 40 + from the same module), with `client_id = <origin>/client-metadata.json`. 41 + Requires an **https** origin with a DNS hostname, and the `ORIGIN` env var 42 + must exactly equal the public origin. 43 + 44 + Changing scopes (or the client_id) invalidates existing sessions — users land 45 + logged-out and must sign in again. 22 46 23 47 ## Dev workflow 24 48 25 49 1. Run the Go service: `go run ./cmd/puzzlemethis` (needs `SERVICE_DID`, `SERVICE_HOST`, `SERVICE_PRIVATE_KEY`). 26 50 2. **Ranked only:** the player's PDS must reach the service from the public internet — run a stable-hostname tunnel (cloudflared named tunnel / tailscale funnel / ngrok reserved domain) to `localhost:8080` and set `SERVICE_HOST=<tunnel-host>`, `SERVICE_DID=did:web:<tunnel-host>` on the Go side (and `PUBLIC_SERVICE_DID` here to match). Check `https://<tunnel-host>/.well-known/did.json` resolves before testing. 27 - 3. `npm run dev` and open **http://127.0.0.1:5173** — sign-in uses the atproto *loopback* OAuth client, which only works from `127.0.0.1`/`[::1]`, not `localhost` (the app shows a banner if you get this wrong). Production would need hosted client metadata instead. 51 + 3. `npm run dev` and open **http://127.0.0.1:5173** — sign-in uses the atproto *loopback* OAuth client, which only works from `127.0.0.1`/`[::1]`, not `localhost` (the app shows a banner if you get this wrong). Deployed origins automatically switch to the hosted metadata at `/client-metadata.json` (see "OAuth client" above). 52 + 53 + ## Deploy 54 + 55 + The repo root has a `docker-compose.yml` running the Go service (`api`, port 8080, root `Dockerfile`) and this client (`web`, port 3000, `client/Dockerfile`). TLS is terminated outside the stack. Required in the root `.env`: `SERVICE_DID`, `SERVICE_HOST`, `SERVICE_PRIVATE_KEY`, `PUBLIC_API_URL` (browser-reachable API origin), `WEB_ORIGIN` (exact public origin of this app — becomes both adapter-node's `ORIGIN` and the OAuth client_id origin). 56 + 57 + Seed the api's data directory with a **snapshot** of the puzzle db (never the live one): 58 + 59 + ```sh 60 + mkdir -p data && sqlite3 puzzles.db "VACUUM INTO 'data/puzzles.db'" 61 + ``` 28 62 29 63 ## Codegen 30 64
+68 -7
client/package-lock.json
··· 353 353 "node": ">=22" 354 354 } 355 355 }, 356 + "node_modules/@emnapi/core": { 357 + "version": "1.11.1", 358 + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", 359 + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", 360 + "dev": true, 361 + "license": "MIT", 362 + "optional": true, 363 + "dependencies": { 364 + "@emnapi/wasi-threads": "1.2.2", 365 + "tslib": "^2.4.0" 366 + } 367 + }, 368 + "node_modules/@emnapi/runtime": { 369 + "version": "1.11.1", 370 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", 371 + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", 372 + "dev": true, 373 + "license": "MIT", 374 + "optional": true, 375 + "dependencies": { 376 + "tslib": "^2.4.0" 377 + } 378 + }, 356 379 "node_modules/@emnapi/wasi-threads": { 357 380 "version": "1.2.2", 358 381 "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", ··· 1226 1249 "integrity": "sha512-RD4ntr/QiXsU5V0ADEoZ+R2lyPlwZbENJ6ss0YtgQXqWn3+U9PnU/EeitysgpVbDV8d9XTmFqdTMKOTOe3yb0A==", 1227 1250 "dev": true, 1228 1251 "license": "MIT", 1229 - "peer": true, 1230 1252 "dependencies": { 1231 1253 "@standard-schema/spec": "^1.0.0", 1232 1254 "@sveltejs/acorn-typescript": "^1.0.9", ··· 1279 1301 "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", 1280 1302 "dev": true, 1281 1303 "license": "MIT", 1282 - "peer": true, 1283 1304 "dependencies": { 1284 1305 "deepmerge": "^4.3.1", 1285 1306 "magic-string": "^0.30.21", ··· 1517 1538 "node": ">=14.0.0" 1518 1539 } 1519 1540 }, 1541 + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { 1542 + "version": "1.2.2", 1543 + "dev": true, 1544 + "inBundle": true, 1545 + "license": "MIT", 1546 + "optional": true, 1547 + "dependencies": { 1548 + "tslib": "^2.4.0" 1549 + } 1550 + }, 1551 + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { 1552 + "version": "1.1.4", 1553 + "dev": true, 1554 + "inBundle": true, 1555 + "license": "MIT", 1556 + "optional": true, 1557 + "dependencies": { 1558 + "@tybys/wasm-util": "^0.10.1" 1559 + }, 1560 + "funding": { 1561 + "type": "github", 1562 + "url": "https://github.com/sponsors/Brooooooklyn" 1563 + }, 1564 + "peerDependencies": { 1565 + "@emnapi/core": "^1.7.1", 1566 + "@emnapi/runtime": "^1.7.1" 1567 + } 1568 + }, 1569 + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { 1570 + "version": "0.10.2", 1571 + "dev": true, 1572 + "inBundle": true, 1573 + "license": "MIT", 1574 + "optional": true, 1575 + "dependencies": { 1576 + "tslib": "^2.4.0" 1577 + } 1578 + }, 1579 + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { 1580 + "version": "2.8.1", 1581 + "dev": true, 1582 + "inBundle": true, 1583 + "license": "0BSD", 1584 + "optional": true 1585 + }, 1520 1586 "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { 1521 1587 "version": "4.3.2", 1522 1588 "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", ··· 1620 1686 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", 1621 1687 "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", 1622 1688 "license": "MIT", 1623 - "peer": true, 1624 1689 "bin": { 1625 1690 "acorn": "bin/acorn" 1626 1691 }, ··· 2550 2615 "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", 2551 2616 "dev": true, 2552 2617 "license": "MIT", 2553 - "peer": true, 2554 2618 "dependencies": { 2555 2619 "@types/estree": "1.0.9" 2556 2620 }, ··· 2666 2730 "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", 2667 2731 "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", 2668 2732 "license": "MIT", 2669 - "peer": true, 2670 2733 "dependencies": { 2671 2734 "@jridgewell/remapping": "^2.3.4", 2672 2735 "@jridgewell/sourcemap-codec": "^1.5.0", ··· 2806 2869 "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", 2807 2870 "dev": true, 2808 2871 "license": "Apache-2.0", 2809 - "peer": true, 2810 2872 "bin": { 2811 2873 "tsc": "bin/tsc", 2812 2874 "tsserver": "bin/tsserver" ··· 2836 2898 "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", 2837 2899 "dev": true, 2838 2900 "license": "MIT", 2839 - "peer": true, 2840 2901 "dependencies": { 2841 2902 "lightningcss": "^1.32.0", 2842 2903 "picomatch": "^4.0.4",
+18 -9
client/src/lib/auth/auth.svelte.ts
··· 5 5 BrowserOAuthClient, 6 6 type OAuthSession, 7 7 } from "@atproto/oauth-client-browser"; 8 + import { PUBLIC_SERVICE_DID } from "$env/static/public"; 8 9 import type { AtpBaseClient } from "$lib/lexicon"; 9 10 import { makeRankedApi } from "$lib/api/client"; 11 + import { buildClientMetadata, buildScope } from "./clientMetadata"; 10 12 11 13 class AuthStore { 12 14 /** False until init() has restored (or ruled out) a previous session. */ ··· 30 32 async init() { 31 33 if (this.client) return; 32 34 try { 33 - // Atproto loopback client (dev-only): the app must be served from 34 - // 127.0.0.1 (not localhost) for redirects to work. Built explicitly 35 - // with the root path as redirect URI — the library default derives it 36 - // from the current URL and breaks on any non-root path. 37 35 const { protocol, hostname, port } = window.location; 36 + const isLoopback = 37 + hostname === "localhost" || 38 + hostname === "127.0.0.1" || 39 + hostname === "[::1]"; 40 + // Dev uses the atproto loopback client (the app must be served from 41 + // 127.0.0.1, not localhost, for redirects to work); deployed origins use 42 + // discoverable metadata hosted at /client-metadata.json, generated from 43 + // the same clientMetadata module. Redirect URI is built explicitly with 44 + // the root path — the library default derives it from the current URL 45 + // and breaks on any non-root path. 46 + const scope = buildScope(PUBLIC_SERVICE_DID); 38 47 const host = hostname === "localhost" ? "127.0.0.1" : hostname; 39 48 const redirectUri = `${protocol}//${host}${port ? `:${port}` : ""}/`; 40 - //TODO come back and do proper scopes 41 - const scopes = encodeURIComponent("atproto transition:generic"); 42 49 this.client = new BrowserOAuthClient({ 43 50 handleResolver: new AtprotoDohHandleResolver({ 44 51 dohEndpoint: "https://cloudflare-dns.com/dns-query", 45 52 }), 46 - clientMetadata: atprotoLoopbackClientMetadata( 47 - `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}`, 48 - ), 53 + clientMetadata: isLoopback 54 + ? atprotoLoopbackClientMetadata( 55 + `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`, 56 + ) 57 + : buildClientMetadata(window.location.origin, PUBLIC_SERVICE_DID), 49 58 }); 50 59 const result = await this.client.init(); 51 60 if (result) this.setSession(result.session);
+54
client/src/lib/auth/clientMetadata.ts
··· 1 + import type { OAuthClientMetadataInput } from "@atproto/oauth-client-browser"; 2 + 3 + /** 4 + * Path of the hosted client-metadata document; also the client_id path. 5 + * Served dynamically by src/routes/client-metadata.json/+server.ts so the 6 + * metadata the authorization server fetches is generated from this same 7 + * module the browser OAuth client uses — they can never drift. 8 + */ 9 + export const CLIENT_METADATA_PATH = "/client-metadata.json"; 10 + 11 + /** 12 + * Granular atproto permission scope for this app (replaces transition:generic): 13 + * create-only writes to the attempt collection, plus service-auth proxied RPC 14 + * to the two ranked endpoints on our service. 15 + */ 16 + export function buildScope(serviceDid: string): string { 17 + // aud is a DID service reference: DID + service fragment, with '#' written 18 + // pre-encoded as %23 inside the scope atom. Do NOT encodeURIComponent the 19 + // DID itself — that would rewrite ':' and change the atom. 20 + const aud = `${serviceDid}#lichess_puzzles`; 21 + return [ 22 + "atproto", 23 + "repo:org.lichess.puzzle.attempt?action=create", 24 + `rpc:org.lichess.puzzle.getNextRanked?aud=${aud}`, 25 + `rpc:org.lichess.puzzle.submitAttempt?aud=${aud}`, 26 + `rpc:org.lichess.puzzle.getById?aud=${aud}`, 27 + `rpc:org.lichess.puzzle.getRandom?aud=${aud}`, 28 + `rpc:org.lichess.puzzle.getThemes?aud=${aud}`, 29 + `rpc:org.lichess.puzzle.getLeaderboard?aud=${aud}`, 30 + ].join(" "); 31 + } 32 + 33 + /** 34 + * Full metadata for the deployed (discoverable) client. Only valid on https 35 + * origins with a DNS hostname — loopback dev keeps the atproto loopback client 36 + * (see auth.svelte.ts). 37 + */ 38 + export function buildClientMetadata( 39 + origin: string, 40 + serviceDid: string, 41 + ): OAuthClientMetadataInput { 42 + return { 43 + client_id: `${origin}${CLIENT_METADATA_PATH}`, 44 + client_name: "PuzzleMeThis", 45 + client_uri: origin, 46 + redirect_uris: [`${origin}/`], 47 + scope: buildScope(serviceDid), 48 + grant_types: ["authorization_code", "refresh_token"], 49 + response_types: ["code"], 50 + token_endpoint_auth_method: "none", 51 + application_type: "web", 52 + dpop_bound_access_tokens: true, 53 + }; 54 + }
+17
client/src/routes/oauth-client-metadata.json/+server.ts
··· 1 + import { json } from "@sveltejs/kit"; 2 + import { PUBLIC_SERVICE_DID } from "$env/static/public"; 3 + import { buildClientMetadata } from "$lib/auth/clientMetadata"; 4 + import type { RequestHandler } from "./$types"; 5 + 6 + export const prerender = false; 7 + 8 + /** 9 + * OAuth client-metadata document (the client_id URL). Authorization servers 10 + * fetch this during the auth flow. url.origin comes from the ORIGIN env var 11 + * under adapter-node — it must exactly equal the public origin or the 12 + * client_id the AS sees won't match what the browser sent. 13 + */ 14 + export const GET: RequestHandler = ({ url }) => 15 + json(buildClientMetadata(url.origin, PUBLIC_SERVICE_DID), { 16 + headers: { "cache-control": "public, max-age=300" }, 17 + });
+51
docker-compose.yml
··· 1 + # Runs the Go XRPC service (api) and the SvelteKit client (web). 2 + # TLS is handled outside this stack — point your reverse proxy / tunnel at 3 + # ports 8080 (api) and 3000 (web). 4 + # 5 + # Interpolated values come from the repo-root .env (compose loads it 6 + # automatically): SERVICE_DID, SERVICE_HOST, SERVICE_PRIVATE_KEY, 7 + # PUBLIC_API_URL, WEB_ORIGIN (and optionally PLC_URL, SLINGSHOT_URL). 8 + services: 9 + api: 10 + build: . 11 + ports: ["8080:8080"] 12 + environment: 13 + SERVICE_DID: ${SERVICE_DID} 14 + SERVICE_HOST: ${SERVICE_HOST} 15 + SERVICE_PRIVATE_KEY: ${SERVICE_PRIVATE_KEY} 16 + PLC_URL: ${PLC_URL:-https://plc.directory} 17 + SLINGSHOT_URL: ${SLINGSHOT_URL:-https://slingshot.microcosm.blue} 18 + volumes: 19 + # Directory, not file: SQLite WAL needs the -wal/-shm sidecars next to 20 + # the db. Seed with a SNAPSHOT of the puzzle db: 21 + # sqlite3 puzzles.db "VACUUM INTO 'data/puzzles.db'" 22 + # NEVER move or mount the live repo-root puzzles.db itself. 23 + # On Linux hosts ./data must be writable by uid 1000 (chown -R 1000 data). 24 + - ./data:/data 25 + healthcheck: 26 + test: ["CMD", "curl", "-fsS", "http://localhost:8080/"] 27 + interval: 30s 28 + timeout: 5s 29 + retries: 3 30 + start_period: 15s 31 + restart: unless-stopped 32 + 33 + web: 34 + build: 35 + context: ./client 36 + args: 37 + # Baked into the browser bundle at build time. PUBLIC_API_URL is what 38 + # the BROWSER calls — the public URL fronting :8080, never http://api:8080. 39 + PUBLIC_API_URL: ${PUBLIC_API_URL} 40 + # Must equal the api's SERVICE_DID verbatim. 41 + PUBLIC_SERVICE_DID: ${SERVICE_DID} 42 + environment: 43 + # Exact public origin of the web app, e.g. https://puzzles.example.com. 44 + # adapter-node uses it for CSRF checks and it becomes the OAuth 45 + # client_id origin served at /client-metadata.json — a mismatch breaks login. 46 + ORIGIN: ${WEB_ORIGIN} 47 + ports: ["3000:3000"] 48 + depends_on: 49 + api: 50 + condition: service_healthy 51 + restart: unless-stopped
lexicons/org/lichess/puzzle/getPuzzle.json lexicons/org/lichess/puzzle/getById.json
lexicons/org/lichess/puzzle/getRandomPuzzle.json lexicons/org/lichess/puzzle/getRandom.json