switching clears the token & responses
No token
50

Joining an app you already have an account for

The mirror of scenario 1 for someone who is ALREADY REGISTERED. An app the operator has marked is_auto_enabled_on_register (scenario 41) is free to acquire at sign-up — a visitor with no account gets a tenant, an enablement and a grant in one transaction just by filling in the registration page. A user who already has an account, opening that same page, is refused 409 …_already_registered, and had no other route: every enablement write was an operator endpoint or that one-shot inside registration. The rule this closes: A CALLER MAY ACQUIRE THE APP EXACTLY WHEN AN ANONYMOUS VISITOR COULD, so both calls gate on the same registry predicates the registration hand-off uses, refusals included. This is NOT the paid subscription path — an app the operator has not opted in is refused here, and reaching it means subscribing, not joining.

  1. GET /v1/me/app-join-targets?clientId=… → which of the caller's OWN organizations could receive the app, each with isAdmin, the current enablement, grantedToMe, and a derived eligible + reason. One call feeds both the picker and the no-picker shortcut
  2. POST /v1/me/app-joins → adds the app to an organization the caller ADMINISTERS (or founds a new one for it), enabling the tenant and granting the caller in ONE transaction
  3. POST /v1/auth/switch-tenant → CONDITIONAL, and not optional bookkeeping: when the joined organization is not the session's active tenant, switch BEFORE entering the app
  4. GET /v1/me/apps → the app now appears in the caller's drop-down
GET/v1/me/app-join-targetsauthWhich of my organizations could take this app?

ONE row per LIVE membership the caller holds, and never a tenant they do not belong to — this is not a directory of organizations. The app is resolved through the SAME FOUR PREDICATES the registration hand-off uses: the client exists and is active, the app is active, it is a product app, and the operator has set is_auto_enabled_on_register. A clientId failing any of them answers ONE UNDIFFERENTIATED 404 — this surface enumerates no client ids, exactly as the register path enables nothing and says nothing. eligible is FALSE when the caller is not an admin of that organization, when the enablement is suspended or terminated (operator state a member cannot override), or when it is already active AND grantedToMe. active WITHOUT grantedToMe stays ELIGIBLE — that is the grant-only case, which POST /v1/me/app-joins answers with success rather than a no-op. hasAnyProductApp excludes the identity app, as GET /v1/tenant/apps does: every tenant has it, so counting it would make the flag true everywhere. Cross-tenant by construction and deliberately not one query — the enablement and grant tables are tenant-isolated with no self policy, so the service reads memberships across tenants and re-binds the tenant per membership, reading each enablement under RLS.

Query parameters
ℹ︎Field guide — what each value means & where it comes from1 field

Which of the CALLER'S OWN organizations could receive a free-to-acquire app. Powers both the picker and the single-organization shortcut in one call, so a client never reconciles two lists. Authenticated, but no admin role at the route: whether the caller may act is a property of the TARGET organization and is decided per row, in `eligible`. One row per LIVE membership the caller holds — never a tenant they do not belong to, because this is not a directory of organizations.

clientIdquerystringrequiredyou choose
The OAuth client of the app being joined. Resolved through the SAME FOUR PREDICATES the registration hand-off uses: the client exists and is active, the app is active, it is a `product` app, and the operator has set is_auto_enabled_on_register. A clientId failing ANY of them answers one undifferentiated 404 — this surface enumerates no client ids, exactly as the register path enables nothing and says nothing. So a 404 here does not tell you which of the four is the reason, and it is not meant to.
Bearer token
GET https://api.kerja.team/v1/me/app-join-targets?clientId=web-body
↩︎Response guide — what comes back & what each value means13 fields

200 OK with { data: { app, targets } }. ONE ROW PER LIVE MEMBERSHIP the caller holds, and never a tenant they do not belong to — this is not a directory of organizations. Cross-tenant by construction and deliberately not one query: the enablement and grant tables are tenant-isolated with no self policy, so the service reads memberships across tenants (which the membership self policy permits) and then re-binds the tenant per membership, reading each enablement under RLS. An empty targets array means the caller belongs to nothing that could take the app — the client's move is then the found-an-organization branch of POST /v1/me/app-joins.

