@isomorph.ai/app-sdk 1.16.0 → 1.17.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/README.md CHANGED
@@ -215,6 +215,30 @@ await isomorph.integrations.execute('company-sharepoint', {
215
215
 
216
216
  (1.16.0; contracts 0.78.0)
217
217
 
218
+ ## Trusted actions
219
+
220
+ A normal action runs with the signed-in person's rights, so a rule in it is advice: whatever it may write, the browser may write directly. A business rule that must hold goes in a trusted action: `export const trusted = true;` beside the default export. Its data calls run as the app on behalf of the caller (the database sees `harbour.user_id = 'workload:…'` and `harbour.on_behalf_of` = the person, with the person's email, groups and role), so the tables it writes give people no write rule (`WITH CHECK (current_setting('harbour.user_id', true) LIKE 'workload:%')`). `ctx.transaction(fn)` runs every call inside `fn` as one database transaction, committed when `fn` returns and rolled back when it throws; `.forUpdate()` on a select locks the row until then. A thrown `Error` with `code` (an error category such as `FORBIDDEN` or `CONFLICT`) and `message` reaches the page with that status and text.
221
+
222
+ ```ts
223
+ // actions/lead-convert.ts
224
+ export const trusted = true;
225
+
226
+ export default async function leadConvert(input: { leadId: string }, ctx) {
227
+ if (!(await mayConvert(ctx.user))) throw Object.assign(new Error('Only Sales converts a Lead.'), { code: 'FORBIDDEN' });
228
+ return ctx.transaction(async () => {
229
+ const { data: lead } = await isomorph.data.from('crm_lead').select('*').eq('lead_public_id', input.leadId).forUpdate().single();
230
+ if (lead.lead_status_code !== 'QUALIFIED') throw Object.assign(new Error('Only a qualified Lead can be converted.'), { code: 'CONFLICT' });
231
+ const { data: [account] } = await isomorph.data.from('business_party').insert({ account_name: lead.company_name }).select();
232
+ await isomorph.data.from('crm_lead').update({ lead_status_code: 'CONVERTED' }).eq('id', lead.id);
233
+ return { accountId: account.business_party_public_id };
234
+ });
235
+ }
236
+ ```
237
+
238
+ The calls go to the local App Gateway beside the runtime (`ISOMORPH_TRUSTED_GATEWAY_URL`; a sidecar on Kubernetes, a child process the runtime starts when `ISOMORPH_LOCAL_GATEWAY_BINARY`, `ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE` and `ISOMORPH_LOCAL_GATEWAY_URL` are set). The kit check refuses a trusted action unless a retained journey invokes it as the second person and is refused. `isomorph-ping` lists trusted actions under `trusted`.
239
+
240
+ (1.17.0; data plane ADR 0057)
241
+
218
242
  ## Browser read tools
219
243
 
220
244
  Install optional peers `ai` and `playwright-core@1.58.2`. Locally run `npx playwright-core install chromium --only-shell`; V2 packages Chromium in the existing app image. An existing system executable can be supplied as executablePath.
package/dist/index.d.ts CHANGED
@@ -867,6 +867,14 @@ interface Query {
867
867
  count?: 'exact';
868
868
  head?: boolean;
869
869
  onConflict?: string[];
870
+ lock?: 'update';
871
+ }
872
+ /** What the kit runtime puts around a running action: the caller's signed context, and for a trusted action the open transaction and the gateway it talks to. */
873
+ export interface ActionIdentity {
874
+ context: string;
875
+ trusted: boolean;
876
+ transactionId?: string;
877
+ baseUrl?: string;
870
878
  }
871
879
  interface IdentityRenewal {
872
880
  renewals(): number;
@@ -917,6 +925,7 @@ export declare class QueryBuilder<T = Record<string, unknown>, Result = T[]> imp
917
925
  }): QueryBuilder<T, Result>;
918
926
  limit(value: number): QueryBuilder<T, Result>;
919
927
  range(from: number, to: number): QueryBuilder<T, Result>;
928
+ forUpdate(): QueryBuilder<T, Result>;
920
929
  single(): QueryBuilder<T, T>;
921
930
  maybeSingle(): QueryBuilder<T, T | null>;
922
931
  execute(): Promise<IsomorphResult<Result>>;
@@ -937,6 +946,8 @@ declare class RealtimeChannelBuilder {
937
946
  on(event: 'postgres_changes' | 'presence' | 'broadcast', filter: unknown, handler: RealtimeHandler): this;
938
947
  subscribe(callback?: (status: RealtimeStatus) => void): RealtimeSubscription;
939
948
  }
