0047. The API platform: organization tokens, client sessions and the MCP server
- Status: proposed
- Date: 2026-10-31, amended 2026-11-01 (OAuth applications, the built-in MCP server and narrower credentials, see the amendment below)
- Deciders: maintainer (owner review pending)
- Extends: ADR 0040, ADR 0022, ADR 0017, ADR 0036
Observable acceptance criteria
Section titled “Observable acceptance criteria”Organization tokens:
- An owner or administrator of the organization opens Settings → Organization tokens, creates a
token with a name, some of the permissions an integration can hold, all or some of the
organization’s workspaces, an optional expiry and an optional allow-list of up to 20 client
address ranges, and copies its secret (
gc_org_…) once. A reload lists the token with its last four characters, permissions, workspaces, creator, creation, expiry, last use and what its creator still holds of it, never the secret. They rotate it (the replacement’s secret is shown once; the old one works for a grace period of 0–168 hours) and revoke it. Authorization: Bearer gc_org_…withGuestCraft-Workspace-Idacts in that workspace, if the token names it (or names none) and its creator still reaches it, with the token’s permissions narrowed to what the creator holds there. A workspace it does not reach, or no header while it reaches several, answers 403 and is audited (organization_token.denied, reasonworkspace).GET /v1/workspaceslists the workspaces it reaches. A revoked or expired token, one whose creator holds nothing of it any more, and one used from an address outside its allow-list answer 401 and are audited. It never reaches browser-only operations, and a workspace administrator who does not administer the organization gets 403 on the token routes.- Creation, rotation and revocation are audited in the organization (
organization_token.created,.rotated,.revoked), never with the secret or the name. Actions taken with a token are audited with the actor typeorganization_token, naming the token and its creator, and shown in the workspace’s Activity. The organization-level entries (administration, and refusals before a workspace is chosen) are listed in the organization’s Activity (organizations.audit, ADR 0049).
Client sessions, as Seam’s client session tokens:
- An integration’s server, with an API key or an organization token holding
client_sessions.manage, mints a client session withPOST /v1/client_sessions: one of its own user identifiers, some permissions on 1–100 properties of one workspace, and a life of at most 24 hours (one by default). The token (gc_cst_…) is returned once. - A front end calls the API with that token alone, from any origin: the API answers the CORS
preflight and lets the page read the answers of requests carrying a client session’s token.
On every request the session holds only what its minting credential may still do there: a
revoked or expired minting credential, or a creator without access, makes it answer 401.
GET /v1/workspacestells the front end its workspace, permissions and properties. - Members, API keys and organization tokens holding
client_sessions.managelist the workspace’s sessions (also of one user identifier), read and revoke them; Settings → Client sessions does it in the dashboard. A session never mints, lists or revokes sessions itself. - The SDK supports both sides:
createGuestCraft({ apiKey | organizationToken })andclientSessions.createon the server,createGuestCraft({ clientSessionToken })in the front end.
MCP server:
@guestcraft/mcp(MIT) exposes every operation that an API key or organization token may call as an MCP tool, generated from the instance’s own OpenAPI document, with its summary, description, method and path; write tools say so in their description and annotations. Reads without required query parameters are also resources (guestcraft://v1/…).- It runs over stdio, with the credential in the environment, and over stateless Streamable HTTP,
with each request’s own
Authorization: Bearercredential and an allow-list of origins. Every call goes through the public API with that credential, so the API’s checks of its grants decide. - A contract test proves that every operation of
openapi/v1.jsonmaps to exactly one tool or is refused to integrations’ credentials.
Context
Section titled “Context”Analysis chapter 8 defines three programmatic credentials: workspace keys, organization tokens
(gc_org_…, several workspaces chosen with the workspace header) and client sessions (very
short-lived, limited to a resource, for guest pages and embedded panels). Chapter 10 takes Seam’s
three credential levels and asks for orgToken and client sessions in createGuestCraft; chapter
16 asks for an MCP server, a stdio one with an API key for local development and a remote one with
OAuth. ADR 0040 implemented workspace keys with their security model: digest only, shown once,
expiry, rotation, allow-lists, the api_key audit actor and the creator’s live ceiling. The owner
asked that new credentials follow that same model.
Decision
Section titled “Decision”One model for the credentials of integrations. A request’s principal carries the credential
that authenticated it (Principal.credential: an API key, an organization token or a client
session), replacing ADR 0040’s apiKeyId; the audit actor integration records it, and the
audit log has a column per kind (api_key_id, organization_token_id, client_session_id). The
core’s integrations/credentials.ts holds what the three kinds share (status, secrets of 32
random bytes behind a prefix, allow-lists, expiry and grace periods, narrowing a grant to a
bound), and auth/integration-credentials.ts the gateway they share: each kind declares how it
is found by its digest (one narrow security definer function each, with the 2-second lock bound
of migration 0028), whom it authenticates now, and whose share of the remembered keys it takes;
the gateway admits each request through ADR 0040’s budgets before the lookup, applies the
allow-list and expiry per request, records last use, and audits refusals at most once a minute
per credential. API keys keep their behaviour and tests.
Organization tokens (guestcraft.organization_tokens, migration 0041) belong to the
organization: row-level security on organization_id, so only contexts that hold the
organization (its owners and administrators, ADR 0017) read or write them. A token holds
permissions from the list API keys may hold, optionally a list of the organization’s workspaces
(null for all, including later ones), and never acts as an organization administrator: its
principal is its creator’s live access to the organization, reduced to its workspaces and in
each to its permissions. Management is browser-only and needs an owner or administrator of the
organization; the creator delegates only permissions they hold. Its audit entries have no
workspace. It takes its organization’s share of the 100 remembered keys per owner.
Client sessions (guestcraft.client_sessions) belong to a workspace and to the API key or
organization token that minted them: a composite foreign key deletes a key’s sessions with it,
and the credential function finds no session whose token is gone (a workspace table references no
organization table by foreign key).
Only those two credentials mint (client_sessions.manage, a new workspace permission, denied to
members limited to properties and built-in for administrators): a session is the server-side
integration’s delegation to a front end, never a person’s. A session always names 1–100
properties, so permissions that act on the whole workspace (events, Channex health,
administration) are refused to it by the existing property-scope rule (ADR 0036). Its user
identifier allows letters, digits and ._:- only, so that it cannot hold an e-mail address or a
name. At each request its grant is narrowed to its minter’s live grant (itself narrowed to its
creator’s); an inactive minter answers 401 with the audited reason minter. A session’s
deadline is the earlier of its own expiry and its minter’s (the end of a rotation’s grace period
included), and each request is admitted against it at its own arrival, also one that joins a
lookup started before: it is refused with the audited reason expired. Sessions record no
last use, take a share of the remembered keys per workspace apart from its API keys
(client_sessions:<workspace>), and those expired more than a week are deleted, 100 at a time,
when a new one is minted in the workspace.
CORS. A front end on another origin must read the API’s answers. The API answers the CORS
preflight of every /v1 operation for any origin, with Access-Control-Allow-Origin: * and
never Access-Control-Allow-Credentials, so there is no credentialed CORS read: the preflight of
a non-simple request that asks to send cookies fails, and a simple request that sends them anyway
(a same-site page on another port can) reaches the API, but its answer carries no
Access-Control-Allow-Origin and the page cannot read it. Unsafe requests authenticated by a
cookie still pass the cookie origin policy, unchanged. Only answers to requests carrying a client
session’s token get Access-Control-Allow-Origin, so a page never reads what a cookie, an API key
or an organization token fetched.
MCP server. packages/mcp (MIT) depends on @guestcraft/sdk and on
@modelcontextprotocol/server 2.4.0 (Apache-2.0, the official SDK’s v2 for the 2026-07-28
specification, which depends only on Zod and on its own @modelcontextprotocol/core 2.4.0, also
Apache-2.0, and bundles Ajv, MIT), through the pnpm catalog with the minimum release
age; tests use @modelcontextprotocol/client 2.4.0. It reads the instance’s OpenAPI document at
first use, so its tools match the installed version, and uses the low-level Server because
tools carry raw JSON Schemas (component references copied into $defs). Tool names are the
operationId in snake case. Annotations follow the effect that every operation declares where it
is defined (effect(…) beside its operationId, published as x-guestcraft-effect): read is
read-only; create, which only adds, is not destructive; update, transition and delete are
destructive, and so is a mutation that declares none (an older instance). A contract test of the
API requires an effect on every operation and read on every GET. HTTP mode is createMcpHandler (stateless, with the legacy stateless
fallback), wrapped in node:http by the CLI, bound to 127.0.0.1 by default; it refuses to start
with a credential in its environment, so it never serves anyone with a key of its own.
Contract (API revision 6.2.0): the organizationToken and clientSession security schemes on
every operation that lists apiKey; organizationTokens.list|create|get|revoke|rotate;
clientSessions.list|create|get|revoke; problems organization_token.invalid|not_found| version_mismatch|not_rotatable and client_session.invalid|not_found|version_mismatch; audit
entries’ actor_type gains organization_token and client_session.
Consequences
Section titled “Consequences”- Integrators get Seam’s three levels of credentials: a key per workspace, a token for a group of workspaces, and a client session for front ends, all bounded by a person’s live access and audited as themselves.
- Removing a member or demoting an organization administrator narrows or disables their tokens and the sessions those minted, as for API keys; administrators create replacements first.
- The budgets stay per instance: on several instances (Vercel) each counts and remembers on its own. One workspace’s front ends have at most 100 sessions remembered per instance within a minute; a busier front end gets 429 for new sessions until places free.
- The MCP server exposes every integration operation, not a hand-picked list; destructive operations are marked for the client’s confirmation, and the credential’s own permissions bound what any agent can do. Agents read text written by guests: give them keys with the minimum permissions.
Alternatives considered
Section titled “Alternatives considered”- Organization tokens as API keys without a workspace. Rejected:
api_keysis a workspace table under workspace RLS; an organization table under organization RLS keeps workspace administrators out of the organization’s tokens. - Tokens owned by the organization, surviving their creator (analysis chapter 8). Rejected as ADR 0040 rejected it for keys: the owner asked that a credential never exceed its creator’s live permissions.
- Members minting client sessions in the browser. Rejected: a session is an integration’s delegation to its own front end; a member’s browser already has a session.
- CORS for every credential, or a per-session origin list. The first lets pages read what keys fetched; the second adds little, since a stolen token works from any client anyway.
- A generated MCP catalog committed with the package. Rejected: an instance’s own document always matches its version, as the SDK’s discovery does.
- The MCP server built into every instance (
/api/mcp) with OAuth. Deferred here because it needed OAuth applications; built by the amendment of 1 November 2026.
Limits and planned work
Section titled “Limits and planned work”The amendment of 1 November 2026 below delivers OAuth applications, the MCP server built into
each instance, client sessions limited to one reservation, one guest or a public booking,
organization permissions for tokens and an organization’s share of the remembered keys.
Organization permissions beyond creating workspaces and reading organization Activity,
OpenID Connect sign-in, the client credentials and device grants, Client ID Metadata
Documents, private_key_jwt and DPoP, budgets shared by every instance, and the publication of
@guestcraft/mcp and the SDK are not offered.
Amendment of 1 November 2026: OAuth applications, the built-in MCP server and narrower credentials
Section titled “Amendment of 1 November 2026: OAuth applications, the built-in MCP server and narrower credentials”Observable acceptance criteria:
- OAuth applications. An MCP client or a third-party application registers itself without
signing in (
POST /api/auth/oauth2/register, RFC 7591), sends a member through the authorization code flow with PKCE S256 and an exactly registered redirect URI (a native loopback URI may change only its port), and the member, after signing in, sees a consent page that names the application as unverified text, its redirect host, a workspace they reach, all or some of its properties and the permissions the application asked for that the member holds and an integration may hold. The token it receives (gc_oat_…, an hour; refreshgc_ort_…, 30 days, rotated on each use) works only at the resource it was issued for (<base>/api/v1or<base>/api/mcp) and acts on every request as that member’s live access in that workspace, narrowed to the grant and to the token’s scopes: a member who loses a permission or the workspace takes it from the application at once (401creator_accessonce nothing is left). RFC 7009 revocation, RFC 7662 introspection (inactive once the grant is ineffective) and refresh (refused once it is) work. Settings → Authorized applications lists each workspace’s applications with their member, permissions, properties, creation, last use and what the member still holds, and revokes one (If-Match): its tokens stop at the next request on every instance. Consent, revocation and every action are audited, the actions asoauth_application, naming the grant and acting for the member. - Discovery.
/.well-known/oauth-authorization-server/api/auth(RFC 8414, issuer<base>/api/auth, S256, no client credentials) and/.well-known/oauth-protected-resource/api/v1and…/api/mcp(RFC 9728) answer at the public origin without credentials and to any origin. - Registration limits. Open registration is allowed, as MCP clients need it, with Better
Auth’s 5 registrations a minute per client address and 30 a minute per instance; past 10,000
applications never authorized, registration answers 429. A client authorized by nobody within
a day is deleted, and one unused for 90 days expires with its grants.
GUESTCRAFT_OAUTH_OPEN_REGISTRATION=falselets only the active organization’s owners and administrators register, from a browser session. - The built-in MCP server.
/api/mcpanswers MCP clients over stateless Streamable HTTP (the 2026-07-28 protocol and the 2025 one) on Vercel and Docker. Without a valid credential it answers 401 withWWW-Authenticate: Bearer resource_metadata="<base>/.well-known/oauth-protected-resource/api/mcp"; it accepts an OAuth token issued for it, an API key or an organization token, never a client session or a device token. It offers@guestcraft/mcp’s generated tools and six composed ones:availability_and_quotes,find_reservation,change_booking,check_in_readiness,check_out_readinessandroom_status. A tool whose effect is an update, a transition or a deletion, andchange_booking, change nothing on their first call: they answer a preview, and run only after the client’s form elicitation is accepted or the one-time confirmation token it returned is sent back with exactly the same arguments, once, across every instance of the deployment; a decline or a cancel ends that challenge. Browser pages of the instance’s own and trusted origins may call it (CORS without credentials); others answer 403. - Narrower client sessions. A client session can be limited to one reservation (with its
property), one guest or one property’s public booking (
ClientSessionScope); it then reaches only the operations that name that object, and every other one answers 403, audited. Each such scope kind also has an explicit permission set in core (NARROW_SCOPE_PERMISSIONS): the permissions its operations check, without the overrides they accept, so a reservation’s session never sets its own cancellation fee and a public booking never prices by hand or books before the business date. Minting with any other answersclient_session.invalidonpermissions, and each request intersects a session’s stored permissions with the set again. First-party guest sessions (GuestSessionIssuer) are issued in process, never through the API, and reach no staff operation. - Organization permissions. An organization token can hold
workspaces.createandorganization_audit.view, which its creator holds live as an owner or administrator; with them it creates workspaces and reads organization Activity without any workspace, and it never administers the organization.
OAuth applications. Better Auth’s @better-auth/oauth-provider 1.7.10 (MIT, the version of
Better Auth) runs the protocol, behind GuestCraft’s guard: only authorize, token, register,
revoke and introspect are reachable, never its client, resource or consent administration. It
is configured without its JWT plugin, so access tokens are opaque and looked up on every
request, and revoking a grant or narrowing a member takes effect on the next request on every
instance with no cache to invalidate: the remembered keys of ADR 0040 are an admission budget,
never an authorization cache. Tokens are 32 bytes of the injected random source behind their
prefix, stored as the SHA-256 hex of the whole secret like every other credential. No
openid scope is offered (no ID tokens). The scopes are offline_access and the permissions an
integration may hold, the same list as API keys; oauth_applications.manage (workspace,
built-in for administrators) is browser-only.
A server-owned grant (guestcraft.oauth_grants, a workspace table) records what one member
authorized one application to do in one workspace: its permissions and properties (or all).
Better Auth’s consent, codes and tokens name it by referenceId, which only the server’s
consent completion supplies: it runs Better Auth’s consent call in process inside a scope bound
to the verified browser session and application, and the provider’s consentReferenceId hook
compares both and fails closed on any mismatch. Outside that scope no stored consent matches, so
every authorization shows the consent page. The consent re-checks the member’s live grant at
completion; asking for more needs a new consent. On each request a token’s grant is found by
guestcraft.oauth_token_grant(digest) (security definer, the 2-second lock bound), which
refuses a token of another application or member, a revoked one and a sender-constrained one;
the principal is the member’s live access narrowed to the grant and the token’s scopes, with
an empty property intersection meaning no access. A request is admitted on its own against the
token’s resources: /api/v1/** needs <base>/api/v1 and /api/mcp needs <base>/api/mcp,
both from the configured base URL. Revoking a grant deletes its tokens and consent in the same
transaction and keeps the row, revoked. A rotated refresh token used again ends that member’s
refresh family for the application. Last use is recorded on the grant at most every five
minutes. Budgets are an API key’s, in a share of their own: the access tokens of one workspace
take at most 100 remembered places apart from its API keys, within the organization’s 1,000, since
they are per member and application and rotate hourly.
Registration takes untrusted metadata: bodies of at most 16 KiB, bounded fields, only the
authentication methods none, client_secret_basic and client_secret_post, the
authorization_code and refresh_token grants, and none of jwks, jwks_uri, software
statements, logout URIs, DPoP or pairwise subjects. Names and URIs are shown as unverified text,
and nothing is fetched from them. Confidential client secrets do not expire: the 90-day rule
bounds abandoned clients instead. guestcraft.oauth_cleanup(at, batch) applies the lifetime
rules in bounded passes, at each registration and from the worker each hour.
The built-in MCP server. server composes @guestcraft/mcp (MIT) at /api/mcp. Each MCP
request is authenticated once through the gateway at the resource’s own URL, without its
cookie; its tools then call the API in process with the caller’s resolved principal, carried by
the request object itself, so the API’s permissions, property scopes, ceilings and audit apply
and no token is passed through or replaced by an instance credential. The confirmation gate
lives in @guestcraft/mcp, so the stdio server has it too: a challenge sealed with HMAC-SHA256
binds the caller, the workspace, the tool, its exact arguments (including if_match), a nonce
and an expiry of ten minutes; the MRTR requestState of form elicitation and the confirmation
token use it. The built-in server derives its key from AUTH_SECRET and consumes each nonce in
guestcraft_meta.mcp_confirmations, which keeps the outcome as identifiers and status only, so a
retry returns that outcome and never runs twice. Elicitation is the client’s mediation of an
approval, not proof of a person. Tool calls end within 25 seconds; one cut off is recorded as of
unknown outcome. Each operation an OAuth token may call publishes the permissions it needs, any
one of which is enough, as x-guestcraft-permissions (an addition to the 6.12.0 document); the
gateway passes the MCP server the permissions the token’s scopes name, before the member’s live
access narrows them. A tool call or resource read that needs one the token lacks (a composed
tool: one of the operations it always calls) is answered 403 before it runs with
WWW-Authenticate: Bearer error="insufficient_scope", scope="…", resource_metadata="…" (RFC 6750,
section 3.1), so that the client asks for those scopes and the member consents again. Every other
refusal stays the API’s 403 as the tool’s error: an API key’s or organization token’s
permissions, which have no scopes, and a permission the member no longer holds, which no
re-authorization could give back; a composed tool reports a refused partial read in its answer.
check_in_readiness mirrors check-in’s rules on the property’s business date and local date
(night_audit.required, check_in.outside_stay) and check_out_readiness reports
night_audit.required as check-out would.
Client session scopes. client_sessions stores its scope with composite foreign keys (a
reservation always at the scope’s property) and a check against contradictory fields; the
credential function returns it and finds no session of a merged guest. The API admits a scoped
session only to an explicit allow-list per kind, with every path parameter equal to the scope:
a reservation, its prepayments, cancellation simulation, cancellation and folios; a guest, its
reservations and consents; and for a public booking the availability, quotes and new bookings
that create a new guest, never one naming an existing guest. A guest-limited session names no
property (its property_ids is [], as a list it stays for every session) and is bounded by its
minter’s properties. First-party guest
sessions are their own table and prefix (guest_sessions, gc_gst_…): the GuestPermission
vocabulary with fixed combinations per scope kind, at most an hour, durable revocation. The
issuer is bound to a workspace by its factory, since the contract’s input names none; its
callers verify a single-use link code or a lookup first, and the API resolves none of these
tokens. The contract types are those of the coordinator, copied verbatim into
core/integrations/client-sessions.ts.
Organization permissions. A catalog apart from the workspace permissions,
ORGANIZATION_PERMISSIONS: workspaces.create and organization_audit.view. Owners and
administrators hold both through their role; a token holds those it was given that its creator
still holds, checked when it authenticates and again under the organization’s lock, and neither
puts the organization in a tenant scope. A token needs one permission of either kind; one with no
workspace permission reaches no workspace (workspace_ids: []) and calls its two operations
without GuestCraft-Workspace-Id. The MCP server’s in-process calls reach these two operations
with the caller it authenticated.
Contract (API revision 6.12.0, an addition-only revision under ADR 0016, which
contract-compatibility.unit.test.ts checks against the 6.11.0 contract): the oauthApplication
security scheme; authorizedApplications.list|get|revoke; ClientSessionScope and the sessions’
scope, with property_ids keeping its type and staying required in the request, where beside a
scope it must name the properties the scope names; the organization_permissions of tokens; problems
oauth_application.invalid|not_found|version_mismatch; audit entries’ actor_type
oauth_application and oauth_grant_id; the permission oauth_applications.manage.
Migrations 0063 (organization permissions), 0064 (client session scopes and guest sessions),
0065 (MCP confirmations) and 0068 (OAuth applications).
Evidence (1 November 2026): through the composed HTTP runtime on PostgreSQL behind PgBouncer,
oauth-applications.integration.test.ts (discovery, registration and its refusals, sign-in to
consent, a tampered consent page, PKCE and literal redirect URIs, the consent ceiling, tokens at
their resource only, browser-only operations, revocation, refresh rotation and reuse, a removed
member, introspection, and OAuth at /api/mcp), mcp-endpoint.integration.test.ts (the
challenge, the credentials accepted and refused, a property-limited caller, one confirmation
across two runtime instances), scoped-sessions.integration.test.ts and the organization-token
cases of api-platform.integration.test.ts; the database tests of the grant, guest-session and
confirmation tables and functions, the RLS matrix and the catalog; unit tests of the gateway, the
confirmation gate and the composed tools; the browser scenario oauth-applications.spec.ts
(keyboard consent, axe, revoke).