The Atmosphere, also called ATProtocol, is an open social protocol. Instead of accounts living inside one company's database, your data lives in your repo — a personal data store you can take with you.
atmo.quest writes connection, check-in, and badge records to your repo. If atmo.quest disappears tomorrow, those records still exist in your repo and can be read by any other Atmosphere app. If atmo.quest starts doing something that you don't like, you can take your data and your connections anywhere else you'd like.
You don't have to start over on a new social media app every few years now. Let's make social media social again.
ATProto is an open social protocol. Instead of accounts living inside one company's database, your records live in your repo — a personal data store you can take with you.
atmo.quest writes connection, check-in, and badge records to your PDS. If atmo.quest disappears tomorrow, those records still exist in your repo and can be read by any other ATProto-aware app.
← backread the protocol docs ↗ ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -78,7 +76,7 @@ func AboutATProto(isAuthed bool) templ.Component {
}
return nil
})
- templ_7745c5c3_Err = layouts.Base("What's the Atmosphere?", "The Atmosphere, also called ATProtocol, is an open social protocol. Your records, connections, and data are owned by you, and you decide what happens with them.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
+ templ_7745c5c3_Err = layouts.Base("What's ATProto?", "ATProto is an open social protocol — your records live in your repo, not ours.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/features/auth/e2e_test.go b/features/auth/e2e_test.go
new file mode 100644
index 0000000..1f37cae
--- /dev/null
+++ b/features/auth/e2e_test.go
@@ -0,0 +1,105 @@
+package auth
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// TestContinueWithoutAccountRedirect verifies the redirect goes to /profile
+func TestContinueWithoutAccountRedirect(t *testing.T) {
+ // Create a simple handler that simulates ContinueWithoutAccount
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Simulate setting the local cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "atmoquest_local",
+ Value: "local_test_12345",
+ Path: "/",
+ MaxAge: 365 * 24 * 60 * 60,
+ HttpOnly: false,
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ // Redirect to /profile (not /)
+ http.Redirect(w, r, "/profile", http.StatusFound)
+ })
+
+ // Create test server
+ ts := httptest.NewServer(handler)
+ defer ts.Close()
+
+ // Create client that doesn't follow redirects
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+
+ // Make request
+ resp, err := client.Get(ts.URL + "/auth/local/continue")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Check that we got a redirect to /profile
+ if resp.StatusCode != http.StatusFound {
+ t.Errorf("expected status 302, got %d", resp.StatusCode)
+ }
+ location := resp.Header.Get("Location")
+ if location != "/profile" {
+ t.Errorf("expected redirect to /profile, got %s", location)
+ }
+
+ t.Log("Redirect test passed!")
+}
+
+// TestLocalCookieAttributes verifies the cookie is set with correct attributes
+func TestLocalCookieAttributes(t *testing.T) {
+ // Create a simple handler that sets the cookie
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Simulate ContinueWithoutAccount setting the cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "atmoquest_local",
+ Value: "local_test_12345",
+ Path: "/",
+ MaxAge: 365 * 24 * 60 * 60,
+ HttpOnly: false, // NOT HttpOnly so JS can read
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ w.WriteHeader(http.StatusOK)
+ })
+
+ // Create test server
+ ts := httptest.NewServer(handler)
+ defer ts.Close()
+
+ // Make request
+ resp, err := http.Get(ts.URL + "/test")
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ // Check cookies
+ cookies := resp.Cookies()
+ found := false
+ for _, c := range cookies {
+ if c.Name == "atmoquest_local" {
+ found = true
+ if c.HttpOnly {
+ t.Error("cookie should NOT be HttpOnly")
+ }
+ if c.Value == "" {
+ t.Error("cookie value is empty")
+ }
+ t.Logf("Cookie: %s=%s, HttpOnly=%v", c.Name, c.Value, c.HttpOnly)
+ }
+ }
+ if !found {
+ t.Error("atmoquest_local cookie not found")
+ }
+
+ t.Log("Cookie attributes test passed!")
+}
diff --git a/features/auth/handlers.go b/features/auth/handlers.go
index cced8f5..84d0e11 100644
--- a/features/auth/handlers.go
+++ b/features/auth/handlers.go
@@ -15,6 +15,7 @@ import (
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
+ "github.com/google/uuid"
"atmoquest/features/auth/pages"
"atmoquest/internal/connection"
@@ -125,11 +126,11 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) {
// without requiring admin intervention.
go h.SyncPDSHistory(sessData)
- // Best-effort: flush any reciprocal connection writes queued while this
- // user was offline. Runs synchronously so the user lands on /profile with
- // their freshly-flushed connections visible. Errors here don't block the
- // redirect — Drain swallows per-row failures and leaves them in the queue
- // for the next login.
+ // Best-effort: flush any reciprocal connection writes queued while
+ // this user was offline. Runs synchronously so the user lands on
+ // /profile with their freshly-flushed connections visible. Errors here
+ // don't block the redirect — Drain swallows per-row failures and
+ // leaves them in the queue for the next login.
if h.ConnQueue != nil {
sess, err := h.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID)
if err == nil {
@@ -144,6 +145,15 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) {
}
}
+ // Check if there's a local account to link
+ // We need to do this after the cookie is set, but we can't call
+ // LinkLocalToATProto directly because it writes headers.
+ // Instead, we'll set a flag and redirect to a link endpoint.
+ if h.Sessions.GetLocal(r) != "" {
+ http.Redirect(w, r, "/auth/link", http.StatusFound)
+ return
+ }
+
http.Redirect(w, r, "/profile", http.StatusFound)
}
@@ -184,15 +194,14 @@ func (h *Handlers) OAuthJWKS(w http.ResponseWriter, _ *http.Request) {
}
}
-// recordUserLogin upserts a row in the users table for this DID and tries
+// RecordUserLogin upserts a row in the users table for this DID and tries
// to enrich it with the user's current Bluesky display name. Runs in a
// goroutine off the OAuth callback's request context — uses
// context.Background() with a short timeout so a slow PDS doesn't keep the
// goroutine alive forever. All errors are logged; nothing is returned.
//
// Handle resolution (via an atproto identity directory) is deferred until
-// the admin step; the users.Touch COALESCE preserves any previously-stored
-// handle across enrichment-less re-logins.
+// the admin step; the users.Touch COALESCEs.
func (h *Handlers) RecordUserLogin(sessData *oauth.ClientSessionData) {
if h.DB == nil || sessData == nil {
return
@@ -224,7 +233,7 @@ func (h *Handlers) RecordUserLogin(sessData *oauth.ClientSessionData) {
func sanitizeAuthError(err error) string {
s := err.Error()
if len(s) > 200 {
- s = s[:200] + "…"
+ return s[:200] + "…"
}
return s
}
@@ -248,3 +257,93 @@ func sanitizeASCode(code string) string {
}
return string(out)
}
+
+// ContinueWithoutAccount handles the "continue without atmosphere account" flow.
+// Generates a local_id server-side, sets the cookie, creates user row.
+// On subsequent visits (after sign-out), auto-recovers the previous local account
+// via the known-accounts cookie.
+func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request) {
+ // Check if already authenticated (ATProto or local)
+ if _, _, err := h.ResumeSession(r); err == nil {
+ http.Redirect(w, r, "/profile", http.StatusFound)
+ return
+ }
+
+ // Check current local cookie first.
+ existingLocalID := h.Sessions.GetLocal(r)
+ if existingLocalID != "" {
+ var exists string
+ err := h.DB.QueryRowContext(r.Context(), `
+ SELECT did FROM users WHERE did = ?
+ `, existingLocalID).Scan(&exists)
+ if err == nil {
+ http.Redirect(w, r, "/profile", http.StatusFound)
+ return
+ }
+ }
+
+ // Check known-accounts cookie for recovery.
+ known := h.Sessions.GetKnownLocals(r)
+ for _, id := range known {
+ if id == "" {
+ continue
+ }
+ var exists string
+ err := h.DB.QueryRowContext(r.Context(), `
+ SELECT did FROM users WHERE did = ?
+ `, id).Scan(&exists)
+ if err == nil {
+ // Found a valid known account — resume it.
+ slog.Info("continue without account: recovering known local account", "local_id", id)
+ if err := h.Sessions.SetLocal(w, id); err != nil {
+ slog.Error("continue without account: set local cookie", "err", err)
+ http.Error(w, "failed to set cookie", http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/profile", http.StatusFound)
+ return
+ }
+ }
+
+ // No recoverable account — create a new one.
+ localID := "local_" + uuid.New().String()
+
+ _, err := h.DB.ExecContext(r.Context(), `
+ INSERT OR IGNORE INTO users (did, handle, user_type) VALUES (?, '', 'local')
+ `, localID)
+ if err != nil {
+ slog.Warn("continue without account: create user", "err", err)
+ }
+
+ var exists string
+ err = h.DB.QueryRowContext(r.Context(), `
+ SELECT did FROM users WHERE did = ?
+ `, localID).Scan(&exists)
+ if err != nil {
+ slog.Error("continue without account: verify user", "err", err)
+ http.Error(w, "user not found after creation", http.StatusInternalServerError)
+ return
+ }
+
+ if err := h.Sessions.SetLocal(w, localID); err != nil {
+ slog.Error("continue without account: set local cookie", "err", err)
+ http.Error(w, "failed to set cookie", http.StatusInternalServerError)
+ return
+ }
+ h.Sessions.RememberLocal(w, localID)
+
+ http.Redirect(w, r, "/profile", http.StatusFound)
+}
+
+// LocalLogout clears the local session cookie and redirects home.
+// Before clearing, the local ID is saved to the known-accounts cookie so the
+// user can recover their account on the next visit.
+func (h *Handlers) LocalLogout(w http.ResponseWriter, r *http.Request) {
+ if id := h.Sessions.GetLocal(r); id != "" {
+ h.Sessions.RememberLocal(w, id)
+ }
+ h.Sessions.ClearLocal(w)
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+
diff --git a/features/auth/link.go b/features/auth/link.go
new file mode 100644
index 0000000..72b2ae7
--- /dev/null
+++ b/features/auth/link.go
@@ -0,0 +1,272 @@
+package auth
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
+
+ "atmoquest/internal/badge"
+ "atmoquest/internal/checkin"
+ "atmoquest/internal/connection"
+ "atmoquest/internal/profile"
+)
+
+// LinkLocalToATProto migrates a local account to an ATProto account.
+// Writes all local data (connections, checkins, profile, badges) to the user's PDS.
+func (h *Handlers) LinkLocalToATProto(w http.ResponseWriter, r *http.Request) {
+ localID := h.Sessions.GetLocal(r)
+ if localID == "" {
+ http.Error(w, "no local account to link", http.StatusBadRequest)
+ return
+ }
+
+ did, sess, err := h.ResumeSession(r)
+ if err != nil {
+ http.Redirect(w, r, "/signin", http.StatusFound)
+ return
+ }
+
+ // Start transaction for local table updates
+ tx, err := h.DB.BeginTx(r.Context(), nil)
+ if err != nil {
+ slog.Error("link account: begin tx", "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback()
+
+ ctx := r.Context()
+
+ // 1. Migrate connections from local_connections to PDS
+ // Only migrate where target is ATProto (target_did != '')
+ rows, err := tx.QueryContext(ctx, `
+ SELECT target_did, event_uri FROM local_connections
+ WHERE viewer_local_id = ? AND target_did != ''
+ `, localID)
+ if err == nil {
+ for rows.Next() {
+ var targetDID, eventURI string
+ if err := rows.Scan(&targetDID, &eventURI); err != nil {
+ continue
+ }
+ target, err := syntax.ParseDID(targetDID)
+ if err != nil {
+ continue
+ }
+ // Write connection to PDS
+ rec := connection.Record{
+ With: target,
+ EventURI: eventURI,
+ ConnectedAt: time.Now().UTC(),
+ }
+ if _, _, err := connection.Put(ctx, sess, rec); err != nil {
+ slog.Warn("link: migrate connection", "target", targetDID, "err", err)
+ }
+ }
+ rows.Close()
+ }
+
+ // 2. Migrate checkins to PDS
+ checkinRows, err := tx.QueryContext(ctx, `
+ SELECT event_uri, checked_in_at FROM checkins WHERE did = ?
+ `, localID)
+ if err == nil {
+ for checkinRows.Next() {
+ var eventURI string
+ var checkedInAt time.Time
+ if err := checkinRows.Scan(&eventURI, &checkedInAt); err != nil {
+ continue
+ }
+ // Write checkin to PDS
+ if _, err := checkin.Put(ctx, sess, h.DB, eventURI, checkedInAt); err != nil {
+ slog.Warn("link: migrate checkin", "event", eventURI, "err", err)
+ }
+ }
+ checkinRows.Close()
+ }
+
+ // 3. Migrate profile to PDS (quest.atmo.profile)
+ var displayName, bio, location, worksAt, contactMethod string
+ var hiring, looking, hideFromLeaderboard, hideBadges bool
+ var interestsJSON, linksJSON string
+ err = tx.QueryRowContext(ctx, `
+ SELECT display_name, bio, location, works_at, contact_method,
+ hiring, "looking", hide_from_leaderboard, hide_badges, interests, links
+ FROM users WHERE did = ?
+ `, localID).Scan(&displayName, &bio, &location, &worksAt, &contactMethod,
+ &hiring, &looking, &hideFromLeaderboard, &hideBadges, &interestsJSON, &linksJSON)
+ if err == nil {
+ // Build QuestRecord
+ rec := profile.QuestRecord{
+ Bio: bio,
+ Location: location,
+ WorksAt: worksAt,
+ ContactMethod: contactMethod,
+ UpdatedAt: time.Now().UTC(),
+ }
+ if hiring {
+ t := true
+ rec.Hiring = &t
+ }
+ if looking {
+ t := true
+ rec.Looking = &t
+ }
+ // Parse interests from JSON
+ if interestsJSON != "" {
+ var interests []string
+ if err := json.Unmarshal([]byte(interestsJSON), &interests); err == nil {
+ rec.Interests = interests
+ }
+ }
+ // Parse links from JSON
+ if linksJSON != "" {
+ var links []profile.Link
+ if err := json.Unmarshal([]byte(linksJSON), &links); err == nil {
+ rec.Links = links
+ }
+ }
+ // Write to PDS
+ if _, err := profile.PutQuest(ctx, sess, did, rec); err != nil {
+ slog.Warn("link: migrate profile", "err", err)
+ }
+ }
+
+ // 4. Migrate badges to PDS
+ badgeRows, err := tx.QueryContext(ctx, `
+ SELECT d.trigger FROM earned_badges e
+ JOIN badge_definitions d ON e.badge_id = d.id
+ WHERE e.did = ?
+ `, localID)
+ if err == nil {
+ for badgeRows.Next() {
+ var trigger string
+ if err := badgeRows.Scan(&trigger); err != nil {
+ continue
+ }
+ // Award badge to PDS
+ if _, err := badge.Award(ctx, sess, h.DB, badge.AwardType(trigger), ""); err != nil {
+ slog.Warn("link: migrate badge", "trigger", trigger, "err", err)
+ }
+ }
+ badgeRows.Close()
+ }
+
+ // 5. Update local tables: change did from localID to new DID.
+ // Note: connection_notes uses viewer_did, not did.
+ tables := []struct {
+ name string
+ col string
+ }{
+ {"checkins", "did"},
+ {"connection_notes", "viewer_did"},
+ {"earned_badges", "did"},
+ }
+ for _, t := range tables {
+ q := "UPDATE " + t.name + " SET " + t.col + " = ? WHERE " + t.col + " = ?"
+ if _, e := tx.ExecContext(ctx, q, did.String(), localID); e != nil {
+ slog.Warn("link: update table", "table", t.name, "err", e)
+ }
+ }
+
+ // 6. Update users table: delete existing ATProto user row (created by
+ // RecordUserLogin on first OAuth sign-in), then relabel the local row.
+ // We use two statements inside the same transaction so they're atomic.
+ _, _ = tx.ExecContext(ctx, `DELETE FROM users WHERE did = ?`, did.String())
+ _, err = tx.ExecContext(ctx, `
+ UPDATE users SET did = ?, user_type = 'atproto' WHERE did = ?
+ `, did.String(), localID)
+ if err != nil {
+ slog.Error("link: update users", "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ // 7. Update local_connections: change viewer_local_id to viewer_did
+ _, err = tx.ExecContext(ctx, `
+ UPDATE local_connections SET viewer_did = ?, viewer_local_id = NULL
+ WHERE viewer_local_id = ?
+ `, did.String(), localID)
+ if err != nil {
+ slog.Warn("link: update local_connections", "err", err)
+ }
+
+ // 8. Record the link in account_links table
+ _, err = tx.ExecContext(ctx, `
+ INSERT OR IGNORE INTO account_links (local_did, did) VALUES (?, ?)
+ `, localID, did.String())
+ if err != nil {
+ slog.Error("link: record link", "err", err)
+ }
+
+ // 9. Clear local cookie
+ h.Sessions.ClearLocal(w)
+
+ if err := tx.Commit(); err != nil {
+ slog.Error("link: commit", "err", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ http.Redirect(w, r, "/profile", http.StatusFound)
+}
+
+// ExportLocalData returns all local user data as JSON for manual export.
+func (h *Handlers) ExportLocalData(w http.ResponseWriter, r *http.Request) {
+ localID := h.Sessions.GetLocal(r)
+ if localID == "" {
+ http.Error(w, "no local account", http.StatusBadRequest)
+ return
+ }
+
+ data := map[string]any{}
+
+ // Get user info
+ userRow := h.DB.QueryRowContext(r.Context(),
+ "SELECT did, handle, display_name, user_type FROM users WHERE did = ?", localID)
+ var did, handle, displayName, userType string
+ if err := userRow.Scan(&did, &handle, &displayName, &userType); err == nil {
+ data["user"] = map[string]string{
+ "did": did,
+ "handle": handle,
+ "display_name": displayName,
+ "user_type": userType,
+ }
+ }
+
+ // Get connections from local_connections
+ connRows, err := h.DB.QueryContext(r.Context(), `
+ SELECT id, viewer_did, viewer_local_id, target_did, target_local_id, event_uri, connected_at
+ FROM local_connections
+ WHERE viewer_local_id = ? OR target_local_id = ?
+ `, localID, localID)
+ if err == nil {
+ defer connRows.Close()
+ conns := []map[string]any{}
+ for connRows.Next() {
+ var id int
+ var viewerDID, viewerLocalID, targetDID, targetLocalID, eventURI, connectedAt string
+ if err := connRows.Scan(&id, &viewerDID, &viewerLocalID, &targetDID, &targetLocalID, &eventURI, &connectedAt); err == nil {
+ conns = append(conns, map[string]any{
+ "id": id,
+ "viewer_did": viewerDID,
+ "viewer_local": viewerLocalID,
+ "target_did": targetDID,
+ "target_local": targetLocalID,
+ "event_uri": eventURI,
+ "connected_at": connectedAt,
+ })
+ }
+ }
+ data["connections"] = conns
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Disposition", "attachment; filename=\"atmoquest-export.json\"")
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ slog.Error("export: encode json", "err", err)
+ }
+}
diff --git a/features/auth/pages/signin.templ b/features/auth/pages/signin.templ
index d02b9e5..e99f09d 100644
--- a/features/auth/pages/signin.templ
+++ b/features/auth/pages/signin.templ
@@ -40,6 +40,9 @@ templ Signin() {
atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.
atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.