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.
- 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 standardapp.bsky.graph.listitemwhosecreatedAtis the "added at" timestamp. - 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/api0.20.41,@atproto/oauth-client-browserlatest). - 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://<repo-did>/<collection-nsid>/<rkey>. - 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 hardcodebsky.socialas a user's PDS — resolve each user's DID. - OAuth: DPoP-bound tokens, PAR, hosted
client-metadata.json. Scopeatproto+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.mdis 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.jsANDpublic/client-metadata.json[^1d238a#57-60][^427530#55-58]; build tobuild/and deploy withwispctl ... --spa[^44ea6a#38-44]; Jest tests with fixtures insrc/__tests__/__fixtures__/[^44ea6a#28]; pure logic insrc/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/api0.13.35 +@atproto/oauth-client-browser0.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 appbetter-sqlite3,dotenvsetIntervalscheduler (no extra dep)- Node.js 20+ (arm64 macOS)
3c. Repo & remote (Tangled) #
- Remote repo: 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 (repoDidfield) viahttps://api.tangled.org/xrpc/com.atproto.repo.getRecord?repo=did:plc:stznz7qsokto2345qtdzogjb&collection=sh.tangled.repo&rkey=timer. NOTE: the plan draft's original stringdid:plc:lauzdywpxgdmxeqd4ejj44zcwas a transposition typo (ejjvs realejy) — verified 404. The scaffold defaultgit@tangled.org:psingletary.com/timerdoes 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_tangledis 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/timeris 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.mdin 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]):
public/client-metadata.json— served at/client-metadata.jsonsrc/contexts/AuthContext.js—clientMetadataobject
client_id=https://timer.psingletary.com/client-metadata.json.- Flow: handle → resolve DID (
.well-known/atproto-didorresolveHandle) → 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/.envasBLUESKY_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.describeRepoapp.bsky.graph.getBlocks[^7591e5],app.bsky.graph.getListcom.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. Ifnow >= dueAtand no reminder row → DM + insert.- Pull-based → no replay problem.
sendMessagefailure → 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, createAGENTS.mdpointing 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.listrecords named exactlyT-7/T-30/T-90/♾️, purposemodlist. [Exactly 4 exist.] - S5 Block scan + columns.
getBlocks→ drag source;getListper 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 insrc/__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) #
{
"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, andscopeinsrc/contexts/AuthContext.js'sclientMetadataobject. Both must match the domain (per slicer/sifter/altifier/verifier).
12. schema.sql (→ ~/dev/timer/bot/schema.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) #
// 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<string, number | null> = {
"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<string, number | null> = 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<void> {
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<void> {
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<Array<{ subjectDid: string; createdAt: string; uri: string }>> {
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<void> {
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<void> {
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<void> {
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/; deploywispctl 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 → CNAME3611bbb027df7d1b.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+.
.envinbot/. launchd (or pm2) for always-on + boot persistence. Logs →bot/logs/; persisttimer.db. - Rate limits: 5-min poll per user is fine; backoff on 429/5xx.
- Moderation: user curating own modlists — standard
app.bsky.graphusage, no special obligations. Don't misrepresent as official labeler action.
15. Guardrails (do not violate) #
- Never hardcode
bsky.socialas a user's PDS. Resolve each user's real PDS from their DID doc.https://public.api.bsky.appis fine for unauthenticated reads; all writes go through the user's PDS. - Resolve DIDs properly. Store
subjectas DID, not handle. - Respect consent. Bot only messages users who DM'd first;
activegates everything; honor "stop". - Never log credentials. Redact app password / refresh tokens everywhere.
- Don't write on the user's behalf except through their OAuth session. Bot never writes to user repos.
- Don't use Spaces. Keep lists public.
- Handle migration/tombstones. DID change or deleted account → clean error; bot re-verifies.
♾️(Eternity) = no reminder.- Idempotent reminders.
remindersPK prevents double DMs. - Don't force mute or block. Provide both.
- 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. - 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. - No emojis in code/docs (web_themes convention [^566038#73]); the
♾️list name is the sole sanctioned exception (explicit product decision). - Keep both OAuth metadata files in sync (
public/client-metadata.jsonandsrc/contexts/AuthContext.js); they must match the deployed domain [^1d238a#57-60][^427530#55-58].
16. Verified facts (2026-09-06) #
@atproto/apilatest: 0.20.41 (npm, 2026-08-14) [^700fde].- slicer pins
@atproto/api0.13.35 +@atproto/oauth-client-browser0.3.30 to avoid ESM issues under CRA/webpack — the source of the §17 risk [^c2950c#32]. app.bsky.graph.listitemrequiredsubject/list/createdAt[^ef7ac5#11-32]; duplicates ignored [^ed4af0].app.bsky.graph.listpurpose 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" orBad token scope[^bfe399]. chat.bsky.convo.sendMessagetext-only [^bc0f41][^bfe399]; DMs launched 2024-05-22 [^3646d4].- wisp.place static-only via
place.wisp.fs[^4327fa][^b38606]. AGENTS.mdis 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.
- Scaffold:
npx create-react-app timer-spike(react-scripts 5), thennpm install @atproto/api@0.20.41 @atproto/oauth-client-browser@latest. - Import smoke test. Import
BskyAgentandconfigureOAuth/OAuthClientin a component, mount it, and assert no import-time error (aSyntaxError: Cannot use import statement outside a moduleorModule parse failedhere is the primary failure). - Build test (production).
npm run buildmust complete with exit code 0 and emitbuild/index.html+ hashed assets. - Dev-server test.
npm startmust serve without webpack module-parse errors. - Jest test-runner test.
npm testmust pass a trivial test (react-scripts' built-in Babel transform must handle the atproto imports). - 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.
- Bundle-integrity check. Confirm the produced bundle includes atproto code (no empty chunks)
and no
@atprotoexternalized/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:
- Record the exact error text and the step that failed (F1–F7 table).
- Apply the corresponding remediation.
- Re-run the full §17.2 suite.
- 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:
testscript →craco test; add@craco/craco@^7.1.0(dev).craco.config.jsjest.configure:transformIgnorePatterns: []— transform ALL node_modules (a whitelist regex alone still left atproto dist files raw).moduleNameMapper:^multiformats/(.*)$→<rootDir>/node_modules/multiformats/dist/src/$1.js,^multiformats$→.../index.js,^await-lock$→<rootDir>/node_modules/await-lock/build/AwaitLock.js— jest 27 cannot read package.jsonexportsmaps.- override the js transform to
babel-jestwithbabel-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).
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.- 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. - 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.