@isomorph.ai/app-sdk 1.16.0 → 1.18.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 +24 -0
- package/dist/index.d.ts +62 -2
- package/dist/index.js +43 -11
- package/dist/integration-execute-input-rules.d.ts +41 -0
- package/dist/integration-execute-input-rules.js +37 -9
- package/dist/integration-operations.d.ts +14 -0
- package/dist/integration-operations.js +18 -0
- package/dist/jobs.d.ts +2 -0
- package/dist/jobs.js +18 -1
- package/dist/local-gateway.d.ts +17 -0
- package/dist/local-gateway.js +50 -0
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.js +53 -9
- package/package.json +1 -1
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
|
@@ -172,6 +172,38 @@ export interface WarehouseViewReadRequest {
|
|
|
172
172
|
limit?: number;
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
|
+
/** One row to append: every key an approved column of the alias; values typed by the catalog, `null` for any column. */
|
|
176
|
+
export type WarehouseAppendRow = Record<string, string | number | boolean | null>;
|
|
177
|
+
/**
|
|
178
|
+
* Land rows in the warehouse. The resource is an alias IT approved with access `append` (an alias reads or
|
|
179
|
+
* appends, never both). Up to 500 rows per call; a larger report is several calls, each with its own fresh key.
|
|
180
|
+
* On a lane without a warehouse binding the rows are validated and answered as a dry run; nothing is written.
|
|
181
|
+
*/
|
|
182
|
+
export interface WarehouseTableAppendRequest {
|
|
183
|
+
operation: 'warehouse.table.append';
|
|
184
|
+
resource: string;
|
|
185
|
+
input: {
|
|
186
|
+
/** 1 to 500 rows. */
|
|
187
|
+
rows: WarehouseAppendRow[];
|
|
188
|
+
};
|
|
189
|
+
/** A fresh UUID per append, reused only to retry that same append. */
|
|
190
|
+
idempotencyKey: string;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The SMS a company-owned number received. The resource is a number alias of a `phone` connection. An app waiting
|
|
194
|
+
* for a one-time code polls this every few seconds with `since` set to when it asked, and reads the code out of
|
|
195
|
+
* the message body itself; there is no OTP concept on the platform.
|
|
196
|
+
*/
|
|
197
|
+
export interface PhoneSmsListRequest {
|
|
198
|
+
operation: 'phone.sms.list';
|
|
199
|
+
resource: string;
|
|
200
|
+
input: {
|
|
201
|
+
/** ISO 8601 instant; only messages received at or after it are returned. */
|
|
202
|
+
since: string;
|
|
203
|
+
/** Only messages from this sender id or number, up to 32 characters. */
|
|
204
|
+
from?: string;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
175
207
|
/** Gmail runs as the signed-in person after their consent. Resources are approved mailbox aliases (`inbox`). */
|
|
176
208
|
export interface GmailThreadListRequest {
|
|
177
209
|
operation: 'gmail.thread.list';
|
|
@@ -497,7 +529,7 @@ export interface ServiceRequestResult {
|
|
|
497
529
|
headers: Record<string, string>;
|
|
498
530
|
body: unknown;
|
|
499
531
|
}
|
|
500
|
-
export type IntegrationRequest = ServiceRequest | SlackChannelHistoryRequest | SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest | SlackDmSendRequest | SlackUserLookupRequest | WarehouseViewReadRequest | GmailThreadListRequest | GmailThreadReadRequest | GmailMessageReadRequest | GmailAttachmentReadRequest | GmailMessageSendRequest | BasecampProjectListRequest | BasecampTodolistListRequest | BasecampTodoListRequest | BasecampTodoCreateRequest | BasecampTodoCompleteRequest | BasecampMessageListRequest | BasecampMessagePostRequest | BasecampCommentListRequest | BasecampCommentPostRequest | BasecampCampfirePostRequest | BasecampPeopleListRequest | SharePointListListRequest | SharePointItemListRequest | SharePointItemCreateRequest | SharePointItemUpdateRequest | SharePointDriveListRequest | SharePointFileListRequest | SharePointFileSearchRequest | SharePointFileReadRequest | SharePointFileUploadRequest;
|
|
532
|
+
export type IntegrationRequest = ServiceRequest | SlackChannelHistoryRequest | SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest | SlackDmSendRequest | SlackUserLookupRequest | WarehouseViewReadRequest | WarehouseTableAppendRequest | PhoneSmsListRequest | GmailThreadListRequest | GmailThreadReadRequest | GmailMessageReadRequest | GmailAttachmentReadRequest | GmailMessageSendRequest | BasecampProjectListRequest | BasecampTodolistListRequest | BasecampTodoListRequest | BasecampTodoCreateRequest | BasecampTodoCompleteRequest | BasecampMessageListRequest | BasecampMessagePostRequest | BasecampCommentListRequest | BasecampCommentPostRequest | BasecampCampfirePostRequest | BasecampPeopleListRequest | SharePointListListRequest | SharePointItemListRequest | SharePointItemCreateRequest | SharePointItemUpdateRequest | SharePointDriveListRequest | SharePointFileListRequest | SharePointFileSearchRequest | SharePointFileReadRequest | SharePointFileUploadRequest;
|
|
501
533
|
export type IntegrationOperation = IntegrationRequest['operation'];
|
|
502
534
|
export interface SlackMessage {
|
|
503
535
|
id: string;
|
|
@@ -534,6 +566,23 @@ export interface WarehouseViewReadResult {
|
|
|
534
566
|
/** True when more rows matched than `limit`: the rows are incomplete, so never total or average them in code. */
|
|
535
567
|
truncated: boolean;
|
|
536
568
|
}
|
|
569
|
+
export interface WarehouseTableAppendResult {
|
|
570
|
+
/** How many rows landed (or, on a lane without a warehouse binding, would have). */
|
|
571
|
+
appended: number;
|
|
572
|
+
/** The write receipt's id; a retry with the same idempotencyKey answers the same receipt. */
|
|
573
|
+
jobId: string;
|
|
574
|
+
/** Present and true when the lane has no warehouse binding: the rows were validated, nothing was written. */
|
|
575
|
+
dryRun?: boolean;
|
|
576
|
+
}
|
|
577
|
+
export interface PhoneSmsMessage {
|
|
578
|
+
from: string;
|
|
579
|
+
body: string;
|
|
580
|
+
receivedAt: string;
|
|
581
|
+
}
|
|
582
|
+
/** Up to 50 messages received since `since`, newest first. */
|
|
583
|
+
export interface PhoneSmsListResult {
|
|
584
|
+
messages: PhoneSmsMessage[];
|
|
585
|
+
}
|
|
537
586
|
export interface GmailThreadSummary {
|
|
538
587
|
id: string;
|
|
539
588
|
messageId: string;
|
|
@@ -753,7 +802,7 @@ export interface SharePointFileUploadResult {
|
|
|
753
802
|
filename: string;
|
|
754
803
|
webUrl: string;
|
|
755
804
|
}
|
|
756
|
-
export type IntegrationResult<R extends IntegrationRequest> = R extends ServiceRequest ? ServiceRequestResult : R extends SlackChannelHistoryRequest ? SlackChannelHistoryResult : R extends SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest ? SlackMessagePostResult : R extends SlackDmSendRequest ? SlackDmSendResult : R extends SlackUserLookupRequest ? SlackUserLookupResult : R extends WarehouseViewReadRequest ? WarehouseViewReadResult : R extends GmailThreadListRequest ? GmailThreadListResult : R extends GmailThreadReadRequest ? GmailThreadReadResult : R extends GmailMessageReadRequest ? GmailMessageReadResult : R extends GmailAttachmentReadRequest ? GmailAttachmentReadResult : R extends GmailMessageSendRequest ? GmailMessageSendResult : R extends BasecampProjectListRequest ? BasecampProjectListResult : R extends BasecampTodolistListRequest ? BasecampTodolistListResult : R extends BasecampTodoListRequest ? BasecampTodoListResult : R extends BasecampMessageListRequest ? BasecampMessageListResult : R extends BasecampCommentListRequest ? BasecampCommentListResult : R extends BasecampPeopleListRequest ? BasecampPeopleListResult : R extends BasecampTodoCreateRequest | BasecampMessagePostRequest | BasecampCommentPostRequest | BasecampCampfirePostRequest ? BasecampRecordingResult : R extends BasecampTodoCompleteRequest ? BasecampTodoCompleteResult : R extends SharePointListListRequest ? SharePointListListResult : R extends SharePointItemListRequest ? SharePointItemListResult : R extends SharePointItemCreateRequest ? SharePointItemCreateResult : R extends SharePointItemUpdateRequest ? SharePointItemUpdateResult : R extends SharePointDriveListRequest ? SharePointDriveListResult : R extends SharePointFileListRequest | SharePointFileSearchRequest ? SharePointFileListResult : R extends SharePointFileReadRequest ? SharePointFileReadResult : R extends SharePointFileUploadRequest ? SharePointFileUploadResult : never;
|
|
805
|
+
export type IntegrationResult<R extends IntegrationRequest> = R extends ServiceRequest ? ServiceRequestResult : R extends SlackChannelHistoryRequest ? SlackChannelHistoryResult : R extends SlackMessagePostRequest | SlackMessageUpdateRequest | SlackMessageDeleteRequest ? SlackMessagePostResult : R extends SlackDmSendRequest ? SlackDmSendResult : R extends SlackUserLookupRequest ? SlackUserLookupResult : R extends WarehouseViewReadRequest ? WarehouseViewReadResult : R extends WarehouseTableAppendRequest ? WarehouseTableAppendResult : R extends PhoneSmsListRequest ? PhoneSmsListResult : R extends GmailThreadListRequest ? GmailThreadListResult : R extends GmailThreadReadRequest ? GmailThreadReadResult : R extends GmailMessageReadRequest ? GmailMessageReadResult : R extends GmailAttachmentReadRequest ? GmailAttachmentReadResult : R extends GmailMessageSendRequest ? GmailMessageSendResult : R extends BasecampProjectListRequest ? BasecampProjectListResult : R extends BasecampTodolistListRequest ? BasecampTodolistListResult : R extends BasecampTodoListRequest ? BasecampTodoListResult : R extends BasecampMessageListRequest ? BasecampMessageListResult : R extends BasecampCommentListRequest ? BasecampCommentListResult : R extends BasecampPeopleListRequest ? BasecampPeopleListResult : R extends BasecampTodoCreateRequest | BasecampMessagePostRequest | BasecampCommentPostRequest | BasecampCampfirePostRequest ? BasecampRecordingResult : R extends BasecampTodoCompleteRequest ? BasecampTodoCompleteResult : R extends SharePointListListRequest ? SharePointListListResult : R extends SharePointItemListRequest ? SharePointItemListResult : R extends SharePointItemCreateRequest ? SharePointItemCreateResult : R extends SharePointItemUpdateRequest ? SharePointItemUpdateResult : R extends SharePointDriveListRequest ? SharePointDriveListResult : R extends SharePointFileListRequest | SharePointFileSearchRequest ? SharePointFileListResult : R extends SharePointFileReadRequest ? SharePointFileReadResult : R extends SharePointFileUploadRequest ? SharePointFileUploadResult : never;
|
|
757
806
|
export type IntegrationConnectResult = {
|
|
758
807
|
status: 'connected';
|
|
759
808
|
accountLabel: string;
|
|
@@ -867,6 +916,14 @@ interface Query {
|
|
|
867
916
|
count?: 'exact';
|
|
868
917
|
head?: boolean;
|
|
869
918
|
onConflict?: string[];
|
|
919
|
+
lock?: 'update';
|
|
920
|
+
}
|
|
921
|
+
/** 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. */
|
|
922
|
+
export interface ActionIdentity {
|
|
923
|
+
context: string;
|
|
924
|
+
trusted: boolean;
|
|
925
|
+
transactionId?: string;
|
|
926
|
+
baseUrl?: string;
|
|
870
927
|
}
|
|
871
928
|
interface IdentityRenewal {
|
|
872
929
|
renewals(): number;
|
|
@@ -917,6 +974,7 @@ export declare class QueryBuilder<T = Record<string, unknown>, Result = T[]> imp
|
|
|
917
974
|
}): QueryBuilder<T, Result>;
|
|
918
975
|
limit(value: number): QueryBuilder<T, Result>;
|
|
919
976
|
range(from: number, to: number): QueryBuilder<T, Result>;
|
|
977
|
+
forUpdate(): QueryBuilder<T, Result>;
|
|
920
978
|
single(): QueryBuilder<T, T>;
|
|
921
979
|
maybeSingle(): QueryBuilder<T, T | null>;
|
|
922
980
|
execute(): Promise<IsomorphResult<Result>>;
|
|
@@ -937,6 +995,8 @@ declare class RealtimeChannelBuilder {
|
|
|
937
995
|
on(event: 'postgres_changes' | 'presence' | 'broadcast', filter: unknown, handler: RealtimeHandler): this;
|
|
938
996
|
subscribe(callback?: (status: RealtimeStatus) => void): RealtimeSubscription;
|
|
939
997
|
}
|
|
998
|
+
/** One request on the gateway with the client's headers and refusals; for the kit runtime's own calls. */
|
|
999
|
+
export declare function gatewayRequest<T>(options: ClientOptions, path: string, init?: RequestInit): Promise<T>;
|
|
940
1000
|
export declare function createClient(options?: ClientOptions): {
|
|
941
1001
|
identity: {
|
|
942
1002
|
current: () => Promise<IsomorphUser | null>;
|
package/dist/index.js
CHANGED
|
@@ -30,12 +30,31 @@ function warehouseReadProblem({ columns, aggregates }) {
|
|
|
30
30
|
return { field: 'columns', message: 'columns may use "*" only as the sole entry' };
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
34
|
+
// The append rules governance states in prose: 1 to 500 rows per call (a
|
|
35
|
+
// larger report is several calls) and a UUID idempotency key per call.
|
|
36
|
+
// Governance checks the columns and the value types against the catalog.
|
|
37
|
+
function warehouseAppendProblem({ input, idempotencyKey }) {
|
|
38
|
+
const rows = input?.rows;
|
|
39
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
40
|
+
return { field: 'rows', message: 'a warehouse append needs 1 to 500 rows' };
|
|
41
|
+
if (rows.length > 500)
|
|
42
|
+
return { field: 'rows', message: 'a warehouse append takes at most 500 rows per call; send a larger report as several calls, each with its own idempotencyKey' };
|
|
43
|
+
if (typeof idempotencyKey !== 'string' || !UUID_PATTERN.test(idempotencyKey))
|
|
44
|
+
return { field: 'idempotencyKey', message: 'a warehouse append needs a fresh UUID idempotencyKey per call' };
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
33
47
|
/** The AI reason code carried by an error, when it is a governed-AI refusal. */
|
|
34
48
|
export function aiErrorCode(error) {
|
|
35
49
|
const code = error instanceof IsomorphError ? error.details?.code : undefined;
|
|
36
50
|
return typeof code === 'string' && AI_ERROR_CODES.has(code) ? code : undefined;
|
|
37
51
|
}
|
|
38
52
|
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']);
|
|
53
|
+
function currentAction() {
|
|
54
|
+
if (typeof location !== 'undefined')
|
|
55
|
+
return undefined;
|
|
56
|
+
return globalThis[Symbol.for('isomorph.actionIdentity')]?.();
|
|
57
|
+
}
|
|
39
58
|
// Header names are case-insensitive, but a plain object is not: `Headers`
|
|
40
59
|
// JOINS `X-Harbour-Identity-Context` and `x-harbour-identity-context` into one
|
|
41
60
|
// value "a, b". The kit starter's retained checks attach the identity context
|
|
@@ -76,17 +95,23 @@ function mergeHeaders(...layers) {
|
|
|
76
95
|
// client with createClient({ fetch }). The ownership and cross-user coverage
|
|
77
96
|
// rules require that identity, so either the brief should sanction that option
|
|
78
97
|
// or this transport should take a per-request identity.
|
|
79
|
-
function runnerIdentityHeaders(identity) {
|
|
98
|
+
function runnerIdentityHeaders(identity, action = currentAction()) {
|
|
80
99
|
if (typeof location !== 'undefined')
|
|
81
100
|
return {};
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (!actionToken)
|
|
101
|
+
const environment = globalThis.process?.env;
|
|
102
|
+
if (action !== undefined) {
|
|
103
|
+
if (!action.context)
|
|
86
104
|
throw new IsomorphError('AUTH_REQUIRED', 'The action has no invoking-user identity.');
|
|
87
|
-
|
|
105
|
+
const headers = { 'x-harbour-identity-context': action.context };
|
|
106
|
+
if (action.trusted) {
|
|
107
|
+
if (!environment?.ISOMORPH_WORKLOAD_TOKEN)
|
|
108
|
+
throw new IsomorphError('AUTH_REQUIRED', 'The trusted action has no workload token to act with.');
|
|
109
|
+
headers.authorization = `Bearer ${environment.ISOMORPH_WORKLOAD_TOKEN}`;
|
|
110
|
+
}
|
|
111
|
+
if (action.transactionId)
|
|
112
|
+
headers['x-harbour-tx'] = action.transactionId;
|
|
113
|
+
return headers;
|
|
88
114
|
}
|
|
89
|
-
const environment = globalThis.process?.env;
|
|
90
115
|
const name = environment?.ISOMORPH_IDENTITY_CONTEXT_HEADER;
|
|
91
116
|
const token = identity === 'second-user' ? environment?.ISOMORPH_IDENTITY_CONTEXT_SECOND_USER : environment?.ISOMORPH_IDENTITY_CONTEXT;
|
|
92
117
|
const workloadToken = environment?.ISOMORPH_WORKLOAD_TOKEN;
|
|
@@ -184,9 +209,10 @@ class Transport {
|
|
|
184
209
|
}
|
|
185
210
|
async request(path, init, renewOnUnauthorized, onProgress) {
|
|
186
211
|
const sentAt = this.renewal?.renewals() ?? 0;
|
|
212
|
+
const action = currentAction();
|
|
187
213
|
// (headers with an undefined value are removed so a multipart body can
|
|
188
214
|
// clear the JSON default and let the browser set its own boundary)
|
|
189
|
-
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
|
215
|
+
const response = await this.fetch(`${action?.baseUrl ?? this.baseUrl}${path}`, {
|
|
190
216
|
credentials: 'same-origin',
|
|
191
217
|
...init,
|
|
192
218
|
headers: mergeHeaders(
|
|
@@ -195,7 +221,7 @@ class Transport {
|
|
|
195
221
|
// (OPTIONS, one more round trip, on a separate uncredentialed
|
|
196
222
|
// connection a preconnect cannot warm) before the GET itself. Without
|
|
197
223
|
// 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),
|
|
224
|
+
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
225
|
});
|
|
200
226
|
if (onProgress && response.ok && response.body && response.headers.get('content-type')?.startsWith(PROGRESS_MEDIA_TYPE)) {
|
|
201
227
|
return readProgress(response, onProgress);
|
|
@@ -367,6 +393,7 @@ export class QueryBuilder {
|
|
|
367
393
|
}
|
|
368
394
|
limit(value) { return this.next({ limit: value }); }
|
|
369
395
|
range(from, to) { return this.next({ range: { from, to } }); }
|
|
396
|
+
forUpdate() { return this.next({ lock: 'update' }); }
|
|
370
397
|
single() { return this.next({ cardinality: 'single' }); }
|
|
371
398
|
maybeSingle() { return this.next({ cardinality: 'maybeSingle' }); }
|
|
372
399
|
async execute() {
|
|
@@ -400,6 +427,10 @@ class RealtimeChannelBuilder {
|
|
|
400
427
|
}
|
|
401
428
|
}
|
|
402
429
|
}
|
|
430
|
+
/** One request on the gateway with the client's headers and refusals; for the kit runtime's own calls. */
|
|
431
|
+
export function gatewayRequest(options, path, init) {
|
|
432
|
+
return new Transport(options).json(path, init);
|
|
433
|
+
}
|
|
403
434
|
export function createClient(options = {}) {
|
|
404
435
|
const authListeners = new Set();
|
|
405
436
|
let currentUser;
|
|
@@ -734,8 +765,9 @@ export function createClient(options = {}) {
|
|
|
734
765
|
// `isomorph check` (which runs retained journeys through this client)
|
|
735
766
|
// and the deployed app fail the same way at build time, not at runtime.
|
|
736
767
|
const checked = validateIntegrationExecuteInput(request.operation, request.input);
|
|
737
|
-
const problem =
|
|
738
|
-
|
|
768
|
+
const problem = (request.operation === 'warehouse.table.append' ? warehouseAppendProblem(request) : undefined)
|
|
769
|
+
?? (!checked.ok ? checked.problem
|
|
770
|
+
: request.operation === 'warehouse.view.read' ? warehouseReadProblem(request.input ?? {}) : undefined);
|
|
739
771
|
if (problem) {
|
|
740
772
|
return Promise.reject(new IsomorphError('VALIDATION_FAILED', problem.message, undefined, { code: 'INPUT_INVALID', field: problem.field }));
|
|
741
773
|
}
|
|
@@ -63,6 +63,15 @@ export type IntegrationExecuteInputFieldRule = {
|
|
|
63
63
|
maxProperties: number;
|
|
64
64
|
types: readonly IntegrationExecuteInputRecordValueType[];
|
|
65
65
|
maxLength?: number;
|
|
66
|
+
} | {
|
|
67
|
+
kind: 'record[]';
|
|
68
|
+
required: boolean;
|
|
69
|
+
minItems: number;
|
|
70
|
+
maxItems: number;
|
|
71
|
+
minProperties: number;
|
|
72
|
+
maxProperties: number;
|
|
73
|
+
types: readonly IntegrationExecuteInputRecordValueType[];
|
|
74
|
+
maxLength?: number;
|
|
66
75
|
};
|
|
67
76
|
/** The scalar types a `value` input accepts, alone or in its list form. */
|
|
68
77
|
export type IntegrationExecuteInputValueType = 'string' | 'number' | 'boolean';
|
|
@@ -771,6 +780,23 @@ export declare const IntegrationExecuteInputRules: {
|
|
|
771
780
|
};
|
|
772
781
|
};
|
|
773
782
|
};
|
|
783
|
+
readonly "phone.sms.list": {
|
|
784
|
+
readonly idempotencyKey: false;
|
|
785
|
+
readonly fields: {
|
|
786
|
+
readonly since: {
|
|
787
|
+
readonly kind: "string";
|
|
788
|
+
readonly required: true;
|
|
789
|
+
readonly minLength: 1;
|
|
790
|
+
readonly pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})$";
|
|
791
|
+
};
|
|
792
|
+
readonly from: {
|
|
793
|
+
readonly kind: "string";
|
|
794
|
+
readonly required: false;
|
|
795
|
+
readonly minLength: 0;
|
|
796
|
+
readonly maxLength: 32;
|
|
797
|
+
};
|
|
798
|
+
};
|
|
799
|
+
};
|
|
774
800
|
readonly "warehouse.view.read": {
|
|
775
801
|
readonly idempotencyKey: false;
|
|
776
802
|
readonly fields: {
|
|
@@ -880,6 +906,21 @@ export declare const IntegrationExecuteInputRules: {
|
|
|
880
906
|
};
|
|
881
907
|
};
|
|
882
908
|
};
|
|
909
|
+
readonly "warehouse.table.append": {
|
|
910
|
+
readonly idempotencyKey: true;
|
|
911
|
+
readonly fields: {
|
|
912
|
+
readonly rows: {
|
|
913
|
+
readonly kind: "record[]";
|
|
914
|
+
readonly required: true;
|
|
915
|
+
readonly minItems: 1;
|
|
916
|
+
readonly maxItems: 500;
|
|
917
|
+
readonly minProperties: 1;
|
|
918
|
+
readonly maxProperties: 64;
|
|
919
|
+
readonly types: readonly ["string", "number", "boolean", "null"];
|
|
920
|
+
readonly maxLength: 4000;
|
|
921
|
+
};
|
|
922
|
+
};
|
|
923
|
+
};
|
|
883
924
|
readonly "service.request": {
|
|
884
925
|
readonly idempotencyKey: false;
|
|
885
926
|
readonly fields: {
|
|
@@ -241,6 +241,13 @@ export const IntegrationExecuteInputRules = {
|
|
|
241
241
|
conflict: { kind: "enum", required: false, values: ["rename", "replace", "fail"], default: "rename" },
|
|
242
242
|
},
|
|
243
243
|
},
|
|
244
|
+
"phone.sms.list": {
|
|
245
|
+
idempotencyKey: false,
|
|
246
|
+
fields: {
|
|
247
|
+
since: { kind: "string", required: true, minLength: 1, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})$" },
|
|
248
|
+
from: { kind: "string", required: false, minLength: 0, maxLength: 32 },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
244
251
|
"warehouse.view.read": {
|
|
245
252
|
idempotencyKey: false,
|
|
246
253
|
fields: {
|
|
@@ -263,6 +270,12 @@ export const IntegrationExecuteInputRules = {
|
|
|
263
270
|
limit: { kind: "integer", required: false, minimum: 1, maximum: 1000, default: 100 },
|
|
264
271
|
},
|
|
265
272
|
},
|
|
273
|
+
"warehouse.table.append": {
|
|
274
|
+
idempotencyKey: true,
|
|
275
|
+
fields: {
|
|
276
|
+
rows: { kind: "record[]", required: true, minItems: 1, maxItems: 500, minProperties: 1, maxProperties: 64, types: ["string", "number", "boolean", "null"], maxLength: 4000 },
|
|
277
|
+
},
|
|
278
|
+
},
|
|
266
279
|
"service.request": {
|
|
267
280
|
idempotencyKey: false,
|
|
268
281
|
fields: {
|
|
@@ -337,6 +350,19 @@ function checkFields(operation, prefix, fields, given, nullIsAbsent) {
|
|
|
337
350
|
}
|
|
338
351
|
return { ok: true, value: out };
|
|
339
352
|
}
|
|
353
|
+
/** One record: a bounded count of free keys, every value one of the record's scalar types. */
|
|
354
|
+
function checkRecord(path, field, value) {
|
|
355
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
356
|
+
return refuse(path, `${path} must be a JSON object`);
|
|
357
|
+
const keys = Object.keys(value);
|
|
358
|
+
if (keys.length < field.minProperties || keys.length > field.maxProperties)
|
|
359
|
+
return refuse(path, `${path} must have ${field.minProperties} to ${field.maxProperties} properties`);
|
|
360
|
+
for (const key of keys) {
|
|
361
|
+
if (!isScalar(field.types, field.maxLength, value[key]))
|
|
362
|
+
return refuse(`${path}.${key}`, `${path}.${key} must be ${either(scalarShapes(field.types, field.maxLength))}`);
|
|
363
|
+
}
|
|
364
|
+
return { ok: true, value };
|
|
365
|
+
}
|
|
340
366
|
function checkField(operation, path, name, field, value) {
|
|
341
367
|
switch (field.kind) {
|
|
342
368
|
case 'object':
|
|
@@ -347,15 +373,17 @@ function checkField(operation, path, name, field, value) {
|
|
|
347
373
|
if (typeof value !== 'boolean')
|
|
348
374
|
return refuse(path, `${path} must be a boolean`);
|
|
349
375
|
return { ok: true, value };
|
|
350
|
-
case 'record':
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
376
|
+
case 'record':
|
|
377
|
+
return checkRecord(path, field, value);
|
|
378
|
+
case 'record[]': {
|
|
379
|
+
if (!Array.isArray(value) || value.length < field.minItems || value.length > field.maxItems)
|
|
380
|
+
return refuse(path, `${path} must list ${field.minItems} to ${field.maxItems} items`);
|
|
381
|
+
for (const [index, item] of value.entries()) {
|
|
382
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
383
|
+
return refuse(path, `${path}[${index}] is not a valid ${name} entry`);
|
|
384
|
+
const entry = checkRecord(`${path}[${index}]`, field, item);
|
|
385
|
+
if (!entry.ok)
|
|
386
|
+
return entry;
|
|
359
387
|
}
|
|
360
388
|
return { ok: true, value };
|
|
361
389
|
}
|
|
@@ -217,6 +217,13 @@ export declare const IntegrationOperations: {
|
|
|
217
217
|
readonly declaredMode: false;
|
|
218
218
|
readonly idempotencyKey: true;
|
|
219
219
|
};
|
|
220
|
+
readonly "phone.sms.list": {
|
|
221
|
+
readonly kind: "saas";
|
|
222
|
+
readonly identities: readonly ["app"];
|
|
223
|
+
readonly readOnly: true;
|
|
224
|
+
readonly declaredMode: false;
|
|
225
|
+
readonly idempotencyKey: false;
|
|
226
|
+
};
|
|
220
227
|
readonly "warehouse.view.read": {
|
|
221
228
|
readonly kind: "database";
|
|
222
229
|
readonly identities: readonly ["app"];
|
|
@@ -224,6 +231,13 @@ export declare const IntegrationOperations: {
|
|
|
224
231
|
readonly declaredMode: false;
|
|
225
232
|
readonly idempotencyKey: false;
|
|
226
233
|
};
|
|
234
|
+
readonly "warehouse.table.append": {
|
|
235
|
+
readonly kind: "database";
|
|
236
|
+
readonly identities: readonly ["app"];
|
|
237
|
+
readonly readOnly: false;
|
|
238
|
+
readonly declaredMode: false;
|
|
239
|
+
readonly idempotencyKey: true;
|
|
240
|
+
};
|
|
227
241
|
readonly "service.request": {
|
|
228
242
|
readonly kind: "service";
|
|
229
243
|
readonly identities: readonly ["app"];
|
|
@@ -285,6 +285,15 @@ export const IntegrationOperations = {
|
|
|
285
285
|
"declaredMode": false,
|
|
286
286
|
"idempotencyKey": true
|
|
287
287
|
},
|
|
288
|
+
"phone.sms.list": {
|
|
289
|
+
"kind": "saas",
|
|
290
|
+
"identities": [
|
|
291
|
+
"app"
|
|
292
|
+
],
|
|
293
|
+
"readOnly": true,
|
|
294
|
+
"declaredMode": false,
|
|
295
|
+
"idempotencyKey": false
|
|
296
|
+
},
|
|
288
297
|
"warehouse.view.read": {
|
|
289
298
|
"kind": "database",
|
|
290
299
|
"identities": [
|
|
@@ -294,6 +303,15 @@ export const IntegrationOperations = {
|
|
|
294
303
|
"declaredMode": false,
|
|
295
304
|
"idempotencyKey": false
|
|
296
305
|
},
|
|
306
|
+
"warehouse.table.append": {
|
|
307
|
+
"kind": "database",
|
|
308
|
+
"identities": [
|
|
309
|
+
"app"
|
|
310
|
+
],
|
|
311
|
+
"readOnly": false,
|
|
312
|
+
"declaredMode": false,
|
|
313
|
+
"idempotencyKey": true
|
|
314
|
+
},
|
|
297
315
|
"service.request": {
|
|
298
316
|
"kind": "service",
|
|
299
317
|
"identities": [
|
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 =
|
|
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
|
|
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
|
-
|
|
550
|
-
|
|
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. */
|