949
+ /** One request on the gateway with the client's headers and refusals; for the kit runtime's own calls. */
950
+ export declare function gatewayRequest<T>(options: ClientOptions, path: string, init?: RequestInit): Promise<T>;
940
951
  export declare function createClient(options?: ClientOptions): {
941
952
  identity: {
942
953
  current: () => Promise<IsomorphUser | null>;
package/dist/index.js CHANGED
@@ -36,6 +36,11 @@ export function aiErrorCode(error) {
36
36
  return typeof code === 'string' && AI_ERROR_CODES.has(code) ? code : undefined;
37
37
  }
38
38
  const AI_ERROR_CODES = new Set(['AI_NOT_ENABLED', 'AI_POLICY_DENIED', 'AI_QUOTA_EXCEEDED', 'AI_PROVIDER_ERROR', 'AI_UNAVAILABLE', 'REQUEST_TOO_LARGE', 'VALIDATION_FAILED']);
39
+ function currentAction() {
40
+ if (typeof location !== 'undefined')
41
+ return undefined;
42
+ return globalThis[Symbol.for('isomorph.actionIdentity')]?.();
43
+ }
39
44
  // Header names are case-insensitive, but a plain object is not: `Headers`
40
45
  // JOINS `X-Harbour-Identity-Context` and `x-harbour-identity-context` into one
41
46
  // value "a, b". The kit starter's retained checks attach the identity context
@@ -76,17 +81,23 @@ function mergeHeaders(...layers) {
76
81
  // client with createClient({ fetch }). The ownership and cross-user coverage
77
82
  // rules require that identity, so either the brief should sanction that option
78
83
  // or this transport should take a per-request identity.
79
- function runnerIdentityHeaders(identity) {
84
+ function runnerIdentityHeaders(identity, action = currentAction()) {
80
85
  if (typeof location !== 'undefined')
81
86
  return {};
82
- const resolver = globalThis[Symbol.for('isomorph.actionIdentity')];
83
- const actionToken = resolver?.();
84
- if (actionToken !== undefined) {
85
- if (!actionToken)
87
+ const environment = globalThis.process?.env;
88
+ if (action !== undefined) {
89
+ if (!action.context)
86
90
  throw new IsomorphError('AUTH_REQUIRED', 'The action has no invoking-user identity.');
87
- return { 'x-harbour-identity-context': actionToken };
91
+ const headers = { 'x-harbour-identity-context': action.context };
92
+ if (action.trusted) {
93
+ if (!environment?.ISOMORPH_WORKLOAD_TOKEN)
94
+ throw new IsomorphError('AUTH_REQUIRED', 'The trusted action has no workload token to act with.');
95
+ headers.authorization = `Bearer ${environment.ISOMORPH_WORKLOAD_TOKEN}`;
96
+ }
97
+ if (action.transactionId)
98
+ headers['x-harbour-tx'] = action.transactionId;
99
+ return headers;
88
100
  }
89
- const environment = globalThis.process?.env;
90
101
  const name = environment?.ISOMORPH_IDENTITY_CONTEXT_HEADER;
91
102
  const token = identity === 'second-user' ? environment?.ISOMORPH_IDENTITY_CONTEXT_SECOND_USER : environment?.ISOMORPH_IDENTITY_CONTEXT;
92
103
  const workloadToken = environment?.ISOMORPH_WORKLOAD_TOKEN;
@@ -184,9 +195,10 @@ class Transport {
184
195
  }
185
196
  async request(path, init, renewOnUnauthorized, onProgress) {
186
197
  const sentAt = this.renewal?.renewals() ?? 0;
198
+ const action = currentAction();
187
199
  // (headers with an undefined value are removed so a multipart body can
188
200
  // clear the JSON default and let the browser set its own boundary)
189
- const response = await this.fetch(`${this.baseUrl}${path}`, {
201
+ const response = await this.fetch(`${action?.baseUrl ?? this.baseUrl}${path}`, {
190
202
  credentials: 'same-origin',
191
203
  ...init,
192
204
  headers: mergeHeaders(
@@ -195,7 +207,7 @@ class Transport {
195
207
  // (OPTIONS, one more round trip, on a separate uncredentialed
196
208
  // connection a preconnect cannot warm) before the GET itself. Without
197
209
  // it the identity GET and every other read is one round trip.
198
- init.body === undefined && ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase()) ? {} : { 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity), init.headers, onProgress ? { accept: PROGRESS_MEDIA_TYPE } : undefined),
210
+ init.body === undefined && ['GET', 'HEAD'].includes((init.method ?? 'GET').toUpperCase()) ? {} : { 'content-type': 'application/json' }, runnerIdentityHeaders(this.identity, action), init.headers, onProgress ? { accept: PROGRESS_MEDIA_TYPE } : undefined),
199
211
  });
200
212
  if (onProgress && response.ok && response.body && response.headers.get('content-type')?.startsWith(PROGRESS_MEDIA_TYPE)) {
201
213
  return readProgress(response, onProgress);
@@ -367,6 +379,7 @@ export class QueryBuilder {
367
379
  }
368
380
  limit(value) { return this.next({ limit: value }); }
369
381
  range(from, to) { return this.next({ range: { from, to } }); }
382
+ forUpdate() { return this.next({ lock: 'update' }); }
370
383
  single() { return this.next({ cardinality: 'single' }); }
371
384
  maybeSingle() { return this.next({ cardinality: 'maybeSingle' }); }
372
385
  async execute() {
@@ -400,6 +413,10 @@ class RealtimeChannelBuilder {
400
413
  }
401
414
  }
402
415
  }
416
+ /** One request on the gateway with the client's headers and refusals; for the kit runtime's own calls. */
417
+ export function gatewayRequest(options, path, init) {
418
+ return new Transport(options).json(path, init);
419
+ }
403
420
  export function createClient(options = {}) {
404
421
  const authListeners = new Set();
405
422
  let currentUser;
package/dist/jobs.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface JobContext {
10
10
  progress(percent: number, note?: string): Promise<void>;
11
11
  cancelled(): Promise<boolean>;
12
12
  }
13
+ /** Whether the module says `export const <name> = true`, read without importing it; a module that does not parse does not. */
14
+ export declare function exportsTrue(file: string, name: string): Promise<boolean>;
13
15
  export declare function discoverJobs(root: string): Promise<JobDefinition[]>;
14
16
  type Handler = (...args: unknown[]) => unknown;
15
17
  export declare function loadHandler(root: string, dir: string, name: string): Promise<Handler>;
package/dist/jobs.js CHANGED
@@ -16,6 +16,23 @@ function bindsSchedule(binding) {
16
16
  default: return false;
17
17
  }
18
18
  }
19
+ function parseModule(source) {
20
+ return parse(stripTypeScriptTypes(source), { ecmaVersion: 'latest', sourceType: 'module' }).body;
21
+ }
22
+ /** Whether the module says `export const <name> = true`, read without importing it; a module that does not parse does not. */
23
+ export async function exportsTrue(file, name) {
24
+ let statements;
25
+ try {
26
+ statements = parseModule(await readFile(file, 'utf8'));
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ return statements.some(statement => statement.type === 'ExportNamedDeclaration'
32
+ && statement.declaration?.type === 'VariableDeclaration' && statement.declaration.kind === 'const'
33
+ && statement.declaration.declarations.some(declaration => declaration.id.type === 'Identifier' && declaration.id.name === name
34
+ && declaration.init?.type === 'Literal' && declaration.init.value === true));
35
+ }
19
36
  export async function discoverJobs(root) {
20
37
  const directory = join(root, JOBS_DIR);
21
38
  const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
@@ -34,7 +51,7 @@ export async function discoverJobs(root) {
34
51
  const source = await readFile(join(directory, entry.name), 'utf8');
35
52
  let statements;
36
53
  try {
37
- statements = parse(stripTypeScriptTypes(source), { ecmaVersion: 'latest', sourceType: 'module' }).body;
54
+ statements = parseModule(source);
38
55
  }
39
56
  catch (error) {
40
57
  fail(`cannot parse the job module: ${error instanceof Error ? error.message : String(error)}`);
@@ -0,0 +1,17 @@
1
+ export interface LocalGatewayOptions {
2
+ binary: string;
3
+ configFile: string;
4
+ url: string;
5
+ log: (line: string) => void;
6
+ }
7
+ /** The App Gateway as a child of the runtime (the Lambda option; Kubernetes runs it as a sidecar): started once, restarted when it exits, killed on stop. */
8
+ export declare class LocalGateway {
9
+ private readonly options;
10
+ private child;
11
+ private stopped;
12
+ constructor(options: LocalGatewayOptions);
13
+ start(): Promise<void>;
14
+ stop(): void;
15
+ private spawn;
16
+ private listening;
17
+ }
@@ -0,0 +1,50 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ const LISTEN_WAIT_MS = 20_000;
4
+ const LISTEN_POLL_MS = 250;
5
+ const RESTART_DELAY_MS = 1_000;
6
+ /** The App Gateway as a child of the runtime (the Lambda option; Kubernetes runs it as a sidecar): started once, restarted when it exits, killed on stop. */
7
+ export class LocalGateway {
8
+ options;
9
+ child;
10
+ stopped = false;
11
+ constructor(options) {
12
+ this.options = options;
13
+ }
14
+ async start() {
15
+ if (!this.options.url)
16
+ throw new Error('ISOMORPH_LOCAL_GATEWAY_URL names where the local gateway listens');
17
+ this.spawn();
18
+ const deadline = Date.now() + LISTEN_WAIT_MS;
19
+ while (!(await this.listening())) {
20
+ if (Date.now() > deadline)
21
+ throw new Error(`the local gateway did not listen on ${this.options.url} within ${LISTEN_WAIT_MS / 1000} s`);
22
+ await sleep(LISTEN_POLL_MS);
23
+ }
24
+ process.env.ISOMORPH_TRUSTED_GATEWAY_URL = this.options.url;
25
+ }
26
+ stop() {
27
+ this.stopped = true;
28
+ this.child?.kill('SIGTERM');
29
+ }
30
+ spawn() {
31
+ this.child = spawn(this.options.binary, [], { env: { ...process.env, HARBOUR_APP_GATEWAY_CONFIG_FILE: this.options.configFile }, stdio: ['ignore', 'ignore', 'pipe'] });
32
+ this.child.stderr?.on('data', (chunk) => process.stderr.write(chunk));
33
+ this.child.once('exit', (code, signal) => {
34
+ if (this.stopped)
35
+ return;
36
+ this.options.log(`the local gateway exited (${signal ?? code}); restarting`);
37
+ void sleep(RESTART_DELAY_MS).then(() => { if (!this.stopped)
38
+ this.spawn(); });
39
+ });
40
+ }
41
+ async listening() {
42
+ try {
43
+ await fetch(`${this.options.url}/_harbour/identity/current`, { signal: AbortSignal.timeout(LISTEN_POLL_MS * 4) });
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ }
package/dist/runtime.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import { type IsomorphClient, type IsomorphUser, type JobRun } from './index.js';
3
3
  import { type JobDefinition } from './jobs.js';
4
+ import { type LocalGatewayOptions } from './local-gateway.js';
4
5
  export { JOBS_DIR, type JobContext } from './jobs.js';
5
6
  export declare const ACTIONS_DIR = "actions";
6
7
  export declare const RUNS_TABLE = "isomorph_job_runs";
@@ -21,9 +22,19 @@ export interface ActionContext {
21
22
  * step over 1 KiB, further calls are dropped.
22
23
  */
23
24
  progress(step: string | Record<string, unknown>): void;
25
+ /**
26
+ * Runs `fn` in one database transaction: every data call inside it, from
27
+ * any client, is part of it; it commits when `fn` returns and rolls back
28
+ * when `fn` throws (the error is rethrown). Only in a trusted action
29
+ * (`export const trusted = true`). A transaction inside a transaction joins
30
+ * the outer one.
31
+ */
32
+ transaction<T>(fn: () => Promise<T>): Promise<T>;
24
33
  }
25
34
  export interface RuntimeInventory {
26
35
  actions: string[];
36
+ /** The actions whose module says `export const trusted = true`: they act as the app on behalf of the caller. */
37
+ trusted: string[];
27
38
  jobs: JobDefinition[];
28
39
  }
29
40
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
@@ -72,10 +83,13 @@ export interface RuntimeOptions {
72
83
  log?: (line: string) => void;
73
84
  /** The pause between queue passes while work is found; the tests shorten it. */
74
85
  queuePollMs?: number;
86
+ /** Set when the runtime itself runs the App Gateway for its trusted actions (the Lambda option). */
87
+ localGateway?: Omit<LocalGatewayOptions, 'log'>;
75
88
  }
76
89
  export declare class Runtime {
77
90
  private readonly options;
78
91
  private inventory;
92
+ private localGateway;
79
93
  private readonly running;
80
94
  private readonly timers;
81
95
  private stopped;
@@ -121,6 +135,7 @@ export declare class Runtime {
121
135
  */
122
136
  work(): Promise<boolean>;
123
137
  private serve;
138
+ private transaction;
124
139
  private loadFailures;
125
140
  private reserved;
126
141
  }
package/dist/runtime.js CHANGED
@@ -56,10 +56,11 @@ import { register } from 'node:module';
56
56
  import { join, resolve } from 'node:path';
57
57
  import { realpathSync } from 'node:fs';
58
58
  import { pathToFileURL } from 'node:url';
59
- import { createClient, IsomorphError } from './index.js';
59
+ import { createClient, gatewayRequest, IsomorphError } from './index.js';
60
60
  import { userGroupsFromHeader } from './user-groups.js';
61
61
  import { userRole } from './user-role.js';
62
- import { discoverJobs, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
62
+ import { discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
63
+ import { LocalGateway } from './local-gateway.js';
63
64
  export { JOBS_DIR } from './jobs.js';
64
65
  const actionIdentity = new AsyncLocalStorage();
65
66
  const identityKey = Symbol.for('isomorph.actionIdentity');
@@ -94,8 +95,9 @@ export async function discover(root) {
94
95
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith('.ts')).map((entry) => entry.name.slice(0, -3)).filter((name) => LOGICAL_NAME.test(name)).sort();
95
96
  };
96
97
  const actions = (await list(ACTIONS_DIR)).filter((name) => !name.startsWith(RESERVED_PREFIX));
98
+ const trusted = await Promise.all(actions.map((name) => exportsTrue(join(root, ACTIONS_DIR, `${name}.ts`), 'trusted')));
97
99
  const jobs = await discoverJobs(root);
98
- return { actions, jobs };
100
+ return { actions, trusted: actions.filter((_, index) => trusted[index]), jobs };
99
101
  }
100
102
  // ---- Cron ------------------------------------------------------------------
101
103
  /** One field of a five-field cron expression: numbers, `*`, lists, ranges and steps, no names. */
@@ -148,6 +150,16 @@ function answer(response, status, body, requestId) {
148
150
  function refuse(response, status, category, message, requestId) {
149
151
  answer(response, status, { error: { category, message } }, requestId);
150
152
  }
153
+ const REFUSAL_STATUS = { AUTH_REQUIRED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, VALIDATION_FAILED: 400, RATE_LIMITED: 429, UNAVAILABLE: 503, INTERNAL: 500 };
154
+ /** An action's refusal for the browser: an IsomorphError, or any error carrying one of the SDK's categories as `code`. */
155
+ function refusal(error) {
156
+ if (error instanceof IsomorphError)
157
+ return { status: error.status ?? REFUSAL_STATUS[error.category], category: error.category, message: error.message };
158
+ const code = error?.code;
159
+ if (error instanceof Error && typeof code === 'string' && code in REFUSAL_STATUS)
160
+ return { status: REFUSAL_STATUS[code], category: code, message: error.message };
161
+ return undefined;
162
+ }
151
163
  async function readBody(request) {
152
164
  const chunks = [];
153
165
  let size = 0;
@@ -225,7 +237,8 @@ export class Runs {
225
237
  }
226
238
  export class Runtime {
227
239
  options;
228
- inventory = { actions: [], jobs: [] };
240
+ inventory = { actions: [], trusted: [], jobs: [] };
241
+ localGateway;
229
242
  running = new Set();
230
243
  timers = [];
231
244
  stopped = false;
@@ -245,6 +258,10 @@ export class Runtime {
245
258
  async start() {
246
259
  this.inventory = await discover(this.options.root);
247
260
  await this.refreshToken();
261
+ if (this.options.localGateway && this.inventory.trusted.length > 0) {
262
+ this.localGateway = new LocalGateway({ ...this.options.localGateway, log: this.log });
263
+ await this.localGateway.start();
264
+ }
248
265
  await new Promise((done, fail) => {
249
266
  this.server.once('error', fail);
250
267
  this.server.listen(this.options.port, '0.0.0.0', () => done());
@@ -346,6 +363,7 @@ export class Runtime {
346
363
  this.stopped = true;
347
364
  for (const timer of this.timers)
348
365
  clearTimeout(timer);
366
+ this.localGateway?.stop();
349
367
  await new Promise((done) => this.server.close(() => done()));
350
368
  }
351
369
  every(ms, task) {
@@ -522,7 +540,7 @@ export class Runtime {
522
540
  response.writeHead(200, { 'content-type': PROGRESS_MEDIA_TYPE, ...(requestId ? { 'x-request-id': requestId } : {}) });
523
541
  response.write(line);
524
542
  };
525
- const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress };
543
+ const context = { signal: controller.signal, user, tenant, app, requestId: requestId ?? '', progress, transaction: (fn) => this.transaction(name, fn) };
526
544
  const raw = await readBody(request);
527
545
  if (raw === undefined)
528
546
  return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
@@ -542,12 +560,15 @@ export class Runtime {
542
560
  return refuse(response, 404, 'NOT_FOUND', `this app has no ${ACTIONS_DIR}/${name}.ts`, requestId);
543
561
  await this.prepareAI();
544
562
  const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
545
- const result = await actionIdentity.run(header(request, 'X-Harbour-Identity-Context'), () => handler(input, context));
563
+ const trusted = this.inventory.trusted.includes(name);
564
+ const identity = { context: header(request, 'X-Harbour-Identity-Context'), trusted, baseUrl: trusted ? process.env.ISOMORPH_TRUSTED_GATEWAY_URL || undefined : undefined };
565
+ const result = await actionIdentity.run(identity, () => handler(input, context));
546
566
  return answer(response, 200, { data: result === undefined ? null : result }, requestId);
547
567
  }
548
568
  catch (error) {
549
- if (error instanceof IsomorphError)
550
- return refuse(response, error.status ?? 400, error.category, error.message, requestId);
569
+ const refused = refusal(error);
570
+ if (refused)
571
+ return refuse(response, refused.status, refused.category, refused.message, requestId);
551
572
  this.log(`${name}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
552
573
  return refuse(response, 500, 'INTERNAL', `the action ${name} failed; its log names the cause`, requestId);
553
574
  }
@@ -563,6 +584,26 @@ export class Runtime {
563
584
  // first click, hosted and in isomorph dev alike (fourier app-06b08cba,
564
585
  // 2026-09-29). Ping still answers 200: the gate's readiness probe is ping.
565
586
  // Messages are capped: the gate's coverage ledger parses the first 64 KiB.
587
+ async transaction(name, fn) {
588
+ const identity = actionIdentity.getStore();
589
+ if (!identity?.trusted)
590
+ throw new IsomorphError('INTERNAL', `ctx.transaction needs a trusted action: add export const trusted = true to ${ACTIONS_DIR}/${name}.ts`, undefined, undefined, 500);
591
+ if (identity.transactionId)
592
+ return fn();
593
+ const gateway = { baseUrl: this.options.gatewayUrl };
594
+ const { id } = await gatewayRequest(gateway, '/_harbour/data/tx', { method: 'POST', body: '{}' });
595
+ const end = (outcome) => gatewayRequest(gateway, `/_harbour/data/tx/${encodeURIComponent(id)}/${outcome}`, { method: 'POST', body: '{}' });
596
+ let result;
597
+ try {
598
+ result = await actionIdentity.run({ ...identity, transactionId: id }, fn);
599
+ }
600
+ catch (error) {
601
+ await end('rollback').catch(() => undefined);
602
+ throw error;
603
+ }
604
+ await end('commit');
605
+ return result;
606
+ }
566
607
  async loadFailures() {
567
608
  const failures = [];
568
609
  const modules = [[ACTIONS_DIR, this.inventory.actions], [JOBS_DIR, this.inventory.jobs.map((job) => job.name)]];
@@ -583,7 +624,7 @@ export class Runtime {
583
624
  const owner = context.user.id;
584
625
  switch (name) {
585
626
  case PING_ACTION: {
586
- const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, jobs: this.inventory.jobs.map((job) => job.name) };
627
+ const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, jobs: this.inventory.jobs.map((job) => job.name) };
587
628
  return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
588
629
  }
589
630
  case ENQUEUE_ACTION: {
@@ -622,6 +663,9 @@ export function optionsFromEnvironment(env = process.env) {
622
663
  mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
623
664
  tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
624
665
  jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
666
+ ...(env.ISOMORPH_LOCAL_GATEWAY_BINARY && env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE
667
+ ? { localGateway: { binary: env.ISOMORPH_LOCAL_GATEWAY_BINARY, configFile: env.ISOMORPH_LOCAL_GATEWAY_CONFIG_FILE, url: (env.ISOMORPH_LOCAL_GATEWAY_URL || '').replace(/\/$/, '') } }
668
+ : {}),
625
669
  };
626
670
  }
627
671
  /** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",