@ai21/gateway 0.5.3

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.
Files changed (44) hide show
  1. package/README.md +91 -0
  2. package/dist/args.d.ts +74 -0
  3. package/dist/args.js +304 -0
  4. package/dist/args.js.map +1 -0
  5. package/dist/cli.d.ts +10 -0
  6. package/dist/cli.js +140 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config.d.ts +76 -0
  9. package/dist/config.js +187 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/login/browser.d.ts +7 -0
  12. package/dist/login/browser.js +41 -0
  13. package/dist/login/browser.js.map +1 -0
  14. package/dist/login/contract.d.ts +42 -0
  15. package/dist/login/contract.js +60 -0
  16. package/dist/login/contract.js.map +1 -0
  17. package/dist/login/loopback.d.ts +42 -0
  18. package/dist/login/loopback.js +192 -0
  19. package/dist/login/loopback.js.map +1 -0
  20. package/dist/login/session.d.ts +36 -0
  21. package/dist/login/session.js +75 -0
  22. package/dist/login/session.js.map +1 -0
  23. package/dist/prompt.d.ts +32 -0
  24. package/dist/prompt.js +72 -0
  25. package/dist/prompt.js.map +1 -0
  26. package/dist/report.d.ts +50 -0
  27. package/dist/report.js +214 -0
  28. package/dist/report.js.map +1 -0
  29. package/dist/result.d.ts +26 -0
  30. package/dist/result.js +22 -0
  31. package/dist/result.js.map +1 -0
  32. package/dist/revert.d.ts +60 -0
  33. package/dist/revert.js +230 -0
  34. package/dist/revert.js.map +1 -0
  35. package/dist/router.d.ts +21 -0
  36. package/dist/router.js +271 -0
  37. package/dist/router.js.map +1 -0
  38. package/dist/snippets.d.ts +52 -0
  39. package/dist/snippets.js +71 -0
  40. package/dist/snippets.js.map +1 -0
  41. package/dist/verify.d.ts +96 -0
  42. package/dist/verify.js +309 -0
  43. package/dist/verify.js.map +1 -0
  44. package/package.json +61 -0
