fix(knowledge-fs): propagate preview capability to crawl import

This commit is contained in:
FFXN 2026-09-04 21:09:44 +08:00
parent 8be760ac12
commit 90dd7fa894
5 changed files with 127 additions and 14 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 5,
"subtreeTree": "84cd26e3b3345f1d1718097bceaec6e18f99dc92",
"subtreeTree": "33e1204f278037d1dc25556691038acb0534329e",
"openapiSha256": "eba6f0e32eb27fd68ac20021c46b5f647217005fdb85522e7e4de6f0a1afc9a8",
"capabilityV2AuthManifestSha256": "e322a2fa779d1f40b95c54c1021cffecaec77abbd7b34899573dcdf4ff353109",
"capabilityV2AuthTestVectorSha256": "ae0de37b1ff05c40f905cf17a7b410d8971acacf64db07d5ee3d6fecfa559ce3",

View File

@ -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<KnowledgeGatewayEnv>();
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,
},
);
});
});

View File

@ -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<LooseOpenApiContext, "get">) {
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")

View File

@ -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({

View File

@ -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,