@cueai/omni-reader-mcp 1.0.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 +49 -0
- package/dist/artifact-store.d.ts +65 -0
- package/dist/artifact-store.js +720 -0
- package/dist/cli/agent-config.d.ts +28 -0
- package/dist/cli/agent-config.js +335 -0
- package/dist/cli/clean.d.ts +9 -0
- package/dist/cli/clean.js +93 -0
- package/dist/cli/doctor.d.ts +17 -0
- package/dist/cli/doctor.js +157 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +59 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/cube-client.d.ts +48 -0
- package/dist/cube-client.js +161 -0
- package/dist/cursor.d.ts +14 -0
- package/dist/cursor.js +101 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +26 -0
- package/dist/iiis-client.d.ts +43 -0
- package/dist/iiis-client.js +694 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +174 -0
- package/dist/multipart-body.d.ts +12 -0
- package/dist/multipart-body.js +90 -0
- package/dist/operation-journal.d.ts +28 -0
- package/dist/operation-journal.js +351 -0
- package/dist/path-security.d.ts +22 -0
- package/dist/path-security.js +240 -0
- package/dist/progress.d.ts +4 -0
- package/dist/progress.js +3 -0
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.js +27 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +10 -0
- package/dist/tools.d.ts +24 -0
- package/dist/tools.js +224 -0
- package/package.json +35 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
interface WritableLike {
|
|
3
|
+
write(chunk: string): unknown;
|
|
4
|
+
}
|
|
5
|
+
export interface RunCliOptions {
|
|
6
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
7
|
+
readonly homeDirectory?: string;
|
|
8
|
+
readonly cwd?: string;
|
|
9
|
+
readonly artifactRoot?: string;
|
|
10
|
+
readonly platform?: NodeJS.Platform;
|
|
11
|
+
readonly now?: () => Date;
|
|
12
|
+
readonly npmVersion?: string;
|
|
13
|
+
readonly packageVersion?: string;
|
|
14
|
+
readonly fetchImpl?: typeof fetch;
|
|
15
|
+
readonly stdout?: WritableLike;
|
|
16
|
+
readonly stderr?: WritableLike;
|
|
17
|
+
readonly ask?: (question: string) => Promise<string>;
|
|
18
|
+
readonly startServer?: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export declare function startStdioServer(options?: RunCliOptions): Promise<void>;
|
|
21
|
+
export declare function runCli(args: readonly string[], options?: RunCliOptions): Promise<number>;
|
|
22
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { stdin, stdout as processStdout } from "node:process";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { ArtifactStore, defaultArtifactRoot } from "./artifact-store.js";
|
|
9
|
+
import { runClean } from "./cli/clean.js";
|
|
10
|
+
import { runDoctor } from "./cli/doctor.js";
|
|
11
|
+
import { runSetup } from "./cli/setup.js";
|
|
12
|
+
import { CubeGrantClient } from "./cube-client.js";
|
|
13
|
+
import { IiisClient } from "./iiis-client.js";
|
|
14
|
+
import { OperationJournal } from "./operation-journal.js";
|
|
15
|
+
import { splitAllowedRoots } from "./path-security.js";
|
|
16
|
+
import { createOmniMcpServer } from "./server.js";
|
|
17
|
+
function helpText() {
|
|
18
|
+
return [
|
|
19
|
+
"Usage: omni-reader-mcp [setup|doctor|clean|--help|--version]",
|
|
20
|
+
"",
|
|
21
|
+
"No arguments start the stdio MCP server.",
|
|
22
|
+
"setup Configure a supported user-scope Agent",
|
|
23
|
+
"doctor Check local configuration and protocol health",
|
|
24
|
+
"clean Delete Bridge-created local artifacts and expired records",
|
|
25
|
+
"",
|
|
26
|
+
].join("\n");
|
|
27
|
+
}
|
|
28
|
+
async function defaultAsk(question) {
|
|
29
|
+
const prompt = createInterface({ input: stdin, output: processStdout });
|
|
30
|
+
try {
|
|
31
|
+
return await prompt.question(question);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
prompt.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function installedPackageVersion() {
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
40
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
41
|
+
const version = value.version;
|
|
42
|
+
if (typeof version === "string" && version.length > 0)
|
|
43
|
+
return version;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Report an explicit unknown version rather than a stale compiled constant.
|
|
48
|
+
}
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
async function installedNpmVersion(env) {
|
|
52
|
+
const userAgent = env.npm_config_user_agent;
|
|
53
|
+
const embedded = userAgent === undefined ? null : /(?:^|\s)npm\/([^\s]+)/u.exec(userAgent);
|
|
54
|
+
if (embedded !== null)
|
|
55
|
+
return embedded[1];
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
execFileCallback("npm", ["--version"], { encoding: "utf8", env, timeout: 5_000, windowsHide: true }, (error, output) => {
|
|
58
|
+
const version = output.trim();
|
|
59
|
+
resolve(error === null && version.length > 0 ? version : "unavailable");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export async function startStdioServer(options = {}) {
|
|
64
|
+
const env = options.env ?? process.env;
|
|
65
|
+
const cwd = options.cwd ?? process.cwd();
|
|
66
|
+
const artifactRoot = options.artifactRoot ?? defaultArtifactRoot({
|
|
67
|
+
platform: options.platform,
|
|
68
|
+
homeDirectory: options.homeDirectory,
|
|
69
|
+
env,
|
|
70
|
+
});
|
|
71
|
+
const artifactStore = await ArtifactStore.open({
|
|
72
|
+
rootDirectory: artifactRoot,
|
|
73
|
+
projectDirectory: cwd,
|
|
74
|
+
now: options.now,
|
|
75
|
+
});
|
|
76
|
+
const journal = new OperationJournal({ rootDirectory: artifactStore.rootDirectory, now: options.now });
|
|
77
|
+
const cubeClient = new CubeGrantClient({
|
|
78
|
+
journal,
|
|
79
|
+
environment: { CUE_API_KEY: env.CUE_API_KEY },
|
|
80
|
+
fetchImpl: options.fetchImpl,
|
|
81
|
+
});
|
|
82
|
+
const server = createOmniMcpServer({
|
|
83
|
+
workspace: cwd,
|
|
84
|
+
extraRoots: splitAllowedRoots(env.OMNI_ALLOWED_ROOTS),
|
|
85
|
+
cubeClient,
|
|
86
|
+
iiisClient: new IiisClient({ fetchImpl: options.fetchImpl }),
|
|
87
|
+
artifactStore,
|
|
88
|
+
});
|
|
89
|
+
const close = async () => {
|
|
90
|
+
await server.close().catch(() => undefined);
|
|
91
|
+
await artifactStore.close().catch(() => undefined);
|
|
92
|
+
};
|
|
93
|
+
process.once("SIGINT", () => { void close(); });
|
|
94
|
+
process.once("SIGTERM", () => { void close(); });
|
|
95
|
+
await server.connect(new StdioServerTransport());
|
|
96
|
+
}
|
|
97
|
+
export async function runCli(args, options = {}) {
|
|
98
|
+
const env = options.env ?? process.env;
|
|
99
|
+
const homeDirectory = options.homeDirectory ?? (await import("node:os")).homedir();
|
|
100
|
+
const cwd = options.cwd ?? process.cwd();
|
|
101
|
+
const platform = options.platform ?? process.platform;
|
|
102
|
+
const artifactRoot = options.artifactRoot ?? defaultArtifactRoot({ platform, homeDirectory, env });
|
|
103
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
104
|
+
const output = options.stdout ?? process.stdout;
|
|
105
|
+
const errorOutput = options.stderr ?? process.stderr;
|
|
106
|
+
const ask = options.ask ?? defaultAsk;
|
|
107
|
+
const write = (text) => { output.write(text); };
|
|
108
|
+
const packageVersion = options.packageVersion ?? await installedPackageVersion();
|
|
109
|
+
try {
|
|
110
|
+
if (args.length === 0) {
|
|
111
|
+
await (options.startServer ?? (() => startStdioServer({
|
|
112
|
+
...options,
|
|
113
|
+
env,
|
|
114
|
+
homeDirectory,
|
|
115
|
+
cwd,
|
|
116
|
+
platform,
|
|
117
|
+
artifactRoot,
|
|
118
|
+
fetchImpl,
|
|
119
|
+
})))();
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
const command = args[0];
|
|
123
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
124
|
+
write(helpText());
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
if (command === "--version" || command === "-v") {
|
|
128
|
+
write(`${packageVersion}\n`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
if (command === "setup") {
|
|
132
|
+
await runSetup({ env, homeDirectory, platform, fetchImpl, ask, write });
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
if (command === "doctor") {
|
|
136
|
+
const lines = await runDoctor({
|
|
137
|
+
env,
|
|
138
|
+
homeDirectory,
|
|
139
|
+
platform,
|
|
140
|
+
artifactRoot,
|
|
141
|
+
fetchImpl,
|
|
142
|
+
npmVersion: options.npmVersion ?? await installedNpmVersion(env),
|
|
143
|
+
packageVersion,
|
|
144
|
+
});
|
|
145
|
+
write(`${lines.join("\n")}\n`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
if (command === "clean") {
|
|
149
|
+
const result = await runClean({ artifactRoot, projectDirectory: cwd, now: options.now });
|
|
150
|
+
write(`Removed ${result.removed} Bridge cache item(s).\n`);
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
errorOutput.write(`Unknown command: ${command}\n`);
|
|
154
|
+
errorOutput.write(helpText());
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const message = error instanceof Error ? error.message : "The Bridge command failed.";
|
|
159
|
+
errorOutput.write(`Error: ${message}\n`);
|
|
160
|
+
return 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function main() {
|
|
164
|
+
const code = await runCli(process.argv.slice(2));
|
|
165
|
+
if (code !== 0)
|
|
166
|
+
process.exitCode = code;
|
|
167
|
+
}
|
|
168
|
+
const invokedPath = process.argv[1];
|
|
169
|
+
if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href) {
|
|
170
|
+
void main().catch(() => {
|
|
171
|
+
process.stderr.write("Bridge startup failed.\n");
|
|
172
|
+
process.exitCode = 1;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OpenedAllowedFile } from "./path-security.js";
|
|
2
|
+
export interface MultipartBodyOptions {
|
|
3
|
+
readonly signal?: AbortSignal;
|
|
4
|
+
readonly onProgress?: (uploadedBytes: number) => void;
|
|
5
|
+
readonly boundary?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface MultipartBody {
|
|
8
|
+
readonly contentType: string;
|
|
9
|
+
readonly contentLength: number;
|
|
10
|
+
readonly stream: AsyncIterable<Uint8Array>;
|
|
11
|
+
}
|
|
12
|
+
export declare function createMultipartBody(openedFile: OpenedAllowedFile, options?: MultipartBodyOptions): MultipartBody;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { OmniBridgeError } from "./errors.js";
|
|
3
|
+
function bridgeError(code, message, retryable) {
|
|
4
|
+
return new OmniBridgeError({
|
|
5
|
+
code,
|
|
6
|
+
message,
|
|
7
|
+
fileUploaded: false,
|
|
8
|
+
billed: false,
|
|
9
|
+
contentReleased: false,
|
|
10
|
+
retryable,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function cancellationError() {
|
|
14
|
+
return bridgeError("CANCELED_BEFORE_UPLOAD_COMPLETE", "The local upload was canceled before it completed.", false);
|
|
15
|
+
}
|
|
16
|
+
function throwIfAborted(signal) {
|
|
17
|
+
if (signal?.aborted) {
|
|
18
|
+
throw cancellationError();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function validatedBoundary(boundary) {
|
|
22
|
+
const value = boundary ?? `omni-${randomBytes(18).toString("hex")}`;
|
|
23
|
+
if (!/^[A-Za-z0-9-]{1,70}$/u.test(value)) {
|
|
24
|
+
throw bridgeError("INVALID_MULTIPART_BOUNDARY", "The multipart upload boundary is invalid.", false);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
export function createMultipartBody(openedFile, options = {}) {
|
|
29
|
+
const boundary = validatedBoundary(options.boundary);
|
|
30
|
+
const preamble = Buffer.from([
|
|
31
|
+
`--${boundary}`,
|
|
32
|
+
`Content-Disposition: form-data; name="file"; filename="document${openedFile.safeExtension}"`,
|
|
33
|
+
`Content-Type: ${openedFile.contentType}`,
|
|
34
|
+
"",
|
|
35
|
+
"",
|
|
36
|
+
].join("\r\n"), "utf8");
|
|
37
|
+
const trailer = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
|
|
38
|
+
const contentLength = preamble.length + openedFile.size + trailer.length;
|
|
39
|
+
const stream = (async function* () {
|
|
40
|
+
throwIfAborted(options.signal);
|
|
41
|
+
await options.onProgress?.(0);
|
|
42
|
+
throwIfAborted(options.signal);
|
|
43
|
+
yield preamble;
|
|
44
|
+
throwIfAborted(options.signal);
|
|
45
|
+
const fileStream = openedFile.createReadStream();
|
|
46
|
+
const abortStream = () => {
|
|
47
|
+
fileStream.destroy();
|
|
48
|
+
};
|
|
49
|
+
options.signal?.addEventListener("abort", abortStream, { once: true });
|
|
50
|
+
let uploadedBytes = 0;
|
|
51
|
+
try {
|
|
52
|
+
for await (const rawChunk of fileStream) {
|
|
53
|
+
throwIfAborted(options.signal);
|
|
54
|
+
const chunk = Buffer.isBuffer(rawChunk)
|
|
55
|
+
? rawChunk
|
|
56
|
+
: Buffer.from(rawChunk);
|
|
57
|
+
if (uploadedBytes + chunk.length > openedFile.size) {
|
|
58
|
+
throw bridgeError("FILE_CHANGED_DURING_UPLOAD", "The local file changed during upload. Retry after file changes stop.", true);
|
|
59
|
+
}
|
|
60
|
+
yield chunk;
|
|
61
|
+
uploadedBytes += chunk.length;
|
|
62
|
+
await options.onProgress?.(uploadedBytes);
|
|
63
|
+
throwIfAborted(options.signal);
|
|
64
|
+
}
|
|
65
|
+
if (uploadedBytes !== openedFile.size) {
|
|
66
|
+
throw bridgeError("FILE_CHANGED_DURING_UPLOAD", "The local file changed during upload. Retry after file changes stop.", true);
|
|
67
|
+
}
|
|
68
|
+
throwIfAborted(options.signal);
|
|
69
|
+
yield trailer;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (options.signal?.aborted) {
|
|
73
|
+
throw cancellationError();
|
|
74
|
+
}
|
|
75
|
+
if (error instanceof OmniBridgeError) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
throw bridgeError("LOCAL_FILE_READ_FAILED", "The local file could not be read during upload.", true);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
options.signal?.removeEventListener("abort", abortStream);
|
|
82
|
+
fileStream.destroy();
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
return {
|
|
86
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
87
|
+
contentLength,
|
|
88
|
+
stream,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type JournalState = "GRANT_PENDING" | "GRANT_ISSUED";
|
|
2
|
+
export interface JournalRecord {
|
|
3
|
+
readonly clientRequestId: string;
|
|
4
|
+
readonly requestHash: string;
|
|
5
|
+
readonly operationId: string | null;
|
|
6
|
+
readonly operationToken: string | null;
|
|
7
|
+
readonly uploadUrl: string | null;
|
|
8
|
+
readonly state: JournalState;
|
|
9
|
+
readonly createdAt: string;
|
|
10
|
+
readonly expiresAt: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface IssuedGrantJournalFields {
|
|
13
|
+
readonly operationId: string;
|
|
14
|
+
readonly operationToken: string;
|
|
15
|
+
readonly uploadUrl: string;
|
|
16
|
+
readonly expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface OperationJournalOptions {
|
|
19
|
+
readonly rootDirectory?: string;
|
|
20
|
+
readonly now?: () => Date;
|
|
21
|
+
}
|
|
22
|
+
export declare class OperationJournal {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(options?: OperationJournalOptions);
|
|
25
|
+
begin(clientRequestId: string, requestHash: string): Promise<JournalRecord>;
|
|
26
|
+
markGrantIssued(clientRequestId: string, fields: IssuedGrantJournalFields): Promise<JournalRecord>;
|
|
27
|
+
load(clientRequestId: string): Promise<JournalRecord | null>;
|
|
28
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID, } from "node:crypto";
|
|
2
|
+
import { chmod, link, mkdir, open, readFile, unlink, } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { OmniBridgeError } from "./errors.js";
|
|
6
|
+
function bridgeError(code, message, retryable) {
|
|
7
|
+
return new OmniBridgeError({
|
|
8
|
+
code,
|
|
9
|
+
message,
|
|
10
|
+
fileUploaded: false,
|
|
11
|
+
billed: false,
|
|
12
|
+
contentReleased: false,
|
|
13
|
+
retryable,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function defaultRootDirectory() {
|
|
17
|
+
if (process.platform === "darwin") {
|
|
18
|
+
return path.join(homedir(), "Library", "Caches", "cue", "omni-reader-mcp");
|
|
19
|
+
}
|
|
20
|
+
if (process.platform === "win32") {
|
|
21
|
+
return path.join(homedir(), "AppData", "Local", "cue", "omni-reader-mcp");
|
|
22
|
+
}
|
|
23
|
+
return path.join(homedir(), ".cache", "cue", "omni-reader-mcp");
|
|
24
|
+
}
|
|
25
|
+
function recordFileName(clientRequestId) {
|
|
26
|
+
validateClientRequestId(clientRequestId);
|
|
27
|
+
return `${createHash("sha256").update(clientRequestId, "utf8").digest("hex")}.json`;
|
|
28
|
+
}
|
|
29
|
+
function validateClientRequestId(clientRequestId) {
|
|
30
|
+
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(clientRequestId)) {
|
|
31
|
+
throw bridgeError("INVALID_CLIENT_REQUEST_ID", "The local grant request identifier is invalid.", false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isEncryptedToken(value) {
|
|
35
|
+
if (value === null || typeof value !== "object") {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const token = value;
|
|
39
|
+
return (token.algorithm === "aes-256-gcm" &&
|
|
40
|
+
typeof token.nonce === "string" &&
|
|
41
|
+
typeof token.ciphertext === "string" &&
|
|
42
|
+
typeof token.tag === "string");
|
|
43
|
+
}
|
|
44
|
+
function parsePersistedRecord(value) {
|
|
45
|
+
if (value === null || typeof value !== "object") {
|
|
46
|
+
throw new Error("record is not an object");
|
|
47
|
+
}
|
|
48
|
+
const record = value;
|
|
49
|
+
if (record.version !== 1 ||
|
|
50
|
+
typeof record.clientRequestId !== "string" ||
|
|
51
|
+
typeof record.requestHash !== "string" ||
|
|
52
|
+
(record.operationId !== null && typeof record.operationId !== "string") ||
|
|
53
|
+
(record.operationToken !== null && !isEncryptedToken(record.operationToken)) ||
|
|
54
|
+
(record.uploadUrl !== null && typeof record.uploadUrl !== "string") ||
|
|
55
|
+
(record.state !== "GRANT_PENDING" && record.state !== "GRANT_ISSUED") ||
|
|
56
|
+
typeof record.createdAt !== "string" ||
|
|
57
|
+
(record.expiresAt !== null && typeof record.expiresAt !== "string")) {
|
|
58
|
+
throw new Error("record fields are invalid");
|
|
59
|
+
}
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
async function closeQuietly(handle) {
|
|
63
|
+
await handle?.close().catch(() => undefined);
|
|
64
|
+
}
|
|
65
|
+
export class OperationJournal {
|
|
66
|
+
#rootDirectory;
|
|
67
|
+
#now;
|
|
68
|
+
#keyPromise;
|
|
69
|
+
constructor(options = {}) {
|
|
70
|
+
this.#rootDirectory = options.rootDirectory ?? defaultRootDirectory();
|
|
71
|
+
this.#now = options.now ?? (() => new Date());
|
|
72
|
+
}
|
|
73
|
+
async begin(clientRequestId, requestHash) {
|
|
74
|
+
validateClientRequestId(clientRequestId);
|
|
75
|
+
const existing = await this.#readRecord(clientRequestId);
|
|
76
|
+
if (existing !== null) {
|
|
77
|
+
return this.#requireMatchingRequest(clientRequestId, requestHash, existing);
|
|
78
|
+
}
|
|
79
|
+
const pending = {
|
|
80
|
+
version: 1,
|
|
81
|
+
clientRequestId,
|
|
82
|
+
requestHash,
|
|
83
|
+
operationId: null,
|
|
84
|
+
operationToken: null,
|
|
85
|
+
uploadUrl: null,
|
|
86
|
+
state: "GRANT_PENDING",
|
|
87
|
+
createdAt: this.#now().toISOString(),
|
|
88
|
+
expiresAt: null,
|
|
89
|
+
};
|
|
90
|
+
if (await this.#createRecord(pending, this.#pendingRecordPath(clientRequestId))) {
|
|
91
|
+
return this.#publicRecord(pending, null);
|
|
92
|
+
}
|
|
93
|
+
const raced = await this.#readRecord(clientRequestId);
|
|
94
|
+
if (raced === null) {
|
|
95
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local grant journal could not be saved.", true);
|
|
96
|
+
}
|
|
97
|
+
return this.#requireMatchingRequest(clientRequestId, requestHash, raced);
|
|
98
|
+
}
|
|
99
|
+
async markGrantIssued(clientRequestId, fields) {
|
|
100
|
+
const persisted = await this.#readRecord(clientRequestId);
|
|
101
|
+
if (persisted === null) {
|
|
102
|
+
throw bridgeError("JOURNAL_RECORD_NOT_FOUND", "The local grant journal record is missing.", true);
|
|
103
|
+
}
|
|
104
|
+
if (persisted.state === "GRANT_ISSUED") {
|
|
105
|
+
return this.#requireMatchingGrant(clientRequestId, persisted, fields);
|
|
106
|
+
}
|
|
107
|
+
const operationToken = await this.#encryptToken(clientRequestId, fields.operationToken);
|
|
108
|
+
const issued = {
|
|
109
|
+
...persisted,
|
|
110
|
+
operationId: fields.operationId,
|
|
111
|
+
operationToken,
|
|
112
|
+
uploadUrl: fields.uploadUrl,
|
|
113
|
+
state: "GRANT_ISSUED",
|
|
114
|
+
expiresAt: fields.expiresAt,
|
|
115
|
+
};
|
|
116
|
+
if (await this.#createRecord(issued, this.#issuedRecordPath(clientRequestId))) {
|
|
117
|
+
return this.#publicRecord(issued, fields.operationToken);
|
|
118
|
+
}
|
|
119
|
+
const raced = await this.#readRecord(clientRequestId);
|
|
120
|
+
if (raced === null || raced.state !== "GRANT_ISSUED") {
|
|
121
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local grant journal could not be saved.", true);
|
|
122
|
+
}
|
|
123
|
+
return this.#requireMatchingGrant(clientRequestId, raced, fields);
|
|
124
|
+
}
|
|
125
|
+
async load(clientRequestId) {
|
|
126
|
+
const persisted = await this.#readRecord(clientRequestId);
|
|
127
|
+
if (persisted === null) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return this.#recordToPublic(clientRequestId, persisted);
|
|
131
|
+
}
|
|
132
|
+
async #recordToPublic(clientRequestId, persisted) {
|
|
133
|
+
const operationToken = persisted.operationToken === null
|
|
134
|
+
? null
|
|
135
|
+
: await this.#decryptToken(clientRequestId, persisted.operationToken);
|
|
136
|
+
return this.#publicRecord(persisted, operationToken);
|
|
137
|
+
}
|
|
138
|
+
async #requireMatchingRequest(clientRequestId, requestHash, persisted) {
|
|
139
|
+
if (persisted.requestHash !== requestHash) {
|
|
140
|
+
throw bridgeError("IDEMPOTENCY_COLLISION", "This local grant request identifier is already bound to different metadata.", false);
|
|
141
|
+
}
|
|
142
|
+
return this.#recordToPublic(clientRequestId, persisted);
|
|
143
|
+
}
|
|
144
|
+
async #requireMatchingGrant(clientRequestId, persisted, fields) {
|
|
145
|
+
const existing = await this.#recordToPublic(clientRequestId, persisted);
|
|
146
|
+
if (existing.operationId !== fields.operationId ||
|
|
147
|
+
existing.operationToken !== fields.operationToken ||
|
|
148
|
+
existing.uploadUrl !== fields.uploadUrl ||
|
|
149
|
+
existing.expiresAt !== fields.expiresAt) {
|
|
150
|
+
throw bridgeError("JOURNAL_GRANT_CONFLICT", "Cube returned conflicting data for the same grant request.", false);
|
|
151
|
+
}
|
|
152
|
+
return existing;
|
|
153
|
+
}
|
|
154
|
+
async #ensureRoot() {
|
|
155
|
+
await mkdir(this.#rootDirectory, { recursive: true, mode: 0o700 });
|
|
156
|
+
if (process.platform !== "win32") {
|
|
157
|
+
await chmod(this.#rootDirectory, 0o700);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
#pendingRecordPath(clientRequestId) {
|
|
161
|
+
return path.join(this.#rootDirectory, recordFileName(clientRequestId));
|
|
162
|
+
}
|
|
163
|
+
#issuedRecordPath(clientRequestId) {
|
|
164
|
+
return this.#pendingRecordPath(clientRequestId).replace(/\.json$/u, ".issued.json");
|
|
165
|
+
}
|
|
166
|
+
async #readRecord(clientRequestId) {
|
|
167
|
+
const issued = await this.#readRecordFile(clientRequestId, this.#issuedRecordPath(clientRequestId));
|
|
168
|
+
if (issued !== null) {
|
|
169
|
+
return issued;
|
|
170
|
+
}
|
|
171
|
+
return this.#readRecordFile(clientRequestId, this.#pendingRecordPath(clientRequestId));
|
|
172
|
+
}
|
|
173
|
+
async #readRecordFile(clientRequestId, recordPath) {
|
|
174
|
+
let serialized;
|
|
175
|
+
try {
|
|
176
|
+
serialized = await readFile(recordPath, "utf8");
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error.code === "ENOENT") {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
throw bridgeError("JOURNAL_READ_FAILED", "The local grant journal could not be read.", true);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
const persisted = parsePersistedRecord(JSON.parse(serialized));
|
|
186
|
+
if (persisted.clientRequestId !== clientRequestId) {
|
|
187
|
+
throw new Error("record identity mismatch");
|
|
188
|
+
}
|
|
189
|
+
return persisted;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
throw bridgeError("JOURNAL_CORRUPT", "The local grant journal is invalid.", false);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async #createRecord(record, recordPath) {
|
|
196
|
+
await this.#ensureRoot();
|
|
197
|
+
const temporaryPath = path.join(this.#rootDirectory, `.${path.basename(recordPath)}.${randomUUID()}.tmp`);
|
|
198
|
+
let handle;
|
|
199
|
+
try {
|
|
200
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
201
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
202
|
+
await handle.sync();
|
|
203
|
+
await handle.close();
|
|
204
|
+
handle = undefined;
|
|
205
|
+
await link(temporaryPath, recordPath);
|
|
206
|
+
if (process.platform !== "win32") {
|
|
207
|
+
await chmod(recordPath, 0o600);
|
|
208
|
+
}
|
|
209
|
+
await this.#syncDirectory();
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
await closeQuietly(handle);
|
|
214
|
+
if (error.code === "EEXIST") {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
throw bridgeError("JOURNAL_WRITE_FAILED", "The local grant journal could not be saved.", true);
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async #journalKey() {
|
|
224
|
+
this.#keyPromise ??= this.#loadOrCreateKey();
|
|
225
|
+
return this.#keyPromise;
|
|
226
|
+
}
|
|
227
|
+
async #existingJournalKey() {
|
|
228
|
+
if (this.#keyPromise !== undefined) {
|
|
229
|
+
return this.#keyPromise;
|
|
230
|
+
}
|
|
231
|
+
const keyPath = path.join(this.#rootDirectory, "journal.key");
|
|
232
|
+
try {
|
|
233
|
+
const existing = await readFile(keyPath);
|
|
234
|
+
if (existing.length !== 32) {
|
|
235
|
+
throw new Error("invalid key length");
|
|
236
|
+
}
|
|
237
|
+
this.#keyPromise = Promise.resolve(existing);
|
|
238
|
+
return existing;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
throw bridgeError("JOURNAL_KEY_INVALID", "The local grant journal key is missing or invalid.", false);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async #loadOrCreateKey() {
|
|
245
|
+
await this.#ensureRoot();
|
|
246
|
+
const keyPath = path.join(this.#rootDirectory, "journal.key");
|
|
247
|
+
try {
|
|
248
|
+
const existing = await readFile(keyPath);
|
|
249
|
+
if (existing.length !== 32) {
|
|
250
|
+
throw new Error("invalid key length");
|
|
251
|
+
}
|
|
252
|
+
return existing;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error.code !== "ENOENT") {
|
|
256
|
+
throw bridgeError("JOURNAL_KEY_INVALID", "The local grant journal key is invalid.", false);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const key = randomBytes(32);
|
|
260
|
+
const temporaryPath = path.join(this.#rootDirectory, `.journal.key.${randomUUID()}.tmp`);
|
|
261
|
+
let handle;
|
|
262
|
+
try {
|
|
263
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
264
|
+
await handle.writeFile(key);
|
|
265
|
+
await handle.sync();
|
|
266
|
+
await handle.close();
|
|
267
|
+
handle = undefined;
|
|
268
|
+
await link(temporaryPath, keyPath);
|
|
269
|
+
if (process.platform !== "win32") {
|
|
270
|
+
await chmod(keyPath, 0o600);
|
|
271
|
+
}
|
|
272
|
+
await this.#syncDirectory();
|
|
273
|
+
return key;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
await closeQuietly(handle);
|
|
277
|
+
if (error.code === "EEXIST") {
|
|
278
|
+
const existing = await readFile(keyPath);
|
|
279
|
+
if (existing.length === 32) {
|
|
280
|
+
return existing;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
throw bridgeError("JOURNAL_KEY_WRITE_FAILED", "The local grant journal key could not be saved.", true);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async #encryptToken(clientRequestId, operationToken) {
|
|
290
|
+
const key = await this.#journalKey();
|
|
291
|
+
const nonce = randomBytes(12);
|
|
292
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
293
|
+
cipher.setAAD(Buffer.from(clientRequestId, "utf8"));
|
|
294
|
+
const ciphertext = Buffer.concat([
|
|
295
|
+
cipher.update(operationToken, "utf8"),
|
|
296
|
+
cipher.final(),
|
|
297
|
+
]);
|
|
298
|
+
return {
|
|
299
|
+
algorithm: "aes-256-gcm",
|
|
300
|
+
nonce: nonce.toString("base64url"),
|
|
301
|
+
ciphertext: ciphertext.toString("base64url"),
|
|
302
|
+
tag: cipher.getAuthTag().toString("base64url"),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async #decryptToken(clientRequestId, encrypted) {
|
|
306
|
+
try {
|
|
307
|
+
const key = await this.#existingJournalKey();
|
|
308
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(encrypted.nonce, "base64url"));
|
|
309
|
+
decipher.setAAD(Buffer.from(clientRequestId, "utf8"));
|
|
310
|
+
decipher.setAuthTag(Buffer.from(encrypted.tag, "base64url"));
|
|
311
|
+
return Buffer.concat([
|
|
312
|
+
decipher.update(Buffer.from(encrypted.ciphertext, "base64url")),
|
|
313
|
+
decipher.final(),
|
|
314
|
+
]).toString("utf8");
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error instanceof OmniBridgeError) {
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
throw bridgeError("JOURNAL_TOKEN_DECRYPT_FAILED", "The local operation token could not be recovered.", false);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
#publicRecord(persisted, operationToken) {
|
|
324
|
+
return {
|
|
325
|
+
clientRequestId: persisted.clientRequestId,
|
|
326
|
+
requestHash: persisted.requestHash,
|
|
327
|
+
operationId: persisted.operationId,
|
|
328
|
+
operationToken,
|
|
329
|
+
uploadUrl: persisted.uploadUrl,
|
|
330
|
+
state: persisted.state,
|
|
331
|
+
createdAt: persisted.createdAt,
|
|
332
|
+
expiresAt: persisted.expiresAt,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
async #syncDirectory() {
|
|
336
|
+
if (process.platform === "win32") {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
let directory;
|
|
340
|
+
try {
|
|
341
|
+
directory = await open(this.#rootDirectory, "r");
|
|
342
|
+
await directory.sync();
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// File and key fsync still provide the primary durability guarantee.
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
await closeQuietly(directory);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|