data.appobjectalways
The app being joined, echoed back so the picker can name it without a second lookup.
data.app.clientIdstringalways
Echoes the clientId query param — the value to send to POST /v1/me/app-joins.
data.app.codestringalways
The app's registry code (what a grant grants and what azp names). Apps are not addressable, clients are — which is why the join is keyed by clientId and this is informational.
data.app.labelstringalways
Display name to render in the picker.
data.targetsarrayalways
One row per live membership. A SINGLE eligible row is the no-picker shortcut: join straight into it rather than showing a one-item list.
data.targets[].tenantIdUUIDalways
The organization. Pass it as tenantId to POST /v1/me/app-joins, and to POST /v1/auth/switch-tenant afterwards if it is not the session's active tenant.
data.targets[].namestringalways
The organization's display name.
data.targets[].isAdminbooleanalways
Whether the caller administers this organization. This is why the route carries no admin role gate: whether the caller may act is a property of the TARGET organization, decided per row.
  • trueThe caller may commit this organization to the app.
  • falseNot eligible. A member must not commit their organization to an app on its admins' behalf — point them at founding their own instead. Sending it anyway is 403 not_tenant_admin.
data.targets[].hasAnyProductAppbooleanalways
Whether the organization already holds any product app. EXCLUDES THE IDENTITY APP, as GET /v1/tenant/apps does: every tenant has it, so counting it would make the flag true everywhere and say nothing. Useful for copy — “add a second app” vs “this will be your first”.
data.targets[].enablementForThisAppenumalways
The organization's current enablement state for this app.
  • noneNever enabled. The join will enable it and grant the caller in one transaction.
  • activeAlready enabled. Eligible ONLY while grantedToMe is false — that is the grant-only case, which the join answers with success rather than a no-op. With grantedToMe true there is nothing left to do.
  • suspendedOperator state a member cannot override → not eligible, and the join answers 409 tenant_app_unavailable. Never resurrected: the copy says contact support, not try again.
  • terminatedAs suspended — refused and never resurrected.
data.targets[].grantedToMebooleanalways
Whether the caller already holds a grant for the app in this organization. false against an active enablement is a legitimate, successful join — the organization has the app and the caller simply lacks the grant.
data.targets[].eligiblebooleanalways
The derived verdict — the one field a picker should gate on, so the client never re-implements the rule.
  • truePOST /v1/me/app-joins with this tenantId will succeed. Includes the grant-only case (active enablement, no grant).
  • falseNot an admin of it, the enablement is suspended/terminated, or it is already active AND granted. reason says which.
data.targets[].reasonstring | nullalways
Why the row is ineligible; null when eligible. The refusals do not collapse into one — “not an admin of it” and “the operator suspended it” each give the caller a different thing to do.
POST/v1/me/app-joinsauthJoin — into an organization I administer

EXACTLY ONE BRANCH: sending both tenantId and createOrganization, or neither, is 422 at the gate. ENABLE AND GRANT COMMIT IN ONE TRANSACTION — split them and the backfill can claim the (tenant, app) pair in between, leaving the caller authorized for an app that has never heard of them. A live enablement is READ BEFORE IT IS WRITTEN: suspended or terminated is operator state and is REFUSED, never resurrected (409 tenant_app_unavailable); active means only the grant is needed, WHICH IS A SUCCESS, NOT A NO-OP. status is joined when something was written and already when the caller was there all along — idempotent on the business key, with no duplicate audit rows. No step-up: the same write happens for an anonymous visitor at registration. THE REFUSALS DO NOT COLLAPSE INTO ONE: an app that cannot be joined is a single undifferentiated 404 app_not_joinable, a tenantId naming an organization the caller does not belong to is 404 not_found (a 403 would confirm the organization exists), but a member who is NOT AN ADMIN of it is 403 not_tenant_admin — they have proven they belong, so hiding the reason would tell someone who could simply ask their admin that the app does not exist. Audited under its own reason, self_join_existing_account, never the register path's auto_enable_on_register.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from4 fields

Adds a free-to-acquire app to one of the caller's organizations, or founds a new one for it. The counterpart of the registration hand-off's clientId: what that does for a visitor with no account, this does for someone who already has one. Authenticated, NO STEP-UP — the same write happens for an anonymous visitor at registration. Body is one of two mutually exclusive branches. Idempotency-Key is optional and honoured durably, and MATTERS MOST for createOrganization, which has no business key: a retry without one founds a SECOND organization. The tester sends no Idempotency-Key header, so treat every Send of the createOrganization branch as a fresh founding.

