@userreason/cli 0.1.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 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +103 -0
- package/dist/client.d.ts +56 -0
- package/dist/client.js +140 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +88 -0
- package/dist/contracts.d.ts +55 -0
- package/dist/contracts.js +58 -0
- package/dist/credentials.d.ts +3 -0
- package/dist/credentials.js +50 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/lock.d.ts +2 -0
- package/dist/lock.js +45 -0
- package/dist/workos.d.ts +21 -0
- package/dist/workos.js +97 -0
- package/package.json +19 -0
- package/skills/userreason/SKILL.md +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @userreason/cli
|
|
2
|
+
|
|
3
|
+
The public CLI uses the same authenticated onboarding API as the dashboard and MCP. This source package is prepared for publication; the npm package is not published by building it.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm ci
|
|
7
|
+
npm test
|
|
8
|
+
node dist/bin.js --help
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
After publication:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx -y @userreason/cli@latest login --open
|
|
15
|
+
npx -y @userreason/cli@latest whoami --json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The CLI discovers the public WorkOS client ID from `/onboarding/v1/auth-config` when no override, environment value or saved configuration exists. Discovery is bounded, timed out, environment checked and never follows redirects or receives authentication secrets. The public client ID is configuration, not a secret; `--client-id` or `UR_WORKOS_CLIENT_ID` can override it. `--dev` selects `https://api-dev.userreason.com` and uses `UR_WORKOS_DEV_CLIENT_ID` when provided. Production defaults to `https://api.userreason.com`. `--api-url` accepts HTTPS origins or loopback HTTP for local development. Successful login saves nonsecret client/API configuration separately for each environment.
|
|
19
|
+
|
|
20
|
+
Login uses [WorkOS CLI Auth](https://workos.com/docs/authkit/cli-auth): a public device client confirms a code in the browser, polls at the prescribed interval and receives access/refresh tokens. No WorkOS API key, client secret or UserReason signing key is required. Device codes and tokens are never printed.
|
|
21
|
+
|
|
22
|
+
Credentials live in macOS Keychain, Windows Credential Manager or Linux Secret Service through `@napi-rs/keyring`. There is no plaintext fallback. If the store is unavailable, enable it before logging in. Rotating refresh-token exchanges are serialized across CLI/wizard processes. Credentials are separated by API origin and WorkOS client ID. On provider outages, stored credentials are retained; a revoked/expired refresh grant requires a new login.
|
|
23
|
+
|
|
24
|
+
Authentication does not grant workspace or site access to an agent. A human approves a local UserReason grant through the dashboard:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
userreason grant request \
|
|
28
|
+
--scope workspace:create \
|
|
29
|
+
--scope workspace:read \
|
|
30
|
+
--scope site:create \
|
|
31
|
+
--scope site:read \
|
|
32
|
+
--scope site:update \
|
|
33
|
+
--wait
|
|
34
|
+
|
|
35
|
+
userreason workspace create --name "Acme" --json
|
|
36
|
+
userreason site create \
|
|
37
|
+
--workspace WORKSPACE_UUID \
|
|
38
|
+
--name "Acme application" \
|
|
39
|
+
--origin https://app.example.com \
|
|
40
|
+
--environment production \
|
|
41
|
+
--json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
An account-level workspace-creation grant permits one creation. The backend binds its remaining permissions to the new workspace and removes `workspace:create`. Creating another workspace requires another approval. For an existing workspace, include `--workspace WORKSPACE_UUID` when requesting a grant. Use `--site SITE_UUID` to request site restrictions. Survey editing and campaign publishing have separate scopes and are not requested by installation.
|
|
45
|
+
|
|
46
|
+
Use `--request-id UUID` and `--workspace-id UUID` or `--site-id UUID` to make externally orchestrated creation retries deterministic. Reuse the exact payload and IDs on retry. Default IDs are generated once per command and preserved across an access-token refresh; separate command invocations generate new IDs.
|
|
47
|
+
|
|
48
|
+
`--json` emits a single JSON result on stdout. Browser links, login instructions and errors use stderr. A nonzero exit code means the command failed. An authorization-required error may contain an `approval_url`; request and approve the required scope before retrying. The `x-ur-client` header is a transport hint: the Worker derives identity and grant binding from the verified token.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
userreason workspace list --json
|
|
52
|
+
userreason site list --workspace WORKSPACE_UUID --json
|
|
53
|
+
userreason grant status --id REQUEST_UUID --wait --json
|
|
54
|
+
userreason logout
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Approved agents can also use the delegated management endpoints:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
userreason query --workspace WORKSPACE_UUID --site SITE_UUID --resource surveys --limit 50 --json
|
|
61
|
+
userreason command --file survey-command.json --json
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The command file is a versioned management command with `action`, `workspace_id`, `request_id`, site/resource IDs and its action-specific fields. Edits/publishing/deletion also require the current `expected_revision` where defined by the API. The CLI streams at most 64 KB from the local JSON file, and the server validates the complete command and enforces the exact action's scope. Reuse the unchanged file/UUIDs for a retry. Commands named `*.delete` archive or disable records; responses and published versions remain immutable. Workspace/site creation uses the dedicated onboarding commands instead.
|
|
65
|
+
|
|
66
|
+
Queries support `sites`, `surveys`, `campaigns`, `events`, `responses` and `audits`, with a limit of 1–100 and an optional `--after` UUID from the server's `next` field. Site restrictions and current workspace membership are enforced server-side.
|
|
67
|
+
|
|
68
|
+
Logout removes local credentials. Revoke the relevant grant in the dashboard to remove server-side agent access. The packaged [UserReason skill](skills/userreason/SKILL.md) describes agent use of these commands.
|
|
69
|
+
|
|
70
|
+
The CLI uses `/onboarding/v1/...`; those endpoints require the new migrations, configured WorkOS environment and deployed Worker. Live provider authentication is not exercised by the local fixture tests.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
5
|
+
import { UserReasonClient, ApiError } from "./client.js";
|
|
6
|
+
import { resolveConfig, saveConfig } from "./config.js";
|
|
7
|
+
import { keychainCredentials } from "./credentials.js";
|
|
8
|
+
import { object, origin, scope, uuid } from "./contracts.js";
|
|
9
|
+
import { login } from "./workos.js";
|
|
10
|
+
const help = `UserReason CLI\n\nuserreason login [--open]\nuserreason logout\nuserreason whoami\nuserreason workspace list\nuserreason workspace create --name NAME\nuserreason site list --workspace UUID\nuserreason site create --workspace UUID --name NAME --origin URL [--origin URL]\nuserreason grant request --scope site:read [--scope site:create] [--workspace UUID] [--wait]\nuserreason grant status --id UUID [--wait]\nuserreason command --file COMMAND.json\nuserreason query --workspace UUID --resource surveys [--site UUID] [--limit 50] [--after UUID]\n\nGlobal options: --dev, --api-url ORIGIN, --client-id CLIENT_ID, --json\nCreation options: --request-id UUID, --workspace-id UUID / --site-id UUID\nSite environment: --environment development|production (default production)\nCredentials are stored in the OS credential store. WorkOS API keys are never required.\n`;
|
|
11
|
+
async function main() {
|
|
12
|
+
const parsed = parseArgs({ allowPositionals: true, strict: true, options: {
|
|
13
|
+
help: { type: "boolean", short: "h" }, json: { type: "boolean" }, dev: { type: "boolean" }, open: { type: "boolean" }, wait: { type: "boolean" },
|
|
14
|
+
"api-url": { type: "string" }, "client-id": { type: "string" }, name: { type: "string" }, workspace: { type: "string" }, id: { type: "string" },
|
|
15
|
+
origin: { type: "string", multiple: true }, scope: { type: "string", multiple: true }, site: { type: "string", multiple: true }, environment: { type: "string" },
|
|
16
|
+
"request-id": { type: "string" }, "workspace-id": { type: "string" }, "site-id": { type: "string" },
|
|
17
|
+
file: { type: "string" }, resource: { type: "string" }, limit: { type: "string" }, after: { type: "string" },
|
|
18
|
+
} });
|
|
19
|
+
const values = parsed.values, [command, action] = parsed.positionals;
|
|
20
|
+
if (values.help || !command) {
|
|
21
|
+
process.stdout.write(help);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const required = (key) => { const value = values[key]; if (typeof value !== "string" || !value)
|
|
25
|
+
throw new Error(`Missing --${key}`); return value; };
|
|
26
|
+
const config = await resolveConfig({ development: values.dev, apiUrl: values["api-url"], clientId: values["client-id"] });
|
|
27
|
+
const credentials = keychainCredentials(config), client = new UserReasonClient(config, credentials);
|
|
28
|
+
let result;
|
|
29
|
+
if (command === "login") {
|
|
30
|
+
const authenticated = await login(config, credentials, device => {
|
|
31
|
+
process.stderr.write(`Open ${device.verificationUriComplete}\nConfirm code: ${device.userCode}\n`);
|
|
32
|
+
if (values.open) {
|
|
33
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
|
34
|
+
const child = spawn(command, [device.verificationUriComplete], { stdio: "ignore", detached: true });
|
|
35
|
+
child.on("error", () => { });
|
|
36
|
+
child.unref();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
await saveConfig(config, values.dev);
|
|
40
|
+
result = { authenticated: true, email: authenticated.email, api_url: config.apiUrl };
|
|
41
|
+
}
|
|
42
|
+
else if (command === "logout") {
|
|
43
|
+
await credentials.clear();
|
|
44
|
+
result = { logged_out: true, message: "Local credentials removed. Revoke approved agent access in the dashboard if needed." };
|
|
45
|
+
}
|
|
46
|
+
else if (command === "whoami")
|
|
47
|
+
result = await client.account();
|
|
48
|
+
else if (command === "workspace" && action === "list")
|
|
49
|
+
result = (await client.account()).workspaces;
|
|
50
|
+
else if (command === "workspace" && action === "create")
|
|
51
|
+
result = await client.createWorkspace(required("name"), { requestId: values["request-id"], workspaceId: values["workspace-id"] });
|
|
52
|
+
else if (command === "site" && action === "list")
|
|
53
|
+
result = await client.sites(uuid(required("workspace")));
|
|
54
|
+
else if (command === "site" && action === "create") {
|
|
55
|
+
const environment = values.environment ?? (values.dev ? "development" : "production");
|
|
56
|
+
if (environment !== "production" && environment !== "development")
|
|
57
|
+
throw new Error("Invalid --environment");
|
|
58
|
+
if (!values.origin?.length)
|
|
59
|
+
throw new Error("Supply one or more --origin values");
|
|
60
|
+
result = await client.createSite({ workspaceId: uuid(required("workspace")), name: required("name"), environment, origins: [...new Set(values.origin.map(value => origin(value, environment)))], requestId: values["request-id"], siteId: values["site-id"] });
|
|
61
|
+
}
|
|
62
|
+
else if (command === "grant" && action === "request") {
|
|
63
|
+
const request = await client.requestGrant({ workspaceId: values.workspace, siteIds: values.site, scopes: (values.scope ?? []).flatMap(value => value.split(",")).map(scope), requestId: values["request-id"] });
|
|
64
|
+
if (request.approval_url)
|
|
65
|
+
process.stderr.write(`Approve access: ${request.approval_url}\n`);
|
|
66
|
+
result = values.wait ? await client.waitForGrant(request.id) : request;
|
|
67
|
+
}
|
|
68
|
+
else if (command === "grant" && action === "status")
|
|
69
|
+
result = values.wait ? await client.waitForGrant(required("id")) : await client.grantStatus(required("id"));
|
|
70
|
+
else if (command === "command") {
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let size = 0;
|
|
73
|
+
for await (const part of createReadStream(required("file"), { highWaterMark: 16_384 })) {
|
|
74
|
+
const chunk = Buffer.from(part);
|
|
75
|
+
size += chunk.byteLength;
|
|
76
|
+
if (size > 65_536)
|
|
77
|
+
throw new Error("Command JSON file exceeds 64 KB");
|
|
78
|
+
chunks.push(chunk);
|
|
79
|
+
}
|
|
80
|
+
let body;
|
|
81
|
+
try {
|
|
82
|
+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error("Command file must contain valid UTF-8 JSON");
|
|
86
|
+
}
|
|
87
|
+
result = await client.command(object(body, "command"));
|
|
88
|
+
}
|
|
89
|
+
else if (command === "query") {
|
|
90
|
+
if (values.site && values.site.length !== 1)
|
|
91
|
+
throw new Error("A query accepts one --site");
|
|
92
|
+
result = await client.query({ workspaceId: required("workspace"), siteId: values.site?.[0], resource: required("resource"), limit: values.limit === undefined ? undefined : Number(values.limit), after: values.after });
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
throw new Error("Unknown command. Run userreason --help");
|
|
96
|
+
process.stdout.write(JSON.stringify(result, null, values.json ? undefined : 2) + "\n");
|
|
97
|
+
}
|
|
98
|
+
main().catch(error => {
|
|
99
|
+
const json = process.argv.includes("--json");
|
|
100
|
+
const detail = { error: error instanceof ApiError ? error.code : "command_failed", message: error instanceof Error ? error.message : "Command failed", ...(error instanceof ApiError && error.approvalUrl ? { approval_url: error.approvalUrl } : {}) };
|
|
101
|
+
process.stderr.write((json ? JSON.stringify(detail) : detail.message) + "\n");
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type Account, type Credentials, type GrantRequest, type Scope, type Site, type Workspace } from "./contracts.js";
|
|
2
|
+
import type { ClientConfig } from "./config.js";
|
|
3
|
+
import { type Fetch } from "./workos.js";
|
|
4
|
+
export declare class ApiError extends Error {
|
|
5
|
+
status: number;
|
|
6
|
+
code: string;
|
|
7
|
+
approvalUrl?: string | undefined;
|
|
8
|
+
constructor(status: number, code: string, approvalUrl?: string | undefined);
|
|
9
|
+
}
|
|
10
|
+
export declare class UserReasonClient {
|
|
11
|
+
readonly config: ClientConfig;
|
|
12
|
+
private credentials;
|
|
13
|
+
private options;
|
|
14
|
+
private fetcher;
|
|
15
|
+
constructor(config: ClientConfig, credentials: Credentials, options?: {
|
|
16
|
+
fetcher?: Fetch;
|
|
17
|
+
lock?: <T>(operation: () => Promise<T>) => Promise<T>;
|
|
18
|
+
});
|
|
19
|
+
private token;
|
|
20
|
+
request(path: string, body?: unknown): Promise<unknown>;
|
|
21
|
+
account(): Promise<Account>;
|
|
22
|
+
createWorkspace(name: string, options?: {
|
|
23
|
+
requestId?: string;
|
|
24
|
+
workspaceId?: string;
|
|
25
|
+
}): Promise<Workspace>;
|
|
26
|
+
sites(workspaceId: string): Promise<Site[]>;
|
|
27
|
+
createSite(input: {
|
|
28
|
+
workspaceId: string;
|
|
29
|
+
name: string;
|
|
30
|
+
environment: Site["environment"];
|
|
31
|
+
origins: string[];
|
|
32
|
+
requestId?: string;
|
|
33
|
+
siteId?: string;
|
|
34
|
+
}): Promise<Site>;
|
|
35
|
+
requestGrant(input: {
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
siteIds?: string[];
|
|
38
|
+
scopes: Scope[];
|
|
39
|
+
requestId?: string;
|
|
40
|
+
}): Promise<GrantRequest>;
|
|
41
|
+
grantStatus(id: string): Promise<GrantRequest>;
|
|
42
|
+
/** The server parses the complete versioned command and derives authorization from the verified caller. */
|
|
43
|
+
command(input: Record<string, unknown>): Promise<unknown>;
|
|
44
|
+
query(input: {
|
|
45
|
+
workspaceId: string;
|
|
46
|
+
siteId?: string;
|
|
47
|
+
resource: string;
|
|
48
|
+
limit?: number;
|
|
49
|
+
after?: string;
|
|
50
|
+
}): Promise<unknown>;
|
|
51
|
+
waitForGrant(id: string, options?: {
|
|
52
|
+
timeoutMs?: number;
|
|
53
|
+
intervalMs?: number;
|
|
54
|
+
onStatus?: (request: GrantRequest) => void;
|
|
55
|
+
}): Promise<GrantRequest>;
|
|
56
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { object, string, uuid, workspace, site, grantRequest } from "./contracts.js";
|
|
3
|
+
import { refresh } from "./workos.js";
|
|
4
|
+
import { withSessionLock } from "./lock.js";
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
approvalUrl;
|
|
9
|
+
constructor(status, code, approvalUrl) {
|
|
10
|
+
super(`UserReason request failed (${status}, ${code})${approvalUrl ? `. Approve access at ${approvalUrl}` : ""}`);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.approvalUrl = approvalUrl;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class UserReasonClient {
|
|
17
|
+
config;
|
|
18
|
+
credentials;
|
|
19
|
+
options;
|
|
20
|
+
fetcher;
|
|
21
|
+
constructor(config, credentials, options = {}) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
this.credentials = credentials;
|
|
24
|
+
this.options = options;
|
|
25
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
26
|
+
}
|
|
27
|
+
async token(forceRefresh = false, previous) {
|
|
28
|
+
const lock = this.options.lock ?? ((operation) => withSessionLock(this.config, operation));
|
|
29
|
+
return lock(async () => {
|
|
30
|
+
const current = await this.credentials.load();
|
|
31
|
+
if (!current)
|
|
32
|
+
throw new Error("Run userreason login first");
|
|
33
|
+
if (current.apiUrl !== this.config.apiUrl || current.clientId !== this.config.clientId)
|
|
34
|
+
throw new Error("Stored login belongs to a different environment or WorkOS client");
|
|
35
|
+
if (current.expiresAt > Date.now() + 60_000 && (!forceRefresh || current.accessToken !== previous))
|
|
36
|
+
return current;
|
|
37
|
+
return refresh(this.config, this.credentials, current, this.fetcher);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async request(path, body) {
|
|
41
|
+
if (!path.startsWith("/onboarding/v1/") || path.includes("//") || path.includes("\\"))
|
|
42
|
+
throw new Error("Invalid UserReason API path");
|
|
43
|
+
let session = await this.token();
|
|
44
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
45
|
+
const response = await this.fetcher(this.config.apiUrl + path, { method: body === undefined ? "GET" : "POST", redirect: "error", headers: { Authorization: `Bearer ${session.accessToken}`, "x-ur-client": "cli", "Content-Type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(20_000) });
|
|
46
|
+
if (response.status === 401 && attempt === 0) {
|
|
47
|
+
session = await this.token(true, session.accessToken);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
result = await response.json();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new ApiError(response.status, "invalid_response");
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const data = object(result, "error"), detail = typeof data.error === "object" && data.error ? object(data.error, "error") : data;
|
|
59
|
+
const code = typeof detail.code === "string" && /^[a-z0-9_:-]{1,100}$/i.test(detail.code) ? detail.code : typeof data.error === "string" && /^[a-z0-9_:-]{1,100}$/i.test(data.error) ? data.error : "request_failed";
|
|
60
|
+
const candidate = detail.approval_url ?? data.approval_url;
|
|
61
|
+
let approvalUrl;
|
|
62
|
+
if (typeof candidate === "string") {
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(candidate);
|
|
65
|
+
if (url.protocol === "https:" && !url.username && !url.password)
|
|
66
|
+
approvalUrl = url.toString();
|
|
67
|
+
}
|
|
68
|
+
catch { /* Reject malformed URLs. */ }
|
|
69
|
+
}
|
|
70
|
+
throw new ApiError(response.status, code, approvalUrl);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
throw new ApiError(401, "authentication_required");
|
|
75
|
+
}
|
|
76
|
+
async account() {
|
|
77
|
+
const data = object(await this.request("/onboarding/v1/account"), "account"), user = object(data.user, "user");
|
|
78
|
+
uuid(string(user.id, "user ID"));
|
|
79
|
+
if (!Array.isArray(data.workspaces) || !Array.isArray(data.grants))
|
|
80
|
+
throw new Error("Invalid account memberships");
|
|
81
|
+
return { ...data, user, workspaces: data.workspaces.map(workspace) };
|
|
82
|
+
}
|
|
83
|
+
async createWorkspace(name, options = {}) {
|
|
84
|
+
if (!name.trim() || name.length > 240)
|
|
85
|
+
throw new Error("Workspace name must contain 1–240 characters");
|
|
86
|
+
const value = object(await this.request("/onboarding/v1/workspaces", { request_id: uuid(options.requestId ?? randomUUID()), workspace_id: uuid(options.workspaceId ?? randomUUID()), name: name.trim() }), "workspace creation");
|
|
87
|
+
return workspace(value.workspace ?? value);
|
|
88
|
+
}
|
|
89
|
+
async sites(workspaceId) {
|
|
90
|
+
const data = object(await this.request(`/onboarding/v1/workspaces/${uuid(workspaceId)}/sites`), "sites"), items = data.items ?? data.sites;
|
|
91
|
+
if (!Array.isArray(items))
|
|
92
|
+
throw new Error("Invalid site list");
|
|
93
|
+
return items.map(site);
|
|
94
|
+
}
|
|
95
|
+
async createSite(input) {
|
|
96
|
+
if (!input.name.trim() || input.name.length > 240)
|
|
97
|
+
throw new Error("Site name must contain 1–240 characters");
|
|
98
|
+
const value = object(await this.request("/onboarding/v1/sites", { request_id: uuid(input.requestId ?? randomUUID()), workspace_id: uuid(input.workspaceId), site_id: uuid(input.siteId ?? randomUUID()), name: input.name.trim(), environment: input.environment, allowed_origins: input.origins }), "site creation");
|
|
99
|
+
return site(value.site ?? value);
|
|
100
|
+
}
|
|
101
|
+
async requestGrant(input) {
|
|
102
|
+
if (!input.scopes.length || input.scopes.length > 7)
|
|
103
|
+
throw new Error("Request at least one supported permission");
|
|
104
|
+
return grantRequest(await this.request("/onboarding/v1/grants/request", { request_id: uuid(input.requestId ?? randomUUID()), workspace_id: input.workspaceId ? uuid(input.workspaceId) : null, site_ids: input.siteIds?.map(id => uuid(id)) ?? null, scopes: [...new Set(input.scopes)] }));
|
|
105
|
+
}
|
|
106
|
+
async grantStatus(id) { return grantRequest(await this.request(`/onboarding/v1/grants/requests/${uuid(id)}`)); }
|
|
107
|
+
/** The server parses the complete versioned command and derives authorization from the verified caller. */
|
|
108
|
+
async command(input) {
|
|
109
|
+
uuid(string(input.workspace_id, "workspace ID"));
|
|
110
|
+
uuid(string(input.request_id, "request ID"));
|
|
111
|
+
if (typeof input.action !== "string" || !/^[a-z]+\.[a-z]+$/.test(input.action))
|
|
112
|
+
throw new Error("Command requires a supported resource.action");
|
|
113
|
+
if (input.action === "workspace.create" || input.action === "site.create")
|
|
114
|
+
throw new Error("Use workspace create or site create for authenticated onboarding");
|
|
115
|
+
return this.request("/onboarding/v1/command", input);
|
|
116
|
+
}
|
|
117
|
+
async query(input) {
|
|
118
|
+
if (!["sites", "surveys", "campaigns", "events", "responses", "audits"].includes(input.resource))
|
|
119
|
+
throw new Error("Unsupported query resource");
|
|
120
|
+
const limit = input.limit ?? 50;
|
|
121
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
122
|
+
throw new Error("Query limit must be an integer from 1 to 100");
|
|
123
|
+
return this.request("/onboarding/v1/query", { workspace_id: uuid(input.workspaceId), ...(input.siteId ? { site_id: uuid(input.siteId) } : {}), resource: input.resource, limit, ...(input.after ? { after: uuid(input.after) } : {}) });
|
|
124
|
+
}
|
|
125
|
+
async waitForGrant(id, options = {}) {
|
|
126
|
+
const deadline = Date.now() + (options.timeoutMs ?? 300_000);
|
|
127
|
+
do {
|
|
128
|
+
const request = await this.grantStatus(id);
|
|
129
|
+
options.onStatus?.(request);
|
|
130
|
+
if (request.status === "approved")
|
|
131
|
+
return request;
|
|
132
|
+
if (request.status !== "pending")
|
|
133
|
+
throw new Error(`Access request ${request.status}; request access again if needed`);
|
|
134
|
+
if (request.expires_at && Date.parse(request.expires_at) <= Date.now())
|
|
135
|
+
throw new Error("Access request expired; request access again");
|
|
136
|
+
await new Promise(resolve => setTimeout(resolve, options.intervalMs ?? 2000));
|
|
137
|
+
} while (Date.now() < deadline);
|
|
138
|
+
throw new Error("Access approval timed out; check userreason grant status --id " + id);
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ClientConfig {
|
|
2
|
+
apiUrl: string;
|
|
3
|
+
clientId: string;
|
|
4
|
+
}
|
|
5
|
+
export declare const configDirectory: () => string;
|
|
6
|
+
export declare function discoverAuthConfig(url: string, development?: boolean, fetcher?: typeof fetch): Promise<string>;
|
|
7
|
+
export declare function resolveConfig(options?: {
|
|
8
|
+
development?: boolean;
|
|
9
|
+
apiUrl?: string;
|
|
10
|
+
clientId?: string;
|
|
11
|
+
}): Promise<ClientConfig>;
|
|
12
|
+
export declare function saveConfig(config: ClientConfig, development?: boolean): Promise<void>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { apiUrl, object } from "./contracts.js";
|
|
5
|
+
export const configDirectory = () => join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "userreason");
|
|
6
|
+
export async function discoverAuthConfig(url, development = false, fetcher = fetch) {
|
|
7
|
+
let response;
|
|
8
|
+
try {
|
|
9
|
+
response = await fetcher(`${apiUrl(url)}/onboarding/v1/auth-config`, { headers: { Accept: "application/json" }, redirect: "error", signal: AbortSignal.timeout(10_000) });
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error("Unable to discover UserReason authentication configuration. Check the API deployment or supply its public --client-id.");
|
|
13
|
+
}
|
|
14
|
+
if (!response.ok)
|
|
15
|
+
throw new Error(`UserReason authentication configuration is unavailable (${response.status}). Configure WorkOS in the Worker before running the wizard.`);
|
|
16
|
+
if (!response.headers.get("content-type")?.toLowerCase().startsWith("application/json") || !response.body)
|
|
17
|
+
throw new Error("Invalid UserReason authentication configuration response");
|
|
18
|
+
const reader = response.body.getReader(), chunks = [];
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
try {
|
|
21
|
+
while (true) {
|
|
22
|
+
const part = await reader.read();
|
|
23
|
+
if (part.done)
|
|
24
|
+
break;
|
|
25
|
+
bytes += part.value.byteLength;
|
|
26
|
+
if (bytes > 4096) {
|
|
27
|
+
await reader.cancel();
|
|
28
|
+
throw new Error("UserReason authentication configuration response is too large");
|
|
29
|
+
}
|
|
30
|
+
chunks.push(part.value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
reader.releaseLock();
|
|
35
|
+
}
|
|
36
|
+
const buffer = new Uint8Array(bytes);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
buffer.set(chunk, offset);
|
|
40
|
+
offset += chunk.byteLength;
|
|
41
|
+
}
|
|
42
|
+
let config;
|
|
43
|
+
try {
|
|
44
|
+
config = object(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer)), "authentication configuration");
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error("Invalid UserReason authentication configuration response");
|
|
48
|
+
}
|
|
49
|
+
if (Object.keys(config).some(key => !["client_id", "authkit_domain", "environment"].includes(key)) || typeof config.client_id !== "string" || !/^client_[A-Za-z0-9]{1,230}$/.test(config.client_id) || typeof config.authkit_domain !== "string" || config.authkit_domain.length > 2048)
|
|
50
|
+
throw new Error("Invalid UserReason authentication configuration response");
|
|
51
|
+
const domain = apiUrl(config.authkit_domain);
|
|
52
|
+
if (!domain.startsWith("https://") || domain !== config.authkit_domain)
|
|
53
|
+
throw new Error("Invalid WorkOS AuthKit domain in authentication configuration");
|
|
54
|
+
if (config.environment !== "local" && config.environment !== (development ? "development" : "production"))
|
|
55
|
+
throw new Error("API authentication environment does not match the selected CLI environment; use --dev for development");
|
|
56
|
+
return config.client_id;
|
|
57
|
+
}
|
|
58
|
+
export async function resolveConfig(options = {}) {
|
|
59
|
+
const environment = options.development ? "development" : "production";
|
|
60
|
+
let saved = {};
|
|
61
|
+
try {
|
|
62
|
+
const root = object(JSON.parse(await readFile(join(configDirectory(), "config.json"), "utf8")), "configuration");
|
|
63
|
+
saved = object(root[environment] ?? {}, "environment configuration");
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== "ENOENT")
|
|
67
|
+
throw new Error("Unable to read UserReason configuration");
|
|
68
|
+
}
|
|
69
|
+
const url = apiUrl(options.apiUrl ?? process.env.UR_API_URL ?? (typeof saved.apiUrl === "string" ? saved.apiUrl : options.development ? "https://api-dev.userreason.com" : "https://api.userreason.com"));
|
|
70
|
+
const configured = options.clientId ?? (options.development ? process.env.UR_WORKOS_DEV_CLIENT_ID : undefined) ?? process.env.UR_WORKOS_CLIENT_ID ?? (typeof saved.clientId === "string" ? saved.clientId : undefined);
|
|
71
|
+
const clientId = configured ?? await discoverAuthConfig(url, options.development);
|
|
72
|
+
if (!/^client_[A-Za-z0-9]+$/.test(clientId))
|
|
73
|
+
throw new Error("Set UR_WORKOS_CLIENT_ID or pass --client-id with the public WorkOS client ID. Never supply a WorkOS API key.");
|
|
74
|
+
return { apiUrl: url, clientId };
|
|
75
|
+
}
|
|
76
|
+
export async function saveConfig(config, development = false) {
|
|
77
|
+
await mkdir(configDirectory(), { recursive: true, mode: 0o700 });
|
|
78
|
+
let existing = {};
|
|
79
|
+
try {
|
|
80
|
+
existing = object(JSON.parse(await readFile(join(configDirectory(), "config.json"), "utf8")), "configuration");
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== "ENOENT")
|
|
84
|
+
throw new Error("Unable to read UserReason configuration");
|
|
85
|
+
}
|
|
86
|
+
existing[development ? "development" : "production"] = config;
|
|
87
|
+
await writeFile(join(configDirectory(), "config.json"), JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 });
|
|
88
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export declare const SCOPES: readonly ["workspace:create", "workspace:read", "site:create", "site:read", "site:update", "survey:write", "campaign:publish"];
|
|
2
|
+
export type Scope = typeof SCOPES[number];
|
|
3
|
+
export interface Workspace {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
role?: string;
|
|
7
|
+
revision?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface Site {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
workspace_id: string;
|
|
13
|
+
environment: "development" | "production";
|
|
14
|
+
allowed_origins: string[];
|
|
15
|
+
revision?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface Account {
|
|
18
|
+
user: {
|
|
19
|
+
id: string;
|
|
20
|
+
email?: string | null;
|
|
21
|
+
name?: string | null;
|
|
22
|
+
};
|
|
23
|
+
workspaces: Workspace[];
|
|
24
|
+
grants: unknown[];
|
|
25
|
+
}
|
|
26
|
+
export interface GrantRequest {
|
|
27
|
+
id: string;
|
|
28
|
+
status: "pending" | "approved" | "rejected" | "denied" | "expired";
|
|
29
|
+
approval_url?: string;
|
|
30
|
+
expires_at?: string;
|
|
31
|
+
grant_id?: string | null;
|
|
32
|
+
}
|
|
33
|
+
export interface Session {
|
|
34
|
+
accessToken: string;
|
|
35
|
+
refreshToken: string;
|
|
36
|
+
expiresAt: number;
|
|
37
|
+
userId: string;
|
|
38
|
+
email: string;
|
|
39
|
+
clientId: string;
|
|
40
|
+
apiUrl: string;
|
|
41
|
+
}
|
|
42
|
+
export interface Credentials {
|
|
43
|
+
load(): Promise<Session | null>;
|
|
44
|
+
save(session: Session): Promise<void>;
|
|
45
|
+
clear(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export declare function object(value: unknown, label: string): Record<string, unknown>;
|
|
48
|
+
export declare function string(value: unknown, label: string): string;
|
|
49
|
+
export declare function uuid(value: string, label?: string): string;
|
|
50
|
+
export declare function scope(value: string): Scope;
|
|
51
|
+
export declare function apiUrl(value: string): string;
|
|
52
|
+
export declare function origin(value: string, environment: Site["environment"]): string;
|
|
53
|
+
export declare function workspace(value: unknown): Workspace;
|
|
54
|
+
export declare function site(value: unknown): Site;
|
|
55
|
+
export declare function grantRequest(value: unknown): GrantRequest;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const SCOPES = ["workspace:create", "workspace:read", "site:create", "site:read", "site:update", "survey:write", "campaign:publish"];
|
|
2
|
+
export function object(value, label) {
|
|
3
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4
|
+
throw new Error(`Invalid ${label} response`);
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
export function string(value, label) {
|
|
8
|
+
if (typeof value !== "string" || value.length === 0)
|
|
9
|
+
throw new Error(`Invalid ${label}`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function uuid(value, label = "ID") {
|
|
13
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value))
|
|
14
|
+
throw new Error(`${label} must be a UUID`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
export function scope(value) {
|
|
18
|
+
if (!SCOPES.includes(value))
|
|
19
|
+
throw new Error(`Unknown scope: ${value}`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
export function apiUrl(value) {
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== "/")
|
|
25
|
+
throw new Error("API URL must be an origin without credentials, query or path");
|
|
26
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
27
|
+
throw new Error("API URL must use HTTPS; loopback HTTP is allowed for local development");
|
|
28
|
+
return url.origin;
|
|
29
|
+
}
|
|
30
|
+
export function origin(value, environment) {
|
|
31
|
+
const normalized = apiUrl(value);
|
|
32
|
+
if (environment === "production" && !normalized.startsWith("https://"))
|
|
33
|
+
throw new Error("Production sites require HTTPS origins");
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
export function workspace(value) {
|
|
37
|
+
const data = object(value, "workspace");
|
|
38
|
+
return { ...data, id: uuid(string(data.id, "workspace ID")), name: string(data.name, "workspace name") };
|
|
39
|
+
}
|
|
40
|
+
export function site(value) {
|
|
41
|
+
const data = object(value, "site");
|
|
42
|
+
if (data.environment !== "development" && data.environment !== "production")
|
|
43
|
+
throw new Error("Invalid site environment");
|
|
44
|
+
if (!Array.isArray(data.allowed_origins) || !data.allowed_origins.every(item => typeof item === "string"))
|
|
45
|
+
throw new Error("Invalid site origins");
|
|
46
|
+
return { ...data, id: uuid(string(data.id ?? data.site_id, "site ID")), workspace_id: uuid(string(data.workspace_id, "workspace ID")), name: string(data.name, "site name") };
|
|
47
|
+
}
|
|
48
|
+
export function grantRequest(value) {
|
|
49
|
+
const data = object(value, "grant request");
|
|
50
|
+
if (!["pending", "approved", "rejected", "denied", "expired"].includes(String(data.status)))
|
|
51
|
+
throw new Error("Invalid grant request status");
|
|
52
|
+
if (data.approval_url !== undefined) {
|
|
53
|
+
const url = new URL(string(data.approval_url, "approval URL"));
|
|
54
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
55
|
+
throw new Error("Invalid grant approval URL");
|
|
56
|
+
}
|
|
57
|
+
return { ...data, id: uuid(string(data.id, "grant request ID")) };
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function keychainCredentials(config) {
|
|
3
|
+
const account = createHash("sha256").update(`${config.apiUrl}\n${config.clientId}`).digest("hex");
|
|
4
|
+
async function entry() {
|
|
5
|
+
try {
|
|
6
|
+
const { Entry } = await import("@napi-rs/keyring");
|
|
7
|
+
return new Entry("com.userreason.cli", account, { linux: { store: "secret-service" } });
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw new Error("UserReason cannot access the OS credential store. Enable macOS Keychain, Windows Credential Manager or Linux Secret Service; credentials will not be written to a plaintext file.");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
async load() {
|
|
15
|
+
let serialized;
|
|
16
|
+
try {
|
|
17
|
+
serialized = (await entry()).getPassword();
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (/No entry|NoEntry|not found|not exist/i.test(String(error.message)))
|
|
21
|
+
return null;
|
|
22
|
+
throw new Error("Unable to read the UserReason OS credential-store entry");
|
|
23
|
+
}
|
|
24
|
+
if (!serialized)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const session = JSON.parse(serialized);
|
|
28
|
+
if (session.apiUrl !== config.apiUrl || session.clientId !== config.clientId || typeof session.accessToken !== "string" || typeof session.refreshToken !== "string" || typeof session.expiresAt !== "number")
|
|
29
|
+
throw new Error();
|
|
30
|
+
return session;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("Invalid stored UserReason session; run userreason logout, then login again");
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
async save(session) { try {
|
|
37
|
+
(await entry()).setPassword(JSON.stringify(session));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new Error("Unable to save the UserReason session in the OS credential store");
|
|
41
|
+
} },
|
|
42
|
+
async clear() { try {
|
|
43
|
+
(await entry()).deletePassword();
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (!/No entry|NoEntry|not found|not exist/i.test(String(error.message)))
|
|
47
|
+
throw new Error("Unable to remove the UserReason OS credential-store entry");
|
|
48
|
+
} },
|
|
49
|
+
};
|
|
50
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/lock.d.ts
ADDED
package/dist/lock.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { configDirectory } from "./config.js";
|
|
5
|
+
// Serialize rotating refresh tokens across CLI/wizard processes. No credentials enter this file.
|
|
6
|
+
export async function withSessionLock(config, operation) {
|
|
7
|
+
const directory = join(configDirectory(), "locks");
|
|
8
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
9
|
+
const path = join(directory, createHash("sha256").update(`${config.apiUrl}\n${config.clientId}`).digest("hex") + ".lock");
|
|
10
|
+
const id = randomUUID(), deadline = Date.now() + 35_000;
|
|
11
|
+
while (true) {
|
|
12
|
+
try {
|
|
13
|
+
const file = await open(path, "wx", 0o600);
|
|
14
|
+
await file.writeFile(JSON.stringify({ id, createdAt: Date.now() }));
|
|
15
|
+
await file.close();
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error.code !== "EEXIST")
|
|
20
|
+
throw new Error("Unable to lock the UserReason credential store");
|
|
21
|
+
try {
|
|
22
|
+
const old = JSON.parse(await readFile(path, "utf8"));
|
|
23
|
+
if (typeof old.createdAt === "number" && Date.now() - old.createdAt > 120_000) {
|
|
24
|
+
await unlink(path);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch { /* Another process may be writing or releasing its lock. */ }
|
|
29
|
+
if (Date.now() >= deadline)
|
|
30
|
+
throw new Error("Another UserReason process is refreshing credentials; try again shortly");
|
|
31
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return await operation();
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
try {
|
|
39
|
+
const own = JSON.parse(await readFile(path, "utf8"));
|
|
40
|
+
if (own.id === id)
|
|
41
|
+
await unlink(path);
|
|
42
|
+
}
|
|
43
|
+
catch { /* Cleanup does not replace the operation's result. */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/workos.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Credentials, Session } from "./contracts.js";
|
|
2
|
+
import type { ClientConfig } from "./config.js";
|
|
3
|
+
export type Fetch = typeof fetch;
|
|
4
|
+
export interface DeviceAuthorization {
|
|
5
|
+
userCode: string;
|
|
6
|
+
verificationUri: string;
|
|
7
|
+
verificationUriComplete: string;
|
|
8
|
+
expiresIn: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class AuthenticationError extends Error {
|
|
11
|
+
code: string;
|
|
12
|
+
constructor(code: string, message: string);
|
|
13
|
+
}
|
|
14
|
+
declare const sleep: (milliseconds: number) => Promise<void>;
|
|
15
|
+
export declare function login(config: ClientConfig, credentials: Credentials, onDevice: (device: DeviceAuthorization) => void | Promise<void>, options?: {
|
|
16
|
+
fetcher?: Fetch;
|
|
17
|
+
sleep?: typeof sleep;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
}): Promise<Session>;
|
|
20
|
+
export declare function refresh(config: ClientConfig, credentials: Credentials, current: Session, fetcher?: Fetch): Promise<Session>;
|
|
21
|
+
export {};
|
package/dist/workos.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { object, string } from "./contracts.js";
|
|
2
|
+
export class AuthenticationError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
10
|
+
const endpoint = "https://api.workos.com/user_management";
|
|
11
|
+
function session(data, config) {
|
|
12
|
+
const user = object(data.user, "authenticated user");
|
|
13
|
+
if (user.email_verified !== true)
|
|
14
|
+
throw new AuthenticationError("email_unverified", "Complete email verification before continuing");
|
|
15
|
+
const accessToken = string(data.access_token, "access token");
|
|
16
|
+
let expiresAt = Date.now() + 120_000;
|
|
17
|
+
try {
|
|
18
|
+
const payload = object(JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url").toString()), "token payload");
|
|
19
|
+
if (typeof payload.exp === "number" && Number.isFinite(payload.exp))
|
|
20
|
+
expiresAt = payload.exp * 1000;
|
|
21
|
+
}
|
|
22
|
+
catch { /* Server verifies the token; decoding here only schedules refresh. */ }
|
|
23
|
+
return { accessToken, refreshToken: string(data.refresh_token, "refresh token"), expiresAt, userId: string(user.id, "user ID"), email: string(user.email, "email"), ...config };
|
|
24
|
+
}
|
|
25
|
+
async function post(path, body, fetcher, signal = AbortSignal.timeout(20_000)) {
|
|
26
|
+
const response = await fetcher(`${endpoint}/${path}`, { method: "POST", redirect: "error", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body), signal });
|
|
27
|
+
const data = object(await response.json(), "authentication");
|
|
28
|
+
return { response, data };
|
|
29
|
+
}
|
|
30
|
+
export async function login(config, credentials, onDevice, options = {}) {
|
|
31
|
+
const fetcher = options.fetcher ?? fetch, wait = options.sleep ?? sleep, now = options.now ?? Date.now;
|
|
32
|
+
const authorization = await post("authorize/device", { client_id: config.clientId }, fetcher);
|
|
33
|
+
if (!authorization.response.ok)
|
|
34
|
+
throw new AuthenticationError("device_authorization_failed", `Unable to start WorkOS login (${authorization.response.status})`);
|
|
35
|
+
const data = authorization.data;
|
|
36
|
+
const expiresIn = Number(data.expires_in), initialInterval = Number(data.interval);
|
|
37
|
+
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 1800 || !Number.isFinite(initialInterval) || initialInterval < 1 || initialInterval > 60)
|
|
38
|
+
throw new Error("Invalid device authorization timing");
|
|
39
|
+
const deviceCode = string(data.device_code, "device authorization code");
|
|
40
|
+
const verificationUri = string(data.verification_uri, "verification URL"), verificationUriComplete = string(data.verification_uri_complete, "complete verification URL");
|
|
41
|
+
for (const uri of [verificationUri, verificationUriComplete]) {
|
|
42
|
+
const url = new URL(uri);
|
|
43
|
+
if (url.protocol !== "https:" || url.username || url.password)
|
|
44
|
+
throw new Error("WorkOS returned an invalid verification URL");
|
|
45
|
+
}
|
|
46
|
+
await onDevice({ userCode: string(data.user_code, "user authorization code"), verificationUri, verificationUriComplete, expiresIn });
|
|
47
|
+
let interval = initialInterval;
|
|
48
|
+
const deadline = now() + expiresIn * 1000;
|
|
49
|
+
while (now() < deadline) {
|
|
50
|
+
await wait(interval * 1000);
|
|
51
|
+
if (now() >= deadline)
|
|
52
|
+
break;
|
|
53
|
+
let result;
|
|
54
|
+
try {
|
|
55
|
+
result = await post("authenticate", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: config.clientId }, fetcher, AbortSignal.timeout(Math.min(20_000, deadline - now())));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
interval = Math.min(interval * 2, 30);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (result.response.ok) {
|
|
62
|
+
const authenticated = session(result.data, config);
|
|
63
|
+
await credentials.save(authenticated);
|
|
64
|
+
return authenticated;
|
|
65
|
+
}
|
|
66
|
+
const code = result.data.error ?? result.data.code;
|
|
67
|
+
if (code === "authorization_pending")
|
|
68
|
+
continue;
|
|
69
|
+
if (code === "slow_down") {
|
|
70
|
+
interval += 5;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (result.response.status >= 500 || result.response.status === 429) {
|
|
74
|
+
interval = Math.min(interval * 2, 30);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (code === "access_denied")
|
|
78
|
+
throw new AuthenticationError("access_denied", "Login was declined");
|
|
79
|
+
if (code === "expired_token")
|
|
80
|
+
break;
|
|
81
|
+
throw new AuthenticationError("authorization_failed", `WorkOS authorization failed (${result.response.status})`);
|
|
82
|
+
}
|
|
83
|
+
throw new AuthenticationError("expired_token", "The login code expired; run login again");
|
|
84
|
+
}
|
|
85
|
+
export async function refresh(config, credentials, current, fetcher = fetch) {
|
|
86
|
+
const result = await post("authenticate", { grant_type: "refresh_token", refresh_token: current.refreshToken, client_id: config.clientId }, fetcher);
|
|
87
|
+
if (!result.response.ok) {
|
|
88
|
+
if (result.data.error === "invalid_grant" || result.data.code === "invalid_grant") {
|
|
89
|
+
await credentials.clear();
|
|
90
|
+
throw new AuthenticationError("invalid_grant", "Your session expired or was revoked; run userreason login again");
|
|
91
|
+
}
|
|
92
|
+
throw new AuthenticationError("refresh_failed", `Unable to refresh your session (${result.response.status}); the stored session was retained`);
|
|
93
|
+
}
|
|
94
|
+
const updated = session(result.data, config);
|
|
95
|
+
await credentials.save(updated);
|
|
96
|
+
return updated;
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@userreason/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Authenticated UserReason onboarding and management CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": { "node": ">=22.12.0" },
|
|
8
|
+
"bin": { "userreason": "dist/bin.js" },
|
|
9
|
+
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
10
|
+
"files": ["dist", "README.md", "skills"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
15
|
+
"prepack": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": { "@napi-rs/keyring": "2.1.0" },
|
|
18
|
+
"devDependencies": { "@types/node": "^24.0.0", "typescript": "^5.9.0" }
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: userreason
|
|
3
|
+
description: Set up UserReason workspaces, sites and SDK installations through the authenticated UserReason CLI, or inspect existing setup. Use for UserReason onboarding and agent access; use other tooling for unrelated analytics products.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Use the installed `userreason` CLI or `npx -y @userreason/cli@latest`. Check `--help` for the available commands. Use `--json` for machine-readable results; stdout contains the result and stderr contains login/approval instructions.
|
|
7
|
+
|
|
8
|
+
Run `whoami` to discover the authenticated account and its approved workspaces. `--dev` selects the development environment. The CLI discovers the public WorkOS client ID from the API, with optional saved configuration, `UR_WORKOS_CLIENT_ID` or `--client-id` overrides; no API key or signing secret is needed. If login is required, run `login` and show the browser URL and confirmation code to the human. The human completes authentication. Credentials stay in the OS credential store.
|
|
9
|
+
|
|
10
|
+
Authentication and resource authorization are separate. An authorization-required response means the human must approve a grant through the provided dashboard URL. Request only the permissions needed for the task, then use `grant status --id REQUEST_UUID --wait`. A new-workspace installation needs `workspace:create`, `workspace:read`, `site:create`, `site:read` and optionally `site:update`; scope an existing-workspace request using `--workspace UUID`. Installation does not require `campaign:publish`.
|
|
11
|
+
|
|
12
|
+
List existing resources before creating duplicates. For intended workspace/site creation, generate request and resource UUIDs once, pass `--request-id` plus `--workspace-id`/`--site-id`, and reuse the same IDs and payload on retries. A workspace-creation grant is consumed once and its remaining permissions become bound to that workspace.
|
|
13
|
+
|
|
14
|
+
For approved management work, use `query --workspace UUID --site UUID --resource surveys|campaigns|events|responses|audits` and follow the server's `next` cursor with `--after`. Read the current resource revision before editing. Write the versioned management command to a local JSON file and run `command --file PATH --json`; reuse its `request_id` and payload on uncertain outcomes. Publishing needs `campaign:publish`; ordinary draft work needs `survey:write`. Delete commands archive/disable rather than erase immutable records. The backend validates the full command and derives the actor from authentication.
|
|
15
|
+
|
|
16
|
+
For an authorized SDK installation, run `npx -y @userreason/wizard@latest` in the intended project. It needs a remote-compatible `@userreason/js >=0.2.0`. Use explicit workspace/site UUIDs or creation names, origins and `--yes` for unattended operation after approval. Review the generated integration and mount it at the application root with lifecycle cleanup. Only public SDK identifiers and API configuration belong in host code.
|
|
17
|
+
|
|
18
|
+
The wizard verifies configuration availability. It does not claim that the customer's browser has connected. Exercise the application in its real allowed origin and check config/session requests before reporting installation as connected. Revoked access requires a new approval; do not substitute bootstrap/system credentials or another person's identity.
|