From b96517762fab660300d852642cfaf74d59defdeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 09:29:52 +0000 Subject: [PATCH 1/2] Let operators grant waitlist GitHub access from an env allowlist. SYNCIDIAN_GITHUB_ALLOWED_EMAILS accepts a JSON array, commas, or one email per line so Railway can admit specific waitlist people without opening GitHub sign-in to everyone. Co-authored-by: Shangeeth Sivan --- .env.example | 12 ++-- AGENT.md | 6 +- README.md | 8 +-- cmd/syncidian/main.go | 6 +- docker-compose.yml | 1 + docs/architecture.md | 2 +- docs/github-app.md | 2 +- internal/config/config.go | 45 ++++++++++++-- internal/config/config_test.go | 31 ++++++++++ internal/server/auth_github.go | 2 +- internal/server/auth_github_allowlist_test.go | 5 +- internal/server/waitlist.go | 23 ++++++- internal/server/waitlist_test.go | 60 +++++++++++++++++++ internal/web/static/agents/auth.md | 2 +- internal/web/static/agents/landing.md | 2 +- internal/web/static/index.html | 18 +++++- 16 files changed, 196 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index 377175e..6749360 100644 --- a/.env.example +++ b/.env.example @@ -20,11 +20,15 @@ SYNCIDIAN_PUBLIC_URL=http://localhost:8080 # installation tokens at rest. If unset, the server writes data/secret.key. # SYNCIDIAN_DATA_KEY= -# Restrict GitHub sign-in to these emails (comma-separated). Empty = anyone. -# Hosted Railway should set this to the operator email until public launch. -# PRODUCTION: unset this so every GitHub account can sign in, and stop hiding +# Restrict GitHub sign-in to these emails. Empty = anyone. +# Hosted Railway: keep this set until public launch so waitlist people you +# choose can sign in without opening GitHub OAuth to everyone. +# JSON array, commas, or one address per line all work: +# SYNCIDIAN_GITHUB_ALLOWED_EMAILS=["Shangeeth95@gmail.com","waitlist@example.com"] +# SYNCIDIAN_GITHUB_ALLOWED_EMAIL is still accepted as a singular alias. +# PRODUCTION: unset both so every GitHub account can sign in, and stop hiding # the landing GitHub buttons (see AGENT.md). -# SYNCIDIAN_GITHUB_ALLOWED_EMAIL=Shangeeth95@gmail.com +# SYNCIDIAN_GITHUB_ALLOWED_EMAILS=["Shangeeth95@gmail.com"] # Google Analytics 4 measurement ID for the public landing (hosted). Empty = off. # Events: click_discord, click_github, join_waitlist, click_github_signin. diff --git a/AGENT.md b/AGENT.md index 64c8fc3..5b95af3 100644 --- a/AGENT.md +++ b/AGENT.md @@ -42,7 +42,7 @@ If a diagram no longer matches the code, rewrite it. Do not leave comments like Document and implement these unless a later change explicitly replaces them — and if you replace them, update this list too: -1. **Public landing, private operator surface.** Unauthenticated visitors see a one-page story. On hosted Syncidian.com, vault users sign in with GitHub (hidden until the app name is tapped six times whenever `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` is set). On any other host, vault users sign in with email — a GitHub App is not required to create an account. There is no public Admin link. Self-host operators create the first admin at `SYNCIDIAN_ADMIN_PATH` (default `/admin`) or via `SYNCIDIAN_BOOTSTRAP_USER` / `SYNCIDIAN_BOOTSTRAP_PASSWORD`, persist `/data`, and skip Tailscale (`SYNCIDIAN_ADMIN_PRIVATE=0` or leave `SYNCIDIAN_ADMIN_HOST` unset). A Tailscale hostname (`SYNCIDIAN_ADMIN_HOST`, for example `admin.syncidian.com`) is optional. The instance GitHub App is optional on self-host (needed only for GitHub sign-in or per-user backup). Repository install stays per user after identity. +1. **Public landing, private operator surface.** Unauthenticated visitors see a one-page story. On hosted Syncidian.com, vault users sign in with GitHub (hidden until the app name is tapped six times whenever `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` is set). On any other host, vault users sign in with email — a GitHub App is not required to create an account. There is no public Admin link. Self-host operators create the first admin at `SYNCIDIAN_ADMIN_PATH` (default `/admin`) or via `SYNCIDIAN_BOOTSTRAP_USER` / `SYNCIDIAN_BOOTSTRAP_PASSWORD`, persist `/data`, and skip Tailscale (`SYNCIDIAN_ADMIN_PRIVATE=0` or leave `SYNCIDIAN_ADMIN_HOST` unset). A Tailscale hostname (`SYNCIDIAN_ADMIN_HOST`, for example `admin.syncidian.com`) is optional. The instance GitHub App is optional on self-host (needed only for GitHub sign-in or per-user backup). Repository install stays per user after identity. 2. **GitHub is per user, after identity.** One repository per `user_id`. `GET/POST /api/v1/github` requires `authenticate()`. 3. **Admin login does not need repo sync.** Admins manage users. Vault/GitHub/token-list/device/activity/MCP routes use `vaultAuthed` or `sessionVaultAuthed` and return 403 for admins. Admins may mint a **one-time** `sk_sync_` token for a vault user via `POST /api/v1/users/tokens` (by username) or `issue_token` on user create — they still cannot list existing tokens or read vault/GitHub/MCP usage data. 4. **Admin user list is public fields only:** `username`, `is_admin`, `created_at` (no `id`, vault, tokens, or GitHub). @@ -71,11 +71,11 @@ Docs-only or server-only PRs do not bump the plugin version. Hosted GitHub sign-in is **intentionally restricted** until public launch: * The landing **Continue with GitHub** / **Log in** controls stay hidden. Tapping the Syncidian name in the header **six times** reveals them (session only). -* `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` (Railway variable) is a comma-separated allowlist. Only those GitHub account emails can complete OAuth. Hosted should be `Shangeeth95@gmail.com`. An empty value allows every GitHub user. +* `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` (Railway variable) is the GitHub sign-in allowlist. Only those GitHub account emails can complete OAuth. Use a JSON array (`["a@x.com","b@x.com"]`), commas, or one email per line. `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` is still accepted as an alias. Hosted should at least include `Shangeeth95@gmail.com`. An empty value allows every GitHub user. **When opening GitHub sign-in for all users, do all of the following in one change:** -1. Unset `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` on Railway (and any other host). Empty allowlist = any GitHub account may sign in. +1. Unset `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` and `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` on Railway (and any other host). Empty allowlist = any GitHub account may sign in. 2. Stop hiding the landing GitHub buttons: `githubSignInHidden()` in `internal/server/auth_github.go` should return false for the public site (remove the hosted-domain hide). The six-tap easter egg in `internal/web/static/index.html` can then be removed. 3. Update this list, the landing/auth markdown, and the app-workflow diagrams so they again describe public GitHub sign-in for everyone. diff --git a/README.md b/README.md index 7189153..14819c8 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Syncidian is an open-source, self-hostable **Obsidian synchronization and AI bri It runs as an Obsidian plugin on your devices and connects to a lightweight Syncidian server that coordinates synchronization between them. -Each signed-in user can connect **one private GitHub repository**. GitHub is the durable, versioned **source of truth** for that user's vault. The public site is a one-page story: join the waitlist and sign in with GitHub on Syncidian.com, or sign in with email on a self-hosted host. GitHub sign-in is hidden on the hosted landing (tap the app name six times to reveal it) and limited to `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` until public launch. Operators use a private hostname (`SYNCIDIAN_ADMIN_HOST`, for example `admin.syncidian.com` on Tailscale) or an unlisted path (`SYNCIDIAN_ADMIN_PATH`, default `/admin`) — neither is linked from the landing page. Admins manage users and an optional instance GitHub App, and never see vault or GitHub data. +Each signed-in user can connect **one private GitHub repository**. GitHub is the durable, versioned **source of truth** for that user's vault. The public site is a one-page story: join the waitlist and sign in with GitHub on Syncidian.com, or sign in with email on a self-hosted host. GitHub sign-in is hidden on the hosted landing (tap the app name six times to reveal it) and limited to `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` until public launch. Operators use a private hostname (`SYNCIDIAN_ADMIN_HOST`, for example `admin.syncidian.com` on Tailscale) or an unlisted path (`SYNCIDIAN_ADMIN_PATH`, default `/admin`) — neither is linked from the landing page. Admins manage users and an optional instance GitHub App, and never see vault or GitHub data. Syncidian also includes a built-in **MCP server**, allowing compatible AI tools and agents to securely interact with your Obsidian knowledge base. @@ -72,7 +72,7 @@ Users, GitHub App credentials, and vault files live in SQLite on disk. Railway 1. New project → deploy this GitHub repo. Railway builds the `Dockerfile`. 2. **Settings → Volumes → Add volume**, mount path **`/data`**. Do this before creating the admin or registering the GitHub App. `railway.json` sets `requiredMountPath` to `/data` so a deploy without a volume fails instead of silently resetting the instance, and `overlapSeconds` to `0` so two replicas do not share SQLite during a rollout. 3. Generate a public domain. The server listens on Railway’s `PORT` and uses `RAILWAY_PUBLIC_DOMAIN` for the dashboard URL unless you set `SYNCIDIAN_PUBLIC_URL`. -4. Optional variables: `SYNCIDIAN_BOOTSTRAP_USER`, `SYNCIDIAN_BOOTSTRAP_PASSWORD`. To keep the GitHub App if the volume is missing, also set `SYNCIDIAN_GITHUB_APP_*` (see [Set up the GitHub App](docs/github-app.md)). Set `SYNCIDIAN_DATA_KEY` (32-byte hex) so GitHub App secrets stay encrypted even if `syncidian.db` is copied off the volume. Until public GitHub sign-in, set `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` to the operator GitHub email (hosted: `Shangeeth95@gmail.com`) so only that account can complete GitHub OAuth. +4. Optional variables: `SYNCIDIAN_BOOTSTRAP_USER`, `SYNCIDIAN_BOOTSTRAP_PASSWORD`. To keep the GitHub App if the volume is missing, also set `SYNCIDIAN_GITHUB_APP_*` (see [Set up the GitHub App](docs/github-app.md)). Set `SYNCIDIAN_DATA_KEY` (32-byte hex) so GitHub App secrets stay encrypted even if `syncidian.db` is copied off the volume. Until public GitHub sign-in, set `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` to the GitHub emails that may complete OAuth (JSON array, commas, or one per line). Hosted should include `Shangeeth95@gmail.com`. Joining the waitlist does not grant access — add each person to this variable, then redeploy. 5. Open the public URL. Create the admin at `/admin`. People can then sign up with email on `/`. Register the GitHub App ([walkthrough](docs/github-app.md)) only if you want GitHub sign-in or backup. `/admin` warns if the data directory is still ephemeral. On the hosted landing, tap **Syncidian** six times to reveal GitHub sign-in. Point the plugin at that URL with that user's token. Health check: `GET /health`. Agents can also fetch `/robots.txt`, `/auth.md`, `/.well-known/mcp/server-card.json`, and markdown for `GET /` (`Accept: text/markdown`). `/robots.txt` disallows the operator path (`/admin` by default) and operator APIs; that HTML also sends `X-Robots-Tag: noindex` so search engines should not list it. The operator page is not in `/sitemap.xml` and is not linked from `/`. If a CDN (Cloudflare AI Crawl Control) injects its own `robots.txt`, turn that off so the origin file is what crawlers see. @@ -245,7 +245,7 @@ Eventually, Syncidian should be able to detect a conflict, have a small LLM reso # 🗺️ App workflow -The public site is a one-pager. Hosted visitors join a waitlist and sign in with GitHub (hidden until the app name is tapped six times, allowlisted via `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` until public launch). Self-hosted visitors sign in with email. Operators use a private hostname (`SYNCIDIAN_ADMIN_HOST`) or an unlisted path (`SYNCIDIAN_ADMIN_PATH`, default `/admin`) to create the first admin. +The public site is a one-pager. Hosted visitors join a waitlist and sign in with GitHub (hidden until the app name is tapped six times, allowlisted via `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` until public launch). Self-hosted visitors sign in with email. Operators use a private hostname (`SYNCIDIAN_ADMIN_HOST`) or an unlisted path (`SYNCIDIAN_ADMIN_PATH`, default `/admin`) to create the first admin. ```mermaid flowchart TD @@ -434,7 +434,7 @@ Optional env vars if you prefer not to use the in-dashboard manifest flow: `SYNC Rules: -* **Public landing, private operator surface.** Create the first admin at `SYNCIDIAN_ADMIN_HOST` (Tailscale) or `SYNCIDIAN_ADMIN_PATH` (default `/admin`). Self-hosted vault users sign in with email from `/`. The GitHub App is optional. On hosted Syncidian.com, GitHub sign-in is hidden until the app name is tapped six times and limited by `SYNCIDIAN_GITHUB_ALLOWED_EMAIL`. +* **Public landing, private operator surface.** Create the first admin at `SYNCIDIAN_ADMIN_HOST` (Tailscale) or `SYNCIDIAN_ADMIN_PATH` (default `/admin`). Self-hosted vault users sign in with email from `/`. The GitHub App is optional. On hosted Syncidian.com, GitHub sign-in is hidden until the app name is tapped six times and limited by `SYNCIDIAN_GITHUB_ALLOWED_EMAILS`. * **One repository per user.** `github_config` is keyed by `user_id`. * **GitHub App only.** Connect with GitHub, install on a repository, Contents read and write. No personal access tokens and no deploy keys. * **Main branch only.** Syncidian always uses `main`. diff --git a/cmd/syncidian/main.go b/cmd/syncidian/main.go index 2d0d825..47f8107 100644 --- a/cmd/syncidian/main.go +++ b/cmd/syncidian/main.go @@ -88,8 +88,10 @@ Environment: SYNCIDIAN_GITHUB_CLIENT_ID GitHub App OAuth client ID SYNCIDIAN_GITHUB_CLIENT_SECRET GitHub App OAuth client secret SYNCIDIAN_GITHUB_APP_PRIVATE_KEY PEM; use \n for newlines - SYNCIDIAN_GITHUB_ALLOWED_EMAIL Comma-separated GitHub emails allowed to sign in. - Empty = any GitHub user. Production: unset this. + SYNCIDIAN_GITHUB_ALLOWED_EMAILS JSON array, commas, or one GitHub email per line + allowed to sign in. Empty = any GitHub user. + SYNCIDIAN_GITHUB_ALLOWED_EMAIL is still accepted + as an alias. Production: unset both. SYNCIDIAN_GA_ID Google Analytics 4 ID (G-…). Landing CTA clicks only when set. `) } diff --git a/docker-compose.yml b/docker-compose.yml index 4cc71a2..83720ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: SYNCIDIAN_GITHUB_CLIENT_ID: ${SYNCIDIAN_GITHUB_CLIENT_ID:-} SYNCIDIAN_GITHUB_CLIENT_SECRET: ${SYNCIDIAN_GITHUB_CLIENT_SECRET:-} SYNCIDIAN_GITHUB_APP_PRIVATE_KEY: ${SYNCIDIAN_GITHUB_APP_PRIVATE_KEY:-} + SYNCIDIAN_GITHUB_ALLOWED_EMAILS: ${SYNCIDIAN_GITHUB_ALLOWED_EMAILS:-} SYNCIDIAN_GITHUB_ALLOWED_EMAIL: ${SYNCIDIAN_GITHUB_ALLOWED_EMAIL:-} volumes: # Named volume survives container recreate. `docker compose down -v` deletes it. diff --git a/docs/architecture.md b/docs/architecture.md index 1acccba..4a0c8bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ This document describes the **current MVP** as implemented in this repository. GitHub renders the Mermaid diagrams below — open this file on GitHub to visualize them. -The plugin is the client (Windows, macOS, Linux, Android, and iOS — `isDesktopOnly` is false). HTTP from the plugin uses Obsidian `requestUrl`. The Go server is the coordination layer. A per-user vault on disk plus SQLite metadata is the working copy. GitHub is an **optional, per-user** durable source of truth — after identity, a user installs the App on one repository. On hosted Syncidian.com, people join a waitlist and sign in with GitHub (hidden until the app name is tapped six times, allowlisted via `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` until public launch). On a self-hosted host, people sign in with email; the GitHub App is optional. Operators use a private hostname (`SYNCIDIAN_ADMIN_HOST`, for example `admin.syncidian.com` on Tailscale) or an unlisted path (`SYNCIDIAN_ADMIN_PATH`, default `/admin`) to create the first admin. MCP is the AI bridge. +The plugin is the client (Windows, macOS, Linux, Android, and iOS — `isDesktopOnly` is false). HTTP from the plugin uses Obsidian `requestUrl`. The Go server is the coordination layer. A per-user vault on disk plus SQLite metadata is the working copy. GitHub is an **optional, per-user** durable source of truth — after identity, a user installs the App on one repository. On hosted Syncidian.com, people join a waitlist and sign in with GitHub (hidden until the app name is tapped six times, allowlisted via `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` until public launch). On a self-hosted host, people sign in with email; the GitHub App is optional. Operators use a private hostname (`SYNCIDIAN_ADMIN_HOST`, for example `admin.syncidian.com` on Tailscale) or an unlisted path (`SYNCIDIAN_ADMIN_PATH`, default `/admin`) to create the first admin. MCP is the AI bridge. When you change this architecture, update the Mermaid diagrams in this file and in `README.md`. See [`AGENT.md`](../AGENT.md). diff --git a/docs/github-app.md b/docs/github-app.md index d9c029b..f3c8c12 100644 --- a/docs/github-app.md +++ b/docs/github-app.md @@ -43,7 +43,7 @@ This posts a GitHub App **manifest**, so permissions and URLs are filled in for Those credentials are stored encrypted in SQLite. Attach a volume at `/data` (or copy them into `SYNCIDIAN_GITHUB_APP_*` env vars — Option B) so the next deploy does not wipe the GitHub App. Set `SYNCIDIAN_DATA_KEY` if you want the encryption key in environment variables instead of `data/secret.key`. -People can now use **Continue with GitHub** / **Log in** / **Connect to your GitHub repository** on `{base}/`. Self-hosted email login already works without this app. On hosted Syncidian.com those GitHub buttons stay hidden until you tap the app name six times, and `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` must match the GitHub account. +People can now use **Continue with GitHub** / **Log in** / **Connect to your GitHub repository** on `{base}/`. Self-hosted email login already works without this app. On hosted Syncidian.com those GitHub buttons stay hidden until you tap the app name six times, and `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` must include the GitHub account. The manifest requests: diff --git a/internal/config/config.go b/internal/config/config.go index a138f5e..b7c2abd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "os" "path/filepath" "strconv" @@ -31,9 +32,10 @@ type Config struct { GitHubAppPEM string // GitHubAllowedEmails, when non-empty, is the only set of GitHub account // emails that may complete GitHub sign-in. Empty means any GitHub user. - // Set SYNCIDIAN_GITHUB_ALLOWED_EMAIL on Railway to restrict hosted sign-in - // until public launch. PRODUCTION: unset it (and show the landing GitHub - // buttons) when opening sign-in to everyone. See AGENT.md. + // Set SYNCIDIAN_GITHUB_ALLOWED_EMAILS on Railway to grant waitlist people + // access without opening sign-in to everyone. SYNCIDIAN_GITHUB_ALLOWED_EMAIL + // is still accepted as an alias. PRODUCTION: unset both (and show the + // landing GitHub buttons) when opening sign-in to everyone. See AGENT.md. GitHubAllowedEmails []string // GAID is a Google Analytics 4 measurement ID (G-…). Empty disables gtag. // Set SYNCIDIAN_GA_ID on hosted Railway to count landing CTA clicks. @@ -55,7 +57,7 @@ func FromEnv() Config { GitHubClientID: env("SYNCIDIAN_GITHUB_CLIENT_ID", ""), GitHubClientSecret: env("SYNCIDIAN_GITHUB_CLIENT_SECRET", ""), GitHubAppPEM: strings.ReplaceAll(env("SYNCIDIAN_GITHUB_APP_PRIVATE_KEY", ""), `\n`, "\n"), - GitHubAllowedEmails: ParseEmailList(env("SYNCIDIAN_GITHUB_ALLOWED_EMAIL", "")), + GitHubAllowedEmails: GitHubAllowedEmailsFromEnv(), GAID: NormalizeGAID(env("SYNCIDIAN_GA_ID", "")), } if id, err := strconv.ParseInt(env("SYNCIDIAN_GITHUB_APP_ID", "0"), 10, 64); err == nil { @@ -164,15 +166,46 @@ func NormalizeGAID(raw string) string { return s } -// ParseEmailList splits a comma-separated email list, lowercased and trimmed. +// GitHubAllowedEmailsFromEnv reads SYNCIDIAN_GITHUB_ALLOWED_EMAILS (preferred) +// and the singular SYNCIDIAN_GITHUB_ALLOWED_EMAIL alias, then merges them. +func GitHubAllowedEmailsFromEnv() []string { + return MergeEmailLists( + ParseEmailList(os.Getenv("SYNCIDIAN_GITHUB_ALLOWED_EMAILS")), + ParseEmailList(os.Getenv("SYNCIDIAN_GITHUB_ALLOWED_EMAIL")), + ) +} + +// ParseEmailList turns an environment value into unique, lowercased emails. +// It accepts a JSON array, commas, semicolons, or one address per line so the +// allowlist can be edited as an array on Railway without a code change. func ParseEmailList(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } + if strings.HasPrefix(raw, "[") { + var arr []string + if json.Unmarshal([]byte(raw), &arr) == nil { + return uniqueLowerEmails(arr) + } + } + replacer := strings.NewReplacer("\r\n", ",", "\n", ",", "\r", ",", ";", ",") + return uniqueLowerEmails(strings.Split(replacer.Replace(raw), ",")) +} + +// MergeEmailLists concatenates allowlists, dropping blanks and duplicates. +func MergeEmailLists(lists ...[]string) []string { + var all []string + for _, list := range lists { + all = append(all, list...) + } + return uniqueLowerEmails(all) +} + +func uniqueLowerEmails(parts []string) []string { seen := make(map[string]struct{}) var out []string - for _, part := range strings.Split(raw, ",") { + for _, part := range parts { e := strings.ToLower(strings.TrimSpace(part)) if e == "" { continue diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d8eeabf..2b96cdd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -18,6 +18,7 @@ func TestFromEnvDefaults(t *testing.T) { t.Setenv("SYNCIDIAN_ADMIN_PRIVATE", "") t.Setenv("TAILSCALE_IP", "") t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAIL", "") + t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAILS", "") t.Setenv("SYNCIDIAN_GA_ID", "") c := FromEnv() @@ -130,6 +131,17 @@ func TestParseEmailList(t *testing.T) { if len(got) != 2 || got[0] != "shangeeth95@gmail.com" || got[1] != "other@example.com" { t.Fatalf("got %v", got) } + pretty := ParseEmailList(`[ + "Ada@Example.com", + "bob@example.com" +]`) + if len(pretty) != 2 || pretty[0] != "ada@example.com" || pretty[1] != "bob@example.com" { + t.Fatalf("pretty json: %v", pretty) + } + lines := ParseEmailList("ada@example.com\nbob@example.com; cara@example.com") + if len(lines) != 3 || lines[0] != "ada@example.com" || lines[2] != "cara@example.com" { + t.Fatalf("lines: %v", lines) + } } func TestEmailAllowed(t *testing.T) { @@ -149,6 +161,7 @@ func TestEmailAllowed(t *testing.T) { } func TestFromEnvGitHubAllowedEmail(t *testing.T) { + t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAILS", "") t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAIL", "Shangeeth95@gmail.com") c := FromEnv() if len(c.GitHubAllowedEmails) != 1 || c.GitHubAllowedEmails[0] != "shangeeth95@gmail.com" { @@ -156,6 +169,24 @@ func TestFromEnvGitHubAllowedEmail(t *testing.T) { } } +func TestFromEnvGitHubAllowedEmailsJSON(t *testing.T) { + t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAIL", "") + t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAILS", `["Shangeeth95@gmail.com","waitlist@example.com"]`) + c := FromEnv() + if len(c.GitHubAllowedEmails) != 2 || c.GitHubAllowedEmails[0] != "shangeeth95@gmail.com" || c.GitHubAllowedEmails[1] != "waitlist@example.com" { + t.Fatalf("GitHubAllowedEmails=%v", c.GitHubAllowedEmails) + } +} + +func TestFromEnvGitHubAllowedEmailsMergesAlias(t *testing.T) { + t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAILS", "ada@example.com") + t.Setenv("SYNCIDIAN_GITHUB_ALLOWED_EMAIL", "bob@example.com, ada@example.com") + c := FromEnv() + if len(c.GitHubAllowedEmails) != 2 || c.GitHubAllowedEmails[0] != "ada@example.com" || c.GitHubAllowedEmails[1] != "bob@example.com" { + t.Fatalf("GitHubAllowedEmails=%v", c.GitHubAllowedEmails) + } +} + func TestNormalizeGAID(t *testing.T) { if got := NormalizeGAID(""); got != "" { t.Fatalf("empty: %q", got) diff --git a/internal/server/auth_github.go b/internal/server/auth_github.go index 047043b..7eb6341 100644 --- a/internal/server/auth_github.go +++ b/internal/server/auth_github.go @@ -203,7 +203,7 @@ func (s *Server) githubSignInRestricted() bool { func (s *Server) githubSignInHidden(r *http.Request) bool { // Hosted Syncidian.com hides GitHub until public launch. An allowlist // also hides it on any host. PRODUCTION: stop hiding on the hosted - // domain and unset SYNCIDIAN_GITHUB_ALLOWED_EMAIL so everyone can sign in. + // domain and unset SYNCIDIAN_GITHUB_ALLOWED_EMAILS so everyone can sign in. return s.isHostedPublic(r) || s.githubSignInRestricted() } diff --git a/internal/server/auth_github_allowlist_test.go b/internal/server/auth_github_allowlist_test.go index 9a2365f..64c5440 100644 --- a/internal/server/auth_github_allowlist_test.go +++ b/internal/server/auth_github_allowlist_test.go @@ -12,13 +12,16 @@ import ( ) func TestGitHubSignInAllowlist(t *testing.T) { - s := &Server{Cfg: config.Config{GitHubAllowedEmails: []string{"shangeeth95@gmail.com"}}} + s := &Server{Cfg: config.Config{GitHubAllowedEmails: []string{"shangeeth95@gmail.com", "waitlist@example.com"}}} if s.githubSignInAllowed(&githubapp.User{Email: "other@example.com"}) { t.Fatal("other email must be denied") } if !s.githubSignInAllowed(&githubapp.User{Email: "Shangeeth95@gmail.com"}) { t.Fatal("allowlist email must match case-insensitively") } + if !s.githubSignInAllowed(&githubapp.User{Email: "waitlist@example.com"}) { + t.Fatal("second allowlist email must be accepted") + } if !s.githubSignInAllowed(&githubapp.User{Emails: []string{"shangeeth95@gmail.com"}}) { t.Fatal("verified secondary email must match") } diff --git a/internal/server/waitlist.go b/internal/server/waitlist.go index 98c37e3..48d6567 100644 --- a/internal/server/waitlist.go +++ b/internal/server/waitlist.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/shangeethsivan/Syncidian/internal/config" "github.com/shangeethsivan/Syncidian/internal/store" ) @@ -94,5 +95,25 @@ func (s *Server) handleListWaitlist(w http.ResponseWriter, r *http.Request, _ *s writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusOK, map[string]any{"count": n, "emails": list}) + restricted := s.githubSignInRestricted() + allow := s.Cfg.GitHubAllowedEmails + if allow == nil { + allow = []string{} + } + rows := make([]map[string]any, 0, len(list)) + for _, e := range list { + allowed := !restricted || config.EmailAllowed(s.Cfg.GitHubAllowedEmails, e.Email) + rows = append(rows, map[string]any{ + "id": e.ID, + "email": e.Email, + "created_at": e.CreatedAt, + "allowed": allowed, + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "count": n, + "emails": rows, + "github_allowed_emails": allow, + "github_signin_restricted": restricted, + }) } diff --git a/internal/server/waitlist_test.go b/internal/server/waitlist_test.go index 1a4fd69..3a499d3 100644 --- a/internal/server/waitlist_test.go +++ b/internal/server/waitlist_test.go @@ -157,6 +157,66 @@ func TestWaitlistListRequiresAdminOnHostedDomain(t *testing.T) { if !strings.Contains(rec.Body.String(), "ada@example.com") { t.Fatalf("admin list missing email: %s", rec.Body.Bytes()) } + if !strings.Contains(rec.Body.String(), `"github_signin_restricted":false`) { + t.Fatalf("empty allowlist should not restrict: %s", rec.Body.Bytes()) + } +} + +func TestWaitlistListMarksEnvAllowlist(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if _, err := st.AddWaitlistEmail("ada@example.com"); err != nil { + t.Fatal(err) + } + if _, err := st.AddWaitlistEmail("bob@example.com"); err != nil { + t.Fatal(err) + } + srv := New(config.Config{ + Addr: ":0", DataDir: dir, PublicURL: "https://syncidian.com", + GitHubAllowedEmails: []string{"ada@example.com"}, + }, st, nil) + hs := httptest.NewServer(srv.Handler()) + defer hs.Close() + cookies := setupAdmin(t, hs) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/waitlist", nil) + req.Host = "syncidian.com" + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("admin list %d %s", rec.Code, rec.Body.Bytes()) + } + var body struct { + Count int `json:"count"` + GitHubSignInRestricted bool `json:"github_signin_restricted"` + GitHubAllowedEmails []string `json:"github_allowed_emails"` + Emails []map[string]any `json:"emails"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Count != 2 || !body.GitHubSignInRestricted { + t.Fatalf("list meta: %+v", body) + } + if len(body.GitHubAllowedEmails) != 1 || body.GitHubAllowedEmails[0] != "ada@example.com" { + t.Fatalf("allowlist: %v", body.GitHubAllowedEmails) + } + allowed := map[string]bool{} + for _, e := range body.Emails { + email, _ := e["email"].(string) + ok, _ := e["allowed"].(bool) + allowed[email] = ok + } + if !allowed["ada@example.com"] || allowed["bob@example.com"] { + t.Fatalf("allowed flags: %v", allowed) + } } func TestSetupExposesGAID(t *testing.T) { diff --git a/internal/web/static/agents/auth.md b/internal/web/static/agents/auth.md index ccd87cd..1da8287 100644 --- a/internal/web/static/agents/auth.md +++ b/internal/web/static/agents/auth.md @@ -35,7 +35,7 @@ Protected resource metadata: [/.well-known/oauth-protected-resource](/.well-know ## Human sign-in (not for agents) -On hosted Syncidian.com, people sign in with GitHub (`GET /api/v1/auth/github/start`). GitHub sign-in is hidden until the app name is tapped six times, and `SYNCIDIAN_GITHUB_ALLOWED_EMAIL` limits which GitHub accounts can complete OAuth. On a self-hosted host, people use email (`POST /api/v1/auth/signup` and `POST /api/v1/auth/login`); those routes return 404 on Syncidian.com. Operators use `/admin` (`POST /api/v1/auth/login`). GitHub App install for vault backup is optional after identity, on one repository, `main` only. +On hosted Syncidian.com, people sign in with GitHub (`GET /api/v1/auth/github/start`). GitHub sign-in is hidden until the app name is tapped six times, and `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` limits which GitHub accounts can complete OAuth. On a self-hosted host, people use email (`POST /api/v1/auth/signup` and `POST /api/v1/auth/login`); those routes return 404 on Syncidian.com. Operators use `/admin` (`POST /api/v1/auth/login`). GitHub App install for vault backup is optional after identity, on one repository, `main` only. ## Revocation diff --git a/internal/web/static/agents/landing.md b/internal/web/static/agents/landing.md index 723cdd6..b5b5fb3 100644 --- a/internal/web/static/agents/landing.md +++ b/internal/web/static/agents/landing.md @@ -6,7 +6,7 @@ Syncidian is a self-hosted sync server and MCP bridge for Obsidian. The same plu ## Sign in -- **Waitlist (Syncidian.com):** use the form on the [homepage](/). Hosted GitHub sign-in is hidden until you tap the app name six times, and only allowlisted emails can complete it until public launch. +- **Waitlist (Syncidian.com):** use the form on the [homepage](/). Hosted GitHub sign-in is hidden until you tap the app name six times, and only emails in `SYNCIDIAN_GITHUB_ALLOWED_EMAILS` can complete it until public launch. - **GitHub (Syncidian.com, after revealing it):** [Continue with GitHub](/api/v1/auth/github/start?next=install) - **Email (self-host):** use the email form on the [homepage](/). You do not need a GitHub App to create an account. The operator still creates the first admin at `/admin`. diff --git a/internal/web/static/index.html b/internal/web/static/index.html index f2a0b9d..f8a3353 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1396,7 +1396,7 @@

Admin

What you can do