···88 "database/sql"
99 "encoding/json"
1010 "errors"
1111+ "fmt"
1112 "io"
1213 "log/slog"
1314 "net/http"
···21222223 "atmoquest/features/auth/pages"
2324 "atmoquest/internal/connection"
2525+ "atmoquest/internal/oauthclient"
2426 "atmoquest/internal/profile"
2527 "atmoquest/internal/session"
2628 "atmoquest/internal/users"
···9799 }
98100}
99101100100-// OAuthLogin starts the OAuth flow: takes a handle / DID / PDS URL, calls
101101-// indigo's StartAuthFlow, redirects to the AS.
102102+// OAuthLogin starts the OAuth flow with the minimal scope set. Takes a
103103+// handle / DID / PDS URL, resolves identity, sends PAR with DefaultScopes,
104104+// and redirects to the AS.
102105func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) {
103106 if err := r.ParseForm(); err != nil {
104107 http.Error(w, "invalid form", http.StatusBadRequest)
···112115 return
113116 }
114117115115- redirectURL, err := h.OAuth.StartAuthFlow(r.Context(), identifier)
118118+ redirectURL, err := h.StartMinimalAuthFlow(r.Context(), identifier)
116119 if err != nil {
117117- // Indigo already emits a slog.Warn with the full AS response body
118118- // from parseAuthErrorReason — check the line above this one in the
119119- // log for the AS's error_description. We log identifier + client_id
120120- // + callback_url here so the two log entries are easy to correlate.
121121- slog.Warn("oauth start failed",
120120+ slog.Warn("oauth login failed",
122121 "identifier", identifier,
123122 "client_id", h.OAuth.Config.ClientID,
124123 "callback_url", h.OAuth.Config.CallbackURL,
125125- "scopes", h.OAuth.Config.Scopes,
124124+ "scopes", oauthclient.DefaultScopes,
126125 "err", err,
127126 )
128127 w.Header().Set("Content-Type", "text/html; charset=utf-8")
···130129 _ = pages.SigninATProto("couldn't start sign-in: "+sanitizeAuthError(err)).Render(r.Context(), w)
131130 return
132131 }
133133- slog.Info("oauth start ok", "identifier", identifier, "redirect", redirectURL)
132132+ slog.Info("oauth login ok", "identifier", identifier, "redirect", redirectURL)
133133+ http.Redirect(w, r, redirectURL, http.StatusFound)
134134+}
135135+136136+// StartMinimalAuthFlow replicates indigo's StartAuthFlow logic but sends
137137+// only the DefaultScopes (without repo:app.bsky.actor.profile). The client
138138+// config's FullScopes is still used for client metadata so the AS knows the
139139+// client may request the broader set on upgrade.
140140+func (h *Handlers) StartMinimalAuthFlow(ctx context.Context, identifier string) (string, error) {
141141+ var authserverURL string
142142+ var accountDID syntax.DID
143143+144144+ if strings.HasPrefix(identifier, "https://") {
145145+ authserverURL = identifier
146146+ identifier = ""
147147+ } else {
148148+ atid, err := syntax.ParseAtIdentifier(identifier)
149149+ if err != nil {
150150+ return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err)
151151+ }
152152+ ident, err := h.OAuth.Dir.Lookup(ctx, atid)
153153+ if err != nil {
154154+ return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err)
155155+ }
156156+ accountDID = ident.DID
157157+ host := ident.PDSEndpoint()
158158+ if host == "" {
159159+ return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
160160+ }
161161+ authserverURL, err = h.OAuth.Resolver.ResolveAuthServerURL(ctx, host)
162162+ if err != nil {
163163+ return "", fmt.Errorf("resolving auth server: %w", err)
164164+ }
165165+ }
166166+167167+ authserverMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(ctx, authserverURL)
168168+ if err != nil {
169169+ return "", fmt.Errorf("fetching auth server metadata: %w", err)
170170+ }
171171+172172+ info, err := h.OAuth.SendAuthRequest(ctx, authserverMeta, oauthclient.DefaultScopes, identifier)
173173+ if err != nil {
174174+ return "", fmt.Errorf("auth request failed: %w", err)
175175+ }
176176+177177+ if accountDID != "" {
178178+ info.AccountDID = &accountDID
179179+ }
180180+181181+ if err := h.OAuth.Store.SaveAuthRequestInfo(ctx, *info); err != nil {
182182+ return "", fmt.Errorf("persist auth request: %w", err)
183183+ }
184184+185185+ params := url.Values{}
186186+ params.Set("client_id", h.OAuth.Config.ClientID)
187187+ params.Set("request_uri", info.RequestURI)
188188+ return authserverMeta.AuthorizationEndpoint + "?" + params.Encode(), nil
189189+}
190190+191191+// OAuthUpgrade starts an OAuth flow with the full scope set so the user
192192+// can grant repo:app.bsky.actor.profile access to edit display name/avatar.
193193+// The existing session remains valid until the callback completes.
194194+func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) {
195195+ did, sess, err := h.ResumeSession(r)
196196+ if err != nil {
197197+ http.Redirect(w, r, "/signin", http.StatusFound)
198198+ return
199199+ }
200200+201201+ if oauthclient.HasBskyProfileScope(sess) {
202202+ http.Redirect(w, r, "/profile/edit", http.StatusFound)
203203+ return
204204+ }
205205+206206+ http.SetCookie(w, &http.Cookie{
207207+ Name: "upgrade_pending",
208208+ Value: sess.Data.SessionID,
209209+ Path: "/",
210210+ HttpOnly: true,
211211+ SameSite: http.SameSiteLaxMode,
212212+ Secure: r.TLS != nil,
213213+ MaxAge: 600,
214214+ })
215215+216216+ pdsURL := sess.Data.HostURL
217217+ if pdsURL == "" {
218218+ slog.Error("oauth upgrade: no host URL", "did", did.String())
219219+ http.Error(w, "no PDS host URL available", http.StatusInternalServerError)
220220+ return
221221+ }
222222+223223+ authserverURL, err := h.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL)
224224+ if err != nil {
225225+ slog.Error("oauth upgrade: resolve auth server URL", "did", did.String(), "pds", pdsURL, "err", err)
226226+ http.Error(w, "couldn't resolve authorization server", http.StatusInternalServerError)
227227+ return
228228+ }
229229+230230+ authMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL)
231231+ if err != nil {
232232+ slog.Error("oauth upgrade: resolve auth server metadata", "did", did.String(), "err", err)
233233+ http.Error(w, "couldn't resolve authorization server metadata", http.StatusInternalServerError)
234234+ return
235235+ }
236236+237237+ info, err := h.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String())
238238+ if err != nil {
239239+ slog.Error("oauth upgrade: send auth request", "did", did.String(), "err", err)
240240+ http.Error(w, "couldn't start upgrade flow", http.StatusInternalServerError)
241241+ return
242242+ }
243243+244244+ info.AccountDID = &did
245245+246246+ if err := h.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil {
247247+ slog.Error("oauth upgrade: save auth request info", "did", did.String(), "err", err)
248248+ http.Error(w, "couldn't persist auth request", http.StatusInternalServerError)
249249+ return
250250+ }
251251+252252+ params := url.Values{}
253253+ params.Set("client_id", h.OAuth.Config.ClientID)
254254+ params.Set("request_uri", info.RequestURI)
255255+ redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode()
256256+257257+ slog.Info("oauth upgrade started", "did", did.String())
134258 http.Redirect(w, r, redirectURL, http.StatusFound)
135259}
136260···154278 if err := h.Sessions.Set(w, r, sessData.AccountDID.String(), sessData.SessionID); err != nil {
155279 slog.Error("set session cookie", "err", err)
156280 http.Error(w, "internal error", http.StatusInternalServerError)
281281+ return
282282+ }
283283+284284+ // Check for upgrade flow — clean up old session and redirect to profile
285285+ // editor so the user can immediately use the newly-granted scope.
286286+ if upgradeCookie, err := r.Cookie("upgrade_pending"); err == nil && upgradeCookie.Value != "" {
287287+ oldSid := upgradeCookie.Value
288288+ http.SetCookie(w, &http.Cookie{
289289+ Name: "upgrade_pending",
290290+ Value: "",
291291+ Path: "/",
292292+ MaxAge: -1,
293293+ })
294294+ slog.Info("oauth upgrade complete", "did", sessData.AccountDID.String())
295295+ if err := h.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, oldSid); err != nil {
296296+ slog.Warn("oauth upgrade: delete old session", "did", sessData.AccountDID.String(), "old_sid", oldSid, "err", err)
297297+ }
298298+ http.Redirect(w, r, "/profile/edit", http.StatusFound)
157299 return
158300 }
159301
+8-7
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+811import (
99- "atmoquest/features/common/layouts"
1012 "net/url"
11131212- "github.com/a-h/templ"
1313- templruntime "github.com/a-h/templ/runtime"
1414+ "atmoquest/features/common/layouts"
1415)
15161617// Signin renders the chooser: existing Atmosphere account vs create a new one.
···6162 if templ_7745c5c3_Err != nil {
6263 return templ_7745c5c3_Err
6364 }
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=\"")
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=\"hero-block\"><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 (Bluesky)</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=\"")
6566 if templ_7745c5c3_Err != nil {
6667 return templ_7745c5c3_Err
6768 }
6869 var templ_7745c5c3_Var4 templ.SafeURL
6970 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(continueLocalURL(next)))
7071 if templ_7745c5c3_Err != nil {
7171- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 40, Col: 52}
7272+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 38, Col: 52}
7273 }
7374 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
7475 if templ_7745c5c3_Err != nil {
7576 return templ_7745c5c3_Err
7677 }
7777- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"auth-card\"><div class=\"auth-card-tag\">option b</div><div class=\"auth-card-title\">Continue without an <span class=\"kw2\">Atmosphere</span> account</div><div class=\"auth-card-desc\">use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.</div><div class=\"auth-card-cta\">▸ continue</div></a></div></div><div class=\"statusbar\"><a href=\"/\" class=\"statusbar-brand\">atmo.quest</a></div></div>")
7878+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"auth-card\"><div class=\"auth-card-tag\">option b</div><div class=\"auth-card-title\">Continue without an <span class=\"kw2\">Atmosphere (Bluesky)</span> account</div><div class=\"auth-card-desc\">use atmo.quest locally — link an Atmosphere account later in Settings to claim your data.</div><div class=\"auth-card-cta\">▸ continue</div></a></div></div><div class=\"statusbar\"><a href=\"/\" class=\"statusbar-brand\">atmo.quest</a></div></div>")
7879 if templ_7745c5c3_Err != nil {
7980 return templ_7745c5c3_Err
8081 }
8182 return nil
8283 })
8383- templ_7745c5c3_Err = layouts.Base("sign in", "Sign in with your ATProto account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
8484+ templ_7745c5c3_Err = layouts.Base("sign in", "Sign in with your Atmosphere (Bluesky) account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
8485 if templ_7745c5c3_Err != nil {
8586 return templ_7745c5c3_Err
8687 }
+2
features/auth/routes.go
···1313// - GET /signin — chooser page
1414// - GET /signin/atproto — handle entry form
1515// - POST /oauth/login — start auth flow (PAR → AS redirect)
1616+// - POST /oauth/upgrade — re-auth with full scopes (display name/avatar)
1617// - GET /oauth/callback — finish auth flow, set cookie, redirect
1718// - POST /oauth/logout — revoke + clear cookie + redirect home
1819// - GET /oauth/client-metadata.json — public client metadata doc
···2526 router.Get("/signin", h.Signin)
2627 router.Get("/signin/atproto", h.SigninATProto)
2728 router.Post("/oauth/login", h.OAuthLogin)
2929+ router.Post("/oauth/upgrade", h.OAuthUpgrade)
2830 router.Get("/oauth/callback", h.OAuthCallback)
2931 router.Post("/oauth/logout", h.OAuthLogout)
3032 router.Get("/oauth/client-metadata.json", h.OAuthClientMetadata)
+3-3
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+811import (
912 "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
+9-15
features/profile/pages/profile_edit.templ
···88888989 <form method="POST" action="/profile/edit" class="auth-form profile-edit-form" autocomplete="off" enctype="multipart/form-data">
90909191- if v.MissingScope {
9292- <div class="auth-error" role="alert">
9393- <span class="auth-error-prefix">info:</span>
9494- To edit your display name and avatar, you need to re-authenticate with updated permissions.
9595- <a href="/oauth/logout" class="muted-link">sign out and sign back in</a>
9696- </div>
9797- }
9898-9991 <div class="profile-card">
10092 <div class="profile-avatar-edit">
10193 if !v.IsLocal {
10294 <p class="field-hint" style="margin-bottom:6px">These fields are shared with your bluesky profile.</p>
10395 }
9696+ <label class="field-label">profile photo</label>
10497 <div class="profile-avatar-edit-img">
10598 if v.AvatarURL != "" {
10699 <img class="profile-avatar" src={ v.AvatarURL } alt={ avatarAlt(v.DisplayName) } loading="lazy"/>
···109102 }
110103 </div>
111104 if v.AvatarDisabled {
112112- if v.MissingScope {
113113- <p class="field-hint">sign out and sign back in to change your avatar</p>
114114- } else {
105105+ if !v.MissingScope {
115106 <p class="field-hint">Link your ATmosphere account to upload a profile picture</p>
116107 }
117108 } else {
···135126136127 <div class="profile-card-fields-row">
137128 <div class="field">
138138- <label class="field-label" for="display_name">display name</label>
129129+ <label class="field-label" for="display_name">name</label>
139130 <div class="field-input-wrap">
140131 <input type="text" id="display_name" name="display_name"
141132 class="field-input" value={ v.DisplayName }
···145136 }
146137 placeholder="your name" />
147138 </div>
148148- if v.DisplayNameDisabled {
149149- <div class="field-hint">sign out and sign back in to edit your display name</div>
150150- }
151139 </div>
152140153141 if v.IsLocal {
···179167 </span>
180168 </label>
181169 }
170170+ if v.MissingScope && !v.IsLocal {
171171+ <div class="profile-card-footer">
172172+ <span>Grant atmo.quest permission to change these values:</span>
173173+ <button type="submit" formaction="/oauth/upgrade" formmethod="POST" class="btn btn-small">▸ Grant access</button>
174174+ </div>
175175+ }
182176 </div>
183177184178 if v.Error != "" {
···2525 "atmoquest/config"
2626)
27272828-// DefaultScopes is the scope set requested for every login.
2828+// DefaultScopes is the minimal scope set requested on initial login.
2929//
3030// Design: principle of least privilege. We ask only for the `atproto`
3131// identity scope plus full CRUD on the records this app owns in the user's
···3434// that's the legacy blanket scope, and a quest-tracking app has no business
3535// holding it.
3636//
3737-// Reading public records (e.g. `app.bsky.actor.profile` for display name /
3838-// avatar) is unauthenticated — public records on a PDS need no scope. If we
3939-// ever need to call AppView XRPCs (e.g. `app.bsky.actor.getProfile` for a
4040-// hydrated profile), add the corresponding `rpc:<lxm>?aud=<appview-did>`
4141-// scope at that point.
3737+// Notably, `repo:app.bsky.actor.profile` is NOT in this list. Reading
3838+// public records (e.g. `app.bsky.actor.profile` for display name / avatar)
3939+// is unauthenticated — public records on a PDS need no scope. Writing the
4040+// Bluesky display name and avatar is gated behind an opt-in upgrade flow
4141+// (see FullScopes and POST /oauth/upgrade).
4242//
4343// Each `repo:<nsid>` here omits `action=`, which per spec grants
4444// create + update + delete on that collection.
4545var DefaultScopes = []string{
4646 "atproto",
4747- "repo:app.bsky.actor.profile", // write displayName + avatar
4747+ "repo:quest.atmo.profile",
4848+ "repo:quest.atmo.event",
4949+ "repo:quest.atmo.checkin",
5050+ "repo:quest.atmo.connection",
5151+ "repo:quest.atmo.badge",
5252+}
5353+5454+// FullScopes is the scope set used during the opt-in upgrade flow
5555+// (POST /oauth/upgrade). It includes everything in DefaultScopes plus
5656+// repo:app.bsky.actor.profile so the user can edit their Bluesky
5757+// display name and avatar from the profile editor.
5858+var FullScopes = []string{
5959+ "atproto",
6060+ "repo:app.bsky.actor.profile",
4861 "repo:quest.atmo.profile",
4962 "repo:quest.atmo.event",
5063 "repo:quest.atmo.checkin",
···5568// Build constructs an *oauth.ClientApp wired against the provided store.
5669// Returns the app plus the resolved client_id URL (for surfacing in logs /
5770// debug pages).
7171+//
7272+// The client config is initialised with FullScopes so the client metadata
7373+// (served at /oauth/client-metadata.json or embedded in the localhost
7474+// client_id) advertises every scope the app may ever request — including
7575+// repo:app.bsky.actor.profile, which is gated behind an opt-in upgrade
7676+// flow. The initial login flow (OAuthLogin) uses SendAuthRequest with
7777+// DefaultScopes instead of calling StartAuthFlow, so the actual PAR only
7878+// contains the minimal set.
5879func Build(cfg *config.Config, store oauth.ClientAuthStore) (*oauth.ClientApp, string, error) {
5980 if cfg.IsLocalhost() {
6081 callbackURL := cfg.PublicURL + "/oauth/callback"
6161- ocfg := oauth.NewLocalhostConfig(callbackURL, DefaultScopes)
8282+ ocfg := oauth.NewLocalhostConfig(callbackURL, FullScopes)
6283 app := oauth.NewClientApp(&ocfg, store)
6384 return app, ocfg.ClientID, nil
6485 }
···6889 // atcrypto.ParsePrivateMultibase or generation + persistence.
6990 clientID := cfg.PublicURL + "/oauth/client-metadata.json"
7091 callbackURL := cfg.PublicURL + "/oauth/callback"
7171- ocfg := oauth.NewPublicConfig(clientID, callbackURL, DefaultScopes)
9292+ ocfg := oauth.NewPublicConfig(clientID, callbackURL, FullScopes)
7293 app := oauth.NewClientApp(&ocfg, store)
73947495 // Sanity check: NewPublicConfig should always set ClientID, but we depend
+32-1
internal/oauthclient/oauthclient_test.go
···1313// instead of spinning up SQLite for what's a pure-construction test.
1414var nilStore = (*oauthstore.Store)(nil)
15151616+func TestFullScopes(t *testing.T) {
1717+ if len(FullScopes) < 2 {
1818+ t.Fatalf("FullScopes should request at least atproto + a write scope; got %v", FullScopes)
1919+ }
2020+2121+ set := make(map[string]bool, len(FullScopes))
2222+ for _, s := range FullScopes {
2323+ set[s] = true
2424+ }
2525+2626+ if !set["atproto"] {
2727+ t.Errorf("FullScopes must include 'atproto'; got %v", FullScopes)
2828+ }
2929+3030+ if !set["repo:app.bsky.actor.profile"] {
3131+ t.Errorf("FullScopes must include 'repo:app.bsky.actor.profile'; got %v", FullScopes)
3232+ }
3333+3434+ // FullScopes should be a superset of DefaultScopes.
3535+ for _, s := range DefaultScopes {
3636+ if !set[s] {
3737+ t.Errorf("FullScopes missing scope from DefaultScopes: %q; got %v", s, FullScopes)
3838+ }
3939+ }
4040+}
4141+1642func TestBuild_Localhost(t *testing.T) {
1743 cfg := &config.Config{PublicURL: "http://localhost:3000"}
1844 app, clientID, err := Build(cfg, nilStore)
···85111 t.Errorf("DefaultScopes should not request transition:* scopes; got %v", DefaultScopes)
86112 }
87113114114+ // DefaultScopes should NOT include repo:app.bsky.actor.profile —
115115+ // that scope is gated behind an opt-in upgrade flow (FullScopes).
116116+ if set["repo:app.bsky.actor.profile"] {
117117+ t.Errorf("DefaultScopes must NOT include 'repo:app.bsky.actor.profile' (moved to FullScopes); got %v", DefaultScopes)
118118+ }
119119+88120 // Every app-owned lexicon under quest.atmo.* must have a corresponding
89121 // repo: write scope, otherwise the feature that needs it will silently
90122 // fail at write time. Update this list when adding a new lexicon.
91123 wantRepoScopes := []string{
9292- "repo:app.bsky.actor.profile",
93124 "repo:quest.atmo.profile",
94125 "repo:quest.atmo.event",
95126 "repo:quest.atmo.checkin",