2026-10-06_user-mgmt-module_spec
Spec — Wine2You user-mgmt module (task 2)
Date: 2026-10-06
Repo: digid-co/wine2you → apps/user-mgmt/
Stack: Next.js (App Router) + TypeScript + Prisma + Tailwind
Depends on: task 1 (infra/identity/compose.yaml — Postgres, ZITADEL, OpenFGA, Unkey)
1. Goal & Boundary
Deliverable: the Wine2You user-management module — the source of truth for users, user types, entities, memberships, and integrator→entity associations — plus the integrator UI to administer (ZITADEL user/role) + (OpenFGA relations) + (Unkey keys) as one combination.
In scope:
- Domain schema + migrations (Prisma) for:
User,UserType(multi, M:N),Entity(type:estate|shop),Membership(user↔entity + role),IntegratorGrant(integrator↔entity — "may manage keys for"),ApiKeyRef(local mirror of Unkey key id → entity; one key ↔ one entity). - Service adapters:
zitadel.ts(create user/org, assign roles),openfga.ts(write/check relationship tuples),unkey.ts(createKey/revokeKey). - API routes: register user, assign types, create entity, add member, grant integrator, mint key (OpenFGA-gated), revoke key.
- Integrator UI: a page listing the integrator's entities + a "create API key" flow that calls the gated route and shows the plaintext key once.
- Unit tests for the OpenFGA gate (deny when no
IntegratorGrant, allow when present).
Out of scope:
- Storefront/Medusa/Meilisearch.
- Actual ZITADEL/OpenFGA/Unkey deployment (task 1).
- Billing, notifications.
2. Type & API Contracts
type UserType = 'buyer' | 'supplier' | 'integrator' | 'curator' | 'staff';
type EntityKind = 'estate' | 'shop';
interface User { id: string; email: string; types: UserType[]; }
interface Entity { id: string; kind: EntityKind; name: string; }
interface Membership { userId: string; entityId: string; role: string; }
interface IntegratorGrant { integratorUserId: string; entityId: string; canMintKeys: boolean; }
interface ApiKeyRef { id: string; unkeyKeyId: string; entityId: string; createdAt: Date; revokedAt?: Date; }
Key-mint invariant (enforced in the route, not the UI):
POST /api/keys with { entityId } MUST call
openfga.check({ user: 'integrator:'+session.userId, relation: 'can_mint_key', object: 'entity:'+entityId })
and only on allowed===true call Unkey. On deny → HTTP 403. Never mint without the check.
3. File Execution Matrix
| Action | Path |
|---|---|
| Create | apps/user-mgmt/package.json, tsconfig.json, next.config.ts, tailwind.config.ts |
| Create | apps/user-mgmt/prisma/schema.prisma |
| Create | apps/user-mgmt/src/lib/{zitadel,openfga,unkey,prisma}.ts |
| Create | apps/user-mgmt/src/app/api/{users,entities,members,grants,keys}/route.ts |
| Create | apps/user-mgmt/src/app/integrator/keys/page.tsx |
| Create | apps/user-mgmt/tests/ (openfga gate tests) |
| Create | apps/user-mgmt/README.md, .env.example |
| Modify | root README.md (monorepo note) |
4. Verification & Testing Strategy
cd apps/user-mgmt
npm ci
npx prisma generate
npx tsc --noEmit
npm test # openfga gate: deny-without-grant, allow-with-grant
npm run build # Next.js production build succeeds
Constraints: no secrets in code (all via env); OpenFGA check is server-side only.