From fbc3f7f83304d7a8a1f61fe69c8c4f820e6dc128 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sat, 5 Sep 2026 17:18:51 +0200 Subject: [PATCH 01/25] fix: patch Debian base image and Node dependency CVEs - apt-get upgrade in Dockerfile runtime stage (Debian OS CVEs) - override fast-uri to ^3.1.3 (was 3.1.0) - override fast-xml-builder to ^1.1.7 (was 1.1.3) - override fast-xml-parser to ^5.5.6 (was 5.4.1) - override ip-address to ^10.3.1 (was 10.1.0) - override path-to-regexp to ^8.4.0 (was 8.3.0) - bump hono to ^4.12.25 (was 4.12.7, direct dependency) --- Dockerfile | 2 +- daemon/bun.lock | 33 ++++++++++++++++++++++++--------- daemon/package.json | 9 ++++++++- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 24514614..a05e4070 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ FROM oven/bun:1-slim AS runtime RUN groupadd -r littleimp && useradd -r -g littleimp littleimp # Install necessary system dependencies -RUN apt-get update && apt-get install -y \ +RUN apt-get update && apt-get upgrade -y && apt-get install -y \ curl \ ca-certificates \ && rm -rf /var/lib/apt/lists/* diff --git a/daemon/bun.lock b/daemon/bun.lock index 1623ce96..0a68d487 100644 --- a/daemon/bun.lock +++ b/daemon/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", "@modelcontextprotocol/sdk": "^1.27.1", - "hono": "^4.12.7", + "hono": "^4.12.25", "pdf-parse": "^2.4.5", "sqlite-vec": "^0.1.7-alpha.2", "zod": "^4.3.6", @@ -17,6 +17,13 @@ }, }, }, + "overrides": { + "fast-uri": "^3.1.3", + "fast-xml-builder": "^1.1.7", + "fast-xml-parser": "^5.5.6", + "ip-address": "^10.3.1", + "path-to-regexp": "^8.4.0", + }, "packages": { "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -124,6 +131,8 @@ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.80", "", { "os": "win32", "cpu": "x64" }, "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q=="], "@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw=="], @@ -236,6 +245,8 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], @@ -290,11 +301,11 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], - "fast-xml-builder": ["fast-xml-builder@1.1.3", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg=="], + "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], - "fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + "fast-xml-parser": ["fast-xml-parser@5.11.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.2", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], @@ -314,7 +325,7 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="], + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -322,12 +333,14 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-unsafe": ["is-unsafe@2.0.2", "", {}, "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="], @@ -360,11 +373,11 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "pdf-parse": ["pdf-parse@2.4.5", "", { "dependencies": { "@napi-rs/canvas": "0.1.80", "pdfjs-dist": "5.4.296" }, "bin": { "pdf-parse": "bin/cli.mjs" } }, "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg=="], @@ -418,7 +431,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="], + "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -436,6 +449,8 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], diff --git a/daemon/package.json b/daemon/package.json index 75382de5..8acb608f 100644 --- a/daemon/package.json +++ b/daemon/package.json @@ -18,12 +18,19 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", "@modelcontextprotocol/sdk": "^1.27.1", - "hono": "^4.12.7", + "hono": "^4.12.25", "pdf-parse": "^2.4.5", "sqlite-vec": "^0.1.7-alpha.2", "zod": "^4.3.6" }, "devDependencies": { "@types/bun": "^1.2" + }, + "overrides": { + "fast-uri": "^3.1.3", + "fast-xml-builder": "^1.1.7", + "fast-xml-parser": "^5.5.6", + "ip-address": "^10.3.1", + "path-to-regexp": "^8.4.0" } } From 9738755e9f8bd9b758da4aeee07b78931ad831ab Mon Sep 17 00:00:00 2001 From: Robert Goniszewski Date: Mon, 7 Sep 2026 09:52:30 +0200 Subject: [PATCH 02/25] feat(TASK-138): add browser companion protocol --- API.md | 95 ++++++++ daemon/src/api/contract.ts | 90 ++++++++ daemon/src/routes/browser-integration.ts | 50 +++++ daemon/src/server.ts | 75 ++++++- .../integration/browser-integration.test.ts | 131 +++++++++++ docs/03-using-grimoire.md | 6 +- docs/api-contract.json | 190 ++++++++++++++++ docs/openapi.json | 204 ++++++++++++++++++ 8 files changed, 837 insertions(+), 4 deletions(-) create mode 100644 daemon/src/routes/browser-integration.ts create mode 100644 daemon/src/test/integration/browser-integration.test.ts diff --git a/API.md b/API.md index f7933f8b..dca86733 100644 --- a/API.md +++ b/API.md @@ -2481,6 +2481,43 @@ Content-Type: application/json } ``` +#### GET /integrations/browser/v1/capabilities + +Negotiate the packaged browser extension capture protocol. + +Requires a managed integration bearer token. Packaged Chrome and Firefox clients use this endpoint to distinguish current Grimoire from the legacy account API before sending capture data. The response describes stable paths and enforced field limits; clients must not infer compatibility from the daemon marketing version alone. + +Responses: + +| Status | Content type | Schema | Description | +|---|---|---|---| +| `200` | application/json | `BrowserIntegrationCapabilitiesResponse` | Supported browser integration capabilities | +| `401` | application/problem+json | `ProblemDetails` | Missing, invalid, rotated, or revoked integration token | + +Examples: + +**Negotiate browser capture support** + +Request: + +```bash +curl http://127.0.0.1:3210/integrations/browser/v1/capabilities \ + -H "Authorization: Bearer limp_it_example" +``` + +#### GET /integrations/browser/v1/taxonomy + +List the categories and tags available to the packaged browser extension. + +Requires a managed integration bearer token. This route keeps extension-origin reads inside the versioned browser integration surface instead of exposing unrelated local-library GET routes. + +Responses: + +| Status | Content type | Schema | Description | +|---|---|---|---| +| `200` | application/json | `BrowserIntegrationTaxonomyResponse` | Browser capture categories and tags | +| `401` | application/problem+json | `ProblemDetails` | Missing, invalid, rotated, or revoked integration token | + #### GET /capture/bookmarklet Bookmarklet capture bridge page. @@ -4518,6 +4555,64 @@ Response data | `uptime` | integer | yes | Process uptime in milliseconds | | `queueSize` | integer | yes | Queued background jobs | +### BrowserIntegrationCapabilities + +Authenticated capabilities advertised to packaged browser extensions + +| Field | Type | Required | Description | +|---|---|---:|---| +| `protocol` | "grimoire-browser-capture" | yes | Stable browser integration protocol identifier | +| `protocol_version` | integer | yes | Browser integration protocol version | +| `grimoire_version` | string | yes | Daemon package version | +| `endpoints` | object | yes | | +| `endpoints.capture` | string | yes | Authenticated bookmark capture path | +| `endpoints.taxonomy` | string | yes | Authenticated category and tag discovery path | +| `limits` | object | yes | | +| `limits.request_bytes` | integer | yes | Maximum capture request bytes | +| `limits.title_characters` | integer | yes | Maximum title characters | +| `limits.notes_characters` | integer | yes | Maximum notes characters | +| `limits.selected_text_characters` | integer | yes | Maximum selected text characters | +| `limits.tag_characters` | integer | yes | Maximum characters per tag | + +### BrowserIntegrationCapabilitiesResponse + +Browser integration capability response + +| Field | Type | Required | Description | +|---|---|---:|---| +| `data` | BrowserIntegrationCapabilities | yes | | +| `data.protocol` | "grimoire-browser-capture" | yes | Stable browser integration protocol identifier | +| `data.protocol_version` | integer | yes | Browser integration protocol version | +| `data.grimoire_version` | string | yes | Daemon package version | +| `data.endpoints` | object | yes | | +| `data.endpoints.capture` | string | yes | Authenticated bookmark capture path | +| `data.endpoints.taxonomy` | string | yes | Authenticated category and tag discovery path | +| `data.limits` | object | yes | | +| `data.limits.request_bytes` | integer | yes | Maximum capture request bytes | +| `data.limits.title_characters` | integer | yes | Maximum title characters | +| `data.limits.notes_characters` | integer | yes | Maximum notes characters | +| `data.limits.selected_text_characters` | integer | yes | Maximum selected text characters | +| `data.limits.tag_characters` | integer | yes | Maximum characters per tag | + +### BrowserIntegrationTaxonomy + +Authenticated taxonomy subset used by the browser extension + +| Field | Type | Required | Description | +|---|---|---:|---| +| `categories` | array | yes | Category tree | +| `tags` | array | yes | Tags | + +### BrowserIntegrationTaxonomyResponse + +Browser integration taxonomy response + +| Field | Type | Required | Description | +|---|---|---:|---| +| `data` | BrowserIntegrationTaxonomy | yes | | +| `data.categories` | array | yes | Category tree | +| `data.tags` | array | yes | Tags | + ### Diagnostics Redacted local diagnostics payload for user-shared support bundles diff --git a/daemon/src/api/contract.ts b/daemon/src/api/contract.ts index 7baad38b..46b98274 100644 --- a/daemon/src/api/contract.ts +++ b/daemon/src/api/contract.ts @@ -1456,6 +1456,59 @@ const schemas = { }, ["status", "version", "uptime", "queueSize"] ), + BrowserIntegrationCapabilities: objectSchema( + { + protocol: stringSchema("Stable browser integration protocol identifier", { + enum: ["grimoire-browser-capture"], + }), + protocol_version: integerSchema("Browser integration protocol version", { + minimum: 1, + maximum: 1, + }), + grimoire_version: stringSchema("Daemon package version"), + endpoints: objectSchema( + { + capture: stringSchema("Authenticated bookmark capture path"), + taxonomy: stringSchema("Authenticated category and tag discovery path"), + }, + ["capture", "taxonomy"] + ), + limits: objectSchema( + { + request_bytes: integerSchema("Maximum capture request bytes", { minimum: 1 }), + title_characters: integerSchema("Maximum title characters", { minimum: 1 }), + notes_characters: integerSchema("Maximum notes characters", { minimum: 1 }), + selected_text_characters: integerSchema("Maximum selected text characters", { minimum: 1 }), + tag_characters: integerSchema("Maximum characters per tag", { minimum: 1 }), + }, + [ + "request_bytes", + "title_characters", + "notes_characters", + "selected_text_characters", + "tag_characters", + ] + ), + }, + ["protocol", "protocol_version", "grimoire_version", "endpoints", "limits"], + "Authenticated capabilities advertised to packaged browser extensions" + ), + BrowserIntegrationCapabilitiesResponse: envelope( + ref("BrowserIntegrationCapabilities"), + "Browser integration capability response" + ), + BrowserIntegrationTaxonomy: objectSchema( + { + categories: arrayOf(ref("CategoryNode"), "Category tree"), + tags: arrayOf(ref("TagWithCount"), "Tags"), + }, + ["categories", "tags"], + "Authenticated taxonomy subset used by the browser extension" + ), + BrowserIntegrationTaxonomyResponse: envelope( + ref("BrowserIntegrationTaxonomy"), + "Browser integration taxonomy response" + ), Diagnostics: objectSchema( { generated_at: stringSchema("Diagnostics generation timestamp", { format: "date-time" }), @@ -3233,6 +3286,43 @@ export const apiContract = { }, ], }, + { + method: "GET", + path: "/integrations/browser/v1/capabilities", + tag: "Integrations", + summary: "Negotiate the packaged browser extension capture protocol.", + description: + "Requires a managed integration bearer token. Packaged Chrome and Firefox clients use this endpoint to distinguish current Grimoire from the legacy account API before sending capture data. The response describes stable paths and enforced field limits; clients must not infer compatibility from the daemon marketing version alone.", + responses: { + "200": jsonResponse( + "Supported browser integration capabilities", + ref("BrowserIntegrationCapabilitiesResponse") + ), + "401": problemResponse("Missing, invalid, rotated, or revoked integration token"), + }, + examples: [ + { + title: "Negotiate browser capture support", + request: + "curl http://127.0.0.1:3210/integrations/browser/v1/capabilities \\\n -H \"Authorization: Bearer limp_it_example\"", + }, + ], + }, + { + method: "GET", + path: "/integrations/browser/v1/taxonomy", + tag: "Integrations", + summary: "List the categories and tags available to the packaged browser extension.", + description: + "Requires a managed integration bearer token. This route keeps extension-origin reads inside the versioned browser integration surface instead of exposing unrelated local-library GET routes.", + responses: { + "200": jsonResponse( + "Browser capture categories and tags", + ref("BrowserIntegrationTaxonomyResponse") + ), + "401": problemResponse("Missing, invalid, rotated, or revoked integration token"), + }, + }, { method: "GET", path: "/capture/bookmarklet", diff --git a/daemon/src/routes/browser-integration.ts b/daemon/src/routes/browser-integration.ts new file mode 100644 index 00000000..5cfda4f9 --- /dev/null +++ b/daemon/src/routes/browser-integration.ts @@ -0,0 +1,50 @@ +import { Hono } from "hono"; +import type { Database } from "bun:sqlite"; +import { requireIntegrationToken } from "../lib/integration-auth.js"; +import { CategoryRepository } from "../db/category-repository.js"; +import { TagRepository } from "../db/tag-repository.js"; + +interface BrowserIntegrationDeps { + db: Database; + version: string; +} + +export function createBrowserIntegrationRoute(deps: BrowserIntegrationDeps): Hono { + const router = new Hono(); + const categoryRepo = new CategoryRepository(deps.db); + const tagRepo = new TagRepository(deps.db); + + router.use("/integrations/browser/v1/*", requireIntegrationToken(deps.db)); + + router.get("/integrations/browser/v1/capabilities", (c) => { + return c.json({ + data: { + protocol: "grimoire-browser-capture", + protocol_version: 1, + grimoire_version: deps.version, + endpoints: { + capture: "/capture", + taxonomy: "/integrations/browser/v1/taxonomy", + }, + limits: { + request_bytes: 256 * 1024, + title_characters: 2_000, + notes_characters: 100_000, + selected_text_characters: 10_000, + tag_characters: 50, + }, + }, + }); + }); + + router.get("/integrations/browser/v1/taxonomy", (c) => { + return c.json({ + data: { + categories: categoryRepo.listTree(), + tags: tagRepo.list(), + }, + }); + }); + + return router; +} diff --git a/daemon/src/server.ts b/daemon/src/server.ts index b29e0ec9..98025e2d 100644 --- a/daemon/src/server.ts +++ b/daemon/src/server.ts @@ -28,6 +28,7 @@ import { createMediaRoute } from "./routes/media.js"; import { createIntegrationTokensRoute } from "./routes/integration-tokens.js"; import { createCaptureRoute } from "./routes/capture.js"; import { createDemoRoute } from "./routes/demo.js"; +import { createBrowserIntegrationRoute } from "./routes/browser-integration.js"; import { validatePresentedIntegrationToken } from "./lib/integration-auth.js"; import { join } from "path"; import { existsSync } from "fs"; @@ -108,6 +109,43 @@ function isAllowedLocalOrigin(origin: string | undefined): boolean { return !!normalized && allowedBrowserOrigins().has(normalized); } +function isBrowserExtensionOrigin(origin: string | undefined): boolean { + if (!origin) return false; + try { + const parsed = new URL(origin); + if (parsed.protocol !== "chrome-extension:" && parsed.protocol !== "moz-extension:") return false; + if (!parsed.hostname || parsed.username || parsed.password || parsed.search || parsed.hash) return false; + return parsed.pathname === "" || parsed.pathname === "/"; + } catch { + return false; + } +} + +function hasBrowserExtensionScheme(origin: string | undefined): boolean { + if (!origin) return false; + try { + const protocol = new URL(origin).protocol; + return protocol === "chrome-extension:" || protocol === "moz-extension:"; + } catch { + return false; + } +} + +const BROWSER_EXTENSION_INTEGRATION_PATHS = new Set([ + "/capture", + "/integrations/browser/v1/capabilities", + "/integrations/browser/v1/taxonomy", +]); + +function isBrowserExtensionIntegrationPath(path: string): boolean { + return BROWSER_EXTENSION_INTEGRATION_PATHS.has(path); +} + +function isAllowedRequestOrigin(origin: string | undefined, path: string): boolean { + return isAllowedLocalOrigin(origin) || + (isBrowserExtensionIntegrationPath(path) && isBrowserExtensionOrigin(origin)); +} + function isValidCspSourceOrigin(origin: string): boolean { try { const parsed = new URL(origin); @@ -175,17 +213,35 @@ async function enforceLocalOrigin(c: Context, next: Next): Promise { + const origin = c.req.header("origin"); + if (!isBrowserExtensionOrigin(origin) || !isBrowserExtensionIntegrationPath(c.req.path)) { + await next(); + return; + } + + await next(); + c.header("Access-Control-Allow-Origin", origin); + c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + c.header("Access-Control-Allow-Headers", "Content-Type, Authorization"); + c.header("Vary", "Origin", { append: true }); +} + async function enforceRequestBodyLimits(c: Context, next: Next): Promise { const limit = bodyLimitFor(c.req.path, c.req.method); const contentLength = declaredContentLength(c); @@ -227,6 +283,7 @@ export function createApp(deps: AppDeps): Hono { // Middleware app.use("*", applySecurityHeaders); + app.use("*", applyBrowserExtensionCors); app.use("*", enforceLocalOrigin); app.use("*", enforceRequestBodyLimits); app.use( @@ -237,7 +294,18 @@ export function createApp(deps: AppDeps): Hono { allowHeaders: ["Content-Type", "Authorization", "X-LittleImp-Frontend"], }) ); - app.use("*", validatePresentedIntegrationToken(deps.db, new Set(["/mcp", "/capture"]))); + app.use( + "*", + validatePresentedIntegrationToken( + deps.db, + new Set([ + "/mcp", + "/capture", + "/integrations/browser/v1/capabilities", + "/integrations/browser/v1/taxonomy", + ]) + ) + ); // JSON error handler — never leak internal details in production app.onError((err, c) => { @@ -258,6 +326,7 @@ export function createApp(deps: AppDeps): Hono { app.route("/", createDiagnosticsRoute(deps)); app.route("/", createBookmarksRoute({ db: deps.db, queue: deps.queue, dataDir: deps.dataDir ?? Config.DATA_DIR })); app.route("/", createCaptureRoute({ db: deps.db, queue: deps.queue })); + app.route("/", createBrowserIntegrationRoute({ db: deps.db, version: deps.version })); app.route("/", createMediaRoute({ db: deps.db, dataDir: deps.dataDir ?? Config.DATA_DIR })); app.route("/", createSearchRoute({ db: deps.db })); app.route("/", createImportRoute({ db: deps.db, queue: deps.queue })); diff --git a/daemon/src/test/integration/browser-integration.test.ts b/daemon/src/test/integration/browser-integration.test.ts new file mode 100644 index 00000000..30f35f58 --- /dev/null +++ b/daemon/src/test/integration/browser-integration.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import type { Database } from "bun:sqlite"; +import { createApp } from "../../server.js"; +import { JobQueue } from "../../queue.js"; +import { makeTestDb } from "../helpers/db.js"; + +describe("browser extension integration", () => { + let db: Database; + let app: ReturnType; + + beforeEach(() => { + db = makeTestDb(); + app = createApp({ + db, + queue: new JobQueue(db), + startTime: new Date(), + version: "1.2.3-test", + staticDir: false, + }); + }); + + afterEach(() => db.close()); + + async function token(): Promise { + const response = await app.request("/integration-tokens", { + method: "POST", + headers: { "Content-Type": "application/json", Origin: "http://127.0.0.1:3210" }, + body: JSON.stringify({ name: "Grimoire Companion" }), + }); + const body = (await response.json()) as { data: { token: string } }; + return body.data.token; + } + + it("advertises the versioned capture contract only to an authenticated client", async () => { + const unauthorized = await app.request("/integrations/browser/v1/capabilities"); + expect(unauthorized.status).toBe(401); + + const response = await app.request("/integrations/browser/v1/capabilities", { + headers: { + Authorization: `Bearer ${await token()}`, + Origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe( + "chrome-extension://abcdefghijklmnopabcdefghijklmnop" + ); + expect(await response.json()).toMatchObject({ + data: { + protocol: "grimoire-browser-capture", + protocol_version: 1, + grimoire_version: "1.2.3-test", + endpoints: { + capture: "/capture", + taxonomy: "/integrations/browser/v1/taxonomy", + }, + }, + }); + }); + + it("accepts authenticated capture from Chrome and Firefox extension origins", async () => { + const bearer = await token(); + for (const origin of [ + "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + "moz-extension://8f2d3a0b-31dc-47f4-b783-36f8c9746ee8", + ]) { + const response = await app.request("/capture", { + method: "POST", + headers: { + Authorization: `Bearer ${bearer}`, + "Content-Type": "application/json", + Origin: origin, + }, + body: JSON.stringify({ url: `https://example.com/${encodeURIComponent(origin)}` }), + }); + expect(response.status).toBe(201); + expect(response.headers.get("access-control-allow-origin")).toBe(origin); + } + }); + + it("does not extend extension-origin access to other browser writes", async () => { + const response = await app.request("/tags", { + method: "POST", + headers: { + Authorization: `Bearer ${await token()}`, + "Content-Type": "application/json", + Origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + }, + body: JSON.stringify({ name: "blocked" }), + }); + expect(response.status).toBe(403); + }); + + it("keeps extension-origin taxonomy reads authenticated and blocks unrelated reads", async () => { + const origin = "chrome-extension://abcdefghijklmnopabcdefghijklmnop"; + const blocked = await app.request("/bookmarks", { headers: { Origin: origin } }); + expect(blocked.status).toBe(403); + const unknownIntegrationRead = await app.request("/integrations/browser/v1/future", { + headers: { Origin: origin }, + }); + expect(unknownIntegrationRead.status).toBe(403); + + const unauthorized = await app.request("/integrations/browser/v1/taxonomy", { + headers: { Origin: origin }, + }); + expect(unauthorized.status).toBe(401); + + const response = await app.request("/integrations/browser/v1/taxonomy", { + headers: { Origin: origin, Authorization: `Bearer ${await token()}` }, + }); + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe(origin); + expect(await response.json()).toEqual({ data: { categories: [], tags: [] } }); + }); + + it("allows capture preflight without treating extension origins as first-party", async () => { + const response = await app.request("/capture", { + method: "OPTIONS", + headers: { + Origin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type", + }, + }); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe( + "chrome-extension://abcdefghijklmnopabcdefghijklmnop" + ); + }); +}); diff --git a/docs/03-using-grimoire.md b/docs/03-using-grimoire.md index 647b7d65..8a2df8bb 100644 --- a/docs/03-using-grimoire.md +++ b/docs/03-using-grimoire.md @@ -43,7 +43,8 @@ Related bookmarks use embeddings when available. ## Capture from the browser -There is no Chrome/Firefox store extension in 1.x yet. Use the built-in bookmarklet: +The rewritten Chrome/Firefox extension is still being prepared for publication. +Until it is available in the existing store listings, use the built-in bookmarklet: 1. Open **Settings → Browser Integration** 2. Create an integration token (the full secret is shown once — copy it if you need it elsewhere) @@ -72,6 +73,9 @@ From Settings or the `littleimp` CLI you can create, list, verify, and restore l - **REST** — see [API.md](../API.md); health at `GET /health` - **MCP** — Streamable HTTP at `http://127.0.0.1:3210/mcp` with an integration bearer token - **Capture API** — token-protected `POST /capture` for same-machine clients +- **Browser Companion protocol** — authenticated capability negotiation at + `GET /integrations/browser/v1/capabilities`, with authenticated taxonomy at + `GET /integrations/browser/v1/taxonomy`, before extension capture ## Privacy defaults diff --git a/docs/api-contract.json b/docs/api-contract.json index 632dbd18..3d3d51e1 100644 --- a/docs/api-contract.json +++ b/docs/api-contract.json @@ -4911,6 +4911,144 @@ ], "additionalProperties": false }, + "BrowserIntegrationCapabilities": { + "type": "object", + "properties": { + "protocol": { + "type": "string", + "description": "Stable browser integration protocol identifier", + "enum": [ + "grimoire-browser-capture" + ] + }, + "protocol_version": { + "type": "integer", + "description": "Browser integration protocol version", + "minimum": 1, + "maximum": 1 + }, + "grimoire_version": { + "type": "string", + "description": "Daemon package version" + }, + "endpoints": { + "type": "object", + "properties": { + "capture": { + "type": "string", + "description": "Authenticated bookmark capture path" + }, + "taxonomy": { + "type": "string", + "description": "Authenticated category and tag discovery path" + } + }, + "required": [ + "capture", + "taxonomy" + ], + "additionalProperties": false + }, + "limits": { + "type": "object", + "properties": { + "request_bytes": { + "type": "integer", + "description": "Maximum capture request bytes", + "minimum": 1 + }, + "title_characters": { + "type": "integer", + "description": "Maximum title characters", + "minimum": 1 + }, + "notes_characters": { + "type": "integer", + "description": "Maximum notes characters", + "minimum": 1 + }, + "selected_text_characters": { + "type": "integer", + "description": "Maximum selected text characters", + "minimum": 1 + }, + "tag_characters": { + "type": "integer", + "description": "Maximum characters per tag", + "minimum": 1 + } + }, + "required": [ + "request_bytes", + "title_characters", + "notes_characters", + "selected_text_characters", + "tag_characters" + ], + "additionalProperties": false + } + }, + "required": [ + "protocol", + "protocol_version", + "grimoire_version", + "endpoints", + "limits" + ], + "additionalProperties": false, + "description": "Authenticated capabilities advertised to packaged browser extensions" + }, + "BrowserIntegrationCapabilitiesResponse": { + "type": "object", + "properties": { + "data": { + "ref": "BrowserIntegrationCapabilities" + } + }, + "required": [ + "data" + ], + "additionalProperties": false, + "description": "Browser integration capability response" + }, + "BrowserIntegrationTaxonomy": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "description": "Category tree", + "items": { + "ref": "CategoryNode" + } + }, + "tags": { + "type": "array", + "description": "Tags", + "items": { + "ref": "TagWithCount" + } + } + }, + "required": [ + "categories", + "tags" + ], + "additionalProperties": false, + "description": "Authenticated taxonomy subset used by the browser extension" + }, + "BrowserIntegrationTaxonomyResponse": { + "type": "object", + "properties": { + "data": { + "ref": "BrowserIntegrationTaxonomy" + } + }, + "required": [ + "data" + ], + "additionalProperties": false, + "description": "Browser integration taxonomy response" + }, "Diagnostics": { "type": "object", "properties": { @@ -9254,6 +9392,58 @@ } ] }, + { + "method": "GET", + "path": "/integrations/browser/v1/capabilities", + "tag": "Integrations", + "summary": "Negotiate the packaged browser extension capture protocol.", + "description": "Requires a managed integration bearer token. Packaged Chrome and Firefox clients use this endpoint to distinguish current Grimoire from the legacy account API before sending capture data. The response describes stable paths and enforced field limits; clients must not infer compatibility from the daemon marketing version alone.", + "responses": { + "200": { + "description": "Supported browser integration capabilities", + "contentType": "application/json", + "schema": { + "ref": "BrowserIntegrationCapabilitiesResponse" + } + }, + "401": { + "description": "Missing, invalid, rotated, or revoked integration token", + "contentType": "application/problem+json", + "schema": { + "ref": "ProblemDetails" + } + } + }, + "examples": [ + { + "title": "Negotiate browser capture support", + "request": "curl http://127.0.0.1:3210/integrations/browser/v1/capabilities \\\n -H \"Authorization: Bearer limp_it_example\"" + } + ] + }, + { + "method": "GET", + "path": "/integrations/browser/v1/taxonomy", + "tag": "Integrations", + "summary": "List the categories and tags available to the packaged browser extension.", + "description": "Requires a managed integration bearer token. This route keeps extension-origin reads inside the versioned browser integration surface instead of exposing unrelated local-library GET routes.", + "responses": { + "200": { + "description": "Browser capture categories and tags", + "contentType": "application/json", + "schema": { + "ref": "BrowserIntegrationTaxonomyResponse" + } + }, + "401": { + "description": "Missing, invalid, rotated, or revoked integration token", + "contentType": "application/problem+json", + "schema": { + "ref": "ProblemDetails" + } + } + } + }, { "method": "GET", "path": "/capture/bookmarklet", diff --git a/docs/openapi.json b/docs/openapi.json index 711a9dc8..78bb9c93 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3882,6 +3882,72 @@ "x-grimoire-source-method": "POST" } }, + "/integrations/browser/v1/capabilities": { + "get": { + "tags": [ + "Integrations" + ], + "summary": "Negotiate the packaged browser extension capture protocol.", + "description": "Requires a managed integration bearer token. Packaged Chrome and Firefox clients use this endpoint to distinguish current Grimoire from the legacy account API before sending capture data. The response describes stable paths and enforced field limits; clients must not infer compatibility from the daemon marketing version alone.", + "operationId": "getIntegrationsBrowserV1Capabilities", + "responses": { + "200": { + "description": "Supported browser integration capabilities", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserIntegrationCapabilitiesResponse" + } + } + } + }, + "401": { + "description": "Missing, invalid, rotated, or revoked integration token", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "x-grimoire-source-method": "GET" + } + }, + "/integrations/browser/v1/taxonomy": { + "get": { + "tags": [ + "Integrations" + ], + "summary": "List the categories and tags available to the packaged browser extension.", + "description": "Requires a managed integration bearer token. This route keeps extension-origin reads inside the versioned browser integration surface instead of exposing unrelated local-library GET routes.", + "operationId": "getIntegrationsBrowserV1Taxonomy", + "responses": { + "200": { + "description": "Browser capture categories and tags", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrowserIntegrationTaxonomyResponse" + } + } + } + }, + "401": { + "description": "Missing, invalid, rotated, or revoked integration token", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "x-grimoire-source-method": "GET" + } + }, "/capture/bookmarklet": { "get": { "tags": [ @@ -9094,6 +9160,144 @@ ], "additionalProperties": false }, + "BrowserIntegrationCapabilities": { + "type": "object", + "description": "Authenticated capabilities advertised to packaged browser extensions", + "properties": { + "protocol": { + "type": "string", + "description": "Stable browser integration protocol identifier", + "enum": [ + "grimoire-browser-capture" + ] + }, + "protocol_version": { + "type": "integer", + "description": "Browser integration protocol version", + "minimum": 1, + "maximum": 1 + }, + "grimoire_version": { + "type": "string", + "description": "Daemon package version" + }, + "endpoints": { + "type": "object", + "properties": { + "capture": { + "type": "string", + "description": "Authenticated bookmark capture path" + }, + "taxonomy": { + "type": "string", + "description": "Authenticated category and tag discovery path" + } + }, + "required": [ + "capture", + "taxonomy" + ], + "additionalProperties": false + }, + "limits": { + "type": "object", + "properties": { + "request_bytes": { + "type": "integer", + "description": "Maximum capture request bytes", + "minimum": 1 + }, + "title_characters": { + "type": "integer", + "description": "Maximum title characters", + "minimum": 1 + }, + "notes_characters": { + "type": "integer", + "description": "Maximum notes characters", + "minimum": 1 + }, + "selected_text_characters": { + "type": "integer", + "description": "Maximum selected text characters", + "minimum": 1 + }, + "tag_characters": { + "type": "integer", + "description": "Maximum characters per tag", + "minimum": 1 + } + }, + "required": [ + "request_bytes", + "title_characters", + "notes_characters", + "selected_text_characters", + "tag_characters" + ], + "additionalProperties": false + } + }, + "required": [ + "protocol", + "protocol_version", + "grimoire_version", + "endpoints", + "limits" + ], + "additionalProperties": false + }, + "BrowserIntegrationCapabilitiesResponse": { + "type": "object", + "description": "Browser integration capability response", + "properties": { + "data": { + "$ref": "#/components/schemas/BrowserIntegrationCapabilities" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + }, + "BrowserIntegrationTaxonomy": { + "type": "object", + "description": "Authenticated taxonomy subset used by the browser extension", + "properties": { + "categories": { + "type": "array", + "description": "Category tree", + "items": { + "$ref": "#/components/schemas/CategoryNode" + } + }, + "tags": { + "type": "array", + "description": "Tags", + "items": { + "$ref": "#/components/schemas/TagWithCount" + } + } + }, + "required": [ + "categories", + "tags" + ], + "additionalProperties": false + }, + "BrowserIntegrationTaxonomyResponse": { + "type": "object", + "description": "Browser integration taxonomy response", + "properties": { + "data": { + "$ref": "#/components/schemas/BrowserIntegrationTaxonomy" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + }, "Diagnostics": { "type": "object", "description": "Redacted local diagnostics payload for user-shared support bundles", From d697f0b747f85eba053f384b6a53bb9601cdd1dd Mon Sep 17 00:00:00 2001 From: Robert Goniszewski Date: Mon, 7 Sep 2026 11:59:16 +0200 Subject: [PATCH 03/25] feat(TASK-138): support rich browser captures --- API.md | 10 +++++ daemon/src/api/contract.ts | 10 +++++ daemon/src/routes/browser-integration.ts | 4 ++ daemon/src/routes/capture.ts | 37 ++++++++++++++++++- daemon/src/routes/settings.ts | 8 +++- .../integration/browser-integration.test.ts | 4 ++ daemon/src/test/integration/capture.test.ts | 23 ++++++++++++ .../test/integration/static-frontend.test.ts | 15 ++++++++ docs/api-contract.json | 27 ++++++++++++++ docs/openapi.json | 27 ++++++++++++++ 10 files changed, 161 insertions(+), 4 deletions(-) diff --git a/API.md b/API.md index dca86733..255638f3 100644 --- a/API.md +++ b/API.md @@ -2401,6 +2401,8 @@ Request body: | `category_id` | string \| null | no | Existing category ID to assign | | `category` | string | no | Root category name to resolve or create when category_id is omitted | | `notes` | string \| null | no | Personal notes, or null to leave empty | +| `is_pinned` | boolean | no | Whether the new bookmark should be pinned | +| `read_later` | boolean | no | Whether the new bookmark should be added to Read Later | | `source` | CaptureSource | no | | | `source.client` | string \| null | no | Optional local integration client label | | `source.source_url` | string \| null | no | Optional public HTTP or HTTPS page/context URL | @@ -3175,6 +3177,8 @@ Protected one-click capture request for explicit local integrations | `category_id` | string \| null | no | Existing category ID to assign | | `category` | string | no | Root category name to resolve or create when category_id is omitted | | `notes` | string \| null | no | Personal notes, or null to leave empty | +| `is_pinned` | boolean | no | Whether the new bookmark should be pinned | +| `read_later` | boolean | no | Whether the new bookmark should be added to Read Later | | `source` | CaptureSource | no | | | `source.client` | string \| null | no | Optional local integration client label | | `source.source_url` | string \| null | no | Optional public HTTP or HTTPS page/context URL | @@ -4567,6 +4571,9 @@ Authenticated capabilities advertised to packaged browser extensions | `endpoints` | object | yes | | | `endpoints.capture` | string | yes | Authenticated bookmark capture path | | `endpoints.taxonomy` | string | yes | Authenticated category and tag discovery path | +| `capture_fields` | object | no | Optional capture fields supported by this daemon | +| `capture_fields.is_pinned` | boolean | yes | Capture accepts an initial pinned state | +| `capture_fields.read_later` | boolean | yes | Capture accepts an initial Read Later state | | `limits` | object | yes | | | `limits.request_bytes` | integer | yes | Maximum capture request bytes | | `limits.title_characters` | integer | yes | Maximum title characters | @@ -4587,6 +4594,9 @@ Browser integration capability response | `data.endpoints` | object | yes | | | `data.endpoints.capture` | string | yes | Authenticated bookmark capture path | | `data.endpoints.taxonomy` | string | yes | Authenticated category and tag discovery path | +| `data.capture_fields` | object | no | Optional capture fields supported by this daemon | +| `data.capture_fields.is_pinned` | boolean | yes | Capture accepts an initial pinned state | +| `data.capture_fields.read_later` | boolean | yes | Capture accepts an initial Read Later state | | `data.limits` | object | yes | | | `data.limits.request_bytes` | integer | yes | Maximum capture request bytes | | `data.limits.title_characters` | integer | yes | Maximum title characters | diff --git a/daemon/src/api/contract.ts b/daemon/src/api/contract.ts index 46b98274..050c98d2 100644 --- a/daemon/src/api/contract.ts +++ b/daemon/src/api/contract.ts @@ -385,6 +385,8 @@ const schemas = { category_id: nullable(stringSchema("Existing category ID to assign")), category: stringSchema("Root category name to resolve or create when category_id is omitted", { maxLength: 100 }), notes: nullable(stringSchema("Personal notes, or null to leave empty", { maxLength: 100000 })), + is_pinned: booleanSchema("Whether the new bookmark should be pinned"), + read_later: booleanSchema("Whether the new bookmark should be added to Read Later"), source: ref("CaptureSource"), }, ["url"], @@ -1473,6 +1475,14 @@ const schemas = { }, ["capture", "taxonomy"] ), + capture_fields: objectSchema( + { + is_pinned: booleanSchema("Capture accepts an initial pinned state"), + read_later: booleanSchema("Capture accepts an initial Read Later state"), + }, + ["is_pinned", "read_later"], + "Optional capture fields supported by this daemon" + ), limits: objectSchema( { request_bytes: integerSchema("Maximum capture request bytes", { minimum: 1 }), diff --git a/daemon/src/routes/browser-integration.ts b/daemon/src/routes/browser-integration.ts index 5cfda4f9..a61b1204 100644 --- a/daemon/src/routes/browser-integration.ts +++ b/daemon/src/routes/browser-integration.ts @@ -26,6 +26,10 @@ export function createBrowserIntegrationRoute(deps: BrowserIntegrationDeps): Hon capture: "/capture", taxonomy: "/integrations/browser/v1/taxonomy", }, + capture_fields: { + is_pinned: true, + read_later: true, + }, limits: { request_bytes: 256 * 1024, title_characters: 2_000, diff --git a/daemon/src/routes/capture.ts b/daemon/src/routes/capture.ts index b4b898af..3943a9f1 100644 --- a/daemon/src/routes/capture.ts +++ b/daemon/src/routes/capture.ts @@ -18,7 +18,17 @@ interface CaptureDeps { type ProblemStatus = 400 | 404 | 409 | 422 | 500; -const captureFields = new Set(["url", "title", "tags", "category_id", "category", "notes", "source"]); +const captureFields = new Set([ + "url", + "title", + "tags", + "category_id", + "category", + "notes", + "is_pinned", + "read_later", + "source", +]); const sourceFields = new Set(["client", "source_url", "referrer_url", "selected_text"]); const tagPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; const MAX_TITLE_LENGTH = 2000; @@ -126,6 +136,14 @@ function parseTags(body: Record): string[] | undefined { return tags; } +function parseOptionalBoolean(body: Record, field: string): boolean | undefined { + if (!(field in body)) return undefined; + if (typeof body[field] !== "boolean") { + throw new ValidationError(`\`${field}\` must be a boolean`); + } + return body[field]; +} + function parseSource(body: Record): CaptureMetadataInput { if (!("source" in body) || body.source === null || body.source === undefined) return {}; if (!isRecord(body.source)) { @@ -222,6 +240,8 @@ type DoCaptureInput = { resolveCategory?: () => string | null; tags?: string[]; notes?: string | null; + isPinned?: boolean; + readLater?: boolean; source?: CaptureMetadataInput; }; @@ -271,10 +291,17 @@ function doCapture( if (!fetched) throw new Error("Captured bookmark could not be fetched"); // Apply tags and notes after creation if provided - if (input.tags !== undefined || input.notes !== undefined) { + if ( + input.tags !== undefined || + input.notes !== undefined || + input.isPinned !== undefined || + input.readLater !== undefined + ) { const updated = bookmarkRepo.update(fetched.id, { ...(input.tags !== undefined ? { tags: input.tags } : {}), ...(input.notes !== undefined ? { notes: input.notes } : {}), + ...(input.isPinned !== undefined ? { is_pinned: input.isPinned ? 1 : 0 } : {}), + ...(input.readLater !== undefined ? { read_later: input.readLater ? 1 : 0 } : {}), }); if (!updated) throw new Error("Captured bookmark could not be updated"); fetched = updated; @@ -453,6 +480,8 @@ export function createCaptureRoute(deps: CaptureDeps): Hono { let tags: string[] | undefined; let categorySelection: CategorySelection; let notes: string | null | undefined; + let isPinned: boolean | undefined; + let readLater: boolean | undefined; let source: CaptureMetadataInput; try { url = parsePublicUrl(body.url, "url"); @@ -461,6 +490,8 @@ export function createCaptureRoute(deps: CaptureDeps): Hono { categorySelection = parseCategorySelection(body); validateCategorySelection(categorySelection, categoryRepo); notes = parseOptionalText(body, "notes", MAX_NOTES_LENGTH, { emptyAsNull: true, nullable: true }); + isPinned = parseOptionalBoolean(body, "is_pinned"); + readLater = parseOptionalBoolean(body, "read_later"); source = parseSource(body); } catch (err) { if (err instanceof ValidationError) { @@ -475,6 +506,8 @@ export function createCaptureRoute(deps: CaptureDeps): Hono { title, tags, notes, + isPinned, + readLater, resolveCategory: () => resolveCategoryId(categorySelection, categoryRepo), source, }, diff --git a/daemon/src/routes/settings.ts b/daemon/src/routes/settings.ts index 647c9ee8..514140c7 100644 --- a/daemon/src/routes/settings.ts +++ b/daemon/src/routes/settings.ts @@ -1,4 +1,4 @@ -import { Hono, Context } from "hono"; +import { Hono, Context, type Next } from "hono"; import { settingsManager, redactSettings, validateSettingsPatch } from "../settings.js"; import { log } from "../logger.js"; import { resolveRuntimeSettings } from "../runtime-settings.js"; @@ -41,7 +41,11 @@ export function createSettingsRoute(): Hono { * GET /settings * Returns current settings. API keys are redacted (shown as "***" if set). */ - app.get("/settings", (c) => { + app.get("/settings", async (c, next: Next) => { + if (c.req.header("Accept")?.includes("text/html")) { + await next(); + return; + } const settings = settingsManager.read(); return ok(c, { ...redactSettings(settings), diff --git a/daemon/src/test/integration/browser-integration.test.ts b/daemon/src/test/integration/browser-integration.test.ts index 30f35f58..27d66e00 100644 --- a/daemon/src/test/integration/browser-integration.test.ts +++ b/daemon/src/test/integration/browser-integration.test.ts @@ -55,6 +55,10 @@ describe("browser extension integration", () => { capture: "/capture", taxonomy: "/integrations/browser/v1/taxonomy", }, + capture_fields: { + is_pinned: true, + read_later: true, + }, }, }); }); diff --git a/daemon/src/test/integration/capture.test.ts b/daemon/src/test/integration/capture.test.ts index fe476cd1..b7366d00 100644 --- a/daemon/src/test/integration/capture.test.ts +++ b/daemon/src/test/integration/capture.test.ts @@ -18,6 +18,8 @@ type CaptureResponse = { title: string | null; category_id: string | null; notes: string | null; + is_pinned: 0 | 1; + read_later: 0 | 1; tags: string[]; }; capture: { @@ -96,6 +98,8 @@ describe("capture endpoint", () => { tags: ["TypeScript", "rag", "typescript"], category_id: category.id, notes: "Read after the SQLite notes.", + is_pinned: true, + read_later: true, source: { client: "bookmarklet", source_url: "https://example.com/article-list", @@ -114,6 +118,8 @@ describe("capture endpoint", () => { title: "Captured resource", category_id: category.id, notes: "Read after the SQLite notes.", + is_pinned: 1, + read_later: 1, tags: ["rag", "typescript"], }); expect(json.data.capture).toMatchObject({ @@ -134,6 +140,23 @@ describe("capture endpoint", () => { }); }); + it("rejects non-boolean capture state fields", async () => { + const res = await app.request("/capture", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...bearer(await createToken()), + }, + body: JSON.stringify({ + url: "https://example.com/invalid-capture-state", + is_pinned: 1, + }), + }); + + expect(res.status).toBe(422); + expect(await res.json()).toMatchObject({ detail: "`is_pinned` must be a boolean" }); + }); + it("resolves a root category name when category_id is not supplied", async () => { const token = await createToken(); diff --git a/daemon/src/test/integration/static-frontend.test.ts b/daemon/src/test/integration/static-frontend.test.ts index 8e51396d..2e8383e7 100644 --- a/daemon/src/test/integration/static-frontend.test.ts +++ b/daemon/src/test/integration/static-frontend.test.ts @@ -63,4 +63,19 @@ describe("Static frontend serving", () => { expect(res.status).toBe(200); await expect(res.text()).resolves.toContain("Little Imp"); }); + + it("serves the frontend for a browser navigation to the settings API path", async () => { + const res = await app.request("/settings", { + headers: { Accept: "text/html" }, + }); + + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + await expect(res.text()).resolves.toContain("Little Imp"); + + const apiRes = await app.request("/settings"); + expect(apiRes.status).toBe(200); + expect(apiRes.headers.get("content-type")).toContain("application/json"); + expect((await apiRes.json())?.data).toBeDefined(); + }); }); diff --git a/docs/api-contract.json b/docs/api-contract.json index 3d3d51e1..52cd6e5d 100644 --- a/docs/api-contract.json +++ b/docs/api-contract.json @@ -947,6 +947,14 @@ "maxLength": 100000, "nullable": true }, + "is_pinned": { + "type": "boolean", + "description": "Whether the new bookmark should be pinned" + }, + "read_later": { + "type": "boolean", + "description": "Whether the new bookmark should be added to Read Later" + }, "source": { "ref": "CaptureSource" } @@ -4949,6 +4957,25 @@ ], "additionalProperties": false }, + "capture_fields": { + "type": "object", + "properties": { + "is_pinned": { + "type": "boolean", + "description": "Capture accepts an initial pinned state" + }, + "read_later": { + "type": "boolean", + "description": "Capture accepts an initial Read Later state" + } + }, + "required": [ + "is_pinned", + "read_later" + ], + "additionalProperties": false, + "description": "Optional capture fields supported by this daemon" + }, "limits": { "type": "object", "properties": { diff --git a/docs/openapi.json b/docs/openapi.json index 78bb9c93..dab967c6 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5210,6 +5210,14 @@ "maxLength": 100000, "nullable": true }, + "is_pinned": { + "type": "boolean", + "description": "Whether the new bookmark should be pinned" + }, + "read_later": { + "type": "boolean", + "description": "Whether the new bookmark should be added to Read Later" + }, "source": { "$ref": "#/components/schemas/CaptureSource" } @@ -9199,6 +9207,25 @@ ], "additionalProperties": false }, + "capture_fields": { + "type": "object", + "description": "Optional capture fields supported by this daemon", + "properties": { + "is_pinned": { + "type": "boolean", + "description": "Capture accepts an initial pinned state" + }, + "read_later": { + "type": "boolean", + "description": "Capture accepts an initial Read Later state" + } + }, + "required": [ + "is_pinned", + "read_later" + ], + "additionalProperties": false + }, "limits": { "type": "object", "properties": { From 1c6d44bbb578f6fee09e430b654154aa60d90ddc Mon Sep 17 00:00:00 2001 From: Robert Goniszewski Date: Mon, 7 Sep 2026 12:04:10 +0200 Subject: [PATCH 04/25] test(TASK-138): align browser integration docs check --- scripts/api-docs-generator.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/api-docs-generator.test.ts b/scripts/api-docs-generator.test.ts index 4c22e457..84314836 100644 --- a/scripts/api-docs-generator.test.ts +++ b/scripts/api-docs-generator.test.ts @@ -269,7 +269,7 @@ describe("API documentation generator", () => { expect(markdown).toContain("Content-Type: application/json"); }); - it("documents common local-client error flows without extension or bookmarklet examples", () => { + it("documents common local-client error flows without extension or bookmarklet error examples", () => { const document = buildApiContractDocument(apiContract); const markdown = buildApiMarkdown(document); const problemExamples = document.routes.flatMap((route) => @@ -285,7 +285,6 @@ describe("API documentation generator", () => { ); expect(markdown).toContain('"type": "https://littleimp.app/problems/unprocessable-entity"'); expect(markdown).toContain('WWW-Authenticate: Bearer realm="littleimp-local-integrations"'); - expect(markdown).not.toMatch(/browser extension/i); // The bookmarklet endpoint is documented but must not appear in error-flow example titles expect(problemExamples.map((example) => example.title)).not.toEqual( expect.arrayContaining(["bookmarklet"]) @@ -300,7 +299,6 @@ describe("API documentation generator", () => { expect(markdown).toContain("http://localhost:5173"); expect(markdown).toContain("http://127.0.0.1:3210"); expect(markdown).toContain("Non-loopback origins are ignored"); - expect(markdown).not.toMatch(/browser extension/i); // The bookmarklet endpoint appears in the route table but not in the CORS setup prose expect(markdown).toContain("/capture/bookmarklet"); }); From 0b4ae537821c2267e6c3878ee4c3d1035ed654f7 Mon Sep 17 00:00:00 2001 From: Robert Goniszewski Date: Mon, 7 Sep 2026 12:19:46 +0200 Subject: [PATCH 05/25] docs(TASK-138): clarify integration token usage --- src/pages/Settings.test.tsx | 16 ++++++++++++++++ src/pages/Settings.tsx | 22 ++++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/pages/Settings.test.tsx b/src/pages/Settings.test.tsx index 30ea90b3..00828004 100644 --- a/src/pages/Settings.test.tsx +++ b/src/pages/Settings.test.tsx @@ -57,6 +57,7 @@ const mockedCheckHealthAfterRestore = (api as unknown as { checkHealthAfterResto const mockedGetDiagnostics = (api as unknown as { getDiagnostics: ReturnType }).getDiagnostics; const mockedReprocessBookmarks = (api as unknown as { reprocessBookmarks: ReturnType }).reprocessBookmarks; const mockedGetReprocessStatus = (api as unknown as { getReprocessStatus: ReturnType }).getReprocessStatus; +const mockedListIntegrationTokens = (api as unknown as { listIntegrationTokens: ReturnType }).listIntegrationTokens; const mockedUseBackupList = backupHooks.useBackupList as unknown as ReturnType; const mockedUseCreateBackup = backupHooks.useCreateBackup as unknown as ReturnType; const mockedUseRestoreBackup = backupHooks.useRestoreBackup as unknown as ReturnType; @@ -230,6 +231,7 @@ beforeEach(() => { failed: 0, }, }); + mockedListIntegrationTokens.mockResolvedValue({ data: [] }); mockedUseBackupList.mockReturnValue({ data: [ { @@ -263,6 +265,20 @@ beforeEach(() => { mockedUseRestoreEncryptedBackupPackage.mockReturnValue({ mutate: vi.fn(), isPending: false }); }); +describe("Browser Integration guidance", () => { + it("explains that integration tokens authenticate every supported local client", async () => { + render(, { wrapper: makeWrapper() }); + + expect(await screen.findByText("Browser Integration")).toBeInTheDocument(); + expect(screen.getByText(/official browser extension, bookmarklets, MCP clients/)).toBeInTheDocument(); + expect(screen.getByText(/Create one for the official browser extension, a bookmarklet, an MCP client/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Setup guide" })); + expect(screen.getByText("Using integration tokens")).toBeInTheDocument(); + expect(screen.getByText(/paste the token into its connection screen/)).toBeInTheDocument(); + }); +}); + describe("Settings update checks", () => { it("renders when the daemon returns an older settings shape", async () => { const legacySettings = settingsResponse(); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 123611fd..6adc1c97 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -2284,7 +2284,8 @@ function BrowserIntegration() {

Browser Integration

- Create an integration token and install the bookmarklet to save pages from any browser. + Integration tokens authenticate the official browser extension, bookmarklets, MCP clients, + and other supported local integrations. Create a separate token for each client so you can revoke access independently.

- Then{" "} + Paste the token into the official browser extension or another supported integration. For a bookmarklet, {" "}