clientIdbodystringrequiredyou choose
The app to join, by OAuth client id — the same value passed to GET /v1/me/app-join-targets, and gated on the same four registry predicates. An app that is unknown, inactive, identity-kind, or not opted in by the operator is a single undifferentiated 404 app_not_joinable. THIS IS NOT THE PAID SUBSCRIPTION PATH: an app the operator has not opted in is refused here, and reaching it means subscribing, not joining.
tenantIdbodyUUIDconditionalfrom a response
BRANCH A — join into an organization that already exists. Copy it from a `targets[]` row of GET /v1/me/app-join-targets. Exactly one branch: sending this AND createOrganization, or neither, is 422 at the gate. A tenantId naming an organization the caller does not belong to is 404 not_found rather than 403 — answering “you are not an admin” would confirm the organization exists. A caller who IS a member but NOT an admin of it is 403 not_tenant_admin, deliberately NOT collapsed into that 404: they have proven they belong, so hiding the reason would tell someone who could simply ask their admin that the app does not exist. An organization whose enablement is suspended or terminated is 409 tenant_app_unavailable — operator state, refused and never resurrected, so the copy must say contact support rather than try again. An organization that already has the app where the caller merely lacks the grant is a SUCCESS, not a no-op.
createOrganizationbodyobjectconditionalyou choose
BRANCH B — found a new organization for the app. `{ name }`. This is where a member who administers none of their organizations is pointed: granting a member an app is an admin act everywhere else, and a member must not commit their organization to an app on its admins' behalf. RATE-LIMITED PER USER and fails closed → 429 rate_limited over the cap. The response returns the new tenantId, which the caller could not otherwise have known — and must then switch into before entering the app.
createOrganization.namebodystringconditionalyou choose
Display name of the organization to found. Required when createOrganization is sent; a malformed field is 422 validation_error.
Bearer token
POST https://api.kerja.team/v1/me/app-joins
↩︎Response guide — what comes back & what each value means4 fields

200 OK with { data: { tenantId, code, status } }. ENABLE AND GRANT COMMIT IN ONE TRANSACTION — split them and the backfill can claim the (tenant, app) pair in between, leaving the caller authorized for an app that has never heard of them. A live enablement is READ BEFORE IT IS WRITTEN: suspended or terminated is refused, never resurrected. Audited under its OWN reason, self_join_existing_account, never the register path's auto_enable_on_register — this must not be indistinguishable from an operator's act in the trail. THE RESPONSE IS NOT THE END OF THE FLOW: the session still carries whichever tenant it had, so switch to the returned tenantId BEFORE entering the app.

dataobjectalways
The join result.
data.tenantIdUUIDalways
The organization the app now belongs to — echoed for the existing-organization branch, and NEWLY MINTED for createOrganization, where the caller could not have known it. This is the value to pass to POST /v1/auth/switch-tenant. Redirecting into the app without switching hands it a token scoped to the wrong tenant and it refuses — the very refusal the caller came here to escape, having just successfully joined.
data.codestringalways
The app's registry code, as it now appears in the caller's GET /v1/me/apps drop-down and in GET /v1/tenant/apps for the organization's admins.
data.statusenumalways
Whether this call wrote anything. Idempotent on the business key, with no duplicate audit rows.
  • joinedSomething was written — the enablement, the grant, or both. Exactly one audit row was appended.
  • alreadyThe caller was there all along: the organization held the app and they already held the grant. A no-op, and not an error — re-running the join is safe.
POST/v1/me/app-joinsauthJoin — founding a new organization for it

The branch for a caller who administers none of their organizations, or wants the app somewhere new. Founds the tenant, enables it for the app and grants the caller, all in one transaction. THE RESPONSE RETURNS tenantId — including for a brand-new organization the caller could not have known the id of — because the session still carries the OLD tenant and the client must switch to this one before entering the app. Idempotency-Key MATTERS MOST HERE: createOrganization has no business key, so a retry without one founds a SECOND organization (the tester sends no Idempotency-Key header, so treat every Send as a fresh founding). createOrganization is RATE-LIMITED PER USER and fails closed → 429 rate_limited over the cap. A member who does not administer any organization is pointed here rather than at somebody else's tenant: granting a member an app is an admin act everywhere else (scenario 49), and a member must not commit their organization to an app on its admins' behalf.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from4 fields

