witnora 0.10.2 → 0.10.4
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 +21 -0
- package/dist/cli.js +17 -0
- package/dist/command-help.js +15 -0
- package/dist/onboard.js +23 -4
- package/dist/onboarding-templates.js +29 -14
- package/dist/private-discovery.js +146 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,27 @@ boundary, writes only missing configuration, and verifies the local evidence
|
|
|
26
26
|
path. `.witnora/onboarding/receipt.json` is synthetic: it cannot create a run,
|
|
27
27
|
evidence object, release decision, or `CURRENT` assurance.
|
|
28
28
|
|
|
29
|
+
Onboarding also performs the default private capability discovery locally. It
|
|
30
|
+
does not read source-file contents or upload source code, prompt text,
|
|
31
|
+
credentials, tool inputs, tool outputs, database rows, or file contents. It
|
|
32
|
+
uploads a signed snapshot containing generalized capability metadata and
|
|
33
|
+
digests. Refresh it later with:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx witnora@latest discover
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Customers that do not permit repository metadata inspection can choose
|
|
40
|
+
runtime-only discovery in Hosted. Source-assisted analysis is a separate,
|
|
41
|
+
explicit GitHub authorization and is never enabled implicitly.
|
|
42
|
+
|
|
43
|
+
After the connection self-test, Hosted shows one template-specific next step.
|
|
44
|
+
Place the generated boundary in one meaningful sandbox workflow and run it
|
|
45
|
+
normally. The generated local adapter reuses the saved restricted connection;
|
|
46
|
+
CI and production should use the same project-scoped values from a secret
|
|
47
|
+
manager. The first non-synthetic run completes onboarding, but remains
|
|
48
|
+
`reported` evidence and does not establish a release decision or `CURRENT`.
|
|
49
|
+
|
|
29
50
|
Without an agent repository, run `npx witnora@latest try --template workflow`
|
|
30
51
|
for an offline-only sample.
|
|
31
52
|
|
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,8 @@ import { runAssuranceLoopDemo } from "./assurance-loop-demo.js";
|
|
|
36
36
|
import { importGenericEval, renderGenericEvalReport } from "./generic-eval.js";
|
|
37
37
|
import { runDesignPartnerCommand } from "./design-partner-v02.js";
|
|
38
38
|
import { runOnboard } from "./onboard.js";
|
|
39
|
+
import { inspectRepository } from "./onboard.js";
|
|
40
|
+
import { runPrivateCapabilityDiscovery } from "./private-discovery.js";
|
|
39
41
|
import { verifyEvidencePacketV02 } from "./evidence-v02.js";
|
|
40
42
|
process.on("uncaughtException", reportFatalError);
|
|
41
43
|
process.on("unhandledRejection", reportFatalError);
|
|
@@ -155,6 +157,21 @@ else if (command === "onboard") {
|
|
|
155
157
|
openBrowser: readBoolFlag("--no-browser") ? async () => { } : undefined,
|
|
156
158
|
});
|
|
157
159
|
}
|
|
160
|
+
else if (command === "discover") {
|
|
161
|
+
const connectionName = readFlag("--connection");
|
|
162
|
+
const connection = await resolveConnection({
|
|
163
|
+
name: connectionName,
|
|
164
|
+
server: readFlag("--server"),
|
|
165
|
+
projectId: readFlag("--project"),
|
|
166
|
+
apiKey: readFlag("--api-key"),
|
|
167
|
+
});
|
|
168
|
+
const repositoryPath = resolve(readFlag("--repo") ?? process.cwd());
|
|
169
|
+
const repository = await inspectRepository(repositoryPath, readFlag("--template") ? parseAgentTemplate(readFlag("--template")) : undefined);
|
|
170
|
+
const result = await runPrivateCapabilityDiscovery({ connection, repository, connectionName: connectionName ?? repository.slug });
|
|
171
|
+
process.stdout.write(`Private capability discovery completed.\n`);
|
|
172
|
+
process.stdout.write(`Capability groups: ${result.capabilityCount}\nUnknown groups awaiting review: ${result.unknownCapabilityCount}\n`);
|
|
173
|
+
process.stdout.write("Uploaded: signed capability metadata and digests only.\nStayed local: source code, prompts, credentials, tool inputs and outputs, database rows, and file contents.\n");
|
|
174
|
+
}
|
|
158
175
|
else if (command === "try") {
|
|
159
176
|
const template = parseAgentTemplate(readFlag("--template") ?? "workflow");
|
|
160
177
|
const subject = readFlag("--subject") ?? `witnora-sample-${template}`;
|
package/dist/command-help.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
export function renderCommandHelp(command) {
|
|
2
2
|
if (command === "sandbox" || command === "browser-adapter")
|
|
3
3
|
return undefined;
|
|
4
|
+
if (command === "discover")
|
|
5
|
+
return `Usage:
|
|
6
|
+
witnora discover [--connection <name>] [--repo <directory>]
|
|
7
|
+
|
|
8
|
+
Runs capability discovery inside the customer environment and uploads only a signed,
|
|
9
|
+
metadata-only snapshot. Source code, prompt text, credentials, tool inputs and outputs,
|
|
10
|
+
database rows, and file contents are not uploaded. New capability groups remain pending
|
|
11
|
+
until reviewed or observed through a controlled runtime boundary.
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--connection <name> Saved Hosted connection (default: current connection)
|
|
15
|
+
--repo <directory> Repository whose local metadata is inspected (default: current directory)
|
|
16
|
+
--template <type> Optional repository-type hint
|
|
17
|
+
--help, -h Show this help
|
|
18
|
+
`;
|
|
4
19
|
if (command === "onboard")
|
|
5
20
|
return `Usage:
|
|
6
21
|
witnora onboard --project <project-id>
|
package/dist/onboard.js
CHANGED
|
@@ -4,7 +4,8 @@ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { DEFAULT_WITNORA_SERVER, saveConnection } from "./credentials.js";
|
|
6
6
|
import { verifyControlPlaneConnection } from "./control-plane.js";
|
|
7
|
-
import {
|
|
7
|
+
import { inferPrivateCapabilities, runPrivateCapabilityDiscovery } from "./private-discovery.js";
|
|
8
|
+
import { parseAgentTemplate, starterAdapter, starterInstructions, starterProfile, starterTripwireConfig } from "./onboarding-templates.js";
|
|
8
9
|
import { writeTryEvidence } from "./try.js";
|
|
9
10
|
export async function runOnboard(options) {
|
|
10
11
|
const requestFetch = options.fetch ?? fetch;
|
|
@@ -43,12 +44,21 @@ export async function runOnboard(options) {
|
|
|
43
44
|
const receiptPath = join(repositoryPath, ".witnora", "onboarding", "receipt.json");
|
|
44
45
|
await mkdir(dirname(receiptPath), { recursive: true });
|
|
45
46
|
await writeFile(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
47
|
+
const discovery = await runPrivateCapabilityDiscovery({
|
|
48
|
+
connection: { server, projectId: token.projectId, apiKey: token.apiKey },
|
|
49
|
+
repository,
|
|
50
|
+
connectionName: token.connectionName,
|
|
51
|
+
configHome: options.configHome,
|
|
52
|
+
fetch: requestFetch,
|
|
53
|
+
});
|
|
46
54
|
output(`\nConnected ${repository.name} to Witnora.\n`);
|
|
47
55
|
output(`Project: ${token.projectId}\nTemplate: ${repository.template} (${repository.kind})\n`);
|
|
48
56
|
output(`Credentials: ${credentialsPath}\nSelf-test receipt: ${receiptPath}\n`);
|
|
57
|
+
output(`Private discovery: ${discovery.capabilityCount} capability group(s); source code, prompts, credentials, and payloads stayed local.\n`);
|
|
49
58
|
output("The synthetic self-test verified the local evidence path. It did not create a run, evidence object, release decision, or CURRENT assurance.\n");
|
|
59
|
+
output(starterInstructions(repository.template, repository.name));
|
|
50
60
|
return { projectId: token.projectId, connectionName: token.connectionName, credentialsPath, template: repository.template,
|
|
51
|
-
repositoryKind: repository.kind, generatedFiles, receiptPath };
|
|
61
|
+
repositoryKind: repository.kind, generatedFiles, receiptPath, discovery };
|
|
52
62
|
}
|
|
53
63
|
async function waitForToken(requestFetch, server, device, codeVerifier, sleep, timeoutMs) {
|
|
54
64
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -65,7 +75,7 @@ async function waitForToken(requestFetch, server, device, codeVerifier, sleep, t
|
|
|
65
75
|
}
|
|
66
76
|
throw new Error("Device authorization timed out. Run witnora onboard again to create a new one-time request.");
|
|
67
77
|
}
|
|
68
|
-
async function inspectRepository(repositoryPath, explicitTemplate) {
|
|
78
|
+
export async function inspectRepository(repositoryPath, explicitTemplate) {
|
|
69
79
|
const entries = await readdir(repositoryPath, { withFileTypes: true });
|
|
70
80
|
const names = entries.map((entry) => entry.name).sort();
|
|
71
81
|
const packageJson = await optionalJson(join(repositoryPath, "package.json"));
|
|
@@ -81,7 +91,13 @@ async function inspectRepository(repositoryPath, explicitTemplate) {
|
|
|
81
91
|
const name = rawName.replace(/^@[^/]+\//, "") || "agent-repository";
|
|
82
92
|
const slug = (name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agent-repository").slice(0, 64);
|
|
83
93
|
const fingerprintSha256 = createHash("sha256").update(JSON.stringify({ kind, name, files: names.filter((value) => !value.startsWith(".")).slice(0, 200) })).digest("hex");
|
|
84
|
-
|
|
94
|
+
const dependencyNames = packageJson ? [
|
|
95
|
+
...Object.keys(record(packageJson.dependencies)),
|
|
96
|
+
...Object.keys(record(packageJson.devDependencies)),
|
|
97
|
+
...Object.keys(record(packageJson.peerDependencies)),
|
|
98
|
+
] : [];
|
|
99
|
+
const capabilities = inferPrivateCapabilities({ dependencyNames, topLevelNames: names });
|
|
100
|
+
return { kind, name, slug, template, fingerprintSha256, capabilities };
|
|
85
101
|
}
|
|
86
102
|
async function generateRepositoryConfig(repositoryPath, template, subject) {
|
|
87
103
|
const files = new Map();
|
|
@@ -130,3 +146,6 @@ catch {
|
|
|
130
146
|
return false;
|
|
131
147
|
} }
|
|
132
148
|
function normalizeServer(value) { return new URL(value).toString().replace(/\/$/, ""); }
|
|
149
|
+
function record(value) {
|
|
150
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
151
|
+
}
|
|
@@ -33,10 +33,13 @@ export function starterAdapter(template, subject) {
|
|
|
33
33
|
return `#!/usr/bin/env node
|
|
34
34
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
35
35
|
import { readFile } from "node:fs/promises";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { join } from "node:path";
|
|
36
38
|
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
39
|
+
const connection = await resolveHostedConnection();
|
|
40
|
+
const baseUrl = connection.baseUrl.replace(/\\\/$/, "");
|
|
41
|
+
const projectId = connection.projectId;
|
|
42
|
+
const apiKey = connection.apiKey;
|
|
40
43
|
const agentId = branded("AGENT_ID") ?? ${JSON.stringify(subject)};
|
|
41
44
|
const agentName = branded("AGENT_NAME") ?? humanize(agentId);
|
|
42
45
|
const agentVersion = await discoverAgentVersion();
|
|
@@ -65,17 +68,29 @@ if (!response.ok) {
|
|
|
65
68
|
}
|
|
66
69
|
process.stdout.write(\`Recorded ${eventType} for ${subject}.\\n\`);
|
|
67
70
|
|
|
68
|
-
function brandedRequired(suffix) {
|
|
69
|
-
const name = \`WITNORA_\${suffix}\`;
|
|
70
|
-
const value = process.env[name] ?? process.env[\`AGENTCERT_\${suffix}\`];
|
|
71
|
-
if (!value) throw new Error(\`\${name} is required. Connect the Witnora CLI or set the hosted project variables. Legacy AGENTCERT_* names remain supported.\`);
|
|
72
|
-
return value;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
71
|
function branded(suffix) {
|
|
76
72
|
return process.env[\`WITNORA_\${suffix}\`] ?? process.env[\`AGENTCERT_\${suffix}\`];
|
|
77
73
|
}
|
|
78
74
|
|
|
75
|
+
async function resolveHostedConnection() {
|
|
76
|
+
const environment = {
|
|
77
|
+
baseUrl: branded("BASE_URL"),
|
|
78
|
+
projectId: branded("PROJECT_ID"),
|
|
79
|
+
apiKey: branded("API_KEY"),
|
|
80
|
+
};
|
|
81
|
+
if (environment.baseUrl && environment.projectId && environment.apiKey) return environment;
|
|
82
|
+
const configHome = process.env.WITNORA_CONFIG_HOME ?? process.env.AGENTCERT_CONFIG_HOME ?? join(homedir(), ".witnora");
|
|
83
|
+
try {
|
|
84
|
+
const credentials = JSON.parse(await readFile(join(configHome, "credentials.json"), "utf8"));
|
|
85
|
+
const name = process.env.WITNORA_CONNECTION ?? credentials.defaultConnection;
|
|
86
|
+
const saved = credentials.connections?.[name];
|
|
87
|
+
if (saved?.server && saved?.projectId && saved?.apiKey) {
|
|
88
|
+
return { baseUrl: saved.server, projectId: saved.projectId, apiKey: saved.apiKey };
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
throw new Error("No Witnora connection is available. Run witnora onboard --project <project-id> locally, or set WITNORA_BASE_URL, WITNORA_PROJECT_ID, and WITNORA_API_KEY in the runtime secret manager.");
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
function humanize(value) {
|
|
80
95
|
const unscoped = value.includes("/") ? value.slice(value.lastIndexOf("/") + 1) : value;
|
|
81
96
|
return unscoped.replace(/[-_.]+/g, " ").replace(/\\b\\w/g, (character) => character.toUpperCase()).trim();
|
|
@@ -117,19 +132,19 @@ export function starterInstructions(template, subject) {
|
|
|
117
132
|
Next:
|
|
118
133
|
1. Edit tripwire.yml so startUrl and agent.command/agent.args match your app and browser agent.
|
|
119
134
|
2. Run in CI with Kakarottoooo/agentcert/actions/tripwire@v0, or re-run init with --github-action.
|
|
120
|
-
3. Run: npx witnora@latest run --tripwire .tripwire/latest/tripwire-result.json --subject ${JSON.stringify(subject)} --fail-on-verdict
|
|
135
|
+
3. Run: npx witnora@latest run --tripwire .tripwire/latest/tripwire-result.json --subject ${JSON.stringify(subject)} --fail-on-verdict --push
|
|
121
136
|
`;
|
|
122
137
|
if (template === "mcp")
|
|
123
138
|
return `
|
|
124
139
|
Next:
|
|
125
140
|
1. Run MCPBench and write its JSON result to .mcpbench/latest/results.json.
|
|
126
|
-
2. Run: npx witnora@latest run --mcpbench .mcpbench/latest/results.json --subject ${JSON.stringify(subject)} --fail-on-verdict
|
|
141
|
+
2. Run: npx witnora@latest run --mcpbench .mcpbench/latest/results.json --subject ${JSON.stringify(subject)} --fail-on-verdict --push
|
|
127
142
|
`;
|
|
128
143
|
return `
|
|
129
144
|
Next:
|
|
130
145
|
1. Wrap the meaningful ${template} agent boundary with witnora.adapter.mjs or copy its envelope call into your framework hook.
|
|
131
|
-
2.
|
|
132
|
-
3.
|
|
146
|
+
2. Run one sandbox workflow normally. For a local boundary check, run: node witnora.adapter.mjs
|
|
147
|
+
3. Local runs reuse the restricted connection saved by onboard. In CI or production, set WITNORA_BASE_URL, WITNORA_PROJECT_ID, and WITNORA_API_KEY in your secret manager.
|
|
133
148
|
4. Generate and push a full evidence bundle when the workflow reaches a deterministic verification point.
|
|
134
149
|
`;
|
|
135
150
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export async function runPrivateCapabilityDiscovery(input) {
|
|
5
|
+
const requestFetch = input.fetch ?? fetch;
|
|
6
|
+
const request = await jsonRequest(requestFetch, `${input.connection.server}/v1/projects/${encodeURIComponent(input.connection.projectId)}/private-discovery`, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: authorization(input.connection.apiKey),
|
|
9
|
+
body: JSON.stringify({ mode: "private_discovery" }),
|
|
10
|
+
});
|
|
11
|
+
const identityPath = discoveryIdentityPath(input.connection.projectId, input.configHome);
|
|
12
|
+
const identity = await loadOrCreateIdentity(identityPath, input.connection.projectId, input.connectionName);
|
|
13
|
+
const payload = {
|
|
14
|
+
schemaVersion: "witnora.private_capability_snapshot.v0.1",
|
|
15
|
+
requestId: request.id,
|
|
16
|
+
projectId: input.connection.projectId,
|
|
17
|
+
challenge: request.challenge,
|
|
18
|
+
collectorId: `private-discovery:${safeIdentifier(input.connectionName)}`,
|
|
19
|
+
mode: "private_discovery",
|
|
20
|
+
generatedAt: new Date().toISOString(),
|
|
21
|
+
repository: {
|
|
22
|
+
kind: input.repository.kind,
|
|
23
|
+
name: `repository-${input.repository.fingerprintSha256.slice(0, 12)}`,
|
|
24
|
+
fingerprintSha256: input.repository.fingerprintSha256,
|
|
25
|
+
},
|
|
26
|
+
privacy: {
|
|
27
|
+
sourceCodeUploaded: false,
|
|
28
|
+
rawPromptsUploaded: false,
|
|
29
|
+
credentialsUploaded: false,
|
|
30
|
+
payloadsUploaded: false,
|
|
31
|
+
localRedactionApplied: true,
|
|
32
|
+
},
|
|
33
|
+
capabilities: input.repository.capabilities,
|
|
34
|
+
unknownCapabilityCount: input.repository.capabilities.length,
|
|
35
|
+
};
|
|
36
|
+
const payloadSha256 = sha256(canonicalJson(payload));
|
|
37
|
+
const privateKey = createPrivateKey(identity.privateKeyPem);
|
|
38
|
+
const publicKey = createPublicKey(identity.publicKeyPem);
|
|
39
|
+
const snapshot = {
|
|
40
|
+
payload,
|
|
41
|
+
payloadSha256,
|
|
42
|
+
source: {
|
|
43
|
+
algorithm: "Ed25519",
|
|
44
|
+
keyId: identity.keyId,
|
|
45
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
46
|
+
publicKeySha256: sha256(publicKey.export({ type: "spki", format: "der" })),
|
|
47
|
+
signature: sign(null, Buffer.from(payloadSha256), privateKey).toString("base64url"),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const completed = await jsonRequest(requestFetch, `${input.connection.server}/v1/projects/${encodeURIComponent(input.connection.projectId)}/private-discovery/${encodeURIComponent(request.id)}/complete`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: authorization(input.connection.apiKey),
|
|
53
|
+
body: JSON.stringify(snapshot),
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
requestId: completed.id,
|
|
57
|
+
capabilityCount: input.repository.capabilities.length,
|
|
58
|
+
unknownCapabilityCount: completed.snapshot?.payload?.unknownCapabilityCount ?? input.repository.capabilities.length,
|
|
59
|
+
identityPath,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function inferPrivateCapabilities(input) {
|
|
63
|
+
const searchable = [...input.dependencyNames, ...input.topLevelNames].join(" ").toLowerCase();
|
|
64
|
+
const detected = new Map();
|
|
65
|
+
const add = (key, observedName, transport) => detected.set(key, {
|
|
66
|
+
key,
|
|
67
|
+
observedName,
|
|
68
|
+
transport,
|
|
69
|
+
disposition: "pending",
|
|
70
|
+
schemaSha256: sha256(canonicalJson({ key, observedName, transport, source: "repository_metadata" })),
|
|
71
|
+
source: "repository_metadata",
|
|
72
|
+
});
|
|
73
|
+
add("workflow:orchestration", "Workflow orchestration", "workflow");
|
|
74
|
+
if (/playwright|puppeteer|browser-use|stagehand|selenium|computer-use/.test(searchable))
|
|
75
|
+
add("browser:automation", "Browser automation", "browser");
|
|
76
|
+
if (/modelcontextprotocol|(^|[^a-z])mcp([^a-z]|$)/.test(searchable))
|
|
77
|
+
add("mcp:tool-calling", "MCP tool calling", "mcp");
|
|
78
|
+
if (/axios|undici|node-fetch|httpx|requests|express|fastapi|fetch/.test(searchable))
|
|
79
|
+
add("http:tool-calling", "HTTP tool calling", "http");
|
|
80
|
+
if (/codex|aider|coding-agent|swe-agent|openhands/.test(searchable))
|
|
81
|
+
add("coding:workspace", "Coding workspace", "coding");
|
|
82
|
+
if (/pandas|duckdb|prisma|sequelize|sqlalchemy|postgres|sqlite|(^|[^a-z])(pg|sql)([^a-z]|$)/.test(searchable))
|
|
83
|
+
add("data:structured", "Structured data access", "data");
|
|
84
|
+
if (/slack|discord|twilio|resend|sendgrid|postmark|nodemailer|email/.test(searchable))
|
|
85
|
+
add("messaging:external", "External messaging", "messaging");
|
|
86
|
+
return [...detected.values()].sort((left, right) => left.key.localeCompare(right.key));
|
|
87
|
+
}
|
|
88
|
+
function discoveryIdentityPath(projectId, configHome) {
|
|
89
|
+
const root = configHome ?? process.env.WITNORA_CONFIG_HOME ?? process.env.AGENTCERT_CONFIG_HOME
|
|
90
|
+
?? join(process.env.USERPROFILE ?? process.env.HOME ?? ".", ".witnora");
|
|
91
|
+
return join(root, "private-discovery", `${safeIdentifier(projectId)}.json`);
|
|
92
|
+
}
|
|
93
|
+
async function loadOrCreateIdentity(path, projectId, connectionName) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
96
|
+
if (!parsed.keyId || !parsed.privateKeyPem || !parsed.publicKeyPem)
|
|
97
|
+
throw new Error("identity fields are missing");
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code !== "ENOENT")
|
|
102
|
+
throw new Error(`Witnora private discovery identity is invalid: ${path}`);
|
|
103
|
+
}
|
|
104
|
+
const pair = generateKeyPairSync("ed25519");
|
|
105
|
+
const identity = {
|
|
106
|
+
keyId: `discovery:${safeIdentifier(connectionName)}:${sha256(projectId).slice(0, 12)}`,
|
|
107
|
+
privateKeyPem: pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
|
|
108
|
+
publicKeyPem: pair.publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
109
|
+
};
|
|
110
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
111
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
112
|
+
await writeFile(temporary, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
113
|
+
await rename(temporary, path);
|
|
114
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
115
|
+
return identity;
|
|
116
|
+
}
|
|
117
|
+
function authorization(apiKey) {
|
|
118
|
+
return { authorization: `Bearer ${apiKey}`, "content-type": "application/json" };
|
|
119
|
+
}
|
|
120
|
+
async function jsonRequest(requestFetch, url, init) {
|
|
121
|
+
const response = await requestFetch(url, init);
|
|
122
|
+
const body = await response.json().catch(() => ({}));
|
|
123
|
+
if (!response.ok)
|
|
124
|
+
throw new Error(String(body.error ?? `${init.method ?? "GET"} ${url} failed with HTTP ${response.status}.`));
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
function safeIdentifier(value) {
|
|
128
|
+
return value.trim().replace(/[^A-Za-z0-9._:-]+/g, "-").replace(/^-|-$/g, "").slice(0, 120) || "local";
|
|
129
|
+
}
|
|
130
|
+
function sha256(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
131
|
+
function canonicalJson(value) {
|
|
132
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
133
|
+
return JSON.stringify(value);
|
|
134
|
+
if (typeof value === "number") {
|
|
135
|
+
if (!Number.isFinite(value))
|
|
136
|
+
throw new Error("Canonical JSON does not support non-finite numbers.");
|
|
137
|
+
return JSON.stringify(value);
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(value))
|
|
140
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
141
|
+
if (value && typeof value === "object")
|
|
142
|
+
return `{${Object.entries(value)
|
|
143
|
+
.filter(([, item]) => item !== undefined).sort(([left], [right]) => left.localeCompare(right))
|
|
144
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(",")}}`;
|
|
145
|
+
throw new Error("Canonical JSON contains an unsupported value.");
|
|
146
|
+
}
|