@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 +27 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +20 -0
- package/dist/jobs.d.ts +2 -0
- package/dist/jobs.js +6 -2
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +36 -6
- package/package.json +1 -1
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,
|
|
42
|
-
* whose `id` and `name` are the credential's
|
|
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
|
|
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 ===
|
|
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.
|
|
732
|
-
|
|
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: {
|