Adds a free-to-acquire app to one of the caller's organizations, or founds a new one for it. The counterpart of the registration hand-off's clientId: what that does for a visitor with no account, this does for someone who already has one. Authenticated, NO STEP-UP — the same write happens for an anonymous visitor at registration. Body is one of two mutually exclusive branches. Idempotency-Key is optional and honoured durably, and MATTERS MOST for createOrganization, which has no business key: a retry without one founds a SECOND organization. The tester sends no Idempotency-Key header, so treat every Send of the createOrganization branch as a fresh founding.

clientIdbodystringrequiredyou choose
The app to join, by OAuth client id — the same value passed to GET /v1/me/app-join-targets, and gated on the same four registry predicates. An app that is unknown, inactive, identity-kind, or not opted in by the operator is a single undifferentiated 404 app_not_joinable. THIS IS NOT THE PAID SUBSCRIPTION PATH: an app the operator has not opted in is refused here, and reaching it means subscribing, not joining.
tenantIdbodyUUIDconditionalfrom a response
BRANCH A — join into an organization that already exists. Copy it from a `targets[]` row of GET /v1/me/app-join-targets. Exactly one branch: sending this AND createOrganization, or neither, is 422 at the gate. A tenantId naming an organization the caller does not belong to is 404 not_found rather than 403 — answering “you are not an admin” would confirm the organization exists. A caller who IS a member but NOT an admin of it is 403 not_tenant_admin, deliberately NOT collapsed into that 404: they have proven they belong, so hiding the reason would tell someone who could simply ask their admin that the app does not exist. An organization whose enablement is suspended or terminated is 409 tenant_app_unavailable — operator state, refused and never resurrected, so the copy must say contact support rather than try again. An organization that already has the app where the caller merely lacks the grant is a SUCCESS, not a no-op.
createOrganizationbodyobjectconditionalyou choose
BRANCH B — found a new organization for the app. `{ name }`. This is where a member who administers none of their organizations is pointed: granting a member an app is an admin act everywhere else, and a member must not commit their organization to an app on its admins' behalf. RATE-LIMITED PER USER and fails closed → 429 rate_limited over the cap. The response returns the new tenantId, which the caller could not otherwise have known — and must then switch into before entering the app.
createOrganization.namebodystringconditionalyou choose
Display name of the organization to found. Required when createOrganization is sent; a malformed field is 422 validation_error.
Bearer token
POST https://api.kerja.team/v1/me/app-joins
↩︎Response guide — what comes back & what each value means4 fields

200 OK with { data: { tenantId, code, status } }. ENABLE AND GRANT COMMIT IN ONE TRANSACTION — split them and the backfill can claim the (tenant, app) pair in between, leaving the caller authorized for an app that has never heard of them. A live enablement is READ BEFORE IT IS WRITTEN: suspended or terminated is refused, never resurrected. Audited under its OWN reason, self_join_existing_account, never the register path's auto_enable_on_register — this must not be indistinguishable from an operator's act in the trail. THE RESPONSE IS NOT THE END OF THE FLOW: the session still carries whichever tenant it had, so switch to the returned tenantId BEFORE entering the app.

dataobjectalways
The join result.
data.tenantIdUUIDalways
The organization the app now belongs to — echoed for the existing-organization branch, and NEWLY MINTED for createOrganization, where the caller could not have known it. This is the value to pass to POST /v1/auth/switch-tenant. Redirecting into the app without switching hands it a token scoped to the wrong tenant and it refuses — the very refusal the caller came here to escape, having just successfully joined.
data.codestringalways
The app's registry code, as it now appears in the caller's GET /v1/me/apps drop-down and in GET /v1/tenant/apps for the organization's admins.
data.statusenumalways
Whether this call wrote anything. Idempotent on the business key, with no duplicate audit rows.
  • joinedSomething was written — the enablement, the grant, or both. Exactly one audit row was appended.
  • alreadyThe caller was there all along: the organization held the app and they already held the grant. A no-op, and not an error — re-running the join is safe.
POST/v1/auth/switch-tenantauthSwitch into the joined organization

