switching clears the token & responses
No token
41

Register an app and enable it for a tenant

MULTI_APP_ENABLED

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

  1. POST /v1/admin/client-apps → register: { code, label, description?, kind?, sortOrder? }. 201. No step-up — the row is inert on arrival
  2. GET /v1/admin/client-apps → read back the registry, INCLUDING deactivated rows, which are usually exactly what an operator is looking for
  3. PUT /v1/admin/tenant-apps → { tenantId, appCode, status: 'active' } — the tenant may now use the app, and this is what triggers the projection backfill
  4. 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
  5. POST /v1/admin/tenant-apps/resync → if needed, clears backfilled_at so the pair is re-projected in full
  6. 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
POST/v1/admin/client-appsadminRegister an app

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

Request body (JSON)
ℹ︎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 choose
The app's stable identifier, 1–64 chars, lowercase kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$ — mirrors ck_client_apps_code). This is how seeds, runbooks and humans address the app, and it is NOT updatable afterwards (PATCH rejects it with 422) because a rename would silently detach operational knowledge from the rows it acts on. A duplicate code is a 409.
labelbodystringrequiredyou choose
Human-readable display name, 1–200 chars. This is what appears in the member's app drop-down (GET /v1/me/apps). Unlike code, it IS updatable later without step-up.
descriptionbodystringoptionalyou choose
Optional free-text description, 1–500 chars. Can later be cleared by PATCHing an explicit null (an omitted field means unchanged — the two are different intents and the API lets you express both).
kindbodyenumoptionalyou choose
What sort of app this is. Defaults to product. NOT updatable afterwards (422 from PATCH) because it decides whether /internal/validate gates on a grant at all — changing it is a reviewed migration.
  • productA 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.
  • identityThe 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 choose
Ordering hint, 0–10000, default 0. GET /v1/admin/client-apps orders by sortOrder then code, and the member drop-down carries it through.
Bearer token
POST https://api.kerja.team/v1/admin/client-apps
↩︎Response 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.

dataobjectalways
The newly registered app row.
data.idUUIDalways
The registry row's id, assigned by the server.
data.codestringalways
Echoes the submitted code. Not updatable afterwards.
data.labelstringalways
Echoes the submitted label.
data.descriptionstring | nullconditional
Echoes the submitted description; null when omitted.
data.kindenumalways
product unless identity was explicitly requested. Not updatable afterwards.
data.sortOrderintegeralways
Echoes the submitted sortOrder, or 0 by default.
data.isActivebooleanalways
Always true on creation — there is no isActive input, and sending one is a 422.
GET/v1/admin/client-appsadminList the app registry

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

Bearer token
GET https://api.kerja.team/v1/admin/client-apps
↩︎Response 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.itemsarrayalways
Every registry row, active and deactivated alike, ordered by sortOrder then code.
data.items[].idUUIDalways
The registry row's id. Note that every other table addresses the app by this id, while humans and runbooks address it by code — which is why code is not updatable.
data.items[].codestringalways
The lowercase kebab-case identifier. This is the appCode you pass to the tenant-apps and per-member grant endpoints.
data.items[].labelstringalways
Human-readable display name, as shown in the member's app drop-down.
data.items[].descriptionstring | nullconditional
Free-text description, or null when never set or explicitly cleared via PATCH.
data.items[].kindenumalways
What sort of app this is — and therefore whether access gates on a grant at all.
  • productA normal product app: access needs a tenant enablement AND a per-member grant, re-checked at every /internal/validate hop.
  • identityThe 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[].sortOrderintegeralways
Ordering hint, 0–10000. Primary sort key for this list.
data.items[].isActivebooleanalways
Whether the app is live. This is the field an operator is usually hunting for when an app has stopped working.
  • trueLive and usable (subject to enablement + grant).
  • falseRetired 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.
PUT/v1/admin/tenant-appsadminEnable / suspend / terminate a tenant for 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. 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.

Request body (JSON)
ℹ︎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 response
The tenant being enabled/suspended/terminated for the app. From the tenants listing or POST /v1/admin/tenants.
appCodebodystringrequiredfrom a response
The app's registry code (lowercase kebab-case), from GET /v1/admin/client-apps. The identity app cannot be enabled for a tenant → 422: membership alone governs access to it, so the row would be one scope() deliberately ignores, which reads as drift to anyone inspecting the tables later.
statusbodyenumrequiredyou choose
The enablement state to assert. The three statuses are NOT three shades of off — they differ in what happens to the projection, which is what makes suspend reversible and terminate not.
  • activeAccess 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.
  • suspendedAccess 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.
  • terminatedAccess denied and the projection RETIRED — a tombstone is emitted downstream.
Bearer token
PUT https://api.kerja.team/v1/admin/tenant-apps
↩︎Response 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.

dataobjectalways
The enablement after the write.
data.tenantIdUUIDalways
Echoes the tenant half of the pair.
data.appCodestringalways
Echoes the app half of the pair.
data.statusenumalways
The asserted state, now in effect.
  • activePermitted 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.
  • suspendedDenied at the auth boundary, projection retained, per-member grants untouched.
  • terminatedDenied and retired; a tombstone is emitted downstream.
GET/v1/admin/tenant-appsadminList a tenant's enablements

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

