From 85a937a5eda7138332ddb3de59d6eac88044e80f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 16:52:08 +0000 Subject: [PATCH] test(cloud-connection): envelope conformance for the plugin-route door + teach the gate its third surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `plugin-route` door — a plugin mounting its own Hono routes and answering refusals with `c.json({ success: false, error: { … } })` — had no envelope conformance coverage of any kind. Its bodies pass through neither the dispatcher's `errorFromThrown` nor packages/rest's responders, so neither the central narrowing nor the central suites ever saw them. Both halves, per triage direction 3: - A conformance suite in packages/cloud-connection drives the four plugins' real error exits through their mounted routes and parses each emitted body against BaseResponseSchema / envelopeViolations / ApiErrorSchema from @objectstack/spec/api, rather than reading one field off it. - scripts/check-route-envelope.mjs gains a third surface: plugin-mounted Hono routes, discovered by parsing rather than by filename, counting the bodies that depart from the declared envelope. Write-site totals are reported and never pinned (the #7295 lesson); every violation counter ticks down only. What the new surface found and this fixes, all local to an error exit: - Eleven refusals on /api/v1/cloud-connection/* emitted `error: { code }` with no `message`. ApiErrorSchema requires it, so `body.error.message` read `undefined` — the #3843 class. The Console had already grown the consumer-side accommodation that produces, displaying `body?.error?.message ?? body?.error?.code`. - /bind/poll stamped the upstream RFC 8628 spelling (`expired_token`, …) straight into the closed ADR-0112 `code` slot, and carried no `message`. The verbatim spelling now rides `declaredCode`, the open producer-authored channel ADR-0112 declares for exactly this; `code` carries the registered member. The remaining 20 non-conforming bodies the surface found across five other packages are filed as #9364 and recorded as ratchets — measured, pinned, and not blessed. Two of them are cross-repo breaking wire changes, not conformance tidying. Co-Authored-By: Claude --- .../plugin-route-envelope-conformance.md | 30 + .../src/cloud-connection-plugin.ts | 58 +- .../src/error-envelope.conformance.test.ts | 532 ++++++++++++++++++ scripts/check-route-envelope.mjs | 459 +++++++++++++++ 4 files changed, 1067 insertions(+), 12 deletions(-) create mode 100644 .changeset/plugin-route-envelope-conformance.md create mode 100644 packages/cloud-connection/src/error-envelope.conformance.test.ts diff --git a/.changeset/plugin-route-envelope-conformance.md b/.changeset/plugin-route-envelope-conformance.md new file mode 100644 index 0000000000..dd7b6297ef --- /dev/null +++ b/.changeset/plugin-route-envelope-conformance.md @@ -0,0 +1,30 @@ +--- +"@objectstack/cloud-connection": patch +--- + +Cloud-connection refusals now emit the response envelope they declare. + +Eleven error exits on `/api/v1/cloud-connection/*` answered with +`error: { code }` and no `message`. `ApiErrorSchema.message` is REQUIRED, so +`body.error.message` read `undefined` on the wire for every one of them — the +Console had already grown the accommodation that produces, displaying +`body?.error?.message ?? body?.error?.code` and so showing a machine code to a +human. All eleven now carry a readable message; no status and no code changed. + +`POST /api/v1/cloud-connection/bind/poll` additionally stamped the UPSTREAM +RFC 8628 spelling (`expired_token`, `access_denied`, …) straight into +`error.code`, which is a closed ADR-0112 vocabulary — so that body failed its +own contract. The wire change, for anyone branching on it: + + before: { success: false, data: { pending: false }, + error: { code: "expired_token" } } + after: { success: false, data: { pending: false }, + error: { code: "DEVICE_CODE_FAILED", + declaredCode: "expired_token", + message: "Device authorization failed: expired_token" } } + +Nothing is lost: the verbatim upstream spelling now rides `declaredCode`, the +open producer-authored channel ADR-0112 declares for a code the serving side's +ledger does not know. Read `error.declaredCode` where you previously read +`error.code` for the RFC 8628 value; `error.code` is now the registered member, +which is what a consumer branching on platform conditions should key on. diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 579b32b769..6b703c171f 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -73,6 +73,20 @@ import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; +/** + * The one message every `ENVIRONMENT_NOT_FOUND` refusal on this surface carries. + * + * `ApiErrorSchema.message` is REQUIRED, and eight exits here used to emit + * `error: { code }` alone — so `body.error.message` read `undefined` on the + * wire. That is the #3843 class exactly, and the Console had already grown the + * consumer-side accommodation it produces: `CloudConnectionPanel` displays + * `body?.error?.message ?? body?.error?.code`, i.e. it shows a machine code to a + * human because the readable half was never sent. One constant rather than eight + * literals, because the condition is one condition: the request did not resolve + * to an environment. + */ +const ENVIRONMENT_NOT_FOUND_MESSAGE = 'Could not resolve an environment for this request host.'; + export interface CloudConnectionPluginConfig { /** Control-plane base URL. Default: `OS_CLOUD_URL` (read lazily at kernel:ready). */ controlPlaneUrl?: string; @@ -211,7 +225,7 @@ export class CloudConnectionPlugin implements Plugin { const stored = this.store.read(); const runtimeId = stored?.runtimeId; if (!environmentId && !this.cfg.singleEnvironment) { - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } // A single-env runtime with no env id and no credential is // simply not bound yet — valid state, not an error. @@ -263,7 +277,7 @@ export class CloudConnectionPlugin implements Plugin { environmentId = String(body?.environment_id ?? body?.environmentId ?? '').trim() || undefined; } if (!environmentId && !this.cfg.singleEnvironment) { - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401); @@ -318,11 +332,11 @@ export class CloudConnectionPlugin implements Plugin { environmentId = String(body?.environment_id ?? body?.environmentId ?? '').trim() || undefined; } if (!environmentId && !this.cfg.singleEnvironment) { - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); - if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED' } }, 401); - if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED' } }, 503); + if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); + if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); const deviceCode = String(body?.device_code ?? body?.deviceCode ?? '').trim(); if (!deviceCode) return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'device_code is required' } }, 400); @@ -340,7 +354,27 @@ export class CloudConnectionPlugin implements Plugin { // are non-terminal; everything else is terminal. const errCode = String(tok?.error ?? `device/token ${tokResp.status}`); const pending = errCode === 'authorization_pending' || errCode === 'slow_down'; - return c.json({ success: pending, data: { pending }, error: pending ? undefined : { code: errCode } }, pending ? 200 : 400); + if (pending) return c.json({ success: true, data: { pending: true } }, 200); + // The upstream RFC 8628 spelling (`expired_token`, + // `access_denied`, …) is NOT a member of the closed + // `ApiErrorSchema.code` vocabulary, so stamping it into + // `code` emitted a body that fails its own contract. It + // rides `declaredCode` instead — the open, producer- + // authored channel ADR-0112 declares for exactly this + // (a code the serving side's ledger does not know) — + // while `code` carries the registered member. Nothing is + // lost: the verbatim spelling still reaches the caller. + return c.json({ + success: false, + // Retained on the failure body too: the Console polls + // this route and reads `body?.data?.pending` first. + data: { pending: false }, + error: { + code: 'DEVICE_CODE_FAILED', + declaredCode: errCode, + message: `Device authorization failed: ${errCode}`, + }, + }, 400); } // Persist the binding through the control plane. The // registration claim rides along (ADR @@ -396,10 +430,10 @@ export class CloudConnectionPlugin implements Plugin { rawApp.post(`${CLOUD_CONNECTION_PREFIX}/unbind`, async (c: any) => { const environmentId = await resolveEnvironmentId(c); if (!environmentId && !this.cfg.singleEnvironment) { - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); - if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED' } }, 401); + if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); // Revoke cloud-side FIRST (the oscc_ bearer self-identifies; // env-keyed bindings name the environment), then clear the @@ -441,7 +475,7 @@ export class CloudConnectionPlugin implements Plugin { // control plane using the env→cloud service credential. rawApp.post(`${CLOUD_CONNECTION_PREFIX}/install`, async (c: any) => { const environmentId = await resolveEnvironmentId(c); - if (!environmentId) return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + if (!environmentId) return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); // Local authz: require a valid env session. (TODO: tighten to an // explicit env-admin role check; today only owners/admins obtain @@ -493,7 +527,7 @@ export class CloudConnectionPlugin implements Plugin { // env-scoped; a registration-only runtime tracks installs // locally (LocalManifestSource). Report not-installed. if (this.cfg.singleEnvironment) return c.json({ success: true, data: { installed: false } }); - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId, c.req.raw); @@ -555,7 +589,7 @@ export class CloudConnectionPlugin implements Plugin { if (this.cfg.singleEnvironment) { return c.json({ success: true, data: { packages: [], total: 0, connected: Boolean(credential()) } }); } - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId, c.req.raw); @@ -592,7 +626,7 @@ export class CloudConnectionPlugin implements Plugin { rawApp.get(`${CLOUD_CONNECTION_PREFIX}/org-packages`, async (c: any) => { const environmentId = await resolveEnvironmentId(c); if (!environmentId && !this.cfg.singleEnvironment) { - return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404); + return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); } const session = await resolveSession(environmentId ?? '', c.req.raw); diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts new file mode 100644 index 0000000000..da823c50c4 --- /dev/null +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -0,0 +1,532 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Error-envelope conformance for the `plugin-route` door (#9267). + * + * ## The door this covers, and why it had none + * + * A plugin that mounts its OWN Hono routes and answers refusals with its own + * `c.json({ success: false, error: { … } })` is a THIRD way this platform emits + * REST. Its bodies pass through neither the dispatcher's `errorFromThrown` nor + * `packages/rest`'s responders, so none of the central narrowing and none of the + * central conformance suites ever sees them. Measured on `origin/main` before + * this file existed: `ApiErrorSchema|envelopeViolations|BaseResponseSchema` + * matched ZERO lines anywhere in this package, source and tests alike, while + * four plugin files here hand-build response envelopes. + * + * The tests that DID sit at this seam assert individual fields + * (`res.payload.error.code`, `details.findings`). That is the assertion style + * #3843 was filed about: it pins one key and notices nothing about the body + * around it, so a body drifting off `BaseResponseSchema` keeps every one of them + * green. + * + * ## What this file asserts, and what it deliberately does not + * + * Every case drives a REAL error exit through the route the plugin actually + * mounts — `start()` + `kernel:ready`, the kernel's own lifecycle — and then + * parses the emitted body against the declared contract rather than reading one + * field off it: + * + * 1. `BaseResponseSchema.safeParse` — it parses as an envelope at all. + * 2. `envelopeViolations` — it IS the declared envelope. `safeParse` alone + * passes `{ success: true }` with no payload and passes a payload + * duplicated into a stray top-level key (#4038 / #4049); this is the check + * that does not. + * 3. `ApiErrorSchema.safeParse(body.error)` — the nested error is the declared + * error, INCLUDING that `code` is a member of the closed ADR-0112 + * vocabulary. An invented spelling fails here rather than reaching a wire + * nobody audits, which is the specific way `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` + * once shipped unregistered behind a green gate (#9246). + * + * The status and code are asserted too, but as the case's IDENTITY — proof the + * exit under test is the one that ran — not as the conformance claim. A bare + * `expect(...).toThrow()` or a lone `error.code` equality is not a rejection + * pin: an unfixed producer emitting a naked `Error` keeps both green. + * + * What this file does NOT govern is the shape of `data`. That is each route's + * own payload schema, and conflating the two is what let a payload type describe + * a whole body before #3843. + * + * ## The other half + * + * A suite catches what it DRIVES. It structurally cannot see the exit nobody + * wrote a case for, and this package's four plugin files carry ~80 hand-built + * bodies between them. The structural half is + * `scripts/check-route-envelope.mjs`, which counts non-conforming hand-built + * Hono bodies across the whole repo as its third surface — added by #9267 + * alongside this file, for exactly that reason. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { BaseResponseSchema, ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; +import { CloudConnectionPlugin } from './cloud-connection-plugin.js'; +import { MarketplaceProxyPlugin } from './marketplace-proxy-plugin.js'; +import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; +import { RuntimeConfigPlugin } from './runtime-config-plugin.js'; + +// ── Shared harness ─────────────────────────────────────────────────────────── + +type Handler = (c: any, next?: any) => Promise; +type Captured = { status: number; body: any }; + +function makeRawApp() { + const routes = new Map(); + return { + routes, + get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), + post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), + put: (p: string, h: Handler) => routes.set(`PUT ${p}`, h), + delete: (p: string, h: Handler) => routes.set(`DELETE ${p}`, h), + head: (p: string, h: Handler) => routes.set(`HEAD ${p}`, h), + all: (p: string, h: Handler) => routes.set(`ALL ${p}`, h), + }; +} + +/** + * A Hono-ish context. `json` captures rather than serialises, so a case reads + * the object the handler built — which is the thing the envelope governs. + */ +function makeC(opts: { + url: string; + method?: string; + body?: unknown; + params?: Record; + headers?: Record; +} ): { c: any; captured: Captured } { + const captured: Captured = { status: 0, body: undefined }; + const h = new Headers(opts.headers ?? {}); + const c: any = { + req: { + url: opts.url, + method: opts.method ?? 'GET', + path: new URL(opts.url).pathname, + raw: new Request(opts.url, { headers: h }), + header: (n: string) => h.get(n) ?? undefined, + json: async () => opts.body ?? {}, + param: (n: string) => opts.params?.[n], + query: () => undefined, + }, + header: () => undefined, + json: (body: any, status?: number) => { + captured.body = body; + captured.status = status ?? 200; + return captured; + }, + }; + return { c, captured }; +} + +/** + * The conformance assertion itself — the whole point of the file. + * + * Kept as one helper so every case is held to the SAME three checks: a case that + * quietly dropped one would be indistinguishable from a case that passed it. + */ +function expectDeclaredErrorEnvelope(captured: Captured, expected: { status: number; code: string }) { + const { status, body } = captured; + const shown = JSON.stringify(body); + + // Identity of the exit under test — not the conformance claim. + expect(status, `wrong exit ran: ${shown}`).toBe(expected.status); + + // 1. Parses as an envelope. + const parsed = BaseResponseSchema.safeParse(body); + expect(parsed.success, `body is not a BaseResponse: ${shown}`).toBe(true); + + // 2. IS the declared envelope — the check safeParse cannot express. + expect(envelopeViolations(body), `not the declared envelope: ${shown}`).toEqual([]); + + // 3. The nested error is the declared error, code vocabulary included. + expect(body.success).toBe(false); + const err = ApiErrorSchema.safeParse(body.error); + expect( + err.success, + `error is not an ApiError (an unregistered code fails HERE): ${shown} ${JSON.stringify(err.error?.issues ?? [])}`, + ).toBe(true); + expect(body.error.code).toBe(expected.code); + + // The pre-#3675 dialect and its #7035 sibling, explicitly dead on this door. + expect(typeof body.error, `\`error\` is a bare string — the pre-#3675 dialect: ${shown}`).not.toBe('string'); + expect(body.code, `\`code\` sits BESIDE \`error\` rather than inside it: ${shown}`).toBeUndefined(); + expect(typeof body.error.message).toBe('string'); + expect(body.error.message.length).toBeGreaterThan(0); +} + +// ── CloudConnectionPlugin — /api/v1/cloud-connection/* ─────────────────────── + +const CC = '/api/v1/cloud-connection'; +const HOST = 'https://tenant.example.com'; + +async function mountCloudConnection(opts: { + controlPlaneUrl?: string; + controlPlaneApiKey?: string; + singleEnvironment?: boolean; + environmentId?: string; + userId?: string; + resolvesEnvironment?: boolean; +} = {}) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const auth = { + api: { + getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null), + }, + }; + const services: Record = { + 'env-registry': { + resolveByHostname: async () => + (opts.resolvesEnvironment ? { environmentId: 'env-123' } : undefined), + }, + 'kernel-manager': { getOrCreate: async () => ({ getServiceAsync: async () => auth }) }, + auth, + manifest: { register: vi.fn() }, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no service ${name}`); + return svc; + }, + registerService: () => undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const plugin = new CloudConnectionPlugin({ + controlPlaneUrl: opts.controlPlaneUrl ?? 'http://cloud.test', + controlPlaneApiKey: opts.controlPlaneApiKey, + singleEnvironment: opts.singleEnvironment, + environmentId: opts.environmentId, + } as any); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + return rawApp.routes; +} + +async function driveCloudConnection( + routes: Map, + key: string, + opts: { body?: unknown } = {}, +): Promise { + const handler = routes.get(key); + if (!handler) throw new Error(`no handler for ${key} (have: ${[...routes.keys()].join(', ')})`); + const [method, path] = key.split(' '); + const { c, captured } = makeC({ url: `${HOST}${path}`, method, body: opts.body }); + await handler(c); + return captured; +} + +beforeEach(() => { delete process.env.OS_ENVIRONMENT_ID; delete process.env.OS_CLOUD_URL; }); +afterEach(() => { vi.unstubAllGlobals(); delete process.env.OS_ENVIRONMENT_ID; delete process.env.OS_CLOUD_URL; }); + +describe('plugin-route door — CloudConnectionPlugin error exits (#9267)', () => { + it('GET /status with no resolvable environment → 404 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: false }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `GET ${CC}/status`), + { status: 404, code: 'ENVIRONMENT_NOT_FOUND' }, + ); + }); + + it('POST /bind/start without a session → 401 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/bind/start`, { body: {} }), + { status: 401, code: 'UNAUTHENTICATED' }, + ); + }); + + it('POST /bind/poll without a session → 401 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/bind/poll`, { body: { device_code: 'dc_1' } }), + { status: 401, code: 'UNAUTHENTICATED' }, + ); + }); + + it('POST /bind/poll with no device_code → 400 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1' }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/bind/poll`, { body: {} }), + { status: 400, code: 'INVALID_REQUEST' }, + ); + }); + + it('POST /unbind without a session → 401 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/unbind`, { body: {} }), + { status: 401, code: 'UNAUTHENTICATED' }, + ); + }); + + it('POST /install without a session → 401 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/install`, { body: { package_id: 'pkg_1' } }), + { status: 401, code: 'UNAUTHENTICATED' }, + ); + }); + + it('POST /install with a session but no cloud credential → 503 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1' }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/install`, { body: { package_id: 'pkg_1' } }), + { status: 503, code: 'CLOUD_UNCONFIGURED' }, + ); + }); + + it('POST /install past the credential gate with no package_id → 400 in the declared envelope', async () => { + const routes = await mountCloudConnection({ + resolvesEnvironment: true, userId: 'usr_1', controlPlaneApiKey: 'svc-key', + }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `POST ${CC}/install`, { body: {} }), + { status: 400, code: 'INVALID_REQUEST' }, + ); + }); + + it('GET /installed without a session → 401 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `GET ${CC}/installed`), + { status: 401, code: 'UNAUTHENTICATED' }, + ); + }); + + it('GET /org-packages without a session → 401 in the declared envelope', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + expectDeclaredErrorEnvelope( + await driveCloudConnection(routes, `GET ${CC}/org-packages`), + { status: 401, code: 'UNAUTHENTICATED' }, + ); + }); + + /** + * The exit this suite was worth writing for. + * + * `/bind/poll` relays RFC 8628 token-endpoint errors. Their spellings — + * `expired_token`, `access_denied`, `invalid_grant` — are the UPSTREAM + * vocabulary, and stamping one into `error.code` emitted a body that failed + * its own contract twice over: an unregistered code in the closed ADR-0112 + * slot, and no `message` at all. Neither was visible to any assertion at + * this seam, because none of them parsed the body. + * + * The verbatim spelling still reaches the caller — on `declaredCode`, the + * open producer-authored channel ADR-0112 declares for precisely this case. + * That is asserted here rather than left implicit: dropping it would be a + * silent loss of what the caller used to be told. + */ + it('POST /bind/poll relaying a terminal RFC 8628 error → 400 with the upstream spelling on `declaredCode`', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1' }); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: 'expired_token' }), + }))); + + const captured = await driveCloudConnection(routes, `POST ${CC}/bind/poll`, { + body: { device_code: 'dc_expired' }, + }); + + expectDeclaredErrorEnvelope(captured, { status: 400, code: 'DEVICE_CODE_FAILED' }); + expect(captured.body.error.declaredCode).toBe('expired_token'); + expect(captured.body.error.message).toContain('expired_token'); + }); + + /** + * The non-terminal half of the same exit, kept beside it: `authorization_pending` + * is a 200 the Console polls on, and it must be a conformant SUCCESS body — + * a success envelope carrying no `data`, or carrying a stray `error` key, is + * exactly what `envelopeViolations` exists to catch. + */ + it('POST /bind/poll while authorization is pending → 200 success envelope, no `error` key', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1' }); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: false, + status: 400, + json: async () => ({ error: 'authorization_pending' }), + }))); + + const { status, body } = await driveCloudConnection(routes, `POST ${CC}/bind/poll`, { + body: { device_code: 'dc_pending' }, + }); + + expect(status).toBe(200); + expect(BaseResponseSchema.safeParse(body).success, JSON.stringify(body)).toBe(true); + expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]); + expect(body.success).toBe(true); + expect(body.data.pending).toBe(true); + }); +}); + +// ── MarketplaceProxyPlugin — /api/v1/marketplace/* ─────────────────────────── + +async function mountMarketplaceProxy(controlPlaneUrl: string) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => (name === 'http-server' ? { getRawApp: () => rawApp } : undefined), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + await new MarketplaceProxyPlugin({ controlPlaneUrl, cacheDisabled: true } as any).start(ctx); + await hooks.get('kernel:ready')?.(); + return rawApp.routes.get('ALL /api/v1/marketplace/*')!; +} + +describe('plugin-route door — MarketplaceProxyPlugin error exits (#9267)', () => { + it('GET with no control plane configured → 503 in the declared envelope', async () => { + const handler = await mountMarketplaceProxy('off'); + const { c, captured } = makeC({ + url: 'http://env.test/api/v1/marketplace/packages', + method: 'GET', + }); + await handler(c, async () => 'NEXT'); + expectDeclaredErrorEnvelope(captured, { status: 503, code: 'MARKETPLACE_UNAVAILABLE' }); + }); + + it('GET whose upstream fetch throws → 502 in the declared envelope', async () => { + const handler = await mountMarketplaceProxy('http://cloud.test'); + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); })); + const { c, captured } = makeC({ + url: 'http://env.test/api/v1/marketplace/packages', + method: 'GET', + }); + await handler(c, async () => 'NEXT'); + expectDeclaredErrorEnvelope(captured, { status: 502, code: 'MARKETPLACE_PROXY_FAILED' }); + }); +}); + +// ── MarketplaceInstallLocalPlugin — /api/v1/marketplace/install-local ──────── + +const IL = '/api/v1/marketplace/install-local'; + +async function mountInstallLocal(opts: { storageDir: string; userId?: string; controlPlaneUrl?: string }) { + const rawApp = makeRawApp(); + const hooks = new Map(); + const services: Record = { + manifest: { register: vi.fn() }, + auth: { + api: { + getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null), + }, + }, + objectql: { syncSchemas: vi.fn(async () => undefined), find: async () => [] }, + metadata: {}, + }; + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => { + if (name === 'http-server') return { getRawApp: () => rawApp }; + const svc = services[name]; + if (svc === undefined) throw new Error(`no service ${name}`); + return svc; + }, + registerService: () => undefined, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + const plugin = new MarketplaceInstallLocalPlugin({ + controlPlaneUrl: opts.controlPlaneUrl ?? 'off', + storageDir: opts.storageDir, + } as any); + await plugin.start(ctx); + await hooks.get('kernel:ready')?.(); + return rawApp.routes; +} + +describe('plugin-route door — MarketplaceInstallLocalPlugin error exits (#9267)', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-envelope-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('POST without a session → 401 in the declared envelope', async () => { + const routes = await mountInstallLocal({ storageDir: dir }); + const handler = routes.get(`POST ${IL}`)!; + const { c, captured } = makeC({ + url: `http://env.test${IL}`, + method: 'POST', + body: { packageId: 'pkg_1' }, + }); + await handler(c); + expectDeclaredErrorEnvelope(captured, { status: 401, code: 'UNAUTHENTICATED' }); + }); + + it('GET (the listing) without a session → 401 in the declared envelope', async () => { + const routes = await mountInstallLocal({ storageDir: dir }); + const handler = routes.get(`GET ${IL}`)!; + const { c, captured } = makeC({ url: `http://env.test${IL}`, method: 'GET' }); + await handler(c); + expectDeclaredErrorEnvelope(captured, { status: 401, code: 'UNAUTHENTICATED' }); + }); + + it('DELETE without a session → 401 in the declared envelope', async () => { + const routes = await mountInstallLocal({ storageDir: dir }); + const handler = routes.get(`DELETE ${IL}/:manifestId`)!; + const { c, captured } = makeC({ + url: `http://env.test${IL}/app.test.crm`, + method: 'DELETE', + params: { manifestId: 'app.test.crm' }, + }); + await handler(c); + expectDeclaredErrorEnvelope(captured, { status: 401, code: 'UNAUTHENTICATED' }); + }); +}); + +// ── RuntimeConfigPlugin — the one exit that is NOT enveloped ───────────────── + +/** + * `GET /api/v1/runtime/config` answers a BARE payload — + * `{ cloudUrl, singleEnvironment, defaultOrgId, defaultEnvironmentId, features, + * branding }` — with no `success` flag and six top-level keys the envelope does + * not declare. + * + * ⚠️ This is NOT blessed, and this pin is not an assertion that the shape is + * right. It is the honest record of measured drift, in the same spirit as the + * gate's `ratchet` state: the day someone envelopes this route, this test goes + * red and tells them to delete it, which is exactly what a silent `it.skip` or a + * missing case would fail to do. + * + * It is deliberately NOT fixed in #9267. The route is a discovery endpoint read + * bare by the Console SPA before first paint — `initRuntimeConfig()` in + * objectui's `app-shell/src/runtime-config.ts` reads `body.cloudUrl`, + * `body.features`, `body.branding` off the top level — so enveloping it is a + * cross-repo breaking wire change, not the "small and local to an error exit" + * fix this card admits. Filed as #9364; the gate's third surface carries the + * matching ratchet. + */ +describe('plugin-route door — RuntimeConfigPlugin is NOT enveloped (recorded, not blessed) (#9267)', () => { + it('GET /runtime/config answers a bare payload — measured drift, pinned so a fix cannot pass unnoticed', async () => { + const rawApp = makeRawApp(); + const hooks = new Map(); + const ctx: any = { + hook: (e: string, h: any) => hooks.set(e, h), + getService: (name: string) => + (name === 'http-server' ? { getRawApp: () => rawApp, getApp: () => rawApp } : undefined), + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }; + await new RuntimeConfigPlugin({ cloudUrl: 'http://cloud.test' } as any).start(ctx); + await hooks.get('kernel:ready')?.(); + + const handler = rawApp.routes.get('GET /api/v1/runtime/config'); + expect(handler, 'runtime/config was never mounted').toBeTypeOf('function'); + + const { c, captured } = makeC({ url: 'http://env.test/api/v1/runtime/config' }); + await handler!(c); + + // The body does not parse as an envelope, and every reason is recorded. + expect(BaseResponseSchema.safeParse(captured.body).success).toBe(false); + expect(envelopeViolations(captured.body)).toEqual([ + 'success is missing, must be a boolean', + 'stray top-level key `cloudUrl` — the payload belongs under `data`', + 'stray top-level key `singleEnvironment` — the payload belongs under `data`', + 'stray top-level key `defaultOrgId` — the payload belongs under `data`', + 'stray top-level key `defaultEnvironmentId` — the payload belongs under `data`', + 'stray top-level key `features` — the payload belongs under `data`', + 'stray top-level key `branding` — the payload belongs under `data`', + ]); + }); +}); diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 70ce3283c1..e7facf71f6 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -435,6 +435,292 @@ const DISPATCHER_DOMAINS = { }, }; +// ── The plugin-mounted Hono routes: the THIRD surface ──────────────────────── + +/** + * Surface 3 — a plugin that mounts its OWN Hono routes and answers with + * `c.json(body, status)` (#9267). + * + * Neither scan above can see these. They do not write to a response object + * (surface 1) and they do not return `{ status, body }` for a central sender + * (surface 2): they call the Hono context directly, from a plugin entry point + * that is not named `*-routes.ts`. So they were outside this gate for as long as + * it has existed — `cloud-connection` appeared nowhere in this file — while + * `packages/cloud-connection` alone hand-builds ~80 response bodies. + * + * That blind spot cost something real, twice. `UNIQUE_SCOPE_CONFIRMATION_REQUIRED` + * reached a wire unregistered for as long as the gate was green, because the + * body passed through neither the dispatcher's `errorFromThrown` nor + * `packages/rest`'s responders (#9223 named the door, #9246 registered the + * code). And eight refusals on `/api/v1/cloud-connection/*` emitted + * `error: { code }` with no `message` at all — `ApiErrorSchema.message` is + * REQUIRED — until #9267 measured them. The Console had already grown the + * consumer-side accommodation that produces: it displays + * `body?.error?.message ?? body?.error?.code`, showing a machine code to a human + * because the readable half was never sent. + * + * ## What is counted, and what is deliberately NOT + * + * The `rest-server.ts` lesson (#7295, maintainer ruling 2026-08-10) applies here + * in full: these files are hot — `marketplace-install-local-plugin.ts` changed + * twice in one day — so pinning how many bodies they build would go red for + * every route added, and the pressure would be to raise the number rather than + * fix anything. So the TOTAL write-site count is reported and never asserted. + * + * What IS pinned is the count of bodies that DEPART from the declared envelope. + * Those numbers move only when a non-conforming body is added or removed — which + * is precisely the guarded event — so an ordinary edit cannot move them, and + * every one of them ticks DOWN only, like the dialect ratchets above. + * + * unenveloped — a literal body with no `success` key at all. The whole + * body is off-envelope, so its stray keys are NOT also + * counted below: one defect, one number. + * errorWithoutMessage — `success: false` whose `error` literal has no + * `message`, so `body.error.message` reads `undefined`. + * The #3843 class, one key in from the bare string. + * errorCodeNotString — `error.code` is a NUMERIC literal (the HTTP status + * written into the semantic slot). `ApiErrorSchema.code` + * is a string enum, so such a body fails its own + * contract while looking nested and correct. + * strayKeys — a body that DOES carry `success` but also a top-level + * key outside `success`/`data`/`error`/`meta` — the + * general form of the duplicate-payload drift (#4038). + * stringError — `error` is a bare string (the pre-#3675 dialect). + * siblingCode — `code` beside `error` rather than inside it (#7035). + * + * ## Relayed bodies are not counted, on purpose + * + * Every counter reads an OBJECT LITERAL. A `c.json(upstreamBody, status)` that + * relays a control plane's own answer is invisible to all six, which is the + * correct answer rather than a gap: this gate governs the bodies this repo + * BUILDS, not the bytes it passes through — the same reasoning that makes a + * dispatcher domain's passthrough "kind 2" rather than drift. + * + * ## Discovery is by BEHAVIOUR, not by filename + * + * `discoverHonoRoutes()` parses every non-test file under `packages/` and keeps + * the ones that actually write a Hono context response. That is deliberate and + * it is the lesson of this gate's own history: surface 1 discovers by the + * `*-routes.ts` convention, and so `rest-server.ts` — the largest + * response-emitting file in the repo — sat unaudited for as long as the gate + * existed, until it was named by hand (#7295); #8884 closed the same + * "module outside the naming convention" gap. A plugin mounting Hono routes + * follows no naming convention at all, so a name-based surface here would have + * to be extended by hand for every new plugin — and the one nobody remembered to + * add is exactly the one that drifts. + * + * A discovered file absent from the table is an ERROR, never a default. + */ +const HONO_CONTEXT_RECEIVERS = new Set(['c', 'ctx']); + +const PLUGIN_ROUTE_MODULES = { + // ── Conformant ────────────────────────────────────────────────────────── + // + // Every hand-built body these emit is the declared envelope. They still build + // their own — there is no shared Hono sender to route through — so the zeros + // here mean "nothing you build departs from the contract", not "you build + // nothing". That is a weaker claim than surface 1's `responses: 0`, and it is + // the strongest one a hot, sender-less surface can honestly carry. + 'packages/cloud-connection/src/cloud-connection-plugin.ts': {}, + 'packages/cloud-connection/src/marketplace-install-local-plugin.ts': {}, + 'packages/cloud-connection/src/marketplace-proxy-plugin.ts': {}, + 'packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts': {}, + + // ── Ratchet: real, tracked, NOT blessed ───────────────────────────────── + // + // Measured by #9267 when this surface was added, not chosen. Each entry names + // the issue that will drive it to zero; every number ticks DOWN only. These + // are the finding this surface was worth adding for — none of them was + // visible to any check in the repo before it. + 'packages/cloud-connection/src/runtime-config-plugin.ts': { + unenveloped: 1, + ratchet: '#9364 (envelope /api/v1/runtime/config)', + note: 'the discovery payload the Console SPA reads BARE before first paint (objectui `app-shell/src/runtime-config.ts` reads body.cloudUrl / body.features / body.branding off the top level) — enveloping it is a cross-repo breaking wire change, deliberately not done in #9267', + }, + 'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts': { + unenveloped: 9, + ratchet: '#9364 (envelope the bare plugin-route payloads)', + note: 'nine `{ authenticated, userId, … }` bodies with no `success` flag — the same bare-payload class as runtime-config, read directly by the Console', + }, + 'packages/plugins/plugin-hono-server/src/adapter.ts': { + unenveloped: 4, + stringError: 4, + siblingCode: 1, + ratchet: '#9364 (convert the adapter refusals onto the declared envelope)', + note: 'the adapter\'s own refusals — `{ error: \'Not found\' }` 404, `{ error: \'No response from handler\' }` 500, `{ error: \'Fallback handler failed\' }` 500, and the 405 that adds `code`/`method`/`path`/`allowed` beside `error`. The pre-#3675 dialect and its #7035 sibling, alive at a door no scan reached', + }, + 'packages/adapters/hono/src/index.ts': { + unenveloped: 2, + errorCodeNotString: 1, + ratchet: '#9364 (envelope the hono adapter bodies)', + note: 'two `{ data }` discovery bodies with no `success`, plus a shared `errorJson` writing the HTTP status into `error.code` — a number where ApiErrorSchema declares a string enum', + }, + 'packages/cli/src/commands/serve.ts': { + unenveloped: 1, + stringError: 1, + ratchet: '#9364 (envelope the serve host-resolution refusal)', + note: 'the unbound-hostname 404 — `{ error: \'environment_not_found\', message, hostname }`, a bare-string error with two stray top-level keys', + }, + 'packages/plugins/plugin-auth/src/auth-plugin.ts': { + unenveloped: 3, + ratchet: '#9364 (envelope the bare plugin-route payloads)', + note: 'three `{ hasOwner: … }` bodies from `/bootstrap-status`, polled by the Account SPA before any credential exists — the same bare-payload class as runtime-config. The rest of this file\'s ~46 bodies are better-auth\'s own wire format, relayed rather than built, and so are invisible to these counters by design', + }, + 'packages/triggers/trigger-api/src/plugin.ts': {}, +}; + +/** + * Count the ways one plugin-route module's hand-built Hono bodies depart from + * the declared envelope. + * + * Only `.json(, …)` is judged — see the header on why + * relayed bodies are deliberately invisible here. + * + * @param {string} source TypeScript source text. + * @returns {{bodies: number, unenveloped: number, errorWithoutMessage: number, errorCodeNotString: number, strayKeys: number, stringError: number, siblingCode: number, sites: Record}} + */ +export function scanHonoRouteSource(source, fileName = 'plugin.ts') { + const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + const found = { + bodies: 0, + unenveloped: 0, errorWithoutMessage: 0, errorCodeNotString: 0, + strayKeys: 0, stringError: 0, siblingCode: 0, + sites: { + unenveloped: [], errorWithoutMessage: [], errorCodeNotString: [], + strayKeys: [], stringError: [], siblingCode: [], + }, + }; + const line = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + const hit = (key, node) => { found[key] += 1; found.sites[key].push(`${fileName}:${line(node)}`); }; + + const visit = (node) => { + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'json' && + ts.isIdentifier(node.expression.expression) && + HONO_CONTEXT_RECEIVERS.has(node.expression.expression.text) + ) { + found.bodies += 1; + const arg = node.arguments[0]; + // A relayed body (an identifier, a call, a member access) is not one this + // repo built — see the header. + if (arg && ts.isObjectLiteralExpression(arg)) { + const topKeys = new Set(); + let errorInit; + let successInit; + for (const prop of arg.properties) { + if (ts.isShorthandPropertyAssignment(prop)) { topKeys.add(prop.name.text); continue; } + if (ts.isSpreadAssignment(prop)) continue; + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue; + topKeys.add(prop.name.text); + if (prop.name.text === 'error') errorInit = prop.initializer; + if (prop.name.text === 'success') successInit = prop.initializer; + } + + // A body with no `success` at all is wholly off-envelope. Counted ONCE + // here rather than again per stray key: one defect, one number. + if (!topKeys.has('success')) { + hit('unenveloped', node); + } else if ([...topKeys].some((k) => !['success', 'data', 'error', 'meta'].includes(k))) { + hit('strayKeys', node); + } + + if (errorInit) { + // The pre-#3675 dialect: `error` is a bare string. + if ( + ts.isStringLiteral(errorInit) || ts.isTemplateExpression(errorInit) || + ts.isNoSubstitutionTemplateLiteral(errorInit) + ) hit('stringError', node); + + // The #7035 dialect: `code` beside `error` rather than inside it. + if (topKeys.has('code')) hit('siblingCode', node); + + if (ts.isObjectLiteralExpression(errorInit)) { + const errKeys = new Map(); + for (const p of errorInit.properties) { + // A shorthand `{ code }` stands for `code: code` — record the + // implied identifier, or the status-carrying form below is + // invisible in exactly the spelling that ships it. + if (ts.isShorthandPropertyAssignment(p)) { errKeys.set(p.name.text, p.name); continue; } + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) errKeys.set(p.name.text, p.initializer); + } + // `message` is REQUIRED by ApiErrorSchema. Only judged where the + // producer declared a failure — a conditional `error` on a body + // whose `success` is computed is not a failure body statically. + const declaresFailure = successInit && successInit.kind === ts.SyntaxKind.FalseKeyword; + if (declaresFailure && !errKeys.has('message')) hit('errorWithoutMessage', node); + + // `error.code` carrying the HTTP STATUS rather than a semantic code. + // Two syntactically decidable forms, and no others — a guess about + // what an arbitrary expression evaluates to is not something a + // source scan can honestly make: + // 1. a numeric literal: `error: { code: 404 }` + // 2. the SAME identifier that is passed as this call's status + // argument: `c.json({ error: { message, code } }, code)`. That + // one is the shape `packages/adapters/hono` ships, and the + // shorthand is why a value-blind scan sees nothing wrong. + const codeInit = errKeys.get('code'); + const statusArg = node.arguments[1]; + if (codeInit && ts.isNumericLiteral(codeInit)) { + hit('errorCodeNotString', node); + } else if ( + codeInit && ts.isIdentifier(codeInit) && + statusArg && ts.isIdentifier(statusArg) && + statusArg.text === codeInit.text + ) { + hit('errorCodeNotString', node); + } + } + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return found; +} + +/** The counters a plugin-route module is held to. All tick DOWN only. */ +const PLUGIN_ROUTE_COUNTERS = { + unenveloped: 'the body carries no `success` flag at all — `unwrapResponse` hands it to callers raw', + errorWithoutMessage: '`error` has no `message`, so `body.error.message` reads `undefined`', + errorCodeNotString: '`error.code` is a number — ApiErrorSchema declares a string enum there', + strayKeys: 'a top-level key outside `success`/`data`/`error`/`meta` — the payload belongs under `data`', + stringError: '`error` is a bare string, so `body.error.message` reads `undefined`', + siblingCode: '`code` sits beside `error` rather than inside it, so `body.error.code` reads `undefined`', +}; + +/** + * Files that write a Hono context response, found by parsing rather than by + * name — see the header on why. + * + * Files already audited as surface 1 are excluded so no module is governed by + * two tables at once. + */ +function discoverHonoRoutes() { + const out = []; + const skip = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage']); + const walk = (dir) => { + for (const entry of readdirSync(dir)) { + if (skip.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { walk(full); continue; } + if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue; + if (entry.includes('.test.') || entry.includes('.conformance.')) continue; + const rel = relative(ROOT, full).split(sep).join('/'); + if (MODULES[rel]) continue; + const source = readFileSync(full, 'utf8'); + // Cheap text pre-filter, then the AST decides. The pre-filter can only + // over-select (a mention in a comment), never under-select a real call. + if (!source.includes('.json(')) continue; + if (scanHonoRouteSource(source, rel).bodies > 0) out.push(rel); + } + }; + walk(join(ROOT, 'packages')); + return out.sort(); +} + /** * Count the envelope-relevant facts in one module's source. * @@ -833,6 +1119,74 @@ function audit() { } } + // ── The plugin-mounted Hono routes (#9267) ──────────────────────────────── + const honoRoutes = discoverHonoRoutes(); + + for (const file of honoRoutes) { + const declared = PLUGIN_ROUTE_MODULES[file]; + if (!declared) { + problems.push( + `${file}\n NOT DECLARED. This file writes Hono responses (\`c.json(…)\`), so it is a\n` + + ` plugin-route module — add it to PLUGIN_ROUTE_MODULES in\n` + + ` scripts/check-route-envelope.mjs. If every body it BUILDS is the declared\n` + + ` envelope, declare {}. If some are not, declare the CURRENT counts plus a\n` + + ` \`ratchet\` naming the issue that will fix them, and a \`note\` saying what they\n` + + ` are — never leave a response-emitting module unaudited.`, + ); + continue; + } + if (declared.exempt) continue; + + const got = scanHonoRouteSource(readFileSync(join(ROOT, file), 'utf8'), file); + + for (const [key, what] of Object.entries(PLUGIN_ROUTE_COUNTERS)) { + const want = declared[key] ?? 0; + if (got[key] === want) continue; + const sites = got.sites[key].map((s) => s.slice(s.lastIndexOf(':') + 1)).join(', ') || '(none)'; + problems.push( + got[key] > want + ? `${file}\n ${key}: found ${got[key]}, declared ${want} — a NEW non-conforming body.\n` + + ` ${what}.\n` + + ` Emit the envelope BaseResponseSchema declares —\n` + + ` { success: false, error: { code, message } } — with \`code\` a member of the\n` + + ` ADR-0112 vocabulary. Raising the declared number is not the fix.\n` + + (declared.ratchet ? ` (ratchet for ${declared.ratchet})\n` : '') + + ` Lines: ${sites}` + : `${file}\n ${key}: found ${got[key]}, declared ${want} — ${want - got[key]} fewer than pinned.\n` + + ` That is progress, and banking it is the other half of the ratchet: lower the\n` + + ` declared number to ${got[key]} in PLUGIN_ROUTE_MODULES so the ground cannot be\n` + + ` given back` + (got[key] === 0 ? ` (and drop the \`ratchet\`/\`note\` if this was the last one)` : '') + `.\n` + + (declared.ratchet ? ` (ratchet for ${declared.ratchet})\n` : '') + + ` Lines: ${sites}`, + ); + } + + const pinned = Object.keys(PLUGIN_ROUTE_COUNTERS).reduce((n, k) => n + (declared[k] ?? 0), 0); + if (pinned > 0 && !declared.ratchet) { + problems.push( + `${file}\n pins ${pinned} non-conforming body/bodies with no \`ratchet\`.\n` + + ` A pinned count is tracked drift, not a blessing — name the issue that will\n` + + ` drive it to zero.`, + ); + } + if (pinned > 0 && !declared.note) { + problems.push( + `${file}\n pins ${pinned} non-conforming body/bodies with no \`note\`.\n` + + ` Say what they are, so the next reader can tell tracked drift from a shape\n` + + ` somebody decided was fine.`, + ); + } + } + + for (const file of Object.keys(PLUGIN_ROUTE_MODULES)) { + if (!honoRoutes.includes(file)) { + problems.push( + `${file}\n declared in PLUGIN_ROUTE_MODULES but no longer writes a Hono response —\n` + + ` moved, deleted, or converted? Update the table.`, + ); + } + } + if (problems.length) { console.error('✗ Route-envelope conformance (#3843)\n'); for (const p of problems) console.error(' ' + p + '\n'); @@ -880,6 +1234,27 @@ function audit() { for (const [name, m] of dRatcheted) { console.log(` ⚠ ratchet ${m.ratchet}: ${DISPATCHER_DOMAIN_DIR}/${name} — ${m.note}`); } + + const pEntries = Object.entries(PLUGIN_ROUTE_MODULES); + const pRatcheted = pEntries.filter(([, m]) => m.ratchet); + const pExempt = pEntries.filter(([, m]) => m.exempt); + const totalBodies = honoRoutes.reduce( + (n, f) => n + scanHonoRouteSource(readFileSync(join(ROOT, f), 'utf8'), f).bodies, 0, + ); + console.log( + `✓ Plugin-mounted Hono routes — ${honoRoutes.length} module(s) audited, ` + + `${totalBodies} hand-built body/bodies (count reported, NOT pinned): ` + + `${honoRoutes.length - pRatcheted.length - pExempt.length} conformant, ` + + `${pRatcheted.length} ratcheted, ${pExempt.length} exempt`, + ); + for (const [file, m] of pRatcheted) { + const counts = Object.keys(PLUGIN_ROUTE_COUNTERS) + .filter((k) => m[k]).map((k) => `${k} ${m[k]}`).join(', '); + console.log(` ⚠ ratchet ${m.ratchet}: ${file} (${counts}; ticks down only) — ${m.note}`); + } + for (const [file, m] of pExempt) { + console.log(` – exempt: ${file} — ${m.exempt}`); + } } // ── Self-test ──────────────────────────────────────────────────────────────── @@ -1040,6 +1415,90 @@ function selfTest() { d = scanDomainSource(`return { handled: true, response: deps.success({ response: { status: 'queued' } }) };`); assert(d.handBuilt === 0, `a data field named response must not count → ${JSON.stringify(d)}`); + // ── Plugin-mounted Hono routes (#9267) ──────────────────────────────────── + // Every case below is a shape measured in the repo when this surface was + // added, not an invented one. + + // The conformant body: nothing to report, and the write site is still seen. + let p = scanHonoRouteSource(` + return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in.' } }, 401); + `); + assert( + p.bodies === 1 && p.unenveloped === 0 && p.errorWithoutMessage === 0 && + p.strayKeys === 0 && p.stringError === 0 && p.siblingCode === 0 && p.errorCodeNotString === 0, + `a conformant Hono body must report nothing → ${JSON.stringify(p)}`, + ); + + // The #9267 finding itself: `error` with no `message`. ApiErrorSchema requires + // it, so `body.error.message` read `undefined` on eight cloud-connection exits. + p = scanHonoRouteSource(`return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND' } }, 404);`); + assert(p.errorWithoutMessage === 1, `a message-less error not caught → ${JSON.stringify(p)}`); + + // A body with no `success` at all — counted ONCE, not again per stray key. + p = scanHonoRouteSource(`return c.json({ cloudUrl, features, branding });`); + assert( + p.unenveloped === 1 && p.strayKeys === 0, + `a bare payload must count once as unenveloped → ${JSON.stringify(p)}`, + ); + + // …while a body that IS trying to be an envelope and leaks a key counts as + // strayKeys, not unenveloped. The two are exclusive on purpose. + p = scanHonoRouteSource(`return c.json({ success: true, data: link, link });`); + assert( + p.strayKeys === 1 && p.unenveloped === 0, + `a leaked payload key must count as strayKeys → ${JSON.stringify(p)}`, + ); + + // Both pre-existing dialects reach this door too. + p = scanHonoRouteSource(`return c.json({ error: 'Not found' }, 404);`); + assert(p.stringError === 1 && p.unenveloped === 1, `bare-string error at this door → ${JSON.stringify(p)}`); + p = scanHonoRouteSource(`return c.json({ error: 'Method Not Allowed', code: 'METHOD_NOT_ALLOWED', method }, 405);`); + assert(p.siblingCode === 1, `sibling code at this door → ${JSON.stringify(p)}`); + + // `error.code` carrying the HTTP STATUS. The literal form… + p = scanHonoRouteSource(`return c.json({ success: false, error: { message, code: 404 } }, 404);`); + assert(p.errorCodeNotString === 1, `a numeric error.code not caught → ${JSON.stringify(p)}`); + // …and the SHORTHAND form that actually ships (packages/adapters/hono). This + // is the case a value-blind scan misses: `code` reads like a semantic code and + // is the status argument. Pinned because dropping the shorthand handling in + // `errKeys` silently returns this counter to zero. + p = scanHonoRouteSource(` + const errorJson = (c, message, code = 500) => c.json({ success: false, error: { message, code } }, code); + `); + assert(p.errorCodeNotString === 1, `the shorthand status-as-code not caught → ${JSON.stringify(p)}`); + // NEGATIVE: a `code` identifier that is NOT this call's status is an ordinary + // semantic code passed in a variable — the common conformant spelling. + p = scanHonoRouteSource(`return c.json({ success: false, error: { message, code } }, 400);`); + assert(p.errorCodeNotString === 0, `a semantic code in a variable counted → ${JSON.stringify(p)}`); + + // NEGATIVE: a RELAYED body is not one this repo built — see the header. + p = scanHonoRouteSource(`return c.json(upstreamJson, resp.status);`); + assert( + p.bodies === 1 && p.unenveloped === 0, + `a relayed body must be counted as a site but judged as nothing → ${JSON.stringify(p)}`, + ); + + // NEGATIVE: `c.req.json()` READS a request — the bug the regex predecessor had. + p = scanHonoRouteSource(`const body = await c.req.json();`); + assert(p.bodies === 0, `a request read must not count as a body → ${JSON.stringify(p)}`); + + // NEGATIVE: prose quoting any of these is not a code path. The table above + // quotes several, and must not move anyone's count. + p = scanHonoRouteSource(` + /* was: c.json({ error: 'Not found' }, 404) — see #9267 */ + // return c.json({ hasOwner: true }); + return c.json({ success: true, data }); + `); + assert( + p.bodies === 1 && p.stringError === 0 && p.unenveloped === 0, + `commented-out Hono bodies counted → ${JSON.stringify(p)}`, + ); + + // NEGATIVE: `res.json(...)` is surface 1's shape, not this one — the two + // scans must not both claim the same write site. + p = scanHonoRouteSource(`res.status(404).json({ error: 'nope' });`); + assert(p.bodies === 0, `a res.json write must not enter surface 3 → ${JSON.stringify(p)}`); + console.log('✓ check-route-envelope self-test passed'); }