···99// Signin renders the chooser: existing Atmosphere account vs create a new one.
1010// next is an optional path to redirect to after sign-in.
1111templ Signin(next string) {
1212- @layouts.Base("sign in", "Sign in with your ATProto account.") {
1212+ @layouts.Base("sign in", "Sign in with your Atmosphere (Bluesky) account.") {
1313 <div class="window">
1414 <div class="window-bar">
1515 <div class="dot dot-r"></div>
···2222 <span class="user">you</span><span class="at">{ "@" }</span><span class="path">atmo.quest</span><span class="sep">:~$</span>
2323 <span class="cmd">auth --pick</span>
2424 </div>
2525- <div class="output indent">choose how to start your quest.</div>
2625 <div class="hero-block">
2727- <span class="hero-tag">step 1 of 1 · pick a path</span>
2826 <h1>sign <span class="quest">in</span></h1>
2927 <p class="lede">
3028 atmo.quest is built on <span class="kw">ATProto</span>. Your records live in <span class="kw2">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.
···3331 <div class="auth-choices">
3432 <a href="/signin/atproto" class="auth-card">
3533 <div class="auth-card-tag">option a</div>
3636- <div class="auth-card-title">sign in with your <span class="kw2">Atmosphere</span> account</div>
3434+ <div class="auth-card-title">Sign in with your <span class="kw2">Atmosphere (Bluesky)</span> account</div>
3735 <div class="auth-card-desc">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div>
3836 <div class="auth-card-cta">▸ continue <span class="shortcut">↵</span></div>
3937 </a>
4038 <a href={ templ.SafeURL(continueLocalURL(next)) } class="auth-card">
4139 <div class="auth-card-tag">option b</div>
4242- <div class="auth-card-title">Continue without an <span class="kw2">Atmosphere</span> account</div>
4040+ <div class="auth-card-title">Continue without an <span class="kw2">Atmosphere (Bluesky)</span> account</div>
4341 <div class="auth-card-desc">use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.</div>
4442 <div class="auth-card-cta">▸ continue</div>
4543 </a>
+4-5
features/auth/pages/signin_templ.go
···5566//lint:file-ignore SA4006 This context is only used if a nested component is present.
7788-import "github.com/a-h/templ"
99-import templruntime "github.com/a-h/templ/runtime"
1010-118import (
99+ "atmoquest/features/common/layouts"
1210 "net/url"
13111414- "atmoquest/features/common/layouts"
1212+ "github.com/a-h/templ"
1313+ templruntime "github.com/a-h/templ/runtime"
1514)
16151716// Signin renders the chooser: existing Atmosphere account vs create a new one.
···6261 if templ_7745c5c3_Err != nil {
6362 return templ_7745c5c3_Err
6463 }
6565- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">auth --pick</span></div><div class=\"output indent\">choose how to start your quest.</div><div class=\"hero-block\"><span class=\"hero-tag\">step 1 of 1 · pick a path</span><h1>sign <span class=\"quest\">in</span></h1><p class=\"lede\">atmo.quest is built on <span class=\"kw\">ATProto</span>. Your records live in <span class=\"kw2\">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.</p></div><div class=\"auth-choices\"><a href=\"/signin/atproto\" class=\"auth-card\"><div class=\"auth-card-tag\">option a</div><div class=\"auth-card-title\">sign in with your <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div><div class=\"auth-card-cta\">▸ continue <span class=\"shortcut\">↵</span></div></a> <a href=\"")
6464+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</span><span class=\"path\">atmo.quest</span><span class=\"sep\">:~$</span> <span class=\"cmd\">auth --pick</span></div><div class=\"hero-block\"><span class=\"hero-tag\">step 1 of 1 · pick a path</span><h1>sign <span class=\"quest\">in</span></h1><p class=\"lede\">atmo.quest is built on <span class=\"kw\">ATProto</span>. Your records live in <span class=\"kw2\">your repo</span>, not ours. You'll sign in with the same identity you use anywhere on the open social web.</p></div><div class=\"auth-choices\"><a href=\"/signin/atproto\" class=\"auth-card\"><div class=\"auth-card-tag\">option a</div><div class=\"auth-card-title\">sign in with your <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">already have a handle like <code>you.bsky.social</code> or any ATProto PDS? Go this way.</div><div class=\"auth-card-cta\">▸ continue <span class=\"shortcut\">↵</span></div></a> <a href=\"")
6665 if templ_7745c5c3_Err != nil {
6766 return templ_7745c5c3_Err
6867 }
+66-9
features/connect/handlers.go
···1818 "strings"
1919 "time"
20202121+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
2122 "github.com/bluesky-social/indigo/atproto/syntax"
2223 "github.com/go-chi/chi/v5"
2324···430431 continue
431432 }
432433 if strings.HasPrefix(raw, "local_") {
433433- // local→local: write local_connections
434434- err := connection.WriteLocal(r.Context(), h.DB, "", identity.DID, "", raw, "")
434434+ // local→local: write local_connections, linking to either
435435+ // party's ongoing event and checking the viewer in.
436436+ eventURI := h.flushEventURI(r.Context(), raw, identity.DID)
437437+ err := connection.WriteLocal(r.Context(), h.DB, "", identity.DID, "", raw, eventURI)
435438 if err != nil {
436439 skipped++
437440 errs = append(errs, "write failed for "+raw)
438441 continue
439442 }
443443+ h.autoCheckinViewer(r.Context(), nil, identity.DID, eventURI)
440444 _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardFirstConnect)
441445 written++
442446 } else {
···446450 skipped++
447451 continue
448452 }
449449- err = connection.WriteLocal(r.Context(), h.DB, "", identity.DID, target.String(), "", "")
453453+ eventURI := h.flushEventURI(r.Context(), target.String(), identity.DID)
454454+ err = connection.WriteLocal(r.Context(), h.DB, "", identity.DID, target.String(), "", eventURI)
450455 if err != nil {
451456 skipped++
452457 continue
453458 }
454454- _ = h.Queue.EnqueueLocal(r.Context(), target, identity.DID, "")
459459+ _ = h.Queue.EnqueueLocal(r.Context(), target, identity.DID, eventURI)
460460+ h.autoCheckinViewer(r.Context(), nil, identity.DID, eventURI)
455461 _ = badge.AwardLocal(r.Context(), h.DB, identity.DID, badge.AwardFirstConnect)
456462 written++
457463 }
···479485 }
480486481487 viewerDIDStr := viewerDID.String()
482482- err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, "")
488488+ eventURI := h.flushEventURI(r.Context(), raw, viewerDIDStr)
489489+ err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, eventURI)
483490 if err != nil {
484491 slog.Warn("flush-local: write local connection", "target", raw, "err", err)
485492 skipped++
486493 errs = append(errs, "write failed for "+raw)
487494 continue
488495 }
496496+ h.autoCheckinViewer(r.Context(), viewerSess, viewerDIDStr, eventURI)
489497 _ = badge.AwardLocal(r.Context(), h.DB, viewerDIDStr, badge.AwardFirstConnect)
490498 written++
491499 continue
···500508 if target == viewerDID {
501509 continue
502510 }
511511+ // Link to the target's ongoing event (fall back to the viewer's)
512512+ // so the replayed connection records the event and checks the
513513+ // viewer in, exactly as the live /c/{did} handler would have.
514514+ eventURI := h.flushEventURI(r.Context(), target.String(), viewerDID.String())
503515 viewerPDS := h.lookupPDSForDID(r, viewerDID)
504504- if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, "") {
516516+ if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, eventURI) {
505517 skipped++
506518 continue
507519 }
508508- if _, _, err := connection.Put(r.Context(), viewerSess, h.DB, connection.Record{With: target}); err != nil {
520520+ if _, _, err := connection.Put(r.Context(), viewerSess, h.DB, connection.Record{With: target, EventURI: eventURI}); err != nil {
509521 slog.Warn("flush-local: viewer record", "target", target.String(), "err", err)
510522 skipped++
511523 errs = append(errs, "write failed for "+target.String())
512524 continue
513525 }
514514- if err := h.Queue.Enqueue(r.Context(), target, viewerDID, ""); err != nil {
526526+ if err := h.Queue.Enqueue(r.Context(), target, viewerDID, eventURI); err != nil {
515527 slog.Warn("flush-local: enqueue", "target", target.String(), "err", err)
516528 }
529529+ h.autoCheckinViewer(r.Context(), viewerSess, viewerDID.String(), eventURI)
517530 // Award first-connect badge (idempotent).
518518- if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, ""); err != nil {
531531+ if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, eventURI); err != nil {
519532 slog.Info("flush-local: first-connect badge", "err", err)
520533 }
521534 written++
···552565 return evURI
553566 }
554567 return ""
568568+}
569569+570570+// flushEventURI resolves the ongoing event tying a flushed connection to a
571571+// check-in: prefer the scanned target's current event, fall back to the
572572+// viewer's, mirroring the live /c/{did} handler. Returns "" if neither party
573573+// is at an ongoing event. targetID/viewerID may be ATProto DIDs or local_xxx
574574+// IDs (the underlying checkin lookup keys on the string id).
575575+func (h *Handlers) flushEventURI(ctx context.Context, targetID, viewerID string) string {
576576+ if ev := h.ongoingEventURI(ctx, targetID); ev != "" {
577577+ return ev
578578+ }
579579+ return h.ongoingEventURI(ctx, viewerID)
580580+}
581581+582582+// autoCheckinViewer checks the viewer into eventURI and records the
583583+// event-attendance side effects (attendee badge + event stats), mirroring the
584584+// auto-checkin the live /c/{did} handler performs so a connection replayed
585585+// through flush-local also checks the viewer in. A non-nil sess writes the
586586+// check-in through the viewer's PDS; a nil sess means a local viewer whose
587587+// check-in is written straight to the local cache keyed on viewerID. No-op
588588+// when eventURI is "".
589589+func (h *Handlers) autoCheckinViewer(ctx context.Context, sess *oauth.ClientSession, viewerID, eventURI string) {
590590+ if eventURI == "" {
591591+ return
592592+ }
593593+ if sess != nil {
594594+ if _, err := checkin.Put(ctx, sess, h.DB, eventURI, time.Time{}); err != nil {
595595+ slog.Warn("flush-local: auto-checkin", "event_uri", eventURI, "err", err)
596596+ }
597597+ if _, err := badge.Award(ctx, sess, h.DB, badge.AwardEventAttendee, eventURI); err != nil {
598598+ slog.Info("flush-local: event-attendee badge", "err", err)
599599+ }
600600+ } else {
601601+ if err := checkin.PutLocal(ctx, h.DB, viewerID, eventURI, time.Time{}); err != nil {
602602+ slog.Warn("flush-local: auto-checkin local", "event_uri", eventURI, "err", err)
603603+ }
604604+ _ = badge.AwardLocal(ctx, h.DB, viewerID, badge.AwardEventAttendee)
605605+ }
606606+ if err := event.IncrementCheckins(ctx, h.DB, eventURI); err != nil {
607607+ slog.Info("flush-local: increment checkins", "err", err)
608608+ }
609609+ if err := event.IncrementConnectors(ctx, h.DB, eventURI); err != nil {
610610+ slog.Info("flush-local: increment connectors", "err", err)
611611+ }
555612}
556613557614// lookupPDSForDID returns the PDS host for the given DID, using whatever we
+127
features/connect/handlers_test.go
···11+package connect
22+33+import (
44+ "context"
55+ "net/http"
66+ "net/http/httptest"
77+ "path/filepath"
88+ "strings"
99+ "testing"
1010+ "time"
1111+1212+ "atmoquest/config"
1313+ "atmoquest/features/auth"
1414+ "atmoquest/internal/checkin"
1515+ "atmoquest/internal/connection"
1616+ atdb "atmoquest/internal/db"
1717+ "atmoquest/internal/session"
1818+1919+ "database/sql"
2020+)
2121+2222+// newTestHandlers wires a connect.Handlers backed by a fresh in-temp-dir DB and
2323+// a session manager that only needs to read the local cookie (no OAuth).
2424+func newTestHandlers(t *testing.T) (*Handlers, *sql.DB) {
2525+ t.Helper()
2626+ dir := t.TempDir()
2727+ dsn := "file:" + filepath.Join(dir, "test.db") + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"
2828+ conn, err := atdb.Open(dsn)
2929+ if err != nil {
3030+ t.Fatalf("open db: %v", err)
3131+ }
3232+ t.Cleanup(func() { _ = conn.Close() })
3333+ if err := atdb.Migrate(conn); err != nil {
3434+ t.Fatalf("migrate: %v", err)
3535+ }
3636+3737+ mgr, err := session.New(&config.Config{
3838+ Environment: config.Dev,
3939+ SessionSecret: "0123456789abcdef0123456789abcdef",
4040+ PublicURL: "http://127.0.0.1:8080",
4141+ })
4242+ if err != nil {
4343+ t.Fatalf("session.New: %v", err)
4444+ }
4545+ authH := &auth.Handlers{DB: conn, Sessions: mgr}
4646+ return NewHandlers(conn, authH, connection.NewQueue(conn)), conn
4747+}
4848+4949+// seedOngoingEvent inserts an events row whose window contains now, plus a
5050+// check-in tying `attendeeDID` to it, so the user counts as "at the event".
5151+func seedOngoingEvent(t *testing.T, db *sql.DB, eventURI, attendeeDID string) {
5252+ t.Helper()
5353+ ctx := context.Background()
5454+ now := time.Now().UTC()
5555+ _, err := db.ExecContext(ctx, `
5656+ INSERT INTO events (uri, name, start_time, end_time)
5757+ VALUES (?, 'Test Event', ?, ?)
5858+ `, eventURI, now.Add(-time.Hour), now.Add(time.Hour))
5959+ if err != nil {
6060+ t.Fatalf("seed event: %v", err)
6161+ }
6262+ _, err = db.ExecContext(ctx, `
6363+ INSERT INTO checkins (record_uri, did, event_uri, checked_in_at)
6464+ VALUES (?, ?, ?, ?)
6565+ `, "rec://"+attendeeDID, attendeeDID, eventURI, now)
6666+ if err != nil {
6767+ t.Fatalf("seed checkin: %v", err)
6868+ }
6969+}
7070+7171+// TestConnectFlushLocal_ChecksInLocalViewerToTargetEvent reproduces the bug: a
7272+// not-logged-in user scans an ATProto user's QR while that user is checked into
7373+// an ongoing event. The target is stashed in localStorage and replayed through
7474+// /connect/flush-local after sign-in. The connection record should be created
7575+// AND the viewer should be auto-checked into the target's current event.
7676+func TestConnectFlushLocal_ChecksInLocalViewerToTargetEvent(t *testing.T) {
7777+ h, db := newTestHandlers(t)
7878+ ctx := context.Background()
7979+8080+ const (
8181+ viewerLocalID = "local_viewer1"
8282+ targetDID = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"
8383+ eventURI = "at://did:plc:org/quest.atmo.event/evt1"
8484+ )
8585+8686+ // Target must exist in users (local_connections.target_did FK) and be at
8787+ // an ongoing event.
8888+ if _, err := db.ExecContext(ctx,
8989+ `INSERT INTO users (did, handle) VALUES (?, 'target.test')`, targetDID); err != nil {
9090+ t.Fatalf("seed target user: %v", err)
9191+ }
9292+ seedOngoingEvent(t, db, eventURI, targetDID)
9393+9494+ req := httptest.NewRequest(http.MethodPost, "/connect/flush-local",
9595+ strings.NewReader(`{"targets":["`+targetDID+`"]}`))
9696+ req.Header.Set("Content-Type", "application/json")
9797+ req.AddCookie(&http.Cookie{Name: "atmoquest_local", Value: viewerLocalID})
9898+ rr := httptest.NewRecorder()
9999+100100+ h.ConnectFlushLocal(rr, req)
101101+102102+ if rr.Code != http.StatusOK {
103103+ t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
104104+ }
105105+106106+ // The viewer should now be checked into the target's event.
107107+ evURI, ok, err := checkin.CurrentForLocal(ctx, db, viewerLocalID)
108108+ if err != nil {
109109+ t.Fatalf("lookup viewer checkin: %v", err)
110110+ }
111111+ if !ok || evURI != eventURI {
112112+ t.Fatalf("viewer checkin = (%q, %v), want (%q, true)", evURI, ok, eventURI)
113113+ }
114114+115115+ // And the connection record should be linked to that event.
116116+ var connEvent string
117117+ err = db.QueryRowContext(ctx, `
118118+ SELECT event_uri FROM local_connections
119119+ WHERE viewer_local_id = ? AND target_did = ?
120120+ `, viewerLocalID, targetDID).Scan(&connEvent)
121121+ if err != nil {
122122+ t.Fatalf("query local_connection: %v", err)
123123+ }
124124+ if connEvent != eventURI {
125125+ t.Errorf("connection event_uri = %q, want %q", connEvent, eventURI)
126126+ }
127127+}
+1-1
features/index/pages/index.templ
···262262263263 <div class="hero-block">
264264 <span class="hero-tag">Find your people in the Atmosphere.</span>
265265- <h1>atmo<span class="quest">.quest</span><span class="cursor"> </span></h1>
265265+ <h1>atmo<span class="quest">.quest</span></h1>
266266 <p class="lede">
267267 An <span class="kw">event companion</span> { "for" } the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to <span class="kw2">your repo</span>, not ours.
268268 </p>
+4-4
features/index/pages/index_templ.go
···5566//lint:file-ignore SA4006 This context is only used if a nested component is present.
7788-import "github.com/a-h/templ"
99-import templruntime "github.com/a-h/templ/runtime"
1010-118import (
129 "atmoquest/features/common/layouts"
1010+1111+ "github.com/a-h/templ"
1212+ templruntime "github.com/a-h/templ/runtime"
1313)
14141515// IndexView passes session-aware values into the landing page. When a user
···693693 if templ_7745c5c3_Err != nil {
694694 return templ_7745c5c3_Err
695695 }
696696- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span><span class=\"path\">cascadiajs</span><span class=\"sep\">:~$</span> <span class=\"cmd\">whoami</span></div><div class=\"hero-block\"><span class=\"hero-tag\">Find your people in the Atmosphere.</span><h1>atmo<span class=\"quest\">.quest</span><span class=\"cursor\"> </span></h1><p class=\"lede\">An <span class=\"kw\">event companion</span> ")
696696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span><span class=\"path\">cascadiajs</span><span class=\"sep\">:~$</span> <span class=\"cmd\">whoami</span></div><div class=\"hero-block\"><span class=\"hero-tag\">Find your people in the Atmosphere.</span><h1>atmo<span class=\"quest\">.quest</span></h1><p class=\"lede\">An <span class=\"kw\">event companion</span> ")
697697 if templ_7745c5c3_Err != nil {
698698 return templ_7745c5c3_Err
699699 }