The forum where a badge gets you in and a pseudonym is all anyone sees (deforum.space). A Reddit-shaped forum where every sub-forum is gated by Minister badges, provable facts about you (a verified email domain, a GitHub account with some history behind it), and each one sets its own rules for who reads, posts, and votes. You take part under a stable pseudonym, one per sub-forum, with no karma trailing you and no global identity ever exposed.
The foundation wave shipped the scaffold, the @ministryofmany/* primitive
wiring, the data model + migrations, and the identity / membership helpers. The
SPACES + JOIN wave (this layer) adds the live Sign in with Minister OIDC
routes + flow-state, sub-forum create/configure with preset-toggle capability
matrices, the badge-gated join flow (gate -> membership -> pseudonym -> device
leaf -> snapshot), and per-role tree narrowing. The full forum UI, posting /
comment flows, governance, ranking, and the directory land in later waves.
Design spec: ../ecosystem-planner/design/deforum-spec.md.
SvelteKit 2 + Svelte 5 + Drizzle ORM + Postgres + @sveltejs/adapter-node,
pnpm, TypeScript strict. ZK proving is client-side only
(@semaphore-protocol/proof is SSR-excluded in vite.config.ts).
npm publish of the @ministryofmany/* scope is deferred. Until then Deforum is its own
pnpm workspace whose pnpm-workspace.yaml includes the sibling
minister-client checkout via a relative glob:
packages:
- '.'
- '../minister-client' # @ministryofmany/client (the OIDC RP SDK)
- '../minister-client/packages/*' # @ministryofmany/{policy,minister-verify,nullifier,identity,membership,blind-token,poll,rln}This resolves each primitive as a workspace package, which is what makes their
internal workspace:* inter-deps (e.g. @ministryofmany/poll -> @ministryofmany/membership)
resolve correctly - a per-package file: link cannot do that. The packages ship
a committed/built dist/, so after editing one in minister-client you rebuild
it there (pnpm -r build) and the change is picked up here on the next install.
pnpm install
pnpm exec svelte-kit sync
pnpm run check # svelte-check / typecheck
pnpm run build # vite build + adapter-node
pnpm run db:generate # drizzle-kit generate -> migrations/
pnpm run db:migrate # apply migrations (advisory-locked)
pnpm run smoke # exercise each @ministryofmany/* primitive with a real call
pnpm test # smoke + DB-backed integration (integration skips if no DATABASE_URL)src/lib/server/
db/ Drizzle schema (deforum-spec section 7) + lazy pooled client
identity/ the two-layer nullifier scheme + per-sub-forum device identity
membership/ DeforumGroupProvider (per-role tree narrowing) + Drizzle SnapshotStore
+ createMembership wiring + join() (the gated join pipeline)
auth/ Sign in with Minister (OIDC RP) + flow-state + id_token/badge verify
+ account resolution + app sessions (HMAC cookie)
subforum/ sub-forum create/configure + the preset-toggle capability matrix
policy/ badge-policy gating (@ministryofmany/policy)
src/routes/
api/auth/oidc/{start,callback}/ the OIDC dance (single-use flow-state)
api/join/ finish a gated join (POST device commitment)
j/[subforumId]/ minimal join page (client derives device identity)
src/lib/smoke/ the @ministryofmany/* smoke test
tests/ DB-backed integration: membership, join flow, OIDC flow-state
scripts/migrate.ts advisory-locked Drizzle migrator
GET /api/auth/oidc/start?subforum=<slug>&role=<name>builds PKCE + state + nonce, sends the role's badge policy asminister_policy, and persists the single-use flow-state todeforum_oidc_sessions.GET /api/auth/oidc/callbackconsumes that flow-state atomically bystate(oneDELETE ... RETURNINGgated on not-expired - the replay guard), exchanges the code, re-verifies the id_token + disclosed badges via@ministryofmany/minister-verify, resolves the platform account (hashing the raw sub to the user nullifier - never stored raw), issues an app session, and for a gated join stashes the verified sub + badges in a short-lived single-usedeforum_pending_joinsrow (the only place the raw sub lives, transiently).- The join page derives the per-device Semaphore identity client-side and
POST /api/join { pseudonym, deviceId, identityCommitment }. The server re-runs the badge gate (@ministryofmany/policyevaluate on the verified disclosure), creates the membership keyed on the user-sub-forum nullifier, records which roles the badges satisfied, enrolls the device leaf, and refreshes the member snapshot.
Per-role tree narrowing. DeforumGroupProvider.listEligible admits a member
to a role-named sub-tree iff a deforum_membership_roles row ties them to
that role (set at join from the verified badges); the anon:<action> sub-tree
admits all active, non-banned members. Banned/revoked are always excluded.
Presets are additive toggles, not exclusive modes (fork 11): applying
qa + members-only composes both capability sets into one matrix the operator
can then edit.
- User nullifier = the verified Minister pairwise
sub, stored hashed (HMAC overUSER_NULLIFIER_HASH_SECRET, namespaced by issuer). The raw sub is never persisted. - User-sub-forum nullifier =
poseidon2(toField(sub), toField(subforumId))via@ministryofmany/nullifier- the membership + ban anchor. Rows are keyed only on this value, never the raw sub. - Per-sub-forum Semaphore identity =
@ministryofmany/identity.deriveIdentity(seed, subforumId)- one backed-up device seed yields a distinct, unlinkable commitment per sub-forum.
The app session cookie is HMAC-signed with SESSION_SECRET
(<sessionId>.<HMAC(sessionId)>); a tampered cookie fails the constant-time
check and is treated as signed-out.
The badge-VC issuer DID is derived from the OIDC issuer host (did:web:<host>)
by @ministryofmany/client, with no override. MINISTER_ISSUER's host must equal
Minister's issuer domain, or every disclosed badge silently lands in rejected
(fails closed: login still works, but no badge counts). The verifier surfaces a
rejected-badge count so this misconfiguration is observable.