Query parameters
ℹ︎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 response
The tenant whose enablements to list. Obtain it from the tenants listing or from POST /v1/admin/tenants. Missing or malformed → 422.
Bearer token
GET https://api.kerja.team/v1/admin/tenant-apps?tenantId=11111111-1111-7111-8111-111111111111
↩︎Response 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.itemsarrayalways
The tenant's app enablements.
data.items[].codestringalways
The app's registry code.
data.items[].labelstringalways
The app's display name.
data.items[].kindenumalways
product for everything that can appear here — the identity app cannot be enabled for a tenant (422), so it never shows up.
data.items[].statusenumalways
The enablement state. Check this before concluding a member is missing: a suspended enablement denies access without any projection fault.
  • activeAccess permitted, projection maintained.
  • suspendedAccess denied at the auth boundary, projection RETAINED — the app's domain data still foreign-keys to those rows. Per-member grants are untouched.
  • terminatedAccess denied and the projection RETIRED with a tombstone. Re-activating clears the backfill and re-projects the tenant in full.
data.items[].backfilledAttimestamp | nullconditional
When the initial projection backfill for this (tenant, app) pair completed. null while it has never finished — or after a resync cleared it.
data.items[].backfillOutstandingbooleanalways
Whether the chunker still has work to do for this pair. The single most useful field on this response.
  • trueThe 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.
  • falseThe backfill is complete. A member still missing downstream now points at the drainer or at a missing grant, not at onboarding.
POST/v1/admin/tenant-apps/resyncadminRe-run the projection backfill

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

Request body (JSON)
ℹ︎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 response
The tenant half of the pair to re-project. Must have a LIVE enablement for appCode, else 404.
appCodebodystringrequiredfrom a response
The app half of the pair, by registry code. Must have a live enablement for tenantId, else 404.
Bearer token
POST https://api.kerja.team/v1/admin/tenant-apps/resync
↩︎Response 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.

dataobjectalways
Confirmation that the pair was queued for re-projection.
data.tenantIdUUIDalways
Echoes the tenant half of the pair.
data.appCodestringalways
Echoes the app half of the pair.
data.backfilledAtnullalways
Reset to null — that reset IS the resync. It is what makes backfillOutstanding read true again until the chunker finishes.
PATCH/v1/admin/client-apps/{code}adminRelabel, reorder or retire an app

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

⛓ Needs an out-of-band value?
Path parameters
Request body (JSON)
ℹ︎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 response
The app to update, addressed by its registry code (from GET /v1/admin/client-apps). Unknown code → 404. Note this is the path segment; sending code in the BODY is a 422.
labelbodystringoptionalyou choose
New display name, 1–200 chars. Omit to leave unchanged. No step-up needed.
descriptionbodystring | nulloptionalyou choose
New description (1–500 chars), or an explicit null to CLEAR it. Omitting the field leaves it unchanged — omitted and null are different intents and both are expressible.
sortOrderbodyintegeroptionalyou choose
New ordering hint, 0–10000. Omit to leave unchanged.
isAutoEnabledOnRegisterbodybooleanoptionalyou choose
Opts the app into SELF-SERVICE ACQUISITION, on both of its doors. AT SIGN-UP: when a POST /v1/auth/registration/complete carries a clientId that resolves to an ACTIVE PRODUCT app with this flag on, that same transaction also enables the brand-new tenant for the app and grants the founder it — so the backfill projects the pair (tenant.upserted + user.upserted + membership.upserted) within one poll interval, with no operator step. FOR AN EXISTING ACCOUNT: the same flag is the fourth of the four predicates GET /v1/me/app-join-targets and POST /v1/me/app-joins resolve the app through (scenario 50), which is what makes “a caller may acquire the app exactly when an anonymous visitor could” true rather than aspirational. Switching it off closes BOTH doors at once. FALSE BY DEFAULT, deliberately: turning it on bypasses the pay-first subscription flow, so a sign-up link is enough to hand out an app. Any other clientId — unknown, inactive, identity-kind, or this flag off — registers normally and enables nothing, without distinguishing which, so the surface enumerates no client ids and a stale sign-up link never blocks account creation. No step-up. Omit to leave unchanged.
isActivebodybooleanoptionalyou choose
Whether the app is live. Setting false RETIRES the app for every tenant and additionally requires stepUp (missing stepUp → 422). Deactivating the identity app is a 422 at any step-up level — it is the path to every account-recovery screen, including for the operator trying to undo the change, so a lockout there has no in-band recovery.
  • trueRe-activate a retired app. Ungated — no stepUp required, because restoring service is not the dangerous direction.
  • falseRetire the app. Requires stepUp. Refused with 422 for the identity app.
stepUpbodyobjectconditionalout-of-band
Step-up re-auth proof, required ONLY when isActive is false. Shape { password?, recoveryCode? } with at least one present. A failed factor is a 401 (not a 403). Mint a known password with the “Set a known password” helper.
Bearer token
PATCH https://api.kerja.team/v1/admin/client-apps/billing
↩︎Response 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.

dataobjectalways
The app row after the update.
data.idUUIDalways
The registry row's id (unchanged).
data.codestringalways
The code — always unchanged, because it is not a mutable field.
data.labelstringalways
The new label if one was sent, else the previous value.
data.descriptionstring | nullconditional
The new description, the previous one if the field was omitted, or null if an explicit null cleared it.
data.kindenumalways
Always unchanged — kind is not mutable.
data.sortOrderintegeralways
The new ordering hint if one was sent, else the previous value.
data.isActivebooleanalways
The app's live state after the update.
  • trueLive, or just restored. Re-activation needed no step-up — restoring service is not the dangerous direction.
  • falseRetired for every tenant and every user at once. This is the transition step-up exists to gate.