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": "…" }
  }
}
  • allowedScopes is the project ceiling for application scopes. It must include openid, 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 with 400 scope_in_use.
  • Every existing application is re-validated with validateApplicationPolicy against 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).
  • clientSecret is write-only. It is encrypted with AES-256-GCM using AUTH_ENCRYPTION_KEY_RING, bound by authenticated data to the tenant and the purpose provider:discord:client_secret, and stored in provider_credentials. Responses only report clientSecretConfigured and clientSecretUpdatedAt; audit metadata records only provider and status. Without the key-ring secret the request fails with 503 and nothing is stored.
  • Changes write the management.tenant.updated event plus management.provider.updated for 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"
    }

    allowedScopes defaults 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 (or PUT) /management/tenants/{tenantId}/clients/{clientId} accepts any non-empty subset of displayName, enabled, redirectUris, allowedOrigins, allowedScopes. clientType is 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 needs Content-Type: application/json and a JSON body such as {}.

Validation (400, details[].field names the field):

  • invalid_redirect_uri — not HTTPS (loopback http://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’s allowedScopes.
  • 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