From e8eeb7327cbc2f8c89161d11f706a82b99105e31 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:47:43 +0200 Subject: [PATCH] remove starter --- .../.claude/settings.local.json | 12 - apps/atproto-starter/.env.example | 6 - apps/atproto-starter/.gitignore | 27 -- apps/atproto-starter/.mcp.json | 8 - apps/atproto-starter/.npmrc | 1 - apps/atproto-starter/.prettierignore | 4 - apps/atproto-starter/.prettierrc | 15 - apps/atproto-starter/CONTRAIL.md | 440 ------------------ apps/atproto-starter/LICENSE | 21 - apps/atproto-starter/README.md | 245 ---------- apps/atproto-starter/eslint.config.js | 35 -- apps/atproto-starter/package.json | 72 --- apps/atproto-starter/src/app.css | 11 - apps/atproto-starter/src/app.d.ts | 34 -- apps/atproto-starter/src/app.html | 11 - apps/atproto-starter/src/hooks.server.ts | 15 - .../src/lib/atproto/auth.svelte.ts | 75 --- .../src/lib/atproto/image-helper.ts | 152 ------ apps/atproto-starter/src/lib/atproto/index.ts | 20 - .../src/lib/atproto/methods.ts | 373 --------------- apps/atproto-starter/src/lib/atproto/port.ts | 3 - .../src/lib/atproto/scripts/generate-key.ts | 4 - .../lib/atproto/scripts/generate-secret.ts | 3 - .../src/lib/atproto/scripts/setup-dev.ts | 60 --- .../src/lib/atproto/scripts/tunnel.ts | 195 -------- .../src/lib/atproto/server/kv-store.ts | 38 -- .../src/lib/atproto/server/oauth.remote.ts | 56 --- .../src/lib/atproto/server/oauth.ts | 97 ---- .../src/lib/atproto/server/profile.ts | 51 -- .../src/lib/atproto/server/repo.remote.ts | 96 ---- .../src/lib/atproto/server/session.ts | 73 --- .../src/lib/atproto/server/signed-cookie.ts | 69 --- .../src/lib/atproto/settings.ts | 26 -- .../src/lib/atproto/ui/LoginModal.svelte | 142 ------ .../src/lib/contrail.config.ts | 8 - .../src/lib/contrail/client.ts | 32 -- .../atproto-starter/src/lib/contrail/index.ts | 29 -- apps/atproto-starter/src/lib/index.ts | 1 - .../oauth-client-metadata.json/+server.ts | 20 - .../routes/(oauth)/oauth/callback/+server.ts | 41 -- .../routes/(oauth)/oauth/jwks.json/+server.ts | 8 - .../src/routes/+layout.server.ts | 5 - .../atproto-starter/src/routes/+layout.svelte | 19 - apps/atproto-starter/src/routes/+layout.ts | 1 - apps/atproto-starter/src/routes/+page.svelte | 31 -- .../src/routes/api/cron/+server.ts | 15 - .../src/routes/xrpc/[...path]/+server.ts | 14 - apps/atproto-starter/svelte.config.js | 16 - apps/atproto-starter/tsconfig.json | 21 - apps/atproto-starter/vite.config.ts | 13 - apps/atproto-starter/wrangler.jsonc | 37 -- 51 files changed, 2801 deletions(-) delete mode 100644 apps/atproto-starter/.claude/settings.local.json delete mode 100644 apps/atproto-starter/.env.example delete mode 100644 apps/atproto-starter/.gitignore delete mode 100644 apps/atproto-starter/.mcp.json delete mode 100644 apps/atproto-starter/.npmrc delete mode 100644 apps/atproto-starter/.prettierignore delete mode 100644 apps/atproto-starter/.prettierrc delete mode 100644 apps/atproto-starter/CONTRAIL.md delete mode 100644 apps/atproto-starter/LICENSE delete mode 100644 apps/atproto-starter/README.md delete mode 100644 apps/atproto-starter/eslint.config.js delete mode 100644 apps/atproto-starter/package.json delete mode 100644 apps/atproto-starter/src/app.css delete mode 100644 apps/atproto-starter/src/app.d.ts delete mode 100644 apps/atproto-starter/src/app.html delete mode 100644 apps/atproto-starter/src/hooks.server.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/auth.svelte.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/image-helper.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/index.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/methods.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/port.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/scripts/generate-key.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/scripts/generate-secret.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/scripts/setup-dev.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/scripts/tunnel.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/kv-store.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/oauth.remote.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/oauth.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/profile.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/repo.remote.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/session.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/server/signed-cookie.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/settings.ts delete mode 100644 apps/atproto-starter/src/lib/atproto/ui/LoginModal.svelte delete mode 100644 apps/atproto-starter/src/lib/contrail.config.ts delete mode 100644 apps/atproto-starter/src/lib/contrail/client.ts delete mode 100644 apps/atproto-starter/src/lib/contrail/index.ts delete mode 100644 apps/atproto-starter/src/lib/index.ts delete mode 100644 apps/atproto-starter/src/routes/(oauth)/oauth-client-metadata.json/+server.ts delete mode 100644 apps/atproto-starter/src/routes/(oauth)/oauth/callback/+server.ts delete mode 100644 apps/atproto-starter/src/routes/(oauth)/oauth/jwks.json/+server.ts delete mode 100644 apps/atproto-starter/src/routes/+layout.server.ts delete mode 100644 apps/atproto-starter/src/routes/+layout.svelte delete mode 100644 apps/atproto-starter/src/routes/+layout.ts delete mode 100644 apps/atproto-starter/src/routes/+page.svelte delete mode 100644 apps/atproto-starter/src/routes/api/cron/+server.ts delete mode 100644 apps/atproto-starter/src/routes/xrpc/[...path]/+server.ts delete mode 100644 apps/atproto-starter/svelte.config.js delete mode 100644 apps/atproto-starter/tsconfig.json delete mode 100644 apps/atproto-starter/vite.config.ts delete mode 100644 apps/atproto-starter/wrangler.jsonc diff --git a/apps/atproto-starter/.claude/settings.local.json b/apps/atproto-starter/.claude/settings.local.json deleted file mode 100644 index 32f6420..0000000 --- a/apps/atproto-starter/.claude/settings.local.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(npx tsc:*)", - "mcp__plugin_svelte_svelte__svelte-autofixer", - "mcp__plugin_svelte_svelte__get-documentation", - "Bash(pnpm check:*)", - "Bash(pnpm env:generate-secret:*)", - "Bash(pnpm build:agent-setup:*)" - ] - } -} diff --git a/apps/atproto-starter/.env.example b/apps/atproto-starter/.env.example deleted file mode 100644 index ede6fba..0000000 --- a/apps/atproto-starter/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -# Generate both with: pnpm env:setup-dev -CLIENT_ASSERTION_KEY= -COOKIE_SECRET= - -# Set to your tunnel URL to use a confidential client in dev -# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com diff --git a/apps/atproto-starter/.gitignore b/apps/atproto-starter/.gitignore deleted file mode 100644 index c9c0bfb..0000000 --- a/apps/atproto-starter/.gitignore +++ /dev/null @@ -1,27 +0,0 @@ -node_modules - -# Output -.output -.vercel -.netlify -.wrangler -/.svelte-kit -/build - -# OS -.DS_Store -Thumbs.db - -# Env -.env -.env.* -!.env.example -!.env.test - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -# Generated by contrail-lex (regenerated via pnpm generate:pull) -src/lexicon-types/ -lex.config.js diff --git a/apps/atproto-starter/.mcp.json b/apps/atproto-starter/.mcp.json deleted file mode 100644 index 74737dd..0000000 --- a/apps/atproto-starter/.mcp.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "mcpServers": { - "lexicon-garden": { - "type": "http", - "url": "https://lexicon.garden/mcp" - } - } -} diff --git a/apps/atproto-starter/.npmrc b/apps/atproto-starter/.npmrc deleted file mode 100644 index b6f27f1..0000000 --- a/apps/atproto-starter/.npmrc +++ /dev/null @@ -1 +0,0 @@ -engine-strict=true diff --git a/apps/atproto-starter/.prettierignore b/apps/atproto-starter/.prettierignore deleted file mode 100644 index ab78a95..0000000 --- a/apps/atproto-starter/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -# Package Managers -package-lock.json -pnpm-lock.yaml -yarn.lock diff --git a/apps/atproto-starter/.prettierrc b/apps/atproto-starter/.prettierrc deleted file mode 100644 index 7ebb855..0000000 --- a/apps/atproto-starter/.prettierrc +++ /dev/null @@ -1,15 +0,0 @@ -{ - "useTabs": true, - "singleQuote": true, - "trailingComma": "none", - "printWidth": 100, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ] -} diff --git a/apps/atproto-starter/CONTRAIL.md b/apps/atproto-starter/CONTRAIL.md deleted file mode 100644 index e8810c0..0000000 --- a/apps/atproto-starter/CONTRAIL.md +++ /dev/null @@ -1,440 +0,0 @@ -# Contrail patterns (for AI agents) - -Companion to — that's the full reference. This file is the opinionated short version: how this template uses contrail in practice, so you don't have to derive it from docs every time. - -## Reading data — almost always `listRecords` - -The default pattern for *any* data fetch is: - -```ts -const client = getServerClient(platform!.env.DB); // server -// or: const client = getClient(); // browser - -const res = await client.get('..listRecords', { - params: { - limit: 50, - sort: 'createdAt', - order: 'desc', - // ...filter params from contrail.config.ts `queryable` block - // ...hydration flags (e.g. `profiles: true`) - } -}); - -if (!res.ok) return { records: [] }; -const records = res.data.records; // typed against your lexicon -``` - -Where: -- `` is `config.namespace` from `src/lib/contrail.config.ts`. -- `` is the key in `config.collections` (the short name, *not* the full NSID). - -If the user gives a vague spec ("show me X"), the answer is almost always a `listRecords` call with a filter. Reach for `getRecord` only when you genuinely need one record by URI — listing with a filter that narrows to one is usually fine and keeps the codepath uniform. - -## Filtering — declared in `contrail.config.ts` - -Available filter params are declared per-collection in `queryable`. If a filter doesn't exist, add it there and re-run `pnpm generate:pull` — don't filter in JS after the fact. - -```ts -// src/lib/contrail.config.ts -collections: { - bookmark: { - collection: 'app.mybookmarks.bookmark', - queryable: { - tag: {}, // → ?tag=design - createdAt: { type: 'range' } // → ?createdAtMin=...&createdAtMax=... - } - } -} -``` - -### "Filter by author" is built-in — don't add it to `queryable` - -`?actor=` (and its alias `?did=`) is a built-in filter on every `listRecords` endpoint. It resolves the actor to a DID, triggers a one-shot PDS backfill for fresh data, and filters records to that author. **Do not** add `did: {}` or `actor: {}` to `queryable` — it's redundant and may fight the built-in handler. - -```ts -// ✅ "this user's bookmarks" — works with no queryable changes -client.get('.bookmark.listRecords', { params: { actor: did } }); -``` - -`queryable` is for *record-content* filters (fields inside the record JSON), not for author lookup. - -There is **no `hydration` field** on a collection config — don't add one. Hydration is controlled by *query parameters at request time* (next section). - -## Hydration — query-time, not config-time - -To pull related data alongside records, pass it as a `params` flag on the `listRecords` call: - -```ts -const res = await client.get('..listRecords', { - params: { limit: 50, profiles: true } // ← query param -}); - -res.data.records // the records -res.data.profiles // the hydrated author profiles, keyed by did -``` - -`profiles: true` joins each record's author profile (defaults to `app.bsky.actor.profile`) into `res.data.profiles`. Use this instead of fetching profiles in a loop. - -If you need *additional* profile NSIDs beyond `app.bsky.actor.profile` (e.g. an app-specific profile type), declare them at the **top level** of `ContrailConfig`: - -```ts -export const config: ContrailConfig = { - namespace: 'app.example', - profiles: ['app.bsky.actor.profile', 'app.example.actor.profile'], - collections: { /* ... */ } -}; -``` - -That's a config-time concern (which collections count as profiles); the `profiles: true` flag at query time is what triggers hydration on a given request. - -## Where to call from - -| Where | Use | Why | -|---|---|---| -| `+page.server.ts`, `+layout.server.ts`, `+server.ts` | `getServerClient(platform!.env.DB)` | In-process, zero HTTP, full types | -| `.svelte` (client-side, on user action) | `getClient()` from `$lib/contrail/client` | Hits `/xrpc/` over fetch — same typed surface | - -### Two `getClient` exports — they're different - -There are two `getClient`s in the codebase. They do different things: - -| Import | Returns | Use when | -|---|---|---| -| `getClient()` from `$lib/contrail/client` | typed contrail client (calls local `/xrpc/`) | Any in-app data fetch from the browser. **Default to this.** | -| `getPDSClient({ did })` from `$lib/atproto/methods` | `Promise` for a specific user's PDS | Direct PDS calls (rare in app code — usually only the `methods.ts` helpers need this) | - -If autocomplete gives you `Promise` and `.get` doesn't exist on the result, you imported the wrong one. Switch to `$lib/contrail/client`. - -### "Get *this user's* records" → `actor` param, not the `did` arg of `getServerClient` - -The `did` second argument to `getServerClient(db, did)` is **only** for acting as that user when reading permissioned data (spaces, communities). Public `listRecords` calls do *not* need it — and passing it does nothing useful for filtering. - -To fetch records created by a specific user, pass `actor` as a **query param**: - -```ts -// ✅ Correct — filter records to those whose author is this DID -const client = getServerClient(platform!.env.DB); -const res = await client.get('..listRecords', { - params: { actor: did, limit: 1 } -}); - -// ❌ Wrong — `did` here means "auth as this user", not "filter by author" -const client = getServerClient(platform!.env.DB, did); -const res = await client.get('..listRecords', { - params: { limit: 1 } -}); -``` - -`actor` accepts a DID or a handle and is resolved to a DID server-side. `did` is also accepted as a synonym. Use `actor` for both "this is the user whose records I want" and "this is the actor of a feed read". -| Inside `routes/api/cron/+server.ts` | Don't call client APIs — use `contrail.ingest()` directly | That's the indexer, not a reader | - -Default to server-side loading via `+page.server.ts` for initial page data. Use client-side only for live updates, infinite scroll, or post-action refetches. - -## Writes — `putRecord`, `deleteRecord`, `uploadBlob` - -Three helpers handle all writes. The actual SvelteKit remote functions live in `src/lib/atproto/server/repo.remote.ts`; client-friendly wrappers are in `src/lib/atproto/methods.ts`. From a `.svelte` file, always import from `methods`: - -```ts -import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto/methods'; - -await putRecord({ - collection: 'app.mybookmarks.bookmark', // must be listed in settings.ts `collections` - rkey: createTID(), - record: { url, title, createdAt: new Date().toISOString() } -}); -``` - -The collection must be in `src/lib/atproto/settings.ts` `collections` — that array drives both the OAuth scope (`scope.repo({ collection: [...] })`) *and* the runtime allowlist in `repo.remote.ts`. Adding a new writable collection means: lexicon → `contrail.config.ts` → `settings.ts` → `pnpm generate:pull`. - -### Indexing happens automatically — don't call `contrail.notify` yourself - -After every successful `putRecord`, the remote function calls `contrail.notify(uri, db)` so contrail re-indexes the record immediately. You do **not** need to do this in app code — and shouldn't. The next `listRecords` call will see the new record. (See `repo.remote.ts:38-44`.) - -`deleteRecord` does *not* notify; deletions propagate via Jetstream within ~minute. If a record needs to disappear from the UI right away, filter it out optimistically and let the index catch up. - -### Optimistic UI is still worth doing - -Even with auto-notify, `putRecord` takes ~100–300ms round-trip. For interactive flows (post-as-you-type, emoji reactions, like buttons), render the local copy immediately and let the network call settle in the background — don't await before updating state. - -Concrete pattern with rollback on failure: - -```ts -import { SvelteSet } from 'svelte/reactivity'; -import { putRecord, deleteRecord, createTID } from '$lib/atproto/methods'; - -let local = $state>([]); -let pending = new SvelteSet(); // rkeys mid-flight - -async function post(text: string) { - const rkey = createTID(); - const createdAt = new Date().toISOString(); - // 1. Render immediately — UI updates this tick. - local = [{ rkey, text, createdAt }, ...local]; - pending.add(rkey); - - try { - // 2. Write in the background. - await putRecord({ - collection: '.note', - rkey, - record: { text, createdAt } - }); - pending.delete(rkey); - // Auto-notify already re-indexed; the next listRecords will see it. - } catch (e) { - // 3. Roll back on failure. - local = local.filter((r) => r.rkey !== rkey); - pending.delete(rkey); - throw e; - } -} - -// In the template, dedupe local + server records (see "List rendering" above) -// so the server copy doesn't re-render once it shows up via listRecords. -$: allRecords = Array.from( - new Map( - [...local, ...serverRecords].map((r) => [r.rkey ?? r.uri, r]) - ).values() -); -``` - -Key points: generate the `rkey` *client-side* (via `createTID()`) so the optimistic copy and the server copy share the same identity for dedup. Track in-flight rkeys in a `SvelteSet` if you want to disable retries / show a spinner. Roll back the local insert if the write throws. - -### Blobs - -`uploadBlob` handles the bytes-over-remote-function dance and auto-detects image dimensions for `aspectRatio`. Embed the returned blob in a record: - -```ts -const uploaded = await uploadBlob({ blob: file }); -await putRecord({ - collection: 'app.mybookmarks.thumbnail', - rkey: createTID(), - record: { image: uploaded, createdAt: new Date().toISOString() } -}); -``` - -To enable blob uploads, add `scope.blob({ accept: ['image/*'] })` to `scopes` in `settings.ts` (and adjust `accept` for the mime types you allow). Without that scope, the OAuth flow won't grant blob-write permission and uploads 401. - -## Following feeds — opt-in - -If the app has a "follow other users → see their stuff in a feed" pattern, contrail's `feeds` config does the fan-out for you. **Skip this section if the app doesn't have a social graph** — public `listRecords` is the right primitive for most apps. - -### Mental model - -A feed is a (follow-collection, [target-collections]) pair, named by you. Every time someone the *actor* follows posts to a target collection, contrail inserts one row into `feed_items` for that actor. Reading a feed reads back through that table, joined with the standard pipeline (filters, hydration, profiles). - -``` -feed: timeline - follow: app.bsky.graph.follow // contains { subject: did, createdAt } - targets: [app.bsky.feed.post] -``` - -### Config - -```ts -// src/lib/contrail.config.ts -export const config: ContrailConfig = { - namespace: 'app.example', - collections: { - follow: { collection: 'app.bsky.graph.follow' }, - post: { collection: 'app.bsky.feed.post', queryable: { /* ... */ } } - }, - feeds: { - timeline: { - follow: 'follow', // short name from collections, NOT the NSID - targets: ['post'], - maxItems: 500 // optional, default 200 - } - } -}; -``` - -Both the follow collection and every target collection must be declared in `collections`. Names in `feeds` are the *short names* (the keys of `collections`), not NSIDs. Config validation will throw if you reference an unknown short name. - -### Follow-record shape requirement - -The follow collection's record must have a `subject` field at the top level whose value is the followed DID. `app.bsky.graph.follow` matches this naturally (`{ subject: 'did:plc:...', createdAt }`). For a custom follow lexicon, keep the `subject` field — contrail extracts it via JSON path `$.subject` to determine "who is being followed." - -### Read - -``` -GET /xrpc/.getFeed?feed=timeline&actor=&limit=50 -``` - -Optional `&collection=` to filter to one target (defaults to the first in `targets`). Filters and hydration from the target collection's `queryable` config also work — same params as `listRecords`. - -The `actor` parameter is **whose feed** you're reading, not a filter on record creator. Feeds are always per-user; there's no anonymous feed read. - -### How fan-out works - -- **On a write to a target collection** (e.g. someone you follow posts): contrail inserts a `feed_items` row for every follower whose follow record has `subject = `. One write → N inserts. There's no max-followers cap; if a celebrity has 1M followers and posts, that's 1M inserts. -- **On a write to the follow collection** (someone follows a new user): contrail backfills the most recent 100 target records from that user into the new follower's feed. The 100 is hardcoded — separate from the per-feed `maxItems` cap. -- **On first read for a (actor, feed) pair**: contrail backfills the actor's follow records from their PDS, then populates `feed_items` from existing target records. Marked complete in `feed_backfills` so it only happens once. -- **Pruning**: the cron run trims `feed_items` per actor down to `maxItems`, keeping newest by `time_us`. - -### Gotchas - -- The 100-record-per-new-follow backfill is hardcoded in `core/router/feed.ts` — not currently tunable per feed. -- No tests exist specifically for feeds yet; the write/read paths are live but treat the integration as load-bearing-but-untested in your sanity checks. -- Following many users with a viral target collection is expensive on writes (one row per follower). For an app expecting that scale, partition feeds or rate-limit writes upstream — contrail will not back off on its own. -- Feeds live in the main DB regardless of the spaces split — no `feeds_db` binding. - -## Spaces and communities — opt-in, default to *not* using them - -The template ships with public records only. **Don't enable spaces or communities unless the user's data model genuinely needs them** — they add config, secrets, and a parallel set of XRPC methods (`.space.*`, `.community.*`). For 80% of atproto apps (public posts, public lists, public anything), skip this whole section. - -### When to reach for **spaces** - -When records *can't* be public on a PDS — invite-only event guest lists, members-only forum threads, private group calendars. Records inside a space are gated by an ACL (the space's member list), not visible to the world. - -The mental model: *a space is a bag of records with one lock; the member list says who has the key*. One owner, one record type per space, every member has read + write. No nested ACLs — richer permission models = more spaces. - -To enable, add a `spaces` block to `contrail.config.ts`: - -```ts -spaces: { - type: 'app.example.event.space', // NSID for the space record type - serviceDid: 'did:web:example.com', // DID for the worker — must be your deployed domain -} -``` - -Plus per-collection: `allowInSpaces: false` to keep a collection public-only. - -`serviceDid` is the catch — `did:web:` requires serving a `/.well-known/did.json` at that domain. The user has to set this up; the AI can scaffold the JSON file but the domain has to be theirs. For dev, skip spaces entirely. - -`listRecords` becomes auth-aware: anonymous → public only; authenticated with no `spaceUri` → public ∪ caller's spaces; with `?spaceUri=...` → that one space (ACL-checked). Records from spaces carry a `space: ` field in responses. - -#### Default to a separate D1 for spaces data - -When enabling spaces, provision a **second** D1 database for the spaces tables — keep the public-records DB and the permissioned-data DB isolated. Contrail supports this natively via a `spacesDb` parameter; defaults to the main DB if omitted, but don't omit it. - -```sh -npx wrangler d1 create -spaces -# → copy database_id into wrangler.jsonc -``` - -`wrangler.jsonc`: - -```jsonc -"d1_databases": [ - { "binding": "DB", "database_name": "", "database_id": "..." }, - { "binding": "SPACES_DB", "database_name": "-spaces", "database_id": "..." } -] -``` - -Then thread it through `src/lib/contrail/index.ts`: - -```ts -export async function ensureInit(db: D1Database, spacesDb: D1Database) { - if (!initialized) { - await contrail.init(db, spacesDb); - initialized = true; - } -} - -export function getServerClient(db: D1Database, spacesDb: D1Database, did?: string): Client { - return createServerClient(async (req) => { - await ensureInit(db, spacesDb); - return handle(req, db, spacesDb) as Promise; - }, did); -} -``` - -Every call site (`+page.server.ts`, `+layout.server.ts`, `routes/api/cron/+server.ts`, `repo.remote.ts`) needs to pass `platform.env.SPACES_DB` alongside `platform.env.DB`. - -Full reference: . - -### When to reach for **communities** - -When records should be published under a *shared* identity — a team's calendar, a project's announcements, not "user X posted this". A community is a DID that multiple members can act *through*, with tiered access levels. - -Two modes: -- **Minted** — contrail creates a fresh `did:plc` and holds its keys. Irreversible without the recovery rotation key (returned once at mint time). -- **Adopted** — contrail takes over an existing account via an app password. Reversible — the owner can revoke the app password anytime. - -Communities sit *on top of* spaces — a community owns spaces, and member-access-levels decide who can act in which spaces. - -Config: - -```ts -community: { - masterKey: env.COMMUNITY_MASTER_KEY, // 32-byte encryption key (secret!) - serviceDid: 'did:web:example.com', - levels: ['admin', 'moderator'], // your custom levels, ranked highest-first -} -``` - -`masterKey` envelope-encrypts stored credentials (app passwords for adopted communities, signing keys for minted). Set as a wrangler secret: `pnpm env:generate-secret | npx wrangler secret put COMMUNITY_MASTER_KEY`. Never check it in. - -Full reference: . - -### Decision flow - -Quick decision tree before adding either: - -1. Are all the records the app deals with OK to be world-readable on the user's PDS? → **No spaces, no communities. Stop.** -2. Some records are private but always belong to one user? → Still no spaces; just don't show them in your UI / use atproto's own scope system. Spaces are for *shared* private data. -3. Records belong to a *group* (multiple members can read/write)? → Spaces. -4. Records should be published under a *shared identity* (the group itself "posts" things, not individual users)? → Communities (which use spaces under the hood). - -If the user describes their app in two sentences and never says "members", "invite-only", "private group", or "team-owned", you don't need this section. - -## End-to-end types — trust them, debug from them - -Everything is typed: - -- `client.get('...')` autocompletes the method name from your registered lexicons. -- `params` is typed against the `queryable` block in `contrail.config.ts`. -- `res.data.records[i].value` is typed against the lexicon record schema (the `record` block of the NSID). - -**If types feel wrong, the cause is almost always one of:** -1. The collection isn't in `contrail.config.ts` yet → add it, then `pnpm generate:pull`. -2. You changed `contrail.config.ts` but didn't regenerate → run `pnpm generate:pull`. -3. The lexicon JSON in `lexicons/custom/` doesn't match what you're calling → run lexicon.garden's `validate_lexicon` MCP tool against it. -4. You're typing `res.data.records[i]` directly without checking `res.ok` first → the `!res.ok` early-return narrows the type. - -When something looks off, **read the generated types in `src/lexicon-types/`** — they're the ground truth for what the API actually returns. Don't guess from the lexicon JSON. - -## List rendering — always dedupe before `{#each}` - -Contrail's `listRecords` can return the same record twice during a tight indexing window (especially right after `notify`). With `{#each records as r (r.uri)}`, that throws `each_key_duplicate` at runtime. Dedupe before rendering: - -```svelte -{#each Array.from(new Map(records.map((r) => [r.uri, r])).values()) as r (r.uri)} - ... -{/each} -``` - -Same pattern for any union of sources (server data + optimistic local data + jetstream live updates) — collapse all of them through one `Map` keyed by `uri` before passing to `{#each}`. Cheaper than rendering, and saves you a runtime crash the first time the index races itself. - -## Upsert pattern — look up before write - -User clicks "subscribe" twice while the first request is in flight → two records created → duplicate sidebar entries → `each_key_duplicate`. The fix is to look up by a queryable field first, reuse the existing rkey: - -```ts -const client = getClient(); -const existing = await client.get('.subscription.listRecords', { - params: { actor: user.did, feedUrl, limit: 1 } // requires `did` + `feedUrl` queryable -}); - -if (existing.ok && existing.data.records.length > 0) { - return; // already subscribed — no-op (or update the existing record) -} - -await putRecord({ - collection: '.subscription', - rkey: createTID(), - record: { feedUrl, createdAt: new Date().toISOString() } -}); -``` - -For things that are unique-per-user-per-target (subscriptions, follows, likes), this is the right shape. Add a queryable on the "uniqueness" field so the lookup is one query, not a scan. - -## Profile data, specifically - -Most apps want to show "who posted this" alongside records. Two paths: - -1. **Hydrate via `profiles: true`** in `listRecords` — best for lists. Use `extractProfile()` from `$lib/contrail/client` to normalize the entries. -2. **Fetch separately** for one-off lookups. Don't loop fetches; if you need many, use `listRecords` with a filter on `did`. - -Whatever you do, **don't fetch profiles from `app.bsky.actor.getProfile` over the network in a loop** — that's the failure mode this hydration setup exists to avoid. diff --git a/apps/atproto-starter/LICENSE b/apps/atproto-starter/LICENSE deleted file mode 100644 index 696e4f6..0000000 --- a/apps/atproto-starter/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License Copyright (c) 2025 flo-bit - -Permission is hereby granted, free of -charge, to any person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice -(including the next paragraph) shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. \ No newline at end of file diff --git a/apps/atproto-starter/README.md b/apps/atproto-starter/README.md deleted file mode 100644 index f911c10..0000000 --- a/apps/atproto-starter/README.md +++ /dev/null @@ -1,245 +0,0 @@ -# atproto-starter - -> **For AI agents.** This README is the playbook for taking a fresh copy of this template and turning it into a deployed atproto app on Cloudflare. Follow it top-to-bottom. Stop and ask the user when a step says to. - -A SvelteKit + Cloudflare Workers starter for atproto apps. Includes: - -- OAuth login (`@atcute/oauth-node-client`, KV-backed sessions, signed cookies) -- Indexed XRPC endpoints over D1 via [contrail](https://github.com/flo-bit/contrail) + Jetstream -- Typed client for in-process queries -- `.mcp.json` wires up the [lexicon.garden MCP](https://lexicon.garden/help/mcp) — use its `validate_lexicon`, `describe_lexicon`, `create_record_cid`, `invoke_xrpc`, `facet_text` tools whenever authoring or debugging lexicons - -**Reference docs:** -- [`./CONTRAIL.md`](./CONTRAIL.md) — opinionated short notes on how *this template* uses contrail (data fetching patterns, where to call from, types). Read this before writing any data-fetching code. -- Full contrail docs at — fetch this before Phase 5 if authoring lexicons, configuring queryables, or anything beyond the happy path. - ---- - -## Phase 0 — Ask the user (do this once, all up front) - -Ask **all** of these in a single batch before touching anything. The goal: gather everything once so you can run uninterrupted from Phase 1 to deploy. Don't stop mid-flow to ask follow-ups. - -1. **Project name?** Used for package name, worker name, D1 database name, KV namespace names. Recommend lowercase-kebab, e.g. `mybookmarks`. -2. **Domain?** (e.g. `mybookmarks.app`) - - If **yes**: use it for `OAUTH_PUBLIC_URL` and reverse it for the contrail namespace (`mybookmarks.app` → `app.mybookmarks`). **Also ask: is the zone already on Cloudflare?** (CF must be the DNS provider — if not, the user needs to add the zone at dash.cloudflare.com first; you can't.) - - If **no**: ask for their **workers.dev subdomain** (visible at `dash.cloudflare.com → Workers & Pages → top right`, e.g. `john-doe.workers.dev`). The deploy URL will be `https://..workers.dev`. Use that as `OAUTH_PUBLIC_URL`. For namespace, fall back to `dev.` and note they should switch to a real domain before going public. (`wrangler whoami` does **not** return the subdomain — you must ask.) -3. **Which collections does the app write/index?** One or more atproto NSIDs (e.g. `community.lexicon.calendar.event`, `app.bsky.feed.post`). For custom record types, you'll author a new lexicon in Phase 5. Use lexicon.garden's `describe_lexicon` MCP tool to inspect any NSIDs the user mentions. -4. **Open signup?** (default yes) Controls `ALLOW_SIGNUP` in `src/lib/atproto/settings.ts`. If yes, which PDS for signups? (default: `https://selfhosted.social/` for prod, `https://pds.rip/` for dev) -5. **`wrangler login` done?** You can't run it for them — it's interactive. If they haven't, ask them to run it now in another terminal before you start. You'll verify with `npx wrangler whoami` once you reach Phase 3. - -Restate the answers back in one block before continuing so the user can correct anything. - -**Then, before Phase 1, invoke the `impeccable` skill in `teach` mode:** `Skill(skill: 'impeccable', args: 'teach')`. This runs a short branding/design interview with the user and writes a design guidelines file the rest of the build references. Do not skip — later UI work depends on it. The branding questions are *separate* from the technical questions above, but happen in the same up-front block so the user isn't interrupted later. - -From Phase 1 onward, no more questions unless something genuinely fails. - ---- - -## Phase 1 — Local dev (zero Cloudflare) - -```sh -pnpm install -pnpm env:setup-dev # generates COOKIE_SECRET + CLIENT_ASSERTION_KEY into .env, randomizes DEV_PORT -pnpm dev -``` - -Dev mode uses a loopback OAuth client — no public URL, no Cloudflare resources, no secrets in CF. Verify in the browser that login works against a real PDS before continuing. - -### Custom env vars at runtime — use `.dev.vars`, not `.env` - -`.env` is read by Vite for *app code* at build/dev time. `platform.env.MY_VAR` (Cloudflare bindings, used by anything in `+server.ts`, `+page.server.ts`, `repo.remote.ts`, etc.) reads from **wrangler bindings**, not `.env`. In `pnpm dev` (vite + miniflare), wrangler bindings come from a gitignored `.dev.vars` file at the project root: - -``` -# .dev.vars -PODCAST_INDEX_KEY=xxxxx -PODCAST_INDEX_SECRET=yyyyy -``` - -Then `platform.env.PODCAST_INDEX_KEY` works in dev. For production, set the same names via `npx wrangler secret put PODCAST_INDEX_KEY`. - -`.dev.vars` is already covered by the starter's `.gitignore` (matches `.env.*`), but double-check before committing. - ---- - -## Phase 2 — Apply the user's answers - -Edit these files using the answers from Phase 0. Every placeholder from the original template lives here: - -| File | What to change | -|---|---| -| `package.json` | `name` → `` | -| `wrangler.jsonc` | `name` → ``; `vars.OAUTH_PUBLIC_URL` → `https://`; `d1_databases[0].database_name` → ``; leave `database_id` as `REPLACE_WITH_D1_DATABASE_ID` (Phase 3 fills it). **If custom domain:** also add the `routes` block below. | -| `src/lib/contrail.config.ts` | `namespace` → reversed domain; `collections` → user's collections | -| `src/lib/atproto/settings.ts` | `collections` → user's writable NSIDs; `signUpPDS` (devPDS/prodPDS); `ALLOW_SIGNUP` | - -**Custom domain — `wrangler.jsonc` `routes` block.** If the user has a domain (and the zone is on Cloudflare), append this at the top level of `wrangler.jsonc`: - -```jsonc -"routes": [ - { "pattern": "mybookmarks.app", "custom_domain": true } -], -"workers_dev": false -``` - -`custom_domain: true` makes wrangler set up DNS records and the SSL cert automatically on `wrangler deploy` — no dashboard clicks. `workers_dev: false` disables the `..workers.dev` URL so the worker is *only* reachable at the custom domain (recommended; otherwise OAuth metadata can drift between two URLs). - -For subdomains, use the full hostname (e.g. `pattern: "app.mybookmarks.com"`). The zone (`mybookmarks.com`) still has to be on Cloudflare; the subdomain doesn't need to exist as a DNS record yet — wrangler creates it. - -If the deploy in Phase 6 fails with a zone error, the user needs to add the domain to Cloudflare first (point registrar nameservers to CF). That's the one step you can't automate. - -**Do not** edit `lex.config.js` — it's regenerated by `contrail-lex generate` from `contrail.config.ts` on every run, and is gitignored. - -**Do not** keep statusphere references anywhere. After editing, grep for `statusphere`, `xyz.statusphere` — anything left is project residue. - -After namespace/collection changes, regenerate types: `pnpm generate:pull` (this runs the full pipeline: regenerates `lex.config.js`, pulls NSIDs, emits types). - ---- - -## Phase 3 — Cloudflare provisioning - -Verify the user is logged in (they confirmed in Phase 0): `npx wrangler whoami`. If it fails, stop and tell the user to run `wrangler login`. - -All resources are **project-scoped** so the user can run multiple apps from this template in the same CF account without collision. The bindings (`DB`, `OAUTH_SESSIONS`, `OAUTH_STATES`) stay constant since the code references those by name; only the resource `name`/`title` changes. - -```sh -# D1 -npx wrangler d1 create -# → copy the returned database_id into wrangler.jsonc d1_databases[0].database_id - -# KV (project-prefixed names so multiple projects don't collide) -npx wrangler kv namespace create -OAUTH_SESSIONS -npx wrangler kv namespace create -OAUTH_STATES -# → copy each returned id into wrangler.jsonc kv_namespaces[*].id -``` - -After this phase, `wrangler.jsonc` should have no `REPLACE_WITH_*` strings and no leftover ids from another project. - ---- - -## Phase 4 — Secrets - -Three secrets needed in production. **Never put these in `wrangler.jsonc` `vars`** — use `wrangler secret put`. Each script just prints to stdout, so pipe directly: - -```sh -# 1. CLIENT_ASSERTION_KEY — OAuth client signing key (JWK JSON) -# `--silent` is critical: without it pnpm 10 prepends its `> name@version: …` header -# to stdout and the secret value gets corrupted. Production then 500s on JSON.parse. -pnpm --silent env:generate-key | npx wrangler secret put CLIENT_ASSERTION_KEY - -# 2. COOKIE_SECRET — HMAC for signed session cookies -pnpm --silent env:generate-secret | npx wrangler secret put COOKIE_SECRET - -# 3. CRON_SECRET — bearer the scheduled handler uses to call /api/cron -pnpm --silent env:generate-secret | npx wrangler secret put CRON_SECRET -``` - -`OAUTH_PUBLIC_URL` is a `var` (already set in Phase 2), not a secret — it's the public URL. - ---- - -## Phase 5 — Lexicons (only if user wants custom record types) - -If the user is only indexing existing NSIDs (e.g. `app.bsky.feed.post`), skip this phase — `pnpm generate:pull` already fetched them. - -For a new lexicon under the user's namespace: - -1. Use the lexicon.garden MCP `describe_lexicon` tool to look at neighbors with similar shape. Match conventions. -2. Author the JSON in `lexicons/custom/.json`. -3. Validate with the MCP `validate_lexicon` tool before saving. -4. Add the collection to `src/lib/contrail.config.ts` (and `src/lib/atproto/settings.ts` if it's writable from the app). -5. `pnpm generate:pull` — regenerates `lex.config.js` from the contrail config, pulls referenced NSIDs, emits types into `src/lexicon-types/`. - -> **Expected warnings:** `pnpm generate:pull` prints `lexicon authority not found` for any NSID under your namespace that isn't published yet. The local JSON is still used and types are still emitted — these warnings are harmless until you run `contrail-lex publish`. - ---- - -## Phase 6 — Deploy + backfill - -```sh -pnpm build -npx wrangler deploy -pnpm backfill:remote # one-shot historical backfill from Jetstream -``` - -> **Expected warnings during backfill:** wrangler may print `--env=production not configured`. Harmless — the script still runs against the deployed worker. Ignore. - -> **After deploying, hard-refresh the browser** (Cmd/Ctrl+Shift+R). Cloudflare caches `_app/immutable/*.js` chunks aggressively; if the user reports "the fix didn't take", it's almost always a stale chunk. - -After deploy, the cron trigger (`*/1 * * * *` in `wrangler.jsonc`) keeps the index fresh. - -Sanity check: hit `https:///xrpc/..listRecords?limit=10` and confirm JSON comes back. Then load the site root and try logging in. - ---- - -## Styling / UI - -**Before composing any UI, invoke `Skill(skill: 'impeccable', args: 'craft')`.** This produces components shape-first using the design guidelines created by `impeccable teach` in Phase 0. It's what stops the output from defaulting to generic AI aesthetics. Run it once per significant UI surface (home page, settings page, feature flow) — not per component. - -### The starter is foxui-free by default - -`src/routes/+page.svelte`, `+layout.svelte`, and `src/lib/atproto/ui/LoginModal.svelte` are all hand-rolled with plain Tailwind v4. **Custom styling is the path of least resistance** — there's no foxui component to fight or override; just edit the markup. Tailwind v4 is set up, and `bits-ui` is also pre-installed if you need accessible headless primitives. - -### Optional: opt back into [foxui](https://flo-bit.dev/ui-kit/docs/llms.txt) - -`@foxui/core`, `@foxui/social`, `@foxui/time` are still in `package.json` deps (so no `pnpm add` needed) — foxui ships polished atproto-specific components: `AtprotoLoginModal`, `GithubCorner`, `RelativeTime`, profile/handle helpers, theme tokens. Worth using *if* the design direction from Phase 0 fits foxui's look, since they handle OAuth subtleties (loopback vs. confidential client, redirect URIs, scope handling) correctly. - -**To use foxui:** -1. Add these two lines to `src/app.css` (a comment in the file shows you exactly where): - ```css - @import '@foxui/core/theme.css'; - @source "../node_modules/@foxui"; - ``` - Without **both** lines, foxui components mount but render invisibly — Tailwind doesn't scan their classes. This is a frequent silent-failure mode. -2. Import + use components as normal. See [foxui's repo](https://github.com/flo-bit/foxui) for prop shapes. - -Mixing modes (some foxui, some custom) is fine — but the moment any `@foxui/*` component is in use, both `app.css` lines must be present. - -**After building, optional finishing skills** — invoke when the work calls for them, not as a checklist: - -- `Skill(skill: 'polish')` — final pass for alignment, spacing, micro-detail consistency. -- `Skill(skill: 'audit')` — a11y, performance, theming, anti-patterns; produces a scored report. -- `Skill(skill: 'animate')` — purposeful motion / micro-interactions if the design calls for it. -- `Skill(skill: 'clarify')` — improve UX copy, error messages, labels. -- `Skill(skill: 'impeccable', args: 'extract')` — once you have a few components, pull reusable patterns + tokens into a design system. - ---- - -## File map (for quick reference) - -``` -src/ - lib/ - contrail.config.ts # collections + queryable fields - atproto/ - settings.ts # writable collections, scopes, PDS, ALLOW_SIGNUP - server/oauth.ts # OAuth client (loopback in dev, confidential in prod) - server/signed-cookie.ts # cookie HMAC - auth.svelte.ts # client-side auth state - methods.ts # write helpers (createRecord, etc.) - contrail/ # XRPC handler wiring - routes/ - api/cron/+server.ts # cron-triggered Jetstream ingest - xrpc/[...path]/+server.ts # XRPC endpoint mount - (oauth)/ # login/callback routes -lexicons/ - custom/ # your hand-authored lexicons - pulled/ # NSIDs fetched by contrail-lex -lex.config.js # auto-generated by contrail-lex (gitignored, do not edit) -wrangler.jsonc # CF bindings + cron + vars -.mcp.json # lexicon.garden MCP wiring (auto-loaded) -``` - -## Common scripts - -| Script | What | -|---|---| -| `pnpm dev` | Local dev with loopback OAuth | -| `pnpm env:setup-dev` | Generates COOKIE_SECRET + CLIENT_ASSERTION_KEY into `.env`, randomizes DEV_PORT | -| `pnpm env:generate-key` | Print a fresh CLIENT_ASSERTION_KEY JWK to stdout | -| `pnpm env:generate-secret` | Print a fresh 32-byte base64url secret to stdout | -| `pnpm tunnel` | Cloudflare tunnel for testing confidential OAuth in dev (requires `cloudflared`) | -| `pnpm generate` | Regenerate types from `lexicons/` | -| `pnpm generate:pull` | Full pipeline: regenerate lex.config.js, pull NSIDs, emit types | -| `pnpm backfill[:remote]` | One-shot historical Jetstream ingest (local D1 / remote D1) | -| `pnpm refresh[:remote]` | Re-ingest from a known cursor | -| `pnpm build` | Build + run `contrail append-scheduled` | diff --git a/apps/atproto-starter/eslint.config.js b/apps/atproto-starter/eslint.config.js deleted file mode 100644 index 97d98fe..0000000 --- a/apps/atproto-starter/eslint.config.js +++ /dev/null @@ -1,35 +0,0 @@ -import prettier from 'eslint-config-prettier'; -import js from '@eslint/js'; -import { includeIgnoreFile } from '@eslint/compat'; -import svelte from 'eslint-plugin-svelte'; -import globals from 'globals'; -import { fileURLToPath } from 'node:url'; -import ts from 'typescript-eslint'; -const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); - -export default ts.config( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs['flat/recommended'], - prettier, - ...svelte.configs['flat/prettier'], - { - languageOptions: { - globals: { - ...globals.browser, - ...globals.node - } - } - }, - { - files: ['**/*.svelte'], - - languageOptions: { - parserOptions: { - parser: ts.parser, - tsconfigRootDir: import.meta.dirname - } - } - } -); diff --git a/apps/atproto-starter/package.json b/apps/atproto-starter/package.json deleted file mode 100644 index fb280b4..0000000 --- a/apps/atproto-starter/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "atproto-starter", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "contrail-lex generate && contrail-lex types && vite build && contrail append-scheduled", - "generate": "contrail-lex generate", - "generate:pull": "contrail-lex all", - "backfill": "contrail backfill", - "backfill:remote": "contrail backfill --remote", - "refresh": "contrail refresh", - "refresh:remote": "contrail refresh --remote", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint .", - "env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts", - "env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts", - "env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts", - "tunnel": "npx tsx src/lib/atproto/scripts/tunnel.ts" - }, - "devDependencies": { - "@atcute/atproto": "^3.1.10", - "@atcute/bluesky": "^3.3.0", - "@atcute/client": "^4.2.1", - "@atcute/identity-resolver": "^1.2.2", - "@atcute/lex-cli": "^2.5.3", - "@atcute/lexicon-doc": "^2.1.2", - "@atcute/lexicons": "^1.2.9", - "@atcute/oauth-node-client": "^1.1.0", - "@atcute/tid": "^1.1.2", - "@cloudflare/workers-types": "^4.20260317.1", - "@eslint/compat": "^2.0.3", - "@types/node": "^22.0.0", - "@eslint/js": "^10.0.1", - "@sveltejs/adapter-cloudflare": "^7.2.8", - "@sveltejs/kit": "^2.55.0", - "@sveltejs/vite-plugin-svelte": "^7.0.0", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/vite": "^4.2.2", - "bits-ui": "^2.16.4", - "eslint": "^10.1.0", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-svelte": "^3.16.0", - "globals": "^17.4.0", - "prettier": "^3.8.1", - "prettier-plugin-svelte": "^3.5.1", - "prettier-plugin-tailwindcss": "^0.7.2", - "svelte": "^5.55.0", - "svelte-check": "^4.4.5", - "tailwindcss": "^4.2.2", - "tsx": "^4.21.0", - "typescript": "^6.0.2", - "typescript-eslint": "^8.57.2", - "vite": "^8.0.3", - "wrangler": "^4.78.0" - }, - "license": "MIT", - "dependencies": { - "@atcute/jetstream": "^1.1.2", - "@atmo-dev/contrail": "^0.4.1", - "@atmo-dev/contrail-lexicons": "^0.4.1", - "@foxui/core": "^0.9.1", - "@foxui/social": "^0.8.10", - "@foxui/time": "^0.8.5", - "valibot": "^1.3.1" - } -} diff --git a/apps/atproto-starter/src/app.css b/apps/atproto-starter/src/app.css deleted file mode 100644 index d947f65..0000000 --- a/apps/atproto-starter/src/app.css +++ /dev/null @@ -1,11 +0,0 @@ -@import 'tailwindcss'; - -@plugin '@tailwindcss/forms'; - -@custom-variant dark (&:where(.dark, .dark *)); - -/* If you import any @foxui/* component (Button, AtprotoLoginModal, etc.), add: - * @import '@foxui/core/theme.css'; - * @source "../node_modules/@foxui"; - * Without both lines foxui components mount but render invisibly because Tailwind - * doesn't scan their classes. The default starter is foxui-free, so they're omitted. */ diff --git a/apps/atproto-starter/src/app.d.ts b/apps/atproto-starter/src/app.d.ts deleted file mode 100644 index 831d778..0000000 --- a/apps/atproto-starter/src/app.d.ts +++ /dev/null @@ -1,34 +0,0 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces -import type { OAuthSession } from '@atcute/oauth-node-client'; -import type { Client } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; - -declare global { - namespace App { - // interface Error {} - interface Locals { - session: OAuthSession | null; - client: Client | null; - did: Did | null; - } - // interface PageData {} - // interface PageState {} - interface Platform { - env: { - OAUTH_SESSIONS: KVNamespace; - OAUTH_STATES: KVNamespace; - CLIENT_ASSERTION_KEY: string; - COOKIE_SECRET: string; - OAUTH_PUBLIC_URL: string; - PROFILE_CACHE?: KVNamespace; - DB: D1Database; - CRON_SECRET: string; - }; - } - } -} -import type {} from '@atcute/atproto'; -import type {} from '@atcute/bluesky'; - -export {}; diff --git a/apps/atproto-starter/src/app.html b/apps/atproto-starter/src/app.html deleted file mode 100644 index 0954ebc..0000000 --- a/apps/atproto-starter/src/app.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - %sveltekit.head% - - -
%sveltekit.body%
- - diff --git a/apps/atproto-starter/src/hooks.server.ts b/apps/atproto-starter/src/hooks.server.ts deleted file mode 100644 index 98b4fae..0000000 --- a/apps/atproto-starter/src/hooks.server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Handle } from '@sveltejs/kit'; -import { restoreSession } from '$lib/atproto/server/session'; - -export const handle: Handle = async ({ event, resolve }) => { - const { session, client, did } = await restoreSession( - event.cookies, - event.platform?.env - ); - - event.locals.session = session; - event.locals.client = client; - event.locals.did = did; - - return resolve(event); -}; diff --git a/apps/atproto-starter/src/lib/atproto/auth.svelte.ts b/apps/atproto-starter/src/lib/atproto/auth.svelte.ts deleted file mode 100644 index 2859236..0000000 --- a/apps/atproto-starter/src/lib/atproto/auth.svelte.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AppBskyActorDefs } from '@atcute/bluesky'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; -import { page } from '$app/state'; -import { ALLOW_SIGNUP, REDIRECT_TO_LAST_PAGE_ON_LOGIN } from './settings'; - -export const user = { - get profile() { - return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null; - }, - get isLoggedIn() { - return !!page.data?.did; - }, - get did() { - return (page.data?.did as Did | null) ?? null; - } -}; - -function saveReturnTo() { - if (REDIRECT_TO_LAST_PAGE_ON_LOGIN) { - document.cookie = `oauth_return_to=${encodeURIComponent(window.location.pathname + window.location.search)};path=/;max-age=600;samesite=lax`; - } -} - -export async function login(handle: string) { - if (handle.startsWith('did:')) { - if (handle.length < 6) throw new Error('DID must be at least 6 characters'); - } else if (handle.includes('.') && handle.length > 3) { - handle = (handle.startsWith('@') ? handle.slice(1) : handle) as ActorIdentifier; - if (handle.length < 4) throw new Error('Handle must be at least 4 characters'); - } else if (handle.length > 3) { - handle = ((handle.startsWith('@') ? handle.slice(1) : handle) + - '.bsky.social') as ActorIdentifier; - } else { - throw new Error('Please provide a valid handle or DID.'); - } - - const { oauthLogin } = await import('./server/oauth.remote'); - const { url } = await oauthLogin({ handle }); - saveReturnTo(); - window.location.assign(url); - - // Wait for navigation (prevents UI flash) - await new Promise((_resolve, reject) => { - window.addEventListener('pageshow', () => reject(new Error('user aborted the login request')), { - once: true - }); - }); -} - -export async function signup() { - if (!ALLOW_SIGNUP) throw new Error('Signup is not enabled'); - - const { oauthLogin } = await import('./server/oauth.remote'); - const { url } = await oauthLogin({ signup: true }); - saveReturnTo(); - window.location.assign(url); - - await new Promise((_resolve, reject) => { - window.addEventListener('pageshow', () => reject(new Error('user aborted the signup request')), { - once: true - }); - }); -} - -export async function logout() { - try { - const { oauthLogout } = await import('./server/oauth.remote'); - await oauthLogout(); - } catch (e) { - console.error('Error logging out:', e); - } - - // Full reload to clear server session state - window.location.href = '/'; -} diff --git a/apps/atproto-starter/src/lib/atproto/image-helper.ts b/apps/atproto-starter/src/lib/atproto/image-helper.ts deleted file mode 100644 index 08ddf93..0000000 --- a/apps/atproto-starter/src/lib/atproto/image-helper.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { getCDNImageBlobUrl, uploadBlob } from './methods'; - -export function compressImage( - file: File | Blob, - maxSize: number = 900 * 1024, - maxDimension: number = 2048 -): Promise<{ - blob: Blob; - aspectRatio: { - width: number; - height: number; - }; -}> { - return new Promise((resolve, reject) => { - const img = new Image(); - const reader = new FileReader(); - - reader.onload = (e) => { - if (!e.target?.result) { - return reject(new Error('Failed to read file.')); - } - img.src = e.target.result as string; - }; - - reader.onerror = (err) => reject(err); - reader.readAsDataURL(file); - - img.onload = () => { - let width = img.width; - let height = img.height; - - // If image is already small enough, return original - if (file.size <= maxSize) { - console.log('skipping compression+resizing, already small enough'); - return resolve({ - blob: file, - aspectRatio: { - width, - height - } - }); - } - - if (width > maxDimension || height > maxDimension) { - if (width > height) { - height = Math.round((maxDimension / width) * height); - width = maxDimension; - } else { - width = Math.round((maxDimension / height) * width); - height = maxDimension; - } - } - - // Create a canvas to draw the image - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d'); - if (!ctx) return reject(new Error('Failed to get canvas context.')); - ctx.drawImage(img, 0, 0, width, height); - - // Use WebP for both compression and transparency support - let quality = 0.9; - - function attemptCompression() { - canvas.toBlob( - (blob) => { - if (!blob) { - return reject(new Error('Compression failed.')); - } - if (blob.size <= maxSize || quality < 0.3) { - resolve({ - blob, - aspectRatio: { - width, - height - } - }); - } else { - quality -= 0.1; - attemptCompression(); - } - }, - 'image/webp', - quality - ); - } - - attemptCompression(); - }; - - img.onerror = (err) => reject(err); - }); -} - -export async function checkAndUploadImage( - recordWithImage: Record, - key: string = 'image', - // e.g. /api/image-proxy?url= - imageProxy?: string -) { - if (!recordWithImage[key]) return; - - // Already uploaded as blob - if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { - return; - } - - if (typeof recordWithImage[key] === 'string' && imageProxy) { - const proxyUrl = imageProxy + encodeURIComponent(recordWithImage[key]); - const response = await fetch(proxyUrl); - if (!response.ok) { - throw Error('failed to get image from image proxy'); - } - - const blob = await response.blob(); - const { blob: compressed, aspectRatio } = await compressImage(blob); - - recordWithImage[key] = await uploadBlob({ blob: compressed, aspectRatio }); - - return; - } - - if (recordWithImage[key]?.blob) { - if (recordWithImage[key].objectUrl) { - URL.revokeObjectURL(recordWithImage[key].objectUrl); - } - const { blob: compressed, aspectRatio } = await compressImage(recordWithImage[key].blob); - recordWithImage[key] = await uploadBlob({ blob: compressed, aspectRatio }); - } -} - -export function getImageFromRecord( - recordWithImage: Record | undefined, - did: string, - key: string = 'image' -): string | undefined { - if (!recordWithImage?.[key]) return; - - if (typeof recordWithImage[key] === 'object' && recordWithImage[key].$type === 'blob') { - return getCDNImageBlobUrl({ did, blob: recordWithImage[key] }); - } - - if (recordWithImage[key].objectUrl) return recordWithImage[key].objectUrl; - - if (recordWithImage[key].blob) { - recordWithImage[key].objectUrl = URL.createObjectURL(recordWithImage[key].blob); - return recordWithImage[key].objectUrl; - } - - return recordWithImage[key]; -} diff --git a/apps/atproto-starter/src/lib/atproto/index.ts b/apps/atproto-starter/src/lib/atproto/index.ts deleted file mode 100644 index c0cf642..0000000 --- a/apps/atproto-starter/src/lib/atproto/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { user, login, signup, logout } from './auth.svelte'; - -export { - parseUri, - resolveHandle, - actorToDid, - getPDS, - getDetailedProfile, - getPDSClient, - listRecords, - getRecord, - putRecord, - deleteRecord, - uploadBlob, - describeRepo, - getBlobURL, - getCDNImageBlobUrl, - searchActorsTypeahead, - createTID -} from './methods'; diff --git a/apps/atproto-starter/src/lib/atproto/methods.ts b/apps/atproto-starter/src/lib/atproto/methods.ts deleted file mode 100644 index c768ab5..0000000 --- a/apps/atproto-starter/src/lib/atproto/methods.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons'; -import { isDid } from '@atcute/lexicons/syntax'; -import { user } from './auth.svelte'; -import { DOH_RESOLVER, type AllowedCollection } from './settings'; -import { - CompositeDidDocumentResolver, - CompositeHandleResolver, - DohJsonHandleResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - WellKnownHandleResolver -} from '@atcute/identity-resolver'; -import { Client, simpleFetchHandler } from '@atcute/client'; -import { type AppBskyActorDefs } from '@atcute/bluesky'; - -export type Collection = `${string}.${string}.${string}`; -import * as TID from '@atcute/tid'; - -/** - * Parses an AT Protocol URI into its components. - */ -export function parseUri(uri: string) { - const parts = parseResourceUri(uri); - if (!parts.ok) return; - return parts.value; -} - -/** - * Resolves a handle to a DID using DNS and HTTP methods. - */ -export async function resolveHandle({ handle }: { handle: Handle }) { - const handleResolver = new CompositeHandleResolver({ - methods: { - dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), - http: new WellKnownHandleResolver() - } - }); - - const data = await handleResolver.resolve(handle); - return data; -} - -/** - * Returns a DID given a handle or DID string. - */ -export async function actorToDid(actor: string): Promise { - if (isDid(actor)) return actor; - return await resolveHandle({ handle: actor as Handle }); -} - -const didResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver() - } -}); - -/** - * Gets the PDS (Personal Data Server) URL for a given DID. - */ -export async function getPDS(did: Did) { - const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>); - if (!doc.service) throw new Error('No PDS found'); - for (const service of doc.service) { - if (service.id === '#atproto_pds') { - return service.serviceEndpoint.toString(); - } - } -} - -/** - * Fetches a detailed Bluesky profile for a user. - */ -export async function getDetailedProfile(data?: { did?: Did; client?: Client }) { - data ??= {}; - data.did ??= user.did ?? undefined; - - if (!data.did) throw new Error('Error getting detailed profile: no did'); - - data.client ??= new Client({ - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) - }); - - const response = await data.client.get('app.bsky.actor.getProfile', { - params: { actor: data.did } - }); - - if (!response.ok) return; - - return response.data; -} - -/** - * Creates an AT Protocol client for a user's PDS. - */ -export async function getPDSClient({ did }: { did: Did }) { - const pds = await getPDS(did); - if (!pds) throw new Error('PDS not found'); - - const client = new Client({ - handler: simpleFetchHandler({ service: pds }) - }); - - return client; -} - -/** - * Lists records from a repository collection with pagination support. - */ -export async function listRecords({ - did, - collection, - cursor, - limit = 100, - client -}: { - did?: Did; - collection: `${string}.${string}.${string}`; - cursor?: string; - limit?: number; - client?: Client; -}) { - did ??= user.did ?? undefined; - if (!collection) { - throw new Error('Missing parameters for listRecords'); - } - if (!did) { - throw new Error('Missing did for listRecords'); - } - - client ??= await getPDSClient({ did }); - - const allRecords = []; - - let currentCursor = cursor; - do { - const response = await client.get('com.atproto.repo.listRecords', { - params: { - repo: did, - collection, - limit: !limit || limit > 100 ? 100 : limit, - cursor: currentCursor - } - }); - - if (!response.ok) { - return allRecords; - } - - allRecords.push(...response.data.records); - currentCursor = response.data.cursor; - } while (currentCursor && (!limit || allRecords.length < limit)); - - return allRecords; -} - -/** - * Fetches a single record from a repository. - */ -export async function getRecord({ - did, - collection, - rkey = 'self', - client -}: { - did?: Did; - collection: Collection; - rkey?: string; - client?: Client; -}) { - did ??= user.did ?? undefined; - - if (!collection) { - throw new Error('Missing parameters for getRecord'); - } - if (!did) { - throw new Error('Missing did for getRecord'); - } - - client ??= await getPDSClient({ did }); - - const record = await client.get('com.atproto.repo.getRecord', { - params: { - repo: did, - collection, - rkey - } - }); - - return JSON.parse(JSON.stringify(record.data)); -} - -/** - * Creates or updates a record via remote function. - */ -export async function putRecord({ - collection, - rkey = 'self', - record -}: { - collection: AllowedCollection; - rkey?: string; - record: Record; -}) { - if (!user.did) throw new Error('Not logged in'); - - const { putRecord: putRecordRemote } = await import('./server/repo.remote'); - const data = await putRecordRemote({ collection, rkey, record }); - return { ok: true, data }; -} - -/** - * Deletes a record via remote function. - */ -export async function deleteRecord({ - collection, - rkey = 'self' -}: { - collection: AllowedCollection; - rkey: string; -}) { - if (!user.did) throw new Error('Not logged in'); - - const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote'); - const data = await deleteRecordRemote({ collection, rkey }); - return data.ok; -} - -/** - * Gets the dimensions of an image blob. - */ -function getImageDimensions(blob: Blob): Promise<{ width: number; height: number }> { - return new Promise((resolve, reject) => { - const img = new Image(); - const url = URL.createObjectURL(blob); - img.onload = () => { - URL.revokeObjectURL(url); - resolve({ width: img.naturalWidth, height: img.naturalHeight }); - }; - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load image for dimensions')); - }; - img.src = url; - }); -} - -/** - * Uploads a blob via remote function. - * Converts the Blob to a byte array for serialization across the remote boundary. - * For image blobs, automatically includes aspectRatio with width and height. - */ -export async function uploadBlob({ - blob, - aspectRatio -}: { - blob: Blob; - aspectRatio?: { width: number; height: number }; -}) { - if (!user.did) throw new Error("Can't upload blob: Not logged in"); - - // Auto-detect dimensions for image blobs if not provided - if (!aspectRatio && blob.type.startsWith('image/')) { - try { - aspectRatio = await getImageDimensions(blob); - } catch { - // Non-critical — proceed without aspectRatio - } - } - - const arrayBuffer = await blob.arrayBuffer(); - const bytes = Array.from(new Uint8Array(arrayBuffer)); - - const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote'); - const result = await uploadBlobRemote({ bytes, mimeType: blob.type || 'application/octet-stream' }); - - if (aspectRatio) { - return { ...result, aspectRatio }; - } - return result; -} - -/** - * Gets metadata about a repository. - */ -export async function describeRepo({ client, did }: { client?: Client; did?: Did }) { - did ??= user.did ?? undefined; - if (!did) { - throw new Error('Error describeRepo: No did'); - } - client ??= await getPDSClient({ did }); - - const repo = await client.get('com.atproto.repo.describeRepo', { - params: { - repo: did - } - }); - if (!repo.ok) return; - - return repo.data; -} - -/** - * Constructs a URL to fetch a blob directly from a user's PDS. - */ -export async function getBlobURL({ - did, - blob -}: { - did: Did; - blob: { - $type: 'blob'; - ref: { - $link: string; - }; - }; -}) { - const pds = await getPDS(did); - return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`; -} - -/** - * Constructs a Bluesky CDN URL for an image blob. - */ -export function getCDNImageBlobUrl({ - did, - blob -}: { - did?: string; - blob: { - $type: 'blob'; - ref: { - $link: string; - }; - }; -}) { - did ??= user.did ?? undefined; - - return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`; -} - -/** - * Searches for actors with typeahead/autocomplete functionality. - */ -export async function searchActorsTypeahead( - q: string, - limit: number = 10, - host?: string -): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> { - host ??= 'https://public.api.bsky.app'; - - const client = new Client({ - handler: simpleFetchHandler({ service: host }) - }); - - const response = await client.get('app.bsky.actor.searchActorsTypeahead', { - params: { - q, - limit - } - }); - - if (!response.ok) return { actors: [], q }; - - return { actors: response.data.actors, q }; -} - -/** - * Return a TID based on current time - */ -export function createTID() { - return TID.now(); -} diff --git a/apps/atproto-starter/src/lib/atproto/port.ts b/apps/atproto-starter/src/lib/atproto/port.ts deleted file mode 100644 index 4d6686b..0000000 --- a/apps/atproto-starter/src/lib/atproto/port.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Dev server port — generated by setup-dev, shared by vite, oauth, and tunnel. -// Each project gets a unique random port (5200–7200) so multiple projects can run simultaneously. -export const DEV_PORT = 5291; diff --git a/apps/atproto-starter/src/lib/atproto/scripts/generate-key.ts b/apps/atproto-starter/src/lib/atproto/scripts/generate-key.ts deleted file mode 100644 index 3d1cc8a..0000000 --- a/apps/atproto-starter/src/lib/atproto/scripts/generate-key.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { generateClientAssertionKey } from '@atcute/oauth-node-client'; - -const key = await generateClientAssertionKey('main-key'); -console.log(JSON.stringify(key)); diff --git a/apps/atproto-starter/src/lib/atproto/scripts/generate-secret.ts b/apps/atproto-starter/src/lib/atproto/scripts/generate-secret.ts deleted file mode 100644 index 9092066..0000000 --- a/apps/atproto-starter/src/lib/atproto/scripts/generate-secret.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { randomBytes } from 'node:crypto'; - -console.log(randomBytes(32).toString('base64url')); diff --git a/apps/atproto-starter/src/lib/atproto/scripts/setup-dev.ts b/apps/atproto-starter/src/lib/atproto/scripts/setup-dev.ts deleted file mode 100644 index bdc8ccf..0000000 --- a/apps/atproto-starter/src/lib/atproto/scripts/setup-dev.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { existsSync } from 'node:fs'; -import { copyFile, readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { randomBytes, randomInt } from 'node:crypto'; - -import { generateClientAssertionKey } from '@atcute/oauth-node-client'; - -const cwd = process.cwd(); -const examplePath = resolve(cwd, '.env.example'); -const envPath = resolve(cwd, '.env'); - -if (!existsSync(envPath)) { - if (!existsSync(examplePath)) { - throw new Error(`missing .env.example (expected at ${examplePath})`); - } - await copyFile(examplePath, envPath); - console.log(`created ${envPath}`); -} - -const upsertVar = (input: string, key: string, value: string): string => { - const line = `${key}=${value}`; - const re = new RegExp(`^${key}=.*$`, 'm'); - - if (re.test(input)) { - const match = input.match(re); - const current = match ? match[0].slice(key.length + 1).trim() : ''; - // Only overwrite if empty/placeholder - if (current === '' || current === "''" || current === '""' || current.includes('...')) { - return input.replace(re, line); - } - return input; - } - - const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; - return `${input}${suffix}${line}\n`; -}; - -let vars = await readFile(envPath, 'utf8'); - -const secret = randomBytes(32).toString('base64url'); -vars = upsertVar(vars, 'COOKIE_SECRET', secret); - -const jwk = await generateClientAssertionKey('main-key'); -vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk)); - -await writeFile(envPath, vars); -console.log(`updated ${envPath}`); - -// Generate a random dev port (5200–7200) so multiple projects can run simultaneously -const portPath = resolve(cwd, 'src/lib/atproto/port.ts'); -const portFile = await readFile(portPath, 'utf8'); -const currentPort = portFile.match(/DEV_PORT\s*=\s*(\d+)/); -if (currentPort && parseInt(currentPort[1]) === 5183) { - const port = randomInt(5200, 7201); - const updated = portFile.replace(/DEV_PORT\s*=\s*\d+/, `DEV_PORT = ${port}`); - await writeFile(portPath, updated); - console.log(`set DEV_PORT to ${port} in port.ts`); -} else { - console.log(`DEV_PORT already customized (${currentPort?.[1]}), skipping`); -} diff --git a/apps/atproto-starter/src/lib/atproto/scripts/tunnel.ts b/apps/atproto-starter/src/lib/atproto/scripts/tunnel.ts deleted file mode 100644 index 9c37d5f..0000000 --- a/apps/atproto-starter/src/lib/atproto/scripts/tunnel.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { spawn } from 'node:child_process'; -import { DEV_PORT } from '../port'; - -const cwd = process.cwd(); -const envPath = resolve(cwd, '.env'); -const vitePath = resolve(cwd, 'vite.config.ts'); - -let tunnelUrl: string | null = null; -let statusBarActive = false; - -// ── ANSI status bar ────────────────────────────────────────────── -// Reserves the bottom row of the terminal for a persistent status line. -// Logs scroll in the region above it. - -function getColumns(): number { - return process.stdout.columns || 80; -} - -function getRows(): number { - return process.stdout.rows || 24; -} - -function setupScrollRegion(): void { - if (!process.stdout.isTTY) return; - statusBarActive = true; - const rows = getRows(); - // Set scroll region to all rows except the last - process.stdout.write(`\x1b[1;${rows - 1}r`); - // Move cursor into scroll region - process.stdout.write(`\x1b[${rows - 1};1H`); -} - -function drawStatusBar(text: string): void { - if (!process.stdout.isTTY) { - process.stdout.write(text + '\n'); - return; - } - const rows = getRows(); - const cols = getColumns(); - // Save cursor, move to bottom row, clear it, write status, restore cursor - process.stdout.write('\x1b7'); - process.stdout.write(`\x1b[${rows};1H`); - process.stdout.write('\x1b[2K'); - // Inverse video for the bar - process.stdout.write(`\x1b[7m ${text.padEnd(cols - 1)}\x1b[0m`); - process.stdout.write('\x1b8'); -} - -function clearStatusBar(): void { - if (!process.stdout.isTTY || !statusBarActive) return; - const rows = getRows(); - // Reset scroll region to full terminal - process.stdout.write(`\x1b[1;${rows}r`); - // Clear the bottom row - process.stdout.write(`\x1b[${rows};1H\x1b[2K`); - // Move cursor up - process.stdout.write(`\x1b[${rows - 1};1H`); - statusBarActive = false; -} - -function writeLog(text: string): void { - if (statusBarActive && process.stdout.isTTY) { - // Write inside the scroll region, which auto-scrolls above the bar - process.stdout.write(text); - } else { - process.stdout.write(text); - } -} - -// Redraw on terminal resize -process.stdout.on('resize', () => { - if (!statusBarActive || !tunnelUrl) return; - setupScrollRegion(); - drawStatusBar(`Tunnel: ${tunnelUrl} | Ctrl+C to stop`); -}); - -// ── .env helpers ───────────────────────────────────────────────── - -function readEnv(): string { - return readFileSync(envPath, 'utf8'); -} - -function writeEnv(content: string): void { - writeFileSync(envPath, content); -} - -function setEnvVar(key: string, value: string): void { - let env = readEnv(); - const re = new RegExp(`^(#\\s*)?${key}=.*$`, 'm'); - const line = `${key}=${value}`; - - if (re.test(env)) { - env = env.replace(re, line); - } else { - env = env.trimEnd() + '\n' + line + '\n'; - } - writeEnv(env); -} - -function clearEnvVar(key: string): void { - let env = readEnv(); - const re = new RegExp(`^${key}=.*$`, 'm'); - - if (re.test(env)) { - env = env.replace(re, `# ${key}=`); - writeEnv(env); - } -} - -// ── vite config helpers ────────────────────────────────────────── - -function setViteAllowedHosts(hostname: string): void { - let vite = readFileSync(vitePath, 'utf8'); - - if (/allowedHosts\s*:/.test(vite)) { - vite = vite.replace(/allowedHosts\s*:\s*\[.*?\]/s, `allowedHosts: ['${hostname}']`); - } else if (/server\s*:\s*\{/.test(vite)) { - vite = vite.replace(/server\s*:\s*\{/, `server: {\n\t\tallowedHosts: ['${hostname}'],`); - } - - writeFileSync(vitePath, vite); -} - -function clearViteAllowedHosts(): void { - let vite = readFileSync(vitePath, 'utf8'); - - if (/allowedHosts\s*:/.test(vite)) { - vite = vite.replace(/allowedHosts\s*:\s*\[.*?\]/s, 'allowedHosts: []'); - } - - writeFileSync(vitePath, vite); -} - -// ── cleanup ────────────────────────────────────────────────────── - -function cleanup(): void { - clearStatusBar(); - console.log('\nCleaning up...'); - if (tunnelUrl) { - clearEnvVar('OAUTH_PUBLIC_URL'); - console.log(' Cleared OAUTH_PUBLIC_URL from .env'); - clearViteAllowedHosts(); - console.log(' Cleared allowedHosts from vite.config.ts'); - } -} - -// ── main ───────────────────────────────────────────────────────── - -const child = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${DEV_PORT}`], { - stdio: ['ignore', 'pipe', 'pipe'] -}); - -child.stderr.on('data', (data: Buffer) => { - const output = data.toString(); - - if (!tunnelUrl) { - const match = output.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/); - if (match) { - tunnelUrl = match[0]; - const hostname = new URL(tunnelUrl).hostname; - - setEnvVar('OAUTH_PUBLIC_URL', tunnelUrl); - setViteAllowedHosts(hostname); - - writeLog(`\n Set OAUTH_PUBLIC_URL=${tunnelUrl}\n`); - writeLog(` Set vite allowedHosts to [${hostname}]\n`); - writeLog(` Tunnel is ready! Restart your dev server to pick up the new URL.\n\n`); - - setupScrollRegion(); - drawStatusBar(`Tunnel: ${tunnelUrl} | Ctrl+C to stop`); - return; - } - } - - writeLog(output); -}); - -child.stdout.on('data', (data: Buffer) => { - writeLog(data.toString()); -}); - -child.on('close', (code) => { - cleanup(); - process.exit(code ?? 0); -}); - -process.on('SIGINT', () => { - child.kill('SIGINT'); -}); - -process.on('SIGTERM', () => { - child.kill('SIGTERM'); -}); diff --git a/apps/atproto-starter/src/lib/atproto/server/kv-store.ts b/apps/atproto-starter/src/lib/atproto/server/kv-store.ts deleted file mode 100644 index 35c3dcb..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/kv-store.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Store } from '@atcute/oauth-node-client'; - -export class KVStore implements Store { - private kv: KVNamespace; - private expirationTtl?: number; - - constructor(kv: KVNamespace, options?: { expirationTtl?: number }) { - this.kv = kv; - this.expirationTtl = options?.expirationTtl; - } - - async get(key: K): Promise { - const value = await this.kv.get(key, 'text'); - if (value === null) return undefined; - return JSON.parse(value) as V; - } - - async set(key: K, value: V): Promise { - await this.kv.put(key, JSON.stringify(value), { - expirationTtl: this.expirationTtl - }); - } - - async delete(key: K): Promise { - await this.kv.delete(key); - } - - async clear(): Promise { - let cursor: string | undefined; - do { - const result = await this.kv.list({ cursor }); - for (const key of result.keys) { - await this.kv.delete(key.name); - } - cursor = result.list_complete ? undefined : result.cursor; - } while (cursor); - } -} diff --git a/apps/atproto-starter/src/lib/atproto/server/oauth.remote.ts b/apps/atproto-starter/src/lib/atproto/server/oauth.remote.ts deleted file mode 100644 index f087d53..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/oauth.remote.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as v from 'valibot'; -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import { createOAuthClient } from './oauth'; -import { getSignedCookie } from './signed-cookie'; -import { scopes, signUpPDS } from '../settings'; -import type { ActorIdentifier, Did } from '@atcute/lexicons'; - -export const oauthLogin = command( - v.object({ - handle: v.optional(v.pipe(v.string(), v.minLength(3))), - signup: v.optional(v.boolean()) - }), - async (input) => { - const { platform } = getRequestEvent(); - - try { - const oauth = createOAuthClient(platform?.env); - - const target = input.signup - ? ({ type: 'pds', serviceUrl: signUpPDS } as const) - : ({ type: 'account', identifier: input.handle as ActorIdentifier } as const); - - const { url } = await oauth.authorize({ - target, - scope: scopes.join(' '), - prompt: input.signup ? 'create' : undefined - }); - - return { url: url.toString() }; - } catch (e) { - if (e && typeof e === 'object' && 'status' in e) throw e; // re-throw SvelteKit errors - const message = e instanceof Error ? e.message : 'Login failed'; - error(400, message); - } - } -); - -export const oauthLogout = command(async () => { - const { cookies, platform } = getRequestEvent(); - const did = getSignedCookie(cookies, 'did') as Did | null; - - if (did) { - try { - const oauth = createOAuthClient(platform?.env); - await oauth.revoke(did); - } catch (e) { - console.error('Error revoking session:', e); - } - } - - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - - return { ok: true }; -}); diff --git a/apps/atproto-starter/src/lib/atproto/server/oauth.ts b/apps/atproto-starter/src/lib/atproto/server/oauth.ts deleted file mode 100644 index e0d0f95..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/oauth.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - OAuthClient, - MemoryStore, - type ClientAssertionPrivateJwk, - type OAuthClientStores, - type OAuthSession, - type StoredSession, - type StoredState -} from '@atcute/oauth-node-client'; -import type { Did } from '@atcute/lexicons'; -import { - CompositeDidDocumentResolver, - CompositeHandleResolver, - DohJsonHandleResolver, - LocalActorResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - WellKnownHandleResolver -} from '@atcute/identity-resolver'; -import { KVStore } from './kv-store'; -import { DOH_RESOLVER, REDIRECT_PATH, scopes } from '../settings'; -import { DEV_PORT } from '../port'; -import { dev } from '$app/environment'; - -function createActorResolver() { - return new LocalActorResolver({ - handleResolver: new CompositeHandleResolver({ - methods: { - dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }), - http: new WellKnownHandleResolver() - } - }), - didDocumentResolver: new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver() - } - }) - }); -} - -function createStores(env?: App.Platform['env']): OAuthClientStores { - if (env?.OAUTH_SESSIONS && env?.OAUTH_STATES) { - return { - sessions: new KVStore(env.OAUTH_SESSIONS), - states: new KVStore(env.OAUTH_STATES, { expirationTtl: 600 }) - }; - } - // Fallback to in-memory stores (dev without wrangler) - return { - sessions: new MemoryStore(), - states: new MemoryStore({ ttl: 600_000 }) - }; -} - -export function createOAuthClient(env?: App.Platform['env']): OAuthClient { - const actorResolver = createActorResolver(); - const stores = createStores(env); - - if (dev && !env?.OAUTH_PUBLIC_URL) { - // Dev without tunnel: loopback public client (no keyset). - // Omit client_id — the library builds it automatically from redirect_uris + scope. - // redirect_uris must use 127.0.0.1 (not localhost). - return new OAuthClient({ - metadata: { - redirect_uris: [`http://127.0.0.1:${DEV_PORT}${REDIRECT_PATH}`], - scope: scopes - }, - actorResolver, - stores - }); - } - - // Confidential client (production, or dev with tunnel via OAUTH_PUBLIC_URL) - if (!env?.OAUTH_PUBLIC_URL) { - throw new Error('OAUTH_PUBLIC_URL is not set'); - } - if (!env.CLIENT_ASSERTION_KEY) { - throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key'); - } - const site = env.OAUTH_PUBLIC_URL; - const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY); - - return new OAuthClient({ - metadata: { - client_id: site + '/oauth-client-metadata.json', - redirect_uris: [site + REDIRECT_PATH], - scope: scopes, - jwks_uri: site + '/oauth/jwks.json' - }, - keyset: [key], - actorResolver, - stores - }); -} - -export type { OAuthSession }; diff --git a/apps/atproto-starter/src/lib/atproto/server/profile.ts b/apps/atproto-starter/src/lib/atproto/server/profile.ts deleted file mode 100644 index 6e51975..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/profile.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Did } from '@atcute/lexicons'; -import { getDetailedProfile, describeRepo } from '../methods'; - -const PROFILE_CACHE_TTL = 60 * 60; // 1 hour - -/** - * Loads a user's profile, with optional KV caching. - * Falls back to a fresh fetch if the cache KV doesn't exist or on cache miss. - * Returns undefined if the profile can't be loaded. - */ -export async function getProfile(did: Did, profileCache?: KVNamespace) { - // Try cache first - if (profileCache) { - try { - const cached = await profileCache.get(did, 'json'); - if (cached) return cached as Record; - } catch { - // Cache read failed, continue to fresh fetch - } - } - - const profile = await fetchProfile(did); - - // Write to cache (fire-and-forget) - if (profileCache && profile) { - profileCache - .put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL }) - .catch(() => {}); - } - - return profile; -} - -async function fetchProfile(did: Did) { - try { - let profile = await getDetailedProfile({ did }); - - if (!profile || profile.handle === 'handle.invalid') { - const repo = await describeRepo({ did }); - profile = { - did, - handle: repo?.handle || 'handle.invalid' - } as typeof profile; - } - - return profile; - } catch (e) { - console.error('Failed to load profile:', e); - return undefined; - } -} diff --git a/apps/atproto-starter/src/lib/atproto/server/repo.remote.ts b/apps/atproto-starter/src/lib/atproto/server/repo.remote.ts deleted file mode 100644 index fc7f4d8..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/repo.remote.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { error } from '@sveltejs/kit'; -import { command, getRequestEvent } from '$app/server'; -import * as v from 'valibot'; -import { collections } from '../settings'; -import { contrail, ensureInit } from '$lib/contrail'; - -// Validate collection format and check against allowed list from settings -const collectionSchema = v.pipe( - v.string(), - v.regex(/^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z][a-zA-Z0-9-]*){2,}$/), - v.check((c) => collections.includes(c as (typeof collections)[number]), 'Collection not in allowed list') -); - -// AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot) -const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/))); - -export const putRecord = command( - v.object({ - collection: collectionSchema, - rkey: rkeySchema, - record: v.record(v.string(), v.unknown()) - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const response = await locals.client.post('com.atproto.repo.putRecord', { - input: { - collection: input.collection as `${string}.${string}.${string}`, - repo: locals.did, - rkey: input.rkey || 'self', - record: input.record - } - }); - - if (!response.ok) error(502, 'putRecord failed'); - - // Immediately index the new/updated record in contrail - const { platform } = getRequestEvent(); - const db = platform?.env?.DB; - if (db) { - await ensureInit(db); - await contrail.notify(response.data.uri, db).catch(() => {}); - } - - return response.data; - } -); - -export const deleteRecord = command( - v.object({ - collection: collectionSchema, - rkey: rkeySchema - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const response = await locals.client.post('com.atproto.repo.deleteRecord', { - input: { - collection: input.collection as `${string}.${string}.${string}`, - repo: locals.did, - rkey: input.rkey || 'self' - } - }); - - return { ok: response.ok }; - } -); - -export const uploadBlob = command( - v.object({ - bytes: v.array(v.number()), - mimeType: v.string() - }), - async (input) => { - const { locals } = getRequestEvent(); - if (!locals.client || !locals.did) error(401, 'Not authenticated'); - - const blob = new Blob([new Uint8Array(input.bytes)], { type: input.mimeType }); - - const response = await locals.client.post('com.atproto.repo.uploadBlob', { - params: { repo: locals.did }, - input: blob - }); - - if (!response.ok) error(500, 'Upload failed'); - - return response.data.blob as { - $type: 'blob'; - ref: { $link: string }; - mimeType: string; - size: number; - }; - } -); diff --git a/apps/atproto-starter/src/lib/atproto/server/session.ts b/apps/atproto-starter/src/lib/atproto/server/session.ts deleted file mode 100644 index 2c1fe8f..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/session.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Cookies } from '@sveltejs/kit'; -import { Client } from '@atcute/client'; -import type { Did } from '@atcute/lexicons'; -import { - type OAuthSession, - TokenInvalidError, - TokenRefreshError, - TokenRevokedError, - AuthMethodUnsatisfiableError -} from '@atcute/oauth-node-client'; -import { createOAuthClient } from './oauth'; -import { getSignedCookie } from './signed-cookie'; -import { scopes } from '../settings'; - -export type SessionLocals = { - session: OAuthSession | null; - client: Client | null; - did: Did | null; -}; - -/** - * Restores an OAuth session from the signed `did` cookie. - * Returns session locals to be assigned to `event.locals`. - * Deletes the cookie only if the session is genuinely unrecoverable. - * Transient failures (network, KV) preserve the cookie for retry. - */ -export async function restoreSession( - cookies: Cookies, - env?: App.Platform['env'] -): Promise { - const did = getSignedCookie(cookies, 'did') as Did | null; - - if (!did) { - return { session: null, client: null, did: null }; - } - - // If permissions changed since login, invalidate the session - const savedScope = getSignedCookie(cookies, 'scope'); - if (savedScope !== null && savedScope !== scopes.join(' ')) { - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - return { session: null, client: null, did: null }; - } - - try { - const oauth = createOAuthClient(env); - const session = await oauth.restore(did); - - return { - session, - client: new Client({ handler: session }), - did - }; - } catch (e) { - console.error('Failed to restore session:', e); - - // Only delete cookies when the session is genuinely unrecoverable. - // Transient errors (network issues, KV hiccups) should preserve the - // cookie so the next request can retry without forcing a full re-login. - const isSessionGone = - e instanceof TokenInvalidError || - e instanceof TokenRevokedError || - e instanceof TokenRefreshError || - e instanceof AuthMethodUnsatisfiableError; - - if (isSessionGone) { - cookies.delete('did', { path: '/' }); - cookies.delete('scope', { path: '/' }); - } - - return { session: null, client: null, did: null }; - } -} diff --git a/apps/atproto-starter/src/lib/atproto/server/signed-cookie.ts b/apps/atproto-starter/src/lib/atproto/server/signed-cookie.ts deleted file mode 100644 index 6cde19c..0000000 --- a/apps/atproto-starter/src/lib/atproto/server/signed-cookie.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; - -import type { Cookies } from '@sveltejs/kit'; - -import { env } from '$env/dynamic/private'; -import { dev } from '$app/environment'; - -const SEPARATOR = '.'; - -function getSecret(): string { - const secret = env.COOKIE_SECRET; - if (secret) return secret; - if (dev) return 'dev-cookie-secret-not-for-production'; - throw new Error('COOKIE_SECRET is not set'); -} - -function toBase64Url(bytes: Uint8Array): string { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function fromBase64Url(str: string): Uint8Array { - const padded = str + '='.repeat((4 - (str.length % 4)) % 4); - const base64 = padded.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -function hmacSha256(data: string): Uint8Array { - return createHmac('sha256', getSecret()).update(data).digest(); -} - -export function getSignedCookie(cookies: Cookies, name: string): string | null { - const signed = cookies.get(name); - if (!signed) return null; - - const idx = signed.lastIndexOf(SEPARATOR); - if (idx === -1) return null; - - const value = signed.slice(0, idx); - const sig = signed.slice(idx + 1); - - let expected: Uint8Array; - let got: Uint8Array; - try { - expected = hmacSha256(value); - got = fromBase64Url(sig); - } catch { - return null; - } - - if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null; - - return value; -} - -export function setSignedCookie( - cookies: Cookies, - name: string, - value: string, - options: Parameters[2] -): void { - const sig = toBase64Url(hmacSha256(value)); - const signed = `${value}${SEPARATOR}${sig}`; - cookies.set(name, signed, options); -} diff --git a/apps/atproto-starter/src/lib/atproto/settings.ts b/apps/atproto-starter/src/lib/atproto/settings.ts deleted file mode 100644 index 19be7dc..0000000 --- a/apps/atproto-starter/src/lib/atproto/settings.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { dev } from '$app/environment'; -import { scope } from '@atcute/oauth-node-client'; - -// writable collections — list every NSID the app writes to the user's PDS -export const collections = [] as const; - -export type AllowedCollection = (typeof collections)[number]; - -// OAuth scope — add scope.blob({ accept: ['image/*'] }), scope.rpc(), etc. as needed -export const scopes = ['atproto', scope.repo({ collection: [...collections] })]; - -// set to false to disable signup -export const ALLOW_SIGNUP = true; - -// which PDS to use for signup (change to your preferred PDS) -const devPDS = 'https://pds.rip/'; -const prodPDS = 'https://selfhosted.social/'; -export const signUpPDS = dev ? devPDS : prodPDS; - -// where to redirect after oauth login/signup -export const REDIRECT_PATH = '/oauth/callback'; - -// redirect the user back to the page they were on before login -export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true; - -export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query'; diff --git a/apps/atproto-starter/src/lib/atproto/ui/LoginModal.svelte b/apps/atproto-starter/src/lib/atproto/ui/LoginModal.svelte deleted file mode 100644 index db13102..0000000 --- a/apps/atproto-starter/src/lib/atproto/ui/LoginModal.svelte +++ /dev/null @@ -1,142 +0,0 @@ - - - - -{#if atProtoLoginModalState.open} - -{/if} diff --git a/apps/atproto-starter/src/lib/contrail.config.ts b/apps/atproto-starter/src/lib/contrail.config.ts deleted file mode 100644 index dfb2dec..0000000 --- a/apps/atproto-starter/src/lib/contrail.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ContrailConfig } from '@atmo-dev/contrail'; - -// Replace `namespace` with your reverse-DNS domain (e.g. mybookmarks.app → app.mybookmarks). -// Add collections the app indexes — see https://flo-bit.dev/contrail/llms-full.txt -export const config: ContrailConfig = { - namespace: 'app.example', - collections: {} -}; diff --git a/apps/atproto-starter/src/lib/contrail/client.ts b/apps/atproto-starter/src/lib/contrail/client.ts deleted file mode 100644 index 13f8f51..0000000 --- a/apps/atproto-starter/src/lib/contrail/client.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Client, simpleFetchHandler } from '@atcute/client'; - -export interface Profile { - handle: string; - displayName?: string; - avatar?: string; -} - -/** - * Extract a simple profile from a contrail profile entry. - * Contrail returns { did, handle, value: { displayName, avatar, ... } } - * while components expect { handle, displayName?, avatar? }. - */ -export function extractProfile(entry: { - did: string; - handle?: string; - value?: unknown; -}): Profile { - const value = entry.value as { displayName?: string; avatar?: string } | undefined; - return { - handle: entry.handle ?? entry.did, - displayName: value?.displayName, - avatar: value?.avatar - }; -} - -/** - * Client-side: fully typed @atcute/client that queries the app's own /xrpc/ endpoints. - */ -export function getClient() { - return new Client({ handler: simpleFetchHandler({ service: '' }) }); -} diff --git a/apps/atproto-starter/src/lib/contrail/index.ts b/apps/atproto-starter/src/lib/contrail/index.ts deleted file mode 100644 index 29f4474..0000000 --- a/apps/atproto-starter/src/lib/contrail/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Contrail } from '@atmo-dev/contrail'; -import { createHandler, createServerClient } from '@atmo-dev/contrail/server'; -import type { Client } from '@atcute/client'; -import { config } from '../contrail.config'; - -export const contrail = new Contrail(config); - -let initialized = false; - -export async function ensureInit(db: D1Database) { - if (!initialized) { - await contrail.init(db); - initialized = true; - } -} - -const handle = createHandler(contrail); - -/** - * Typed `@atcute/client` that calls contrail in-process. Pass `did` to act - * as that user (server-side principal via WeakMap marker — no JWT, no PDS - * roundtrip). Omit for anonymous calls against public endpoints. - */ -export function getServerClient(db: D1Database, did?: string): Client { - return createServerClient(async (req) => { - await ensureInit(db); - return handle(req, db) as Promise; - }, did); -} diff --git a/apps/atproto-starter/src/lib/index.ts b/apps/atproto-starter/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/apps/atproto-starter/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/apps/atproto-starter/src/routes/(oauth)/oauth-client-metadata.json/+server.ts b/apps/atproto-starter/src/routes/(oauth)/oauth-client-metadata.json/+server.ts deleted file mode 100644 index c09dfb0..0000000 --- a/apps/atproto-starter/src/routes/(oauth)/oauth-client-metadata.json/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { dev } from '$app/environment'; -import { createOAuthClient } from '$lib/atproto/server/oauth'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ platform }) => { - try { - const oauth = createOAuthClient(platform?.env); - return json(oauth.metadata); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - console.error('[oauth-client-metadata]', message, e); - // Surface the real cause in dev so misconfig isn't a silent 500. - // In production we still hide details, but they land in `wrangler tail`. - return json( - { error: 'oauth_client_misconfigured', message: dev ? message : 'See server logs' }, - { status: 500 } - ); - } -}; diff --git a/apps/atproto-starter/src/routes/(oauth)/oauth/callback/+server.ts b/apps/atproto-starter/src/routes/(oauth)/oauth/callback/+server.ts deleted file mode 100644 index f6a71e8..0000000 --- a/apps/atproto-starter/src/routes/(oauth)/oauth/callback/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { redirect } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/atproto/server/oauth'; -import { setSignedCookie } from '$lib/atproto/server/signed-cookie'; -import { scopes } from '$lib/atproto/settings'; -import { dev } from '$app/environment'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ url, platform, cookies }) => { - const oauth = createOAuthClient(platform?.env); - - // oauth.callback() validates the state parameter (CSRF protection) and - // exchanges the authorization code for tokens via the token endpoint. - try { - const { session } = await oauth.callback(url.searchParams); - - const cookieOpts = { - path: '/', - httpOnly: true, - secure: !dev, - sameSite: 'lax' as const, - maxAge: 60 * 60 * 24 * 180 // 180 days - }; - - setSignedCookie(cookies, 'did', session.did, cookieOpts); - setSignedCookie(cookies, 'scope', scopes.join(' '), cookieOpts); - } catch (e) { - console.error('OAuth callback failed:', e); - redirect(303, '/?error=auth_failed'); - } - - const returnTo = cookies.get('oauth_return_to'); - if (returnTo) { - cookies.delete('oauth_return_to', { path: '/' }); - const decoded = decodeURIComponent(returnTo); - if (decoded.startsWith('/') && !decoded.startsWith('//')) { - redirect(303, decoded); - } - } - - redirect(303, '/'); -}; diff --git a/apps/atproto-starter/src/routes/(oauth)/oauth/jwks.json/+server.ts b/apps/atproto-starter/src/routes/(oauth)/oauth/jwks.json/+server.ts deleted file mode 100644 index ae4dd84..0000000 --- a/apps/atproto-starter/src/routes/(oauth)/oauth/jwks.json/+server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { createOAuthClient } from '$lib/atproto/server/oauth'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ platform }) => { - const oauth = createOAuthClient(platform?.env); - return json(oauth.jwks ?? { keys: [] }); -}; diff --git a/apps/atproto-starter/src/routes/+layout.server.ts b/apps/atproto-starter/src/routes/+layout.server.ts deleted file mode 100644 index cf36194..0000000 --- a/apps/atproto-starter/src/routes/+layout.server.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { LayoutServerLoad } from './$types'; - -export const load: LayoutServerLoad = async ({ locals }) => { - return { did: locals.did }; -}; diff --git a/apps/atproto-starter/src/routes/+layout.svelte b/apps/atproto-starter/src/routes/+layout.svelte deleted file mode 100644 index 9053fd5..0000000 --- a/apps/atproto-starter/src/routes/+layout.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - - atproto-starter - - - - -{@render children()} - - diff --git a/apps/atproto-starter/src/routes/+layout.ts b/apps/atproto-starter/src/routes/+layout.ts deleted file mode 100644 index 77ab0a0..0000000 --- a/apps/atproto-starter/src/routes/+layout.ts +++ /dev/null @@ -1 +0,0 @@ -export const ssr = true; diff --git a/apps/atproto-starter/src/routes/+page.svelte b/apps/atproto-starter/src/routes/+page.svelte deleted file mode 100644 index 9c54bdd..0000000 --- a/apps/atproto-starter/src/routes/+page.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - -
-

atproto-starter

- - {#if !user.isLoggedIn} - - {:else} -
- - Signed in as {user.profile?.handle ?? user.did} - - -
- {/if} - - -
diff --git a/apps/atproto-starter/src/routes/api/cron/+server.ts b/apps/atproto-starter/src/routes/api/cron/+server.ts deleted file mode 100644 index 77ff19e..0000000 --- a/apps/atproto-starter/src/routes/api/cron/+server.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { contrail, ensureInit } from '$lib/contrail'; -import type { RequestHandler } from './$types'; - -export const POST: RequestHandler = async ({ request, platform }) => { - const secret = request.headers.get('X-Cron-Secret'); - if (secret !== platform!.env.CRON_SECRET) { - return new Response('Unauthorized', { status: 401 }); - } - - const db = platform!.env.DB; - await ensureInit(db); - await contrail.ingest({}, db); - - return new Response('OK'); -}; diff --git a/apps/atproto-starter/src/routes/xrpc/[...path]/+server.ts b/apps/atproto-starter/src/routes/xrpc/[...path]/+server.ts deleted file mode 100644 index 400502c..0000000 --- a/apps/atproto-starter/src/routes/xrpc/[...path]/+server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createHandler } from '@atmo-dev/contrail/server'; -import { contrail, ensureInit } from '$lib/contrail'; -import type { RequestHandler } from './$types'; - -const handle = createHandler(contrail); - -async function handler(request: Request, platform: App.Platform | undefined) { - const db = platform!.env.DB; - await ensureInit(db); - return handle(request, db) as Promise; -} - -export const GET: RequestHandler = async ({ request, platform }) => handler(request, platform); -export const POST: RequestHandler = async ({ request, platform }) => handler(request, platform); diff --git a/apps/atproto-starter/svelte.config.js b/apps/atproto-starter/svelte.config.js deleted file mode 100644 index eb8101d..0000000 --- a/apps/atproto-starter/svelte.config.js +++ /dev/null @@ -1,16 +0,0 @@ -import adapter from '@sveltejs/adapter-cloudflare'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; - -/** @type {import('@sveltejs/kit').Config} */ -const config = { - preprocess: vitePreprocess(), - - kit: { - adapter: adapter(), - experimental: { - remoteFunctions: true - } - } -}; - -export default config; diff --git a/apps/atproto-starter/tsconfig.json b/apps/atproto-starter/tsconfig.json deleted file mode 100644 index 46af250..0000000 --- a/apps/atproto-starter/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler", - "types": ["@cloudflare/workers-types", "node"] - }, - "exclude": ["src/lib/atproto/scripts/**"] - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in -} diff --git a/apps/atproto-starter/vite.config.ts b/apps/atproto-starter/vite.config.ts deleted file mode 100644 index 87b3f88..0000000 --- a/apps/atproto-starter/vite.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import tailwindcss from '@tailwindcss/vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; -import { DEV_PORT } from './src/lib/atproto/port'; - -export default defineConfig({ - plugins: [sveltekit(), tailwindcss()], - server: { - host: '127.0.0.1', - port: DEV_PORT, - allowedHosts: [] - } -}); diff --git a/apps/atproto-starter/wrangler.jsonc b/apps/atproto-starter/wrangler.jsonc deleted file mode 100644 index cefb33d..0000000 --- a/apps/atproto-starter/wrangler.jsonc +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "atproto-starter", - "main": ".svelte-kit/cloudflare/_worker.js", - "compatibility_date": "2025-12-25", - "compatibility_flags": ["nodejs_compat_v2"], - "assets": { - "binding": "ASSETS", - "directory": ".svelte-kit/cloudflare" - }, - "observability": { - "enabled": true - }, - "vars": { - "OAUTH_PUBLIC_URL": "REPLACE_WITH_PUBLIC_URL" - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "atproto-starter", - "database_id": "REPLACE_WITH_D1_DATABASE_ID" - } - ], - "triggers": { - "crons": ["*/1 * * * *"] - }, - "kv_namespaces": [ - { - "binding": "OAUTH_SESSIONS", - "id": "REPLACE_WITH_KV_OAUTH_SESSIONS_ID" - }, - { - "binding": "OAUTH_STATES", - "id": "REPLACE_WITH_KV_OAUTH_STATES_ID" - } - ] -} -- 2.51.2