Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion wikibrowser/app/api/source/run/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,13 @@ export async function POST(request: Request): Promise<Response> {
authorization: `Bearer ${token}`,
"content-type": "application/json"
},
body: JSON.stringify({ databaseId: input.databaseId, sourcePath: input.sourcePath, sourceEtag: input.sourceEtag, dryRun: false })
body: JSON.stringify({
databaseId: input.databaseId,
sourcePath: input.sourcePath,
sourceEtag: input.sourceEtag,
sessionNonce: input.sessionNonce,
dryRun: false
})
});
if (response.status === 409) return jsonError("source etag mismatch", 409, origin);
if (!response.ok) return jsonError(`worker trigger failed: HTTP ${response.status}`, 502, origin);
Expand Down
4 changes: 2 additions & 2 deletions wikibrowser/app/api/url-ingest/trigger/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export async function POST(request: Request): Promise<Response> {
authorization: `Bearer ${token}`,
"content-type": "application/json"
},
body: JSON.stringify({ canisterId: input.canisterId, databaseId: input.databaseId, requestPath: input.requestPath })
body: JSON.stringify({ canisterId: input.canisterId, databaseId: input.databaseId, requestPath: input.requestPath, sessionNonce: input.sessionNonce })
});
if (!response.ok) {
return jsonError(`worker trigger failed: HTTP ${response.status}`, 502, origin);
Expand All @@ -94,7 +94,7 @@ export async function POST(request: Request): Promise<Response> {
}

function parseTriggerRequest(value: unknown): TriggerRequest | string {
if (!isRecord(value)) return "canisterId, databaseId, and requestPath are required";
if (!isRecord(value)) return "canisterId, databaseId, requestPath, and sessionNonce are required";
const canisterId = value.canisterId;
const databaseId = value.databaseId;
const requestPath = value.requestPath;
Expand Down
11 changes: 10 additions & 1 deletion wikibrowser/scripts/check-url-security.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ await withEnv(
assert.deepEqual(JSON.parse(init?.body), {
canisterId: "aaaaa-aa",
databaseId: "db_1",
requestPath: "/Sources/ingest-requests/1.md"
requestPath: "/Sources/ingest-requests/1.md",
sessionNonce: "session-1"
});
return Response.json({ accepted: true }, { status: 202 });
}, async () => {
Expand Down Expand Up @@ -157,6 +158,7 @@ await withEnv(
databaseId: "db_1",
sourcePath: "/Sources/raw/web/abc.md",
sourceEtag: "etag-source",
sessionNonce: "session-1",
dryRun: false
});
return Response.json({ queued: true }, { status: 202 });
Expand Down Expand Up @@ -207,6 +209,13 @@ await withEnv({ NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID: "aaaaa-aa", DEEPSEEK_API_KEY
const deniedSession = await queryAnswerRouteModule.POST(queryAnswerRequest("https://wiki.kinic.xyz"));
assert.equal(deniedSession.status, 403);

await withMockFetch(async () => {
throw new Error("DeepSeek should not be called");
}, async () => {
const deniedWithoutFetch = await queryAnswerRouteModule.POST(queryAnswerRequest("https://wiki.kinic.xyz"));
assert.equal(deniedWithoutFetch.status, 403);
});

queryAnswerRouteModule.setQueryAnswerDepsForTest({
checkSession: async () => ({ principal: "principal-1" }),
rateLimitStore: rateLimitStore(10)
Expand Down
13 changes: 8 additions & 5 deletions workers/wiki-generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Raw web sources keep URL provenance only. Request/source correspondence is track
Trusted servers trigger a single request with bearer-authenticated `POST /url-ingest`:

```json
{ "canisterId": "xis3j-paaaa-aaaai-axumq-cai", "databaseId": "db_...", "requestPath": "/Sources/ingest-requests/<request-id>.md" }
{ "canisterId": "xis3j-paaaa-aaaai-axumq-cai", "databaseId": "db_...", "requestPath": "/Sources/ingest-requests/<request-id>.md", "sessionNonce": "<authorized-session-nonce>" }
```

For each queued request it:
Expand All @@ -30,6 +30,8 @@ For each queued request it:
The worker identity in `KINIC_WIKI_WORKER_IDENTITY_PEM` must have writer access to the target database.
Use the exact PEM output from `icp identity export <identity-name>`.
New databases include the default LLM writer service principal as a `writer` member. That automatic grant is part of the URL ingest permission model: if an owner revokes the service principal, URL ingest session authorization and checks fail until writer access is restored.
Session checks are not permanent capability grants. The canister rejects them after credits suspension or low balance, and the worker re-checks immediately before external URL fetch and DeepSeek generation.
Manual `/run` and source queue jobs without a browser session call `check_database_write_credits` before DeepSeek; the worker identity must be writer or owner.

## Cloudflare Setup

Expand All @@ -49,9 +51,10 @@ After `d1 create`, copy the returned database id into `wrangler.jsonc`.
Use this order when enabling WikiBrowser URL ingest:

1. Deploy this Worker with `KINIC_WIKI_WORKER_TOKEN` and `KINIC_WIKI_WORKER_IDENTITY_PEM` set.
2. Grant the Worker identity writer access to target databases, or keep the default LLM writer service principal grant.
3. Set WikiBrowser `KINIC_WIKI_GENERATOR_URL` to this Worker URL.
4. Set the same `KINIC_WIKI_WORKER_TOKEN` as a WikiBrowser runtime secret.
5. Run a smoke from WikiBrowser's `/<database-id>/Wiki?tab=ingest` route and confirm `/Sources/ingest-requests/...` plus `/Sources/raw/...` output.
2. Confirm the target canister exposes `authorize_url_ingest_trigger_session`, `check_url_ingest_trigger_session`, `check_source_run_session`, and `check_database_write_credits`.
3. Grant the Worker identity writer access to target databases, or keep the default LLM writer service principal grant.
4. Set WikiBrowser `KINIC_WIKI_GENERATOR_URL` to this Worker URL.
5. Set the same `KINIC_WIKI_WORKER_TOKEN` as a WikiBrowser runtime secret.
6. Run a smoke from WikiBrowser's `/<database-id>/Wiki?tab=ingest` route and confirm `/Sources/ingest-requests/...` plus `/Sources/raw/...` output.

PDF, authenticated pages, and multi-URL batching are out of scope for this worker path.
10 changes: 3 additions & 7 deletions workers/wiki-generator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// What: Cloudflare Worker entrypoints for manual, URL ingest, and queue triggers.
// Why: Generation should run outside the wiki browser UI server.
import { isAuthorized } from "./auth.js";
import { parseManualRunInput, parseQueueMessage, processQueueMessage, runManual } from "./processing.js";
import { parseManualRunInput, parseQueueMessageEnvelope, processQueueMessageEnvelope, runManual } from "./processing.js";
import { parseUrlIngestTriggerInput, UrlIngestTriggerError, validateUrlIngestTriggerInput } from "./url-ingest.js";
import type { QueueMessage } from "./types.js";
import type { RuntimeEnv } from "./env.js";
Expand Down Expand Up @@ -56,13 +56,9 @@ export default {

async queue(batch, env): Promise<void> {
for (const message of batch.messages) {
const parsed = parseQueueMessage(message.body);
if (!parsed) {
message.ack();
continue;
}
const parsed = parseQueueMessageEnvelope(message.body);
try {
await processQueueMessage(env, parsed);
await processQueueMessageEnvelope(env, parsed);
} catch (error) {
console.error("wiki-generator queue message failed", errorMessage(error));
throw error;
Expand Down
Loading