@isomorph.ai/app-sdk 2.5.0 → 2.6.1

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
@@ -365,6 +365,33 @@ The calls go to the local App Gateway beside the runtime (`ISOMORPH_TRUSTED_GATE
365
365
 
366
366
  (1.17.0; data plane ADR 0057)
367
367
 
368
+ ## Public actions
369
+
370
+ One door with nobody signed in: a sign-up form, a feedback box, an inbound lead. An action that
371
+ exports `access = "public"` beside `expose = { description, input }` runs for a caller with no
372
+ sign-in as `public:<app>` (`ctx.user.kind === 'public'`, no email, no groups). It may only insert
373
+ rows: a select, update, delete, file, integration, AI call, realtime, another action or a job
374
+ enqueue inside it is refused with `PUBLIC_DENIED`. A public action cannot be trusted. The page
375
+ calls it with `isomorph.actions.invokePublic(name, input)`: a plain POST with no cookie and no
376
+ sign-in, answering the result or an `IsomorphError` with the gateway's category. The table it
377
+ writes says so in its row rule (`WITH CHECK (current_setting('harbour.user_id', true) = 'public:<app>')`).
378
+
379
+ ```ts
380
+ // actions/signup.ts
381
+ export const access = "public";
382
+ export const expose = { description: 'Sign a visitor up for the launch list', input: { type: 'object', properties: { email: { type: 'string' } }, required: ['email'] } };
383
+
384
+ export default async function signup(input: { email: string }) {
385
+ await isomorph.data.from('launch_signups').insert({ email: input.email });
386
+ return { ok: true };
387
+ }
388
+
389
+ // page
390
+ await isomorph.actions.invokePublic('signup', { email });
391
+ ```
392
+
393
+ (2.6.0)
394
+
368
395
  ## Browser read tools
369
396
 
370
397
  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
@@ -38,10 +38,12 @@ export interface IsomorphUser {
38
38
  appUser?: unknown;
39
39
  /**
40
40
  * Who is calling: a `'person'` signed in through the browser or an MCP
41
- * client, or a `'service'` holding a credential the company's IT issued,
42
- * whose `id` and `name` are the credential's. Absent means a person.
41
+ * client, a `'service'` holding a credential the company's IT issued,
42
+ * whose `id` and `name` are the credential's, or `'public'`: nobody signed
43
+ * in, calling an action that exports `access = "public"` (`id` is
44
+ * `public:<app>`, no email, no groups). Absent means a person.
43
45
  */
44
- kind?: 'person' | 'service';
46
+ kind?: 'person' | 'service' | 'public';
45
47
  }
46
48
  /**
47
49
  * `export const expose = { description, input }` on an action, beside
@@ -2066,6 +2068,13 @@ export declare function createClient(options?: ClientOptions): {
2066
2068
  invoke: <T = unknown, P = string>(name: string, input: unknown, options?: {
2067
2069
  onProgress?: (step: P) => void;
2068
2070
  }) => Promise<T>;
2071
+ /**
2072
+ * Call an action that exports `access = "public"` with no sign-in: a
2073
+ * plain POST to `/_harbour/api/<name>` on the API host, no cookie, no
2074
+ * credential, no sign-in redirect. The result is the action's, the
2075
+ * error an `IsomorphError` with the gateway's category.
2076
+ */
2077
+ invokePublic: <T = unknown>(name: string, input: unknown) => Promise<T>;
2069
2078
  };
2070
2079
  jobs: {
2071
2080
  /** Select the owner's folder for a declared configurable file trigger. */
package/dist/index.js CHANGED
@@ -819,6 +819,26 @@ export function createClient(options = {}) {
819
819
  }
820
820
  return transport.json(path, { method: 'POST', body: JSON.stringify(input) }, options.onProgress);
821
821
  },
822
+ /**
823
+ * Call an action that exports `access = "public"` with no sign-in: a
824
+ * plain POST to `/_harbour/api/<name>` on the API host, no cookie, no
825
+ * credential, no sign-in redirect. The result is the action's, the
826
+ * error an `IsomorphError` with the gateway's category.
827
+ */
828
+ invokePublic: async (name, input) => {
829
+ const response = await transport.fetch(`${transport.baseUrl}/_harbour/api/${encodeURIComponent(name)}`, {
830
+ method: 'POST', credentials: 'omit', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input ?? {}),
831
+ });
832
+ let body = {};
833
+ try {
834
+ body = await response.json();
835
+ }
836
+ catch { /* not JSON: the status says what happened */ }
837
+ if (!response.ok || body.error) {
838
+ throw new IsomorphError(body.error?.category ?? 'INTERNAL', body.error?.message ?? `the public action answered ${response.status}`, body.requestId, undefined, response.status);
839
+ }
840
+ return body.data;
841
+ },
822
842
  },
823
843
  jobs: {
824
844
  /** Select the owner's folder for a declared configurable file trigger. */
package/dist/jobs.d.ts CHANGED
@@ -58,6 +58,8 @@ export interface JobContext {
58
58
  }
59
59
  /** Whether the module says `export const <name> = true`, read without importing it; a module that does not parse does not. */
60
60
  export declare function exportsTrue(file: string, name: string): Promise<boolean>;
61
+ /** Whether the module says `export const <name> = <value>` with that one literal, read statically; nothing computed counts. */
62
+ export declare function exportsLiteral(file: string, name: string, value: string | boolean): Promise<boolean>;
61
63
  export declare function discoverJobs(root: string): Promise<JobDefinition[]>;
62
64
  type Handler = (...args: unknown[]) => unknown;
63
65
  export declare function loadHandler(root: string, dir: string, name: string): Promise<Handler>;
package/dist/jobs.js CHANGED
@@ -103,7 +103,11 @@ function parseModule(source) {
103
103
  return parse(stripTypeScriptTypes(source), { ecmaVersion: 'latest', sourceType: 'module' }).body;
104
104
  }
105
105
  /** Whether the module says `export const <name> = true`, read without importing it; a module that does not parse does not. */
106
- export async function exportsTrue(file, name) {
106
+ export function exportsTrue(file, name) {
107
+ return exportsLiteral(file, name, true);
108
+ }
109
+ /** Whether the module says `export const <name> = <value>` with that one literal, read statically; nothing computed counts. */
110
+ export async function exportsLiteral(file, name, value) {
107
111
  let statements;
108
112
  try {
109
113
  statements = parseModule(await readFile(file, 'utf8'));
@@ -114,7 +118,7 @@ export async function exportsTrue(file, name) {
114
118
  return statements.some(statement => statement.type === 'ExportNamedDeclaration'
115
119
  && statement.declaration?.type === 'VariableDeclaration' && statement.declaration.kind === 'const'
116
120
  && statement.declaration.declarations.some(declaration => declaration.id.type === 'Identifier' && declaration.id.name === name
117
- && declaration.init?.type === 'Literal' && declaration.init.value === true));
121
+ && declaration.init?.type === 'Literal' && declaration.init.value === value));
118
122
  }
119
123
  export async function discoverJobs(root) {
120
124
  const directory = join(root, JOBS_DIR);
package/dist/runtime.d.ts CHANGED
@@ -39,6 +39,8 @@ export interface RuntimeInventory {
39
39
  trusted: string[];
40
40
  /** The actions whose module says `export const expose = { description, input }`, by name: what the kit gate records as exposed (ADR 0100). */
41
41
  exposed: Record<string, ActionExposure>;
42
+ /** The actions whose module says `export const access = "public"`: the gateway runs them for callers with no sign-in (data plane ADR 0114). */
43
+ public: string[];
42
44
  jobs: JobDefinition[];
43
45
  }
44
46
  /** Every `actions/<name>.ts` and `jobs/<name>.ts` the app carries, by logical name. */
@@ -68,6 +70,8 @@ export declare class Runs {
68
70
  cancel(id: string, owner: string): Promise<JobRun | null>;
69
71
  /** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
70
72
  reclaim(now: Date, active?: ReadonlySet<string>): Promise<number>;
73
+ /** Deletes finished runs created before `before` (the queue index covers status and created_at); how many went. */
74
+ prune(before: Date): Promise<number>;
71
75
  /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
72
76
  claim(now: Date): Promise<JobRun | null>;
73
77
  progress(id: string, percent: number, note?: string): Promise<void>;
@@ -118,6 +122,7 @@ export declare class Runtime {
118
122
  private queueWorkers;
119
123
  private queueWake;
120
124
  private claimTail;
125
+ private pruneDue;
121
126
  private readonly activeRuns;
122
127
  private readonly runs;
123
128
  private session;
@@ -191,6 +196,8 @@ export declare class Runtime {
191
196
  * when the queue is empty or both execution slots are occupied.
192
197
  */
193
198
  work(): Promise<boolean>;
199
+ /** A failed prune is logged and tried again next day; it never stops the pass that carries it. */
200
+ private pruneRuns;
194
201
  private serve;
195
202
  private transaction;
196
203
  private loadFailures;
package/dist/runtime.js CHANGED
@@ -68,7 +68,7 @@ import { pathToFileURL } from 'node:url';
68
68
  import { createClient, gatewayRequest, IsomorphError } from './index.js';
69
69
  import { userGroupsFromHeader } from './user-groups.js';
70
70
  import { userRole } from './user-role.js';
71
- import { FILE_MIME_TYPES, providerFileId, actionExposure, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
71
+ import { FILE_MIME_TYPES, providerFileId, actionExposure, discoverJobs, exportsLiteral, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
72
72
  import { WEBHOOK_ADAPTERS } from './webhook-adapters.js';
73
73
  import { LocalGateway } from './local-gateway.js';
74
74
  export { JOBS_DIR } from './jobs.js';
@@ -88,6 +88,12 @@ const MAX_BODY_BYTES = 1 << 20;
88
88
  const LEASE_MS = 5 * 60_000;
89
89
  const QUEUE_WORKERS = 2;
90
90
  const QUEUE_POLL_MS = 2_000;
91
+ // A finished run's row (input, result, error) is kept this long, then the
92
+ // queue loop deletes it: once per process start and once a day after that,
93
+ // never on every pass. Nothing else ever removed a row (the outbox backlog
94
+ // of 2026-10-26 was the same shape).
95
+ const RUN_RETENTION_MS = 30 * 24 * 60 * 60_000;
96
+ const PRUNE_EVERY_MS = 24 * 60 * 60_000;
91
97
  const AI_RETRY_MS = 30_000;
92
98
  const TOKEN_REFRESH_MS = 60_000;
93
99
  // A gateway session lasts an hour. An action runs at most 300 s, so a request
@@ -121,8 +127,9 @@ export async function discover(root) {
121
127
  const trusted = await Promise.all(actions.map((name) => exportsTrue(join(root, ACTIONS_DIR, `${name}.ts`), 'trusted')));
122
128
  const exposures = await Promise.all(actions.map((name) => actionExposure(join(root, ACTIONS_DIR, `${name}.ts`))));
123
129
  const exposed = Object.fromEntries(actions.flatMap((name, index) => exposures[index] ? [[name, exposures[index]]] : []));
130
+ const publicAccess = await Promise.all(actions.map((name) => exportsLiteral(join(root, ACTIONS_DIR, `${name}.ts`), 'access', 'public')));
124
131
  const jobs = await discoverJobs(root);
125
- return { actions, trusted: actions.filter((_, index) => trusted[index]), exposed, jobs };
132
+ return { actions, trusted: actions.filter((_, index) => trusted[index]), exposed, public: actions.filter((_, index) => publicAccess[index]), jobs };
126
133
  }
127
134
  // ---- Cron ------------------------------------------------------------------
128
135
  /** One field of a five-field cron expression: numbers, `*`, lists, ranges and steps, no names. */
@@ -279,6 +286,11 @@ export class Runs {
279
286
  }
280
287
  return interrupted.length;
281
288
  }
289
+ /** Deletes finished runs created before `before` (the queue index covers status and created_at); how many went. */
290
+ async prune(before) {
291
+ const { data } = await this.table().delete().in('status', ['succeeded', 'failed', 'cancelled']).lt('created_at', before.toISOString()).select('id');
292
+ return data.length;
293
+ }
282
294
  /** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
283
295
  async claim(now) {
284
296
  while (true) {
@@ -321,7 +333,7 @@ export class Runs {
321
333
  }
322
334
  export class Runtime {
323
335
  options;
324
- inventory = { actions: [], trusted: [], exposed: {}, jobs: [] };
336
+ inventory = { actions: [], trusted: [], exposed: {}, public: [], jobs: [] };
325
337
  localGateway;
326
338
  running = new Set();
327
339
  timers = [];
@@ -331,6 +343,7 @@ export class Runtime {
331
343
  queueWorkers = 0;
332
344
  queueWake = 0;
333
345
  claimTail = Promise.resolve();
346
+ pruneDue = 0;
334
347
  activeRuns = new Set();
335
348
  runs;
336
349
  // Built on first use: the AWS signing modules load only on a serverless
@@ -670,6 +683,10 @@ export class Runtime {
670
683
  if (this.activeRuns.size >= QUEUE_WORKERS)
671
684
  return false;
672
685
  const now = new Date();
686
+ if (now.getTime() >= this.pruneDue) {
687
+ this.pruneDue = now.getTime() + PRUNE_EVERY_MS;
688
+ await this.pruneRuns(now);
689
+ }
673
690
  reclaimed = await this.runs.reclaim(now, this.activeRuns);
674
691
  run = await this.runs.claim(now);
675
692
  if (run)
@@ -710,6 +727,17 @@ export class Runtime {
710
727
  this.activeRuns.delete(run.id);
711
728
  }
712
729
  }
730
+ /** A failed prune is logged and tried again next day; it never stops the pass that carries it. */
731
+ async pruneRuns(now) {
732
+ try {
733
+ const pruned = await this.runs.prune(new Date(now.getTime() - RUN_RETENTION_MS));
734
+ if (pruned > 0)
735
+ this.log(`pruned ${pruned} finished run(s) older than 30 days`);
736
+ }
737
+ catch (error) {
738
+ this.log(`pruning finished runs failed: ${error instanceof Error ? error.message : String(error)}`);
739
+ }
740
+ }
713
741
  async serve(request, response) {
714
742
  const requestId = header(request, 'x-request-id') || undefined;
715
743
  const name = decodeURIComponent((request.url ?? '/').split('?')[0].replace(/^\/+/, ''));
@@ -728,8 +756,10 @@ export class Runtime {
728
756
  id: userId,
729
757
  // A service credential's call arrives with the credential as the verified user
730
758
  // (`service:<clientId>`, ADR 0101); the action sees it as `kind: 'service'`, the
731
- // shape `/_harbour/identity/current` already answers. Anything else is a person.
732
- ...(userId.startsWith('service:') ? { kind: 'service' } : {}),
759
+ // shape `/_harbour/identity/current` already answers. A public action's caller
760
+ // arrives as `public:<app>` with no email and no groups (data plane ADR 0114).
761
+ // Anything else is a person.
762
+ ...(userId.startsWith('service:') ? { kind: 'service' } : userId.startsWith('public:') ? { kind: 'public' } : {}),
733
763
  email: header(request, 'X-Harbour-Verified-User-Email'),
734
764
  groups: userGroupsFromHeader(header(request, 'X-Harbour-Verified-User-Groups')),
735
765
  role: userRole(header(request, 'X-Harbour-Verified-User-Role')),
@@ -887,7 +917,7 @@ export class Runtime {
887
917
  case PING_ACTION: {
888
918
  // `hooks` is what the local gateway's hooks entrance serves under `isomorph dev` (data plane ADR 0112).
889
919
  const hooks = this.inventory.jobs.flatMap((job) => job.trigger?.type === 'webhook' ? [{ job: job.name, provider: job.trigger.provider }] : []);
890
- const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, exposed: this.inventory.exposed, jobs: this.inventory.jobs.map((job) => job.name), hooks };
920
+ const answer = { ok: true, mode: this.options.mode, actions: this.inventory.actions, trusted: this.inventory.trusted, exposed: this.inventory.exposed, public: this.inventory.public, jobs: this.inventory.jobs.map((job) => job.name), hooks };
891
921
  return this.options.mode === 'gate' ? { ...answer, loadFailures: await this.loadFailures() } : answer;
892
922
  }
893
923
  case ENQUEUE_ACTION: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",