atjamalpha

Test plan

What needs to work, and what’s actually been confirmed.

atjam is in alpha. The lexicon is shipped end-to-end, most read paths render, and a few write paths exist (round creation, signup, submission). This page is the manual run-through that confirms each piece works.

The list is hand-maintained. Status markers go stale fast — they reflect the most recent walk-through, not the live deployment. Re-verify before any production-affecting decision.

Status legend

  • ✓ Verified end-to-end at least once on production.
  • ⏳ Implemented; not yet manually verified.
  • ✗ Not yet implemented in the web app.

Site infrastructure

  • ✓ /healthz.txt returns 200 with body “ok”.
  • ✓ /oauth-client-metadata.json returns 200 with the five-collection scope including at.atjam.invitation.
  • ✓ Static pages render: /lexicon, /privacy, /terms, /test-plan, /robots.txt, /favicon.svg.
  • ⏳ 404 catch-all ($.tsx) renders on unknown paths.
  • ✓ NSID DNS: dig +short TXT _lexicon.atjam.at returns the authority DID.
  • ✓ Each of the five at.atjam.* schemas resolves at at://<authority-did>/com.atproto.lexicon.schema/<nsid>.
  • ⏳ Security headers present on document responses: Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy.

Authentication

  • ⏳ Sign in: type a handle in the AuthBar → redirect to the handle’s PDS → consent → return to /oauth/callback → land on home with handle in header.
  • ⏳ Resume: reload after sign-in; header still shows handle without re-auth.
  • ⏳ Sign out: header reverts to handle input; atjam:did cleared from localStorage.
  • ⏳ Sign in with a did:web account, not just did:plc.
  • ⏳ Loopback dev: signing in at http://127.0.0.1:3000 uses the inline client_id pattern; http://localhost:3000 should fail per OAuth spec.
  • ⏳ Stale session: invalidate the OAuth session on the PDS side; next page load gracefully returns to signed-out state rather than crashing.

Organizer flows

Today the only organizer write path is /new — create a round, optionally creating a new jam in the same submit.

  • ⏳ One-shot: create a brand-new jam and its first round in one submit. Verify both records on the organizer’s PDS; verify redirect lands on the new round’s detail page.
  • ⏳ Attach to existing: when the organizer already has jams, the form defaults to “attach to existing”; the round attaches to the selected jam.
  • ⏳ Validation: empty assignment, missing deadline, and empty accepted-types are each blocked with an inline error.
  • ⏳ Accepted submission types: comma-separated NSIDs are parsed and trimmed; the cleaned array is what lands in the record.
  • ⏳ Default joinMode: rounds created via /new are written without a joinMode field; readers resolve to “open”.
  • ✗ Choose joinMode (hosted or network) at round-creation time.
  • ✗ Choose networkGate (signup vs contributed) at round-creation time.
  • ✗ Add a signup-deadline milestone (currently only submission-deadline is exposed).
  • ✗ Write an at.atjam.invitation record (no “invite a DID” form yet).
  • ✗ Edit a round’s milestones after creation.
  • ✗ Delete a round you’ve created.

Participant flows

  • ⏳ Sign up to an open round: button on the round page writes at.atjam.signup on the participant’s PDS, strong-reffing the round.
  • ⏳ Idempotent signup: clicking sign-up twice — does the UI block the second attempt, or does it write a duplicate record?
  • ⏳ Submit work: paste an AT URI for a record on another app (e.g. a Bluesky post). Writes at.atjam.submission with the payload strong-ref.
  • ⏳ Submit a record whose $type isn’t in the round’s acceptedSubmissionTypes: the form should warn or block (the lexicon’s SHOULD).
  • ⏳ Submit to a round after its submission-deadline: the write succeeds at the protocol level but the UI should signal “late.”
  • ✗ See invitations targeting your DID (no inbox view).
  • ✗ Sign up to a hosted round with a valid invitation strong-ref attached to your signup.
  • ✗ In a network round, invite someone else from your own UI.

Read flows

  • ⏳ Home feed (/): rounds from VITE_KNOWN_ORGANIZERS plus the signed-in user’s own appear, sorted as expected.
  • ⏳ Jam detail (/jam/$did/$rkey): renders jam name, description, and the list of its rounds.
  • ⏳ Round detail (/round/$did/$rkey): renders assignment, milestones, signups, submissions; signup button for non-organizers; submit form for signed-up participants.
  • ⏳ OG/Twitter meta: view-source a jam page and a round page; confirm og:title and og:description include the actual jam and round names.
  • ⏳ Round with no signups: shows an empty-state, not a crash.
  • ⏳ Round with no submissions: shows an empty-state, not a crash.
  • ⏳ Round whose owning PDS is offline / unreachable: page degrades gracefully with an error, not a blank screen.

Round state derivation

deriveState() turns milestones into a state string for the badge. Today /new only sets submission-deadline, so all UI-created rounds follow the two-state path (open → closed). The three-state path requires hand-crafted records until the UI exposes signup-deadline.

  • ⏳ No milestones at all → “open” forever.
  • ⏳ Submission-deadline in future → badge shows “open” (emerald).
  • ⏳ Submission-deadline in past → badge shows “closed” (stone).
  • ✗ Both signup-deadline + submission-deadline, between them → badge shows “in-progress” (amber).
  • ✗ Only signup-deadline (no submission-deadline), after it → “in-progress” indefinitely.

Invitation validation (consumer-side)

The lexicon defines who counts as a valid inviter; readers MUST validate the relationship. None of this is enforced in the web app yet — these tests target the validation logic when it lands.

  • ✗ Hosted round + signup without an invitation strong-ref → reader treats as invalid.
  • ✗ Hosted round + signup with an invitation whose creator DID matches the jam organizer → valid.
  • ✗ Hosted round + signup with an invitation from a DID that is NOT the jam organizer → invalid.
  • ✗ Invitation invitee DID does not match the signup’s creator DID → invalid (impostor signup).
  • ✗ Network round + invitation chain: organizer invites A; A invites B; B’s signup is valid.
  • ✗ Network round + forged chain: someone fakes an invitation pretending to be from a participant who hasn’t actually signed up → invalid.
  • ✗ Network round with networkGate: "contributed": inviter has signed up but NOT submitted → their invitations are invalid.
  • ✗ Network round with networkGate: "contributed": inviter has both signed up AND submitted → their invitations are valid.

Smoke tests after every deploy

The minimum to confirm a deploy didn’t break anything:

  • curl -I https://atjam.at/healthz.txt → 200.
  • curl -I https://atjam.at/oauth-client-metadata.json → 200.
  • curl -s https://atjam.at/oauth-client-metadata.json | grep at.atjam.invitation → one match.
  • curl -s https://atjam.at/ | head -c 200 → SSR’d HTML with the homepage hero.
  • flyctl status → machine in started state.

What this page isn’t

Not automated tests. There’s no test runner that ticks these off — each marker is the result of a human walk-through. Building an actual integration suite is its own piece of work, and the lexicon shape needs to stabilize first. Until then, this is the checklist.