@permute/sdk 0.7.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 +139 -13
- package/dist/app.d.ts +8 -0
- package/dist/app.js +6 -0
- 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/cli.d.ts +2 -0
- package/dist/cli.js +127 -0
- package/dist/client.d.ts +40 -14
- package/dist/client.js +32 -3
- package/dist/deploy.d.ts +1 -1
- package/dist/generated/openapi.d.ts +401 -9
- package/dist/generated/runtime-contract.d.ts +34 -28
- package/dist/generated/runtime-contract.js +98 -75
- package/dist/index.d.ts +15 -15
- package/dist/types.d.ts +28 -1
- package/package.json +14 -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
|
|
|
@@ -379,6 +407,14 @@ query (materialized connector fields come from the stored SQL table). Warehouse
|
|
|
379
407
|
sources use DuckDB SQL and can be queried together. Live remote sources use
|
|
380
408
|
their native SQL dialect and must be queried one at a time.
|
|
381
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
|
+
|
|
382
418
|
```ts
|
|
383
419
|
const dataset = sources.items.find((source) => source.type === 'dataset');
|
|
384
420
|
if (!dataset) throw new Error('No dataset is available');
|
|
@@ -424,6 +460,14 @@ handling).
|
|
|
424
460
|
|
|
425
461
|
## Error Handling
|
|
426
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
|
+
|
|
427
471
|
```ts
|
|
428
472
|
import { PermuteApiError } from '@permute/sdk';
|
|
429
473
|
|
|
@@ -589,14 +633,21 @@ directly; invoking any bound action requires viewer confirmation.
|
|
|
589
633
|
|
|
590
634
|
Source reads require whole-resource read permission for both the app and viewer,
|
|
591
635
|
including databases, datasets, connectors, and operation outputs. Table, row,
|
|
592
|
-
and column grants are unsupported; subResourceId is available
|
|
593
|
-
existing scoped grant.
|
|
594
|
-
|
|
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
|
|
595
643
|
source list describes that viewer's app access, not the workspace's contents.
|
|
596
644
|
|
|
597
645
|
Plan app and viewer permissions separately. Use `client.apps.setGrant()` for exact
|
|
598
646
|
approved app source IDs; an administrator uses Permute sharing for viewer app and
|
|
599
|
-
source access.
|
|
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.
|
|
600
651
|
Inspect the complete source before approval (a database may contain other apps’
|
|
601
652
|
tables); never widen a rejected table grant automatically. Apps and keys do not
|
|
602
653
|
inherit their creators’ access. The public
|
|
@@ -664,6 +715,15 @@ operation only after resolving any earlier uncertain write.
|
|
|
664
715
|
|
|
665
716
|
## Business actions
|
|
666
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
|
+
|
|
667
727
|
Action handlers can receive a declared `platform` SDK facade. Add `platform.methods`
|
|
668
728
|
and `platform.permissions` to the definition, then call
|
|
669
729
|
`platform.withStep('upload').datasets.create({ type: 'file', fileName: input.fileName, contentType: input.contentType, sizeBytes: input.sizeBytes })`.
|
|
@@ -671,8 +731,9 @@ Without `data`, the call returns dataset metadata and an upload target; the hand
|
|
|
671
731
|
can return that target for the browser to upload bytes. Providing `data` also
|
|
672
732
|
uploads bytes inside the action and must fit its execution and payload limits.
|
|
673
733
|
The server runs the real SDK using the action's delegated grants (callers need
|
|
674
|
-
only action invocation permission). Saving
|
|
675
|
-
|
|
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.
|
|
676
737
|
Completed calls replay their saved results; reusing an ID with a different method
|
|
677
738
|
or arguments returns `request_conflict`. Calls with uncertain outcomes return
|
|
678
739
|
`outcome_unknown` and block automatic resend. Earlier SDK effects aren't rolled
|
|
@@ -681,14 +742,63 @@ Calls share action time and payload limits; dataset helpers return plain data.
|
|
|
681
742
|
See the [business-actions guide](https://permute.ai/docs/guides/business-actions)
|
|
682
743
|
for supported methods, limits, and authoring examples.
|
|
683
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
|
+
|
|
684
765
|
Use `client.actions.create`, `definition`, and `saveDefinition` to author a typed
|
|
685
766
|
handler with declared database, connector, or SDK capabilities. SDK-only and
|
|
686
767
|
connector-only actions need no managed database. Publish with
|
|
687
768
|
`client.revisions.publish`, then call `client.actions.invoke` with a stable
|
|
688
|
-
`requestId` and input. `list`
|
|
689
|
-
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)
|
|
690
771
|
for the complete create, save, publish, and invoke workflow.
|
|
691
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
|
+
|
|
692
802
|
Permute authenticates callers and checks the action’s required invocation
|
|
693
803
|
permission. Hosted app calls also require app access, an app invocation grant,
|
|
694
804
|
and the action revision pinned in the served release. Administrators approve
|
|
@@ -729,6 +839,10 @@ values up to 500). Invalid rejections and unexpected exceptions return a safe
|
|
|
729
839
|
`execution_failed` response; exception text stays in server logs.
|
|
730
840
|
|
|
731
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.
|
|
732
846
|
Branch on the code (not message text):
|
|
733
847
|
|
|
734
848
|
| Code | HTTP | Recovery |
|
|
@@ -749,7 +863,9 @@ does not establish that nothing happened; reconcile provider state first.
|
|
|
749
863
|
### Connector bindings
|
|
750
864
|
|
|
751
865
|
An action definition can bind multiple connector accounts by name alongside its database
|
|
752
|
-
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.
|
|
753
869
|
The action runs with those delegated capabilities; callers need its invocation permission.
|
|
754
870
|
|
|
755
871
|
```ts
|
|
@@ -836,3 +952,13 @@ await client.apps.setAddress(app.id, {
|
|
|
836
952
|
```
|
|
837
953
|
|
|
838
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,3 +1,5 @@
|
|
|
1
|
+
import type { AccessRequest, AccessRequestInput, AccessRequestList } from './types.js';
|
|
2
|
+
export type { AccessRequest, AccessRequestInput, AccessRequestList } from './types.js';
|
|
1
3
|
import type { ActionRequest, ActionReceipt, QueryRequest, QueryResponse, AppUser, ChatSendRequest, ChatDecisionRequest, ChatResponse, SourceList, SourceDetail } from './types.js';
|
|
2
4
|
export { PermuteApiError } from './errors.js';
|
|
3
5
|
export type { ActionRequest, ActionReceipt, QueryRequest, QueryResponse, AppUser, ChatSendRequest, ChatDecisionRequest, ChatResponse, SourceList, SourceDetail, } from './types.js';
|
|
@@ -15,6 +17,12 @@ export declare class PermuteAppClient {
|
|
|
15
17
|
private readonly revisionId;
|
|
16
18
|
private readonly fetcher;
|
|
17
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
|
+
};
|
|
18
26
|
readonly actions: {
|
|
19
27
|
/** Keep requestId and input unchanged when retrying an uncertain result. */
|
|
20
28
|
invoke: (actionId: string, request: ActionRequest, options?: AppRequestOptions) => Promise<ActionReceipt>;
|
package/dist/app.js
CHANGED
|
@@ -19,6 +19,12 @@ export class PermuteAppClient {
|
|
|
19
19
|
this.revisionId = revision[1];
|
|
20
20
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
21
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
|
+
};
|
|
22
28
|
actions = {
|
|
23
29
|
/** Keep requestId and input unchanged when retrying an uncertain result. */
|
|
24
30
|
invoke: (actionId, request, options) => this.request(`actions/${encodeURIComponent(actionId)}/invoke`, request, options),
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { PermuteClient } from '../client.js';
|
|
2
|
+
import { type CredentialStore, type LoginCredentials } from './store.js';
|
|
3
|
+
export { SystemCredentialStore, type CredentialStore, type LoginCredentials } from './store.js';
|
|
4
|
+
export declare const DEFAULT_ISSUER = "https://auth.permute.ai";
|
|
5
|
+
export declare const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
6
|
+
export interface OAuthOptions {
|
|
7
|
+
issuer?: string;
|
|
8
|
+
apiUrl?: string;
|
|
9
|
+
store?: CredentialStore;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
export declare class OAuthLogin {
|
|
13
|
+
readonly issuer: string;
|
|
14
|
+
readonly apiUrl: string;
|
|
15
|
+
private readonly store;
|
|
16
|
+
private readonly fetchImpl;
|
|
17
|
+
constructor(options?: OAuthOptions);
|
|
18
|
+
private endpoint;
|
|
19
|
+
private post;
|
|
20
|
+
private tokens;
|
|
21
|
+
private persist;
|
|
22
|
+
beginDevice(): Promise<{
|
|
23
|
+
deviceCode: string;
|
|
24
|
+
userCode: string;
|
|
25
|
+
url: string;
|
|
26
|
+
expiresIn: number;
|
|
27
|
+
expiresAt: number;
|
|
28
|
+
interval: number;
|
|
29
|
+
}>;
|
|
30
|
+
finishDevice(request: Awaited<ReturnType<OAuthLogin['beginDevice']>>, signal?: AbortSignal): Promise<{
|
|
31
|
+
issuer: string;
|
|
32
|
+
apiUrl: string;
|
|
33
|
+
workspaceId: string | undefined;
|
|
34
|
+
organizationId: string;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
access: string;
|
|
37
|
+
refresh: string;
|
|
38
|
+
expiresAt: number;
|
|
39
|
+
}>;
|
|
40
|
+
beginBrowser(redirectURI: string): {
|
|
41
|
+
url: string;
|
|
42
|
+
verifier: string;
|
|
43
|
+
state: string;
|
|
44
|
+
redirectURI: string;
|
|
45
|
+
};
|
|
46
|
+
finishBrowser(request: ReturnType<OAuthLogin['beginBrowser']>, callback: URL): Promise<{
|
|
47
|
+
issuer: string;
|
|
48
|
+
apiUrl: string;
|
|
49
|
+
workspaceId: string | undefined;
|
|
50
|
+
organizationId: string;
|
|
51
|
+
sessionId: string;
|
|
52
|
+
access: string;
|
|
53
|
+
refresh: string;
|
|
54
|
+
expiresAt: number;
|
|
55
|
+
}>;
|
|
56
|
+
credentials(): Promise<LoginCredentials>;
|
|
57
|
+
client(): Promise<PermuteClient>;
|
|
58
|
+
logout(): Promise<void>;
|
|
59
|
+
private revoke;
|
|
60
|
+
}
|
|
61
|
+
export declare class OAuthLoginError extends Error {
|
|
62
|
+
readonly code: string;
|
|
63
|
+
constructor(code: string);
|
|
64
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { setTimeout } from 'node:timers/promises';
|
|
4
|
+
import { PermuteClient } from '../client.js';
|
|
5
|
+
import { SystemCredentialStore } from './store.js';
|
|
6
|
+
export { SystemCredentialStore } from './store.js';
|
|
7
|
+
export const DEFAULT_ISSUER = 'https://auth.permute.ai';
|
|
8
|
+
const CLIENT_ID = 'permute-cli';
|
|
9
|
+
export const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
10
|
+
const TokensSchema = z.object({
|
|
11
|
+
access_token: z.string().min(1),
|
|
12
|
+
refresh_token: z.string().min(1),
|
|
13
|
+
expires_in: z.number().int().positive(),
|
|
14
|
+
workspace_id: z.string().min(1).optional(),
|
|
15
|
+
organization_id: z.string().min(1),
|
|
16
|
+
session_id: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
const DeviceSchema = z.object({
|
|
19
|
+
device_code: z.string().min(1),
|
|
20
|
+
user_code: z.string().min(1),
|
|
21
|
+
verification_uri_complete: z.string().url(),
|
|
22
|
+
expires_in: z.number().int().positive(),
|
|
23
|
+
interval: z.number().int().min(5),
|
|
24
|
+
});
|
|
25
|
+
export class OAuthLogin {
|
|
26
|
+
issuer;
|
|
27
|
+
apiUrl;
|
|
28
|
+
store;
|
|
29
|
+
fetchImpl;
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
this.issuer = this.endpoint(options.issuer ?? DEFAULT_ISSUER);
|
|
32
|
+
this.apiUrl = this.endpoint(options.apiUrl ?? 'https://api.permute.ai');
|
|
33
|
+
this.store = options.store ?? new SystemCredentialStore(this.issuer);
|
|
34
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
35
|
+
}
|
|
36
|
+
endpoint(value) {
|
|
37
|
+
const url = new URL(value);
|
|
38
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))) ||
|
|
39
|
+
url.username ||
|
|
40
|
+
url.password ||
|
|
41
|
+
url.search ||
|
|
42
|
+
url.hash ||
|
|
43
|
+
url.pathname !== '/')
|
|
44
|
+
throw new Error('Use an HTTPS origin (or loopback for local development).');
|
|
45
|
+
return url.origin;
|
|
46
|
+
}
|
|
47
|
+
async post(path, input) {
|
|
48
|
+
const response = await this.fetchImpl(`${this.issuer}${path}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
51
|
+
body: new URLSearchParams(input),
|
|
52
|
+
signal: AbortSignal.timeout(15_000),
|
|
53
|
+
redirect: 'error',
|
|
54
|
+
});
|
|
55
|
+
const result = await response.json();
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const error = z.object({ error: z.string().max(100) }).safeParse(result);
|
|
58
|
+
throw new OAuthLoginError(error.success ? error.data.error : 'login_failed');
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
tokens(value) {
|
|
63
|
+
const parsed = TokensSchema.safeParse(value);
|
|
64
|
+
if (!parsed.success)
|
|
65
|
+
throw new Error('Invalid credential response');
|
|
66
|
+
return parsed.data;
|
|
67
|
+
}
|
|
68
|
+
async persist(tokens) {
|
|
69
|
+
const credentials = {
|
|
70
|
+
issuer: this.issuer,
|
|
71
|
+
apiUrl: this.apiUrl,
|
|
72
|
+
workspaceId: tokens.workspace_id,
|
|
73
|
+
organizationId: tokens.organization_id,
|
|
74
|
+
sessionId: tokens.session_id,
|
|
75
|
+
access: tokens.access_token,
|
|
76
|
+
refresh: tokens.refresh_token,
|
|
77
|
+
expiresAt: Date.now() + tokens.expires_in * 1000,
|
|
78
|
+
};
|
|
79
|
+
try {
|
|
80
|
+
const previous = await this.store.read();
|
|
81
|
+
if (previous && previous.sessionId !== tokens.session_id)
|
|
82
|
+
await this.revoke(previous.refresh);
|
|
83
|
+
await this.store.write(credentials);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// Failed replacement or secure storage must not orphan the new credential.
|
|
87
|
+
await this.revoke(tokens.refresh_token);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return credentials;
|
|
91
|
+
}
|
|
92
|
+
async beginDevice() {
|
|
93
|
+
const parsed = DeviceSchema.safeParse(await this.post('/cli/device/authorization', { client_id: CLIENT_ID, scope: 'workspace' }));
|
|
94
|
+
if (!parsed.success)
|
|
95
|
+
throw new Error('Invalid device login response');
|
|
96
|
+
const result = parsed.data;
|
|
97
|
+
return {
|
|
98
|
+
deviceCode: result.device_code,
|
|
99
|
+
userCode: result.user_code,
|
|
100
|
+
url: result.verification_uri_complete,
|
|
101
|
+
expiresIn: result.expires_in,
|
|
102
|
+
expiresAt: Date.now() + result.expires_in * 1000,
|
|
103
|
+
interval: result.interval,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async finishDevice(request, signal) {
|
|
107
|
+
const deadline = request.expiresAt;
|
|
108
|
+
let interval = request.interval;
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
await setTimeout(interval * 1000, undefined, { signal });
|
|
111
|
+
try {
|
|
112
|
+
return await this.store.exclusive(async () => {
|
|
113
|
+
const tokens = this.tokens(await this.post('/token', {
|
|
114
|
+
client_id: CLIENT_ID,
|
|
115
|
+
grant_type: DEVICE_GRANT,
|
|
116
|
+
device_code: request.deviceCode,
|
|
117
|
+
}));
|
|
118
|
+
return this.persist(tokens);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (!(error instanceof OAuthLoginError))
|
|
123
|
+
throw error;
|
|
124
|
+
if (error.code === 'authorization_pending')
|
|
125
|
+
continue;
|
|
126
|
+
if (error.code === 'slow_down') {
|
|
127
|
+
interval += 5;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new OAuthLoginError('expired_token');
|
|
134
|
+
}
|
|
135
|
+
beginBrowser(redirectURI) {
|
|
136
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
137
|
+
const state = randomBytes(32).toString('base64url');
|
|
138
|
+
const url = new URL(`${this.issuer}/authorize`);
|
|
139
|
+
url.search = new URLSearchParams({
|
|
140
|
+
client_id: CLIENT_ID,
|
|
141
|
+
scope: 'workspace',
|
|
142
|
+
response_type: 'code',
|
|
143
|
+
redirect_uri: redirectURI,
|
|
144
|
+
state,
|
|
145
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'),
|
|
146
|
+
code_challenge_method: 'S256',
|
|
147
|
+
}).toString();
|
|
148
|
+
return { url: url.toString(), verifier, state, redirectURI };
|
|
149
|
+
}
|
|
150
|
+
async finishBrowser(request, callback) {
|
|
151
|
+
if (callback.origin + callback.pathname !== request.redirectURI ||
|
|
152
|
+
callback.searchParams.get('state') !== request.state)
|
|
153
|
+
throw new Error('Login callback did not match this request');
|
|
154
|
+
if (callback.searchParams.has('error'))
|
|
155
|
+
throw new OAuthLoginError('access_denied');
|
|
156
|
+
const code = callback.searchParams.get('code');
|
|
157
|
+
if (!code)
|
|
158
|
+
throw new Error('Login callback has no authorization code');
|
|
159
|
+
return this.store.exclusive(async () => {
|
|
160
|
+
const tokens = this.tokens(await this.post('/token', {
|
|
161
|
+
client_id: CLIENT_ID,
|
|
162
|
+
grant_type: 'authorization_code',
|
|
163
|
+
redirect_uri: request.redirectURI,
|
|
164
|
+
code_verifier: request.verifier,
|
|
165
|
+
code,
|
|
166
|
+
}));
|
|
167
|
+
return this.persist(tokens);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async credentials() {
|
|
171
|
+
return this.store.exclusive(async () => {
|
|
172
|
+
const current = await this.store.read();
|
|
173
|
+
if (!current || current.apiUrl !== this.apiUrl)
|
|
174
|
+
throw new Error('No login for this server. Run permute auth login.');
|
|
175
|
+
if (current.expiresAt > Date.now() + 60_000)
|
|
176
|
+
return current;
|
|
177
|
+
const tokens = this.tokens(await this.post('/token', {
|
|
178
|
+
client_id: CLIENT_ID,
|
|
179
|
+
grant_type: 'refresh_token',
|
|
180
|
+
refresh_token: current.refresh,
|
|
181
|
+
}));
|
|
182
|
+
return this.persist(tokens);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
async client() {
|
|
186
|
+
const credentials = await this.credentials();
|
|
187
|
+
return new PermuteClient({
|
|
188
|
+
baseUrl: this.apiUrl,
|
|
189
|
+
fetch: this.fetchImpl,
|
|
190
|
+
workspaceId: credentials.workspaceId,
|
|
191
|
+
accessToken: async () => (await this.credentials()).access,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async logout() {
|
|
195
|
+
await this.store.exclusive(async () => {
|
|
196
|
+
const current = await this.store.read();
|
|
197
|
+
if (!current)
|
|
198
|
+
return;
|
|
199
|
+
// Keep the local record on failure so revocation can be retried.
|
|
200
|
+
await this.revoke(current.refresh);
|
|
201
|
+
await this.store.remove();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async revoke(refresh) {
|
|
205
|
+
const response = await this.fetchImpl(`${this.issuer}/revoke`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
body: new URLSearchParams({ client_id: CLIENT_ID, token: refresh }),
|
|
208
|
+
signal: AbortSignal.timeout(15_000),
|
|
209
|
+
redirect: 'error',
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
throw new Error('Could not revoke login. Retry logout when connected.');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export class OAuthLoginError extends Error {
|
|
216
|
+
code;
|
|
217
|
+
constructor(code) {
|
|
218
|
+
super(`Login failed (${code}). Run permute auth login to try again.`);
|
|
219
|
+
this.code = code;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const LoginCredentialsSchema: z.ZodObject<{
|
|
3
|
+
issuer: z.ZodString;
|
|
4
|
+
apiUrl: z.ZodString;
|
|
5
|
+
workspaceId: z.ZodOptional<z.ZodString>;
|
|
6
|
+
organizationId: z.ZodOptional<z.ZodString>;
|
|
7
|
+
sessionId: z.ZodString;
|
|
8
|
+
access: z.ZodString;
|
|
9
|
+
refresh: z.ZodString;
|
|
10
|
+
expiresAt: z.ZodNumber;
|
|
11
|
+
}, z.core.$strip>;
|
|
12
|
+
export type LoginCredentials = z.infer<typeof LoginCredentialsSchema>;
|
|
13
|
+
export interface CredentialStore {
|
|
14
|
+
read(): Promise<LoginCredentials | null>;
|
|
15
|
+
write(value: LoginCredentials): Promise<void>;
|
|
16
|
+
remove(): Promise<void>;
|
|
17
|
+
exclusive<T>(run: () => Promise<T>): Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
/** Tokens never fall back to a plaintext file. The disk lock contains no credentials. */
|
|
20
|
+
export declare class SystemCredentialStore implements CredentialStore {
|
|
21
|
+
private readonly issuer;
|
|
22
|
+
private readonly account;
|
|
23
|
+
constructor(issuer: string);
|
|
24
|
+
private entry;
|
|
25
|
+
read(): Promise<{
|
|
26
|
+
issuer: string;
|
|
27
|
+
apiUrl: string;
|
|
28
|
+
workspaceId?: string | undefined;
|
|
29
|
+
organizationId?: string | undefined;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
access: string;
|
|
32
|
+
refresh: string;
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
} | null>;
|
|
35
|
+
write(value: LoginCredentials): Promise<void>;
|
|
36
|
+
remove(): Promise<void>;
|
|
37
|
+
exclusive<T>(run: () => Promise<T>): Promise<T>;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { mkdir, rm } from 'node:fs/promises';
|
|
6
|
+
import { setTimeout } from 'node:timers/promises';
|
|
7
|
+
const LoginCredentialsSchema = z.object({
|
|
8
|
+
issuer: z.string().url(),
|
|
9
|
+
apiUrl: z.string().url(),
|
|
10
|
+
workspaceId: z.string().min(1).optional(),
|
|
11
|
+
organizationId: z.string().min(1).optional(),
|
|
12
|
+
sessionId: z.string().min(1),
|
|
13
|
+
access: z.string().min(1),
|
|
14
|
+
refresh: z.string().min(1),
|
|
15
|
+
expiresAt: z.number().finite(),
|
|
16
|
+
});
|
|
17
|
+
/** Tokens never fall back to a plaintext file. The disk lock contains no credentials. */
|
|
18
|
+
export class SystemCredentialStore {
|
|
19
|
+
issuer;
|
|
20
|
+
account;
|
|
21
|
+
constructor(issuer) {
|
|
22
|
+
this.issuer = issuer;
|
|
23
|
+
this.account = createHash('sha256').update(issuer).digest('hex');
|
|
24
|
+
}
|
|
25
|
+
async entry() {
|
|
26
|
+
const { AsyncEntry } = await import('@napi-rs/keyring');
|
|
27
|
+
return new AsyncEntry('permute-cli', this.account, { linux: { store: 'secret-service' } });
|
|
28
|
+
}
|
|
29
|
+
async read() {
|
|
30
|
+
const value = await (await this.entry()).getPassword();
|
|
31
|
+
if (!value)
|
|
32
|
+
return null;
|
|
33
|
+
const parsed = LoginCredentialsSchema.safeParse(JSON.parse(value));
|
|
34
|
+
if (!parsed.success || parsed.data.issuer !== this.issuer)
|
|
35
|
+
throw new Error('Stored login is invalid. Run permute auth login again.');
|
|
36
|
+
return parsed.data;
|
|
37
|
+
}
|
|
38
|
+
async write(value) {
|
|
39
|
+
await (await this.entry()).setPassword(JSON.stringify(value));
|
|
40
|
+
}
|
|
41
|
+
async remove() {
|
|
42
|
+
const entry = await this.entry();
|
|
43
|
+
if (await entry.getPassword())
|
|
44
|
+
await entry.deletePassword();
|
|
45
|
+
}
|
|
46
|
+
async exclusive(run) {
|
|
47
|
+
const directory = join(homedir(), '.config', 'permute', 'auth');
|
|
48
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
49
|
+
const lock = join(directory, `${this.account}.lock`);
|
|
50
|
+
const deadline = Date.now() + 30_000;
|
|
51
|
+
while (true) {
|
|
52
|
+
const acquired = await mkdir(lock, { mode: 0o700 }).then(() => true, (error) => {
|
|
53
|
+
if (error.code !== 'EEXIST')
|
|
54
|
+
throw error;
|
|
55
|
+
return false;
|
|
56
|
+
});
|
|
57
|
+
if (acquired)
|
|
58
|
+
break;
|
|
59
|
+
if (Date.now() >= deadline)
|
|
60
|
+
throw new Error(`Another process is updating this login. Retry after it finishes. If a process crashed, stop other Permute CLI processes before removing ${lock}.`);
|
|
61
|
+
await setTimeout(100);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return await run();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await rm(lock, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './auth/oauth.js';
|