Directory module
Identity, organisations, users, roles, and permissions. Every other module reads identity.* for the principal performing a request — this module is the foundation everything else depends on.
- Source:
apps/directory/ - Schema:
identity - Project Tracker prefix:
DIR-* - Hosting: root zone in the multi-zone topology —
constellation.planetb2b.comis served by Directory, which rewrites/projects/*and/catalog/*to the sub-zones.
1. Purpose
Directory owns who in Constellation: tenants, organisations, users, memberships, roles, permissions, and the small slice of policy-glue (locale, time zone, currency) that the platform reads in every request. Every authenticated request flows through Directory's middleware — createConstellationAuthMiddleware — even when the destination is a sub-zone like Catalog or Project Tracker. Tenant locale resolution, the active-membership lookup that withTenantAuth performs, and the user/role/permission tables every other module joins to are all owned here.
2. Component diagram (C4 L3)
Call direction is strict: API Route → Tool → Service → Repository. Routes never call services or repositories directly. Workflows and event handlers must call tools or services, never repositories. See route-wrapping and module-isolation.
3. Schema
identity — owned by Directory; read-only for every other module. Cross-module reads of identity.users / identity.organisations use raw SQL (no Prisma cross-schema joins) per module-isolation.
4. Entities
| Aggregate | Purpose |
|---|---|
tenants | Top-level isolation boundary. Owns default_locale, enabled_locales, time_zone, currency. |
organisations | Member companies inside a tenant. Hierarchical via parent_id. |
users | Principals. tenant_id is the user's "home tenant". |
user_tenant_memberships | Active-tenant resolution. withTenantAuth validates membership before any tenant-scoped call. |
roles, permissions | RBAC. role_permissions and user_roles are the join tables. |
organisation_certifications | Compliance state with expiry → drives the credential.expired event. |
organisation_competence_domains | Capability tags for organisation matching (used by Catalog supplier offer matching). |
5. Domain events
Published from src/server/events/directory.events.ts via the transactional outbox under the directory.* namespace. The full payload schemas live in @constellation/contracts and are catalogued in the Domain events index.
directory.tenant.created, directory.tenant.updated, directory.organisation.created, directory.organisation.verified, directory.organisation.verification.requested, directory.organisation.verification.rejected, directory.organisation.verification.completed, directory.user.created, directory.user.suspended, directory.user_credential.updated, directory.role.assigned, directory.role.unassigned, directory.role.permission_changed, directory.credential.expired, directory.credential.expiry.approaching, directory.credential.expiry.imminent, directory.qualification.updated.
6. Public API
The Directory API reference is not yet wired into this site — adding Zod→OpenAPI to apps/directory/ is tracked under INF-27. Illustrative routes:
POST /api/organisations— create a new organisation in the active tenant. Worked-example of the full request lifecycle is in Request lifecycle.GET /api/users/:userId— read a user with role assignments.POST /api/auth/memberships— bootstrap call after login; resolves the user's set of{ tenantId, organisationId }pairs for the org switcher.
7. Layers + call direction
| Layer | Path | May import from |
|---|---|---|
| API Routes | src/app/api/ | Tools only |
| Tools | src/server/tools/ | Services, Policies, Events |
| Services | src/server/services/ | Repositories, Policies, @constellation-platform/db |
| Repositories | src/server/repositories/ | @constellation-platform/db (Prisma client + raw SQL) |
| Policies | src/server/policies/ | @constellation-platform/auth-core |
| Workflows | src/server/workflows/ | Tools or Services (never repositories) |
| Events | src/server/events/ | @constellation-platform/events publish() + outbox |
Enforced at PR time by scripts/check-route-wrapping.ts — invoked via npm run check:routes, which scans apps/directory/src/app/api and apps/catalog/src/app/api. Every withAuth must be paired with withTenantAuth. Plus ESLint import boundaries.
8. Code entry points
- Tool:
apps/directory/src/server/tools/organisation.tools.ts—createOrganisation,verifyOrganisation, etc. - Service:
apps/directory/src/server/services/organisation.service.ts—OrganisationService.create, domain invariants. - Repository:
apps/directory/src/server/repositories/organisation.repository.ts— Prisma writes + RLS-enforced reads. - Events module:
apps/directory/src/server/events/directory.events.ts— event-type registry, payload validators,publish()wrappers. - Workflow:
apps/directory/src/server/workflows/credential-expiry.workflow.ts— daily cron that firescredential.expiry.{approaching,imminent,expired}events. - Tenant-auth wrapper:
apps/directory/src/server/tenant-auth.ts— composeswithAuth+withTenantAuthinto the per-appauthedRoute()helper.
9. Known exceptions / pitfalls
- Directory is the root zone in production. It serves
constellation.planetb2b.comand rewrites/projects/*→constellation-platform.vercel.appand/catalog/*→constellation-catalog.vercel.app. Multi-zone wiring lives inapps/directory/next.config.ts. - Tenant locale policy lives here.
tenants.default_locale ∈ tenants.enabled_localesis enforced by a DB CHECK; the form validates client-side too.users.preferred_locale = NULLmeans "inherit tenant default". The locale resolver wires into the platform auth middleware via thelocaleResolveroption — only Directory wires it today; Catalog and Project Tracker stay on the existing path until they opt in. See Multilanguage (i18n). - No Prisma models for cross-module identity reads. Other apps that need to read
identity.users/identity.organisationsuse raw SQL via repositories like Project Tracker'sIdentityUserRepo— never Prisma cross-schema joins. (admin)route group is platform-operator-only. Tenant admins use/settings, not/tenants/[id]. The two pages enforce different RBAC scopes.
Pre-tenant-context identity resolution (RLS bootstrap)
/api/auth/me and the org switcher must read identity.users / identity.user_tenant_memberships before app.tenant_id is set — the membership row is precisely what validates the tenant. Under a non-bypass runtime role those tables are filtered to zero rows by FORCE RLS, so the lookups go through SECURITY DEFINER functions owned by the NOLOGIN identity_bootstrap role: resolve_active_membership / list_active_memberships (migration 016) and resolve_identity_user (migration 024, DIR-72). The bootstrap RLS policies (USING pg_has_role(current_user, 'identity_bootstrap', 'USAGE')) match only inside those functions; outside them tenant isolation is unaffected.
resolve_identity_user is id-first with a fail-closed email fallback: it matches by primary key first and only falls back to email when exactly one non-deleted row matches. identity.users.email is unique only per tenant (and the synthetic agent emails exist in every tenant), so a shared address resolves to no row rather than hydrating an arbitrary tenant.
- Invariant: the app runtime role (
constellation_app) must NOT be a member ofidentity_bootstrap. That membership makes the bootstrap "see-all" policies fire for every direct app query, leakingusers/membershipscross-tenant (DIR-72; the residual INF-60 grant is revoked in DIR-79). - Dependency on INF-60. All of the above only enforces isolation once the app connects as a non-bypass role. While production connects as
postgres(rolbypassrls = true) via Supavisor, identity RLS is inert and these functions are a no-op for isolation — INF-60 switchesDATABASE_URLtoconstellation_app.
10. MCP OAuth authorize endpoint (DIR-75)
Directory acts as the identity provider for the MCP OAuth authorization code grant flow. This enables Claude Connectors, ChatGPT OAuth mode, Codex, and other MCP clients to authenticate users and receive identity claims via a standards-compliant OAuth handshake.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/auth/mcp/authorize | Browser-facing authorize redirect handler. Validates the redirect_uri origin against MCP_REDIRECT_ORIGIN_ALLOWLIST before any session check; writes mcp_authz_pending signed cookie; redirects to /login?redirect=/api/auth/mcp/authorize if unauthenticated. |
POST | /api/auth/mcp/authorize | Consent form submission. Validates CSRF double-submit cookie; on Allow issues the authorization code; on Deny records a denial audit entry. |
GET | /mcp/authorize | Consent page. Server component that reads the mcp_authz_pending and mcp_csrf cookies (both set by the GET Route Handler) and renders the consent UI (client_id, redirect_uri host, scopes, Allow/Deny) with the CSRF nonce embedded in the form. |
POST | /api/internal/auth/mcp-token | Server-to-server only. The MCP AS (INF-164) sends this to exchange the one-time code for identity claims. Protected by X-Mcp-Client-Secret (constant-time check); no browser session required. |
Audit action: mcp_oauth_consent
On a successful token exchange, auditCritical() is called with:
action = 'mcp_oauth_consent'resourceType = 'mcp_client'resourceId = client_idmodule = 'directory'changes = { scope, redirect_uri, client_id }
On a denied consent, auditAction() is called with action = 'mcp_oauth_consent_denied'.
Environment variables
| Variable | Purpose |
|---|---|
MCP_CLIENT_SECRET | Shared secret validated with timingSafeEqual on POST /api/internal/auth/mcp-token. |
MCP_REDIRECT_ORIGIN_ALLOWLIST | Comma-separated exact scheme+host allowlist (e.g. https://constellation-pt-mcp.vercel.app). |
MCP_COOKIE_SECRET | HMAC-SHA256 key with a dual use: it signs the mcp_authz_pending cookie and keys the code_hash HMAC for identity.mcp_authorization_codes. Rotating it invalidates outstanding authorization codes as well as in-flight cookies. mcp_csrf is an unsigned random double-submit nonce and is NOT signed with this secret. |
Trust model
The real trust anchor is the redirect_uri allowlist — the one-time authorization code is only ever 302'd to an allowlisted origin. A spoofed client_id cannot exfiltrate the code because it is always delivered to the already-verified origin. This is stated explicitly on the consent screen UI.
See also
- Request lifecycle — worked example uses
POST /api/organisationsin this module. - Architecture overview — C4 L1 / L2 view of how Directory sits inside the platform.
- Rules & invariants — the cross-module rules every layer enforces.
- Domain events index — full payload schemas for the events listed in §5.