@@ -0,0 +1,76 @@
1
+ import { type Client, type Target } from "./args.js";
2
+ /** The `env` keys this tool owns. Anything else in the file is not ours. */
3
+ export declare const MANAGED_KEYS: readonly ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ENABLE_TOOL_SEARCH"];
4
+ /** Injected rather than read from the process, so tests can use a temp directory. */
5
+ export interface PathContext {
6
+ readonly cwd: string;
7
+ readonly home: string;
8
+ }
9
+ export interface ConfigPaths {
10
+ readonly settings: string;
11
+ readonly backup: string;
12
+ }
13
+ /**
14
+ * Whether this file is *ours*, judged by a marker rather than by key names. Key names
15
+ * cannot tell our `ENABLE_TOOL_SEARCH` from one the developer set, so a hand-added key
16
+ * would be deleted by the next `uninstall`. Only this tool writes an `x-ai21-key`.
17
+ */
18
+ export declare function isOurConfig(env: Record<string, unknown> | undefined): boolean;
19
+ /**
20
+ * `--user` targets the home directory, `--repo` (the default) the project. A
21
+ * literal `~` is never expanded, so a directory named `~` is treated as one.
22
+ */
23
+ export declare function resolvePaths(target: Target, ctx: PathContext, client?: Client): ConfigPaths;
24
+ export interface Conflict {
25
+ readonly key: string;
26
+ readonly current: string;
27
+ readonly desired: string;
28
+ }
29
+ export type ConfigOutcome = {
30
+ readonly kind: "created";
31
+ readonly path: string;
32
+ } | {
33
+ readonly kind: "updated";
34
+ readonly path: string;
35
+ readonly backup: string;
36
+ readonly changed: readonly string[];
37
+ } | {
38
+ readonly kind: "unchanged";
39
+ readonly path: string;
40
+ } | {
41
+ readonly kind: "conflict";
42
+ readonly path: string;
43
+ readonly conflicts: readonly Conflict[];
44
+ } | {
45
+ readonly kind: "malformed";
46
+ readonly path: string;
47
+ readonly reason: string;
48
+ } | {
49
+ readonly kind: "failed";
50
+ readonly path: string;
51
+ readonly reason: string;
52
+ };
53
+ /**
54
+ * Turns an fs error into something safe to print. The code is translated rather
55
+ * than forwarded so no stack frame, errno struct or internal path escapes.
56
+ */
57
+ export declare function describeFsError(err: unknown): string;
58
+ type JsonObject = Record<string, unknown>;
59
+ export declare function isPlainObject(value: unknown): value is JsonObject;
60
+ /**
61
+ * Write via a sibling temp file and rename. The rename is atomic within a
62
+ * filesystem, so a reader either sees the old file or the new one — never a
63
+ * half-written config. The temp file is removed on any failure so a crashed run
64
+ * leaves no litter next to the real file.
65
+ */
66
+ export declare function atomicWrite(target: string, contents: string): void;
67
+ export declare function serialize(settings: JsonObject): string;
68
+ /**
69
+ * Merge `desiredEnv` into `paths.settings`. Returns what happened rather than
70
+ * throwing, so the caller owns reporting and the exit code. Nothing is written on
71
+ * the `conflict` or `malformed` outcomes.
72
+ */
73
+ export declare function writeConfig(paths: ConfigPaths, desiredEnv: Record<string, string>, options?: {
74
+ readonly force: boolean;
75
+ }): ConfigOutcome;
76
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Reading, merging and writing `settings.json`.
3
+ *
4
+ * The governing constraint: this edits a file a developer owns and did not create
5
+ * for us. Every path leaves it byte-identical or replaces it atomically with a
6
+ * backup alongside, and no refusal path touches it at all.
7
+ *
8
+ * Four helpers are exported for `revert.ts`, which upholds the same guarantees on
9
+ * the way back out — a second atomic write is a second chance to get it wrong.
10
+ */
11
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { DEFAULT_CLIENT } from "./args.js";
14
+ /** The `env` keys this tool owns. Anything else in the file is not ours. */
15
+ export const MANAGED_KEYS = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ENABLE_TOOL_SEARCH"];
16
+ /* Where each client's config lives, relative to whichever root the target selects.
17
+ * A second client needs more than a row here — MANAGED_KEYS and the snippet builders
18
+ * are Claude Code's env shape — but this is the fork the rest of the code reads. */
19
+ const CLIENT_CONFIG = {
20
+ "claude-code": { dir: ".claude", file: "settings.json" },
21
+ };
22
+ /**
23
+ * Whether this file is *ours*, judged by a marker rather than by key names. Key names
24
+ * cannot tell our `ENABLE_TOOL_SEARCH` from one the developer set, so a hand-added key
25
+ * would be deleted by the next `uninstall`. Only this tool writes an `x-ai21-key`.
26
+ */
27
+ export function isOurConfig(env) {
28
+ const headers = env?.ANTHROPIC_CUSTOM_HEADERS;
29
+ return typeof headers === "string" && /^\s*x-ai21-key\s*:\s*\S/im.test(headers);
30
+ }
31
+ /**
32
+ * `--user` targets the home directory, `--repo` (the default) the project. A
33
+ * literal `~` is never expanded, so a directory named `~` is treated as one.
34
+ */
35
+ export function resolvePaths(target, ctx, client = DEFAULT_CLIENT) {
36
+ const { dir, file } = CLIENT_CONFIG[client];
37
+ const settings = join(target === "user" ? ctx.home : ctx.cwd, dir, file);
38
+ return { settings, backup: `${settings}.bak` };
39
+ }
40
+ /**
41
+ * Turns an fs error into something safe to print. The code is translated rather
42
+ * than forwarded so no stack frame, errno struct or internal path escapes.
43
+ */
44
+ export function describeFsError(err) {
45
+ const code = err?.code;
46
+ switch (code) {
47
+ case "EACCES":
48
+ case "EPERM":
49
+ return "permission denied";
50
+ case "ENOSPC":
51
+ return "no space left on the device";
52
+ case "EROFS":
53
+ return "the filesystem is read-only";
54
+ case "EISDIR":
55
+ return "it is a directory, not a file";
56
+ default:
57
+ return "the write could not be completed";
58
+ }
59
+ }
60
+ export function isPlainObject(value) {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+ /**
64
+ * Write via a sibling temp file and rename. The rename is atomic within a
65
+ * filesystem, so a reader either sees the old file or the new one — never a
66
+ * half-written config. The temp file is removed on any failure so a crashed run
67
+ * leaves no litter next to the real file.
68
+ */
69
+ export function atomicWrite(target, contents) {
70
+ // Same directory, so the rename cannot cross a filesystem boundary. The pid
71
+ // keeps two concurrent runs from fighting over one temp name.
72
+ const tmp = `${target}.${process.pid}.tmp`;
73
+ try {
74
+ // 0600: this file carries an API key.
75
+ writeFileSync(tmp, contents, { mode: 0o600 });
76
+ renameSync(tmp, target);
77
+ }
78
+ catch (err) {
79
+ rmSync(tmp, { force: true });
80
+ throw err;
81
+ }
82
+ }
83
+ export function serialize(settings) {
84
+ return `${JSON.stringify(settings, null, 2)}\n`;
85
+ }
86
+ /**
87
+ * Merge `desiredEnv` into `paths.settings`. Returns what happened rather than
88
+ * throwing, so the caller owns reporting and the exit code. Nothing is written on
89
+ * the `conflict` or `malformed` outcomes.
90
+ */
91
+ export function writeConfig(paths, desiredEnv, options = { force: false }) {
92
+ const { settings: path } = paths;
93
+ // Dotfile managers routinely symlink settings.json, and `existsSync` follows
94
+ // links — so a *dangling* link reports false and would be clobbered by the
95
+ // create path below. Resolve it ourselves so the link always survives.
96
+ let linkTarget = path;
97
+ try {
98
+ if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() === true) {
99
+ linkTarget = resolve(dirname(path), readlinkSync(path));
100
+ }
101
+ }
102
+ catch {
103
+ linkTarget = path;
104
+ }
105
+ if (!existsSync(path)) {
106
+ try {
107
+ mkdirSync(dirname(linkTarget), { recursive: true });
108
+ atomicWrite(linkTarget, serialize({ env: { ...desiredEnv } }));
109
+ }
110
+ catch (err) {
111
+ // Usually `.claude` is already a regular file, or the directory is not
112
+ // writable. Either way, refuse cleanly rather than surface a stack trace.
113
+ return { kind: "failed", path, reason: describeFsError(err) };
114
+ }
115
+ return { kind: "created", path };
116
+ }
117
+ let realPath;
118
+ let raw;
119
+ try {
120
+ realPath = realpathSync(path);
121
+ // Guarded alongside the resolve: EISDIR for a directory, EACCES when it is
122
+ // not ours to read. Neither should reach the user as a stack trace.
123
+ raw = readFileSync(realPath, "utf8");
124
+ }
125
+ catch (err) {
126
+ return { kind: "failed", path, reason: describeFsError(err) };
127
+ }
128
+ const backupPath = realPath === path ? paths.backup : `${realPath}.bak`;
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(raw);
132
+ }
133
+ catch {
134
+ // Deliberately no parser detail: the message is user-facing and the file may
135
+ // contain credentials. Refusing beats guessing at a repair.
136
+ return { kind: "malformed", path, reason: "it is not valid JSON" };
137
+ }
138
+ if (!isPlainObject(parsed)) {
139
+ return { kind: "malformed", path, reason: "its top level is not a JSON object" };
140
+ }
141
+ const existingEnv = parsed.env;
142
+ if (existingEnv !== undefined && !isPlainObject(existingEnv)) {
143
+ return { kind: "malformed", path, reason: 'its "env" is not a JSON object' };
144
+ }
145
+ const currentEnv = existingEnv ?? {};
146
+ const conflicts = [];
147
+ const changed = [];
148
+ for (const [key, desired] of Object.entries(desiredEnv)) {
149
+ const current = currentEnv[key];
150
+ if (current === desired)
151
+ continue;
152
+ changed.push(key);
153
+ // Only a *different existing* value is a conflict. A key we have not written
154
+ // yet is simply an addition.
155
+ if (current !== undefined) {
156
+ conflicts.push({ key, current: typeof current === "string" ? current : JSON.stringify(current), desired });
157
+ }
158
+ }
159
+ if (conflicts.length > 0 && !options.force) {
160
+ return { kind: "conflict", path, conflicts };
161
+ }
162
+ // Idempotent: no write, and no backup of an unchanged file.
163
+ if (changed.length === 0) {
164
+ return { kind: "unchanged", path };
165
+ }
166
+ // Backup first, so a failure anywhere here leaves the original in place: the
167
+ // replacement is a rename that either happens or does not.
168
+ try {
169
+ /* The invariant: the backup always holds the last state that was *not* ours. So a
170
+ * re-run never buries the developer's original, and a repo we created the file in
171
+ * gets no backup at all — there is no pre-AI21 state to go back to, and inventing
172
+ * one made `uninstall` restore the gateway it was meant to remove. */
173
+ if (!isOurConfig(currentEnv)) {
174
+ copyFileSync(realPath, backupPath);
175
+ // copyFileSync inherits the source's mode, and Claude Code's own file is often
176
+ // 0644 — so the backup would be a world-readable copy of the previous key, left
177
+ // behind after we tighten the real file to 0600.
178
+ chmodSync(backupPath, 0o600);
179
+ }
180
+ atomicWrite(realPath, serialize({ ...parsed, env: { ...currentEnv, ...desiredEnv } }));
181
+ }
182
+ catch (err) {
183
+ return { kind: "failed", path, reason: describeFsError(err) };
184
+ }
185
+ return { kind: "updated", path, backup: backupPath, changed };
186
+ }
187
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ export interface BrowserDeps {
2
+ readonly platform: string;
3
+ /** Returns false when the process could not be started at all. */
4
+ readonly launch: (command: string, args: readonly string[]) => boolean;
5
+ }
6
+ export declare function openBrowser(url: string, deps: BrowserDeps): boolean;
7
+ export declare function nodeBrowserDeps(): BrowserDeps;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Opening the user's browser — best-effort by nature, since a headless box or SSH
3
+ * session has none. Failing is not an error: the caller prints the URL regardless.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ function launcherFor(platform, url) {
7
+ switch (platform) {
8
+ case "darwin":
9
+ return { command: "open", args: [url] };
10
+ case "win32":
11
+ // The empty string is the window *title*: `start "http://…"` would treat the
12
+ // URL as a title and open nothing. Quoting also keeps `&` in the query string
13
+ // from being read as a command separator by cmd.exe.
14
+ return { command: "cmd", args: ["/c", "start", "", url] };
15
+ default:
16
+ return { command: "xdg-open", args: [url] };
17
+ }
18
+ }
19
+ export function openBrowser(url, deps) {
20
+ const { command, args } = launcherFor(deps.platform, url);
21
+ return deps.launch(command, args);
22
+ }
23
+ /** The real launcher: detached, so the CLI never waits on a browser to exit. */
24
+ function launch(command, args) {
25
+ try {
26
+ const child = spawn(command, [...args], { detached: true, stdio: "ignore" });
27
+ // Without unref the event loop stays alive until the browser closes — hours.
28
+ child.unref();
29
+ // A missing binary is reported asynchronously, too late to return here. Swallowed
30
+ // rather than thrown, which is why the caller always prints the URL too.
31
+ child.on("error", () => undefined);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export function nodeBrowserDeps() {
39
+ return { platform: process.platform, launch };
40
+ }
41
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.js","sourceRoot":"","sources":["../../src/login/browser.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAa3C,SAAS,WAAW,CAAC,QAAgB,EAAE,GAAW;IAChD,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,QAAQ;YACX,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1C,KAAK,OAAO;YACV,6EAA6E;YAC7E,8EAA8E;YAC9E,qDAAqD;YACrD,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC;QAC5D;YACE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAChD,CAAC;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,GAAW,EAAE,IAAiB;IACxD,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;IAE1D,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;AACpC,CAAC;AAED,gFAAgF;AAChF,SAAS,MAAM,CAAC,OAAe,EAAE,IAAuB;IACtD,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,IAAI,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC7E,6EAA6E;QAC7E,KAAK,CAAC,KAAK,EAAE,CAAC;QAEd,kFAAkF;QAClF,yEAAyE;QACzE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAEnC,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;AAChD,CAAC"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The interface between this CLI and the gateway webapp: the CLI opens
3
+ * `{app}/welcome` with a callback URL and a `state` nonce, the user signs in, and the
4
+ * page POSTs the credentials back to the loopback listener.
5
+ *
6
+ * The webapp half lives in ai21-intelligent-gateway-webapp
7
+ * (src/pages/welcome/cliHandoff.ts); this file is the only place either side's shape
8
+ * is defined, so a change to the agreement is a change to this module.
9
+ *
10
+ * Three properties are what make it safe rather than merely convenient: the secret
11
+ * arrives in a POST body and never a URL; `state` is compared before anything is
12
+ * written; and the listener is loopback-only.
13
+ */
14
+ /** Query parameters the CLI adds to the `/welcome` URL it opens. */
15
+ export declare const CALLBACK_PARAM = "cli_callback";
16
+ export declare const STATE_PARAM = "state";
17
+ /** Path the listener answers on. Only this path, and only POST (plus preflight). */
18
+ export declare const CALLBACK_PATH = "/cli-callback";
19
+ /**
20
+ * Opened first, and redirects to `/welcome`, so the nonce stays out of the browser's
21
+ * argv where other local users could read it.
22
+ *
23
+ * A mitigation, not a cure — it still reaches the URL bar and history, and a local
24
+ * attacker who guesses the port can request `/start`. Hence single-use: losing that
25
+ * race breaks the sign-in visibly rather than quietly.
26
+ */
27
+ export declare const START_PATH = "/start";
28
+ /** What the webapp is expected to POST as JSON. */
29
+ export interface CliCallback {
30
+ readonly state: string;
31
+ readonly ai21Key: string;
32
+ readonly agentId: string;
33
+ /** Drives the dashboard link; the webapp routes on `/w/:workspaceId`. Optional. */
34
+ readonly workspaceId?: string;
35
+ }
36
+ export declare function isWorkspaceId(value: unknown): value is string;
37
+ /**
38
+ * Reads an untrusted JSON body into the contract shape. Strict by design: this is
39
+ * remote input, so a missing or non-string field is a rejection, not a coercion.
40
+ * Values are validated separately, by the rule the flags use.
41
+ */
42
+ export declare function parseCallback(body: unknown): CliCallback | undefined;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The interface between this CLI and the gateway webapp: the CLI opens
3
+ * `{app}/welcome` with a callback URL and a `state` nonce, the user signs in, and the
4
+ * page POSTs the credentials back to the loopback listener.
5
+ *
6
+ * The webapp half lives in ai21-intelligent-gateway-webapp
7
+ * (src/pages/welcome/cliHandoff.ts); this file is the only place either side's shape
8
+ * is defined, so a change to the agreement is a change to this module.
9
+ *
10
+ * Three properties are what make it safe rather than merely convenient: the secret
11
+ * arrives in a POST body and never a URL; `state` is compared before anything is
12
+ * written; and the listener is loopback-only.
13
+ */
14
+ /** Query parameters the CLI adds to the `/welcome` URL it opens. */
15
+ export const CALLBACK_PARAM = "cli_callback";
16
+ export const STATE_PARAM = "state";
17
+ /** Path the listener answers on. Only this path, and only POST (plus preflight). */
18
+ export const CALLBACK_PATH = "/cli-callback";
19
+ /**
20
+ * Opened first, and redirects to `/welcome`, so the nonce stays out of the browser's
21
+ * argv where other local users could read it.
22
+ *
23
+ * A mitigation, not a cure — it still reaches the URL bar and history, and a local
24
+ * attacker who guesses the port can request `/start`. Hence single-use: losing that
25
+ * race breaks the sign-in visibly rather than quietly.
26
+ */
27
+ export const START_PATH = "/start";
28
+ /**
29
+ * An id, never a URL: the dashboard link is built from the baked-in origin, so a
30
+ * callback cannot choose where the browser lands. No `/`, `:` or `%` means an id
31
+ * cannot escape its path segment. Not a UUID check — that would be a guessed format,
32
+ * and the property needed is only "cannot escape".
33
+ */
34
+ const SAFE_ID = /^[A-Za-z0-9._-]{1,64}$/;
35
+ export function isWorkspaceId(value) {
36
+ return typeof value === "string" && SAFE_ID.test(value) && value !== "." && value !== "..";
37
+ }
38
+ /**
39
+ * Reads an untrusted JSON body into the contract shape. Strict by design: this is
40
+ * remote input, so a missing or non-string field is a rejection, not a coercion.
41
+ * Values are validated separately, by the rule the flags use.
42
+ */
43
+ export function parseCallback(body) {
44
+ if (typeof body !== "object" || body === null || Array.isArray(body))
45
+ return undefined;
46
+ const { state, ai21Key, agentId, workspaceId } = body;
47
+ if (typeof state !== "string" || typeof ai21Key !== "string" || typeof agentId !== "string")
48
+ return undefined;
49
+ if (state === "" || ai21Key === "" || agentId === "")
50
+ return undefined;
51
+ return {
52
+ state,
53
+ ai21Key,
54
+ agentId,
55
+ // Dropped rather than rejected when malformed: the dashboard redirect is a
56
+ // convenience, and losing it must not fail an otherwise good sign-in.
57
+ ...(isWorkspaceId(workspaceId) ? { workspaceId } : {}),
58
+ };
59
+ }
60
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../../src/login/contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,oEAAoE;AACpE,MAAM,CAAC,MAAM,cAAc,GAAG,cAAc,CAAC;AAC7C,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC;AAEnC,oFAAoF;AACpF,MAAM,CAAC,MAAM,aAAa,GAAG,eAAe,CAAC;AAE7C;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,QAAQ,CAAC;AAWnC;;;;;GAKG;AACH,MAAM,OAAO,GAAG,wBAAwB,CAAC;AAEzC,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,KAAK,GAAG,IAAI,KAAK,KAAK,IAAI,CAAC;AAC7F,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,IAAa;IACzC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,SAAS,CAAC;IAEvF,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,IAA+B,CAAC;IACjF,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAE9G,IAAI,KAAK,KAAK,EAAE,IAAI,OAAO,KAAK,EAAE,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAEvE,OAAO;QACL,KAAK;QACL,OAAO;QACP,OAAO;QACP,2EAA2E;QAC3E,sEAAsE;QACtE,GAAG,CAAC,aAAa,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvD,CAAC;AACJ,CAAC"}
@@ -0,0 +1,42 @@
1
+ import { type CliCallback } from "./contract.js";
2
+ /** Two minutes: long enough to sign in, short enough not to look wedged. */
3
+ export declare const LOGIN_TIMEOUT_MS = 120000;
4
+ export type LoopbackOutcome = {
5
+ readonly kind: "callback";
6
+ readonly payload: CliCallback;
7
+ } | {
8
+ readonly kind: "timeout";
9
+ } | {
10
+ readonly kind: "failed";
11
+ readonly reason: string;
12
+ };
13
+ export interface Listener {
14
+ /** Open this in the browser; it redirects to the sign-in page. */
15
+ readonly startUrl: string;
16
+ /** Where the sign-in page is redirected to, and what to print if no browser opens. */
17
+ readonly signInUrl: string;
18
+ /** The URL to hand the webapp. Always loopback, always an ephemeral port. */
19
+ readonly callbackUrl: string;
20
+ readonly state: string;
21
+ /** The interface the socket is actually bound to — asserted in tests. */
22
+ readonly host: string;
23
+ readonly port: number;
24
+ /** Settles once a valid callback arrives, on timeout, or on a listener error. */
25
+ readonly result: Promise<LoopbackOutcome>;
26
+ /** Idempotent; safe to call after the promise has settled. */
27
+ readonly close: () => void;
28
+ }
29
+ /** 256 bits from the CSPRNG. Guessing it is not a threat model we need to model. */
30
+ export declare function newState(): string;
31
+ export interface LoopbackOptions {
32
+ /** Origin allowed to POST here — the webapp's, never `*`. */
33
+ readonly allowedOrigin: string;
34
+ readonly timeoutMs?: number;
35
+ /** Builds the sign-in URL once the port is known. */
36
+ readonly signInUrl: (callbackUrl: string, state: string) => string;
37
+ }
38
+ /**
39
+ * Starts the listener. Resolves once it is bound, so the caller can put the real
40
+ * port into the URL it opens.
41
+ */
42
+ export declare function startLoopback(options: LoopbackOptions): Promise<Listener>;