Register an app and enable it for a tenant
MULTI_APP_ENABLEDStanding up a new product app is TWO separate acts, deliberately: registering the app (platform configuration, one row serving every tenant) and enabling a tenant for it (access control, per tenant). Neither grants any individual anything — that is scenario 49.
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.
- POST /v1/admin/client-apps → register: { code, label, description?, kind?, sortOrder? }. 201. No step-up — the row is inert on arrival
- GET /v1/admin/client-apps → read back the registry, INCLUDING deactivated rows, which are usually exactly what an operator is looking for
- PUT /v1/admin/tenant-apps → { tenantId, appCode, status: 'active' } — the tenant may now use the app, and this is what triggers the projection backfill
- GET /v1/admin/tenant-apps?tenantId=… → watch backfillOutstanding; true means a member may legitimately be missing downstream. FALSE DOES NOT MEAN EVERY MEMBER IS PRESENT: anyone granted the app AFTER this backfill completed is projected by nothing, and re-running the resync is what brings them in
- POST /v1/admin/tenant-apps/resync → if needed, clears backfilled_at so the pair is re-projected in full
- PATCH /v1/admin/client-apps/{code} → later: relabel, reorder, retire (isActive: false), or opt the app into self-service acquisition (isAutoEnabledOnRegister — at sign-up, scenario 3, and for an existing account, scenario 50). 🔐 step-up ONLY to deactivate
/v1/admin/client-appsadminRegister an app201. NO step-up, and that is proportionate: a new registry row grants nobody anything — the app is unreachable until a tenant enablement AND a per-member grant exist, so it is inert on arrival, unlike the deactivation in PATCH. An app is NOT an OAuth client: the app is the unit of access control (what azp names and what a grant grants), an OAuth client is a program that authenticates into it, and lookup.oauth_clients.client_app_id is the link — separate tables so an app gaining a second client does not mean migrating every grant and every projected row. 409 on a duplicate code, or on kind: 'identity' when an identity app already exists — and that refusal comes from the DATABASE (uq_client_apps_single_identity, a partial unique index mapped from 23505), not from the handler, because the cardinality of the /internal/validate exemption must not depend on a process reading its own copy of the rule. Sending isActive is a 422, not a silently ignored field.
Field guide — what each value means & where it comes from5 fields
Registers an app. Requires an operator session with iam-platform-admin. NO step-up, and that is proportionate: a new registry row grants nobody anything — an app is unreachable until a tenant enablement (PUT /v1/admin/tenant-apps) and a per-membership grant (PUT /v1/users/{userId}/apps/{appCode}) exist, so the row is inert on arrival. Body is .strict(): any unknown field is a 422, including isActive — a registry row is created live and retiring one is PATCH /v1/admin/client-apps/{code}. Returns 201.
codebodystringrequiredyou chooselabelbodystringrequiredyou choosedescriptionbodystringoptionalyou choosekindbodyenumoptionalyou chooseproduct— A normal product app (the default). Access requires BOTH a tenant enablement and a per-member grant, and /internal/validate gates on that grant at every forward-auth hop.identity— The account/identity shell app. At most ONE may exist and the DATABASE enforces it (uq_client_apps_single_identity, a partial unique index) — a second one is a 409 mapped from the 23505, not a check in the handler, because the cardinality of the /internal/validate exemption must not depend on a process reading its own copy of the rule. The identity app is exempt from grants: membership alone governs access, it cannot be enabled for a tenant (422), cannot be granted per member (422 identity_app_not_grantable), and cannot be deactivated at any step-up level.
sortOrderbodyintegeroptionalyou choosePOST https://api.kerja.team/v1/admin/client-appsResponse guide — what comes back & what each value means8 fields
201 Created with the registered row. The app is created with isActive: true, but it is INERT on arrival — it grants nobody anything until a tenant enablement (PUT /v1/admin/tenant-apps) and a per-member grant (PUT /v1/users/{userId}/apps/{appCode}) exist. That inertness is why no step-up is required here. Audits client_app_registered. A duplicate code, or kind: "identity" when an identity app already exists, is a 409 — and the second-identity refusal comes from the DATABASE (uq_client_apps_single_identity mapped from 23505), not from the handler.
dataobjectalwaysdata.idUUIDalwaysdata.codestringalwaysdata.labelstringalwaysdata.descriptionstring | nullconditionaldata.kindenumalwaysdata.sortOrderintegeralwaysdata.isActivebooleanalways/v1/admin/client-appsadminList the app registryUnpaged — the registry is platform configuration with a handful of rows — ordered by sortOrder then code. Deactivated apps are INCLUDED deliberately: an operator listing the registry is very often looking for exactly the row that has been switched off, so filtering it out would hide the answer to the question being asked. The tenant-facing drop-down (GET /v1/me/apps) does its own filtering to live product apps rather than relying on a default here.
Field guide — what each value means & where it comes fromno inputs
Lists the app registry (lookup.client_apps) — the set of apps a token can be scoped to. No inputs, no paging: the registry is platform configuration with a handful of rows. Read-only and NOT step-up gated; the iam-platform-admin role gate is sufficient for a read of platform configuration. Deactivated rows ARE included, deliberately — an operator listing the registry is very often looking for exactly the row that has been switched off.
GET https://api.kerja.team/v1/admin/client-appsResponse guide — what comes back & what each value means8 fields
200 OK with { data: { items: [...] } }. Unpaged — the registry is platform configuration with a handful of rows — and ordered by sortOrder, then code. Deactivated rows ARE included: an operator listing the registry is very often looking for exactly the row that has been switched off, so filtering it out would hide the answer to the question being asked. The tenant-facing drop-down (GET /v1/me/apps) filters to live product apps itself rather than relying on a default here.
data.itemsarrayalwaysdata.items[].idUUIDalwaysdata.items[].codestringalwaysdata.items[].labelstringalwaysdata.items[].descriptionstring | nullconditionaldata.items[].kindenumalwaysproduct— A normal product app: access needs a tenant enablement AND a per-member grant, re-checked at every /internal/validate hop.identity— The account shell. At most one exists (DB-enforced). Exempt from enablements and grants — membership alone governs access — and it can never be deactivated.
data.items[].sortOrderintegeralwaysdata.items[].isActivebooleanalwaystrue— Live and usable (subject to enablement + grant).false— Retired via PATCH … { isActive: false }. There is no DELETE — five tables reference an app ON DELETE RESTRICT — so this is what a removed app looks like.
/v1/admin/tenant-appsadminEnable / suspend / terminate a tenant for an appPUT because a status is a state to ASSERT rather than a set of verbs: sending active twice is a no-op, and moving between states is one operation in both directions. The three statuses are not three shades of off — suspended DENIES access but RETAINS the projection (the app's domain data still foreign-keys to those rows), terminated denies and RETIRES it with a tombstone. Suspending never touches per-member grants: destroying state an operator would have to reconstruct by hand on restore would make it one-way in practice. Re-activating a TERMINATED enablement clears the backfill so the tenant is projected in full — the tombstone retired the app's copy, so returning is a fresh projection, not a resume. The identity app cannot be enabled → 422: membership alone governs access to it, so the row would be one scope() deliberately ignores. tenantId is a BODY field here and a QUERY param on the read, never a path segment (IAM-API-02 / REST-ROUTE-03) — the addressed resource is the enablement, not the tenant.
Field guide — what each value means & where it comes from3 fields
Enables, suspends or terminates a tenant's access to an app. PUT because a status is a state to ASSERT rather than a set of verbs: sending active twice is a no-op, and moving between states is one operation in both directions. Body is .strict(). tenantId is a body field on the writes and a query param on the read — never a path segment. Audits one code per transition (tenant_app_enabled / tenant_app_suspended / tenant_app_terminated) so each is countable on its own.
tenantIdbodyUUIDrequiredfrom a responseappCodebodystringrequiredfrom a responsestatusbodyenumrequiredyou chooseactive— Access permitted, projection maintained. This is what triggers the projection backfill — watch backfillOutstanding at GET /v1/admin/tenant-apps. Re-activating a TERMINATED enablement clears the backfill so the tenant is projected in full: the tombstone already retired the app's copy, so returning is a fresh projection, not a resume.suspended— Access DENIED at the auth boundary, projection RETAINED — the app's domain data still foreign-keys to those rows. Suspending never touches per-member grants: it is a tenant-level lever, and destroying per-member state an operator would have to reconstruct by hand on restore would make it one-way in practice.terminated— Access denied and the projection RETIRED — a tombstone is emitted downstream.
PUT https://api.kerja.team/v1/admin/tenant-appsResponse guide — what comes back & what each value means4 fields
200 OK confirming the asserted state. PUT is idempotent here by design: sending active twice is a no-op, and moving between states is one operation in both directions. Audits one code per transition — tenant_app_enabled / tenant_app_suspended / tenant_app_terminated — so each is countable on its own. Setting active is what TRIGGERS the projection backfill; watch backfillOutstanding at GET /v1/admin/tenant-apps.
dataobjectalwaysdata.tenantIdUUIDalwaysdata.appCodestringalwaysdata.statusenumalwaysactive— Permitted and projected. If the previous state was terminated, the backfill was cleared and the tenant will be projected in FULL — the tombstone retired the app's copy, so returning is a fresh projection, not a resume.suspended— Denied at the auth boundary, projection retained, per-member grants untouched.terminated— Denied and retired; a tombstone is emitted downstream.
/v1/admin/tenant-appsadminList a tenant's enablementsbackfillOutstanding is the operational field: true means the chunker has not finished projecting this tenant into that app, so a member may legitimately be missing downstream. FALSE DOES NOT MEAN EVERY MEMBER IS PRESENT — anyone granted the app AFTER this backfill completed is not projected by anything, and re-running the resync below is what brings them in. It is the read that answers “is onboarding still in progress, or is something broken” — see scenario 42.
Field guide — what each value means & where it comes from1 field
Lists one tenant's app enablements. tenantId is a required QUERY parameter, not a path segment (IAM-API-02 / REST-ROUTE-03): the addressed resource is the enablement, not the tenant. This is the first question when a member is reported missing from a product app, and usually the last — read backfillOutstanding before assuming anything is broken. A missing or malformed tenantId is a 422.
tenantIdqueryUUIDrequiredfrom a responseGET https://api.kerja.team/v1/admin/tenant-apps?tenantId=11111111-1111-7111-8111-111111111111Response guide — what comes back & what each value means7 fields
200 OK with { data: { items: [...] } } — one row per app this tenant has an enablement for. backfillOutstanding is the operational field and the reason this endpoint is the first stop when a member is reported missing downstream: it answers “is onboarding still in progress, or is something broken” before anyone starts guessing.
data.itemsarrayalwaysdata.items[].codestringalwaysdata.items[].labelstringalwaysdata.items[].kindenumalwaysdata.items[].statusenumalwaysactive— Access permitted, projection maintained.suspended— Access denied at the auth boundary, projection RETAINED — the app's domain data still foreign-keys to those rows. Per-member grants are untouched.terminated— Access denied and the projection RETIRED with a tombstone. Re-activating clears the backfill and re-projects the tenant in full.
data.items[].backfilledAttimestamp | nullconditionaldata.items[].backfillOutstandingbooleanalwaystrue— The chunker has NOT finished projecting this tenant into that app, so a member may legitimately be missing downstream. Nothing is broken — wait, and watch it return to false.false— The backfill is complete. A member still missing downstream now points at the drainer or at a missing grant, not at onboarding.
/v1/admin/tenant-apps/resyncadminRe-run the projection backfillClears backfilled_at and the keyset cursor so the chunker re-emits the whole (tenant, app) pair. Safe at any time and safe twice — every downstream upsert is version-guarded, so a re-emitted row at an equal or lower version is discarded. That is what makes it a legitimate FIRST response rather than a last resort: the cost is delivery work, and nothing already correct changes. 404 when there is no live enablement for the pair — silently succeeding would leave an operator believing a repair had run.
Field guide — what each value means & where it comes from2 fields
Re-runs the projection backfill for one (tenant, app) pair: resets backfilled_at and the keyset cursor so the chunker re-emits the whole pair. Body is .strict(). Safe at any time and safe twice — every downstream upsert is version-guarded, so a re-emitted row at an equal or lower version is discarded. That property is what makes “re-sync it” a legitimate FIRST response to a suspected projection problem rather than a last resort: the cost is delivery work, and nothing already correct changes. 404 when there is no live enablement for that pair — silently succeeding would leave an operator believing a repair had run.
tenantIdbodyUUIDrequiredfrom a responseappCodebodystringrequiredfrom a responsePOST https://api.kerja.team/v1/admin/tenant-apps/resyncResponse guide — what comes back & what each value means4 fields
200 OK once backfilled_at and the keyset cursor have been reset, so the chunker re-emits the whole (tenant, app) pair. Safe at any time and safe twice — every downstream upsert is version-guarded, so a re-emitted row at an equal or lower version is discarded. Confirm the repair by watching backfillOutstanding at GET /v1/admin/tenant-apps go true and then back to false. 404 when there is no live enablement for the pair.
dataobjectalwaysdata.tenantIdUUIDalwaysdata.appCodestringalwaysdata.backfilledAtnullalways/v1/admin/client-apps/{code}adminRelabel, reorder or retire an appAt least one mutable field is required (an empty body is 422). MUTABLE: label, description (an explicit null clears it), sortOrder, isActive, and isAutoEnabledOnRegister — the last opts the app into SELF-SERVICE SIGN-UP ENABLEMENT: when a POST /v1/auth/registration/complete carries a clientId naming an active product app with this flag on, that same transaction enables the new tenant for the app and grants the founder it, so the backfill projects the pair within one poll interval with no operator step (scenario 3). The SAME flag is what makes the app joinable by someone who ALREADY has an account (scenario 50) — both surfaces gate on the same four predicates, so switching it off closes both at once. It defaults FALSE because it bypasses the pay-first subscription flow. 🔐 Step-up is required ONLY to deactivate: isActive: false takes an app out of service for every tenant and every user at once, whereas a label fix cannot — gating the relabel too would train operators to keep a step-up factor to hand for routine work, which is how the control stops meaning anything. Re-activation is likewise ungated; restoring service is not the dangerous direction. To retire, send { isActive: false, stepUp: { password } }. The IDENTITY app can never be deactivated at any step-up level — it is the path to every account-recovery screen, including for the operator trying to undo the change. code and kind are NOT updatable → 422, not a silently ignored field: code is how runbooks and humans address an app while every row addresses it by id, and kind decides whether /internal/validate gates on a grant at all. An omitted field is unchanged; an explicit null description clears it. There is no DELETE — five tables reference an app ON DELETE RESTRICT and the projection ledger must outlive everything it points at.
Field guide — what each value means & where it comes from7 fields
Updates a registry entry — relabel, reorder, or retire. Body is .strict() and at least one mutable field is required (an empty body is a 422). Step-up is required ONLY when deactivating (isActive: false): that takes an app out of service for every tenant and every user at once, whereas a label fix cannot. Gating the relabel too would train operators to keep a step-up factor to hand for routine work, which is how the control stops meaning anything. Re-activation is likewise ungated — restoring service is not the dangerous direction. There is no DELETE: five tables reference an app ON DELETE RESTRICT and the projection ledger must outlive everything it points at, so retiring is isActive: false.
codepathstringrequiredfrom a responselabelbodystringoptionalyou choosedescriptionbodystring | nulloptionalyou choosesortOrderbodyintegeroptionalyou chooseisAutoEnabledOnRegisterbodybooleanoptionalyou chooseisActivebodybooleanoptionalyou choosetrue— Re-activate a retired app. Ungated — no stepUp required, because restoring service is not the dangerous direction.false— Retire the app. Requires stepUp. Refused with 422 for the identity app.
stepUpbodyobjectconditionalout-of-bandPATCH https://api.kerja.team/v1/admin/client-apps/billingResponse guide — what comes back & what each value means8 fields
200 OK with the updated row. Audits client_app_updated with an old→new summary. An omitted field is unchanged; an explicit null description clears it. Remember what this endpoint refuses: code and kind (422, both are a reviewed migration), deactivating the identity app (422 at any step-up level), and isActive: false without stepUp (422). A failed step-up factor is a 401, not a 403.
dataobjectalwaysdata.idUUIDalwaysdata.codestringalwaysdata.labelstringalwaysdata.descriptionstring | nullconditionaldata.kindenumalwaysdata.sortOrderintegeralwaysdata.isActivebooleanalwaystrue— Live, or just restored. Re-activation needed no step-up — restoring service is not the dangerous direction.false— Retired for every tenant and every user at once. This is the transition step-up exists to gate.