@volter/twin-fal 0.1.0 → 0.1.2

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.
@@ -23,13 +23,15 @@
23
23
  // construction — loudly, never a silent fallback.
24
24
  import { serveHttp } from '@volter/world-core';
25
25
  import { handleFalTwinRequest } from "./fal-twin.js";
26
- import { createTwinFetchFromHandler, twinManifest } from '@volter/world-core';
26
+ import { createTwinFetchFromHandler, twinManifest, withRequestScopes } from '@volter/world-core';
27
27
  import { createFalScenarioEngine, loadFalScenarioDocument } from "./fal-scenario.js";
28
28
  export function createFalTwinFetch(options = {}) {
29
29
  const scenarioPath = options.scenarioPath ?? process.env.TWIN_FAL_SCENARIO;
30
30
  const scenarioEngine = scenarioPath ? createFalScenarioEngine(loadFalScenarioDocument(scenarioPath)) : undefined;
31
31
  const { scenarioPath: _scenarioPath, ...handlerOptions } = options;
32
- return createTwinFetchFromHandler(handleFalTwinRequest, {
32
+ // a read-only request (x-volter-read-only) is a request to a read-only twin: a status poll answers the
33
+ // request's current status without advancing it, nothing is written (withRequestScopes: the pack opts in)
34
+ return withRequestScopes(createTwinFetchFromHandler(handleFalTwinRequest, {
33
35
  ...handlerOptions,
34
36
  manifest: () => twinManifest({
35
37
  vendor: 'fal',
@@ -41,7 +43,7 @@ export function createFalTwinFetch(options = {}) {
41
43
  }),
42
44
  scenarioStatus: () => (scenarioEngine ? scenarioEngine.status() : { vendor: 'fal', handlers: [], misses: 0, recentMisses: [] }),
43
45
  ...(scenarioEngine ? { handlerOptions: { scenarioEngine } } : {}),
44
- });
46
+ }), { refuse: () => Response.json({ detail: 'twin is read-only; omit readOnly to accept writes' }, { status: 405 }) });
45
47
  }
46
48
  export async function createFalTwinServer(options = {}) {
47
49
  const server = await serveHttp({
@@ -45,7 +45,7 @@
45
45
  // Kernel SUBJECT ids are type-prefixed (`request:<uuid>`) — the PUBLIC request_id emitted to
46
46
  // clients is the bare uuid. `kid()` builds the subject id; `rows()` strips the prefix back off.
47
47
  import { createHash } from 'node:crypto';
48
- import { applyTwinWrite, projectResources } from '@volter/world-core';
48
+ import { applyTwinWrite, projectResources, worldNow } from '@volter/world-core';
49
49
  import { falJwks } from "./fal-webhooks.js";
50
50
  const SERVICE = 'fal';
51
51
  // Resource types the twin actually PROJECTS in the kernel — the honest v1 subset. `signing_key`
@@ -114,7 +114,7 @@ function parseBody(body) {
114
114
  }
115
115
  }
116
116
  function nowIso(occurredAt) {
117
- return occurredAt ?? new Date().toISOString();
117
+ return occurredAt ?? worldNow();
118
118
  }
119
119
  function kid(type, id) {
120
120
  return `${type}:${id}`;
@@ -301,7 +301,10 @@ async function handleStatus(modelId, requestId, includeLogs, req) {
301
301
  const existing = getRow('request', requestId, req.root);
302
302
  if (!existing)
303
303
  return detailNotFound('Request not found.');
304
- await progressOnce(requestId, req);
304
+ // ADVANCE ON POLL — one step per status check. A read-only twin (a World's read token) answers the
305
+ // request's current status and never advances it: the app's next poll sees what it would have.
306
+ if (!req.readOnly)
307
+ await progressOnce(requestId, req);
305
308
  const row = getRow('request', requestId, req.root);
306
309
  return { status: 200, body: statusView(row, modelId, requestId, includeLogs) };
307
310
  }
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // `node:crypto` is required LAZILY (server-only), same convention as replicate-webhooks.ts, so
42
42
  // this module stays browser-bundle safe.
43
- import { applyTwinWriteAtomic, projectResources, nodeBuiltin } from '@volter/world-core';
43
+ import { applyTwinWriteAtomic, projectResources, nodeBuiltin, worldNow, runAsVendorMove } from '@volter/world-core';
44
44
  function nodeCrypto() {
45
45
  // eslint-disable-next-line @typescript-eslint/no-require-imports
46
46
  return nodeBuiltin('node:crypto');
@@ -79,7 +79,8 @@ async function ensureFalSigningSeed(root) {
79
79
  const fast = storedSeed(projectResources(SERVICE, root));
80
80
  if (fast)
81
81
  return fast;
82
- const { value } = await applyTwinWriteAtomic(SERVICE, (resources) => {
82
+ // fal's own signing key: a vendor move, made on first use whoever asks (a read-only request too)
83
+ const { value } = await runAsVendorMove(() => applyTwinWriteAtomic(SERVICE, (resources) => {
83
84
  const existing = storedSeed(resources);
84
85
  if (existing)
85
86
  return { kind: 'skip', value: existing };
@@ -95,7 +96,7 @@ async function ensureFalSigningSeed(root) {
95
96
  actor: { kind: 'system' },
96
97
  },
97
98
  };
98
- }, root);
99
+ }, root));
99
100
  return value;
100
101
  }
101
102
  /** The per-root ed25519 keypair, derived from the PERSISTED seed. */
@@ -152,7 +153,7 @@ function verifyRaw(rawBody, headers, publicKey, opts) {
152
153
  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).');
153
154
  }
154
155
  const tolerance = opts.tolerance ?? 300;
155
- const now = opts.now ?? Math.floor(Date.now() / 1000);
156
+ const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
156
157
  if (Math.abs(now - ts) > tolerance) {
157
158
  throw new FalWebhookVerificationError(`Webhook timestamp outside the ${tolerance}s tolerance.`);
158
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/twin-fal",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local fal.ai twin for the model-inference queue lifecycle (submit/status/response/cancel on queue.fal.run, the sync fal.run variant, host-split routing, ed25519-signed webhooks) built on @volter/world-core.",
5
5
  "author": "Volter (https://github.com/volter-ai)",
6
6
  "license": "Apache-2.0",
@@ -35,12 +35,12 @@
35
35
  "postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
36
36
  },
37
37
  "peerDependencies": {
38
- "@volter/world-core": "2.0.0"
38
+ "@volter/world-core": "2.0.2"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.2.20",
42
42
  "@types/node": "^24.0.0",
43
- "@volter/world-core": "2.0.0",
43
+ "@volter/world-core": "2.0.2",
44
44
  "@volter/world-tooling": "0.1.0",
45
45
  "@fal-ai/client": "^1.10.1",
46
46
  "typescript": "^5.9.0"
package/src/fal-server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  // construction — loudly, never a silent fallback.
24
24
  import { serveHttp } from '@volter/world-core';
25
25
  import { handleFalTwinRequest } from './fal-twin.ts';
26
- import { createTwinFetchFromHandler, twinManifest } from '@volter/world-core';
26
+ import { createTwinFetchFromHandler, twinManifest, withRequestScopes } from '@volter/world-core';
27
27
  import { createFalScenarioEngine, loadFalScenarioDocument, type FalScenarioEngine } from './fal-scenario.ts';
28
28
 
29
29
  /** Options every fal-twin HTTP surface needs, independent of who owns the socket. */
@@ -39,7 +39,9 @@ export function createFalTwinFetch(options: FalTwinFetchOptions = {}): (request:
39
39
  const scenarioPath = options.scenarioPath ?? process.env.TWIN_FAL_SCENARIO;
40
40
  const scenarioEngine: FalScenarioEngine | undefined = scenarioPath ? createFalScenarioEngine(loadFalScenarioDocument(scenarioPath)) : undefined;
41
41
  const { scenarioPath: _scenarioPath, ...handlerOptions } = options;
42
- return createTwinFetchFromHandler(handleFalTwinRequest, {
42
+ // a read-only request (x-volter-read-only) is a request to a read-only twin: a status poll answers the
43
+ // request's current status without advancing it, nothing is written (withRequestScopes: the pack opts in)
44
+ return withRequestScopes(createTwinFetchFromHandler(handleFalTwinRequest, {
43
45
  ...handlerOptions,
44
46
  manifest: () => twinManifest({
45
47
  vendor: 'fal',
@@ -51,7 +53,7 @@ export function createFalTwinFetch(options: FalTwinFetchOptions = {}): (request:
51
53
  }),
52
54
  scenarioStatus: () => (scenarioEngine ? scenarioEngine.status() : { vendor: 'fal', handlers: [], misses: 0, recentMisses: [] }),
53
55
  ...(scenarioEngine ? { handlerOptions: { scenarioEngine } } : {}),
54
- });
56
+ }), { refuse: () => Response.json({ detail: 'twin is read-only; omit readOnly to accept writes' }, { status: 405 }) });
55
57
  }
56
58
 
57
59
  export async function createFalTwinServer(options: { root?: string; port?: number; readOnly?: boolean; scenarioPath?: string } = {}): Promise<{ port: number; stop: () => void }> {
package/src/fal-twin.ts CHANGED
@@ -45,7 +45,7 @@
45
45
  // Kernel SUBJECT ids are type-prefixed (`request:<uuid>`) — the PUBLIC request_id emitted to
46
46
  // clients is the bare uuid. `kid()` builds the subject id; `rows()` strips the prefix back off.
47
47
  import { createHash } from 'node:crypto';
48
- import { applyTwinWrite, projectResources } from '@volter/world-core';
48
+ import { applyTwinWrite, projectResources, worldNow } from '@volter/world-core';
49
49
  import { falJwks } from './fal-webhooks.ts';
50
50
  import type { FalScenarioEngine, FalScenarioRespond } from './fal-scenario.ts';
51
51
 
@@ -149,7 +149,7 @@ function parseBody(body?: string): { ok: true; value: Record<string, unknown> }
149
149
  }
150
150
  }
151
151
  function nowIso(occurredAt?: string): string {
152
- return occurredAt ?? new Date().toISOString();
152
+ return occurredAt ?? worldNow();
153
153
  }
154
154
  function kid(type: string, id: string): string {
155
155
  return `${type}:${id}`;
@@ -352,7 +352,9 @@ async function handleSubmit(modelId: string, rawBody: string | undefined, surfac
352
352
  async function handleStatus(modelId: string, requestId: string, includeLogs: boolean, req: FalRequest): Promise<FalResponse> {
353
353
  const existing = getRow('request', requestId, req.root);
354
354
  if (!existing) return detailNotFound('Request not found.');
355
- await progressOnce(requestId, req);
355
+ // ADVANCE ON POLL — one step per status check. A read-only twin (a World's read token) answers the
356
+ // request's current status and never advances it: the app's next poll sees what it would have.
357
+ if (!req.readOnly) await progressOnce(requestId, req);
356
358
  const row = getRow('request', requestId, req.root)!;
357
359
  return { status: 200, body: statusView(row, modelId, requestId, includeLogs) };
358
360
  }
@@ -40,7 +40,7 @@
40
40
  //
41
41
  // `node:crypto` is required LAZILY (server-only), same convention as replicate-webhooks.ts, so
42
42
  // this module stays browser-bundle safe.
43
- import { applyTwinWriteAtomic, projectResources, nodeBuiltin } from '@volter/world-core';
43
+ import { applyTwinWriteAtomic, projectResources, nodeBuiltin, worldNow, runAsVendorMove } from '@volter/world-core';
44
44
 
45
45
  type NodeCrypto = typeof import('node:crypto');
46
46
  function nodeCrypto(): NodeCrypto {
@@ -96,7 +96,8 @@ function storedSeed(resources: readonly { type: string; id: string }[]): string
96
96
  async function ensureFalSigningSeed(root?: string): Promise<string> {
97
97
  const fast = storedSeed(projectResources(SERVICE, root));
98
98
  if (fast) return fast;
99
- const { value } = await applyTwinWriteAtomic<string>(SERVICE, (resources) => {
99
+ // fal's own signing key: a vendor move, made on first use whoever asks (a read-only request too)
100
+ const { value } = await runAsVendorMove(() => applyTwinWriteAtomic<string>(SERVICE, (resources) => {
100
101
  const existing = storedSeed(resources);
101
102
  if (existing) return { kind: 'skip', value: existing };
102
103
  const seed = nodeCrypto().randomBytes(SEED_BYTES).toString('base64');
@@ -111,7 +112,7 @@ async function ensureFalSigningSeed(root?: string): Promise<string> {
111
112
  actor: { kind: 'system' },
112
113
  },
113
114
  };
114
- }, root);
115
+ }, root));
115
116
  return value;
116
117
  }
117
118
 
@@ -173,7 +174,7 @@ function verifyRaw(rawBody: string, headers: Record<string, string>, publicKey:
173
174
  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
  }
175
176
  const tolerance = opts.tolerance ?? 300;
176
- const now = opts.now ?? Math.floor(Date.now() / 1000);
177
+ const now = opts.now ?? Math.floor(Date.parse(worldNow()) / 1000);
177
178
  if (Math.abs(now - ts) > tolerance) {
178
179
  throw new FalWebhookVerificationError(`Webhook timestamp outside the ${tolerance}s tolerance.`);
179
180
  }