The Go replacement for DysonNetwork.Padlock (auth, accounts, OIDC, admin,
E2EE/MLS) plus the Passport account/profile domain (accounts, profiles,
relationships, followers/following). Serves the same /api/** routes the C#
services served (the Blade gateway adds the /padlock and /passport
prefixes) with identical JSON shapes. Account board items stay in
Passport (routes + account_board_items table); Stargate only consumes
board updates indirectly via the profile read-model.
cmd/stargate— the service (HTTP on:8080, gRPC on:9090)cmd/stargate-migrate— one-shot data copy from the legacy C# databasescmd/stargate-module-import— per-module legacy data import when a module's table layout changedcmd/stargate-hash-password— bcrypt hash for[auth.singleUser]internal/module— compile-time module registry, dependency resolution, ports and schema hooksinternal/modules/*— feature modules and ecosystem integrationsinternal/app— process bootstrap and module lifecycleinternal/core— shared service graph handed to modulesinternal/ports— interfaces between feature modules and integrationsinternal/auth— JWT (RS256), token validation, session/challenge logicinternal/httpserver/*ctl— controller packages per domaininternal/grpcserver— the gRPC surface the C# fleet callsinternal/store— persistence layer: typed GORM entities + query builders (schema ininternal/migrate/0001_initial.sql)internal/dbtest— dedicated-database helper for migration tests (the DDL starts withDROP TABLE IF EXISTS, so isolated migrations must run in a fresh database)internal/migrate— embedded DDL, applied on bootinternal/permission— permission registry + evaluation + seedinternal/grpcclient— outbound clients (wallet, develop, drive, pass, blade, ring)internal/nats— thin wrapper over the shared event bus (src.solsynth.dev/sosys/go/pkg/eventbus): JetStream events (auth.session.revoked,websocket_push)internal/webui— serves the built SPA (web/dist) with history fallbackweb/— the Vue 3 + Vite single-page frontend
Stargate is a compile-time module system (Caddy style). Every surface is a
module in internal/modules; cmd/stargate selects the standard set.
Runtime config enables or disables modules without rebuilding:
[modules]
enabled = [] # empty = every compiled-in module with a default of on
disabled = ["app.solian.e2ee", "app.solian.id.relationships"]Module IDs use reverse-DNS namespaces. Only app.solian.id.auth is required.
Identity features live under app.solian.id.*; optional ecosystem adapters use
app.solian.drive, app.solian.wallet, app.solian.pass, app.solian.ring,
app.solian.blade and app.solian.develop.
Disabling a module unmounts its HTTP/gRPC/consumer surface and, on a fresh database, prevents its GORM models from being migrated. Existing tables are never dropped. Re-enabling a module reuses the existing data.
Modules own their schema:
Factory.Modelsis passed toAutoMigrateonly while the module is enabled.Factory.SQLis the raw-SQL hook for indexes/constraints AutoMigrate cannot express.- Core SQL migrations only contain the required auth/permission schema.
- A module never adds columns to a core table; it owns a side table.
app.solian.id.oauthappsowns the OAuth/OIDC client registry (oauth_apps,oauth_app_secrets), so the provider does not need Develop. Seedocs/oauth-apps.md.
GET /api/modules returns the resolved set and capabilities. The full design
is in docs/module-system-plan.md.
For a minimal deployment with only app.solian.id.auth:
[auth]
provider = "single-user"
[auth.singleUser]
username = "admin"
passwordHash = "$2b$12$..." # go run ./cmd/stargate-hash-password
superuser = true
allowRefresh = trueThis identity is stateless: no account row, sessions or server-side
revocation. Use provider = "database" for normal multi-account deployments.
When extracting a Passport table and changing its layout, use the module importer:
go run ./cmd/stargate-module-import \
--module app.solian.id.relationships \
--source-dsn "postgres://.../dyson_passport" \
--target-dsn "postgres://.../dyson_stargate"Importers are idempotent, never truncate the target, and record progress in
module_import_state.
Two modules ship an importer, each with a wrapper script that reads the
password from PGPASSWORD and defaults the databases:
| Module | Script | Moves |
|---|---|---|
app.solian.id.relationships |
scripts/import-relationships.sh |
Passport account_relationships |
app.solian.id.oauthapps |
scripts/import-oauth-apps.sh |
Develop custom_apps + custom_app_secrets |
Both accept PGDRY=1 for a dry run. The OAuth importer reports per-table
counts and skips rows it refuses to guess at: a slug the target already
holds, an app with no project_id, and a secret whose app was skipped.
The SPA in web/ is served by Stargate itself from [web] distPath (the
shipped config.example.toml uses web/dist); there is no separate static
file server. One command builds both and serves them on :8080:
make start # bun install + SPA build + Go build, then serve
make start CONFIG=config.tomlmake dev runs the Go API and the Vite server together (Vite proxies the API,
so the console uses same-origin URLs in both modes). make web-check runs the
type checker, linter, unit tests, and a production build. web/Makefile has
the same targets scoped to the frontend.
The console ships English and Simplified Chinese, and a light and dark theme.
Both follow the browser/OS on first visit, then remember the reader's choice in
localStorage; the footer carries the theme toggle and language dropdown.
The sign-in screen lists a social provider only when its credentials are
complete in [oidc], so no button leads to an unavailable provider. It also
offers sign-in by passkey and by QR code: the browser starts a challenge, a
signed-in device scans and approves it, and the browser redeems the result.
Other apps on the cookie domain reuse that page instead of building their own:
/sign-in?redirectUri={callback} returns the person to the app once they are
signed in. The session cookie is scoped by [auth] cookieDomain, and only a
host that cookie reaches is accepted as a landing target, so a shared link
cannot turn the page into an open redirect.
Linking a provider from Settings → Linked Identities uses the same login
endpoint with flow=connect, which attaches the identity to the signed-in
account and returns to the console.
The emails Stargate sends for contact verification and password reset link to
{siteUrl}/spells/{word}, which the console serves as a page: the reset form
asks for a new password, and the verification form confirms the contact. A link
that is wrong, already spent, or past its 24 hours says so instead of offering a
form that cannot work.
The visual system — tokens, type, layout tiers, and component rules — is
documented in docs/design-system.md.
Routes resolve as follows:
- existing files under
web/distare served directly (/assets/*is fingerprinted by Vite and cached immutably; everything else revalidates); - any other GET without a file extension returns
index.html, so client-side routes survive a reload; /api/**,/.well-known/**,/swagger/**,/healthand/alivenever fall back and answer with the canonical JSON 404;- an empty
[web] distPathleaves the server API-only.
In development run make dev (or make web-dev for the Vite server alone)
and open http://localhost:5173: Vite proxies /api, /.well-known,
/swagger, /health and /alive to STARGATE_DEV_PROXY (default
http://localhost:8080). The Docker image builds web/ with
Bun and copies the output to /app/web/dist.
cp config.example.toml config.toml # set DSNs, keys, service targets
CONFIG_PATH=config.toml make runPostgres is required for the core account and session store. Redis and NATS
are optional: without Redis, cache-dependent features (QR login, passkeys,
one-time codes, and deletion throttling) return 503 while DB-backed
authentication continues; without NATS, event consumers and realtime event
publishing are disabled. Every outbound target in [services] is optional
and disables only its dependent enrichment, lookup, notification, or
realtime feature when unset or unavailable.
Stargate normally reads custom OAuth/OIDC clients from
DysonNetwork.Develop. Deployments that do not run Develop can define the
clients locally instead:
[[oidcProvider.clients]]
id = "00000000-0000-0000-0000-000000000001"
slug = "my-client"
name = "My Client"
clientSecret = "replace-with-a-secret"
status = 2
redirectUris = ["https://client.example.com/oauth/callback"]
allowedScopes = ["openid", "profile", "email"]
isPublicClient = falseLocal entries are checked before Develop. Keep each id stable because it is
stored in OAuth sessions. status = 2 (Production) enforces redirectUris;
public clients should use PKCE and omit clientSecret.
go run ./cmd/stargate-migrate \
--padlock-dsn "postgres://…/dyson_padlock" \
--passport-dsn "postgres://…/dyson_pass" \
--target-dsn "postgres://…/dyson_stargate"Order matters: boot Stargate once (creates the schema + records migrations),
then copy data, then restart (the seed then enrolls accounts in the
permission groups). UUIDs, bcrypt password hashes and E2EE blobs are copied
verbatim; schema drift (e.g. an epoch column the live DB lacks) is
zero-filled per type.
- Point Blade's routes for
/padlock/**(and the moved/passport/**paths) at Stargate. - Point the C# fleet's
services__padlock__grpc__0env at Stargate (the_grpc.padlock/_grpc.passportDNS targets stay; only the service address changes). - Keep Passport serving its remaining paths; its account lookups now hit Stargate.
- Run the migration tool, restart, verify with the compat sweep in the
plan (
local://stargate-user-domain-plan.md).
- JSON is globally snake_case;
ApiError.traceIdis camelCase; enums are ints; times are UTC RFC3339; nulls are omitted. - JWTs use the same RSA keys as the C# fleet (
Keys/), so in-flight tokens keep validating; refresh rotation bumps the session epoch and revokes prior tokens. The immediately previous refresh token stays acceptable forauth.refreshGracePeriod(default60s) after a rotation so a lost or timed-out refresh response retries instead of forcing a re-login. - Session cache keys (
dyson:auth:session:*), account versions and theauth.session.revokedJetStream events interoperate with the C# fleet and downstream Go services.
- Trusted proxies. The client IP used by fail2ban and the per-IP quotas
is read from
X-Forwarded-Forcounting from the right ([security] trustedProxyHops, default 1). The edge (Blade) must append to or overwriteX-Forwarded-For— never pass a client-supplied header through untouched — otherwise a client can pick the address the rate limiter sees. SettrustedProxyHopsto the real proxy depth when more than one proxy sits in front of Stargate. - Captcha is fail-closed. Account creation, the
password-reset request and/api/auth/captcha/verifyare captcha-gated. Stargate refuses to start when captcha is unusable unless the deployment opted out explicitly with[captcha] allow_disabled = true; without that opt-out,AuthService.ValidateCaptchareturns an error and the gated flows reject. - Credential guessing is throttled.
PATCH /api/auth/challenge/{id}andPOST /api/auth/tokenconsult the fail2ban IP block before verifying, and[security] maxChallengeAttemptscaps the failed attempts one challenge accepts. - A challenge id is a bearer credential.
GET /api/auth/challenge/{id}, its/factorsand code-request sub-paths, and thePATCHthat verifies a factor serve whoever presents the id; elevation (sudo) challenges additionally require the session they were bound to. The id is a random v4 UUID returned only to the client that created the challenge, so it must never be logged where other operators can read it, embedded in a URL that leaks to third parties, or minted from a guessable value. - Disclosure.
GET /.well-known/security.txt(contact from[securityTxt] contact) andSECURITY.mddescribe how to report a vulnerability privately.