@volter/twin-fal 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 +51 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +27 -0
- package/dist/src/fal-budget.d.ts +85 -0
- package/dist/src/fal-budget.js +410 -0
- package/dist/src/fal-capabilities.d.ts +4 -0
- package/dist/src/fal-capabilities.js +419 -0
- package/dist/src/fal-conformance.d.ts +7 -0
- package/dist/src/fal-conformance.js +38 -0
- package/dist/src/fal-connector.d.ts +58 -0
- package/dist/src/fal-connector.js +91 -0
- package/dist/src/fal-scenario.d.ts +26 -0
- package/dist/src/fal-scenario.js +100 -0
- package/dist/src/fal-server.d.ts +18 -0
- package/dist/src/fal-server.js +53 -0
- package/dist/src/fal-twin.d.ts +36 -0
- package/dist/src/fal-twin.js +414 -0
- package/dist/src/fal-webhooks.d.ts +60 -0
- package/dist/src/fal-webhooks.js +202 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +55 -0
- package/package.json +51 -0
- package/src/cli.ts +26 -0
- package/src/fal-budget.ts +456 -0
- package/src/fal-capabilities.ts +448 -0
- package/src/fal-conformance.ts +42 -0
- package/src/fal-connector.ts +123 -0
- package/src/fal-scenario.ts +112 -0
- package/src/fal-server.ts +64 -0
- package/src/fal-twin.ts +479 -0
- package/src/fal-webhooks.ts +234 -0
- package/src/index.ts +100 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// fal.ai webhooks — REAL ed25519-signed delivery + JWKS, written FRESH for this pack (the build
|
|
2
|
+
// spec is explicit: do NOT port replicate-webhooks.ts, which is Standard-Webhooks HMAC — fal uses
|
|
3
|
+
// a completely different, asymmetric scheme).
|
|
4
|
+
//
|
|
5
|
+
// GROUNDED (2026-07-09, read-only) from fal.ai/docs/model-endpoints/webhooks:
|
|
6
|
+
// - Four request headers on every webhook delivery: `X-Fal-Webhook-Request-Id`,
|
|
7
|
+
// `X-Fal-Webhook-User-Id`, `X-Fal-Webhook-Timestamp` (unix seconds), `X-Fal-Webhook-Signature`
|
|
8
|
+
// (HEX-encoded, not base64 — unlike replicate's Standard-Webhooks base64 HMAC).
|
|
9
|
+
// - Signed message: `${request_id}\n${user_id}\n${timestamp}\n${sha256hex(raw_body)}` — four
|
|
10
|
+
// newline-joined fields, the last being a SHA-256 hex digest of the raw request body (not the
|
|
11
|
+
// body itself).
|
|
12
|
+
// - Public keys are served as a JWKS document at `https://rest.fal.ai/.well-known/jwks.json`
|
|
13
|
+
// (NOT rest.alpha.fal.ai) — `{keys: [{kty:'OKP', crv:'Ed25519', x: <base64url raw pubkey>}]}`
|
|
14
|
+
// (RFC 8037 OKP/Ed25519 JWK — modeled per the RFC's standard shape; the doc confirms the
|
|
15
|
+
// endpoint + field names, not byte-for-byte against a captured live document, so the exact
|
|
16
|
+
// document shape carries the same doc-UNVERIFIED caveat routine capture-verify would close).
|
|
17
|
+
// - ±300s (5 minute) timestamp leeway.
|
|
18
|
+
// - Payload shape: `{request_id, gateway_request_id, status: 'OK'|'ERROR', payload, error?,
|
|
19
|
+
// payload_error?}`.
|
|
20
|
+
// - Retry policy: 15s per-attempt timeout, up to 10 retries over a 2h window (`todo` —
|
|
21
|
+
// `fal.webhooks.retry_policy` — this pack signs/verifies deliveries; it does not implement
|
|
22
|
+
// the delivery/retry loop itself, the same shape replicate's `webhooks.delivery` todo takes).
|
|
23
|
+
//
|
|
24
|
+
// ENTROPY-BORN, PERSISTED KEYPAIR (the googleoauth-jwt.ts `ensureKeypair` precedent). The ed25519
|
|
25
|
+
// SEED is 32 bytes of REAL entropy, generated ONCE per root and PERSISTED as twin state — the
|
|
26
|
+
// `signing_key` resource FAL_RESOURCE_TYPES already declares, folded on first touch through the
|
|
27
|
+
// kernel action log (so it rides snapshots/hydrate/flush like every other fact this twin holds,
|
|
28
|
+
// and no `node:fs` is on the serve path). Every later read derives the keypair from that stored
|
|
29
|
+
// seed: the seed is wrapped in the fixed PKCS8 DER prefix for an Ed25519 private key (RFC 8410 —
|
|
30
|
+
// `302e020100300506032b657004220420` + 32-byte seed = 48 bytes total) and imported via
|
|
31
|
+
// `crypto.createPrivateKey`, so signing and the served JWKS are always the SAME key and both
|
|
32
|
+
// survive a process restart.
|
|
33
|
+
//
|
|
34
|
+
// This is the runtime contract's ONE NAMED EXCEPTION to R9 (keying material generated once per
|
|
35
|
+
// root and persisted, exactly like googleoauth's RSA pair): same-root byte-identical, cross-root
|
|
36
|
+
// divergent BY CONSTRUCTION. The reads that serve it — `/.well-known/jwks.json` — therefore stay
|
|
37
|
+
// out of this pack's replay, which gate.ts states. The seed is never derived from the root PATH:
|
|
38
|
+
// a key computed from where a world happens to sit is a key the environment owns, and it moves
|
|
39
|
+
// with the directory instead of with the state.
|
|
40
|
+
//
|
|
41
|
+
// `node:crypto` is required LAZILY (server-only), same convention as replicate-webhooks.ts, so
|
|
42
|
+
// this module stays browser-bundle safe.
|
|
43
|
+
import { applyTwinWriteAtomic, projectResources, nodeBuiltin } from '@volter/world-core';
|
|
44
|
+
|
|
45
|
+
type NodeCrypto = typeof import('node:crypto');
|
|
46
|
+
function nodeCrypto(): NodeCrypto {
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
48
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ED25519_PKCS8_PREFIX_HEX = '302e020100300506032b657004220420';
|
|
52
|
+
// The fixed 12-byte ASN.1 header every Ed25519 SPKI (public-key) DER encoding starts with (RFC
|
|
53
|
+
// 8410) — followed by exactly 32 raw public-key bytes (44 bytes total). Slicing it off recovers
|
|
54
|
+
// the raw key JWKS wants in its `x` field.
|
|
55
|
+
const ED25519_SPKI_HEADER_LEN = 12;
|
|
56
|
+
|
|
57
|
+
export type FalWebhookEvent = {
|
|
58
|
+
request_id: string;
|
|
59
|
+
gateway_request_id: string;
|
|
60
|
+
status: 'OK' | 'ERROR';
|
|
61
|
+
payload: unknown;
|
|
62
|
+
error?: string;
|
|
63
|
+
payload_error?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export class FalWebhookVerificationError extends Error {
|
|
67
|
+
constructor(message: string) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = 'FalWebhookVerificationError';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type FalKeyPair = { privateKey: import('node:crypto').KeyObject; publicKey: import('node:crypto').KeyObject };
|
|
74
|
+
|
|
75
|
+
const SERVICE = 'fal';
|
|
76
|
+
// The seed is stored as the pack's declared `signing_key` resource — the SAME substrate every
|
|
77
|
+
// other fal fact uses, no parallel side-store (D1). Subject ids are type-prefixed exactly like
|
|
78
|
+
// fal-twin.ts's `kid()`; `_`-prefixed fields are twin-internal and never served (fal-twin.ts's
|
|
79
|
+
// `view()` strips them, and no route projects this type at all).
|
|
80
|
+
const SIGNING_KEY_TYPE = 'signing_key';
|
|
81
|
+
const SIGNING_KEY_SUBJECT = 'signing_key:webhook';
|
|
82
|
+
const SEED_BYTES = 32; // ed25519's seed length, exactly
|
|
83
|
+
|
|
84
|
+
function storedSeed(resources: readonly { type: string; id: string }[]): string | undefined {
|
|
85
|
+
const row = resources.find((r) => r.type === SIGNING_KEY_TYPE && r.id === SIGNING_KEY_SUBJECT) as Record<string, unknown> | undefined;
|
|
86
|
+
const seed = row?.['_seed'];
|
|
87
|
+
return typeof seed === 'string' && seed.length > 0 ? seed : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The base64 ed25519 SEED for `root` — generated with real entropy on FIRST use and persisted,
|
|
92
|
+
* stable forever after (across calls AND across processes). The decide-and-append runs inside the
|
|
93
|
+
* kernel's own action lock (`applyTwinWriteAtomic`), so two processes racing a first use agree on
|
|
94
|
+
* ONE seed rather than each signing with a seed the other's state does not hold.
|
|
95
|
+
*/
|
|
96
|
+
async function ensureFalSigningSeed(root?: string): Promise<string> {
|
|
97
|
+
const fast = storedSeed(projectResources(SERVICE, root));
|
|
98
|
+
if (fast) return fast;
|
|
99
|
+
const { value } = await applyTwinWriteAtomic<string>(SERVICE, (resources) => {
|
|
100
|
+
const existing = storedSeed(resources);
|
|
101
|
+
if (existing) return { kind: 'skip', value: existing };
|
|
102
|
+
const seed = nodeCrypto().randomBytes(SEED_BYTES).toString('base64');
|
|
103
|
+
return {
|
|
104
|
+
kind: 'write',
|
|
105
|
+
value: seed,
|
|
106
|
+
write: {
|
|
107
|
+
operation: 'signing_key.create',
|
|
108
|
+
subjectType: SIGNING_KEY_TYPE,
|
|
109
|
+
subjectId: SIGNING_KEY_SUBJECT,
|
|
110
|
+
fields: { _seed: seed },
|
|
111
|
+
actor: { kind: 'system' },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}, root);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The per-root ed25519 keypair, derived from the PERSISTED seed. */
|
|
119
|
+
async function falSigningKey(root?: string): Promise<FalKeyPair> {
|
|
120
|
+
const crypto = nodeCrypto();
|
|
121
|
+
const seed = Buffer.from(await ensureFalSigningSeed(root), 'base64');
|
|
122
|
+
if (seed.length !== SEED_BYTES) throw new Error(`fal signing key: persisted seed is ${seed.length} bytes, expected ${SEED_BYTES}`);
|
|
123
|
+
const der = Buffer.concat([Buffer.from(ED25519_PKCS8_PREFIX_HEX, 'hex'), seed]);
|
|
124
|
+
const privateKey = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
125
|
+
const publicKey = crypto.createPublicKey(privateKey);
|
|
126
|
+
return { privateKey, publicKey };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rawPublicKeyBytes(publicKey: import('node:crypto').KeyObject): Buffer {
|
|
130
|
+
const spki = publicKey.export({ format: 'der', type: 'spki' }) as Buffer;
|
|
131
|
+
return spki.subarray(ED25519_SPKI_HEADER_LEN);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The JWKS document served at GET /.well-known/jwks.json — stable per root, across processes. */
|
|
135
|
+
export async function falJwks(root?: string): Promise<{ keys: Array<{ kty: 'OKP'; crv: 'Ed25519'; x: string }> }> {
|
|
136
|
+
const { publicKey } = await falSigningKey(root);
|
|
137
|
+
const x = rawPublicKeyBytes(publicKey).toString('base64url');
|
|
138
|
+
return { keys: [{ kty: 'OKP', crv: 'Ed25519', x }] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Reconstruct a public key from a served JWKS entry — used to verify a signature using ONLY
|
|
142
|
+
* what the JWKS *endpoint* returned (not a fresh local re-derivation from `root`), so a
|
|
143
|
+
* signature-verification capability that routes through this has real teeth against a
|
|
144
|
+
* sabotaged handler (build spec §3: "through handler → teeth"). */
|
|
145
|
+
export function falWebhookPublicKeyFromJwk(jwk: { x: string }): import('node:crypto').KeyObject {
|
|
146
|
+
return nodeCrypto().createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build the exact signed message fal signs: id \n user \n ts \n sha256hex(body). */
|
|
150
|
+
function signingMessage(requestId: string, userId: string, timestamp: number, rawBody: string): string {
|
|
151
|
+
const bodyHash = nodeCrypto().createHash('sha256').update(rawBody, 'utf8').digest('hex');
|
|
152
|
+
return `${requestId}\n${userId}\n${timestamp}\n${bodyHash}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Compute the hex ed25519 signature for a webhook delivery (real crypto, not a stub — a real
|
|
156
|
+
* Ed25519/JWKS verifier accepts this unchanged). */
|
|
157
|
+
export async function computeFalWebhookSignature(requestId: string, userId: string, timestamp: number, rawBody: string, root?: string): Promise<string> {
|
|
158
|
+
const { privateKey } = await falSigningKey(root);
|
|
159
|
+
const message = signingMessage(requestId, userId, timestamp, rawBody);
|
|
160
|
+
// Node's `crypto.sign(algorithm, data, key)` takes `null` for the one-shot Ed25519 signing
|
|
161
|
+
// algorithm (Ed25519 has no separate digest step — verified against Node's own crypto docs).
|
|
162
|
+
return nodeCrypto().sign(null, Buffer.from(message, 'utf8'), privateKey).toString('hex');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function verifyRaw(rawBody: string, headers: Record<string, string>, publicKey: import('node:crypto').KeyObject, opts: { tolerance?: number; now?: number }): FalWebhookEvent {
|
|
166
|
+
const lower: Record<string, string> = {};
|
|
167
|
+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
|
|
168
|
+
const requestId = lower['x-fal-webhook-request-id'];
|
|
169
|
+
const userId = lower['x-fal-webhook-user-id'];
|
|
170
|
+
const ts = Number(lower['x-fal-webhook-timestamp']);
|
|
171
|
+
const sigHex = lower['x-fal-webhook-signature'];
|
|
172
|
+
if (!requestId || !userId || !sigHex || !Number.isFinite(ts)) {
|
|
173
|
+
throw new FalWebhookVerificationError('Missing required webhook headers (X-Fal-Webhook-Request-Id / X-Fal-Webhook-User-Id / X-Fal-Webhook-Timestamp / X-Fal-Webhook-Signature).');
|
|
174
|
+
}
|
|
175
|
+
const tolerance = opts.tolerance ?? 300;
|
|
176
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
177
|
+
if (Math.abs(now - ts) > tolerance) {
|
|
178
|
+
throw new FalWebhookVerificationError(`Webhook timestamp outside the ${tolerance}s tolerance.`);
|
|
179
|
+
}
|
|
180
|
+
const message = signingMessage(requestId, userId, ts, rawBody);
|
|
181
|
+
let sigBuf: Buffer;
|
|
182
|
+
try {
|
|
183
|
+
sigBuf = Buffer.from(sigHex, 'hex');
|
|
184
|
+
} catch {
|
|
185
|
+
throw new FalWebhookVerificationError('Malformed hex signature.');
|
|
186
|
+
}
|
|
187
|
+
const ok = sigBuf.length > 0 && nodeCrypto().verify(null, Buffer.from(message, 'utf8'), publicKey, sigBuf);
|
|
188
|
+
if (!ok) throw new FalWebhookVerificationError('Signature verification failed.');
|
|
189
|
+
return JSON.parse(rawBody) as FalWebhookEvent;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Verify a webhook delivery against a caller-provided public key (e.g. one recovered from a
|
|
193
|
+
* served JWKS document via `falWebhookPublicKeyFromJwk`). Throws `FalWebhookVerificationError`
|
|
194
|
+
* on any missing header, stale timestamp, or signature mismatch. */
|
|
195
|
+
export function verifyFalWebhookSignatureWithKey(rawBody: string, headers: Record<string, string>, publicKey: import('node:crypto').KeyObject, opts: { tolerance?: number; now?: number } = {}): FalWebhookEvent {
|
|
196
|
+
return verifyRaw(rawBody, headers, publicKey, opts);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Verify a webhook delivery against the root's persisted keypair directly (pure local crypto —
|
|
200
|
+
* does not touch the twin handler/JWKS route at all). */
|
|
201
|
+
export async function verifyFalWebhook(rawBody: string, headers: Record<string, string>, root?: string, opts: { tolerance?: number; now?: number } = {}): Promise<FalWebhookEvent> {
|
|
202
|
+
const { publicKey } = await falSigningKey(root);
|
|
203
|
+
return verifyRaw(rawBody, headers, publicKey, opts);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Build a SIGNED webhook delivery for a completed (or failed) request: the fal webhook payload +
|
|
208
|
+
* the real ed25519-signed X-Fal-Webhook-* headers. Deterministic + offline.
|
|
209
|
+
*/
|
|
210
|
+
export async function buildSignedFalWebhook(args: {
|
|
211
|
+
requestId: string;
|
|
212
|
+
userId?: string;
|
|
213
|
+
gatewayRequestId?: string;
|
|
214
|
+
status: 'OK' | 'ERROR';
|
|
215
|
+
payload?: unknown;
|
|
216
|
+
error?: string;
|
|
217
|
+
occurredAt: string;
|
|
218
|
+
root?: string;
|
|
219
|
+
}): Promise<{ headers: Record<string, string>; body: string; event: FalWebhookEvent }> {
|
|
220
|
+
const userId = args.userId ?? 'twin-user';
|
|
221
|
+
const ts = Math.floor(Date.parse(args.occurredAt) / 1000);
|
|
222
|
+
const event: FalWebhookEvent =
|
|
223
|
+
args.status === 'OK'
|
|
224
|
+
? { request_id: args.requestId, gateway_request_id: args.gatewayRequestId ?? args.requestId, status: 'OK', payload: args.payload ?? null }
|
|
225
|
+
: { request_id: args.requestId, gateway_request_id: args.gatewayRequestId ?? args.requestId, status: 'ERROR', payload: null, error: args.error ?? 'unknown error' };
|
|
226
|
+
const body = JSON.stringify(event);
|
|
227
|
+
const headers = {
|
|
228
|
+
'x-fal-webhook-request-id': args.requestId,
|
|
229
|
+
'x-fal-webhook-user-id': userId,
|
|
230
|
+
'x-fal-webhook-timestamp': String(ts),
|
|
231
|
+
'x-fal-webhook-signature': await computeFalWebhookSignature(args.requestId, userId, ts, body, args.root),
|
|
232
|
+
};
|
|
233
|
+
return { headers, body, event };
|
|
234
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @volter/twin-fal — the fal.ai (fal.run / queue.fal.run) model-inference queue-lifecycle twin,
|
|
2
|
+
// built on the shared @volter/world-core kernel. REST transport over the queue.fal.run / fal.run /
|
|
3
|
+
// rest.fal.ai host-split shapes; state lives entirely in the kernel action log (no side-store).
|
|
4
|
+
// Queue lifecycle (submit → status polls with logs → response → cancel), the synchronous
|
|
5
|
+
// `fal.run` variant, host-split surface routing (incl. the real fal-js `x-fal-target-url` proxy
|
|
6
|
+
// protocol), and ed25519-signed webhooks + JWKS.
|
|
7
|
+
//
|
|
8
|
+
// THE GENERATIVE STUB: a request's output is a clearly-labeled DETERMINISTIC stub
|
|
9
|
+
// (`[twin-stub:fal:…]`) derived from hash(model_id, input), while the protocol envelope (status
|
|
10
|
+
// machine, queue_position, logs, urls, metrics) is faithful. The stub is also the ONE thing a
|
|
11
|
+
// scenario handler may replace: a world
|
|
12
|
+
// dir's handlers/fal.json scripts what a submitted request settles to, run by the ONE kernel
|
|
13
|
+
// engine through this pack's adapter (fal-scenario.ts) and inspectable at GET /twin/scenario.
|
|
14
|
+
// (Conformance/capability tooling lives in @volter/world-tooling, a dev dependency — NOT shipped.)
|
|
15
|
+
export {
|
|
16
|
+
handleFalTwinRequest,
|
|
17
|
+
routeFalSurface,
|
|
18
|
+
falTwinSnapshot,
|
|
19
|
+
FAL_RESOURCE_TYPES,
|
|
20
|
+
} from './fal-twin.ts';
|
|
21
|
+
export type {
|
|
22
|
+
FalRequest,
|
|
23
|
+
FalResponse,
|
|
24
|
+
FalResourceType,
|
|
25
|
+
FalSurface,
|
|
26
|
+
FalTwinSnapshot,
|
|
27
|
+
} from './fal-twin.ts';
|
|
28
|
+
export { createFalTwinFetch, createFalTwinServer, type FalTwinFetchOptions } from './fal-server.ts';
|
|
29
|
+
// The pack's HALF of the scenario system (the grammar is the kernel's ONE engine). Exported so a
|
|
30
|
+
// test can build an engine and `use()` handlers in-process, and so the fetch adapter can mount it.
|
|
31
|
+
export { canonicalInputText, createFalScenarioEngine, falScenarioAdapter, loadFalScenarioDocument } from './fal-scenario.ts';
|
|
32
|
+
export type { FalScenarioEngine, FalScenarioRequest, FalScenarioRespond } from './fal-scenario.ts';
|
|
33
|
+
export {
|
|
34
|
+
mapQueueRequest,
|
|
35
|
+
pullFalRequests,
|
|
36
|
+
syncFalFromReal,
|
|
37
|
+
} from './fal-connector.ts';
|
|
38
|
+
export type { FalLikeClient, FalRequestHandle, FalRealQueueStatus, FalRealQueueLog, FalBudgetedOptions } from './fal-connector.ts';
|
|
39
|
+
// The client-side rate budget — the fail-closed backstop every live call goes through. The
|
|
40
|
+
// MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives here is this vendor's
|
|
41
|
+
// DECLARATION (window/ceiling/per-method weights) plus `guardFalClient`, the choke point the
|
|
42
|
+
// connector entrypoints apply unconditionally. Exported so an operator can inspect spend
|
|
43
|
+
// (`snapshot`) and a caller can catch `FalBudgetError` by type; there is deliberately no export
|
|
44
|
+
// that disables the guard.
|
|
45
|
+
export {
|
|
46
|
+
FAL_BUDGETED_METHODS,
|
|
47
|
+
FAL_BUDGET_CEILING,
|
|
48
|
+
FAL_BUDGET_MAX_RETRY_AFTER_S,
|
|
49
|
+
FAL_BUDGET_WINDOW_MS,
|
|
50
|
+
FAL_CALL_WEIGHTS,
|
|
51
|
+
FAL_RATE_BUDGET,
|
|
52
|
+
FalBudget,
|
|
53
|
+
FalBudgetError,
|
|
54
|
+
falBudgetPath,
|
|
55
|
+
falCallWeight,
|
|
56
|
+
falClientBudget,
|
|
57
|
+
guardFalClient,
|
|
58
|
+
} from './fal-budget.ts';
|
|
59
|
+
export type { FalBudgetErrorKind, FalBudgetOptions, FalBudgetReservation, FalBudgetSnapshot } from './fal-budget.ts';
|
|
60
|
+
export {
|
|
61
|
+
falJwks,
|
|
62
|
+
falWebhookPublicKeyFromJwk,
|
|
63
|
+
computeFalWebhookSignature,
|
|
64
|
+
verifyFalWebhook,
|
|
65
|
+
verifyFalWebhookSignatureWithKey,
|
|
66
|
+
buildSignedFalWebhook,
|
|
67
|
+
FalWebhookVerificationError,
|
|
68
|
+
} from './fal-webhooks.ts';
|
|
69
|
+
export type { FalWebhookEvent } from './fal-webhooks.ts';
|
|
70
|
+
|
|
71
|
+
// Registry descriptor: the pack self-describes so tooling can discover it.
|
|
72
|
+
import type { TwinPack } from '@volter/world-core';
|
|
73
|
+
import { FAL_RATE_BUDGET as RATE_BUDGET } from './fal-budget.ts';
|
|
74
|
+
export const pack: TwinPack = {
|
|
75
|
+
// The SAME object fal-budget.ts declares at module load — one source of truth, so registering
|
|
76
|
+
// the pack and importing the connector can never arm two different ceilings.
|
|
77
|
+
rateBudget: RATE_BUDGET,
|
|
78
|
+
vendor: 'fal',
|
|
79
|
+
transport: 'rest',
|
|
80
|
+
archetype: 'generative',
|
|
81
|
+
bin: 'world-fal',
|
|
82
|
+
resources: ['request', 'signing_key'],
|
|
83
|
+
specSource: 'fal-conformance.ts (self-referential endpoint/resource inventory) — grounded read-only against fal.ai/docs/model-endpoints/{queue,webhooks} and the fal-js client source (github.com/fal-ai/fal-js) fetched during the build; see spec-sources.json.',
|
|
84
|
+
description: 'fal.ai model-inference queue-lifecycle twin — submit/status/response/cancel on queue.fal.run, the synchronous fal.run variant, host-split surface routing (incl. the real fal-js x-fal-target-url proxy protocol), and ed25519-signed webhooks + JWKS. Request output is a deterministic [twin-stub] value (no GPU/model weights) with a faithful protocol envelope. Kernel-backed.',
|
|
85
|
+
browserRouting: { apiPathPrefix: '/', loaderHost: 'https://queue.fal.run' },
|
|
86
|
+
// INTERCEPTION RULING — hostsNone, the pack's own home for it: host-split surface (fal.run /
|
|
87
|
+
// queue.fal.run) with no injector entry grounded yet — worlds wire the twin via the client's
|
|
88
|
+
// explicit endpoint config; a visible gap until its hosts are grounded here.
|
|
89
|
+
hostsNone:
|
|
90
|
+
"host-split surface (fal.run / queue.fal.run) with no injector entry grounded yet — worlds wire the twin via the client's explicit endpoint config; a visible gap until its hosts are grounded here",
|
|
91
|
+
// Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
92
|
+
// 2026-08-31).
|
|
93
|
+
adoption: {
|
|
94
|
+
// Both official fal.ai distributions: the API client and the serverless framework.
|
|
95
|
+
pypi: ['fal-client', 'fal'],
|
|
96
|
+
sdks: ['@fal-ai/client', '@fal-ai/serverless-client'],
|
|
97
|
+
scopes: ['@fal-ai/'],
|
|
98
|
+
envStems: ['FAL'],
|
|
99
|
+
},
|
|
100
|
+
};
|