simocracy-v2 · template-event-image-url

Create template + event with custom image URL

@daviddao

Tried to drive the full New-Event flow end-to-end on prod — author an interview template from inside the dialog, then create a gathering with a pasted custom building-image URL. The very first write fails: POST /api/records returns 500 expected non-null body source for every collection. Nothing reaches the PDS; the UI shows no error.

Date: 2026-04-22 · fail


Headline result

500
/api/records POST
0
Records written
0
Toasts shown on error
Auth + read flow

Blocked. OAuth login against the climateai.org PDS succeeds, the events gallery loads, the Create-Event dialog renders correctly, and the nested Author-Template dialog opens and accepts input. As soon as either Save action runs, the internal POST /api/records handler returns {"error":"expected non-null body source"} with 500. Because the client handler catches the error and relies on a sonner toast that never mounts, the user sees nothing — the button just does nothing. Both downstream steps (attaching a template, creating a gathering with a custom buildingImageUrl) are therefore unreachable.

Walkthrough

Full clip: sign-in through the dialog interactions and the silent Save failure.

00Prod landing — https://www.simocracy.org, signed out.
01Navbar login popover, handle satyam2.climateai.org entered.
02PDS OAuth sign-in page on climateai.org — identifier pre-filled.
03ATProto authorize screen — granting the simocracy.org client access to the account.
04Bounced back to simocracy.org signed in — avatar and MY SIMS nav item render.
05/events gallery — three existing gatherings visible; the Create an Event CTA is live.
06GatheringFormDialog open — event name, type, sim size, building-image presets, the or custom URL input, and Suggested Interview Templates with the + Add Template / ✎ Author new buttons.
07State right after clicking ✎ Author new the first time (via agent-browser's default click): both dialogs dismiss. Reproducible only via that path — see Findings.
09When the button is clicked via the plain DOM click(), the TemplateAuthorDialog stacks cleanly on top of the outer form, as intended.
10Template fully filled — name QA Template — QA-3754, description, one open question, one yes/no question. Ready to save.
12After the Save click: /events still shows the same three gatherings. Nothing new was created, which matches the empty listRecords on the PDS.

Findings

Blocker — POST /api/records returns 500 "expected non-null body source"

Every write through /api/records fails the same way, regardless of collection (org.simocracy.interviewTemplate, org.simocracy.gathering, with or without $type set by the client). The handler in app/api/records/route.ts successfully reads the incoming JSON (validation errors surface with the right status) — the failure happens inside the ATProto agent's outbound call. Reads via com.atproto.repo.listRecords against the same PDS succeed, so the session/OAuth state is not the root cause.

Sample reproduction from the authenticated browser session:

await fetch('/api/records', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    collection: 'org.simocracy.interviewTemplate',
    record: {
      name: 'probe',
      questions: [{ id: 'q1', type: 'open', prompt: 'test' }],
      createdAt: new Date().toISOString(),
    },
  }),
});

// → 500
// → { "error": "expected non-null body source" }

Same body shape for org.simocracy.gathering and for a record with an explicit $type field — identical response. A different write route (/api/stars) responds with business-logic 403 against the same session, so this is specific to /api/records, not a global write outage.

Blocker — Save Template silently no-ops in the UI

When the 500 comes back, TemplateAuthorDialog.handleSave catches the error and calls toast.error(err.message). No toast ever renders in this session — document.querySelectorAll('li[data-sonner-toast]').length stays at 0 after the click, and no element with [data-sonner-toaster] exists in the DOM. The user's only feedback is "nothing happened": the dialog keeps showing the filled-in form, no error, no spinner, no retry hint. This mis-classifies a server outage as a dead button.

Warning — Author-new first click on a focused element dismisses the outer dialog (agent-browser interaction, not a product bug)

Using agent-browser click @ref on the ✎ Author new button inside the open GatheringFormDialog closes both dialogs with no replacement. Calling button.click() directly via eval works as expected and the TemplateAuthorDialog opens on top. GatheringFormDialog's onInteractOutside guard is correct in source, and only activates after showTemplateAuthor flips to true — the dismissal appears to be a Radix focus-outside signal fired by playwright's click path in the moment before state flips. Not a product bug — flagged only because anyone scripting this flow with a similar tool will hit the same surprise.

What worked

ATProto OAuth against a non-bsky.social PDS (climateai.org) was clean: handle → redirect → password → authorize → bounce back. The events gallery renders; the Create Event dialog is complete and usable; the form controls are correctly wired (building-image presets, custom URL input, treasury, mechanism picker); the nested TemplateAuthorDialog, once open, accepts open and yes/no questions, shows the q-xxxxxxxx id, and switches the prompt placeholder to "Statement the responder agrees or disagrees with" for yes/no. Read paths (com.atproto.repo.listRecords directly against the PDS) work and confirm the repo is empty.

XRPC evidence

Resolve the handle, follow the DID doc to the PDS, list both collections. Both return empty — the E2E run wrote nothing.

$ curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=satyam2.climateai.org"
{"did":"did:plc:cpoagodpqrgs4t7thi5z37uf"}

$ curl -s https://plc.directory/did:plc:cpoagodpqrgs4t7thi5z37uf | jq .service
[
  {
    "id": "#atproto_pds",
    "type": "AtprotoPersonalDataServer",
    "serviceEndpoint": "https://climateai.org"
  }
]

$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords\
?repo=did:plc:cpoagodpqrgs4t7thi5z37uf\
&collection=org.simocracy.interviewTemplate&limit=5"
{"records":[]}

$ curl -s "https://climateai.org/xrpc/com.atproto.repo.listRecords\
?repo=did:plc:cpoagodpqrgs4t7thi5z37uf\
&collection=org.simocracy.gathering&limit=5"
{"records":[]}

Reproducing

Targethttps://www.simocracy.org Test handlesatyam2.climateai.org · DID did:plc:cpoagodpqrgs4t7thi5z37uf · PDS climateai.org Date2026-04-22 Steps 1. Open /, click Sign in → enter handle → complete OAuth on climateai.org.
2. Navigate to /events, click Create an Event.
3. Click ✎ Author new under Suggested Interview Templates (via plain DOM .click() — the agent-browser ref-click dismisses).
4. Fill name + description + one open question + one yes/no question, click Save template.
5. Observe: no toast; watch network → POST /api/records 500 expected non-null body source.
Videowalkthrough.webm (~5 MB) Screenshotsassets/00…12-*.png Reproducer probefetch('/api/records', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ collection:'org.simocracy.interviewTemplate', record:{ name:'probe', questions:[{id:'q1',type:'open',prompt:'x'}], createdAt:new Date().toISOString() } }) })

Suggested next step for the eng side: the error string expected non-null body source is a Node undici message emitted when the fetch client is asked to send a body source that has already been consumed or was never set. Most likely somewhere between app/api/records/route.ts reading request.json() and the @atproto/oauth-client-node agent re-issuing a request with a null body — plausibly a serverless cold-path that loses the agent's signed-fetch handler after redeploy dpl_J42DGkfeqmCy1uqHSx1gQ8BnwvTp. A one-line fix candidate is to log the caught exception server-side and to surface a non-generic error for /api/records specifically; independently, the client should fall back to an inline error banner when sonner hasn't mounted.