Files
l1shen b969ec0b4e feat: add cloud project OAuth and remote execution (#627)
## 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`.
2026-09-30 13:25:49 +08:00

53 lines
2.2 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, it } from "vitest";
import { AesGcmSecretCodec } from "../src/server/secrets/secret-codec.ts";
import { SqliteRuntimeDatabase } from "../src/server/storage/sqlite/runtime-store.ts";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
it("runs the offline SQLite reset-instance command while retaining local encrypted data", async () => {
const directory = await mkdtemp(join(tmpdir(), "connect-reset-instance-"));
directories.push(directory);
const codec = new AesGcmSecretCodec("old-key");
let database = new SqliteRuntimeDatabase(join(directory, "connect.sqlite"), { secretCodec: codec });
const oldId = await database.saasProjectStore.getInstanceId();
await database.saasProjectStore.saveProject({
id: "managed",
projectId: "project",
baseUrl: "https://saas.example",
apiKey: "remote-secret",
});
const local = await database.connectionStore.set("local", "default", {
authType: "api_key",
apiKey: "local-secret",
values: {},
metadata: {},
profile: { accountId: "local", displayName: "Local", grantedScopes: [] },
});
database.close();
const { stdout } = await promisify(execFile)(
process.execPath,
[resolve("scripts/runtime-data.ts"), "reset-instance", "--yes", "--data-dir", directory],
{
env: { ...process.env, OOMOL_CONNECT_DATABASE_URL: "", OOMOL_CONNECT_ENCRYPTION_KEY: "old-key" },
},
);
expect(stdout).toContain(oldId);
expect(stdout).not.toContain("remote-secret");
expect(stdout).not.toContain("local-secret");
database = new SqliteRuntimeDatabase(join(directory, "connect.sqlite"), { secretCodec: codec });
try {
expect(await database.saasProjectStore.getInstanceId()).not.toBe(oldId);
expect(await database.saasProjectStore.getProject()).toBeUndefined();
expect(await database.connectionStore.get("local", "default")).toEqual(local);
} finally {
database.close();
}
});