mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
## Summary Add OOMOL cloud OAuth as a provider authorization source. Users can save an encrypted project API key, select a cloud provider configuration, authorize named connections, and run their actions and proxy requests through SaaS. Existing connections retain their source when the provider default changes, and third-party credentials remain in SaaS. - Implement project discovery, OAuth request synchronization, remote execution, and persistent cleanup of disconnected cloud accounts. Add SQLite, PostgreSQL, and D1 migrations and storage support, with instance reset and maintenance tooling. - Add Console project and authorization settings, localized errors, disconnect notifications, and styled OAuth completion pages that close after successful authorization. Align local development callback origins and proxy routes. - Improve safe request diagnostics and retry transient project health-check failures once. Execution requests are not automatically replayed. - Group storage implementations by database backend, share guarded HTTP requests with Marketplace, update Zod to 4.6.5, and load provider icons from the public catalog. - Document setup, maintenance, and the manual acceptance checklist. ## Validation - `npm run fix-check` passed. - `npm run build:web` passed. - Latest focused regression run: 346 tests passed across 33 files covering Console, SaaS project/client/routes, OAuth completion, and guarded HTTP requests. - Earlier implementation validation included the broader runtime/storage suites and a real PostgreSQL integration run. Full deployed action/proxy, cleanup recovery, and D1 maintenance acceptance remain documented manual checks; they are not claimed as completed by the unit tests. ## Deployment notes Apply migrations `0014_saas_project` and `0015_saas_cleanup_runtime`, configure encryption and an explicit `OOMOL_CONNECT_ORIGIN`, and enable the relevant cleanup scheduling. The remote SaaS deployment must support the project OAuth and remote execution APIs described in `docs/saas-oauth.md`.
221 lines
9.3 KiB
TypeScript
221 lines
9.3 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseArgs } from "node:util";
|
|
import { unstable_readConfig } from "wrangler";
|
|
import { z } from "zod";
|
|
|
|
const snapshotSchema = z.object({
|
|
databaseId: z.uuid(),
|
|
instanceId: z.uuid(),
|
|
connections: z.number().int().nonnegative(),
|
|
requests: z.number().int().nonnegative(),
|
|
sources: z.number().int().nonnegative(),
|
|
projects: z.number().int().nonnegative(),
|
|
cleanup: z.number().int().nonnegative(),
|
|
});
|
|
const operationSchema = z.strictObject({
|
|
accountId: z.string().regex(/^[a-f0-9]{32}$/i),
|
|
databaseId: z.uuid(),
|
|
databaseName: z.string().min(1),
|
|
newInstanceId: z.uuid(),
|
|
token: z.string().regex(/^[a-f0-9]{64}$/),
|
|
snapshot: snapshotSchema.optional(),
|
|
completed: z.boolean(),
|
|
});
|
|
|
|
const { positionals, values } = parseArgs({
|
|
allowPositionals: true,
|
|
options: {
|
|
config: { type: "string" },
|
|
env: { type: "string" },
|
|
remote: { type: "boolean" },
|
|
yes: { type: "boolean" },
|
|
"operation-file": { type: "string" },
|
|
},
|
|
});
|
|
if (positionals.length !== 1 || positionals[0] !== "reset-instance" || !values.config || !values.remote)
|
|
throw new Error(
|
|
"Usage: node scripts/d1-runtime-data.ts reset-instance --config <wrangler.jsonc> [--env name] --remote [--yes] [--operation-file path]. Stop all writers first; --yes confirms they are stopped. Omit --yes to inspect only.",
|
|
);
|
|
const config = z
|
|
.object({
|
|
account_id: z.string().optional(),
|
|
compatibility_date: z.string().optional(),
|
|
d1_databases: z.array(
|
|
z.object({ binding: z.string(), database_id: z.string().optional(), database_name: z.string().optional() }),
|
|
),
|
|
})
|
|
.parse(unstable_readConfig({ config: resolve(values.config), env: values.env }));
|
|
const bindings = config.d1_databases.filter((binding) => binding.binding === "DB");
|
|
if (bindings.length !== 1) throw new Error("The selected configuration must contain exactly one DB binding.");
|
|
const binding = bindings[0];
|
|
const databaseId = z.uuid().parse(binding.database_id);
|
|
const databaseName = z.string().min(1).parse(binding.database_name);
|
|
const accountId = z
|
|
.string()
|
|
.regex(/^[a-f0-9]{32}$/i)
|
|
.parse(config.account_id || process.env.CLOUDFLARE_ACCOUNT_ID);
|
|
const operationFile = resolve(values["operation-file"] ?? `.tmp/d1-reset-${databaseId}.json`);
|
|
await mkdir(dirname(operationFile), { recursive: true, mode: 0o700 });
|
|
let operation: z.infer<typeof operationSchema>;
|
|
try {
|
|
operation = operationSchema.parse(JSON.parse(await readFile(operationFile, "utf8")));
|
|
} catch (error) {
|
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
operation = {
|
|
accountId,
|
|
databaseId,
|
|
databaseName,
|
|
newInstanceId: randomUUID(),
|
|
token: randomBytes(32).toString("hex"),
|
|
completed: false,
|
|
};
|
|
await writeFile(operationFile, `${JSON.stringify(operation, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
}
|
|
if (operation.accountId !== accountId || operation.databaseId !== databaseId || operation.databaseName !== databaseName)
|
|
throw new Error(
|
|
"The saved operation targets another account/database. Select the original configuration or a new operation file.",
|
|
);
|
|
const workerName = `oc-instance-reset-${operation.newInstanceId.replaceAll("-", "")}`;
|
|
const workDir = resolve(".tmp", workerName);
|
|
await mkdir(workDir, { recursive: true, mode: 0o700 });
|
|
const workerConfig = join(workDir, "wrangler.json");
|
|
await writeFile(
|
|
workerConfig,
|
|
JSON.stringify({
|
|
name: workerName,
|
|
account_id: accountId,
|
|
main: fileURLToPath(new URL("../src/server/cloudflare/instance-maintenance-worker.ts", import.meta.url)),
|
|
compatibility_date: config.compatibility_date || "2026-07-02",
|
|
compatibility_flags: ["nodejs_compat"],
|
|
workers_dev: true,
|
|
vars: { TARGET_DATABASE_ID: databaseId },
|
|
d1_databases: [{ binding: "DB", database_id: databaseId, database_name: databaseName }],
|
|
}),
|
|
{ mode: 0o600 },
|
|
);
|
|
|
|
let deployed = false;
|
|
try {
|
|
const info = z
|
|
.object({ uuid: z.uuid(), name: z.string() })
|
|
.parse(JSON.parse(await wrangler(["d1", "info", "DB", "--json"])));
|
|
if (info.uuid !== databaseId || info.name !== databaseName)
|
|
throw new Error("Cloudflare database identity does not match the selected DB binding.");
|
|
console.log(`Target: account ${accountId}, DB ${databaseName} (${databaseId}). Operation: ${operationFile}`);
|
|
deployed = true;
|
|
const output = await wrangler(["deploy"]);
|
|
const workerUrl = output.match(new RegExp(`https://${workerName}\\.[a-zA-Z0-9-]+\\.workers\\.dev`))?.[0];
|
|
if (!workerUrl)
|
|
throw new Error("Could not determine the temporary Worker URL. The saved operation is retained for retry.");
|
|
await wrangler(["secret", "put", "MAINTENANCE_TOKEN"], operation.token);
|
|
const snapshot = snapshotSchema.parse(await callWorker(workerUrl, `/inspect?databaseId=${databaseId}`));
|
|
if (snapshot.databaseId !== databaseId) throw new Error("Maintenance Worker reported the wrong database.");
|
|
console.log(`Current identity and affected records: ${JSON.stringify(snapshot)}`);
|
|
if (!operation.snapshot) {
|
|
operation.snapshot = snapshot;
|
|
await saveOperation();
|
|
}
|
|
if (snapshot.instanceId !== operation.snapshot.instanceId && snapshot.instanceId !== operation.newInstanceId)
|
|
throw new Error("Instance identity conflicts with the saved operation. No reset was sent.");
|
|
if (!values.yes) {
|
|
console.log(
|
|
"Inspection only. Stop all HTTP writers, cron triggers and queue consumers, then rerun with --yes and the same operation file.",
|
|
);
|
|
} else {
|
|
console.log(
|
|
`Resetting ${operation.snapshot.instanceId} to ${operation.newInstanceId}; local connections and Marketplace are retained. No SaaS calls will be made.`,
|
|
);
|
|
const result = z
|
|
.object({ databaseId: z.uuid(), instanceId: z.uuid(), result: z.enum(["reset", "already_reset"]) })
|
|
.parse(
|
|
await callWorker(workerUrl, "/reset-instance", {
|
|
databaseId,
|
|
expectedInstanceId: operation.snapshot.instanceId,
|
|
newInstanceId: operation.newInstanceId,
|
|
}),
|
|
);
|
|
if (result.databaseId !== databaseId || result.instanceId !== operation.newInstanceId)
|
|
throw new Error("Reset result identity does not match. Retry the same saved operation.");
|
|
const verified = snapshotSchema.parse(await callWorker(workerUrl, `/inspect?databaseId=${databaseId}`));
|
|
if (
|
|
verified.databaseId !== databaseId ||
|
|
verified.instanceId !== operation.newInstanceId ||
|
|
verified.connections ||
|
|
verified.requests ||
|
|
verified.sources ||
|
|
verified.projects ||
|
|
verified.cleanup
|
|
)
|
|
throw new Error(
|
|
"Post-reset verification failed. Keep writers stopped and inspect the database; do not start a new reset operation.",
|
|
);
|
|
operation.completed = true;
|
|
await saveOperation();
|
|
console.log(`Verified ${result.result}: ${JSON.stringify(verified)}. Preserve the existing encryption key.`);
|
|
}
|
|
} finally {
|
|
if (deployed) {
|
|
try {
|
|
await wrangler(["delete", "--force"]);
|
|
console.log(`Removed temporary Worker ${workerName} and its maintenance secret.`);
|
|
await rm(workDir, { recursive: true, force: true });
|
|
} catch {
|
|
console.error(
|
|
`Temporary Worker cleanup failed. Keep the operation file and remove ${workerName} in account ${accountId} before restoring service. Config: ${workerConfig}`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
} else await rm(workDir, { recursive: true, force: true });
|
|
}
|
|
|
|
async function saveOperation(): Promise<void> {
|
|
const temporary = `${operationFile}.tmp`;
|
|
await writeFile(temporary, `${JSON.stringify(operation, null, 2)}\n`, { mode: 0o600 });
|
|
await rename(temporary, operationFile);
|
|
}
|
|
|
|
async function callWorker(origin: string, path: string, body?: unknown): Promise<unknown> {
|
|
const response = await fetch(`${origin}${path}`, {
|
|
method: body ? "POST" : "GET",
|
|
redirect: "error",
|
|
headers: { authorization: `Bearer ${operation.token}`, "content-type": "application/json" },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(`Maintenance request failed with HTTP ${response.status}; retry using the same operation file.`);
|
|
return response.json();
|
|
}
|
|
|
|
async function wrangler(args: string[], input?: string): Promise<string> {
|
|
const cli = fileURLToPath(new URL("../node_modules/wrangler/bin/wrangler.js", import.meta.url));
|
|
return new Promise((resolvePromise, reject) => {
|
|
const child = spawn(process.execPath, [cli, ...args, "--config", workerConfig], {
|
|
env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: "false" },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let output = "";
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
});
|
|
child.on("error", reject);
|
|
child.on("close", (code) =>
|
|
code === 0
|
|
? resolvePromise(output)
|
|
: reject(
|
|
new Error(
|
|
`Wrangler ${args[0]} failed with exit ${code}; inspect Cloudflare deployment permissions and the target configuration.`,
|
|
),
|
|
),
|
|
);
|
|
child.stdin.end(input);
|
|
});
|
|
}
|