@2kw/ai 5.1.0-dev.7 → 5.2.0-dev.10
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 +79 -33
- package/dist/commands/auth.d.ts +91 -1
- package/dist/commands/auth.js +344 -54
- package/dist/commands/config.d.ts +16 -0
- package/dist/commands/config.js +52 -19
- package/dist/commands/context.d.ts +25 -0
- package/dist/commands/context.js +108 -14
- package/dist/commands/convert.js +3 -2
- package/dist/commands/docs.js +10 -6
- package/dist/commands/transcribe.js +3 -1
- package/dist/lib/auth-service.d.ts +131 -0
- package/dist/lib/auth-service.js +240 -0
- package/dist/lib/auth-session.d.ts +139 -0
- package/dist/lib/auth-session.js +275 -0
- package/dist/lib/client.d.ts +57 -0
- package/dist/lib/client.js +81 -5
- package/dist/lib/config.d.ts +64 -5
- package/dist/lib/config.js +115 -15
- package/dist/lib/errors.d.ts +18 -0
- package/dist/lib/errors.js +105 -3
- package/dist/lib/redact.d.ts +35 -0
- package/dist/lib/redact.js +54 -0
- package/dist/lib/update-notifier.js +1 -1
- package/package.json +28 -4
package/dist/commands/context.js
CHANGED
|
@@ -2,11 +2,79 @@ import { Command } from "commander";
|
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import Table from "cli-table3";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
-
import { store, isJsonOutput, getAllContexts, getActiveContextName, getContextCount, setContext, deleteContext, renameContext, setActiveContext, validateContextName, DEFAULT_BASE_URL, } from "../lib/config.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { store, isJsonOutput, getAllContexts, getActiveContext, getActiveContextName, getContextCount, setContext, deleteContext, renameContext, setActiveContext, updateContext, validateContextName, DEFAULT_BASE_URL, } from "../lib/config.js";
|
|
6
|
+
import { listOrganizations, setActiveOrganization, } from "../lib/auth-service.js";
|
|
7
|
+
import { selectOrganization } from "../lib/auth-session.js";
|
|
8
|
+
import { runAction } from "../lib/client.js";
|
|
9
|
+
import { credentialSummary, maskApiKey, maskSessionToken, redactContext, } from "../lib/redact.js";
|
|
10
|
+
/** An API key carries its organization inside it — there is nothing to switch. */
|
|
11
|
+
const API_KEY_CONTEXT_MESSAGE = "The active context uses an API key, which is bound to one organization. " +
|
|
12
|
+
'Sign in with "2kw auth login" to switch organizations.';
|
|
13
|
+
/** Same dead end, but claiming an API key the context does not have would lie. */
|
|
14
|
+
const NO_SESSION_MESSAGE = "The active context has no browser session. " +
|
|
15
|
+
'Sign in with "2kw auth login" to switch organizations.';
|
|
16
|
+
/**
|
|
17
|
+
* Point the active session context at a different organization.
|
|
18
|
+
*
|
|
19
|
+
* The switch is server-side state (the session's active organization), so the
|
|
20
|
+
* local write that follows is not a cache but the record of what the server was
|
|
21
|
+
* told. A cached JWT minted for the previous organization would still be
|
|
22
|
+
* accepted by the API, silently answering as the old org, so it is dropped in
|
|
23
|
+
* the same write.
|
|
24
|
+
*
|
|
25
|
+
* @param orgArg an organization id or slug; omitted means "prompt".
|
|
26
|
+
*/
|
|
27
|
+
export async function performSetOrg(orgArg, deps = {}) {
|
|
28
|
+
const { listOrgs = listOrganizations, chooseOrg = selectOrganization, setActiveOrg = setActiveOrganization, persist = updateContext, ask, } = deps;
|
|
29
|
+
const contextName = getActiveContextName();
|
|
30
|
+
const ctx = getActiveContext();
|
|
31
|
+
// An API-key context has no session to re-scope: the key itself decides the
|
|
32
|
+
// organization, and storing an organizationId next to it would be a lie the
|
|
33
|
+
// next command reads back.
|
|
34
|
+
if (!ctx?.sessionToken || !ctx.authUrl) {
|
|
35
|
+
console.error(chalk.red(ctx?.apiKey ? API_KEY_CONTEXT_MESSAGE : NO_SESSION_MESSAGE));
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const orgs = await listOrgs(ctx.authUrl, ctx.sessionToken);
|
|
40
|
+
let org;
|
|
41
|
+
if (orgArg) {
|
|
42
|
+
// Both keys, because the id is what `--json` output shows and the slug is
|
|
43
|
+
// what a human remembers.
|
|
44
|
+
const match = orgs.find((o) => o.id === orgArg || o.slug === orgArg);
|
|
45
|
+
if (!match) {
|
|
46
|
+
const known = orgs.map((o) => o.slug).join(", ");
|
|
47
|
+
console.error(chalk.red(`No organization "${orgArg}" in this account.` +
|
|
48
|
+
(known ? ` Available: ${known}.` : "")));
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
org = match;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
org = await chooseOrg(orgs, { ask });
|
|
56
|
+
}
|
|
57
|
+
await setActiveOrg(ctx.authUrl, ctx.sessionToken, org.id);
|
|
58
|
+
try {
|
|
59
|
+
persist(contextName, {
|
|
60
|
+
organizationId: org.id,
|
|
61
|
+
cachedJwt: undefined,
|
|
62
|
+
cachedJwtExp: undefined,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Loud on purpose. The server has already switched, so a swallowed write
|
|
67
|
+
// failure leaves the CLI holding a JWT for the previous organization and no
|
|
68
|
+
// record of the new one — every later command would quietly act as the old
|
|
69
|
+
// org, which is far worse than this command failing.
|
|
70
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
71
|
+
console.error(chalk.red(`Switched to ${org.name} on the server, but could not record it in ` +
|
|
72
|
+
`${store.path}: ${detail}. Later commands may keep using the previous ` +
|
|
73
|
+
'organization — run "2kw auth login" again once the config store is writable.'));
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
console.log(chalk.green(`Active organization: ${org.name} (${org.slug})`));
|
|
10
78
|
}
|
|
11
79
|
export function makeContextCommand() {
|
|
12
80
|
const cmd = new Command("context").description("Manage CLI contexts for multiple environments");
|
|
@@ -17,16 +85,19 @@ export function makeContextCommand() {
|
|
|
17
85
|
const contexts = getAllContexts();
|
|
18
86
|
const activeName = getActiveContextName();
|
|
19
87
|
if (isJsonOutput(command)) {
|
|
20
|
-
|
|
88
|
+
// Redacted entry by entry: this output is routinely piped into logs and
|
|
89
|
+
// issue reports, and a session token in one is a live credential.
|
|
90
|
+
const safe = Object.fromEntries(Object.entries(contexts).map(([name, ctx]) => [name, redactContext(ctx)]));
|
|
91
|
+
console.log(JSON.stringify({ activeContext: activeName, contexts: safe }, null, 2));
|
|
21
92
|
return;
|
|
22
93
|
}
|
|
23
94
|
const names = Object.keys(contexts);
|
|
24
95
|
if (names.length === 0) {
|
|
25
|
-
console.log(chalk.dim('No contexts configured. Run "
|
|
96
|
+
console.log(chalk.dim('No contexts configured. Run "2kw auth login" to get started.'));
|
|
26
97
|
return;
|
|
27
98
|
}
|
|
28
99
|
const table = new Table({
|
|
29
|
-
head: ["", "NAME", "BASE URL", "
|
|
100
|
+
head: ["", "NAME", "BASE URL", "CREDENTIAL"].map((h) => chalk.cyan(h)),
|
|
30
101
|
});
|
|
31
102
|
for (const name of names) {
|
|
32
103
|
const ctx = contexts[name];
|
|
@@ -34,7 +105,7 @@ export function makeContextCommand() {
|
|
|
34
105
|
name === activeName ? chalk.green("*") : "",
|
|
35
106
|
name,
|
|
36
107
|
ctx.baseUrl,
|
|
37
|
-
|
|
108
|
+
credentialSummary(ctx),
|
|
38
109
|
]);
|
|
39
110
|
}
|
|
40
111
|
console.log(table.toString());
|
|
@@ -58,8 +129,13 @@ export function makeContextCommand() {
|
|
|
58
129
|
.argument("<name>", "Context name")
|
|
59
130
|
.option("--base-url <url>", "Base URL")
|
|
60
131
|
.option("--api-key <key>", "API key")
|
|
61
|
-
.action(async (name,
|
|
132
|
+
.action(async (name, _opts, command) => {
|
|
62
133
|
validateContextName(name);
|
|
134
|
+
// Merged, not own — the root program declares --base-url and --api-key
|
|
135
|
+
// too and swallows both, leaving this subcommand's own opts empty for the
|
|
136
|
+
// flags it documents (see the same note on `auth login`). Without this the
|
|
137
|
+
// command prompted for values the user had already typed.
|
|
138
|
+
const opts = command.optsWithGlobals();
|
|
63
139
|
const contexts = getAllContexts();
|
|
64
140
|
if (contexts[name]) {
|
|
65
141
|
throw new Error(`Context "${name}" already exists. Delete it first or choose a different name.`);
|
|
@@ -106,16 +182,34 @@ export function makeContextCommand() {
|
|
|
106
182
|
const contexts = getAllContexts();
|
|
107
183
|
const ctx = contexts[name];
|
|
108
184
|
if (isJsonOutput(command)) {
|
|
109
|
-
console.log(JSON.stringify({ name, ...(ctx
|
|
185
|
+
console.log(JSON.stringify({ name, ...(ctx ? redactContext(ctx) : {}) }, null, 2));
|
|
110
186
|
return;
|
|
111
187
|
}
|
|
112
188
|
if (!ctx) {
|
|
113
|
-
console.log(chalk.yellow('No active context configured. Run "
|
|
189
|
+
console.log(chalk.yellow('No active context configured. Run "2kw auth login" to get started.'));
|
|
114
190
|
return;
|
|
115
191
|
}
|
|
116
|
-
console.log(`${chalk.cyan("Context")}:
|
|
192
|
+
console.log(`${chalk.cyan("Context")}: ${name}`);
|
|
117
193
|
console.log(`${chalk.cyan("Base URL")}: ${ctx.baseUrl}`);
|
|
118
|
-
|
|
194
|
+
if (ctx.sessionToken) {
|
|
195
|
+
console.log(`${chalk.cyan("Auth")}: browser session (${ctx.authUrl ?? "no auth URL"})`);
|
|
196
|
+
console.log(`${chalk.cyan("Session")}: ${maskSessionToken(ctx.sessionToken)}`);
|
|
197
|
+
if (ctx.organizationId) {
|
|
198
|
+
console.log(`${chalk.cyan("Org")}: ${ctx.organizationId}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
console.log(`${chalk.cyan("API Key")}: ${maskApiKey(ctx.apiKey)}`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
// Interactive by nature (it prompts when no org is named) and it reports its
|
|
206
|
+
// own outcomes as prose, `--json` or not — same convention as `auth login`.
|
|
207
|
+
cmd
|
|
208
|
+
.command("set-org")
|
|
209
|
+
.description("Switch the active organization (browser-session contexts only)")
|
|
210
|
+
.argument("[org]", "Organization id or slug (prompts if omitted)")
|
|
211
|
+
.action(async (orgArg, _opts, command) => {
|
|
212
|
+
await runAction(command, () => performSetOrg(orgArg));
|
|
119
213
|
});
|
|
120
214
|
cmd
|
|
121
215
|
.command("delete")
|
package/dist/commands/convert.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
-
import { getClient, runAction } from "../lib/client.js";
|
|
4
|
+
import { getClient, resolveAuthHeader, runAction } from "../lib/client.js";
|
|
5
5
|
import { resolveConfig, isJsonOutput } from "../lib/config.js";
|
|
6
6
|
import { BackboneApiError } from "../lib/errors.js";
|
|
7
7
|
import { formatDetail, withSpinner } from "../lib/output.js";
|
|
@@ -138,13 +138,14 @@ function buildFormData(paths, opts) {
|
|
|
138
138
|
}
|
|
139
139
|
async function multipartConvert(command, formData, endpoint, pipeline) {
|
|
140
140
|
const config = resolveConfig(command);
|
|
141
|
+
const authHeader = await resolveAuthHeader(config);
|
|
141
142
|
const baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
142
143
|
const url = pipeline
|
|
143
144
|
? `${baseUrl}${endpoint}?pipeline=${encodeURIComponent(pipeline)}`
|
|
144
145
|
: `${baseUrl}${endpoint}`;
|
|
145
146
|
const res = await fetch(url, {
|
|
146
147
|
method: "POST",
|
|
147
|
-
headers: { Authorization:
|
|
148
|
+
headers: { Authorization: authHeader },
|
|
148
149
|
body: formData,
|
|
149
150
|
});
|
|
150
151
|
if (!res.ok) {
|
package/dist/commands/docs.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { runAction } from "../lib/client.js";
|
|
2
|
+
import { resolveAuthHeader, runAction } from "../lib/client.js";
|
|
3
3
|
import { resolveConfig, isJsonOutput } from "../lib/config.js";
|
|
4
4
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
5
5
|
/**
|
|
6
|
-
* Fetch the OpenAPI spec from the backend.
|
|
6
|
+
* Fetch the OpenAPI spec from the backend. Takes a ready-made Authorization
|
|
7
|
+
* header value so the caller decides between an API key and a session JWT.
|
|
7
8
|
*/
|
|
8
|
-
async function fetchSpec(baseUrl,
|
|
9
|
+
async function fetchSpec(baseUrl, authHeader) {
|
|
9
10
|
const specUrl = `${baseUrl.replace(/\/+$/, "")}/v3/api-docs`;
|
|
11
|
+
// Known asymmetry: this GET is outside the typed client, so a 401 on a
|
|
12
|
+
// session context is not auto-retried with a re-minted JWT. The 30s freshness
|
|
13
|
+
// margin in ensureJwt makes an in-flight expiry rare enough to live with.
|
|
10
14
|
const res = await fetch(specUrl, {
|
|
11
|
-
headers: { Authorization:
|
|
15
|
+
headers: { Authorization: authHeader },
|
|
12
16
|
});
|
|
13
17
|
if (!res.ok) {
|
|
14
18
|
if (res.status === 404 || res.status === 403) {
|
|
@@ -74,7 +78,7 @@ export function makeDocsCommand() {
|
|
|
74
78
|
.action(async (_opts, command) => {
|
|
75
79
|
await runAction(command, async () => {
|
|
76
80
|
const config = resolveConfig(command);
|
|
77
|
-
const spec = await fetchSpec(config.baseUrl, config
|
|
81
|
+
const spec = await fetchSpec(config.baseUrl, await resolveAuthHeader(config));
|
|
78
82
|
const tags = spec.tags ?? [];
|
|
79
83
|
const paths = spec.paths ?? {};
|
|
80
84
|
// Count endpoints per tag
|
|
@@ -115,7 +119,7 @@ export function makeDocsCommand() {
|
|
|
115
119
|
.action(async (section, _opts, command) => {
|
|
116
120
|
await runAction(command, async () => {
|
|
117
121
|
const config = resolveConfig(command);
|
|
118
|
-
const spec = await fetchSpec(config.baseUrl, config
|
|
122
|
+
const spec = await fetchSpec(config.baseUrl, await resolveAuthHeader(config));
|
|
119
123
|
const allTags = spec.tags ?? [];
|
|
120
124
|
const tagNames = allTags.map((t) => t.name);
|
|
121
125
|
if (!tagNames.includes(section)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { resolveAuthHeader } from "../lib/client.js";
|
|
2
3
|
import { resolveConfig, isJsonOutput } from "../lib/config.js";
|
|
3
4
|
import { handleError } from "../lib/errors.js";
|
|
4
5
|
import { formatDetail, withSpinner } from "../lib/output.js";
|
|
@@ -17,6 +18,7 @@ export function makeTranscribeCommand() {
|
|
|
17
18
|
const json = isJsonOutput(command);
|
|
18
19
|
try {
|
|
19
20
|
const config = resolveConfig(command);
|
|
21
|
+
const authHeader = await resolveAuthHeader(config);
|
|
20
22
|
const { blob, filename } = fileToBlob(file);
|
|
21
23
|
const formData = new FormData();
|
|
22
24
|
formData.append("file", blob, filename);
|
|
@@ -34,7 +36,7 @@ export function makeTranscribeCommand() {
|
|
|
34
36
|
const data = await withSpinner("Transcribing...", async () => {
|
|
35
37
|
const res = await fetch(url, {
|
|
36
38
|
method: "POST",
|
|
37
|
-
headers: { Authorization:
|
|
39
|
+
headers: { Authorization: authHeader },
|
|
38
40
|
body: formData,
|
|
39
41
|
});
|
|
40
42
|
if (!res.ok) {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch wrappers for the auth service (Better Auth) endpoints the CLI
|
|
3
|
+
* needs. Deliberately dependency-free: endpoints are stable REST paths and a
|
|
4
|
+
* full better-auth client would drag react-oriented tooling into the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Failures leave through exactly three exits, and callers branch on which:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Mapped {@link DeviceTokenResult} values** — {@link pollDeviceToken} only.
|
|
9
|
+
* `pending` / `slow_down` / `denied` / `expired` are ordinary device-flow
|
|
10
|
+
* states, so they are returned rather than thrown and the poll loop reads as
|
|
11
|
+
* the state machine it is instead of a try/catch.
|
|
12
|
+
* 2. **{@link AuthServiceError}** — the service answered, and the answer was no.
|
|
13
|
+
* Carries the HTTP `status` and any OAuth `code`. A response came back, so
|
|
14
|
+
* replaying the identical request is usually pointless.
|
|
15
|
+
* 3. **A raw fetch `TypeError`** ("fetch failed") — the transport never
|
|
16
|
+
* completed: DNS, TLS, connection refused. This module neither catches nor
|
|
17
|
+
* wraps it; it propagates untouched, and it is the retry-worthy case.
|
|
18
|
+
*
|
|
19
|
+
* So `err instanceof AuthServiceError` separates "the server rejected us" from
|
|
20
|
+
* "we never reached the server" — the split retry and diagnostics need.
|
|
21
|
+
* `classifyAuthFailure` in errors.ts already covers the transport half.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* OAuth client id sent with every device-flow request.
|
|
25
|
+
*
|
|
26
|
+
* Cross-repo coupling: this must equal the auth service's configured app slug
|
|
27
|
+
* (`APP_1_SLUG`, default `backbone` — see compose.yaml), which the service's
|
|
28
|
+
* `validateClient` checks the incoming `client_id` against. Change it on one
|
|
29
|
+
* side only and every login fails with `invalid_client`.
|
|
30
|
+
*/
|
|
31
|
+
export declare const CLI_CLIENT_ID = "backbone";
|
|
32
|
+
/** RFC 8628 grant type used when exchanging a device code for a session. */
|
|
33
|
+
export declare const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
34
|
+
/**
|
|
35
|
+
* Injectable fetch. Every wrapper takes one as its last parameter so tests (and
|
|
36
|
+
* later retry/proxy layers) can supply their own without touching globals.
|
|
37
|
+
*/
|
|
38
|
+
export type FetchFn = typeof fetch;
|
|
39
|
+
/**
|
|
40
|
+
* A failed auth-service call. Carries the HTTP status and, when the service
|
|
41
|
+
* supplied one, the machine-readable OAuth error code (e.g. "invalid_client").
|
|
42
|
+
*/
|
|
43
|
+
export declare class AuthServiceError extends Error {
|
|
44
|
+
readonly status: number;
|
|
45
|
+
readonly code?: string | undefined;
|
|
46
|
+
constructor(message: string, status: number, code?: string | undefined);
|
|
47
|
+
}
|
|
48
|
+
export interface DeviceCodeResponse {
|
|
49
|
+
device_code: string;
|
|
50
|
+
user_code: string;
|
|
51
|
+
verification_uri: string;
|
|
52
|
+
verification_uri_complete: string;
|
|
53
|
+
/** Optional per RFC 8628 — absent unless the service sends a number, so the
|
|
54
|
+
* caller's default (RFC recommends 5 s) is type-driven rather than a guess. */
|
|
55
|
+
interval?: number;
|
|
56
|
+
/** Optional per RFC 8628 — see {@link DeviceCodeResponse.interval}. */
|
|
57
|
+
expires_in?: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Outcome of one device-token poll. The non-"ok" variants are the RFC 8628
|
|
61
|
+
* states a poll loop acts on; anything else is thrown as an AuthServiceError.
|
|
62
|
+
*/
|
|
63
|
+
export type DeviceTokenResult = {
|
|
64
|
+
status: "ok";
|
|
65
|
+
accessToken: string;
|
|
66
|
+
} | {
|
|
67
|
+
status: "pending";
|
|
68
|
+
} | {
|
|
69
|
+
status: "slow_down";
|
|
70
|
+
} | {
|
|
71
|
+
status: "denied";
|
|
72
|
+
} | {
|
|
73
|
+
status: "expired";
|
|
74
|
+
};
|
|
75
|
+
export interface OrgSummary {
|
|
76
|
+
id: string;
|
|
77
|
+
name: string;
|
|
78
|
+
slug: string;
|
|
79
|
+
}
|
|
80
|
+
export interface SessionInfo {
|
|
81
|
+
email: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Start a device authorization: returns the user code to display and the URL
|
|
85
|
+
* to open, plus the polling parameters for {@link pollDeviceToken}.
|
|
86
|
+
*
|
|
87
|
+
* The payload is validated rather than cast — the login flow prints these
|
|
88
|
+
* strings and polls with `device_code`, so a malformed 200 must fail here
|
|
89
|
+
* instead of surfacing as the literal "undefined" in a user's terminal.
|
|
90
|
+
*/
|
|
91
|
+
export declare function requestDeviceCode(authUrl: string, clientId: string, fetchFn?: FetchFn): Promise<DeviceCodeResponse>;
|
|
92
|
+
/**
|
|
93
|
+
* Poll once for the session token behind a device code.
|
|
94
|
+
*
|
|
95
|
+
* Unlike the other wrappers this does not treat a 400 as fatal: the RFC 8628
|
|
96
|
+
* flow reports its in-progress and terminal states through the error field of a
|
|
97
|
+
* 400 body, so those are mapped to results the caller's loop can act on. Only
|
|
98
|
+
* an outcome that is neither a token nor a known state throws.
|
|
99
|
+
*/
|
|
100
|
+
export declare function pollDeviceToken(authUrl: string, clientId: string, deviceCode: string, fetchFn?: FetchFn): Promise<DeviceTokenResult>;
|
|
101
|
+
/**
|
|
102
|
+
* Exchange a browser session for a short-lived organization JWT — the token the
|
|
103
|
+
* API itself accepts. Scoped to the session's currently active organization.
|
|
104
|
+
*/
|
|
105
|
+
export declare function fetchJwt(authUrl: string, sessionToken: string, fetchFn?: FetchFn): Promise<string>;
|
|
106
|
+
/**
|
|
107
|
+
* Organizations the session's user belongs to, projected to what the CLI shows.
|
|
108
|
+
*
|
|
109
|
+
* Entries without a usable string `id` are dropped: an org that cannot be named
|
|
110
|
+
* in `set-active` is not selectable, and coercing one into the list would put
|
|
111
|
+
* the string "undefined" in front of the user.
|
|
112
|
+
*/
|
|
113
|
+
export declare function listOrganizations(authUrl: string, sessionToken: string, fetchFn?: FetchFn): Promise<OrgSummary[]>;
|
|
114
|
+
/**
|
|
115
|
+
* Switch the session's active organization. The choice is server-side state, so
|
|
116
|
+
* every JWT minted afterwards is scoped to it.
|
|
117
|
+
*/
|
|
118
|
+
export declare function setActiveOrganization(authUrl: string, sessionToken: string, organizationId: string, fetchFn?: FetchFn): Promise<void>;
|
|
119
|
+
/** Who the stored session belongs to — used by `auth status` and login output. */
|
|
120
|
+
export declare function getSessionInfo(authUrl: string, sessionToken: string, fetchFn?: FetchFn): Promise<SessionInfo>;
|
|
121
|
+
/**
|
|
122
|
+
* Ask the auth service to revoke the session.
|
|
123
|
+
*
|
|
124
|
+
* A 200 is *not* proof of revocation: Better Auth answers `{success: true}`
|
|
125
|
+
* unconditionally and only deletes the session when the bearer actually
|
|
126
|
+
* resolves to one. Callers should therefore clear local credentials regardless
|
|
127
|
+
* of the outcome, and must not report "revoked server-side" on the strength of
|
|
128
|
+
* this call alone.
|
|
129
|
+
*/
|
|
130
|
+
export declare function revokeSession(authUrl: string, sessionToken: string, fetchFn?: FetchFn): Promise<void>;
|
|
131
|
+
//# sourceMappingURL=auth-service.d.ts.map
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch wrappers for the auth service (Better Auth) endpoints the CLI
|
|
3
|
+
* needs. Deliberately dependency-free: endpoints are stable REST paths and a
|
|
4
|
+
* full better-auth client would drag react-oriented tooling into the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Failures leave through exactly three exits, and callers branch on which:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Mapped {@link DeviceTokenResult} values** — {@link pollDeviceToken} only.
|
|
9
|
+
* `pending` / `slow_down` / `denied` / `expired` are ordinary device-flow
|
|
10
|
+
* states, so they are returned rather than thrown and the poll loop reads as
|
|
11
|
+
* the state machine it is instead of a try/catch.
|
|
12
|
+
* 2. **{@link AuthServiceError}** — the service answered, and the answer was no.
|
|
13
|
+
* Carries the HTTP `status` and any OAuth `code`. A response came back, so
|
|
14
|
+
* replaying the identical request is usually pointless.
|
|
15
|
+
* 3. **A raw fetch `TypeError`** ("fetch failed") — the transport never
|
|
16
|
+
* completed: DNS, TLS, connection refused. This module neither catches nor
|
|
17
|
+
* wraps it; it propagates untouched, and it is the retry-worthy case.
|
|
18
|
+
*
|
|
19
|
+
* So `err instanceof AuthServiceError` separates "the server rejected us" from
|
|
20
|
+
* "we never reached the server" — the split retry and diagnostics need.
|
|
21
|
+
* `classifyAuthFailure` in errors.ts already covers the transport half.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* OAuth client id sent with every device-flow request.
|
|
25
|
+
*
|
|
26
|
+
* Cross-repo coupling: this must equal the auth service's configured app slug
|
|
27
|
+
* (`APP_1_SLUG`, default `backbone` — see compose.yaml), which the service's
|
|
28
|
+
* `validateClient` checks the incoming `client_id` against. Change it on one
|
|
29
|
+
* side only and every login fails with `invalid_client`.
|
|
30
|
+
*/
|
|
31
|
+
export const CLI_CLIENT_ID = "backbone";
|
|
32
|
+
/** RFC 8628 grant type used when exchanging a device code for a session. */
|
|
33
|
+
export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
34
|
+
/**
|
|
35
|
+
* A failed auth-service call. Carries the HTTP status and, when the service
|
|
36
|
+
* supplied one, the machine-readable OAuth error code (e.g. "invalid_client").
|
|
37
|
+
*/
|
|
38
|
+
export class AuthServiceError extends Error {
|
|
39
|
+
status;
|
|
40
|
+
code;
|
|
41
|
+
constructor(message, status, code) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.name = "AuthServiceError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
49
|
+
/** Build an absolute endpoint URL, tolerating trailing slashes on authUrl. */
|
|
50
|
+
function api(authUrl, path) {
|
|
51
|
+
return `${authUrl.replace(/\/+$/, "")}/api/auth${path}`;
|
|
52
|
+
}
|
|
53
|
+
function bearer(sessionToken) {
|
|
54
|
+
return { Authorization: `Bearer ${sessionToken}` };
|
|
55
|
+
}
|
|
56
|
+
function str(value) {
|
|
57
|
+
return typeof value === "string" ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
function num(value) {
|
|
60
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** View a parsed body as a keyed object; anything else (string, null) reads as empty. */
|
|
63
|
+
function rec(value) {
|
|
64
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse a JSON body, degrading to `{}` rather than throwing on an empty or
|
|
68
|
+
* non-JSON payload (a gateway's HTML 502, say). Returns `unknown` because these
|
|
69
|
+
* endpoints legitimately answer with arrays as well as objects.
|
|
70
|
+
*/
|
|
71
|
+
async function readJson(res) {
|
|
72
|
+
try {
|
|
73
|
+
return await res.json();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Return the parsed body of a successful response, or throw an AuthServiceError
|
|
81
|
+
* built from whatever the service reported.
|
|
82
|
+
*/
|
|
83
|
+
async function requireOk(res, what) {
|
|
84
|
+
const body = await readJson(res);
|
|
85
|
+
if (res.ok)
|
|
86
|
+
return body;
|
|
87
|
+
const err = rec(body);
|
|
88
|
+
const detail = str(err.error_description) ?? str(err.message) ?? `HTTP ${res.status}`;
|
|
89
|
+
throw new AuthServiceError(`${what}: ${detail}`, res.status, str(err.error));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Start a device authorization: returns the user code to display and the URL
|
|
93
|
+
* to open, plus the polling parameters for {@link pollDeviceToken}.
|
|
94
|
+
*
|
|
95
|
+
* The payload is validated rather than cast — the login flow prints these
|
|
96
|
+
* strings and polls with `device_code`, so a malformed 200 must fail here
|
|
97
|
+
* instead of surfacing as the literal "undefined" in a user's terminal.
|
|
98
|
+
*/
|
|
99
|
+
export async function requestDeviceCode(authUrl, clientId, fetchFn = fetch) {
|
|
100
|
+
const res = await fetchFn(api(authUrl, "/device/code"), {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { ...JSON_HEADERS },
|
|
103
|
+
body: JSON.stringify({ client_id: clientId }),
|
|
104
|
+
});
|
|
105
|
+
const body = rec(await requireOk(res, "Device authorization request failed"));
|
|
106
|
+
const deviceCode = str(body.device_code);
|
|
107
|
+
const userCode = str(body.user_code);
|
|
108
|
+
const verificationUri = str(body.verification_uri);
|
|
109
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
110
|
+
throw new AuthServiceError("Auth service returned an incomplete device authorization " +
|
|
111
|
+
"(device_code, user_code, and verification_uri are required).", res.status);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
device_code: deviceCode,
|
|
115
|
+
user_code: userCode,
|
|
116
|
+
verification_uri: verificationUri,
|
|
117
|
+
// RFC 8628 marks the pre-filled URI optional; fall back to the bare
|
|
118
|
+
// verification URI so callers always have something to open.
|
|
119
|
+
verification_uri_complete: str(body.verification_uri_complete) ?? verificationUri,
|
|
120
|
+
interval: num(body.interval),
|
|
121
|
+
expires_in: num(body.expires_in),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Poll once for the session token behind a device code.
|
|
126
|
+
*
|
|
127
|
+
* Unlike the other wrappers this does not treat a 400 as fatal: the RFC 8628
|
|
128
|
+
* flow reports its in-progress and terminal states through the error field of a
|
|
129
|
+
* 400 body, so those are mapped to results the caller's loop can act on. Only
|
|
130
|
+
* an outcome that is neither a token nor a known state throws.
|
|
131
|
+
*/
|
|
132
|
+
export async function pollDeviceToken(authUrl, clientId, deviceCode, fetchFn = fetch) {
|
|
133
|
+
const res = await fetchFn(api(authUrl, "/device/token"), {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { ...JSON_HEADERS },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
138
|
+
device_code: deviceCode,
|
|
139
|
+
client_id: clientId,
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
const body = rec(await readJson(res));
|
|
143
|
+
const accessToken = str(body.access_token);
|
|
144
|
+
if (res.ok && accessToken)
|
|
145
|
+
return { status: "ok", accessToken };
|
|
146
|
+
switch (str(body.error)) {
|
|
147
|
+
case "authorization_pending":
|
|
148
|
+
return { status: "pending" };
|
|
149
|
+
case "slow_down":
|
|
150
|
+
return { status: "slow_down" };
|
|
151
|
+
case "access_denied":
|
|
152
|
+
return { status: "denied" };
|
|
153
|
+
case "expired_token":
|
|
154
|
+
return { status: "expired" };
|
|
155
|
+
}
|
|
156
|
+
// Neither a token nor a state we know. Say which, because "failed: HTTP 200"
|
|
157
|
+
// reads as a contradiction when the real fault is a well-formed empty answer.
|
|
158
|
+
const detail = str(body.error_description) ?? str(body.message);
|
|
159
|
+
throw new AuthServiceError(detail
|
|
160
|
+
? `Device token exchange failed: ${detail}`
|
|
161
|
+
: `Unrecognized device token response (HTTP ${res.status}, no access_token, no error).`, res.status, str(body.error));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Exchange a browser session for a short-lived organization JWT — the token the
|
|
165
|
+
* API itself accepts. Scoped to the session's currently active organization.
|
|
166
|
+
*/
|
|
167
|
+
export async function fetchJwt(authUrl, sessionToken, fetchFn = fetch) {
|
|
168
|
+
const res = await fetchFn(api(authUrl, "/token"), { headers: bearer(sessionToken) });
|
|
169
|
+
const body = rec(await requireOk(res, "Could not obtain an organization token"));
|
|
170
|
+
const token = str(body.token);
|
|
171
|
+
if (!token) {
|
|
172
|
+
throw new AuthServiceError("Auth service returned no token for this session.", res.status);
|
|
173
|
+
}
|
|
174
|
+
return token;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Organizations the session's user belongs to, projected to what the CLI shows.
|
|
178
|
+
*
|
|
179
|
+
* Entries without a usable string `id` are dropped: an org that cannot be named
|
|
180
|
+
* in `set-active` is not selectable, and coercing one into the list would put
|
|
181
|
+
* the string "undefined" in front of the user.
|
|
182
|
+
*/
|
|
183
|
+
export async function listOrganizations(authUrl, sessionToken, fetchFn = fetch) {
|
|
184
|
+
const res = await fetchFn(api(authUrl, "/organization/list"), {
|
|
185
|
+
headers: bearer(sessionToken),
|
|
186
|
+
});
|
|
187
|
+
const body = await requireOk(res, "Could not list organizations");
|
|
188
|
+
if (!Array.isArray(body))
|
|
189
|
+
return [];
|
|
190
|
+
return body.flatMap((entry) => {
|
|
191
|
+
const org = rec(entry);
|
|
192
|
+
const id = str(org.id);
|
|
193
|
+
if (!id)
|
|
194
|
+
return [];
|
|
195
|
+
// A nameless org is still selectable — show the id rather than drop it.
|
|
196
|
+
return [{ id, name: str(org.name) ?? id, slug: str(org.slug) ?? id }];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Switch the session's active organization. The choice is server-side state, so
|
|
201
|
+
* every JWT minted afterwards is scoped to it.
|
|
202
|
+
*/
|
|
203
|
+
export async function setActiveOrganization(authUrl, sessionToken, organizationId, fetchFn = fetch) {
|
|
204
|
+
const res = await fetchFn(api(authUrl, "/organization/set-active"), {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { ...JSON_HEADERS, ...bearer(sessionToken) },
|
|
207
|
+
body: JSON.stringify({ organizationId }),
|
|
208
|
+
});
|
|
209
|
+
await requireOk(res, "Could not set the active organization");
|
|
210
|
+
}
|
|
211
|
+
/** Who the stored session belongs to — used by `auth status` and login output. */
|
|
212
|
+
export async function getSessionInfo(authUrl, sessionToken, fetchFn = fetch) {
|
|
213
|
+
const res = await fetchFn(api(authUrl, "/get-session"), {
|
|
214
|
+
headers: bearer(sessionToken),
|
|
215
|
+
});
|
|
216
|
+
const body = rec(await requireOk(res, "Could not read the current session"));
|
|
217
|
+
const email = str(rec(body.user).email);
|
|
218
|
+
if (!email) {
|
|
219
|
+
throw new AuthServiceError("Auth service returned a session without a user email.", res.status);
|
|
220
|
+
}
|
|
221
|
+
return { email };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Ask the auth service to revoke the session.
|
|
225
|
+
*
|
|
226
|
+
* A 200 is *not* proof of revocation: Better Auth answers `{success: true}`
|
|
227
|
+
* unconditionally and only deletes the session when the bearer actually
|
|
228
|
+
* resolves to one. Callers should therefore clear local credentials regardless
|
|
229
|
+
* of the outcome, and must not report "revoked server-side" on the strength of
|
|
230
|
+
* this call alone.
|
|
231
|
+
*/
|
|
232
|
+
export async function revokeSession(authUrl, sessionToken, fetchFn = fetch) {
|
|
233
|
+
const res = await fetchFn(api(authUrl, "/sign-out"), {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { ...JSON_HEADERS, ...bearer(sessionToken) },
|
|
236
|
+
body: "{}",
|
|
237
|
+
});
|
|
238
|
+
await requireOk(res, "Could not sign out");
|
|
239
|
+
}
|
|
240
|
+
//# sourceMappingURL=auth-service.js.map
|