diff --git a/go.mod b/go.mod
index 7c8f60b..7ead6b2 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/goki/freetype v1.0.5
github.com/golang-jwt/jwt/v5 v5.3.1
+ github.com/google/go-querystring v1.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/ipfs/go-block-format v0.2.3
@@ -105,7 +106,6 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
- github.com/google/go-querystring v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/handlers v1.5.2 // indirect
github.com/gorilla/mux v1.8.1 // indirect
diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go
index 6db53d4..4f467f0 100644
--- a/pkg/appview/handlers/base.go
+++ b/pkg/appview/handlers/base.go
@@ -10,6 +10,7 @@ import (
"atcr.io/pkg/appview/webhooks"
"atcr.io/pkg/auth/oauth"
"atcr.io/pkg/billing"
+ indigooauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/identity"
)
@@ -33,6 +34,7 @@ type BaseUIHandler struct {
Directory identity.Directory
BillingManager *billing.Manager
WebhookDispatcher *webhooks.Dispatcher
+ OAuthClientApp *indigooauth.ClientApp
// Stores
SessionStore *db.SessionStore
diff --git a/pkg/appview/handlers/signup.go b/pkg/appview/handlers/signup.go
new file mode 100644
index 0000000..05d7d3e
--- /dev/null
+++ b/pkg/appview/handlers/signup.go
@@ -0,0 +1,187 @@
+package handlers
+
+import (
+ "log/slog"
+ "net/http"
+ "strings"
+
+ "atcr.io/pkg/auth/oauth"
+)
+
+// SignupProvider is a curated Atmosphere PDS that accepts prompt=create.
+//
+// v1 keeps the list hardcoded. When the community has a curated directory of
+// providers we can link or syndicate, this moves to a config file.
+type SignupProvider struct {
+ // Domain is the brand hostname shown to the user on the picker and
+ // interstitial β typically the site they already know (tangled.org).
+ Domain string
+
+ // AuthServerDomain is the actual PDS origin the OAuth redirect points
+ // at. When blank it defaults to Domain. Set this only when the brand
+ // the user recognizes is not the same host as the PDS (e.g. the
+ // tangled.org site runs against a tngl.sh PDS).
+ AuthServerDomain string
+
+ // AvatarPath is the provider's mark. For mono vector marks, AvatarMono
+ // should be true and this should point to a monochrome SVG used as a
+ // CSS mask (the fill value in the file is irrelevant β theme color is
+ // applied via `background-color` on the element). For raster avatars
+ // (photographs, brand-colored icons that should render unchanged),
+ // AvatarMono is false and this is emitted as a plain ``.
+ AvatarPath string
+
+ // AvatarMono toggles the mask-based theme-aware render pipeline. True
+ // for single-color SVG marks that should invert with theme; false for
+ // raster or multi-color art that should render as-is.
+ AvatarMono bool
+
+ // RegionFlag is the flag emoji shown before the region code.
+ RegionFlag string
+
+ // Region is a short code shown in the region chip ("US", "EU", ...).
+ Region string
+
+ // RegionFullName is the long form shown in the chip's title attribute
+ // for hover/assistive tech ("United States", "European Union").
+ RegionFullName string
+
+ // TermsURL and PrivacyURL link out to the provider's policies (new tab).
+ // Blank links are omitted rather than rendered as dead links.
+ TermsURL string
+ PrivacyURL string
+}
+
+// AuthServerHost returns the origin string (https://domain) passed to the
+// OAuth resolver. Prefers AuthServerDomain when set (so the brand label on
+// the row can differ from the PDS β e.g. tangled.org displayed, tngl.sh
+// used for auth). Hardcoded scheme is intentional β PAR against http PDSes
+// violates the AT Protocol OAuth spec.
+func (p SignupProvider) AuthServerHost() string {
+ host := p.AuthServerDomain
+ if host == "" {
+ host = p.Domain
+ }
+ return "https://" + host
+}
+
+// signupProviders is the v1 curated list. Order is the order users see.
+// Avatars live at pkg/appview/public/static/providers/*.svg.
+var signupProviders = []SignupProvider{
+ {
+ Domain: "selfhosted.social",
+ AvatarPath: "/static/providers/selfhosted-social.png",
+ AvatarMono: false, // raster avatar, renders as-is
+ RegionFlag: "\U0001F1FA\U0001F1F8", // πΊπΈ
+ Region: "US",
+ RegionFullName: "United States",
+ TermsURL: "https://selfhosted.social/legal",
+ PrivacyURL: "https://selfhosted.social/legal",
+ },
+ {
+ Domain: "eurosky.social",
+ AvatarPath: "/static/providers/eurosky-social.svg",
+ AvatarMono: true,
+ RegionFlag: "\U0001F1EA\U0001F1FA", // πͺπΊ
+ Region: "EU",
+ RegionFullName: "European Union",
+ TermsURL: "https://eurosky.tech/accounts/terms/",
+ PrivacyURL: "https://eurosky.tech/accounts/privacy/",
+ },
+}
+
+// findProvider returns the entry matching the given domain, or nil.
+func findProvider(domain string) *SignupProvider {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ for i := range signupProviders {
+ if signupProviders[i].Domain == domain {
+ return &signupProviders[i]
+ }
+ }
+ return nil
+}
+
+// SignupHandler serves GET /signup β the provider picker.
+type SignupHandler struct {
+ BaseUIHandler
+}
+
+func (h *SignupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ meta := NewPageMeta(
+ "Create your account - "+h.ClientShortName,
+ "Pick an Atmosphere provider to create an account that works on "+h.ClientShortName+" and every other app on the AT Protocol.",
+ ).WithCanonical("https://" + h.SiteURL + "/signup").
+ WithSiteName(h.ClientShortName)
+
+ data := struct {
+ PageData
+ Meta *PageMeta
+ Providers []SignupProvider
+ }{
+ PageData: NewPageData(r, &h.BaseUIHandler),
+ Meta: meta,
+ Providers: signupProviders,
+ }
+
+ if err := h.Templates.ExecuteTemplate(w, "signup", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+// SignupContinueHandler serves /signup/continue β the branded handoff.
+//
+// GET renders the interstitial. POST kicks off OAuth with prompt=create and
+// redirects to the provider's authorize URL.
+type SignupContinueHandler struct {
+ BaseUIHandler
+}
+
+func (h *SignupContinueHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ to := r.URL.Query().Get("to")
+ if r.Method == http.MethodPost {
+ to = r.FormValue("to")
+ }
+
+ provider := findProvider(to)
+ if provider == nil {
+ http.Redirect(w, r, "/signup", http.StatusSeeOther)
+ return
+ }
+
+ if r.Method == http.MethodPost {
+ redirectURL, err := oauth.StartSignupFlow(r.Context(), h.OAuthClientApp, provider.AuthServerHost())
+ if err != nil {
+ slog.Error("signup flow start failed",
+ "component", "signup",
+ "provider", provider.Domain,
+ "error", err,
+ )
+ http.Redirect(w, r, "/signup?error=provider_unreachable&domain="+provider.Domain, http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, redirectURL, http.StatusFound)
+ return
+ }
+
+ meta := NewPageMeta(
+ "Create your account on "+provider.Domain+" - "+h.ClientShortName,
+ "You're about to be sent to "+provider.Domain+" to create your account.",
+ ).WithRobots("noindex").
+ WithSiteName(h.ClientShortName)
+
+ data := struct {
+ PageData
+ Meta *PageMeta
+ Provider SignupProvider
+ }{
+ PageData: NewPageData(r, &h.BaseUIHandler),
+ Meta: meta,
+ Provider: *provider,
+ }
+
+ if err := h.Templates.ExecuteTemplate(w, "signup-continue", data); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
diff --git a/pkg/appview/handlers/signup_test.go b/pkg/appview/handlers/signup_test.go
new file mode 100644
index 0000000..13e7c67
--- /dev/null
+++ b/pkg/appview/handlers/signup_test.go
@@ -0,0 +1,123 @@
+package handlers_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "atcr.io/pkg/appview"
+ "atcr.io/pkg/appview/handlers"
+)
+
+func TestSignupHandler_RendersProviderPicker(t *testing.T) {
+ templates, err := appview.Templates(nil)
+ if err != nil {
+ t.Fatalf("load templates: %v", err)
+ }
+
+ h := &handlers.SignupHandler{
+ BaseUIHandler: handlers.BaseUIHandler{
+ Templates: templates,
+ RegistryURL: "seamark.dev",
+ SiteURL: "seamark.dev",
+ ClientShortName: "Seamark",
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/signup", nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+
+ body := rr.Body.String()
+
+ // Each provider's domain should appear as a row title.
+ for _, domain := range []string{"selfhosted.social", "eurosky.social"} {
+ if !strings.Contains(body, domain) {
+ t.Errorf("missing provider domain %q in body", domain)
+ }
+ }
+
+ // The {appviewName} substitution should carry through from ClientShortName.
+ if !strings.Contains(body, "Seamark") {
+ t.Error("expected rendered body to include ClientShortName 'Seamark'")
+ }
+
+ // Each row should link to the branded handoff, not directly to OAuth.
+ if !strings.Contains(body, "/signup/continue?to=selfhosted.social") {
+ t.Error("expected CTA link to /signup/continue?to={domain}")
+ }
+
+ // Fallback route to existing Bluesky users must remain intact.
+ if !strings.Contains(body, `href="/auth/oauth/login"`) {
+ t.Error("expected footer link to /auth/oauth/login")
+ }
+}
+
+func TestSignupContinueHandler_GETRendersInterstitial(t *testing.T) {
+ templates, err := appview.Templates(nil)
+ if err != nil {
+ t.Fatalf("load templates: %v", err)
+ }
+
+ h := &handlers.SignupContinueHandler{
+ BaseUIHandler: handlers.BaseUIHandler{
+ Templates: templates,
+ RegistryURL: "seamark.dev",
+ SiteURL: "seamark.dev",
+ ClientShortName: "Seamark",
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/signup/continue?to=eurosky.social", nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+
+ body := rr.Body.String()
+
+ if !strings.Contains(body, "eurosky.social") {
+ t.Error("expected interstitial to show target domain")
+ }
+ if !strings.Contains(body, `action="/signup/continue"`) {
+ t.Error("expected continue form to POST back to /signup/continue")
+ }
+ if !strings.Contains(body, `href="/signup"`) {
+ t.Error("expected back-link to /signup")
+ }
+ // Atmosphere story anchored with concrete app names.
+ if !strings.Contains(body, "Bluesky") || !strings.Contains(body, "Tangled") {
+ t.Error("expected body copy to name Bluesky and Tangled")
+ }
+}
+
+func TestSignupContinueHandler_UnknownProviderRedirectsToPicker(t *testing.T) {
+ templates, err := appview.Templates(nil)
+ if err != nil {
+ t.Fatalf("load templates: %v", err)
+ }
+
+ h := &handlers.SignupContinueHandler{
+ BaseUIHandler: handlers.BaseUIHandler{
+ Templates: templates,
+ },
+ }
+
+ req := httptest.NewRequest("GET", "/signup/continue?to=not-a-real-provider.example", nil)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d", rr.Code, http.StatusSeeOther)
+ }
+ if loc := rr.Header().Get("Location"); loc != "/signup" {
+ t.Errorf("redirect Location = %q, want /signup", loc)
+ }
+}
diff --git a/pkg/appview/public/static/providers/eurosky-social.svg b/pkg/appview/public/static/providers/eurosky-social.svg
new file mode 100644
index 0000000..f45b1be
--- /dev/null
+++ b/pkg/appview/public/static/providers/eurosky-social.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/pkg/appview/public/static/providers/selfhosted-social.png b/pkg/appview/public/static/providers/selfhosted-social.png
new file mode 100644
index 0000000..4743030
Binary files /dev/null and b/pkg/appview/public/static/providers/selfhosted-social.png differ
diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go
index 41bafbb..35fdac5 100644
--- a/pkg/appview/routes/routes.go
+++ b/pkg/appview/routes/routes.go
@@ -70,6 +70,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
HealthChecker: deps.HealthChecker,
ReadmeFetcher: deps.ReadmeFetcher,
Directory: deps.OAuthClientApp.Dir,
+ OAuthClientApp: deps.OAuthClientApp,
SessionStore: deps.SessionStore,
DeviceStore: deps.DeviceStore,
OAuthStore: deps.OAuthStore,
@@ -88,6 +89,12 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
router.Get("/auth/oauth/login", (&uihandlers.LoginHandler{BaseUIHandler: base}).ServeHTTP)
router.Post("/auth/oauth/login", (&uihandlers.LoginSubmitHandler{BaseUIHandler: base}).ServeHTTP)
+ // Signup: provider picker + branded OAuth handoff (both public)
+ router.Get("/signup", (&uihandlers.SignupHandler{BaseUIHandler: base}).ServeHTTP)
+ signupContinue := &uihandlers.SignupContinueHandler{BaseUIHandler: base}
+ router.Get("/signup/continue", signupContinue.ServeHTTP)
+ router.Post("/signup/continue", signupContinue.ServeHTTP)
+
// Public routes (with optional auth for navbar)
router.Get("/", middleware.OptionalAuth(deps.SessionStore, deps.Database)(
&uihandlers.HomeHandler{BaseUIHandler: base},
diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css
index d683a6a..f29f5cf 100644
--- a/pkg/appview/src/css/main.css
+++ b/pkg/appview/src/css/main.css
@@ -730,7 +730,7 @@
SAILOR INFO DISCLOSURE
---------------------------------------- */
.sailor-info summary {
- @apply cursor-pointer text-sm text-base-content/70;
+ @apply cursor-pointer text-base font-medium text-base-content/80;
@apply py-2 select-none;
list-style: none;
}
@@ -749,7 +749,7 @@
}
.sailor-info-body {
- @apply text-sm text-base-content/70 space-y-2 pl-5 pt-1 pb-2;
+ @apply text-base text-base-content/75 leading-relaxed space-y-3 pl-5 pt-1 pb-2;
}
/* ----------------------------------------
@@ -834,6 +834,171 @@
.vuln-box-high { background-color: var(--color-severity-high); color: var(--color-severity-high-content); }
.vuln-box-medium { background-color: var(--color-severity-medium); color: var(--color-severity-medium-content); }
.vuln-box-low { background-color: var(--color-severity-low); color: var(--color-severity-low-content); }
+
+ /* ----------------------------------------
+ SIGNUP PROVIDER LIST
+ A single bordered container where each row is a "mooring" the user
+ can pick between. No per-row card, no nested borders, no shadows.
+
+ The single maritime gesture on this surface is the row divider:
+ a repeating short-dash pattern that reads as a depth-sounding plot
+ mark. It replaces a plain 1px rule without becoming decoration
+ (the dividers still do the work of separating rows).
+
+ Typography intent: the domain is rendered in the mono face because
+ the domain IS the identity β same thing users will see in the URL
+ bar on the provider's signup page. Mono treats it as an instrument
+ label, not prose.
+ ---------------------------------------- */
+ .provider-row + .provider-row {
+ /* Background-image is a row of 4px dashes with 4px gaps β
+ the plotted-depth look without resorting to a border-image. */
+ background-image: linear-gradient(
+ to right,
+ var(--color-base-300) 0 4px,
+ transparent 4px 8px
+ );
+ background-repeat: repeat-x;
+ background-size: 8px 1px;
+ background-position: left top;
+ }
+
+ .provider-row-inner {
+ @apply flex items-center gap-4 p-4 sm:px-5;
+ @apply transition-colors duration-150;
+ background: transparent;
+ }
+
+ .provider-row:hover .provider-row-inner {
+ background: color-mix(in oklch, var(--color-base-200) 55%, transparent);
+ }
+
+ .provider-mark {
+ @apply shrink-0 w-10 h-10 rounded-full overflow-hidden bg-base-200;
+ @apply flex items-center justify-center;
+ /* Subtle inner ring so the avatar has a defined edge even when the
+ art fills corner-to-corner. */
+ box-shadow: inset 0 0 0 1px oklch(from var(--color-base-content) l c h / 0.08);
+ }
+ .provider-mark img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+
+ /* Mono mark variant: the SVG is used as a CSS mask and the element's
+ background-color carries the theme-aware fill. One file per provider,
+ theme colors driven by CSS, no shipping of light/dark pairs. */
+ .provider-mark-mono {
+ display: block;
+ width: 60%;
+ height: 60%;
+ background-color: var(--color-base-content);
+ mask-image: var(--mark-url);
+ mask-size: contain;
+ mask-repeat: no-repeat;
+ mask-position: center;
+ -webkit-mask-image: var(--mark-url);
+ -webkit-mask-size: contain;
+ -webkit-mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ }
+
+ .provider-body {
+ @apply flex-1 min-w-0 flex flex-col;
+ gap: 0.125rem;
+ }
+
+ .provider-title-row {
+ @apply flex items-baseline gap-2 flex-wrap;
+ }
+
+ .provider-domain {
+ font-family: var(--font-mono);
+ font-weight: 500;
+ font-size: 0.975rem;
+ letter-spacing: -0.01em;
+ color: var(--color-base-content);
+ }
+
+ /* Region chip. Tight, instrumenty. Tabular-like letterforms via the
+ mono face; flag emoji sits first and uses the system emoji font stack
+ to keep rendering consistent across platforms (Windows, Linux, macOS). */
+ .provider-chip {
+ @apply inline-flex items-center gap-1.5 px-2 py-[0.15rem] rounded-sm;
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
+ background: color-mix(in oklch, var(--color-base-300) 55%, transparent);
+ }
+ .provider-chip-flag {
+ font-family:
+ "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
+ "Twemoji Mozilla", "EmojiOne Color", sans-serif;
+ font-size: 0.9em;
+ line-height: 1;
+ letter-spacing: 0;
+ }
+
+ .provider-meta {
+ @apply flex items-center gap-2 text-xs;
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
+ }
+ .provider-meta a {
+ @apply underline-offset-2;
+ }
+ .provider-meta a:hover {
+ @apply underline text-base-content;
+ }
+ .provider-meta span {
+ color: color-mix(in oklch, var(--color-base-content) 30%, transparent);
+ }
+
+ .provider-cta {
+ @apply shrink-0;
+ }
+
+ /* Mobile: avatar + domain on row 1; region/links + CTA on row 2 so the
+ CTA gets full width and the row doesn't wrap into 3 lines. */
+ @media (max-width: 32rem) {
+ .provider-row-inner {
+ display: grid;
+ grid-template-columns: 2.5rem 1fr;
+ grid-template-areas:
+ "mark body"
+ "cta cta";
+ row-gap: 0.85rem;
+ }
+ .provider-mark { grid-area: mark; }
+ .provider-body { grid-area: body; }
+ .provider-cta { grid-area: cta; width: 100%; justify-content: center; }
+ }
+
+ /* ----------------------------------------
+ SIGNUP CONTINUE β handoff mark
+ A slightly larger avatar ringed in primary, so the "you are leaving"
+ page reads as explicitly about the destination provider.
+ ---------------------------------------- */
+ .signup-handoff-mark {
+ @apply w-16 h-16 rounded-full overflow-hidden bg-base-200;
+ @apply flex items-center justify-center;
+ box-shadow:
+ 0 0 0 1px var(--color-base-300),
+ 0 0 0 5px color-mix(in oklch, var(--color-primary) 12%, transparent);
+ }
+ .signup-handoff-mark img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ .signup-handoff-mark .provider-mark-mono {
+ width: 60%;
+ height: 60%;
+ }
}
/* ========================================
diff --git a/pkg/appview/templates/pages/login.html b/pkg/appview/templates/pages/login.html
index a45ac40..9462243 100644
--- a/pkg/appview/templates/pages/login.html
+++ b/pkg/appview/templates/pages/login.html
@@ -67,14 +67,15 @@
Not sure if you have an account?
- An Atmosphere Account is a portable identity on the AT Protocol β
- the same network that powers Bluesky, Tangled, and other apps. One account works
- across every application built on the protocol.
+ An Atmosphere account is your personal identity across a whole network
+ of apps. Think of it as a digital passport that you own, not locked to any single app
+ or company.
- The easiest way to create one is at
- bsky.app.
- Already have a Bluesky handle? You're all set β use it here.
+ It works the way an email address does: the same account signs you into many different
+ apps. If you already have an account from a site like Bluesky, Blacksky, or Tangled,
+ you're already set. If you don't, you can
+ create one here β
+ {{ if .Provider.AvatarMono }}
+
+ {{ else }}
+
+ {{ end }}
+
+
+
+ Create your account on
+ {{ .Provider.Domain }}
+
+
+
+ You're being sent to
+ {{ .Provider.Domain }}
+ to create your account.
+ The same account works on {{ .ClientShortName }} and every other app on the Atmosphere,
+ including Bluesky, Tangled, and dozens more.
+