ADR 0022: restore the OpenAPI client pipeline and independent integration keys
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: accepted
- Date: 2026-10-26
- Context: issue 15, architecture alignment requested by the owner
- Supersedes: ADR 0021’s initial SDK implementation and authentication-derived integration encryption
Acceptance
Section titled “Acceptance”The existing connection workflow must keep working after reload, failed replacement, workspace changes and cancellation. OpenAPI generates the SDK and query factories; the dashboard consumes those clients. Credentials remain encrypted and never enter logs, response state, read caches or mutation caches. Existing v1 credentials must remain readable during migration, and explicit maintenance must rewrite all records atomically. Isolation, PostgreSQL/PgBouncer tests, coverage gates and a production build remain required. This change does not implement booking or inventory sync.
Decision
Section titled “Decision”tooling/codegen runs pinned Hey API 0.99.0 against the offline OpenAPI artifact.
Hey API generates operations, TypeScript types, Zod validators and TanStack Query
factories. There is no local schema/operation generator. The MIT sdk package owns
the HTTP client and safe error/cancellation handling. MIT react owns the generated
query factories and workspace-scoped query lifetimes. Credential writes use the
SDK directly so the plaintext never enters a TanStack mutation cache.
Hey API’s emitted Fetch runtime is incompatible with exactOptionalPropertyTypes
(the earlier API review already identified it). Only upstream-generated code is
compiled using the isolated TypeScript 6 API with that option disabled; it emits
JavaScript and declarations. Relative declaration imports are normalized to .js.
Every handwritten consumer still uses strict TypeScript 7 with exact optional types.
Generation runs in a temporary directory; a check compares every output byte without
rewriting it. Generated runtime/types carry MIT notices and stay outside handwritten
coverage and lint. The generated query artifacts reach SDK exports by package names,
not relative imports across package boundaries. Zod is the SDK’s runtime dependency;
React and TanStack Query belong to react, with no React dependency in the SDK.
The core’s channex context owns authorization, validation and persistence orchestration.
Its repository, remote and credential-vault ports contain no infrastructure imports.
The channex package implements bounded production HTTP reads. Database repositories
implement core ports. server supplies crypto and wires those adapters. Bootstrap,
identity shell and protected first-account setup remain server entry points; operational
tenant data uses the public API/SDK path. They do not grant the dashboard database access.
ENCRYPTION_KEYS is an independent, versioned keyring (2:secret,1:secret, newest
first; v2:secret is also accepted). Authentication secrets cannot be reused as its
values. New credentials use v2 envelope encryption: a random 256-bit data key per
record, wrapped by the current integration key. Both encrypted values bind the
instance, organization, workspace and key version. Removed-key and tampering errors
remain generic. Fresh installations generate this key separately from AUTH_SECRET.
Without the keyring, login/setup and old connection reads still work; new connection
writes fail before any outbound request. An auth secret is never a fallback write key.
Legacy v1 values remain decryptable using ENCRYPTION_LEGACY_AUTH_SECRETS, or the
existing session secret ring while transitioning. credentials:reencrypt uses the
direct migration connection, checks the initialized instance and locks credential rows.
Every rewrite commits together or rolls back together. It makes no remote requests,
preserves connection metadata and never prints credential values or database errors.
Retain old keys while backups still need them. Rewrapping current records does not
make an old backup readable without the keys that encrypted it.
Remaining scope
Section titled “Remaining scope”The SDK and React package remain private, unpublished workspaces. Discovery, bearer keys, automatic retries/idempotency, pagination helpers, Fumadocs, mobile, worker entry points and synchronization are not claimed as implemented by this decision. Existing ADR 0019 (pg-boss), 0011 (SQL migrations) and 0018 (design-system adjustments) stand.
Amendment 2026-10-30: one generated module per OpenAPI tag
Section titled “Amendment 2026-10-30: one generated module per OpenAPI tag”Bundlers drop a module’s unused exports for the whole application, not per route, and they keep
the z.object(…) calls of unused schemas, which they cannot prove pure. With one module of
operations, one of schemas and one of query factories, every route that used the SDK carried all
of them: one chunk of about 53.6 kB of gzip with every operation, including the settings-only API
key and webhook ones (ADR 0040).
Decision. After Hey API generates, tooling/codegen/split.ts moves each declaration of
sdk.gen, zod.gen and react-query.gen into one module per tag, named in kebab case:
operations/<tag>.gen.js, zod/<tag>.gen.js and react-query/<tag>.gen.js. An operation goes to
its OpenAPI tag, a schema to the one tag whose operations validate with it, a query factory to the
tag of the operation it calls. A schema no operation uses, such as a request body, goes with the
tag modules it uses. Everything else goes to shared, with whatever it uses, so a shared module
never imports a tag module. The split moves Hey API’s declarations unchanged: it does not interpret
the contract beyond each operation’s tag, and the compiled output stays byte-checked. The three
original files remain barrels of exactly their original exports. Zod request schemas are no longer
generated: the SDK validates responses only, and bundles kept their construction calls.
The packages export @guestcraft/sdk/operations/<tag> and @guestcraft/react/query/<tag>.
createGuestCraftTransport sends any generated operation with the error, cancellation and
workspace-header behaviour of the facade, without referencing any operation. send is typed by
the operation it sends, without casts; for that, the generator marks GuestCraft-Workspace-Id
optional in its copy of the contract (the client sends the transport’s), and openapi/v1.json
still says where the API requires it; createGuestCraft
is built on it and is unchanged for its callers. The two facade groups with checks of their own
(propertiesFacade, channexConnectionFacade) are exported for @guestcraft/react, whose
createGuestCraftQueries no longer builds the whole facade. A bundler keeps every module that a
barrel re-exports, so handwritten SDK code imports schemas from their defining module, and the web
app imports tag modules, never the barrels, the root operations namespace or createGuestCraft
(a Biome rule). pnpm web:budgets also fails when a route other than their two settings pages
defines an operation of the api_keys or webhooks tag (tooling/quality/web-route-operations.json,
ADR 0040); it reads each tag’s operations from the contract, so a new one is checked too.
Consequences. Every route that uses the SDK is 5.0 to 13.8 kB of gzip smaller in paired production builds; routes without it are unchanged. A route ships whole tags: the shell of every workspace page ships the properties, Channex (19 operations) and messages (24 operations) tags for the property list, the connection status and the unread count. Moving an operation to another tag in the contract moves it to another module, so its import path changes; the barrels keep the old names.
Amendment 2026-10-31: transport problem schema isolation
Section titled “Amendment 2026-10-31: transport problem schema isolation”The transport validates errors on every route. Importing its problem schema from the
generated shared module also initialized unrelated guest and reservation schemas, so new
guest fields increased bundles on screens such as invitations. The split now treats
zProblem as a separate schema root; its dependencies, including zFieldError, follow the
existing dependency graph into zod/problem.gen. The transport imports that defining module.
Hey API still owns the declarations and validation, and the original barrel keeps every
public export. A regression checks that error validation has no feature-schema imports.
Guest-only review panels load dynamically with loading, failure and retry states.
The handwritten facade validators are also explicit roots: zPropertySettings belongs to
the properties module, and zChannexConnectionInput to Channex. These two inputs previously
had no generated operation consumer and fell into the shared feature-schema module.
Keeping them with their own tags prevents property setup and the shared workspace shell
from initializing guest/stay schemas. Their declarations and input validation stay intact.
The workspace command palette loads the generated guest and reservation query modules only when a search is active. Both tags must be deferred because their response schemas share a module. A resolved import checks that the search context is still active before fetching; closing the palette or changing workspace cancels its queries and clears its private cache.
Amendment 2026-10-31: request bodies go with the tags that take them
Section titled “Amendment 2026-10-31: request bodies go with the tags that take them”No response reaches a request body’s Zod schema, so the split placed one by the schemas it uses;
a body built only from shared schemas (money, dates, identifiers) went to shared and shipped with
every route that reports a problem. tags.ts now also reads, for each operation, the component
schemas its JSON request body names, and the split roots those schemas at the operation’s tag, as it
does a response: a body one tag takes goes to that tag’s module, one several tags take stays shared.
The split still moves Hey API’s declarations unchanged. In the build that introduced it, the shared
Zod module went from 4,437 to 3,192 bytes of gzip, and /invitations from 258,706 to 257,780 bytes
of initial JavaScript. facades.ts imports the Channex connection and property settings schemas
from their tag modules, which the workspace shell already ships.