···11+# atmo.quest — Product Spec
22+33+> Working document for the atmo.quest project. Intended audience: human collaborators and the AI assistants helping them. If you are an AI assistant reading this as context, treat decisions in this doc as authoritative unless the user explicitly overrides them in conversation.
44+55+**Status:** Pre-launch, targeting CascadiaJS 2026 (June 1–2, Seattle)
66+**Domain:** atmo.quest
77+**Last updated:** May 2026
88+99+---
1010+1111+## What it is
1212+1313+A conference companion app on ATProto. You scan a QR, you take a note, you leave with a real follow-up list. Your connections live in your own ATProto repo, not in a database that will be gone next year.
1414+1515+The launch event is CascadiaJS 2026. The longer-term goal is a general-purpose tool any organizer can use for their event.
1616+1717+### One-line pitch
1818+1919+> The people you meet are yours to keep.
2020+2121+### Why it exists
2222+2323+Conferences produce stacks of business cards and zero memory of who anyone was. Closed event apps own that data and shut down. ATProto offers a path where connections, notes, and event records live in the attendee's own repo — portable, durable, theirs.
2424+2525+---
2626+2727+## Goals
2828+2929+1. **Make it easy to connect at events through ATProto.** Scanning a QR should work in under 5 seconds, with or without an existing account.
3030+2. **Remove the "you need an account first" wall.** Guest mode collects connections in local storage; account creation happens on the user's schedule, not the app's.
3131+3. **Demonstrate ATProto's portability story in a concrete way.** Records are written to user PDSes. The lesson is in the product, not the marketing copy.
3232+4. **Ship something polished for CascadiaJS.** A working, polished v1 by June 1, 2026.
3333+3434+### Non-goals (v1)
3535+3636+- Replacing Bluesky, Linkedin, or any general-purpose social app
3737+- Real-time messaging between users
3838+- Public profiles browsable outside an active event
3939+- Multi-event analytics dashboards for organizers
4040+- The "side quests published by other atproto accounts" idea from Will — interesting but explicit scope creep for v1
4141+4242+---
4343+4444+## Aesthetic direction
4545+4646+**Terminal Quest.** Catppuccin-themed terminal/dev aesthetic. JetBrains Mono for body, Instrument Serif italic for display accents. Peach (#fab387) as the accent color, lavender (#b4befe) for secondary highlights. Dark theme only for v1.
4747+4848+Reference files: `app-screens.html`, `design-3-terminal.html` in the design folder.
4949+5050+**Avoid:** generic SaaS landing-page aesthetics, purple gradients on white, Inter/Roboto/system fonts. The audience is JS developers at Cascadia; they recognize and reject generic AI design.
5151+5252+---
5353+5454+## Users
5555+5656+1. **The connected attendee** — already has a Bluesky/ATProto account. Most ATProto-curious folks.
5757+2. **The guest attendee** — does not have a Bluesky/ATProto account. Wants to scan a stranger's QR without committing to anything first.
5858+3. **The event organizer** (post-Cascadia) — wants to set up an event for their meetup/conf.
5959+4. **The admin** — Brittany + core team. Needs usage stats and operational visibility.
6060+6161+---
6262+6363+## Features
6464+6565+### Onboarding
6666+6767+#### When a user lands on atmo.quest cold
6868+6969+- See the home page (`design-3-terminal.html`)
7070+- Two CTAs: "Start scanning" and "What's ATProto?"
7171+- "What's ATProto?" routes to `/about-atproto`
7272+7373+#### When a user scans someone's QR before signing in
7474+7575+- Land on the scanned person's profile preview
7676+- Prompt: "Connect with [name]? You don't need an account yet."
7777+- If accepted: connection saved to local storage, prompted lightly to sign up "when you have time"
7878+- If user later signs up, local connections are migrated to their PDS
7979+8080+#### Sign-up flow
8181+8282+- "Do you already have an Atmosphere account?" → Login (OAuth via `@atproto/oauth-client-*`)
8383+- "Create an account" → default flow creates account on `bsky.social`
8484+- **Advanced (disclosure):** "Choose your own PDS / handle" — hidden behind a collapse for v1 to avoid edge-case support load
8585+- **Stretch (not v1):** "Log in with Mastodon via bridgy fed"
8686+8787+#### Account creation default
8888+8989+- New accounts default to `bsky.social` PDS
9090+- Default handle suggestion: from user's name, like `firstname.bsky.social`
9191+- The user can override but the defaults make signup near-frictionless
9292+9393+### Profile
9494+9595+#### Self profile
9696+9797+- Pull display name, avatar, bio from the user's `app.bsky.actor.profile`
9898+- Enrich with atmo.quest-specific fields:
9999+ - Important links (GitHub, personal site, mastodon, etc. — arbitrary pills)
100100+ - Interests (free-text tags, used for matching at events)
101101+ - Location (city/region)
102102+ - Where they work
103103+ - Best contact method
104104+ - Optional flags: `hiring` (employer), `looking` (job seeker)
105105+- These extra fields live in a `quest.atmo.profile` record in the user's repo
106106+107107+#### Other person's profile (after connecting)
108108+109109+- All public profile info
110110+- "Connected · [date]" badge
111111+- Hiring/looking badges if set
112112+- Pill links and interests
113113+- **Notes section** (private to the viewer, local storage only for v1)
114114+- **Follow-up checkbox** — flags this person for the post-conference summary
115115+116116+### QR / Connection
117117+118118+- Each logged-in user has a QR code that encodes a `bsky://` or `at://` URI pointing to their profile
119119+- Home screen shows your avatar; tap to flip to QR
120120+- Scanning a QR:
121121+ - **Both users logged in:** writes a `quest.atmo.connection` record to each user's repo
122122+ - **Scanner is a guest:** saves the connection to local storage with a "claim me later" flag
123123+ - **Scanned person is a guest:** rare edge case; same as above but reversed
124124+- Connections during an active event window (both parties checked in, scan happens within event start/end) are automatically associated with that event in the connection record
125125+- Users can connect to the same person at multiple future events; each creates a new connection record with a different event reference (or no event)
126126+127127+#### Stretch: mobile app with widget
128128+129129+- Native iOS/Android wrapper around the web app
130130+- Home screen widget showing your QR code for one-tap-from-lockscreen sharing
131131+- Not in v1 scope, but treat as plausible v1.1
132132+133133+### Events
134134+135135+#### Event creation
136136+137137+- **Hidden from public UI until after CascadiaJS** — "hey, you can use this at your events too!" launch
138138+- Required fields: name, start time, end time, expected participant count
139139+- Optional fields: geofence (lat/long + radius), location name, organizer Bluesky handle
140140+- Calculated: total possible *unique connecting pairs* = N attendees (NOT N×(N−1)/2, see Metrics section)
141141+142142+#### Event check-in
143143+144144+- Triggered manually by the user (tap "check in" on the event)
145145+- Optional soft geofence check: if user has location permission and is outside the radius, show "you don't seem to be at the event, are you sure?" prompt — never a hard block (indoor venues mess with GPS, desktop users have no GPS)
146146+- Writes a `quest.atmo.checkin` record to the user's repo, referencing the event
147147+148148+#### Event merging (post-Cascadia)
149149+150150+- Two event creators can merge their events into a single super-event
151151+- Both get the original event-starter badge plus a special "interop" badge
152152+- Connection records continue to reference the merged event
153153+154154+### Unlocks (group progress)
155155+156156+Progress metric: **unique attendees who have made at least one connection** at the event. NOT total connection count (gameable), NOT N×(N−1)/2 possible pairs (mathematically unreachable for any real event size).
157157+158158+Unlock thresholds for CascadiaJS (375-attendee estimate, ~30% adoption target = ~115 active users):
159159+160160+| Threshold | Unlock | Notes |
161161+|---|---|---|
162162+| 1 (everyone, on check-in) | Badge, mini profile, scan-to-connect, private notes, basic post-conference summary | Default |
163163+| 20 unique connectors | Leaderboard appears | Early signal, ~6% adoption |
164164+| 75 unique connectors | Interest matching surface (see attendees with overlapping interests you haven't connected with) | Mid, ~20% adoption |
165165+| 150 unique connectors | Enhanced in-app summary with stats | Stretch, ~40% adoption |
166166+167167+**Note:** These numbers should be revisited closer to the event based on actual ticket sales and known attendance. Numbers must feel reachable but not trivial.
168168+169169+**The progress bar should fill via WebSocket** so the experience feels alive when more people are joining.
170170+171171+### Badges
172172+173173+Stored as `quest.atmo.badge` records in user repo. Cosmetic, not gating.
174174+175175+- `first-connect` — your first connection ever
176176+- `total-connections-50`, `-100`, `-200`, etc.
177177+- `profile-complete` — all profile fields filled
178178+- `event-attendee` — checked in to an event
179179+- `event-starter` — created an event
180180+- `event-interop` — merged your event with another
181181+- `event-unlock-N` — earned per group-unlock tier hit (e.g. you were here when the leaderboard unlocked)
182182+183183+### Notes & Follow-ups
184184+185185+- **Notes are local storage only for v1.** Reasoning: ATProto doesn't have production-ready private records yet (see Daniel Holmgren's permissioned data work). Putting personal observations about someone in your public repo is bad. Notes live in `localStorage` keyed by `(viewer DID, target DID)`.
186186+- **Follow-up flag** on each connection — included in the post-conference summary email/export.
187187+- Future migration plan: when ATProto supports private records, offer to migrate notes to the user's repo.
188188+189189+### Post-conference summary
190190+191191+- **Everyone gets a basic summary:** list of people met, your notes, links to their profiles
192192+- **Enhanced version (at 150-unlock):** adds interest overlap analysis, "people you almost met" (shared interests, didn't connect), conference-wide stats
193193+- Delivered: in-app view + optional emailed/exported markdown
194194+195195+### About-ATProto page
196196+197197+- `/about-atproto`
198198+- Plain-language explanation of:
199199+ - What ATProto is (decentralized social protocol, your data lives in your repo)
200200+ - What a PDS is, what a handle is
201201+ - Why atmo.quest is built on it
202202+ - How to get involved (links to Bluesky, atproto.com, other atmosphere apps)
203203+- Link visible from onboarding ("What's ATProto?" CTA) and from a header link on the home page
204204+205205+### Admin interface
206206+207207+- Auth: any logged-in user with `is_admin = true` in the database (set initially via DB script)
208208+- Pages:
209209+ - Usage summary (DAU, total connections, total users, active events)
210210+ - User list (search by handle)
211211+ - Event list with per-event stats
212212+ - Connection trends over time
213213+- Initial admins: Brittany + core team
214214+215215+---
216216+217217+## Lexicons (preliminary)
218218+219219+> These are working drafts. Real lexicon definitions to be finalized before the alpha. Namespace `quest.atmo.*` (reverse-DNS of the `atmo.quest` domain).
220220+221221+### `quest.atmo.profile`
222222+223223+```
224224+{
225225+ "links": [{ "label": "GitHub", "url": "..." }, ...],
226226+ "interests": ["edge computing", "rust", ...],
227227+ "location": "Vancouver, WA",
228228+ "worksAt": "GitHub",
229229+ "contactMethod": "DM on bsky",
230230+ "hiring": false,
231231+ "looking": true,
232232+ "updatedAt": "2026-05-..."
233233+}
234234+```
235235+236236+### `quest.atmo.event`
237237+238238+```
239239+{
240240+ "name": "CascadiaJS 2026",
241241+ "startTime": "2026-06-01T08:00-08:00",
242242+ "endTime": "2026-06-02T23:00-08:00",
243243+ "expectedAttendees": 375,
244244+ "location": "Seattle, WA",
245245+ "geofence": { "lat": ..., "lng": ..., "radiusMeters": 500 },
246246+ "organizerDid": "did:plc:..."
247247+}
248248+```
249249+250250+### `quest.atmo.checkin`
251251+252252+```
253253+{
254254+ "event": "at://did:plc:.../quest.atmo.event/cascadiajs-2026",
255255+ "checkedInAt": "2026-06-01T09:42-08:00"
256256+}
257257+```
258258+259259+### `quest.atmo.connection`
260260+261261+```
262262+{
263263+ "with": "did:plc:...",
264264+ "connectedAt": "2026-06-01T10:42-08:00",
265265+ "event": "at://did:plc:.../quest.atmo.event/cascadiajs-2026" // optional
266266+}
267267+```
268268+269269+### `quest.atmo.badge`
270270+271271+```
272272+{
273273+ "type": "first-connect" | "event-attendee" | ...,
274274+ "earnedAt": "2026-06-01T10:42-08:00",
275275+ "event": "at://..." // optional, for event-specific badges
276276+}
277277+```
278278+279279+---
280280+281281+## Screens
282282+283283+Reference: `app-screens.html`
284284+285285+### Home
286286+287287+- Top bar: logo + user handle
288288+- Greeting + time
289289+- QR flip card (tap avatar to flip to QR)
290290+- Active event banner (when checked into one) — links to event screen
291291+- Recent connections grid (4-col, ~8 visible, tap to view profile)
292292+293293+### Connections (replaces "last seen" tab)
294294+295295+- Full list of all your connections (across all events + non-event)
296296+- Search by handle
297297+- Filter by event (dropdown)
298298+- Sort: most recent, alphabetical, has-follow-up-flag
299299+- Tap any connection → that person's profile screen
300300+301301+### Profile (other person)
302302+303303+- Avatar + name + handle
304304+- Connection metadata badge ("connected · Jun 1")
305305+- Hiring/looking badges
306306+- Profile detail card (bio, works at, location, contact)
307307+- Pill rows: links + interests
308308+- Notes textarea (private, local)
309309+- Follow-up checkbox
310310+311311+### Event
312312+313313+- Event title, dates, status indicator (live / upcoming / ended)
314314+- Progress bar with tick-marks for unlock thresholds
315315+- Earned/locked rewards list
316316+- Checked-in attendees grid
317317+ - Connected = color avatar w/ peach border
318318+ - Unconnected = grayscale avatar
319319+- Filter: all / mine
320320+- Live (WebSocket) indicator
321321+322322+### Bottom nav
323323+324324+- Home / Connections / Event (when applicable, otherwise Events list)
325325+326326+---
327327+328328+## Tech stack (proposed)
329329+330330+> These are starting points, not commitments. Adjust per implementation reality.
331331+332332+- **Frontend:** TBD — needs to be lightweight, mobile-first, dark-mode-only, offline-capable for the guest-mode local-storage path. Astro + a sprinkle of interactivity, or a plain React SPA, both viable.
333333+- **Auth:** `@atproto/oauth-client-browser` for browser-based OAuth, `@atproto/oauth-client-node` for any server-side flows
334334+- **Records:** Standard `@atproto/api` `Agent` for record CRUD
335335+- **Backend:** Minimal — needs to handle the WebSocket for live event progress, store admin flags, possibly cache event stats. Could be a thin Node/Hono server.
336336+- **Database:** Whatever's quickest. SQLite + Kysely (like Statusphere) is fine for v1.
337337+338338+---
339339+340340+## Marketing
341341+342342+- **May 31 pre-party:** hacker train to Seattle, socialize the app in person
343343+- **Printed postcards** with QR to atmo.quest and a "best way to connect at CascadiaJS" tagline
344344+- **ATProto PDX community sponsor slot:** mention during their stage time
345345+- **Brittany's talk** is near the end of the conference — final shoutout, possibly a live stats reveal if unlocks have happened
346346+- **Emcee/organizer shoutouts** where possible
347347+- **Bluesky posts** from `@brittany.dev` and atmosphere community accounts
348348+349349+---
350350+351351+## Open questions / decisions pending
352352+353353+- [ ] Final unlock thresholds based on actual ticket sales / attendance estimate
354354+- [ ] Mobile app stretch goal: yes for v1.1 or punt?
355355+- [ ] Connection record privacy model — currently both parties write a record referencing each other. Is the existence of a `quest.atmo.connection` referencing another DID itself sensitive? Probably not, but worth thinking about.
356356+- [ ] Confirm `quest.atmo.*` namespace is OK / not conflicting with existing lexicons
357357+- [ ] What's the rate limit / sustainability story if this goes viral mid-conference?
358358+359359+---
360360+361361+## Hard rules for AI assistants helping on this repo
362362+363363+- Notes are local storage only in v1. Do not write code that puts user notes about other people into a public PDS record.
364364+- Do not invent ATProto API behavior. Check `@atproto/api` docs, atproto.com, or the bluesky-social/atproto repo for actual specs.
365365+- Default new accounts to `bsky.social`. Custom PDS / handle is an advanced disclosure, not a top-level choice.
366366+- The aesthetic is Terminal Quest (Catppuccin + JetBrains Mono + Instrument Serif italic accents, peach as accent). Do not propose Inter, do not propose purple gradients on white.
367367+- The unlock metric is **unique attendees who have made at least one connection**. Not total connections, not pairs.
368368+- Geofencing is a soft prompt, never a hard block.
+36-4
features/auth/drain_middleware.go
···2233import (
44 "context"
55+ "database/sql"
56 "log/slog"
67 "net/http"
78 "strings"
89 "sync"
910 "time"
10111212+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1113 "github.com/bluesky-social/indigo/atproto/syntax"
12141515+ "atmoquest/internal/badge"
1616+ "atmoquest/internal/checkin"
1317 "atmoquest/internal/connection"
1818+ "atmoquest/internal/event"
1419)
15201621// DrainMiddleware returns an http.Handler middleware that automatically
···1823// request. To avoid hammering SQLite + PDS on every page load, it debounces
1924// per-DID: after a successful check it won't re-check for the same user
2025// until drainInterval has elapsed.
2121-func DrainMiddleware(authH *Handlers, queue *connection.Queue, interval time.Duration) func(http.Handler) http.Handler {
2626+func DrainMiddleware(authH *Handlers, queue *connection.Queue, db *sql.DB, interval time.Duration) func(http.Handler) http.Handler {
2227 dm := &drainState{
2328 interval: interval,
2429 last: make(map[string]time.Time),
···4146 // Extract session data now — the goroutine must NOT
4247 // use r or r.Context() because the request will be
4348 // done by the time it runs.
4444- go drainAsync(authH, queue, did, sid, dm)
4949+ go drainAsync(authH, queue, db, did, sid, dm)
4550 }
4651 }
4752 }
···7681// drainAsync runs the queue drain in a background goroutine so it doesn't
7782// block the page render. Uses a fresh context because the originating HTTP
7883// request will be done by the time this runs.
7979-func drainAsync(authH *Handlers, queue *connection.Queue, did syntax.DID, sid string, dm *drainState) {
8484+func drainAsync(authH *Handlers, queue *connection.Queue, db *sql.DB, did syntax.DID, sid string, dm *drainState) {
8085 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
8186 defer cancel()
8287···95100 return
96101 }
971029898- res, err := connection.Drain(ctx, queue, sess, slog.Default())
103103+ // Auto-check-in hook: when a drained connection references an ongoing
104104+ // event, check the user into that event so both parties end up as
105105+ // attendees regardless of who scanned whose QR.
106106+ onDrain := func(ctx context.Context, sess *oauth.ClientSession, item connection.PendingItem) {
107107+ if item.EventURI == "" {
108108+ return
109109+ }
110110+ ev, err := event.Get(ctx, db, item.EventURI)
111111+ if err != nil || !ev.IsOngoing(time.Now()) {
112112+ return
113113+ }
114114+ if _, err := checkin.Put(ctx, sess, db, item.EventURI, time.Time{}); err != nil {
115115+ slog.Warn("drain middleware: auto-checkin", "did", did.String(), "event_uri", item.EventURI, "err", err)
116116+ return
117117+ }
118118+ slog.Info("drain middleware: auto-checked in via connection", "did", did.String(), "event_uri", item.EventURI)
119119+ if _, err := badge.Award(ctx, sess, db, badge.AwardEventAttendee, item.EventURI); err != nil {
120120+ slog.Info("drain middleware: event-attendee badge", "err", err)
121121+ }
122122+ if err := event.IncrementCheckins(ctx, db, item.EventURI); err != nil {
123123+ slog.Info("drain middleware: increment checkins", "err", err)
124124+ }
125125+ if err := event.IncrementConnectors(ctx, db, item.EventURI); err != nil {
126126+ slog.Info("drain middleware: increment connectors", "err", err)
127127+ }
128128+ }
129129+130130+ res, err := connection.Drain(ctx, queue, sess, slog.Default(), onDrain)
99131 if err != nil {
100132 slog.Warn("drain middleware: drain failed", "did", did.String(), "err", err)
101133 dm.reset(did) // allow retry on next request
···1010 "net/http"
1111 "strings"
12121313+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1314 "github.com/bluesky-social/indigo/atproto/identity"
1415 "github.com/bluesky-social/indigo/atproto/syntax"
1516···119120}
120121121122// DeleteData handles POST /settings/delete — deletes all the viewer's notes
122122-// after verifying the handle confirmation field matches.
123123+// after verifying the handle confirmation field matches. If the "purge_pds"
124124+// checkbox is checked, also deletes all quest.atmo.* records from the user's
125125+// PDS and clears local cache tables.
123126func (h *Handlers) DeleteData(w http.ResponseWriter, r *http.Request) {
124124- did, _, ok := h.Auth.RequireSession(w, r)
127127+ did, sess, ok := h.Auth.RequireSession(w, r)
125128 if !ok {
126129 return
127130 }
···133136134137 handle := h.resolveHandle(r, did.String())
135138 confirm := strings.TrimSpace(r.FormValue("confirm_handle"))
139139+ purgePDS := r.FormValue("purge_pds") == "1"
136140137141 if confirm != handle {
138142 // Re-render with error.
···154158 http.Error(w, "failed to delete", http.StatusInternalServerError)
155159 return
156160 }
161161+ slog.Info("settings: deleted notes", "did", did.String(), "count", count)
157162158158- slog.Info("settings: deleted notes", "did", did.String(), "count", count)
163163+ if purgePDS {
164164+ h.purgePDSRecords(r, did, sess)
165165+ }
166166+159167 http.Redirect(w, r, "/settings?deleted=1", http.StatusSeeOther)
168168+}
169169+170170+// purgePDSRecords deletes all quest.atmo.* records from the user's PDS
171171+// and clears local cache tables. Best-effort: errors are logged but don't
172172+// block the redirect.
173173+func (h *Handlers) purgePDSRecords(r *http.Request, did syntax.DID, sess *oauth.ClientSession) {
174174+ collections := []string{
175175+ "quest.atmo.connection",
176176+ "quest.atmo.checkin",
177177+ "quest.atmo.badge",
178178+ "quest.atmo.event",
179179+ "quest.atmo.profile",
180180+ }
181181+182182+ apiClient := sess.APIClient()
183183+ for _, coll := range collections {
184184+ deleted, errors := 0, 0
185185+ cursor := ""
186186+ for {
187187+ params := map[string]any{
188188+ "repo": did.String(),
189189+ "collection": coll,
190190+ "limit": 100,
191191+ }
192192+ if cursor != "" {
193193+ params["cursor"] = cursor
194194+ }
195195+ var resp struct {
196196+ Records []struct {
197197+ URI string `json:"uri"`
198198+ } `json:"records"`
199199+ Cursor string `json:"cursor,omitempty"`
200200+ }
201201+ if err := apiClient.Get(r.Context(), syntax.NSID("com.atproto.repo.listRecords"), params, &resp); err != nil {
202202+ slog.Warn("settings purge: list", "collection", coll, "err", err)
203203+ break
204204+ }
205205+ for _, rec := range resp.Records {
206206+ rkey := rkeyFromURI(rec.URI)
207207+ if rkey == "" {
208208+ errors++
209209+ continue
210210+ }
211211+ input := map[string]any{
212212+ "repo": did.String(),
213213+ "collection": coll,
214214+ "rkey": rkey,
215215+ }
216216+ if err := apiClient.Post(r.Context(), syntax.NSID("com.atproto.repo.deleteRecord"), input, nil); err != nil {
217217+ slog.Warn("settings purge: delete", "uri", rec.URI, "err", err)
218218+ errors++
219219+ } else {
220220+ deleted++
221221+ }
222222+ }
223223+ if resp.Cursor == "" || len(resp.Records) == 0 {
224224+ break
225225+ }
226226+ cursor = resp.Cursor
227227+ }
228228+ if deleted > 0 || errors > 0 {
229229+ slog.Info("settings purge", "collection", coll, "deleted", deleted, "errors", errors)
230230+ }
231231+ }
232232+233233+ // Clear local cache tables.
234234+ _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM checkins WHERE did = ?`, did.String())
235235+ _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM pending_connections WHERE target_did = ? OR initiator_did = ?`, did.String(), did.String())
236236+ _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM earned_badges WHERE did = ?`, did.String())
237237+ _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM connection_notes WHERE target_did = ?`, did.String())
238238+ _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM oauth_sessions WHERE did = ?`, did.String())
239239+ _, _ = h.DB.ExecContext(r.Context(), `DELETE FROM users WHERE did = ? AND is_admin = 0`, did.String())
240240+241241+ slog.Info("settings purge: local cache cleared", "did", did.String())
242242+}
243243+244244+// rkeyFromURI extracts the record key (last path segment) from an at:// URI.
245245+func rkeyFromURI(uri string) string {
246246+ parts := strings.Split(uri, "/")
247247+ if len(parts) == 0 {
248248+ return ""
249249+ }
250250+ return parts[len(parts)-1]
160251}
161252162253// resolveHandle returns the user's atproto handle, falling back to a short
+6-2
features/settings/pages/settings.templ
···4040 if v.Deleted {
4141 <div class="settings-success" role="status">
4242 <span class="settings-success-icon">✓</span>
4343- all your notes and follow-up flags have been deleted.
4343+ your atmo.quest data has been deleted.
4444 </div>
4545 }
4646···101101 <div class="settings-row-icon">◇</div>
102102 <div class="settings-row-body">
103103 <div class="settings-row-title">Local notes</div>
104104- <div class="settings-row-desc">stored on this device only</div>
104104+ <div class="settings-row-desc">stored in the app</div>
105105 </div>
106106 <div class="settings-row-value">{ settingsItoa(v.NoteCount) } notes <span class="arrow">›</span></div>
107107 </div>
···203203 </div>
204204 }
205205 <form method="POST" action="/settings/delete" class="settings-delete-form" style="margin-top:8px;">
206206+ <label class="pv-followup-toggle" style="margin-bottom:8px;display:flex;align-items:center;gap:8px;cursor:pointer;">
207207+ <input type="checkbox" name="purge_pds" value="1"/>
208208+ <span class="settings-row-desc" style="margin:0;">also delete all quest.atmo.* records from my PDS</span>
209209+ </label>
206210 <input
207211 type="text"
208212 name="confirm_handle"
+4-4
features/settings/pages/settings_templ.go
···7676 return templ_7745c5c3_Err
7777 }
7878 if v.Deleted {
7979- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"settings-success\" role=\"status\"><span class=\"settings-success-icon\">✓</span> all your notes and follow-up flags have been deleted.</div>")
7979+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"settings-success\" role=\"status\"><span class=\"settings-success-icon\">✓</span> your atmo.quest data has been deleted.</div>")
8080 if templ_7745c5c3_Err != nil {
8181 return templ_7745c5c3_Err
8282 }
···168168 return templ_7745c5c3_Err
169169 }
170170 }
171171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Privacy</div><div class=\"settings-list\"><div class=\"settings-row\"><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Show me on event attendee lists</div><div class=\"settings-row-desc\">when off, you can still scan and connect</div></div><div class=\"toggle on\"></div></div><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Appear on the leaderboard</div><div class=\"settings-row-desc\">opt out anytime</div></div><div class=\"toggle on\"></div></div></div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Your data</div><div class=\"settings-list\"><a href=\"/settings/export\" class=\"settings-row\" download><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Export connections & notes</div><div class=\"settings-row-desc\">download a CSV summary</div></div><div class=\"settings-row-value\"><span class=\"arrow\">›</span></div></a><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Local notes</div><div class=\"settings-row-desc\">stored on this device only</div></div><div class=\"settings-row-value\">")
171171+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Privacy</div><div class=\"settings-list\"><div class=\"settings-row\"><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Show me on event attendee lists</div><div class=\"settings-row-desc\">when off, you can still scan and connect</div></div><div class=\"toggle on\"></div></div><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Appear on the leaderboard</div><div class=\"settings-row-desc\">opt out anytime</div></div><div class=\"toggle on\"></div></div></div></div><div class=\"settings-group\"><div class=\"settings-group-label\">Your data</div><div class=\"settings-list\"><a href=\"/settings/export\" class=\"settings-row\" download><div class=\"settings-row-icon\">◆</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Export connections & notes</div><div class=\"settings-row-desc\">download a CSV summary</div></div><div class=\"settings-row-value\"><span class=\"arrow\">›</span></div></a><div class=\"settings-row\"><div class=\"settings-row-icon\">◇</div><div class=\"settings-row-body\"><div class=\"settings-row-title\">Local notes</div><div class=\"settings-row-desc\">stored in the app</div></div><div class=\"settings-row-value\">")
172172 if templ_7745c5c3_Err != nil {
173173 return templ_7745c5c3_Err
174174 }
···261261 return templ_7745c5c3_Err
262262 }
263263 }
264264- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<form method=\"POST\" action=\"/settings/delete\" class=\"settings-delete-form\" style=\"margin-top:8px;\"><input type=\"text\" name=\"confirm_handle\" placeholder=\"")
264264+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<form method=\"POST\" action=\"/settings/delete\" class=\"settings-delete-form\" style=\"margin-top:8px;\"><label class=\"pv-followup-toggle\" style=\"margin-bottom:8px;display:flex;align-items:center;gap:8px;cursor:pointer;\"><input type=\"checkbox\" name=\"purge_pds\" value=\"1\"> <span class=\"settings-row-desc\" style=\"margin:0;\">also delete all quest.atmo.* records from my PDS</span></label> <input type=\"text\" name=\"confirm_handle\" placeholder=\"")
265265 if templ_7745c5c3_Err != nil {
266266 return templ_7745c5c3_Err
267267 }
268268 var templ_7745c5c3_Var12 string
269269 templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("type " + v.Handle + " to confirm")
270270 if templ_7745c5c3_Err != nil {
271271- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 209, Col: 53}
271271+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 213, Col: 53}
272272 }
273273 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
274274 if templ_7745c5c3_Err != nil {
+12-1
internal/connection/drain.go
···1717 Skipped int
1818}
19192020+// DrainHook is called after each successfully drained pending connection.
2121+// It gives the caller a chance to perform side-effects like auto-check-in
2222+// without coupling the connection package to the checkin/badge packages.
2323+type DrainHook func(ctx context.Context, sess *oauth.ClientSession, item PendingItem)
2424+2025// Drain flushes every pending_connection row for sess.AccountDID by calling
2126// Put for each, then deleting the row on success. Errors on individual rows
2227// are logged (if logger is non-nil) and the row is left in the queue for a
2328// future retry.
2429//
3030+// If onDrain is non-nil it is called after each successfully written +
3131+// deleted pending item, e.g. to auto-check-in the user to the event.
3232+//
2533// Returns a DrainResult summarizing the operation; never returns an error
2634// from the per-row writes — the goal is best-effort flush on login, not
2735// blocking the user's redirect to /profile.
2836//
2937// A wrapper-level error (e.g. queue lookup failure) is returned as-is.
3030-func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slog.Logger) (DrainResult, error) {
3838+func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slog.Logger, onDrain DrainHook) (DrainResult, error) {
3139 res := DrainResult{}
3240 if sess == nil {
3341 return res, errNoSession
···6472 continue
6573 }
6674 res.Written++
7575+ if onDrain != nil {
7676+ onDrain(ctx, sess, item)
7777+ }
6778 }
6879 return res, nil
6980}
+1-1
router/router.go
···57575858 // Drain pending reciprocal connections on every authenticated page load,
5959 // debounced to once per 30 seconds per user.
6060- router.Use(auth.DrainMiddleware(authH, connQueue, 30*time.Second))
6060+ router.Use(auth.DrainMiddleware(authH, connQueue, conn, 30*time.Second))
61616262 if config.Global.Environment == config.Dev {
6363 setupReload(router)