Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/22774-inbound-hook-member.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/triggers/trigger-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"devDependencies": {
"@types/node": "^26.6.3",
"hono": "^4.13.9",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
},
Expand Down
8 changes: 6 additions & 2 deletions packages/triggers/trigger-api/src/api-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down
371 changes: 371 additions & 0 deletions packages/triggers/trigger-api/src/inbound-hook-member.test.ts

Large diffs are not rendered by default.

130 changes: 115 additions & 15 deletions packages/triggers/trigger-api/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -24,14 +25,95 @@ 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<R> {
req: { text(): Promise<string>; 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<Response> {
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<R>(
trigger: ApiTrigger,
c: InboundHookDoorContext<R>,
hook: { readonly flowName: string; readonly hookId: string },
): Promise<R> {
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)
*
* Makes `type: 'api'` flows actually fire. The automation engine derives an
* `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;
Expand Down Expand Up @@ -61,7 +143,10 @@ export class ApiTriggerPlugin implements Plugin {
ctx.hook('kernel:ready', async () => {
const automation = this.resolveService<AutomationTriggerRegistry>(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<QueueServiceSurface>(ctx, 'queue')) {
Expand All @@ -74,23 +159,38 @@ export class ApiTriggerPlugin implements Plugin {
);
automation.registerTrigger(trigger);

const http = this.resolveService<HttpServerSurface>(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<HttpServerSurface>(ctx, 'http.server')
?? this.resolveService<HttpServerSurface>(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}`);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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);
});

Expand All @@ -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', () => {
Expand Down
7 changes: 4 additions & 3 deletions packages/triggers/trigger-api/src/trigger-api-route-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading