From 90dd7fa894295b8bf2fd2beee998cfe7046717a8 Mon Sep 17 00:00:00 2001 From: FFXN Date: Fri, 4 Sep 2026 21:09:44 +0800 Subject: [PATCH] fix(knowledge-fs): propagate preview capability to crawl import --- api/knowledge-fs-contract.lock.json | 2 +- .../namespace-source-preview-handlers.test.ts | 75 +++++++++++++++++++ .../src/namespace-source-preview-handlers.ts | 21 +++++- .../api/src/namespace-source-preview.test.ts | 32 ++++++-- .../api/src/namespace-source-preview.ts | 11 ++- 5 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 knowledge-fs/packages/api/src/namespace-source-preview-handlers.test.ts diff --git a/api/knowledge-fs-contract.lock.json b/api/knowledge-fs-contract.lock.json index 7b30ba6781e..026a1486691 100644 --- a/api/knowledge-fs-contract.lock.json +++ b/api/knowledge-fs-contract.lock.json @@ -1,6 +1,6 @@ { "schemaVersion": 5, - "subtreeTree": "84cd26e3b3345f1d1718097bceaec6e18f99dc92", + "subtreeTree": "33e1204f278037d1dc25556691038acb0534329e", "openapiSha256": "eba6f0e32eb27fd68ac20021c46b5f647217005fdb85522e7e4de6f0a1afc9a8", "capabilityV2AuthManifestSha256": "e322a2fa779d1f40b95c54c1021cffecaec77abbd7b34899573dcdf4ff353109", "capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3", diff --git a/knowledge-fs/packages/api/src/namespace-source-preview-handlers.test.ts b/knowledge-fs/packages/api/src/namespace-source-preview-handlers.test.ts new file mode 100644 index 00000000000..c2bf766fd55 --- /dev/null +++ b/knowledge-fs/packages/api/src/namespace-source-preview-handlers.test.ts @@ -0,0 +1,75 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { describe, expect, it, vi } from "vitest"; + +import type { KnowledgeGatewayEnv } from "./gateway-openapi-contracts"; +import { registerNamespaceSourcePreviewHandlers } from "./namespace-source-preview-handlers"; + +const knowledgeSpaceId = "11111111-1111-4111-8111-111111111111"; +const sourceId = "22222222-2222-4222-8222-222222222222"; +const previewJobId = "33333333-3333-4333-8333-333333333333"; +const workflowId = "44444444-4444-4444-8444-444444444444"; + +describe("namespace website source preview handlers", () => { + it("forwards the admitted capability principal to the import workflow", async () => { + const consume = vi.fn(async () => workflowId); + const app = new OpenAPIHono(); + app.use("*", async (context, next) => { + context.set("subject", { + scopes: [], + subjectId: "account-1", + tenantId: "tenant-1", + }); + context.set("callerKind", "interactive"); + context.set("capabilityV2Grant", { + contentScopeIds: ["tenant:tenant-1", `source:${sourceId}`], + grantId: "capability-grant-1", + } as never); + await next(); + }); + registerNamespaceSourcePreviewHandlers({ + app, + service: { consume } as never, + }); + + const response = await app.request( + `/knowledge-spaces/${knowledgeSpaceId}/sources/${sourceId}/namespace-preview-import`, + { + body: JSON.stringify({ + configurationFingerprint: "f".repeat(64), + pageIds: ["page-1"], + previewJobId, + }), + headers: { + "content-type": "application/json", + "idempotency-key": "preview-import-1", + }, + method: "POST", + }, + ); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ workflowId }); + expect(consume).toHaveBeenCalledWith( + { + callerKind: "interactive", + capability: { + contentScopeIds: ["tenant:tenant-1", `source:${sourceId}`], + grantId: "capability-grant-1", + }, + subject: { + scopes: [], + subjectId: "account-1", + tenantId: "tenant-1", + }, + }, + { + configurationFingerprint: "f".repeat(64), + idempotencyKey: "preview-import-1", + jobId: previewJobId, + knowledgeSpaceId, + pageIds: ["page-1"], + sourceId, + }, + ); + }); +}); diff --git a/knowledge-fs/packages/api/src/namespace-source-preview-handlers.ts b/knowledge-fs/packages/api/src/namespace-source-preview-handlers.ts index 1ec9ad53827..ba13dbf5bed 100644 --- a/knowledge-fs/packages/api/src/namespace-source-preview-handlers.ts +++ b/knowledge-fs/packages/api/src/namespace-source-preview-handlers.ts @@ -96,7 +96,7 @@ export function registerNamespaceSourcePreviewHandlers(input: { const headers = context.req.valid("header"); return context.json( { - workflowId: await input.service.consume(context.get("subject"), { + workflowId: await input.service.consume(workflowPrincipal(context), { jobId: body.previewJobId, pageIds: body.pageIds, configurationFingerprint: body.configurationFingerprint, @@ -112,6 +112,25 @@ export function registerNamespaceSourcePreviewHandlers(input: { } }); } + +function workflowPrincipal(context: Pick) { + const apiKey = context.get("authenticatedApiKey"); + const capabilityGrant = context.get("capabilityV2Grant"); + return { + ...(apiKey ? { apiKey } : {}), + ...(capabilityGrant + ? { + capability: { + contentScopeIds: capabilityGrant.contentScopeIds, + grantId: capabilityGrant.grantId, + }, + } + : {}), + callerKind: context.get("callerKind") ?? "interactive", + subject: context.get("subject"), + } as const; +} + function failure(context: LooseOpenApiContext, error: unknown) { if (error instanceof NamespaceSourcePreviewError) { const status = error.code.includes("NOT_FOUND") diff --git a/knowledge-fs/packages/api/src/namespace-source-preview.test.ts b/knowledge-fs/packages/api/src/namespace-source-preview.test.ts index cea837a65b1..6f85ed203cb 100644 --- a/knowledge-fs/packages/api/src/namespace-source-preview.test.ts +++ b/knowledge-fs/packages/api/src/namespace-source-preview.test.ts @@ -64,18 +64,34 @@ describe("namespace website source preview", () => { expect(page).toMatchObject({ sourceUrl: "https://example.com/a" }); if (!page) throw new Error("Expected a preview page"); - const workflowId = await service.consume(subject, { - jobId: job.id, - pageIds: [page.pageId], - configurationFingerprint: "f".repeat(64), - knowledgeSpaceId: "11111111-1111-4111-8111-111111111111", - sourceId: "33333333-3333-4333-8333-333333333333", - idempotencyKey: "request:crawl-import", - }); + const workflowId = await service.consume( + { + callerKind: "interactive", + capability: { + contentScopeIds: ["tenant:tenant", "source:preview"], + grantId: "preview-grant", + }, + subject, + }, + { + jobId: job.id, + pageIds: [page.pageId], + configurationFingerprint: "f".repeat(64), + knowledgeSpaceId: "11111111-1111-4111-8111-111111111111", + sourceId: "33333333-3333-4333-8333-333333333333", + idempotencyKey: "request:crawl-import", + }, + ); expect(workflowId).toBe("22222222-2222-4222-8222-222222222222"); expect(createCrawlImport).toHaveBeenCalledWith( expect.objectContaining({ + callerKind: "interactive", + capability: { + contentScopeIds: ["tenant:tenant", "source:preview"], + grantId: "preview-grant", + }, + subject, sourceUrls: ["https://example.com/a"], pageReferences: [ expect.objectContaining({ diff --git a/knowledge-fs/packages/api/src/namespace-source-preview.ts b/knowledge-fs/packages/api/src/namespace-source-preview.ts index 8f5ccd2e8cc..1fbc038f881 100644 --- a/knowledge-fs/packages/api/src/namespace-source-preview.ts +++ b/knowledge-fs/packages/api/src/namespace-source-preview.ts @@ -14,7 +14,10 @@ import { jsonInsertPlaceholder, quoteDatabaseIdentifier, } from "./database-sql-utils"; -import type { SourceProductWorkflowService } from "./source-product-workflow"; +import type { + SourceProductWorkflowService, + SourceWorkflowPrincipal, +} from "./source-product-workflow"; import type { SourceRepository } from "./source-repository"; import type { WebsiteCrawlConnector } from "./website-crawl-connector"; @@ -558,7 +561,7 @@ export function createNamespaceSourcePreviewService(input: { return Boolean(job || cleanupJob); }, consume: async ( - subject: AuthSubject, + principal: SourceWorkflowPrincipal, request: { jobId: string; pageIds: readonly string[]; @@ -568,6 +571,7 @@ export function createNamespaceSourcePreviewService(input: { idempotencyKey: string; }, ) => { + const { subject } = principal; const job = await requireJob(subject, request.jobId); if (job.importWorkflowId) return job.importWorkflowId; if (job.expiresAt <= now().toISOString()) @@ -607,8 +611,7 @@ export function createNamespaceSourcePreviewService(input: { selected.push(page); } const workflow = await input.workflows.createCrawlImport({ - subject, - callerKind: "interactive", + ...principal, knowledgeSpaceId: request.knowledgeSpaceId, sourceId: request.sourceId, idempotencyKey: request.idempotencyKey,