switching clears the token & responses
No token
49

Managing which apps a member may use

MULTI_APP_ENABLED

The 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.

  1. GET /v1/tenant/apps → the tenant's app estate with live grantedMembers counts and backfilledAt. The identity app is excluded
  2. 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
  3. PUT /v1/users/{userId}/apps/{appCode} → grant. Idempotent un-delete upsert; emits app_grant_granted
  4. DELETE /v1/users/{userId}/apps/{appCode} → revoke (soft delete); emits app_grant_revoked
GET/v1/tenant/appsadminThe tenant's app estate

The 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.

Bearer token
GET https://api.kerja.team/v1/tenant/apps
↩︎Response 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.itemsarrayalways
Every app the tenant is enabled for, identity app excluded.
data.items[].codestringalways
The app's registry code — the appCode for the per-member grant endpoints.
data.items[].labelstringalways
The app's display name.
data.items[].statusenumalways
The tenant's enablement state for this app.
  • activeMembers may be granted this app and may enter it.
  • suspendedAccess denied tenant-wide; existing grants are retained untouched, and NEW grants are refused with 422 tenant_app_not_enabled.
  • terminatedAccess denied and the projection retired.
data.items[].grantedMembersintegeralways
Live count of members currently holding a grant for this app. Counted at read time, so it reflects revocations immediately.
data.items[].backfilledAttimestamp | nullconditional
When the projection backfill for this app completed; null while it has never finished or after a resync.
GET/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.

Path parameters
ℹ︎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 response
The member whose grants to read, from the members listing. Loaded TENANT-SCOPED, so a foreign-tenant id is a 404, not a 403 (AUTH-OBJECT-03) — a 403 would confirm the id names a real user somewhere.
Bearer token
GET https://api.kerja.team/v1/users/{userId}/apps
↩︎Response 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.itemsarrayalways
One row per app the TENANT is enabled for — not only the ones this member holds. That is what makes it a toggle set.
data.items[].codestringalways
The app's registry code — the appCode for PUT/DELETE /v1/users/{userId}/apps/{appCode}.
data.items[].labelstringalways
The app's display name.
data.items[].grantedbooleanalways
Whether THIS member currently holds a grant for the app.
  • trueThe member may enter the app; it appears in their GET /v1/me/apps drop-down (given an active enablement).
  • falseNo 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.
PUT/v1/users/{userId}/apps/{appCode}adminGrant an app to a member

No 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.

Path parameters
ℹ︎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 response
The member to grant. Malformed → 422; no such member IN THIS TENANT → 404 (never 403 — see AUTH-OBJECT-03).
appCodepathstringrequiredfrom a response
The app to grant, by registry code (lowercase kebab-case, else 422). The tenant must already be enabled for it — otherwise 422 tenant_app_not_enabled, and a merely SUSPENDED enablement is refused the same way. Refusing an un-enabled app is the point: writing the grant anyway would leave the member seeing nothing in their drop-down with the API having answered 200. The identity app is never granted per member → 422 identity_app_not_grantable.
Bearer token
PUT https://api.kerja.team/v1/users/{userId}/apps/billing
↩︎Response 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.

dataobjectalways
Confirmation of the grant.
data.userIdUUIDalways
Echoes the member granted.
data.appCodestringalways
Echoes the app granted.
data.grantedbooleanalways
true. Present so a client can treat grant and revoke responses uniformly.
DELETE/v1/users/{userId}/apps/{appCode}adminRevoke a member's app grant

Soft 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).

Path parameters
ℹ︎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 response
The member whose grant to revoke. Foreign-tenant id → 404, not 403.
appCodepathstringrequiredfrom a response
The app to revoke, by registry code. The member sees the result immediately at GET /v1/me/apps.
Bearer token
DELETE https://api.kerja.team/v1/users/{userId}/apps/billing
↩︎Response 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.

dataobjectalways
Confirmation of the revocation.
data.userIdUUIDalways
Echoes the member whose grant was revoked.
data.appCodestringalways
Echoes the app revoked.
data.grantedbooleanalways
false — including when the member did not hold the grant to begin with.