@volter/twin-inngest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +131 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +61 -0
- package/dist/src/inngest-budget.d.ts +83 -0
- package/dist/src/inngest-budget.js +404 -0
- package/dist/src/inngest-capabilities.d.ts +4 -0
- package/dist/src/inngest-capabilities.js +512 -0
- package/dist/src/inngest-conformance.d.ts +7 -0
- package/dist/src/inngest-conformance.js +39 -0
- package/dist/src/inngest-connector.d.ts +60 -0
- package/dist/src/inngest-connector.js +109 -0
- package/dist/src/inngest-runtime.d.ts +98 -0
- package/dist/src/inngest-runtime.js +329 -0
- package/dist/src/inngest-server.d.ts +14 -0
- package/dist/src/inngest-server.js +42 -0
- package/dist/src/inngest-signing.d.ts +32 -0
- package/dist/src/inngest-signing.js +173 -0
- package/dist/src/inngest-twin.d.ts +30 -0
- package/dist/src/inngest-twin.js +332 -0
- package/package.json +51 -0
- package/src/cli.ts +25 -0
- package/src/index.ts +107 -0
- package/src/inngest-budget.ts +450 -0
- package/src/inngest-capabilities.ts +554 -0
- package/src/inngest-conformance.ts +43 -0
- package/src/inngest-connector.ts +133 -0
- package/src/inngest-runtime.ts +391 -0
- package/src/inngest-server.ts +50 -0
- package/src/inngest-signing.ts +180 -0
- package/src/inngest-twin.ts +366 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare class InngestSignatureError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
/** The kernel resource type the key is stored as — declared in `pack.resources` and mirrored by
|
|
5
|
+
* inngest-twin.ts's `INNGEST_RESOURCE_TYPES`. */
|
|
6
|
+
export declare const INNGEST_SIGNING_KEY_TYPE = "signing_key";
|
|
7
|
+
/** The root's established signing key, or undefined on a world that has never touched one.
|
|
8
|
+
* Pure read — never writes, so a read-only serve can ask. */
|
|
9
|
+
export declare function storedInngestSigningKey(root?: string): string | undefined;
|
|
10
|
+
/** A freshly minted key in the real `signkey-prod-<64 hex chars>` shape — the SHAPE the old
|
|
11
|
+
* per-root derivation served (so a `removeSigningKeyPrefix`-style strip still round-trips it
|
|
12
|
+
* exactly as a real signing key would), now drawn from real entropy. */
|
|
13
|
+
export declare function mintInngestSigningKey(): string;
|
|
14
|
+
/**
|
|
15
|
+
* The root's signing key, established on first use and stable forever after — across calls AND
|
|
16
|
+
* across processes. Minted from real entropy (Inngest's protocol offers nothing to seed from — see
|
|
17
|
+
* the header) and persisted as the declared `signing_key` resource through the kernel action log.
|
|
18
|
+
*/
|
|
19
|
+
export declare function inngestSigningKey(root?: string): Promise<string>;
|
|
20
|
+
/** Sign a request body: HMAC-SHA256(stripPrefix(signingKey), canonicalJson(body) + timestamp),
|
|
21
|
+
* hex digest — real crypto, not a stub. */
|
|
22
|
+
export declare function signInngestRequest(body: unknown, timestamp: number, signingKey: string): string;
|
|
23
|
+
/** Build the real `x-inngest-signature` header value: `t=<unix_seconds>&s=<hex_hmac>` (SDK-source
|
|
24
|
+
* grounded format, `RequestSignature`/`URLSearchParams`-compatible). */
|
|
25
|
+
export declare function buildInngestSignatureHeader(body: unknown, timestamp: number, signingKey: string): string;
|
|
26
|
+
/** Verify a request's `x-inngest-signature` header against a signing key. Throws
|
|
27
|
+
* `InngestSignatureError` on a missing/malformed header, an expired timestamp (>300s from
|
|
28
|
+
* `now`), or a signature mismatch (constant-time compare). Pure — never touches the handler. */
|
|
29
|
+
export declare function verifyInngestSignature(body: unknown, header: string, signingKey: string, opts?: {
|
|
30
|
+
now?: number;
|
|
31
|
+
toleranceSec?: number;
|
|
32
|
+
}): void;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// inngest EXECUTOR<->SDK REQUEST SIGNING — written FRESH for this pack (build spec §1
|
|
2
|
+
// divergence 3), pure crypto, NEVER imports the twin handler (`inngest-twin.ts`) — this is what
|
|
3
|
+
// makes the two mutation-test ALLOW entries (`inngest.auth.signature_hmac`,
|
|
4
|
+
// `inngest.auth.signature_tamper`) legitimate: a saboteured `handleInngestTwinRequest` cannot
|
|
5
|
+
// possibly affect a verify that never calls it.
|
|
6
|
+
//
|
|
7
|
+
// GROUNDED (2026-07-09, read-only `npm pack inngest@4.12.0` — the actually-installed devDep —
|
|
8
|
+
// NOT just docs prose) against the compiled SDK source:
|
|
9
|
+
// - helpers/net.ts (`signDataWithKey`/`signWithHashJs`/`signWithNative`): the signed message is
|
|
10
|
+
// `canonicalize(body) + timestamp` (string concat, NOT newline-joined) — `canonicalize` is
|
|
11
|
+
// the JCS (RFC 8785) canonical-JSON `canonicalize` npm package the SDK itself depends on;
|
|
12
|
+
// this twin uses the same canonical-JSON convention every other pack in this repo already
|
|
13
|
+
// uses (`canonicalJson` in inngest-runtime.ts / fal-twin.ts's own copy) — both produce
|
|
14
|
+
// sorted-key, delimiter-stable JSON, which is what JCS guarantees for the plain-object
|
|
15
|
+
// payload shapes this twin signs (no need for the real npm `canonicalize` dependency itself).
|
|
16
|
+
// - HMAC-SHA256(key, message) -> hex digest.
|
|
17
|
+
// - `removeSigningKeyPrefix` (helpers/strings.ts): `signingKey.replace(/^signkey-[\w]+-/, '')`
|
|
18
|
+
// — strips a `signkey-<env>-` prefix before using the key bytes (matches build spec §1's
|
|
19
|
+
// "key = signing key with signkey-*- prefix removed, hex").
|
|
20
|
+
// - `RequestSignature` (components/InngestCommHandler.js): the `x-inngest-signature` header
|
|
21
|
+
// value is `t=<unix_seconds>&s=<hex_hmac>` (URLSearchParams-encoded, not a bare hex string) —
|
|
22
|
+
// genuinely different from — and MORE grounded than — the build spec's own prose guess of a
|
|
23
|
+
// bare hex signature; this pack follows the SDK-source-confirmed `t=&s=` format (deviation
|
|
24
|
+
// disclosed in the landing commit).
|
|
25
|
+
// - 5-minute (300s) freshness tolerance: `Math.abs(Date.now() - ts*1000) > 5*60*1000` rejects.
|
|
26
|
+
// - REJECT STATUS: the build spec's §3/§13⚠8 cited SDK_SPEC.md's prose as "500"; reading the
|
|
27
|
+
// ACTUAL installed SDK's `InngestCommHandler.js` shows the real client-side comm handler
|
|
28
|
+
// returns `unauthorizedResponse` (status **401**) for a signature-validation failure — a
|
|
29
|
+
// genuine, source-confirmed correction over the doc-prose guess. This twin does not yet
|
|
30
|
+
// claim a `done` status-code-parity capability either way (`auth.signature_500_reject_parity`
|
|
31
|
+
// stays `todo`, its title annotated with this 401 finding) — only the PURE crypto sign/
|
|
32
|
+
// verify/tamper-reject round-trip below is claimed `done`.
|
|
33
|
+
//
|
|
34
|
+
// THE SIGNING KEY IS STATE, NOT THE ROOT'S PATH. It used to be
|
|
35
|
+
// `sha256('inngest-twin-signing-key:' + root)` wrapped in `signkey-prod-`: the world's DIRECTORY
|
|
36
|
+
// was the key. A world COPIED to another directory then rotated its key out from under every
|
|
37
|
+
// consumer that had already verified against it (R4 says snapshot/fork IS a copy of the root); an
|
|
38
|
+
// addressed namespace (R7b) has no local directory to derive from at all; and anyone who knew
|
|
39
|
+
// where a world sat on disk could compute the key this pack's whole executor<->SDK signing scheme
|
|
40
|
+
// rests on. That is environment masquerading as state.
|
|
41
|
+
//
|
|
42
|
+
// A root's key is now MINTED ONCE from real entropy and persisted as the pack's declared
|
|
43
|
+
// `signing_key` resource, folded through the kernel action log — so it rides snapshots/hydrate/
|
|
44
|
+
// flush like every other fact this twin holds, no `node:fs` touches the serve path, and two serves
|
|
45
|
+
// on one root agree across a process restart. The decide-and-append runs inside the kernel's own
|
|
46
|
+
// action lock (`applyTwinWriteAtomic`), so two processes racing a first use agree on ONE key
|
|
47
|
+
// rather than each signing with a key the other's state does not hold. `GET /api/v2/keys/signing`
|
|
48
|
+
// is the route that establishes it on first touch (fold-on-first-touch, the idiom twilio's
|
|
49
|
+
// IncomingPhoneNumbers list takes) and still answers exactly the masked constant it always did.
|
|
50
|
+
//
|
|
51
|
+
// There is NO trust-on-first-use seeding here, and that is a PROTOCOL FACT rather than an
|
|
52
|
+
// omission: Inngest's SDK never puts its signing key on the wire — it presents
|
|
53
|
+
// `Authorization: Bearer <sha256(key)>`, a one-way hash — so a virgin world has nothing to adopt.
|
|
54
|
+
// (twilio seeds from the Basic pair and figma from `X-Figma-Token` because those protocols DO hand
|
|
55
|
+
// the credential over; this one does not.) The key is minted instead.
|
|
56
|
+
//
|
|
57
|
+
// This is the runtime contract's ONE NAMED EXCEPTION to R9 (keying material generated once per
|
|
58
|
+
// root and persisted, exactly like fal's ed25519 seed and twilio's AuthToken): same-root
|
|
59
|
+
// byte-identical, cross-root divergent BY CONSTRUCTION — from entropy now, never from a path.
|
|
60
|
+
// Nothing this pack SERVES carries the key material — the introspection route masks it, and
|
|
61
|
+
// signatures ride requests the handler never emits — so the exception costs the replay nothing.
|
|
62
|
+
//
|
|
63
|
+
// `node:crypto` is required LAZILY (server-only), same convention as fal-webhooks.ts, so this
|
|
64
|
+
// module stays browser-bundle safe.
|
|
65
|
+
import { applyTwinWriteAtomic, projectResources, nodeBuiltin } from '@volter/world-core';
|
|
66
|
+
function nodeCrypto() {
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
|
+
return nodeBuiltin('node:crypto');
|
|
69
|
+
}
|
|
70
|
+
function canonicalJson(v) {
|
|
71
|
+
if (v === null || typeof v !== 'object')
|
|
72
|
+
return JSON.stringify(v);
|
|
73
|
+
if (Array.isArray(v))
|
|
74
|
+
return `[${v.map(canonicalJson).join(',')}]`;
|
|
75
|
+
const keys = Object.keys(v).sort();
|
|
76
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
|
|
77
|
+
}
|
|
78
|
+
export class InngestSignatureError extends Error {
|
|
79
|
+
constructor(message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = 'InngestSignatureError';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const SERVICE = 'inngest';
|
|
85
|
+
/** The kernel resource type the key is stored as — declared in `pack.resources` and mirrored by
|
|
86
|
+
* inngest-twin.ts's `INNGEST_RESOURCE_TYPES`. */
|
|
87
|
+
export const INNGEST_SIGNING_KEY_TYPE = 'signing_key';
|
|
88
|
+
const SIGNING_KEY_SUBJECT = 'signing_key:executor';
|
|
89
|
+
function readStored(resources) {
|
|
90
|
+
const row = resources.find((r) => r.type === INNGEST_SIGNING_KEY_TYPE && r.id === SIGNING_KEY_SUBJECT);
|
|
91
|
+
const key = row?.['_key'];
|
|
92
|
+
return typeof key === 'string' && key.length > 0 ? key : undefined;
|
|
93
|
+
}
|
|
94
|
+
/** The root's established signing key, or undefined on a world that has never touched one.
|
|
95
|
+
* Pure read — never writes, so a read-only serve can ask. */
|
|
96
|
+
export function storedInngestSigningKey(root) {
|
|
97
|
+
return readStored(projectResources(SERVICE, root));
|
|
98
|
+
}
|
|
99
|
+
/** A freshly minted key in the real `signkey-prod-<64 hex chars>` shape — the SHAPE the old
|
|
100
|
+
* per-root derivation served (so a `removeSigningKeyPrefix`-style strip still round-trips it
|
|
101
|
+
* exactly as a real signing key would), now drawn from real entropy. */
|
|
102
|
+
export function mintInngestSigningKey() {
|
|
103
|
+
return `signkey-prod-${nodeCrypto().randomBytes(32).toString('hex')}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The root's signing key, established on first use and stable forever after — across calls AND
|
|
107
|
+
* across processes. Minted from real entropy (Inngest's protocol offers nothing to seed from — see
|
|
108
|
+
* the header) and persisted as the declared `signing_key` resource through the kernel action log.
|
|
109
|
+
*/
|
|
110
|
+
export async function inngestSigningKey(root) {
|
|
111
|
+
const fast = storedInngestSigningKey(root);
|
|
112
|
+
if (fast)
|
|
113
|
+
return fast;
|
|
114
|
+
const { value } = await applyTwinWriteAtomic(SERVICE, (resources) => {
|
|
115
|
+
const existing = readStored(resources);
|
|
116
|
+
if (existing)
|
|
117
|
+
return { kind: 'skip', value: existing };
|
|
118
|
+
const minted = mintInngestSigningKey();
|
|
119
|
+
return {
|
|
120
|
+
kind: 'write',
|
|
121
|
+
value: minted,
|
|
122
|
+
write: {
|
|
123
|
+
operation: 'signing_key.create',
|
|
124
|
+
subjectType: INNGEST_SIGNING_KEY_TYPE,
|
|
125
|
+
subjectId: SIGNING_KEY_SUBJECT,
|
|
126
|
+
// `_`-prefixed fields are twin-internal: no view serves them and no store door projects
|
|
127
|
+
// this type, so the key material can never leave through the wire.
|
|
128
|
+
fields: { _key: minted },
|
|
129
|
+
actor: { kind: 'system' },
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}, root);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
function stripPrefix(signingKey) {
|
|
136
|
+
return signingKey.replace(/^signkey-[\w]+-/, '');
|
|
137
|
+
}
|
|
138
|
+
/** Sign a request body: HMAC-SHA256(stripPrefix(signingKey), canonicalJson(body) + timestamp),
|
|
139
|
+
* hex digest — real crypto, not a stub. */
|
|
140
|
+
export function signInngestRequest(body, timestamp, signingKey) {
|
|
141
|
+
const message = `${canonicalJson(body)}${timestamp}`;
|
|
142
|
+
return nodeCrypto().createHmac('sha256', stripPrefix(signingKey)).update(message).digest('hex');
|
|
143
|
+
}
|
|
144
|
+
/** Build the real `x-inngest-signature` header value: `t=<unix_seconds>&s=<hex_hmac>` (SDK-source
|
|
145
|
+
* grounded format, `RequestSignature`/`URLSearchParams`-compatible). */
|
|
146
|
+
export function buildInngestSignatureHeader(body, timestamp, signingKey) {
|
|
147
|
+
const sig = signInngestRequest(body, timestamp, signingKey);
|
|
148
|
+
return `t=${timestamp}&s=${sig}`;
|
|
149
|
+
}
|
|
150
|
+
function parseHeader(header) {
|
|
151
|
+
const params = new URLSearchParams(header);
|
|
152
|
+
const t = Number(params.get('t'));
|
|
153
|
+
const s = params.get('s') ?? '';
|
|
154
|
+
if (!Number.isFinite(t) || !s)
|
|
155
|
+
throw new InngestSignatureError(`Invalid x-inngest-signature header: "${header}"`);
|
|
156
|
+
return { t, s };
|
|
157
|
+
}
|
|
158
|
+
/** Verify a request's `x-inngest-signature` header against a signing key. Throws
|
|
159
|
+
* `InngestSignatureError` on a missing/malformed header, an expired timestamp (>300s from
|
|
160
|
+
* `now`), or a signature mismatch (constant-time compare). Pure — never touches the handler. */
|
|
161
|
+
export function verifyInngestSignature(body, header, signingKey, opts = {}) {
|
|
162
|
+
const { t, s } = parseHeader(header);
|
|
163
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
164
|
+
const tolerance = opts.toleranceSec ?? 300;
|
|
165
|
+
if (Math.abs(now - t) > tolerance)
|
|
166
|
+
throw new InngestSignatureError(`Signature timestamp outside the ${tolerance}s tolerance.`);
|
|
167
|
+
const expected = signInngestRequest(body, t, signingKey);
|
|
168
|
+
const a = Buffer.from(expected, 'hex');
|
|
169
|
+
const b = Buffer.from(s, 'hex');
|
|
170
|
+
const ok = a.length === b.length && a.length > 0 && nodeCrypto().timingSafeEqual(a, b);
|
|
171
|
+
if (!ok)
|
|
172
|
+
throw new InngestSignatureError('Signature verification failed.');
|
|
173
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type InngestRequest = {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
body?: string;
|
|
5
|
+
host?: string;
|
|
6
|
+
headers?: Record<string, string>;
|
|
7
|
+
occurredAt?: string;
|
|
8
|
+
root?: string;
|
|
9
|
+
readOnly?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type InngestResponse = {
|
|
12
|
+
status: number;
|
|
13
|
+
body: unknown;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
export declare const INNGEST_RESOURCE_TYPES: readonly ["event", "function", "run", "step", "signing_key"];
|
|
17
|
+
export type InngestResourceType = typeof INNGEST_RESOURCE_TYPES[number];
|
|
18
|
+
export type InngestSurface = 'event' | 'exec' | 'api';
|
|
19
|
+
export declare function routeInngestSurface(req: {
|
|
20
|
+
host?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
path: string;
|
|
23
|
+
method?: string;
|
|
24
|
+
}): InngestSurface;
|
|
25
|
+
export declare function handleInngestTwinRequest(req: InngestRequest): Promise<InngestResponse>;
|
|
26
|
+
export type InngestTwinSnapshot = {
|
|
27
|
+
resourceTypes: readonly InngestResourceType[];
|
|
28
|
+
implementedEndpoints: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
export declare function inngestTwinSnapshot(): InngestTwinSnapshot;
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// Inngest API twin REQUEST HANDLER — a v1 slice of Inngest's durable workflow-orchestration
|
|
2
|
+
// surface, backed by the event/action-log kernel (@volter/world-core). Contract:
|
|
3
|
+
// handleInngestTwinRequest({ method, path, body, host?, headers, root, readOnly }) -> { status, body, headers }
|
|
4
|
+
//
|
|
5
|
+
// SOURCE OF TRUTH: grounded read-only (2026-07-09) against three independent sources —
|
|
6
|
+
// inngest.com/docs (event/serving/cancellation pages), the ACTUALLY-INSTALLED `inngest@4.12.0`
|
|
7
|
+
// npm package's own compiled source (`npm pack`, read-only — not just its docs), and the
|
|
8
|
+
// first-party v2 REST OpenAPI document fetched LIVE from `api-docs.inngest.com/api-specs/v2.json`
|
|
9
|
+
// (unauthenticated GET). See spec-sources.json for the full citation + every correction this
|
|
10
|
+
// build made over the build spec's own doc-prose assumptions (§13 ⚠1/⚠2/⚠6/⚠8 below).
|
|
11
|
+
//
|
|
12
|
+
// THREE API SURFACES + host/path split (build spec §1 divergence 2), routed by
|
|
13
|
+
// `routeInngestSurface`:
|
|
14
|
+
// 'event' — POST /e/{eventKey} the real Event API (SDK-source grounded: `Inngest.send()`
|
|
15
|
+
// builds `new URL('e/'+eventKey, eventBaseUrl)`).
|
|
16
|
+
// 'exec' — /api/inngest, /fn/register the SDK execution protocol: GET introspects, PUT
|
|
17
|
+
// registers, POST (twin-only simplification, see below)
|
|
18
|
+
// advances a run's step machine.
|
|
19
|
+
// 'api' — /api/v2/* the dev-server/cloud REST management API — GROUNDED at
|
|
20
|
+
// this exact path from the live-fetched v2 OpenAPI document
|
|
21
|
+
// (`servers[1].url == "http://localhost:8288/api/v2"`),
|
|
22
|
+
// correcting the build spec's speculative `/v1/*` guess
|
|
23
|
+
// (⚠2 resolution, disclosed in the landing commit).
|
|
24
|
+
//
|
|
25
|
+
// THE STEP MACHINE (honesty, mirrors fal's generative-stub pattern): this twin does NOT execute
|
|
26
|
+
// user code. `POST /api/inngest` — which in the real protocol is INNGEST
|
|
27
|
+
// calling INTO a running app to execute one step — has no real app to call here, so this twin
|
|
28
|
+
// repurposes it as the run-advance trigger: given a `{runId}`, it performs one poll-fold step of
|
|
29
|
+
// `advanceRun` (inngest-runtime.ts) against that run's DECLARED step plan. `GET /api/v2/runs/
|
|
30
|
+
// {runId}` ALSO drives one advance per call (the fal-twin.ts `progressOnce`-on-poll pattern),
|
|
31
|
+
// since polling run status is the natural, vendor-faithful place a caller observes progress.
|
|
32
|
+
// `exec.sdk_executor_roundtrip` (a real running app + real step functions Inngest calls into
|
|
33
|
+
// over HTTP) is the filed `todo` — see README ## Coverage.
|
|
34
|
+
//
|
|
35
|
+
// State lives ENTIRELY in the kernel action log (inngest-runtime.ts's `write`/`rows`/`getRow`
|
|
36
|
+
// wrap `applyTwinWrite`/`projectResources`) — there is NO Map/array side-store. No real Inngest
|
|
37
|
+
// is ever contacted.
|
|
38
|
+
//
|
|
39
|
+
// Honesty (D2): an unmodeled route returns Inngest's real v2 error envelope
|
|
40
|
+
// (`{errors:[{code,message}]}`, GROUNDED from the fetched v2 OpenAPI `v2ErrorResponse` schema),
|
|
41
|
+
// never a fabricated success. `readOnly` rejects writes with 405.
|
|
42
|
+
import { advanceRun, cancelRunsForFunction, createRun, functionsCancelledByEvent, functionsMatchingEvent, getRow, listFunctions, recordEvent, registerFunction, runsForEvent, } from "./inngest-runtime.js";
|
|
43
|
+
const SERVICE = 'inngest';
|
|
44
|
+
// The subject types this twin stores and serves — the mirror of the pack descriptor's
|
|
45
|
+
// `resources` (index.ts). `signing_key` IS one: the world's executor<->SDK signing key is minted
|
|
46
|
+
// once from real entropy and persisted (inngest-signing.ts), and `GET /api/v2/keys/signing` is the
|
|
47
|
+
// route that establishes it on first touch — so a row of that type really does exist per root.
|
|
48
|
+
// What the route SERVES is still the masked constant real Inngest serves; the key material never
|
|
49
|
+
// leaves.
|
|
50
|
+
export const INNGEST_RESOURCE_TYPES = ['event', 'function', 'run', 'step', 'signing_key'];
|
|
51
|
+
function lowerHeaders(h) {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(h ?? {}))
|
|
54
|
+
out[k.toLowerCase()] = v;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
export function routeInngestSurface(req) {
|
|
58
|
+
const path = (req.path.split('?')[0] ?? '/').replace(/\/+$/, '') || '/';
|
|
59
|
+
if (path.startsWith('/e/'))
|
|
60
|
+
return 'event';
|
|
61
|
+
if (path === '/api/inngest' || path === '/fn/register')
|
|
62
|
+
return 'exec';
|
|
63
|
+
if (path.startsWith('/api/v2/'))
|
|
64
|
+
return 'api';
|
|
65
|
+
return 'exec'; // default: the exec surface's own root path (introspection) is the most common bare entry point
|
|
66
|
+
}
|
|
67
|
+
// ── error envelopes ────────────────────────────────────────────────────────────────────────
|
|
68
|
+
// v2 REST envelope GROUNDED verbatim from the live-fetched v2 OpenAPI document's own
|
|
69
|
+
// `v2ErrorResponse`/`v2Error` component schemas (api-docs.inngest.com/api-specs/v2.json).
|
|
70
|
+
function apiError(status, code, message) {
|
|
71
|
+
return { status, body: { errors: [{ code, message }] } };
|
|
72
|
+
}
|
|
73
|
+
// The Event API / exec-protocol surfaces don't share the v2 management API's envelope (they
|
|
74
|
+
// predate it); modeled on the SDK's own `sendEventResponseSchema`-adjacent plain-object shape.
|
|
75
|
+
function plainError(status, error) {
|
|
76
|
+
return { status, body: { error } };
|
|
77
|
+
}
|
|
78
|
+
// ── body helpers ───────────────────────────────────────────────────────────────────────────
|
|
79
|
+
function parseJson(body) {
|
|
80
|
+
if (body === undefined || body === '')
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(body);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { __parseError: true };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function nowIso(occurredAt) {
|
|
90
|
+
return occurredAt ?? new Date().toISOString();
|
|
91
|
+
}
|
|
92
|
+
// ── views ──────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
const RUN_STATUS_MAP = { QUEUED: 'QUEUED', RUNNING: 'RUNNING', COMPLETED: 'COMPLETED', FAILED: 'FAILED', CANCELLED: 'CANCELLED' };
|
|
94
|
+
function runView(row) {
|
|
95
|
+
return {
|
|
96
|
+
id: row.id,
|
|
97
|
+
function_id: row.function_id,
|
|
98
|
+
event_id: row.event_id,
|
|
99
|
+
status: RUN_STATUS_MAP[row.status] ?? row.status,
|
|
100
|
+
cursor: row.cursor,
|
|
101
|
+
output: row.output ?? null,
|
|
102
|
+
error: row.error ?? null,
|
|
103
|
+
created_at: row.created_at,
|
|
104
|
+
started_at: row.started_at ?? null,
|
|
105
|
+
ended_at: row.ended_at ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function stepView(row) {
|
|
109
|
+
return {
|
|
110
|
+
run_id: row.run_id,
|
|
111
|
+
step_id: row.step_id,
|
|
112
|
+
op: row.op,
|
|
113
|
+
state: row.state,
|
|
114
|
+
attempts: row.attempts,
|
|
115
|
+
output: row.output ?? null,
|
|
116
|
+
error: row.error ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function functionView(row) {
|
|
120
|
+
return {
|
|
121
|
+
id: row.fn_id,
|
|
122
|
+
triggers: row.triggers,
|
|
123
|
+
steps: row.step_plan,
|
|
124
|
+
config: row.config,
|
|
125
|
+
cancel_on: row.cancel_on,
|
|
126
|
+
url: row.url ?? null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// ── event send (Event API) ────────────────────────────────────────────────────────────────
|
|
130
|
+
function normalizeSendBody(raw) {
|
|
131
|
+
if (raw === undefined)
|
|
132
|
+
return { ok: true, events: [] };
|
|
133
|
+
if (Array.isArray(raw)) {
|
|
134
|
+
if (raw.some((e) => typeof e !== 'object' || e === null))
|
|
135
|
+
return { ok: false };
|
|
136
|
+
return { ok: true, events: raw };
|
|
137
|
+
}
|
|
138
|
+
if (typeof raw === 'object' && raw !== null)
|
|
139
|
+
return { ok: true, events: [raw] };
|
|
140
|
+
return { ok: false };
|
|
141
|
+
}
|
|
142
|
+
async function handleSendEvents(rawBody, req) {
|
|
143
|
+
const parsed = parseJson(rawBody);
|
|
144
|
+
const normalized = normalizeSendBody(parsed);
|
|
145
|
+
if (!normalized.ok)
|
|
146
|
+
return plainError(400, 'Event payload must be an object or an array of objects.');
|
|
147
|
+
if (normalized.events.length === 0)
|
|
148
|
+
return { status: 200, body: { ids: [], status: 200 } };
|
|
149
|
+
const ids = [];
|
|
150
|
+
for (const raw of normalized.events) {
|
|
151
|
+
const name = raw.name;
|
|
152
|
+
if (typeof name !== 'string' || name.length === 0)
|
|
153
|
+
return plainError(400, 'Event payload must include a "name" (string).');
|
|
154
|
+
const { id, deduped } = await recordEvent({ name, data: raw.data, user: raw.user, id: typeof raw.id === 'string' ? raw.id : undefined, ts: typeof raw.ts === 'number' ? raw.ts : undefined, v: typeof raw.v === 'string' ? raw.v : undefined }, req.root, req.occurredAt);
|
|
155
|
+
ids.push(id);
|
|
156
|
+
if (deduped)
|
|
157
|
+
continue; // an idempotent replay never re-triggers fan-out or cancellation
|
|
158
|
+
for (const fn of functionsMatchingEvent(name, req.root)) {
|
|
159
|
+
await createRun(fn.fn_id, id, req.root, req.occurredAt);
|
|
160
|
+
}
|
|
161
|
+
for (const fn of functionsCancelledByEvent(name, req.root)) {
|
|
162
|
+
await cancelRunsForFunction(fn.fn_id, req.root, req.occurredAt);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// sendEventResponseSchema GROUNDED (types.js): {ids: string[], status: number, error?: string}
|
|
166
|
+
// — the real client's `.send()` throws unless `status === 200`, so this exact field is
|
|
167
|
+
// load-bearing for the SDK integration path (inngest-sdk.integration.test.ts).
|
|
168
|
+
return { status: 200, body: { ids, status: 200 } };
|
|
169
|
+
}
|
|
170
|
+
// ── exec surface (SDK protocol) ───────────────────────────────────────────────────────────
|
|
171
|
+
function stepFromWire(raw) {
|
|
172
|
+
const id = raw.id;
|
|
173
|
+
const op = raw.op;
|
|
174
|
+
if (typeof id !== 'string' || (op !== 'StepRun' && op !== 'Sleep' && op !== 'WaitForEvent' && op !== 'InvokeFunction'))
|
|
175
|
+
return undefined;
|
|
176
|
+
const out = { id, op };
|
|
177
|
+
if (typeof raw.eventName === 'string')
|
|
178
|
+
out.eventName = raw.eventName;
|
|
179
|
+
if (typeof raw.timeoutAfterPolls === 'number')
|
|
180
|
+
out.timeoutAfterPolls = raw.timeoutAfterPolls;
|
|
181
|
+
if (typeof raw.failTimes === 'number')
|
|
182
|
+
out.failTimes = raw.failTimes;
|
|
183
|
+
if ('output' in raw)
|
|
184
|
+
out.output = raw.output;
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
async function handleRegister(rawBody, req) {
|
|
188
|
+
const parsed = parseJson(rawBody);
|
|
189
|
+
const list = parsed?.functions;
|
|
190
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
191
|
+
return plainError(400, 'Register body must include a non-empty "functions" array.');
|
|
192
|
+
let anyModified = false;
|
|
193
|
+
let allSkipped = true;
|
|
194
|
+
for (const raw of list) {
|
|
195
|
+
if (typeof raw.id !== 'string')
|
|
196
|
+
return plainError(400, 'Every function must have a string "id".');
|
|
197
|
+
const triggers = Array.isArray(raw.triggers) ? raw.triggers : [];
|
|
198
|
+
const steps = Array.isArray(raw.steps) ? raw.steps.map(stepFromWire).filter((s) => s !== undefined) : [];
|
|
199
|
+
const fn = {
|
|
200
|
+
id: raw.id,
|
|
201
|
+
triggers,
|
|
202
|
+
steps,
|
|
203
|
+
config: raw.config ?? {},
|
|
204
|
+
cancelOn: Array.isArray(raw.cancelOn) ? raw.cancelOn : [],
|
|
205
|
+
url: typeof parsed?.url === 'string' ? parsed.url : undefined,
|
|
206
|
+
};
|
|
207
|
+
const { modified, skipped } = await registerFunction(fn, req.root, req.occurredAt);
|
|
208
|
+
if (modified)
|
|
209
|
+
anyModified = true;
|
|
210
|
+
if (!skipped)
|
|
211
|
+
allSkipped = false;
|
|
212
|
+
}
|
|
213
|
+
// registerResSchema GROUNDED (types.js): {status, error (default "Successfully registered"),
|
|
214
|
+
// skipped?, modified?} — the "error" field's SUCCESS default is a real, if oddly-named, wire
|
|
215
|
+
// quirk confirmed from the SDK's own compiled zod schema, not guessed.
|
|
216
|
+
return { status: 200, body: { status: 200, error: 'Successfully registered', skipped: allSkipped, modified: anyModified } };
|
|
217
|
+
}
|
|
218
|
+
function introspectView(req) {
|
|
219
|
+
const fns = listFunctions(req.root);
|
|
220
|
+
return {
|
|
221
|
+
status: 200,
|
|
222
|
+
body: {
|
|
223
|
+
function_count: fns.length,
|
|
224
|
+
functions: fns.map(functionView),
|
|
225
|
+
mode: 'dev',
|
|
226
|
+
sdk_language: 'js',
|
|
227
|
+
sdk_version: '4.12.0',
|
|
228
|
+
authentication_succeeded: false,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async function handleExecInvoke(rawBody, req) {
|
|
233
|
+
const parsed = parseJson(rawBody);
|
|
234
|
+
const runId = parsed?.runId;
|
|
235
|
+
if (typeof runId !== 'string')
|
|
236
|
+
return plainError(400, 'Invoke body must include "runId" (twin-only exec-protocol simplification — see inngest-twin.ts header).');
|
|
237
|
+
const result = await advanceRun(runId, req.root, req.occurredAt);
|
|
238
|
+
if (!result)
|
|
239
|
+
return apiError(404, 'run_not_found', `No run with id "${runId}".`);
|
|
240
|
+
return { status: 200, body: { run: runView(result.run), ...(result.step ? { step: stepView(result.step) } : {}) } };
|
|
241
|
+
}
|
|
242
|
+
// ── api surface (dev-server/cloud v2 REST management API) ────────────────────────────────
|
|
243
|
+
async function handleGetRun(runId, req) {
|
|
244
|
+
const existing = getRow('run', runId, req.root);
|
|
245
|
+
if (!existing)
|
|
246
|
+
return apiError(404, 'run_not_found', `No run with id "${runId}".`);
|
|
247
|
+
const result = await advanceRun(runId, req.root, req.occurredAt); // poll-fold advance on read (fal-twin.ts progressOnce pattern)
|
|
248
|
+
const row = result?.run ?? existing;
|
|
249
|
+
return { status: 200, body: { data: runView(row), metadata: { fetchedAt: nowIso(req.occurredAt) } } };
|
|
250
|
+
}
|
|
251
|
+
function handleEventRuns(eventId, req) {
|
|
252
|
+
const event = getRow('event', eventId, req.root);
|
|
253
|
+
if (!event)
|
|
254
|
+
return apiError(404, 'event_not_found', `No event with id "${eventId}".`);
|
|
255
|
+
const runs = runsForEvent(eventId, req.root).map(runView);
|
|
256
|
+
return { status: 200, body: { data: runs, metadata: { fetchedAt: nowIso(req.occurredAt) }, page: { cursor: '', hasMore: false, limit: runs.length } } };
|
|
257
|
+
}
|
|
258
|
+
async function handleSigningKeys(req) {
|
|
259
|
+
// `GET /keys/signing` is a REAL v2 REST path (grounded from the fetched OpenAPI document). The
|
|
260
|
+
// key VALUE is deliberately masked here (real Inngest never returns raw key material over this
|
|
261
|
+
// introspection route either) — the usable key is `inngest-signing.ts`'s `inngestSigningKey`,
|
|
262
|
+
// which this route ESTABLISHES on first touch: the key is state (minted once from real entropy,
|
|
263
|
+
// persisted as the `signing_key` resource), so the introspection route for it is where a virgin
|
|
264
|
+
// world materializes it, exactly as twilio's IncomingPhoneNumbers list folds its seeded rows on
|
|
265
|
+
// first read. The import is LAZY so the signing module stays out of this file's static graph and
|
|
266
|
+
// the mutation-test ALLOW entries keep their meaning in both directions.
|
|
267
|
+
//
|
|
268
|
+
// A read-only serve does not establish it: readOnly worlds serve, they never fold. The masked
|
|
269
|
+
// body never depended on the key, so it answers identically either way.
|
|
270
|
+
if (!req.readOnly) {
|
|
271
|
+
const { inngestSigningKey } = await import("./inngest-signing.js");
|
|
272
|
+
await inngestSigningKey(req.root);
|
|
273
|
+
}
|
|
274
|
+
return { status: 200, body: { data: [{ id: 'signing-key', key: 'signkey-prod-***' }], metadata: { fetchedAt: nowIso(req.occurredAt) } } };
|
|
275
|
+
}
|
|
276
|
+
// ── route handler ────────────────────────────────────────────────────────────────────
|
|
277
|
+
export async function handleInngestTwinRequest(req) {
|
|
278
|
+
const method = req.method.toUpperCase();
|
|
279
|
+
const [rawPath] = req.path.split('?');
|
|
280
|
+
const path = (rawPath ?? '/').replace(/\/+$/, '') || '/';
|
|
281
|
+
const surface = routeInngestSurface({ host: req.host, headers: req.headers, path, method });
|
|
282
|
+
if (req.readOnly && method !== 'GET' && method !== 'HEAD') {
|
|
283
|
+
return { status: 405, body: { error: 'read_only', hint: 'this twin was started read-only; omit readOnly to accept writes' } };
|
|
284
|
+
}
|
|
285
|
+
if (path === '/' || path === '') {
|
|
286
|
+
return { status: 200, body: { service: 'inngest', object: 'twin' } };
|
|
287
|
+
}
|
|
288
|
+
if (surface === 'event') {
|
|
289
|
+
if (method === 'POST')
|
|
290
|
+
return handleSendEvents(req.body, req);
|
|
291
|
+
return plainError(404, 'not found');
|
|
292
|
+
}
|
|
293
|
+
if (surface === 'exec') {
|
|
294
|
+
if ((method === 'PUT' && path === '/api/inngest') || (method === 'POST' && path === '/fn/register')) {
|
|
295
|
+
return handleRegister(req.body, req);
|
|
296
|
+
}
|
|
297
|
+
if (method === 'GET' && path === '/api/inngest')
|
|
298
|
+
return introspectView(req);
|
|
299
|
+
if (method === 'POST' && path === '/api/inngest')
|
|
300
|
+
return handleExecInvoke(req.body, req);
|
|
301
|
+
return plainError(404, 'not found');
|
|
302
|
+
}
|
|
303
|
+
// surface === 'api' (/api/v2/*)
|
|
304
|
+
const seg = path.replace(/^\/+/, '').split('/').filter(Boolean); // ['api','v2', ...]
|
|
305
|
+
const rest = seg.slice(2);
|
|
306
|
+
if (method === 'GET' && rest[0] === 'runs' && rest.length === 2)
|
|
307
|
+
return handleGetRun(decodeURIComponent(rest[1]), req);
|
|
308
|
+
if (method === 'GET' && rest[0] === 'events' && rest.length === 3 && rest[2] === 'runs')
|
|
309
|
+
return handleEventRuns(decodeURIComponent(rest[1]), req);
|
|
310
|
+
if (method === 'GET' && rest[0] === 'keys' && rest[1] === 'signing')
|
|
311
|
+
return handleSigningKeys(req);
|
|
312
|
+
return apiError(404, 'not_found', `No route for ${method} ${path}`);
|
|
313
|
+
}
|
|
314
|
+
// Endpoint inventory used by the conformance snapshot (self-referential — see
|
|
315
|
+
// inngest-conformance.ts header note and docs/contributing/conformance.md's "2 spec" discussion of this pattern,
|
|
316
|
+
// shared with replicate/elevenlabs/polar/fal/pinecone/algolia).
|
|
317
|
+
export function inngestTwinSnapshot() {
|
|
318
|
+
return {
|
|
319
|
+
resourceTypes: INNGEST_RESOURCE_TYPES,
|
|
320
|
+
implementedEndpoints: [
|
|
321
|
+
'POST /e/{eventKey} (event send, single or batch)',
|
|
322
|
+
'PUT /api/inngest (function register)',
|
|
323
|
+
'POST /fn/register (function register, real SDK outbound target)',
|
|
324
|
+
'GET /api/inngest (introspection)',
|
|
325
|
+
'POST /api/inngest (twin exec-protocol simplification: run advance)',
|
|
326
|
+
'GET /api/v2/runs/{runId} (run get, poll-fold advance)',
|
|
327
|
+
'GET /api/v2/runs/{runId} (404 unknown run)',
|
|
328
|
+
'GET /api/v2/events/{eventId}/runs (runs for an event)',
|
|
329
|
+
'GET /api/v2/keys/signing',
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-inngest",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Inngest twin for the durable-execution workflow-orchestration surface: event send/batch (Event API), function register/introspect (SDK exec protocol), and a REAL kernel-persisted step-machine (step.run memoize/replay, sleep/sleepUntil instant-advance, waitForEvent gating, exact retry-attempt counters), run status/get/list/cancel-via-event, HMAC-SHA256 executor<->SDK request signing, built on @volter/world-core.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"!**/*.test.ts",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
17
|
+
"directory": "packages/twin/inngest"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/inngest#readme",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/src/index.d.ts",
|
|
24
|
+
"default": "./dist/src/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"world-inngest": "dist/src/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test src/*.test.ts",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
34
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
35
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@volter/world-core": "2.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/bun": "^1.2.20",
|
|
42
|
+
"@types/node": "^24.0.0",
|
|
43
|
+
"@volter/world-core": "2.0.0",
|
|
44
|
+
"@volter/world-tooling": "0.1.0",
|
|
45
|
+
"inngest": "4.12.0",
|
|
46
|
+
"typescript": "^5.9.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
// world-inngest CLI: serve the KERNEL-BACKED Inngest API twin, or run conformance. State
|
|
4
|
+
// lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
|
|
5
|
+
// lazy-imported so the bin runs without @volter/world-tooling (E2).
|
|
6
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
7
|
+
import { createInngestTwinServer } from './inngest-server.ts';
|
|
8
|
+
|
|
9
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
10
|
+
const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
|
|
11
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
12
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
13
|
+
|
|
14
|
+
if (cmd === 'serve' || cmd === undefined) {
|
|
15
|
+
const s = await createInngestTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
16
|
+
process.stdout.write(`inngest twin (durable-execution workflow orchestration: events/functions/runs/steps, host/path split)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`);
|
|
17
|
+
await keepProcessAlive();
|
|
18
|
+
} else if (cmd === 'conformance') {
|
|
19
|
+
const { checkInngestConformance } = await import('./inngest-conformance.ts');
|
|
20
|
+
const report = checkInngestConformance();
|
|
21
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
22
|
+
if (!report.ok) process.exitCode = 1;
|
|
23
|
+
} else {
|
|
24
|
+
process.stdout.write('Usage: world-inngest serve|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
25
|
+
}
|