openvibers/openvibe.network Commits

Docs: developer projects model, token claims, limits and quotas; contracts proposal (app claims, sandbox refusal, prj prefix, partner visibility, network.project.read|manage, network events)

OP OpenVibers committed 23 Sept 2026 · 846978952c · 7 files, +445 −0


Changes

ddiff --git a/README.md b/README.md
iindex 7cece48c..bff1c63d 100644
---- a/README.md
++++ b/README.md
@@@ -16,6 +16,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Protected by `X-Internal-Key`.
++- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance, recorded quotas and an append-only audit at `/api/v1/projects`. Bearer user tokens only; no portal UI yet (that is OpenVibe.Codes). See [docs/developer-projects.md](docs/developer-projects.md).
- **OpenCoins Wallet** — Network-wide currency. User balance/history at `/api/coins/*`; atomic credit/debit/transfer for services at `/internal/coins/*` with idempotency-key dedupe.
---
@@@ -357,6 +358,10 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
| `follows` | User follow relationships |
| `wallets` | OpenCoins balance per Network user |
| `coin_transactions` | OpenCoins ledger (idempotency-key deduped) |
++| `dev_projects`, `dev_project_members` | Developer projects and member roles |
++| `dev_apps`, `dev_credentials`, `dev_auth_codes` | Developer apps, hashed client secrets, app authorization codes |
++| `dev_grants`, `dev_quotas` | App capability grants; per-project quotas (enforced by the owning service) |
++| `dev_audit` | Append-only developer audit; rows that are platform events carry an event envelope |
---
ddiff --git a/docs/capabilities-proposal/network.project.manage.json b/docs/capabilities-proposal/network.project.manage.json
nnew file mode 100644
iindex 00000000..157494df
---- /dev/null
++++ b/docs/capabilities-proposal/network.project.manage.json
@@@ -0,0 +1,29 @@
++{
++ "id": "network.project.manage",
++ "version": "1.0",
++ "owner": "network",
++ "status": "planned",
++ "visibility": "public",
++ "description": "Manage developer projects on behalf of a signed-in member: projects, members, apps, credential rotation and revocation, grant requests and approvals within the project allowance. Today the API is used with the member's own Network access token and authorized by project role; this capability is what a delegated client (Codes CLI, SDK) will hold. Allowance, environment policy and quotas stay staff-only.",
++ "permissions": [
++ "projects:write"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "projects-write",
++ "events": [
++ "network.app.created",
++ "network.app.revoked",
++ "network.credential.rotated",
++ "network.credential.revoked",
++ "network.grant.changed"
++ ],
++ "implementedBy": [
++ "POST /api/v1/projects",
++ "POST /api/v1/projects/:project/apps",
++ "POST /api/v1/projects/:project/apps/:app/credentials/rotate",
++ "POST /api/v1/projects/:project/apps/:app/credentials/:credential/revoke",
++ "POST /api/v1/projects/:project/apps/:app/grants"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/network.project.read.json b/docs/capabilities-proposal/network.project.read.json
nnew file mode 100644
iindex 00000000..dbf38bdb
---- /dev/null
++++ b/docs/capabilities-proposal/network.project.read.json
@@@ -0,0 +1,19 @@
++{
++ "id": "network.project.read",
++ "version": "1.0",
++ "owner": "network",
++ "status": "planned",
++ "visibility": "first-party",
++ "description": "Read a developer project as an owning service: its apps and their revocation state, approved grants, environment policy and quotas. Services use it to key tenancy by project_id and to enforce the quotas Network records (ADR-014). Never grantable to apps.",
++ "permissions": [
++ "projects:read"
++ ],
++ "resourceConstraints": [
++ "project"
++ ],
++ "quotaClass": "projects-read",
++ "events": [],
++ "implementedBy": [
++ "GET /internal/projects/:project_id (planned)"
++ ]
++}
ddiff --git a/docs/contracts-proposal/README.md b/docs/contracts-proposal/README.md
nnew file mode 100644
iindex 00000000..a1802d92
---- /dev/null
++++ b/docs/contracts-proposal/README.md
@@@ -0,0 +1,56 @@
++# OpenVibe.Contracts proposal: developer projects (Wave 20 foundation)
++
++Changes Network needs in the next openvibe-contracts release. Network works without them today, because the new claims fit into `additionalProperties: true` and the sandbox check runs at issuance. Releasing them makes the rules binding for every receiver. Everything here is additive and backward compatible.
++
++## 1. `identity.service-token-claims@1`: optional app claims
++
++File: `identity.service-token-claims.v1.json`, the full proposed schema.
++
++- New optional `project_id` (`^prj_<ULID>$`).
++- New optional `env` (`sandbox | production`).
++- New optional `on_behalf_of` (`^usr_<ULID>$`, authorization-code tokens only).
++- New `allOf` rule: when `actor_type` is `app`, both `project_id` and `env` are required.
++
++First-party service tokens are unchanged: no `env`, and they are treated as production. Add fixtures: a valid app token with `env: sandbox`, and an invalid app token without `project_id`.
++
++## 2. `serviceAuth`: refuse sandbox tokens by default
++
++`verifyServiceToken(token, { ..., acceptSandbox = false })` returns `{ ok: false, code: 'token.sandbox_refused' }` for claims with `env: 'sandbox'` unless `acceptSandbox` is true. `requireCapability(capability, { ..., acceptSandbox })` passes it through, and the problem status is 401. An unknown `env` value fails claim validation (`token.invalid_claims`).
++
++Network's reference implementation is `server/developer/policy.js` `environmentDecision()`. Network's own guard already applies it.
++
++## 3. `ids`: a project prefix
++
++Add `project: 'prj'` to `PREFIX`, so `ids.newId('project')` works. Network currently builds `prj_` + `ids.ulid()`. Projects are not subjects, so `SUBJECT_TYPES` is unchanged.
++
++## 4. Capability `visibility`: add `partner`
++
++Today the enum is `public | first-party | internal`. The rule Network applies:
++
++| visibility | grantable to third-party apps |
++|---|---|
++| `public` | yes, within the project's allowance (and may appear in a default allowance) |
++| `partner` (new) | yes, but only when staff add it to one project's allowance by hand, never by default |
++| `first-party` | never |
++| `internal` | never |
++
++A capability must also have `status: active`. Network already treats `partner` as grantable, so releasing the enum value needs no Network change.
++
++## 5. New capabilities
++
++In `../capabilities-proposal/`:
++
++- `network.project.read`: first-party, planned. Owning services read a project's apps, revocation state, grants and quotas, so they can enforce quotas and key tenancy by `project_id`.
++- `network.project.manage`: public, planned. What a delegated client (Codes CLI, SDK) will hold to manage projects for a member. Today the API authorizes members' own user tokens by project role.
++
++## 6. Network service manifest
++
++File: `network.service-manifest.json`. It adds the two capabilities, and it adds these to `eventsProduced`:
++
++- `network.app.created`
++- `network.app.revoked`
++- `network.credential.rotated`
++- `network.credential.revoked`
++- `network.grant.changed`
++
++Payloads are in `../developer-projects.md#events`. They are recorded in Network's audit as envelopes but not relayed to Events yet.
ddiff --git a/docs/contracts-proposal/identity.service-token-claims.v1.json b/docs/contracts-proposal/identity.service-token-claims.v1.json
nnew file mode 100644
iindex 00000000..d647fe76
---- /dev/null
++++ b/docs/contracts-proposal/identity.service-token-claims.v1.json
@@@ -0,0 +1,106 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/identity/service-token-claims.v1.json",
++ "title": "ServiceTokenClaims",
++ "description": "Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.",
++ "type": "object",
++ "required": [
++ "iss",
++ "sub",
++ "actor_type",
++ "aud",
++ "cap",
++ "iat",
++ "exp",
++ "jti"
++ ],
++ "additionalProperties": true,
++ "properties": {
++ "iss": {
++ "type": "string",
++ "format": "uri",
++ "description": "Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken()."
++ },
++ "sub": {
++ "type": "string",
++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26})$"
++ },
++ "actor_type": {
++ "enum": [
++ "service",
++ "app",
++ "mod"
++ ]
++ },
++ "aud": {
++ "type": "array",
++ "minItems": 1,
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z0-9.-]+$"
++ }
++ },
++ "cap": {
++ "type": "array",
++ "items": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_*]+)+$"
++ },
++ "description": "Granted capability ids; a trailing .* grants a family."
++ },
++ "ns": {
++ "type": "array",
++ "items": {
++ "type": "string"
++ },
++ "description": "Namespace constraints, e.g. live.* or mod.example.*"
++ },
++ "iat": {
++ "type": "integer"
++ },
++ "exp": {
++ "type": "integer"
++ },
++ "jti": {
++ "type": "string",
++ "minLength": 8
++ },
++ "project_id": {
++ "type": "string",
++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "Developer project of an app principal (ADR-014). Services key tenancy by it. Absent on first-party service tokens."
++ },
++ "env": {
++ "enum": [
++ "sandbox",
++ "production"
++ ],
++ "description": "Environment of an app principal. A receiver MUST refuse env=sandbox (401 token.sandbox_refused) unless it opted in to sandbox tokens. Absent on first-party service tokens, which are production."
++ },
++ "on_behalf_of": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens."
++ }
++ },
++ "allOf": [
++ {
++ "if": {
++ "properties": {
++ "actor_type": {
++ "const": "app"
++ }
++ },
++ "required": [
++ "actor_type"
++ ]
++ },
++ "then": {
++ "required": [
++ "project_id",
++ "env"
++ ]
++ }
++ }
++ ]
++}
ddiff --git a/docs/contracts-proposal/network.service-manifest.json b/docs/contracts-proposal/network.service-manifest.json
nnew file mode 100644
iindex 00000000..c35695c6
---- /dev/null
++++ b/docs/contracts-proposal/network.service-manifest.json
@@@ -0,0 +1,46 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.2.0 <1.0.0"
++ },
++ "id": "network",
++ "name": "OpenVibe.Network",
++ "status": "stable",
++ "repository": "OpenVibers/OpenVibe.Network",
++ "domains": [
++ "openvibe.network",
++ "auth.openvibe.network",
++ "api.openvibe.network",
++ "admin.openvibe.network",
++ "my.openvibe.network",
++ "themes.openvibe.network"
++ ],
++ "publicOrigin": "https://openvibe.network",
++ "health": "/api/health",
++ "capabilities": [
++ "identity.subject.resolve",
++ "network.coins.credit",
++ "network.coins.debit",
++ "network.coins.transfer",
++ "network.notifications.push",
++ "network.modules.read",
++ "network.modules.write",
++ "mods.grant.manage",
++ "network.project.read",
++ "network.project.manage"
++ ],
++ "eventsProduced": [
++ "network.app.created",
++ "network.app.revoked",
++ "network.credential.rotated",
++ "network.credential.revoked",
++ "network.grant.changed"
++ ],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "identity.*",
++ "network.*",
++ "theme.*",
++ "notification.*"
++ ]
++}
ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
nnew file mode 100644
iindex 00000000..d054e743
---- /dev/null
++++ b/docs/developer-projects.md
@@@ -0,0 +1,184 @@
++# Developer projects
++
++Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
++
++What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, and the audit. What does not exist yet: a UI (Codes), a consent screen that lists capabilities, refresh tokens for apps, an Events relay for the audit's event rows, service-side reads of projects (`network.project.read`, proposed), and quota enforcement in any owning service.
++
++## Model
++
++| Thing | Id | Notes |
++|---|---|---|
++| Project | `prj_<ULID>` | One owner (`usr_` subject). Has a name, an environment policy, an allowance and members. Archiving a project cannot be undone and revokes every app in it. |
++| Member | subject `usr_<ULID>` | Role `owner`, `admin`, `developer` or `viewer`. There is exactly one owner, and ownership cannot be transferred through this API. |
++| App | `app_<ULID>` (subject `app:app_<ULID>`) | An OAuth client. Its `client_id` is its app id. Environment `sandbox` or `production`. Type `confidential` (has a secret) or `public` (no secret, authorization code + PKCE only). Apps are stored apart from `oauth_clients`, so they never inherit first-party trust (CORS, SSO fan-out, FedCM). |
++| Credential | `crd_<ULID>` | A client secret (`ovsec_…`, 256 random bits). Only its SHA-256 hash and last four characters are stored. |
++| Grant | (app, capability) | `requested`, `approved`, `denied` or `revoked`. The audience is `openvibe.<capability owner>`. |
++| Quota | (project, capability) | A limit, a window (`minute`, `hour`, `day`, `month`, `total`) and a unit (`requests`, `bytes`, `tokens`, …). |
++| Audit | integer, append-only | SQLite triggers refuse UPDATE and DELETE. |
++
++### Roles
++
++| Action | viewer | developer | admin | owner | staff (`users.role = admin`) |
++|---|---|---|---|---|---|
++| See the project, members, apps, grants, quotas | yes | yes | yes | yes | yes, any project |
++| Create or edit sandbox apps; rotate or revoke their secrets | | yes | yes | yes | |
++| Create or edit production apps; rotate or revoke their secrets | | | yes | yes | |
++| Revoke an app or a credential | | sandbox | yes | yes | yes |
++| Request a grant | | yes | yes | yes | |
++| Approve or deny a grant (inside the allowance only) | | | yes | yes | |
++| Revoke a grant | | | yes | yes | yes |
++| Add or remove developers and viewers | | | yes | yes | |
++| Add or remove admins | | | | yes | |
++| Read the audit | | | yes | yes | yes |
++| Archive the project | | | | yes | yes |
++| Set the allowance, environment policy and quotas | | | | | yes |
++
++Non-members get `404 project.not_found`, so a project's existence is not disclosed. Any member can leave, except the owner.
++
++### Environments
++
++A new project is `sandbox` only. Staff switch it to `sandbox+production`, which allows production apps. Apps never change environment: to move to production, create a production app. It gets its own credentials.
++
++## Grants, the allowance and the capability catalog
++
++- The only grants that exist are capabilities in the openvibe-contracts catalog, at the version Network has installed.
++- **The grantability rule.** The contracts `visibility` enum is `public | first-party | internal`. Only `active` capabilities with visibility `public` may be granted to apps. `first-party` and `internal` capabilities are never grantable: staff cannot add them to an allowance, members cannot request them, and token issuance filters them out again. The code also accepts a proposed `partner` visibility. Staff may put a `partner` capability in one project's allowance by hand, but it never comes from the default allowance. `partner` is not in the enum yet; see `docs/contracts-proposal/`.
++- **The allowance** is the set of grantable capabilities one project's apps may hold. Only staff set it. `DEV_DEFAULT_ALLOWANCE` seeds new projects, with public capabilities only. It is empty by default, so staff decide each project.
++- **A grant never exceeds the allowance.** It is checked in three places. Approval refuses with `403 grant.beyond_allowance`. Shrinking the allowance revokes approved grants, and denies requested ones, that fall outside it. Token issuance intersects approved grants with the current allowance and with the current grantability.
++- A developer's grant request waits in `requested`. When an owner or admin makes the request and the capability is inside the allowance, it is approved at once.
++- `GET /api/v1/projects/catalog` lists what could ever be granted.
++
++## Tokens
++
++Apps use the same `/oauth/token` endpoint as service principals. A client id of the form `app_<ULID>` routes the request to the developer path.
++
++| App type | Grant | Request |
++|---|---|---|
++| confidential | `client_credentials` | `client_id`, `client_secret`, `audience`, optional `scope` (space-separated capability ids to narrow) |
++| public or confidential | `authorization_code` | `client_id`, `code`, `redirect_uri`, `code_verifier`, `audience`, optional `scope`, plus `client_secret` if confidential |
++
++Authorization code flow: `/oauth/authorize` needs `code_challenge` with `code_challenge_method=S256` for every app, public or confidential. The redirect URI must match a registered one exactly. `prompt=none` is refused with `error=interaction_required`, so an app never gets a code without the person choosing to continue. The account chooser labels the app "(third-party app)". A sandbox app can be authorized only by members of its project. Codes are single use and expire after 5 minutes. A failed PKCE check burns the code. The `scope` passed to `/authorize` (capability ids) limits what the code can yield, and the exchange can narrow it but never widen it.
++
++### Claims
++
++Tokens are RS256 and last 5 minutes. They are signed with Network's key (JWKS at `/api/.well-known/jwks`) and shaped by `identity.service-token-claims@1`.
++
++| Claim | Value |
++|---|---|
++| `sub` | `app:app_<ULID>` |
++| `actor_type` | `app` |
++| `aud` | `[audience]`, one audience per token |
++| `cap` | approved grants for that audience ∩ allowance ∩ grantable now (or the requested `scope` subset) |
++| `ns` | `[project_id]`. Tenancy in other services is keyed by project id (ADR-014). |
++| `project_id` | `prj_<ULID>` (optional in the contract today; proposed as required for apps) |
++| `env` | `sandbox` or `production` (same) |
++| `on_behalf_of` | `usr_<ULID>` of the person who authorized the app (authorization code only) |
++| `iat`, `exp`, `jti` | 300-second lifetime |
++
++No refresh tokens are issued to apps. A confidential app asks again with its secret, and a public app goes through authorization again.
++
++### Sandbox tokens
++
++The sandbox check works in two layers.
++
++1. **Issuance.** A sandbox app gets a token only for an audience listed in `DEV_SANDBOX_AUDIENCES`, which is empty by default. Any other audience gets `400 invalid_target`. This layer protects every receiver today, including ones whose openvibe-contracts `verifyServiceToken` does not know about `env` yet.
++2. **Receivers.** A receiver refuses `env: sandbox` with `401 token.sandbox_refused` unless it opted in. Network does this in its own capability guard (`server/identity/principals.js`) and accepts sandbox only if `openvibe.network` is in `DEV_SANDBOX_AUDIENCES`. `server/developer/policy.js` exports `environmentDecision(claims, { acceptSandbox })` for the check. The proposal adds the same check to openvibe-contracts `verifyServiceToken` / `requireCapability` as an `acceptSandbox` option that defaults to false.
++
++An audience opts in when it can keep sandbox traffic apart from real data: for example, test-flagged money in Billing (ADR-012 rule 9), or a sandbox tenant in Media. First-party service tokens carry no `env` and are treated as production.
++
++### Revocation
++
++| Action | Effect on new tokens | Effect on tokens already issued |
++|---|---|---|
++| Revoke a credential | That secret fails at once (`401 invalid_client`) | They stay valid until `exp`, at most 5 minutes |
++| Rotate a credential | New secret returned once. Previous secrets stay valid for `overlap_seconds` (default `DEV_CREDENTIAL_OVERLAP_S` = 86400, range 0–604800), then fail. | unchanged |
++| Revoke an app | All of its credentials and pending codes fail at once | at most 5 minutes |
++| Revoke a grant, or shrink the allowance | The capability leaves new tokens at once | at most 5 minutes |
++| Archive a project | Every app is revoked | at most 5 minutes |
++
++This is ADR-014's acceptance rule: "a revoked credential fails everywhere within one token lifetime (5 minutes)". Receivers verify tokens offline, so Network cannot recall a token it has already issued.
++
++## Quotas
++
++A quota says how much of one capability a project may use: for example, `media.object.upload` with limit 1073741824, window `total`, unit `bytes`. Network records quotas and shows them to members. **Network does not enforce them.** The service that owns the capability does (`enforced_by` in the response is that audience). Until an owning service reads project quotas (proposed capability `network.project.read`), a quota is a recorded limit and not a guarantee. No usage numbers are collected here.
++
++## API
++
++Base: `/api/v1/projects`. Every call needs `Authorization: Bearer <Network user access token>`.
++
++- Cookies are not read, so this API has no CSRF surface.
++- `X-Internal-Key` is never accepted.
++- Service and app tokens are not users and get `401`.
++- Tokens past `exp` are refused, even inside the normal 60-day session grace period.
++- Errors are RFC 9457 `application/problem+json`, with a stable `code`.
++- Every response carries `X-OpenVibe-Request-Id` (a caller's own id is echoed back) and `traceparent`.
++- Responses are `Cache-Control: private, no-store`.
++- Rate limit: 60 requests per minute (plus the global `/api` limit).
++
++| Method and path | Who | Body / result |
++|---|---|---|
++| `GET /catalog` | any user | grantable capabilities |
++| `POST /` | any user | `{ name }` → project (caller is owner). At most `DEV_MAX_PROJECTS_PER_OWNER` active projects. |
++| `GET /[?all=1]` | any user | my projects; staff may pass `all=1` |
++| `GET /:project` | viewer+ | project with `role`, `allowance`, `environments`, counts |
++| `PATCH /:project` | admin+ | `{ name }` |
++| `POST /:project/archive` | owner, staff | irreversible; revokes every app |
++| `PUT /:project/allowance` | staff | `{ capabilities: [...] }` → `{ allowance, trimmed }` |
++| `PUT /:project/environment-policy` | staff | `{ environment_policy: 'sandbox' \| 'sandbox+production' }` |
++| `GET /:project/members` | viewer+ | |
++| `POST /:project/members` | admin+ (owner for `admin`) | `{ username \| subject_id, role }` |
++| `PATCH /:project/members/:subject` | admin+ (owner for admins) | `{ role }` |
++| `DELETE /:project/members/:subject` | admin+, or yourself | |
++| `GET /:project/apps`, `GET /:project/apps/:app` | viewer+ | never secrets |
++| `POST /:project/apps` | developer+ (admin+ for production) | `{ name, environment, type, redirect_uris }` → app, plus `credential.client_secret` **once** if confidential |
++| `PATCH /:project/apps/:app` | as above | `{ name, redirect_uris }` |
++| `DELETE /:project/apps/:app` | as above, or staff | revoke |
++| `GET /:project/apps/:app/credentials` | viewer+ | id, last four characters, state (`active`, `expiring`, `expired`, `revoked`), dates |
++| `POST /:project/apps/:app/credentials/rotate` | as app | `{ overlap_seconds? }` → new secret **once** |
++| `POST /:project/apps/:app/credentials/:credential/revoke` | as app, or staff | immediate |
++| `GET /:project/apps/:app/grants` | viewer+ | |
++| `POST /:project/apps/:app/grants` | developer+ | `{ capability }` |
++| `POST /:project/apps/:app/grants/:capability/approve` / `deny` | admin+ | |
++| `DELETE /:project/apps/:app/grants/:capability` | admin+, staff | revoke |
++| `GET /:project/quotas` | viewer+ | |
++| `PUT /:project/quotas/:capability` / `DELETE` | staff | `{ limit, window, unit }` |
++| `GET /:project/audit[?before=&limit=]` | admin+, staff | newest first, paged by `next_before` |
++
++Redirect URIs must be https. `http://localhost`, `127.0.0.1` and `[::1]` are allowed for sandbox apps only. Fragments and embedded credentials are refused. At most 10 per app. A public app needs at least one. A project has at most `DEV_MAX_APPS_PER_PROJECT` active apps.
++
++## Events
++
++Topic, with subject and payload:
++
++- `network.app.created`: subject `{ type: 'app', id }`, payload `{ project_id, environment, client_type }`
++- `network.app.revoked`: subject `{ type: 'app', id }`, payload `{ project_id, environment, reason }`
++- `network.credential.rotated`: subject `{ type: 'app', id }`, payload `{ project_id, credential_id, previous_valid_until }`
++- `network.credential.revoked`: subject `{ type: 'app', id }`, payload `{ project_id, credential_id }`
++- `network.grant.changed`: subject `{ type: 'app', id }`, payload `{ project_id, capability, audience, from, to }`
++
++Network has no Events producer yet: no outbox, no Events client. Each event is written as a validated `events.event-envelope@1` in the same transaction as the change, inside its `dev_audit` row (`event_type`, `event`). This matches the outbox semantics. **TODO:** when Network gets an Events client, relay those rows in id order, keyed by `event_id`, instead of adding a second write path. Until then nobody else receives these events. Services must not assume they do.
++
++## Secrets
++
++- A client secret is returned only in the response that created it (app creation or rotation), marked `shown_once`. After that it cannot be retrieved. Rotate to get a new one.
++- Only `sha256(secret)` is stored. The secret is 256 random bits, so a fast hash is enough.
++- Audit rows, error responses and logs carry credential ids and the last four characters, never the secret. Unexpected errors are logged without request bodies.
++- The test (`test/developer-projects.test.js`) scans every table and all captured log output for each secret it created.
++
++## Configuration
++
++| Env var | Default | Meaning |
++|---|---|---|
++| `DEV_SANDBOX_AUDIENCES` | empty | Audiences that accept sandbox tokens (comma list) |
++| `DEV_CREDENTIAL_OVERLAP_S` | 86400 | Default rotation overlap (0–604800) |
++| `DEV_DEFAULT_ALLOWANCE` | empty | Public capabilities a new project starts with |
++| `DEV_MAX_PROJECTS_PER_OWNER` | 10 | Active projects per owner |
++| `DEV_MAX_APPS_PER_PROJECT` | 20 | Active apps per project |
++
++## Known gaps (next steps)
++
++- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live.
++- **Service-side project reads.** `network.project.read` (proposed) and `GET /internal/projects/:id` let owning services enforce quotas and check tenancy.
++- **Events relay.** See the TODO in [Events](#events).
++- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
++- **Receivers.** Media, Events and other services must adopt the `env` check (contracts proposal) and key tenancy by `project_id` before they opt in to sandbox tokens.