From e3a1cc9edea00dfcaa9c70c0ed0b672850ea31df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:19:10 +0000 Subject: [PATCH 1/2] feat(trigger-api): the trigger-api service serves the inbound hook from a Request, and the mount reads http.server first WIP: implementation and pins; changeset follows. Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- packages/triggers/trigger-api/package.json | 1 + .../triggers/trigger-api/src/api-trigger.ts | 8 +- .../src/inbound-hook-member.test.ts | 371 ++++++++++++++++++ packages/triggers/trigger-api/src/plugin.ts | 130 +++++- ...igger-api-route-ledger.conformance.test.ts | 14 +- .../src/trigger-api-route-ledger.ts | 7 +- pnpm-lock.yaml | 3 + 7 files changed, 511 insertions(+), 23 deletions(-) create mode 100644 packages/triggers/trigger-api/src/inbound-hook-member.test.ts diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 4c2e1f4f6e8..380a808f435 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@types/node": "^26.6.3", + "hono": "^4.13.9", "typescript": "^6.0.3", "vitest": "^4.1.11" }, diff --git a/packages/triggers/trigger-api/src/api-trigger.ts b/packages/triggers/trigger-api/src/api-trigger.ts index a920e032dcd..7b5457059f5 100644 --- a/packages/triggers/trigger-api/src/api-trigger.ts +++ b/packages/triggers/trigger-api/src/api-trigger.ts @@ -161,7 +161,8 @@ export function verifySignature(secret: string, rawBody: string, header: string * hook (URL path + the required HMAC secret from the start node's `config`) * and subscribes a queue consumer that runs the flow. The HTTP side * ({@link handleRequest}) validates and **enqueues** — it never executes the - * flow in-band: + * flow in-band. It answers the same through the self-hosted mount and through + * the `trigger-api` service's `handleInboundHook` (#22774): * * POST /api/v1/automation/hooks/:flowName/:hookId * → 401 INVALID_SIGNATURE — every post that does not verify, one answer: @@ -271,7 +272,10 @@ export class ApiTrigger implements FlowTrigger { /** * Handle one inbound request. Transport-agnostic: the plugin adapts this - * to the host HTTP server. Returns the response to send. + * to both faces of the door, the self-hosted raw-app mount and the + * `trigger-api` service's `handleInboundHook` (#22774), through one + * adapter, so both answer exactly what this returns. Returns the response + * to send. */ async handleRequest(input: { flowName: string; diff --git a/packages/triggers/trigger-api/src/inbound-hook-member.test.ts b/packages/triggers/trigger-api/src/inbound-hook-member.test.ts new file mode 100644 index 00000000000..75fee83fc28 --- /dev/null +++ b/packages/triggers/trigger-api/src/inbound-hook-member.test.ts @@ -0,0 +1,371 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22774] `ITriggerApiService.handleInboundHook`: the inbound-hook door + * (`POST /api/v1/automation/hooks/:flowName/:hookId`) served from a + * web-standard `Request`, through the kernel's `trigger-api` slot, for a + * kernel with no raw app. + * + * Driven on a real `LiteKernel`, with the plugin's REAL boot: its + * `kernel:ready` hook registers the `api` trigger with the automation + * registry, registers the `trigger-api` slot beside it, and mounts the + * self-hosted route on a real Hono app when `http.server` (or its alias) + * offers one. Every answer below is the trigger's own (`ApiTrigger`'s hook + * match, secret read and verifier), not a fixture's. + * + * What this pins: + * 1. the member, read off the started slot on a kernel with no HTTP server + * (the hosted shape), answers every arm of the contract, each with its + * `code` and `status`, in both signature forms, and enqueues nothing for + * a post that does not verify; + * 2. parity: for the same request the member answers what the self-hosted + * mount answers on `http.server`, status, headers and body bytes; + * 3. the composition: the route is mounted once where a raw app exists, read + * from `http.server` before the `http-server` alias; the slot is + * registered wherever the trigger is installed, and nowhere else; + * 4. the member takes its path parameters from `hook`, never from the URL, + * and reads the body bytes the sender signed, never a re-serialised copy. + */ + +import { createHmac } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { ITriggerApiService } from '@objectstack/spec/contracts'; +import type { ApiTrigger, FlowTrigger } from './api-trigger.js'; +import { ApiTriggerPlugin, HOOKS_PATH } from './plugin.js'; + +const ORIGIN = 'http://hooks.test'; +const NOW = 1760000000; +const FLOW = 'lead_intake'; +const HOOK_ID = 'hk_22774'; +const SECRET = 'pin-member-secret-7c31'; +const UNREADABLE = 'unreadable_secret'; +/** Every publish answers this id, so a `202` is the same bytes through both faces. */ +const MESSAGE_ID = 'msg_parity'; +const BODY = JSON.stringify({ event: 'invoice.paid', id: 'evt_22774' }); + +const sigBodyOnly = (secret: string, body: string): string => + 'sha256=' + createHmac('sha256', secret).update(body, 'utf8').digest('hex'); +const sigV1 = (secret: string, body: string, t: number): string => + `t=${t},v1=` + createHmac('sha256', secret).update(`${t}.${body}`, 'utf8').digest('hex'); + +type ServerShape = 'none' | 'canonical' | 'alias' | 'both' | 'split'; + +interface BootOptions { + server?: ServerShape; + /** `false` registers no automation service: the trigger is never installed. */ + automation?: boolean; + /** `'absent'` registers no queue service; `'throws'` registers one whose publish throws. */ + queue?: 'ok' | 'absent' | 'throws'; +} + +interface Published { + queue: string; + data: unknown; + idempotencyKey?: string; +} + +/** + * Stands in for the plugin's declared dependency + * (`dependencies = ['com.objectstack.service.queue']`) and registers the + * collaborators it resolves at `kernel:ready`: the automation trigger + * registry, the queue, and the host's server under the names the shape asks. + */ +class FixturePlugin implements Plugin { + name = 'com.objectstack.service.queue'; + type = 'standard' as const; + version = '1.0.0'; + readonly triggers: FlowTrigger[] = []; + readonly published: Published[] = []; + readonly canonicalApp = new Hono(); + readonly aliasApp = new Hono(); + + constructor(private readonly options: BootOptions) {} + + init(ctx: PluginContext): void { + if (this.options.automation !== false) { + ctx.registerService('automation', { registerTrigger: (t: FlowTrigger) => { this.triggers.push(t); } }); + } + const queueMode = this.options.queue ?? 'ok'; + if (queueMode !== 'absent') { + const published = this.published; + ctx.registerService('queue', { + async publish(queue: string, data: unknown, options?: { idempotencyKey?: string }) { + if (queueMode === 'throws') throw new Error('broker offline'); + published.push({ queue, data, idempotencyKey: options?.idempotencyKey }); + return MESSAGE_ID; + }, + async subscribe() { /* the consumer is not under test here */ }, + async unsubscribe() { /* nothing subscribed */ }, + }); + } + const server = (app: Hono) => ({ getRawApp: () => app }); + const shape = this.options.server ?? 'none'; + if (shape === 'canonical' || shape === 'both' || shape === 'split') { + ctx.registerService('http.server', server(this.canonicalApp)); + } + if (shape === 'alias' || shape === 'both') ctx.registerService('http-server', server(this.canonicalApp)); + if (shape === 'split') ctx.registerService('http-server', server(this.aliasApp)); + } +} + +/** The real plugin, with what it logs during boot observed. */ +class ObservedApiTriggerPlugin extends ApiTriggerPlugin { + readonly logs: Array<{ level: string; message: string }> = []; + + override async init(ctx: PluginContext): Promise { + for (const level of ['debug', 'info', 'warn', 'error'] as const) { + vi.spyOn(ctx.logger, level).mockImplementation((message: unknown) => { + this.logs.push({ level, message: String(message) }); + }); + } + await super.init(ctx); + } + + lines(level: string, fragment: string): string[] { + return this.logs.filter((l) => l.level === level && l.message.includes(fragment)).map((l) => l.message); + } +} + +const openKernels: LiteKernel[] = []; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW * 1000); +}); + +afterEach(async () => { + while (openKernels.length) { + try { await openKernels.pop()?.shutdown(); } catch { /* already stopped */ } + } + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +async function boot(options: BootOptions = {}) { + const fixture = new FixturePlugin(options); + const plugin = new ObservedApiTriggerPlugin(); + const kernel = new LiteKernel({ logger: { level: 'silent' } as any }); + openKernels.push(kernel); + kernel.use(fixture); + kernel.use(plugin); + await kernel.bootstrap(); + + // Arm the flows through the trigger the plugin registered, as the engine + // binds them: one with a literal secret, one whose secret cannot be read. + const trigger = fixture.triggers[0] as ApiTrigger | undefined; + if (trigger) { + trigger.start({ flowName: FLOW, config: { hookId: HOOK_ID, secret: SECRET } }, async () => {}); + trigger.start( + { flowName: UNREADABLE, config: {}, resolveSecret: async () => { throw new Error('credential store offline'); } }, + async () => {}, + ); + } + + const slot = (): ITriggerApiService | undefined => { + try { return kernel.getService('trigger-api'); } catch { return undefined; } + }; + const member = (request: Request, hook: { flowName: string; hookId: string }): Promise => { + const svc = slot(); + if (typeof svc?.handleInboundHook !== 'function') throw new Error('the trigger-api slot holds no handleInboundHook'); + return svc.handleInboundHook(request, hook); + }; + const mounted = (app: Hono) => app.routes.filter((r) => r.path === HOOKS_PATH).map((r) => r.method); + + return { kernel, fixture, plugin, trigger, slot, member, mounted }; +} + +/** An inbound-hook `POST` to `flowName`/`hookId`, the body sent as the bytes given. */ +const hookRequest = ( + flowName: string, + hookId: string, + body: string, + headers: Record = {}, +): Request => + new Request(`${ORIGIN}/api/v1/automation/hooks/${encodeURIComponent(flowName)}/${encodeURIComponent(hookId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body, + }); + +/** Everything a response says, the body as bytes. */ +async function snapshot(res: Response) { + return { + status: res.status, + headers: [...res.headers.entries()], + body: new Uint8Array(await res.arrayBuffer()), + }; +} +const json = (s: { body: Uint8Array }) => JSON.parse(new TextDecoder().decode(s.body)); +const JSON_HEADERS = [['content-type', 'application/json']]; + +/** A body whose bytes are not what `JSON.stringify` of its value produces: spacing and key order. */ +const HAND_WRITTEN_BODY = '{ "id" : "evt_hand",\n "event": "invoice.paid" }'; + +/** + * One row per arm of the contract: where the post goes, what it carries, and + * the answer the contract declares. `published` is whether it may enqueue. + */ +interface Row { + label: string; + flowName: string; + hookId: string; + body: string; + headers: Record; + status: number; + code: string | null; + published: boolean; +} + +const sig = (body: string) => ({ 'x-objectstack-signature': sigV1(SECRET, body, NOW) }); + +const ROWS: Row[] = [ + // One refusal for every post that does not verify. + { label: 'an unknown flow, unsigned', flowName: 'never_armed', hookId: HOOK_ID, body: BODY, headers: {}, status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'an unknown flow, signed with an armed flow\'s secret', flowName: 'never_armed', hookId: HOOK_ID, body: BODY, headers: sig(BODY), status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'the wrong hook id, correctly signed', flowName: FLOW, hookId: 'default', body: BODY, headers: sig(BODY), status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'an armed flow, unsigned', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: {}, status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'a malformed signature', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: { 'x-objectstack-signature': 'not-a-signature-value' }, status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'a body-only signature under another secret', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: { 'x-objectstack-signature': sigBodyOnly('not-the-secret', BODY) }, status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'a timestamped signature under another secret', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: { 'x-objectstack-signature': sigV1('not-the-secret', BODY, NOW) }, status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'the right secret, a timestamp outside the window', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: { 'x-objectstack-signature': sigV1(SECRET, BODY, NOW - 3600) }, status: 401, code: 'INVALID_SIGNATURE', published: false }, + { label: 'unsigned, from a signed-in caller', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: { Authorization: 'Bearer tok_admin', Cookie: 'better-auth.session_token=tok.sig' }, status: 401, code: 'INVALID_SIGNATURE', published: false }, + // The residual: an armed hook whose secret cannot be read. + { label: 'an unreadable secret, unsigned', flowName: UNREADABLE, hookId: 'default', body: BODY, headers: {}, status: 503, code: 'SERVICE_UNAVAILABLE', published: false }, + { label: 'an unreadable secret, signed', flowName: UNREADABLE, hookId: 'default', body: BODY, headers: { 'x-objectstack-signature': sigBodyOnly('anything', BODY) }, status: 503, code: 'SERVICE_UNAVAILABLE', published: false }, + // Only then is the body read as JSON. + { label: 'a verified body that is not JSON', flowName: FLOW, hookId: HOOK_ID, body: '{not json', headers: sig('{not json'), status: 400, code: 'INVALID_REQUEST', published: false }, + { label: 'a verified body that is a JSON array', flowName: FLOW, hookId: HOOK_ID, body: '[1,2]', headers: sig('[1,2]'), status: 400, code: 'INVALID_REQUEST', published: false }, + // Accepted: both signature forms, the empty body, and bytes as the sender wrote them. + { label: 'a timestamped signature', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: sig(BODY), status: 202, code: null, published: true }, + { label: 'a body-only signature (deprecated, still accepted)', flowName: FLOW, hookId: HOOK_ID, body: BODY, headers: { 'x-objectstack-signature': sigBodyOnly(SECRET, BODY) }, status: 202, code: null, published: true }, + { label: 'an empty body, signed', flowName: FLOW, hookId: HOOK_ID, body: '', headers: sig(''), status: 202, code: null, published: true }, + { label: 'a hand-written body, signed over its own bytes', flowName: FLOW, hookId: HOOK_ID, body: HAND_WRITTEN_BODY, headers: sig(HAND_WRITTEN_BODY), status: 202, code: null, published: true }, +]; + +describe('[#22774] handleInboundHook — the inbound-hook door from a Request, on a kernel with no HTTP server', () => { + it('the started kernel holds the member in its trigger-api slot, and mounts nothing', async () => { + const { slot, plugin, fixture, mounted } = await boot(); + expect(typeof slot()?.handleInboundHook).toBe('function'); + expect(mounted(fixture.canonicalApp)).toEqual([]); + expect(plugin.lines('info', 'hooks mounted')).toEqual([]); + expect(plugin.lines('warn', 'hooks endpoint not mounted')).toHaveLength(1); + }); + + it('answers every arm the contract declares, each with its code and its status, and enqueues only what verified', async () => { + const { member, fixture } = await boot(); + for (const row of ROWS) { + const before = fixture.published.length; + const answer = await snapshot(await member( + hookRequest(row.flowName, row.hookId, row.body, row.headers), + { flowName: row.flowName, hookId: row.hookId }, + )); + expect(answer.status, row.label).toBe(row.status); + expect(answer.headers, row.label).toEqual(JSON_HEADERS); + const body = json(answer); + if (row.code) { + expect(body, row.label).toMatchObject({ success: false, error: { code: row.code } }); + expect(typeof body.error.message, row.label).toBe('string'); + } else { + expect(body, row.label).toEqual({ accepted: true, messageId: MESSAGE_ID }); + } + expect(fixture.published.length - before, `${row.label}: enqueued`).toBe(row.published ? 1 : 0); + } + }); + + it('enqueues the payload on the flow\'s queue, passing x-idempotency-key through', async () => { + const { member, fixture } = await boot(); + const res = await member( + hookRequest(FLOW, HOOK_ID, BODY, { ...sig(BODY), 'x-idempotency-key': 'idem-22774' }), + { flowName: FLOW, hookId: HOOK_ID }, + ); + expect(res.status).toBe(202); + expect(fixture.published).toEqual([ + { queue: `flow-api:${FLOW}`, data: { payload: JSON.parse(BODY) }, idempotencyKey: 'idem-22774' }, + ]); + }); + + it('answers 503 SERVICE_UNAVAILABLE with no queue service, and 503 ENQUEUE_FAILED when the publish throws', async () => { + for (const [queue, code] of [['absent', 'SERVICE_UNAVAILABLE'], ['throws', 'ENQUEUE_FAILED']] as const) { + const { member } = await boot({ queue }); + const answer = await snapshot(await member(hookRequest(FLOW, HOOK_ID, BODY, sig(BODY)), { flowName: FLOW, hookId: HOOK_ID })); + expect(answer.status, queue).toBe(503); + expect(json(answer), queue).toMatchObject({ success: false, error: { code } }); + } + }); + + it('takes the flow and the hook id from `hook`, never from the request URL', async () => { + const { member, fixture } = await boot(); + // The URL names another flow and hook id, under the environment scope + // a dispatcher strips; the route the caller matched is the authority. + const request = new Request(`${ORIGIN}/api/v1/environments/env_1/automation/hooks/other_flow/zzz`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...sig(BODY) }, + body: BODY, + }); + const res = await member(request, { flowName: FLOW, hookId: HOOK_ID }); + expect(res.status).toBe(202); + expect(fixture.published.map((p) => p.queue)).toEqual([`flow-api:${FLOW}`]); + }); +}); + +describe('[#22774] parity — the member answers what the self-hosted mount answers on http.server', () => { + it('every arm: status, headers and body bytes equal, through the member and the mount', async () => { + const { fixture, member } = await boot({ server: 'canonical' }); + for (const row of ROWS) { + const make = () => hookRequest(row.flowName, row.hookId, row.body, row.headers); + const viaMember = await snapshot(await member(make(), { flowName: row.flowName, hookId: row.hookId })); + const viaMount = await snapshot(await fixture.canonicalApp.fetch(make())); + expect(viaMount.status, `${row.label}: the mount's own answer`).toBe(row.status); + expect(viaMember, row.label).toEqual(viaMount); + } + // Every accepted row enqueued once per face, and nothing else did. + expect(fixture.published).toHaveLength(2 * ROWS.filter((r) => r.published).length); + }); + + it('the queue faults: the same status, headers and bytes through both faces', async () => { + for (const queue of ['absent', 'throws'] as const) { + const { fixture, member } = await boot({ server: 'canonical', queue }); + const make = () => hookRequest(FLOW, HOOK_ID, BODY, sig(BODY)); + const viaMember = await snapshot(await member(make(), { flowName: FLOW, hookId: HOOK_ID })); + const viaMount = await snapshot(await fixture.canonicalApp.fetch(make())); + expect(viaMount.status, queue).toBe(503); + expect(viaMember, queue).toEqual(viaMount); + } + }); +}); + +describe('[#22774] the composition — one mount where a raw app exists, read from http.server first', () => { + it('http.server and its alias on one server (plugin-hono-server\'s shape): mounted once', async () => { + const { fixture, mounted, slot, plugin } = await boot({ server: 'both' }); + expect(mounted(fixture.canonicalApp)).toEqual(['POST']); + expect(typeof slot()?.handleInboundHook).toBe('function'); + expect(plugin.lines('info', 'hooks mounted')).toHaveLength(1); + }); + + it('http.server alone (runtime.ts\'s config.server shape, no alias): mounted once', async () => { + const { fixture, mounted } = await boot({ server: 'canonical' }); + expect(mounted(fixture.canonicalApp)).toEqual(['POST']); + }); + + it('the alias alone: still mounted once', async () => { + const { fixture, mounted } = await boot({ server: 'alias' }); + expect(mounted(fixture.canonicalApp)).toEqual(['POST']); + }); + + it('two servers under the two names: the mount goes on http.server\'s, and only there', async () => { + const { fixture, mounted } = await boot({ server: 'split' }); + expect(mounted(fixture.canonicalApp)).toEqual(['POST']); + expect(mounted(fixture.aliasApp)).toEqual([]); + }); + + it('no automation service: no trigger installed, so no trigger-api slot and no mount, and the boot says so', async () => { + const { fixture, mounted, slot, plugin } = await boot({ automation: false, server: 'both' }); + expect(fixture.triggers).toEqual([]); + expect(slot()).toBeUndefined(); + expect(mounted(fixture.canonicalApp)).toEqual([]); + expect(plugin.lines('warn', 'trigger-api service not registered')).toHaveLength(1); + }); +}); diff --git a/packages/triggers/trigger-api/src/plugin.ts b/packages/triggers/trigger-api/src/plugin.ts index d9edb649e95..858161694f2 100644 --- a/packages/triggers/trigger-api/src/plugin.ts +++ b/packages/triggers/trigger-api/src/plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import type { ITriggerApiService } from '@objectstack/spec/contracts'; import { ApiTrigger } from './api-trigger.js'; import type { FlowTrigger, QueueServiceSurface } from './api-trigger.js'; @@ -24,6 +25,83 @@ interface HttpServerSurface { /** Mount point for inbound hooks on the host HTTP server. */ export const HOOKS_PATH = '/api/v1/automation/hooks/:flowName/:hookId'; +/** + * [#22774] The service slot this plugin fills: `ITriggerApiService` + * (`@objectstack/spec/contracts`). The key is the package's own name, the + * name ADR-0041 §2 gives the trigger family, as the contract's docblock + * declares it. + */ +const TRIGGER_API_SLOT = 'trigger-api'; + +/** + * [#22774] The slice of a Hono context the inbound-hook door reads and answers + * through, and nothing more: the raw body as text, two request headers, and + * `json(body, status)`. + * + * Both faces of the door hand {@link serveInboundHook} one of these. The + * self-hosted mount hands it the real Hono context, so its answers are the ones + * it always gave. The `trigger-api` service's `handleInboundHook` hands it + * {@link requestDoorContext} over a web-standard `Request`. So both faces run + * ONE copy of the door: one body read, one pair of header names, one + * {@link ApiTrigger.handleRequest} (the hook match, the secret read, the + * verifier and every status), and one way of answering. + */ +interface InboundHookDoorContext { + req: { text(): Promise; header(name: string): string | undefined }; + json(body: unknown, status: number): R; +} + +/** + * [#22774] A web-standard `Request` as the door's context, answering the way + * Hono's `c.json` answers: the body is `JSON.stringify(body)` and the one + * header is `Content-Type: application/json`. + * + * The body is read with `request.text()`, which is what Hono's `c.req.text()` + * reads (the raw request's own `text()`), so the verifier sees the bytes the + * sender signed, read once and never re-serialised. A header is read as Hono's + * `c.req.header(name)` reads it: `headers.get(name)`, absent as `undefined`. + */ +function requestDoorContext(request: Request): InboundHookDoorContext { + return { + req: { + text: () => request.text(), + header: (name) => request.headers.get(name) ?? undefined, + }, + json: (body, status) => new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + }; +} + +/** + * [#22774] The inbound-hook door, `POST /api/v1/automation/hooks/:flowName/:hookId`, + * ONE body behind two faces: the self-hosted raw-app mount, and the + * `trigger-api` service's transport-neutral `handleInboundHook`. Neither face + * carries a rule of its own, so they cannot drift: the same hook match, the + * same per-hook secret, the same constant-time verifier before anything is + * enqueued, and the same answer for every outcome. + * + * The path parameters arrive as `hook`, from the route each face matched, and + * are never re-parsed from a URL. The mount is not a caller of the member, and + * nothing here mounts anything. + */ +async function serveInboundHook( + trigger: ApiTrigger, + c: InboundHookDoorContext, + hook: { readonly flowName: string; readonly hookId: string }, +): Promise { + const rawBody = await c.req.text(); + const out = await trigger.handleRequest({ + flowName: hook.flowName, + hookId: hook.hookId, + rawBody, + signatureHeader: c.req.header('x-objectstack-signature'), + idempotencyKey: c.req.header('x-idempotency-key') || undefined, + }); + return c.json(out.body, out.status); +} + /** * ApiTriggerPlugin (ADR-0041 Tier 1) * @@ -31,7 +109,11 @@ export const HOOKS_PATH = '/api/v1/automation/hooks/:flowName/:hookId'; * `api` binding from the flow; this plugin provides the concrete trigger: * * - mounts `POST /api/v1/automation/hooks/:flowName/:hookId` on the host - * Hono app (resolved via the `http-server` service); + * Hono app (resolved via the `http.server` service, then its deprecated + * `http-server` alias); + * - [#22774] registers the `trigger-api` service, whose + * `handleInboundHook(request, hook)` serves the same door from a + * web-standard `Request`, for a kernel with no raw app; * - validates hookId + HMAC signature (`x-objectstack-signature`, * GitHub/Stripe style, constant-time) against the flow's start-node * config; @@ -61,7 +143,10 @@ export class ApiTriggerPlugin implements Plugin { ctx.hook('kernel:ready', async () => { const automation = this.resolveService(ctx, 'automation'); if (!automation || typeof automation.registerTrigger !== 'function') { - ctx.logger.warn('ApiTriggerPlugin: automation service not available — api trigger NOT installed'); + ctx.logger.warn( + 'ApiTriggerPlugin: automation service not available — api trigger NOT installed, ' + + 'and the trigger-api service not registered', + ); return; } if (!this.resolveService(ctx, 'queue')) { @@ -74,23 +159,38 @@ export class ApiTriggerPlugin implements Plugin { ); automation.registerTrigger(trigger); - const http = this.resolveService(ctx, 'http-server'); + // [#22774] The `trigger-api` service, registered wherever the + // trigger is: where no trigger is installed the slot stays empty, + // and the member's caller answers its typed absence (the + // contract's rule), just as no route is mounted here then. + const service: ITriggerApiService = { + handleInboundHook: (request, hook) => serveInboundHook(trigger, requestDoorContext(request), hook), + }; + ctx.registerService(TRIGGER_API_SLOT, service); + + // [#22774] The server is read as `http.server` first, then the + // deprecated `http-server` alias, as `plugin-webhooks` and + // `plugin-approvals` read it. The canonical name is the only one + // present on every provider path: `runtime.ts`'s `config.server` + // path registers no alias, so an alias-only read never mounted the + // door there. `resolveService` asks each name in its own `try`, + // because `getService` throws on an empty slot. Where both names + // are registered (`plugin-hono-server` registers one server under + // both), the first answers, so the path is mounted once. + const http = this.resolveService(ctx, 'http.server') + ?? this.resolveService(ctx, 'http-server'); const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null; if (!rawApp) { - ctx.logger.warn('ApiTriggerPlugin: HTTP server not available — hooks endpoint not mounted'); + ctx.logger.warn( + 'ApiTriggerPlugin: HTTP server not available — hooks endpoint not mounted ' + + '(the trigger-api service still serves it to a caller that forwards the request)', + ); return; } - rawApp.post(HOOKS_PATH, async (c: any) => { - const rawBody = await c.req.text(); - const out = await trigger.handleRequest({ - flowName: c.req.param('flowName'), - hookId: c.req.param('hookId'), - rawBody, - signatureHeader: c.req.header('x-objectstack-signature'), - idempotencyKey: c.req.header('x-idempotency-key') || undefined, - }); - return c.json(out.body, out.status); - }); + rawApp.post(HOOKS_PATH, async (c: any) => serveInboundHook(trigger, c, { + flowName: c.req.param('flowName'), + hookId: c.req.param('hookId'), + })); ctx.logger.info(`ApiTriggerPlugin: api trigger registered, hooks mounted at POST ${HOOKS_PATH}`); }); } diff --git a/packages/triggers/trigger-api/src/trigger-api-route-ledger.conformance.test.ts b/packages/triggers/trigger-api/src/trigger-api-route-ledger.conformance.test.ts index 9fa74c32e15..15e1a0000bc 100644 --- a/packages/triggers/trigger-api/src/trigger-api-route-ledger.conformance.test.ts +++ b/packages/triggers/trigger-api/src/trigger-api-route-ledger.conformance.test.ts @@ -242,8 +242,14 @@ function pathLiteralsIn(file: string): string[] { /** Absolute-path-shaped string literals, in any of the three quote styles. */ const ABSOLUTE_PATH_LITERAL = /(['"`])(\/[A-Za-z0-9._~:@-][^'"`\s]*)\1/g; -/** The two spellings by which a module in this package reaches the HOST app. */ -const HOST_APP_REACH = /getRawApp|['"`]http-server['"`]/; +/** + * The spellings by which a module in this package reaches the HOST app: the + * raw-app accessor, and the server's service key under its canonical name + * `http.server` and its deprecated alias `http-server` (#22774: `plugin.ts` + * reads the canonical name first, so a census that knew only the alias would + * miss a second module reaching the same server the way the plugin now does). + */ +const HOST_APP_REACH = /getRawApp|['"`]http[.-]server['"`]/; /** * Does `source` reach for the host app IN CODE? @@ -381,6 +387,7 @@ describe('comment-stripper (the scan machinery, pinned in both directions)', () expect(reachesHostApp("const s = ctx.getService('http-server');\n")).toBe(true); expect(reachesHostApp('const s = ctx.getService("http-server");\n')).toBe(true); expect(reachesHostApp('const s = ctx.getService(`http-server`);\n')).toBe(true); + expect(reachesHostApp("const s = ctx.getService('http.server');\n")).toBe(true); expect(reachesHostApp('const app = server.getRawApp();\n')).toBe(true); }); @@ -391,10 +398,11 @@ describe('comment-stripper (the scan machinery, pinned in both directions)', () 'plugin.ts': 'const app = http.getRawApp();\n', 'prose-only.ts': '// getRawApp() is reached in plugin.ts, never here\n', 'zzz-second-reacher.ts': "const s = ctx.getService('http-server');\n", + 'zzz-third-reacher.ts': "const s = ctx.getService('http.server');\n", }; expect( filesReachingHostApp(Object.keys(fake).sort(), (f) => fake[f]), - ).toEqual(['plugin.ts', 'zzz-second-reacher.ts']); + ).toEqual(['plugin.ts', 'zzz-second-reacher.ts', 'zzz-third-reacher.ts']); }); it('drops paths that only appear in prose, and keeps every path in code', () => { diff --git a/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts b/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts index 802b6da0387..d4f893c73a7 100644 --- a/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts +++ b/packages/triggers/trigger-api/src/trigger-api-route-ledger.ts @@ -6,9 +6,10 @@ * (#11863, in the #3636 pattern). * * WHY THIS EXISTS. This plugin mounts its inbound-hooks endpoint on the HOST - * Hono app: it resolves the `http-server` service at `kernel:ready`, takes the - * framework-native handle through `getRawApp()`, and registers `POST` straight - * on it. That mount is outside every ledger the platform had: + * Hono app: it resolves the `http.server` service (then its deprecated + * `http-server` alias, #22774) at `kernel:ready`, takes the framework-native + * handle through `getRawApp()`, and registers `POST` straight on it. That + * mount is outside every ledger the platform had: * * - the dispatcher ledger (`packages/runtime/src/route-ledger.ts`) sees * `RouteManager` branches; this is not one, and diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4bd6bb7f0f..5bd3089cb2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2952,6 +2952,9 @@ importers: '@types/node': specifier: ^26.6.3 version: 26.6.3 + hono: + specifier: ^4.13.5 + version: 4.13.12 typescript: specifier: ^6.0.3 version: 6.0.3 From 986f2b4607eb1fb32bb9e1a4dcb382ab20213b83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:29:55 +0000 Subject: [PATCH 2/2] chore(changeset): trigger-api inbound-hook member, minor Claude-Session: https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA Co-authored-by: Claude --- .changeset/22774-inbound-hook-member.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/22774-inbound-hook-member.md diff --git a/.changeset/22774-inbound-hook-member.md b/.changeset/22774-inbound-hook-member.md new file mode 100644 index 00000000000..5b6c57ad311 --- /dev/null +++ b/.changeset/22774-inbound-hook-member.md @@ -0,0 +1,13 @@ +--- +'@objectstack/trigger-api': minor +--- + +feat(trigger-api): the `trigger-api` service serves the inbound hook from a web-standard `Request` (`ITriggerApiService.handleInboundHook`), and the self-hosted route is found under `http.server` too (#22774) + +Clause-②: yes + +- **What is new.** Wherever `ApiTriggerPlugin` installs its `api` trigger, it now registers the `trigger-api` service and implements its optional `ITriggerApiService.handleInboundHook(request, hook)` member. That member is the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, taking a `Request` and returning a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it. Its one caller is the runtime HTTP dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain, which lands separately. The flow name and the hook id come from `hook`, the route the caller matched, and are never parsed again from the URL. The body is read once, from the request itself, and verified as the sender's own bytes. +- **One door, two faces.** The member and the self-hosted route run the same code: the same hook match, the same per-hook secret, read at that moment, the same constant-time verification of `x-objectstack-signature` (the timestamped form and the deprecated body-only form alike), done before anything is enqueued, and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 INVALID_SIGNATURE` for every post that does not verify, `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be read or no queue service is registered, `400 INVALID_REQUEST` for a body that is not a JSON object, `503 ENQUEUE_FAILED`, or `202` with `{ accepted: true, messageId }`. +- **No trigger, no service.** Where the automation service is absent, no trigger is installed, so the `trigger-api` slot stays empty (as no route is mounted). The member's caller then answers its typed absence. The boot `warn` line says so. +- **The route is found under `http.server` too.** The plugin reads `http.server` first, then the deprecated `http-server` alias. A host that registers its server only as `http.server` (the runtime's `config.server` path) now gets the self-hosted route. One that registers a single server under both names still gets it once. Where the two names hold two servers, the route goes on the `http.server` one. +- **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and the path is never mounted twice.