@2kw/ai 7.3.0-dev.1 → 7.3.0-dev.8

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 (39) hide show
  1. package/dist/bridge/hub.d.ts +11 -0
  2. package/dist/bridge/hub.js +44 -0
  3. package/dist/bridge/inventory.d.ts +27 -0
  4. package/dist/bridge/inventory.js +41 -0
  5. package/dist/bridge/pairing.d.ts +41 -0
  6. package/dist/bridge/pairing.js +158 -0
  7. package/dist/bridge/paths.d.ts +7 -0
  8. package/dist/bridge/paths.js +16 -0
  9. package/dist/bridge/run.d.ts +12 -0
  10. package/dist/bridge/run.js +134 -0
  11. package/dist/bridge/shell.d.ts +17 -0
  12. package/dist/bridge/shell.js +48 -0
  13. package/dist/bridge/store.d.ts +33 -0
  14. package/dist/bridge/store.js +110 -0
  15. package/dist/bridge/toml.d.ts +16 -0
  16. package/dist/bridge/toml.js +39 -0
  17. package/dist/commands/bridge.d.ts +7 -0
  18. package/dist/commands/bridge.js +228 -0
  19. package/dist/commands/extractions.js +24 -5
  20. package/dist/index.js +2 -0
  21. package/dist/relay-protocol/b64.d.ts +13 -0
  22. package/dist/relay-protocol/b64.js +34 -0
  23. package/dist/relay-protocol/canonical-json.d.ts +22 -0
  24. package/dist/relay-protocol/canonical-json.js +101 -0
  25. package/dist/relay-protocol/frames.d.ts +75 -0
  26. package/dist/relay-protocol/frames.js +224 -0
  27. package/dist/relay-protocol/headers.d.ts +18 -0
  28. package/dist/relay-protocol/headers.js +88 -0
  29. package/dist/relay-protocol/jws.d.ts +28 -0
  30. package/dist/relay-protocol/jws.js +113 -0
  31. package/dist/relay-protocol/keys.d.ts +26 -0
  32. package/dist/relay-protocol/keys.js +80 -0
  33. package/dist/relay-protocol/pairing.d.ts +26 -0
  34. package/dist/relay-protocol/pairing.js +49 -0
  35. package/dist/relay-protocol/wire.d.ts +101 -0
  36. package/dist/relay-protocol/wire.js +63 -0
  37. package/dist/relay-protocol/wordlist.d.ts +30 -0
  38. package/dist/relay-protocol/wordlist.js +289 -0
  39. package/package.json +4 -1
