AUTH4D-18 project and application management
Developers manage projects (customer tenants) and their public applications through
/management/tenants/**. Every route first passes AUTH4D-17 management authentication: a
control-plane access token with the route’s management scope and a live control-plane session.
The projects module (apps/api/src/modules/projects/) then re-reads the caller’s project role
from D1 on every request. The console reaches these routes through its BFF at
/api/management/tenants/**; browsers never hold management tokens.
Roles and authorization
| Operation | Token scope | Project role |
|---|---|---|
| List / create projects | tenant:read / tenant:write |
any authenticated developer |
| Read a project and its settings | tenant:read |
owner, admin, viewer |
| Change project settings, providers, secrets | tenant:write |
owner |
| List / read applications | client:read |
owner, admin, viewer |
| Create, update, disable applications | client:write |
owner, admin |
Roles follow the shared tenancy matrix (packages/tenancy hasProjectPermission). A missing
token scope returns 401. A caller without a membership in the project receives 403 forbidden
(unknown and foreign projects are indistinguishable); a member whose role lacks the capability
receives 403 insufficient_role. Writes to a suspended project return 409 project_suspended.
An application ID is only resolved inside the project in the path, so another project’s client
ID returns 404.
Memberships live in the foundation memberships table, keyed by (tenant_id, user_id) where
user_id is the developer’s control-plane user ID. Migration 0004_project_management.sql adds
project_role (owner | admin | viewer); triggers keep it consistent with the coarse
role (owner | member). Rows without project_role keep working: owner is owner and
member is a read-only viewer. The membership foreign key needs a tenant-local users anchor
row for the developer; it has no identities, so it can never sign in.
Projects
GET /management/tenants lists the caller’s projects. Items keep the AUTH4D-17 shape:
{
"tenants": [
{
"tenant_id": "ten_…",
"display_name": "Space Game",
"status": "active",
"created_at": "2026-10-04T00:00:00.000Z",
"role": "owner"
}
]
}
POST /management/tenants creates a project:
{ "displayName": "Space Game", "idempotencyKey": "optional-16-to-128-chars" }
One D1 batch (one transaction) inserts the tenant (/t/{tenantId} issuer, customer realm), the
caller’s owner membership and its anchor row, default project_settings, the email
(enabled) and discord (disabled) provider rows, the optional idempotency record, and the
management.tenant.created audit event. Any failure rolls back all of them. Each developer may
own at most 25 projects (409 project_limit_reached). Response 201:
{
"tenant": {
"tenantId": "ten_…",
"displayName": "Space Game",
"status": "active",
"createdAt": "…"
},
"role": "owner",
"settings": {
"signupEnabled": true,
"guestEnabled": true,
"allowedScopes": ["openid", "profile", "email", "offline_access"],
"providers": {
"email": { "enabled": true },
"discord": {
"enabled": false,
"clientId": null,
"clientSecretConfigured": false,
"clientSecretUpdatedAt": null
}
}
}
}
GET /management/tenants/{tenantId} returns the same document for any member.
PATCH /management/tenants/{tenantId} (owner only) accepts any non-empty subset of:
{
"displayName": "Space Game 2",
"signupEnabled": false,
"guestEnabled": true,
"allowedScopes": ["openid", "profile", "game:read"],
"providers": {
"email": { "enabled": true },
"discord": { "enabled": true, "clientId": "123456789012345678", "clientSecret": "…" }
}
}
allowedScopesis the project ceiling for application scopes. It must includeopenid, be unique, and must not contain management scopes or reserved namespaces (auth4:,management:,tenant:,client:,user:,session:,audit:,admin:). Removing a scope still used by an application fails with400 scope_in_use.- Every existing application is re-validated with
validateApplicationPolicyagainst the proposed signup and provider policy before anything is written. - Enabling Discord requires an application ID (17–20 digit snowflake) and a client secret;
changing the application ID requires a new secret (
400 provider_credentials_required). clientSecretis write-only. It is encrypted with AES-256-GCM usingAUTH_ENCRYPTION_KEY_RING, bound by authenticated data to the tenant and the purposeprovider:discord:client_secret, and stored inprovider_credentials. Responses only reportclientSecretConfiguredandclientSecretUpdatedAt; audit metadata records onlyproviderandstatus. Without the key-ring secret the request fails with503and nothing is stored.- Changes write the
management.tenant.updatedevent plusmanagement.provider.updatedfor each provider touched, in the same batch.
Applications
Applications are public clients only: browser (authorization code with S256 PKCE plus refresh)
or native (device authorization plus refresh). Callers never choose credentials, grants or
audiences: clientType: "server" returns 400 privileged_client_type, and any of
tokenEndpointAuthMethod, clientSecret, grantTypes, audience, audiences, tenantId or
clientId in a body returns 400 reserved_field. Responses use the contract
ManagementClientResponse / ManagementClientListResponse shapes and never include secret hashes.
-
GET /management/tenants/{tenantId}/clients→{ "clients": [ClientPolicy…] } -
POST /management/tenants/{tenantId}/clients→201 { "client": ClientPolicy }{ "displayName": "Web client", "clientType": "browser", "redirectUris": ["https://game.example.test/callback"], "allowedOrigins": ["https://game.example.test"], "allowedScopes": ["openid", "profile"], "idempotencyKey": "optional" }allowedScopesdefaults to the built-in OIDC scopes allowed by the project. A project may hold at most 50 applications (409 client_limit_reached). -
GET /management/tenants/{tenantId}/clients/{clientId}→{ "client": ClientPolicy } -
PATCH(orPUT)/management/tenants/{tenantId}/clients/{clientId}accepts any non-empty subset ofdisplayName,enabled,redirectUris,allowedOrigins,allowedScopes.clientTypeis accepted only when unchanged (400 immutable_field). -
DELETE /management/tenants/{tenantId}/clients/{clientId}disables the application (soft delete, idempotent) and returns it. Like every management write, it needsContent-Type: application/jsonand a JSON body such as{}.
Validation (400, details[].field names the field):
invalid_redirect_uri— not HTTPS (loopbackhttp://localhost,127.0.0.1,[::1]allowed), contains credentials, a fragment or*, uses an auth4.dev origin (AUTH_ORIGIN,CONSOLE_ORIGIN,SITE_ORIGIN), a browser client without a redirect, or a native client with redirects or origins.invalid_origin— allowed origins must be exact canonical origins with the same rules.invalid_scope— a reserved scope or one outside the project’sallowedScopes.validation_failed— any other schema or tenancy-policy violation.
Writes emit management.client.created, management.client.updated or
management.client.disabled with client_type, status and scope_count metadata.
Idempotent creation
Both create routes accept an optional idempotencyKey (16–128 of [A-Za-z0-9_-]) in the body;
the console BFF does not forward custom request headers. Keys are scoped to the calling developer
and stored in management_idempotency inside the creation transaction. Repeating a key with the
same request returns 200 with the current state of the original resource; reusing it for a
different request returns 409 idempotency_conflict. Concurrent duplicates are resolved by the
primary key: the losing batch rolls back and replays the winner.
Effect on authentication
Authentication features read configuration from D1 on every request, so changes apply to the next
decision: disabled applications and changed redirects or origins are enforced by the OAuth,
Discord, email and guest modules; provider rows gate email and Discord sign-in. Discord sign-in
uses the stored, decrypted credential in preference to DISCORD_CLIENT_SECRET bindings and fails
closed if a stored envelope cannot be decrypted. createProjectPolicyAdapters(db) supplies the
trusted signupEnabled and resolveEnabledProviders options for createTenantPolicyRepository,
applying signupEnabled and guestEnabled.
Errors
All errors use { "error": { "code", "message", "request_id", "details"? } }. Unexpected storage
failures return 503 temporarily_unavailable without partial writes.
Operations
Apply infra/cloudflare/d1/migrations/0004_project_management.sql. The API Worker needs the
AUTH_ENCRYPTION_KEY_RING secret ({"version":1,"activeKid":"…","keys":[{"kid":"…","key":"<32-byte base64url>"}]})
to store provider secrets; keep retired keys in the ring until stored envelopes are re-saved.
Tests: tests/integration/auth4d-18.projects.test.ts (run pnpm test) applies the real D1
migrations and covers atomic creation and rollback, idempotent replay, browser and native
lifecycle, redirect and privilege validation, owner/admin/viewer and cross-project denial, token
scopes, encrypted Discord credential replacement and its use by Discord sign-in, and policy
changes observed by later authentication decisions.
Source: docs/api-spec/auth4d-18.md