···11+# syntax=docker/dockerfile:1
22+# Go XRPC service (cmd/puzzlemethis). The SvelteKit client has its own
33+# Dockerfile in client/; docker-compose.yml runs both.
44+#
55+# Example:
66+# docker build -t puzzlemethis-api .
77+# docker run -p 8080:8080 -v ./data:/data \
88+# -e SERVICE_DID=did:web:api.example.com \
99+# -e SERVICE_HOST=api.example.com \
1010+# -e SERVICE_PRIVATE_KEY=... puzzlemethis-api
1111+1212+FROM golang:1.26-bookworm AS build
1313+WORKDIR /src
1414+COPY go.mod go.sum ./
1515+RUN go mod download
1616+COPY . .
1717+# mattn/go-sqlite3 compiles SQLite from C source: CGO is required, and the
1818+# runtime image only needs a matching glibc (hence bookworm on both stages),
1919+# no libsqlite3 package. themes.xml and board-image sprites are go:embed'd.
2020+RUN CGO_ENABLED=1 go build -trimpath -o /out/puzzlemethis ./cmd/puzzlemethis
2121+2222+FROM debian:bookworm-slim
2323+# curl is for the compose healthcheck; ca-certificates for HTTPS to PLC/slingshot.
2424+RUN apt-get update \
2525+ && apt-get install -y --no-install-recommends ca-certificates curl \
2626+ && rm -rf /var/lib/apt/lists/* \
2727+ && useradd --uid 1000 --create-home app
2828+COPY --from=build /out/puzzlemethis /usr/local/bin/
2929+# /data holds the SQLite db plus its WAL sidecars — mount a directory there
3030+# that uid 1000 can write.
3131+ENV SERVER_HOST=0.0.0.0 \
3232+ DATABASE_URL=/data/puzzles.db \
3333+ LOG_FORMAT=json
3434+USER app
3535+EXPOSE 8080
3636+# Exec form so SIGTERM reaches the process directly — the server checkpoints
3737+# the WAL on graceful shutdown.
3838+ENTRYPOINT ["/usr/local/bin/puzzlemethis"]
···11# PuzzleMeThis client
2233-SvelteKit SPA (Svelte 5 + Tailwind v4 + daisyUI) for the PuzzleMeThis XRPC service.
33+SvelteKit app (adapter-node, Svelte 5 + Tailwind v4 + daisyUI) for the PuzzleMeThis XRPC service.
4455- **Casual** (`/`): random puzzles with theme/rating filters, direct calls to the Go service.
66- **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.
···1717| Variable | Meaning |
1818| --- | --- |
1919| `PUBLIC_API_URL` | Go service base URL, used only for the public endpoints (`http://localhost:8080` in dev). |
2020-| `PUBLIC_SERVICE_DID` | Must equal the Go service's `SERVICE_DID` verbatim (`did:web:<host>`). |
2121-| `PUBLIC_HANDLE_RESOLVER` | Handle resolver for OAuth sign-in (`https://bsky.social`). |
2020+| `PUBLIC_SERVICE_DID` | Must equal the Go service's `SERVICE_DID` verbatim (`did:web:<host>`). Also baked into the OAuth scope's `aud`. |
2121+2222+(Handle resolution for OAuth sign-in is hardcoded to Cloudflare DNS-over-HTTPS in `src/lib/auth/auth.svelte.ts`.)
2323+2424+## OAuth client
2525+2626+Scopes are granular atproto permissions (no `transition:generic`), built in
2727+`src/lib/auth/clientMetadata.ts`:
2828+2929+- `atproto`
3030+- `repo:org.lichess.puzzle.attempt?action=create` — create-only attempt records
3131+- `rpc:org.lichess.puzzle.getNextRanked?aud=<did>%23lichess_puzzles`
3232+- `rpc:org.lichess.puzzle.submitAttempt?aud=<did>%23lichess_puzzles`
3333+3434+Two client modes, picked automatically by hostname in `auth.svelte.ts`:
3535+3636+- **Loopback (dev)** on `127.0.0.1`/`localhost`/`[::1]`: the atproto loopback
3737+ client, scope embedded in the `http://localhost?...` client_id.
3838+- **Discoverable (deployed)** everywhere else: client metadata served
3939+ dynamically at `/client-metadata.json` (a `+server.ts` endpoint generated
4040+ from the same module), with `client_id = <origin>/client-metadata.json`.
4141+ Requires an **https** origin with a DNS hostname, and the `ORIGIN` env var
4242+ must exactly equal the public origin.
4343+4444+Changing scopes (or the client_id) invalidates existing sessions — users land
4545+logged-out and must sign in again.
22462347## Dev workflow
244825491. Run the Go service: `go run ./cmd/puzzlemethis` (needs `SERVICE_DID`, `SERVICE_HOST`, `SERVICE_PRIVATE_KEY`).
26502. **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.
2727-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.
5151+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).
5252+5353+## Deploy
5454+5555+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).
5656+5757+Seed the api's data directory with a **snapshot** of the puzzle db (never the live one):
5858+5959+```sh
6060+mkdir -p data && sqlite3 puzzles.db "VACUUM INTO 'data/puzzles.db'"
6161+```
28622963## Codegen
3064
···55 BrowserOAuthClient,
66 type OAuthSession,
77} from "@atproto/oauth-client-browser";
88+import { PUBLIC_SERVICE_DID } from "$env/static/public";
89import type { AtpBaseClient } from "$lib/lexicon";
910import { makeRankedApi } from "$lib/api/client";
1111+import { buildClientMetadata, buildScope } from "./clientMetadata";
10121113class AuthStore {
1214 /** False until init() has restored (or ruled out) a previous session. */
···3032 async init() {
3133 if (this.client) return;
3234 try {
3333- // Atproto loopback client (dev-only): the app must be served from
3434- // 127.0.0.1 (not localhost) for redirects to work. Built explicitly
3535- // with the root path as redirect URI — the library default derives it
3636- // from the current URL and breaks on any non-root path.
3735 const { protocol, hostname, port } = window.location;
3636+ const isLoopback =
3737+ hostname === "localhost" ||
3838+ hostname === "127.0.0.1" ||
3939+ hostname === "[::1]";
4040+ // Dev uses the atproto loopback client (the app must be served from
4141+ // 127.0.0.1, not localhost, for redirects to work); deployed origins use
4242+ // discoverable metadata hosted at /client-metadata.json, generated from
4343+ // the same clientMetadata module. Redirect URI is built explicitly with
4444+ // the root path — the library default derives it from the current URL
4545+ // and breaks on any non-root path.
4646+ const scope = buildScope(PUBLIC_SERVICE_DID);
3847 const host = hostname === "localhost" ? "127.0.0.1" : hostname;
3948 const redirectUri = `${protocol}//${host}${port ? `:${port}` : ""}/`;
4040- //TODO come back and do proper scopes
4141- const scopes = encodeURIComponent("atproto transition:generic");
4249 this.client = new BrowserOAuthClient({
4350 handleResolver: new AtprotoDohHandleResolver({
4451 dohEndpoint: "https://cloudflare-dns.com/dns-query",
4552 }),
4646- clientMetadata: atprotoLoopbackClientMetadata(
4747- `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scopes}`,
4848- ),
5353+ clientMetadata: isLoopback
5454+ ? atprotoLoopbackClientMetadata(
5555+ `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`,
5656+ )
5757+ : buildClientMetadata(window.location.origin, PUBLIC_SERVICE_DID),
4958 });
5059 const result = await this.client.init();
5160 if (result) this.setSession(result.session);
···11+import type { OAuthClientMetadataInput } from "@atproto/oauth-client-browser";
22+33+/**
44+ * Path of the hosted client-metadata document; also the client_id path.
55+ * Served dynamically by src/routes/client-metadata.json/+server.ts so the
66+ * metadata the authorization server fetches is generated from this same
77+ * module the browser OAuth client uses — they can never drift.
88+ */
99+export const CLIENT_METADATA_PATH = "/client-metadata.json";
1010+1111+/**
1212+ * Granular atproto permission scope for this app (replaces transition:generic):
1313+ * create-only writes to the attempt collection, plus service-auth proxied RPC
1414+ * to the two ranked endpoints on our service.
1515+ */
1616+export function buildScope(serviceDid: string): string {
1717+ // aud is a DID service reference: DID + service fragment, with '#' written
1818+ // pre-encoded as %23 inside the scope atom. Do NOT encodeURIComponent the
1919+ // DID itself — that would rewrite ':' and change the atom.
2020+ const aud = `${serviceDid}#lichess_puzzles`;
2121+ return [
2222+ "atproto",
2323+ "repo:org.lichess.puzzle.attempt?action=create",
2424+ `rpc:org.lichess.puzzle.getNextRanked?aud=${aud}`,
2525+ `rpc:org.lichess.puzzle.submitAttempt?aud=${aud}`,
2626+ `rpc:org.lichess.puzzle.getById?aud=${aud}`,
2727+ `rpc:org.lichess.puzzle.getRandom?aud=${aud}`,
2828+ `rpc:org.lichess.puzzle.getThemes?aud=${aud}`,
2929+ `rpc:org.lichess.puzzle.getLeaderboard?aud=${aud}`,
3030+ ].join(" ");
3131+}
3232+3333+/**
3434+ * Full metadata for the deployed (discoverable) client. Only valid on https
3535+ * origins with a DNS hostname — loopback dev keeps the atproto loopback client
3636+ * (see auth.svelte.ts).
3737+ */
3838+export function buildClientMetadata(
3939+ origin: string,
4040+ serviceDid: string,
4141+): OAuthClientMetadataInput {
4242+ return {
4343+ client_id: `${origin}${CLIENT_METADATA_PATH}`,
4444+ client_name: "PuzzleMeThis",
4545+ client_uri: origin,
4646+ redirect_uris: [`${origin}/`],
4747+ scope: buildScope(serviceDid),
4848+ grant_types: ["authorization_code", "refresh_token"],
4949+ response_types: ["code"],
5050+ token_endpoint_auth_method: "none",
5151+ application_type: "web",
5252+ dpop_bound_access_tokens: true,
5353+ };
5454+}
···11+import { json } from "@sveltejs/kit";
22+import { PUBLIC_SERVICE_DID } from "$env/static/public";
33+import { buildClientMetadata } from "$lib/auth/clientMetadata";
44+import type { RequestHandler } from "./$types";
55+66+export const prerender = false;
77+88+/**
99+ * OAuth client-metadata document (the client_id URL). Authorization servers
1010+ * fetch this during the auth flow. url.origin comes from the ORIGIN env var
1111+ * under adapter-node — it must exactly equal the public origin or the
1212+ * client_id the AS sees won't match what the browser sent.
1313+ */
1414+export const GET: RequestHandler = ({ url }) =>
1515+ json(buildClientMetadata(url.origin, PUBLIC_SERVICE_DID), {
1616+ headers: { "cache-control": "public, max-age=300" },
1717+ });
···11+# Runs the Go XRPC service (api) and the SvelteKit client (web).
22+# TLS is handled outside this stack — point your reverse proxy / tunnel at
33+# ports 8080 (api) and 3000 (web).
44+#
55+# Interpolated values come from the repo-root .env (compose loads it
66+# automatically): SERVICE_DID, SERVICE_HOST, SERVICE_PRIVATE_KEY,
77+# PUBLIC_API_URL, WEB_ORIGIN (and optionally PLC_URL, SLINGSHOT_URL).
88+services:
99+ api:
1010+ build: .
1111+ ports: ["8080:8080"]
1212+ environment:
1313+ SERVICE_DID: ${SERVICE_DID}
1414+ SERVICE_HOST: ${SERVICE_HOST}
1515+ SERVICE_PRIVATE_KEY: ${SERVICE_PRIVATE_KEY}
1616+ PLC_URL: ${PLC_URL:-https://plc.directory}
1717+ SLINGSHOT_URL: ${SLINGSHOT_URL:-https://slingshot.microcosm.blue}
1818+ volumes:
1919+ # Directory, not file: SQLite WAL needs the -wal/-shm sidecars next to
2020+ # the db. Seed with a SNAPSHOT of the puzzle db:
2121+ # sqlite3 puzzles.db "VACUUM INTO 'data/puzzles.db'"
2222+ # NEVER move or mount the live repo-root puzzles.db itself.
2323+ # On Linux hosts ./data must be writable by uid 1000 (chown -R 1000 data).
2424+ - ./data:/data
2525+ healthcheck:
2626+ test: ["CMD", "curl", "-fsS", "http://localhost:8080/"]
2727+ interval: 30s
2828+ timeout: 5s
2929+ retries: 3
3030+ start_period: 15s
3131+ restart: unless-stopped
3232+3333+ web:
3434+ build:
3535+ context: ./client
3636+ args:
3737+ # Baked into the browser bundle at build time. PUBLIC_API_URL is what
3838+ # the BROWSER calls — the public URL fronting :8080, never http://api:8080.
3939+ PUBLIC_API_URL: ${PUBLIC_API_URL}
4040+ # Must equal the api's SERVICE_DID verbatim.
4141+ PUBLIC_SERVICE_DID: ${SERVICE_DID}
4242+ environment:
4343+ # Exact public origin of the web app, e.g. https://puzzles.example.com.
4444+ # adapter-node uses it for CSRF checks and it becomes the OAuth
4545+ # client_id origin served at /client-metadata.json — a mismatch breaks login.
4646+ ORIGIN: ${WEB_ORIGIN}
4747+ ports: ["3000:3000"]
4848+ depends_on:
4949+ api:
5050+ condition: service_healthy
5151+ restart: unless-stopped