Managing which apps a member may use
MULTI_APP_ENABLEDThe tenant-admin half of the app dimension, and the sibling of scenario 55: 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 43). The tenant must first be enabled for the app (scenario 47). 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 57 — 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; audits app_grant_granted and projects the member into the app (user.upserted + membership.upserted)
- DELETE /v1/users/{userId}/apps/{appCode} → revoke (soft delete); audits app_grant_revoked and emits membership.removed for that app alone
/v1/tenant/appsadminThe tenant's app estateThe ADMIN view, not the member's own drop-down (that is GET /v1/me/apps, scenario 18). 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. THE GRANT IS PROJECTED AT THE REQUEST: it emits user.upserted + membership.upserted in the same transaction (§2.47), so a member granted after the tenant's backfill now reaches the app without an operator resync. ⚠ DOC DISCREPANCY: §3.96 and a §2.55 gotcha still say this call emits no projection event and that a first-time grant is never projected; §2.47 says the gap is closed, and the service (app-access.service.ts) enqueues both events — the tester follows §2.47. If a granted member is nonetheless missing downstream, that is now an anomaly to diagnose (scenario 48), not the expected state.
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. Audited app_grant_granted. PROJECTION: the tester follows §2.47 — the grant emits user.upserted + membership.upserted AT THE REQUEST (verified in the service); §3.96's older “emits no projection event, and for a first-time grant none ever follows” text is a doc discrepancy. Walk-through: scenario 56.
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. Audits app_grant_granted. PROJECTION: the grant write emits user.upserted + membership.upserted for that app IN THE REQUEST TRANSACTION (as §2.47 describes and the service does), so a first-time grant after the tenant's backfill now reaches the app without a resync — a granted-but-invisible member is a genuine anomaly (undrained outbox, failed delivery), diagnosed per scenario 48. DOC DISCREPANCY: §3.96 / §2.55 still say this endpoint “emits no projection event”; the tester follows §2.47 and the service. 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. The revoke also emits membership.removed for THAT app alone, in the request transaction (§2.47), so the app's projected copy is told at once rather than lingering. ⚠ DOC DISCREPANCY: §3.97 and a §2.55 gotcha still say no projection event is emitted here and a revoked member can linger in the app's own UI; the service enqueues it, and the tester follows §2.47. Either way authorization and visibility are decided by different mechanisms — the live /internal/validate check is what enforces the revoke. The member sees the result at GET /v1/me/apps (scenario 18).
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. Audited app_grant_revoked. PROJECTION: the tester follows §2.47 — the revoke emits membership.removed AT THE REQUEST (verified in the service), so the app's projected copy is told; §3.97's older “no projection event is emitted here either” text is a doc discrepancy. Walk-through: scenario 56.
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. Audits app_grant_revoked. PROJECTION: the revoke emits membership.removed for THAT app alone in the request transaction (§2.47, verified in the service), so the app's own copy retires the member rather than lingering. DOC DISCREPANCY: §3.97 / §2.55 still say “no projection event is emitted here”; the tester follows §2.47 and the service.
dataobjectalwaysdata.userIdUUIDalwaysdata.appCodestringalwaysdata.grantedbooleanalways