@@ -0,0 +1,110 @@
1
+ import { basename, dirname, join, resolve } from "node:path";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { CliUsageError } from "../lib/errors.js";
4
+ import { privateKeyFromPem, privateKeyToPem } from "../relay-protocol/keys.js";
5
+ import { MAX_WORKSPACES, WORKSPACE_NAME, parseBridgeToml, stringifyBridgeToml } from "./toml.js";
6
+ /**
7
+ * The bridge's three files (#1377, plan P14): `key.pem` and `identity.json`, readable by the user
8
+ * only, and `bridge.toml`. On Windows the mode bits are inert; the files rely on the profile
9
+ * directory's own permissions.
10
+ */
11
+ const KEY_FILE = "key.pem";
12
+ const IDENTITY_FILE = "identity.json";
13
+ const CONFIG_FILE = "bridge.toml";
14
+ function writeSecretFile(path, data) {
15
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
16
+ const tmp = `${path}.tmp`;
17
+ // Owner-only from the first byte; the rename replaces the old file in one step.
18
+ writeFileSync(tmp, data, { mode: 0o600 });
19
+ renameSync(tmp, path);
20
+ chmodSync(path, 0o600);
21
+ }
22
+ export function writeKey(dir, key) {
23
+ writeSecretFile(join(dir, KEY_FILE), privateKeyToPem(key));
24
+ }
25
+ export function readKey(dir) {
26
+ return privateKeyFromPem(readFileSync(join(dir, KEY_FILE), "utf8"));
27
+ }
28
+ export function writeIdentity(dir, identity) {
29
+ writeSecretFile(join(dir, IDENTITY_FILE), JSON.stringify(identity, null, 2));
30
+ }
31
+ /** identity.json is not JSON: `connect` pairs over it, every other command names the file to delete. */
32
+ export class UnreadableIdentityError extends CliUsageError {
33
+ path;
34
+ constructor(path) {
35
+ super(`${path} is unreadable. Delete ${path}, then run 2kw bridge connect again.`);
36
+ this.path = path;
37
+ this.name = "UnreadableIdentityError";
38
+ }
39
+ }
40
+ /** null when this computer is not connected. */
41
+ export function readIdentity(dir) {
42
+ const path = join(dir, IDENTITY_FILE);
43
+ if (!existsSync(path))
44
+ return null;
45
+ try {
46
+ return JSON.parse(readFileSync(path, "utf8"));
47
+ }
48
+ catch {
49
+ throw new UnreadableIdentityError(path);
50
+ }
51
+ }
52
+ export function deleteIdentity(dir) {
53
+ rmSync(join(dir, IDENTITY_FILE), { force: true });
54
+ }
55
+ export function configPath(dir) {
56
+ return join(dir, CONFIG_FILE);
57
+ }
58
+ export function readConfig(dir) {
59
+ const path = configPath(dir);
60
+ if (!existsSync(path))
61
+ return { workspaces: [] };
62
+ try {
63
+ return parseBridgeToml(readFileSync(path, "utf8"));
64
+ }
65
+ catch (error) {
66
+ throw new CliUsageError(`${path}: ${error instanceof Error ? error.message : String(error)}`);
67
+ }
68
+ }
69
+ function writeConfig(dir, config) {
70
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ writeFileSync(configPath(dir), stringifyBridgeToml(config));
72
+ }
73
+ /** The folder name, lowercased, with every character a workspace name cannot hold turned into `-`. */
74
+ function defaultName(path) {
75
+ return basename(path)
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9._-]+/g, "-")
78
+ .replace(/^[^a-z0-9]+/, "")
79
+ .slice(0, 64);
80
+ }
81
+ export function addWorkspace(dir, path, name) {
82
+ const resolved = resolve(path);
83
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
84
+ throw new CliUsageError(`${resolved} is not an existing folder`);
85
+ }
86
+ const workspaceName = name ?? defaultName(resolved);
87
+ if (!WORKSPACE_NAME.test(workspaceName)) {
88
+ throw new CliUsageError(`'${workspaceName}' cannot be a workspace name: use 1 to 64 lowercase letters, digits, '.', '_' or '-', ` +
89
+ "starting with a letter or digit (--name sets it)");
90
+ }
91
+ const config = readConfig(dir);
92
+ if (config.workspaces.length >= MAX_WORKSPACES) {
93
+ throw new CliUsageError(`This computer already offers ${MAX_WORKSPACES} workspaces, the most it can. Remove one first with 2kw bridge remove <name>.`);
94
+ }
95
+ if (config.workspaces.some((w) => w.name === workspaceName)) {
96
+ throw new CliUsageError(`A workspace named '${workspaceName}' already exists`);
97
+ }
98
+ const workspace = { name: workspaceName, path: resolved };
99
+ writeConfig(dir, { ...config, workspaces: [...config.workspaces, workspace] });
100
+ return workspace;
101
+ }
102
+ export function removeWorkspace(dir, name) {
103
+ const config = readConfig(dir);
104
+ const workspaces = config.workspaces.filter((w) => w.name !== name);
105
+ if (workspaces.length === config.workspaces.length) {
106
+ throw new CliUsageError(`No workspace named '${name}'`);
107
+ }
108
+ writeConfig(dir, { ...config, workspaces });
109
+ }
110
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1,16 @@
1
+ export interface Workspace {
2
+ name: string;
3
+ path: string;
4
+ }
5
+ /** bridge.toml: an optional top-level `shell` and `[[workspace]]` tables with `name` and `path`. */
6
+ export interface BridgeConfig {
7
+ shell?: string;
8
+ workspaces: Workspace[];
9
+ }
10
+ export declare const WORKSPACE_NAME: RegExp;
11
+ /** The most workspaces the backend takes in one inventory. */
12
+ export declare const MAX_WORKSPACES = 50;
13
+ /** Throws a plain message on anything that is not a bridge.toml this bridge wrote or could have. */
14
+ export declare function parseBridgeToml(text: string): BridgeConfig;
15
+ export declare function stringifyBridgeToml(config: BridgeConfig): string;
16
+ //# sourceMappingURL=toml.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { parse, stringify } from "smol-toml";
2
+ export const WORKSPACE_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
3
+ /** The most workspaces the backend takes in one inventory. */
4
+ export const MAX_WORKSPACES = 50;
5
+ /** Throws a plain message on anything that is not a bridge.toml this bridge wrote or could have. */
6
+ export function parseBridgeToml(text) {
7
+ const raw = parse(text);
8
+ const shell = raw.shell;
9
+ if (shell !== undefined && typeof shell !== "string") {
10
+ throw new Error("shell must be a path");
11
+ }
12
+ const tables = raw.workspace ?? [];
13
+ if (!Array.isArray(tables)) {
14
+ throw new Error("workspace must be a list of [[workspace]] tables");
15
+ }
16
+ if (tables.length > MAX_WORKSPACES) {
17
+ throw new Error(`at most ${MAX_WORKSPACES} [[workspace]] tables`);
18
+ }
19
+ const names = new Set();
20
+ const workspaces = tables.map((table) => {
21
+ const { name, path } = (table ?? {});
22
+ if (typeof name !== "string" || !WORKSPACE_NAME.test(name) || typeof path !== "string" || path === "") {
23
+ throw new Error("every [[workspace]] needs a valid name and a path");
24
+ }
25
+ if (names.has(name))
26
+ throw new Error(`the workspace name '${name}' is used twice`);
27
+ names.add(name);
28
+ return { name, path };
29
+ });
30
+ return shell === undefined ? { workspaces } : { shell, workspaces };
31
+ }
32
+ export function stringifyBridgeToml(config) {
33
+ const doc = {};
34
+ if (config.shell !== undefined)
35
+ doc.shell = config.shell;
36
+ doc.workspace = config.workspaces.map(({ name, path }) => ({ name, path }));
37
+ return `${stringify(doc)}\n`;
38
+ }
39
+ //# sourceMappingURL=toml.js.map
@@ -0,0 +1,7 @@
1
+ import { Command } from "commander";
2
+ /**
3
+ * `2kw bridge`: lets chat run the commands you approve on this computer (#1377, spec §2.1, plan P14).
4
+ * S1 pairs, registers and stays present; commands arrive with #1378.
5
+ */
6
+ export declare function makeBridgeCommand(): Command;
7
+ //# sourceMappingURL=bridge.d.ts.map
@@ -0,0 +1,228 @@
1
+ import chalk from "chalk";
2
+ import { Command } from "commander";
3
+ import { LABEL, collectInventory, deviceName, deviceOs } from "../bridge/inventory.js";
4
+ import { DeviceGoneError, awaitConfirmation, disconnectDevice, redeem, reportInventory } from "../bridge/pairing.js";
5
+ import { bridgeDir } from "../bridge/paths.js";
6
+ import { runBridge } from "../bridge/run.js";
7
+ import { findBash } from "../bridge/shell.js";
8
+ import { addWorkspace, configPath, deleteIdentity, readConfig, readIdentity, readKey, removeWorkspace, UnreadableIdentityError, writeIdentity, writeKey, } from "../bridge/store.js";
9
+ import { getClient, runAction } from "../lib/client.js";
10
+ import { DEFAULT_BASE_URL, getActiveContext, isJsonOutput } from "../lib/config.js";
11
+ import { CliUsageError } from "../lib/errors.js";
12
+ import { formatList, formatSuccess } from "../lib/output.js";
13
+ import { generatePrivateKey } from "../relay-protocol/keys.js";
14
+ /** Account endpoints take a browser sign-in; said where `--help` shows it, as in connectors.ts. */
15
+ const SESSION_ONLY = "Signed-in accounts only: run 2kw auth login and sign in through the browser";
16
+ /** The backend to pair with: `--base-url`, then AI_2KW_BASE_URL, the active context, the default. No credential needed. */
17
+ function baseUrlOf(command) {
18
+ let root = command;
19
+ while (root.parent)
20
+ root = root.parent;
21
+ const url = root.opts().baseUrl ?? process.env.AI_2KW_BASE_URL ?? getActiveContext()?.baseUrl;
22
+ return (url ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
23
+ }
24
+ /** Plain http only to this computer: the pairing answer carries the keys the bridge trusts. */
25
+ function requireHttps(baseUrl) {
26
+ const { protocol, hostname } = new URL(baseUrl);
27
+ if (protocol === "https:" || (protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(hostname))) {
28
+ return baseUrl;
29
+ }
30
+ throw new CliUsageError(`${baseUrl} is not an https address. The bridge connects only over https, or over http to this computer (localhost).`);
31
+ }
32
+ function checkLabel(value, what) {
33
+ if (!LABEL.test(value)) {
34
+ throw new CliUsageError(`${what} must be 1 to 64 characters on one line, without control or invisible characters`);
35
+ }
36
+ return value;
37
+ }
38
+ /** Loads the key and identity of a connected computer, or says how to connect one. */
39
+ function connected(dir) {
40
+ const identity = readIdentity(dir);
41
+ if (identity === null) {
42
+ throw new CliUsageError("This computer is not connected. Run 2kw bridge connect <code> with the code from chat.");
43
+ }
44
+ return { identity, key: readKey(dir) };
45
+ }
46
+ /**
47
+ * `2kw bridge`: lets chat run the commands you approve on this computer (#1377, spec §2.1, plan P14).
48
+ * S1 pairs, registers and stays present; commands arrive with #1378.
49
+ */
50
+ export function makeBridgeCommand() {
51
+ const cmd = new Command("bridge").description("Connect this computer to chat, so commands you approve in a conversation can run here");
52
+ cmd
53
+ .command("connect")
54
+ .description("Connect this computer with the code shown in chat under Devices")
55
+ .argument("<code>", "Pairing code from chat (bbp_…)")
56
+ .option("--name <name>", "Name chat shows for this computer; the computer name when omitted")
57
+ .action(async (code, opts, command) => {
58
+ await runAction(command, async () => {
59
+ const dir = bridgeDir();
60
+ let existing;
61
+ try {
62
+ existing = readIdentity(dir);
63
+ }
64
+ catch (error) {
65
+ if (!(error instanceof UnreadableIdentityError))
66
+ throw error;
67
+ console.error(chalk.yellow(`${error.path} is unreadable; this pairing replaces it once you confirm.`));
68
+ existing = null;
69
+ }
70
+ if (existing !== null) {
71
+ throw new CliUsageError(`This computer is already connected as '${existing.name}'. Run 2kw bridge disconnect first.`);
72
+ }
73
+ const baseUrl = requireHttps(baseUrlOf(command));
74
+ const name = checkLabel(opts.name ?? deviceName(), "--name");
75
+ const key = generatePrivateKey();
76
+ const redeemed = await redeem(baseUrl, key, code.trim(), name, deviceOs());
77
+ console.log(`Check that chat shows these six words for '${name}':\n`);
78
+ console.log(` ${chalk.bold(redeemed.fingerprint.join(" "))}\n`);
79
+ console.log("If they match, confirm in chat. If they do not, do not confirm. Waiting…");
80
+ const confirmed = await awaitConfirmation(baseUrl, key, redeemed);
81
+ if (confirmed === null) {
82
+ throw new Error("The code expired before it was confirmed. Create a new one in chat and run this again.");
83
+ }
84
+ const identity = { ...confirmed, baseUrl, name, pairedAt: new Date().toISOString() };
85
+ writeKey(dir, key);
86
+ writeIdentity(dir, identity);
87
+ try {
88
+ await reportInventory(identity, key, await collectInventory(readConfig(dir)));
89
+ }
90
+ catch (error) {
91
+ // Paired all the same; `2kw bridge run` reports again when it starts.
92
+ console.error(chalk.yellow(`Could not send this computer's folders yet: ${String(error)}`));
93
+ }
94
+ formatSuccess(`Connected as '${name}'. Add folders with 2kw bridge add <path>, then keep 2kw bridge run open.`, command);
95
+ });
96
+ });
97
+ cmd
98
+ .command("add")
99
+ .description("Make a folder on this computer available as a workspace")
100
+ .argument("<path>", "An existing folder")
101
+ .option("--name <name>", "Workspace name; the folder name when omitted")
102
+ .action(async (path, opts, command) => {
103
+ await runAction(command, async () => {
104
+ const workspace = addWorkspace(bridgeDir(), path, opts.name);
105
+ formatSuccess(`Added workspace '${workspace.name}' (${workspace.path}).`, command);
106
+ });
107
+ });
108
+ cmd
109
+ .command("remove")
110
+ .description("Stop offering a workspace; the folder itself is untouched")
111
+ .argument("<name>", "Workspace name, from 2kw bridge list")
112
+ .action(async (name, _opts, command) => {
113
+ await runAction(command, async () => {
114
+ removeWorkspace(bridgeDir(), name);
115
+ formatSuccess(`Removed workspace '${name}'.`, command);
116
+ });
117
+ });
118
+ cmd
119
+ .command("list")
120
+ .description("Show whether this computer is connected, its shell and its workspaces")
121
+ .action(async (_opts, command) => {
122
+ await runAction(command, async () => {
123
+ const dir = bridgeDir();
124
+ const identity = readIdentity(dir);
125
+ const config = readConfig(dir);
126
+ const shell = findBash(config.shell);
127
+ if (isJsonOutput(command)) {
128
+ console.log(JSON.stringify({ connected: identity !== null, name: identity?.name, deviceId: identity?.relayId, shell, workspaces: config.workspaces }, null, 2));
129
+ return;
130
+ }
131
+ console.log(identity === null
132
+ ? "Not connected. Run 2kw bridge connect <code> with the code from chat."
133
+ : `Connected as '${identity.name}' to ${identity.baseUrl} (device ${identity.relayId}).`);
134
+ if (shell !== null) {
135
+ console.log(`Shell: ${shell}`);
136
+ }
137
+ else if (config.shell !== undefined) {
138
+ console.log(chalk.yellow(`The shell set in ${configPath(dir)} does not exist: ${config.shell}`));
139
+ }
140
+ else {
141
+ console.log(chalk.yellow(process.platform === "win32"
142
+ ? "No bash found: install Git for Windows, or set shell = \"<path to bash.exe>\" in " + configPath(dir)
143
+ : "No bash found on PATH: install bash, or set shell = \"<path>\" in " + configPath(dir)));
144
+ }
145
+ if (config.workspaces.length === 0) {
146
+ console.log("No workspaces. Add one with 2kw bridge add <path>.");
147
+ }
148
+ else {
149
+ formatList(config.workspaces, command, ["name", "path"]);
150
+ }
151
+ });
152
+ });
153
+ cmd
154
+ .command("run")
155
+ .description("Keep this computer reachable from chat while this command runs (Ctrl+C stops)")
156
+ .action(async (_opts, command) => {
157
+ await runAction(command, async () => {
158
+ const dir = bridgeDir();
159
+ const { identity, key } = connected(dir);
160
+ requireHttps(identity.baseUrl);
161
+ const stop = new AbortController();
162
+ const onSignal = () => stop.abort();
163
+ process.once("SIGINT", onSignal);
164
+ process.once("SIGTERM", onSignal);
165
+ console.log(`'${identity.name}' is reachable from chat while this runs. Press Ctrl+C to stop.`);
166
+ try {
167
+ await runBridge(dir, identity, key, stop.signal);
168
+ }
169
+ catch (error) {
170
+ // Revoked: these files can never authenticate again.
171
+ if (error instanceof DeviceGoneError)
172
+ deleteIdentity(dir);
173
+ throw error;
174
+ }
175
+ finally {
176
+ process.off("SIGINT", onSignal);
177
+ process.off("SIGTERM", onSignal);
178
+ }
179
+ });
180
+ });
181
+ cmd
182
+ .command("disconnect")
183
+ .description("Disconnect this computer; chat can no longer reach it")
184
+ .action(async (_opts, command) => {
185
+ await runAction(command, async () => {
186
+ const dir = bridgeDir();
187
+ const { identity, key } = connected(dir);
188
+ try {
189
+ await disconnectDevice(identity, key);
190
+ }
191
+ catch (error) {
192
+ // Already revoked elsewhere: forgetting it here is all that is left to do.
193
+ if (!(error instanceof DeviceGoneError))
194
+ throw error;
195
+ }
196
+ deleteIdentity(dir);
197
+ formatSuccess(`Disconnected '${identity.name}'.`, command);
198
+ });
199
+ });
200
+ cmd
201
+ .command("devices")
202
+ .description(`List the computers connected to your account. ${SESSION_ONLY}`)
203
+ .option("--all", "Every computer in the organization, without its folders (admins only)")
204
+ .action(async (opts, command) => {
205
+ await runAction(command, async () => {
206
+ const client = getClient(command);
207
+ const { data } = await client.GET("/v1/local-bridge/devices", {
208
+ params: { query: opts.all ? { all: true } : {} },
209
+ });
210
+ formatList(data, command, opts.all
211
+ ? ["id", "name", "ownerMemberId", "os", "status", "lastSeenAt"]
212
+ : ["id", "name", "os", "status", "lastSeenAt", "workspaces"]);
213
+ });
214
+ });
215
+ cmd
216
+ .command("revoke")
217
+ .description(`Disconnect one of your computers; an admin can disconnect any. ${SESSION_ONLY}`)
218
+ .argument("<deviceId>", "Device ID, from 2kw bridge devices")
219
+ .action(async (deviceId, _opts, command) => {
220
+ await runAction(command, async () => {
221
+ const client = getClient(command);
222
+ await client.DELETE("/v1/local-bridge/devices/{id}", { params: { path: { id: deviceId } } });
223
+ formatSuccess(`Device ${deviceId} disconnected.`, command);
224
+ });
225
+ });
226
+ return cmd;
227
+ }
228
+ //# sourceMappingURL=bridge.js.map
@@ -131,13 +131,32 @@ export function makeExtractionsCommand() {
131
131
  });
