diff --git a/apps/host-cloudflare/DELEGATION.md b/apps/host-cloudflare/DELEGATION.md index f2b9ba4e0b..bc90496fdf 100644 --- a/apps/host-cloudflare/DELEGATION.md +++ b/apps/host-cloudflare/DELEGATION.md @@ -118,3 +118,20 @@ reaches through Access. With `X-Executor-Subject` (and `X-Executor-Subject-Email it acts for that person, as a trusted delegator would. The trusted mode lives on a config only the entrypoint builds (`loadConfig(env, { internal: true })`), so no header or variable can switch the public `fetch` into it. + +## The link-preview door + +For link previews in internal services there is a second, much narrower +entrypoint, `ExecutorUnfurl`: + +```jsonc +"services": [ + { "binding": "EXECUTOR_UNFURL", "service": "executor-cloudflare", "entrypoint": "ExecutorUnfurl" } +] +``` + +It is RPC only: `artifactSummary(id)` returns `{ title, description, updatedAt }` +for an artifact in this deployment's tenant, whoever owns it, or `null` for an +unknown or malformed id. It takes no identity, writes nothing and never returns +source, bindings, previews, owner or subject. Like the door above, the binding +is the trust boundary and it has no public route. diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 828492e4cb..68f92275c7 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -6,7 +6,6 @@ import { ExecutorApp, makeScopedExecutor, textFailureStrategy, - type ExecutorDbHandle, } from "@executor-js/api/server"; import { internalPrincipal } from "./auth/cloudflare-access"; @@ -14,7 +13,7 @@ import { reconcileInternalIntegrations } from "./internal-integrations/reconcile import { makeInternalListTools } from "./internal-integrations/list-tools"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "./config"; import { makeCloudflarePlugins, type CloudflarePlugins } from "./plugins"; -import { createD1ExecutorDb } from "./db/d1"; +import { openDb } from "./db/handle"; import { cloudflareAccessIdentityLayer } from "./auth/cloudflare-access"; import { CloudflareCodeExecutorProvider, @@ -42,21 +41,6 @@ import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; // so the providers close over it instead of reading process.env. // =========================================================================== -// One D1 handle per isolate, shared by the request path and the cron. A failed -// open is dropped so the next caller retries. -let dbHandle: Promise | null = null; - -const openDb = (env: CloudflareEnv): Promise => { - if (dbHandle === null) { - const opening = createD1ExecutorDb(env.DB, env.BLOBS); - dbHandle = opening; - void opening.then(undefined, () => { - if (dbHandle === opening) dbHandle = null; - }); - } - return dbHandle; -}; - // One reconcile pass, run from the cron trigger (never on the request path), by a // system executor that may write org rows. Also refreshes existing declared // catalogs whose tool list changed. Never throws. diff --git a/apps/host-cloudflare/src/db/handle.ts b/apps/host-cloudflare/src/db/handle.ts new file mode 100644 index 0000000000..27e8ceb145 --- /dev/null +++ b/apps/host-cloudflare/src/db/handle.ts @@ -0,0 +1,19 @@ +import type { ExecutorDbHandle } from "@executor-js/api/server"; + +import type { CloudflareEnv } from "../config"; +import { createD1ExecutorDb } from "./d1"; + +// One D1 handle per isolate, shared by the request path, the cron and the +// unfurl door. A failed open is dropped so the next caller retries. +let dbHandle: Promise | null = null; + +export const openDb = (env: CloudflareEnv): Promise => { + if (dbHandle === null) { + const opening = createD1ExecutorDb(env.DB, env.BLOBS); + dbHandle = opening; + void opening.then(undefined, () => { + if (dbHandle === opening) dbHandle = null; + }); + } + return dbHandle; +}; diff --git a/apps/host-cloudflare/src/unfurl.test.ts b/apps/host-cloudflare/src/unfurl.test.ts new file mode 100644 index 0000000000..47d5f640c4 --- /dev/null +++ b/apps/host-cloudflare/src/unfurl.test.ts @@ -0,0 +1,133 @@ +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; +import type { D1Database } from "@cloudflare/workers-types"; +import { withQueryContext } from "@executor-js/fumadb/query"; + +import { collectTables, type SqliteDataMigrationClient } from "@executor-js/sdk"; +import { createSqliteTestFumaDb, type SqliteTestFumaDb } from "@executor-js/sdk/testing"; + +import type { CloudflareEnv } from "./config"; +import { artifactSummaryFor } from "./unfurl"; + +const TENANT = "acme"; + +const makeCountingD1 = ( + client: SqliteDataMigrationClient, +): { readonly db: D1Database; readonly calls: () => number } => { + let calls = 0; + const prepare = (sql: string) => { + const statement = (args: readonly unknown[]): Record => ({ + bind: (...values: readonly unknown[]) => statement([...args, ...values]), + all: async () => { + calls += 1; + const result = await client.execute({ sql, args }); + return { success: true, meta: {}, results: result.rows }; + }, + raw: async () => { + calls += 1; + const result = await client.execute({ sql, args }); + return result.rows.map((row) => Object.values(row)); + }, + run: async () => { + calls += 1; + await client.execute({ sql, args }); + return { success: true, meta: {}, results: [] }; + }, + }); + return statement([]); + }; + // oxlint-disable-next-line executor/no-double-cast -- test double: only the D1 methods used by schema preparation and reads are implemented + const db = { prepare, withSession: () => ({ prepare }) } as unknown as D1Database; + return { db, calls: () => calls }; +}; + +const insertArtifact = ( + sqlite: SqliteTestFumaDb, + row: { + readonly tenant: string; + readonly owner: "org" | "user"; + readonly subject: string; + readonly id: string; + readonly title: string; + readonly description: string | null; + }, +) => + withQueryContext(sqlite.db, { tenant: row.tenant, subject: row.subject }).create("artifact", { + ...row, + code: "export default function App() { return
SECRET-source
; }", + bindings: null, + preview: "
SECRET-preview
", + preview_capture: null, + compiled_fingerprint: null, + compiled_js: null, + compiled_css: null, + created_by: null, + created_at: new Date(1_700_000_000_000), + updated_at: new Date(1_700_000_060_000), + }); + +describe("artifactSummaryFor", () => { + let sqlite: SqliteTestFumaDb; + let d1Calls: () => number; + let env: CloudflareEnv; + + beforeAll(async () => { + sqlite = await createSqliteTestFumaDb({ tables: collectTables() }); + const counting = makeCountingD1(sqlite.client); + d1Calls = counting.calls; + // oxlint-disable-next-line executor/no-double-cast -- test double: only the vars and the D1 binding the code under test reads are set + env = { + DB: counting.db, + ENABLE_DEV_AUTH: "true", + EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + SELF_HOSTED_ORG_ID: TENANT, + } as unknown as CloudflareEnv; + // Open once so the schema exists, then seed straight through storage. + await artifactSummaryFor(env, "art_warmup"); + await insertArtifact(sqlite, { + tenant: TENANT, + owner: "user", + subject: "someone", + id: "art_personal", + title: "My scratchpad", + description: "Only for me", + }); + await insertArtifact(sqlite, { + tenant: "other", + owner: "org", + subject: "", + id: "art_foreign", + title: "Other tenant", + description: null, + }); + }); + + afterAll(async () => { + await sqlite.close(); + }); + + it("summarises a personal artifact of someone else, and nothing more than its summary", async () => { + const summary = await artifactSummaryFor(env, "art_personal"); + + expect(summary).toEqual({ + title: "My scratchpad", + description: "Only for me", + updatedAt: 1_700_000_060_000, + }); + expect(JSON.stringify(summary)).not.toContain("SECRET"); + }); + + it("answers null for an unknown id and for another tenant's artifact", async () => { + expect(await artifactSummaryFor(env, "art_unknown")).toBe(null); + expect(await artifactSummaryFor(env, "art_foreign")).toBe(null); + }); + + it("answers null for a malformed id without touching storage", async () => { + const before = d1Calls(); + + for (const id of ["../x", "ART_personal", "art_", `art_${"a".repeat(61)}`, "", "art_a b"]) { + expect(await artifactSummaryFor(env, id)).toBe(null); + } + + expect(d1Calls()).toBe(before); + }); +}); diff --git a/apps/host-cloudflare/src/unfurl.ts b/apps/host-cloudflare/src/unfurl.ts new file mode 100644 index 0000000000..5af0977cbe --- /dev/null +++ b/apps/host-cloudflare/src/unfurl.ts @@ -0,0 +1,55 @@ +import { Effect, Layer } from "effect"; + +import { dbProviderLayer, makePlatformExecutor } from "@executor-js/api/server"; +import type { AdminArtifactSummary, Executor } from "@executor-js/sdk"; + +import { loadConfig, type CloudflareEnv } from "./config"; +import { openDb } from "./db/handle"; +import { makeCloudflareHostConfig, makeCloudflarePluginsProvider } from "./execution"; + +// --------------------------------------------------------------------------- +// The read behind `ExecutorUnfurl`: link previews for internal services. One +// subject-less, read-only platform executor per isolate, bound to the same D1 +// database and tenant (`organizationId`) as the public and internal doors. It +// sees every artifact in the tenant whoever owns it, and selects only title, +// description and last edit. +// --------------------------------------------------------------------------- + +const ARTIFACT_ID = /^art_[a-z0-9]{1,60}$/; + +let executor: Promise | null = null; + +// A failed build is dropped so the next caller retries. +const platformExecutor = (env: CloudflareEnv): Promise => { + if (executor === null) { + const building = openDb(env).then((handle) => { + const config = loadConfig(env); + return Effect.runPromise( + makePlatformExecutor(config.organizationId).pipe( + Effect.provide( + Layer.mergeAll( + dbProviderLayer(Effect.succeed(handle)), + makeCloudflarePluginsProvider(config), + makeCloudflareHostConfig(config), + ), + ), + ), + ); + }); + executor = building; + void building.then(undefined, () => { + if (executor === building) executor = null; + }); + } + return executor; +}; + +export const artifactSummaryFor = async ( + env: CloudflareEnv, + id: string, +): Promise => { + if (!ARTIFACT_ID.test(id)) return null; + const admin = (await platformExecutor(env)).admin; + if (admin === undefined) return null; + return Effect.runPromise(admin.artifactSummary(id)); +}; diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index 9a59c8b5a2..823b8bc2e1 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -1,6 +1,7 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import { setQuickJSModuleLoader } from "@executor-js/runtime-quickjs"; +import type { AdminArtifactSummary } from "@executor-js/sdk"; import { makeCloudflareApp, reconcileInternalIntegrationsOnce } from "./app"; import { @@ -10,6 +11,7 @@ import { type CloudflareEnv, } from "./config"; import { mcpResourceFromPath } from "./mcp/resource"; +import { artifactSummaryFor } from "./unfurl"; // Workers cannot fetch the default QuickJS .wasm, so every QuickJS user in this // script (the fetch handlers and the MCP Durable Objects) resolves the @@ -34,6 +36,8 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; // reaches it through a `services` binding with `entrypoint: "ExecutorInternal"`. // It builds its OWN app from a config marked `trustedInternal`; nothing in // `env` or in a request can switch the public door into that mode. +// - `ExecutorUnfurl` is a narrow read-only RPC door, reached the same way with +// `entrypoint: "ExecutorUnfurl"`: link previews for internal services. // --------------------------------------------------------------------------- interface Serve { @@ -99,6 +103,19 @@ export class ExecutorInternal extends WorkerEntrypoint { } } +/** + * The link-preview door. Reachable only through a `services` binding with + * `entrypoint: "ExecutorUnfurl"`; it has no route and takes no identity. It + * returns the title, description and last edit of an artifact in this + * deployment's tenant, whoever owns it, and never the source, bindings, + * previews, owner or subject. Read-only. + */ +export class ExecutorUnfurl extends WorkerEntrypoint { + artifactSummary(id: string): Promise { + return artifactSummaryFor(this.env, id); + } +} + export default { fetch: (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => serveRequest(request, env, ctx, resolvePublic), diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index b2c73c8e7f..1834eec218 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -1220,6 +1220,7 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ calls.push("getSubjectWithConnections"); return Effect.succeed({ ...A_SUBJECT, connections: [] }); }, + artifactSummary: () => Effect.succeed(null), }); describe("admin users reads — the ?email= filter is applied before the read", () => { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index c8ff8ccf87..e8b5b762b7 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -678,6 +678,15 @@ const normalizeAdminPaging = ( return { limit, offset }; }; +/** The title, description and last edit of one artifact: a link preview, and + * nothing that could render or run it. */ +export interface AdminArtifactSummary { + readonly title: string; + readonly description: string | null; + /** Epoch ms of the last edit. */ + readonly updatedAt: number; +} + export interface ExecutorAdmin { /** One page of subjects under the tenant, oldest first (stable: ties break on * `external_id`). ALWAYS bounded: no arguments means @@ -719,6 +728,16 @@ export interface ExecutorAdmin { readonly getSubjectWithConnections: ( externalId: string, ) => Effect.Effect; + /** + * The preview fields of one artifact by id, whichever tier or subject owns + * it, or `null` when the tenant has none. Selects only title, description and + * `updated_at`: no source, bindings, compiled output, preview, owner or + * subject ever leaves storage. Ids are random, so a shared id is only + * theoretical; the most recently edited row wins. + */ + readonly artifactSummary: ( + id: string, + ) => Effect.Effect; } export interface ExecutorDb { @@ -7859,12 +7878,34 @@ export const createExecutor = => + platformCore + .findFirst("artifact", { + where: (b: AnyCb) => b("id", "=", id), + orderBy: [["updated_at", "desc"]], + select: ["title", "description", "updated_at"], + }) + .pipe( + Effect.map((row) => + row === null + ? null + : { + title: row.title, + description: row.description ?? null, + updatedAt: new Date(row.updated_at).getTime(), + }, + ), + ); + return { listSubjects, getSubject, listSubjectConnections, listSubjectsWithConnections, getSubjectWithConnections, + artifactSummary, }; }; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 26dc8093b1..0c300cc8d2 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -433,6 +433,7 @@ export { export { ADMIN_DEFAULT_PAGE_SIZE, ADMIN_MAX_PAGE_SIZE, + type AdminArtifactSummary, type AdminConnection, type AdminListSubjectsOptions, type AdminSubject, diff --git a/packages/core/sdk/src/platform-view.test.ts b/packages/core/sdk/src/platform-view.test.ts index 9c5ef821f3..e3e6cc70bd 100644 --- a/packages/core/sdk/src/platform-view.test.ts +++ b/packages/core/sdk/src/platform-view.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { type InStatement } from "@libsql/client"; +import { withQueryContext } from "@executor-js/fumadb/query"; import { ADMIN_DEFAULT_PAGE_SIZE, @@ -962,3 +963,133 @@ describe("platform view — the joined read does not fan out per subject", () => ), ); }); + +describe("platform view — admin.artifactSummary", () => { + const SOURCE = "export default function App() { return
SECRET-source
; }"; + + const insertArtifact = ( + db: SqliteTestFumaDb, + row: { + readonly tenant: string; + readonly owner: "org" | "user"; + readonly subject: string; + readonly id: string; + readonly title: string; + readonly description: string | null; + readonly updatedAtMs: number; + }, + ) => + Effect.promise(() => + withQueryContext(db.db, { tenant: row.tenant, subject: row.subject }).create("artifact", { + tenant: row.tenant, + owner: row.owner, + subject: row.subject, + id: row.id, + title: row.title, + description: row.description, + code: SOURCE, + bindings: null, + preview: "
SECRET-preview
", + preview_capture: null, + compiled_fingerprint: null, + compiled_js: null, + compiled_css: null, + created_by: row.subject === "" ? null : row.subject, + created_at: new Date(row.updatedAtMs), + updated_at: new Date(row.updatedAtMs), + }), + ); + + const seedArtifacts = (db: SqliteTestFumaDb) => + Effect.gen(function* () { + yield* insertArtifact(db, { + tenant: TENANT, + owner: "org", + subject: "", + id: "art_orgone", + title: "Revenue", + description: "Monthly revenue", + updatedAtMs: 1_700_000_000_000, + }); + yield* insertArtifact(db, { + tenant: TENANT, + owner: "user", + subject: SUBJECT_B, + id: "art_personalb", + title: "B's scratchpad", + description: null, + updatedAtMs: 1_700_000_060_000, + }); + yield* insertArtifact(db, { + tenant: OTHER_TENANT, + owner: "org", + subject: "", + id: "art_elsewhere", + title: "Other tenant", + description: "Not ours", + updatedAtMs: 1_700_000_120_000, + }); + }); + + it.effect("summarises an org artifact", () => + withDb((db) => + Effect.gen(function* () { + yield* seedArtifacts(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db, { subject: null })); + + expect(yield* admin.artifactSummary("art_orgone")).toEqual({ + title: "Revenue", + description: "Monthly revenue", + updatedAt: 1_700_000_000_000, + }); + }), + ), + ); + + it.effect("summarises another member's personal artifact, and only its summary fields", () => + withDb((db) => + Effect.gen(function* () { + yield* seedArtifacts(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db, { subject: SUBJECT_A })); + + const summary = yield* admin.artifactSummary("art_personalb"); + + expect(summary).toEqual({ + title: "B's scratchpad", + description: null, + updatedAt: 1_700_000_060_000, + }); + expect(JSON.stringify(summary)).not.toContain("SECRET"); + }), + ), + ); + + it.effect("returns null for an unknown id and for another tenant's artifact", () => + withDb((db) => + Effect.gen(function* () { + yield* seedArtifacts(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db, { subject: null })); + + expect(yield* admin.artifactSummary("art_missing")).toBe(null); + expect(yield* admin.artifactSummary("art_elsewhere")).toBe(null); + }), + ), + ); + + it.effect("cannot write artifacts", () => + withDb((db) => + Effect.gen(function* () { + yield* seedArtifacts(db); + const executor = yield* makePlatformExecutor(db, { subject: SUBJECT_A }); + + yield* expectWriteRefused(executor.artifacts.save({ title: "Intruder", code: SOURCE })); + yield* expectWriteRefused( + executor.artifacts.rename({ id: "art_orgone", title: "Renamed" }), + ); + + const admin = yield* requireAdmin(executor); + expect((yield* admin.artifactSummary("art_orgone"))?.title).toBe("Revenue"); + }), + ), + ); +});