Salta ai contenuti

0047. The API platform: organization tokens, client sessions and the MCP server

Questi contenuti non sono ancora disponibili nella tua lingua.

  • 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

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_… with GuestCraft-Workspace-Id acts 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, reason workspace). GET /v1/workspaces lists 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 type organization_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 with POST /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/workspaces tells the front end its workspace, permissions and properties.
  • Members, API keys and organization tokens holding client_sessions.manage list 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 }) and clientSessions.create on 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: Bearer credential 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.json maps to exactly one tool or is refused to integrations’ credentials.

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.

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.

  • 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.
  • Organization tokens as API keys without a workspace. Rejected: api_keys is 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.

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; refresh gc_ort_…, 30 days, rotated on each use) works only at the resource it was issued for (<base>/api/v1 or <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 (401 creator_access once 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 as oauth_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/v1 and …/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=false lets only the active organization’s owners and administrators register, from a browser session.
  • The built-in MCP server. /api/mcp answers 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 with WWW-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_readiness and room_status. A tool whose effect is an update, a transition or a deletion, and change_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 answers client_session.invalid on permissions, 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.create and organization_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).