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)
Changes
@@@ -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 |---nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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)"++ ]++}nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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"++ ]++ }++ }++ ]++}nnew file mode 100644@@@ -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.*"++ ]++}nnew file mode 100644@@@ -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.
@@ -16,6 +16,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -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.
- **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.
- **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`.
- **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.
- **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/`.
@@ -357,6 +358,10 @@ Accessible to users with `role = 'admin'`. All endpoints under `/api/admin/`.
| `follows` | User follow relationships |
| `follows` | User follow relationships |
| `wallets` | OpenCoins balance per Network user |
| `wallets` | OpenCoins balance per Network user |
| `coin_transactions` | OpenCoins ledger (idempotency-key deduped) |
| `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 |
---
---
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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)"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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"
+ ]
+ }
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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.*"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -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.