@halofy/agent-connect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # Halofy agent lifecycle installer
2
+
3
+ Status: production release candidate; activation remains gated on exact npm
4
+ publication and deployed protocol verification.
5
+
6
+ This package contains the host-neutral client pieces for installation-bound
7
+ agent connections:
8
+
9
+ - Ed25519 installation keys and signed canonical HTTP requests;
10
+ - a bounded AES-256-GCM pending queue with a mode-`0600` key/file fallback;
11
+ - deterministic session hashes, JSONL cursoring, exact supported UTF-8 text,
12
+ stable structured tool events, explicit digest-only capture-gap records,
13
+ prefix acknowledgement, and suffix retry;
14
+ - signed heartbeat, open, append, recall, context-use, commit, status, and
15
+ close transport methods;
16
+ - a stdio-to-signed-Streamable-HTTP MCP proof proxy; and
17
+ - the local claim consumer used by the Claude Code adapter.
18
+
19
+ The one supported Claude Code setup path is the lifecycle installer:
20
+
21
+ ```bash
22
+ npx --yes @halofy/agent-connect@0.1.0 install claude-code \
23
+ --server https://app.halofy.ai \
24
+ --claim '<one-time-claim>'
25
+ ```
26
+
27
+ Run the server-returned command in the operating-system terminal from the
28
+ project where Claude Code runs, never in agent chat. The installer displays
29
+ the capture disclosure, requires the recipient to type `CONNECT`, generates
30
+ the Ed25519 private key locally, consumes the one-use claim in a JSON body,
31
+ copies the reviewed runtime out of the transient npx cache, and installs the
32
+ signed MCP proxy and lifecycle hooks together. It replaces an existing Halofy
33
+ bearer MCP entry and legacy Halofy hooks in place while preserving unrelated
34
+ Claude settings. It never runs both Halofy capture paths for one host session.
35
+
36
+ The current storage backend is the explicitly reported mode-`0600` file
37
+ fallback (or the closest Windows ACL), not hardware-backed storage. No bearer
38
+ or claim is stored in the runtime queue or Claude configuration.
39
+
40
+ The request canonicalization in `src/crypto.mjs` follows AL4's strict raw path
41
+ and query rules. Publication is fail-closed until the package tarball, current
42
+ Claude fixtures, cross-implementation signature fixtures, and deployed server
43
+ protocol have all passed for the exact version.
44
+
45
+ The Claude adapter advertises archive protocol v1 explicitly. Its current
46
+ body capabilities are user/assistant text, structured tool inputs/results and
47
+ failures, and host artifact references. Inline images and artifact bodies are
48
+ represented by digest-only placeholders and make coverage partial;
49
+ `images`/`artifactBodies` remain false until the signed chunk-upload protocol,
50
+ encrypted retry queue, and real-host fixtures are verified. The adapter never
51
+ opens an arbitrary transcript-referenced local file to fill that gap.
52
+
53
+ Run focused checks from this directory:
54
+
55
+ ```bash
56
+ npm test
57
+ npm run check
58
+ ```
59
+
60
+ Content-free local queue/version evidence is available without decrypting or
61
+ printing any pending event body:
62
+
63
+ ```bash
64
+ node kernel/integrations/agent-runtime/bin/halofy-agent.mjs diagnostics
65
+ ```
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { ConnectionStore, defaultRuntimeDirectory } from "../src/storage.mjs";
3
+ import { loadActiveConnection } from "../src/active.mjs";
4
+ import { runStdioMcpProxy } from "../src/mcp-proxy.mjs";
5
+ import { BoundedEncryptedQueue } from "../src/queue.mjs";
6
+ import { runClaudeLifecycleHook } from "../src/claude-hook.mjs";
7
+ import { join } from "node:path";
8
+
9
+ function option(name) {
10
+ const index = process.argv.indexOf(name);
11
+ return index === -1 ? null : process.argv[index + 1];
12
+ }
13
+
14
+ const command = process.argv[2];
15
+ const hookEvent = command === "hook" ? process.argv[3] : null;
16
+ const hookEvents = new Set([
17
+ "SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd",
18
+ "SubagentStart", "SubagentStop", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
19
+ ]);
20
+ if (!["mcp", "diagnostics", "hook"].includes(command) ||
21
+ (command === "hook" && !hookEvents.has(hookEvent))) {
22
+ process.stderr.write("Usage: halofy-agent <mcp|diagnostics|hook EVENT> [--connection <installation-id>]\n");
23
+ process.exitCode = 2;
24
+ } else {
25
+ try {
26
+ const installationId = option("--connection");
27
+ const connection = installationId
28
+ ? await new ConnectionStore(defaultRuntimeDirectory()).load(installationId)
29
+ : await loadActiveConnection("claude-code");
30
+ if (!connection) throw new Error("no active installation-bound connection");
31
+ if (command === "mcp") {
32
+ await runStdioMcpProxy(connection);
33
+ } else if (command === "hook") {
34
+ await runClaudeLifecycleHook(connection, hookEvent);
35
+ } else {
36
+ const queue = new BoundedEncryptedQueue(join(defaultRuntimeDirectory(), connection.installationId));
37
+ process.stdout.write(`${JSON.stringify({
38
+ installationId: connection.installationId,
39
+ clientKind: connection.clientKind,
40
+ protocolVersion: connection.protocolVersion,
41
+ pluginVersion: connection.pluginVersion,
42
+ proofStorage: connection.proofStorage,
43
+ queue: await queue.diagnostics(),
44
+ }, null, 2)}\n`);
45
+ }
46
+ } catch (error) {
47
+ process.stderr.write(`${error?.message || "Halofy MCP proxy unavailable"}\n`);
48
+ process.exitCode = 1;
49
+ }
50
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runInstaller } from "../src/installer-cli.mjs";
3
+
4
+ try {
5
+ const result = await runInstaller(process.argv.slice(2));
6
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
7
+ } catch (error) {
8
+ process.stderr.write(`${error?.message || "installation failed"}\n`);
9
+ process.exitCode = 1;
10
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@halofy/agent-connect",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Signed Halofy lifecycle installer and runtime for supported agents",
6
+ "bin": {
7
+ "agent-connect": "bin/install.mjs",
8
+ "halofy-agent": "bin/halofy-agent.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/halofyai/halomem.git",
18
+ "directory": "kernel/integrations/agent-runtime"
19
+ },
20
+ "homepage": "https://github.com/halofyai/halomem/tree/master/kernel/integrations/agent-runtime",
21
+ "license": "UNLICENSED",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "scripts": {
26
+ "test": "node --test test/*.test.mjs",
27
+ "check": "node --check src/*.mjs && node --check bin/*.mjs",
28
+ "prepack": "npm run check && npm test"
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ }
33
+ }
package/src/active.mjs ADDED
@@ -0,0 +1,15 @@
1
+ import { join } from "node:path";
2
+ import { ConnectionStore, defaultRuntimeDirectory, readJson } from "./storage.mjs";
3
+
4
+ export async function loadActiveConnection(clientKind = "claude-code", root = defaultRuntimeDirectory()) {
5
+ const explicit = process.env.HALOFY_INSTALLATION_ID;
6
+ const marker = explicit ? { installationId: explicit } : await readJson(join(root, `active-${clientKind}.json`));
7
+ if (!marker?.installationId || marker.protocolVersion === "legacy") return null;
8
+ return new ConnectionStore(root).load(marker.installationId);
9
+ }
10
+
11
+ export async function hasLifecycleMarker(clientKind = "claude-code", root = defaultRuntimeDirectory()) {
12
+ if (process.env.HALOFY_INSTALLATION_ID) return true;
13
+ const marker = await readJson(join(root, `active-${clientKind}.json`));
14
+ return Boolean(marker?.installationId && marker.protocolVersion !== "legacy");
15
+ }
@@ -0,0 +1,139 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { readJson, writePrivateFile } from "./storage.mjs";
4
+
5
+ const CLAUDE_HOOKS = Object.freeze({
6
+ SessionStart: { matcher: "startup|resume|clear|compact", timeout: 15 },
7
+ UserPromptSubmit: { timeout: 15 },
8
+ Stop: { timeout: 20 },
9
+ PreCompact: { timeout: 20 },
10
+ SessionEnd: { timeout: 20 },
11
+ SubagentStart: { timeout: 8 },
12
+ SubagentStop: { timeout: 12 },
13
+ PostToolUse: { timeout: 8 },
14
+ PostToolUseFailure: { timeout: 8 },
15
+ PostToolBatch: { timeout: 8 },
16
+ });
17
+
18
+ function commandArg(value) {
19
+ const text = String(value);
20
+ if (/[^A-Za-z0-9_./:@+-]/.test(text)) {
21
+ if (/[\0\r\n"`$\\]/.test(text)) throw new Error("local integration path cannot be encoded safely for a Claude command hook");
22
+ return `"${text}"`;
23
+ }
24
+ return text;
25
+ }
26
+
27
+ function managedHookCommand(nodePath, runtimePath, eventName, installationId) {
28
+ return [nodePath, runtimePath, "hook", eventName, "--connection", installationId]
29
+ .map(commandArg).join(" ");
30
+ }
31
+
32
+ function managedHookRegistration(nodePath, runtimePath, eventName, installationId, definition) {
33
+ return {
34
+ ...(definition.matcher ? { matcher: definition.matcher } : {}),
35
+ hooks: [{
36
+ type: "command",
37
+ command: managedHookCommand(nodePath, runtimePath, eventName, installationId),
38
+ timeout: definition.timeout,
39
+ }],
40
+ };
41
+ }
42
+
43
+ function isManagedHalofyHook(hook) {
44
+ const command = String(hook?.command || "");
45
+ return /(?:halofy-agent\.mjs\s+hook\s+|claude-code\/scripts\/(?:session-start|session-end|lifecycle-hook|mcp-router)\.mjs)/.test(command);
46
+ }
47
+
48
+ function normalizedMcpUrl(entry) {
49
+ const value = entry?.url || entry?.httpUrl || entry?.serverUrl;
50
+ if (typeof value !== "string") return null;
51
+ try { return new URL(value); } catch { return null; }
52
+ }
53
+
54
+ function isHalofyMcpEntry(name, entry, serverUrl) {
55
+ if (!entry || typeof entry !== "object") return false;
56
+ const command = String(entry.command || "");
57
+ if (/halofy-(?:agent|mcp)|agent-runtime|agent-connect/i.test(command)) return true;
58
+ const candidate = normalizedMcpUrl(entry);
59
+ if (!candidate) return false;
60
+ const server = new URL(serverUrl);
61
+ return ["halofy", "halomem"].includes(String(name).toLowerCase()) &&
62
+ candidate.origin === server.origin && candidate.pathname.replace(/\/+$/, "") === "/mcp";
63
+ }
64
+
65
+ function removeHalofyEntries(map, serverUrl) {
66
+ if (!map || typeof map !== "object" || Array.isArray(map)) return 0;
67
+ let removed = 0;
68
+ for (const [name, entry] of Object.entries(map)) {
69
+ if (isHalofyMcpEntry(name, entry, serverUrl)) {
70
+ delete map[name];
71
+ removed += 1;
72
+ }
73
+ }
74
+ return removed;
75
+ }
76
+
77
+ /**
78
+ * Installs one lifecycle-bound Claude connection. Existing Halofy bearer MCP
79
+ * entries and legacy Halofy hooks are replaced in place; unrelated Claude
80
+ * settings and MCP servers are preserved.
81
+ */
82
+ export async function configureClaudeProject({
83
+ projectRoot,
84
+ installationId,
85
+ serverUrl,
86
+ nodePath = process.execPath,
87
+ runtimePath,
88
+ claudeConfigPath = join(homedir(), ".claude.json"),
89
+ }) {
90
+ if (!/^[A-Za-z0-9_-]{1,160}$/.test(String(installationId))) throw new Error("invalid installation id");
91
+ if (!runtimePath) throw new Error("installed runtime path is required");
92
+ const server = new URL(serverUrl);
93
+ const root = resolve(projectRoot);
94
+ const mcpPath = join(root, ".mcp.json");
95
+ const settingsPath = join(root, ".claude", "settings.json");
96
+ const mcp = await readJson(mcpPath, {});
97
+ mcp.mcpServers = mcp.mcpServers && typeof mcp.mcpServers === "object" ? mcp.mcpServers : {};
98
+ const replacedProjectMcpEntries = removeHalofyEntries(mcp.mcpServers, server.toString());
99
+ mcp.mcpServers.halofy = {
100
+ type: "stdio",
101
+ command: resolve(nodePath),
102
+ args: [resolve(runtimePath), "mcp", "--connection", installationId],
103
+ };
104
+
105
+ const settings = await readJson(settingsPath, {});
106
+ settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
107
+ for (const [eventName, definition] of Object.entries(CLAUDE_HOOKS)) {
108
+ const current = Array.isArray(settings.hooks[eventName]) ? settings.hooks[eventName] : [];
109
+ const unrelated = current.filter((registration) =>
110
+ !Array.isArray(registration?.hooks) || !registration.hooks.some(isManagedHalofyHook));
111
+ settings.hooks[eventName] = [
112
+ ...unrelated,
113
+ managedHookRegistration(nodePath, runtimePath, eventName, installationId, definition),
114
+ ];
115
+ }
116
+
117
+ let replacedClaudeMcpEntries = 0;
118
+ const claude = await readJson(claudeConfigPath, null);
119
+ if (claude && typeof claude === "object") {
120
+ replacedClaudeMcpEntries += removeHalofyEntries(claude.mcpServers, server.toString());
121
+ const projects = claude.projects && typeof claude.projects === "object" ? claude.projects : {};
122
+ for (const [path, project] of Object.entries(projects)) {
123
+ if (resolve(path) !== root || !project || typeof project !== "object") continue;
124
+ replacedClaudeMcpEntries += removeHalofyEntries(project.mcpServers, server.toString());
125
+ }
126
+ if (replacedClaudeMcpEntries > 0) {
127
+ await writePrivateFile(claudeConfigPath, `${JSON.stringify(claude, null, 2)}\n`);
128
+ }
129
+ }
130
+
131
+ await writePrivateFile(mcpPath, `${JSON.stringify(mcp, null, 2)}\n`);
132
+ await writePrivateFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
133
+ return {
134
+ mcpPath,
135
+ settingsPath,
136
+ hookEvents: Object.keys(CLAUDE_HOOKS),
137
+ replacedLegacyMcpEntries: replacedProjectMcpEntries + replacedClaudeMcpEntries,
138
+ };
139
+ }
@@ -0,0 +1,154 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { LifecycleRuntime } from "./runtime.mjs";
4
+ import { normalizeClaudeHookEvent } from "./session.mjs";
5
+ import { defaultRuntimeDirectory } from "./storage.mjs";
6
+
7
+ function id(value) {
8
+ return createHash("sha256").update(String(value)).digest("hex");
9
+ }
10
+
11
+ export function readHookInput(stream = process.stdin) {
12
+ return new Promise((resolve) => {
13
+ let raw = "";
14
+ stream.setEncoding("utf8");
15
+ stream.on("data", (chunk) => { raw += chunk; });
16
+ stream.on("end", () => {
17
+ try { resolve(JSON.parse(raw)); } catch { resolve({}); }
18
+ });
19
+ stream.on("error", () => resolve({}));
20
+ });
21
+ }
22
+
23
+ function hostSession(input) {
24
+ return String(input.session_id || "");
25
+ }
26
+
27
+ function childSession(input) {
28
+ const parent = hostSession(input);
29
+ const child = input.agent_id || input.agent_type || input.subagent_id;
30
+ return child ? `${parent}:subagent:${child}` : parent;
31
+ }
32
+
33
+ function recallText(result, eventName) {
34
+ const blocks = Array.isArray(result?.blocks) ? result.blocks : Array.isArray(result) ? result : [];
35
+ const rankedBlocks = blocks
36
+ .filter((block) => block && typeof block.recallRef === "string" &&
37
+ typeof block.content === "string" && block.content.trim())
38
+ .slice(0, 12)
39
+ .map((block, rank) => ({
40
+ rank: rank + 1,
41
+ recallRef: block.recallRef.slice(0, 256),
42
+ content: block.content.trim(),
43
+ }));
44
+ if (rankedBlocks.length === 0) return null;
45
+ return JSON.stringify({
46
+ hookSpecificOutput: {
47
+ hookEventName: eventName,
48
+ additionalContext: `<<<HALOFY_CONTEXT_BLOCKS_V1>>>\n${JSON.stringify({ rankedBlocks })}\n<<<END_HALOFY_CONTEXT_BLOCKS_V1>>>`,
49
+ },
50
+ });
51
+ }
52
+
53
+ async function catchUp(runtime, input, session = hostSession(input)) {
54
+ const transcriptPath = input.agent_transcript_path || input.transcript_path;
55
+ if (!transcriptPath || !session) return;
56
+ await runtime.captureClaudeTranscript(session, String(transcriptPath));
57
+ }
58
+
59
+ async function within(milliseconds, action) {
60
+ return Promise.race([
61
+ action(),
62
+ new Promise((resolve) => setTimeout(() => resolve(null), milliseconds)),
63
+ ]);
64
+ }
65
+
66
+ /** Runs one reviewed Claude hook. Brownouts never block the host session. */
67
+ export async function runClaudeLifecycleHook(connection, eventName, {
68
+ input,
69
+ root = defaultRuntimeDirectory(),
70
+ stdout = process.stdout,
71
+ stderr = process.stderr,
72
+ } = {}) {
73
+ try {
74
+ const hookInput = input ?? await readHookInput();
75
+ const session = hostSession(hookInput);
76
+ if (!session) return { handled: true };
77
+ const runtime = new LifecycleRuntime(connection, { root });
78
+
79
+ if (eventName === "SessionStart") {
80
+ await runtime.replay();
81
+ await runtime.heartbeat(connection.capabilities || {});
82
+ const project = hookInput.cwd ? basename(String(hookInput.cwd)) : "";
83
+ const recalled = await runtime.recall(
84
+ session,
85
+ `${project ? `${project} ` : ""}project policy, conventions, decisions, preferences, and known context`,
86
+ );
87
+ const output = recallText(recalled, "SessionStart");
88
+ if (output) stdout.write(output);
89
+ } else if (eventName === "UserPromptSubmit") {
90
+ const prompt = String(hookInput.prompt || hookInput.user_prompt || "").slice(0, 8_000);
91
+ if (prompt.trim()) {
92
+ const recalled = await runtime.recall(session, prompt);
93
+ const output = recallText(recalled, "UserPromptSubmit");
94
+ if (output) stdout.write(output);
95
+ }
96
+ } else if (eventName === "Stop") {
97
+ if (!hookInput.stop_hook_active) {
98
+ await catchUp(runtime, hookInput);
99
+ await runtime.commitIfThreshold(session);
100
+ }
101
+ } else if (eventName === "PreCompact") {
102
+ await catchUp(runtime, hookInput);
103
+ const cursor = await runtime.cursors.get(runtime.sessionHash(session));
104
+ await runtime.enqueueEvent(session, normalizeClaudeHookEvent("compaction", hookInput, {
105
+ sessionHash: runtime.sessionHash(session),
106
+ sequence: cursor.sequence + 1,
107
+ }));
108
+ await runtime.commit(session, "pre_compaction");
109
+ } else if (eventName === "SessionEnd") {
110
+ await within(15_000, async () => {
111
+ await catchUp(runtime, hookInput);
112
+ await runtime.close(session);
113
+ });
114
+ } else if (eventName === "SubagentStart") {
115
+ await runtime.resolveSession(childSession(hookInput), session);
116
+ } else if (eventName === "SubagentStop") {
117
+ const child = childSession(hookInput);
118
+ await within(10_000, async () => {
119
+ await catchUp(runtime, hookInput, child);
120
+ await runtime.close(child, "session_end");
121
+ });
122
+ } else if (["PostToolUse", "PostToolUseFailure"].includes(eventName)) {
123
+ await catchUp(runtime, hookInput);
124
+ const sessionHash = runtime.sessionHash(session);
125
+ const cursor = await runtime.cursors.get(sessionHash);
126
+ const common = { ...hookInput, event_id: hookInput.event_id || id(`${hookInput.tool_use_id}:${eventName}`) };
127
+ await runtime.enqueueEvents(session, [
128
+ normalizeClaudeHookEvent("tool_call", common, { sessionHash, sequence: cursor.sequence + 1 }),
129
+ normalizeClaudeHookEvent("tool_result", {
130
+ ...common,
131
+ ...(eventName === "PostToolUseFailure" ? { tool_error: hookInput.error || true } : {}),
132
+ }, { sessionHash, sequence: cursor.sequence + 2 }),
133
+ ]);
134
+ } else if (eventName === "PostToolBatch") {
135
+ const records = Array.isArray(hookInput.tool_calls) ? hookInput.tool_calls : [];
136
+ for (const record of records.slice(0, 100)) {
137
+ const sessionHash = runtime.sessionHash(session);
138
+ const cursor = await runtime.cursors.get(sessionHash);
139
+ await runtime.enqueueEvents(session, [
140
+ normalizeClaudeHookEvent("tool_call", record, { sessionHash, sequence: cursor.sequence + 1 }),
141
+ normalizeClaudeHookEvent("tool_result", record, { sessionHash, sequence: cursor.sequence + 2 }),
142
+ ]);
143
+ }
144
+ } else {
145
+ throw new Error("unsupported Claude lifecycle hook");
146
+ }
147
+ return { handled: true };
148
+ } catch (error) {
149
+ const code = error && typeof error === "object" && typeof error.code === "string"
150
+ ? error.code : "runtime_unavailable";
151
+ stderr.write(`[halofy] lifecycle hook degraded: ${code}\n`);
152
+ return { handled: true, degraded: true };
153
+ }
154
+ }
package/src/crypto.mjs ADDED
@@ -0,0 +1,143 @@
1
+ import {
2
+ createHash,
3
+ createPrivateKey,
4
+ generateKeyPairSync,
5
+ randomBytes,
6
+ sign as signBytes,
7
+ } from "node:crypto";
8
+
9
+ const SIGNING_PREFIX = "halofy-installation-v1";
10
+
11
+ function base64url(value) {
12
+ return Buffer.from(value).toString("base64url");
13
+ }
14
+
15
+ export function sha256(value) {
16
+ return createHash("sha256").update(value).digest("hex");
17
+ }
18
+
19
+ export function generateInstallationKeyPair() {
20
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
21
+ const publicJwk = publicKey.export({ format: "jwk" });
22
+ const privateJwk = privateKey.export({ format: "jwk" });
23
+ const thumbprintInput = JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x });
24
+ return {
25
+ publicJwk,
26
+ privateJwk,
27
+ thumbprint: base64url(createHash("sha256").update(thumbprintInput).digest()),
28
+ };
29
+ }
30
+
31
+ function strictDecode(value, label) {
32
+ const bytes = [];
33
+ for (let index = 0; index < value.length;) {
34
+ if (value[index] === "%") {
35
+ const encoded = value.slice(index + 1, index + 3);
36
+ if (!/^[0-9A-Fa-f]{2}$/.test(encoded)) throw new Error(`${label} has malformed percent encoding`);
37
+ bytes.push(Number.parseInt(encoded, 16));
38
+ index += 3;
39
+ continue;
40
+ }
41
+ const codePoint = value.codePointAt(index);
42
+ const character = String.fromCodePoint(codePoint);
43
+ bytes.push(...Buffer.from(character, "utf8"));
44
+ index += character.length;
45
+ }
46
+ try {
47
+ return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes));
48
+ } catch {
49
+ throw new Error(`${label} is not valid UTF-8`);
50
+ }
51
+ }
52
+
53
+ function percentEncode(value) {
54
+ let output = "";
55
+ for (const byte of Buffer.from(value, "utf8")) {
56
+ const unreserved =
57
+ (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) ||
58
+ (byte >= 0x30 && byte <= 0x39) || [0x2d, 0x2e, 0x5f, 0x7e].includes(byte);
59
+ output += unreserved ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`;
60
+ }
61
+ return output;
62
+ }
63
+
64
+ /**
65
+ * Provisional v1 canonical query encoding. The lifecycle contract must freeze
66
+ * this byte representation before a public runtime is published.
67
+ */
68
+ export function canonicalPathAndQuery(input) {
69
+ if (typeof input !== "string" || !input.startsWith("/") || input.includes("#")) {
70
+ throw new Error("request target must be origin-form without a fragment");
71
+ }
72
+ const queryIndex = input.indexOf("?");
73
+ const rawPath = queryIndex === -1 ? input : input.slice(0, queryIndex);
74
+ const rawQuery = queryIndex === -1 ? null : input.slice(queryIndex + 1);
75
+ if (rawPath !== "/" && rawPath.endsWith("/")) throw new Error("path has a trailing empty segment");
76
+ const rawSegments = rawPath.split("/");
77
+ if (rawSegments[0] !== "" || (rawPath !== "/" && rawSegments.slice(1).some((segment) => segment === ""))) {
78
+ throw new Error("path has an empty segment");
79
+ }
80
+ const path = rawPath === "/" ? "/" : `/${rawSegments.slice(1).map((segment) => {
81
+ const decoded = strictDecode(segment, "path segment");
82
+ if (decoded === "." || decoded === ".." || /[\0\\/]/.test(decoded)) {
83
+ throw new Error("path segment changes route structure");
84
+ }
85
+ return percentEncode(decoded);
86
+ }).join("/")}`;
87
+ if (rawQuery === null || rawQuery === "") return path;
88
+ const compare = (left, right) => left < right ? -1 : left > right ? 1 : 0;
89
+ const pairs = rawQuery.split("&").map((pair) => {
90
+ const equals = pair.indexOf("=");
91
+ const rawName = equals === -1 ? pair : pair.slice(0, equals);
92
+ const rawValue = equals === -1 ? "" : pair.slice(equals + 1);
93
+ return [percentEncode(strictDecode(rawName, "query name")), percentEncode(strictDecode(rawValue, "query value"))];
94
+ }).sort(([leftKey, leftValue], [rightKey, rightValue]) =>
95
+ compare(leftKey, rightKey) || compare(leftValue, rightValue),
96
+ );
97
+ return `${path}?${pairs.map(([key, value]) => `${key}=${value}`).join("&")}`;
98
+ }
99
+
100
+ export function canonicalRequest({ method, path, bodyBytes, timestamp, nonce }) {
101
+ const body = Buffer.isBuffer(bodyBytes) ? bodyBytes : Buffer.from(bodyBytes ?? "");
102
+ return [
103
+ SIGNING_PREFIX,
104
+ String(method).toUpperCase(),
105
+ canonicalPathAndQuery(path),
106
+ sha256(body),
107
+ timestamp,
108
+ nonce,
109
+ ].join("\n");
110
+ }
111
+
112
+ export function signInstallationRequest({
113
+ installationId,
114
+ privateJwk,
115
+ method,
116
+ path,
117
+ bodyBytes = Buffer.alloc(0),
118
+ timestamp = new Date().toISOString(),
119
+ nonce = randomBytes(16).toString("base64url"),
120
+ }) {
121
+ const canonicalMethod = String(method).toUpperCase();
122
+ if (!/^[A-Z]+$/.test(canonicalMethod)) throw new Error("request method is not canonical ASCII");
123
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(timestamp) ||
124
+ new Date(timestamp).toISOString() !== timestamp) {
125
+ throw new Error("request timestamp is not canonical UTC");
126
+ }
127
+ if (!/^[A-Za-z0-9_-]+$/.test(nonce) || Buffer.from(nonce, "base64url").length < 16 ||
128
+ Buffer.from(nonce, "base64url").length > 64) {
129
+ throw new Error("request nonce must encode 16-64 bytes without padding");
130
+ }
131
+ const canonical = canonicalRequest({ method, path, bodyBytes, timestamp, nonce });
132
+ const signature = signBytes(null, Buffer.from(canonical), createPrivateKey({ key: privateJwk, format: "jwk" }));
133
+ return {
134
+ canonical,
135
+ headers: {
136
+ Authorization: `Halofy-Installation ${installationId}`,
137
+ "X-Halofy-Timestamp": timestamp,
138
+ "X-Halofy-Nonce": nonce,
139
+ "X-Halofy-Body-SHA256": sha256(bodyBytes),
140
+ "X-Halofy-Signature": signature.toString("base64url"),
141
+ },
142
+ };
143
+ }