# PLAN-timer.md — Timer: Time-Boxed Moderation Lists + DM Reminder Bot (atproto) > **Verified as of:** 2026-09-06. Web app uses **Create React App (react-scripts 5, repo convention)** > with **latest `@atproto/*`** (0.20.41). Known build risk + verification plan in §17. ## 1. Mission Build "timer": a two-part atproto product. 1. **Management web app** (static SPA, hosted on wisp.place at `https://timer.psingletary.com`) that lets a user OAuth-login, scan their **existing blocks**, and drag-and-drop accounts into one of **four moderation lists**: `T-7`, `T-30`, `T-90`, `♾️` (Eternity). Each membership is a standard `app.bsky.graph.listitem` whose `createdAt` is the "added at" timestamp. 2. **Reminder bot** (always-on process on the **Mac Studio 2022**, local, NOT wisp.place). Users opt in by DMing the bot first (first DM = consent). The bot reads each opted-in user's four lists, computes which listitems passed their window (`createdAt` + duration), and DMs a removal nudge. The bot only reminds — it never writes to a user's repo. **Success =** block → sort → be reminded, end-to-end; web app from the wisp URL; bot running locally on the Mac Studio. **Locked decisions (do not revisit):** - User chooses **mute vs block** per list in the UI (not hardcoded). - Bot DM setting = **"anyone"** (users need not follow it). - **Spaces skipped** (private lists can't enforce block/mute). Lists stay public. - List display names exactly **`T-7`, `T-30`, `T-90`, `♾️`** — a contract (§5/§7). - **Bot runs on the Mac Studio 2022** (arm64, Node 20+) as a local process. - Repo hosted on **Tangled**; agent creates SSH keypair (§3c). - **Web app build tool = Create React App (react-scripts 5)**, matching slicer/sifter/altifier/ verifier. - **Latest packages** (`@atproto/api` 0.20.41, `@atproto/oauth-client-browser` latest). - The combination of CRA + latest atproto carries a **known build risk**; §17 defines the verification spike, failure modes, and remediation. Do not proceed to §9 without first passing §17. ## 2. Context (atproto + repo conventions) - **Record**: JSON doc in a user's repo, typed by Lexicon NSID, addressed by AT-URI `at:////`. - **rkey / TID**: record key. Listitems use TID keys with `createdAt`; AppView ignores duplicate listitems (same subject + list) [^ed4af0] — always fresh TID. - **Collections**: `app.bsky.graph.list`, `listitem`, `block`, `listblock` — all **public**. **Mutes private** (client pref); **blocks public** [^7963c3][^6573a8]. - **PDS**: user's repo lives here; all writes via `com.atproto.repo.*`. Never hardcode `bsky.social` as a user's PDS — resolve each user's DID. - **OAuth**: DPoP-bound tokens, PAR, hosted `client-metadata.json`. Scope `atproto` + `transition:chat.bsky` [^fbc40b][^40152f]. - **App passwords**: must have "Allow access to your direct messages" checked, else `InvalidToken: Bad token scope` [^bfe399]. - **DMs**: `chat.bsky.*`, text-only [^bfe399]. - **wisp.place**: static-only. No server runtime, no scheduler [^4327fa][^b38606]. - **Repo conventions (from web_themes):** `AGENTS.md` is the primary agent-instructions file; **no emojis in code/docs** [^566038#72-73]. The `♾️` list name is an explicit product decision and stays, but no emojis appear anywhere else in code. - **App conventions (from slicer/sifter/altifier/verifier):** Create React App (react-scripts 5) [^c2950c#32][^44ea6a#26]; OAuth metadata duplicated in `src/contexts/AuthContext.js` AND `public/client-metadata.json` [^1d238a#57-60][^427530#55-58]; build to `build/` and deploy with `wispctl ... --spa` [^44ea6a#38-44]; Jest tests with fixtures in `src/__tests__/__fixtures__/` [^44ea6a#28]; pure logic in `src/lib/` [^44ea6a#29]. ## 3. Stack, Dependencies & Repo ### 3a. Web app (wisp.place, static SPA — CRA) | Package | Version | Notes | |---|---|---| | `react-scripts` | **^5.0.1** | CRA, per slicer/sifter [^c2950c#32][^44ea6a#26] | | `@atproto/api` | **latest (0.20.41)** [^700fde] | client — **carries the §17 risk** | | `@atproto/oauth-client-browser` | latest | OAuth flow — **carries the §17 risk** | | `react` / `react-dom` | latest LTS for CRA 5 | UI | > Note: slicer pins `@atproto/api` 0.13.35 + `@atproto/oauth-client-browser` 0.3.30 "to avoid ESM > issues" with CRA [^c2950c#32]. We intentionally use latest; therefore §17's verification spike > is **mandatory** before any build work. ### 3b. Reminder bot (Mac Studio 2022, local) - `@atproto/api` (latest, 0.20.41) — same version as web app - `better-sqlite3`, `dotenv` - `setInterval` scheduler (no extra dep) - Node.js 20+ (arm64 macOS) ### 3c. Repo & remote (Tangled) - Remote repo: [tangled.org/psingletary.com/timer](https://tangled.org/psingletary.com/timer) **CREATED via web UI 2026-09-06** (empty repo; description "create moderation lists to temporarily mute people, then be remined to take them off"). Namespace owner: `did:plc:stznz7qsokto2345qtdzogjb` (same account as slicer/verifier/_shared). - SSH remote: `git@tangled.org:did:plc:lauzdywpxgdmxeqd4ejy44zc` — **CONFIRMED 2026-09-06** (bare-DID form; ls-remote OK, first push done). Sourced from the owner's sh.tangled.repo record (`repoDid` field) via `https://api.tangled.org/xrpc/com.atproto.repo.getRecord?repo=did:plc:stznz7qsokto2345qtdzogjb&collection=sh.tangled.repo&rkey=timer`. NOTE: the plan draft's original string `did:plc:lauzdywpxgdmxeqd4ejj44zc` was a transposition typo (`ejj` vs real `ejy`) — verified 404. The scaffold default `git@tangled.org:psingletary.com/timer` does NOT resolve; do not use the human-path form. - SSH keypair: plan draft said "agent generates fresh ed25519 keypair, adds public key to Tangled account (approved)". **SUPERSEDED** — the shared `~/.ssh/id_ed25519_tangled` is already registered on the owner account and authorizes pushes (verified 2026-09-06: first push to this repo OK). One shared key for all Tangled repos is the house convention (slicer/verifier/_shared). Working dir `~/dev/timer` is repo root. - Structure: ``` ~/dev/timer/ AGENTS.md # primary agent-instructions file src/ contexts/AuthContext.js # OAuth clientMetadata object (must match domain) lib/ # pure logic: scan.js, lists.js, schedule.js __tests__/__fixtures__/ # Jest fixtures App.js / components/ # UI public/ client-metadata.json # served at /client-metadata.json (must match AuthContext) index.html bot/ scheduler.ts schema.sql .env.example package.json build/ # gitignored; produced by npm run build, deployed via wispctl ``` ### 3d. Plan intake - Agent picks up tasks from `~/dev/_shared/docs/plans/PLAN-*.md` (this doc). - Concrete files are embedded inline (§11–§13) and referenced at their paths; `AGENTS.md` in the repo points the agent back to this plan. - **Gate:** complete §17 (build-risk spike) successfully before starting §9 implementation. ## 4. Identity & Auth ### 4a. Web app — OAuth (two-place metadata) - **Two files must stay in sync and match the deployed domain** (per all 4 SPA repos [^1d238a#57-60]): 1. `public/client-metadata.json` — served at `/client-metadata.json` 2. `src/contexts/AuthContext.js` — `clientMetadata` object - `client_id` = `https://timer.psingletary.com/client-metadata.json`. - Flow: handle → resolve DID (`.well-known/atproto-did` or `resolveHandle`) → discover PDS → authorize → DPoP-bound session; persist for refresh. ### 4b. Bot — app password with DM access - Bot account + app password with **"Allow access to your direct messages"** checked; store in `bot/.env` as `BLUESKY_APP_PASSWORD`. Without it → `Bad token scope` [^bfe399]. - Bot inbound DM = **"anyone"**. Bot reads public lists (no auth); only DMs use the app password. ## 5. Data Model Standard `app.bsky.graph.*` records; no custom Lexicons; lists public. ### 5a. The four lists — `app.bsky.graph.list` (purpose `modlist`) | Display name | Duration | Due reminder? | |---|---|---| | `T-7` | 7 days | yes | | `T-30` | 30 days | yes | | `T-90` | 90 days | yes | | `♾️` | eternity | **no** | ### 5b. Membership — `app.bsky.graph.listitem` [^ef7ac5#11-32] Required `subject`(did), `list`(at-uri), `createdAt`(datetime). `createdAt` = now on add = timer. ### 5c. Input — existing blocks (`app.bsky.graph.block`) Read via authenticated `app.bsky.graph.getBlocks` (paginated, `ProfileView[]`) [^7591e5]. `ProfileView.did` is the drag-source. ## 6. API Surface ### Web app (user's PDS via OAuth session) - `com.atproto.identity.resolveHandle`, `com.atproto.repo.describeRepo` - `app.bsky.graph.getBlocks` [^7591e5], `app.bsky.graph.getList` - `com.atproto.repo.createRecord` (list + listitem), `com.atproto.repo.deleteRecord` (listitem) - `app.bsky.graph.muteActorList` (mute list, private) [^3f3b94] - `com.atproto.repo.createRecord` `{collection:"app.bsky.graph.listblock"}` (block list, public) [^7963c3] ### Bot - `app.bsky.graph.getLists` / `getList` (public) - `chat.bsky.convo.listConvos` / `getMessages` (opt-in detect) - `chat.bsky.convo.sendMessage` (reminder) [^bc0f41] ## 7. Sync Strategy - **No firehose/Jetstream.** Poll each opted-in user's four public lists every 5 minutes. - `dueAt = createdAt + duration`; `♾️` → null → skip. If `now >= dueAt` and no reminder row → DM + insert. - Pull-based → no replay problem. `sendMessage` failure → mark user inactive. Backoff on 429/5xx. ## 8. Architecture ``` ┌──────── wisp.place (static SPA) — timer.psingletary.com ─────────┐ │ CRA (react-scripts 5) + React + @atproto/api 0.20.41 + │ │ oauth-client-browser (latest) │ │ 1 OAuth login 2 getBlocks → drag source 3 4 columns (T-7/ │ │ T-30/T-90/♾️) 4 drag-drop → createRecord listitem │ │ 5 per-list Mute|Block toggle │ └───────────────────────────────────────────────────────────────────┘ │ writes (user OAuth session) ▼ public repo + AppView (network enforcement) ▲ public reads (listitems) │ ┌──────── Mac Studio 2022 — local always-on bot ────────────────────┐ │ DM poll loop (opt-in detect → users table) │ │ Scheduler (5 min): getLists → dueAt from createdAt → sendMessage│ │ → dedupe in reminders │ │ SQLite: users(did,convo_id,active) · reminders(user,listitem) │ └───────────────────────────────────────────────────────────────────┘ ``` ## 9. Implementation Steps (acceptance criteria in brackets) - **S0 Build-risk spike (from §17).** Scaffold CRA, install latest atproto, run the §17 verification tests. [All §17 pass criteria green, or documented remediation applied.] - **S1 Repo + AGENTS.md.** Init git in `~/dev/timer`, add Tangled SSH remote, create `AGENTS.md` pointing at this plan. [Repo reachable on Tangled; AGENTS.md present.] - **S2 CRA scaffold.** `npx create-react-app .` (react-scripts 5); add `@atproto/api` (latest, 0.20.41) + `@atproto/oauth-client-browser` (latest). [Build succeeds per §17.] - **S3 OAuth (two-place metadata).** Fill `public/client-metadata.json` + `src/contexts/ AuthContext.js`; handle→DID→PDS; auth-code + DPoP. [Session persists; both files match domain.] - **S4 Ensure 4 lists.** describeRepo; create missing `app.bsky.graph.list` records named exactly `T-7`/`T-30`/`T-90`/`♾️`, purpose `modlist`. [Exactly 4 exist.] - **S5 Block scan + columns.** `getBlocks` → drag source; `getList` per column. [Drag persists listitem with createdAt set.] - **S6 Mute/Block toggle per list.** muteActorList vs listblock. [Either applies; AppView reflects.] - **S7 Manual removal.** deleteRecord listitem. [Column updates persist.] - **S8 Bot DM poll + opt-in.** Poll listConvos/getMessages; first inbound DM → store `(did, convo_id, active=1)` + onboarding reply ("reply 'stop' to opt out"). [Register once; no dupes.] - **S9 Scheduler + reminders.** Every 5 min per active user; compute dueAt; send DM; insert row. [A T-7 listitem 7+ days old triggers exactly one DM.] - **S10 Opt-out/error handling.** "stop" → active=0; sendMessage failure → inactive. [Opted-out users not polled/messaged; failures don't crash.] - **S11 wisp.place deploy.** `npm run build` → `build/`; `wispctl deploy psingletary.com --path ./build --site timer --spa --yes`. [Site loads; OAuth works in prod.] - **S12 Mac Studio runtime.** Install bot deps; `.env`; launchd (or pm2) always-on; logs → `bot/logs/`. [Bot stays alive; DB persisted.] - **S13 Tests.** Jest for `src/lib/` logic (deadline math, list-name→duration map). [Jest green.] ## 10. Testing & Validation - Jest via `react-scripts test`; fixtures in `src/__tests__/__fixtures__/` [^44ea6a#28]. - Dev against a throwaway Bluesky account or dev PDS; don't mutate a real account. - **Lexicon validation:** after createRecord, re-fetch, assert `subject`/`list`/`createdAt`. - **Integration:** listitem appears in getList; mute vs block behaves; bot sends exactly one DM per due listitem (run scheduler twice, count rows). - **Auth:** DM-scoped app password; sendMessage must not throw `Bad token scope`. - **OAuth:** both metadata files reachable and matching the deployed domain. - **Build-risk validation:** the §17 spike covers the CRA + latest-atproto compatibility tests. ## 11. client-metadata.json (→ `~/dev/timer/public/client-metadata.json`) ```json { "client_id": "https://timer.psingletary.com/client-metadata.json", "client_name": "timer", "client_uri": "https://timer.psingletary.com/", "logo_uri": "https://timer.psingletary.com/logo.png", "tos_uri": "https://timer.psingletary.com/terms", "policy_uri": "https://timer.psingletary.com/privacy", "redirect_uris": ["https://timer.psingletary.com/oauth/callback"], "scope": "atproto transition:chat.bsky", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "application_type": "web", "dpop_bound_access_tokens": true, "token_endpoint_auth_method": "none", "contacts": ["burner@psingletary.com"] } ``` > Mirror the same `client_id`, `redirect_uris`, and `scope` in `src/contexts/AuthContext.js`'s > `clientMetadata` object. Both must match the domain (per slicer/sifter/altifier/verifier). ## 12. schema.sql (→ `~/dev/timer/bot/schema.sql`) ```sql PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS users ( did TEXT PRIMARY KEY, handle TEXT, convo_id TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, last_polled_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), updated_at TEXT ); CREATE TABLE IF NOT EXISTS reminders ( user_did TEXT NOT NULL, list_uri TEXT NOT NULL, listitem_uri TEXT NOT NULL, subject_did TEXT NOT NULL, due_at TEXT NOT NULL, sent_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), PRIMARY KEY (user_did, listitem_uri), FOREIGN KEY (user_did) REFERENCES users(did) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, last_polled_at); CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(user_did); CREATE VIEW IF NOT EXISTS v_due_reminders AS SELECT u.did AS user_did, u.convo_id AS convo_id, r.list_uri AS list_uri, r.listitem_uri AS listitem_uri, r.subject_did AS subject_did, r.due_at AS due_at FROM users u JOIN reminders r ON r.user_did = u.did WHERE u.active = 1 AND NOT EXISTS ( SELECT 1 FROM reminders r2 WHERE r2.user_did = r.user_did AND r2.listitem_uri = r.listitem_uri AND r2.sent_at IS NOT NULL ); ``` ## 13. scheduler.ts (→ `~/dev/timer/bot/scheduler.ts`) ```typescript // timer reminder bot scheduler — Mac Studio 2022 local process. // @atproto/api latest (0.20.41 as of 2026-09-06). import { BskyAgent } from "@atproto/api"; import Database from "better-sqlite3"; import "dotenv/config"; const BLUESKY_HANDLE = process.env.BLUESKY_HANDLE!; const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD!; // DM-scoped const DATABASE_PATH = process.env.DATABASE_PATH ?? "timer.db"; const POLL_INTERVAL_MS = 5 * 60 * 1000; const DURATIONS: Record = { "T-7": 7 * 24 * 60 * 60 * 1000, "T-30": 30 * 24 * 60 * 60 * 1000, "T-90": 90 * 24 * 60 * 60 * 1000, "♾️": null, }; const listDurationByUri: Map = new Map(); const db = new Database(DATABASE_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); const agent = new BskyAgent({ service: "https://bsky.social" }); async function login(): Promise { await agent.login({ identifier: BLUESKY_HANDLE, password: BLUESKY_APP_PASSWORD }); console.log(`[login] bot logged in as ${BLUESKY_HANDLE}`); } async function cacheUserListDurations(did: string): Promise { const publicAgent = new BskyAgent({ service: "https://public.api.bsky.app" }); let cursor: string | undefined; do { const res = await publicAgent.app.bsky.graph.getLists({ actor: did, cursor, limit: 50 }); for (const list of res.data.lists) { const duration = DURATIONS[list.name]; if (duration !== undefined) listDurationByUri.set(list.uri, duration); } cursor = res.data.cursor; } while (cursor); } async function listItemsFor( did: string, listUri: string ): Promise> { const publicAgent = new BskyAgent({ service: "https://public.api.bsky.app" }); const out: Array<{ subjectDid: string; createdAt: string; uri: string }> = []; let cursor: string | undefined; do { const res = await publicAgent.app.bsky.graph.getList({ list: listUri, cursor, limit: 100 }); for (const item of res.data.items) { out.push({ subjectDid: item.subject.did, createdAt: item.createdAt, uri: item.uri }); } cursor = res.data.cursor; } while (cursor); return out; } // FINDING (2026-09-07, verified against the @atproto/api 0.20.41 runtime // lexicon): app.bsky.graph.getList returns items as listItemView { uri, // subject } ONLY — there is NO createdAt on the view (required: // ['uri','subject']; properties uri+subject only). The code above reads // `item.createdAt`, which is therefore undefined → new Date(undefined) = // NaN → `now < dueAtMs` is always false → every item looks due → // sendMessage fires immediately, and new Date(NaN).toISOString() throws // RangeError on the later INSERT (outside its try) → the dedupe row never // lands → repeat DMs. The scheduler must source each listitem's createdAt // from the listitem RECORD (com.atproto.repo.getRecord, collection // app.bsky.graph.listitem, rkey from item.uri) or decode the TID // timestamp. OWNER DECISION REQUIRED before S9 implementation; the code // in this section is illustrative until then. async function pollUser(did: string, convoId: string): Promise { const listsRes = await agent.app.bsky.graph.getLists({ actor: did, limit: 100 }); const now = Date.now(); for (const list of listsRes.data.lists) { const duration = listDurationByUri.get(list.uri) ?? DURATIONS[list.name] ?? null; if (duration === null) continue; const items = await listItemsFor(did, list.uri); for (const item of items) { const addedMs = new Date(item.createdAt).getTime(); const dueAtMs = addedMs + duration; if (now < dueAtMs) continue; const already = db .prepare("SELECT 1 FROM reminders WHERE user_did = ? AND listitem_uri = ?") .get(did, item.uri); if (already) continue; const dueIso = new Date(dueAtMs).toISOString(); const daysLabel = msToDays(duration); try { await agent.chat.bsky.convo.sendMessage({ convoId, message: { text: `Timer due: @${item.subjectDid} was added to the ${daysLabel} list on ${item.createdAt.slice(0, 10)}. Visit https://timer.psingletary.com to remove them.`, }, }); } catch (err: any) { if (err?.status === 400 || err?.status === 403) { db.prepare("UPDATE users SET active = 0 WHERE did = ?").run(did); console.warn(`[poll] ${did}: DM failed (${err.status}), marking inactive`); return; } console.error(`[poll] ${did}: sendMessage failed:`, err.message); continue; } db.prepare( `INSERT OR IGNORE INTO reminders (user_did, list_uri, listitem_uri, subject_did, due_at) VALUES (?, ?, ?, ?, ?)` ).run(did, list.uri, item.uri, item.subjectDid, dueIso); console.log(`[poll] ${did}: reminded about ${item.subjectDid} (${daysLabel})`); } } db.prepare("UPDATE users SET last_polled_at = ? WHERE did = ?").run( new Date().toISOString(), did ); } async function tick(): Promise { const users = db .prepare("SELECT did, convo_id FROM users WHERE active = 1") .all() as Array<{ did: string; convo_id: string }>; for (const u of users) { try { await cacheUserListDurations(u.did); await pollUser(u.did, u.convo_id); } catch (err: any) { console.error(`[tick] ${u.did}: ${err.message}`); } } } async function main(): Promise { await login(); console.log(`[main] scheduler started, polling every ${POLL_INTERVAL_MS / 1000}s`); await tick(); setInterval(() => tick().catch(console.error), POLL_INTERVAL_MS); } main().catch((err) => { console.error("fatal:", err); process.exit(1); }); function msToDays(ms: number): string { const days = Math.round(ms / (24 * 60 * 60 * 1000)); return `${days}-day`; } ``` ## 14. Deployment & Operations - **Web app:** `npm run build` → `build/`; deploy `wispctl deploy --path ./build --site timer --spa --yes --db ~/.config/wispctl/state.sqlite psingletary.com`. **DEPLOYED 2026-09-07.** wisp.place CONFIGURED (verified): DNS timer.psingletary.com → CNAME `3611bbb027df7d1b.dns.wisp.place` (A 150.136.127.67, wisp edge); HTTPS serves 404 = edge up, no content deployed yet (first content lands at S11). **PITFALL (live 2026-09-07): wispctl deploy does NOT bind an existing custom domain — the owner must log into wisp.place and set the site to the domain; until then the custom domain 404s while sites.wisp.place serves fine.** No server secrets. - **Bot:** Mac Studio 2022. Node 20+. `.env` in `bot/`. launchd (or pm2) for always-on + boot persistence. Logs → `bot/logs/`; persist `timer.db`. - **Rate limits:** 5-min poll per user is fine; backoff on 429/5xx. - **Moderation:** user curating own modlists — standard `app.bsky.graph` usage, no special obligations. Don't misrepresent as official labeler action. ## 15. Guardrails (do not violate) 1. **Never hardcode `bsky.social` as a user's PDS.** Resolve each user's real PDS from their DID doc. `https://public.api.bsky.app` is fine for unauthenticated reads; all writes go through the user's PDS. 2. **Resolve DIDs properly.** Store `subject` as DID, not handle. 3. **Respect consent.** Bot only messages users who DM'd first; `active` gates everything; honor "stop". 4. **Never log credentials.** Redact app password / refresh tokens everywhere. 5. **Don't write on the user's behalf except through their OAuth session.** Bot never writes to user repos. 6. **Don't use Spaces.** Keep lists public. 7. **Handle migration/tombstones.** DID change or deleted account → clean error; bot re-verifies. 8. **`♾️` (Eternity) = no reminder.** 9. **Idempotent reminders.** `reminders` PK prevents double DMs. 10. **Don't force mute or block.** Provide both. 11. **Exact list names are a contract** (`T-7`/`T-30`/`T-90`/`♾️`); web app must create them exactly or the scheduler's duration map won't match. 12. **Use latest `@atproto/*` (0.20.41) under CRA/react-scripts 5.** This combo has a documented ESM-build risk (slicer pins to avoid it [^c2950c#32]). **Do not silently pin** — if it breaks, follow §17 remediation or escalate. This is a user-locked decision; do not change versions without approval. 13. **No emojis in code/docs** (web_themes convention [^566038#73]); the `♾️` list name is the sole sanctioned exception (explicit product decision). 14. **Keep both OAuth metadata files in sync** (`public/client-metadata.json` and `src/contexts/AuthContext.js`); they must match the deployed domain [^1d238a#57-60][^427530#55-58]. ## 16. Verified facts (2026-09-06) - `@atproto/api` latest: **0.20.41** (npm, 2026-08-14) [^700fde]. - slicer pins `@atproto/api` 0.13.35 + `@atproto/oauth-client-browser` 0.3.30 **to avoid ESM issues** under CRA/webpack — the source of the §17 risk [^c2950c#32]. - `app.bsky.graph.listitem` required `subject`/`list`/`createdAt` [^ef7ac5#11-32]; duplicates ignored [^ed4af0]. - `app.bsky.graph.list` purpose values: `modlist`/`curatelist`/`referencelist` [^1ac21e][^9653f0]. - Blocks public; `getBlocks` → `ProfileView[]` [^7591e5][^7963c3]. Mutes private; `muteActorList` [^6573a8][^3f3b94]. - DM OAuth scope `transition:chat.bsky` [^fbc40b]; app password needs "Allow access to your DMs" or `Bad token scope` [^bfe399]. - `chat.bsky.convo.sendMessage` text-only [^bc0f41][^bfe399]; DMs launched 2024-05-22 [^3646d4]. - wisp.place static-only via `place.wisp.fs` [^4327fa][^b38606]. - `AGENTS.md` is the primary agent-instructions file; zero-emojis convention [^566038#72-73]. ## 17. Build Risk: CRA (react-scripts 5) + latest @atproto 0.20.41 (MANDATORY SPIKE) ### 17.1 Risk statement **Risk:** The latest `@atproto/*` packages are shipped as ESM with modern JS, and CRA's webpack 5 / Babel toolchain is not fully ESM-compatible for these packages. This is a *known, real* failure class — slicer's repo pins `@atproto/api` 0.13.35 + `@atproto/oauth-client-browser` 0.3.30 specifically "to avoid ESM issues" [^c2950c#32]. We are deliberately using latest, so we must prove it builds before committing to it. **Why it matters for this project:** the entire web app — OAuth login, block scan, drag-and-drop writes — depends on `@atproto/api` and `@atproto/oauth-client-browser` bundling successfully. If the build breaks, nothing ships, so this is gated at **S0, before any feature work**. **Likelihood / impact:** Moderate (recent atproto versions have largely added CJS output, but the risk is real and version-specific). High impact if it hits — blocks all web-app delivery. ### 17.2 Verification spike — what to build and the tests to run Build a minimal throwaway CRA scaffold and run the full set of tests below. Do this in a scratch dir (or a `spike/` branch) — not in the real app. 1. **Scaffold:** `npx create-react-app timer-spike` (react-scripts 5), then `npm install @atproto/api@0.20.41 @atproto/oauth-client-browser@latest`. 2. **Import smoke test.** Import `BskyAgent` and `configureOAuth`/`OAuthClient` in a component, mount it, and assert no import-time error (a `SyntaxError: Cannot use import statement outside a module` or `Module parse failed` here is the primary failure). 3. **Build test (production).** `npm run build` must complete with exit code 0 and emit `build/index.html` + hashed assets. 4. **Dev-server test.** `npm start` must serve without webpack module-parse errors. 5. **Jest test-runner test.** `npm test` must pass a trivial test (react-scripts' built-in Babel transform must handle the atproto imports). 6. **Runtime OAuth test.** Exercise a real (throwaway-account) OAuth round trip in the spike to confirm the DPoP/PAR flow runs in-browser, not just at compile time. **[OWNER AMENDMENT 2026-09-07 (approved): test 6 is POST-DEPLOY.** The metadata's client_id/redirect is fixed to https://timer.psingletary.com, which does not exist before S11, and §17.3 offers no remediation for an unrunnable test (F1–F7 are build/runtime-code failures; test 6 is none of them). S0 therefore clears on tests 1–5 + 7 per §9's "[pass criteria green, or documented remediation applied]"; test 6 is tracked as a post-deploy validation (S11 checklist) using a throwaway account. This amendment is a plan decision — it does not reopen §1 locked decisions and does not change any other §17 gate.] **RESOLVED 2026-09-07:** post-deploy round trip DONE — owner logged in live (psingletary.selfhosted.social); session persisted, board loaded, writes succeeded. Throwaway account not needed; the live flow was verified end-to-end. 7. **Bundle-integrity check.** Confirm the produced bundle includes atproto code (no empty chunks) and no `@atproto` externalized/unresolved module warnings. **Pass criteria:** all 7 pass → proceed to §9. Any fail → see §17.3. ### 17.3 Failure modes, diagnostics, and remediation | # | Symptom (build/log output) | Root cause | Diagnosis | Remediation | |---|---|---|---|---| | F1 | `SyntaxError: Cannot use import statement outside a module` at runtime, or `Module parse failed: Unexpected token` in webpack | atproto ships ESM-only output that webpack 5 treats as a pre-ESM module | Confirm by inspecting `node_modules/@atproto/api/package.json` → `"type"` and `"main"`/`"module"`; reproduce in the spike | **Preferred:** upgrade to CRA 5 with a webpack 5 `resolve.alias`/`fullySpecified` fix, or add `vite`-style CJS fallback. If 0.20.x ships CJS too, try `npm i @atproto/api@0.20.41` with `"resolve": {"fullySpecified": false}` in a CRACO override. **Fallback (only with owner approval):** pin to slicer's proven 0.13.35/0.3.30 [^c2950c#32]. | | F2 | `Can't resolve '@atproto/oauth-client-browser'` or missing exports | subpath / ESM export-map mismatch (`exports` field) between the package and webpack's resolver | Check `exports` map in that package's `package.json`; confirm import path matches a declared export | Align the import to the package's real export name; ensure CRA is not resolving to a broken `browser` field. | | F3 | Blank page / `Uncaught TypeError: Class extends value undefined` in browser | top-level `await` or circular ESM import that CRA's Babel pass fails to transform | Open devtools console; grep the built bundle for the atproto chunk; check for `top-level-await` in source | If caused by **top-level await**, switch to a CRA config that disables `topLevelAwait` or wrap initialization in an `async` bootstrap (load `OAuthClient` only after `DOMContentLoaded`/in a lazy chunk). | | F4 | Build succeeds but OAuth silently fails / `client_id` fetch 404 | `public/client-metadata.json` not copied, or OAuth expects it at a different path | `curl` the metadata path locally; verify `public/` contents are copied to `build/` | Ensure metadata lives in `public/` so CRA copies it verbatim; keep `AuthContext.js` and the file in sync (§4a). | | F5 | `npm test` fails with `Cannot use import statement outside a module` in Jest | Jest (CJS) cannot transform the ESM atproto package | Run Jest with `transformIgnorePatterns` un-commented to include `@atproto`; confirm via `jest --showConfig` | Add `"transformIgnorePatterns": ["node_modules/(?!(@atproto)/)"]` to the CRA Jest config (via `react-scripts test` override or CRACO). | | F6 | `TypeError: Cannot read properties of undefined (reading 'parse')` / crypto errors at runtime | atproto uses WebCrypto APIs not polyfilled in the test/dev environment | Reproduce in a real browser vs node; check `isSecureContext` (OAuth/DPoP requires `https` or `localhost`) | Test OAuth from `https://localhost` or the deployed domain; never from plain `http://` (DPoP requires a secure context). | | F7 | Large bundle / slow build warnings (not fatal) | importing the full atproto API surface | Webpack warning output | Code-split `@atproto` into a lazy-loaded chunk; tree-shake by importing only used methods. Non-blocking. | ### 17.4 Reporting If any test in §17.2 fails, the agent must: 1. Record the exact error text and the step that failed (F1–F7 table). 2. Apply the corresponding remediation. 3. Re-run the full §17.2 suite. 4. Only if the preferred remediation does not resolve it, report back with the failure detail and the pinned-version fallback **for owner decision** — never silently change versions (guardrail 12). ### 17.5 Spike results — RUN 2026-09-07 (scratch dir /tmp/timer-spike) Setup: hand-written package.json (react-scripts 5.0.1, react 18.2.0, @atproto/api 0.20.41, @atproto/oauth-client-browser 0.5.4 latest; CRA's generator is broken on this machine under npm 11.19 allow-scripts — known pitfall, see plan-intake skill). Node 26.8.1, npm 11.19.0. | Test (§17.2) | Result | Notes | |---|---|---| | 1 Scaffold + install | PASS | npm install clean (benign core-js install-scripts warnings only) | | 2 Import smoke | PASS | Component importing `BskyAgent` + `@atproto/oauth-client-browser` mounts and asserts `import-ok` in Jest; webpack compiles the graph | | 3 Prod build | PASS | exit 0, "Compiled successfully", build/index.html + hashed main.f39ba698.js (211 kB gzip) | | 4 Dev server | PASS | "Compiled successfully!", HTTP 200 bundle.js (5.1 MB), atproto code present, no module-parse errors | | 5 Jest trivial test importing atproto | PASS (after remediation) | see stack below | | 6 Runtime OAuth round trip | NOT RUN — blocked | needs throwaway-account creds + metadata hosted at https://timer.psingletary.com/client-metadata.json (domain up, metadata not deployed). Not a code failure; re-run post-S11 or when owner supplies a throwaway account | | 7 Bundle integrity | PASS | atproto code in bundle (5660 refs), no empty chunks, no unresolved/externalized warnings | **Verdict: build risk (F1–F4, F7) is CLEAR at latest versions — webpack 5 bundles @atproto 0.20.41 + oauth-client-browser 0.5.4 with zero config.** The ESM risk that made slicer pin versions does not materialize in the bundler. The failures are confined to Jest (CJS, jest 27 under CRA 5). **Jest remediation stack actually required (test 5), in order:** 1. `test` script → `craco test`; add `@craco/craco@^7.1.0` (dev). 2. `craco.config.js` jest.configure: - `transformIgnorePatterns: []` — transform ALL node_modules (a whitelist regex alone still left atproto dist files raw). - `moduleNameMapper`: `^multiformats/(.*)$` → `/node_modules/multiformats/dist/src/$1.js`, `^multiformats$` → `.../index.js`, `^await-lock$` → `/node_modules/await-lock/build/AwaitLock.js` — jest 27 cannot read package.json `exports` maps. - override the js transform to `babel-jest` with `babel-preset-react-app` + `@babel/plugin-transform-class-static-block` (preset-react-app does not enable class-static-block transform; @atproto 0.20 dist uses static class blocks). 3. `src/setupTests.js`: `require("@testing-library/jest-dom")` + polyfills: TextEncoder/TextDecoder (util), crypto (node webcrypto), TransformStream/ ReadableStream/WritableStream (stream/web), BroadcastChannel (worker_threads). CRA auto-loads setupTests.js; jest 27's jsdom env lacks all of these and atproto references them at import time. 4. Test runs need `--forceExit` (BroadcastChannel polyfill keeps an open handle). jsdom also lacks navigator.locks — oauth-client-browser emits a benign console.warn and falls back. 5. Test files that render JSX need `import React from "react"` (classic runtime under preset-react-app in tests). **Impact on S13:** the plan's Jest tests target pure `src/lib/` logic (deadline math, list-name→duration map) which does NOT import @atproto — those pass under default CRA Jest with no shims. The shim stack is only needed if a test must import @atproto directly. Copy the spike's craco.config.js + setupTests.js into the app (S2) only if/when such a test is written; do not add CRACO to the app preemptively. **Test 6 disposition:** POST-DEPLOY by owner amendment (2026-09-07, see §17.2 note). S0 clears on tests 1–5 + 7. Re-run test 6 as an S11 post-deploy checklist item with a throwaway account.