@permute/sdk 0.6.0 → 0.8.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 +358 -22
- package/dist/app.d.ts +24 -2
- package/dist/app.js +31 -5
- package/dist/auth/oauth.d.ts +64 -0
- package/dist/auth/oauth.js +221 -0
- package/dist/auth/store.d.ts +39 -0
- package/dist/auth/store.js +70 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +1 -0
- package/dist/bridge.d.ts +10 -0
- package/dist/bridge.js +62 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +127 -0
- package/dist/client.d.ts +152 -10
- package/dist/client.js +142 -105
- package/dist/deploy.d.ts +77 -3
- package/dist/deploy.js +136 -58
- package/dist/generated/openapi.d.ts +726 -16
- package/dist/generated/runtime-contract.d.ts +34 -27
- package/dist/generated/runtime-contract.js +123 -8680
- package/dist/index.d.ts +16 -15
- package/dist/index.js +1 -0
- package/dist/method.d.ts +10 -0
- package/dist/method.js +21 -0
- package/dist/sourceFiles.d.ts +16 -0
- package/dist/sourceFiles.js +114 -0
- package/dist/types.d.ts +45 -1
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -6,6 +6,32 @@ The SDK supports Node.js 20 and newer and includes TypeScript declarations.
|
|
|
6
6
|
|
|
7
7
|
See the [changelog](https://permute.ai/docs/changelog) for release notes and upgrade guidance.
|
|
8
8
|
|
|
9
|
+
## Sign in without an API key
|
|
10
|
+
|
|
11
|
+
The CLI ships with `@permute/sdk`. Run it with `bunx`:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
bunx @permute/sdk auth login
|
|
15
|
+
# Or approve from another computer:
|
|
16
|
+
bunx @permute/sdk auth login --device-code --no-browser
|
|
17
|
+
bunx @permute/sdk auth status
|
|
18
|
+
bunx @permute/sdk auth logout
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Choose an organization, optionally limit the connection to one workspace, then select resources and permissions to approve in Permute. The CLI stores renewable credentials in your operating system’s credential store (no plaintext fallback). Active logins have no fixed maximum lifetime and expire after 30 days without renewal; company sign-in rules can require earlier authentication. Logging out revokes access before removing the local credential. Signing in again revokes the previous stored CLI session.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { OAuthLogin } from '@permute/sdk/auth';
|
|
25
|
+
|
|
26
|
+
const client = await new OAuthLogin().client();
|
|
27
|
+
const workspace = client.withWorkspace('wksp_...');
|
|
28
|
+
const apps = await workspace.apps.list();
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The client renews access automatically. Existing permissions still control data, drafting, publication and action execution. Each request must fit both the approved selection and your current permissions. For organization-wide logins, use `client.withWorkspace(id)` for workspace API calls. Local stdio MCP can reuse this saved login when no API key is configured. Hosted app and hosted MCP credentials stay bound to their own audiences. API-key clients continue to work with `new PermuteClient({ apiKey })`.
|
|
32
|
+
|
|
33
|
+
For a development environment, pass both `--issuer https://auth.example.test` and `--api-url https://api.example.test` to CLI commands, or the matching `issuer` and `apiUrl` to `OAuthLogin`. Only HTTPS origins and local loopback HTTP are accepted. Use the same environment for login and logout.
|
|
34
|
+
|
|
9
35
|
## Dashboard source authoring
|
|
10
36
|
|
|
11
37
|
API-key clients with dashboard write access can read and replace the complete
|
|
@@ -96,10 +122,12 @@ replacement without checking the changes. Graph commands use `get`’s numeric f
|
|
|
96
122
|
Publishing requires a successful run of the current flow version. Deleting a flow
|
|
97
123
|
requires admin access; its owned operations are deleted with it.
|
|
98
124
|
|
|
99
|
-
Data Flow and operation authoring are REST/SDK only. MCP
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
125
|
+
Data Flow and operation authoring are REST/SDK only. MCP does not expose their code
|
|
126
|
+
editing, draft inspection, execution, or publication. Hosted MCP access uses
|
|
127
|
+
selected sources or a hosted app's current grants and published action bindings,
|
|
128
|
+
intersected with the user's permissions. App actions require confirmation in
|
|
129
|
+
Permute; see the [MCP guide](https://permute.ai/docs/mcp). Flow scheduling and
|
|
130
|
+
standalone operation creation are not part of this surface.
|
|
103
131
|
|
|
104
132
|
## Publish write syncs
|
|
105
133
|
|
|
@@ -224,10 +252,36 @@ console.log(uploaded.sourceId, uploaded.status);
|
|
|
224
252
|
```
|
|
225
253
|
|
|
226
254
|
The SDK infers `sizeBytes` for strings, buffers, typed arrays, ArrayBuffers, and
|
|
227
|
-
Blobs.
|
|
255
|
+
Blobs. Omit `data` and provide `sizeBytes` to create the dataset and return its
|
|
256
|
+
upload target without sending bytes. This lets an action approve upload metadata
|
|
257
|
+
while the browser uploads a file directly to storage (outside action limits):
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
const prepared = await client.datasets.create({
|
|
261
|
+
type: 'file',
|
|
262
|
+
fileName: file.name,
|
|
263
|
+
contentType: file.type,
|
|
264
|
+
sizeBytes: file.size,
|
|
265
|
+
});
|
|
266
|
+
const upload = prepared.data.upload;
|
|
267
|
+
if (!upload) throw new Error('Missing upload target');
|
|
268
|
+
const response = await fetch(upload.url, {
|
|
269
|
+
method: upload.method,
|
|
270
|
+
headers: upload.headers,
|
|
271
|
+
body: file,
|
|
272
|
+
});
|
|
273
|
+
if (!response.ok) throw new Error('Upload failed');
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For browser uploads, prepare the target on the server or inside a Business Action,
|
|
277
|
+
then return the dataset ID and target to the browser. The storage PUT uses the
|
|
278
|
+
signed target without an API key; keep API-key SDK clients on the server.
|
|
279
|
+
|
|
280
|
+
Use every header returned with the target. REST callers provide `sizeBytes`, then send a raw `PUT` to the returned
|
|
228
281
|
`data.upload.url` with every header in `data.upload.headers`. Poll
|
|
229
|
-
`client.datasets.get(result.id)` for processing status
|
|
230
|
-
|
|
282
|
+
`client.datasets.get(result.id)` for processing status with dataset read permission.
|
|
283
|
+
If the caller has only action invocation access, use a separate status action.
|
|
284
|
+
There is no completion API call.
|
|
231
285
|
|
|
232
286
|
CSV, TSV, JSON, NDJSON, and Parquet files can be up to 1 GiB (1,073,741,824
|
|
233
287
|
bytes). Other supported file types can be up to 100 MiB (104,857,600 bytes).
|
|
@@ -353,6 +407,14 @@ query (materialized connector fields come from the stored SQL table). Warehouse
|
|
|
353
407
|
sources use DuckDB SQL and can be queried together. Live remote sources use
|
|
354
408
|
their native SQL dialect and must be queried one at a time.
|
|
355
409
|
|
|
410
|
+
Warehouse SQL queries accept up to **20 distinct sources per query**. The shared
|
|
411
|
+
[`WAREHOUSE_QUERY_SOURCE_LIMIT`](../core/src/constants/queryLimits.ts) keeps the
|
|
412
|
+
SQL executor, natural-language query sessions, and UI source picker consistent.
|
|
413
|
+
This cap bounds the number of sources prepared for a query (each source can
|
|
414
|
+
contain many tables); it does not limit workspace connections or change access
|
|
415
|
+
permissions. Select only the sources needed. Requests above the cap are rejected
|
|
416
|
+
before source loading.
|
|
417
|
+
|
|
356
418
|
```ts
|
|
357
419
|
const dataset = sources.items.find((source) => source.type === 'dataset');
|
|
358
420
|
if (!dataset) throw new Error('No dataset is available');
|
|
@@ -382,7 +444,11 @@ the same transaction after an uncertain response.
|
|
|
382
444
|
Schema definitions use `client.databases.definition(id)` and
|
|
383
445
|
`client.databases.saveDefinition(id, { definition, expectedDraft })`. Save the
|
|
384
446
|
complete table list, then publish the exact draft through `client.revisions`.
|
|
385
|
-
|
|
447
|
+
Publication supports new tables and new nullable columns on existing tables.
|
|
448
|
+
Set `nullable: true` for added columns; existing rows read `NULL` and keep their
|
|
449
|
+
record-version tokens. Preserve existing columns, types, nullability and primary
|
|
450
|
+
keys. Rows are not part of schema history. Publication applies DDL before moving
|
|
451
|
+
the published ref; after a partial failure, retry the same definition to resume.
|
|
386
452
|
|
|
387
453
|
**Upgrading from 0.3.x:** `createTable` now saves a draft. Publish the database
|
|
388
454
|
before using the new table. Direct `transact` calls require database admin
|
|
@@ -394,6 +460,14 @@ handling).
|
|
|
394
460
|
|
|
395
461
|
## Error Handling
|
|
396
462
|
|
|
463
|
+
App and Business Action authorization failures can return `permission_denied` with
|
|
464
|
+
`error.details` containing `operation`, `requiredLevel` (`read`, `write`, or `admin`),
|
|
465
|
+
and `reason`. Reasons are `caller_permission_required`, `application_grant_required`,
|
|
466
|
+
`source_delegation_required`, or `declared_capability_required`. Both SDK entries
|
|
467
|
+
preserve these fields on `PermuteApiError.details`. Details are optional: unreadable,
|
|
468
|
+
missing, and foreign resources return the same opaque 404, so a caller cannot use
|
|
469
|
+
denials to discover private resources. See [authentication](https://permute.ai/docs/authentication#authorization-errors).
|
|
470
|
+
|
|
397
471
|
```ts
|
|
398
472
|
import { PermuteApiError } from '@permute/sdk';
|
|
399
473
|
|
|
@@ -425,7 +499,22 @@ const files = await client.apps.uploadFiles(app.id, [
|
|
|
425
499
|
{ path: 'index.html', data: await readFile('dist/index.html') },
|
|
426
500
|
{ path: 'assets/main.js', data: await readFile('dist/assets/main.js') },
|
|
427
501
|
]);
|
|
502
|
+
const sourceFiles = await client.apps.uploadFiles(app.id, [
|
|
503
|
+
{ path: 'package.json', data: await readFile('package.json') },
|
|
504
|
+
{ path: 'bun.lock', data: await readFile('bun.lock') },
|
|
505
|
+
{ path: 'src/main.tsx', data: await readFile('src/main.tsx') },
|
|
506
|
+
{ path: 'index.html', data: await readFile('index.html') },
|
|
507
|
+
{ path: 'vite.config.ts', data: await readFile('vite.config.ts') },
|
|
508
|
+
]);
|
|
428
509
|
const saved = await client.apps.saveSite(app.id, {
|
|
510
|
+
source: {
|
|
511
|
+
files: sourceFiles,
|
|
512
|
+
build: {
|
|
513
|
+
command: 'bun run build --base=./',
|
|
514
|
+
outputDirectory: 'dist',
|
|
515
|
+
runtime: 'bun@1.3.12',
|
|
516
|
+
},
|
|
517
|
+
},
|
|
429
518
|
site: { files, spa: true },
|
|
430
519
|
expectedDraft: app.draft,
|
|
431
520
|
});
|
|
@@ -436,40 +525,62 @@ if (state.draft?.revisionId !== saved.draft.revisionId || state.draft.sequence !
|
|
|
436
525
|
await client.revisions.publish(app.id, { expectedVersion: state.version });
|
|
437
526
|
```
|
|
438
527
|
|
|
439
|
-
Import `readFile` from `node:fs/promises`. Supply
|
|
528
|
+
Import `readFile` from `node:fs/promises`. Supply complete source and compiled manifests, including every file required for a standalone build; the filenames above are illustrative. Uploads hash the bytes and use signed, conditional S3 PUTs without forwarding API credentials. Uploading does not create a draft. `saveSite` verifies the uploaded files and saves source, build instructions and compiled output as one draft revision; `revisions.publish` makes that draft live. Source allows 1,000 files; compiled output allows 200. Each manifest allows 4 MiB per file and 50 MiB total. Optional source-file `executable: true` preserves script permissions. Keep secrets outside source and browser bundles.
|
|
440
529
|
|
|
441
530
|
Identical contents reuse existing files. Changed contents at the same path are stored separately, preserving earlier versions for rollback. Each save replaces the complete manifest (including unchanged files); omitted paths disappear from the live app when published. Resolve a 409 conflict against the current draft before retrying.
|
|
442
531
|
|
|
532
|
+
Customize login appearance with `apps.update(app.id, { login, expectedDraft: app.draft })`. The optional `login` object accepts `header`, `subheader`, `title`, `subtitle`, `primaryColor` and `secondaryColor` (six-digit hex colors). For `sidePanelImage`, upload a PNG, JPEG or WebP image up to 2 MiB with `apps.uploadFiles` and pass the returned file descriptor. Publish the saved draft through `revisions.publish` to apply the same appearance to Permute sign-in and shared-password access. Set `login: null` to restore default styling. Images displayed before sign-in are public; normal site files stay gated.
|
|
533
|
+
|
|
534
|
+
Set an app favicon with `apps.update(app.id, { favicon, expectedDraft: app.draft })`, where `favicon` is the descriptor returned by uploading a PNG up to 256 KiB through `apps.uploadFiles`. Publish to apply it to browser tabs and sign-in screens (independent of custom login styling). The configured icon overrides bundled icons; `favicon: null` removes the override. Only the current published icon is public before sign-in; historical published icons require an app session. Organization defaults are not supported.
|
|
535
|
+
|
|
443
536
|
Visitors sign in through Permute and need app read permission. HTML receives a revision-specific base URL; use relative URLs for bundled and public assets. Inline scripts, external requests, workers, and server runtimes are not supported. Keep API keys and secrets out of compiled browser files. Restore a historical revision with `client.revisions.restore`, then publish the resulting draft to roll back. See the [complete app guide](https://permute.ai/docs/guides/deploy-app) for creation, directory uploads, updates, and rollback.
|
|
444
537
|
|
|
445
|
-
### Deploy
|
|
538
|
+
### Deploy source and compiled output (SDK 0.7.0)
|
|
446
539
|
|
|
447
540
|
The Node/Bun entry `@permute/sdk/deploy` combines validation, upload, draft save,
|
|
448
|
-
and publication for an existing live app. Use SDK 0.
|
|
449
|
-
API shown here.
|
|
541
|
+
and publication for an existing live app. Use SDK 0.7.0 for source-backed app saves. Builds still run locally.
|
|
450
542
|
|
|
451
543
|
```ts
|
|
452
544
|
import { deployApp } from '@permute/sdk/deploy';
|
|
453
545
|
|
|
454
|
-
const deployed = await deployApp(client, {
|
|
546
|
+
const deployed = await deployApp(client, {
|
|
547
|
+
appId,
|
|
548
|
+
directory: './dist',
|
|
549
|
+
expectedDraft: app.draft,
|
|
550
|
+
source: {
|
|
551
|
+
directory: '.',
|
|
552
|
+
build: {
|
|
553
|
+
command: 'bun run build --base=./',
|
|
554
|
+
outputDirectory: 'dist',
|
|
555
|
+
runtime: 'bun@1.3.12',
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
});
|
|
455
559
|
console.log(deployed.url, deployed.revisionId, deployed.changed);
|
|
456
560
|
```
|
|
457
561
|
|
|
458
562
|
The helper rejects symbolic links, unsupported paths, missing `index.html`, and
|
|
459
|
-
oversized builds before uploading.
|
|
460
|
-
after reviewing an existing draft, pass its exact `expectedDraft` to approve
|
|
461
|
-
replacing it. Omitted `actions` preserve current bindings (an empty array clears them). To change action bindings, supply explicit published revisions:
|
|
563
|
+
oversized builds before uploading. Pass the `expectedDraft` token from checkout or a reviewed app read. A stale token stops deployment before uploads; the helper never adopts a newer remote draft as the base of local files. Omitted `actions` preserve current bindings (an empty array clears them). To change action bindings, supply explicit published revisions:
|
|
462
564
|
|
|
463
565
|
```ts
|
|
464
566
|
await deployApp(client, {
|
|
465
567
|
appId,
|
|
568
|
+
expectedDraft: app.draft,
|
|
466
569
|
directory: './dist',
|
|
570
|
+
source: {
|
|
571
|
+
directory: '.',
|
|
572
|
+
build: {
|
|
573
|
+
command: 'bun run build --base=./',
|
|
574
|
+
outputDirectory: 'dist',
|
|
575
|
+
runtime: 'bun@1.3.12',
|
|
576
|
+
},
|
|
577
|
+
},
|
|
467
578
|
actions: [{ actionId, revisionId: publishedActionRevisionId }],
|
|
468
579
|
});
|
|
469
580
|
```
|
|
470
581
|
|
|
471
582
|
An unchanged build returns `changed: false` without uploading, saving, or publishing
|
|
472
|
-
(files, SPA mode, and
|
|
583
|
+
(source, build instructions, compiled files, SPA mode, and supplied bindings must match). It rechecks refs before
|
|
473
584
|
reporting no change. An approved unpublished draft is still published so settings
|
|
474
585
|
edits are preserved. Changed deployments return `changed: true`.
|
|
475
586
|
|
|
@@ -480,23 +591,107 @@ publication may already have completed. `deployment_conflict` requires review;
|
|
|
480
591
|
when saving a draft for later review. The helper does not build, provision, or wait
|
|
481
592
|
for hosting, and must not be imported into browser code.
|
|
482
593
|
|
|
594
|
+
### Shared source checkpoints and checkout
|
|
595
|
+
|
|
596
|
+
Source and compiled output share one App history. Source checkpoints advance the draft without publishing; publication checks that the saved build matches current source and build instructions. Settings-only edits reuse matching builds. Restore recovers both manifests together; older compiled-only revisions have no downloadable source.
|
|
597
|
+
|
|
598
|
+
Invited editors can use **Download source** in App details with their Permute user session (no API key required). The ZIP contains project files under `source/` and a separate `app-source.json` with build instructions, the exact revision and the observed draft token. The SDK uses an API key with its own App write grant; a user editing invitation does not grant access to any API key.
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
import { checkoutApp, saveAppSource } from '@permute/sdk/deploy';
|
|
602
|
+
|
|
603
|
+
const checkout = await checkoutApp(client, { appId, directory: './app-project' });
|
|
604
|
+
// Edit the downloaded files. Retain the token from checkout for your save.
|
|
605
|
+
if (!checkout.expectedDraft) throw new Error('Review the current draft first');
|
|
606
|
+
const saved = await saveAppSource(client, {
|
|
607
|
+
appId,
|
|
608
|
+
expectedDraft: checkout.expectedDraft,
|
|
609
|
+
source: { directory: './app-project', build: checkout.source.build },
|
|
610
|
+
});
|
|
611
|
+
// Run the stored build command, then use deployApp with expectedDraft: saved.draft.
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Checkout requires a new folder and verifies every downloaded file against its hash and size. Pass `revisionId` to select historical source; historical checkout returns no draft token unless it is also the current draft. A failed checkout may leave a partial folder; inspect it and choose a new folder before retrying.
|
|
615
|
+
|
|
616
|
+
Source helpers apply root and nested `.gitignore` rules, including directory patterns and `!` exceptions, using [`ignore` 7.0.5](https://github.com/kaelzhang/node-ignore/tree/7.0.5). Visited `.gitignore` files stay in the source snapshot. Rules apply to all local files, including files tracked by Git; the Git index, local/global excludes and rules outside the selected source root are not consulted. Git installation and commits are unnecessary.
|
|
617
|
+
|
|
618
|
+
Mandatory exclusions cover dependencies, credential files, common caches, the declared build output directory, and personal agent state (including `CLAUDE.local.md`, `.claude/settings.local.json`, agent worktrees, authentication files and session logs). Ignore exceptions cannot override these exclusions. Keep shared instructions, skills, setup scripts, lockfiles, tests and configuration inside the source root. Environment templates ending in `.example`, `.sample` or `.template` are allowed; remove actual secrets from their contents. Filename exclusions do not scan for secrets embedded in ordinary files.
|
|
619
|
+
|
|
620
|
+
Internal source symlinks are copied as ordinary files or directories, and checkout restores executable scripts. External, dangling, cyclic and excluded-target links fail before uploads; compiled output rejects symlinks. Unsafe paths, case collisions and oversized manifests also fail. Checkout never executes project hooks or scripts. Build the exact source you intend to save and keep it unchanged while deploying; build pairing records the uploader’s declaration and does not independently verify compilation.
|
|
621
|
+
|
|
622
|
+
App write/admin permission is required for source files and complete revision reads. App admins can grant editing access through Share; app read permission continues to govern hosted compiled files. Source files never enter hosted-file delivery.
|
|
623
|
+
|
|
483
624
|
### Inside a hosted app
|
|
484
625
|
|
|
485
626
|
Use the browser entry with the app's existing sign-in session (no API key in frontend code):
|
|
486
627
|
|
|
628
|
+
Use `app.sources.list()`, `app.sources.get(sourceId)`, and `app.query()` for ordinary
|
|
629
|
+
reads, including counts, lists, filters, and aggregates. Create read actions only
|
|
630
|
+
for deliberate delegated contracts, reusable business rules, or live connector
|
|
631
|
+
calls outside the query API. The built-in assistant queries permitted sources
|
|
632
|
+
directly; invoking any bound action requires viewer confirmation.
|
|
633
|
+
|
|
634
|
+
Source reads require whole-resource read permission for both the app and viewer,
|
|
635
|
+
including databases, datasets, connectors, and operation outputs. Table, row,
|
|
636
|
+
and column grants within a shared source are unsupported; subResourceId is available
|
|
637
|
+
only to revoke an existing scoped grant. Role-based reads can use separate filtered
|
|
638
|
+
Data Table or Data Flow outputs shared with the appropriate viewers and apps while
|
|
639
|
+
broader upstream sources stay restricted. Follow the
|
|
640
|
+
[filtered-output guide](https://permute.ai/docs/guides/deploy-app#app-filtered-views)
|
|
641
|
+
for allowed rows/columns, historical access, and refresh after assignment changes.
|
|
642
|
+
Test source discovery and a bounded query as the intended viewer before testing the assistant; an empty
|
|
643
|
+
source list describes that viewer's app access, not the workspace's contents.
|
|
644
|
+
|
|
645
|
+
Plan app and viewer permissions separately. Use `client.apps.setGrant()` for exact
|
|
646
|
+
approved app source IDs; an administrator uses Permute sharing for viewer app and
|
|
647
|
+
source access. Authorized management clients can also use `client.permissions.list`,
|
|
648
|
+
`create`, `update`, `delete`, and `inspect`; hosted app, delegated action, MCP, and
|
|
649
|
+
connector sessions cannot manage grants. The management key needs its own access
|
|
650
|
+
and delegation authority.
|
|
651
|
+
Inspect the complete source before approval (a database may contain other apps’
|
|
652
|
+
tables); never widen a rejected table grant automatically. Apps and keys do not
|
|
653
|
+
inherit their creators’ access. The public
|
|
654
|
+
[app guide](https://permute.ai/docs/guides/deploy-app#app-grants) includes the permission matrix.
|
|
655
|
+
|
|
656
|
+
Use the built-in assistant rather than adding another AI backend or chat overlay.
|
|
657
|
+
When migrating ordinary read actions, update their callers to the source/query API,
|
|
658
|
+
publish without those bindings, revoke their app grants, and delete the actions
|
|
659
|
+
after confirming no remaining callers. Keep approved write actions and revision pins.
|
|
660
|
+
|
|
487
661
|
```ts
|
|
488
662
|
import { PermuteAppClient } from '@permute/sdk/app';
|
|
489
663
|
|
|
490
664
|
const app = new PermuteAppClient();
|
|
665
|
+
const user = await app.me(); // { id, name, email } for the current signed-in viewer
|
|
491
666
|
const result = await app.query({
|
|
492
667
|
sourceIds: ['dbas_example'],
|
|
493
668
|
sql: 'SELECT * FROM app.contacts',
|
|
494
669
|
limit: 100,
|
|
495
670
|
});
|
|
671
|
+
// Optional: only for an explicitly requested operation with a pinned action.
|
|
496
672
|
const request = { requestId: crypto.randomUUID(), input: { name: 'Ada' } };
|
|
497
673
|
const receipt = await app.actions.invoke('actn_example', request);
|
|
498
674
|
```
|
|
499
675
|
|
|
676
|
+
`app.me()` returns the current user’s `id`, `name` and `email`, after checking the app session, membership and app access. Requests use the public API on the app origin (including custom domains), with the HttpOnly cookie and a validated `X-Permute-App-Revision` header. Source reads and lists intersect current user permissions and app grants; action invocation retains the served release’s pinned binding. Password demos cannot call these APIs.
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
const sources = await app.sources.list();
|
|
680
|
+
const chat = await app.chats.create({
|
|
681
|
+
action: 'send_message',
|
|
682
|
+
clientRequestId: crypto.randomUUID(),
|
|
683
|
+
message: 'Show the pipeline',
|
|
684
|
+
});
|
|
685
|
+
const state = await app.chats.get(chat.id);
|
|
686
|
+
await app.chats.send(chat.id, {
|
|
687
|
+
action: 'send_message',
|
|
688
|
+
clientRequestId: crypto.randomUUID(),
|
|
689
|
+
message: 'Group it by stage',
|
|
690
|
+
});
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`/v1/chats` preserves conversation and supports turns, cancellation and action decisions. `/v1/answer-jobs` remains one-shot analysis. Chat and basic-profile routes require user authentication; API keys cannot own chats. Other API routes retain their declared authentication requirements. Legacy hosted `__me`, `__queries` and `__actions/{actionId}/invoke` URLs forward to these public handlers. Port older apps by updating the SDK, rebuilding and publishing their bundles so these aliases can be removed. Existing users must sign in again when migrating from opaque cookies to app-scoped JWTs.
|
|
694
|
+
|
|
500
695
|
The client reads the hosted page's revision-pinned base URL. Save
|
|
501
696
|
`actions: [{ actionId, revisionId }]` with its site manifest, then publish the app revision.
|
|
502
697
|
Queries require current app and user read permissions. The query request selects its
|
|
@@ -520,13 +715,110 @@ operation only after resolving any earlier uncertain write.
|
|
|
520
715
|
|
|
521
716
|
## Business actions
|
|
522
717
|
|
|
718
|
+
Handlers can read declared managed database tables with
|
|
719
|
+
`db.read({ sourceId, table, where, limit })`. Use trusted `caller: { id, type }`
|
|
720
|
+
to choose permitted rows, return only approved fields, and grant invocation access
|
|
721
|
+
without granting database Read. Declare the published schema revision and
|
|
722
|
+
`readTables` in the action's source binding (`writeTables: []` for read-only use).
|
|
723
|
+
Caller identity does not include membership roles; application-specific team rules
|
|
724
|
+
can use a server-side roster. See the
|
|
725
|
+
[caller-filtered database read example](https://permute.ai/docs/guides/business-actions#action-database-reads).
|
|
726
|
+
|
|
727
|
+
Action handlers can receive a declared `platform` SDK facade. Add `platform.methods`
|
|
728
|
+
and `platform.permissions` to the definition, then call
|
|
729
|
+
`platform.withStep('upload').datasets.create({ type: 'file', fileName: input.fileName, contentType: input.contentType, sizeBytes: input.sizeBytes })`.
|
|
730
|
+
Without `data`, the call returns dataset metadata and an upload target; the handler
|
|
731
|
+
can return that target for the browser to upload bytes. Providing `data` also
|
|
732
|
+
uploads bytes inside the action and must fit its execution and payload limits.
|
|
733
|
+
The server runs the real SDK using the action's delegated grants (callers need
|
|
734
|
+
only action invocation permission). Saving requires Edit draft on the action and
|
|
735
|
+
Read on declared resources; publishing requires Publish and resource Admin to
|
|
736
|
+
delegate those capabilities. Every SDK call needs a distinct, deterministic step ID, including reads.
|
|
737
|
+
Completed calls replay their saved results; reusing an ID with a different method
|
|
738
|
+
or arguments returns `request_conflict`. Calls with uncertain outcomes return
|
|
739
|
+
`outcome_unknown` and block automatic resend. Earlier SDK effects aren't rolled
|
|
740
|
+
back if the action fails.
|
|
741
|
+
Calls share action time and payload limits; dataset helpers return plain data.
|
|
742
|
+
See the [business-actions guide](https://permute.ai/docs/guides/business-actions)
|
|
743
|
+
for supported methods, limits, and authoring examples.
|
|
744
|
+
|
|
745
|
+
For hosted browser uploads, send file metadata through the bound action and return
|
|
746
|
+
`{ datasetId: dataset.id, upload: dataset.upload }` in its declared output contract.
|
|
747
|
+
Send the `File` as the raw body of `fetch(upload.url, { method: upload.method,
|
|
748
|
+
headers: upload.headers, body: file, credentials: 'omit', redirect: 'error' })`.
|
|
749
|
+
Keep every returned header (including `If-None-Match: *`); don't wrap bytes in
|
|
750
|
+
`FormData` or send cookies or API keys to S3. File bytes bypass the action's
|
|
751
|
+
100 KB input limit. Compiled signed-in apps permit connections to the stage's exact
|
|
752
|
+
regional Datasets bucket; its CORS rule supports PUT and the returned headers,
|
|
753
|
+
including from custom app domains.
|
|
754
|
+
|
|
755
|
+
S3 object-created events start normal dataset processing. Use a separately bound
|
|
756
|
+
status action declaring `datasets.get` to poll with a new invocation request ID
|
|
757
|
+
for each fresh read; enforce any caller-specific ownership rules in that action.
|
|
758
|
+
Hosted app sessions cannot call dataset APIs directly. Upload URLs are bearer
|
|
759
|
+
capabilities valid for up to 15 minutes (temporary AWS credentials can expire
|
|
760
|
+
earlier), so don't persist or log them. Replaying the create action returns the
|
|
761
|
+
original target without renewing its expiry. After an uncertain PUT, inspect
|
|
762
|
+
processing status before requesting another dataset; a 412 means an object
|
|
763
|
+
already occupies the signed key.
|
|
764
|
+
|
|
523
765
|
Use `client.actions.create`, `definition`, and `saveDefinition` to author a typed
|
|
524
|
-
handler with
|
|
766
|
+
handler with declared database, connector, or SDK capabilities. SDK-only and
|
|
767
|
+
connector-only actions need no managed database. Publish with
|
|
525
768
|
`client.revisions.publish`, then call `client.actions.invoke` with a stable
|
|
526
|
-
`requestId` and input. `list`
|
|
527
|
-
requires
|
|
769
|
+
`requestId` and input. `list` and `get` expose published contracts and declared capabilities; reading definitions
|
|
770
|
+
requires View code permission. See the [business action guide](https://permute.ai/docs/guides/business-actions)
|
|
528
771
|
for the complete create, save, publish, and invoke workflow.
|
|
529
772
|
|
|
773
|
+
### Self-describing action contracts
|
|
774
|
+
|
|
775
|
+
Use input/output schema descriptions to explain purpose, business terms,
|
|
776
|
+
relationships, create/update behavior, consequential effects, and result meaning
|
|
777
|
+
(external callers cannot inspect the handler). Declare known fields, enums,
|
|
778
|
+
required/optional and nullable values, bounds, and typed outputs structurally.
|
|
779
|
+
Keep field descriptions beside their fields and operation descriptions beside
|
|
780
|
+
their alternatives. Reuse schema definitions for published contracts and handler
|
|
781
|
+
validation where practical.
|
|
782
|
+
|
|
783
|
+
Bounded `oneOf` alternatives support operation-specific inputs: give each
|
|
784
|
+
alternative a required selector with a distinct `const` value. Exactly one must
|
|
785
|
+
match. Limits are 32 alternatives per `oneOf`, 500 schema nodes, and depth 12.
|
|
786
|
+
Regexes, references, formats, and other composition keywords remain unsupported.
|
|
787
|
+
Keep authorization, normalization, formats, and live business-state checks in the handler.
|
|
788
|
+
|
|
789
|
+
Supply input/output and field `examples`; save and publication validate them
|
|
790
|
+
without executing the handler. Limits are 10 values per examples array and 50
|
|
791
|
+
examples per input/output contract. Defaults are descriptive and are not inserted.
|
|
792
|
+
Test the handler against examples, rejections, and conflicts as well: structural
|
|
793
|
+
validation cannot guarantee a later write succeeds.
|
|
794
|
+
|
|
795
|
+
`client.actions.list()` and `get()` return the current published contract, or
|
|
796
|
+
`contract: null` before publication. App MCP discovery retains its app's permitted
|
|
797
|
+
action bindings and exact revision pins. Draft changes do not update those views.
|
|
798
|
+
Declared capabilities are bounds, not a preview of affected records. See the
|
|
799
|
+
[contract-authoring guide](https://permute.ai/docs/guides/business-actions#action-contracts)
|
|
800
|
+
for a complete schema example.
|
|
801
|
+
|
|
802
|
+
Permute authenticates callers and checks the action’s required invocation
|
|
803
|
+
permission. Hosted app calls also require app access, an app invocation grant,
|
|
804
|
+
and the action revision pinned in the served release. Administrators approve
|
|
805
|
+
delegated resources when authoring and publishing the action.
|
|
806
|
+
|
|
807
|
+
Execution uses the action’s approved delegated capabilities; callers do not need
|
|
808
|
+
direct access to the underlying sources. A bound connector uses the connected
|
|
809
|
+
account’s access even when the caller has no connector access. Permute enforces
|
|
810
|
+
declared execution capabilities and validates input/output schemas; the handler
|
|
811
|
+
defines the operation exposed to callers.
|
|
812
|
+
|
|
813
|
+
Handlers receive trusted `caller: { id, type }` (`type` is `user` or `apiKey`).
|
|
814
|
+
An action can use that identity and server-side business data to check or deny a
|
|
815
|
+
request when its rules warrant it (such as an ownership rule or allowed state transition).
|
|
816
|
+
Apply those checks before the affected reads or writes. They supplement the
|
|
817
|
+
platform checks. Permute does not require callers to hold the action’s underlying
|
|
818
|
+
source permissions. Browser-supplied actor IDs or roles are not proof of identity
|
|
819
|
+
or authority. The caller object contains no membership roles or general
|
|
820
|
+
permission lookup API.
|
|
821
|
+
|
|
530
822
|
### Expected failures in handlers
|
|
531
823
|
|
|
532
824
|
Use `reject(code, message, details?)` for a deliberate business rejection:
|
|
@@ -540,13 +832,17 @@ exports.handler = ({ input, db, reject }) => {
|
|
|
540
832
|
```
|
|
541
833
|
|
|
542
834
|
Rejection stops the handler and discards staged database writes. It cannot undo
|
|
543
|
-
connector
|
|
835
|
+
connector or SDK effects already sent. Messages and details are public to callers; never
|
|
544
836
|
put credentials or private diagnostic data in them. A rejection message permits
|
|
545
837
|
1–1,000 characters, with up to 20 string detail fields (keys 1–100 characters,
|
|
546
838
|
values up to 500). Invalid rejections and unexpected exceptions return a safe
|
|
547
839
|
`execution_failed` response; exception text stays in server logs.
|
|
548
840
|
|
|
549
841
|
Both SDK entries expose `PermuteApiError.code`, `.status`, and `.details`.
|
|
842
|
+
Schema validation details include `path`, `rule`, and `schemaPath`, with bounded
|
|
843
|
+
`allowedValues` or `constraint` where available. Compound errors can include
|
|
844
|
+
`issues`; truncation flags indicate omitted diagnostics. Hosted MCP retains the
|
|
845
|
+
same public error code and details in its error result.
|
|
550
846
|
Branch on the code (not message text):
|
|
551
847
|
|
|
552
848
|
| Code | HTTP | Recovery |
|
|
@@ -567,7 +863,9 @@ does not establish that nothing happened; reconcile provider state first.
|
|
|
567
863
|
### Connector bindings
|
|
568
864
|
|
|
569
865
|
An action definition can bind multiple connector accounts by name alongside its database
|
|
570
|
-
schema bindings.
|
|
866
|
+
schema bindings. Saving requires Edit draft on the action and Read on each selected
|
|
867
|
+
resource; publishing requires Publish on the action and source Admin to delegate
|
|
868
|
+
the capabilities.
|
|
571
869
|
The action runs with those delegated capabilities; callers need its invocation permission.
|
|
572
870
|
|
|
573
871
|
```ts
|
|
@@ -626,3 +924,41 @@ step IDs stable in code. The first attempt pins the revision; successful externa
|
|
|
626
924
|
replay saved responses. Steps with uncertain outcomes block automatic resend and require
|
|
627
925
|
provider-state reconciliation. There is no automatic rollback, reconciliation API, or
|
|
628
926
|
long-running workflow runner. Connector-only actions can use `sources: []`.
|
|
927
|
+
|
|
928
|
+
## Hosted app visitor access (next release)
|
|
929
|
+
|
|
930
|
+
App admins can read `client.apps.access(appId)` and update access with
|
|
931
|
+
`client.apps.setAccess(appId, { mode, expectedVersion, password? })`. Use the
|
|
932
|
+
version returned by the read. `mode: 'password'` requires a new password on each
|
|
933
|
+
save; `mode: 'permute'` clears it. These methods are not in SDK 0.6.0.
|
|
934
|
+
|
|
935
|
+
Access changes take effect immediately and invalidate app sessions (without
|
|
936
|
+
publishing a site revision). Shared passwords gate published frontend files only,
|
|
937
|
+
including older releases. Use mocked data; demo sessions cannot query sources or
|
|
938
|
+
invoke Business Actions. Permute sign-in retains existing permissions and app grants.
|
|
939
|
+
|
|
940
|
+
### Custom app addresses
|
|
941
|
+
|
|
942
|
+
An organization admin first connects a namespace in Organization Settings → App domains and completes the displayed DNS validation. App admins can then assign a name without a new build or CloudFront rollout (the generated Permute URL stays available):
|
|
943
|
+
|
|
944
|
+
```ts
|
|
945
|
+
const current = await client.apps.address(app.id);
|
|
946
|
+
const namespace = current.namespaces[0]; // Ready namespaces in this organization.
|
|
947
|
+
if (!namespace) throw new Error('Connect an organization app domain first.');
|
|
948
|
+
await client.apps.setAddress(app.id, {
|
|
949
|
+
address: { namespaceId: namespace.id, label: 'crm' },
|
|
950
|
+
expectedUrl: current.address?.url ?? null,
|
|
951
|
+
});
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
Set `address: null` with the currently observed URL to remove it. Stale edits and occupied names return 409. Each hostname uses its own app session; permissions and password-demo restrictions apply on every address. Namespace ownership management is restricted to signed-in organization admins, while these app-level methods support API keys with app admin permission.
|
|
955
|
+
|
|
956
|
+
## Request missing access
|
|
957
|
+
|
|
958
|
+
Use `client.accessRequests.create({ resourceId, level: 'read', reason: 'Need this app for customer support' })` to record a request for the authenticated caller. Recording a request does not grant access or confirm that the resource exists. List your requests with `client.accessRequests.list()`.
|
|
959
|
+
|
|
960
|
+
Reviewers use `client.accessRequests.list({ view: 'review', state: 'pending' })`, then `client.accessRequests.decide(requestId, { decision: 'approved' })` to grant the requested caller access, verify effective access and close the request atomically. Approval never expands app grants (missing app access must be handled separately). Use `resolved` only to verify access already granted. Requesters can cancel with `{ decision: 'cancelled' }` (reviewers can reject with `{ decision: 'rejected' }`).
|
|
961
|
+
|
|
962
|
+
Lists include a `total` count under the same authorization and filters as their rows. Use `{ view: 'mine', state: 'pending' }` for your pending requests and `{ view: 'history' }` for completed or expired requests you own or currently administer. Resource administrators can list across their authorized resources or narrow with `resourceId`. Expiry and visibility filters apply before cursor pagination.
|
|
963
|
+
|
|
964
|
+
Hosted apps use `app.accessRequests.create/list/get/cancel`; the server derives app scope from the signed session. See the [access-request contract](../core/src/permissions/README.md) for expiry, reviewer authority and current limits.
|
package/dist/app.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AccessRequest, AccessRequestInput, AccessRequestList } from './types.js';
|
|
2
|
+
export type { AccessRequest, AccessRequestInput, AccessRequestList } from './types.js';
|
|
3
|
+
import type { ActionRequest, ActionReceipt, QueryRequest, QueryResponse, AppUser, ChatSendRequest, ChatDecisionRequest, ChatResponse, SourceList, SourceDetail } from './types.js';
|
|
2
4
|
export { PermuteApiError } from './errors.js';
|
|
3
|
-
export type { ActionRequest, ActionReceipt, QueryRequest, QueryResponse } from './types.js';
|
|
5
|
+
export type { ActionRequest, ActionReceipt, QueryRequest, QueryResponse, AppUser, ChatSendRequest, ChatDecisionRequest, ChatResponse, SourceList, SourceDetail, } from './types.js';
|
|
4
6
|
export interface PermuteAppClientOptions {
|
|
5
7
|
/** Defaults to the hosted page's revision-pinned document.baseURI. */
|
|
6
8
|
baseUrl?: string;
|
|
@@ -12,12 +14,32 @@ export interface AppRequestOptions {
|
|
|
12
14
|
/** Browser client authenticated by the hosted app's HttpOnly session cookie. */
|
|
13
15
|
export declare class PermuteAppClient {
|
|
14
16
|
private readonly baseUrl;
|
|
17
|
+
private readonly revisionId;
|
|
15
18
|
private readonly fetcher;
|
|
16
19
|
constructor(options?: PermuteAppClientOptions);
|
|
20
|
+
readonly accessRequests: {
|
|
21
|
+
create: (body: AccessRequestInput, options?: AppRequestOptions) => Promise<AccessRequest>;
|
|
22
|
+
list: (cursor?: string, options?: AppRequestOptions) => Promise<AccessRequestList>;
|
|
23
|
+
get: (requestId: string, options?: AppRequestOptions) => Promise<AccessRequest>;
|
|
24
|
+
cancel: (requestId: string, options?: AppRequestOptions) => Promise<AccessRequest>;
|
|
25
|
+
};
|
|
17
26
|
readonly actions: {
|
|
18
27
|
/** Keep requestId and input unchanged when retrying an uncertain result. */
|
|
19
28
|
invoke: (actionId: string, request: ActionRequest, options?: AppRequestOptions) => Promise<ActionReceipt>;
|
|
20
29
|
};
|
|
30
|
+
readonly sources: {
|
|
31
|
+
list: (options?: AppRequestOptions) => Promise<SourceList>;
|
|
32
|
+
get: (sourceId: string, options?: AppRequestOptions) => Promise<SourceDetail>;
|
|
33
|
+
};
|
|
34
|
+
readonly chats: {
|
|
35
|
+
create: (request: ChatSendRequest, options?: AppRequestOptions) => Promise<ChatResponse>;
|
|
36
|
+
get: (chatId: string, options?: AppRequestOptions) => Promise<ChatResponse>;
|
|
37
|
+
send: (chatId: string, request: ChatSendRequest, options?: AppRequestOptions) => Promise<ChatResponse>;
|
|
38
|
+
stop: (chatId: string, options?: AppRequestOptions) => Promise<ChatResponse>;
|
|
39
|
+
decide: (chatId: string, request: ChatDecisionRequest, options?: AppRequestOptions) => Promise<ChatResponse>;
|
|
40
|
+
};
|
|
21
41
|
query(request: QueryRequest, options?: AppRequestOptions): Promise<QueryResponse>;
|
|
42
|
+
/** Basic profile of the current Permute user. Password demos have no user identity. */
|
|
43
|
+
me(options?: AppRequestOptions): Promise<AppUser>;
|
|
22
44
|
private request;
|
|
23
45
|
}
|
package/dist/app.js
CHANGED
|
@@ -3,6 +3,7 @@ export { PermuteApiError } from './errors.js';
|
|
|
3
3
|
/** Browser client authenticated by the hosted app's HttpOnly session cookie. */
|
|
4
4
|
export class PermuteAppClient {
|
|
5
5
|
baseUrl;
|
|
6
|
+
revisionId;
|
|
6
7
|
fetcher;
|
|
7
8
|
constructor(options = {}) {
|
|
8
9
|
const base = options.baseUrl ?? (typeof document !== 'undefined' ? document.baseURI : undefined);
|
|
@@ -14,23 +15,48 @@ export class PermuteAppClient {
|
|
|
14
15
|
throw new Error('Use a revision-pinned hosted app URL.');
|
|
15
16
|
if (typeof location !== 'undefined' && url.origin !== location.origin)
|
|
16
17
|
throw new Error('App requests must use the current origin.');
|
|
17
|
-
this.baseUrl = `${url.origin}
|
|
18
|
+
this.baseUrl = `${url.origin}/v1/`;
|
|
19
|
+
this.revisionId = revision[1];
|
|
18
20
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
19
21
|
}
|
|
22
|
+
accessRequests = {
|
|
23
|
+
create: (body, options) => this.request('access-requests', body, options),
|
|
24
|
+
list: (cursor, options) => this.request(`access-requests${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`, undefined, options),
|
|
25
|
+
get: (requestId, options) => this.request(`access-requests/${encodeURIComponent(requestId)}`, undefined, options),
|
|
26
|
+
cancel: (requestId, options) => this.request(`access-requests/${encodeURIComponent(requestId)}/decisions`, { decision: 'cancelled' }, options),
|
|
27
|
+
};
|
|
20
28
|
actions = {
|
|
21
29
|
/** Keep requestId and input unchanged when retrying an uncertain result. */
|
|
22
|
-
invoke: (actionId, request, options) => this.request(`
|
|
30
|
+
invoke: (actionId, request, options) => this.request(`actions/${encodeURIComponent(actionId)}/invoke`, request, options),
|
|
31
|
+
};
|
|
32
|
+
sources = {
|
|
33
|
+
list: (options) => this.request('sources', undefined, options),
|
|
34
|
+
get: (sourceId, options) => this.request(`sources/${encodeURIComponent(sourceId)}`, undefined, options),
|
|
35
|
+
};
|
|
36
|
+
chats = {
|
|
37
|
+
create: (request, options) => this.request('chats', request, options),
|
|
38
|
+
get: (chatId, options) => this.request(`chats/${encodeURIComponent(chatId)}`, undefined, options),
|
|
39
|
+
send: (chatId, request, options) => this.request(`chats/${encodeURIComponent(chatId)}/turns`, request, options),
|
|
40
|
+
stop: (chatId, options) => this.request(`chats/${encodeURIComponent(chatId)}/stop`, {}, options),
|
|
41
|
+
decide: (chatId, request, options) => this.request(`chats/${encodeURIComponent(chatId)}/decisions`, request, options),
|
|
23
42
|
};
|
|
24
43
|
query(request, options) {
|
|
25
|
-
return this.request('
|
|
44
|
+
return this.request('queries', request, options);
|
|
45
|
+
}
|
|
46
|
+
/** Basic profile of the current Permute user. Password demos have no user identity. */
|
|
47
|
+
me(options) {
|
|
48
|
+
return this.request('me', undefined, options);
|
|
26
49
|
}
|
|
27
50
|
async request(path, body, options) {
|
|
28
51
|
const response = await this.fetcher(`${this.baseUrl}${path}`, {
|
|
29
|
-
method: 'POST',
|
|
52
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
30
53
|
credentials: 'same-origin',
|
|
31
54
|
mode: 'same-origin',
|
|
32
55
|
redirect: 'error',
|
|
33
|
-
headers: {
|
|
56
|
+
headers: {
|
|
57
|
+
'X-Permute-App-Revision': this.revisionId,
|
|
58
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
59
|
+
},
|
|
34
60
|
body: JSON.stringify(body),
|
|
35
61
|
signal: options?.signal,
|
|
36
62
|
});
|