diff --git a/serverless_cloud_blueprint.md b/serverless_cloud_blueprint.md index e795ff5..fba8dc6 100644 --- a/serverless_cloud_blueprint.md +++ b/serverless_cloud_blueprint.md @@ -1,7 +1,7 @@ # Project Blueprint: Lightweight Event-Based Serverless Cloud **Status**: Phase 4 — Blueprint Complete -**Last Updated**: 2026-04-10 +**Last Updated**: 2026-05-27 **Audience**: Solo developer (DIY self-hosted) --- @@ -1156,6 +1156,35 @@ DELETE /api/v1/admin/api-keys/{id} — caller's own only Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity). +### App Member Management Endpoints + +Exposes the `app_members` table as a first-class CRUD surface so app admins can manage who they share an app with from the dashboard, not just from SQL. + +``` +GET /api/v1/admin/apps/{id_or_slug}/members — list members (ordered by username), + joined with admin_users for + username / email / instance_role / is_active +POST /api/v1/admin/apps/{id_or_slug}/members — { user_id, role } → 201 enriched DTO + 409 on duplicate (promotions go through PATCH) + 422 if target user is_active = false + 422 if target user instance_role != 'member' + (owners/admins have implicit authority; + an explicit row would be dead weight) +PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id} — { role } → 200 enriched DTO + 404 if no existing membership +DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id} — 204 (idempotent — 204 also when missing) +``` + +All four are gated on `Capability::AppAdmin(app_id)`. Editors and viewers get 403 on list and never see the dashboard's Members tab. + +**`my_role` on the app lookup endpoint.** `GET /api/v1/admin/apps/{id_or_slug}` now returns an additional `my_role: Option`, computed server-side from the principal: `Owner → app_admin`, `Admin → editor`, `Member → app_members.role`. The dashboard uses this single field to decide whether to render the Members tab (visible iff `my_role == app_admin`), keeping API and UI gate logic identical. + +**No last-app-admin guard.** Unlike the last-owner protection on `admin_users`, removing the final `app_admin` row from `app_members` is allowed. Every `owner` instance-role user implicitly satisfies `Capability::AppAdmin(_)` via the top-level `role_grants` branch, so no app can become permanently orphaned — an owner can always re-issue grants. The `admin` instance role is only implicit *editor*, so it does **not** provide a fallback path; the owner guarantee alone is what makes the no-guard position safe. + +**Dead-row sweep on promotion (deferred).** Promoting a user from `member` → `admin`/`owner` leaves their `app_members` rows in place. They become inert (implicit grants supersede), but are not auto-deleted. A future hook can sweep them; harmless for now. + +Additive within `/api/v1/admin/...` — no API major bump per [docs/versioning.md](docs/versioning.md). + ### Out of Scope (Phase 3.5) Schema room only, not built: @@ -1164,7 +1193,7 @@ Schema room only, not built: - **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`. - **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row. -Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping. +Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl is the supported interface this phase — member management has a dashboard tab; see above), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping. ---