switching clears the token & responses
No token
56

Managing which apps a member may use

MULTI_APP_ENABLED

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

  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; audits app_grant_granted and projects the member into the app (user.upserted + membership.upserted)
  4. DELETE /v1/users/{userId}/apps/{appCode} → revoke (soft delete); audits app_grant_revoked and emits membership.removed for that app alone
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 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.

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.
  • active — 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[].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.
  • true — 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.
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. 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.

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. 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 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; no such app → 404). 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. 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.

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

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

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.