Managing which apps a member may use
MULTI_APP_ENABLEDThe tenant-admin half of the app dimension, and the sibling of scenario 48: roles say what a member may do INSIDE an app, grants say which apps they may enter at all. An operator reaches this the same way — through act-as (scenario 37). The tenant must first be enabled for the app (scenario 41). THIS IS THE ADMIN-DRIVEN HALF: a member can also acquire a FREE-TO-ACQUIRE app for an organization they administer themselves, without an admin or an operator, at scenario 50 — the same write, reached from the other side.
Every endpoint on this page sits behind MULTI_APP_ENABLED and answers 404 when it is off. A 404 here means the flag, not a wrong path.
- GET /v1/tenant/apps → the tenant's app estate with live grantedMembers counts and backfilledAt. The identity app is excluded
- GET /v1/users/{userId}/apps → EVERY app the tenant is enabled for, each flagged granted or not for this member — the whole toggle set from one call
- PUT /v1/users/{userId}/apps/{appCode} → grant. Idempotent un-delete upsert; emits app_grant_granted
- DELETE /v1/users/{userId}/apps/{appCode} → revoke (soft delete); emits app_grant_revoked
/v1/tenant/appsadminThe tenant's app estateThe ADMIN view, not the member's own drop-down (that is GET /v1/me/apps, scenario 17). grantedMembers is counted live, so it reflects revocations immediately. The identity app is EXCLUDED: every member reaches it by virtue of membership, so listing it would invite an admin to try to revoke access they cannot revoke.
Field guide — what each value means & where it comes fromno inputs
The tenant-admin view of the tenant's app estate — every app the tenant is enabled for, with live grantedMembers counts and backfilledAt. Admin-only, and deliberately not the member's own drop-down (that is GET /v1/me/apps). No inputs; the tenant comes from the session. The identity app is EXCLUDED: every member reaches it by virtue of membership, so listing it would invite an admin to try to revoke access they cannot revoke.
GET https://api.kerja.team/v1/tenant/appsResponse guide — what comes back & what each value means6 fields
200 OK with { data: { items: [...] } } — the tenant's app estate as an ADMIN sees it, with live grant counts. The identity app is excluded: every member reaches it by virtue of membership, so listing it would invite an admin to try to revoke access they cannot revoke.
data.itemsarrayalwaysdata.items[].codestringalwaysdata.items[].labelstringalwaysdata.items[].statusenumalwaysactive— Members may be granted this app and may enter it.suspended— Access denied tenant-wide; existing grants are retained untouched, and NEW grants are refused with 422 tenant_app_not_enabled.terminated— Access denied and the projection retired.
data.items[].grantedMembersintegeralwaysdata.items[].backfilledAttimestamp | nullconditional/v1/users/{userId}/appsadminWhich apps does this member hold?Returns EVERY app the tenant is enabled for, each flagged granted true/false for this member, so the UI renders the whole toggle set from one call rather than diffing two lists. A foreign-tenant userId is 404, not 403 (AUTH-OBJECT-03) — a 403 would confirm the id names a real user somewhere.
Field guide — what each value means & where it comes from1 field
Every app the tenant is enabled for, each flagged granted or not for this member — so the UI renders the whole toggle set from ONE call rather than diffing two lists. Admin-only. Also the second step when diagnosing a member missing from a product app: it rules out the mundane cause, that they were simply never granted the app. A missing grant is not projection drift.
userIdpathUUIDrequiredfrom a responseGET https://api.kerja.team/v1/users/{userId}/appsResponse guide — what comes back & what each value means4 fields
200 OK with { data: { items: [...] } } — EVERY app the tenant is enabled for, each flagged granted or not for this member, so the UI renders the whole toggle set from one call rather than diffing two lists. A foreign-tenant userId is 404, not 403 (AUTH-OBJECT-03) — a 403 would confirm the id names a real user somewhere. When diagnosing a member missing downstream, a false here is the mundane answer: they were simply never granted the app, which is not projection drift.
data.itemsarrayalwaysdata.items[].codestringalwaysdata.items[].labelstringalwaysdata.items[].grantedbooleanalwaystrue— The member may enter the app; it appears in their GET /v1/me/apps drop-down (given an active enablement).false— No grant. Grant it with PUT /v1/users/{userId}/apps/{appCode}. This is the first thing to rule out when a member is reported missing from an app.
/v1/users/{userId}/apps/{appCode}adminGrant an app to a memberNo body. Granting an app the tenant is not enabled for is 422 tenant_app_not_enabled, never a silent success — writing the grant anyway would leave the member seeing nothing in their drop-down with the API having answered 200. A merely SUSPENDED enablement is refused the same way. The identity app is not granted per member → 422 identity_app_not_grantable. Re-granting must UN-DELETE: the write is ON CONFLICT … DO UPDATE SET deleted_at = NULL, because a plain DO NOTHING would silently no-op on re-grant and leave the member locked out while the API reported success. Every grant write also touches the membership row in the same transaction so the next projection emission carries a strictly greater version — without it, revoke-then-re-grant emits twice at one version and the projector's guard silently discards the re-grant. ⚠ THIS CALL EMITS NO PROJECTION EVENT, AND FOR A FIRST-TIME GRANT NOTHING ELSE WILL. The membership touch above only helps a (member, app) pair that ALREADY holds a ledger row; a first grant creates none, the reconciler walks only ledger rows, and the sweep that would enrol it ships report-only. So a member granted an app after that tenant's backfill completed DOES NOT APPEAR IN THE APP AT ALL — the grant is live for authorization (checked at every /internal/validate hop) while the app's own copy never learns of them. The fix is an operator resync (POST /v1/admin/tenant-apps/resync); the diagnosis is scenario 42.
Field guide — what each value means & where it comes from2 fields
Grants a member an app. Admin-only, no body. Idempotent by construction: the write is an un-delete upsert (ON CONFLICT … DO UPDATE SET deleted_at = NULL), so granting an already-granted app is a no-op and re-granting a revoked one revives the row. A plain DO NOTHING would silently no-op on re-grant and leave the member locked out while the API reported success. Also touches the membership row in the same transaction so the next projection emission carries a strictly greater version. Emits app_grant_granted.
userIdpathUUIDrequiredfrom a responseappCodepathstringrequiredfrom a responsePUT https://api.kerja.team/v1/users/{userId}/apps/billingResponse guide — what comes back & what each value means4 fields
200 OK confirming the grant. Idempotent by construction — an un-delete upsert (ON CONFLICT … DO UPDATE SET deleted_at = NULL) — so granting an already-granted app is a no-op and re-granting a revoked one revives the row. The same transaction also touches the membership row so the next projection emission carries a strictly greater version; without that, revoke-then-re-grant would emit twice at one version and the projector's guard would silently discard the re-grant. Emits app_grant_granted. The member sees the result at GET /v1/me/apps.
dataobjectalwaysdata.userIdUUIDalwaysdata.appCodestringalwaysdata.grantedbooleanalways/v1/users/{userId}/apps/{appCode}adminRevoke a member's app grantSoft delete, no body. Takes effect at the member's NEXT REQUEST, not their next login — the grant is re-checked live at every /internal/validate hop, which is what makes it a real control rather than a note that applies whenever they next sign in. Revoking a grant that is not held answers 200 and still advances the membership version: the alternative would make the version sequence depend on whether a caller sent a redundant request. REVOKING HAS THE MIRROR SHAPE OF GRANTING: authorization stops immediately, but the app's projected copy is NOT retired, so a revoked member can linger in an app's own UI while being unable to act. Visibility and permission are decided by different mechanisms here, and only one of them is synchronous. The member sees the result at GET /v1/me/apps (scenario 17).
Field guide — what each value means & where it comes from2 fields
Revokes a member's app grant (soft delete). Admin-only, no body. Takes effect at the member's NEXT REQUEST, not their next login — the grant is re-checked live at every /internal/validate forward-auth hop, which is what makes revocation a real control rather than a note that applies whenever they next sign in. Revoking a grant that is not held answers 200 and still advances the membership version: making the version sequence depend on whether a caller sent a redundant request would be worse than one no-op emission the projector's guard discards. Emits app_grant_revoked.
userIdpathUUIDrequiredfrom a responseappCodepathstringrequiredfrom a responseDELETE https://api.kerja.team/v1/users/{userId}/apps/billingResponse guide — what comes back & what each value means4 fields
200 OK confirming the revocation (a soft delete). Takes effect at the member's NEXT REQUEST, not their next login — the grant is re-checked live at every /internal/validate forward-auth hop. Revoking a grant that is not held also answers 200 and still advances the membership version: making the version sequence depend on whether a caller sent a redundant request would be worse than one no-op emission the projector's guard discards. Emits app_grant_revoked.
dataobjectalwaysdata.userIdUUIDalwaysdata.appCodestringalwaysdata.grantedbooleanalways