diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index c6b8a2c99..f53d6f662 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -66,7 +66,6 @@ import { } from "./mailbox-persist"; import { captureMailboxRequest, createMailboxDeliver } from "./mailbox-send"; import { installWebhooks, type HookMailRouter } from "@corbits/webhooks"; -import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; import { createProcessSidecarProvisioner, readProcessProvisionerConfig, @@ -93,9 +92,9 @@ const grantConditionRegistry: ConditionRegistry = { }; // The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated -// Corbits surface below takes structurally (workflow-authoring, -// `@corbits/artifacts`' `mountWorkflowArtifacts`): a sidecar bearer token + -// run address resolve to the tenant/principal/run it names. +// Corbits surface below takes structurally (`@corbits/artifacts`' +// `mountWorkflowArtifacts`): a sidecar bearer token + run address resolve to +// the tenant/principal/run it names. function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { return { async resolve(token: string, runAddress: string) { @@ -601,20 +600,6 @@ export async function createHubServer({ app.route("/", memoryApp); } - app.route( - "/api/workflow-workflow-authoring", - createWorkflowAuthorRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - registry: createWorkflowAuthorRegistry({ - db, - assetService, - repoStore: agentRepoStore.repoStore, - grantStore, - conditionRegistry: grantConditionRegistry, - }), - }), - ); - // `HookMailRouter` types its payloads as `unknown` at the package // boundary; this just narrows them back to `sidecarRouter`'s own types // on the way through, with no behavior change. diff --git a/docs/workflow-authoring-registry.md b/docs/workflow-authoring-registry.md deleted file mode 100644 index cc2402938..000000000 --- a/docs/workflow-authoring-registry.md +++ /dev/null @@ -1,33 +0,0 @@ -# Workflow authoring registry - -Notes on `@corbits/workflows`'s `authoring/registry.ts`. - -## What it is - -An agent's in-tenant surface for publishing a workflow codebase as a native -`kind:"workflow"` hub asset, republishing it, and reading it back. Every -write is gated by own-tenant scoping plus an explicit grant-store -authorization call, and by `validateWorkflowSourceTree` before anything -reaches `RepoStore`. Deploying is not this registry's job — an agent deploys -a committed asset through the stock deployments route with the run bearer. - -## Why `populateAsset` uses the `hub` principal - -`workflowKindHandler.workflowAuthorize` only recognizes three principal -kinds for a workflow-asset write: `hub` (full access), `sidecar` -(read-only), and `user` (gated by git-token-shaped bearer claims a -sidecar-authenticated caller never carries). There's no fourth -"workflow-run" principal kind the substrate understands, so the real -per-write authorization decision has to be made here, by this registry, -against the grant store and the resolved caller identity — before the -already-authorized write is handed to the substrate as a hub-mediated -commit under `hub` (the same principal `@corbits/skills`'s `writeSkillMd` -uses). - -## Why head-sha reads go through `RepoStore` directly - -`AssetService` exposes blob and directory reads pinned to a ref but never -the sha that ref resolves to, and `listAssetBlobs` lists blobs only (no -subtrees), so a full tree walk needs `RepoStore.openCommittedReads`. The -repo id is the asset id under the `workflow` kind, exactly as `AssetService` -composes it internally. diff --git a/packages/workflows/package.json b/packages/workflows/package.json index f2ea808a2..81f7bc500 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -2,7 +2,7 @@ "name": "@corbits/workflows", "version": "0.0.1", "private": true, - "description": "The workflow domain package: the two-file source codebase every authoring path renders/reads (`./source`), the definition detail read a workflow's own page uses (`./detail`), and letting an agent author/republish/deploy a workflow through Interchange's native source pipeline (`./authoring`)", + "description": "The workflow domain package: the two-file source codebase every authoring path renders/reads (`./source`), and the definition detail read a workflow's own page uses (`./detail`)", "license": "LGPL-2.1-only", "type": "module", "exports": { diff --git a/packages/workflows/src/authoring/errors.ts b/packages/workflows/src/authoring/errors.ts deleted file mode 100644 index bbeee35f1..000000000 --- a/packages/workflows/src/authoring/errors.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type WorkflowAuthorErrorReason = - | "forbidden" - | "not_found" - | "conflict" - | "invalid" - | "unavailable"; - -export class WorkflowAuthorError extends Error { - readonly reason: WorkflowAuthorErrorReason; - /** Set on a `conflict` raised by an `expectedHeadSha` mismatch: the sha - * `refs/heads/main` actually points at, so the caller can re-read and - * retry against it. */ - readonly currentHeadSha?: string; - constructor( - reason: WorkflowAuthorErrorReason, - message: string, - options: { readonly currentHeadSha?: string } = {}, - ) { - super(message); - this.name = "WorkflowAuthorError"; - this.reason = reason; - if (options.currentHeadSha !== undefined) { - this.currentHeadSha = options.currentHeadSha; - } - } -} diff --git a/packages/workflows/src/authoring/index.ts b/packages/workflows/src/authoring/index.ts deleted file mode 100644 index 2d53a3fa5..000000000 --- a/packages/workflows/src/authoring/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { WorkflowAuthorError, type WorkflowAuthorErrorReason } from "./errors"; -export { - createWorkflowAuthorRegistry, - WORKFLOW_ASSET_NAME_PATTERN, - type AuthorWorkflowInput, - type CreateWorkflowAuthorRegistryDeps, - type DeployWorkflowInput, - type RepublishWorkflowInput, - type WorkflowAssetSummary, - type WorkflowAuthorCaller, - type WorkflowAuthorRegistry, - type WorkflowDeployer, - type WorkflowDeployResult, - type WorkflowAuthorRepoReads, - type WorkflowSourceSnapshot, -} from "./registry"; -export { - MAX_SOURCE_FILE_BYTES, - MAX_SOURCE_FILE_COUNT, - MAX_SOURCE_TREE_BYTES, - validateWorkflowSourceTree, - type ValidatedWorkflowSourceTree, -} from "./source-tree"; -export { - createWorkflowAuthorRoutes, - type CreateWorkflowAuthorRoutesDeps, - type WorkflowAuthoringEnv, - type WorkflowRunAuthenticator, - type WorkflowRunScope, -} from "./workflow-routes"; diff --git a/packages/workflows/src/authoring/registry.test.ts b/packages/workflows/src/authoring/registry.test.ts deleted file mode 100644 index e49c3f463..000000000 --- a/packages/workflows/src/authoring/registry.test.ts +++ /dev/null @@ -1,542 +0,0 @@ -import { expect, test } from "bun:test"; -import type { ConditionRegistry, GrantStore, GrantRule } from "@intx/types/authz"; -import { AssetServiceError, type AssetService } from "@intx/hub-sessions"; -import type { DB } from "@intx/db"; - -import { WorkflowAuthorError } from "./errors"; -import { - createWorkflowAuthorRegistry, - type CreateWorkflowAuthorRegistryDeps, - type WorkflowAuthorRepoReads, -} from "./registry"; - -const MANIFEST = JSON.stringify({ - name: "daily-digest", - version: "0.0.1", - type: "module", - interchange: { workflow: "./workflow.ts" }, -}); - -const ENTRY = "export default {};\n"; - -function sourceTree(extra: Record = {}): Record { - return { "package.json": MANIFEST, "workflow.ts": ENTRY, ...extra }; -} - -function fakeRepoStore(overrides: Partial = {}): WorkflowAuthorRepoReads { - return { - resolveRef: async () => "sha_head", - openCommittedReads: async () => null, - openCommittedReadsAtCommit: async () => null, - ...overrides, - }; -} - -function allowGrant(action: string): GrantRule { - return { - id: `g_${action}`, - resource: "asset:*", - action, - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; -} - -function fakeGrantStore(grants: readonly GrantRule[]): GrantStore { - return { - collectGrants: async () => [...grants], - collectGrantsInChain: async () => [...grants], - }; -} - -const conditionRegistry: ConditionRegistry = {}; - -function fakeAssetService(overrides: Partial = {}): AssetService { - return { - createAsset: async (params) => ({ - id: "asset_1", - tenantId: params.tenantId, - kind: params.kind, - name: params.name, - displayName: params.displayName ?? null, - creatorPrincipalId: params.creatorPrincipalId ?? null, - createdAt: new Date(), - updatedAt: new Date(), - }), - populateAsset: async () => ({ commitSha: "sha_1" }), - readAssetBlob: async () => { - throw new Error("not implemented in fake"); - }, - listAssetBlobs: async () => [], - ...overrides, - }; -} - -type AssetRow = { - id: string; - tenantId: string; - kind: string; - name: string; -}; - -/** A `db.query.asset.findFirst` fake resolving to whatever the test wires - * up — standing in for the real drizzle `and(eq(id), eq(tenantId), - * eq(kind))` lookup registry.ts performs. A row from another tenant (or a - * nonexistent id) never resolves through that real query, so the fake - * models that outcome as `undefined` directly rather than re-implementing - * drizzle's where-clause evaluation. */ -function fakeDb(row: AssetRow | undefined): DB["db"] { - return { - query: { - asset: { - findFirst: async () => row, - }, - }, - } as unknown as DB["db"]; -} - -function deps( - overrides: Partial = {}, -): CreateWorkflowAuthorRegistryDeps { - return { - db: fakeDb(undefined), - assetService: fakeAssetService(), - repoStore: fakeRepoStore(), - grantStore: fakeGrantStore([allowGrant("create"), allowGrant("write"), allowGrant("read")]), - conditionRegistry, - ...overrides, - }; -} - -function workflowGrant(action: string): GrantRule { - return { - id: `g_workflow_${action}`, - resource: "workflow:*", - action, - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: null, - }; -} - -const caller = { tenantId: "tenant_1", principalId: "principal_1" }; - -test("author publishes a workflow codebase as a workflow-kind asset", async () => { - let created: { kind: string; tenantId: string } | undefined; - let populated: { assetId: string; files: unknown } | undefined; - const assetService = fakeAssetService({ - createAsset: async (params) => { - created = { kind: params.kind, tenantId: params.tenantId }; - return { - id: "asset_1", - tenantId: params.tenantId, - kind: params.kind, - name: params.name, - displayName: params.displayName ?? null, - creatorPrincipalId: params.creatorPrincipalId ?? null, - createdAt: new Date(), - updatedAt: new Date(), - }; - }, - populateAsset: async (params) => { - populated = { assetId: params.assetId, files: params.tree.files }; - return { commitSha: "sha_abc" }; - }, - }); - - const registry = createWorkflowAuthorRegistry(deps({ assetService })); - const summary = await registry.author(caller, { - name: "daily-digest", - files: sourceTree(), - }); - - expect(summary).toEqual({ - assetId: "asset_1", - name: "daily-digest", - commitSha: "sha_abc", - }); - expect(created).toEqual({ kind: "workflow", tenantId: "tenant_1" }); - expect(populated?.assetId).toBe("asset_1"); -}); - -test("author rejects a malformed name before ever calling the asset service", async () => { - let called = false; - const assetService = fakeAssetService({ - createAsset: async () => { - called = true; - throw new Error("must not be called"); - }, - }); - const registry = createWorkflowAuthorRegistry(deps({ assetService })); - - await expect( - registry.author(caller, { name: "Not Kebab!", files: sourceTree() }), - ).rejects.toMatchObject({ reason: "invalid" }); - expect(called).toBe(false); -}); - -test("author refuses when the principal's grants do not include asset:*/create", async () => { - let called = false; - const assetService = fakeAssetService({ - createAsset: async () => { - called = true; - throw new Error("must not be called"); - }, - }); - const registry = createWorkflowAuthorRegistry( - deps({ assetService, grantStore: fakeGrantStore([]) }), - ); - - const err = await registry - .author(caller, { name: "daily-digest", files: sourceTree() }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("forbidden"); - expect(called).toBe(false); -}); - -test("author rejects a tree with no interchange.workflow entry before any asset is created", async () => { - let created = false; - const assetService = fakeAssetService({ - createAsset: async () => { - created = true; - throw new Error("must not be called"); - }, - }); - const registry = createWorkflowAuthorRegistry(deps({ assetService })); - - const err = await registry - .author(caller, { - name: "daily-digest", - files: { "package.json": '{"name":"x","version":"0.0.1"}' }, - }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("invalid"); - expect((err as Error).message).toMatch(/interchange\.workflow/); - expect(created).toBe(false); -}); - -test("author surfaces a substrate push rejection as an invalid-source error, not a raw throw", async () => { - const assetService = fakeAssetService({ - populateAsset: async () => { - throw new AssetServiceError( - "path_violation", - "a committed top-level node_modules directory is not allowed", - ); - }, - }); - const registry = createWorkflowAuthorRegistry(deps({ assetService })); - - const err = await registry - .author(caller, { name: "daily-digest", files: sourceTree() }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("invalid"); - expect((err as Error).message).toMatch(/node_modules/); -}); - -test("republish refuses an asset id that does not resolve in the caller's own tenant", async () => { - let populateCalled = false; - const assetService = fakeAssetService({ - populateAsset: async () => { - populateCalled = true; - return { commitSha: "sha_x" }; - }, - }); - // No row resolves — the same outcome the real tenant-scoped query - // produces for another tenant's asset id, or an id that never existed. - const registry = createWorkflowAuthorRegistry(deps({ assetService, db: fakeDb(undefined) })); - - const err = await registry - .republish(caller, "asset_from_another_tenant", { files: sourceTree() }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("not_found"); - expect(populateCalled).toBe(false); -}); - -test("republish writes a new commit once the asset resolves in-tenant and the grant allows write", async () => { - const row: AssetRow = { - id: "asset_1", - tenantId: "tenant_1", - kind: "workflow", - name: "daily-digest", - }; - let populatedAssetId: string | undefined; - const assetService = fakeAssetService({ - populateAsset: async (params) => { - populatedAssetId = params.assetId; - return { commitSha: "sha_new" }; - }, - }); - const registry = createWorkflowAuthorRegistry(deps({ assetService, db: fakeDb(row) })); - - const summary = await registry.republish(caller, "asset_1", { - files: sourceTree(), - }); - expect(summary).toEqual({ - assetId: "asset_1", - name: "daily-digest", - commitSha: "sha_new", - }); - expect(populatedAssetId).toBe("asset_1"); -}); - -test("republish refuses when the grant store has no matching write grant", async () => { - const row: AssetRow = { - id: "asset_1", - tenantId: "tenant_1", - kind: "workflow", - name: "daily-digest", - }; - let populateCalled = false; - const assetService = fakeAssetService({ - populateAsset: async () => { - populateCalled = true; - return { commitSha: "sha_new" }; - }, - }); - const registry = createWorkflowAuthorRegistry( - deps({ - assetService, - db: fakeDb(row), - // Only "create" is granted, never "write" — a principal that can - // author brand-new workflows but not overwrite an existing one. - grantStore: fakeGrantStore([allowGrant("create")]), - }), - ); - - const err = await registry - .republish(caller, "asset_1", { files: sourceTree() }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("forbidden"); - expect(populateCalled).toBe(false); -}); - -const ownRow: AssetRow = { - id: "asset_1", - tenantId: "tenant_1", - kind: "workflow", - name: "daily-digest", -}; - -test("republish with a stale expectedHeadSha is refused as a conflict carrying the current head, and writes nothing", async () => { - let populateCalled = false; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - assetService: fakeAssetService({ - populateAsset: async () => { - populateCalled = true; - return { commitSha: "sha_new" }; - }, - }), - repoStore: fakeRepoStore({ resolveRef: async () => "sha_current" }), - }), - ); - - const err = await registry - .republish(caller, "asset_1", { - files: sourceTree(), - expectedHeadSha: "sha_stale", - }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("conflict"); - expect((err as WorkflowAuthorError).currentHeadSha).toBe("sha_current"); - expect(populateCalled).toBe(false); -}); - -test("republish with a matching expectedHeadSha proceeds", async () => { - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - repoStore: fakeRepoStore({ resolveRef: async () => "sha_current" }), - }), - ); - const summary = await registry.republish(caller, "asset_1", { - files: sourceTree(), - expectedHeadSha: "sha_current", - }); - expect(summary.commitSha).toBe("sha_1"); -}); - -test("republish rejects a traversal path before the grant check or any write", async () => { - let authorized = false; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: { - collectGrants: async () => { - authorized = true; - return [allowGrant("write")]; - }, - collectGrantsInChain: async () => [allowGrant("write")], - }, - }), - ); - const err = await registry - .republish(caller, "asset_1", { - files: sourceTree({ "../escape.ts": "x" }), - }) - .catch((e: unknown) => e); - expect((err as WorkflowAuthorError).reason).toBe("invalid"); - expect(authorized).toBe(false); -}); - -test("readSource walks the whole committed tree, including subdirectories, and reports the head sha", async () => { - const blobs: Record = { - oid_pkg: MANIFEST, - oid_entry: ENTRY, - oid_helper: "export const x = 1;\n", - }; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - repoStore: fakeRepoStore({ - resolveRef: async () => "sha_head", - openCommittedReadsAtCommit: async () => ({ - listDir: async (dir) => - dir === "" - ? [ - { name: "package.json", oid: "oid_pkg", type: "blob" }, - { name: "workflow.ts", oid: "oid_entry", type: "blob" }, - { name: "lib", oid: "oid_lib", type: "tree" }, - ] - : dir === "lib" - ? [{ name: "helper.ts", oid: "oid_helper", type: "blob" }] - : [], - readBlobByOid: async (oid) => new TextEncoder().encode(blobs[oid] ?? ""), - treeOid: async () => null, - }), - }), - }), - ); - - const snapshot = await registry.readSource(caller, "asset_1"); - expect(snapshot).toEqual({ - assetId: "asset_1", - name: "daily-digest", - headSha: "sha_head", - files: { - "package.json": MANIFEST, - "workflow.ts": ENTRY, - "lib/helper.ts": "export const x = 1;\n", - }, - }); -}); - -test("readSource refuses without an asset read grant", async () => { - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: fakeGrantStore([allowGrant("write")]), - }), - ); - const err = await registry.readSource(caller, "asset_1").catch((e: unknown) => e); - expect((err as WorkflowAuthorError).reason).toBe("forbidden"); -}); - -test("previewDeploy is a static read of the committed source at commitSha: file list, package name, and declared tool pins from an inert entry", async () => { - const pinnedEntry = - 'export default { toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }] };\n'; - const blobs: Record = { - oid_pkg: MANIFEST, - oid_entry: pinnedEntry, - }; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: fakeGrantStore([workflowGrant("create")]), - repoStore: fakeRepoStore({ - openCommittedReadsAtCommit: async (_p, _r, commitSha) => - commitSha === "sha_1" - ? { - listDir: async (dir) => - dir === "" - ? [ - { name: "package.json", oid: "oid_pkg", type: "blob" }, - { name: "workflow.ts", oid: "oid_entry", type: "blob" }, - ] - : [], - readBlobByOid: async (oid) => new TextEncoder().encode(blobs[oid] ?? ""), - treeOid: async () => null, - } - : null, - }), - }), - ); - - const result = await registry.previewDeploy(caller, "asset_1", { - commitSha: "sha_1", - entry: "workflow.ts", - }); - - expect(result).toEqual({ - commitSha: "sha_1", - entry: "workflow.ts", - files: ["package.json", "workflow.ts"], - toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], - packageName: "daily-digest", - }); -}); - -test("previewDeploy lists files only, with no tool pins, when the entry is not an inert object literal", async () => { - const blobs: Record = { oid_pkg: MANIFEST, oid_entry: ENTRY }; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: fakeGrantStore([workflowGrant("create")]), - repoStore: fakeRepoStore({ - openCommittedReadsAtCommit: async () => ({ - listDir: async (dir) => - dir === "" - ? [ - { name: "package.json", oid: "oid_pkg", type: "blob" }, - { name: "workflow.ts", oid: "oid_entry", type: "blob" }, - ] - : [], - readBlobByOid: async (oid) => new TextEncoder().encode(blobs[oid] ?? ""), - treeOid: async () => null, - }), - }), - }), - ); - - const result = await registry.previewDeploy(caller, "asset_1", { - commitSha: "sha_1", - entry: "workflow.ts", - }); - expect(result.toolPackagePins).toEqual([]); - expect(result.files).toEqual(["package.json", "workflow.ts"]); -}); - -test("previewDeploy is not_found when the commit does not exist", async () => { - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDb(ownRow), - grantStore: fakeGrantStore([workflowGrant("create")]), - repoStore: fakeRepoStore({ - openCommittedReadsAtCommit: async () => null, - }), - }), - ); - - const err = await registry - .previewDeploy(caller, "asset_1", { - commitSha: "sha_missing", - entry: "workflow.ts", - }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("not_found"); -}); diff --git a/packages/workflows/src/authoring/registry.ts b/packages/workflows/src/authoring/registry.ts deleted file mode 100644 index 14b98d6b2..000000000 --- a/packages/workflows/src/authoring/registry.ts +++ /dev/null @@ -1,444 +0,0 @@ -// The workflow-authoring registry: an agent's in-tenant surface for -// publishing, republishing, and reading back a `kind:"workflow"` hub asset. -// See docs/workflow-authoring-registry.md. -import { authorize } from "@intx/authz"; -import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; -import { - AssetServiceError, - DEFAULT_ASSET_REF, - type AssetService, - type CommittedReads, - type RepoStore, -} from "@intx/hub-sessions"; -import type { DB } from "@intx/db"; -import { asset as assetTable } from "@intx/db/schema"; -import { and, eq } from "drizzle-orm"; -import { type } from "arktype"; -import { PackageJSON } from "@intx/types/package-json"; - -import { WorkflowAuthorError } from "./errors"; -import { normalizeEntryPath, PACKAGE_JSON_PATH, validateWorkflowSourceTree } from "./source-tree"; - -const WORKFLOW_ASSET_KIND = "workflow"; -const HUB_PRINCIPAL = { kind: "hub" } as const; - -export const WORKFLOW_ASSET_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -export type WorkflowAuthorCaller = { - readonly tenantId: string; - readonly principalId: string; -}; - -export type WorkflowAssetSummary = { - readonly assetId: string; - readonly name: string; - readonly commitSha: string; -}; - -export type WorkflowSourceSnapshot = { - readonly assetId: string; - readonly name: string; - readonly headSha: string; - /** Repo-relative path -> UTF-8 file contents, every blob on the head - * commit of `refs/heads/main`. */ - readonly files: Readonly>; -}; - -export type AuthorWorkflowInput = { - readonly name: string; - /** Repo-relative path -> file contents; see `validateWorkflowSourceTree` - * for the rules a tree must satisfy before it is written. */ - readonly files: Record; - readonly message?: string; -}; - -export type RepublishWorkflowInput = { - readonly files: Record; - readonly message?: string; - /** When set, the write is refused with `conflict` (carrying the current - * head) unless `refs/heads/main` still points here. */ - readonly expectedHeadSha?: string; -}; - -export type WorkflowDeployResult = { - readonly deploymentId: string; - readonly definitionAssetId: string; - readonly status: "deployed" | "pending"; -}; - -/** The apps/hub-supplied seam onto `prepareProvisionedDeployment`, used by - * the hub's own on-demand catalog-block deploy, not by an authoring agent - * (which calls the stock route with the run bearer). */ -export type WorkflowDeployer = { - deploy(params: { - tenantId: string; - principalId: string; - assetId: string; - assetName: string; - commitSha: string; - entry: string; - }): Promise; -}; - -/** The commit a `previewDeploy` renders. */ -export type DeployWorkflowInput = { - readonly commitSha: string; - readonly entry: string; -}; - -export type WorkflowDeployPreviewResult = { - readonly commitSha: string; - readonly entry: string; - /** Every repo-relative file path in the committed tree at `commitSha`. */ - readonly files: readonly string[]; - /** The `toolPackagePins` an inert `export default {...}` entry declares; - * empty when the entry isn't a plain object literal (a folded/built - * workflow — pins aren't statically knowable there without execution). */ - readonly toolPackagePins: readonly { - readonly name: string; - readonly version: string; - }[]; - readonly packageName: string; -}; - -export type WorkflowAuthorRegistry = { - author(caller: WorkflowAuthorCaller, input: AuthorWorkflowInput): Promise; - republish( - caller: WorkflowAuthorCaller, - assetId: string, - input: RepublishWorkflowInput, - ): Promise; - readSource(caller: WorkflowAuthorCaller, assetId: string): Promise; - previewDeploy( - caller: WorkflowAuthorCaller, - assetId: string, - input: DeployWorkflowInput, - ): Promise; -}; - -export type WorkflowAuthorRepoReads = Pick< - RepoStore, - "resolveRef" | "openCommittedReads" | "openCommittedReadsAtCommit" ->; - -export type CreateWorkflowAuthorRegistryDeps = { - db: DB["db"]; - assetService: AssetService; - repoStore: WorkflowAuthorRepoReads; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; -}; - -async function requireAuthorized( - deps: Pick, - caller: WorkflowAuthorCaller, - resource: string, - action: "create" | "write" | "read", -): Promise { - const verdict = await authorize( - deps.grantStore, - caller.principalId, - caller.tenantId, - resource, - action, - deps.conditionRegistry, - ); - if (verdict.effect !== "allow") { - throw new WorkflowAuthorError( - "forbidden", - `principal ${caller.principalId} is not granted "${action}" on "${resource}"`, - ); - } -} - -async function writeCodebase( - assetService: AssetService, - assetId: string, - files: Record, - message: string, -): Promise<{ commitSha: string }> { - try { - return await assetService.populateAsset({ - assetId, - ref: DEFAULT_ASSET_REF, - principal: HUB_PRINCIPAL, - // `populateAsset` is additive (`RepoStore.writeTree` refuses a root - // `clearPrefix`), so a republish overwrites the paths it names and - // carries every other committed file forward; `readSource` shows the - // caller the whole resulting tree. - tree: { files, message }, - }); - } catch (err) { - if (err instanceof AssetServiceError) { - throw new WorkflowAuthorError( - err.reason === "not_found" ? "not_found" : "invalid", - err.message, - ); - } - throw err; - } -} - -async function resolveHeadSha( - repoStore: WorkflowAuthorRepoReads, - assetId: string, -): Promise { - const sha = await repoStore.resolveRef( - HUB_PRINCIPAL, - { kind: WORKFLOW_ASSET_KIND, id: assetId }, - DEFAULT_ASSET_REF, - ); - if (sha === null) { - throw new WorkflowAuthorError( - "not_found", - `workflow asset ${assetId} has no ${DEFAULT_ASSET_REF} yet`, - ); - } - return sha; -} - -async function collectTree( - reads: CommittedReads, - dir: string, - into: Record, -): Promise { - const decoder = new TextDecoder(); - for (const entry of await reads.listDir(dir)) { - const path = dir === "" ? entry.name : `${dir}/${entry.name}`; - if (entry.type === "tree") { - await collectTree(reads, path, into); - } else if (entry.type === "blob") { - into[path] = decoder.decode(await reads.readBlobByOid(entry.oid)); - } - } -} - -/** Best-effort, read-only render of an inert `export default {...}` object - * literal. Deliberately not a JS parser or evaluator — the source is - * untrusted agent output and must never be executed. */ -function tryReadInertDefaultExport(source: string): unknown { - const trimmed = source.trim(); - const match = /^export\s+default\s+([\s\S]*?);?\s*$/.exec(trimmed); - if (match === null || match[1] === undefined) return undefined; - const quotedKeys = match[1].replace(/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)/g, '$1"$2"$3'); - try { - return JSON.parse(quotedKeys); - } catch { - // report-error-ignore: a non-JSON entry (real code, not an inert - // literal) is the expected, common case for a folded/multi-step - // workflow — falling back to listing files only, not an error. - return undefined; - } -} - -function extractToolPackagePins( - literal: unknown, -): readonly { readonly name: string; readonly version: string }[] { - if (literal === undefined || literal === null || typeof literal !== "object") { - return []; - } - const pins = (literal as Record).toolPackagePins; - if (!Array.isArray(pins)) return []; - const out: { readonly name: string; readonly version: string }[] = []; - for (const pin of pins) { - if ( - pin !== null && - typeof pin === "object" && - typeof (pin as Record).name === "string" && - typeof (pin as Record).version === "string" - ) { - out.push({ - name: (pin as { name: string }).name, - version: (pin as { version: string }).version, - }); - } - } - return out; -} - -export function createWorkflowAuthorRegistry( - deps: CreateWorkflowAuthorRegistryDeps, -): WorkflowAuthorRegistry { - const { db, assetService, repoStore } = deps; - - async function requireOwnWorkflowAsset( - caller: WorkflowAuthorCaller, - assetId: string, - ): Promise<{ id: string; name: string }> { - // Own-tenant scoping is resolved from the DB row BEFORE the grant - // check runs: an asset id from another tenant must read as - // "not_found", never leak a 403 that confirms the id exists. - const row = await db.query.asset.findFirst({ - where: and( - eq(assetTable.id, assetId), - eq(assetTable.tenantId, caller.tenantId), - eq(assetTable.kind, WORKFLOW_ASSET_KIND), - ), - }); - if (row === undefined) { - throw new WorkflowAuthorError("not_found", `no workflow asset ${assetId} in this tenant`); - } - return { id: row.id, name: row.name }; - } - - return { - async author(caller, input) { - await requireAuthorized(deps, caller, "asset:*", "create"); - - if (!WORKFLOW_ASSET_NAME_PATTERN.test(input.name)) { - throw new WorkflowAuthorError( - "invalid", - `workflow name ${JSON.stringify(input.name)} must be lowercase-kebab (letters, digits, hyphens; no leading or trailing hyphen)`, - ); - } - const { files } = validateWorkflowSourceTree(input.files); - - let created; - try { - created = await assetService.createAsset({ - tenantId: caller.tenantId, - kind: WORKFLOW_ASSET_KIND, - name: input.name, - displayName: input.name, - creatorPrincipalId: caller.principalId, - }); - } catch (err) { - if (err instanceof AssetServiceError) { - throw new WorkflowAuthorError( - err.reason === "duplicate_asset" ? "conflict" : "invalid", - err.message, - ); - } - throw err; - } - - const { commitSha } = await writeCodebase( - assetService, - created.id, - { ...files }, - input.message ?? `Author ${input.name}`, - ); - return { assetId: created.id, name: created.name, commitSha }; - }, - - async republish(caller, assetId, input) { - const row = await requireOwnWorkflowAsset(caller, assetId); - const { files } = validateWorkflowSourceTree(input.files); - - await requireAuthorized(deps, caller, `asset:${assetId}`, "write"); - - if (input.expectedHeadSha !== undefined) { - const currentHeadSha = await resolveHeadSha(repoStore, assetId); - if (currentHeadSha !== input.expectedHeadSha) { - throw new WorkflowAuthorError( - "conflict", - `workflow asset ${assetId} moved: expected head ${input.expectedHeadSha} but ${DEFAULT_ASSET_REF} is at ${currentHeadSha}; re-read the source and retry`, - { currentHeadSha }, - ); - } - } - - const { commitSha } = await writeCodebase( - assetService, - assetId, - { ...files }, - input.message ?? `Update ${row.name}`, - ); - return { assetId, name: row.name, commitSha }; - }, - - async readSource(caller, assetId) { - const row = await requireOwnWorkflowAsset(caller, assetId); - await requireAuthorized(deps, caller, `asset:${assetId}`, "read"); - - // Resolve the head sha and open the tree read from the SAME ref - // resolution so a concurrent republish landing between two separate - // calls can never produce a headSha/files mismatch. - const headSha = await resolveHeadSha(repoStore, assetId); - const reads = await repoStore.openCommittedReadsAtCommit( - HUB_PRINCIPAL, - { kind: WORKFLOW_ASSET_KIND, id: assetId }, - headSha, - ); - if (reads === null) { - throw new WorkflowAuthorError( - "not_found", - `workflow asset ${assetId} has no readable ${DEFAULT_ASSET_REF}`, - ); - } - const files: Record = {}; - await collectTree(reads, "", files); - return { assetId, name: row.name, headSha, files }; - }, - - async previewDeploy(caller, assetId, input) { - // Own-tenant scoping and the same `workflow:*`/create authorization - // as `deploy`: a preview shows exactly what `deploy` would name. - await requireOwnWorkflowAsset(caller, assetId); - await requireAuthorized(deps, caller, "workflow:*", "create"); - - // A STATIC read of the already-committed source at `commitSha` — - // never install/probe/gate/freeze, so this truly cannot deploy - // anything. - const reads = await repoStore.openCommittedReadsAtCommit( - HUB_PRINCIPAL, - { kind: WORKFLOW_ASSET_KIND, id: assetId }, - input.commitSha, - ); - if (reads === null) { - throw new WorkflowAuthorError( - "not_found", - `workflow asset ${assetId} has no commit ${input.commitSha}`, - ); - } - const files: Record = {}; - await collectTree(reads, "", files); - // Normalized the same way `validateWorkflowSourceTree` normalizes an - // author-time `interchange.workflow` entry: the tree's keys carry no - // leading `./`, but a caller (this same test suite included) may - // still pass the entry as written in `package.json`. - const entry = normalizeEntryPath(input.entry); - if (!(entry in files)) { - throw new WorkflowAuthorError( - "invalid", - `entry ${JSON.stringify(input.entry)} names no file in commit ${input.commitSha}`, - ); - } - const manifestSource = files[PACKAGE_JSON_PATH]; - if (manifestSource === undefined) { - throw new WorkflowAuthorError( - "invalid", - `commit ${input.commitSha} has no top-level ${PACKAGE_JSON_PATH}`, - ); - } - let manifestJson: unknown; - try { - manifestJson = JSON.parse(manifestSource); - } catch (cause) { - throw new WorkflowAuthorError( - "invalid", - `${PACKAGE_JSON_PATH} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - const manifest = PackageJSON(manifestJson); - if (manifest instanceof type.errors) { - throw new WorkflowAuthorError( - "invalid", - `${PACKAGE_JSON_PATH} failed validation: ${manifest.summary}`, - ); - } - const packageName = manifest.name; - const entrySource = files[entry] ?? ""; - const inertLiteral = tryReadInertDefaultExport(entrySource); - const toolPackagePins = extractToolPackagePins(inertLiteral); - - return { - commitSha: input.commitSha, - entry, - files: Object.keys(files), - toolPackagePins, - packageName, - }; - }, - }; -} diff --git a/packages/workflows/src/authoring/run-client.ts b/packages/workflows/src/authoring/run-client.ts deleted file mode 100644 index 4649766d6..000000000 --- a/packages/workflows/src/authoring/run-client.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Shared fetch plumbing for a run-authenticated tool client, so every tool -// bundle behind `WorkflowRunAuthenticator` shares the same headers and -// error-envelope parse instead of reimplementing both. -import { type } from "arktype"; - -export interface RunBearerClientConfig { - readonly sidecarToken: string; - readonly address: string; - /** Override for tests; defaults to the global `fetch`. */ - readonly fetchImpl?: typeof fetch; -} - -/** The two headers every run-authenticated route resolves tenant and - * principal from — identity never rides in a request body. */ -export function runBearerHeaders(config: RunBearerClientConfig): Record { - return { - authorization: `Bearer ${config.sidecarToken}`, - "x-workflow-run-address": config.address, - }; -} - -const RunBearerErrorEnvelope = type({ - error: { code: "string", userMessage: "string" }, -}); - -/** Pulls `error.userMessage` out of the canonical hub envelope - * (`{ error: { code, userMessage } }`), if `body` matches that shape — - * `undefined` for a differently-shaped or absent body, never a throw. */ -export function runBearerErrorMessage(body: unknown): string | undefined { - const parsed = RunBearerErrorEnvelope(body); - return parsed instanceof type.errors ? undefined : parsed.error.userMessage; -} - -/** The envelope's `error.code`, alongside the message above — some - * callers (a republish `conflict`, a preview `not_found`) branch on the - * code, not just the message. */ -export function runBearerErrorCode(body: unknown): string | undefined { - const parsed = RunBearerErrorEnvelope(body); - return parsed instanceof type.errors ? undefined : parsed.error.code; -} - -export function runBearerFetch(config: RunBearerClientConfig): typeof fetch { - return config.fetchImpl ?? fetch; -} diff --git a/packages/workflows/src/authoring/source-tree.test.ts b/packages/workflows/src/authoring/source-tree.test.ts deleted file mode 100644 index e0a79498c..000000000 --- a/packages/workflows/src/authoring/source-tree.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { expect, test } from "bun:test"; - -import { WorkflowAuthorError } from "./errors"; -import { - MAX_SOURCE_FILE_BYTES, - MAX_SOURCE_TREE_BYTES, - validateWorkflowSourceTree, -} from "./source-tree"; - -const MANIFEST = JSON.stringify({ - name: "daily-digest", - version: "0.0.1", - type: "module", - interchange: { workflow: "./workflow.ts" }, -}); - -const ENTRY = - 'import { defineWorkflow } from "@intx/workflow";\nexport default defineWorkflow({});\n'; - -function validTree(): Record { - return { "package.json": MANIFEST, "workflow.ts": ENTRY }; -} - -function rejection(files: Record): WorkflowAuthorError { - try { - validateWorkflowSourceTree(files); - } catch (err) { - if (err instanceof WorkflowAuthorError) return err; - throw err; - } - throw new Error("expected validateWorkflowSourceTree to reject"); -} - -test("accepts a minimal package and resolves the normalized entry", () => { - const result = validateWorkflowSourceTree(validTree()); - expect(result.entry).toBe("workflow.ts"); -}); - -test.each([ - ["../escape.ts", /\.\./], - ["src/../../escape.ts", /\.\./], - ["/abs.ts", /repo-relative/], - ["src\\win.ts", /separators/], - [".git/config", /\.git/], - ["nested/.git/HEAD", /\.git/], - ["", /empty/], - ["src//double.ts", /empty segment/], - ["trailing/", /empty segment/], -])("rejects the path %p", (path, message) => { - const err = rejection({ ...validTree(), [path]: "x" }); - expect(err.reason).toBe("invalid"); - expect(err.message).toMatch(message); -}); - -test.each([ - ".env", - ".env.local", - "config/.env.production", - "certs/server.pem", - "keys/private.key", - "id_rsa", - ".ssh/id_rsa.pub", - "bundle.p12", -])("rejects the secret-like file %p", (path) => { - const err = rejection({ ...validTree(), [path]: "shh" }); - expect(err.message).toMatch(/looks like a secret/); -}); - -test("rejects a tree without package.json", () => { - expect(rejection({ "workflow.ts": ENTRY }).message).toMatch(/package\.json/); -}); - -test("rejects a package.json that does not parse", () => { - const err = rejection({ ...validTree(), "package.json": "{ nope" }); - expect(err.message).toMatch(/not valid JSON/); -}); - -test("rejects a package.json with no interchange.workflow entry", () => { - const err = rejection({ - ...validTree(), - "package.json": JSON.stringify({ name: "x", version: "0.0.1" }), - }); - expect(err.message).toMatch(/interchange\.workflow/); -}); - -test("rejects an entry that escapes the package", () => { - const err = rejection({ - ...validTree(), - "package.json": JSON.stringify({ - name: "x", - version: "0.0.1", - interchange: { workflow: "../outside.ts" }, - }), - }); - expect(err.message).toMatch(/escape/); -}); - -test("rejects an entry the tree does not carry", () => { - const err = rejection({ "package.json": MANIFEST, "other.ts": ENTRY }); - expect(err.message).toMatch(/no file at "workflow\.ts"/); -}); - -test("rejects a single file over the per-file cap", () => { - const err = rejection({ - ...validTree(), - "big.ts": "x".repeat(MAX_SOURCE_FILE_BYTES + 1), - }); - expect(err.message).toMatch(/per-file limit/); -}); - -test("rejects a tree whose total exceeds the tree cap even when every file is under the per-file cap", () => { - const files = validTree(); - const chunk = "x".repeat(MAX_SOURCE_FILE_BYTES); - for (let i = 0; i * MAX_SOURCE_FILE_BYTES <= MAX_SOURCE_TREE_BYTES; i++) { - files[`chunk-${i}.ts`] = chunk; - } - expect(rejection(files).message).toMatch(/source tree totals/); -}); diff --git a/packages/workflows/src/authoring/source-tree.ts b/packages/workflows/src/authoring/source-tree.ts deleted file mode 100644 index 3f115279a..000000000 --- a/packages/workflows/src/authoring/source-tree.ts +++ /dev/null @@ -1,160 +0,0 @@ -// The trust-boundary validator for a workflow source tree an agent hands -// the authoring registry. Runs before any write so a traversal path, an -// oversize tree, or a dangling entry is rejected without touching git. -import path from "node:path"; -import { type } from "arktype"; -import { isContainedEntryPath, PackageJSON } from "@intx/types/package-json"; - -import { WorkflowAuthorError } from "./errors"; - -export const MAX_SOURCE_FILE_BYTES = 256 * 1024; -export const MAX_SOURCE_TREE_BYTES = 2 * 1024 * 1024; -export const MAX_SOURCE_FILE_COUNT = 200; -export const PACKAGE_JSON_PATH = "package.json"; - -const SECRET_LIKE_BASENAME_PATTERNS: readonly RegExp[] = [ - /^\.env(?:\..+)?$/, - /\.pem$/, - /\.key$/, - /^id_rsa/, - /\.p12$/, - /\.pfx$/, - /\.ppk$/, - /^credentials\.json$/, - /^service-account.*\.json$/, - /^\.npmrc$/, - /^\.netrc$/, -]; - -const FORBIDDEN_SEGMENTS = new Set([".", "..", ".git"]); - -export type ValidatedWorkflowSourceTree = { - readonly files: Readonly>; - /** The `interchange.workflow` entry, normalized to a repo-relative path - * (no leading `./`). */ - readonly entry: string; -}; - -function invalid(message: string): WorkflowAuthorError { - return new WorkflowAuthorError("invalid", message); -} - -/** Normalizes `./workflow.ts` (or `src/../workflow.ts`) to the - * repo-relative key the tree is addressed by. */ -export function normalizeEntryPath(entry: string): string { - return path.posix.normalize(entry); -} - -export function assertRepoRelativePath(path: string): void { - if (path === "") throw invalid("a file path must not be empty"); - if (path.includes("\\")) { - throw invalid(`file path ${JSON.stringify(path)} must use "/" separators`); - } - if (path.startsWith("/")) { - throw invalid(`file path ${JSON.stringify(path)} must be repo-relative`); - } - if (path.includes("\0")) { - throw invalid(`file path ${JSON.stringify(path)} contains a NUL byte`); - } - const segments = path.split("/"); - for (const segment of segments) { - if (segment === "") { - throw invalid( - `file path ${JSON.stringify(path)} has an empty segment (trailing or doubled "/")`, - ); - } - if (FORBIDDEN_SEGMENTS.has(segment.toLowerCase())) { - throw invalid( - `file path ${JSON.stringify(path)} may not contain a ${JSON.stringify(segment)} segment`, - ); - } - } - const basename = segments[segments.length - 1] ?? ""; - if (SECRET_LIKE_BASENAME_PATTERNS.some((pattern) => pattern.test(basename))) { - throw invalid( - `file ${JSON.stringify(path)} looks like a secret (.env*, *.pem, *.key, id_rsa*, *.p12) and cannot be committed to a workflow asset`, - ); - } -} - -function utf8ByteLength(text: string): number { - return new TextEncoder().encode(text).byteLength; -} - -function parsePackageJson(raw: string): { - readonly interchange?: { readonly workflow?: string }; -} { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (cause) { - throw invalid( - `${PACKAGE_JSON_PATH} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - const manifest = PackageJSON(parsed); - if (manifest instanceof type.errors) { - throw invalid(`${PACKAGE_JSON_PATH} failed validation: ${manifest.summary}`); - } - return manifest; -} - -/** - * Validates a whole source tree and returns it with the resolved entry. - * Throws `WorkflowAuthorError("invalid", ...)` naming the first violation. - */ -export function validateWorkflowSourceTree( - files: Readonly>, -): ValidatedWorkflowSourceTree { - const paths = Object.keys(files); - if (paths.length === 0) { - throw invalid("a workflow source tree needs at least one file"); - } - if (paths.length > MAX_SOURCE_FILE_COUNT) { - throw invalid( - `a workflow source tree may carry at most ${MAX_SOURCE_FILE_COUNT} files (got ${paths.length})`, - ); - } - - let totalBytes = 0; - for (const path of paths) { - assertRepoRelativePath(path); - const bytes = utf8ByteLength(files[path] ?? ""); - if (bytes > MAX_SOURCE_FILE_BYTES) { - throw invalid( - `file ${JSON.stringify(path)} is ${bytes} bytes; the per-file limit is ${MAX_SOURCE_FILE_BYTES}`, - ); - } - totalBytes += bytes; - } - if (totalBytes > MAX_SOURCE_TREE_BYTES) { - throw invalid( - `the source tree totals ${totalBytes} bytes; the limit is ${MAX_SOURCE_TREE_BYTES}`, - ); - } - - const manifestSource = files[PACKAGE_JSON_PATH]; - if (manifestSource === undefined) { - throw invalid( - `a workflow source tree must carry a top-level ${PACKAGE_JSON_PATH} declaring "interchange.workflow"`, - ); - } - const manifest = parsePackageJson(manifestSource); - const declaredEntry = manifest.interchange?.workflow; - if (declaredEntry === undefined || declaredEntry === "") { - throw invalid(`${PACKAGE_JSON_PATH} must declare a non-empty "interchange.workflow" entry`); - } - if (!isContainedEntryPath(declaredEntry)) { - throw invalid( - `"interchange.workflow" entry ${JSON.stringify(declaredEntry)} must be a package-relative path that does not escape the package`, - ); - } - const entry = normalizeEntryPath(declaredEntry); - if (!(entry in files)) { - throw invalid( - `"interchange.workflow" names ${JSON.stringify(declaredEntry)} but the tree has no file at ${JSON.stringify(entry)}`, - ); - } - - return { files, entry }; -} diff --git a/packages/workflows/src/authoring/workflow-routes.test.ts b/packages/workflows/src/authoring/workflow-routes.test.ts deleted file mode 100644 index ac4181960..000000000 --- a/packages/workflows/src/authoring/workflow-routes.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - createWorkflowAuthorRoutes, - type WorkflowRunAuthenticator, - type WorkflowRunScope, -} from "./workflow-routes"; -import { WorkflowAuthorError } from "./errors"; -import type { WorkflowAuthorRegistry } from "./registry"; - -function fakeAuthenticator(scope: WorkflowRunScope | null): WorkflowRunAuthenticator { - return { resolve: async () => scope }; -} - -function fakeRegistry(overrides: Partial = {}): WorkflowAuthorRegistry { - return { - author: async () => { - throw new Error("author not stubbed"); - }, - republish: async () => { - throw new Error("republish not stubbed"); - }, - readSource: async () => { - throw new Error("readSource not stubbed"); - }, - previewDeploy: async () => { - throw new Error("previewDeploy not stubbed"); - }, - ...overrides, - }; -} - -function req(path: string, body: unknown): Request { - return new Request(`https://hub.example.com${path}`, { - method: "POST", - headers: { - authorization: "Bearer sc-token", - "x-workflow-run-address": "run_1@workflow", - "content-type": "application/json", - }, - body: JSON.stringify(body), - }); -} - -test("rejects a request with no recognizable sidecar bearer token", async () => { - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator(null), - registry: fakeRegistry(), - }); - const res = await app.request( - new Request("https://hub.example.com/author", { - method: "POST", - body: JSON.stringify({ name: "x", files: {} }), - }), - ); - expect(res.status).toBe(401); -}); - -test("author calls the registry with the resolved scope, never a caller-supplied identity", async () => { - let seenScope: WorkflowRunScope | undefined; - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - author: async (caller) => { - seenScope = caller; - return { assetId: "asset_1", name: "daily-digest", commitSha: "sha_1" }; - }, - }), - }); - const res = await app.request( - req("/author", { name: "daily-digest", files: { "package.json": "{}" } }), - ); - expect(res.status).toBe(201); - expect(seenScope).toEqual({ - tenantId: "tenant_1", - principalId: "principal_1", - }); -}); - -test("a republish targeting another tenant's asset comes back not_found, not a 500", async () => { - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - republish: async () => { - throw new WorkflowAuthorError( - "not_found", - "no workflow asset asset_from_another_tenant in this tenant", - ); - }, - }), - }); - const res = await app.request( - req("/republish", { - assetId: "asset_from_another_tenant", - files: { "index.ts": "x" }, - }), - ); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("not_found"); -}); - -test("an invalid codebase (rejected by the workflow kind handler) comes back 400, not 500", async () => { - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - author: async () => { - throw new WorkflowAuthorError( - "invalid", - 'package.json must declare a non-empty "interchange.workflow" entry', - ); - }, - }), - }); - const res = await app.request( - req("/author", { name: "daily-digest", files: { "package.json": "{}" } }), - ); - expect(res.status).toBe(400); - const body = (await res.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("invalid"); - expect(body.error.userMessage).toMatch(/interchange\.workflow/); -}); - -test("a malformed request body is rejected 400 before the registry ever runs", async () => { - let called = false; - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - author: async () => { - called = true; - throw new Error("must not be called"); - }, - }), - }); - const res = await app.request(req("/author", { name: "daily-digest" })); - expect(res.status).toBe(400); - expect(called).toBe(false); -}); - -test("republish forwards expectedHeadSha and a conflict comes back 409 naming the current head", async () => { - let seenExpected: string | undefined; - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - republish: async (_caller, _assetId, input) => { - seenExpected = input.expectedHeadSha; - throw new WorkflowAuthorError("conflict", "asset moved", { - currentHeadSha: "sha_current", - }); - }, - }), - }); - const res = await app.request( - req("/republish", { - assetId: "asset_1", - files: { "package.json": "{}" }, - expectedHeadSha: "sha_stale", - }), - ); - expect(res.status).toBe(409); - expect(seenExpected).toBe("sha_stale"); - const body = (await res.json()) as { - error: { code: string }; - currentHeadSha: string; - }; - expect(body.error.code).toBe("conflict"); - expect(body.currentHeadSha).toBe("sha_current"); -}); - -test("GET /:assetId/source returns the registry's snapshot for the authenticated scope", async () => { - let seen: { tenantId: string; assetId: string } | undefined; - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - readSource: async (caller, assetId) => { - seen = { tenantId: caller.tenantId, assetId }; - return { - assetId, - name: "daily-digest", - headSha: "sha_head", - files: { "package.json": "{}" }, - }; - }, - }), - }); - const res = await app.request( - new Request("https://hub.example.com/asset_1/source", { - headers: { - authorization: "Bearer sc-token", - "x-workflow-run-address": "run_1@workflow", - }, - }), - ); - expect(res.status).toBe(200); - expect(seen).toEqual({ tenantId: "tenant_1", assetId: "asset_1" }); - const body = (await res.json()) as { data: { headSha: string } }; - expect(body.data.headSha).toBe("sha_head"); -}); - -test("POST /:assetId/deploy/preview returns a static read of the committed source", async () => { - let seen: { assetId: string; commitSha: string; entry: string } | undefined; - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - previewDeploy: async (_caller, assetId, input) => { - seen = { assetId, ...input }; - return { - commitSha: input.commitSha, - entry: input.entry, - files: ["package.json", "workflow.ts"], - toolPackagePins: [], - packageName: "daily-digest", - }; - }, - }), - }); - const res = await app.request( - req("/asset_1/deploy/preview", { - commitSha: "sha_1", - entry: "./workflow.ts", - }), - ); - expect(res.status).toBe(200); - expect(seen).toEqual({ - assetId: "asset_1", - commitSha: "sha_1", - entry: "./workflow.ts", - }); - const body = (await res.json()) as { - data: { - commitSha: string; - entry: string; - files: string[]; - toolPackagePins: { name: string; version: string }[]; - packageName: string; - }; - }; - expect(body.data).toEqual({ - commitSha: "sha_1", - entry: "./workflow.ts", - files: ["package.json", "workflow.ts"], - toolPackagePins: [], - packageName: "daily-digest", - }); -}); diff --git a/packages/workflows/src/authoring/workflow-routes.ts b/packages/workflows/src/authoring/workflow-routes.ts deleted file mode 100644 index dfdccfe33..000000000 --- a/packages/workflows/src/authoring/workflow-routes.ts +++ /dev/null @@ -1,160 +0,0 @@ -// The sanctioned path for a workflow-process child to author, republish, or -// read back a workflow-kind asset, authenticated through -// `WorkflowRunAuthenticator`. Deployment goes through the stock route instead. -import { type } from "arktype"; -import { Hono } from "hono"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -import { WorkflowAuthorError } from "./errors"; -import type { WorkflowAuthorRegistry } from "./registry"; - -export type WorkflowRunScope = { - readonly tenantId: string; - readonly principalId: string; -}; - -export type WorkflowRunAuthenticator = { - resolve(token: string, runAddress: string): Promise; -}; - -export type WorkflowAuthoringEnv = { - Variables: { workflowRunScope: WorkflowRunScope }; -}; - -const FilesInput = type("Record"); - -const AuthorBody = type({ - name: "string", - files: FilesInput, - "message?": "string", -}); - -const RepublishBody = type({ - assetId: "string", - files: FilesInput, - "message?": "string", - "expectedHeadSha?": "string", -}); - -const DeployPreviewBody = type({ - commitSha: "string", - entry: "string", -}); - -function statusFor(reason: WorkflowAuthorError["reason"]): 400 | 403 | 404 | 409 | 502 { - switch (reason) { - case "not_found": - return 404; - case "forbidden": - return 403; - case "conflict": - return 409; - case "invalid": - return 400; - case "unavailable": - return 502; - } -} - -export type CreateWorkflowAuthorRoutesDeps = { - authenticator: WorkflowRunAuthenticator; - registry: WorkflowAuthorRegistry; -}; - -export function createWorkflowAuthorRoutes( - deps: CreateWorkflowAuthorRoutesDeps, -): Hono { - const app = new Hono(); - - app.onError((err, c) => { - if (err instanceof WorkflowAuthorError) { - const envelope = makeErrorEnvelope({ - code: err.reason, - userMessage: err.message, - }); - return c.json( - err.currentHeadSha === undefined - ? envelope - : { ...envelope, currentHeadSha: err.currentHeadSha }, - statusFor(err.reason), - ); - } - throw err; - }); - - app.use("*", async (c, next) => { - const authHeader = c.req.header("authorization") ?? ""; - const token = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : ""; - const address = c.req.header("x-workflow-run-address") ?? ""; - const scope = await deps.authenticator.resolve(token, address); - if (scope === null) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Missing or unrecognized sidecar bearer token / run address", - }), - 401, - ); - } - c.set("workflowRunScope", scope); - await next(); - }); - - app.post("/author", async (c) => { - const body = AuthorBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: body.summary, - }), - 400, - ); - } - const scope = c.get("workflowRunScope"); - const summary = await deps.registry.author(scope, body); - return c.json({ data: summary }, 201); - }); - - app.post("/republish", async (c) => { - const body = RepublishBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: body.summary, - }), - 400, - ); - } - const scope = c.get("workflowRunScope"); - const summary = await deps.registry.republish(scope, body.assetId, body); - return c.json({ data: summary }); - }); - - app.get("/:assetId/source", async (c) => { - const scope = c.get("workflowRunScope"); - const snapshot = await deps.registry.readSource(scope, c.req.param("assetId")); - return c.json({ data: snapshot }); - }); - - // A static read of the already-committed source at `commitSha`. Never - // calls install/probe/gate/freeze, so it cannot deploy anything. - app.post("/:assetId/deploy/preview", async (c) => { - const body = DeployPreviewBody(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: body.summary, - }), - 400, - ); - } - const scope = c.get("workflowRunScope"); - const result = await deps.registry.previewDeploy(scope, c.req.param("assetId"), body); - return c.json({ data: result }); - }); - - return app; -} diff --git a/packages/workflows/src/client.ts b/packages/workflows/src/client.ts index 70db810f4..fc7d3f949 100644 --- a/packages/workflows/src/client.ts +++ b/packages/workflows/src/client.ts @@ -1,13 +1,6 @@ // @corbits/workflows browser-safe entry — no `@intx/*`, no `drizzle-orm`, // no `hono`. A structural check walks the import graph from here. export * from "./source"; -export { - runBearerHeaders, - runBearerErrorMessage, - runBearerErrorCode, - runBearerFetch, - type RunBearerClientConfig, -} from "./authoring/run-client"; export { pickLaunchableDefinition, isFrozen, diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 10a8528c8..36e611310 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -2,7 +2,6 @@ // `@corbits/workflows/client` instead (see ./client.ts). export * from "./source"; export * from "./detail/index"; -export * from "./authoring/index"; export { pickLaunchableDefinition, routineTargetRejection,