132
132
  cmd
133
133
  .command("rerun")
134
- .description("Re-run an extraction")
135
- .argument("<id>", "Extraction ID to rerun")
136
- .action(async (id, _opts, command) => {
134
+ .description("Re-run one extraction, or queue a batch (several IDs, or a filter) of up to 100")
135
+ .argument("[ids...]", "Extraction IDs to rerun")
136
+ .option("--status <status>", "Rerun extractions with this status (e.g. FAILED)")
137
+ .option("--schema-version <id>", "Rerun extractions of this schema version")
138
+ .option("-s, --search <term>", "Rerun extractions whose model contains this text")
139
+ .action(async (ids, opts, command) => {
137
140
  await runAction(command, async () => {
138
141
  const client = getClient(command);
139
- const { data } = await withSpinner("Re-running extraction...", () => client.POST("/v1/extractions/{id}/rerun", {
140
- params: { path: { id } },
142
+ const filtered = Boolean(opts.status || opts.schemaVersion || opts.search);
143
+ if (ids.length === 1 && !filtered) {
144
+ const { data } = await withSpinner("Re-running extraction...", () => client.POST("/v1/extractions/{id}/rerun", {
145
+ params: { path: { id: ids[0] } },
146
+ }));
147
+ formatDetail(data, command);
148
+ return;
149
+ }
150
+ if (ids.length === 0 && !filtered) {
151
+ throw new Error("Pass an extraction ID, several IDs, or a filter (--status, --schema-version, --search).");
152
+ }
153
+ if (ids.length > 0 && filtered) {
154
+ throw new Error("Pass extraction IDs or a filter, not both.");
155
+ }
156
+ const { data } = await withSpinner("Queueing re-runs...", () => client.POST("/v1/extractions/rerun", {
157
+ body: filtered
158
+ ? { status: opts.status, schemaVersionId: opts.schemaVersion, search: opts.search }
159
+ : { ids },
141
160
  }));
142
161
  formatDetail(data, command);
143
162
  });
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { makeFilesCommand } from "./commands/files.js";
34
34
  import { makeInstallationsCommand } from "./commands/installations.js";
35
35
  import { makeConnectorsCommand } from "./commands/connectors.js";
36
36
  import { makeRelaysCommand } from "./commands/relays.js";
37
+ import { makeBridgeCommand } from "./commands/bridge.js";
37
38
  import { checkForUpdates } from "./lib/update-notifier.js";
38
39
  const updater = checkForUpdates(pkg.version);
39
40
  const program = new Command();
@@ -75,6 +76,7 @@ program.addCommand(makeFilesCommand());
75
76
  program.addCommand(makeInstallationsCommand());
76
77
  program.addCommand(makeConnectorsCommand());
77
78
  program.addCommand(makeRelaysCommand());
79
+ program.addCommand(makeBridgeCommand());
78
80
  program.addCommand(makeDocsCommand());
79
81
  program.parseAsync().then(() => updater.notify());
80
82
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /** base64url without padding (RFC 4648 §5), contract C1. */
2
+ export declare function b64url(bytes: Uint8Array): string;
3
+ /**
4
+ * Decodes the canonical unpadded base64url of contract C1 and nothing else. Node's decoder is
5
+ * lenient: it drops a dangling character that carries no whole byte ("A" gives no bytes) and
6
+ * ignores non-canonical trailing bits ("-_8", "-_9" and "-__" all give 0xfb 0xff). Re-encoding
7
+ * the result is what rejects those, so one byte string has exactly one accepted spelling.
8
+ */
9
+ export declare function fromB64url(value: string): Buffer;
10
+ export declare function sha256(data: Uint8Array | string): Buffer;
11
+ export declare function sha256b64url(data: Uint8Array | string): string;
12
+ export declare function randomHex(bytes: number): string;
13
+ //# sourceMappingURL=b64.d.ts.map
@@ -0,0 +1,34 @@
1
+ // GENERATED COPY of relay/src/protocol/b64.ts (#1377). Do not edit here: edit the relay's file,
2
+ // then run `npm run sync:relay-protocol` in cli/. `npm run check:relay-protocol` fails on any drift.
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ const BASE64URL = /^[A-Za-z0-9_-]*$/;
5
+ /** base64url without padding (RFC 4648 §5), contract C1. */
6
+ export function b64url(bytes) {
7
+ return Buffer.from(bytes).toString("base64url");
8
+ }
9
+ /**
10
+ * Decodes the canonical unpadded base64url of contract C1 and nothing else. Node's decoder is
11
+ * lenient: it drops a dangling character that carries no whole byte ("A" gives no bytes) and
12
+ * ignores non-canonical trailing bits ("-_8", "-_9" and "-__" all give 0xfb 0xff). Re-encoding
13
+ * the result is what rejects those, so one byte string has exactly one accepted spelling.
14
+ */
15
+ export function fromB64url(value) {
16
+ if (!BASE64URL.test(value)) {
17
+ throw new Error("invalid base64url");
18
+ }
19
+ const decoded = Buffer.from(value, "base64url");
20
+ if (b64url(decoded) !== value) {
21
+ throw new Error("invalid base64url");
22
+ }
23
+ return decoded;
24
+ }
25
+ export function sha256(data) {
26
+ return createHash("sha256").update(data).digest();
27
+ }
28
+ export function sha256b64url(data) {
29
+ return b64url(sha256(data));
30
+ }
31
+ export function randomHex(bytes) {
32
+ return randomBytes(bytes).toString("hex");
33
+ }
34
+ //# sourceMappingURL=b64.js.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Contract C10: keys sorted by code unit, arrays in order, no whitespace.
3
+ *
4
+ * The Java twin is CanonicalJson.java, and the two must agree byte for byte — `configHash` is
5
+ * computed on both sides, so a value the two platforms serialize differently would change the
6
+ * hash silently and every request assertion would then fail with no clue why. Where the
7
+ * platforms differ by default, the vectors in test/vectors/inventory.json pin this side's
8
+ * behaviour and test/vectors/README.md restates it: non-ASCII characters stay raw (never
9
+ * `\uXXXX`), control characters escape to lower-case `\u00xx` with the short forms for
10
+ * \b \f \n \r \t, `/` and DEL stay unescaped, and key order is UTF-16 code unit order
11
+ * (JavaScript `<`, Java `String.compareTo`), not UTF-8 byte order.
12
+ *
13
+ * Ruling C10-strict: everything outside that agreement is REFUSED rather than serialized.
14
+ * Fail closed — a `CanonicalJsonError` at the call site is a bug report; a hash that differs
15
+ * by platform is a support case nobody can diagnose.
16
+ */
17
+ export declare class CanonicalJsonError extends Error {
18
+ readonly code = "unsupported_value";
19
+ constructor(message: string);
20
+ }
21
+ export declare function canonicalJson(value: unknown): string;
22
+ //# sourceMappingURL=canonical-json.d.ts.map
@@ -0,0 +1,101 @@
1
+ // GENERATED COPY of relay/src/protocol/canonical-json.ts (#1377). Do not edit here: edit the relay's file,
2
+ // then run `npm run sync:relay-protocol` in cli/. `npm run check:relay-protocol` fails on any drift.
3
+ /**
4
+ * Contract C10: keys sorted by code unit, arrays in order, no whitespace.
5
+ *
6
+ * The Java twin is CanonicalJson.java, and the two must agree byte for byte — `configHash` is
7
+ * computed on both sides, so a value the two platforms serialize differently would change the
8
+ * hash silently and every request assertion would then fail with no clue why. Where the
9
+ * platforms differ by default, the vectors in test/vectors/inventory.json pin this side's
10
+ * behaviour and test/vectors/README.md restates it: non-ASCII characters stay raw (never
11
+ * `\uXXXX`), control characters escape to lower-case `\u00xx` with the short forms for
12
+ * \b \f \n \r \t, `/` and DEL stay unescaped, and key order is UTF-16 code unit order
13
+ * (JavaScript `<`, Java `String.compareTo`), not UTF-8 byte order.
14
+ *
15
+ * Ruling C10-strict: everything outside that agreement is REFUSED rather than serialized.
16
+ * Fail closed — a `CanonicalJsonError` at the call site is a bug report; a hash that differs
17
+ * by platform is a support case nobody can diagnose.
18
+ */
19
+ export class CanonicalJsonError extends Error {
20
+ code = "unsupported_value";
21
+ constructor(message) {
22
+ super(message);
23
+ this.name = "CanonicalJsonError";
24
+ }
25
+ }
26
+ /**
27
+ * A lone surrogate — a UTF-16 code unit with no partner. It is legal in a JavaScript string
28
+ * and reachable from legal JSON text (`"\ud800"`), but it has no UTF-8 encoding: JavaScript
29
+ * escapes it back, Java writes a replacement character or raises, and the two digests part
30
+ * company. Written as an explicit scan rather than a lookbehind regex so the Java twin can
31
+ * restate it line for line.
32
+ */
33
+ function hasLoneSurrogate(text) {
34
+ for (let i = 0; i < text.length; i += 1) {
35
+ const unit = text.charCodeAt(i);
36
+ if (unit >= 0xd800 && unit <= 0xdbff) {
37
+ const next = i + 1 < text.length ? text.charCodeAt(i + 1) : 0;
38
+ if (next < 0xdc00 || next > 0xdfff) {
39
+ return true;
40
+ }
41
+ i += 1;
42
+ }
43
+ else if (unit >= 0xdc00 && unit <= 0xdfff) {
44
+ return true;
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+ /** Only a plain object carries its data in its own enumerable properties; a Date or a Map does not. */
50
+ function isPlainObject(value) {
51
+ const proto = Object.getPrototypeOf(value);
52
+ return proto === Object.prototype || proto === null;
53
+ }
54
+ function refuse(what) {
55
+ throw new CanonicalJsonError(`canonical JSON does not support ${what}`);
56
+ }
57
+ function encodeString(value, what) {
58
+ if (hasLoneSurrogate(value)) {
59
+ refuse(`a lone surrogate in ${what}`);
60
+ }
61
+ return JSON.stringify(value);
62
+ }
63
+ export function canonicalJson(value) {
64
+ if (Array.isArray(value)) {
65
+ // An array element has no "drop it" option the way a property does: dropping one would
66
+ // change the array's length, so an unrepresentable element is refused.
67
+ return `[${value.map((element) => canonicalJson(element)).join(",")}]`;
68
+ }
69
+ switch (typeof value) {
70
+ case "string":
71
+ return encodeString(value, "a string");
72
+ case "boolean":
73
+ return value ? "true" : "false";
74
+ case "number":
75
+ if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
76
+ // JavaScript prints 1.0 as "1" and 1e21 as "1e+21"; Java's Double.toString gives
77
+ // "1.0" and "1.0E21". Only safe integers spell the same on both sides.
78
+ refuse(`the number ${String(value)}; only safe integers are portable`);
79
+ }
80
+ return String(value);
81
+ case "object": {
82
+ if (value === null) {
83
+ return "null";
84
+ }
85
+ if (!isPlainObject(value)) {
86
+ refuse(`${value.constructor?.name ?? "a non-plain object"}; use a plain object or an array`);
87
+ }
88
+ const entries = Object.entries(value)
89
+ // The brief drops an undefined property: an absent key and an undefined one mean the
90
+ // same thing in JSON, and dropping it changes nothing either side can observe.
91
+ .filter(([, v]) => v !== undefined)
92
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
93
+ return `{${entries.map(([k, v]) => `${encodeString(k, "a key")}:${canonicalJson(v)}`).join(",")}}`;
94
+ }
95
+ case "undefined":
96
+ return refuse("undefined outside an object property");
97
+ default:
98
+ return refuse(`a ${typeof value} value`);
99
+ }
100
+ }
101
+ //# sourceMappingURL=canonical-json.js.map