@isomorph.ai/app-sdk 1.15.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 +70 -1
- package/dist/index.d.ts +188 -2
- package/dist/index.js +26 -9
- package/dist/integration-execute-input-rules.d.ts +202 -0
- package/dist/integration-execute-input-rules.js +88 -2
- package/dist/integration-operations.d.ts +63 -0
- package/dist/integration-operations.js +81 -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
|
@@ -168,7 +168,76 @@ await isomorph.integrations.execute('company-basecamp', {
|
|
|
168
168
|
});
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
SharePoint runs as the signed-in person after their consent. Every operation
|
|
172
|
+
names a site alias IT mapped to one site of the company's Microsoft 365 tenant.
|
|
173
|
+
Ids are opaque Microsoft Graph ids; a list item's `fields` are its columns by
|
|
174
|
+
internal name; adding an item or uploading a file is an explicit action with a
|
|
175
|
+
fresh UUID, while an update is a PATCH and needs none.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
// The site's lists, then one list's open items
|
|
179
|
+
const { lists } = await isomorph.integrations.execute('company-sharepoint', {
|
|
180
|
+
operation: 'sharepoint.list.list', resource: 'intranet', input: {},
|
|
181
|
+
});
|
|
182
|
+
const open = await isomorph.integrations.execute('company-sharepoint', {
|
|
183
|
+
operation: 'sharepoint.item.list', resource: 'intranet',
|
|
184
|
+
input: { listId: lists[0].id, filter: "fields/Status eq 'Open'" },
|
|
185
|
+
});
|
|
186
|
+
// open.nextPageToken, when hasMore, is the next read's pageToken
|
|
187
|
+
|
|
188
|
+
// A new item, then one of its columns changed
|
|
189
|
+
const item = await isomorph.integrations.execute('company-sharepoint', {
|
|
190
|
+
operation: 'sharepoint.item.create', resource: 'intranet',
|
|
191
|
+
input: { listId: lists[0].id, fields: { Title: 'Confirm the venue', Status: 'Open', DueDate: '2026-10-20' } },
|
|
192
|
+
idempotencyKey: crypto.randomUUID(),
|
|
193
|
+
});
|
|
194
|
+
await isomorph.integrations.execute('company-sharepoint', {
|
|
195
|
+
operation: 'sharepoint.item.update', resource: 'intranet',
|
|
196
|
+
input: { listId: lists[0].id, itemId: item.id, fields: { Status: 'Done' } },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The default library's root, a search, one file's bytes, and an upload into a folder
|
|
200
|
+
const { files } = await isomorph.integrations.execute('company-sharepoint', {
|
|
201
|
+
operation: 'sharepoint.file.list', resource: 'intranet', input: {},
|
|
202
|
+
});
|
|
203
|
+
const found = await isomorph.integrations.execute('company-sharepoint', {
|
|
204
|
+
operation: 'sharepoint.file.search', resource: 'intranet', input: { q: 'venue contract' },
|
|
205
|
+
});
|
|
206
|
+
const file = await isomorph.integrations.execute('company-sharepoint', {
|
|
207
|
+
operation: 'sharepoint.file.read', resource: 'intranet', input: { fileId: found.files[0].id },
|
|
208
|
+
});
|
|
209
|
+
await isomorph.integrations.execute('company-sharepoint', {
|
|
210
|
+
operation: 'sharepoint.file.upload', resource: 'intranet',
|
|
211
|
+
input: { filename: 'venue-contract-signed.pdf', contentType: 'application/pdf', dataBase64: file.dataBase64, folderId: files.find((entry) => entry.isFolder)?.id, conflict: 'rename' },
|
|
212
|
+
idempotencyKey: crypto.randomUUID(),
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
(1.16.0; contracts 0.78.0)
|
|
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)
|
|
172
241
|
|
|
173
242
|
## Browser read tools
|
|
174
243
|
|
package/dist/index.d.ts
CHANGED
|
@@ -373,6 +373,113 @@ export interface BasecampPeopleListRequest {
|
|
|
373
373
|
pageToken?: string;
|
|
374
374
|
};
|
|
375
375
|
}
|
|
376
|
+
/** What `sharepoint.file.upload` does when the folder already has a file of that name: `rename` (the default), `replace` or `fail`. */
|
|
377
|
+
export type SharePointConflictBehavior = 'rename' | 'replace' | 'fail';
|
|
378
|
+
/** One column value as a SharePoint write takes it: a string of at most 4000 characters, a number, a boolean, or null (which clears the column on an update). */
|
|
379
|
+
export type SharePointFieldValue = string | number | boolean | null;
|
|
380
|
+
/** Lists the site's lists the signed-in person can see (hidden lists dropped); resource is a site alias. */
|
|
381
|
+
export interface SharePointListListRequest {
|
|
382
|
+
operation: 'sharepoint.list.list';
|
|
383
|
+
resource: string;
|
|
384
|
+
input?: {
|
|
385
|
+
/** The `nextPageToken` of the previous page; leave it out to read the first page. */
|
|
386
|
+
pageToken?: string;
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/** Lists one list's items, 50 a page, filtered when `filter` says so. */
|
|
390
|
+
export interface SharePointItemListRequest {
|
|
391
|
+
operation: 'sharepoint.item.list';
|
|
392
|
+
resource: string;
|
|
393
|
+
input: {
|
|
394
|
+
/** The `id` a `sharepoint.list.list` result named. */
|
|
395
|
+
listId: string;
|
|
396
|
+
/** An OData `$filter` over the columns as `fields/<column> <op> <value>`, e.g. `fields/Status eq 'Open'`; 1 to 512 characters. */
|
|
397
|
+
filter?: string;
|
|
398
|
+
pageToken?: string;
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/** Adds one item to a list as the signed-in person; an explicit action with its own idempotency key. */
|
|
402
|
+
export interface SharePointItemCreateRequest {
|
|
403
|
+
operation: 'sharepoint.item.create';
|
|
404
|
+
resource: string;
|
|
405
|
+
input: {
|
|
406
|
+
listId: string;
|
|
407
|
+
/** 1 to 32 column values keyed by the column's internal name (`Title`, `Status`, `DueDate`...); which columns the list has is Graph's check. */
|
|
408
|
+
fields: Record<string, SharePointFieldValue>;
|
|
409
|
+
};
|
|
410
|
+
/** A fresh UUID per authorized action, reused only to retry that action. */
|
|
411
|
+
idempotencyKey: string;
|
|
412
|
+
}
|
|
413
|
+
/** Changes the named columns of one item; a PATCH, the same outcome however often it runs, so no idempotency key. */
|
|
414
|
+
export interface SharePointItemUpdateRequest {
|
|
415
|
+
operation: 'sharepoint.item.update';
|
|
416
|
+
resource: string;
|
|
417
|
+
input: {
|
|
418
|
+
listId: string;
|
|
419
|
+
/** The `id` a `sharepoint.item.list` or `sharepoint.item.create` result named. */
|
|
420
|
+
itemId: string;
|
|
421
|
+
/** 1 to 32 column values; null clears a column. */
|
|
422
|
+
fields: Record<string, SharePointFieldValue>;
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** Lists the site's document libraries; their `id` is what the file operations take as `driveId`. */
|
|
426
|
+
export interface SharePointDriveListRequest {
|
|
427
|
+
operation: 'sharepoint.drive.list';
|
|
428
|
+
resource: string;
|
|
429
|
+
input?: {
|
|
430
|
+
pageToken?: string;
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
/** Lists one folder's entries, files and folders alike: the site's default library and its root when `driveId` and `folderId` are absent. */
|
|
434
|
+
export interface SharePointFileListRequest {
|
|
435
|
+
operation: 'sharepoint.file.list';
|
|
436
|
+
resource: string;
|
|
437
|
+
input?: {
|
|
438
|
+
driveId?: string;
|
|
439
|
+
/** The `id` of an entry with `isFolder` from an earlier listing. */
|
|
440
|
+
folderId?: string;
|
|
441
|
+
pageToken?: string;
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/** Searches one library's file names and contents. */
|
|
445
|
+
export interface SharePointFileSearchRequest {
|
|
446
|
+
operation: 'sharepoint.file.search';
|
|
447
|
+
resource: string;
|
|
448
|
+
input: {
|
|
449
|
+
/** The words to search for, 1 to 256 characters. */
|
|
450
|
+
q: string;
|
|
451
|
+
driveId?: string;
|
|
452
|
+
pageToken?: string;
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** Reads one file's bytes (at most 2 MiB) as the signed-in person; `fileId` comes from a listing, a search or an upload. */
|
|
456
|
+
export interface SharePointFileReadRequest {
|
|
457
|
+
operation: 'sharepoint.file.read';
|
|
458
|
+
resource: string;
|
|
459
|
+
input: {
|
|
460
|
+
fileId: string;
|
|
461
|
+
driveId?: string;
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** Uploads one file (at most 2 MiB) as the signed-in person; an explicit action with its own idempotency key. */
|
|
465
|
+
export interface SharePointFileUploadRequest {
|
|
466
|
+
operation: 'sharepoint.file.upload';
|
|
467
|
+
resource: string;
|
|
468
|
+
input: {
|
|
469
|
+
/** The name to store it under, 1 to 255 characters; the result's `filename` is the stored name, which `conflict: 'rename'` may have changed. */
|
|
470
|
+
filename: string;
|
|
471
|
+
/** The file's MIME type, e.g. `application/pdf`. */
|
|
472
|
+
contentType: string;
|
|
473
|
+
/** The bytes as standard base64: at most 2 MiB of bytes (2,796,203 base64 characters). */
|
|
474
|
+
dataBase64: string;
|
|
475
|
+
driveId?: string;
|
|
476
|
+
folderId?: string;
|
|
477
|
+
/** `rename` when absent. */
|
|
478
|
+
conflict?: SharePointConflictBehavior;
|
|
479
|
+
};
|
|
480
|
+
/** A fresh UUID per authorized action, reused only to retry that action. */
|
|
481
|
+
idempotencyKey: string;
|
|
482
|
+
}
|
|
376
483
|
export interface ServiceRequest {
|
|
377
484
|
operation: 'service.request';
|
|
378
485
|
resource?: never;
|
|
@@ -390,7 +497,7 @@ export interface ServiceRequestResult {
|
|
|
390
497
|
headers: Record<string, string>;
|
|
391
498
|
body: unknown;
|
|
392
499
|
}
|
|
393
|
-
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;
|
|
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;
|
|
394
501
|
export type IntegrationOperation = IntegrationRequest['operation'];
|
|
395
502
|
export interface SlackMessage {
|
|
396
503
|
id: string;
|
|
@@ -578,7 +685,75 @@ export interface BasecampTodoCompleteResult {
|
|
|
578
685
|
id: string;
|
|
579
686
|
completed: true;
|
|
580
687
|
}
|
|
581
|
-
|
|
688
|
+
/** One list of the site; `id` is the `listId` the item operations take. `template` is what Graph reports (genericList, documentLibrary, tasks, events...) when it does. */
|
|
689
|
+
export interface SharePointList {
|
|
690
|
+
id: string;
|
|
691
|
+
name: string;
|
|
692
|
+
description?: string;
|
|
693
|
+
webUrl: string;
|
|
694
|
+
template?: string;
|
|
695
|
+
}
|
|
696
|
+
/** One list item: `fields` are its column values keyed by internal column name as Graph returns them, system columns dropped and the whole object cut at 16 KiB; `id` is the `itemId` an update takes. */
|
|
697
|
+
export interface SharePointListItem {
|
|
698
|
+
id: string;
|
|
699
|
+
fields: Record<string, unknown>;
|
|
700
|
+
webUrl: string;
|
|
701
|
+
createdAt: string;
|
|
702
|
+
modifiedAt: string;
|
|
703
|
+
}
|
|
704
|
+
/** One document library; `id` is the `driveId` the file operations take. */
|
|
705
|
+
export interface SharePointDrive {
|
|
706
|
+
id: string;
|
|
707
|
+
name: string;
|
|
708
|
+
webUrl: string;
|
|
709
|
+
}
|
|
710
|
+
/** One entry of a folder or a search: a file (`mimeType` set; `id` is a `fileId`) or a folder (`isFolder`; `id` is a `folderId`). `size` is in bytes, a folder's the total of its contents. */
|
|
711
|
+
export interface SharePointFile {
|
|
712
|
+
id: string;
|
|
713
|
+
name: string;
|
|
714
|
+
isFolder: boolean;
|
|
715
|
+
size: number;
|
|
716
|
+
mimeType?: string;
|
|
717
|
+
webUrl: string;
|
|
718
|
+
modifiedAt: string;
|
|
719
|
+
}
|
|
720
|
+
/** One page of a SharePoint list; `nextPageToken` is present only when `hasMore` and is the next read's `pageToken`. */
|
|
721
|
+
export interface SharePointPage {
|
|
722
|
+
hasMore: boolean;
|
|
723
|
+
nextPageToken?: string;
|
|
724
|
+
}
|
|
725
|
+
export interface SharePointListListResult extends SharePointPage {
|
|
726
|
+
lists: SharePointList[];
|
|
727
|
+
}
|
|
728
|
+
export interface SharePointItemListResult extends SharePointPage {
|
|
729
|
+
items: SharePointListItem[];
|
|
730
|
+
}
|
|
731
|
+
export interface SharePointDriveListResult extends SharePointPage {
|
|
732
|
+
drives: SharePointDrive[];
|
|
733
|
+
}
|
|
734
|
+
/** `sharepoint.file.list` and `sharepoint.file.search` answer the same page of entries. */
|
|
735
|
+
export interface SharePointFileListResult extends SharePointPage {
|
|
736
|
+
files: SharePointFile[];
|
|
737
|
+
}
|
|
738
|
+
/** The new item and its SharePoint page, for a person to open. */
|
|
739
|
+
export interface SharePointItemCreateResult {
|
|
740
|
+
id: string;
|
|
741
|
+
webUrl: string;
|
|
742
|
+
}
|
|
743
|
+
export interface SharePointItemUpdateResult {
|
|
744
|
+
id: string;
|
|
745
|
+
}
|
|
746
|
+
/** The Gmail attachment shape plus the file's id. */
|
|
747
|
+
export interface SharePointFileReadResult extends GmailAttachmentReadResult {
|
|
748
|
+
id: string;
|
|
749
|
+
}
|
|
750
|
+
/** The stored file, under the name `conflict: 'rename'` may have given it, and its SharePoint page. */
|
|
751
|
+
export interface SharePointFileUploadResult {
|
|
752
|
+
id: string;
|
|
753
|
+
filename: string;
|
|
754
|
+
webUrl: string;
|
|
755
|
+
}
|
|
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;
|
|
582
757
|
export type IntegrationConnectResult = {
|
|
583
758
|
status: 'connected';
|
|
584
759
|
accountLabel: string;
|
|
@@ -692,6 +867,14 @@ interface Query {
|
|
|
692
867
|
count?: 'exact';
|
|
693
868
|
head?: boolean;
|
|
694
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;
|
|
695
878
|
}
|
|
696
879
|
interface IdentityRenewal {
|
|
697
880
|
renewals(): number;
|
|
@@ -742,6 +925,7 @@ export declare class QueryBuilder<T = Record<string, unknown>, Result = T[]> imp
|
|
|
742
925
|
}): QueryBuilder<T, Result>;
|
|
743
926
|
limit(value: number): QueryBuilder<T, Result>;
|
|
744
927
|
range(from: number, to: number): QueryBuilder<T, Result>;
|
|
928
|
+
forUpdate(): QueryBuilder<T, Result>;
|
|
745
929
|
single(): QueryBuilder<T, T>;
|
|
746
930
|
maybeSingle(): QueryBuilder<T, T | null>;
|
|
747
931
|
execute(): Promise<IsomorphResult<Result>>;
|
|
@@ -762,6 +946,8 @@ declare class RealtimeChannelBuilder {
|
|
|
762
946
|
on(event: 'postgres_changes' | 'presence' | 'broadcast', filter: unknown, handler: RealtimeHandler): this;
|
|
763
947
|
subscribe(callback?: (status: RealtimeStatus) => void): RealtimeSubscription;
|
|
764
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>;
|
|
765
951
|
export declare function createClient(options?: ClientOptions): {
|
|
766
952
|
identity: {
|
|
767
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -56,9 +56,18 @@ export type IntegrationExecuteInputFieldRule = {
|
|
|
56
56
|
} | {
|
|
57
57
|
kind: 'boolean';
|
|
58
58
|
required: boolean;
|
|
59
|
+
} | {
|
|
60
|
+
kind: 'record';
|
|
61
|
+
required: boolean;
|
|
62
|
+
minProperties: number;
|
|
63
|
+
maxProperties: number;
|
|
64
|
+
types: readonly IntegrationExecuteInputRecordValueType[];
|
|
65
|
+
maxLength?: number;
|
|
59
66
|
};
|
|
60
67
|
/** The scalar types a `value` input accepts, alone or in its list form. */
|
|
61
68
|
export type IntegrationExecuteInputValueType = 'string' | 'number' | 'boolean';
|
|
69
|
+
/** The value types a `record` input accepts under each of its keys (0.78.0): a value type or null. */
|
|
70
|
+
export type IntegrationExecuteInputRecordValueType = IntegrationExecuteInputValueType | 'null';
|
|
62
71
|
export type IntegrationExecuteOperationRule = {
|
|
63
72
|
idempotencyKey: boolean;
|
|
64
73
|
fields: Record<string, IntegrationExecuteInputFieldRule>;
|
|
@@ -569,6 +578,199 @@ export declare const IntegrationExecuteInputRules: {
|
|
|
569
578
|
};
|
|
570
579
|
};
|
|
571
580
|
};
|
|
581
|
+
readonly "sharepoint.list.list": {
|
|
582
|
+
readonly idempotencyKey: false;
|
|
583
|
+
readonly fields: {
|
|
584
|
+
readonly pageToken: {
|
|
585
|
+
readonly kind: "string";
|
|
586
|
+
readonly required: false;
|
|
587
|
+
readonly minLength: 1;
|
|
588
|
+
readonly maxLength: 2048;
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
};
|
|
592
|
+
readonly "sharepoint.item.list": {
|
|
593
|
+
readonly idempotencyKey: false;
|
|
594
|
+
readonly fields: {
|
|
595
|
+
readonly listId: {
|
|
596
|
+
readonly kind: "string";
|
|
597
|
+
readonly required: true;
|
|
598
|
+
readonly minLength: 0;
|
|
599
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
600
|
+
};
|
|
601
|
+
readonly filter: {
|
|
602
|
+
readonly kind: "string";
|
|
603
|
+
readonly required: false;
|
|
604
|
+
readonly minLength: 1;
|
|
605
|
+
readonly maxLength: 512;
|
|
606
|
+
};
|
|
607
|
+
readonly pageToken: {
|
|
608
|
+
readonly kind: "string";
|
|
609
|
+
readonly required: false;
|
|
610
|
+
readonly minLength: 1;
|
|
611
|
+
readonly maxLength: 2048;
|
|
612
|
+
};
|
|
613
|
+
};
|
|
614
|
+
};
|
|
615
|
+
readonly "sharepoint.item.create": {
|
|
616
|
+
readonly idempotencyKey: true;
|
|
617
|
+
readonly fields: {
|
|
618
|
+
readonly listId: {
|
|
619
|
+
readonly kind: "string";
|
|
620
|
+
readonly required: true;
|
|
621
|
+
readonly minLength: 0;
|
|
622
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
623
|
+
};
|
|
624
|
+
readonly fields: {
|
|
625
|
+
readonly kind: "record";
|
|
626
|
+
readonly required: true;
|
|
627
|
+
readonly minProperties: 1;
|
|
628
|
+
readonly maxProperties: 32;
|
|
629
|
+
readonly types: readonly ["string", "number", "boolean", "null"];
|
|
630
|
+
readonly maxLength: 4000;
|
|
631
|
+
};
|
|
632
|
+
};
|
|
633
|
+
};
|
|
634
|
+
readonly "sharepoint.item.update": {
|
|
635
|
+
readonly idempotencyKey: false;
|
|
636
|
+
readonly fields: {
|
|
637
|
+
readonly listId: {
|
|
638
|
+
readonly kind: "string";
|
|
639
|
+
readonly required: true;
|
|
640
|
+
readonly minLength: 0;
|
|
641
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
642
|
+
};
|
|
643
|
+
readonly itemId: {
|
|
644
|
+
readonly kind: "string";
|
|
645
|
+
readonly required: true;
|
|
646
|
+
readonly minLength: 0;
|
|
647
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
648
|
+
};
|
|
649
|
+
readonly fields: {
|
|
650
|
+
readonly kind: "record";
|
|
651
|
+
readonly required: true;
|
|
652
|
+
readonly minProperties: 1;
|
|
653
|
+
readonly maxProperties: 32;
|
|
654
|
+
readonly types: readonly ["string", "number", "boolean", "null"];
|
|
655
|
+
readonly maxLength: 4000;
|
|
656
|
+
};
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
readonly "sharepoint.drive.list": {
|
|
660
|
+
readonly idempotencyKey: false;
|
|
661
|
+
readonly fields: {
|
|
662
|
+
readonly pageToken: {
|
|
663
|
+
readonly kind: "string";
|
|
664
|
+
readonly required: false;
|
|
665
|
+
readonly minLength: 1;
|
|
666
|
+
readonly maxLength: 2048;
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
};
|
|
670
|
+
readonly "sharepoint.file.list": {
|
|
671
|
+
readonly idempotencyKey: false;
|
|
672
|
+
readonly fields: {
|
|
673
|
+
readonly driveId: {
|
|
674
|
+
readonly kind: "string";
|
|
675
|
+
readonly required: false;
|
|
676
|
+
readonly minLength: 0;
|
|
677
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
678
|
+
};
|
|
679
|
+
readonly folderId: {
|
|
680
|
+
readonly kind: "string";
|
|
681
|
+
readonly required: false;
|
|
682
|
+
readonly minLength: 0;
|
|
683
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
684
|
+
};
|
|
685
|
+
readonly pageToken: {
|
|
686
|
+
readonly kind: "string";
|
|
687
|
+
readonly required: false;
|
|
688
|
+
readonly minLength: 1;
|
|
689
|
+
readonly maxLength: 2048;
|
|
690
|
+
};
|
|
691
|
+
};
|
|
692
|
+
};
|
|
693
|
+
readonly "sharepoint.file.search": {
|
|
694
|
+
readonly idempotencyKey: false;
|
|
695
|
+
readonly fields: {
|
|
696
|
+
readonly q: {
|
|
697
|
+
readonly kind: "string";
|
|
698
|
+
readonly required: true;
|
|
699
|
+
readonly minLength: 1;
|
|
700
|
+
readonly maxLength: 256;
|
|
701
|
+
};
|
|
702
|
+
readonly driveId: {
|
|
703
|
+
readonly kind: "string";
|
|
704
|
+
readonly required: false;
|
|
705
|
+
readonly minLength: 0;
|
|
706
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
707
|
+
};
|
|
708
|
+
readonly pageToken: {
|
|
709
|
+
readonly kind: "string";
|
|
710
|
+
readonly required: false;
|
|
711
|
+
readonly minLength: 1;
|
|
712
|
+
readonly maxLength: 2048;
|
|
713
|
+
};
|
|
714
|
+
};
|
|
715
|
+
};
|
|
716
|
+
readonly "sharepoint.file.read": {
|
|
717
|
+
readonly idempotencyKey: false;
|
|
718
|
+
readonly fields: {
|
|
719
|
+
readonly fileId: {
|
|
720
|
+
readonly kind: "string";
|
|
721
|
+
readonly required: true;
|
|
722
|
+
readonly minLength: 0;
|
|
723
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
724
|
+
};
|
|
725
|
+
readonly driveId: {
|
|
726
|
+
readonly kind: "string";
|
|
727
|
+
readonly required: false;
|
|
728
|
+
readonly minLength: 0;
|
|
729
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
730
|
+
};
|
|
731
|
+
};
|
|
732
|
+
};
|
|
733
|
+
readonly "sharepoint.file.upload": {
|
|
734
|
+
readonly idempotencyKey: true;
|
|
735
|
+
readonly fields: {
|
|
736
|
+
readonly filename: {
|
|
737
|
+
readonly kind: "string";
|
|
738
|
+
readonly required: true;
|
|
739
|
+
readonly minLength: 1;
|
|
740
|
+
readonly maxLength: 255;
|
|
741
|
+
};
|
|
742
|
+
readonly contentType: {
|
|
743
|
+
readonly kind: "string";
|
|
744
|
+
readonly required: true;
|
|
745
|
+
readonly minLength: 1;
|
|
746
|
+
readonly maxLength: 128;
|
|
747
|
+
};
|
|
748
|
+
readonly dataBase64: {
|
|
749
|
+
readonly kind: "string";
|
|
750
|
+
readonly required: true;
|
|
751
|
+
readonly minLength: 1;
|
|
752
|
+
readonly maxLength: 2796203;
|
|
753
|
+
};
|
|
754
|
+
readonly driveId: {
|
|
755
|
+
readonly kind: "string";
|
|
756
|
+
readonly required: false;
|
|
757
|
+
readonly minLength: 0;
|
|
758
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
759
|
+
};
|
|
760
|
+
readonly folderId: {
|
|
761
|
+
readonly kind: "string";
|
|
762
|
+
readonly required: false;
|
|
763
|
+
readonly minLength: 0;
|
|
764
|
+
readonly pattern: "^[A-Za-z0-9!._,=-]{1,256}$";
|
|
765
|
+
};
|
|
766
|
+
readonly conflict: {
|
|
767
|
+
readonly kind: "enum";
|
|
768
|
+
readonly required: false;
|
|
769
|
+
readonly values: readonly ["rename", "replace", "fail"];
|
|
770
|
+
readonly default: "rename";
|
|
771
|
+
};
|
|
772
|
+
};
|
|
773
|
+
};
|
|
572
774
|
readonly "warehouse.view.read": {
|
|
573
775
|
readonly idempotencyKey: false;
|
|
574
776
|
readonly fields: {
|
|
@@ -172,6 +172,75 @@ export const IntegrationExecuteInputRules = {
|
|
|
172
172
|
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 256 },
|
|
173
173
|
},
|
|
174
174
|
},
|
|
175
|
+
"sharepoint.list.list": {
|
|
176
|
+
idempotencyKey: false,
|
|
177
|
+
fields: {
|
|
178
|
+
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"sharepoint.item.list": {
|
|
182
|
+
idempotencyKey: false,
|
|
183
|
+
fields: {
|
|
184
|
+
listId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
185
|
+
filter: { kind: "string", required: false, minLength: 1, maxLength: 512 },
|
|
186
|
+
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"sharepoint.item.create": {
|
|
190
|
+
idempotencyKey: true,
|
|
191
|
+
fields: {
|
|
192
|
+
listId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
193
|
+
fields: { kind: "record", required: true, minProperties: 1, maxProperties: 32, types: ["string", "number", "boolean", "null"], maxLength: 4000 },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
"sharepoint.item.update": {
|
|
197
|
+
idempotencyKey: false,
|
|
198
|
+
fields: {
|
|
199
|
+
listId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
200
|
+
itemId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
201
|
+
fields: { kind: "record", required: true, minProperties: 1, maxProperties: 32, types: ["string", "number", "boolean", "null"], maxLength: 4000 },
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
"sharepoint.drive.list": {
|
|
205
|
+
idempotencyKey: false,
|
|
206
|
+
fields: {
|
|
207
|
+
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
"sharepoint.file.list": {
|
|
211
|
+
idempotencyKey: false,
|
|
212
|
+
fields: {
|
|
213
|
+
driveId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
214
|
+
folderId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
215
|
+
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
"sharepoint.file.search": {
|
|
219
|
+
idempotencyKey: false,
|
|
220
|
+
fields: {
|
|
221
|
+
q: { kind: "string", required: true, minLength: 1, maxLength: 256 },
|
|
222
|
+
driveId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
223
|
+
pageToken: { kind: "string", required: false, minLength: 1, maxLength: 2048 },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
"sharepoint.file.read": {
|
|
227
|
+
idempotencyKey: false,
|
|
228
|
+
fields: {
|
|
229
|
+
fileId: { kind: "string", required: true, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
230
|
+
driveId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
"sharepoint.file.upload": {
|
|
234
|
+
idempotencyKey: true,
|
|
235
|
+
fields: {
|
|
236
|
+
filename: { kind: "string", required: true, minLength: 1, maxLength: 255 },
|
|
237
|
+
contentType: { kind: "string", required: true, minLength: 1, maxLength: 128 },
|
|
238
|
+
dataBase64: { kind: "string", required: true, minLength: 1, maxLength: 2796203 },
|
|
239
|
+
driveId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
240
|
+
folderId: { kind: "string", required: false, minLength: 0, pattern: "^[A-Za-z0-9!._,=-]{1,256}$" },
|
|
241
|
+
conflict: { kind: "enum", required: false, values: ["rename", "replace", "fail"], default: "rename" },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
175
244
|
"warehouse.view.read": {
|
|
176
245
|
idempotencyKey: false,
|
|
177
246
|
fields: {
|
|
@@ -223,17 +292,22 @@ function matches(pattern, value) {
|
|
|
223
292
|
return compiled.test(value);
|
|
224
293
|
}
|
|
225
294
|
function refuse(field, message) { return { ok: false, problem: { field, message } }; }
|
|
226
|
-
const valueNouns = { string: ['a string', 'strings'], number: ['a number', 'numbers'], boolean: ['a boolean', 'booleans'] };
|
|
295
|
+
const valueNouns = { string: ['a string', 'strings'], number: ['a number', 'numbers'], boolean: ['a boolean', 'booleans'], null: ['null', 'nulls'] };
|
|
227
296
|
function either(parts) {
|
|
228
297
|
return parts.length > 1 ? `${parts.slice(0, -1).join(', ')} or ${parts[parts.length - 1]}` : parts.join('');
|
|
229
298
|
}
|
|
230
299
|
function isScalar(types, maxLength, value) {
|
|
300
|
+
if (value === null)
|
|
301
|
+
return types.includes('null');
|
|
231
302
|
if (typeof value === 'string')
|
|
232
303
|
return types.includes('string') && (maxLength === undefined || value.length <= maxLength);
|
|
233
304
|
if (typeof value === 'number')
|
|
234
305
|
return types.includes('number') && Number.isFinite(value);
|
|
235
306
|
return typeof value === 'boolean' && types.includes('boolean');
|
|
236
307
|
}
|
|
308
|
+
function scalarShapes(types, maxLength) {
|
|
309
|
+
return types.map(type => (type === 'string' && maxLength !== undefined ? `a string of at most ${maxLength} characters` : valueNouns[type][0]));
|
|
310
|
+
}
|
|
237
311
|
/**
|
|
238
312
|
* Checks one object's fields in rule order and applies defaults: the input
|
|
239
313
|
* itself (where null counts as absent), or one entry of an object[] input
|
|
@@ -273,6 +347,18 @@ function checkField(operation, path, name, field, value) {
|
|
|
273
347
|
if (typeof value !== 'boolean')
|
|
274
348
|
return refuse(path, `${path} must be a boolean`);
|
|
275
349
|
return { ok: true, value };
|
|
350
|
+
case 'record': {
|
|
351
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
352
|
+
return refuse(path, `${path} must be a JSON object`);
|
|
353
|
+
const keys = Object.keys(value);
|
|
354
|
+
if (keys.length < field.minProperties || keys.length > field.maxProperties)
|
|
355
|
+
return refuse(path, `${path} must have ${field.minProperties} to ${field.maxProperties} properties`);
|
|
356
|
+
for (const key of keys) {
|
|
357
|
+
if (!isScalar(field.types, field.maxLength, value[key]))
|
|
358
|
+
return refuse(`${path}.${key}`, `${path}.${key} must be ${either(scalarShapes(field.types, field.maxLength))}`);
|
|
359
|
+
}
|
|
360
|
+
return { ok: true, value };
|
|
361
|
+
}
|
|
276
362
|
case 'integer':
|
|
277
363
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < field.minimum || value > field.maximum)
|
|
278
364
|
return refuse(path, `${path} must be an integer between ${field.minimum} and ${field.maximum}`);
|
|
@@ -318,7 +404,7 @@ function checkField(operation, path, name, field, value) {
|
|
|
318
404
|
const { types, maxLength, list } = field;
|
|
319
405
|
if (isScalar(types, maxLength, value) || (list !== undefined && Array.isArray(value) && value.length >= list.minItems && value.length <= list.maxItems && value.every(item => isScalar(list.types, maxLength, item))))
|
|
320
406
|
return { ok: true, value };
|
|
321
|
-
const shapes = types
|
|
407
|
+
const shapes = scalarShapes(types, maxLength);
|
|
322
408
|
if (list !== undefined)
|
|
323
409
|
shapes.push(`a list of ${list.minItems} to ${list.maxItems} ${either(list.types.map(type => valueNouns[type][1]))}`);
|
|
324
410
|
return refuse(path, `${path} must be ${either(shapes)}`);
|
|
@@ -154,6 +154,69 @@ export declare const IntegrationOperations: {
|
|
|
154
154
|
readonly declaredMode: false;
|
|
155
155
|
readonly idempotencyKey: false;
|
|
156
156
|
};
|
|
157
|
+
readonly "sharepoint.list.list": {
|
|
158
|
+
readonly kind: "saas";
|
|
159
|
+
readonly identities: readonly ["user"];
|
|
160
|
+
readonly readOnly: true;
|
|
161
|
+
readonly declaredMode: false;
|
|
162
|
+
readonly idempotencyKey: false;
|
|
163
|
+
};
|
|
164
|
+
readonly "sharepoint.item.list": {
|
|
165
|
+
readonly kind: "saas";
|
|
166
|
+
readonly identities: readonly ["user"];
|
|
167
|
+
readonly readOnly: true;
|
|
168
|
+
readonly declaredMode: false;
|
|
169
|
+
readonly idempotencyKey: false;
|
|
170
|
+
};
|
|
171
|
+
readonly "sharepoint.item.create": {
|
|
172
|
+
readonly kind: "saas";
|
|
173
|
+
readonly identities: readonly ["user"];
|
|
174
|
+
readonly readOnly: false;
|
|
175
|
+
readonly declaredMode: false;
|
|
176
|
+
readonly idempotencyKey: true;
|
|
177
|
+
};
|
|
178
|
+
readonly "sharepoint.item.update": {
|
|
179
|
+
readonly kind: "saas";
|
|
180
|
+
readonly identities: readonly ["user"];
|
|
181
|
+
readonly readOnly: false;
|
|
182
|
+
readonly declaredMode: false;
|
|
183
|
+
readonly idempotencyKey: false;
|
|
184
|
+
};
|
|
185
|
+
readonly "sharepoint.drive.list": {
|
|
186
|
+
readonly kind: "saas";
|
|
187
|
+
readonly identities: readonly ["user"];
|
|
188
|
+
readonly readOnly: true;
|
|
189
|
+
readonly declaredMode: false;
|
|
190
|
+
readonly idempotencyKey: false;
|
|
191
|
+
};
|
|
192
|
+
readonly "sharepoint.file.list": {
|
|
193
|
+
readonly kind: "saas";
|
|
194
|
+
readonly identities: readonly ["user"];
|
|
195
|
+
readonly readOnly: true;
|
|
196
|
+
readonly declaredMode: false;
|
|
197
|
+
readonly idempotencyKey: false;
|
|
198
|
+
};
|
|
199
|
+
readonly "sharepoint.file.search": {
|
|
200
|
+
readonly kind: "saas";
|
|
201
|
+
readonly identities: readonly ["user"];
|
|
202
|
+
readonly readOnly: true;
|
|
203
|
+
readonly declaredMode: false;
|
|
204
|
+
readonly idempotencyKey: false;
|
|
205
|
+
};
|
|
206
|
+
readonly "sharepoint.file.read": {
|
|
207
|
+
readonly kind: "saas";
|
|
208
|
+
readonly identities: readonly ["user"];
|
|
209
|
+
readonly readOnly: true;
|
|
210
|
+
readonly declaredMode: false;
|
|
211
|
+
readonly idempotencyKey: false;
|
|
212
|
+
};
|
|
213
|
+
readonly "sharepoint.file.upload": {
|
|
214
|
+
readonly kind: "saas";
|
|
215
|
+
readonly identities: readonly ["user"];
|
|
216
|
+
readonly readOnly: false;
|
|
217
|
+
readonly declaredMode: false;
|
|
218
|
+
readonly idempotencyKey: true;
|
|
219
|
+
};
|
|
157
220
|
readonly "warehouse.view.read": {
|
|
158
221
|
readonly kind: "database";
|
|
159
222
|
readonly identities: readonly ["app"];
|
|
@@ -204,6 +204,87 @@ export const IntegrationOperations = {
|
|
|
204
204
|
"declaredMode": false,
|
|
205
205
|
"idempotencyKey": false
|
|
206
206
|
},
|
|
207
|
+
"sharepoint.list.list": {
|
|
208
|
+
"kind": "saas",
|
|
209
|
+
"identities": [
|
|
210
|
+
"user"
|
|
211
|
+
],
|
|
212
|
+
"readOnly": true,
|
|
213
|
+
"declaredMode": false,
|
|
214
|
+
"idempotencyKey": false
|
|
215
|
+
},
|
|
216
|
+
"sharepoint.item.list": {
|
|
217
|
+
"kind": "saas",
|
|
218
|
+
"identities": [
|
|
219
|
+
"user"
|
|
220
|
+
],
|
|
221
|
+
"readOnly": true,
|
|
222
|
+
"declaredMode": false,
|
|
223
|
+
"idempotencyKey": false
|
|
224
|
+
},
|
|
225
|
+
"sharepoint.item.create": {
|
|
226
|
+
"kind": "saas",
|
|
227
|
+
"identities": [
|
|
228
|
+
"user"
|
|
229
|
+
],
|
|
230
|
+
"readOnly": false,
|
|
231
|
+
"declaredMode": false,
|
|
232
|
+
"idempotencyKey": true
|
|
233
|
+
},
|
|
234
|
+
"sharepoint.item.update": {
|
|
235
|
+
"kind": "saas",
|
|
236
|
+
"identities": [
|
|
237
|
+
"user"
|
|
238
|
+
],
|
|
239
|
+
"readOnly": false,
|
|
240
|
+
"declaredMode": false,
|
|
241
|
+
"idempotencyKey": false
|
|
242
|
+
},
|
|
243
|
+
"sharepoint.drive.list": {
|
|
244
|
+
"kind": "saas",
|
|
245
|
+
"identities": [
|
|
246
|
+
"user"
|
|
247
|
+
],
|
|
248
|
+
"readOnly": true,
|
|
249
|
+
"declaredMode": false,
|
|
250
|
+
"idempotencyKey": false
|
|
251
|
+
},
|
|
252
|
+
"sharepoint.file.list": {
|
|
253
|
+
"kind": "saas",
|
|
254
|
+
"identities": [
|
|
255
|
+
"user"
|
|
256
|
+
],
|
|
257
|
+
"readOnly": true,
|
|
258
|
+
"declaredMode": false,
|
|
259
|
+
"idempotencyKey": false
|
|
260
|
+
},
|
|
261
|
+
"sharepoint.file.search": {
|
|
262
|
+
"kind": "saas",
|
|
263
|
+
"identities": [
|
|
264
|
+
"user"
|
|
265
|
+
],
|
|
266
|
+
"readOnly": true,
|
|
267
|
+
"declaredMode": false,
|
|
268
|
+
"idempotencyKey": false
|
|
269
|
+
},
|
|
270
|
+
"sharepoint.file.read": {
|
|
271
|
+
"kind": "saas",
|
|
272
|
+
"identities": [
|
|
273
|
+
"user"
|
|
274
|
+
],
|
|
275
|
+
"readOnly": true,
|
|
276
|
+
"declaredMode": false,
|
|
277
|
+
"idempotencyKey": false
|
|
278
|
+
},
|
|
279
|
+
"sharepoint.file.upload": {
|
|
280
|
+
"kind": "saas",
|
|
281
|
+
"identities": [
|
|
282
|
+
"user"
|
|
283
|
+
],
|
|
284
|
+
"readOnly": false,
|
|
285
|
+
"declaredMode": false,
|
|
286
|
+
"idempotencyKey": true
|
|
287
|
+
},
|
|
207
288
|
"warehouse.view.read": {
|
|
208
289
|
"kind": "database",
|
|
209
290
|
"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. */
|