@zieglar/pi-chat-exchange 0.0.0-stage → 0.2.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/README.md +89 -2
  4. package/dist/bin/mcp-stdio.js +196 -0
  5. package/dist/control/debug-log.js +67 -0
  6. package/dist/control/mcp/debug-transport.js +48 -0
  7. package/dist/control/mcp/messaging-tools.js +278 -0
  8. package/dist/control/mcp/ping.js +43 -0
  9. package/dist/control/mcp/server.js +153 -0
  10. package/dist/core/authorization.js +32 -0
  11. package/dist/core/reference.js +32 -0
  12. package/dist/core/rules.js +468 -0
  13. package/dist/endpoints/pi/contract.js +2 -0
  14. package/dist/endpoints/pi/session-host.js +39 -0
  15. package/dist/host/bound-endpoint.js +176 -0
  16. package/dist/host/endpoint-host.js +839 -0
  17. package/dist/host/mcp-binding-config.js +209 -0
  18. package/dist/host/ownership-recovery.js +257 -0
  19. package/dist/index.js +11 -0
  20. package/dist/protocol/capabilities.js +46 -0
  21. package/dist/protocol/endpoint-capabilities.js +15 -0
  22. package/dist/protocol/entities.js +185 -0
  23. package/dist/protocol/formats.js +13 -0
  24. package/dist/protocol/idempotency.js +93 -0
  25. package/dist/protocol/identity.js +130 -0
  26. package/dist/protocol/index.js +8 -0
  27. package/dist/protocol/lifecycle.js +125 -0
  28. package/dist/protocol/outcomes.js +55 -0
  29. package/dist/runtime/desktop-manifest.js +65 -0
  30. package/dist/runtime/diagnostics.js +34 -0
  31. package/dist/runtime/executables.js +222 -0
  32. package/dist/storage/capability.js +221 -0
  33. package/dist/storage/connection.js +94 -0
  34. package/dist/storage/creation-request.js +176 -0
  35. package/dist/storage/creation.js +553 -0
  36. package/dist/storage/current-schema.js +397 -0
  37. package/dist/storage/delivery-ack.js +177 -0
  38. package/dist/storage/errors.js +9 -0
  39. package/dist/storage/filesystem.js +116 -0
  40. package/dist/storage/inbox-discovery.js +135 -0
  41. package/dist/storage/messaging.js +156 -0
  42. package/dist/storage/migration.js +258 -0
  43. package/dist/storage/ownership.js +789 -0
  44. package/dist/storage/participant-identity.js +127 -0
  45. package/dist/storage/participant.js +700 -0
  46. package/dist/storage/recovery.js +99 -0
  47. package/dist/storage/runtime.js +113 -0
  48. package/dist/storage/schema.js +556 -0
  49. package/dist/storage/session-state.js +75 -0
  50. package/dist/storage/store.js +450 -0
  51. package/dist/storage/transaction.js +199 -0
  52. package/dist/storage/trust-root.js +164 -0
  53. package/dist/tunnel/background-manager.js +1727 -0
  54. package/dist/tunnel/codes.js +79 -0
  55. package/dist/tunnel/health.js +75 -0
  56. package/dist/tunnel/keychain.js +526 -0
  57. package/dist/tunnel/managed-binding.js +454 -0
  58. package/dist/tunnel/manager-panel.js +249 -0
  59. package/dist/tunnel/manager-ui.js +341 -0
  60. package/dist/tunnel/manager.js +372 -0
  61. package/dist/tunnel/messaging-activation-ui.js +85 -0
  62. package/dist/tunnel/messaging-config.js +246 -0
  63. package/dist/tunnel/multi-session-admission.js +31 -0
  64. package/dist/tunnel/multi-session-policy.js +314 -0
  65. package/dist/tunnel/offline-client.js +104 -0
  66. package/dist/tunnel/profile.js +319 -0
  67. package/dist/tunnel/redact.js +9 -0
  68. package/dist/tunnel/secure-input.js +318 -0
  69. package/dist/tunnel/session-process-mutex.js +338 -0
  70. package/dist/tunnel/setup-ui.js +443 -0
  71. package/dist/tunnel/setup.js +734 -0
  72. package/dist/tunnel/state.js +127 -0
  73. package/docs/architecture.md +170 -0
  74. package/docs/chatgpt-installation.md +149 -0
  75. package/docs/delivery-ack-contract.md +86 -0
  76. package/docs/independent-install.md +156 -0
  77. package/docs/multi-session-usage.md +77 -0
  78. package/docs/ownership-contract.md +429 -0
  79. package/docs/protocol-v2.md +269 -0
  80. package/docs/releases/0.2.0.md +59 -0
  81. package/docs/storage-contract.md +1241 -0
  82. package/docs/storage-security.md +138 -0
  83. package/docs/tunnel-manager-background.md +169 -0
  84. package/docs/tunnel-setup.md +214 -0
  85. package/native/tunnel-keychain.swift +432 -0
  86. package/package.json +77 -4
  87. package/pi-extension/README.md +125 -0
  88. package/pi-extension/allowlist.example.json +11 -0
  89. package/pi-extension/index.ts +2312 -0
  90. package/pi-extension/multi-session-commands.ts +93 -0
  91. package/pi-extension/multi-session-runtime.ts +75 -0
  92. package/pi-extension/tui-keys.ts +40 -0
  93. package/scripts/reconcile-session-process-mutex.mjs +262 -0
  94. package/scripts/recover-stale-ownership.mjs +91 -0
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, normalize } from "node:path";
4
+ import { admitExecutable, admitInstallationDirectory, qualifyNodeExecutable } from "./executables.js";
5
+ const SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
6
+ const invalid = () => { throw new Error("CE_DESKTOP_CONFIG_INVALID"); };
7
+ /** Explicit preparation of an unpacked, operator-owned LOCAL copy. This never
8
+ * installs/registers/launches the plugin, resolves binding credentials or opens Store. */
9
+ export function configureDesktopManifest(pluginRoot, nodePath) {
10
+ let fd;
11
+ let temporary;
12
+ try {
13
+ if (!isAbsolute(pluginRoot) || normalize(pluginRoot) !== pluginRoot || realpathSync(pluginRoot) !== pluginRoot)
14
+ invalid();
15
+ admitInstallationDirectory(pluginRoot);
16
+ const root = lstatSync(pluginRoot);
17
+ if (!root.isDirectory() || root.uid !== process.geteuid?.() || (root.mode & 0o777) !== 0o700)
18
+ invalid();
19
+ const server = lstatSync(join(pluginRoot, "server"));
20
+ const launcher = lstatSync(join(pluginRoot, "server/launch.mjs"));
21
+ if (!server.isDirectory() || server.isSymbolicLink() || !launcher.isFile() || launcher.isSymbolicLink())
22
+ invalid();
23
+ const manifestPath = join(pluginRoot, "mcp.json");
24
+ fd = openSync(manifestPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
25
+ const original = fstatSync(fd);
26
+ if (!original.isFile() || original.uid !== process.geteuid?.() || original.nlink !== 1 || original.size > 16_384 || (original.mode & 0o022) !== 0)
27
+ invalid();
28
+ const text = readFileSync(fd, "utf8");
29
+ if (Buffer.byteLength(text) > 16_384)
30
+ invalid();
31
+ const template = JSON.parse(text);
32
+ if (template.$schema !== SCHEMA || Object.keys(template).sort().join(",") !== "$schema,mcpServers" ||
33
+ template.mcpServers === null || typeof template.mcpServers !== "object" || Array.isArray(template.mcpServers) ||
34
+ Object.keys(template.mcpServers).length !== 0)
35
+ invalid();
36
+ closeSync(fd);
37
+ fd = undefined;
38
+ const command = admitExecutable(nodePath);
39
+ qualifyNodeExecutable(command);
40
+ const manifest = { $schema: SCHEMA, mcpServers: { "pi-chat-exchange": {
41
+ type: "stdio", command, args: ["${PLUGIN_ROOT}/server/launch.mjs"], cwd: "${PLUGIN_ROOT}",
42
+ } } };
43
+ temporary = join(pluginRoot, `.mcp-${randomUUID()}.tmp`);
44
+ fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
45
+ writeFileSync(fd, `${JSON.stringify(manifest, undefined, 2)}\n`);
46
+ fsyncSync(fd);
47
+ closeSync(fd);
48
+ fd = undefined;
49
+ const current = lstatSync(manifestPath);
50
+ if (current.dev !== original.dev || current.ino !== original.ino || current.size !== original.size || current.mtimeMs !== original.mtimeMs)
51
+ invalid();
52
+ renameSync(temporary, manifestPath);
53
+ temporary = undefined;
54
+ }
55
+ catch {
56
+ invalid();
57
+ }
58
+ finally {
59
+ if (fd !== undefined)
60
+ closeSync(fd);
61
+ if (temporary !== undefined)
62
+ rmSync(temporary, { force: true });
63
+ }
64
+ }
65
+ //# sourceMappingURL=desktop-manifest.js.map
@@ -0,0 +1,34 @@
1
+ import { APPROVED_RUNTIME_TUPLES, assertHostRuntimeEligibility } from "../storage/runtime.js";
2
+ import { ExecutableRefusal, diagnoseTunnelClient, resolveApprovedNode } from "./executables.js";
3
+ /** No profile, credential, Store or transport access. Host uses the actual
4
+ * in-memory SQLite/API battery; only an admitted Node child is executed. */
5
+ export function diagnoseInstallation(options = {}) {
6
+ let host = "QUALIFIED";
7
+ try {
8
+ assertHostRuntimeEligibility();
9
+ }
10
+ catch {
11
+ host = "STORAGE_RUNTIME_INELIGIBLE";
12
+ }
13
+ let child;
14
+ try {
15
+ child = resolveApprovedNode(options) === undefined ? "CE_NODE_UNAVAILABLE" : "QUALIFIED";
16
+ }
17
+ catch (error) {
18
+ child = error instanceof ExecutableRefusal ? error.code : "CE_NODE_INELIGIBLE";
19
+ }
20
+ return { host, child, client: diagnoseTunnelClient(options) };
21
+ }
22
+ export function installationDiagnosticMessage(result) {
23
+ return `ce-tunnel doctor: Pi host=${result.host} (in-memory SQLite/runtime check); MCP child=${result.child}; client=${result.client}. ` +
24
+ "Client trust is metadata only, not compatibility or connectivity. No client executed; no profile/Store/transport changes. " +
25
+ "For a missing client, install separately; for rejected trust, repair the private installation and its ancestors/ACLs or select a trusted absolute CE_TUNNEL_CLIENT_PATH outside projects. Never use PATH fallback or relax trust. " +
26
+ "Select an independently qualified MCP child with CE_NODE_PATH. " + hostRuntimeRepairMessage() +
27
+ ". Installation checks are not transport, delivery, ACK or completion evidence.";
28
+ }
29
+ /** No raw subprocess output, paths, configuration values or exception causes. */
30
+ export function hostRuntimeRepairMessage(nodeVersion = process.version) {
31
+ const current = /^v\d+\.\d+\.\d+$/.test(nodeVersion) ? nodeVersion : "unrecognized";
32
+ return `current Pi Node ${current}; requires approved Node ${APPROVED_RUNTIME_TUPLES.map(({ nodeVersion: version }) => version).join(" or ")} with approved SQLite; restart Pi using a trusted absolute approved Node executable. CE_NODE_PATH selects the MCP child only, not the running Pi host; each qualifies independently and need not use the same executable; mise is not required`;
33
+ }
34
+ //# sourceMappingURL=diagnostics.js.map
@@ -0,0 +1,222 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, lstatSync, realpathSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
5
+ import { APPROVED_RUNTIME_TUPLES, REQUIRED_SQLITE_CAPABILITIES, assertRuntimeEligibility } from "../storage/runtime.js";
6
+ /** These are locations, not PATH search. Every discovered file still needs admission. */
7
+ const SYSTEM_DIRECTORIES = ["/usr/local/bin", "/usr/bin", "/bin", "/opt/homebrew/bin"];
8
+ export class ExecutableRefusal extends Error {
9
+ code;
10
+ constructor(code) {
11
+ super(code);
12
+ this.code = code;
13
+ this.name = "ExecutableRefusal";
14
+ }
15
+ }
16
+ const untrusted = () => { throw new ExecutableRefusal("CE_EXECUTABLE_UNTRUSTED"); };
17
+ function within(root, path) {
18
+ const delta = relative(root, path);
19
+ return delta === "" || (delta !== ".." && !delta.startsWith(`..${sep}`) && !isAbsolute(delta));
20
+ }
21
+ function currentProjectRoot() {
22
+ let directory = realpathSync(process.cwd());
23
+ for (;;) {
24
+ if (existsSync(join(directory, ".git")))
25
+ return directory;
26
+ const parent = dirname(directory);
27
+ if (parent === directory)
28
+ return undefined;
29
+ directory = parent;
30
+ }
31
+ }
32
+ /** macOS ACL grants are independent of POSIX mode. Only absent ACLs or
33
+ * deny-only ACLs are admitted; unknown output/probe failure is fail-closed.
34
+ * The OS utility is fixed, never discovered through PATH or supplied by callers. */
35
+ function assertDarwinAclSafe(path) {
36
+ if (process.platform !== "darwin")
37
+ return;
38
+ const output = execFileSync("/bin/ls", ["-lde", path], {
39
+ cwd: "/", env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" },
40
+ shell: false, timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 16_384,
41
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
42
+ });
43
+ const lines = output.trimEnd().split("\n");
44
+ const header = lines.shift() ?? "";
45
+ if (!/^[bcdlps-][rwxStTs-]{9}[+@ ]/.test(header))
46
+ untrusted();
47
+ for (const entry of lines) {
48
+ if (!/^\s*\d+: \S+ deny [a-z_,]+$/.test(entry))
49
+ untrusted();
50
+ }
51
+ if (header[10] === "+" && lines.length === 0)
52
+ untrusted();
53
+ }
54
+ /** Admission is deliberately before probing. Same-account filesystem mutation is
55
+ * outside this boundary; a private installation is required, not a signature claim. */
56
+ function admitPath(path, options, kind) {
57
+ try {
58
+ if (!isAbsolute(path) || normalize(path) !== path || /[\x00-\x1f\x7f]/.test(path))
59
+ untrusted();
60
+ const project = options.projectRoot === undefined ? currentProjectRoot() : realpathSync(options.projectRoot);
61
+ const uid = process.geteuid?.();
62
+ if (uid === undefined)
63
+ untrusted();
64
+ const visited = new Set();
65
+ const check = (candidate) => {
66
+ if (visited.has(candidate))
67
+ return;
68
+ if (visited.size >= 256 || (project !== undefined && within(project, candidate)) || candidate.split(sep).some((part) => part === "node_modules" || part === ".git"))
69
+ untrusted();
70
+ visited.add(candidate);
71
+ const stat = lstatSync(candidate);
72
+ if (stat.uid !== 0 && stat.uid !== uid)
73
+ untrusted();
74
+ if (!stat.isSymbolicLink()) {
75
+ assertDarwinAclSafe(candidate);
76
+ // OS-owned sticky temp roots protect independently owned private children.
77
+ const stickyRoot = stat.isDirectory() && stat.uid === 0 && (stat.mode & 0o1000) !== 0;
78
+ if ((stat.mode & 0o022) !== 0 && !stickyRoot)
79
+ untrusted();
80
+ }
81
+ const parent = dirname(candidate);
82
+ if (parent !== candidate)
83
+ check(parent);
84
+ if (stat.isSymbolicLink())
85
+ check(realpathSync(candidate));
86
+ };
87
+ check(path);
88
+ const canonical = realpathSync(path);
89
+ check(canonical);
90
+ const stat = statSync(canonical);
91
+ if (kind === "executable") {
92
+ if (!stat.isFile())
93
+ untrusted();
94
+ accessSync(canonical, constants.X_OK);
95
+ }
96
+ else if (!stat.isDirectory())
97
+ untrusted();
98
+ return canonical;
99
+ }
100
+ catch {
101
+ return untrusted();
102
+ }
103
+ }
104
+ export function admitExecutable(path, options = {}) {
105
+ return admitPath(path, options, "executable");
106
+ }
107
+ export function admitInstallationDirectory(path, options = {}) {
108
+ return admitPath(path, options, "directory");
109
+ }
110
+ // Inline probe imports builtins only; no project loader, package resolution,
111
+ // configuration, Store, credentials, network or inherited Node options.
112
+ const NODE_PROBE = `
113
+ const { DatabaseSync } = require('node:sqlite');
114
+ const db = new DatabaseSync(':memory:');
115
+ try {
116
+ const row = db.prepare('SELECT sqlite_source_id() AS sourceId').get();
117
+ const compileOptions = db.prepare('PRAGMA compile_options').all().map(option => {
118
+ const value = Object.values(option)[0];
119
+ if (typeof value !== 'string') throw new Error('invalid compile option');
120
+ return value;
121
+ }).sort();
122
+ db.enableDefensive(true);
123
+ db.exec('PRAGMA writable_schema=ON');
124
+ if (db.prepare('PRAGMA writable_schema').get()?.writable_schema !== 0) throw new Error('defensive mode unavailable');
125
+ db.exec('CREATE TABLE runtime_probe (value INTEGER); BEGIN');
126
+ if (db.isTransaction !== true) throw new Error('transaction state unavailable');
127
+ if (db.prepare('INSERT INTO runtime_probe VALUES (?)').run(1).changes !== 1
128
+ || db.prepare('SELECT value FROM runtime_probe').get()?.value !== 1
129
+ || db.prepare('SELECT value FROM runtime_probe').all().length !== 1) throw new Error('statement APIs unavailable');
130
+ db.exec('ROLLBACK');
131
+ if (db.isTransaction !== false) throw new Error('transaction state unavailable');
132
+ process.stdout.write(JSON.stringify({nodeVersion:process.version,sqliteVersion:process.versions.sqlite,sqliteSourceId:row.sourceId,platform:process.platform,arch:process.arch,compileOptions,sqliteCapabilities:${JSON.stringify(REQUIRED_SQLITE_CAPABILITIES)}}));
133
+ } finally { db.close(); }
134
+ `;
135
+ export function qualifyNodeExecutable(path, options = {}) {
136
+ const canonical = admitExecutable(path, options);
137
+ try {
138
+ const text = execFileSync(canonical, ["--input-type=commonjs", "-e", NODE_PROBE], {
139
+ cwd: "/", env: { PATH: "/usr/bin:/bin", LANG: "C", LC_ALL: "C" },
140
+ shell: false, timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 16_384,
141
+ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
142
+ });
143
+ const identity = JSON.parse(text);
144
+ assertRuntimeEligibility(identity);
145
+ return identity;
146
+ }
147
+ catch {
148
+ throw new ExecutableRefusal("CE_NODE_INELIGIBLE");
149
+ }
150
+ }
151
+ export function resolveApprovedNode(options = {}) {
152
+ const env = options.env ?? {};
153
+ const qualify = (path) => {
154
+ const canonical = admitExecutable(path, options);
155
+ qualifyNodeExecutable(canonical, options);
156
+ return canonical;
157
+ };
158
+ if (env.CE_NODE_PATH !== undefined)
159
+ return qualify(env.CE_NODE_PATH);
160
+ const legacyRoot = env.MISE_DATA_DIR ?? join(options.homeDir ?? homedir(), ".local/share/mise");
161
+ const candidates = [
162
+ options.execPath ?? process.execPath,
163
+ ...(options.systemDirectories ?? SYSTEM_DIRECTORIES).map((directory) => join(directory, "node")),
164
+ ...(isAbsolute(legacyRoot) ? APPROVED_RUNTIME_TUPLES.map(({ nodeVersion }) => join(legacyRoot, "installs/node", nodeVersion.slice(1), "bin/node")) : []),
165
+ ];
166
+ for (const path of new Set(candidates)) {
167
+ try {
168
+ return qualify(path);
169
+ }
170
+ catch { /* Discovery candidates may be absent or unqualified. */ }
171
+ }
172
+ return undefined;
173
+ }
174
+ /** Diagnostic-only closed outcome. Same runtime admission, retaining discovery
175
+ * refusals instead of laundering rejected installations as absent. Explicit
176
+ * overrides are authoritative: even an absent override is a refusal. */
177
+ export function diagnoseTunnelClient(options = {}) {
178
+ const env = options.env ?? {};
179
+ if (env.CE_TUNNEL_CLIENT_PATH !== undefined) {
180
+ try {
181
+ admitExecutable(env.CE_TUNNEL_CLIENT_PATH, options);
182
+ return "TRUSTED_METADATA_ONLY";
183
+ }
184
+ catch {
185
+ return "CE_EXECUTABLE_UNTRUSTED";
186
+ }
187
+ }
188
+ let rejected = false;
189
+ for (const directory of options.systemDirectories ?? SYSTEM_DIRECTORIES) {
190
+ const candidate = join(directory, "tunnel-client");
191
+ try {
192
+ lstatSync(candidate);
193
+ }
194
+ catch (error) {
195
+ if (error.code !== "ENOENT")
196
+ rejected = true;
197
+ continue;
198
+ }
199
+ try {
200
+ admitExecutable(candidate, options);
201
+ return "TRUSTED_METADATA_ONLY";
202
+ }
203
+ catch {
204
+ rejected = true;
205
+ }
206
+ }
207
+ return rejected ? "CE_EXECUTABLE_UNTRUSTED" : "CE_TUNNEL_CLIENT_MISSING";
208
+ }
209
+ /** Discovery is metadata-only: never execute a client to find or qualify it. */
210
+ export function resolveTunnelClient(options = {}) {
211
+ const env = options.env ?? {};
212
+ if (env.CE_TUNNEL_CLIENT_PATH !== undefined)
213
+ return admitExecutable(env.CE_TUNNEL_CLIENT_PATH, options);
214
+ for (const directory of options.systemDirectories ?? SYSTEM_DIRECTORIES) {
215
+ try {
216
+ return admitExecutable(join(directory, "tunnel-client"), options);
217
+ }
218
+ catch { /* Next controlled location. */ }
219
+ }
220
+ return undefined;
221
+ }
222
+ //# sourceMappingURL=executables.js.map
@@ -0,0 +1,221 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { StoreLifecycleError } from "./errors.js";
3
+ import { CAPABILITY_PATTERN, HOST_CAPABILITY_BYTES } from "./participant-identity.js";
4
+ import { deriveCapabilityDigest, digestsMatch, readHostSecretDigest } from "./trust-root.js";
5
+ import { parseSessionRow, SESSION_COLUMNS } from "./session-state.js";
6
+ /**
7
+ * Host Capability authorization plane (CE-0002-B3-P1B).
8
+ *
9
+ * A Host Capability is a 256-bit CSPRNG bearer token issued by the trusted
10
+ * host. Only an HMAC digest keyed by the host secret is persisted
11
+ * (`participant_capability.capability_digest`), so:
12
+ *
13
+ * - a Session ID or Participant ID alone never grants authorization — only
14
+ * presenting the token does;
15
+ * - the stored digest cannot be replayed into another store, because the host
16
+ * secret keys the derivation;
17
+ * - a token is bound to one trust generation, one Participant, and one exact
18
+ * scope, so it cannot be widened or transferred;
19
+ * - an operator trust-root recovery invalidates every outstanding token twice
20
+ * over: by revocation (P1A) and by generation binding.
21
+ *
22
+ * Operational scopes are phase-bounded: P1 create/bind/join, P2 attach/resume/
23
+ * takeover, P3A send/metadata-only read, P4C payload_read, and the P3B
24
+ * authorization-plane-only `ack`. No durable ACK operation, receipt, audit
25
+ * row, or public surface exists yet: `ack` admits the acknowledgment
26
+ * authority into the capability plane (issuance, classification, revocation)
27
+ * and the current-Owner authorization chain only, and no other scope gains
28
+ * acknowledgment authority. `resume`, `takeover`, `send`, `read`,
29
+ * `payload_read`, and `ack` are Session-bound: each authorizes only the
30
+ * Session it was durably issued to.
31
+ * Verification always happens inside the caller's owned transaction, against
32
+ * the same consistent snapshot as the mutation it authorizes. Refusals are a
33
+ * single sanitized classification: the capability plane is never an oracle
34
+ * that distinguishes "unknown" from "revoked", "expired", or "wrong scope".
35
+ */
36
+ export const P1_CAPABILITY_SCOPES = ["create", "bind", "join"];
37
+ export function isP1CapabilityScope(value) {
38
+ return typeof value === "string" && P1_CAPABILITY_SCOPES.includes(value);
39
+ }
40
+ /** CE-0002-B3-P2A: Session binding/acquisition (`attach`) and owner
41
+ * renewal/release/verification (`resume`). */
42
+ export const P2A_CAPABILITY_SCOPES = ["attach", "resume"];
43
+ /** CE-0002-B3-P2B: explicit Owner takeover. Session-bound like `resume`:
44
+ * a takeover capability authorizes only the Session it was durably issued to
45
+ * (the prospective new Owner Session), never a Session presented at will. */
46
+ export const P2B_CAPABILITY_SCOPES = ["takeover"];
47
+ /** CE-0002-B3-P3A: durable storage send and protected metadata read. */
48
+ export const P3A_CAPABILITY_SCOPES = ["send", "read"];
49
+ /** CE-0002-B3-P4C: independent payload access authority. This scope does not
50
+ * widen the historical metadata-only `read` scope and grants no operation by
51
+ * itself; a later protected Storage operation must enforce ownership, fencing,
52
+ * lease, membership, endpoint, and Message eligibility in one transaction. */
53
+ export const P4C_CAPABILITY_SCOPES = ["payload_read"];
54
+ /** CE-0002-B3-P3B: independent ACK authority. Admission to this private
55
+ * authorization plane does not implement an ACK operation or receipt. */
56
+ export const P3B_CAPABILITY_SCOPES = ["ack"];
57
+ /** Scopes whose authority is durably bound to exactly one Session. */
58
+ export const SESSION_BOUND_CAPABILITY_SCOPES = [
59
+ "resume",
60
+ "takeover",
61
+ ...P3A_CAPABILITY_SCOPES,
62
+ ...P4C_CAPABILITY_SCOPES,
63
+ ...P3B_CAPABILITY_SCOPES,
64
+ ];
65
+ export function isSessionBoundCapabilityScope(value) {
66
+ return typeof value === "string" && SESSION_BOUND_CAPABILITY_SCOPES.includes(value);
67
+ }
68
+ export const OPERATIONAL_CAPABILITY_SCOPES = [
69
+ ...P1_CAPABILITY_SCOPES,
70
+ ...P2A_CAPABILITY_SCOPES,
71
+ ...P2B_CAPABILITY_SCOPES,
72
+ ...P3A_CAPABILITY_SCOPES,
73
+ ...P4C_CAPABILITY_SCOPES,
74
+ ...P3B_CAPABILITY_SCOPES,
75
+ ];
76
+ export function isOperationalCapabilityScope(value) {
77
+ return typeof value === "string" && OPERATIONAL_CAPABILITY_SCOPES.includes(value);
78
+ }
79
+ /** Cryptographically random bearer token: `cap_` + 256 bits of hex. */
80
+ export function generateCapability(tokenBytes) {
81
+ const generated = (tokenBytes ?? (() => randomBytes(HOST_CAPABILITY_BYTES)))();
82
+ if (!(generated instanceof Uint8Array) || generated.byteLength !== HOST_CAPABILITY_BYTES) {
83
+ throw new Error("capability generator did not return exactly 256 bits");
84
+ }
85
+ return `cap_${Buffer.from(generated).toString("hex")}`;
86
+ }
87
+ function tokenBody(capability) {
88
+ if (typeof capability !== "string" || !CAPABILITY_PATTERN.test(capability))
89
+ return undefined;
90
+ return capability.slice("cap_".length);
91
+ }
92
+ /** Current trust generation inside an owned transaction, with the same
93
+ * append-only maximum rule the open path uses. Returns undefined when the
94
+ * durable generation row is missing or structurally invalid; callers decide
95
+ * whether that is corruption or a refusal. */
96
+ export function currentGenerationInTransaction(tx) {
97
+ const row = tx
98
+ .prepare("SELECT generation, secret_digest FROM host_trust_root ORDER BY generation DESC LIMIT 1")
99
+ .get();
100
+ if (row === undefined)
101
+ return undefined;
102
+ if (typeof row.generation !== "number" ||
103
+ !Number.isSafeInteger(row.generation) ||
104
+ row.generation < 1 ||
105
+ typeof row.secret_digest !== "string" ||
106
+ !/^[0-9a-f]{64}$/.test(row.secret_digest)) {
107
+ return undefined;
108
+ }
109
+ return { generation: row.generation, secret_digest: row.secret_digest };
110
+ }
111
+ export function capabilityRow(tx, digest) {
112
+ const row = tx
113
+ .prepare("SELECT capability_digest, participant_id, scope, trust_generation, created_at, expires_at, revoked_at, session_id FROM participant_capability WHERE capability_digest = ?")
114
+ .get(digest);
115
+ if (row === undefined)
116
+ return undefined;
117
+ if (typeof row.capability_digest !== "string" ||
118
+ typeof row.participant_id !== "string" ||
119
+ typeof row.scope !== "string" ||
120
+ typeof row.trust_generation !== "number" ||
121
+ typeof row.created_at !== "string" ||
122
+ !(row.expires_at === null || typeof row.expires_at === "string") ||
123
+ !(row.revoked_at === null || typeof row.revoked_at === "string") ||
124
+ !(row.session_id === null || typeof row.session_id === "string")) {
125
+ return undefined;
126
+ }
127
+ return row;
128
+ }
129
+ function instantOf(value) {
130
+ const parsed = Date.parse(value);
131
+ return Number.isFinite(parsed) ? parsed : undefined;
132
+ }
133
+ /**
134
+ * Classifies one presented capability inside an owned transaction.
135
+ *
136
+ * The host trust root is re-validated per authorization: the live secret file
137
+ * must still pass the artifact policy and still match the persisted
138
+ * fingerprint of the current generation. A trust-root failure propagates as a
139
+ * typed trust-root refusal (fail closed), never as a capability rejection: a
140
+ * missing generation row is STORAGE_CORRUPT and a fingerprint disagreement is
141
+ * STORAGE_TRUST_ROOT_MISMATCH.
142
+ */
143
+ export function classifyCapability(tx, databasePath, presentation) {
144
+ const token = tokenBody(presentation.capability);
145
+ if (token === undefined || !isOperationalCapabilityScope(presentation.scope))
146
+ return "MALFORMED";
147
+ const now = instantOf(presentation.now);
148
+ if (now === undefined)
149
+ return "MALFORMED";
150
+ const generation = currentGenerationInTransaction(tx);
151
+ if (generation === undefined) {
152
+ throw new StoreLifecycleError("STORAGE_CORRUPT", "a v2 store has no host trust root generation");
153
+ }
154
+ // Host-secret trust validation: artifact policy plus fingerprint agreement.
155
+ // CE-0002-B3-P1B-R1 (MINOR-2): a trust-root failure is a typed trust-root
156
+ // refusal, never an opaque capability rejection, matching this module's
157
+ // documented classification and `issueCapability`. Fail-closed either way:
158
+ // the mutation is refused and nothing is written.
159
+ if (!digestsMatch(readHostSecretDigest(databasePath), generation.secret_digest)) {
160
+ throw new StoreLifecycleError("STORAGE_TRUST_ROOT_MISMATCH", "the host secret does not match the persisted trust-root fingerprint");
161
+ }
162
+ const digest = deriveCapabilityDigest(databasePath, {
163
+ generation: generation.generation,
164
+ participantId: presentation.participantId,
165
+ scope: presentation.scope,
166
+ token,
167
+ });
168
+ const row = capabilityRow(tx, digest);
169
+ if (row === undefined) {
170
+ // Either the token is forged, or it was derived under another generation,
171
+ // Participant, or scope. All are indistinguishable by construction.
172
+ return "UNKNOWN";
173
+ }
174
+ if (row.participant_id !== presentation.participantId)
175
+ return "PARTICIPANT_MISMATCH";
176
+ if (row.scope !== presentation.scope)
177
+ return "SCOPE_MISMATCH";
178
+ // Session-bound scopes (CE-0002-B3-P2A-R1 `resume`; CE-0002-B3-P2B
179
+ // `takeover`): the capability authorizes only the Session it was durably
180
+ // issued to. A legacy unbound (NULL) or differently bound row can never
181
+ // authorize: fail closed. The check runs against the durable row inside the
182
+ // caller's transaction, so the binding cannot be swapped between check and
183
+ // effect.
184
+ if (isSessionBoundCapabilityScope(presentation.scope) && row.session_id !== presentation.sessionId) {
185
+ return "SESSION_MISMATCH";
186
+ }
187
+ // CE-0002-B3-P2C-B (D7): a Session-bound capability is lifecycle-bound.
188
+ // The persisted `detached_at` of its bound Session is the source of
189
+ // truth, so a DETACHED Session makes the capability permanently unusable
190
+ // for every presenter — read-time invalidation only; no capability row is
191
+ // ever rewritten as a side effect of Detach. P3A `send`/`read`, P4C
192
+ // `payload_read`, and P3B `ack` inherit this check inside the
193
+ // caller's transaction: a Session-bound capability whose binding is missing
194
+ // or malformed is inconsistent durable state — refused as corruption,
195
+ // never adopted (schema v4 makes row deletion trigger-impossible, so this
196
+ // is privileged tampering or foreign state).
197
+ if (isSessionBoundCapabilityScope(row.scope) && row.session_id !== null) {
198
+ const sessionRow = tx
199
+ .prepare(`${SESSION_COLUMNS} WHERE participant_id = ? AND session_id = ?`)
200
+ .get(row.participant_id, row.session_id);
201
+ const binding = sessionRow === undefined ? undefined : parseSessionRow(sessionRow);
202
+ if (binding === undefined || binding.participantId !== row.participant_id || binding.sessionId !== row.session_id) {
203
+ throw new StoreLifecycleError("STORAGE_OWNERSHIP_CORRUPT", "stored Session lifecycle state is inconsistent; operator review required");
204
+ }
205
+ if (binding.state === "DETACHED")
206
+ return "SESSION_DETACHED";
207
+ }
208
+ if (row.trust_generation !== generation.generation)
209
+ return "WRONG_GENERATION";
210
+ if (row.revoked_at !== null)
211
+ return "REVOKED";
212
+ if (row.expires_at !== null) {
213
+ const expiry = instantOf(row.expires_at);
214
+ if (expiry === undefined)
215
+ return "MALFORMED";
216
+ if (expiry <= now)
217
+ return "EXPIRED";
218
+ }
219
+ return "VALID";
220
+ }
221
+ //# sourceMappingURL=capability.js.map
@@ -0,0 +1,94 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { assertSecureDatabaseArtifacts, prepareDatabasePath, exclusivelyCreateDatabase, pathStat, withRestrictiveUmask, } from "./filesystem.js";
3
+ import { assertRuntimeEligibility, readRuntimeIdentity, } from "./runtime.js";
4
+ export const DEFAULT_BUSY_TIMEOUT_MS = 1_000;
5
+ export const MAX_BUSY_TIMEOUT_MS = 5_000;
6
+ export const WAL_AUTOCHECKPOINT_PAGES = 1_000;
7
+ export class ConnectionPolicyError extends Error {
8
+ code = "STORAGE_CONNECTION_POLICY_INVALID";
9
+ constructor(message, options) {
10
+ super(message, options);
11
+ this.name = "ConnectionPolicyError";
12
+ }
13
+ }
14
+ export function assertStorageRuntimeEligibility(options) {
15
+ assertRuntimeEligibility((options.runtimeIdentity ?? readRuntimeIdentity)());
16
+ }
17
+ function readPragma(db, name) {
18
+ const row = db.prepare(`PRAGMA ${name}`).get();
19
+ return row === undefined ? undefined : Object.values(row)[0];
20
+ }
21
+ function assertPragma(db, name, expected) {
22
+ const actual = readPragma(db, name);
23
+ if (actual !== expected) {
24
+ throw new ConnectionPolicyError(`SQLite refused required PRAGMA ${name}: expected ${String(expected)}, received ${String(actual)}`);
25
+ }
26
+ }
27
+ function initializeWriterPolicy(db, busyTimeoutMs) {
28
+ db.enableDefensive(true);
29
+ db.exec(`
30
+ PRAGMA journal_mode=WAL;
31
+ PRAGMA synchronous=FULL;
32
+ PRAGMA foreign_keys=ON;
33
+ PRAGMA recursive_triggers=ON;
34
+ PRAGMA trusted_schema=OFF;
35
+ PRAGMA busy_timeout=${busyTimeoutMs};
36
+ PRAGMA wal_autocheckpoint=${WAL_AUTOCHECKPOINT_PAGES};
37
+ `);
38
+ if (process.platform === "darwin") {
39
+ db.exec("PRAGMA fullfsync=ON; PRAGMA checkpoint_fullfsync=ON;");
40
+ }
41
+ assertPragma(db, "journal_mode", "wal");
42
+ assertPragma(db, "synchronous", 2);
43
+ assertPragma(db, "foreign_keys", 1);
44
+ assertPragma(db, "recursive_triggers", 1);
45
+ assertPragma(db, "trusted_schema", 0);
46
+ assertPragma(db, "busy_timeout", busyTimeoutMs);
47
+ assertPragma(db, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES);
48
+ if (process.platform === "darwin") {
49
+ assertPragma(db, "fullfsync", 1);
50
+ assertPragma(db, "checkpoint_fullfsync", 1);
51
+ }
52
+ }
53
+ export function openStorageConnection(options, validateExisting, createExclusive = false) {
54
+ const busyTimeoutMs = options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS;
55
+ if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 1 || busyTimeoutMs > MAX_BUSY_TIMEOUT_MS) {
56
+ throw new ConnectionPolicyError(`busy timeout must be an integer from 1 to ${MAX_BUSY_TIMEOUT_MS} milliseconds`);
57
+ }
58
+ assertStorageRuntimeEligibility(options);
59
+ const databasePath = prepareDatabasePath(options);
60
+ assertSecureDatabaseArtifacts(databasePath);
61
+ if (createExclusive || pathStat(databasePath) === undefined) {
62
+ if (validateExisting !== undefined)
63
+ throw new ConnectionPolicyError("existing store disappeared before writer open");
64
+ exclusivelyCreateDatabase(databasePath);
65
+ }
66
+ let db;
67
+ try {
68
+ db = withRestrictiveUmask(() => new DatabaseSync(databasePath, { defensive: true, timeout: busyTimeoutMs }));
69
+ // Revalidate identity/compatibility before persistent writer PRAGMAs.
70
+ // A read transaction gives these checks one consistent schema/data snapshot.
71
+ if (validateExisting !== undefined) {
72
+ db.exec("BEGIN");
73
+ try {
74
+ validateExisting(db);
75
+ }
76
+ finally {
77
+ db.exec("ROLLBACK");
78
+ }
79
+ }
80
+ initializeWriterPolicy(db, busyTimeoutMs);
81
+ assertSecureDatabaseArtifacts(databasePath);
82
+ return db;
83
+ }
84
+ catch (error) {
85
+ if (db !== undefined)
86
+ db.close();
87
+ if (error instanceof ConnectionPolicyError)
88
+ throw error;
89
+ throw new ConnectionPolicyError("failed to establish the required SQLite connection policy", {
90
+ cause: error,
91
+ });
92
+ }
93
+ }
94
+ //# sourceMappingURL=connection.js.map