CONDITIONAL, AND NOT OPTIONAL BOOKKEEPING. A session carries ONE tenant. Join into organization B while signed in as A, redirect without switching, and the app receives a token scoped to A — which has no enablement for it — so the caller sees the very refusal they came here to escape, having just successfully joined. Skip this step only when the join landed in the session's already-active tenant. The new accessToken is auto-captured; the refresh token is rotated, and the switch is session-wide so every open app follows it (scenario 20).

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from1 field

Switches the active tenant for the current session and re-mints tokens scoped to the target tenant; rotates the refresh_token cookie. Requires an authenticated session (the tester supplies Authorization and the gateway injects x-gateway-context with the session id); the caller must already hold an active membership in the target tenant. Body has one field.

targetTenantIdbodystringrequiredfrom a response
The id of the tenant to switch into. The user does not type this freely; copy it from a prior tester response that lists the user's tenants/memberships (e.g. a tenant list or GET /v1/me membership entry). The user must hold an active membership in this tenant or the call returns 403. Must be non-empty.
Bearer token
POST https://api.kerja.team/v1/auth/switch-tenant
↩︎Response guide — what comes back & what each value means3 fields

200 OK with the standard { data } envelope; Cache-Control: no-store, and Set-Cookie: refresh_token (rotated) sets the new refresh cookie. The body re-mints an access token scoped to the target tenant's roles.

data.accessTokenJWTalways
Newly minted short-lived bearer token scoped to the target tenant's roles. Auto-captured by the tester as the active bearer token for subsequent calls.
data.tokenTypeenumalways
Token scheme to use in the Authorization header (Authorization: Bearer <accessToken>).
  • BearerSend the access token as 'Authorization: Bearer <accessToken>'.
data.expiresInintegeralways
Lifetime of the access token in seconds (e.g. 600 = 10 minutes). Client should refresh or switch before it elapses.
GET/v1/me/appsauthConfirm it is in my drop-down

The proof the join landed: the app is listed for the SESSION'S ACTIVE TENANT, which is why step 3 comes first. Run this BEFORE switching and the newly joined app is absent — not a failed join, just the wrong tenant on the session. Each row carries the clientId the hand-off needs (scenario 17). THIS route — unlike the two join calls above — sits behind MULTI_APP_ENABLED and answers 404 when it is off; the join itself is not gated by it.

ℹ︎Field guide — what each value means & where it comes fromno inputs

The app drop-down: apps the caller holds in the SESSION'S ACTIVE TENANT ONLY. No inputs — the session supplies both the user and the tenant. Filtered to live product apps with an active enablement: the identity app is the shell that renders this list rather than an entry in it, and a suspended enablement would offer a door that refuses to open. An app held only in ANOTHER tenant is absent — reaching it means an explicit tenant switch first, which moves every open tab and is therefore a visible act rather than a side effect of picking from a list. Needs no admin role: every member sees their own apps.

Bearer token
GET https://api.kerja.team/v1/me/apps
↩︎Response guide — what comes back & what each value means6 fields

200 OK with { data: { items: [...] } } — the app drop-down, scoped to the SESSION'S ACTIVE TENANT. Filtered to live product apps with an active enablement, so the identity app never appears (it is the shell rendering this list) and a suspended app is absent rather than offered as a door that refuses to open. An app held only in another tenant is absent too — reaching it means an explicit tenant switch, which moves every open tab. This list comes from the API, NOT from x-gateway-context: that envelope carries only the current app as the single scalar azp, which is what makes per-route enforcement possible at the edge.

data.itemsarrayalways
Apps the caller holds in the active tenant. Empty when the member has been granted nothing.
data.items[].codestringalways
The app's registry code.
data.items[].labelstringalways
The display name to render in the drop-down.
data.items[].clientIdstringalways
The OAuth client to address this app by — pass it as targetClientId to POST /v1/oauth/handoff. Present because apps are not addressable, clients are: an entry without one is a link to nowhere.
data.items[].sortOrderintegeralways
The registry ordering hint, carried through so the drop-down orders the same way everywhere.
data.items[].isCurrentbooleanalways
Whether this is the app the caller is currently in, matched on the azp the gateway stamped.
  • trueThe app this request came from — render it as the current selection rather than a destination.
  • falseA different app; picking it starts a hand-off (POST /v1/oauth/handoff).