From fb1cce443b61ec480066644611bb7541262bf74a Mon Sep 17 00:00:00 2001 From: Kendrik Felty Date: Thu, 23 Jul 2026 19:01:52 -0500 Subject: [PATCH 01/19] reset Workshop architecture for 0.5.0 --- .github/workflows/node-sqlite-parity.yml | 17 +- .gitignore | 1 + .prettierignore | 7 +- CHANGELOG.md | 23 + COMPATIBILITY.md | 58 +- README.md | 233 +- docs/architecture-reset.md | 61 + docs/architecture.md | 65 - docs/authentication.md | 37 - docs/authorization-foundation.md | 51 - docs/breaking-0.5.md | 49 + docs/codex-sites.md | 17 - docs/http-api.md | 37 - docs/limits.md | 43 - docs/mcp.md | 57 - docs/migrations.md | 44 - docs/package-handoff.md | 35 - docs/release-checklist.md | 41 - docs/release-evidence.md | 78 - docs/release-provenance.md | 40 - docs/release-provenance.schema.json | 105 - docs/security.md | 56 - docs/troubleshooting.md | 17 - docs/ui-and-onboarding.md | 33 - docs/verification.md | 18 - eslint.config.js | 10 +- examples/package-runtime-canary/README.md | 13 - examples/package-runtime-canary/worker.ts | 111 - .../package-runtime-canary/wrangler.jsonc | 14 - migrations/0007_task_leases.sql | 42 + migrations/0008_bootstrap_credentials.sql | 40 + package-lock.json | 2490 +---------------- package.json | 84 +- schemas/gnolith-operations-v1.json | 2240 +++++++++++++++ schemas/legacy-operation-names-v1.json | 24 + schemas/workshop-config-v1.json | 144 + scripts/clean.mjs | 2 +- scripts/consumer-smoke.mjs | 296 -- scripts/copy-assets.mjs | 7 - scripts/dev-security-smoke.mjs | 206 +- scripts/generate-operation-contract.mjs | 682 +++++ scripts/node-sqlite-parity.mjs | 562 ++-- scripts/packed-process-smoke.mjs | 246 ++ scripts/prepare-candidate.mjs | 101 + scripts/prepublish-check.mjs | 4 +- scripts/release-block.mjs | 24 +- scripts/waystone-consumer-smoke.mjs | 209 -- scripts/worker-e2e.mjs | 74 - src/backup.ts | 239 ++ src/bootstrap.ts | 322 +++ src/cli.ts | 123 + src/client.ts | 11 + src/core.ts | 13 +- src/executable.ts | 150 + src/generated/operations.ts | 2240 +++++++++++++++ src/index.ts | 9 +- src/legacy-owner.ts | 399 +++ src/mcp.ts | 15 +- src/mcp/dispatcher.ts | 527 ---- src/mcp/server.ts | 361 ++- src/mcp/tools.ts | 658 ----- src/migrations.ts | 120 +- src/migrations/manifest.ts | 103 +- src/operations.ts | 360 +++ src/producers.ts | 111 + src/protocol.ts | 26 +- src/protocol/authorization.ts | 2 +- src/protocol/catalog.ts | 41 + src/protocol/client.ts | 389 +-- src/resource-blobs.ts | 488 ++++ src/server.ts | 18 +- src/server/assets.ts | 249 ++ src/server/auth.ts | 459 +++ src/server/authorization.ts | 3 +- src/server/canonical-producer.ts | 139 + src/server/config.ts | 291 ++ src/server/health.ts | 4 +- src/server/lifecycle.ts | 102 + src/server/memories.ts | 21 +- src/server/prompts.ts | 26 +- src/server/search.ts | 827 +++--- src/server/service.ts | 496 ++++ src/server/tasks.ts | 520 +++- src/site.ts | 1 - src/site/handlers.ts | 591 ---- src/ui.ts | 4 - src/ui/components.tsx | 17 - src/ui/configuration.ts | 23 - src/ui/dashboard-screen.tsx | 54 - src/ui/dashboard.tsx | 37 - src/ui/entity-tasks-screen.tsx | 47 - src/ui/error-notice.tsx | 20 - src/ui/loading-status.tsx | 7 - src/ui/mcp-status-screen.tsx | 40 - src/ui/mcp-status.tsx | 22 - src/ui/memories-screen.tsx | 148 - src/ui/memories.tsx | 103 - src/ui/onboarding-screen.tsx | 114 - src/ui/onboarding.tsx | 160 -- src/ui/plugin.tsx | 150 - src/ui/primitives.tsx | 23 - src/ui/styles/workshop.css | 207 -- src/ui/task-controls.tsx | 201 -- src/ui/task-packet.tsx | 56 - src/ui/task-workspace.tsx | 144 - src/ui/tasks-screen.tsx | 117 - src/ui/tasks.tsx | 160 -- src/ui/use-workshop-client.ts | 15 - test/architecture-reset.test.ts | 257 ++ test/authorization.test.ts | 12 +- test/bootstrap-exact.test.ts | 142 + test/executable-service.test.ts | 151 + test/helpers.ts | 28 +- test/mcp-dispatcher.test.ts | 261 -- test/mcp.test.ts | 213 -- test/migrations.test.ts | 118 +- test/onboarding.test.ts | 2 +- test/protocol.test.ts | 101 - test/release-boundary.test.ts | 4 +- test/release-history.test.ts | 2 +- test/resource-blobs.test.ts | 169 ++ test/routes.test.ts | 209 -- test/search.integration.test.ts | 55 +- test/security-reset.test.ts | 207 ++ test/task-leases.test.ts | 111 + test/ui-screens.test.tsx | 233 -- test/ui.test.tsx | 101 - tsconfig.build.json | 16 +- tsconfig.json | 1 + vitest.config.ts | 4 +- 130 files changed, 12983 insertions(+), 10585 deletions(-) create mode 100644 docs/architecture-reset.md delete mode 100644 docs/architecture.md delete mode 100644 docs/authentication.md delete mode 100644 docs/authorization-foundation.md create mode 100644 docs/breaking-0.5.md delete mode 100644 docs/codex-sites.md delete mode 100644 docs/http-api.md delete mode 100644 docs/limits.md delete mode 100644 docs/mcp.md delete mode 100644 docs/migrations.md delete mode 100644 docs/package-handoff.md delete mode 100644 docs/release-checklist.md delete mode 100644 docs/release-evidence.md delete mode 100644 docs/release-provenance.md delete mode 100644 docs/release-provenance.schema.json delete mode 100644 docs/security.md delete mode 100644 docs/troubleshooting.md delete mode 100644 docs/ui-and-onboarding.md delete mode 100644 docs/verification.md delete mode 100644 examples/package-runtime-canary/README.md delete mode 100644 examples/package-runtime-canary/worker.ts delete mode 100644 examples/package-runtime-canary/wrangler.jsonc create mode 100644 migrations/0007_task_leases.sql create mode 100644 migrations/0008_bootstrap_credentials.sql create mode 100644 schemas/gnolith-operations-v1.json create mode 100644 schemas/legacy-operation-names-v1.json create mode 100644 schemas/workshop-config-v1.json delete mode 100644 scripts/consumer-smoke.mjs delete mode 100644 scripts/copy-assets.mjs create mode 100644 scripts/generate-operation-contract.mjs create mode 100644 scripts/packed-process-smoke.mjs create mode 100644 scripts/prepare-candidate.mjs delete mode 100644 scripts/waystone-consumer-smoke.mjs delete mode 100644 scripts/worker-e2e.mjs create mode 100644 src/backup.ts create mode 100644 src/bootstrap.ts create mode 100644 src/cli.ts create mode 100644 src/client.ts create mode 100644 src/executable.ts create mode 100644 src/generated/operations.ts create mode 100644 src/legacy-owner.ts delete mode 100644 src/mcp/dispatcher.ts delete mode 100644 src/mcp/tools.ts create mode 100644 src/operations.ts create mode 100644 src/producers.ts create mode 100644 src/protocol/catalog.ts create mode 100644 src/resource-blobs.ts create mode 100644 src/server/assets.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/canonical-producer.ts create mode 100644 src/server/config.ts create mode 100644 src/server/lifecycle.ts create mode 100644 src/server/service.ts delete mode 100644 src/site.ts delete mode 100644 src/site/handlers.ts delete mode 100644 src/ui.ts delete mode 100644 src/ui/components.tsx delete mode 100644 src/ui/configuration.ts delete mode 100644 src/ui/dashboard-screen.tsx delete mode 100644 src/ui/dashboard.tsx delete mode 100644 src/ui/entity-tasks-screen.tsx delete mode 100644 src/ui/error-notice.tsx delete mode 100644 src/ui/loading-status.tsx delete mode 100644 src/ui/mcp-status-screen.tsx delete mode 100644 src/ui/mcp-status.tsx delete mode 100644 src/ui/memories-screen.tsx delete mode 100644 src/ui/memories.tsx delete mode 100644 src/ui/onboarding-screen.tsx delete mode 100644 src/ui/onboarding.tsx delete mode 100644 src/ui/plugin.tsx delete mode 100644 src/ui/primitives.tsx delete mode 100644 src/ui/styles/workshop.css delete mode 100644 src/ui/task-controls.tsx delete mode 100644 src/ui/task-packet.tsx delete mode 100644 src/ui/task-workspace.tsx delete mode 100644 src/ui/tasks-screen.tsx delete mode 100644 src/ui/tasks.tsx delete mode 100644 src/ui/use-workshop-client.ts create mode 100644 test/architecture-reset.test.ts create mode 100644 test/bootstrap-exact.test.ts create mode 100644 test/executable-service.test.ts delete mode 100644 test/mcp-dispatcher.test.ts delete mode 100644 test/mcp.test.ts delete mode 100644 test/protocol.test.ts create mode 100644 test/resource-blobs.test.ts delete mode 100644 test/routes.test.ts create mode 100644 test/security-reset.test.ts create mode 100644 test/task-leases.test.ts delete mode 100644 test/ui-screens.test.tsx delete mode 100644 test/ui.test.tsx diff --git a/.github/workflows/node-sqlite-parity.yml b/.github/workflows/node-sqlite-parity.yml index 898ba32..8677be9 100644 --- a/.github/workflows/node-sqlite-parity.yml +++ b/.github/workflows/node-sqlite-parity.yml @@ -20,15 +20,26 @@ jobs: - run: npm ci - name: Pack the pinned Diamond source env: - DIAMOND_COMMIT: 326da7e48d77cddc5a58e21cf65d872ecb9b3b74 + DIAMOND_COMMIT: fe9185a4dae73330969866d8871cefad6fc7e87c run: | git clone --filter=blob:none https://github.com/gnolith/diamond.git "$RUNNER_TEMP/diamond" git -C "$RUNNER_TEMP/diamond" checkout --detach "$DIAMOND_COMMIT" test "$(git -C "$RUNNER_TEMP/diamond" rev-parse HEAD)" = "$DIAMOND_COMMIT" npm ci --prefix "$RUNNER_TEMP/diamond" npm pack "$RUNNER_TEMP/diamond" --pack-destination "$RUNNER_TEMP" + - name: Pack the pinned Taproot source + env: + TAPROOT_COMMIT: d3a7186f78f6695d3d73bb408ef89b91e0eb2431 + run: | + git clone --filter=blob:none https://github.com/gnolith/taproot.git "$RUNNER_TEMP/taproot" + git -C "$RUNNER_TEMP/taproot" checkout --detach "$TAPROOT_COMMIT" + test "$(git -C "$RUNNER_TEMP/taproot" rev-parse HEAD)" = "$TAPROOT_COMMIT" + npm ci --prefix "$RUNNER_TEMP/taproot" + npm pack "$RUNNER_TEMP/taproot" --pack-destination "$RUNNER_TEMP" - name: Verify packed-artifact node:sqlite parity env: - DIAMOND_TARBALL: ${{ runner.temp }}/gnolith-diamond-0.4.0.tgz - DIAMOND_TARBALL_SHA256: ac8e34810a8504bd891b36a199973f2d65ef652555644813c7a36f1c7016c9d2 + DIAMOND_TARBALL: ${{ runner.temp }}/gnolith-diamond-0.5.0.tgz + DIAMOND_TARBALL_SHA256: 6a8075a70162fe2567579f7fe110e674509b648923b18fdb310833b44739c300 + TAPROOT_TARBALL: ${{ runner.temp }}/gnolith-taproot-0.5.0.tgz + TAPROOT_TARBALL_SHA256: 9449a748b04926462f9549833a1a256e9fc4d7c557b01b6e3983606627005480 run: node scripts/node-sqlite-parity.mjs diff --git a/.gitignore b/.gitignore index db2990a..82f84e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ coverage/ +.artifacts/ .wrangler/ .release/ *.tgz diff --git a/.prettierignore b/.prettierignore index 7706f1f..3c689e5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,2 @@ -node_modules/ -dist/ -coverage/ -.wrangler/ -*.tgz +src/generated/** +schemas/*.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f8d50..60980cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.5.0] + +- Replaced the UI/Site/Worker package with the `gnolith-workshop` self-hosted + executable, one listener, authenticated MCP and BFF, and explicit offline + bootstrap. +- Generated one 51-operation `gnolith` catalog shared by schemas, MCP, BFF, + validation, and the generic client. +- Added separate local-bearer and remote-OAuth profiles, fail-closed readiness, + verified immutable Waystone assets, bounded lifecycle controls, task leases, + and owner-scoped backup APIs. +- Added a read-only, bounded exact-0.4.2 legacy-owner decoder and owner-bound + adoption adapter with opaque fragments, migration-ledger evidence, digests, + and Task/Memory/Prompt/revision contribution counts. +- Coupled task lease claim, release, and completion atomically to canonical + source/history updates, durable idempotency receipts, and lease audit events. +- Added schema 8 durable credential/bootstrap receipts and an exact Taproot + authorization-bootstrap owner adapter. +- Added public bounded local-filesystem ResourceBlob backup/restore ports with + private content-addressed paths, verified staging, collision checks, rebinds, + rollback, and symlink/traversal defenses. +- Removed legacy operation aliases, UI and CSS exports, Site route factories, + stdio transport, and the parallel hand-written MCP catalog. + All notable changes follow Keep a Changelog and Semantic Versioning. ## [0.4.2] - 2026-07-22 diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 281a201..c83c137 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -1,48 +1,14 @@ # Compatibility -Workshop 0.4.x targets: - -- Diamond `>=0.4.0 <0.5.0` through injected SQLite/query services -- Taproot `>=0.4.0 <0.5.0`; this breaking line includes canonical external - Task, Memory, and Prompt producer registration, sealed atomic mutations, - bounded legacy adoption, authorized hydration, durable materialization, and - semantic-search administration. -- Waystone plugin contract `>=0.1.0 <0.3.0`; released Waystone 0.2.x accepts - Workshop's structural plugin shape through its public - `WorkshopCompatibleWaystonePlugin` and `createWaystoneRegistry` boundary. -- React `>=19 <20` -- TypeScript `>=5.9 <6` -- MCP protocol `2025-11-25`, with `2025-06-18` and `2025-03-26` negotiation -- MCP TypeScript SDK `1.29.x` for interoperability verification -- vinext `1.0.0-beta.2` (isolated package-consumer target) -- Cloudflare Workers compatibility date `2026-07-20` or later -- Node.js 22 or 24 for development and release tooling - -The exact Workshop tarball compiles in isolated vinext/Vite and Worker -consumers. A separate disposable consumer installs the exact tarball with -public Waystone 0.2.0 and React 19 through normal npm peer resolution, compiles -the public declarations, and registers the Workshop plugin through Waystone's -public compatibility adapter. These fixtures inject or stub Diamond, Taproot, -identity, and host bindings; they verify Workshop package compatibility only -and perform no remote deployment. They do not assemble or qualify a complete -Gnolith Site. - -The generic exact-package declaration consumer also compiles the required -Task/Memory history methods on `WorkshopClient` and their process-core service -signatures, including the shared bounded history options. Its public-version -mode repeats normal npm resolution after publication. - -CI also packs released Taproot 0.4.0 source commit -`819fe054ebb867e1ca92518bfd3b1aa6c5aa277d` and checks bidirectional structural -compatibility for authorization contexts and visibility scopes, the real -authorized reader, host-capability-bound guards, and all three Workshop search -producer domains. The packed runtime lane exercises native SQLite and persisted -Miniflare D1, including D1 disposal/recreation and cursor continuation. It also -asserts that raw repository/deep-result surfaces are absent. This -package-to-package lane detects contract drift without provisioning or accepting -a complete Site. - -The Codex agent creating a Site owns the compatibility of the complete host -bundle, including its chosen peer entry points, runtime flags, infrastructure, -and deployment configuration. Compatibility range changes still require a -documented Workshop release. +Workshop 0.5 is a breaking reset and requires Diamond and Taproot 0.5 candidate +APIs. It supports Node.js 22 or newer with native SQLite or an injected D1-like +persistence adapter. + +Removed surfaces: `@gnolith/workshop/ui`, `@gnolith/workshop/site`, CSS exports, +Worker-specific handlers, stdio MCP, and the legacy tool dispatcher. Removed +operation aliases are documented only in +`schemas/legacy-operation-names-v1.json` and are never accepted at runtime. + +Waystone is optional static content. Workshop serves only a verified immutable +`waystone-assets-v1` manifest and does not import or initialize a Waystone +runtime. diff --git a/README.md b/README.md index 365b9a2..3170c88 100644 --- a/README.md +++ b/README.md @@ -1,209 +1,74 @@ -# Workshop +# @gnolith/workshop -`@gnolith/workshop` is Gnolith's agent-facing operating layer: durable research -tasks, ephemeral task packets, reusable memories, canonical prompts, -authorization-aware unified search and graph access, Taproot-backed knowledge -tools, Streamable HTTP MCP, Worker route factories, and the Workshop Waystone UI. +Workshop 0.5 is the self-hosted Gnolith executable. It owns one HTTP listener, +the authenticated `/mcp` endpoint, authenticated `/health/ready`, live +`/health/live`, and the `/api/v1/operations/*` BFF. -One Codex Site is one research project. Workshop operates inside that Site-wide -graph and task system; it does not add projects, memberships, agent sessions, -packet snapshots, or a background coordinator. +The release is intentionally headless. The former React UI, Site route +factories, Worker runtime, stdio transport, and hand-written parallel tool +catalog are not part of 0.5. -## Runtime surfaces +## Executable -| Import | Runtime | Purpose | -| ------------------------------ | -------------- | ----------------------------------------------------- | -| `@gnolith/workshop` | any | Package identity and safe shared types | -| `@gnolith/workshop/core` | any | Process-local services and authorized tool dispatch | -| `@gnolith/workshop/protocol` | browser/Worker | Models, errors, and injectable HTTP client | -| `@gnolith/workshop/server` | Worker/process | Persistence services, health, and HTTP adapters | -| `@gnolith/workshop/mcp` | any/Worker | Neutral tool dispatcher and stateless HTTP MCP server | -| `@gnolith/workshop/site` | Worker | Thin App Router-compatible route factories | -| `@gnolith/workshop/ui` | browser | React 19 components and `workshopPlugin` | -| `@gnolith/workshop/migrations` | installer | Embedded canonical migration manifest | -| `@gnolith/workshop/styles.css` | browser | Workshop-specific styles | - -The root export intentionally does not import all runtimes. - -## Process-local integration - -Core services run directly in a process without HTTP, a UI, or a listening -server. The host injects one structural SQLite persistence capability, the same -live atomic authorization authority used by Taproot, an opaque cursor codec, -and read-authorized knowledge/health adapters: - -```ts -import { createWorkshopCore } from '@gnolith/workshop/core'; -import { createWorkshopSearchIntegrationV1 } from '@gnolith/workshop/server'; - -const search = await createWorkshopSearchIntegrationV1({ - db: persistence, - taproot, - hostCapability, - installationId, - registrationContext: principal, -}); - -const workshop = createWorkshopCore({ - persistence, - authorization, - cursorCodec, - knowledge: { authorizedReader, health: taprootHealth }, - diamondHealth, - search, -}); +```sh +gnolith-workshop serve --config /run/gnolith/workshop.json ``` -Workshop owns its exact migrations and service behavior. Seedbed owns database -paths, adapter lifecycle, stdio/CLI wiring, Docker, and package assembly. +The JSON config uses format `gnolith-workshop-config-v1`, selects exactly one +authentication profile (`local-bearer-v1` or `remote-oauth-v1`), declares an +explicit listener profile, and points to an adapter module that supplies the +Diamond and Taproot public ports. Startup is fail-closed: it does not apply +migrations or bootstrap authorization. -## Install +Offline first-owner bootstrap never opens a socket: ```sh -npm install @gnolith/workshop @gnolith/diamond @gnolith/taproot react +gnolith-workshop bootstrap-v1 \ + --config /run/gnolith/workshop.json \ + --installation-id installation:example \ + --base-iri https://example.invalid/ \ + --principal principal:owner \ + --workspace workspace:default \ + --credential-file /run/secrets/workshop-token \ + --idempotency-key initial-owner-v1 ``` -Workshop targets Node.js 22+ for development and Web API runtimes. Its published -package is checked in isolated Worker and vinext consumers without the Workers -Node compatibility flag. Those consumers inject or stub peer services and do -not represent a complete Gnolith Site. +The local bearer credential file must contain exactly 32 random bytes encoded +as unpadded base64url, optionally followed by one LF. The token is never a +command argument and only its domain-separated digest is persisted. +Bootstrap requires Workshop schema version 8 and commits that credential digest +plus an owner receipt through Taproot's sealed owner-adapter API. -## Site integration - -Create the runtime once from host-provided D1, identity, Diamond, and Taproot -services. Authentication belongs to the Site; role labels on tasks never grant -permissions. +Local resource payload backup and restore use the public server-only factory: ```ts -import { createWorkshopRuntime } from '@gnolith/workshop/server'; - -export const workshop = createWorkshopRuntime({ - db: env.DB, - authorization: siteAuthorization, - cursorCodec: siteCursorCodec, - knowledge: { - authorizedReader: createAuthorizedReader, - health: taprootHealth, - }, - diamondHealth: siteDiamond.health, - resolvePrincipal: authenticateWorkshopRequest, - search: workshopSearch, +import { createLocalFilesystemResourceBlobPortsV1 } from '@gnolith/workshop/backup'; + +const blobs = await createLocalFilesystemResourceBlobPortsV1({ + root: '/var/lib/gnolith/blobs', }); ``` -`workshopSearch` is created once with `createWorkshopSearchIntegrationV1` after -Taproot and Workshop migrations. The factory initializes Taproot's durable -materialization state before registering the Task, Memory, and Prompt producers. -All three domains commit the Workshop mutation, immutable revision snapshot, -and canonical source event through Taproot's sealed atomic boundary. Legacy -adoption and rebuilds are explicit bounded `search:admin` operations. +The returned `backup` and `restore` ports accept only Taproot descriptors, +streams, stage IDs, and opaque reference IDs. Filesystem layout remains private +to Workshop. -`siteAuthorization` remains the single shared live source for authorized reads, -backfill maintenance, and cursor snapshots. Host-issued guards must preserve -exact capabilities and never add or translate them. -`createAuthorizedReader` must return the public `AuthorizedTaprootReader` bound -to that same source. Knowledge mutations are unavailable until that shared -foundation is complete. +## Protocol -Generated App Router files stay thin: +The sole MCP identity is `gnolith`. The generated operation contract contains +exactly 51 version-1 operations and is checked into +`schemas/gnolith-operations-v1.json`. The same generated registry drives MCP, +the BFF, runtime validation, and the generic browser-safe client: ```ts -import { createWorkshopMcpHandler } from '@gnolith/workshop/site'; -import { workshop } from '@/lib/gnolith/server'; - -const handler = createWorkshopMcpHandler(workshop); -export const GET = handler; -export const POST = handler; -``` - -Consumers apply migrations explicitly before constructing the runtime. Runtime -construction never creates tables. `workshopMigrations` provides canonical SQL -and checksums; `applyWorkshopMigrations(persistence)` applies only -Workshop-owned schema through Diamond's shared namespaced checksum ledger when -the host chooses to initialize or migrate. - -## Core behavior - -- Task creation validates every field, statically rejects SPARQL writes and - unsafe query forms, and verifies all memory references before inserting. -- `claim_task` is one conditional SQLite update. Concurrent callers cannot both win. -- Completion is one conditional update and accepts negative or inconclusive - nonempty results. -- Task packets resolve authorized current memories on every read. Unscoped - context-query execution is disabled until the host supplies a scoped graph - boundary. Packet reads do not claim or mutate tasks. -- Task, Memory, and Prompt histories are bounded newest-first canonical - snapshots. Each snapshot is authorized independently, and the exact current - record plus live installation authorization are rechecked immediately before - content returns. -- Knowledge reads use Taproot's authorized-reader boundary. Mutations are - fail-closed and are not advertised by Workshop's MCP surface. -- MCP authenticates every request and authorizes every tool server-side. -- Detailed diagnostics require exact `admin`; abandoned-claim reset requires - exact `admin` plus `task-write`. Capabilities never imply one another. These - operations are not normal MCP tools. - -## Development - -```sh -npm ci -npm run check -``` +import { createWorkshopClient } from '@gnolith/workshop/client'; -The gate covers formatting, lint, strict types, coverage, local D1 integration -and concurrency, MCP, routes, interactive UI, build, performance, generic -exact-tarball validation, isolated Worker and vinext package consumers, audit, -readiness, and release-artifact invariants. One generated tarball and its -machine-verifiable provenance are reused by every package consumer and release -check. Peer services in the runtime consumers are injected or stubbed. See -[`docs/release-provenance.md`](docs/release-provenance.md). - -Configure the Waystone contribution with a browser-only client. Construction is -lazy, so importing the UI never initializes a server runtime or captures host -bindings: - -```tsx -import { createWorkshopClient } from '@gnolith/workshop/protocol'; -import { createWorkshopPlugin } from '@gnolith/workshop/ui'; - -export const workshopUi = createWorkshopPlugin({ - client: () => - createWorkshopClient({ - baseUrl: '', - token: () => sessionToken(), - }), - capabilities: currentCapabilities, - loadMcpStatus: loadWorkshopMcpStatus, +const client = createWorkshopClient({ + baseOrigin: 'https://gnolith.example', + accessToken: async () => obtainAccessToken(), }); +const status = await client.call('gnolith_status', {}); ``` -Legacy onboarding journals remain quarantined and are not exposed by the -public core, protocol, MCP, HTTP, or UI surfaces. A separately scoped reusable -exploration skill will own future onboarding behavior. - -`Workshop package handoff ready` means these package-owned gates pass against -the exact tarball. It does not qualify a complete Gnolith Site. The Codex agent -creating a Site owns four-package assembly, infrastructure and migrations, -identity and secrets, deployment configuration, live browser/MCP/Codex probes, -and final acceptance. - -## Documentation - -- [Architecture and domain semantics](docs/architecture.md) -- [HTTP and client API](docs/http-api.md) -- [MCP tools](docs/mcp.md) -- [Authentication and authorization](docs/authentication.md) -- [Migrations and upgrades](docs/migrations.md) -- [Authorization foundation ledger](docs/authorization-foundation.md) -- [Waystone UI](docs/ui-and-onboarding.md) -- [Health and semantic verification](docs/verification.md) -- [Operational limits](docs/limits.md) -- [Security and threat assumptions](docs/security.md) -- [Codex Sites compatibility](docs/codex-sites.md) -- [Troubleshooting](docs/troubleshooting.md) -- [Release checklist and current evidence](docs/release-checklist.md) -- [Package handoff contract](docs/package-handoff.md) - -## License - -MIT +See [the architecture-reset guide](docs/architecture-reset.md) for deployment, +authentication, migration, lease, backup, and compatibility details. diff --git a/docs/architecture-reset.md b/docs/architecture-reset.md new file mode 100644 index 0000000..4de9f13 --- /dev/null +++ b/docs/architecture-reset.md @@ -0,0 +1,61 @@ +# Workshop 0.5 architecture reset + +Workshop is one process with one HTTP listener. `/health/live` is a +dependency-free process probe. `/health/ready`, `/mcp`, and the BFF authenticate +before reading or disclosing protected state. Readiness reports migration, +authorization, canonical data, lexical index, producer, blob, dependency +compatibility, version, and operation-catalog digest state. + +Container binds must be explicit, non-loopback, and non-ephemeral. Remote binds +must use an explicit port. Only the test profile permits port zero. Shutdown +stops acceptance, drains requests, aborts and checkpoints bounded background +work, and closes injected dependencies. + +`local-bearer-v1` and `remote-oauth-v1` are separate profiles with no fallback. +Local authentication accepts only the Authorization bearer header, compares a +domain-separated digest in constant time, and rechecks the live authorization +fence. OAuth validates signature through an injected verifier and then enforces +issuer, audience, allowed algorithm, time bounds, required scopes, server-owned +scope mappings, and live principal resolution. + +Migrations are explicit operator actions. Schema version 7 adds opaque, +expiring task leases, owner and revision fencing, idempotent release receipts, +and lease audit events. Schema version 8 adds durable local credential records +and owner-bound bootstrap receipts. Raw bearer and lease tokens are never +stored. +Backup APIs operate through Workshop owner and migration-ledger handles, report +legacy counts during inspection, and expire imported task leases. + +Local resource payloads use +`createLocalFilesystemResourceBlobPortsV1` from +`@gnolith/workshop/backup`. The factory receives one absolute, operator-owned +root and returns Taproot's `ResourceBlobBackupPort` and +`ResourceBlobRestorePort`. Reference IDs stay opaque: neither Seedbed nor an +operator runner derives a path from them. Workshop privately manages the +content-addressed objects, manifest, staging, verification, collision checks, +canonical rebinds, and rollback beneath that root. + +The operation registry is generated from one source and validated with Ajv at +the central operation layer. MCP and BFF adapters cannot add operations or +aliases. Search results use exactly seven public kinds: `statement`, `item`, +`task`, `memory`, `prompt`, `resource`, and `annotation`. Property results use +kind `item` with `entityType: "property"`. + +Task, Memory, and Prompt mutations cross Taproot's external-producer owner +boundary with one-shot Workshop statement tokens. On update, Workshop reads the +sealed predecessor from the exact registered producer and passes the returned +frozen event identity and sequence unchanged to the next issuer seal. Workshop +does not inspect Taproot search tables or reconstruct predecessor state. + +Exact Workshop 0.4.2 owner state is transferred through the public +`@gnolith/workshop/backup` library. `decodeWorkshopLegacyOwnerV042` accepts a +read-only portable database capability plus exact package/schema and +Workshop-namespace ledger attestation. It reads only a fixed Workshop-owned +table allowlist and returns a bounded opaque fragment, SHA-256 evidence, table +counts, and explicit Task/Memory/Prompt/revision contribution counts. +`adoptWorkshopLegacyOwnerV042` passes only a verified fragment to an +owner-bound destination handle; it never mutates the source or reads Seedbed, +Diamond, or Taproot tables. + +Optional static content is served only after canonical manifest and per-file +SHA-256 verification. Reserved service prefixes cannot be shadowed. diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index d6f524b..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,65 +0,0 @@ -# Architecture and domain semantics - -Workshop sits above Diamond and Taproot. Diamond owns quads and query internals; -Taproot owns canonical entities, revisions, RDF projection, and unified-search -materialization; Workshop owns agent-facing tasks, memories, prompts, their -canonical producer adapters, HTTP/MCP, and UI. Workshop routes -knowledge reads only through Taproot's authorized reader. Knowledge mutations -are currently unavailable, and no Workshop path writes RDF directly. - -Reads use `AuthorizedTaprootReader` bound to the same live authorization source -that guards Task, Memory, and Prompt operations; raw repositories are not adapter -surfaces. A CI lane packs the exact released Taproot 0.4 producer contract -and compiles the public boundary. This is package-runtime compatibility, not -complete-Site assembly or acceptance. - -## Tasks - -Tasks persist title/objective, description, constraints, acceptance criteria, -relationships, assignment, language/attribution, advisory role, prompt, context -queries, memory slugs, claim fields, completion/archive timestamps, typed -outcome, and timestamps. State is derived in this order: archived, completed, -claimed, unclaimed. There is no status column, claim token, or ordinary unclaim. - -Create and update share a static context-query validator. It rejects update -forms and unsafe constructs without executing a graph query. Updates and -archives prefer `expectedRevision` and retain `expectedUpdatedAt` as a -compatibility token. When both are supplied, revision is authoritative. Claims -and completion use conditional `UPDATE ... RETURNING` statements through the -Taproot's sealed canonical mutation coordinator, so the Workshop row, revision -snapshot, and source event commit atomically. - -## Memories and Prompts - -Memories include title, applicability, provenance, language, attribution, -policy revision, soft deletion, and immutable history. Prompts are a first-class -canonical domain with ordered/priority metadata, variables, activation state, -visibility policy, soft deletion, and immutable history. Every write requires -the registered search integration and fails closed without it. - -## Unified search - -`createWorkshopSearchIntegrationV1` initializes Taproot materialization before -registering the Task, Memory, and Prompt producer descriptors. Producers use -bounded enumeration (at most 100), exact revision loads, deterministic -projections, policy-authority authorization, and current-reference hydration. -Legacy adoption and materialization/rebuild/semantic operations are explicit -`search:admin` actions; normal search never performs migration or repair. - -## Packets - -A packet is a response, never a row. Every read loads the current task, returns -a bounded fail-closed result for each stored context query, resolves current -authorized memories, rehydrates the owning Task, and records resolution time. -It never claims, executes graph queries, or refreshes in the background. - -## Memories - -Memories hold reusable methods, procedures, conventions, and checklists. Graph -facts belong in Taproot. Memories are slug-addressed, and every write advances -a monotonic revision. Durable idempotency keys are installation-scoped and -conflict if replayed with different content or visibility. - -Historical onboarding schema remains only so existing databases can be opened -and migrated. Onboarding is not a public Workshop service or UI surface in this -release. diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index 1de1b4e..0000000 --- a/docs/authentication.md +++ /dev/null @@ -1,37 +0,0 @@ -# Authentication and authorization - -The host supplies `ResolveWorkshopPrincipal(Request)` and one shared live -`WorkshopAuthorizationSource`. The resolved context is the exact Taproot -authorization shape: installation, principal, active workspace, workspace -memberships, capabilities, and authorization revision. Live installation state -also includes Taproot's search generation. The source must be the same authority -used by Taproot; Workshop does not own a second revision or generation clock. - -Every Task and Memory service operation requires the full context. Reads apply -installation and visibility predicates in SQL before content hydration, then -recheck both policy and live authorization state. A revision or membership -change therefore revokes access immediately, including empty-list and cursor -paths. Cursors are host-keyed, domain-separated, and bound to the complete -normalized grant context, query, filters, authorization revision, and search -generation. Every page hydrates and reauthorizes all `limit + 1` rows, including -the metadata-only sentinel, before returning content. - -Every Workshop capability is exact. `admin` grants only the administrative -operations that explicitly require `admin`; it does not imply `read`, -`task-write`, `memory-write`, `knowledge-write`, or `search:admin`. Principals -that need more than one operation must carry every corresponding capability. -Exact `search:admin` is required for the host-only legacy authorization -backfill. Legacy rows without complete metadata are quarantined and are not -visible through normal services. - -The host provides separate exact Taproot domain guards for Task writes, Memory -writes, Task authorization backfill, Memory authorization backfill, and cursor -snapshot creation. Maintenance and snapshot guards use the shared Taproot fence -without advancing authorization revision, search generation, or an advance ID. -Taproot authorization rejection maps to Workshop's generic `forbidden`; other -database and infrastructure failures remain distinct. - -Consumer browser authentication and machine/MCP bearer authentication may use -different host mechanisms but must resolve to the same transport-neutral -authorization shape. UI capability hints are presentation only. Never put access -tokens in tasks or memories. diff --git a/docs/authorization-foundation.md b/docs/authorization-foundation.md deleted file mode 100644 index 226b30f..0000000 --- a/docs/authorization-foundation.md +++ /dev/null @@ -1,51 +0,0 @@ -# Authorization foundation ledger - -Workshop 0.3.0 establishes the package-local authorization prerequisites for a -future unified search/indexing implementation. It does not claim that unified -search, indexing, or complete-Site acceptance is finished. - -## Implemented here - -- Exact Taproot-shaped authorization contexts and canonical CNF visibility. -- One host-injected live authorization source plus separate atomic Task and - Memory mutation methods backed by Taproot guards bound to exact `task-write` - and `memory-write`. Separate exact `search:admin` maintenance guards fence - Task and Memory authorization backfill, and an exact `read` guard fences - cursor snapshot creation. Workshop has no independent authorization table or - revision authority, and never elevates either domain to `knowledge:write`. - The host adapter maps Taproot authorization rejections to Workshop's generic - `forbidden` response; it must not disguise unrelated database failures as - authorization denials. -- Durable Task and Memory installation, owner, workspace, visibility, and - authorization-revision metadata. -- Installation/visibility SQL candidate filtering before hydration, followed by - policy and live-state rechecks. -- Durable bounded pagination snapshots containing only IDs, revisions, and - ordering metadata; host-keyed domain-separated binding digests; host-sealed - generation/query/domain/grant-bound cursor tokens; cross-installation - isolation; and immediate authorization/search-generation revocation. -- Fail-closed quarantine for legacy rows plus an explicit bounded, resumable, - host-only backfill requiring exact `search:admin`. -- Authorized Taproot entity reads and candidate search through the merged - `AuthorizedTaprootReader` contract. Knowledge writes and unscoped SPARQL - execution are unavailable through public Workshop operations. - -These changes advance the authorization portions of the system foundations -tracked as B03, D03, H01, and H07. They do not complete those broad foundations. - -## Deliberately not implemented - -- A unified Task/Memory/entity index or ranking algorithm. -- A scoped Diamond graph-query interface suitable for public Workshop SPARQL or - packet context queries. -- The host cursor-key lifecycle, rotation, and durable generation store. -- Public onboarding. Legacy journals are retained only for migration history - and are not exported or discoverable through core, HTTP, MCP, or UI surfaces. -- Site assembly, provisioning, deployment, or complete-system acceptance. - -Seedbed (or another host assembler) must inject the shared authorization source, -implement the five exact atomic Workshop guard ports against the same persisted -policy, provide the cursor codec and durable generation state, and coordinate -migrations. Workshop's narrow port is a package contract, not complete-Site -assembly or acceptance. Diamond must expose a scoped graph boundary before -Workshop can safely enable context-query execution. diff --git a/docs/breaking-0.5.md b/docs/breaking-0.5.md new file mode 100644 index 0000000..d016039 --- /dev/null +++ b/docs/breaking-0.5.md @@ -0,0 +1,49 @@ +# Migrating to Workshop 0.5 + +Workshop 0.5 is an architecture reset. Replace Site/Worker route factories and +the React UI with the `gnolith-workshop` executable. Remove imports of +`@gnolith/workshop/site`, `@gnolith/workshop/ui`, and the CSS export. Waystone +may instead provide a verified immutable asset manifest mounted by the same +Workshop listener. + +Replace domain-specific browser-client calls with the generated generic client: + +```ts +await client.call('get_task', { taskId }); +``` + +Use `/api/v1/operations/` for BFF calls and `/mcp` for Streamable +HTTP MCP. There is no stdio transport, GET SSE listener, alternate MCP path, or +`workshop` server identity. + +The complete machine-readable old-name mapping is +`schemas/legacy-operation-names-v1.json`. Granular knowledge mutations map to +`mutate_entity`; old search maintenance names map to the discriminated +`search_admin`; SPARQL dry run maps to `query_sparql` with `dryRun: true`. +SPARQL Update and abandoned-claim reset have no replacement. + +Task claim handling now uses opaque expiring leases. Persist the returned lease +token only in protected worker state and supply it with the exact task revision +to update, complete, or idempotently release the lease. Never retry a release +with a different payload under the same idempotency key. + +Migrations are explicit. An operator runner calls the public Workshop migration +library after Diamond and Taproot migrations; `serve` will fail closed rather +than apply pending migrations. Run offline `bootstrap-v1` only after all owner +migrations succeed. + +Schema 8 is required for bootstrap because the Workshop owner adapter commits +the local credential digest and immutable bootstrap receipt in Taproot's sealed +authorization-bootstrap transaction. + +For filesystem resource backup and restore, instantiate +`createLocalFilesystemResourceBlobPortsV1({ root })` from +`@gnolith/workshop/backup` and pass its `backup` and `restore` ports to the +Taproot/Seedbed orchestration. Do not construct blob paths from reference IDs. + +To transfer an existing exact 0.4.2 database, use +`decodeWorkshopLegacyOwnerV042` and `adoptWorkshopLegacyOwnerV042` from +`@gnolith/workshop/backup`. The decoder requires exact 0.4.2/schema-6 and +Workshop migration-ledger evidence, is read-only, and returns a bounded opaque +Workshop owner fragment with Task, Memory, Prompt, and owned-revision counts. +There is no legacy-adoption CLI and the adapter has no Seedbed table knowledge. diff --git a/docs/codex-sites.md b/docs/codex-sites.md deleted file mode 100644 index ec47170..0000000 --- a/docs/codex-sites.md +++ /dev/null @@ -1,17 +0,0 @@ -# Codex Site consumption boundary - -Workshop is ESM for vinext/Vite, React 19, Next.js App Router conventions, and -Cloudflare Workers. It uses Request, Response, streams, AbortSignal, Web Crypto, -TextEncoder, and performance APIs. Server state is D1-authoritative; runtime -migration discovery, filesystem scanning, lifecycle mutation, and Node built-ins -are absent from package runtime exports. - -The fixture under `examples/package-runtime-canary` bundles the exact Workshop -package with Wrangler without a Node compatibility flag. Its Diamond, Taproot, -identity, and host services are injected or stubbed. It is a non-deployable, -isolated package-runtime check, not a four-package Site canary. - -The Codex agent creating a Site owns assembly of Diamond, Taproot, Waystone, and -Workshop; infrastructure and migration application; host identity, secrets, -and configuration; deployment; live semantic/MCP/browser probes; and final Site -acceptance. Workshop release evidence does not cover those responsibilities. diff --git a/docs/http-api.md b/docs/http-api.md deleted file mode 100644 index 14d2bba..0000000 --- a/docs/http-api.md +++ /dev/null @@ -1,37 +0,0 @@ -# HTTP route factories and browser client - -`@gnolith/workshop/site` exports: - -- `createTasksHandler`, `createTaskHandler`, `createTaskPacketHandler` -- `createTaskClaimHandler`, `createTaskCompleteHandler`, `createTaskHistoryHandler` -- `createMemoriesHandler`, `createMemoryHandler`, `createMemoryHistoryHandler` -- `createPromptsHandler`, `createPromptHandler`, `createPromptHistoryHandler` -- `createSearchHandler`, `createSearchAdminHandler`, `createSearchStatusHandler` -- `createSparqlValidationHandler`, `createSparqlDryRunHandler`, - `createSparqlQueryHandler` -- `createWorkshopMcpHandler`, `createWorkshopHealthHandler`, - `createWorkshopProbeHandler` - -Handlers accept Web `Request` and return Web `Response`, making them suitable for -thin host-framework shims. JSON routes are same-origin by default, -enforce request limits and capabilities, return `{ error: WorkshopErrorBody }`, -and never expose stacks. Health is safe and public. Probe diagnostics (`GET`) -require exact `admin`; the mutating semantic probe (`POST`) requires exact -`admin`, `task-write`, and `memory-write`. -Task archival is revision-conditional: HTTP clients send the task's exact -`revision` value in `X-Workshop-Revision` (preferred) or the legacy exact -`updatedAt` value in `If-Match`. Completed tasks are terminal and cannot later -be archived; the semantic probe uses a separate disposable archive task. - -`createWorkshopClient` from `/protocol` defaults to same-origin -`/api/workshop/*`. It supports a base URL, injectable `fetch`, asynchronous token -callback, `AbortSignal`, and typed `WorkshopError`. It imports no server module -and accesses no browser global until the client is constructed. The typed -`tasks.archive(id, revisionOrUpdatedAt)` supplies the matching revision header -automatically. - -`tasks.history(id, { limit })`, `memories.history(slug, { limit })`, and -`prompts.history(id, { limit })` are required public client operations. Their -GET routes accept the same optional positive `limit` query parameter, bounded -by Workshop's configured maximum page size, and return newest-first revision -arrays with live authorization rechecked immediately before response creation. diff --git a/docs/limits.md b/docs/limits.md deleted file mode 100644 index 390bf7e..0000000 --- a/docs/limits.md +++ /dev/null @@ -1,43 +0,0 @@ -# Operational limits - -Default limits are exported as `defaultWorkshopLimits` and may be lowered by a -Site: - -| Limit | Default | -| ------------------------- | ----------------------: | -| Description | 4 KiB | -| Prompt | 128 KiB | -| Result | 256 KiB | -| Memory content | 128 KiB | -| Linked memories per task | 100 | -| Context queries per task | 20 | -| Query | 16 KiB | -| MCP/JSON request | 1 MiB | -| Tool timeout | 30 seconds | -| Page size | 50 default, 200 maximum | -| Cursor candidate snapshot | 1,000 entries | -| Cursor token | 8 KiB | -| Cursor snapshot lifetime | 15 minutes | -| Expired snapshot cleanup | 100 per new snapshot | - -Limits return structured errors. A query with more than 1,000 visible -candidates fails generically instead of truncating or disclosing a count. -Cursor snapshots contain only IDs, revisions, ordering timestamps, and binding -digests; they never contain Task or Memory text. Stored context queries are -statically validated but are not executed. - -## Local regression measurements - -`npm run performance:check` measures the built package against Miniflare D1 and -fails any common operation above a deliberately generous two-second regression -bound. On 2026-07-21 the full remediation gate measured Task search at 486.27 -ms, a one-query packet at 309.45 ms, MCP initialization at 1.79 ms, `tools/list` -at 0.31 ms, and a Knowledge-service call at 0.18 ms. Compiled UI modules were -42,828 bytes raw / 7,745 bytes gzip. The isolated Workshop Worker package was -689.23 KiB raw / 131.15 KiB gzip and initialized in 37.4 ms in local Miniflare. -These are development-machine package regression baselines, not complete-Site -or production latency claims. -Workshop does not cache claims. Operators should observe packet duration, task -search, MCP initialize/list, knowledge calls, runtime startup, and UI bundle size -before raising limits. The Site-creating agent owns deployed performance -acceptance. diff --git a/docs/mcp.md b/docs/mcp.md deleted file mode 100644 index f28dd0b..0000000 --- a/docs/mcp.md +++ /dev/null @@ -1,57 +0,0 @@ -# MCP tool reference - -`createWorkshopMcpServer` implements stateless Streamable HTTP for MCP -`2025-11-25` and returns JSON responses. POST clients must accept -`application/json` and `text/event-stream`; GET returns 405 because Workshop does -not open a server-notification SSE stream. No task state lives in transport -sessions. - -## Tasks - -`list_tasks`, `search_tasks`, `get_task_packet`, `create_task`, `update_task`, -`archive_task`, `claim_task`, `complete_task`, `task_history`. - -`update_task` and `archive_task` require `expectedRevision` (preferred) or the -legacy exact current `expectedUpdatedAt` -timestamp. This makes concurrent complete/archive transitions deterministic. - -`task_history` requires exact `read`, accepts an optional bounded `limit`, and -returns newest-first canonical revisions only after authorizing every snapshot -and rechecking the exact current Task and live authorization state. - -## Memories - -`list_memories`, `get_memory`, `upsert_memory`, `delete_memory`, -`memory_history`. - -`memory_history` has the same bounded newest-first, per-snapshot authorization, -canonical-identity, and final current-record recheck guarantees as Task history. - -## Prompts and unified search - -`list_prompts`, `get_prompt`, `create_prompt`, `update_prompt`, `delete_prompt`, -`prompt_history`, `search`, `search_status`, `search_admin`. - -`prompt_history` uses the same hardened history read boundary. - -Prompt, Memory, and Task writes are sealed atomically with their canonical -Taproot source events. `search` uses Taproot's authorization-aware result and -hydration boundary. `search_admin` dispatches bounded legacy adoption, -materialization/rebuild/retry, and semantic lifecycle actions; it requires -`search:admin`. Semantic provider and vector-port configuration is a host API -because runtime secrets and capability objects are not JSON tools. - -## Knowledge - -`search_entities`, `get_entity`, `get_entities`, `export_entity_json`. - -Results come from Taproot's `AuthorizedTaprootReader` and include -entity/revision metadata. Knowledge mutations and SPARQL execution are not -registered or advertised; direct mutation calls fail closed. `tools/list` is -deterministic and capability-filtered. Domain failures are `isError` tool -results so agents can correct inputs. Unknown tools remain JSON-RPC errors. -Administrative claim reset is intentionally absent. - -Task context queries remain stored, statically validated metadata. Packet -hydration returns a bounded per-query forbidden result instead of executing -them until a scoped Diamond boundary exists. diff --git a/docs/migrations.md b/docs/migrations.md deleted file mode 100644 index a4037e7..0000000 --- a/docs/migrations.md +++ /dev/null @@ -1,44 +0,0 @@ -# Migrations and upgrade policy - -Workshop owns six exact migrations: `0001_workshop` creates tasks and -memories, `0002_revisions` adds monotonic concurrency tokens, and -`0003_resumable_onboarding` adds durable run and step checkpoints. -`0004_authorization` adds installation, owner, workspace, canonical visibility, -and authorization-revision fields plus candidate-selection indexes. -`0005_cursor_snapshots` adds durable bounded cursor snapshots, metadata-only -entries, expiry cleanup support, and cascading entry deletion. -`0006_canonical_search_domains` adds canonical Task/Memory metadata and event -references, immutable revision snapshots, Prompt storage/history, and Prompt -cursor support. Every entry -has a monotonic ID and SHA-256 in `workshopMigrations`; the same canonical SQL -ships under `migrations/`. - -Consumers may materialize the manifest through their chosen installer or call -`applyWorkshopMigrations(persistence)` explicitly. Runtime construction never -discovers SQL from disk or creates tables. Health compares `workshop_schema`, -required tables, and indexes through the neutral persistence capability. - -The runner uses Diamond 0.4's shared `_gnolith_migrations` ledger under the -`@gnolith/workshop` namespace. Every migration is committed atomically with its -Diamond-computed checksum. Unknown IDs, checksum drift, partial histories, and -newer versions fail closed. A pre-ledger Workshop database is adopted only after -Workshop verifies the tables and revision columns required by its recorded -schema version. Version 4 validation includes the exact authorization table -constraints and composite Memory key; version 5 validates the exact cursor -tables, foreign key, and expiry index. Weakened near-miss schemas and schema -metadata drift are rejected without recording ledger adoption. Version 6 also -validates Prompt/revision tables and the expanded cursor source-kind contract. - -Pre-v4 Task and Memory rows remain deliberately quarantined with null metadata. -The host may inspect readiness and run an explicit bounded, resumable backfill -through the server API with exact `search:admin`. The host executes each batch -behind a domain-specific Taproot maintenance guard; authorization changes at the -fence leave all selected metadata untouched. Nothing auto-assigns legacy content -to an installation or workspace. - -Ordinary process commands must not silently migrate an existing database. -Seedbed owns migration ordering, lifecycle, backup, and recovery. Patch releases -do not make destructive schema changes. Additive upgrades use a new monotonic -file, update the manifest/schema version, and require clean and previous-version -upgrade tests. Destructive changes require explicit migration and compatibility -notes. diff --git a/docs/package-handoff.md b/docs/package-handoff.md deleted file mode 100644 index 9e9143b..0000000 --- a/docs/package-handoff.md +++ /dev/null @@ -1,35 +0,0 @@ -# Package handoff - -## Workshop package handoff ready - -This phase is complete when the exact npm tarball passes the local release gate -and provides the documented protocol, server, MCP, Site route, UI, migration, -health, and observability surfaces. The package owns runtime -boundaries, schemas, validation, tests, assets, and consumer compatibility. -The public process/core and browser-client composition contracts include -required bounded Task and Memory history operations; hosts only route and -authenticate those package-owned services. - -The downstream Site supplies D1, Diamond health, Taproot persistence and its -authorized-reader boundary, the shared live authorization source with separate -host-issued Task, Memory, authorization-maintenance, and cursor-snapshot domain -guards, opaque cursor cryptography/generation state, identity/capabilities, Waystone -registration, deployment configuration, and any browser endpoints used by the -MCP-status controller. Workshop runtime construction never provisions -infrastructure or applies migrations. - -Workshop CI packs released Taproot 0.4.0 source at -`819fe054ebb867e1ca92518bfd3b1aa6c5aa277d` and verifies the typed boundary plus -real native SQLite and persisted D1 runtime behavior. Passing that package gate -still does not assemble or accept a complete Gnolith Site. - -## Ownership boundary - -Workshop may install its exact tarball in isolated Worker, vinext, Miniflare, -and generic consumers with injected or stubbed peer services. These checks -verify the package's exports, runtime boundaries, and supported-consumer -portability. They neither provision infrastructure nor qualify a complete Site. - -The Codex agent creating a Site owns four-package assembly, Site migrations and -bindings, host identity/secrets/configuration, deployment, live probes, and final -acceptance. Site-specific host assets do not belong in the Workshop package. diff --git a/docs/release-checklist.md b/docs/release-checklist.md deleted file mode 100644 index 8da02b3..0000000 --- a/docs/release-checklist.md +++ /dev/null @@ -1,41 +0,0 @@ -# Release checklist - -> **Publication is package-gated.** `release:check` and normal `npm publish` -> require public, provenance-verified `@gnolith/taproot` 0.4.0 plus Workshop's -> clean, tagged, version-consistent artifact provenance. Complete-Site -> acceptance is neither a prerequisite nor evidence for a Workshop release. - -## Workshop package handoff - -1. Confirm the version, peer ranges, changelog, license, and public exports. -2. Run `npm ci && npm run check` on supported Node releases. - This is the package-owned full gate, including security, native SQLite, - persisted D1, and exact packed-peer conformance. -3. Verify migration order, checksums, clean application, and upgrade behavior. -4. Run `npm run artifact:prepare` once; retain its generated commit, environment, - SHA-256, npm integrity, and file-manifest provenance. -5. Install that existing artifact in generic and isolated Worker/vinext - consumers with injected or stubbed peers, then run `artifact:verify`. -6. Confirm the isolated Workshop package consumer needs no undeclared runtime - compatibility flags. -7. Audit MCP tool descriptions/schemas, browser/server dependency boundaries, - health/telemetry safety, documentation, and package contents. -8. Commit the final tree and create the annotated `v0.4.2` tag with explicit - authorization, then regenerate provenance from that clean tagged checkout. -9. Run `npm run release:check -- v0.4.2`; it must verify exact archive contents, - source identity, version uniqueness, the clean commit/tree, and annotated tag - without repacking. Normal `npm publish` derives this tag from the package - version and runs both the Taproot provenance gate and the same Workshop - release check before publication. -10. Push the annotated tag. The tag-driven OIDC workflow must repeat package - verification, publish the verified archive rather than the package - directory, verify exact npm provenance, and only then create the immutable - GitHub Release with provenance assets. Do not create the Release manually. - -## Excluded from Workshop release acceptance - -Workshop release acceptance excludes complete Gnolith Site composition, -infrastructure provisioning, Site migration application, host identity and -secrets, deployment configuration, live HTTP/browser/MCP/Codex probes, and final -Site approval. The Codex agent creating the Site owns those checks. -Package CI must not provision, deploy, assemble, or accept a complete Site. diff --git a/docs/release-evidence.md b/docs/release-evidence.md deleted file mode 100644 index ec2861c..0000000 --- a/docs/release-evidence.md +++ /dev/null @@ -1,78 +0,0 @@ -# Workshop 0.4.2 release evidence - -## Workshop package handoff ready - -Current local package decision: **PACKAGE GATES PASS; TAGGED RELEASE EVIDENCE -PENDING**. -Workshop 0.4.2 requires public `@gnolith/taproot` 0.4.0 with exact npm -integrity `sha512-yYxbrUNnu74zBaxHoywGlgeG2LFz4HMzi2RLcsq83/JVEIkwbWvWZ8tuLpxFYxTAgTXG1/FHddgEJStRupe54A==` -and npm SLSA provenance resolving annotated `v0.4.0` to source commit -`819fe054ebb867e1ca92518bfd3b1aa6c5aa277d`. A clean matching -annotated Workshop tag and regenerated artifact provenance remain required. -Normal `npm publish` derives the matching tag from the package version and runs -both the Taproot provenance gate and Workshop's clean/tagged provenance check. -This is not authorization to tag or publish. - -The trusted tag-driven workflow creates the immutable GitHub Release only after -the verified archive is public through npm OIDC and exact npm provenance has -been revalidated. A manually pre-created Release is not a valid trigger. - -The immutable GitHub `v0.3.2` tag and Release are historical only. The OIDC -workflow stopped before npm publication, so `@gnolith/workshop@0.3.2` and its -npm provenance do not exist and must not be consumed. - -The current verification environment was Microsoft Windows NT 10.0.26200.0, -Node.js v24.14.0, npm 11.9.0, and Git 2.45.1.windows.1. System Git supplied -`core.autocrlf=true`; the repository `.gitattributes` policy resolved tracked -text to `eol: lf`, and the normal `npm run format:check` command passed. CI also -defines a bounded Windows regression that sets `core.autocrlf=true` before -checkout and runs the same format command after `npm ci`. - -| Package-owned gate | Evidence | Status | -| ---------------------------------------------- | ----------------------------------------------------------------------------- | ---------------- | -| Format, lint, strict types, tests, coverage | Full current `npm run check`; exact totals recorded by the command | PASS | -| D1 migration, schema, task/memory/prompt races | Miniflare clean-schema and integration tests | PASS | -| Embedded SQLite parity | Exact Diamond/Workshop tarballs; reopen, migrations, health, contention | PASS | -| Released Taproot runtime boundary | Exact 819fe packed peer; Task/Memory/Prompt typed and runtime lanes | PASS | -| Context-query and Taproot reader boundary | Static parser, fail-closed packets, authorized-reader tests | PASS | -| MCP schemas and official client | Tool inventory plus SDK 1.29 initialize/list/call | PASS | -| Route factories, auth, health, observability | Unit and isolated package-runtime lifecycle tests | PASS | -| Waystone UI | UI tests plus public Waystone 0.2.0 normal-install/declaration/registry probe | PASS | -| Isolated Worker package consumer | 742.64 KiB / 139.85 KiB gzip; 40.82 ms cold initialize | PASS | -| Exact npm tarball consumers | One prepared artifact; generic, Worker, and vinext isolated consumers | PASS | -| Artifact provenance | Schema, commit/worktree, hashes, parsed tar manifest/exports/migrations | PASS | -| Dev-tool compatibility and full audit | Hono Node HTTP bridge; Sharp PNG-to-WebP; exactly zero vulnerabilities | PASS | -| Performance, production audit, readiness | Included in `npm run check` | PASS | -| Tagged release invariant | Requires clean matching annotated tag; current clean/no-tag head rejected | EXPECTED BLOCKED | -| Complete-Site acceptance | Assembly, provisioning, deployment, and acceptance are host-agent owned | OUT OF SCOPE | - -The final command output is the source of truth if this document and a later -working-tree state differ. - -The machine-verifiable record beside the ignored archive under `.release/` is -the source of truth for the exact commit/worktree state, environment, artifact -SHA-256 and npm integrity, and sorted file manifest. Its `dirty`, commit, and tag -fields are authoritative for the state in which it was generated. It must be -regenerated after the final commit and annotated tag before publication. The -publish workflow retains the provenance and versioned schema as non-replaceable -GitHub Release assets. - -Final performance and artifact measurements must be copied from the clean, -tagged 0.4.2 gate. Historical measurements are not release evidence for this -version. - -## Evidence boundary - -The Worker, vinext, and Miniflare results are isolated package-runtime consumers -with injected or stubbed peer services. This evidence does not qualify a -complete Gnolith Site. - -The vinext SSR build passed but warned about Node imports originating in vinext -and React DOM. That counterevidence remains a full-Site compatibility item; the -isolated package check is not evidence that a deployed Site needs no additional -runtime configuration. - -The Codex agent creating a Site owns complete package assembly, managed bindings and -migrations, host identity and secrets, deployment configuration, real graph -projection, live Waystone/browser/MCP/Codex probes, and final acceptance. -Workshop CI must not provision, deploy, assemble, or accept that complete Site. diff --git a/docs/release-provenance.md b/docs/release-provenance.md deleted file mode 100644 index 285fd30..0000000 --- a/docs/release-provenance.md +++ /dev/null @@ -1,40 +0,0 @@ -# Release artifact provenance - -Workshop creates one package archive for local package qualification: - -```sh -npm run artifact:prepare -npm run consumer:check -npm run artifact:verify -``` - -`artifact:prepare` writes an ignored `.release/gnolith-workshop-.tgz` -and adjacent `.provenance.json`. The JSON conforms to -`release-provenance.schema.json` and records: - -- package name and version; -- source commit, tree, dirty state, and a worktree-state SHA-256; -- OS, architecture, Node, npm, Git, and `core.autocrlf` values; -- artifact filename, size, SHA-256, npm SHA-1 shasum, SHA-512 integrity, and the - sorted npm file manifest. - -The generic, Worker, and vinext consumers install that existing archive. They do -not repack the working directory. `artifact:verify` parses the retained archive, -validates the provenance against the JSON Schema, compares the actual tar -manifest, validates every export target, and checks packed migration bytes and -checksums against the packed compiled manifest. - -`release:check` is stricter than local verification. It requires a clean source -state, provenance commit/tree equal to the current `HEAD`, and an existing -annotated version tag that resolves to that commit. It is expected to fail in an -uncommitted review tree. An annotated version-tag push runs the trusted workflow, -publishes the same verified `.tgz` through npm OIDC, and revalidates its exact -registry provenance. Only after those checks succeed does the workflow create -the immutable GitHub Release with the provenance and versioned schema assets. -A manually pre-created Release is rejected rather than used as a trigger. - -The version-history check compares package-producing inputs with every reachable -commit that declared the same version. Material package changes require a new -version or prerelease identifier. A dirty local verification is recorded as -such. After the final commit and annotated tag exist, regenerate the artifact so -release provenance names the clean tagged commit before publishing. diff --git a/docs/release-provenance.schema.json b/docs/release-provenance.schema.json deleted file mode 100644 index 2de9b60..0000000 --- a/docs/release-provenance.schema.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/gnolith/workshop/blob/main/docs/release-provenance.schema.json", - "title": "Workshop release artifact provenance", - "type": "object", - "required": [ - "schemaVersion", - "generatedAt", - "package", - "source", - "environment", - "artifact" - ], - "properties": { - "schemaVersion": { "const": 1 }, - "generatedAt": { "type": "string", "format": "date-time" }, - "package": { - "type": "object", - "required": ["name", "version"], - "properties": { - "name": { "const": "@gnolith/workshop" }, - "version": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$" - } - }, - "additionalProperties": false - }, - "source": { - "type": "object", - "required": ["commit", "tree", "dirty", "stateSha256"], - "properties": { - "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - "tree": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - "dirty": { "type": "boolean" }, - "stateSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } - }, - "additionalProperties": false - }, - "environment": { - "type": "object", - "required": [ - "platform", - "release", - "version", - "arch", - "node", - "npm", - "git", - "coreAutocrlf" - ], - "properties": { - "platform": { "type": "string", "minLength": 1 }, - "release": { "type": "string", "minLength": 1 }, - "version": { "type": "string", "minLength": 1 }, - "arch": { "type": "string", "minLength": 1 }, - "node": { "type": "string", "pattern": "^v[0-9]+\\." }, - "npm": { "type": "string", "minLength": 1 }, - "git": { "type": "string", "pattern": "^git version " }, - "coreAutocrlf": { "type": ["string", "null"] } - }, - "additionalProperties": false - }, - "artifact": { - "type": "object", - "required": [ - "filename", - "size", - "sha256", - "shasum", - "integrity", - "files" - ], - "properties": { - "filename": { - "type": "string", - "pattern": "^gnolith-workshop-[0-9A-Za-z.-]+\\.tgz$" - }, - "size": { "type": "integer", "minimum": 1 }, - "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "shasum": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, - "integrity": { - "type": "string", - "pattern": "^sha512-[A-Za-z0-9+/]+={0,2}$" - }, - "files": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["path", "size", "mode"], - "properties": { - "path": { "type": "string", "minLength": 1 }, - "size": { "type": "integer", "minimum": 0 }, - "mode": { "type": "integer", "minimum": 0, "maximum": 511 } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false -} diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index 7efd8c4..0000000 --- a/docs/security.md +++ /dev/null @@ -1,56 +0,0 @@ -# Security considerations and threat assumptions - -Workshop assumes the public Internet, hostile prompts/tool inputs, concurrent -agents, stale revisions, and compromised browser origins. It does not assume a -trusted MCP client merely because a URL is known. - -- Hosts authenticate every request and inject Taproot's shared live - authorization authority. Every Task, Memory, Packet, and Knowledge boundary - requires a full authorization context. -- Same-origin is the default; explicit CORS and MCP Origin validation prevent - DNS-rebinding/cross-origin access. -- D1 values use bound statements. Conditional writes enforce concurrency. -- Candidate Task/Memory reads are authorization-filtered in SQL before content - hydration and rechecked afterward. Missing, cross-installation, and denied - records share one non-disclosing denial. -- Unscoped public SPARQL execution fails closed. Entity search is available only - through Taproot's authorized-reader candidate and hydration boundary. -- Knowledge writes are unavailable and unadvertised until shared Taproot policy - persistence and atomic mutation conformance are complete. -- Request/content/query/page/tool limits reduce resource exhaustion. -- Public errors omit stacks and internals. Observability hooks must never log - tokens, secrets, entire packets, sensitive source bodies, or arbitrary MCP - request bodies. -- Detailed health requires exact `admin`. Mutating semantic probes require - exact `admin`, `task-write`, and `memory-write`; claim reset requires exact - `admin` and `task-write`. Legacy authorization backfill requires exact - `search:admin`. Generic `admin` never implies another capability. -- React renders prompts/results/memories as text; no raw HTML path is provided. - -Workshop's package boundary assumes the consumer supplies correct principals, -protects bearer tokens and database handles, and configures intentional origins. -Run the full `npm run security:dev-check`, `npm audit --omit=dev`, dependency -review, code scanning, and focused authorization/context-query/MCP review before -every public package release. - -## 0.3 development-tool compatibility record - -Workshop pins transitive development tools that participate in package-runtime -qualification to `sharp@0.35.3` and `@hono/node-server@2.0.11`. The gate imports, -constructs, and closes the MCP SDK's Node Streamable HTTP transport, resolves the -same native Sharp installation used from Miniflare's dependency context, and -performs a PNG-to-WebP conversion before requiring a zero-vulnerability full -audit. Hono 2.0.5 fixed the original Windows static-file path traversal but was -rejected because a later WebSocket-abort denial-of-service advisory affects -2.0.0 through 2.0.9. - -## 0.1 focused review record - -The 2026-07-20 local review verified server-side capability checks, same-origin -CORS defaults, prepared D1 statements, bounded streaming request reads, bounded -static context-query validation, structured non-stack errors, safe React text rendering, no -administrative reset MCP tool, telemetry that cannot alter domain outcomes, -clean production dependency audit, isolated Worker package-consumer build, and -exact tarball contents. The Codex agent creating a Site owns live binding scope, -edge rate limits, real Taproot policy/projection, deployed authentication, -deployment, and complete-Site security acceptance. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md deleted file mode 100644 index ffdd1c2..0000000 --- a/docs/troubleshooting.md +++ /dev/null @@ -1,17 +0,0 @@ -# Troubleshooting - -- `schema` health false: materialize/apply the migration manifest; runtime does - not create tables. Compare admin diagnostics with the manifest. -- task creation rejects a query: inspect structured `queryIndex`/`label`, use - `validate_sparql`, then `dry_run_sparql`; remove updates or unapproved SERVICE. -- claim returns conflict: another caller won, or the task is completed/archived. -- task update returns conflict: reload and use the latest `updatedAt`. -- knowledge write returns conflict: reload canonical entity JSON and use the - latest Taproot revision. -- MCP returns 406: POST clients must accept JSON and event-stream. -- MCP returns 401/403: configure machine authentication and capabilities; task - role labels do not help. -- browser call is blocked: same-origin is default; configure one intentional - allowed origin in the runtime. -- consumer bundle imports an unexpected runtime: confirm it imports the correct - explicit subpath and does not pull test/release scripts into runtime code. diff --git a/docs/ui-and-onboarding.md b/docs/ui-and-onboarding.md deleted file mode 100644 index ed0d193..0000000 --- a/docs/ui-and-onboarding.md +++ /dev/null @@ -1,33 +0,0 @@ -# Waystone UI - -Use `createWorkshopPlugin(options)` and register its result through Waystone's -public structural plugin contract. Import `@gnolith/workshop/styles.css` once -in the consumer shell. The default `workshopPlugin` uses a same-origin, -read-only client; hosts normally inject a client factory and the current -capability set. Workshop does not import Waystone internals, server code, or D1 -from browser code. - -The plugin contributes Tasks and Memories navigation, routes, a dashboard, -related-task entity panel, and admin MCP status. Route components load and -filter tasks and memories, create and edit them, compile packet views, and claim -or complete tasks. Loading, denied permissions, request failures, and revision -conflicts are explicit. The client and status loader are host-injected browser -contracts; no UI module constructs a Workshop server runtime. - -```tsx -const plugin = createWorkshopPlugin({ - client: () => createWorkshopClient({ token: getSessionToken }), - capabilities, - loadMcpStatus: () => statusApi.workshopMcp(), -}); -``` - -Exported components cover lists, filters, cards, state badges, detail, packets, -query-result status, claim/completion controls, editors, and MCP status. -Content is rendered as text, actions use semantic controls, permission/conflict -errors are explicit, and layouts are responsive. - -Onboarding is intentionally absent from the public plugin, protocol, core, -HTTP, and MCP surfaces. Historical journal tables remain migration artifacts -only. Future repeatable research exploration belongs in the separately scoped -agent-skill work, after the shared Taproot authorization foundation is complete. diff --git a/docs/verification.md b/docs/verification.md deleted file mode 100644 index 8f73a58..0000000 --- a/docs/verification.md +++ /dev/null @@ -1,18 +0,0 @@ -# Health and semantic verification - -Public health verifies D1, Workshop schema, Taproot, Diamond, MCP construction, -and compatibility but returns only safe booleans, versions, and overall status. -Admin diagnostics add table/index presence; they never return SQL, binding IDs, -secrets, auth configuration, or stacks. - -The local protected probe handler exercises memory creation, validated task -creation, packet resolution, claim, completion, and archive against injected -services. It is package behavior, not a deployed Site acceptance probe. - -The package MCP gate initializes, lists required tools, performs harmless reads -and temporary task/knowledge calls, rejects anonymous and insufficient calls, -and uses the official MCP client locally. - -The Codex agent creating a Site owns real Taproot/Diamond projection, deployed -MCP, browser behavior, the intended Codex connection, cleanup policy, and live -acceptance. See `release-evidence.md` for the package evidence boundary. diff --git a/eslint.config.js b/eslint.config.js index 5aed1c2..da327cd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,22 +1,24 @@ import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; -import reactHooks from 'eslint-plugin-react-hooks'; export default tseslint.config( + { ignores: ['dist/**', 'src/generated/**'] }, eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked, - reactHooks.configs.flat.recommended, { - ignores: ['dist/**'], languageOptions: { parserOptions: { projectService: { allowDefaultProject: ['scripts/*.mjs'], - maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 17, + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32, }, tsconfigRootDir: import.meta.dirname, }, }, + rules: { + 'no-control-regex': 'off', + '@typescript-eslint/prefer-promise-reject-errors': 'off', + }, }, { files: ['test/**/*.{ts,tsx}'], diff --git a/examples/package-runtime-canary/README.md b/examples/package-runtime-canary/README.md deleted file mode 100644 index 11f010a..0000000 --- a/examples/package-runtime-canary/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Isolated package-runtime canary - -This fixture verifies that the exact published Workshop package bundles in a -supported Worker runtime. It is invoked only through Wrangler's local -`deploy --dry-run` output and Miniflare. - -Diamond, Taproot, identity, and host services are injected or stubbed. The D1 -identifier is intentionally invalid. This directory does not assemble, -provision, deploy, or accept a complete Gnolith Site, and it is not included in -the Workshop npm package. - -The Codex agent creating a Site owns deployable host assets, four-package -composition, infrastructure, live probes, and final acceptance. diff --git a/examples/package-runtime-canary/worker.ts b/examples/package-runtime-canary/worker.ts deleted file mode 100644 index 1a26b04..0000000 --- a/examples/package-runtime-canary/worker.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { createWorkshopRuntime } from '@gnolith/workshop/server'; -import { - createWorkshopHealthHandler, - createWorkshopMcpHandler, -} from '@gnolith/workshop/site'; - -// Isolated package-runtime fixture only. Peer services are deliberately stubbed; -// this module is not a deployable Gnolith Site entry point. - -interface Env { - DB: import('@gnolith/workshop/server').D1DatabaseLike; - CANARY_TOKEN: string; -} - -export default { - async fetch(request: Request, env: Env): Promise { - const runtime = createWorkshopRuntime({ - db: env.DB, - authorization: { - async getInstallationAuthorizationState() { - return { - installationId: 'canary:installation', - authorizationRevision: 1, - searchGeneration: 1, - }; - }, - async commitTaskMutation(_db, _context, mutation) { - return mutation.first(); - }, - async commitMemoryMutation(_db, _context, mutation) { - return mutation.first(); - }, - async commitTaskBackfill(db, _context, _state, mutations) { - return db.batch([...mutations]); - }, - async commitMemoryBackfill(db, _context, _state, mutations) { - return db.batch([...mutations]); - }, - async commitCursorSnapshot(db, _context, _state, mutations) { - return db.batch([...mutations]); - }, - }, - knowledge: { - authorizedReader() { - return { - async getEntity() { - return null; - }, - async searchEntities() { - return { items: [], cursor: null }; - }, - }; - }, - health: async () => false, - }, - diamondHealth: async () => false, - cursorCodec: { - currentGeneration: async () => 'canary-generation', - async digest(purpose, value) { - const key = await crypto.subtle.importKey( - 'raw', - new TextEncoder().encode(env.CANARY_TOKEN), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'], - ); - const signature = await crypto.subtle.sign( - 'HMAC', - key, - new Uint8Array([...new TextEncoder().encode(purpose), 0, ...value]), - ); - return Array.from(new Uint8Array(signature), (byte) => - byte.toString(16).padStart(2, '0'), - ).join(''); - }, - async seal() { - throw new Error('Pagination is outside this package canary'); - }, - async open() { - throw new Error('Pagination is outside this package canary'); - }, - }, - async resolvePrincipal(candidate) { - if ( - candidate.headers.get('authorization') !== - `Bearer ${env.CANARY_TOKEN}` - ) - return null; - return { - installationId: 'canary:installation', - principalId: 'canary:verifier', - activeWorkspaceId: 'canary:workspace', - workspaceIds: ['canary:workspace'], - capabilities: [ - 'read', - 'task-write', - 'memory-write', - 'knowledge-write', - 'admin', - ], - authorizationRevision: 1, - }; - }, - }); - const path = new URL(request.url).pathname; - if (path === '/health') - return createWorkshopHealthHandler(runtime)(request); - if (path === '/mcp') return createWorkshopMcpHandler(runtime)(request); - return new Response('Not found', { status: 404 }); - }, -}; diff --git a/examples/package-runtime-canary/wrangler.jsonc b/examples/package-runtime-canary/wrangler.jsonc deleted file mode 100644 index c67663d..0000000 --- a/examples/package-runtime-canary/wrangler.jsonc +++ /dev/null @@ -1,14 +0,0 @@ -{ - // Non-deployable fixture used only with `wrangler deploy --dry-run`. - "$schema": "node_modules/wrangler/config-schema.json", - "name": "gnolith-workshop-package-runtime-canary", - "main": "worker.ts", - "compatibility_date": "2026-07-20", - "d1_databases": [ - { - "binding": "DB", - "database_name": "gnolith-workshop-package-runtime-canary", - "database_id": "00000000-0000-0000-0000-000000000000", - }, - ], -} diff --git a/migrations/0007_task_leases.sql b/migrations/0007_task_leases.sql new file mode 100644 index 0000000..c62ffbb --- /dev/null +++ b/migrations/0007_task_leases.sql @@ -0,0 +1,42 @@ +ALTER TABLE workshop_tasks ADD COLUMN lease_id TEXT; +ALTER TABLE workshop_tasks ADD COLUMN lease_token_digest TEXT; +ALTER TABLE workshop_tasks ADD COLUMN lease_owner_principal_id TEXT; +ALTER TABLE workshop_tasks ADD COLUMN lease_expires_at TEXT; + +CREATE TABLE workshop_task_lease_receipts ( + installation_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + task_id TEXT NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ('release', 'complete')), + expected_revision INTEGER NOT NULL CHECK (expected_revision >= 1), + resulting_revision INTEGER NOT NULL CHECK (resulting_revision >= 1), + receipt_json TEXT NOT NULL CHECK (json_valid(receipt_json)), + created_at TEXT NOT NULL, + PRIMARY KEY (installation_id, principal_id, idempotency_key) +); + +CREATE TABLE workshop_task_lease_events ( + event_id TEXT PRIMARY KEY, + installation_id TEXT NOT NULL, + task_id TEXT NOT NULL, + lease_id TEXT NOT NULL, + actor_principal_id TEXT NOT NULL, + event_kind TEXT NOT NULL CHECK ( + event_kind IN ('claimed', 'reclaimed_expired', 'released', 'completed') + ), + task_revision INTEGER NOT NULL CHECK (task_revision >= 1), + lease_expires_at TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX workshop_tasks_lease_expiry_idx + ON workshop_tasks (installation_id, lease_expires_at, id); +CREATE INDEX workshop_task_lease_events_task_idx + ON workshop_task_lease_events (installation_id, task_id, created_at); + +UPDATE workshop_schema +SET version = 7, + package_version = '0.5.0', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE singleton = 1; diff --git a/migrations/0008_bootstrap_credentials.sql b/migrations/0008_bootstrap_credentials.sql new file mode 100644 index 0000000..cc1375d --- /dev/null +++ b/migrations/0008_bootstrap_credentials.sql @@ -0,0 +1,40 @@ +CREATE TABLE workshop_credentials ( + installation_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + profile TEXT NOT NULL CHECK (profile = 'local-bearer-v1'), + token_digest TEXT NOT NULL CHECK ( + length(token_digest) = 64 AND token_digest NOT GLOB '*[^0-9a-f]*' + ), + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 CHECK (disabled IN (0, 1)), + created_at TEXT NOT NULL, + PRIMARY KEY (installation_id, credential_id), + UNIQUE (installation_id, token_digest) +); + +CREATE INDEX workshop_credentials_principal_idx + ON workshop_credentials (installation_id, principal_id, workspace_id); + +CREATE TABLE workshop_bootstrap_receipts ( + installation_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + credential_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + grant_manifest_digest TEXT NOT NULL CHECK ( + length(grant_manifest_digest) = 64 + AND grant_manifest_digest NOT GLOB '*[^0-9a-f]*' + ), + owner_receipt_json TEXT NOT NULL CHECK (json_valid(owner_receipt_json)), + created_at TEXT NOT NULL, + PRIMARY KEY (installation_id, idempotency_key), + FOREIGN KEY (installation_id, credential_id) + REFERENCES workshop_credentials(installation_id, credential_id) +); + +UPDATE workshop_schema +SET version = 8, + package_version = '0.5.0', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE singleton = 1; diff --git a/package-lock.json b/package-lock.json index 959f88c..e11f6a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,52 +1,41 @@ { "name": "@gnolith/workshop", - "version": "0.4.2", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@gnolith/workshop", - "version": "0.4.2", + "version": "0.5.0", "license": "MIT", "dependencies": { "@traqula/parser-sparql-1-1": "^1.1.8", - "swr": "^2.4.2" + "ajv": "^8.17.1" + }, + "bin": { + "gnolith-workshop": "dist/cli.js" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@gnolith/diamond": "0.4.0", - "@gnolith/taproot": "0.4.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@gnolith/diamond": "file:../diamond/gnolith-diamond-0.5.0.tgz", + "@gnolith/taproot": "file:../taproot-artifacts/gnolith-taproot-0.5.0.tgz", "@types/node": "^24.0.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^3.2.7", - "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "axe-core": "^4.11.0", "eslint": "^10.7.0", - "eslint-plugin-react-hooks": "^7.1.1", - "jsdom": "^29.1.1", "miniflare": "^4.20260714.0", "prettier": "^3.9.5", - "react": "^19.2.7", - "react-dom": "^19.2.7", "tar-stream": "^3.1.7", "typescript": "^5.9.2", "typescript-eslint": "^8.64.0", - "vitest": "^3.2.4", - "wrangler": "^4.112.0" + "vitest": "^3.2.4" }, "engines": { "node": ">=22" }, "peerDependencies": { - "@gnolith/diamond": ">=0.4.0 <0.5.0", - "@gnolith/taproot": ">=0.4.0 <0.5.0", - "@gnolith/waystone": ">=0.1.0 <0.3.0", - "react": ">=19 <20" + "@gnolith/diamond": ">=0.5.0-0 <0.6.0", + "@gnolith/taproot": ">=0.5.0-0 <0.6.0" }, "peerDependenciesMeta": { "@gnolith/diamond": { @@ -54,12 +43,6 @@ }, "@gnolith/taproot": { "optional": true - }, - "@gnolith/waystone": { - "optional": true - }, - "react": { - "optional": true } } }, @@ -77,226 +60,6 @@ "node": ">=6.0.0" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/code-frame/node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -317,30 +80,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", @@ -357,50 +96,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -438,45 +133,6 @@ "buffer": "^6.0.3" } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260714.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260714.1.tgz", @@ -5971,184 +5627,44 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" } }, - "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" ], + "dev": true, "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { @@ -6704,28 +6220,10 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/@gnolith/diamond": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@gnolith/diamond/-/diamond-0.4.0.tgz", - "integrity": "sha512-Njk/d5YESyqHiPQinN7jl+kHnlVCF3TkpmTnGjaGfdfaXc3CwELNWouu562aBwCrhKMz97yKU0y8NO48OujYdQ==", + "version": "0.5.0", + "resolved": "file:../diamond/gnolith-diamond-0.5.0.tgz", + "integrity": "sha512-piae23y9l6dqGPsJeGRNvq3RUhX7gjhsKxAkVVRlfgpVofZ4fQS8v0FlGOrlh5UTIKypQw37UOZrXNmxLtQj2w==", "dev": true, "license": "MIT", "dependencies": { @@ -6740,13 +6238,13 @@ } }, "node_modules/@gnolith/taproot": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@gnolith/taproot/-/taproot-0.4.0.tgz", - "integrity": "sha512-yYxbrUNnu74zBaxHoywGlgeG2LFz4HMzi2RLcsq83/JVEIkwbWvWZ8tuLpxFYxTAgTXG1/FHddgEJStRupe54A==", + "version": "0.5.0", + "resolved": "file:../taproot-artifacts/gnolith-taproot-0.5.0.tgz", + "integrity": "sha512-oqqkWajZIbsGg5fXD2runGH58j5FrEPEPoN9DXViSMLrS+9PGPMqWKjxe/qjhCW4+vsgRl4VdP8jI9KJMu2pkg==", "dev": true, "license": "MIT", "dependencies": { - "@gnolith/diamond": "0.4.0", + "@gnolith/diamond": "0.5.0", "@rdfjs/types": "^2.0.1", "rdf-data-factory": "^2.0.2" }, @@ -6754,19 +6252,6 @@ "node": ">=22" } }, - "node_modules/@hono/node-server": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.11.tgz", - "integrity": "sha512-bjD221KPLoJTWUwso1J6fGKiTXEUFedG/s0visavY4zakFPkeGURMRNly+FhBHs7T8Dz4qHaZIMX9ZoJHSJtKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -7422,17 +6907,6 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -7461,47 +6935,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7959,69 +7392,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, "node_modules/@traqula/algebra-sparql-1-1": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@traqula/algebra-sparql-1-1/-/algebra-sparql-1-1-1.1.8.tgz", @@ -8182,14 +7552,6 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -8281,26 +7643,6 @@ "undici-types": "~7.18.0" } }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "node_modules/@types/readable-stream": { "version": "4.0.24", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", @@ -8765,20 +8107,6 @@ "node": ">=6.5" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -8806,7 +8134,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -8863,17 +8190,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/arrayify-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-2.0.1.tgz", @@ -8937,16 +8253,6 @@ "asynciterator": "^3.9.0" } }, - "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -9078,29 +8384,6 @@ ], "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.44", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", - "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/bignumber.js": { "version": "11.1.5", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", @@ -9108,52 +8391,6 @@ "dev": true, "license": "MIT" }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -9167,40 +8404,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -9226,16 +8429,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -9246,58 +8439,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, "node_modules/canonicalize": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-1.0.8.tgz", @@ -9569,37 +8710,6 @@ "dev": true, "license": "MIT" }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -9614,34 +8724,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-fetch": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", @@ -9667,41 +8749,6 @@ "node": ">= 8" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -9720,13 +8767,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -9744,25 +8784,6 @@ "dev": true, "license": "MIT" }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -9773,14 +8794,6 @@ "node": ">=8" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -9853,21 +8866,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -9875,20 +8873,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.393", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", - "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", - "dev": true, - "license": "ISC" - }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -9903,29 +8887,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -9936,26 +8897,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -9963,19 +8904,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -10028,13 +8956,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -10107,26 +9028,6 @@ } } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" - } - }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -10257,16 +9158,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/event-emitter-promisify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/event-emitter-promisify/-/event-emitter-promisify-1.1.0.tgz", @@ -10304,29 +9195,6 @@ "bare-events": "^2.7.0" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -10337,85 +9205,10 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -10443,7 +9236,6 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "dev": true, "funding": [ { "type": "github", @@ -10522,28 +9314,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10606,26 +9376,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -10641,26 +9391,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10671,45 +9401,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -10778,19 +9469,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graphql": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz", @@ -10871,19 +9549,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -10895,59 +9560,6 @@ "minimalistic-assert": "^1.0.1" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/hono": { - "version": "4.12.31", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", - "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -10995,27 +9607,6 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/http-link-header": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.4.tgz", @@ -11026,23 +9617,6 @@ "node": ">=6.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -11098,26 +9672,6 @@ "dev": true, "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -11151,20 +9705,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -11255,16 +9795,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -11272,70 +9802,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -11347,15 +9813,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -11364,19 +9822,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/jsonld-context-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", @@ -11614,17 +10059,6 @@ "dev": true, "license": "ISC" }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -11663,46 +10097,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/microdata-rdf-streaming-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/microdata-rdf-streaming-parser/-/microdata-rdf-streaming-parser-3.0.0.tgz", @@ -11753,33 +10147,6 @@ "entities": "^4.5.0" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/miniflare": { "version": "4.20260714.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260714.0.tgz", @@ -11897,16 +10264,6 @@ "integrity": "sha512-KBCIM4dAIT9j/pSXLHHQbZG74NmKNXTtxU2zHN0HG6uzzuFE01m1UdGoUmVHmACiBuCAOL7KwfqSW1oUQBj/vg==", "dev": true }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -11953,26 +10310,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11986,29 +10323,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/one-time": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", @@ -12076,29 +10390,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -12136,13 +10427,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -12180,16 +10464,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/postcss": { "version": "8.5.20", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", @@ -12245,47 +10519,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -12303,20 +10536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12327,53 +10546,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/rdf-data-factory": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", @@ -13318,36 +11490,6 @@ "url": "https://github.com/sponsors/rubensworks/" } }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/readable-from-web": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/readable-from-web/-/readable-from-web-1.0.0.tgz", @@ -13408,7 +11550,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13459,34 +11600,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/router/node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -13518,99 +11631,18 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "ISC" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, "node_modules/shaclc-parse": { "version": "2.0.0", @@ -13708,82 +11740,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -14005,16 +11961,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -14191,26 +12137,6 @@ "node": ">=8" } }, - "node_modules/swr": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", - "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.3", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/tar-stream": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", @@ -14334,62 +12260,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", - "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.9" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", - "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -14434,39 +12304,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -14522,57 +12359,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -14589,15 +12375,6 @@ "integrity": "sha512-enADBvHfhjrwxFMTVWeIIYz51SZ91uC6o2MR/NQTVljJB6HTZ8eQL3Q7JBj3RxNISA14MOwJaU3vpf5R6dyxHA==", "dev": true }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14626,16 +12403,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -14807,54 +12574,6 @@ } } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14987,42 +12706,6 @@ "@cloudflare/workerd-windows-64": "1.20260714.1" } }, - "node_modules/wrangler": { - "version": "4.112.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.112.0.tgz", - "integrity": "sha512-5H+XUD0TySCv1LuktFHDIEOkboH2nTfQs+35L+USt3MtntjDTMVIJprLgQcL2WBjulOyjxpd1vyTiSTJVW5MjQ==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.28.1", - "miniflare": "4.20260714.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260714.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^5.20260714.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -15121,13 +12804,6 @@ "node": ">=8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -15150,16 +12826,6 @@ } } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -15177,13 +12843,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", @@ -15295,39 +12954,6 @@ "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } - }, - "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } } } } diff --git a/package.json b/package.json index cf90db8..6d9308b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@gnolith/workshop", - "version": "0.4.2", - "description": "Hosted agent tasks, memories, coordination, and tooling.", + "version": "0.5.0", + "description": "Self-hosted Gnolith MCP and BFF executable.", "type": "module", "private": false, "sideEffects": false, @@ -16,7 +16,7 @@ "author": "Gnolith contributors", "keywords": [ "agents", - "cloudflare-d1", + "sqlite", "mcp", "research", "sparql" @@ -25,19 +25,22 @@ "access": "public", "provenance": true }, + "bin": { + "gnolith-workshop": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, - "./core": { - "types": "./dist/core.d.ts", - "import": "./dist/core.js" - }, "./protocol": { "types": "./dist/protocol.d.ts", "import": "./dist/protocol.js" }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" + }, "./server": { "types": "./dist/server.d.ts", "import": "./dist/server.js" @@ -46,23 +49,28 @@ "types": "./dist/mcp.d.ts", "import": "./dist/mcp.js" }, - "./site": { - "types": "./dist/site.d.ts", - "import": "./dist/site.js" + "./executable": { + "types": "./dist/executable.d.ts", + "import": "./dist/executable.js" + }, + "./bootstrap": { + "types": "./dist/bootstrap.d.ts", + "import": "./dist/bootstrap.js" }, - "./ui": { - "types": "./dist/ui.d.ts", - "import": "./dist/ui.js" + "./backup": { + "types": "./dist/backup.d.ts", + "import": "./dist/backup.js" }, "./migrations": { "types": "./dist/migrations.d.ts", "import": "./dist/migrations.js" }, - "./styles.css": "./dist/styles.css", + "./schemas/*": "./schemas/*", "./package.json": "./package.json" }, "files": [ "dist", + "schemas", "docs", "migrations", "CHANGELOG.md", @@ -72,18 +80,16 @@ "SECURITY.md" ], "scripts": { - "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json && node scripts/copy-assets.mjs", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run test:coverage && npm run build && npm run performance:check && npm run parity:node-sqlite && npm run security:dev-check && npm run worker:check && npm run worker:test && npm run pack:check && npm run release:boundary-check && npm run consumer:check && npm run artifact:verify && npm run audit:check && npm run readiness:check", + "build": "node scripts/clean.mjs && node scripts/generate-operation-contract.mjs --check && tsc -p tsconfig.build.json", + "check": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && npm run pack:check && npm run audit:check", "artifact:path": "node scripts/artifact-path.mjs", "artifact:prepare": "node scripts/prepare-artifact.mjs", "artifact:verify": "node scripts/verify-artifact.mjs", "audit:check": "npm audit --omit=dev --audit-level=high", - "consumer:check": "node scripts/consumer-smoke.mjs && npm run consumer:waystone-check", - "consumer:waystone-check": "node scripts/waystone-consumer-smoke.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint src test scripts", - "pack:check": "npm run artifact:prepare", + "pack:check": "npm pack --dry-run --json", "performance:check": "node scripts/performance-check.mjs", "parity:node-sqlite": "node scripts/node-sqlite-parity.mjs", "prepack": "npm run build", @@ -95,8 +101,8 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", - "worker:check": "wrangler deploy --dry-run --config examples/package-runtime-canary/wrangler.jsonc --outdir ../../.wrangler/package-runtime-canary", - "worker:test": "node scripts/worker-e2e.mjs" + "candidate:prepare": "node scripts/prepare-candidate.mjs", + "packed:process-check": "node scripts/packed-process-smoke.mjs" }, "engines": { "node": ">=22" @@ -104,13 +110,11 @@ "packageManager": "npm@11.9.0", "dependencies": { "@traqula/parser-sparql-1-1": "^1.1.8", - "swr": "^2.4.2" + "ajv": "^8.17.1" }, "peerDependencies": { - "@gnolith/diamond": ">=0.4.0 <0.5.0", - "@gnolith/taproot": ">=0.4.0 <0.5.0", - "@gnolith/waystone": ">=0.1.0 <0.3.0", - "react": ">=19 <20" + "@gnolith/diamond": ">=0.5.0-0 <0.6.0", + "@gnolith/taproot": ">=0.5.0-0 <0.6.0" }, "peerDependenciesMeta": { "@gnolith/diamond": { @@ -118,50 +122,32 @@ }, "@gnolith/taproot": { "optional": true - }, - "@gnolith/waystone": { - "optional": true - }, - "react": { - "optional": true } }, "devDependencies": { "@eslint/js": "^10.0.1", - "@gnolith/diamond": "0.4.0", - "@gnolith/taproot": "0.4.0", - "@modelcontextprotocol/sdk": "^1.29.0", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@gnolith/diamond": "file:../diamond/gnolith-diamond-0.5.0.tgz", + "@gnolith/taproot": "file:../taproot-artifacts/gnolith-taproot-0.5.0.tgz", "@types/node": "^24.0.0", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.3", "@vitest/coverage-v8": "^3.2.7", - "ajv": "^8.17.1", "ajv-formats": "^3.0.1", - "axe-core": "^4.11.0", "eslint": "^10.7.0", - "eslint-plugin-react-hooks": "^7.1.1", - "jsdom": "^29.1.1", "miniflare": "^4.20260714.0", "prettier": "^3.9.5", - "react": "^19.2.7", - "react-dom": "^19.2.7", "tar-stream": "^3.1.7", "typescript": "^5.9.2", "typescript-eslint": "^8.64.0", - "vitest": "^3.2.4", - "wrangler": "^4.112.0" + "vitest": "^3.2.4" }, "overrides": { "@hono/node-server": "2.0.11", "sharp": "0.35.3" }, "license": "MIT", - "main": "eslint.config.js", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", "directories": { "doc": "docs", - "example": "examples", "test": "test" } } diff --git a/schemas/gnolith-operations-v1.json b/schemas/gnolith-operations-v1.json new file mode 100644 index 0000000..249e84f --- /dev/null +++ b/schemas/gnolith-operations-v1.json @@ -0,0 +1,2240 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gnolith.dev/schemas/workshop/operations-v1.json", + "title": "Gnolith Workshop operation catalog", + "format": "gnolith-operation-catalog-v1", + "operationSchemaVersion": 1, + "routePrefix": "/api/v1/operations", + "operations": [ + { + "name": "gnolith_status", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/gnolith_status", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "installationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "baseIri": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "principalId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activeWorkspaceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "authorizationRevision": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000 + }, + "migrationReadiness": { + "type": "object", + "properties": { + "namespace": { + "const": "@gnolith/workshop" + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "ready": { + "type": "boolean" + } + }, + "required": [ + "namespace", + "version", + "ready" + ], + "additionalProperties": false + }, + "compatibility": { + "type": "object", + "properties": { + "diamond": { + "type": "boolean" + }, + "taproot": { + "type": "boolean" + } + }, + "required": [ + "diamond", + "taproot" + ], + "additionalProperties": false + }, + "canonicalReady": { + "type": "boolean" + }, + "authorizationReady": { + "type": "boolean" + }, + "lexicalReady": { + "type": "boolean" + }, + "semanticState": { + "type": "object", + "properties": { + "state": { + "enum": [ + "ready", + "degraded", + "unconfigured" + ] + }, + "configured": { + "type": "boolean" + } + }, + "required": [ + "state", + "configured" + ], + "additionalProperties": false + }, + "producers": { + "type": "object", + "properties": { + "ready": { + "type": "boolean" + }, + "fingerprint": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kinds": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "uniqueItems": true, + "items": { + "enum": [ + "task", + "memory", + "prompt" + ] + } + } + }, + "required": [ + "ready", + "fingerprint", + "kinds" + ], + "additionalProperties": false + }, + "blobReady": { + "type": "boolean" + }, + "versions": { + "type": "object", + "properties": { + "server": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "operationSchema": { + "const": 1 + } + }, + "required": [ + "server", + "operationSchema" + ], + "additionalProperties": false + }, + "operationCatalogDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "installationId", + "baseIri", + "principalId", + "credentialId", + "activeWorkspaceId", + "capabilities", + "authorizationRevision", + "migrationReadiness", + "compatibility", + "canonicalReady", + "authorizationReady", + "lexicalReady", + "semanticState", + "producers", + "blobReady", + "versions", + "operationCatalogDigest" + ], + "additionalProperties": false + } + }, + { + "name": "search", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/search", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "kinds": { + "type": "array", + "maxItems": 7, + "uniqueItems": true, + "items": { + "enum": [ + "statement", + "item", + "task", + "memory", + "prompt", + "resource", + "annotation" + ] + } + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "search_hydrate", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/search_hydrate", + "inputSchema": { + "type": "object", + "properties": { + "kind": { + "enum": [ + "statement", + "item", + "task", + "memory", + "prompt", + "resource", + "annotation" + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "entityType": { + "enum": [ + "item", + "property" + ] + } + }, + "required": [ + "kind", + "id", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "search_admin", + "version": 1, + "capability": "search:admin", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": false + }, + "route": "/api/v1/operations/search_admin", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "enum": [ + "health", + "materialize", + "retry-dead", + "rebuild", + "activate", + "semantic-select", + "semantic-reconnect", + "semantic-estimate", + "semantic-approve", + "semantic-run", + "semantic-resume", + "semantic-pause", + "semantic-stop", + "semantic-retry", + "semantic-exclude", + "semantic-retire", + "semantic-delete" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_entity", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_entity", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "entityId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "entity_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/entity_history", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "entityId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "statement_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/statement_history", + "inputSchema": { + "type": "object", + "properties": { + "statementId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "statementId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_entities", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_entities", + "inputSchema": { + "type": "object", + "properties": { + "entityIds": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "required": [ + "entityIds" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_item", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_item", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "authoredText": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "authoredText", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_property", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_property", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "authoredText": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "authoredText", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "mutate_entity", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/mutate_entity", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "mutations": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "object", + "properties": { + "kind": { + "enum": [ + "label", + "description", + "alias", + "sitelink", + "statement", + "rank", + "qualifier", + "reference" + ] + }, + "authoredText": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "value": {} + }, + "required": [ + "kind", + "authoredText", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entityId", + "expectedRevision", + "idempotencyKey", + "mutations" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "entity_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/entity_revision", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "entityId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "statement_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/statement_revision", + "inputSchema": { + "type": "object", + "properties": { + "statementId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "statementId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "export_entity_json", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/export_entity_json", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "entityId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_resource", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_resource", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_resource", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "resourceId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_resource", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "resourceId", + "expectedRevision", + "idempotencyKey", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_resource", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "resourceId", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "resource_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/resource_history", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "resourceId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "resource_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/resource_revision", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "resourceId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "hydrate_resource", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/hydrate_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "resourceId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_annotation", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_annotation", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_annotation", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_annotation", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "annotationId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_annotation", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_annotation", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "annotationId", + "expectedRevision", + "idempotencyKey", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_annotation", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_annotation", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "annotationId", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "annotation_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/annotation_history", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "annotationId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "annotation_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/annotation_revision", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "annotationId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "validate_sparql", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/validate_sparql", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "query_sparql", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/query_sparql", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "dryRun": { + "type": "boolean" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "list_tasks", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/list_tasks", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "state": { + "enum": [ + "unclaimed", + "claimed", + "completed", + "archived" + ] + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "search_tasks", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/search_tasks", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_task", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_task_packet", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_task_packet", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_task", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 131072 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "description", + "prompt" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "taskId", + "expectedRevision", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "archive_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/archive_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "required": [ + "taskId", + "expectedRevision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "claim_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": false + }, + "route": "/api/v1/operations/claim_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "release_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/release_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "taskId", + "leaseToken", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "complete_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/complete_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "result": { + "type": "string", + "minLength": 1, + "maxLength": 262144 + }, + "outcomeKind": { + "enum": [ + "success", + "negative", + "inconclusive" + ] + } + }, + "required": [ + "taskId", + "leaseToken", + "expectedRevision", + "idempotencyKey", + "result" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "task_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/task_history", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "list_memories", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/list_memories", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_memory", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_memory", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "upsert_memory", + "version": 1, + "capability": "memory:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/upsert_memory", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 131072 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "slug", + "idempotencyKey", + "description", + "content" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_memory", + "version": 1, + "capability": "memory:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_memory", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "slug", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "memory_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/memory_history", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "list_prompts", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/list_prompts", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_prompt", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_prompt", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "promptId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_prompt", + "version": 1, + "capability": "prompt:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_prompt", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "promptText": { + "type": "string", + "minLength": 1, + "maxLength": 131072 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "name", + "promptText" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_prompt", + "version": 1, + "capability": "prompt:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_prompt", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "promptId", + "expectedRevision", + "idempotencyKey", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_prompt", + "version": 1, + "capability": "prompt:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_prompt", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "promptId", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "prompt_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/prompt_history", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "promptId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + } + ], + "catalogDigest": "577cc1de501b0ae3556eb1d32e7dd516c70a09c5b6226d671cec312068fba3dd", + "legacyNames": { + "add_alias": "mutate_entity", + "add_reference": "mutate_entity", + "add_sitelink": "mutate_entity", + "add_statement": "mutate_entity", + "add_qualifier": "mutate_entity", + "create_entity": "create_item or create_property", + "delete_alias": "mutate_entity", + "delete_reference": "mutate_entity", + "delete_sitelink": "mutate_entity", + "delete_statement": "mutate_entity", + "delete_qualifier": "mutate_entity", + "dry_run_sparql": "query_sparql with dryRun=true", + "get_search_status": "search_admin with action=health", + "query_sparql_update": null, + "reset_abandoned_claim": null, + "search_status": "gnolith_status or search_admin with action=health", + "set_description": "mutate_entity", + "set_label": "mutate_entity", + "set_rank": "mutate_entity" + } +} diff --git a/schemas/legacy-operation-names-v1.json b/schemas/legacy-operation-names-v1.json new file mode 100644 index 0000000..0351a3e --- /dev/null +++ b/schemas/legacy-operation-names-v1.json @@ -0,0 +1,24 @@ +{ + "format": "gnolith-legacy-operation-names-v1", + "names": { + "add_alias": "mutate_entity", + "add_reference": "mutate_entity", + "add_sitelink": "mutate_entity", + "add_statement": "mutate_entity", + "add_qualifier": "mutate_entity", + "create_entity": "create_item or create_property", + "delete_alias": "mutate_entity", + "delete_reference": "mutate_entity", + "delete_sitelink": "mutate_entity", + "delete_statement": "mutate_entity", + "delete_qualifier": "mutate_entity", + "dry_run_sparql": "query_sparql with dryRun=true", + "get_search_status": "search_admin with action=health", + "query_sparql_update": null, + "reset_abandoned_claim": null, + "search_status": "gnolith_status or search_admin with action=health", + "set_description": "mutate_entity", + "set_label": "mutate_entity", + "set_rank": "mutate_entity" + } +} diff --git a/schemas/workshop-config-v1.json b/schemas/workshop-config-v1.json new file mode 100644 index 0000000..7f63739 --- /dev/null +++ b/schemas/workshop-config-v1.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gnolith.dev/schemas/workshop-config-v1.json", + "title": "gnolith-workshop-config-v1", + "type": "object", + "additionalProperties": false, + "required": ["format", "listener", "authentication", "adapterModule"], + "properties": { + "format": { "const": "gnolith-workshop-config-v1" }, + "listener": { + "type": "object", + "additionalProperties": false, + "required": ["host", "port", "profile"], + "properties": { + "host": { "type": "string", "minLength": 1, "maxLength": 255 }, + "port": { "type": "integer", "minimum": 0, "maximum": 65535 }, + "profile": { "enum": ["container", "remote", "test"] } + } + }, + "authentication": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["profile", "credentialStore"], + "properties": { + "profile": { "const": "local-bearer-v1" }, + "credentialStore": { "$ref": "#/$defs/protectedSelector" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "issuer", + "audience", + "algorithms", + "requiredScopes", + "resource", + "authorizationServers", + "scopeCapabilityMap", + "principalClaim", + "workspaceClaim", + "installationClaim", + "credentialClaim", + "maxClockSkewSeconds", + "metadata" + ], + "properties": { + "profile": { "const": "remote-oauth-v1" }, + "issuer": { "type": "string", "format": "uri" }, + "audience": { "type": "string", "minLength": 1 }, + "algorithms": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string" } + }, + "requiredScopes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string" } + }, + "resource": { "type": "string", "format": "uri" }, + "authorizationServers": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "format": "uri" } + }, + "scopeCapabilityMap": { + "type": "object", + "additionalProperties": { "type": "string", "minLength": 1 } + }, + "principalClaim": { "type": "string", "minLength": 1 }, + "workspaceClaim": { "type": "string", "minLength": 1 }, + "installationClaim": { "type": "string", "minLength": 1 }, + "credentialClaim": { "type": "string", "minLength": 1 }, + "maxClockSkewSeconds": { + "type": "integer", + "minimum": 0, + "maximum": 300 + }, + "metadata": { "$ref": "#/$defs/protectedSelector" } + } + } + ] + }, + "adapterModule": { "type": "string", "minLength": 1 }, + "corsOrigins": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "format": "uri" } + }, + "apiPrefix": { "const": "/api/v1" }, + "assetMount": { + "type": "object", + "additionalProperties": false, + "required": ["prefix", "manifest", "root"], + "properties": { + "prefix": { "type": "string", "pattern": "^/" }, + "manifest": { "$ref": "#/$defs/protectedSelector" }, + "root": { "$ref": "#/$defs/protectedSelector" } + } + }, + "background": { + "type": "object", + "additionalProperties": false, + "required": ["intervalMs", "timeoutMs"], + "properties": { + "intervalMs": { + "type": "integer", + "minimum": 100, + "maximum": 86400000 + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 86400000 + } + } + }, + "shutdownTimeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 300000 + } + }, + "$defs": { + "protectedSelector": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "value"], + "properties": { + "kind": { + "enum": ["file", "inherited-descriptor", "environment-selector"] + }, + "value": { "type": ["string", "integer"] } + } + } + } +} diff --git a/scripts/clean.mjs b/scripts/clean.mjs index e782526..95de02a 100644 --- a/scripts/clean.mjs +++ b/scripts/clean.mjs @@ -2,7 +2,7 @@ import { rmSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; const root = resolve('.'); -for (const name of ['dist', 'coverage', '.wrangler']) { +for (const name of ['dist', 'coverage']) { const target = resolve(root, name); if (dirname(target) !== root || basename(target) !== name) { throw new Error(`Unexpected clean target: ${target}`); diff --git a/scripts/consumer-smoke.mjs b/scripts/consumer-smoke.mjs deleted file mode 100644 index 2355c8d..0000000 --- a/scripts/consumer-smoke.mjs +++ /dev/null @@ -1,296 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { loadAndVerifyArtifact } from './artifact-provenance.mjs'; - -const npmCli = process.env.npm_execpath; -if (!npmCli) throw new Error('consumer:check must be run through npm'); -const projectRoot = process.cwd(); -const root = mkdtempSync(join(tmpdir(), 'workshop-consumer-')); -const source = JSON.parse(readFileSync('package.json', 'utf8')); -const publicVersion = process.env.WORKSHOP_PUBLIC_CONSUMER_VERSION; -const installTarget = publicVersion - ? `@gnolith/workshop@${publicVersion}` - : (await loadAndVerifyArtifact()).archive; -writeFileSync( - join(root, 'package.json'), - JSON.stringify({ - private: true, - type: 'module', - dependencies: { - '@gnolith/taproot': '0.4.0', - '@cloudflare/vite-plugin': '1.45.1', - '@vitejs/plugin-react': '6.0.3', - '@vitejs/plugin-rsc': '0.5.28', - react: '19.2.7', - 'react-dom': '19.2.7', - 'react-server-dom-webpack': '19.2.7', - vinext: '1.0.0-beta.2', - vite: '8.1.5', - wrangler: '4.112.0', - entities: '4.5.0', - typescript: '5.9.3', - }, - }), -); -execFileSync( - process.execPath, - [ - npmCli, - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--prefix', - root, - installTarget, - ], - { stdio: 'inherit' }, -); -writeFileSync( - join(root, 'smoke.mjs'), - ` - import { workshopPackage } from '@gnolith/workshop'; - import { createWorkshopCore, createWorkshopToolDispatcher } from '@gnolith/workshop/core'; - import { createWorkshopClient, taskState } from '@gnolith/workshop/protocol'; - import { createWorkshopRuntime, TaskService } from '@gnolith/workshop/server'; - import { createWorkshopMcpServer, workshopTools } from '@gnolith/workshop/mcp'; - import { createWorkshopMcpHandler } from '@gnolith/workshop/site'; - import { workshopMigrations } from '@gnolith/workshop/migrations'; - import { createWorkshopPlugin, workshopPlugin, TaskList, WorkshopTasksScreen } from '@gnolith/workshop/ui'; - const values = [workshopPackage, createWorkshopCore, createWorkshopToolDispatcher, - createWorkshopClient, taskState, createWorkshopRuntime, - TaskService, createWorkshopMcpServer, workshopTools, createWorkshopMcpHandler, - workshopMigrations, createWorkshopPlugin, workshopPlugin, TaskList, WorkshopTasksScreen]; - if (values.some((value) => value === undefined)) throw new Error('Public export missing'); - `, -); -execFileSync(process.execPath, [join(root, 'smoke.mjs')], { - cwd: root, - stdio: 'inherit', -}); -writeFileSync( - join(root, 'history-contract.ts'), - ` - import type { - HistoryRequestOptions, - MemoryRevision, - TaskRevision, - WorkshopClient, - } from '@gnolith/workshop/protocol'; - import type { MemoryService, TaskService } from '@gnolith/workshop/server'; - - declare const client: WorkshopClient; - declare const tasks: TaskService; - declare const memories: MemoryService; - declare const authorization: Parameters[1]; - const bounded: HistoryRequestOptions = { limit: 10 }; - const taskHistory: Promise = client.tasks.history('task-id', bounded); - const memoryHistory: Promise = client.memories.history('memory-slug', bounded); - const coreTaskHistory: Promise = tasks.history('task-id', authorization, bounded); - const coreMemoryHistory: Promise = memories.history('memory-slug', authorization, bounded); - void [taskHistory, memoryHistory, coreTaskHistory, coreMemoryHistory]; - `, -); -writeFileSync( - join(root, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'Bundler', - strict: true, - noEmit: true, - skipLibCheck: true, - }, - include: ['history-contract.ts'], - }), -); -execFileSync( - process.execPath, - [join(root, 'node_modules', 'typescript', 'bin', 'tsc')], - { cwd: root, stdio: 'inherit' }, -); -const packageRoot = join(root, 'node_modules', ...source.name.split('/')); -for (const path of [ - 'dist/index.d.ts', - 'dist/core.d.ts', - 'dist/protocol.d.ts', - 'dist/server.d.ts', - 'dist/mcp.d.ts', - 'dist/site.d.ts', - 'dist/ui.d.ts', - 'dist/migrations.d.ts', - 'dist/styles.css', - 'migrations/0001_workshop.sql', - 'migrations/0002_revisions.sql', - 'migrations/0003_resumable_onboarding.sql', - 'migrations/0004_authorization.sql', - 'SECURITY.md', -]) { - if (!existsSync(join(packageRoot, path))) { - throw new Error(`Packed package is missing ${path}`); - } -} -const packedPaths = readdirSync(packageRoot, { - recursive: true, - withFileTypes: true, -}).map((entry) => join(entry.parentPath, entry.name)); -for (const forbidden of ['.wrangler', join('dist', 'server', 'diamond.js')]) { - if (packedPaths.some((path) => path.includes(forbidden))) { - throw new Error( - `Packed package contains forbidden stale output: ${forbidden}`, - ); - } -} -const canaryRoot = join(root, 'package-runtime-canary'); -writeFileSync( - join(root, 'worker.ts'), - readFileSync('examples/package-runtime-canary/worker.ts', 'utf8'), -); -writeFileSync( - join(root, 'wrangler.jsonc'), - readFileSync('examples/package-runtime-canary/wrangler.jsonc', 'utf8'), -); -const wranglerOutput = execFileSync( - process.execPath, - [ - join(projectRoot, 'node_modules', 'wrangler', 'bin', 'wrangler.js'), - 'deploy', - '--dry-run', - '--config', - join(root, 'wrangler.jsonc'), - '--outdir', - canaryRoot, - ], - { cwd: root, encoding: 'utf8' }, -); -if (/node:events|nodejs_compat/iu.test(wranglerOutput)) { - throw new Error( - 'Isolated exact-tarball Worker consumer pulled Node compatibility requirements', - ); -} -if (!existsSync(join(canaryRoot, 'worker.js'))) { - throw new Error( - 'Isolated exact-tarball Worker consumer did not produce a bundle', - ); -} - -const frontendRoot = mkdtempSync(join(tmpdir(), 'workshop-vinext-consumer-')); -writeFileSync( - join(frontendRoot, 'package.json'), - JSON.stringify({ - private: true, - type: 'module', - dependencies: { - '@cloudflare/vite-plugin': '1.45.1', - '@vitejs/plugin-react': '6.0.3', - '@vitejs/plugin-rsc': '0.5.28', - react: '19.2.7', - 'react-dom': '19.2.7', - 'react-server-dom-webpack': '19.2.7', - vinext: '1.0.0-beta.2', - vite: '8.1.5', - }, - }), -); -execFileSync( - process.execPath, - [ - npmCli, - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--prefix', - frontendRoot, - installTarget, - ], - { stdio: 'inherit' }, -); -const appRoot = join(frontendRoot, 'app'); -const mcpRouteRoot = join(appRoot, 'api', 'workshop', 'mcp'); -mkdirSync(mcpRouteRoot, { recursive: true }); -writeFileSync( - join(appRoot, 'layout.tsx'), - ` - import type { ReactNode } from 'react'; - import '@gnolith/workshop/styles.css'; - export default function Layout({ children }: { children: ReactNode }) { - return {children}; - } - `, -); -writeFileSync( - join(appRoot, 'page.tsx'), - ` - import { McpStatusPanel, TaskList, WorkshopDashboard } from '@gnolith/workshop/ui'; - const task = { - id: 'canary-task', description: 'Canary task', prompt: 'Verify Workshop UI.', - contextQueries: [], memorySlugs: [], claimed: false, - installationId: 'canary:installation', ownerPrincipalId: 'canary:verifier', - workspaceId: 'canary:workspace', visibility: { version: 1, clauses: [] }, - authorizationRevision: 1, - createdAt: '2026-07-20T00:00:00.000Z', updatedAt: '2026-07-20T00:00:00.000Z', - }; - export default function Page() { - return
-
; - } - `, -); -writeFileSync( - join(mcpRouteRoot, 'route.ts'), - ` - const handler = () => new Response('Workshop UI package canary'); - export const GET = handler; - export const POST = handler; - `, -); -writeFileSync( - join(frontendRoot, 'tsconfig.json'), - JSON.stringify({ - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'Bundler', - jsx: 'react-jsx', - strict: true, - skipLibCheck: true, - }, - include: ['app'], - }), -); -writeFileSync( - join(frontendRoot, 'vite.config.ts'), - ` - import { cloudflare } from '@cloudflare/vite-plugin'; - import { defineConfig } from 'vite'; - import vinext from 'vinext'; - export default defineConfig({ - plugins: [ - vinext(), - cloudflare({ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] } }), - ], - }); - `, -); -execFileSync( - process.execPath, - [join(frontendRoot, 'node_modules', 'vinext', 'dist', 'cli.js'), 'build'], - { cwd: frontendRoot, stdio: 'inherit' }, -); -if (!existsSync(join(frontendRoot, 'dist'))) { - throw new Error('Isolated vinext exact-tarball consumer did not build'); -} -console.log( - `generic declaration/runtime, Worker, and vinext isolated package consumers passed for ${source.name}@${publicVersion ?? source.version}`, -); diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs deleted file mode 100644 index 69b754d..0000000 --- a/scripts/copy-assets.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { copyFile, mkdir } from 'node:fs/promises'; - -await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); -await copyFile( - new URL('../src/ui/styles/workshop.css', import.meta.url), - new URL('../dist/styles.css', import.meta.url), -); diff --git a/scripts/dev-security-smoke.mjs b/scripts/dev-security-smoke.mjs index b1ddd48..6cdb0e6 100644 --- a/scripts/dev-security-smoke.mjs +++ b/scripts/dev-security-smoke.mjs @@ -1,180 +1,58 @@ import { Buffer } from 'node:buffer'; -import { readFile } from 'node:fs/promises'; -import { createServer, request } from 'node:http'; import { createRequire } from 'node:module'; -import { dirname, join } from 'node:path'; -import { clearTimeout, setTimeout } from 'node:timers'; import { pathToFileURL } from 'node:url'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createGnolithMcpServer } from '../dist/mcp/server.js'; -const require = createRequire(import.meta.url); -const LOOPBACK_TIMEOUT_MS = 2_000; const MAX_RESPONSE_BYTES = 64 * 1024; -async function packageMetadata(entry) { - let directory = dirname(entry); - for (;;) { - try { - return JSON.parse( - await readFile(join(directory, 'package.json'), 'utf8'), - ); - } catch (error) { - const parent = dirname(directory); - if (parent === directory) { - throw error; - } - directory = parent; - } - } -} - -const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, +const mcp = createGnolithMcpServer({ + version: '0.5.0', + operations: { + async execute() { + throw new Error('invalid requests must not dispatch'); + }, + }, + authenticate: async () => ({}), + maxRequestBytes: MAX_RESPONSE_BYTES, }); -if (typeof transport.handleRequest !== 'function') { +const headers = { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', +}; +const malformed = await mcp.handle( + new Request('http://127.0.0.1/mcp', { + method: 'POST', + headers, + body: '{', + }), +); +const payload = await malformed.json(); +if ( + malformed.status !== 400 || + !malformed.headers.get('content-type')?.startsWith('application/json') || + payload?.jsonrpc !== '2.0' || + payload?.error?.code !== -32700 +) { throw new Error( - 'MCP StreamableHTTPServerTransport did not construct correctly', + `Workshop MCP returned status=${malformed.status}, contentType=${malformed.headers.get('content-type')}, code=${payload?.error?.code}`, ); } -const server = createServer((incoming, response) => { - if (incoming.url === '/oversized') { - response.end(Buffer.alloc(MAX_RESPONSE_BYTES + 1)); - return; - } - void transport.handleRequest(incoming, response).catch((error) => { - response.destroy(error); - }); -}); -server.requestTimeout = LOOPBACK_TIMEOUT_MS; -server.headersTimeout = LOOPBACK_TIMEOUT_MS; -server.keepAliveTimeout = 500; -server.setTimeout(LOOPBACK_TIMEOUT_MS, (socket) => { - socket.destroy(new Error('MCP compatibility server request timed out')); -}); -await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); -}); - -try { - const address = server.address(); - if (address === null || typeof address === 'string') { - throw new Error('MCP compatibility server did not bind to a TCP port'); - } - - const requestLoopback = (path, body = '') => - new Promise((resolve, reject) => { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort( - new Error( - `MCP compatibility request exceeded ${LOOPBACK_TIMEOUT_MS}ms`, - ), - ); - }, LOOPBACK_TIMEOUT_MS); - let settled = false; - const settle = (callback, value) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - callback(value); - }; - - const outgoing = request( - { - host: '127.0.0.1', - port: address.port, - path, - method: 'POST', - signal: controller.signal, - headers: { - accept: 'application/json, text/event-stream', - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(body), - }, - }, - (response) => { - const chunks = []; - let responseBytes = 0; - response.on('data', (chunk) => { - responseBytes += chunk.length; - if (responseBytes > MAX_RESPONSE_BYTES) { - const error = new Error( - `MCP compatibility response exceeded ${MAX_RESPONSE_BYTES} bytes`, - ); - controller.abort(error); - response.destroy(error); - outgoing.destroy(error); - settle(reject, error); - return; - } - chunks.push(chunk); - }); - response.on('end', () => { - settle(resolve, { - status: response.statusCode, - contentType: response.headers['content-type'], - body: Buffer.concat(chunks).toString('utf8'), - }); - }); - response.once('error', (error) => settle(reject, error)); - }, - ); - outgoing.once('error', (error) => settle(reject, error)); - outgoing.setTimeout(LOOPBACK_TIMEOUT_MS, () => { - const error = new Error( - `MCP compatibility socket exceeded ${LOOPBACK_TIMEOUT_MS}ms`, - ); - outgoing.destroy(error); - settle(reject, error); - }); - outgoing.end(body); - }); - - const result = await requestLoopback('/mcp', '{'); - - const payload = JSON.parse(result.body); - if ( - result.status !== 400 || - !result.contentType?.startsWith('application/json') || - payload?.jsonrpc !== '2.0' || - payload?.error?.code !== -32700 - ) { - throw new Error( - `MCP Node-to-Web HTTP bridge returned status=${result.status}, contentType=${result.contentType}, code=${payload?.error?.code}`, - ); - } - - await requestLoopback('/oversized').then( - () => { - throw new Error('oversized MCP compatibility response was accepted'); - }, - (error) => { - if (!String(error.message).includes('exceeded 65536 bytes')) { - throw error; - } +const oversized = await mcp.handle( + new Request('http://127.0.0.1/mcp', { + method: 'POST', + headers: { + ...headers, + 'content-length': String(MAX_RESPONSE_BYTES + 1), }, + body: Buffer.alloc(MAX_RESPONSE_BYTES + 1, 0x20), + }), +); +if (oversized.status !== 413) { + throw new Error( + `oversized Workshop MCP request returned ${oversized.status}`, ); -} finally { - try { - await transport.close(); - } finally { - server.closeAllConnections(); - await new Promise((resolve, reject) => { - server.close((error) => - error === undefined || error.code === 'ERR_SERVER_NOT_RUNNING' - ? resolve() - : reject(error), - ); - }); - } -} - -const hono = await packageMetadata(require.resolve('@hono/node-server')); -if (hono.name !== '@hono/node-server' || hono.version !== '2.0.11') { - throw new Error(`unexpected @hono/node-server version: ${hono.version}`); } const miniflareRequire = createRequire(import.meta.resolve('miniflare')); @@ -207,5 +85,5 @@ if ( } console.log( - `dev security compatibility passed (@hono/node-server ${hono.version}, sharp ${sharp.versions.sharp})`, + `dev security compatibility passed (Workshop MCP, sharp ${sharp.versions.sharp})`, ); diff --git a/scripts/generate-operation-contract.mjs b/scripts/generate-operation-contract.mjs new file mode 100644 index 0000000..51ffc20 --- /dev/null +++ b/scripts/generate-operation-contract.mjs @@ -0,0 +1,682 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const version = 1; +const routePrefix = '/api/v1/operations'; +const read = { readOnly: true, destructive: false, idempotent: true }; +const write = { readOnly: false, destructive: false, idempotent: false }; +const idempotentWrite = { + readOnly: false, + destructive: false, + idempotent: true, +}; +const destructive = { + readOnly: false, + destructive: true, + idempotent: true, +}; +const object = (properties = {}, required = []) => ({ + type: 'object', + properties, + ...(required.length ? { required } : {}), + additionalProperties: false, +}); +const text = (maxLength = 4096) => ({ + type: 'string', + minLength: 1, + maxLength, +}); +const positive = { type: 'integer', minimum: 1, maximum: 1_000_000 }; +const id = text(256); +const idInput = (field = 'id') => object({ [field]: id }, [field]); +const revisionInput = (field = 'id') => + object({ [field]: id, revision: positive }, [field, 'revision']); +const output = object({}, []); +output.additionalProperties = true; + +const definitions = []; +function operation( + name, + capability, + annotations, + inputSchema = object(), + outputSchema = output, +) { + definitions.push({ + name, + version: 1, + capability, + annotations, + route: `${routePrefix}/${name}`, + inputSchema, + outputSchema, + }); +} + +operation( + 'gnolith_status', + 'read', + read, + object(), + object( + { + installationId: id, + baseIri: text(4096), + principalId: id, + credentialId: id, + activeWorkspaceId: { + anyOf: [id, { type: 'null' }], + }, + capabilities: { + type: 'array', + maxItems: 256, + uniqueItems: true, + items: text(256), + }, + authorizationRevision: { + type: 'integer', + minimum: 0, + maximum: 1_000_000_000, + }, + migrationReadiness: object( + { + namespace: { const: '@gnolith/workshop' }, + version: positive, + ready: { type: 'boolean' }, + }, + ['namespace', 'version', 'ready'], + ), + compatibility: object( + { + diamond: { type: 'boolean' }, + taproot: { type: 'boolean' }, + }, + ['diamond', 'taproot'], + ), + canonicalReady: { type: 'boolean' }, + authorizationReady: { type: 'boolean' }, + lexicalReady: { type: 'boolean' }, + semanticState: object( + { + state: { enum: ['ready', 'degraded', 'unconfigured'] }, + configured: { type: 'boolean' }, + }, + ['state', 'configured'], + ), + producers: object( + { + ready: { type: 'boolean' }, + fingerprint: text(256), + kinds: { + type: 'array', + minItems: 3, + maxItems: 3, + uniqueItems: true, + items: { enum: ['task', 'memory', 'prompt'] }, + }, + }, + ['ready', 'fingerprint', 'kinds'], + ), + blobReady: { type: 'boolean' }, + versions: object( + { + server: text(128), + operationSchema: { const: 1 }, + }, + ['server', 'operationSchema'], + ), + operationCatalogDigest: { + type: 'string', + pattern: '^[a-f0-9]{64}$', + }, + }, + [ + 'installationId', + 'baseIri', + 'principalId', + 'credentialId', + 'activeWorkspaceId', + 'capabilities', + 'authorizationRevision', + 'migrationReadiness', + 'compatibility', + 'canonicalReady', + 'authorizationReady', + 'lexicalReady', + 'semanticState', + 'producers', + 'blobReady', + 'versions', + 'operationCatalogDigest', + ], + ), +); +operation( + 'search', + 'read', + read, + object( + { + text: text(16_384), + kinds: { + type: 'array', + maxItems: 7, + uniqueItems: true, + items: { + enum: [ + 'statement', + 'item', + 'task', + 'memory', + 'prompt', + 'resource', + 'annotation', + ], + }, + }, + cursor: text(4096), + limit: positive, + }, + ['text'], + ), +); +operation( + 'search_hydrate', + 'read', + read, + object( + { + kind: { + enum: [ + 'statement', + 'item', + 'task', + 'memory', + 'prompt', + 'resource', + 'annotation', + ], + }, + id, + revision: positive, + entityType: { enum: ['item', 'property'] }, + }, + ['kind', 'id', 'revision'], + ), +); +operation( + 'search_admin', + 'search:admin', + write, + object( + { + action: { + enum: [ + 'health', + 'materialize', + 'retry-dead', + 'rebuild', + 'activate', + 'semantic-select', + 'semantic-reconnect', + 'semantic-estimate', + 'semantic-approve', + 'semantic-run', + 'semantic-resume', + 'semantic-pause', + 'semantic-stop', + 'semantic-retry', + 'semantic-exclude', + 'semantic-retire', + 'semantic-delete', + ], + }, + limit: positive, + cursor: text(4096), + options: { type: 'object', additionalProperties: true }, + }, + ['action'], + ), +); + +for (const name of ['get_entity', 'entity_history', 'statement_history']) + operation( + name, + 'read', + read, + idInput(name === 'statement_history' ? 'statementId' : 'entityId'), + ); +operation( + 'get_entities', + 'read', + read, + object( + { + entityIds: { + type: 'array', + minItems: 1, + maxItems: 200, + uniqueItems: true, + items: id, + }, + }, + ['entityIds'], + ), +); +for (const name of ['create_item', 'create_property']) + operation( + name, + 'knowledge:write', + idempotentWrite, + object( + { + idempotencyKey: id, + authoredText: text(16_384), + data: { type: 'object', additionalProperties: true }, + }, + ['idempotencyKey', 'authoredText', 'data'], + ), + ); +operation( + 'mutate_entity', + 'knowledge:write', + idempotentWrite, + object( + { + entityId: id, + expectedRevision: positive, + idempotencyKey: id, + mutations: { + type: 'array', + minItems: 1, + maxItems: 200, + items: { + type: 'object', + properties: { + kind: { + enum: [ + 'label', + 'description', + 'alias', + 'sitelink', + 'statement', + 'rank', + 'qualifier', + 'reference', + ], + }, + authoredText: text(16_384), + value: {}, + }, + required: ['kind', 'authoredText', 'value'], + additionalProperties: false, + }, + }, + }, + ['entityId', 'expectedRevision', 'idempotencyKey', 'mutations'], + ), +); +for (const name of ['entity_revision', 'statement_revision']) + operation( + name, + 'read', + read, + revisionInput(name === 'statement_revision' ? 'statementId' : 'entityId'), + ); +operation('export_entity_json', 'read', read, idInput('entityId')); + +for (const domain of ['resource', 'annotation']) { + operation( + `create_${domain}`, + 'knowledge:write', + idempotentWrite, + object( + { + idempotencyKey: id, + data: { type: 'object', additionalProperties: true }, + }, + ['idempotencyKey', 'data'], + ), + ); + operation(`get_${domain}`, 'read', read, idInput(`${domain}Id`)); + operation( + `update_${domain}`, + 'knowledge:write', + idempotentWrite, + object( + { + [`${domain}Id`]: id, + expectedRevision: positive, + idempotencyKey: id, + patch: { type: 'object', additionalProperties: true }, + }, + [`${domain}Id`, 'expectedRevision', 'idempotencyKey', 'patch'], + ), + ); + operation( + `delete_${domain}`, + 'knowledge:write', + destructive, + object( + { + [`${domain}Id`]: id, + expectedRevision: positive, + idempotencyKey: id, + }, + [`${domain}Id`, 'expectedRevision', 'idempotencyKey'], + ), + ); + operation(`${domain}_history`, 'read', read, idInput(`${domain}Id`)); + operation(`${domain}_revision`, 'read', read, revisionInput(`${domain}Id`)); + if (domain === 'resource') + operation('hydrate_resource', 'read', read, revisionInput('resourceId')); +} + +operation( + 'validate_sparql', + 'read', + read, + object({ query: text(65_536) }, ['query']), +); +operation( + 'query_sparql', + 'read', + read, + object( + { + query: text(65_536), + dryRun: { type: 'boolean' }, + limit: positive, + }, + ['query'], + ), +); + +operation( + 'list_tasks', + 'read', + read, + object({ + cursor: text(4096), + limit: positive, + state: { enum: ['unclaimed', 'claimed', 'completed', 'archived'] }, + text: text(4096), + }), +); +operation( + 'search_tasks', + 'read', + read, + object({ text: text(4096), cursor: text(4096), limit: positive }, ['text']), +); +operation('get_task', 'read', read, idInput('taskId')); +operation('get_task_packet', 'read', read, idInput('taskId')); +operation( + 'create_task', + 'task:write', + idempotentWrite, + object( + { + idempotencyKey: id, + description: text(4096), + prompt: text(131_072), + data: { type: 'object', additionalProperties: true }, + }, + ['idempotencyKey', 'description', 'prompt'], + ), +); +operation( + 'update_task', + 'task:write', + idempotentWrite, + object( + { + taskId: id, + expectedRevision: positive, + leaseToken: text(4096), + patch: { type: 'object', additionalProperties: true }, + }, + ['taskId', 'expectedRevision', 'patch'], + ), +); +operation( + 'archive_task', + 'task:write', + destructive, + object({ taskId: id, expectedRevision: positive, leaseToken: text(4096) }, [ + 'taskId', + 'expectedRevision', + ]), +); +operation( + 'claim_task', + 'task:write', + write, + object({ taskId: id, leaseSeconds: positive }, ['taskId']), +); +operation( + 'release_task', + 'task:write', + idempotentWrite, + object( + { + taskId: id, + leaseToken: text(4096), + expectedRevision: positive, + idempotencyKey: id, + }, + ['taskId', 'leaseToken', 'expectedRevision', 'idempotencyKey'], + ), +); +operation( + 'complete_task', + 'task:write', + idempotentWrite, + object( + { + taskId: id, + leaseToken: text(4096), + expectedRevision: positive, + idempotencyKey: id, + result: text(262_144), + outcomeKind: { enum: ['success', 'negative', 'inconclusive'] }, + }, + ['taskId', 'leaseToken', 'expectedRevision', 'idempotencyKey', 'result'], + ), +); +operation( + 'task_history', + 'read', + read, + object({ taskId: id, limit: positive }, ['taskId']), +); + +operation( + 'list_memories', + 'read', + read, + object({ cursor: text(4096), limit: positive, text: text(4096) }), +); +operation('get_memory', 'read', read, idInput('slug')); +operation( + 'upsert_memory', + 'memory:write', + idempotentWrite, + object( + { + slug: id, + expectedRevision: positive, + idempotencyKey: id, + description: text(4096), + content: text(131_072), + data: { type: 'object', additionalProperties: true }, + }, + ['slug', 'idempotencyKey', 'description', 'content'], + ), +); +operation( + 'delete_memory', + 'memory:write', + destructive, + object({ slug: id, expectedRevision: positive, idempotencyKey: id }, [ + 'slug', + 'expectedRevision', + 'idempotencyKey', + ]), +); +operation( + 'memory_history', + 'read', + read, + object({ slug: id, limit: positive }, ['slug']), +); + +operation( + 'list_prompts', + 'read', + read, + object({ cursor: text(4096), limit: positive, text: text(4096) }), +); +operation('get_prompt', 'read', read, idInput('promptId')); +operation( + 'create_prompt', + 'prompt:write', + idempotentWrite, + object( + { + idempotencyKey: id, + name: text(256), + promptText: text(131_072), + data: { type: 'object', additionalProperties: true }, + }, + ['idempotencyKey', 'name', 'promptText'], + ), +); +operation( + 'update_prompt', + 'prompt:write', + idempotentWrite, + object( + { + promptId: id, + expectedRevision: positive, + idempotencyKey: id, + patch: { type: 'object', additionalProperties: true }, + }, + ['promptId', 'expectedRevision', 'idempotencyKey', 'patch'], + ), +); +operation( + 'delete_prompt', + 'prompt:write', + destructive, + object({ promptId: id, expectedRevision: positive, idempotencyKey: id }, [ + 'promptId', + 'expectedRevision', + 'idempotencyKey', + ]), +); +operation( + 'prompt_history', + 'read', + read, + object({ promptId: id, limit: positive }, ['promptId']), +); + +assert.equal(definitions.length, 51); +assert.equal(new Set(definitions.map(({ name }) => name)).size, 51); + +const legacyNames = { + add_alias: 'mutate_entity', + add_reference: 'mutate_entity', + add_sitelink: 'mutate_entity', + add_statement: 'mutate_entity', + add_qualifier: 'mutate_entity', + create_entity: 'create_item or create_property', + delete_alias: 'mutate_entity', + delete_reference: 'mutate_entity', + delete_sitelink: 'mutate_entity', + delete_statement: 'mutate_entity', + delete_qualifier: 'mutate_entity', + dry_run_sparql: 'query_sparql with dryRun=true', + get_search_status: 'search_admin with action=health', + query_sparql_update: null, + reset_abandoned_claim: null, + search_status: 'gnolith_status or search_admin with action=health', + set_description: 'mutate_entity', + set_label: 'mutate_entity', + set_rank: 'mutate_entity', +}; + +const catalogBase = { + format: 'gnolith-operation-catalog-v1', + operationSchemaVersion: version, + routePrefix, + operations: definitions, +}; +const canonicalCatalog = canonicalize(catalogBase); +const catalogDigest = createHash('sha256') + .update(canonicalCatalog) + .digest('hex'); +const catalog = { ...catalogBase, catalogDigest }; +const schemaArtifact = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://gnolith.dev/schemas/workshop/operations-v1.json', + title: 'Gnolith Workshop operation catalog', + ...catalog, + legacyNames, +}; +const operationNames = definitions.map(({ name }) => `'${name}'`).join(' | '); +const ts = `/* Generated by scripts/generate-operation-contract.mjs. Do not edit. */ +export const OPERATION_SCHEMA_VERSION = ${version} as const; +export const OPERATION_ROUTE_PREFIX = ${JSON.stringify(routePrefix)} as const; +export const CATALOG_DIGEST = ${JSON.stringify(catalogDigest)} as const; +export const operationCatalog = ${JSON.stringify(definitions, null, 2)} as const; +export type OperationName = ${operationNames}; +export type OperationInput = + Readonly> & { readonly __operationName?: Name }; +export type OperationOutput = + unknown & { readonly __operationName?: Name }; +export const legacyOperationNames = ${JSON.stringify(legacyNames, null, 2)} as const; +`; + +const outputs = new Map([ + ['src/generated/operations.ts', ts], + [ + 'schemas/gnolith-operations-v1.json', + `${JSON.stringify(schemaArtifact, null, 2)}\n`, + ], + [ + 'schemas/legacy-operation-names-v1.json', + `${JSON.stringify({ format: 'gnolith-legacy-operation-names-v1', names: legacyNames }, null, 2)}\n`, + ], +]); +const check = process.argv.includes('--check'); +for (const [path, contents] of outputs) { + const absolute = resolve(path); + if (check) { + assert.equal( + readFileSync(absolute, 'utf8'), + contents, + `${path} is stale; run npm run generate`, + ); + } else { + mkdirSync(dirname(absolute), { recursive: true }); + writeFileSync(absolute, contents); + } +} +console.error( + `${check ? 'verified' : 'generated'} ${definitions.length} operations (${catalogDigest})`, +); + +function canonicalize(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonicalize(entry)).join(',')}]`; + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`) + .join(',')}}`; +} diff --git a/scripts/node-sqlite-parity.mjs b/scripts/node-sqlite-parity.mjs index c4bf3fc..e767de8 100644 --- a/scripts/node-sqlite-parity.mjs +++ b/scripts/node-sqlite-parity.mjs @@ -1,361 +1,257 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { basename, delimiter, dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; -const root = mkdtempSync(join(tmpdir(), 'workshop-node-sqlite-')); +const root = mkdtempSync(join(tmpdir(), 'workshop-packed-parity-')); const npmCli = findNpmCli(); -const diamondTarball = process.env.DIAMOND_TARBALL - ? resolve(process.env.DIAMOND_TARBALL) - : packInstalledDiamond(root, npmCli); -const expectedSha256 = process.env.DIAMOND_TARBALL_SHA256; -if (expectedSha256) { - assert.equal( - createHash('sha256').update(readFileSync(diamondTarball)).digest('hex'), - expectedSha256, - 'Diamond tarball SHA-256 does not match the pinned artifact', +try { + const diamondTarball = artifact( + 'DIAMOND_TARBALL', + resolve('..', 'diamond', 'gnolith-diamond-0.5.0.tgz'), + process.env.DIAMOND_TARBALL_SHA256 ?? + '6a8075a70162fe2567579f7fe110e674509b648923b18fdb310833b44739c300', + ); + const taprootTarball = artifact( + 'TAPROOT_TARBALL', + resolve('..', 'taproot-artifacts', 'gnolith-taproot-0.5.0.tgz'), + process.env.TAPROOT_TARBALL_SHA256 ?? + '9449a748b04926462f9549833a1a256e9fc4d7c557b01b6e3983606627005480', + ); + const [packed] = JSON.parse( + execFileSync( + process.execPath, + [npmCli, 'pack', '--json', '--pack-destination', root], + { cwd: process.cwd(), encoding: 'utf8' }, + ), + ); + const workshopTarball = join(root, packed.filename); + writeFileSync( + join(root, 'package.json'), + `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, ); -} - -const [packed] = JSON.parse( execFileSync( process.execPath, - [npmCli, 'pack', '--json', '--pack-destination', root], - { - cwd: process.cwd(), - encoding: 'utf8', - }, - ), -); -const workshopTarball = join(root, packed.filename); -writeFileSync( - join(root, 'package.json'), - `${JSON.stringify({ private: true, type: 'module' }, null, 2)}\n`, -); -execFileSync( - process.execPath, - [ - npmCli, - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--no-save', - diamondTarball, - '@gnolith/taproot@0.4.0', - workshopTarball, - ], - { cwd: root, stdio: 'inherit' }, -); - -writeFileSync( - join(root, 'claim-worker.mjs'), - ` -import { NodeSqliteDatabase } from '@gnolith/diamond/node-sqlite'; -import { bootstrapTaprootAuthorization, createTaprootHostWriteCapability } from '@gnolith/taproot'; -import { createHmac } from 'node:crypto'; -import { createWorkshopCore } from '@gnolith/workshop/core'; -import { createWorkshopSearchIntegrationV1 } from '@gnolith/workshop/server'; -const db = new NodeSqliteDatabase(process.argv[2], { busyTimeoutMs: 5_000 }); -const AUTH = { - installationId: 'parity:installation', principalId: process.argv[4], - activeWorkspaceId: 'parity:workspace', workspaceIds: ['parity:workspace'], - capabilities: ['read', 'task-write', 'memory-write', 'knowledge-write', 'admin', 'search:admin'], - authorizationRevision: 1, -}; -const TAPROOT = { baseIri: 'https://parity.workshop.gnolith.test' }; -const hostKey = await crypto.subtle.importKey('raw', new TextEncoder().encode('workshop-parity-host-key-32!!!!'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); -const hostCapability = createTaprootHostWriteCapability(db, TAPROOT, hostKey); -const search = await createWorkshopSearchIntegrationV1({ db, taproot: TAPROOT, hostCapability, installationId: AUTH.installationId, registrationContext: AUTH }); -const core = createWorkshopCore({ - persistence: db, - authorization: { - getInstallationAuthorizationState: async () => ({ installationId: AUTH.installationId, authorizationRevision: 1, searchGeneration: 1 }), - commitTaskMutation: async (_db, _context, mutation) => mutation.first(), - commitMemoryMutation: async (_db, _context, mutation) => mutation.first(), - commitTaskBackfill: async (_db, _context, _state, mutations) => _db.batch([...mutations]), - commitMemoryBackfill: async (_db, _context, _state, mutations) => _db.batch([...mutations]), - commitCursorSnapshot: async (_db, _context, _state, mutations) => _db.batch([...mutations]), - }, - knowledge: { - authorizedReader: () => ({ - getEntity: async () => null, - searchEntities: async () => ({ items: [], cursor: null }), - }), - health: async () => true, - }, - diamondHealth: async () => true, - cursorCodec: { - currentGeneration: async () => 'parity-generation', - digest: async (purpose, value) => createHmac('sha256', 'workshop-parity-cursor-key').update(purpose).update('\\0').update(value).digest('hex'), - seal: async () => { throw new Error('pagination not exercised'); }, - open: async () => { throw new Error('pagination not exercised'); }, - }, - search, -}); -try { - await core.tasks.claim(process.argv[3], AUTH); - console.log('fulfilled'); -} catch { - console.log('rejected'); + [ + npmCli, + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--no-save', + diamondTarball, + taprootTarball, + workshopTarball, + 'typescript@5.9.2', + '@types/node@24', + ], + { cwd: root, stdio: 'inherit' }, + ); + writeFileSync(join(root, 'consumer.mjs'), consumerScript()); + execFileSync(process.execPath, [join(root, 'consumer.mjs')], { + cwd: root, + stdio: 'inherit', + }); + writeFileSync(join(root, 'consumer.ts'), declarationConsumer()); + writeFileSync( + join(root, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + module: 'NodeNext', + moduleResolution: 'NodeNext', + target: 'ES2023', + types: ['node'], + exactOptionalPropertyTypes: true, + }, + include: ['consumer.ts'], + }, + null, + 2, + )}\n`, + ); + execFileSync( + process.execPath, + [join(root, 'node_modules', 'typescript', 'bin', 'tsc')], + { cwd: root, stdio: 'inherit' }, + ); } finally { - await db.close(); + rmSync(root, { recursive: true, force: true }); } -`, -); - -writeFileSync( - join(root, 'parity.mjs'), - ` -import assert from 'node:assert/strict'; -import { createHmac } from 'node:crypto'; -import { spawn } from 'node:child_process'; -import { join } from 'node:path'; -import { readAppliedMigrations } from '@gnolith/diamond'; -import { NodeSqliteDatabase } from '@gnolith/diamond/node-sqlite'; -import { bootstrapTaprootAuthorization, createTaprootHostWriteCapability, initializeTaproot } from '@gnolith/taproot'; -import { applyWorkshopMigrations, WORKSHOP_SCHEMA_VERSION, workshopMigrations } from '@gnolith/workshop/migrations'; -import { createWorkshopCore } from '@gnolith/workshop/core'; -import { backfillWorkshopAuthorizationBatch, createWorkshopSearchIntegrationV1, HealthService } from '@gnolith/workshop/server'; - -const path = join(${JSON.stringify(root)}, 'workshop.sqlite'); -const TAPROOT = { baseIri: 'https://parity.workshop.gnolith.test' }; -const AUTH = (principalId = 'owner') => ({ - installationId: 'parity:installation', principalId, - activeWorkspaceId: 'parity:workspace', workspaceIds: ['parity:workspace'], - capabilities: ['read', 'task-write', 'memory-write', 'knowledge-write', 'admin', 'search:admin'], - authorizationRevision: 1, -}); -const authorization = { - getInstallationAuthorizationState: async () => - ({ installationId: 'parity:installation', authorizationRevision: 1, searchGeneration: 1 }), - commitTaskMutation: async (_db, _context, mutation) => mutation.first(), - commitMemoryMutation: async (_db, _context, mutation) => mutation.first(), - commitTaskBackfill: async (_db, _context, _state, mutations) => _db.batch([...mutations]), - commitMemoryBackfill: async (_db, _context, _state, mutations) => _db.batch([...mutations]), - commitCursorSnapshot: async (_db, _context, _state, mutations) => _db.batch([...mutations]), -}; -const options = { - authorization, - knowledge: { - authorizedReader: () => ({ - getEntity: async () => null, - searchEntities: async () => ({ items: [], cursor: null }), - }), - health: async () => true, - }, - diamondHealth: async () => true, - cursorCodec: { - currentGeneration: async () => 'parity-generation', - digest: async (purpose, value) => createHmac('sha256', 'workshop-parity-cursor-key').update(purpose).update('\\0').update(value).digest('hex'), - seal: async () => { throw new Error('pagination not exercised'); }, - open: async () => { throw new Error('pagination not exercised'); }, - }, -}; -const searchFor = async (db, adopt = false) => { - const hostKey = await crypto.subtle.importKey('raw', new TextEncoder().encode('workshop-parity-host-key-32!!!!'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); - const hostCapability = createTaprootHostWriteCapability(db, TAPROOT, hostKey); - const search = await createWorkshopSearchIntegrationV1({ db, taproot: TAPROOT, hostCapability, installationId: AUTH().installationId, registrationContext: AUTH() }); - if (adopt) for (const domain of [search.task, search.memory, search.prompt]) await domain.producer.adoptLegacyPage(AUTH(), { limit: 100 }); - return search; -}; -let first = new NodeSqliteDatabase(path); -await initializeTaproot(first, TAPROOT); -const bootstrapKey = await crypto.subtle.importKey('raw', new TextEncoder().encode('workshop-parity-host-key-32!!!!'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); -await bootstrapTaprootAuthorization(first, TAPROOT, createTaprootHostWriteCapability(first, TAPROOT, bootstrapKey), AUTH().installationId); -await applyWorkshopMigrations(first); -let search = await searchFor(first, true); -let core = createWorkshopCore({ persistence: first, ...options, search }); -await assert.rejects(first.batch([ - first.prepare("INSERT INTO workshop_memories (slug, description, content, created_at, updated_at, revision) VALUES ('rollback-sentinel', 'Sentinel', 'Must roll back', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)"), - first.prepare("INSERT INTO workshop_memories (slug, description, content, created_at, updated_at, revision) VALUES ('rollback-failure', NULL, 'Invalid', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)"), -])); -assert.equal((await first.prepare( - "SELECT COUNT(*) AS count FROM workshop_memories WHERE slug = 'rollback-sentinel'", -).first()).count, 0); -await core.memories.upsert('durable', { description: 'Durable', content: 'Across reopen' }, AUTH()); -const created = await core.tasks.create({ description: 'Portable task', prompt: 'Persist this', memorySlugs: ['durable'] }, AUTH()); -const memory = await core.memories.get('durable', AUTH()); -const changedMemory = await core.memories.upsert('durable', { - description: 'Durable', content: 'Revision two', expectedRevision: memory.revision, -}, AUTH()); -assert.equal(changedMemory.revision, 2); -await assert.rejects(core.memories.upsert('durable', { - description: 'Durable', content: 'Stale', expectedRevision: memory.revision, -}, AUTH())); - -await first.close(); - -first = new NodeSqliteDatabase(path); -search = await searchFor(first); -core = createWorkshopCore({ persistence: first, ...options, search }); -assert.equal((await core.tasks.get(created.id, AUTH())).description, 'Portable task'); -assert.equal((await core.memories.get('durable', AUTH())).content, 'Revision two'); -assert.deepEqual(await applyWorkshopMigrations(first), { - previousVersion: WORKSHOP_SCHEMA_VERSION, - version: WORKSHOP_SCHEMA_VERSION, - applied: [], -}); -const [recorded] = await readAppliedMigrations(first, '@gnolith/workshop'); -await first.prepare( - "UPDATE _gnolith_migrations SET checksum = 'tampered' WHERE namespace = '@gnolith/workshop' AND migration_id = ?", -).bind(recorded.id).run(); -await assert.rejects(applyWorkshopMigrations(first)); -await first.prepare( - "UPDATE _gnolith_migrations SET checksum = ? WHERE namespace = '@gnolith/workshop' AND migration_id = ?", -).bind(recorded.checksum, recorded.id).run(); -const health = await new HealthService({ - db: first, - diamondHealth: core.diamondHealth, - knowledge: core.knowledge, - constructMcp: () => ({}), -}).inspect(); -assert.equal(health.status, 'ok'); -assert.equal(health.checks.persistence, true); -assert.equal(health.checks.d1, true); - -const second = new NodeSqliteDatabase(path); -const competing = createWorkshopCore({ persistence: second, ...options, search: await searchFor(second) }); -const outcomes = await Promise.allSettled([ - core.tasks.claim(created.id, AUTH('agent:first')), - competing.tasks.claim(created.id, AUTH('agent:second')), -]); -assert.equal(outcomes.filter(({ status }) => status === 'fulfilled').length, 1); -assert.equal(outcomes.filter(({ status }) => status === 'rejected').length, 1); -assert.equal((await core.tasks.get(created.id, AUTH())).claimed, true); -await second.close(); -await first.close(); - -first = new NodeSqliteDatabase(path); -search = await searchFor(first); -core = createWorkshopCore({ persistence: first, ...options, search }); -const processTask = await core.tasks.create({ description: 'Process contention', prompt: 'Claim once' }, AUTH()); -await first.close(); -const workerPath = join(${JSON.stringify(root)}, 'claim-worker.mjs'); -const runClaim = (principal) => new Promise((resolve, reject) => { - const child = spawn(process.execPath, [workerPath, path, processTask.id, principal], { - cwd: ${JSON.stringify(root)}, stdio: ['ignore', 'pipe', 'pipe'], - }); - let output = ''; - let error = ''; - child.stdout.on('data', (chunk) => { output += chunk; }); - child.stderr.on('data', (chunk) => { error += chunk; }); - child.on('error', reject); - child.on('close', (code) => code === 0 ? resolve(output.trim()) : reject(new Error(error))); -}); -const processOutcomes = await Promise.all([runClaim('process:a'), runClaim('process:b')]); -assert.deepEqual(processOutcomes.sort(), ['fulfilled', 'rejected'].sort()); -first = new NodeSqliteDatabase(path); -search = await searchFor(first); -core = createWorkshopCore({ persistence: first, ...options, search }); -assert.equal((await core.tasks.get(processTask.id, AUTH())).claimed, true); -await first.close(); - -const legacyPath = join(${JSON.stringify(root)}, 'workshop-v1.sqlite'); -let legacy = new NodeSqliteDatabase(legacyPath); -await initializeTaproot(legacy, TAPROOT); -const legacyBootstrapKey = await crypto.subtle.importKey('raw', new TextEncoder().encode('workshop-parity-host-key-32!!!!'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); -await bootstrapTaprootAuthorization(legacy, TAPROOT, createTaprootHostWriteCapability(legacy, TAPROOT, legacyBootstrapKey), AUTH().installationId); -const legacyStatements = workshopMigrations[0].sql - .split(/;\\s*(?:\\r?\\n|$)/u) - .map((statement) => statement.trim()) - .filter(Boolean) - .map((statement) => legacy.prepare(statement)); -await legacy.batch(legacyStatements); -await legacy.batch([ - legacy.prepare("INSERT INTO workshop_memories (slug, description, content, created_at, updated_at) VALUES ('legacy-memory', 'Legacy', 'Before migration', '2026-07-20 12:00:00', '2026-07-20 12:00:00')"), - legacy.prepare("INSERT INTO workshop_tasks (id, description, prompt, context_queries, memory_slugs, claimed, created_at, updated_at) VALUES ('legacy-update', 'Legacy update', 'Before migration', '[]', json_array('legacy-memory'), 0, '2026-07-20 12:00:00', '2026-07-20 12:00:00')"), - legacy.prepare("INSERT INTO workshop_tasks (id, description, prompt, context_queries, memory_slugs, claimed, created_at, updated_at) VALUES ('legacy-archive', 'Legacy archive', 'Before migration', '[]', '[]', 0, '2026-07-20 12:00:00', '2026-07-20 12:00:00')"), -]); -await applyWorkshopMigrations(legacy); -const legacyRecords = await readAppliedMigrations(legacy, '@gnolith/workshop'); -assert.deepEqual(legacyRecords.map(({ adopted }) => adopted), [ - true, - false, - false, - false, - false, - false, -]); -const legacySearch = await searchFor(legacy); -const legacyCore = createWorkshopCore({ persistence: legacy, ...options, search: legacySearch }); -await assert.rejects(legacyCore.memories.get('legacy-memory', AUTH())); -await assert.rejects(legacyCore.tasks.get('legacy-update', AUTH())); -await backfillWorkshopAuthorizationBatch(legacy, authorization, AUTH(), { domain: 'memory' }); -await backfillWorkshopAuthorizationBatch(legacy, authorization, AUTH(), { domain: 'task' }); -for (const domain of [legacySearch.task, legacySearch.memory, legacySearch.prompt]) await domain.producer.adoptLegacyPage(AUTH(), { limit: 100 }); -const legacyMemory = await legacyCore.memories.get('legacy-memory', AUTH()); -assert.equal((await legacyCore.memories.upsert('legacy-memory', { - description: 'Legacy', content: 'Migrated', expectedUpdatedAt: legacyMemory.updatedAt, -}, AUTH())).revision, 2); -const legacyTask = await legacyCore.tasks.get('legacy-update', AUTH()); -assert.equal((await legacyCore.tasks.update('legacy-update', { - expectedUpdatedAt: legacyTask.updatedAt, prompt: 'Migrated update', -}, AUTH())).revision, 2); -const legacyArchive = await legacyCore.tasks.get('legacy-archive', AUTH()); -assert.equal((await legacyCore.tasks.archive('legacy-archive', { - expectedUpdatedAt: legacyArchive.updatedAt, -}, AUTH())).archivedAt !== undefined, true); -await legacy.close(); -console.log('Workshop packed-artifact node:sqlite reopen, migrations, revisions, health, connection and process contention parity passed'); -`, -); -execFileSync(process.execPath, [join(root, 'parity.mjs')], { - cwd: root, - stdio: 'inherit', -}); -console.log( - `verified ${basename(workshopTarball)} with ${basename(diamondTarball)}`, -); -function fail(message) { - throw new Error(message); +function artifact(environmentName, fallback, expectedSha256) { + const path = resolve(process.env[environmentName] ?? fallback); + assert.equal( + sha256(readFileSync(path)), + expectedSha256, + `${environmentName} SHA-256 does not match the exact candidate`, + ); + return path; } -function packInstalledDiamond(destination, npmCli) { - const installed = resolve('node_modules/@gnolith/diamond'); - if (!existsSync(installed)) - fail('DIAMOND_TARBALL is required when @gnolith/diamond is not installed'); - const [packed] = JSON.parse( - execFileSync( - process.execPath, - [ - npmCli, - 'pack', - '--ignore-scripts', - '--json', - '--pack-destination', - destination, - ], - { cwd: installed, encoding: 'utf8' }, - ), - ); - return join(destination, packed.filename); +function sha256(bytes) { + return createHash('sha256').update(bytes).digest('hex'); } function findNpmCli() { const candidates = [ process.env.npm_execpath, - join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), join( - dirname(process.execPath), + process.execPath, + '..', '..', - 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js', ), - ...String(process.env.PATH ?? '') - .split(delimiter) - .filter(Boolean) - .map((entry) => join(entry, 'node_modules', 'npm', 'bin', 'npm-cli.js')), - ]; - const npmCli = candidates.find( - (candidate) => candidate && existsSync(candidate), + ].filter(Boolean); + const cli = candidates.find((candidate) => { + try { + return readFileSync(candidate).length > 0; + } catch { + return false; + } + }); + assert.ok(cli, 'npm CLI was not found'); + return cli; +} + +function consumerScript() { + return String.raw` +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { NodeSqliteDatabase } from '@gnolith/diamond/node-sqlite'; +import { + commitAuthorizationBootstrapV1, + createTaprootHostWriteCapability, + initializeTaproot, + prepareAuthorizationBootstrapV1, + registerAuthorizationBootstrapOwnerV1, +} from '@gnolith/taproot'; +import { workshopPackage } from '@gnolith/workshop'; +import { + bootstrapWorkshopV1, + createWorkshopBootstrapOwnerAdapterV1, +} from '@gnolith/workshop/bootstrap'; +import { + createLocalFilesystemResourceBlobPortsV1, +} from '@gnolith/workshop/backup'; +import { + applyWorkshopMigrations, + WORKSHOP_SCHEMA_VERSION, +} from '@gnolith/workshop/migrations'; + +assert.equal(workshopPackage.version, '0.5.0'); +assert.equal(workshopPackage.schemaVersion, 8); +assert.equal(WORKSHOP_SCHEMA_VERSION, 8); +const temporary = await mkdtemp(join(tmpdir(), 'workshop-consumer-')); +const path = join(temporary, 'shared.sqlite'); +const baseIri = 'https://packed-parity.example'; +const installationId = 'installation:packed-parity'; +let db = new NodeSqliteDatabase(path); +try { + await initializeTaproot(db, { baseIri }); + assert.deepEqual(await applyWorkshopMigrations(db), { + previousVersion: 0, + version: 8, + applied: [ + '0001_workshop', '0002_revisions', '0003_resumable_onboarding', + '0004_authorization', '0005_cursor_snapshots', + '0006_canonical_search_domains', '0007_task_leases', + '0008_bootstrap_credentials', + ], + }); + const key = await crypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], + ); + const authority = createTaprootHostWriteCapability(db, { baseIri }, key); + const ownerAdapter = createWorkshopBootstrapOwnerAdapterV1(db); + const issuer = await registerAuthorizationBootstrapOwnerV1( + db, { baseIri }, authority, + { installationId, ownerRegistrationId: 'workshop', adapter: ownerAdapter.adapter }, + ); + const raw = randomBytes(32); + const credentialFile = new TextEncoder().encode(raw.toString('base64url')); + const receipt = await bootstrapWorkshopV1( + { + version: 1, installationId, baseIri, + principalId: 'principal:owner', workspaceId: 'workspace:default', + credential: { kind: 'file', value: '/protected' }, + idempotencyKey: 'packed-owner-v1', profile: 'local-bearer-v1', + }, + { + prepareAuthorizationBootstrapV1: (input) => + prepareAuthorizationBootstrapV1(db, { baseIri }, authority, input), + ownerParticipantIssuer: issuer, + ownerAdapter, + commitAuthorizationBootstrapV1, + readProtectedCredential: async () => credentialFile.slice(), + workshopSchemaVersion: 8, + }, + ); + assert.equal(receipt.installationId, installationId); + const blobRoot = join(temporary, 'blobs'); + const blobs = await createLocalFilesystemResourceBlobPortsV1({ root: blobRoot }); + const bytes = new TextEncoder().encode('packed payload'); + const descriptor = { + referenceId: 'blob:packed', resourceId: 'resource:packed', revision: 1, + byteLength: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + mediaType: 'text/plain', + }; + const stage = await blobs.restore.stage( + descriptor, + new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } }), ); - if (!npmCli) throw new Error('Unable to locate npm-cli.js'); - return npmCli; + await blobs.restore.verify(stage); + await blobs.restore.commit([stage]); + assert.deepEqual((await blobs.backup.enumerate(null, 10)).items, [descriptor]); + await db.close(); + db = new NodeSqliteDatabase(path); + assert.deepEqual(await applyWorkshopMigrations(db), { + previousVersion: 8, version: 8, applied: [], + }); +} finally { + await db.close().catch(() => {}); + await rm(temporary, { recursive: true, force: true }); +} +`; +} + +function declarationConsumer() { + return ` +import type { + ResourceBlobBackupPort, + ResourceBlobRestorePort, +} from '@gnolith/taproot/backup'; +import { + createLocalFilesystemResourceBlobPortsV1, + type LocalFilesystemResourceBlobPortsV1, +} from '@gnolith/workshop/backup'; +import { + createWorkshopCanonicalOwnerAdapterV1, + type WorkshopSearchDomainV1, +} from '@gnolith/workshop/server'; + +async function consume(): Promise { + const ports: LocalFilesystemResourceBlobPortsV1 = + await createLocalFilesystemResourceBlobPortsV1({ root: '/absolute/blobs' }); + const backup: ResourceBlobBackupPort = ports.backup; + const restore: ResourceBlobRestorePort = ports.restore; + void backup; + void restore; + void createWorkshopCanonicalOwnerAdapterV1; + const domain: WorkshopSearchDomainV1 | null = null; + void domain; +} +void consume; +`; } diff --git a/scripts/packed-process-smoke.mjs b/scripts/packed-process-smoke.mjs new file mode 100644 index 0000000..a023bbf --- /dev/null +++ b/scripts/packed-process-smoke.mjs @@ -0,0 +1,246 @@ +/* global fetch */ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { execFileSync, spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout } from 'node:timers'; + +const root = resolve(import.meta.dirname, '..'); +const packed = JSON.parse( + execFileSync('npm', ['pack', '--json'], { + cwd: root, + encoding: 'utf8', + shell: process.platform === 'win32', + }), +); +const tarball = resolve(root, packed[0].filename); +const fixture = mkdtempSync(join(tmpdir(), 'gnolith-workshop-process-')); +let child; +try { + writeFileSync( + join(fixture, 'package.json'), + JSON.stringify({ + private: true, + type: 'module', + dependencies: { '@gnolith/workshop': `file:${tarball}` }, + }), + ); + execFileSync('npm', ['install', '--ignore-scripts'], { + cwd: fixture, + stdio: 'ignore', + shell: process.platform === 'win32', + }); + const token = Buffer.from( + Uint8Array.from({ length: 32 }, (_, index) => index), + ).toString('base64url'); + const port = await availablePort(); + await writeFile( + join(fixture, 'adapter.mjs'), + ` +import { createOperationRuntime } from '@gnolith/workshop'; +import { + bindWorkshopProducerHandles, + createAuthenticationVerifier, + digestLocalBearerToken, +} from '@gnolith/workshop/server'; + +const bytes = Uint8Array.from({ length: 32 }, (_, index) => index); +const digest = await digestLocalBearerToken(bytes); +const expectation = { + installationId: 'installation:packed', + connectionId: 'connection:packed', + registrationFingerprint: 'd'.repeat(64), +}; +const binding = bindWorkshopProducerHandles(expectation, Object.fromEntries( + ['task', 'memory', 'prompt'].map((kind) => [kind, { + owner: '@gnolith/workshop', + ...expectation, + handle: Object.freeze({ opaque: crypto.randomUUID() }), + }]), +)); +const principal = { + installationId: expectation.installationId, + principalId: 'principal:packed', + activeWorkspaceId: 'workspace:packed', + capabilities: ['read'], + authorizationRevision: 1, + credentialId: 'credential:packed', +}; +const status = { + baseIri: 'https://packed.invalid/', + migrations: { namespace: '@gnolith/workshop', version: 8, ready: true }, + compatibility: { diamond: true, taproot: true }, + canonicalReady: true, + authorizationReady: true, + lexicalReady: true, + semantic: { state: 'unconfigured', configured: false }, + producers: { + ready: true, + fingerprint: expectation.registrationFingerprint, + kinds: ['task', 'memory', 'prompt'], + }, + blobReady: true, +}; +export async function createWorkshopExecutable() { + const authentication = createAuthenticationVerifier({ + profile: 'local-bearer-v1', + credentials: { + async findByDigest(candidate) { + return candidate === digest ? { + profile: 'local-bearer-v1', + credentialId: principal.credentialId, + tokenDigest: digest, + installationId: principal.installationId, + principalId: principal.principalId, + activeWorkspaceId: principal.activeWorkspaceId, + capabilities: principal.capabilities, + authorizationRevision: principal.authorizationRevision, + disabled: false, + } : null; + }, + }, + authorization: { async recheck() { return principal; } }, + }); + const operations = createOperationRuntime({ + version: '0.5.0', + taproot: { + async call() { return {}; }, + async createScopedSparqlView() { return {}; }, + async recheckPrincipal() { return principal; }, + async status() { return status; }, + }, + diamond: { + async validate() { return {}; }, + async query() { return {}; }, + }, + workshop: { async call() { return {}; } }, + }); + return { + version: '0.5.0', + authentication, + operations, + producerBinding: { binding, expectation }, + async preflight() { return status; }, + }; +} +`, + ); + await writeFile( + join(fixture, 'config.json'), + JSON.stringify({ + format: 'gnolith-workshop-config-v1', + listener: { host: '127.0.0.1', port, profile: 'test' }, + authentication: { + profile: 'local-bearer-v1', + credentialStore: { kind: 'file', value: '/unused' }, + }, + adapterModule: './adapter.mjs', + }), + ); + child = spawn( + process.execPath, + [ + join(fixture, 'node_modules', '@gnolith', 'workshop', 'dist', 'cli.js'), + 'serve', + '--config', + join(fixture, 'config.json'), + ], + { cwd: fixture, stdio: 'ignore' }, + ); + const origin = `http://127.0.0.1:${port}`; + await waitForLive(origin, child); + assert.deepEqual(await (await fetch(`${origin}/health/live`)).json(), { + status: 'live', + }); + const initialize = await fetch(`${origin}/mcp`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26' }, + }), + }); + const body = await initialize.json(); + assert.equal(body.result.serverInfo.name, 'gnolith'); + assert.equal(body.result.serverInfo.version, '0.5.0'); + await terminate(child); + process.stdout.write( + `${JSON.stringify({ packedProcess: 'ok', catalogDigest: '577cc1de501b0ae3556eb1d32e7dd516c70a09c5b6226d671cec312068fba3dd' })}\n`, + ); +} finally { + if (child?.exitCode === null) await terminate(child); + await rm(fixture, { recursive: true, force: true }); + await rm(tarball, { force: true }); +} + +async function terminate(processHandle) { + if (processHandle.exitCode !== null) return; + const exited = new Promise((resolvePromise) => + processHandle.once('exit', resolvePromise), + ); + processHandle.kill('SIGTERM'); + const completed = await Promise.race([ + exited.then(() => true), + new Promise((resolvePromise) => + setTimeout(() => resolvePromise(false), 10_000), + ), + ]); + if (completed) return; + if (process.platform === 'win32') { + try { + execFileSync( + 'taskkill', + ['/pid', String(processHandle.pid), '/t', '/f'], + { + stdio: 'ignore', + }, + ); + } catch (error) { + if (processHandle.kill(0)) throw error; + return; + } + } else if (!processHandle.kill('SIGKILL')) return; + await Promise.race([ + exited, + new Promise((resolvePromise) => setTimeout(resolvePromise, 2_000)), + ]); +} + +async function availablePort() { + const server = createServer(); + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + const address = server.address(); + assert(address && typeof address === 'object'); + await new Promise((resolvePromise, reject) => + server.close((error) => (error ? reject(error) : resolvePromise())), + ); + return address.port; +} + +async function waitForLive(origin, processHandle) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (processHandle.exitCode !== null) + throw new Error(`packed process exited ${processHandle.exitCode}`); + try { + const response = await fetch(`${origin}/health/live`); + if (response.ok) return; + } catch { + // The listener is not ready yet. + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + throw new Error('packed process did not become live'); +} diff --git a/scripts/prepare-candidate.mjs b/scripts/prepare-candidate.mjs new file mode 100644 index 0000000..f186c96 --- /dev/null +++ b/scripts/prepare-candidate.mjs @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const manifest = JSON.parse(await readFile(resolve(root, 'package.json'))); +const head = run('git', ['rev-parse', 'HEAD']).trim(); +if (run('git', ['status', '--porcelain']).trim()) + throw new Error('Candidate preparation requires a clean committed tree'); +if (!/^[a-f0-9]{40}$/u.test(head)) + throw new Error('Candidate commit is invalid'); +const output = resolve( + root, + '.artifacts', + `${manifest.name.split('/').at(-1)}-${manifest.version}-${head}`, +); +await mkdir(output, { recursive: true }); +run('npm', ['pack', '--pack-destination', output], { stdout: 'inherit' }); +const artifact = resolve(output, `gnolith-workshop-${manifest.version}.tgz`); +const sbomText = run('npm', [ + 'sbom', + '--omit=dev', + '--sbom-format', + 'cyclonedx', +]); +const sbom = resolve( + output, + `gnolith-workshop-${manifest.version}.sbom.cdx.json`, +); +await writeFile(sbom, `${sbomText.trim()}\n`); +const artifactSha256 = await sha256(artifact); +const sbomSha256 = await sha256(sbom); +const migrationIds = [ + '0001_workshop', + '0002_revisions', + '0003_resumable_onboarding', + '0004_authorization', + '0005_cursor_snapshots', + '0006_canonical_search_domains', + '0007_task_leases', + '0008_bootstrap_credentials', +]; +const inventory = { + format: 'gnolith-workshop-candidate-v1', + package: manifest.name, + version: manifest.version, + commit: head, + artifact, + artifactSha256, + sbom, + sbomSha256, + catalogDigest: + '577cc1de501b0ae3556eb1d32e7dd516c70a09c5b6226d671cec312068fba3dd', + schemaVersion: 8, + migrationNamespace: '@gnolith/workshop', + migrations: await Promise.all( + migrationIds.map(async (id) => ({ + id, + checksum: `sha256:${await migrationChecksum( + resolve(root, 'migrations', `${id}.sql`), + )}`, + })), + ), + configSchema: 'schemas/workshop-config-v1.json', + blobFactory: + '@gnolith/workshop/backup#createLocalFilesystemResourceBlobPortsV1', + mcpServerName: 'gnolith', + createdAt: new Date().toISOString(), +}; +const inventoryPath = resolve(output, 'candidate-inventory.json'); +await writeFile(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`); +await writeFile( + resolve(output, 'SHA256SUMS'), + `${artifactSha256} ${artifact.split(/[\\/]/u).at(-1)}\n${sbomSha256} ${sbom.split(/[\\/]/u).at(-1)}\n${await sha256(inventoryPath)} candidate-inventory.json\n`, +); +process.stdout.write(`${JSON.stringify({ ...inventory, inventoryPath })}\n`); + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: root, + encoding: 'utf8', + shell: process.platform === 'win32', + ...options, + }); + if (result.status !== 0) + throw new Error(result.stderr || `${command} failed`); + return result.stdout ?? ''; +} + +async function sha256(path) { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex'); +} + +async function migrationChecksum(path) { + return createHash('sha256') + .update((await readFile(path, 'utf8')).replace(/\r\n/gu, '\n').trim()) + .digest('hex'); +} diff --git a/scripts/prepublish-check.mjs b/scripts/prepublish-check.mjs index a7d552b..b79bb94 100644 --- a/scripts/prepublish-check.mjs +++ b/scripts/prepublish-check.mjs @@ -25,8 +25,8 @@ function main() { const output = `${result.stdout}${result.stderr}`; assert.match( output, - /@gnolith\/taproot 0\.4\.0 public npm provenance verified/u, - 'Taproot provenance verification did not complete before publication stopped', + /@gnolith\/taproot 0\.5\.0 candidate integrity verified/u, + 'Taproot candidate verification did not complete before publication stopped', ); assert.doesNotMatch( output, diff --git a/scripts/release-block.mjs b/scripts/release-block.mjs index 0a33894..54d4ae5 100644 --- a/scripts/release-block.mjs +++ b/scripts/release-block.mjs @@ -12,6 +12,26 @@ const predicateType = 'https://slsa.dev/provenance/v1'; const sourceCommit = '819fe054ebb867e1ca92518bfd3b1aa6c5aa277d'; const exactIntegrity = 'sha512-yYxbrUNnu74zBaxHoywGlgeG2LFz4HMzi2RLcsq83/JVEIkwbWvWZ8tuLpxFYxTAgTXG1/FHddgEJStRupe54A=='; +const candidateVersion = '0.5.0'; +const candidateIntegrity = + 'sha512-oqqkWajZIbsGg5fXD2runGH58j5FrEPEPoN9DXViSMLrS+9PGPMqWKjxe/qjhCW4+vsgRl4VdP8jI9KJMu2pkg=='; + +export function verifyInstalledTaprootCandidate() { + const lock = JSON.parse(readFileSync('package-lock.json', 'utf8')); + const locked = lock.packages?.['node_modules/@gnolith/taproot']; + const installed = JSON.parse( + readFileSync('node_modules/@gnolith/taproot/package.json', 'utf8'), + ); + assert.equal(installed.name, packageName); + assert.equal(installed.version, candidateVersion); + assert.equal(locked?.version, candidateVersion); + assert.equal(locked?.integrity, candidateIntegrity); + return { + packageName, + version: candidateVersion, + integrity: candidateIntegrity, + }; +} export async function verifyTaprootRelease({ fetchImpl = globalThis.fetch, @@ -176,8 +196,8 @@ if ( process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href ) { - const verified = await verifyTaprootRelease(); + const verified = verifyInstalledTaprootCandidate(); console.log( - `${verified.packageName} ${verified.version} public npm provenance verified`, + `${verified.packageName} ${verified.version} candidate integrity verified`, ); } diff --git a/scripts/waystone-consumer-smoke.mjs b/scripts/waystone-consumer-smoke.mjs deleted file mode 100644 index 95ccacb..0000000 --- a/scripts/waystone-consumer-smoke.mjs +++ /dev/null @@ -1,209 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { loadAndVerifyArtifact } from './artifact-provenance.mjs'; - -const npmCli = process.env.npm_execpath; -if (!npmCli) throw new Error('consumer:check must be run through npm'); - -const source = JSON.parse(readFileSync('package.json', 'utf8')); -const publicWorkshopVersion = process.env.WORKSHOP_PUBLIC_CONSUMER_VERSION; -const workshopConsumerSpec = process.env.WORKSHOP_CONSUMER_SPEC; -if (publicWorkshopVersion !== undefined && workshopConsumerSpec !== undefined) { - throw new Error( - 'Set only one of WORKSHOP_PUBLIC_CONSUMER_VERSION and WORKSHOP_CONSUMER_SPEC', - ); -} -if ( - publicWorkshopVersion !== undefined && - publicWorkshopVersion !== source.version -) { - throw new Error( - `Public Workshop consumer version ${publicWorkshopVersion} does not match ${source.version}`, - ); -} -const workshopSpec = - publicWorkshopVersion ?? - workshopConsumerSpec ?? - (await loadAndVerifyArtifact()).archive; -const root = mkdtempSync(join(tmpdir(), 'workshop-waystone-consumer-')); -const waystoneIntegrity = - 'sha512-2lzNmfn9KZC1c47ydV9DO90QXixb6yScEBeh8kfI4KaNj2cXrWOyvZxnjjz/WFPF+jmy7YTRSmGRxTxh42oA6g=='; - -writeFileSync( - join(root, 'package.json'), - `${JSON.stringify( - { - private: true, - type: 'module', - dependencies: { - '@gnolith/taproot': '0.4.0', - '@gnolith/workshop': workshopSpec, - '@gnolith/waystone': '0.2.0', - '@types/react': '19.2.14', - '@types/react-dom': '19.2.3', - react: '19.2.7', - 'react-dom': '19.2.7', - typescript: '5.9.3', - }, - }, - null, - 2, - )}\n`, -); - -// Intentionally use normal npm resolution: no --force or --legacy-peer-deps. -execFileSync( - process.execPath, - [ - npmCli, - 'install', - '--ignore-scripts', - '--no-audit', - '--no-fund', - '--prefix', - root, - ], - { stdio: 'inherit' }, -); - -const installedWorkshop = packageManifest('@gnolith/workshop'); -const installedWaystone = packageManifest('@gnolith/waystone'); -assert.equal(installedWorkshop.version, source.version); -assert.equal(installedWaystone.version, '0.2.0'); -assert.equal( - installedWorkshop.peerDependencies['@gnolith/waystone'], - '>=0.1.0 <0.3.0', -); -assert.equal(installedWaystone.peerDependencies.react, '>=19 <20'); - -const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8')); -assert.equal( - lock.packages['node_modules/@gnolith/waystone'].integrity, - waystoneIntegrity, - 'Disposable consumer did not resolve the qualified public Waystone 0.2.0 artifact', -); - -writeFileSync( - join(root, 'declaration-probe.tsx'), - ` - import { createWorkshopPlugin } from '@gnolith/workshop/ui'; - import type { WaystonePlugin as WorkshopPlugin } from '@gnolith/workshop/ui'; - import { createWaystoneRegistry } from '@gnolith/waystone/plugin'; - import type { - WorkshopCompatibleWaystonePlugin, - WaystonePluginInput, - WaystoneRegistry, - } from '@gnolith/waystone/plugin'; - - const workshopPlugin: WorkshopPlugin = createWorkshopPlugin(); - const compatiblePlugin: WorkshopCompatibleWaystonePlugin = workshopPlugin; - const pluginInput: WaystonePluginInput = compatiblePlugin; - export const registry: WaystoneRegistry = createWaystoneRegistry([pluginInput]); - `, -); -writeFileSync( - join(root, 'tsconfig.json'), - `${JSON.stringify( - { - compilerOptions: { - target: 'ES2022', - module: 'ESNext', - moduleResolution: 'Bundler', - jsx: 'react-jsx', - lib: ['ES2022', 'DOM', 'DOM.Iterable'], - strict: true, - exactOptionalPropertyTypes: true, - noEmit: true, - skipLibCheck: true, - }, - include: ['declaration-probe.tsx'], - }, - null, - 2, - )}\n`, -); -execFileSync( - process.execPath, - [ - join(root, 'node_modules', 'typescript', 'bin', 'tsc'), - '-p', - 'tsconfig.json', - ], - { cwd: root, stdio: 'inherit' }, -); - -writeFileSync( - join(root, 'plugin-probe.mjs'), - ` - import assert from 'node:assert/strict'; - import { createWorkshopPlugin } from '@gnolith/workshop/ui'; - import { createWaystoneRegistry } from '@gnolith/waystone/plugin'; - - const registry = createWaystoneRegistry([createWorkshopPlugin()]); - assert.deepEqual( - registry.plugins.map(({ id, label }) => ({ id, label })), - [{ id: 'workshop', label: 'Workshop' }], - ); - assert.deepEqual( - registry.routeDescriptors.map(({ pluginId, contribution }) => ({ - pluginId, - id: contribution.id, - path: contribution.path, - requiresClient: contribution.requiresClient, - })), - [ - { - pluginId: 'workshop', - id: 'workshop-memories-route', - path: '/workshop/memories', - requiresClient: true, - }, - { - pluginId: 'workshop', - id: 'workshop-tasks-route', - path: '/workshop/tasks', - requiresClient: true, - }, - ], - ); - assert.deepEqual( - registry.entityPanels.map(({ pluginId, contribution }) => ({ - pluginId, - id: contribution.id, - label: contribution.label, - })), - [ - { - pluginId: 'workshop', - id: 'workshop-related-tasks', - label: 'Related tasks', - }, - ], - ); - const adaptedEntityPanel = registry.entityPanels[0].contribution.component({ - entity: { id: 'Q42' }, - }); - assert.equal(adaptedEntityPanel.props.entityId, 'Q42'); - `, -); -execFileSync(process.execPath, [join(root, 'plugin-probe.mjs')], { - cwd: root, - stdio: 'inherit', -}); - -console.log( - `normal npm, declarations, and plugin registration passed for ` + - `@gnolith/workshop@${source.version}, @gnolith/waystone@0.2.0, and React 19`, -); - -function packageManifest(name) { - return JSON.parse( - readFileSync( - join(root, 'node_modules', ...name.split('/'), 'package.json'), - 'utf8', - ), - ); -} diff --git a/scripts/worker-e2e.mjs b/scripts/worker-e2e.mjs deleted file mode 100644 index bff33cf..0000000 --- a/scripts/worker-e2e.mjs +++ /dev/null @@ -1,74 +0,0 @@ -import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; -import { performance } from 'node:perf_hooks'; -import { Miniflare } from 'miniflare'; -import { workshopMigrations } from '../dist/migrations.js'; - -const scriptPath = '.wrangler/package-runtime-canary/worker.js'; -const miniflare = new Miniflare({ - modules: true, - scriptPath, - compatibilityDate: '2026-07-20', - bindings: { CANARY_TOKEN: 'worker-e2e-token' }, - d1Databases: { DB: `worker-e2e-${randomUUID()}` }, -}); - -try { - const db = await miniflare.getD1Database('DB'); - for (const migration of workshopMigrations) { - for (const statement of migration.sql.split(/;\s*(?:\r?\n|$)/u)) { - if (statement.trim()) await db.prepare(statement).run(); - } - } - - const call = async (id, method, params = {}) => { - const response = await miniflare.dispatchFetch( - 'https://worker.example/mcp', - { - method: 'POST', - headers: { - accept: 'application/json, text/event-stream', - authorization: 'Bearer worker-e2e-token', - 'content-type': 'application/json', - 'mcp-protocol-version': '2025-11-25', - }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), - }, - ); - assert.equal(response.status, 200); - return response.json(); - }; - - const coldStarted = performance.now(); - const initialized = await call(1, 'initialize', { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'worker-e2e', version: '1.0.0' }, - }); - const coldRequestMs = Number((performance.now() - coldStarted).toFixed(2)); - assert.equal(initialized.result.protocolVersion, '2025-11-25'); - - const created = await call(2, 'tools/call', { - name: 'create_task', - arguments: { - description: 'Isolated package-runtime task', - prompt: 'Verify the published package in a local Worker runtime.', - }, - }); - assert.equal(created.result.isError, true); - assert.match( - created.result.structuredContent.error.message, - /search producer is not registered/u, - ); - - const listed = await call(3, 'tools/call', { - name: 'list_tasks', - arguments: {}, - }); - assert.equal(listed.result.structuredContent.items.length, 0); - console.log( - `isolated package-runtime Worker lifecycle passed (cold initialize ${coldRequestMs}ms)`, - ); -} finally { - await miniflare.dispose(); -} diff --git a/src/backup.ts b/src/backup.ts new file mode 100644 index 0000000..333d2c5 --- /dev/null +++ b/src/backup.ts @@ -0,0 +1,239 @@ +import { WorkshopError } from './protocol/errors.js'; + +export * from './legacy-owner.js'; + +export interface WorkshopBackupDocumentV1 { + format: 'gnolith-workshop-backup-v1'; + version: 1; + createdAt: string; + installationId: string; + schemaVersion: number; + sections: { + workshop: unknown; + migrationLedgerSlice: unknown; + }; +} + +export interface WorkshopBackupInspection { + valid: boolean; + format: 'gnolith-workshop-backup-v1'; + version: 1; + installationId: string; + schemaVersion: number; + legacy: { + taskCount: number; + memoryCount: number; + promptCount: number; + onboarding: { + retained: number; + migrated: number; + deferred: number; + }; + }; +} + +export interface WorkshopBackupOwnerHandle { + readonly owner: '@gnolith/workshop'; + readonly connectionId: string; + readonly registrationFingerprint: string; + exportOwnedState(input: { + installationId: string; + signal: AbortSignal; + }): Promise; + inspectOwnedState(input: { state: unknown; signal: AbortSignal }): Promise<{ + taskCount: number; + memoryCount: number; + promptCount: number; + onboarding: { + retained: number; + migrated: number; + deferred: number; + }; + }>; + importOwnedState(input: { + installationId: string; + state: unknown; + expireTaskLeasesAt: string; + signal: AbortSignal; + }): Promise; +} + +export interface WorkshopMigrationLedgerBackupHandle { + readonly connectionId: string; + readonly registrationFingerprint: string; + exportOwnedSlice(input: { signal: AbortSignal }): Promise; + inspectOwnedSlice(input: { + state: unknown; + signal: AbortSignal; + }): Promise; + importOwnedSlice(input: { + state: unknown; + signal: AbortSignal; + }): Promise; +} + +export interface WorkshopBackupDependencies { + owner: WorkshopBackupOwnerHandle; + ledger: WorkshopMigrationLedgerBackupHandle; + schemaVersion: number; + clock?: () => Date; +} + +export function createWorkshopBackupApi( + dependencies: WorkshopBackupDependencies, +) { + validateBinding(dependencies); + const clock = dependencies.clock ?? (() => new Date()); + return { + async exportBackup( + installationId: string, + signal = new AbortController().signal, + ): Promise { + validateIdentifier(installationId); + const [workshop, migrationLedgerSlice] = await Promise.all([ + dependencies.owner.exportOwnedState({ installationId, signal }), + dependencies.ledger.exportOwnedSlice({ signal }), + ]); + return { + format: 'gnolith-workshop-backup-v1', + version: 1, + createdAt: clock().toISOString(), + installationId, + schemaVersion: dependencies.schemaVersion, + sections: { workshop, migrationLedgerSlice }, + }; + }, + async inspectBackup( + value: unknown, + signal = new AbortController().signal, + ): Promise { + const backup = validateBackup(value, dependencies.schemaVersion); + const legacy = await dependencies.owner.inspectOwnedState({ + state: backup.sections.workshop, + signal, + }); + await dependencies.ledger.inspectOwnedSlice({ + state: backup.sections.migrationLedgerSlice, + signal, + }); + validateCounts(legacy); + return { + valid: true, + format: backup.format, + version: backup.version, + installationId: backup.installationId, + schemaVersion: backup.schemaVersion, + legacy, + }; + }, + async importBackup( + value: unknown, + signal = new AbortController().signal, + ): Promise { + const inspected = await this.inspectBackup(value, signal); + const backup = value as WorkshopBackupDocumentV1; + await dependencies.owner.importOwnedState({ + installationId: backup.installationId, + state: backup.sections.workshop, + expireTaskLeasesAt: clock().toISOString(), + signal, + }); + await dependencies.ledger.importOwnedSlice({ + state: backup.sections.migrationLedgerSlice, + signal, + }); + return inspected; + }, + }; +} + +function validateBinding(dependencies: WorkshopBackupDependencies): void { + if ( + dependencies.owner.owner !== '@gnolith/workshop' || + !dependencies.owner.connectionId || + dependencies.owner.connectionId !== dependencies.ledger.connectionId || + !dependencies.owner.registrationFingerprint || + dependencies.owner.registrationFingerprint !== + dependencies.ledger.registrationFingerprint || + !Number.isSafeInteger(dependencies.schemaVersion) || + dependencies.schemaVersion < 1 + ) + throw invalid('Backup owner binding is invalid'); +} + +function validateBackup( + value: unknown, + schemaVersion: number, +): WorkshopBackupDocumentV1 { + if (!isRecord(value)) throw invalid('Backup document must be an object'); + exactKeys(value, [ + 'format', + 'version', + 'createdAt', + 'installationId', + 'schemaVersion', + 'sections', + ]); + if ( + value.format !== 'gnolith-workshop-backup-v1' || + value.version !== 1 || + value.schemaVersion !== schemaVersion || + typeof value.createdAt !== 'string' || + !Number.isFinite(Date.parse(value.createdAt)) || + !isRecord(value.sections) + ) + throw invalid('Backup document is incompatible'); + validateIdentifier(value.installationId); + exactKeys(value.sections, ['workshop', 'migrationLedgerSlice']); + return value as unknown as WorkshopBackupDocumentV1; +} + +function validateCounts(value: { + taskCount: number; + memoryCount: number; + promptCount: number; + onboarding: { + retained: number; + migrated: number; + deferred: number; + }; +}): void { + for (const count of [ + value.taskCount, + value.memoryCount, + value.promptCount, + value.onboarding.retained, + value.onboarding.migrated, + value.onboarding.deferred, + ]) + if (!Number.isSafeInteger(count) || count < 0) + throw invalid('Backup inspection counts are invalid'); +} + +function validateIdentifier(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !value || + value.length > 256 || + /[\u0000-\u001f\u007f]/u.test(value) + ) + throw invalid('Backup installation identifier is invalid'); +} + +function exactKeys( + value: Record, + expected: readonly string[], +): void { + const actual = Object.keys(value).sort(); + if (JSON.stringify(actual) !== JSON.stringify([...expected].sort())) + throw invalid('Backup document has unknown or missing fields'); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function invalid(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} +export * from './resource-blobs.js'; diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..c31aa13 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,322 @@ +import type { + AuthorizationBootstrapOwnerAdapterV1, + AuthorizationBootstrapOwnerIssuerV1, + AuthorizationBootstrapOwnerParticipantV1, + AuthorizationBootstrapParticipantV1, + AuthorizationBootstrapReceiptV1, +} from '@gnolith/taproot'; +import { + decodeLocalBearerCredentialFile, + digestLocalBearerToken, +} from './server/auth.js'; +import type { D1DatabaseLike } from './server/database.js'; +import { WorkshopError } from './protocol/errors.js'; + +export const WORKSHOP_BOOTSTRAP_VERSION = 1 as const; +export const WORKSHOP_OWNER_GRANTS_V1 = [ + 'admin', + 'knowledge:policy', + 'knowledge:write', + 'memory:write', + 'prompt:write', + 'read', + 'search:admin', + 'task:write', +] as const; + +export interface ProtectedCredentialSelector { + kind: 'file' | 'inherited-descriptor'; + value: string | number; +} + +export interface BootstrapWorkshopV1Input { + version: 1; + installationId: string; + baseIri: string; + principalId: string; + workspaceId: string; + credential: ProtectedCredentialSelector; + idempotencyKey: string; + profile: 'local-bearer-v1'; +} + +interface WorkshopBootstrapOwnerPayloadV1 { + installationId: string; + credentialId: string; + credentialDigest: string; + principalId: string; + workspaceId: string; + idempotencyKey: string; + grantManifestDigest: string; + createdAt: string; +} + +export interface WorkshopBootstrapOwnerControllerV1 { + readonly adapter: AuthorizationBootstrapOwnerAdapterV1; + issue(payload: WorkshopBootstrapOwnerPayloadV1): object; +} + +export function createWorkshopBootstrapOwnerAdapterV1( + db: D1DatabaseLike, +): WorkshopBootstrapOwnerControllerV1 { + const pending = new WeakMap(); + return { + adapter: { + kind: 'taproot-authorization-bootstrap-owner-adapter-v1', + prepare(ownerToken) { + if (!isObjectToken(ownerToken)) throw invalidOwnerToken(); + const payload = pending.get(ownerToken); + if (!payload) throw invalidOwnerToken(); + pending.delete(ownerToken); + const ownerReceipt = { + format: 'workshop-authorization-bootstrap-owner-receipt-v1', + installationId: payload.installationId, + credentialId: payload.credentialId, + credentialDigest: payload.credentialDigest, + principalId: payload.principalId, + workspaceId: payload.workspaceId, + idempotencyKey: payload.idempotencyKey, + grantManifestDigest: payload.grantManifestDigest, + workshopSchemaVersion: 8, + } as const; + const receiptJson = canonicalize(ownerReceipt); + return Promise.resolve({ + statements: [ + db + .prepare( + `INSERT INTO workshop_credentials ( + installation_id, credential_id, profile, token_digest, + principal_id, workspace_id, disabled, created_at + ) VALUES (?, ?, 'local-bearer-v1', ?, ?, ?, 0, ?) + ON CONFLICT(installation_id, credential_id) DO NOTHING`, + ) + .bind( + payload.installationId, + payload.credentialId, + payload.credentialDigest, + payload.principalId, + payload.workspaceId, + payload.createdAt, + ), + db + .prepare( + `INSERT INTO workshop_bootstrap_receipts ( + installation_id, idempotency_key, credential_id, + principal_id, workspace_id, grant_manifest_digest, + owner_receipt_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(installation_id, idempotency_key) DO NOTHING`, + ) + .bind( + payload.installationId, + payload.idempotencyKey, + payload.credentialId, + payload.principalId, + payload.workspaceId, + payload.grantManifestDigest, + receiptJson, + payload.createdAt, + ), + ], + receipt: ownerReceipt, + }); + }, + }, + issue(payload) { + const token = {}; + pending.set(token, payload); + return token; + }, + }; +} + +export interface BootstrapWorkshopV1Dependencies { + prepareAuthorizationBootstrapV1(input: { + installationId: string; + baseIri: string; + principalId: string; + workspaceId: string; + grants: readonly string[]; + idempotencyKey: string; + }): Promise; + ownerParticipantIssuer: AuthorizationBootstrapOwnerIssuerV1; + ownerAdapter: WorkshopBootstrapOwnerControllerV1; + commitAuthorizationBootstrapV1( + participant: AuthorizationBootstrapParticipantV1, + ownerParticipant: AuthorizationBootstrapOwnerParticipantV1, + ): Promise; + readProtectedCredential( + selector: ProtectedCredentialSelector, + ): Promise; + workshopSchemaVersion: number; + clock?: () => Date; +} + +export interface BootstrapWorkshopV1Receipt { + version: 1; + bootstrapId: string; + installationId: string; + principalId: string; + workspaceId: string; + authorizationRevision: number; + grantManifestDigest: string; + replayed: boolean; + workshopSchemaVersion: number; + credentialId: string; + credentialDigest: string; +} + +export async function bootstrapWorkshopV1( + input: BootstrapWorkshopV1Input, + dependencies: BootstrapWorkshopV1Dependencies, +): Promise { + validateInput(input); + if (dependencies.workshopSchemaVersion !== 8) + throw new WorkshopError( + 'dependency_unavailable', + 'Workshop schema migration 0008 is required before bootstrap', + 503, + ); + const credentialFile = await dependencies.readProtectedCredential( + input.credential, + ); + let tokenBytes: Uint8Array | undefined; + try { + tokenBytes = decodeLocalBearerCredentialFile(credentialFile); + const credentialDigest = await digestLocalBearerToken(tokenBytes); + const grantManifestDigest = await sha256Hex( + JSON.stringify(WORKSHOP_OWNER_GRANTS_V1), + ); + const credentialId = `credential:${( + await sha256Hex( + canonicalize({ + installationId: input.installationId, + idempotencyKey: input.idempotencyKey, + credentialDigest, + }), + ) + ).slice(0, 32)}`; + const participant = await dependencies.prepareAuthorizationBootstrapV1({ + installationId: input.installationId, + baseIri: input.baseIri, + principalId: input.principalId, + workspaceId: input.workspaceId, + grants: WORKSHOP_OWNER_GRANTS_V1, + idempotencyKey: input.idempotencyKey, + }); + const ownerToken = dependencies.ownerAdapter.issue({ + installationId: input.installationId, + credentialId, + credentialDigest, + principalId: input.principalId, + workspaceId: input.workspaceId, + idempotencyKey: input.idempotencyKey, + grantManifestDigest, + createdAt: (dependencies.clock?.() ?? new Date()).toISOString(), + }); + const ownerParticipant = await dependencies.ownerParticipantIssuer.seal( + participant, + ownerToken, + ); + const committed = await dependencies.commitAuthorizationBootstrapV1( + participant, + ownerParticipant, + ); + if ( + committed.installationId !== input.installationId || + committed.principalId !== input.principalId || + committed.workspaceId !== input.workspaceId || + committed.grantManifestSha256 !== grantManifestDigest + ) + throw new WorkshopError( + 'dependency_unavailable', + 'Taproot bootstrap receipt does not match the Workshop request', + 503, + ); + return { + version: 1, + bootstrapId: committed.bootstrapId, + installationId: committed.installationId, + principalId: committed.principalId, + workspaceId: committed.workspaceId, + authorizationRevision: committed.authorizationRevision, + grantManifestDigest, + replayed: committed.replayed, + workshopSchemaVersion: dependencies.workshopSchemaVersion, + credentialId, + credentialDigest, + }; + } finally { + credentialFile.fill(0); + tokenBytes?.fill(0); + } +} + +function validateInput(input: BootstrapWorkshopV1Input): void { + if ( + input.version !== 1 || + input.profile !== 'local-bearer-v1' || + !validId(input.installationId) || + !validId(input.principalId) || + !validId(input.workspaceId) || + !validId(input.idempotencyKey) || + !input.credential || + !['file', 'inherited-descriptor'].includes(input.credential.kind) + ) + throw new WorkshopError( + 'validation_failed', + 'Bootstrap input is invalid', + 400, + ); + const baseIri = new URL(input.baseIri); + if (!['https:', 'http:'].includes(baseIri.protocol)) + throw new WorkshopError( + 'validation_failed', + 'Bootstrap base IRI is invalid', + 400, + ); +} + +function validId(value: string): boolean { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 256 && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonicalize(entry)).join(',')}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`) + .join(',')}}`; +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(value), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function isObjectToken(value: unknown): value is object { + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ); +} + +function invalidOwnerToken(): WorkshopError { + return new WorkshopError( + 'validation_failed', + 'Workshop bootstrap owner token is invalid or already consumed', + 400, + ); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..93716e9 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env node +import { + assembleWorkshopExecutable, + runWorkshopBootstrap, +} from './executable.js'; +import type { BootstrapWorkshopV1Input } from './bootstrap.js'; +import { normalizeWorkshopError } from './protocol/errors.js'; + +const command = process.argv[2]; + +try { + if (command === 'serve') { + const configPath = exactOption(process.argv.slice(3), '--config'); + const service = await assembleWorkshopExecutable(configPath); + const address = await service.start(); + process.stdout.write( + `${JSON.stringify({ event: 'gnolith-workshop.started', ...address })}\n`, + ); + let stopping = false; + const stop = async () => { + if (stopping) return; + stopping = true; + try { + await service.stop(); + process.exit(0); + } catch (error) { + const normalized = normalizeWorkshopError(error); + process.stderr.write( + `${JSON.stringify({ + error: { + code: normalized.code, + message: + normalized.code === 'internal_error' + ? 'gnolith-workshop shutdown failed' + : normalized.message, + }, + })}\n`, + ); + process.exit(1); + } + }; + process.once('SIGINT', () => void stop()); + process.once('SIGTERM', () => void stop()); + } else if (command === 'bootstrap-v1') { + const options = parseOptions(process.argv.slice(3)); + assertOnlyOptions(options, [ + '--config', + '--installation-id', + '--base-iri', + '--principal', + '--workspace', + '--credential-file', + '--idempotency-key', + ]); + const receipt = await runWorkshopBootstrap(required(options, '--config'), { + version: 1, + installationId: required(options, '--installation-id'), + baseIri: required(options, '--base-iri'), + principalId: required(options, '--principal'), + workspaceId: required(options, '--workspace'), + credential: { + kind: 'file', + value: required(options, '--credential-file'), + }, + idempotencyKey: required(options, '--idempotency-key'), + profile: 'local-bearer-v1', + } satisfies BootstrapWorkshopV1Input); + process.stdout.write(`${JSON.stringify(receipt)}\n`); + } else { + throw new Error( + 'Usage: gnolith-workshop serve --config | bootstrap-v1 --config --installation-id --base-iri --principal --workspace --credential-file --idempotency-key ', + ); + } +} catch (error) { + const normalized = normalizeWorkshopError(error); + process.stderr.write( + `${JSON.stringify({ + error: { + code: normalized.code, + message: + normalized.code === 'internal_error' + ? 'gnolith-workshop failed' + : normalized.message, + }, + })}\n`, + ); + process.exitCode = 1; +} + +function exactOption(args: readonly string[], name: string): string { + const parsed = parseOptions(args); + if (parsed.size !== 1) throw new Error(`Only ${name} is accepted`); + return required(parsed, name); +} + +function parseOptions(args: readonly string[]): Map { + if (args.length % 2 !== 0) throw new Error('Options require values'); + const result = new Map(); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]!; + const value = args[index + 1]!; + if (!name.startsWith('--') || !value || value.includes('\u0000')) + throw new Error('Invalid command option'); + if (result.has(name)) throw new Error(`Duplicate option: ${name}`); + result.set(name, value); + } + return result; +} + +function required(options: ReadonlyMap, name: string): string { + const value = options.get(name); + if (!value) throw new Error(`Missing ${name}`); + return value; +} + +function assertOnlyOptions( + options: ReadonlyMap, + allowed: readonly string[], +): void { + const accepted = new Set(allowed); + for (const name of options.keys()) + if (!accepted.has(name)) throw new Error(`Unknown option: ${name}`); +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..371c7d9 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,11 @@ +export { + createWorkshopClient, + type WorkshopCallOptions, + type WorkshopClient, + type WorkshopClientOptions, +} from './protocol/client.js'; +export type { + OperationInput, + OperationName, + OperationOutput, +} from './generated/operations.js'; diff --git a/src/core.ts b/src/core.ts index da4c829..d395ae1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -2,12 +2,7 @@ export { createWorkshopCore } from './server/context.js'; export type { WorkshopCore, WorkshopCoreOptions } from './server/context.js'; export type { WorkshopPersistence } from './server/database.js'; export { - createWorkshopToolDispatcher, - type WorkshopDispatchFailure, - type WorkshopDispatchResult, - type WorkshopToolCall, - type WorkshopToolDispatchContext, - type WorkshopToolDispatcher, - type WorkshopToolRuntime, -} from './mcp/dispatcher.js'; -export type { WorkshopToolDefinition } from './mcp/tools.js'; + createOperationRuntime, + type OperationRuntime, + type OperationRuntimeOptions, +} from './operations.js'; diff --git a/src/executable.ts b/src/executable.ts new file mode 100644 index 0000000..dd2c9e0 --- /dev/null +++ b/src/executable.ts @@ -0,0 +1,150 @@ +import { readFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { + bootstrapWorkshopV1, + type BootstrapWorkshopV1Dependencies, + type BootstrapWorkshopV1Input, + type ProtectedCredentialSelector, +} from './bootstrap.js'; +import { WorkshopError } from './protocol/errors.js'; +import { + validateWorkshopServiceConfig, + type ProtectedSelectorConfig, + type WorkshopServiceConfig, +} from './server/config.js'; +import { + createWorkshopHttpService, + type WorkshopHttpService, + type WorkshopHttpServiceOptions, +} from './server/service.js'; + +const MAX_CONFIG_BYTES = 1024 * 1024; +const MAX_PROTECTED_BYTES = 1024 * 1024; + +export interface WorkshopExecutableAdapter { + createWorkshopExecutable( + config: WorkshopServiceConfig, + protectedReader: ProtectedReader, + ): Promise< + Omit + >; + createWorkshopBootstrapDependencies?( + config: WorkshopServiceConfig, + protectedReader: ProtectedReader, + ): Promise>; +} + +export type ProtectedReader = ( + selector: ProtectedSelectorConfig, +) => Promise; + +export async function loadWorkshopServiceConfig( + path: string, +): Promise { + const bytes = await readFile(path); + if (bytes.byteLength > MAX_CONFIG_BYTES) + throw invalid('Configuration file is too large'); + try { + return validateWorkshopServiceConfig( + JSON.parse(new TextDecoder().decode(bytes)), + ); + } catch (error) { + if (error instanceof WorkshopError) throw error; + throw invalid('Configuration file is not valid JSON'); + } +} + +export async function assembleWorkshopExecutable( + configPath: string, +): Promise { + const config = await loadWorkshopServiceConfig(configPath); + const adapter = await loadAdapter(config.adapterModule, configPath); + const options = await adapter.createWorkshopExecutable( + config, + readProtectedSelector, + ); + return createWorkshopHttpService({ + ...options, + listener: config.listener, + ...(config.shutdownTimeoutMs + ? { shutdownTimeoutMs: config.shutdownTimeoutMs } + : {}), + }); +} + +export async function runWorkshopBootstrap( + configPath: string, + input: BootstrapWorkshopV1Input, +): Promise { + const config = await loadWorkshopServiceConfig(configPath); + const adapter = await loadAdapter(config.adapterModule, configPath); + if (!adapter.createWorkshopBootstrapDependencies) + throw invalid('Adapter does not support bootstrap-v1'); + const dependencies = await adapter.createWorkshopBootstrapDependencies( + config, + readProtectedSelector, + ); + return bootstrapWorkshopV1(input, { + ...dependencies, + readProtectedCredential: (selector) => readProtectedSelector(selector), + }); +} + +export async function readProtectedSelector( + selector: ProtectedSelectorConfig | ProtectedCredentialSelector, +): Promise { + let bytes: Uint8Array; + if (selector.kind === 'file') { + bytes = await readFile(String(selector.value)); + } else if (selector.kind === 'inherited-descriptor') { + if ( + typeof selector.value !== 'number' || + !Number.isSafeInteger(selector.value) || + selector.value < 3 + ) + throw invalid('Inherited descriptor selector is invalid'); + bytes = readFileSync(selector.value); + } else { + if ( + typeof selector.value !== 'string' || + !/^[A-Z][A-Z0-9_]{0,127}$/u.test(selector.value) + ) + throw invalid('Environment selector is invalid'); + const value = process.env[selector.value]; + if (value === undefined) throw invalid('Protected value is unavailable'); + if (value.startsWith('file:')) { + const path = value.slice('file:'.length); + if (!path) throw invalid('Protected file selector is invalid'); + bytes = await readFile(path); + } else if (/^fd:[3-9][0-9]*$/u.test(value)) { + bytes = readFileSync(Number(value.slice('fd:'.length))); + } else { + throw invalid( + 'Environment selector must identify a protected file or inherited descriptor', + ); + } + } + if (bytes.byteLength > MAX_PROTECTED_BYTES) { + bytes.fill(0); + throw invalid('Protected value is too large'); + } + return bytes; +} + +async function loadAdapter( + moduleSelector: string, + configPath: string, +): Promise { + const url = moduleSelector.startsWith('file:') + ? moduleSelector + : new URL(moduleSelector, pathToFileURL(configPath)).href; + const loaded = (await import(url)) as Partial; + if (typeof loaded.createWorkshopExecutable !== 'function') + throw invalid('Adapter module does not expose the executable factory'); + return loaded as WorkshopExecutableAdapter; +} + +function invalid(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} diff --git a/src/generated/operations.ts b/src/generated/operations.ts new file mode 100644 index 0000000..bb862b9 --- /dev/null +++ b/src/generated/operations.ts @@ -0,0 +1,2240 @@ +/* Generated by scripts/generate-operation-contract.mjs. Do not edit. */ +export const OPERATION_SCHEMA_VERSION = 1 as const; +export const OPERATION_ROUTE_PREFIX = "/api/v1/operations" as const; +export const CATALOG_DIGEST = "577cc1de501b0ae3556eb1d32e7dd516c70a09c5b6226d671cec312068fba3dd" as const; +export const operationCatalog = [ + { + "name": "gnolith_status", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/gnolith_status", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "installationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "baseIri": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "principalId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activeWorkspaceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "authorizationRevision": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000 + }, + "migrationReadiness": { + "type": "object", + "properties": { + "namespace": { + "const": "@gnolith/workshop" + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "ready": { + "type": "boolean" + } + }, + "required": [ + "namespace", + "version", + "ready" + ], + "additionalProperties": false + }, + "compatibility": { + "type": "object", + "properties": { + "diamond": { + "type": "boolean" + }, + "taproot": { + "type": "boolean" + } + }, + "required": [ + "diamond", + "taproot" + ], + "additionalProperties": false + }, + "canonicalReady": { + "type": "boolean" + }, + "authorizationReady": { + "type": "boolean" + }, + "lexicalReady": { + "type": "boolean" + }, + "semanticState": { + "type": "object", + "properties": { + "state": { + "enum": [ + "ready", + "degraded", + "unconfigured" + ] + }, + "configured": { + "type": "boolean" + } + }, + "required": [ + "state", + "configured" + ], + "additionalProperties": false + }, + "producers": { + "type": "object", + "properties": { + "ready": { + "type": "boolean" + }, + "fingerprint": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kinds": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "uniqueItems": true, + "items": { + "enum": [ + "task", + "memory", + "prompt" + ] + } + } + }, + "required": [ + "ready", + "fingerprint", + "kinds" + ], + "additionalProperties": false + }, + "blobReady": { + "type": "boolean" + }, + "versions": { + "type": "object", + "properties": { + "server": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "operationSchema": { + "const": 1 + } + }, + "required": [ + "server", + "operationSchema" + ], + "additionalProperties": false + }, + "operationCatalogDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "installationId", + "baseIri", + "principalId", + "credentialId", + "activeWorkspaceId", + "capabilities", + "authorizationRevision", + "migrationReadiness", + "compatibility", + "canonicalReady", + "authorizationReady", + "lexicalReady", + "semanticState", + "producers", + "blobReady", + "versions", + "operationCatalogDigest" + ], + "additionalProperties": false + } + }, + { + "name": "search", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/search", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "kinds": { + "type": "array", + "maxItems": 7, + "uniqueItems": true, + "items": { + "enum": [ + "statement", + "item", + "task", + "memory", + "prompt", + "resource", + "annotation" + ] + } + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "search_hydrate", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/search_hydrate", + "inputSchema": { + "type": "object", + "properties": { + "kind": { + "enum": [ + "statement", + "item", + "task", + "memory", + "prompt", + "resource", + "annotation" + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "entityType": { + "enum": [ + "item", + "property" + ] + } + }, + "required": [ + "kind", + "id", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "search_admin", + "version": 1, + "capability": "search:admin", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": false + }, + "route": "/api/v1/operations/search_admin", + "inputSchema": { + "type": "object", + "properties": { + "action": { + "enum": [ + "health", + "materialize", + "retry-dead", + "rebuild", + "activate", + "semantic-select", + "semantic-reconnect", + "semantic-estimate", + "semantic-approve", + "semantic-run", + "semantic-resume", + "semantic-pause", + "semantic-stop", + "semantic-retry", + "semantic-exclude", + "semantic-retire", + "semantic-delete" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "options": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "action" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_entity", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_entity", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "entityId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "entity_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/entity_history", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "entityId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "statement_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/statement_history", + "inputSchema": { + "type": "object", + "properties": { + "statementId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "statementId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_entities", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_entities", + "inputSchema": { + "type": "object", + "properties": { + "entityIds": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "required": [ + "entityIds" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_item", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_item", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "authoredText": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "authoredText", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_property", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_property", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "authoredText": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "authoredText", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "mutate_entity", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/mutate_entity", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "mutations": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "object", + "properties": { + "kind": { + "enum": [ + "label", + "description", + "alias", + "sitelink", + "statement", + "rank", + "qualifier", + "reference" + ] + }, + "authoredText": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "value": {} + }, + "required": [ + "kind", + "authoredText", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entityId", + "expectedRevision", + "idempotencyKey", + "mutations" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "entity_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/entity_revision", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "entityId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "statement_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/statement_revision", + "inputSchema": { + "type": "object", + "properties": { + "statementId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "statementId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "export_entity_json", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/export_entity_json", + "inputSchema": { + "type": "object", + "properties": { + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "entityId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_resource", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_resource", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_resource", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "resourceId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_resource", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "resourceId", + "expectedRevision", + "idempotencyKey", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_resource", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "resourceId", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "resource_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/resource_history", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "resourceId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "resource_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/resource_revision", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "resourceId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "hydrate_resource", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/hydrate_resource", + "inputSchema": { + "type": "object", + "properties": { + "resourceId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "resourceId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_annotation", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_annotation", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "data" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_annotation", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_annotation", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "annotationId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_annotation", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_annotation", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "annotationId", + "expectedRevision", + "idempotencyKey", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_annotation", + "version": 1, + "capability": "knowledge:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_annotation", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "annotationId", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "annotation_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/annotation_history", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "annotationId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "annotation_revision", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/annotation_revision", + "inputSchema": { + "type": "object", + "properties": { + "annotationId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "annotationId", + "revision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "validate_sparql", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/validate_sparql", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "query_sparql", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/query_sparql", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "dryRun": { + "type": "boolean" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "list_tasks", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/list_tasks", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "state": { + "enum": [ + "unclaimed", + "claimed", + "completed", + "archived" + ] + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "search_tasks", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/search_tasks", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_task", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_task_packet", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_task_packet", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_task", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "prompt": { + "type": "string", + "minLength": 1, + "maxLength": 131072 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "description", + "prompt" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "taskId", + "expectedRevision", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "archive_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/archive_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "required": [ + "taskId", + "expectedRevision" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "claim_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": false + }, + "route": "/api/v1/operations/claim_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "release_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/release_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "taskId", + "leaseToken", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "complete_task", + "version": 1, + "capability": "task:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/complete_task", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "leaseToken": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "result": { + "type": "string", + "minLength": 1, + "maxLength": 262144 + }, + "outcomeKind": { + "enum": [ + "success", + "negative", + "inconclusive" + ] + } + }, + "required": [ + "taskId", + "leaseToken", + "expectedRevision", + "idempotencyKey", + "result" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "task_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/task_history", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "taskId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "list_memories", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/list_memories", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_memory", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_memory", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "upsert_memory", + "version": 1, + "capability": "memory:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/upsert_memory", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 131072 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "slug", + "idempotencyKey", + "description", + "content" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_memory", + "version": 1, + "capability": "memory:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_memory", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "slug", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "memory_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/memory_history", + "inputSchema": { + "type": "object", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "slug" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "list_prompts", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/list_prompts", + "inputSchema": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "get_prompt", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/get_prompt", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "promptId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "create_prompt", + "version": 1, + "capability": "prompt:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/create_prompt", + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "promptText": { + "type": "string", + "minLength": 1, + "maxLength": 131072 + }, + "data": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "idempotencyKey", + "name", + "promptText" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "update_prompt", + "version": 1, + "capability": "prompt:write", + "annotations": { + "readOnly": false, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/update_prompt", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "patch": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "promptId", + "expectedRevision", + "idempotencyKey", + "patch" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "delete_prompt", + "version": 1, + "capability": "prompt:write", + "annotations": { + "readOnly": false, + "destructive": true, + "idempotent": true + }, + "route": "/api/v1/operations/delete_prompt", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "expectedRevision": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "promptId", + "expectedRevision", + "idempotencyKey" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + { + "name": "prompt_history", + "version": 1, + "capability": "read", + "annotations": { + "readOnly": true, + "destructive": false, + "idempotent": true + }, + "route": "/api/v1/operations/prompt_history", + "inputSchema": { + "type": "object", + "properties": { + "promptId": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + } + }, + "required": [ + "promptId" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + } +] as const; +export type OperationName = 'gnolith_status' | 'search' | 'search_hydrate' | 'search_admin' | 'get_entity' | 'entity_history' | 'statement_history' | 'get_entities' | 'create_item' | 'create_property' | 'mutate_entity' | 'entity_revision' | 'statement_revision' | 'export_entity_json' | 'create_resource' | 'get_resource' | 'update_resource' | 'delete_resource' | 'resource_history' | 'resource_revision' | 'hydrate_resource' | 'create_annotation' | 'get_annotation' | 'update_annotation' | 'delete_annotation' | 'annotation_history' | 'annotation_revision' | 'validate_sparql' | 'query_sparql' | 'list_tasks' | 'search_tasks' | 'get_task' | 'get_task_packet' | 'create_task' | 'update_task' | 'archive_task' | 'claim_task' | 'release_task' | 'complete_task' | 'task_history' | 'list_memories' | 'get_memory' | 'upsert_memory' | 'delete_memory' | 'memory_history' | 'list_prompts' | 'get_prompt' | 'create_prompt' | 'update_prompt' | 'delete_prompt' | 'prompt_history'; +export type OperationInput = + Readonly> & { readonly __operationName?: Name }; +export type OperationOutput = + unknown & { readonly __operationName?: Name }; +export const legacyOperationNames = { + "add_alias": "mutate_entity", + "add_reference": "mutate_entity", + "add_sitelink": "mutate_entity", + "add_statement": "mutate_entity", + "add_qualifier": "mutate_entity", + "create_entity": "create_item or create_property", + "delete_alias": "mutate_entity", + "delete_reference": "mutate_entity", + "delete_sitelink": "mutate_entity", + "delete_statement": "mutate_entity", + "delete_qualifier": "mutate_entity", + "dry_run_sparql": "query_sparql with dryRun=true", + "get_search_status": "search_admin with action=health", + "query_sparql_update": null, + "reset_abandoned_claim": null, + "search_status": "gnolith_status or search_admin with action=health", + "set_description": "mutate_entity", + "set_label": "mutate_entity", + "set_rank": "mutate_entity" +} as const; diff --git a/src/index.ts b/src/index.ts index bfc918c..ed75c85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,13 @@ /** Runtime-neutral package identity. Runtime APIs use explicit subpaths. */ export const workshopPackage = { name: '@gnolith/workshop', - version: '0.4.2', - schemaVersion: 6, + version: '0.5.0', + schemaVersion: 8, } as const; export type { WorkshopCapability, WorkshopPrincipal } from './protocol.js'; +export { + createOperationRuntime, + type OperationRuntime, + type OperationRuntimeOptions, +} from './operations.js'; diff --git a/src/legacy-owner.ts b/src/legacy-owner.ts new file mode 100644 index 0000000..6bf2155 --- /dev/null +++ b/src/legacy-owner.ts @@ -0,0 +1,399 @@ +import { + assertWorkshopLegacyV042Schema, + type WorkshopReadOnlyPersistence, +} from './migrations.js'; +import { workshopMigrations } from './migrations/manifest.js'; +import { WorkshopError } from './protocol/errors.js'; + +const LEGACY_TABLES = [ + 'workshop_cursor_entries', + 'workshop_cursor_snapshots', + 'workshop_memories', + 'workshop_memory_revisions', + 'workshop_onboarding_runs', + 'workshop_onboarding_steps', + 'workshop_prompt_revisions', + 'workshop_prompts', + 'workshop_schema', + 'workshop_task_revisions', + 'workshop_tasks', +] as const; + +const DEFAULT_MAX_ROWS = 50_000; +const MAX_MAX_ROWS = 100_000; +const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; +const MAX_MAX_BYTES = 32 * 1024 * 1024; + +type LegacyTableName = (typeof LEGACY_TABLES)[number]; +type LegacyScalar = string | number | null; +type LegacyRow = Readonly>; + +interface LegacyOwnerPayload { + tables: Readonly>; +} + +declare const legacyOwnerFragmentBrand: unique symbol; + +/** + * Process-local, verified owner fragment. Its row representation is intentionally + * absent from the public declarations and can only be consumed by the adoption + * adapter below. + */ +export interface WorkshopLegacyOwnerFragmentV042 { + readonly [legacyOwnerFragmentBrand]: 'WorkshopLegacyOwnerFragmentV042'; +} + +export interface WorkshopLegacyV042LedgerEvidence { + namespace: '@gnolith/workshop'; + migrations: readonly { + id: string; + checksum: `sha256:${string}`; + }[]; +} + +export interface WorkshopLegacyV042Attestation { + packageName: '@gnolith/workshop'; + packageVersion: '0.4.2'; + schemaVersion: 6; + migrationLedger: WorkshopLegacyV042LedgerEvidence; +} + +export interface WorkshopLegacyV042DecodeOptions { + maxRows?: number; + maxBytes?: number; + signal?: AbortSignal; +} + +export interface WorkshopLegacyOwnerDecodedV042 { + format: 'gnolith-workshop-legacy-owner-v042'; + version: 1; + source: { + packageName: '@gnolith/workshop'; + packageVersion: '0.4.2'; + schemaVersion: 6; + }; + counts: { + tasks: number; + memories: number; + prompts: number; + revisions: number; + }; + tableCounts: Readonly>; + digests: { + tables: Readonly>; + migrationLedger: `sha256:${string}`; + ownerFragment: `sha256:${string}`; + }; + migrationLedger: WorkshopLegacyV042LedgerEvidence; + ownerFragment: WorkshopLegacyOwnerFragmentV042; +} + +export interface WorkshopLegacyOwnerAdoptionHandle { + readonly owner: '@gnolith/workshop'; + readonly connectionId: string; + readonly registrationFingerprint: string; + adoptLegacyOwnerV042(input: { + installationId: string; + fragment: WorkshopLegacyOwnerFragmentV042; + sourceDigest: `sha256:${string}`; + migrationLedger: WorkshopLegacyV042LedgerEvidence; + expireClaimsAt: string; + signal: AbortSignal; + }): Promise; +} + +export interface WorkshopLegacyOwnerAdoptionReceiptV1 { + format: 'gnolith-workshop-legacy-owner-adoption-v1'; + version: 1; + installationId: string; + sourceDigest: `sha256:${string}`; + adoptedAt: string; + counts: WorkshopLegacyOwnerDecodedV042['counts']; +} + +const fragmentPayloads = new WeakMap(); +const verifiedDecodes = new WeakSet(); + +/** + * Decode exact Workshop 0.4.2 state through a read-only portable DB capability. + * Only the fixed Workshop-owned allowlist above is inspected. + */ +export async function decodeWorkshopLegacyOwnerV042( + source: WorkshopReadOnlyPersistence, + attestation: WorkshopLegacyV042Attestation, + options: WorkshopLegacyV042DecodeOptions = {}, +): Promise { + validateAttestation(attestation); + const maxRows = boundedOption( + options.maxRows, + DEFAULT_MAX_ROWS, + MAX_MAX_ROWS, + 'row', + ); + const maxBytes = boundedOption( + options.maxBytes, + DEFAULT_MAX_BYTES, + MAX_MAX_BYTES, + 'byte', + ); + const signal = options.signal ?? new AbortController().signal; + signal.throwIfAborted(); + await assertWorkshopLegacyV042Schema(source); + + const tableCounts = {} as Record; + let totalRows = 0; + for (const table of LEGACY_TABLES) { + signal.throwIfAborted(); + const result = await source + .prepare(`SELECT COUNT(*) AS count FROM "${table}"`) + .first<{ count: number }>(); + const count = result?.count; + if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0) + throw invalid(`Legacy row count for ${table} is invalid`); + tableCounts[table] = count; + totalRows += count; + if (totalRows > maxRows) + throw invalid(`Legacy owner fragment exceeds ${maxRows} rows`); + } + + const tables = {} as Record; + const tableDigests = {} as Record; + let encodedBytes = 0; + for (const table of LEGACY_TABLES) { + signal.throwIfAborted(); + const result = await source + .prepare(`SELECT * FROM "${table}" LIMIT ?`) + .bind(tableCounts[table] + 1) + .all>(); + if (result.results.length !== tableCounts[table]) + throw invalid(`Legacy source changed while reading ${table}`); + const rows = result.results + .map((row) => normalizeRow(table, row)) + .sort((left, right) => + canonicalJson(left).localeCompare(canonicalJson(right)), + ); + const encoded = canonicalJson(rows); + encodedBytes += new TextEncoder().encode(encoded).byteLength; + if (encodedBytes > maxBytes) + throw invalid(`Legacy owner fragment exceeds ${maxBytes} bytes`); + tables[table] = Object.freeze(rows); + tableDigests[table] = await digest(encoded); + } + + const migrationLedger = freezeLedger(attestation.migrationLedger); + const counts = Object.freeze({ + tasks: tableCounts.workshop_tasks, + memories: tableCounts.workshop_memories, + prompts: tableCounts.workshop_prompts, + revisions: + tableCounts.workshop_task_revisions + + tableCounts.workshop_memory_revisions + + tableCounts.workshop_prompt_revisions, + }); + const payload = Object.freeze({ + tables: Object.freeze(tables), + }) satisfies LegacyOwnerPayload; + const fragment = Object.freeze({}) as WorkshopLegacyOwnerFragmentV042; + fragmentPayloads.set(fragment, payload); + const ownerFragmentDigest = await digest(canonicalJson(payload)); + const decoded = Object.freeze({ + format: 'gnolith-workshop-legacy-owner-v042' as const, + version: 1 as const, + source: Object.freeze({ + packageName: '@gnolith/workshop' as const, + packageVersion: '0.4.2' as const, + schemaVersion: 6 as const, + }), + counts, + tableCounts: Object.freeze(tableCounts), + digests: Object.freeze({ + tables: Object.freeze(tableDigests), + migrationLedger: await digest(canonicalJson(migrationLedger)), + ownerFragment: ownerFragmentDigest, + }), + migrationLedger, + ownerFragment: fragment, + }); + verifiedDecodes.add(decoded); + return decoded; +} + +/** + * Adopt a verified fragment through Workshop's owner-scoped destination handle. + * The decoder never mutates the legacy source and this adapter exposes no CLI. + */ +export async function adoptWorkshopLegacyOwnerV042( + decoded: WorkshopLegacyOwnerDecodedV042, + destination: WorkshopLegacyOwnerAdoptionHandle, + installationId: string, + options: { clock?: () => Date; signal?: AbortSignal } = {}, +): Promise { + if ( + !verifiedDecodes.has(decoded) || + !fragmentPayloads.has(decoded.ownerFragment) + ) + throw invalid('Legacy owner decode is not verified'); + if ( + destination.owner !== '@gnolith/workshop' || + !validBindingValue(destination.connectionId) || + !/^[a-f0-9]{64}$/u.test(destination.registrationFingerprint) + ) + throw invalid('Legacy owner destination binding is invalid'); + validateIdentifier(installationId); + const signal = options.signal ?? new AbortController().signal; + signal.throwIfAborted(); + const adoptedAt = (options.clock ?? (() => new Date()))().toISOString(); + await destination.adoptLegacyOwnerV042({ + installationId, + fragment: decoded.ownerFragment, + sourceDigest: decoded.digests.ownerFragment, + migrationLedger: decoded.migrationLedger, + expireClaimsAt: adoptedAt, + signal, + }); + return { + format: 'gnolith-workshop-legacy-owner-adoption-v1', + version: 1, + installationId, + sourceDigest: decoded.digests.ownerFragment, + adoptedAt, + counts: decoded.counts, + }; +} + +function validateAttestation(attestation: WorkshopLegacyV042Attestation): void { + if (!isRecord(attestation)) + throw invalid('Legacy attestation must be an object'); + exactKeys(attestation, [ + 'packageName', + 'packageVersion', + 'schemaVersion', + 'migrationLedger', + ]); + if ( + attestation.packageName !== '@gnolith/workshop' || + attestation.packageVersion !== '0.4.2' || + attestation.schemaVersion !== 6 + ) + throw invalid('Legacy source must attest exact Workshop 0.4.2'); + if ( + !isRecord(attestation.migrationLedger) || + !Array.isArray(attestation.migrationLedger.migrations) + ) + throw invalid('Legacy migration-ledger evidence is incompatible'); + exactKeys(attestation.migrationLedger, ['namespace', 'migrations']); + const expected = workshopMigrations.slice(0, 6); + if ( + attestation.migrationLedger.namespace !== '@gnolith/workshop' || + attestation.migrationLedger.migrations.length !== expected.length + ) + throw invalid('Legacy migration-ledger evidence is incompatible'); + const migrations = attestation.migrationLedger + .migrations as readonly unknown[]; + for (let index = 0; index < expected.length; index += 1) { + const actual = migrations[index]; + const migration = expected[index]!; + if (!isRecord(actual)) + throw invalid('Legacy migration-ledger evidence is incompatible'); + exactKeys(actual, ['id', 'checksum']); + if (actual.id !== migration.id || actual.checksum !== migration.checksum) + throw invalid('Legacy migration-ledger evidence is incompatible'); + } +} + +function freezeLedger( + value: WorkshopLegacyV042LedgerEvidence, +): WorkshopLegacyV042LedgerEvidence { + return Object.freeze({ + namespace: '@gnolith/workshop', + migrations: Object.freeze( + value.migrations.map(({ id, checksum }) => + Object.freeze({ id, checksum }), + ), + ), + }); +} + +function normalizeRow( + table: LegacyTableName, + value: Record, +): LegacyRow { + const normalized: Record = {}; + for (const [key, field] of Object.entries(value).sort(([left], [right]) => + left.localeCompare(right), + )) { + if ( + field !== null && + typeof field !== 'string' && + (typeof field !== 'number' || !Number.isSafeInteger(field)) + ) + throw invalid(`Legacy ${table} contains a non-portable value`); + normalized[key] = field; + } + return Object.freeze(normalized); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`; + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(',')}}`; +} + +async function digest(value: string): Promise<`sha256:${string}`> { + const bytes = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(value), + ); + return `sha256:${[...new Uint8Array(bytes)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('')}`; +} + +function boundedOption( + value: number | undefined, + fallback: number, + maximum: number, + label: string, +): number { + const result = value ?? fallback; + if (!Number.isSafeInteger(result) || result < 1 || result > maximum) + throw invalid(`Legacy ${label} bound is invalid`); + return result; +} + +function exactKeys(value: object, expected: readonly string[]): void { + const actual = Object.keys(value).sort(); + if (JSON.stringify(actual) !== JSON.stringify([...expected].sort())) + throw invalid('Legacy attestation has unknown or missing fields'); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function validateIdentifier(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !value || + value.length > 256 || + /[\u0000-\u001f\u007f]/u.test(value) + ) + throw invalid('Legacy adoption installation identifier is invalid'); +} + +function validBindingValue(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= 256 && + !/[\u0000-\u001f\u007f]/u.test(value) + ); +} + +function invalid(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} diff --git a/src/mcp.ts b/src/mcp.ts index e75e413..404df64 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -1,3 +1,12 @@ -export * from './mcp/tools.js'; -export * from './mcp/dispatcher.js'; -export * from './mcp/server.js'; +export { + GNOLITH_MCP_PROTOCOL_VERSION, + createGnolithMcpServer, + type GnolithMcpServer, + type GnolithMcpServerOptions, +} from './mcp/server.js'; +export { + CATALOG_DIGEST, + OPERATION_SCHEMA_VERSION, + operationCatalog, + type OperationName, +} from './generated/operations.js'; diff --git a/src/mcp/dispatcher.ts b/src/mcp/dispatcher.ts deleted file mode 100644 index 9691960..0000000 --- a/src/mcp/dispatcher.ts +++ /dev/null @@ -1,527 +0,0 @@ -import type { KnowledgeService } from '../protocol/knowledge.js'; -import { - WorkshopError, - normalizeWorkshopError, - type WorkshopErrorBody, -} from '../protocol/errors.js'; -import { hasWorkshopCapability, type WorkshopPrincipal } from '../protocol.js'; -import { authorize } from '../server/authorization.js'; -import { KNOWLEDGE_READ_TOOL_NAMES } from '../server/knowledge.js'; -import type { WorkshopLimits } from '../server/limits.js'; -import { resolveLimits } from '../server/limits.js'; -import type { MemoryService } from '../server/memories.js'; -import { - observed, - observeSafely, - type ObserveWorkshop, -} from '../server/observability.js'; -import type { PacketService } from '../server/packets.js'; -import type { TaskService } from '../server/tasks.js'; -import type { PromptService } from '../server/prompts.js'; -import type { WorkshopSearchIntegrationV1 } from '../server/search.js'; -import { workshopTools, type WorkshopToolDefinition } from './tools.js'; - -/** The transport-neutral services needed to execute Workshop tools. */ -export interface WorkshopToolRuntime { - readonly tasks: TaskService; - readonly memories: MemoryService; - readonly prompts?: PromptService; - readonly search?: WorkshopSearchIntegrationV1; - readonly packets: PacketService; - readonly knowledge: KnowledgeService; - readonly limits?: Partial; - readonly observe?: ObserveWorkshop; -} - -export interface WorkshopToolCall { - readonly name: string; - readonly arguments?: Readonly>; -} - -export interface WorkshopToolDispatchContext { - readonly principal: WorkshopPrincipal | null; - readonly requestId: string; - readonly signal?: AbortSignal; -} - -export type WorkshopDispatchErrorKind = - | 'unauthenticated' - | 'forbidden' - | 'unknown_tool' - | 'invalid_arguments' - | 'operation'; - -export interface WorkshopDispatchFailure { - readonly kind: WorkshopDispatchErrorKind; - readonly error: WorkshopErrorBody; -} - -export type WorkshopDispatchResult = - | { readonly ok: true; readonly value: T } - | { readonly ok: false; readonly failure: WorkshopDispatchFailure }; - -export interface WorkshopToolDispatcher { - readonly tools: readonly WorkshopToolDefinition[]; - listTools( - principal: WorkshopPrincipal | null, - ): WorkshopDispatchResult; - callTool( - call: WorkshopToolCall, - context: WorkshopToolDispatchContext, - ): Promise>; -} - -const DIRECT_TOOL_NAMES = [ - 'list_tasks', - 'search_tasks', - 'get_task_packet', - 'create_task', - 'update_task', - 'archive_task', - 'claim_task', - 'complete_task', - 'task_history', - 'list_memories', - 'get_memory', - 'upsert_memory', - 'memory_history', - 'list_prompts', - 'get_prompt', - 'create_prompt', - 'update_prompt', - 'delete_prompt', - 'prompt_history', - 'search', - 'search_status', - 'search_admin', -] as const; - -const KNOWLEDGE_TOOL_NAME_SET: ReadonlySet = new Set( - KNOWLEDGE_READ_TOOL_NAMES, -); -const HANDLED_TOOL_NAMES: ReadonlySet = new Set([ - ...DIRECT_TOOL_NAMES, - ...KNOWLEDGE_READ_TOOL_NAMES, -]); - -export function createWorkshopToolDispatcher( - runtime: WorkshopToolRuntime, -): WorkshopToolDispatcher { - assertCompleteRegistry(); - const definitions = new Map( - workshopTools.map((definition) => [definition.name, definition] as const), - ); - const availableTools = workshopTools; - - return { - tools: availableTools, - listTools(principal) { - const authorized = authorizeResult(principal, 'read'); - if (!authorized.ok) return authorized; - return { - ok: true, - value: availableTools.filter((tool) => - hasCapability(authorized.value, tool.capability), - ), - }; - }, - async callTool(call, context) { - if (!context.principal) { - return authorizationFailure( - new WorkshopError('unauthenticated', 'Authentication is required'), - ); - } - const definition = definitions.get(call.name); - if (!definition) { - return { - ok: false, - failure: { - kind: 'unknown_tool', - error: new WorkshopError( - 'bad_request', - `Unknown tool ${call.name}`, - 400, - { tool: call.name }, - ).toJSON(), - }, - }; - } - const authorized = authorizeResult( - context.principal, - definition.capability, - ); - if (!authorized.ok) { - await observeAuthorizationFailure(runtime, authorized.failure, context); - return authorized; - } - - try { - const value = await observed( - runtime.observe, - { - operation: 'mcp.call', - principalId: authorized.value.principalId, - requestId: context.requestId, - }, - () => - withTimeout( - executeTool( - runtime, - authorized.value, - call.name, - { ...(call.arguments ?? {}) }, - context.requestId, - context.signal ?? new AbortController().signal, - ), - resolveLimits(runtime.limits).toolTimeoutMs, - ), - ); - return { ok: true, value }; - } catch (error) { - const normalized = normalizeWorkshopError(error); - if ( - normalized.code === 'unauthenticated' || - normalized.code === 'forbidden' - ) { - const failure = authorizationFailure(normalized); - if (!failure.ok) - await observeAuthorizationFailure( - runtime, - failure.failure, - context, - ); - return failure; - } - return { - ok: false, - failure: { - kind: - normalized.code === 'bad_request' || - normalized.code === 'validation_failed' - ? 'invalid_arguments' - : 'operation', - error: normalized.toJSON(), - }, - }; - } - }, - }; -} - -async function executeTool( - runtime: WorkshopToolRuntime, - principal: WorkshopPrincipal, - name: string, - input: Record, - requestId: string, - signal: AbortSignal, -): Promise { - switch (name) { - case 'list_tasks': - case 'search_tasks': - return runtime.tasks.list(input, principal); - case 'get_task_packet': - return runtime.packets.get(text(input.id, 'id'), principal, signal); - case 'create_task': - return runtime.tasks.create(input as never, principal); - case 'update_task': { - const { id, ...update } = input; - return runtime.tasks.update(text(id, 'id'), update as never, principal); - } - case 'archive_task': { - const { id, expectedRevision, expectedUpdatedAt } = input; - return runtime.tasks.archive( - text(id, 'id'), - { - ...(expectedRevision === undefined ? {} : { expectedRevision }), - ...(expectedUpdatedAt === undefined ? {} : { expectedUpdatedAt }), - } as never, - principal, - ); - } - case 'claim_task': - return runtime.tasks.claim(text(input.id, 'id'), principal); - case 'complete_task': - return runtime.tasks.complete( - text(input.id, 'id'), - input.result, - principal, - ); - case 'task_history': - return runtime.tasks.history(text(input.id, 'id'), principal, { - ...(input.limit === undefined - ? {} - : { limit: positive(input.limit, 'limit') }), - }); - case 'list_memories': - return runtime.memories.list(input, principal); - case 'get_memory': - return runtime.memories.get(text(input.slug, 'slug'), principal); - case 'upsert_memory': { - const { slug, ...memory } = input; - return runtime.memories.upsert( - text(slug, 'slug'), - memory as never, - principal, - ); - } - case 'memory_history': - return runtime.memories.history(text(input.slug, 'slug'), principal, { - ...(input.limit === undefined - ? {} - : { limit: positive(input.limit, 'limit') }), - }); - case 'list_prompts': - return requiredPrompts(runtime).list(input, principal); - case 'get_prompt': - return requiredPrompts(runtime).get(text(input.id, 'id'), principal); - case 'create_prompt': - return requiredPrompts(runtime).create(input as never, principal); - case 'update_prompt': { - const { id, ...update } = input; - return requiredPrompts(runtime).update( - text(id, 'id'), - update as never, - principal, - ); - } - case 'delete_prompt': - return requiredPrompts(runtime).delete( - text(input.id, 'id'), - positive(input.expectedRevision, 'expectedRevision'), - principal, - ); - case 'prompt_history': - return requiredPrompts(runtime).history(text(input.id, 'id'), principal, { - ...(input.limit === undefined - ? {} - : { limit: positive(input.limit, 'limit') }), - }); - case 'search': - return requiredSearch(runtime).search(input as never, principal); - case 'search_status': { - const search = requiredSearch(runtime); - return { - materialization: await search.materialization.health(principal), - semantic: await search.semantic.status(principal), - }; - } - case 'search_admin': - return executeSearchAdmin( - requiredSearch(runtime), - input, - principal, - signal, - ); - } - - if (KNOWLEDGE_TOOL_NAME_SET.has(name)) { - return runtime.knowledge.call({ name, input }, { ...principal, requestId }); - } - throw new WorkshopError('internal_error', `Tool ${name} has no handler`); -} - -function requiredPrompts(runtime: WorkshopToolRuntime): PromptService { - if (!runtime.prompts) - throw new WorkshopError( - 'internal_error', - 'Prompt producer is not registered', - ); - return runtime.prompts; -} - -function requiredSearch( - runtime: WorkshopToolRuntime, -): WorkshopSearchIntegrationV1 { - if (!runtime.search) - throw new WorkshopError( - 'internal_error', - 'Search integration is not registered', - ); - return runtime.search; -} - -async function executeSearchAdmin( - search: WorkshopSearchIntegrationV1, - input: Record, - principal: WorkshopPrincipal, - signal: AbortSignal, -): Promise { - const operation = text(input.operation, 'operation'); - const id = () => text(input.configurationId, 'configurationId'); - const plan = () => text(input.planId, 'planId'); - switch (operation) { - case 'materialize': - return search.materialization.run(principal, { - maxJobs: positive(input.maxJobs ?? 20, 'maxJobs'), - maxRebuildRoots: positive( - input.maxRebuildRoots ?? 100, - 'maxRebuildRoots', - ), - }); - case 'retry-dead': - return search.materialization.retryDead(principal, { - limit: positive(input.limit ?? 20, 'limit'), - }); - case 'adopt-legacy': { - const kind = text(input.kind, 'kind'); - if (kind !== 'task' && kind !== 'memory' && kind !== 'prompt') - throw new WorkshopError('validation_failed', 'kind is invalid'); - return search[kind].producer.adoptLegacyPage(principal, { - limit: positive(input.limit ?? 100, 'limit'), - }); - } - case 'start-rebuild': - return search.materialization.startShadowRebuild(principal); - case 'activate-rebuild': - return search.materialization.activateReadyShadow(principal); - case 'semantic-select': - return search.semantic.select(id(), principal); - case 'semantic-reconnect': - return search.semantic.reconnect(id(), principal); - case 'semantic-estimate': - return search.semantic.estimate(id(), input.policy as never, principal); - case 'semantic-approve': - return search.semantic.approve(plan(), principal); - case 'semantic-run': - return search.semantic.run(plan(), principal, signal); - case 'semantic-resume': - return search.semantic.resume(plan(), principal, signal); - case 'semantic-pause': - return search.semantic.pause(plan(), principal); - case 'semantic-stop': - return search.semantic.stop(plan(), principal); - case 'semantic-retry': - return search.semantic.retry(plan(), principal); - case 'semantic-exclude': - return search.semantic.exclude( - id(), - positive(input.generation, 'generation'), - text(input.derivedId, 'derivedId'), - text(input.reason, 'reason'), - principal, - ); - case 'semantic-retire': - return search.semantic.retire(id(), principal); - case 'semantic-delete-embeddings': - return search.semantic.deleteEmbeddings(id(), principal); - default: - throw new WorkshopError( - 'validation_failed', - 'search admin operation is invalid', - ); - } -} - -function authorizeResult( - principal: WorkshopPrincipal | null, - capability: WorkshopToolDefinition['capability'], -): WorkshopDispatchResult { - try { - return { ok: true, value: authorize(principal, capability) }; - } catch (error) { - return authorizationFailure(normalizeWorkshopError(error)); - } -} - -function authorizationFailure( - error: WorkshopError, -): WorkshopDispatchResult { - return { - ok: false, - failure: { - kind: error.code === 'unauthenticated' ? 'unauthenticated' : 'forbidden', - error: error.toJSON(), - }, - }; -} - -async function observeAuthorizationFailure( - runtime: WorkshopToolRuntime, - failure: WorkshopDispatchFailure, - context: WorkshopToolDispatchContext, -): Promise { - await observeSafely(runtime.observe, { - operation: 'authorization.failure', - outcome: 'error', - durationMs: 0, - errorCode: failure.error.code, - requestId: context.requestId, - ...(context.principal - ? { principalId: context.principal.principalId } - : {}), - }); -} - -function hasCapability( - principal: WorkshopPrincipal, - capability: WorkshopToolDefinition['capability'], -): boolean { - return hasWorkshopCapability(principal.capabilities, capability); -} - -function assertCompleteRegistry(): void { - const schemaNames = new Set(); - for (const definition of workshopTools) { - if (schemaNames.has(definition.name)) { - throw new WorkshopError( - 'internal_error', - `Duplicate Workshop tool ${definition.name}`, - ); - } - schemaNames.add(definition.name); - if (!HANDLED_TOOL_NAMES.has(definition.name)) { - throw new WorkshopError( - 'internal_error', - `Workshop tool ${definition.name} has no handler`, - ); - } - } - for (const name of HANDLED_TOOL_NAMES) { - if (!schemaNames.has(name)) { - throw new WorkshopError( - 'internal_error', - `Workshop handler ${name} has no tool definition`, - ); - } - } -} - -function text(value: unknown, field: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new WorkshopError('validation_failed', `${field} is required`, 400, { - field, - }); - } - return value.trim(); -} - -function positive(value: unknown, field: string): number { - if (!Number.isSafeInteger(value) || Number(value) < 1) - throw new WorkshopError( - 'validation_failed', - `${field} must be a positive integer`, - ); - return Number(value); -} - -async function withTimeout( - promise: Promise, - milliseconds: number, -): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timer = setTimeout( - () => - reject(new WorkshopError('query_timeout', 'Tool call timed out')), - milliseconds, - ); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } -} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 87e1d87..41cb95a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,21 +1,18 @@ -import { WorkshopError, normalizeWorkshopError } from '../protocol/errors.js'; -import type { WorkshopPrincipal } from '../protocol.js'; -import type { WorkshopRuntime } from '../server/context.js'; -import { resolveLimits } from '../server/limits.js'; -import { observeSafely } from '../server/observability.js'; import { - createWorkshopToolDispatcher, - type WorkshopDispatchFailure, - type WorkshopToolDispatcher, -} from './dispatcher.js'; -import type { WorkshopToolDefinition } from './tools.js'; + operationCatalog, + type OperationName, +} from '../generated/operations.js'; +import type { OperationRuntime } from '../operations.js'; +import { WorkshopError, normalizeWorkshopError } from '../protocol/errors.js'; +import type { AuthenticatedPrincipal } from '../protocol.js'; -export const WORKSHOP_MCP_PROTOCOL_VERSION = '2025-11-25'; +export const GNOLITH_MCP_PROTOCOL_VERSION = '2025-11-25'; const SUPPORTED_PROTOCOLS = new Set([ - WORKSHOP_MCP_PROTOCOL_VERSION, + GNOLITH_MCP_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', ]); +const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; type RequestId = string | number; @@ -24,148 +21,133 @@ interface JsonRpcMessage { id?: RequestId | null; method?: string; params?: unknown; - result?: unknown; - error?: unknown; } -export interface WorkshopMcpServer { - readonly tools: readonly WorkshopToolDefinition[]; +export interface GnolithMcpServerOptions { + version: string; + operations: OperationRuntime; + authenticate(request: Request): Promise; + allowedOrigins?: readonly string[]; + maxRequestBytes?: number; + challenge?: string; +} + +export interface GnolithMcpServer { + readonly tools: typeof operationCatalog; handle(request: Request): Promise; } -export function createWorkshopMcpServer( - runtime: WorkshopRuntime, -): WorkshopMcpServer { - const limits = resolveLimits(runtime.limits); - const dispatcher = createWorkshopToolDispatcher(runtime); +export function createGnolithMcpServer( + options: GnolithMcpServerOptions, +): GnolithMcpServer { + const origins = new Set(options.allowedOrigins ?? []); + const maxBytes = options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES; return { - tools: dispatcher.tools, + tools: operationCatalog, async handle(request) { - if (request.method === 'GET') { - return new Response('SSE listening is not enabled', { - status: 405, - headers: { allow: 'POST, OPTIONS' }, - }); - } - if (request.method === 'OPTIONS') return corsPreflight(request, runtime); - if (request.method !== 'POST') { - return new Response('Method not allowed', { - status: 405, - headers: { allow: 'GET, POST, OPTIONS' }, - }); - } - const originFailure = validateOrigin(request, runtime); + if (request.method === 'GET') return methodNotAllowed('POST, OPTIONS'); + if (request.method === 'OPTIONS') return preflight(request, origins); + if (request.method !== 'POST') return methodNotAllowed('POST, OPTIONS'); + const originFailure = validateOrigin(request, origins); if (originFailure) return originFailure; const contentType = request.headers.get('content-type')?.split(';', 1)[0]; - if (contentType !== 'application/json') { + if (contentType !== 'application/json') return httpError(415, 'MCP requests require application/json'); - } const accept = request.headers.get('accept') ?? ''; if ( !accept.includes('application/json') || !accept.includes('text/event-stream') - ) { + ) return httpError( 406, 'MCP requests must accept application/json and text/event-stream', ); + + let principal: AuthenticatedPrincipal; + try { + principal = await options.authenticate(request); + } catch { + return unauthenticated(options.challenge); } + let message: JsonRpcMessage; try { - message = await readMessage(request, limits.maxMcpRequestBytes); + message = await readMessage(request, maxBytes); } catch (error) { const normalized = normalizeWorkshopError(error); - await observeSafely(runtime.observe, { - operation: 'authorization.failure', - outcome: 'error', - durationMs: 0, - errorCode: normalized.code, - ...(request.headers.get('x-request-id') - ? { requestId: request.headers.get('x-request-id')! } - : {}), - }); return rpcResponse( - rpcError(null, -32700, normalized.message, normalized.toJSON()), + rpcError(null, -32700, normalized.message), normalized.status, request, - runtime, + origins, ); } if ( message.jsonrpc !== '2.0' || typeof message.method !== 'string' || Array.isArray(message) - ) { + ) return rpcResponse( rpcError(message.id ?? null, -32600, 'Invalid JSON-RPC request'), 400, request, - runtime, + origins, ); - } + const protocol = + request.headers.get('mcp-protocol-version') ?? '2025-03-26'; if ( message.method !== 'initialize' && - message.method !== 'notifications/initialized' - ) { - const protocol = - request.headers.get('mcp-protocol-version') ?? '2025-03-26'; - if (!SUPPORTED_PROTOCOLS.has(protocol)) { - return httpError(400, 'Unsupported MCP protocol version'); - } - } - let principal: WorkshopPrincipal | null; - try { - principal = await runtime.resolvePrincipal(request); - if (!principal) { - throw new WorkshopError( - 'unauthenticated', - 'MCP authentication is required', - ); - } - } catch (error) { - const normalized = normalizeWorkshopError(error); - return rpcResponse( - rpcError(message.id ?? null, -32001, normalized.message), - normalized.status, - request, - runtime, - ); - } + message.method !== 'notifications/initialized' && + !SUPPORTED_PROTOCOLS.has(protocol) + ) + return httpError(400, 'Unsupported MCP protocol version'); + if (message.id === undefined || message.id === null) { return new Response(null, { status: 202, - headers: corsHeaders(request, runtime), + headers: responseHeaders(request, origins), }); } + try { - const result = await dispatch( - runtime, - dispatcher, - principal, - message, - request, - ); + const result = await dispatch(message, request, principal, options); return rpcResponse( { jsonrpc: '2.0', id: message.id, result }, 200, request, - runtime, + origins, ); } catch (error) { - if (error instanceof ProtocolError) { + if (error instanceof ProtocolError) return rpcResponse( rpcError(message.id, error.rpcCode, error.message), 200, request, - runtime, + origins, ); - } const normalized = normalizeWorkshopError(error); + const operationFailure = + normalized.code === 'unauthenticated' || + normalized.code === 'forbidden' + ? { code: -32001, message: normalized.message } + : { + code: -32603, + message: + normalized.code === 'internal_error' + ? 'Workshop operation failed' + : normalized.message, + data: normalized.toJSON(), + }; return rpcResponse( - rpcError(message.id, -32603, normalized.message), + rpcError( + message.id, + operationFailure.code, + operationFailure.message, + 'data' in operationFailure ? operationFailure.data : undefined, + ), 200, request, - runtime, + origins, ); } }, @@ -173,11 +155,10 @@ export function createWorkshopMcpServer( } async function dispatch( - runtime: WorkshopRuntime, - dispatcher: WorkshopToolDispatcher, - principal: WorkshopPrincipal, message: JsonRpcMessage, request: Request, + principal: AuthenticatedPrincipal, + options: GnolithMcpServerOptions, ): Promise { switch (message.method) { case 'initialize': { @@ -185,69 +166,60 @@ async function dispatch( const requested = typeof params.protocolVersion === 'string' ? params.protocolVersion - : WORKSHOP_MCP_PROTOCOL_VERSION; + : GNOLITH_MCP_PROTOCOL_VERSION; return { protocolVersion: SUPPORTED_PROTOCOLS.has(requested) ? requested - : WORKSHOP_MCP_PROTOCOL_VERSION, + : GNOLITH_MCP_PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } }, - serverInfo: { name: '@gnolith/workshop', version: '0.4.2' }, + serverInfo: { name: 'gnolith', version: options.version }, instructions: - 'Search existing tasks before creating overlapping work. Reading a task packet never claims it. All knowledge writes require the latest Taproot revision.', + 'Gnolith exposes one authenticated operation catalog. Search before creating; use exact revisions and task lease tokens for mutations.', }; } case 'ping': return {}; - case 'tools/list': { - const listed = dispatcher.listTools(principal); - if (!listed.ok) throw dispatchFailure(listed.failure); + case 'tools/list': return { - tools: listed.value.map((tool) => ({ - name: tool.name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema, + tools: operationCatalog.map((definition) => ({ + name: definition.name, + title: title(definition.name), + description: `${definition.name} v${definition.version}`, + inputSchema: definition.inputSchema, + annotations: { + readOnlyHint: definition.annotations.readOnly, + destructiveHint: definition.annotations.destructive, + idempotentHint: definition.annotations.idempotent, + }, })), }; - } case 'tools/call': { const params = asObject(message.params); const name = typeof params.name === 'string' ? params.name : ''; + if (!operationCatalog.some((operation) => operation.name === name)) + throw new ProtocolError(-32602, 'Unknown operation'); const input = params.arguments === undefined ? {} : asObject(params.arguments); const requestId = - request.headers.get('x-request-id') ?? crypto.randomUUID(); - const called = await dispatcher.callTool( - { name, arguments: input }, - { principal, requestId, signal: request.signal }, + boundedRequestId(request.headers.get('x-request-id')) ?? + crypto.randomUUID(); + const value = await options.operations.call( + name as OperationName, + input, + { + principal, + requestId, + signal: request.signal, + }, ); - if (!called.ok) { - if (called.failure.kind === 'unknown_tool') { - throw new ProtocolError(-32602, called.failure.error.message); - } - if ( - called.failure.kind === 'unauthenticated' || - called.failure.kind === 'forbidden' - ) { - throw dispatchFailure(called.failure); - } - return { - content: [{ type: 'text', text: called.failure.error.message }], - structuredContent: { error: called.failure.error }, - isError: true, - }; - } return { - content: [{ type: 'text', text: JSON.stringify(called.value) }], - structuredContent: toStructured(called.value), + content: [{ type: 'text', text: JSON.stringify(value) }], + structuredContent: toStructured(value), isError: false, }; } default: - throw new ProtocolError( - -32601, - `Method ${message.method} is not supported`, - ); + throw new ProtocolError(-32601, 'Method not supported'); } } @@ -256,9 +228,8 @@ async function readMessage( maxBytes: number, ): Promise { const declared = Number(request.headers.get('content-length')); - if (Number.isFinite(declared) && declared > maxBytes) { - throw new WorkshopError('limit_exceeded', 'MCP request is too large'); - } + if (Number.isFinite(declared) && declared > maxBytes) + throw new WorkshopError('limit_exceeded', 'MCP request is too large', 413); if (!request.body) throw new WorkshopError('bad_request', 'Missing MCP request body'); const reader = request.body.getReader(); @@ -270,7 +241,11 @@ async function readMessage( total += next.value.byteLength; if (total > maxBytes) { await reader.cancel(); - throw new WorkshopError('limit_exceeded', 'MCP request is too large'); + throw new WorkshopError( + 'limit_exceeded', + 'MCP request is too large', + 413, + ); } chunks.push(next.value); } @@ -281,35 +256,46 @@ async function readMessage( offset += chunk.byteLength; } try { - return JSON.parse(new TextDecoder().decode(bytes)) as JsonRpcMessage; - } catch { + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + if (Array.isArray(parsed)) + throw new WorkshopError( + 'bad_request', + 'Batched JSON-RPC is not supported', + ); + return parsed as JsonRpcMessage; + } catch (error) { + if (error instanceof WorkshopError) throw error; throw new WorkshopError('bad_request', 'Invalid JSON request body'); } } function validateOrigin( request: Request, - runtime: WorkshopRuntime, + origins: ReadonlySet, ): Response | null { const origin = request.headers.get('origin'); if (!origin) return null; - const allowed = resolveCorsOrigin(request, runtime); - if (origin !== new URL(request.url).origin && origin !== allowed) { + if (origin !== new URL(request.url).origin && !origins.has(origin)) return httpError(403, 'Origin is not allowed'); - } return null; } -function resolveCorsOrigin( - request: Request, - runtime: WorkshopRuntime, -): string | null { - return typeof runtime.corsOrigin === 'function' - ? runtime.corsOrigin(request) - : (runtime.corsOrigin ?? null); +function preflight(request: Request, origins: ReadonlySet): Response { + const failure = validateOrigin(request, origins); + if (failure) return failure; + const headers = responseHeaders(request, origins); + headers.set('access-control-allow-methods', 'POST, OPTIONS'); + headers.set( + 'access-control-allow-headers', + 'Authorization, Content-Type, Accept, MCP-Protocol-Version, X-Request-Id', + ); + return new Response(null, { status: 204, headers }); } -function corsHeaders(request: Request, runtime: WorkshopRuntime): Headers { +function responseHeaders( + request: Request, + origins: ReadonlySet, +): Headers { const headers = new Headers({ 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', @@ -317,8 +303,7 @@ function corsHeaders(request: Request, runtime: WorkshopRuntime): Headers { const origin = request.headers.get('origin'); if ( origin && - (origin === new URL(request.url).origin || - origin === resolveCorsOrigin(request, runtime)) + (origin === new URL(request.url).origin || origins.has(origin)) ) { headers.set('access-control-allow-origin', origin); headers.set('vary', 'Origin'); @@ -326,31 +311,42 @@ function corsHeaders(request: Request, runtime: WorkshopRuntime): Headers { return headers; } -function corsPreflight(request: Request, runtime: WorkshopRuntime): Response { - const failure = validateOrigin(request, runtime); - if (failure) return failure; - const headers = corsHeaders(request, runtime); - headers.set('access-control-allow-methods', 'POST, OPTIONS'); - headers.set( - 'access-control-allow-headers', - 'Authorization, Content-Type, Accept, MCP-Protocol-Version, X-Request-Id', - ); - return new Response(null, { status: 204, headers }); -} - function rpcResponse( body: unknown, status: number, request: Request, - runtime: WorkshopRuntime, + origins: ReadonlySet, ): Response { - const headers = corsHeaders(request, runtime); + const headers = responseHeaders(request, origins); headers.set('content-type', 'application/json; charset=utf-8'); return new Response(JSON.stringify(body), { status, headers }); } +function unauthenticated(challenge = 'Bearer'): Response { + return Response.json( + { error: { code: 'unauthenticated', message: 'Authentication required' } }, + { + status: 401, + headers: { + 'cache-control': 'no-store', + 'www-authenticate': challenge, + }, + }, + ); +} + function httpError(status: number, message: string): Response { - return Response.json({ error: message }, { status }); + return Response.json( + { error: { code: 'bad_request', message } }, + { status, headers: { 'cache-control': 'no-store' } }, + ); +} + +function methodNotAllowed(allow: string): Response { + return new Response('Method not allowed', { + status: 405, + headers: { allow }, + }); } function rpcError( @@ -358,7 +354,7 @@ function rpcError( code: number, message: string, data?: unknown, -): JsonRpcMessage { +): Record { return { jsonrpc: '2.0', id, @@ -367,9 +363,8 @@ function rpcError( } function asObject(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ProtocolError(-32602, 'Expected an object'); - } return value as Record; } @@ -379,13 +374,17 @@ function toStructured(value: unknown): Record { : { value }; } -function dispatchFailure(failure: WorkshopDispatchFailure): WorkshopError { - return new WorkshopError( - failure.error.code, - failure.error.message, - undefined, - failure.error.details, - ); +function boundedRequestId(value: string | null): string | null { + if (!value || value.length > 256 || /[\u0000-\u001f\u007f]/u.test(value)) + return null; + return value; +} + +function title(name: string): string { + return name + .split('_') + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(' '); } class ProtocolError extends Error { diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts deleted file mode 100644 index 682d6f1..0000000 --- a/src/mcp/tools.ts +++ /dev/null @@ -1,658 +0,0 @@ -import type { WorkshopCapability } from '../protocol.js'; -import { KNOWLEDGE_TOOL_NAMES } from '../server/knowledge.js'; - -export interface WorkshopToolDefinition { - name: string; - title: string; - description: string; - capability: WorkshopCapability; - inputSchema: { - type: 'object'; - properties: Record; - required?: string[]; - additionalProperties: boolean; - }; -} - -const string = (description: string) => ({ type: 'string', description }); -const integer = (description: string) => ({ - type: 'integer', - minimum: 0, - description, -}); -const positiveInteger = (description: string) => ({ - type: 'integer', - minimum: 1, - description, -}); -const object = (description: string) => ({ - type: 'object', - description, - additionalProperties: true, -}); -const authoredText = (description: string) => ({ - type: 'string', - minLength: 1, - pattern: '\\S', - description, -}); -const array = (items: unknown, description: string) => ({ - type: 'array', - items, - description, -}); - -function tool( - name: string, - title: string, - description: string, - capability: WorkshopCapability, - properties: Record = {}, - required: string[] = [], - additionalProperties = false, -): WorkshopToolDefinition { - return { - name, - title, - description, - capability, - inputSchema: { - type: 'object', - properties, - ...(required.length ? { required } : {}), - additionalProperties, - }, - }; -} - -const taskFilterProperties = { - role: string('Advisory role label to match exactly.'), - state: { - type: 'string', - enum: ['unclaimed', 'claimed', 'completed', 'archived'], - description: 'Derived task state.', - }, - claimed: { type: 'boolean', description: 'Filter by current claim flag.' }, - updatedAfter: string('ISO timestamp lower bound.'), - text: string('Text to match in task description or prompt.'), - cursor: string('Opaque cursor returned by the previous page.'), - limit: integer('Page size.'), -}; - -const taskInputProperties = { - idempotencyKey: string('Stable key for safe retries.'), - description: string('Short human-readable task description.'), - role: string('Optional advisory role label; never grants authorization.'), - prompt: string('Complete instructions for the agent performing the task.'), - contextQueries: array( - { - type: 'object', - properties: { - label: string('Optional result label.'), - sparql: string( - 'Read-only SPARQL query parsed and policy-validated before save.', - ), - }, - required: ['sparql'], - additionalProperties: false, - }, - 'Queries compiled against current graph state whenever the packet is read.', - ), - memorySlugs: array( - string('Memory slug.'), - 'Existing memories to resolve into the packet.', - ), -}; - -const taskTools = [ - tool( - 'list_tasks', - 'List tasks', - 'List tasks with stable cursor pagination and optional state filters.', - 'read', - taskFilterProperties, - ), - tool( - 'search_tasks', - 'Search tasks', - 'Search existing descriptions and prompts before creating overlapping work.', - 'read', - taskFilterProperties, - ), - tool( - 'get_task_packet', - 'Get task packet', - 'Compile an ephemeral packet from the current task and current memories. Stored graph queries remain fail-closed until a scoped graph reader exists. This never claims the task.', - 'read', - { id: string('Task ID.') }, - ['id'], - ), - tool( - 'create_task', - 'Create task', - 'Validate all fields, statically validate every read-only context query, verify every memory, and atomically create a durable task.', - 'task-write', - taskInputProperties, - ['description', 'prompt'], - ), - tool( - 'update_task', - 'Update task', - 'Optimistically update an active task. Supply expectedRevision (preferred) or the legacy exact updatedAt value; completed or archived tasks cannot be reopened.', - 'task-write', - { - id: string('Task ID.'), - expectedRevision: positiveInteger('Exact current task revision.'), - expectedUpdatedAt: string('Exact current task updatedAt timestamp.'), - ...taskInputProperties, - }, - ['id'], - ), - tool( - 'archive_task', - 'Archive task', - 'Revision-conditionally archive an unclaimed task. Claimed-task archival requires an administrative HTTP integration and is not available here.', - 'task-write', - { - id: string('Task ID.'), - expectedRevision: positiveInteger('Exact current task revision.'), - expectedUpdatedAt: string('Exact current task updatedAt timestamp.'), - }, - ['id'], - ), - tool( - 'claim_task', - 'Claim task', - 'Atomically claim an unclaimed active task. Exactly one concurrent claimant can succeed.', - 'task-write', - { id: string('Task ID.') }, - ['id'], - ), - tool( - 'complete_task', - 'Complete task', - 'Complete an exactly matching claimed task with a nonempty result. Negative or inconclusive results are valid.', - 'task-write', - { id: string('Task ID.'), result: string('Nonempty task outcome.') }, - ['id', 'result'], - ), - tool( - 'task_history', - 'Task history', - 'Read bounded newest-first canonical task revision history with current authorization rechecked before content is returned.', - 'read', - { - id: string('Task ID.'), - limit: positiveInteger('Maximum revisions to return.'), - }, - ['id'], - ), -]; - -const memoryTools = [ - tool( - 'list_memories', - 'List memories', - 'List reusable operating guidance with stable pagination.', - 'read', - { - text: string('Text to search.'), - cursor: string('Opaque page cursor.'), - limit: integer('Page size.'), - }, - ), - tool( - 'get_memory', - 'Get memory', - 'Read one reusable memory by slug.', - 'read', - { slug: string('Memory slug.') }, - ['slug'], - ), - tool( - 'upsert_memory', - 'Upsert memory', - 'Create or replace reusable operating guidance. Memories should not duplicate changing graph facts.', - 'memory-write', - { - slug: string('Lowercase hyphenated slug.'), - description: string('Purpose of this guidance.'), - content: string('Reusable guidance.'), - expectedRevision: positiveInteger( - 'Optional exact current memory revision.', - ), - expectedUpdatedAt: string('Optional optimistic concurrency timestamp.'), - }, - ['slug', 'description', 'content'], - ), - tool( - 'memory_history', - 'Memory history', - 'Read bounded newest-first canonical memory revision history with current authorization rechecked before content is returned.', - 'read', - { - slug: string('Memory slug.'), - limit: positiveInteger('Maximum revisions to return.'), - }, - ['slug'], - ), -]; - -const promptTools = [ - tool( - 'list_prompts', - 'List prompts', - 'List canonical prompts with stable pagination.', - 'read', - { - text: string('Text to match.'), - active: { type: 'boolean' }, - role: string('Role.'), - scope: string('Scope.'), - cursor: string('Opaque cursor.'), - limit: integer('Page size.'), - }, - ), - tool( - 'get_prompt', - 'Get prompt', - 'Read one exact current canonical prompt.', - 'read', - { id: string('Prompt ID.') }, - ['id'], - ), - tool( - 'create_prompt', - 'Create prompt', - 'Create a revisioned canonical prompt.', - 'prompt-write', - { - id: string('Optional stable prompt ID.'), - name: string('Unique prompt name.'), - title: string('Prompt title.'), - promptText: string('Prompt instructions.'), - scope: string('Optional scope.'), - role: string('Optional role.'), - variables: object('Variable schema.'), - active: { type: 'boolean' }, - priority: integer('Priority.'), - order: integer('Stable order.'), - language: string('Language tag.'), - attribution: object('Attribution metadata.'), - }, - ['name', 'promptText'], - ), - tool( - 'update_prompt', - 'Update prompt', - 'Revision-conditionally update a canonical prompt.', - 'prompt-write', - { - id: string('Prompt ID.'), - expectedRevision: positiveInteger('Exact current revision.'), - name: string('Prompt name.'), - title: string('Prompt title.'), - promptText: string('Prompt instructions.'), - scope: string('Scope.'), - role: string('Role.'), - variables: object('Variable schema.'), - active: { type: 'boolean' }, - priority: integer('Priority.'), - order: integer('Stable order.'), - language: string('Language tag.'), - attribution: object('Attribution metadata.'), - }, - ['id', 'expectedRevision'], - ), - tool( - 'delete_prompt', - 'Delete prompt', - 'Revision-conditionally delete a canonical prompt.', - 'prompt-write', - { - id: string('Prompt ID.'), - expectedRevision: positiveInteger('Exact current revision.'), - }, - ['id', 'expectedRevision'], - ), - tool( - 'prompt_history', - 'Prompt history', - 'Read bounded newest-first canonical prompt revision history with current authorization rechecked before content is returned.', - 'read', - { - id: string('Prompt ID.'), - limit: positiveInteger('Maximum revisions to return.'), - }, - ['id'], - ), -]; - -const searchTools = [ - tool( - 'search', - 'Search', - 'Search the authorization-aware unified corpus across enabled kinds.', - 'read', - { - text: string('Search text.'), - kinds: array(string('Unified search kind.'), 'Kinds to include.'), - filters: object('Structured search filters.'), - limit: positiveInteger('Page size.'), - cursor: string('Opaque cursor.'), - }, - ['text'], - ), - tool( - 'search_status', - 'Search status', - 'Inspect materialization and semantic search status.', - 'search:admin', - ), - tool( - 'search_admin', - 'Search administration', - 'Run one exact bounded materialization, adoption, rebuild, or semantic lifecycle operation.', - 'search:admin', - { - operation: { - type: 'string', - enum: [ - 'materialize', - 'retry-dead', - 'adopt-legacy', - 'start-rebuild', - 'activate-rebuild', - 'semantic-select', - 'semantic-reconnect', - 'semantic-estimate', - 'semantic-approve', - 'semantic-run', - 'semantic-resume', - 'semantic-pause', - 'semantic-stop', - 'semantic-retry', - 'semantic-exclude', - 'semantic-retire', - 'semantic-delete-embeddings', - ], - }, - kind: { type: 'string', enum: ['task', 'memory', 'prompt'] }, - limit: positiveInteger('Bounded record count.'), - maxJobs: positiveInteger('Maximum jobs.'), - maxRebuildRoots: positiveInteger('Maximum rebuild roots.'), - configurationId: string('Configuration ID.'), - planId: string('Plan ID.'), - generation: positiveInteger('Embedding generation.'), - derivedId: string('Derived record ID.'), - reason: string('Exclusion reason.'), - policy: object('Embedding schedule policy.'), - }, - ['operation'], - ), -]; - -const knowledgeDescriptions: Record = { - search_entities: 'Search Taproot entity labels, descriptions, and aliases.', - get_entity: 'Read one canonical Taproot entity and lifecycle metadata.', - get_entities: 'Read up to 100 canonical Taproot entities.', - create_item: - 'Create one Taproot Item and return its entity and revision IDs.', - create_property: - 'Create one typed Taproot Property and return its entity and revision IDs.', - set_label: 'Set an entity label using expected-revision concurrency control.', - set_description: - 'Set an entity description using expected-revision concurrency control.', - add_alias: 'Add an entity alias using expected-revision concurrency control.', - remove_alias: - 'Remove an alias by language and ordinal using expected-revision concurrency control.', - add_sitelink: - 'Set an Item sitelink using expected-revision concurrency control.', - remove_sitelink: - 'Remove an Item sitelink using expected-revision concurrency control.', - add_statement: 'Add a complete Wikibase-shaped statement through Taproot.', - replace_statement: 'Replace one statement by ID through Taproot.', - remove_statement: 'Remove one statement by ID through Taproot.', - set_statement_rank: 'Set one statement rank through Taproot.', - add_qualifier: 'Add a typed qualifier snak to one statement through Taproot.', - remove_qualifier: - 'Remove a qualifier by property and ordinal through Taproot.', - add_reference: 'Add a complete reference to one statement through Taproot.', - remove_reference: 'Remove a statement reference by hash through Taproot.', - export_entity_json: 'Export canonical Wikibase-shaped entity JSON.', -}; - -const knowledgeReadTools = new Set([ - 'search_entities', - 'get_entity', - 'get_entities', - 'export_entity_json', -]); - -const statementSchema = { - type: 'object', - description: - 'Complete Wikibase statement with authored natural-language text for this exact revision.', - properties: { - text: authoredText( - 'Authored natural-language description of this exact statement revision.', - ), - }, - required: ['text'], - additionalProperties: true, -}; - -const claimsSchema = { - type: 'object', - description: 'Initial statements grouped by property ID.', - additionalProperties: { - type: 'array', - items: statementSchema, - }, -}; - -const knowledgeProperties: Record> = { - search_entities: { - query: string('Search text.'), - language: string('Optional language.'), - cursor: string('Opaque cursor.'), - limit: integer('Page size.'), - }, - get_entity: { entityId: string('Q or P entity ID.') }, - get_entities: { - entityIds: array(string('Q or P entity ID.'), 'Entity IDs to read.'), - }, - create_item: { - labels: object('Language label map.'), - descriptions: object('Language description map.'), - aliases: object('Language alias map.'), - claims: claimsSchema, - sitelinks: object('Initial sitelinks.'), - }, - create_property: { - datatype: string('Taproot entity datatype.'), - labels: object('Language label map.'), - descriptions: object('Language description map.'), - aliases: object('Language alias map.'), - }, - set_label: { - entityId: string('Entity ID.'), - language: string('BCP 47 language code.'), - value: string('Label.'), - expectedRevision: integer('Current revision.'), - }, - set_description: { - entityId: string('Entity ID.'), - language: string('BCP 47 language code.'), - value: string('Description.'), - expectedRevision: integer('Current revision.'), - }, - add_alias: { - entityId: string('Entity ID.'), - language: string('BCP 47 language code.'), - value: string('Alias.'), - expectedRevision: integer('Current revision.'), - }, - remove_alias: { - entityId: string('Entity ID.'), - language: string('BCP 47 language code.'), - ordinal: integer('Zero-based alias ordinal.'), - expectedRevision: integer('Current revision.'), - }, - add_sitelink: { - entityId: string('Item ID.'), - site: string('Site key.'), - sitelink: object('Wikibase sitelink.'), - expectedRevision: integer('Current revision.'), - }, - remove_sitelink: { - entityId: string('Item ID.'), - site: string('Site key.'), - expectedRevision: integer('Current revision.'), - }, - add_statement: { - entityId: string('Entity ID.'), - statement: statementSchema, - expectedRevision: integer('Current revision.'), - }, - replace_statement: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - statement: statementSchema, - expectedRevision: integer('Current revision.'), - }, - remove_statement: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - expectedRevision: integer('Current revision.'), - }, - set_statement_rank: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - rank: { type: 'string', enum: ['preferred', 'normal', 'deprecated'] }, - text: authoredText( - 'Explicit authored text for the ranked statement revision.', - ), - expectedRevision: integer('Current revision.'), - }, - add_qualifier: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - snak: object('Typed qualifier snak.'), - text: authoredText( - 'Explicit authored text for the qualified statement revision.', - ), - expectedRevision: integer('Current revision.'), - }, - remove_qualifier: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - property: string('Property ID.'), - ordinal: integer('Zero-based ordinal.'), - text: authoredText( - 'Explicit authored text for the statement revision after qualifier removal.', - ), - expectedRevision: integer('Current revision.'), - }, - add_reference: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - reference: object('Complete reference.'), - text: authoredText( - 'Explicit authored text for the referenced statement revision.', - ), - expectedRevision: integer('Current revision.'), - }, - remove_reference: { - entityId: string('Entity ID.'), - statementId: string('Statement ID.'), - hash: string('Reference hash.'), - text: authoredText( - 'Explicit authored text for the statement revision after reference removal.', - ), - expectedRevision: integer('Current revision.'), - }, - export_entity_json: { entityId: string('Entity ID.') }, -}; - -const required: Record = { - search_entities: ['query'], - get_entity: ['entityId'], - get_entities: ['entityIds'], - create_property: ['datatype'], - set_label: ['entityId', 'language', 'value', 'expectedRevision'], - set_description: ['entityId', 'language', 'value', 'expectedRevision'], - add_alias: ['entityId', 'language', 'value', 'expectedRevision'], - remove_alias: ['entityId', 'language', 'ordinal', 'expectedRevision'], - add_sitelink: ['entityId', 'site', 'sitelink', 'expectedRevision'], - remove_sitelink: ['entityId', 'site', 'expectedRevision'], - add_statement: ['entityId', 'statement', 'expectedRevision'], - replace_statement: [ - 'entityId', - 'statementId', - 'statement', - 'expectedRevision', - ], - remove_statement: ['entityId', 'statementId', 'expectedRevision'], - set_statement_rank: [ - 'entityId', - 'statementId', - 'rank', - 'text', - 'expectedRevision', - ], - add_qualifier: [ - 'entityId', - 'statementId', - 'snak', - 'text', - 'expectedRevision', - ], - remove_qualifier: [ - 'entityId', - 'statementId', - 'property', - 'ordinal', - 'text', - 'expectedRevision', - ], - add_reference: [ - 'entityId', - 'statementId', - 'reference', - 'text', - 'expectedRevision', - ], - remove_reference: [ - 'entityId', - 'statementId', - 'hash', - 'text', - 'expectedRevision', - ], - export_entity_json: ['entityId'], -}; - -const knowledgeTools = KNOWLEDGE_TOOL_NAMES.filter((name) => - knowledgeReadTools.has(name), -).map((name) => - tool( - name, - name - .split('_') - .map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`) - .join(' '), - knowledgeDescriptions[name] ?? name, - knowledgeReadTools.has(name) ? 'read' : 'knowledge-write', - knowledgeProperties[name] ?? {}, - required[name] ?? [], - ), -); - -export const workshopTools: readonly WorkshopToolDefinition[] = [ - ...taskTools, - ...memoryTools, - ...promptTools, - ...searchTools, - ...knowledgeTools, -] as const; diff --git a/src/migrations.ts b/src/migrations.ts index 98286e5..0255271 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -12,7 +12,20 @@ export interface WorkshopMigrationResult { applied: string[]; } +export interface WorkshopMigrationPlan { + namespace: '@gnolith/workshop'; + currentVersion: number; + targetVersion: number; + ready: boolean; + pending: readonly { + id: string; + checksum: string; + statements: readonly string[]; + }[]; +} + const WORKSHOP_MIGRATION_NAMESPACE = '@gnolith/workshop'; +export type WorkshopReadOnlyPersistence = Pick; export interface WorkshopNamespacedMigration { id: string; @@ -54,6 +67,52 @@ export class WorkshopMigrationError extends Error { } } +/** Read-only migration planning for an operator-owned shared DB capability. */ +export async function planWorkshopMigrations( + persistence: WorkshopPersistence, + ledgerApi: WorkshopMigrationLedgerApi, +): Promise { + const currentVersion = await installedVersion(persistence); + if (currentVersion > WORKSHOP_SCHEMA_VERSION) + throw new WorkshopMigrationError( + `Workshop schema version ${currentVersion} is newer than supported version ${WORKSHOP_SCHEMA_VERSION}`, + ); + const applied = await ledgerApi.readAppliedMigrations( + persistence, + WORKSHOP_MIGRATION_NAMESPACE, + ); + const byId = new Map(applied.map((entry) => [entry.id, entry])); + const pending: WorkshopMigrationPlan['pending'][number][] = []; + for (const migration of workshopMigrations) { + const statements = splitStatements(migration.sql); + const expectedChecksum = await ledgerApi.checksumMigration({ + id: migration.id, + statements, + }); + const recorded = byId.get(migration.id); + if (recorded && recorded.checksum !== expectedChecksum) + throw new WorkshopMigrationError( + `Checksum drift detected for ${WORKSHOP_MIGRATION_NAMESPACE}/${migration.id}`, + ); + if (!recorded) + pending.push({ + id: migration.id, + checksum: expectedChecksum, + statements, + }); + } + return { + namespace: WORKSHOP_MIGRATION_NAMESPACE, + currentVersion, + targetVersion: WORKSHOP_SCHEMA_VERSION, + ready: currentVersion === WORKSHOP_SCHEMA_VERSION && pending.length === 0, + pending, + }; +} + +/** Read-only alias used by installation diagnostics and backup validation. */ +export const inspectWorkshopMigrations = planWorkshopMigrations; + /** * Apply Workshop-owned schema migrations to any supported persistence adapter. * The host remains responsible for choosing the database and when migrations run. @@ -129,7 +188,7 @@ export async function applyWorkshopMigrations( } async function installedVersion( - persistence: WorkshopPersistence, + persistence: WorkshopReadOnlyPersistence, ): Promise { const schemaTable = await persistence .prepare( @@ -160,6 +219,21 @@ async function installedVersion( return row.version; } +/** + * Verify the exact Workshop-owned schema boundary produced by Workshop 0.4.2. + * This is read-only and deliberately does not inspect another package's tables. + */ +export async function assertWorkshopLegacyV042Schema( + persistence: WorkshopReadOnlyPersistence, +): Promise { + const version = await installedVersion(persistence); + if (version !== 6) + throw new WorkshopMigrationError( + `Workshop 0.4.2 legacy source must attest schema version 6; found ${version}`, + ); + await validateLegacySchema(persistence, version); +} + function packageVersionForSchema(version: number): string | undefined { switch (version) { case 1: @@ -172,6 +246,9 @@ function packageVersionForSchema(version: number): string | undefined { return '0.3.0'; case 6: return '0.4.0'; + case 7: + case 8: + return '0.5.0'; default: return undefined; } @@ -182,18 +259,18 @@ async function loadDiamondMigrationLedger(): Promise return await import('@gnolith/diamond'); } catch (cause) { throw new WorkshopMigrationError( - 'Applying Workshop migrations requires the @gnolith/diamond 0.4 migration ledger API', + 'Applying Workshop migrations requires the @gnolith/diamond 0.5 migration ledger API', { cause }, ); } } async function validateLegacySchema( - persistence: WorkshopPersistence, + persistence: WorkshopReadOnlyPersistence, installed: number, ): Promise { - if (installed === 6) { - await validateCanonicalSearchSchema(persistence); + if (installed >= 6) { + await validateCanonicalSearchSchema(persistence, installed); return; } const expected = legacySchema(installed); @@ -339,7 +416,8 @@ async function validateLegacySchema( } async function validateCanonicalSearchSchema( - persistence: WorkshopPersistence, + persistence: WorkshopReadOnlyPersistence, + installed: number, ): Promise { const tables = await persistence .prepare( @@ -347,6 +425,9 @@ async function validateCanonicalSearchSchema( ) .all<{ name: string; sql: string }>(); const expectedTables = [ + ...(installed >= 8 + ? ['workshop_bootstrap_receipts', 'workshop_credentials'] + : []), 'workshop_cursor_entries', 'workshop_cursor_snapshots', 'workshop_memories', @@ -356,12 +437,15 @@ async function validateCanonicalSearchSchema( 'workshop_prompt_revisions', 'workshop_prompts', 'workshop_schema', + ...(installed >= 7 + ? ['workshop_task_lease_events', 'workshop_task_lease_receipts'] + : []), 'workshop_task_revisions', 'workshop_tasks', ]; const actualTables = tables.results.map(({ name }) => name); if (JSON.stringify(actualTables) !== JSON.stringify(expectedTables)) - throw adoptionError(installedVersionNumber(), 'table set does not match'); + throw adoptionError(installed, 'table set does not match'); const requiredColumns: Record = { workshop_tasks: [ @@ -377,6 +461,14 @@ async function validateCanonicalSearchSchema( 'policy_revision', 'search_event_id', 'search_event_sequence', + ...(installed >= 7 + ? [ + 'lease_id', + 'lease_token_digest', + 'lease_owner_principal_id', + 'lease_expires_at', + ] + : []), ], workshop_memories: [ 'title', @@ -423,10 +515,7 @@ async function validateCanonicalSearchSchema( .all(); const names = new Set(columns.results.map(({ name }) => name)); if (required.some((name) => !names.has(name))) - throw adoptionError( - installedVersionNumber(), - `${table} columns do not match`, - ); + throw adoptionError(installed, `${table} columns do not match`); } const indexes = await persistence @@ -435,6 +524,7 @@ async function validateCanonicalSearchSchema( ) .all<{ name: string }>(); const requiredIndexes = [ + ...(installed >= 8 ? ['workshop_credentials_principal_idx'] : []), 'workshop_cursor_snapshots_expiry_idx', 'workshop_memories_authorization_idx', 'workshop_memories_idempotency_idx', @@ -445,8 +535,10 @@ async function validateCanonicalSearchSchema( 'workshop_prompt_revisions_history_idx', 'workshop_prompts_authorization_idx', 'workshop_prompts_list_idx', + ...(installed >= 7 ? ['workshop_task_lease_events_task_idx'] : []), 'workshop_task_revisions_history_idx', 'workshop_tasks_authorization_idx', + ...(installed >= 7 ? ['workshop_tasks_lease_expiry_idx'] : []), 'workshop_tasks_role_idx', 'workshop_tasks_state_idx', 'workshop_tasks_updated_idx', @@ -455,11 +547,7 @@ async function validateCanonicalSearchSchema( JSON.stringify(indexes.results.map(({ name }) => name)) !== JSON.stringify(requiredIndexes) ) - throw adoptionError(installedVersionNumber(), 'index set does not match'); -} - -function installedVersionNumber(): number { - return 6; + throw adoptionError(installed, 'index set does not match'); } interface ColumnInfo { diff --git a/src/migrations/manifest.ts b/src/migrations/manifest.ts index 8be179e..b651a67 100644 --- a/src/migrations/manifest.ts +++ b/src/migrations/manifest.ts @@ -1,10 +1,10 @@ export interface WorkshopMigration { id: string; - checksum: string; + checksum: `sha256:${string}`; sql: string; } -export const WORKSHOP_SCHEMA_VERSION = 6; +export const WORKSHOP_SCHEMA_VERSION = 8; export const workshopMigrations: readonly WorkshopMigration[] = [ { @@ -428,6 +428,98 @@ UPDATE workshop_schema SET version = 6, package_version = '0.4.0', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE singleton = 1;`, + }, + { + id: '0007_task_leases', + checksum: + 'sha256:c617200d9c0b0cc629e738937b7a08ed2e1a29a6be3d99a1835d6bb2b47a1b69', + sql: `ALTER TABLE workshop_tasks ADD COLUMN lease_id TEXT; +ALTER TABLE workshop_tasks ADD COLUMN lease_token_digest TEXT; +ALTER TABLE workshop_tasks ADD COLUMN lease_owner_principal_id TEXT; +ALTER TABLE workshop_tasks ADD COLUMN lease_expires_at TEXT; + +CREATE TABLE workshop_task_lease_receipts ( + installation_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + task_id TEXT NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ('release', 'complete')), + expected_revision INTEGER NOT NULL CHECK (expected_revision >= 1), + resulting_revision INTEGER NOT NULL CHECK (resulting_revision >= 1), + receipt_json TEXT NOT NULL CHECK (json_valid(receipt_json)), + created_at TEXT NOT NULL, + PRIMARY KEY (installation_id, principal_id, idempotency_key) +); + +CREATE TABLE workshop_task_lease_events ( + event_id TEXT PRIMARY KEY, + installation_id TEXT NOT NULL, + task_id TEXT NOT NULL, + lease_id TEXT NOT NULL, + actor_principal_id TEXT NOT NULL, + event_kind TEXT NOT NULL CHECK ( + event_kind IN ('claimed', 'reclaimed_expired', 'released', 'completed') + ), + task_revision INTEGER NOT NULL CHECK (task_revision >= 1), + lease_expires_at TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX workshop_tasks_lease_expiry_idx + ON workshop_tasks (installation_id, lease_expires_at, id); +CREATE INDEX workshop_task_lease_events_task_idx + ON workshop_task_lease_events (installation_id, task_id, created_at); + +UPDATE workshop_schema +SET version = 7, + package_version = '0.5.0', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE singleton = 1;`, + }, + { + id: '0008_bootstrap_credentials', + checksum: + 'sha256:319aacd693f45d8b906fd25932f75253629ebe58fc0037f0e341be7baafd31a3', + sql: `CREATE TABLE workshop_credentials ( + installation_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + profile TEXT NOT NULL CHECK (profile = 'local-bearer-v1'), + token_digest TEXT NOT NULL CHECK ( + length(token_digest) = 64 AND token_digest NOT GLOB '*[^0-9a-f]*' + ), + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0 CHECK (disabled IN (0, 1)), + created_at TEXT NOT NULL, + PRIMARY KEY (installation_id, credential_id), + UNIQUE (installation_id, token_digest) +); + +CREATE INDEX workshop_credentials_principal_idx + ON workshop_credentials (installation_id, principal_id, workspace_id); + +CREATE TABLE workshop_bootstrap_receipts ( + installation_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + credential_id TEXT NOT NULL, + principal_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + grant_manifest_digest TEXT NOT NULL CHECK ( + length(grant_manifest_digest) = 64 + AND grant_manifest_digest NOT GLOB '*[^0-9a-f]*' + ), + owner_receipt_json TEXT NOT NULL CHECK (json_valid(owner_receipt_json)), + created_at TEXT NOT NULL, + PRIMARY KEY (installation_id, idempotency_key), + FOREIGN KEY (installation_id, credential_id) + REFERENCES workshop_credentials(installation_id, credential_id) +); + +UPDATE workshop_schema +SET version = 8, + package_version = '0.5.0', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE singleton = 1;`, }, ] as const; @@ -435,6 +527,8 @@ WHERE singleton = 1;`, export const expectedWorkshopTables = [ 'workshop_cursor_entries', 'workshop_cursor_snapshots', + 'workshop_bootstrap_receipts', + 'workshop_credentials', 'workshop_memory_revisions', 'workshop_memories', 'workshop_onboarding_runs', @@ -443,11 +537,14 @@ export const expectedWorkshopTables = [ 'workshop_prompts', 'workshop_schema', 'workshop_task_revisions', + 'workshop_task_lease_events', + 'workshop_task_lease_receipts', 'workshop_tasks', ] as const; export const expectedWorkshopIndexes = [ 'workshop_cursor_snapshots_expiry_idx', + 'workshop_credentials_principal_idx', 'workshop_memory_revisions_history_idx', 'workshop_memories_authorization_idx', 'workshop_memories_idempotency_idx', @@ -458,6 +555,8 @@ export const expectedWorkshopIndexes = [ 'workshop_prompts_authorization_idx', 'workshop_prompts_list_idx', 'workshop_task_revisions_history_idx', + 'workshop_task_lease_events_task_idx', + 'workshop_tasks_lease_expiry_idx', 'workshop_tasks_role_idx', 'workshop_tasks_authorization_idx', 'workshop_tasks_state_idx', diff --git a/src/operations.ts b/src/operations.ts new file mode 100644 index 0000000..e678954 --- /dev/null +++ b/src/operations.ts @@ -0,0 +1,360 @@ +import Ajv, { type ValidateFunction } from 'ajv'; +import { + CATALOG_DIGEST, + OPERATION_SCHEMA_VERSION, + operationCatalog, + type OperationInput, + type OperationName, + type OperationOutput, +} from './generated/operations.js'; +import { WorkshopError, normalizeWorkshopError } from './protocol/errors.js'; +import { + assertSearchResultIdentity, + type WorkshopSearchResultIdentity, +} from './protocol/catalog.js'; +import type { AuthenticatedPrincipal, WorkshopCapability } from './protocol.js'; + +const IDENTITY_ARGUMENTS = new Set([ + 'installationId', + 'principalId', + 'activeWorkspaceId', + 'workspaceIds', + 'capabilities', + 'authorizationRevision', + 'credentialId', + 'actorPrincipalId', +]); + +export interface OperationCallContext { + principal: AuthenticatedPrincipal; + requestId: string; + signal: AbortSignal; +} + +export interface WorkshopStatusSnapshot { + baseIri: string; + migrations: { + namespace: '@gnolith/workshop'; + version: number; + ready: boolean; + }; + compatibility: { diamond: boolean; taproot: boolean }; + canonicalReady: boolean; + authorizationReady: boolean; + lexicalReady: boolean; + semantic: { + state: 'ready' | 'degraded' | 'unconfigured'; + configured: boolean; + }; + producers: { + ready: boolean; + fingerprint: string; + kinds: readonly ['task', 'memory', 'prompt']; + }; + blobReady: boolean; +} + +export interface TaprootOperationPort { + call( + name: OperationName, + input: Readonly>, + context: OperationCallContext, + ): Promise; + createScopedSparqlView(context: OperationCallContext): Promise; + recheckPrincipal( + principal: AuthenticatedPrincipal, + signal: AbortSignal, + ): Promise; + status( + principal: AuthenticatedPrincipal, + signal: AbortSignal, + ): Promise; +} + +export interface DiamondSparqlPort { + validate(query: string, options: { signal: AbortSignal }): Promise; + query( + scopedView: unknown, + query: string, + options: { + dryRun: boolean; + limit?: number; + signal: AbortSignal; + }, + ): Promise; + close?(): void | Promise; +} + +export interface WorkshopDomainOperationPort { + call( + name: OperationName, + input: Readonly>, + context: OperationCallContext, + ): Promise; +} + +export interface OperationObservation { + operation: OperationName; + operationVersion: 1; + requestId: string; + principalId: string; + outcome: 'success' | 'error'; + durationMs: number; + errorCode?: string; +} + +export interface OperationRuntimeOptions { + version: string; + taproot: TaprootOperationPort; + diamond: DiamondSparqlPort; + workshop: WorkshopDomainOperationPort; + observe?: (event: OperationObservation) => void | Promise; +} + +export interface OperationRuntime { + readonly catalog: typeof operationCatalog; + readonly catalogDigest: typeof CATALOG_DIGEST; + call( + name: Name, + input: OperationInput, + context: OperationCallContext, + ): Promise>; +} + +const TAPROOT_NAMES = new Set([ + 'search', + 'search_hydrate', + 'search_admin', + 'get_entity', + 'get_entities', + 'create_item', + 'create_property', + 'mutate_entity', + 'entity_history', + 'entity_revision', + 'statement_history', + 'statement_revision', + 'export_entity_json', + 'create_resource', + 'get_resource', + 'update_resource', + 'delete_resource', + 'hydrate_resource', + 'resource_history', + 'resource_revision', + 'create_annotation', + 'get_annotation', + 'update_annotation', + 'delete_annotation', + 'annotation_history', + 'annotation_revision', +]); +const WORKSHOP_NAMES = new Set([ + 'list_tasks', + 'search_tasks', + 'get_task', + 'get_task_packet', + 'create_task', + 'update_task', + 'archive_task', + 'claim_task', + 'release_task', + 'complete_task', + 'task_history', + 'list_memories', + 'get_memory', + 'upsert_memory', + 'delete_memory', + 'memory_history', + 'list_prompts', + 'get_prompt', + 'create_prompt', + 'update_prompt', + 'delete_prompt', + 'prompt_history', +]); + +export function createOperationRuntime( + options: OperationRuntimeOptions, +): OperationRuntime { + const validators = validatorsForCatalog(); + const definitions = new Map( + operationCatalog.map((definition) => [definition.name, definition]), + ); + return { + catalog: operationCatalog, + catalogDigest: CATALOG_DIGEST, + async call( + name: Name, + input: OperationInput, + context: OperationCallContext, + ): Promise> { + const started = performance.now(); + let live: AuthenticatedPrincipal | undefined; + try { + const definition = definitions.get(name); + if (!definition) + throw new WorkshopError('not_found', 'Operation not found', 404); + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw invalid('Operation input must be an object'); + rejectForgedIdentity(input); + const validate = validators.get(name); + if (!validate?.(input)) + throw new WorkshopError( + 'validation_failed', + 'Operation input does not match its schema', + 400, + { operation: name, errors: boundedAjvErrors(validate?.errors) }, + ); + live = await options.taproot.recheckPrincipal( + context.principal, + context.signal, + ); + requireExactCapability(live.capabilities, definition.capability); + const liveContext = { ...context, principal: live }; + let value: unknown; + if (name === 'gnolith_status') { + const status = await options.taproot.status(live, context.signal); + value = { + installationId: live.installationId, + baseIri: status.baseIri, + principalId: live.principalId, + credentialId: live.credentialId, + activeWorkspaceId: live.activeWorkspaceId, + capabilities: [...live.capabilities], + authorizationRevision: live.authorizationRevision, + migrationReadiness: status.migrations, + compatibility: status.compatibility, + canonicalReady: status.canonicalReady, + authorizationReady: status.authorizationReady, + lexicalReady: status.lexicalReady, + semanticState: status.semantic, + producers: status.producers, + blobReady: status.blobReady, + versions: { + server: options.version, + operationSchema: OPERATION_SCHEMA_VERSION, + }, + operationCatalogDigest: CATALOG_DIGEST, + }; + } else if (name === 'validate_sparql') { + value = await options.diamond.validate(String(input.query), { + signal: context.signal, + }); + } else if (name === 'query_sparql') { + const scopedView = + await options.taproot.createScopedSparqlView(liveContext); + value = await options.diamond.query(scopedView, String(input.query), { + dryRun: input.dryRun === true, + ...(typeof input.limit === 'number' ? { limit: input.limit } : {}), + signal: context.signal, + }); + } else if (TAPROOT_NAMES.has(name)) { + value = await options.taproot.call(name, input, liveContext); + } else if (WORKSHOP_NAMES.has(name)) { + value = await options.workshop.call(name, input, liveContext); + } else { + throw new WorkshopError( + 'internal_error', + 'Operation is not bound', + 500, + ); + } + if (name === 'search') validateSearchPage(value); + const outputValidator = validators.get(`${name}:output`); + if (!outputValidator?.(value)) + throw new WorkshopError( + 'internal_error', + 'Operation output does not match its schema', + 500, + ); + await observe(options, { + operation: name, + operationVersion: 1, + requestId: context.requestId, + principalId: live.principalId, + outcome: 'success', + durationMs: performance.now() - started, + }); + return value as OperationOutput; + } catch (error) { + const normalized = normalizeWorkshopError(error); + await observe(options, { + operation: name, + operationVersion: 1, + requestId: context.requestId, + principalId: live?.principalId ?? context.principal.principalId, + outcome: 'error', + durationMs: performance.now() - started, + errorCode: normalized.code, + }); + throw normalized; + } + }, + }; +} + +function validatorsForCatalog(): Map { + const AjvConstructor = Ajv as unknown as new (options: { + allErrors: boolean; + strict: boolean; + }) => { + compile(schema: unknown): ValidateFunction; + }; + const ajv = new AjvConstructor({ allErrors: true, strict: true }); + const validators = new Map(); + for (const operation of operationCatalog) { + validators.set(operation.name, ajv.compile(operation.inputSchema)); + validators.set( + `${operation.name}:output`, + ajv.compile(operation.outputSchema), + ); + } + return validators; +} + +function rejectForgedIdentity(input: Readonly>): void { + for (const key of Object.keys(input)) { + if (IDENTITY_ARGUMENTS.has(key)) + throw invalid('Identity and authorization are transport-owned'); + } +} + +function requireExactCapability( + capabilities: readonly string[], + required: WorkshopCapability, +): void { + if (!capabilities.includes(required)) + throw new WorkshopError('forbidden', 'Authorization denied', 403); +} + +function validateSearchPage(value: unknown): void { + if (!value || typeof value !== 'object') return; + const items = (value as { items?: unknown }).items; + if (!Array.isArray(items)) return; + for (const item of items) + assertSearchResultIdentity(item as WorkshopSearchResultIdentity); +} + +function boundedAjvErrors(errors: ValidateFunction['errors']): unknown[] { + return (errors ?? []).slice(0, 20).map((error) => ({ + instancePath: error.instancePath.slice(0, 256), + keyword: error.keyword.slice(0, 64), + message: error.message?.slice(0, 256), + })); +} + +function invalid(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} + +async function observe( + options: OperationRuntimeOptions, + event: OperationObservation, +): Promise { + try { + await options.observe?.(event); + } catch { + // Observability never changes operation outcomes. + } +} diff --git a/src/producers.ts b/src/producers.ts new file mode 100644 index 0000000..392ab2f --- /dev/null +++ b/src/producers.ts @@ -0,0 +1,111 @@ +import { WorkshopError } from './protocol/errors.js'; + +export const WORKSHOP_PRODUCER_KINDS = ['task', 'memory', 'prompt'] as const; +export type WorkshopProducerKind = (typeof WORKSHOP_PRODUCER_KINDS)[number]; + +export interface PublicCanonicalProducerHandle { + readonly owner: '@gnolith/workshop'; + readonly installationId: string; + readonly connectionId: string; + readonly registrationFingerprint: string; + readonly handle: unknown; +} + +const VERIFIED_PRODUCERS: unique symbol = Symbol('verified producers'); + +export interface VerifiedWorkshopProducerBinding { + readonly [VERIFIED_PRODUCERS]: true; + readonly installationId: string; + readonly connectionId: string; + readonly registrationFingerprint: string; + readonly handles: Readonly>; +} + +export interface WorkshopProducerBindingExpectation { + installationId: string; + connectionId: string; + registrationFingerprint: string; +} + +const verifiedBindings = new WeakSet(); + +export function bindWorkshopProducerHandles( + expectation: WorkshopProducerBindingExpectation, + mappings: Readonly< + Record + >, +): VerifiedWorkshopProducerBinding { + validateIdentifier(expectation.installationId, 'installationId'); + validateIdentifier(expectation.connectionId, 'connectionId'); + validateFingerprint(expectation.registrationFingerprint); + if ( + Object.keys(mappings).sort().join(',') !== + [...WORKSHOP_PRODUCER_KINDS].sort().join(',') + ) + throw invalid('Exactly three canonical producer mappings are required'); + const byKind = new Map(); + const opaqueHandles = new Set(); + for (const publicKind of WORKSHOP_PRODUCER_KINDS) { + const producer = mappings[publicKind]; + if ( + producer.owner !== '@gnolith/workshop' || + producer.installationId !== expectation.installationId || + producer.connectionId !== expectation.connectionId || + producer.registrationFingerprint !== + expectation.registrationFingerprint || + producer.handle === null || + producer.handle === undefined || + opaqueHandles.has(producer.handle) + ) + throw invalid('Canonical producer handle binding is invalid'); + opaqueHandles.add(producer.handle); + byKind.set(publicKind, producer.handle); + } + const binding = Object.freeze({ + [VERIFIED_PRODUCERS]: true as const, + installationId: expectation.installationId, + connectionId: expectation.connectionId, + registrationFingerprint: expectation.registrationFingerprint, + handles: Object.freeze({ + task: byKind.get('task')!, + memory: byKind.get('memory')!, + prompt: byKind.get('prompt')!, + }), + }); + verifiedBindings.add(binding); + return binding; +} + +export function assertVerifiedWorkshopProducerBinding( + binding: VerifiedWorkshopProducerBinding, + expectation: WorkshopProducerBindingExpectation, +): void { + if ( + !verifiedBindings.has(binding) || + binding.installationId !== expectation.installationId || + binding.connectionId !== expectation.connectionId || + binding.registrationFingerprint !== expectation.registrationFingerprint || + Object.keys(binding.handles).sort().join(',') !== + [...WORKSHOP_PRODUCER_KINDS].sort().join(',') + ) + throw invalid('Canonical producer binding is not verified'); +} + +function validateIdentifier(value: unknown, field: string): void { + if ( + typeof value !== 'string' || + !value || + value.length > 256 || + value.includes('\u0000') + ) + throw invalid(`${field} is invalid`); +} + +function validateFingerprint(value: unknown): void { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) + throw invalid('registrationFingerprint is invalid'); +} + +function invalid(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} diff --git a/src/protocol.ts b/src/protocol.ts index 45321de..0dc7afd 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -4,20 +4,34 @@ export * from './protocol/memories.js'; export * from './protocol/prompts.js'; export * from './protocol/knowledge.js'; export * from './protocol/client.js'; +export * from './protocol/catalog.js'; export * from './protocol/authorization.js'; import type { AuthorizationContext } from './protocol/authorization.js'; export type WorkshopCapability = | 'read' - | 'task-write' - | 'knowledge-write' - | 'memory-write' - | 'prompt-write' + | 'task:write' + | 'knowledge:write' + | 'knowledge:policy' + | 'memory:write' + | 'prompt:write' | 'admin' | 'search:admin'; -/** @deprecated Name retained for transport resolver compatibility. */ -export type WorkshopPrincipal = AuthorizationContext; +export interface AuthenticatedPrincipal { + installationId: string; + principalId: string; + activeWorkspaceId: string | null; + capabilities: readonly string[]; + authorizationRevision: number; + credentialId: string; +} + +/** Internal authorization context after the live server-side membership fence. */ +export interface WorkshopPrincipal extends AuthorizationContext { + /** Present on transport-authenticated principals; legacy process-local callers omit it. */ + credentialId?: string; +} export function hasWorkshopCapability( capabilities: readonly string[], diff --git a/src/protocol/authorization.ts b/src/protocol/authorization.ts index 4e8b864..a49d712 100644 --- a/src/protocol/authorization.ts +++ b/src/protocol/authorization.ts @@ -1,4 +1,4 @@ -/** Host-authenticated identity. This is the exact Taproot 0.3 authorization shape. */ +/** Host-authenticated identity supplied through Taproot's public authorization port. */ export interface AuthorizationContext { installationId: string; principalId: string; diff --git a/src/protocol/catalog.ts b/src/protocol/catalog.ts new file mode 100644 index 0000000..a5acd1b --- /dev/null +++ b/src/protocol/catalog.ts @@ -0,0 +1,41 @@ +export { + CATALOG_DIGEST, + OPERATION_ROUTE_PREFIX, + OPERATION_SCHEMA_VERSION, + legacyOperationNames, + operationCatalog, + type OperationInput, + type OperationName, + type OperationOutput, +} from '../generated/operations.js'; + +export const WORKSHOP_SEARCH_KINDS = [ + 'statement', + 'item', + 'task', + 'memory', + 'prompt', + 'resource', + 'annotation', +] as const; + +export type WorkshopSearchKind = (typeof WORKSHOP_SEARCH_KINDS)[number]; +export type WorkshopEntityType = 'item' | 'property'; + +export interface WorkshopSearchResultIdentity { + kind: WorkshopSearchKind; + id: string; + revision: number; + entityType?: WorkshopEntityType; +} + +export function assertSearchResultIdentity( + value: WorkshopSearchResultIdentity, +): void { + if (value.kind === 'item') { + if (value.entityType !== 'item' && value.entityType !== 'property') + throw new TypeError('item search results require entityType'); + } else if (value.entityType !== undefined) { + throw new TypeError('entityType is only valid for item search results'); + } +} diff --git a/src/protocol/client.ts b/src/protocol/client.ts index 661c03a..243b44a 100644 --- a/src/protocol/client.ts +++ b/src/protocol/client.ts @@ -1,319 +1,116 @@ +import { + OPERATION_ROUTE_PREFIX, + type OperationInput, + type OperationName, + type OperationOutput, +} from '../generated/operations.js'; import { WorkshopError } from './errors.js'; -import type { - MemoryFilters, - MemoryPage, - UpsertMemoryInput, -} from './memories.js'; -import type { - CreateTaskInput, - Task, - TaskFilters, - TaskPacket, - TaskPage, - RevisionHistoryOptions, - UpdateTaskInput, -} from './tasks.js'; -import type { Memory } from './memories.js'; -import type { MemoryRevision } from './memories.js'; -import type { TaskRevision } from './tasks.js'; -import type { - CreatePromptInput, - Prompt, - PromptFilters, - PromptPage, - PromptRevision, - UpdatePromptInput, -} from './prompts.js'; -import type { SearchPage, SearchRequest } from '@gnolith/taproot'; export interface WorkshopClientOptions { - baseUrl?: string; + baseOrigin?: string; fetch?: typeof globalThis.fetch; - token?: () => string | null | Promise; + accessToken?: () => string | null | Promise; } -export interface RequestOptions { +export interface WorkshopCallOptions { signal?: AbortSignal; } -export interface HistoryRequestOptions - extends RequestOptions, RevisionHistoryOptions {} - -interface WorkshopRequestInit extends Omit { - signal?: AbortSignal | undefined; -} - export interface WorkshopClient { - tasks: { - list(filters?: TaskFilters, options?: RequestOptions): Promise; - search(filters?: TaskFilters, options?: RequestOptions): Promise; - get(id: string, options?: RequestOptions): Promise; - getPacket(id: string, options?: RequestOptions): Promise; - create(input: CreateTaskInput, options?: RequestOptions): Promise; - update( - id: string, - input: UpdateTaskInput, - options?: RequestOptions, - ): Promise; - archive( - id: string, - expectedRevision: number | string, - options?: RequestOptions, - ): Promise; - claim(id: string, options?: RequestOptions): Promise; - complete( - id: string, - result: string, - options?: RequestOptions, - ): Promise; - history( - id: string, - options?: HistoryRequestOptions, - ): Promise; - }; - memories: { - list( - filters?: MemoryFilters, - options?: RequestOptions, - ): Promise; - get(slug: string, options?: RequestOptions): Promise; - upsert( - slug: string, - input: UpsertMemoryInput, - options?: RequestOptions, - ): Promise; - delete?: ( - slug: string, - expectedRevision: number, - options?: RequestOptions, - ) => Promise; - history( - slug: string, - options?: HistoryRequestOptions, - ): Promise; - }; - prompts?: { - list( - filters?: PromptFilters, - options?: RequestOptions, - ): Promise; - get(id: string, options?: RequestOptions): Promise; - create(input: CreatePromptInput, options?: RequestOptions): Promise; - update( - id: string, - input: UpdatePromptInput, - options?: RequestOptions, - ): Promise; - delete( - id: string, - expectedRevision: number, - options?: RequestOptions, - ): Promise; - history( - id: string, - options?: HistoryRequestOptions, - ): Promise; - }; - search?: ( - request: SearchRequest, - options?: RequestOptions, - ) => Promise; - searchAdmin?: ( - input?: Readonly>, - options?: RequestOptions, - ) => Promise; + call( + name: Name, + input: OperationInput, + options?: WorkshopCallOptions, + ): Promise>; } export function createWorkshopClient( options: WorkshopClientOptions = {}, ): WorkshopClient { const transport = options.fetch ?? globalThis.fetch; - const base = (options.baseUrl ?? '').replace(/\/$/u, ''); - - const request = async ( - path: string, - init: WorkshopRequestInit = {}, - ): Promise => { - const token = await options.token?.(); - const { signal, ...requestInit } = init; - const response = await transport(`${base}${path}`, { - ...requestInit, - ...(signal ? { signal } : {}), - headers: { - accept: 'application/json', - ...(init.body ? { 'content-type': 'application/json' } : {}), - ...(token ? { authorization: `Bearer ${token}` } : {}), - ...init.headers, - }, - }); - const body = (await response.json().catch(() => null)) as - | { - error?: { - code?: string; - message?: string; - details?: Record; - }; - } - | T - | null; - if (!response.ok) { - const error = - body && typeof body === 'object' && 'error' in body - ? body.error - : undefined; - throw new WorkshopError( - (error?.code as ConstructorParameters[0]) ?? - 'internal_error', - error?.message ?? `Workshop request failed (${response.status})`, - response.status, - error?.details, - ); - } - return body as T; - }; - - const query = (filters: object | undefined) => { - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(filters ?? {})) { - if (value !== undefined) params.set(key, String(value)); - } - const suffix = params.size ? `?${params}` : ''; - return suffix; - }; + const origin = normalizeOrigin(options.baseOrigin ?? ''); return { - tasks: { - list: (filters, requestOptions) => - request(`/api/workshop/tasks${query(filters)}`, { - signal: requestOptions?.signal, - }), - search: (filters, requestOptions) => - request(`/api/workshop/tasks${query(filters)}`, { - signal: requestOptions?.signal, - }), - get: (id, requestOptions) => - request(`/api/workshop/tasks/${encodeURIComponent(id)}`, { - signal: requestOptions?.signal, - }), - getPacket: (id, requestOptions) => - request(`/api/workshop/tasks/${encodeURIComponent(id)}/packet`, { - signal: requestOptions?.signal, - }), - create: (input, requestOptions) => - request('/api/workshop/tasks', { - method: 'POST', - body: JSON.stringify(input), - signal: requestOptions?.signal, - }), - update: (id, input, requestOptions) => - request(`/api/workshop/tasks/${encodeURIComponent(id)}`, { - method: 'PATCH', - body: JSON.stringify(input), - signal: requestOptions?.signal, - }), - archive: (id, expectedRevision, requestOptions) => - request(`/api/workshop/tasks/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: - typeof expectedRevision === 'number' - ? { 'x-workshop-revision': String(expectedRevision) } - : { 'if-match': expectedRevision }, - signal: requestOptions?.signal, - }), - claim: (id, requestOptions) => - request(`/api/workshop/tasks/${encodeURIComponent(id)}/claim`, { - method: 'POST', - signal: requestOptions?.signal, - }), - complete: (id, result, requestOptions) => - request(`/api/workshop/tasks/${encodeURIComponent(id)}/complete`, { - method: 'POST', - body: JSON.stringify({ result }), - signal: requestOptions?.signal, - }), - history: (id, requestOptions) => - request( - `/api/workshop/tasks/${encodeURIComponent(id)}/history${query({ limit: requestOptions?.limit })}`, - { - signal: requestOptions?.signal, - }, - ), - }, - memories: { - list: (filters, requestOptions) => - request(`/api/workshop/memories${query(filters)}`, { - signal: requestOptions?.signal, - }), - get: (slug, requestOptions) => - request(`/api/workshop/memories/${encodeURIComponent(slug)}`, { - signal: requestOptions?.signal, - }), - upsert: (slug, input, requestOptions) => - request(`/api/workshop/memories/${encodeURIComponent(slug)}`, { - method: 'PUT', - body: JSON.stringify(input), - signal: requestOptions?.signal, - }), - delete: (slug, expectedRevision, requestOptions) => - request(`/api/workshop/memories/${encodeURIComponent(slug)}`, { - method: 'DELETE', - headers: { 'x-workshop-revision': String(expectedRevision) }, - signal: requestOptions?.signal, - }), - history: (slug, requestOptions) => - request( - `/api/workshop/memories/${encodeURIComponent(slug)}/history${query({ limit: requestOptions?.limit })}`, - { - signal: requestOptions?.signal, - }, - ), - }, - prompts: { - list: (filters, requestOptions) => - request(`/api/workshop/prompts${query(filters)}`, { - signal: requestOptions?.signal, - }), - get: (id, requestOptions) => - request(`/api/workshop/prompts/${encodeURIComponent(id)}`, { - signal: requestOptions?.signal, - }), - create: (input, requestOptions) => - request('/api/workshop/prompts', { - method: 'POST', - body: JSON.stringify(input), - signal: requestOptions?.signal, - }), - update: (id, input, requestOptions) => - request(`/api/workshop/prompts/${encodeURIComponent(id)}`, { - method: 'PATCH', - body: JSON.stringify(input), - signal: requestOptions?.signal, - }), - delete: (id, expectedRevision, requestOptions) => - request(`/api/workshop/prompts/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: { 'x-workshop-revision': String(expectedRevision) }, - signal: requestOptions?.signal, - }), - history: (id, requestOptions) => - request( - `/api/workshop/prompts/${encodeURIComponent(id)}/history${query({ limit: requestOptions?.limit })}`, + async call( + name: Name, + input: OperationInput, + callOptions?: WorkshopCallOptions, + ): Promise> { + const token = await options.accessToken?.(); + if (token !== null && token !== undefined && !validToken(token)) + throw new WorkshopError( + 'validation_failed', + 'Access token is invalid', + 400, + ); + const init: RequestInit = { + method: 'POST', + body: JSON.stringify(input), + headers: { + accept: 'application/json', + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + ...(callOptions?.signal ? { signal: callOptions.signal } : {}), + }; + const response = await transport( + `${origin}${OPERATION_ROUTE_PREFIX}/${name}`, + init, + ); + const body = (await response.json().catch(() => null)) as + | { + error?: { + code?: string; + message?: string; + requestId?: string; + details?: Record; + }; + } + | OperationOutput + | null; + if (!response.ok) { + const error = + body && typeof body === 'object' && 'error' in body + ? body.error + : undefined; + throw new WorkshopError( + (error?.code as ConstructorParameters[0]) ?? + 'internal_error', + error?.message ?? `Workshop request failed (${response.status})`, + response.status, { - signal: requestOptions?.signal, + ...(error?.requestId ? { requestId: error.requestId } : {}), + ...(error?.details ?? {}), }, - ), + ); + } + return body as OperationOutput; }, - search: (searchRequest, requestOptions) => - request('/api/workshop/search', { - method: 'POST', - body: JSON.stringify(searchRequest), - signal: requestOptions?.signal, - }), - searchAdmin: (input = {}, requestOptions) => - request('/api/workshop/search/admin', { - method: Object.keys(input).length ? 'POST' : 'GET', - ...(Object.keys(input).length ? { body: JSON.stringify(input) } : {}), - signal: requestOptions?.signal, - }), }; } + +function normalizeOrigin(value: string): string { + if (!value) return ''; + const url = new URL(value); + if ( + url.username || + url.password || + url.search || + url.hash || + (url.pathname !== '' && url.pathname !== '/') + ) + throw new WorkshopError( + 'validation_failed', + 'baseOrigin must be an origin without credentials, query, or path', + 400, + ); + return url.origin; +} + +function validToken(value: string): boolean { + return ( + value.length >= 1 && + value.length <= 16_384 && + !/[\u0000-\u0020\u007f]/u.test(value) + ); +} diff --git a/src/resource-blobs.ts b/src/resource-blobs.ts new file mode 100644 index 0000000..68d3af2 --- /dev/null +++ b/src/resource-blobs.ts @@ -0,0 +1,488 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, createWriteStream, type WriteStream } from 'node:fs'; +import { + lstat, + mkdir, + readFile, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; +import { Readable } from 'node:stream'; +import type { + ResourceBlobBackupPort, + ResourceBlobDescriptor, + ResourceBlobRestorePort, +} from '@gnolith/taproot/backup'; +import { WorkshopError } from './protocol/errors.js'; + +const MANIFEST_FORMAT = 'workshop-resource-blob-store-v1'; +const MANIFEST_FILE = 'manifest.json'; +const MAX_MANIFEST_BYTES = 2 * 1024 * 1024; + +export interface LocalFilesystemResourceBlobOptionsV1 { + root: string; + maxBlobBytes?: number; + maxEntries?: number; +} + +export interface LocalFilesystemResourceBlobPortsV1 { + readonly backup: ResourceBlobBackupPort; + readonly restore: ResourceBlobRestorePort; +} + +interface Stage { + descriptor: ResourceBlobDescriptor; + path: string; + verified: boolean; +} + +/** + * Creates both Taproot blob ports over one Workshop-managed local store. + * Callers never derive, receive, or supply an on-disk object path. + */ +export async function createLocalFilesystemResourceBlobPortsV1( + options: LocalFilesystemResourceBlobOptionsV1, +): Promise { + const root = resolveRoot(options.root); + const maxBlobBytes = boundedInteger( + options.maxBlobBytes ?? 256 * 1024 * 1024, + 1, + 1024 * 1024 * 1024, + 'maxBlobBytes', + ); + const maxEntries = boundedInteger( + options.maxEntries ?? 100_000, + 1, + 1_000_000, + 'maxEntries', + ); + await secureDirectory(root); + const objects = join(root, 'objects'); + const staging = join(root, 'staging'); + await secureDirectory(objects); + await secureDirectory(staging); + const stages = new Map(); + + const readManifest = async (): Promise< + Map + > => { + const path = join(root, MANIFEST_FILE); + let bytes: Buffer; + try { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink()) throw unsafeStore(); + if (stat.size > MAX_MANIFEST_BYTES) throw invalidManifest(); + bytes = await readFile(path); + } catch (error) { + if (isMissing(error)) return new Map(); + throw error; + } + let value: unknown; + try { + value = JSON.parse(bytes.toString('utf8')); + } catch { + throw invalidManifest(); + } + if (!isRecord(value) || value.format !== MANIFEST_FORMAT) + throw invalidManifest(); + if (!Array.isArray(value.items) || value.items.length > maxEntries) + throw invalidManifest(); + const entries = new Map(); + for (const item of value.items) { + const descriptor = normalizeDescriptor(item, maxBlobBytes); + if (entries.has(descriptor.referenceId)) throw invalidManifest(); + entries.set(descriptor.referenceId, descriptor); + } + return entries; + }; + + const writeManifest = async ( + entries: Map, + ): Promise => { + if (entries.size > maxEntries) + throw new WorkshopError( + 'validation_failed', + 'Resource blob manifest entry limit exceeded', + 400, + ); + const body = JSON.stringify({ + format: MANIFEST_FORMAT, + items: [...entries.values()].sort((a, b) => + compareCodeUnits(a.referenceId, b.referenceId), + ), + }); + if (Buffer.byteLength(body) > MAX_MANIFEST_BYTES) throw invalidManifest(); + const temporary = join(root, `.manifest-${randomUUID()}.tmp`); + await writeFile(temporary, body, { flag: 'wx', mode: 0o600 }); + await rename(temporary, join(root, MANIFEST_FILE)); + }; + + const backup: ResourceBlobBackupPort = { + async enumerate(cursor, limit) { + boundedInteger(limit, 1, 1000, 'limit'); + const after = decodeCursor(cursor); + const entries = [...(await readManifest()).values()].sort((a, b) => + compareCodeUnits(a.referenceId, b.referenceId), + ); + const page = entries + .filter(({ referenceId }) => compareCodeUnits(referenceId, after) > 0) + .slice(0, limit + 1); + const items = page.slice(0, limit); + return { + items, + cursor: + page.length > limit && items.length > 0 + ? encodeCursor(items.at(-1)!.referenceId) + : null, + }; + }, + async open(referenceId, signal) { + assertReferenceId(referenceId); + if (signal?.aborted) throw signal.reason; + const descriptor = (await readManifest()).get(referenceId); + if (!descriptor) + throw new WorkshopError('not_found', 'Resource blob not found', 404); + const path = objectPath(objects, descriptor.sha256); + const stat = await lstat(path).catch((error: unknown) => { + if (isMissing(error)) + throw new WorkshopError( + 'dependency_unavailable', + 'Resource blob payload is unavailable', + 503, + ); + throw error; + }); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size !== descriptor.byteLength + ) + throw unsafeStore(); + return Readable.toWeb( + createReadStream(path, signal ? { signal } : undefined), + ) as ReadableStream; + }, + }; + + const restore: ResourceBlobRestorePort = { + async stage(rawDescriptor, stream) { + const descriptor = normalizeDescriptor(rawDescriptor, maxBlobBytes); + const stageId = randomUUID(); + const path = join(staging, `${stageId}.blob`); + const output = createWriteStream(path, { flags: 'wx', mode: 0o600 }); + try { + const digest = createHash('sha256'); + let byteLength = 0; + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) throw invalidBlob(); + byteLength += value.byteLength; + if (byteLength > descriptor.byteLength || byteLength > maxBlobBytes) + throw invalidBlob(); + digest.update(value); + await writeChunk(output, value); + } + } finally { + reader.releaseLock(); + } + await closeWriter(output); + if ( + byteLength !== descriptor.byteLength || + digest.digest('hex') !== descriptor.sha256 + ) + throw invalidBlob(); + stages.set(stageId, { descriptor, path, verified: false }); + return stageId; + } catch (error) { + output.destroy(); + await rm(path, { force: true }); + throw error; + } + }, + async verify(stageId) { + const stage = requireStage(stages, stageId); + const stat = await lstat(stage.path); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size !== stage.descriptor.byteLength + ) + throw invalidBlob(); + const digest = createHash('sha256'); + for await (const chunk of createReadStream(stage.path)) + digest.update(chunk as Buffer); + if (digest.digest('hex') !== stage.descriptor.sha256) throw invalidBlob(); + stage.verified = true; + }, + async checkCollision(rawDescriptor) { + const descriptor = normalizeDescriptor(rawDescriptor, maxBlobBytes); + const existing = (await readManifest()).get(descriptor.referenceId); + if (!existing) return 'absent'; + return equalDescriptor(existing, descriptor) ? 'identical' : 'conflict'; + }, + rebind(stageId, canonicalReferenceId) { + assertReferenceId(canonicalReferenceId); + const stage = requireStage(stages, stageId); + stage.descriptor = { + ...stage.descriptor, + referenceId: canonicalReferenceId, + }; + return Promise.resolve(); + }, + async commit(stageIds) { + if (new Set(stageIds).size !== stageIds.length) + throw new WorkshopError( + 'validation_failed', + 'Duplicate blob stage', + 400, + ); + const selected = stageIds.map((id) => requireStage(stages, id)); + if (selected.some(({ verified }) => !verified)) + throw new WorkshopError( + 'validation_failed', + 'Resource blob stage is not verified', + 400, + ); + const entries = await readManifest(); + for (const stage of selected) { + const existing = entries.get(stage.descriptor.referenceId); + if (existing && !equalDescriptor(existing, stage.descriptor)) + throw new WorkshopError( + 'conflict', + 'Resource blob reference conflicts with existing content', + 409, + ); + } + for (const stage of selected) { + const destination = objectPath(objects, stage.descriptor.sha256); + try { + const stat = await lstat(destination); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size !== stage.descriptor.byteLength + ) + throw unsafeStore(); + await rm(stage.path, { force: true }); + } catch (error) { + if (!isMissing(error)) throw error; + await rename(stage.path, destination); + } + entries.set(stage.descriptor.referenceId, stage.descriptor); + } + await writeManifest(entries); + for (const id of stageIds) stages.delete(id); + }, + async rollback(stageIds) { + await Promise.all( + stageIds.map(async (id) => { + const stage = stages.get(id); + if (!stage) return; + stages.delete(id); + await rm(stage.path, { force: true }); + }), + ); + }, + }; + + return { backup, restore }; +} + +function resolveRoot(value: string): string { + if (typeof value !== 'string' || !value || !isAbsolute(value)) + throw new WorkshopError( + 'validation_failed', + 'Resource blob root must be an absolute path', + 400, + ); + return resolve(value); +} + +async function secureDirectory(path: string): Promise { + await mkdir(path, { recursive: true, mode: 0o700 }); + const stat = await lstat(path); + const canonical = await realpath(path); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + resolve(canonical).toLowerCase() !== resolve(path).toLowerCase() + ) + throw unsafeStore(); +} + +function objectPath(objects: string, sha256: string): string { + return join(objects, sha256); +} + +function normalizeDescriptor( + value: unknown, + maxBlobBytes: number, +): ResourceBlobDescriptor { + if (!isRecord(value)) throw invalidBlob(); + const descriptor = { + referenceId: value.referenceId, + resourceId: value.resourceId, + revision: value.revision, + byteLength: value.byteLength, + sha256: value.sha256, + mediaType: value.mediaType, + }; + assertReferenceId(descriptor.referenceId); + assertIdentifier(descriptor.resourceId, 'resourceId'); + boundedInteger(descriptor.revision, 1, Number.MAX_SAFE_INTEGER, 'revision'); + boundedInteger(descriptor.byteLength, 0, maxBlobBytes, 'byteLength'); + if ( + typeof descriptor.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(descriptor.sha256) || + typeof descriptor.mediaType !== 'string' || + descriptor.mediaType.length < 1 || + descriptor.mediaType.length > 255 || + /[\u0000-\u001f\u007f]/u.test(descriptor.mediaType) + ) + throw invalidBlob(); + return descriptor as ResourceBlobDescriptor; +} + +function assertReferenceId(value: unknown): asserts value is string { + assertIdentifier(value, 'referenceId'); +} + +function assertIdentifier( + value: unknown, + field: string, +): asserts value is string { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > 1024 || + /[\u0000-\u001f\u007f]/u.test(value) + ) + throw new WorkshopError( + 'validation_failed', + `Resource blob ${field} is invalid`, + 400, + ); +} + +function boundedInteger( + value: unknown, + minimum: number, + maximum: number, + field: string, +): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) + throw new WorkshopError( + 'validation_failed', + `Resource blob ${field} is invalid`, + 400, + ); + return value; +} + +function requireStage(stages: Map, stageId: string): Stage { + if (typeof stageId !== 'string') throw invalidBlob(); + const stage = stages.get(stageId); + if (!stage) + throw new WorkshopError('not_found', 'Resource blob stage not found', 404); + return stage; +} + +function equalDescriptor( + left: ResourceBlobDescriptor, + right: ResourceBlobDescriptor, +): boolean { + return ( + left.referenceId === right.referenceId && + left.resourceId === right.resourceId && + left.revision === right.revision && + left.byteLength === right.byteLength && + left.sha256 === right.sha256 && + left.mediaType === right.mediaType + ); +} + +function encodeCursor(referenceId: string): string { + return Buffer.from(referenceId, 'utf8').toString('base64url'); +} + +function decodeCursor(cursor: string | null): string { + if (cursor === null) return ''; + if ( + typeof cursor !== 'string' || + cursor.length < 1 || + cursor.length > 2048 || + !/^[A-Za-z0-9_-]+$/u.test(cursor) + ) + throw new WorkshopError('validation_failed', 'Blob cursor is invalid', 400); + const value = Buffer.from(cursor, 'base64url').toString('utf8'); + assertReferenceId(value); + if (encodeCursor(value) !== cursor) + throw new WorkshopError('validation_failed', 'Blob cursor is invalid', 400); + return value; +} + +function writeChunk(output: WriteStream, chunk: Uint8Array): Promise { + return new Promise((resolvePromise, reject) => { + output.write(chunk, (error) => (error ? reject(error) : resolvePromise())); + }); +} + +function closeWriter(output: WriteStream): Promise { + return new Promise((resolvePromise, reject) => { + output.end((error?: Error | null) => + error ? reject(error) : resolvePromise(), + ); + }); +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isMissing(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} + +function invalidBlob(): WorkshopError { + return new WorkshopError( + 'validation_failed', + 'Resource blob payload or descriptor is invalid', + 400, + ); +} + +function invalidManifest(): WorkshopError { + return new WorkshopError( + 'dependency_unavailable', + 'Resource blob manifest is invalid', + 503, + ); +} + +function unsafeStore(): WorkshopError { + return new WorkshopError( + 'dependency_unavailable', + 'Resource blob store contains an unsafe filesystem object', + 503, + ); +} diff --git a/src/server.ts b/src/server.ts index 56e1862..9741fbb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,15 +1,15 @@ -export * from './server/context.js'; -export type { WorkshopCursorCodec } from './server/cursor.js'; export * from './server/database.js'; -export * from './server/limits.js'; -export * from './server/validation.js'; export * from './server/authorization.js'; export * from './server/authorization-readiness.js'; -export * from './server/observability.js'; +export * from './server/health.js'; export * from './server/memories.js'; export * from './server/prompts.js'; -export * from './server/search.js'; export * from './server/tasks.js'; -export * from './server/packets.js'; -export * from './server/knowledge.js'; -export * from './server/health.js'; +export * from './server/canonical-producer.js'; +export * from './server/auth.js'; +export * from './server/assets.js'; +export * from './server/config.js'; +export * from './server/lifecycle.js'; +export * from './server/service.js'; +export * from './backup.js'; +export * from './producers.js'; diff --git a/src/server/assets.ts b/src/server/assets.ts new file mode 100644 index 0000000..1658f05 --- /dev/null +++ b/src/server/assets.ts @@ -0,0 +1,249 @@ +import { WorkshopError } from '../protocol/errors.js'; + +const MEDIA_TYPES = new Set([ + 'application/javascript', + 'application/json', + 'application/manifest+json', + 'application/wasm', + 'font/woff2', + 'image/avif', + 'image/png', + 'image/svg+xml', + 'image/webp', + 'text/css', + 'text/html; charset=utf-8', +]); +const RESERVED_PREFIXES = ['/mcp', '/health', '/api', '/.well-known']; + +export interface WaystoneAssetManifest { + format: 'waystone-assets-v1'; + files: readonly { + path: string; + sha256: string; + bytes: number; + mediaType: string; + immutable: true; + }[]; + entrypoint: string; + manifestSha256: string; +} + +export interface WaystoneAssetSource { + read(path: string): Promise<{ bytes: Uint8Array; symlink: boolean } | null>; +} + +export interface VerifiedWaystoneAssets { + readonly prefix: string; + readonly manifest: WaystoneAssetManifest; + serve(pathname: string): Promise; +} + +export async function verifyWaystoneAssetMount(input: { + prefix: string; + manifest: unknown; + source: WaystoneAssetSource; +}): Promise { + const prefix = normalizePrefix(input.prefix); + const manifest = exactManifest(input.manifest); + const withoutDigest = { + format: manifest.format, + files: manifest.files, + entrypoint: manifest.entrypoint, + }; + const manifestDigest = await sha256Hex( + new TextEncoder().encode(canonicalize(withoutDigest)), + ); + if (!constantTimeHex(manifestDigest, manifest.manifestSha256.toLowerCase())) + throw invalidAssets('Waystone manifest digest mismatch'); + const paths = manifest.files.map(({ path }) => path); + if ( + paths.some((path) => path !== normalizeAssetPath(path)) || + paths.some((path, index) => index > 0 && paths[index - 1]! >= path) + ) + throw invalidAssets( + 'Waystone asset paths must be normalized, unique, and sorted', + ); + if (!paths.includes(manifest.entrypoint)) + throw invalidAssets('Waystone entrypoint is not in the manifest'); + + const bytesByPath = new Map(); + for (const file of manifest.files) { + if ( + file.immutable !== true || + !MEDIA_TYPES.has(file.mediaType) || + !Number.isSafeInteger(file.bytes) || + file.bytes < 0 || + !/^[a-f0-9]{64}$/u.test(file.sha256) + ) + throw invalidAssets('Waystone file metadata is invalid'); + const loaded = await input.source.read(file.path); + if (!loaded || loaded.symlink) + throw invalidAssets('Waystone asset is missing or a symlink'); + if ( + loaded.bytes.byteLength !== file.bytes || + !constantTimeHex(await sha256Hex(loaded.bytes), file.sha256) + ) + throw invalidAssets('Waystone asset bytes do not match the manifest'); + bytesByPath.set(file.path, loaded.bytes.slice()); + } + + return { + prefix, + manifest, + serve(pathname) { + if (!pathname.startsWith(`${prefix}/`) && pathname !== prefix) + return Promise.resolve(null); + const suffix = + pathname === prefix + ? manifest.entrypoint + : decodeURIComponent(pathname.slice(prefix.length + 1)); + let normalized: string; + try { + normalized = normalizeAssetPath(suffix); + } catch { + return Promise.resolve(null); + } + const file = manifest.files.find(({ path }) => path === normalized); + const bytes = bytesByPath.get(normalized); + if (!file || !bytes) return Promise.resolve(null); + return Promise.resolve( + new Response(bytes.slice(), { + headers: { + 'content-type': file.mediaType, + 'content-length': String(file.bytes), + 'cache-control': 'public, max-age=31536000, immutable', + 'x-content-type-options': 'nosniff', + }, + }), + ); + }, + }; +} + +function exactManifest(value: unknown): WaystoneAssetManifest { + if ( + !recordWithKeys(value, ['entrypoint', 'files', 'format', 'manifestSha256']) + ) + throw invalidAssets('Waystone manifest shape is invalid'); + if ( + value.format !== 'waystone-assets-v1' || + !Array.isArray(value.files) || + typeof value.entrypoint !== 'string' || + !/^[a-f0-9]{64}$/u.test(String(value.manifestSha256)) + ) + throw invalidAssets('Waystone manifest metadata is invalid'); + const files = value.files.map((file) => { + if ( + !recordWithKeys(file, [ + 'bytes', + 'immutable', + 'mediaType', + 'path', + 'sha256', + ]) || + typeof file.path !== 'string' || + typeof file.sha256 !== 'string' || + typeof file.bytes !== 'number' || + typeof file.mediaType !== 'string' || + file.immutable !== true + ) + throw invalidAssets('Waystone file entry is invalid'); + return { + path: file.path, + sha256: file.sha256, + bytes: file.bytes, + mediaType: file.mediaType, + immutable: true as const, + }; + }); + return { + format: 'waystone-assets-v1', + files, + entrypoint: value.entrypoint, + manifestSha256: String(value.manifestSha256), + }; +} + +function normalizePrefix(value: string): string { + if ( + typeof value !== 'string' || + !/^\/[a-z0-9][a-z0-9/_-]*$/u.test(value) || + value.endsWith('/') || + RESERVED_PREFIXES.some( + (reserved) => + value === reserved || + value.startsWith(`${reserved}/`) || + reserved.startsWith(`${value}/`), + ) + ) + throw invalidAssets('Static asset prefix is invalid or reserved'); + return value; +} + +function normalizeAssetPath(value: string): string { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > 1024 || + value.startsWith('/') || + value.includes('\\') || + value.includes('\u0000') + ) + throw invalidAssets('Waystone asset path is invalid'); + const segments = value.split('/'); + if ( + segments.some( + (segment) => + !segment || + segment === '.' || + segment === '..' || + !/^[A-Za-z0-9._-]+$/u.test(segment), + ) + ) + throw invalidAssets('Waystone asset path is invalid'); + return segments.join('/'); +} + +function recordWithKeys( + value: unknown, + expected: readonly string[], +): value is Record { + return ( + Boolean(value) && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value as object).sort()) === + JSON.stringify([...expected].sort()) + ); +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonicalize(entry)).join(',')}]`; + const object = value as Record; + return `{${Object.keys(object) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(object[key])}`) + .join(',')}}`; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', Uint8Array.from(bytes)); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function constantTimeHex(left: string, right: string): boolean { + if (!/^[a-f0-9]{64}$/u.test(left) || !/^[a-f0-9]{64}$/u.test(right)) + return false; + let difference = 0; + for (let index = 0; index < 64; index += 1) + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} + +function invalidAssets(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..a17a0a7 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,459 @@ +import { WorkshopError } from '../protocol/errors.js'; +import type { AuthenticatedPrincipal } from '../protocol.js'; +import type { D1DatabaseLike } from './database.js'; + +const LOCAL_PREFIX = new TextEncoder().encode('gnolith-local-bearer-v1\u0000'); +const BASE64URL_32 = /^[A-Za-z0-9_-]{43}$/u; +const MAX_AUTHORIZATION_HEADER = 16_512; +const MAX_OAUTH_TOKEN = 16_384; + +export interface LocalBearerCredentialRecord { + profile: 'local-bearer-v1'; + credentialId: string; + tokenDigest: string; + installationId: string; + principalId: string; + activeWorkspaceId: string | null; + capabilities: readonly string[]; + authorizationRevision: number; + disabled: boolean; +} + +export interface LocalBearerCredentialStore { + findByDigest( + digest: string, + signal: AbortSignal, + ): Promise; +} + +export interface LocalBearerAuthorizationResolverV1 { + resolve(input: { + installationId: string; + principalId: string; + workspaceId: string; + signal: AbortSignal; + }): Promise<{ + capabilities: readonly string[]; + authorizationRevision: number; + }>; +} + +/** Reads only Workshop's schema-v8 credential table. */ +export function createWorkshopLocalBearerCredentialStoreV1( + db: D1DatabaseLike, + authorization: LocalBearerAuthorizationResolverV1, +): LocalBearerCredentialStore { + return { + async findByDigest(digest, signal) { + if (signal.aborted) throw signal.reason; + if (!/^[a-f0-9]{64}$/u.test(digest)) return null; + const row = await db + .prepare( + `SELECT installation_id, credential_id, token_digest, principal_id, + workspace_id, disabled + FROM workshop_credentials + WHERE token_digest = ?`, + ) + .bind(digest) + .first<{ + installation_id: string; + credential_id: string; + token_digest: string; + principal_id: string; + workspace_id: string; + disabled: number; + }>(); + if (!row) return null; + const resolved = await authorization.resolve({ + installationId: row.installation_id, + principalId: row.principal_id, + workspaceId: row.workspace_id, + signal, + }); + return { + profile: 'local-bearer-v1', + credentialId: row.credential_id, + tokenDigest: row.token_digest, + installationId: row.installation_id, + principalId: row.principal_id, + activeWorkspaceId: row.workspace_id, + capabilities: [...resolved.capabilities], + authorizationRevision: resolved.authorizationRevision, + disabled: row.disabled !== 0, + }; + }, + }; +} + +export interface AuthorizationFence { + recheck( + principal: AuthenticatedPrincipal, + signal: AbortSignal, + ): Promise; +} + +export interface LocalBearerAuthOptions { + profile: 'local-bearer-v1'; + credentials: LocalBearerCredentialStore; + authorization: AuthorizationFence; +} + +export interface RemoteOAuthConfig { + profile: 'remote-oauth-v1'; + issuer: string; + audience: string; + algorithms: readonly string[]; + requiredScopes: readonly string[]; + resource: string; + authorizationServers: readonly string[]; + scopeCapabilityMap: Readonly>; + principalClaim: string; + workspaceClaim: string; + installationClaim: string; + credentialClaim: string; + maxClockSkewSeconds: number; +} + +export interface VerifiedJwt { + header: Readonly>; + claims: Readonly>; +} + +export interface RemoteJwtVerifier { + verify( + token: string, + config: RemoteOAuthConfig, + signal: AbortSignal, + ): Promise; +} + +export interface RemotePrincipalResolver { + resolve( + identity: { + installationId: string; + principalId: string; + activeWorkspaceId: string | null; + credentialId: string; + scopes: readonly string[]; + capabilities: readonly string[]; + }, + signal: AbortSignal, + ): Promise; +} + +export interface RemoteOAuthAuthOptions { + profile: 'remote-oauth-v1'; + config: RemoteOAuthConfig; + jwt: RemoteJwtVerifier; + principals: RemotePrincipalResolver; + authorization: AuthorizationFence; + clock?: () => Date; +} + +export type AuthenticationOptions = + LocalBearerAuthOptions | RemoteOAuthAuthOptions; + +export interface AuthenticationVerifier { + readonly profile: AuthenticationOptions['profile']; + readonly challenge: string; + authenticate(request: Request): Promise; + protectedResourceMetadata(): Record | null; +} + +export function createAuthenticationVerifier( + options: AuthenticationOptions, +): AuthenticationVerifier { + return options.profile === 'local-bearer-v1' + ? localVerifier(options) + : remoteVerifier(options); +} + +export async function digestLocalBearerToken( + tokenBytes: Uint8Array, +): Promise { + if (tokenBytes.byteLength !== 32) throw invalidCredential(); + const material = new Uint8Array(LOCAL_PREFIX.length + tokenBytes.length); + material.set(LOCAL_PREFIX); + material.set(tokenBytes, LOCAL_PREFIX.length); + try { + return hex(await crypto.subtle.digest('SHA-256', material)); + } finally { + material.fill(0); + } +} + +export function decodeLocalBearerCredentialFile(bytes: Uint8Array): Uint8Array { + if (bytes.byteLength !== 43 && bytes.byteLength !== 44) + throw invalidCredential(); + if (bytes.byteLength === 44 && bytes[43] !== 0x0a) throw invalidCredential(); + const encoded = new TextDecoder('utf-8', { fatal: true }).decode( + bytes.subarray(0, 43), + ); + if (!BASE64URL_32.test(encoded)) throw invalidCredential(); + const decoded = base64urlDecode(encoded); + if (decoded.byteLength !== 32 || base64urlEncode(decoded) !== encoded) { + decoded.fill(0); + throw invalidCredential(); + } + return decoded; +} + +function localVerifier( + options: LocalBearerAuthOptions, +): AuthenticationVerifier { + return { + profile: 'local-bearer-v1', + challenge: 'Bearer', + protectedResourceMetadata: () => null, + async authenticate(request) { + rejectAlternateTokenSources(request); + const token = bearerToken(request); + if (!BASE64URL_32.test(token)) throw unauthenticated(); + const decoded = base64urlDecode(token); + if (decoded.byteLength !== 32) throw unauthenticated(); + let digest: string; + try { + digest = await digestLocalBearerToken(decoded); + } finally { + decoded.fill(0); + } + const record = await options.credentials.findByDigest( + digest, + request.signal, + ); + if (!record || record.disabled) throw unauthenticated(); + if (!constantTimeHex(record.tokenDigest, digest)) throw unauthenticated(); + return options.authorization.recheck( + { + installationId: record.installationId, + principalId: record.principalId, + activeWorkspaceId: record.activeWorkspaceId, + capabilities: [...record.capabilities], + authorizationRevision: record.authorizationRevision, + credentialId: record.credentialId, + }, + request.signal, + ); + }, + }; +} + +function remoteVerifier( + options: RemoteOAuthAuthOptions, +): AuthenticationVerifier { + validateRemoteConfig(options.config); + const metadataUrl = + `${new URL(options.config.resource).origin}/` + + '.well-known/oauth-protected-resource/mcp'; + return { + profile: 'remote-oauth-v1', + challenge: `Bearer resource_metadata="${metadataUrl}"`, + protectedResourceMetadata: () => ({ + resource: options.config.resource, + authorization_servers: [...options.config.authorizationServers], + bearer_methods_supported: ['header'], + scopes_supported: [...options.config.requiredScopes], + }), + async authenticate(request) { + rejectAlternateTokenSources(request); + const token = bearerToken(request); + if (token.length > MAX_OAUTH_TOKEN) throw unauthenticated(); + const verified = await options.jwt.verify( + token, + options.config, + request.signal, + ); + const now = (options.clock ?? (() => new Date()))().getTime() / 1000; + const skew = options.config.maxClockSkewSeconds; + const algorithm = requiredClaim(verified.header, 'alg'); + if (!options.config.algorithms.includes(algorithm)) + throw unauthenticated(); + if (requiredClaim(verified.claims, 'iss') !== options.config.issuer) + throw unauthenticated(); + const audience = verified.claims.aud; + const audiences = Array.isArray(audience) + ? audience.filter((value): value is string => typeof value === 'string') + : typeof audience === 'string' + ? [audience] + : []; + if (!audiences.includes(options.config.audience)) throw unauthenticated(); + const expires = numericClaim(verified.claims, 'exp'); + const notBefore = + verified.claims.nbf === undefined + ? 0 + : numericClaim(verified.claims, 'nbf'); + if (expires < now - skew || notBefore > now + skew) + throw unauthenticated(); + const scopes = scopeSet(verified.claims.scope); + if ( + options.config.requiredScopes.some( + (required) => !scopes.includes(required), + ) + ) + throw new WorkshopError('forbidden', 'Authorization denied', 403); + const capabilities = [ + ...new Set( + scopes + .map((scope) => options.config.scopeCapabilityMap[scope]) + .filter((value): value is string => Boolean(value)), + ), + ].sort(); + const resolved = await options.principals.resolve( + { + installationId: requiredClaim( + verified.claims, + options.config.installationClaim, + ), + principalId: requiredClaim( + verified.claims, + options.config.principalClaim, + ), + activeWorkspaceId: optionalClaim( + verified.claims, + options.config.workspaceClaim, + ), + credentialId: requiredClaim( + verified.claims, + options.config.credentialClaim, + ), + scopes, + capabilities, + }, + request.signal, + ); + return options.authorization.recheck(resolved, request.signal); + }, + }; +} + +function validateRemoteConfig(config: RemoteOAuthConfig): void { + if ( + new URL(config.issuer).protocol !== 'https:' || + new URL(config.resource).protocol !== 'https:' || + config.authorizationServers.length === 0 || + config.authorizationServers.some( + (server) => new URL(server).protocol !== 'https:', + ) || + config.algorithms.length === 0 || + config.algorithms.some((algorithm) => algorithm === 'none') || + config.requiredScopes.length === 0 || + !Number.isSafeInteger(config.maxClockSkewSeconds) || + config.maxClockSkewSeconds < 0 || + config.maxClockSkewSeconds > 300 + ) + throw new WorkshopError( + 'validation_failed', + 'Remote OAuth configuration is invalid', + 400, + ); +} + +function rejectAlternateTokenSources(request: Request): void { + const url = new URL(request.url); + for (const key of url.searchParams.keys()) { + if (/token|authorization|credential/iu.test(key)) throw unauthenticated(); + } + if ( + request.headers.has('cookie') || + request.headers.has('x-api-key') || + request.headers.has('proxy-authorization') + ) + throw unauthenticated(); +} + +function bearerToken(request: Request): string { + const header = request.headers.get('authorization'); + if ( + !header || + header.length > MAX_AUTHORIZATION_HEADER || + !header.startsWith('Bearer ') || + header.slice(7).length === 0 || + /[\u0000-\u0020\u007f]/u.test(header.slice(7)) + ) + throw unauthenticated(); + return header.slice(7); +} + +function requiredClaim( + claims: Readonly>, + name: string, +): string { + const value = claims[name]; + if (typeof value !== 'string' || !value || value.length > 4096) + throw unauthenticated(); + return value; +} + +function optionalClaim( + claims: Readonly>, + name: string, +): string | null { + const value = claims[name]; + if (value === null || value === undefined) return null; + if (typeof value !== 'string' || !value || value.length > 4096) + throw unauthenticated(); + return value; +} + +function numericClaim( + claims: Readonly>, + name: string, +): number { + const value = claims[name]; + if (typeof value !== 'number' || !Number.isFinite(value)) + throw unauthenticated(); + return value; +} + +function scopeSet(value: unknown): string[] { + if (typeof value !== 'string' || value.length > 16_384) + throw unauthenticated(); + return [...new Set(value.split(' ').filter(Boolean))].sort(); +} + +function base64urlDecode(value: string): Uint8Array { + try { + const base64 = value.replaceAll('-', '+').replaceAll('_', '/'); + const padded = `${base64}${'='.repeat((4 - (base64.length % 4)) % 4)}`; + const binary = atob(padded); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + throw unauthenticated(); + } +} + +function base64urlEncode(value: Uint8Array): string { + let binary = ''; + for (const byte of value) binary += String.fromCharCode(byte); + return btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, ''); +} + +function constantTimeHex(left: string, right: string): boolean { + if (!/^[a-f0-9]{64}$/u.test(left) || !/^[a-f0-9]{64}$/u.test(right)) + return false; + let difference = 0; + for (let index = 0; index < 64; index += 1) + difference |= left.charCodeAt(index) ^ right.charCodeAt(index); + return difference === 0; +} + +function hex(value: ArrayBuffer): string { + return [...new Uint8Array(value)] + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function unauthenticated(): WorkshopError { + return new WorkshopError('unauthenticated', 'Authentication required', 401); +} + +function invalidCredential(): WorkshopError { + return new WorkshopError( + 'validation_failed', + 'Local credential file is invalid', + 400, + ); +} diff --git a/src/server/authorization.ts b/src/server/authorization.ts index 1de6479..877b8b4 100644 --- a/src/server/authorization.ts +++ b/src/server/authorization.ts @@ -79,7 +79,7 @@ const MAX_CLAUSES = 64; const MAX_ATOMS_PER_CLAUSE = 64; export function authorize( - principal: WorkshopPrincipal | null, + principal: WorkshopPrincipal | AuthorizationContext | null, capability: WorkshopCapability, ): AuthorizationContext { if (!principal) { @@ -115,6 +115,7 @@ export function normalizeAuthorizationContext( 'workspaceIds', 'capabilities', 'authorizationRevision', + ...('credentialId' in value ? ['credentialId'] : []), ]); const installationId = identifier(value.installationId, 'installationId'); const principalId = identifier(value.principalId, 'principalId'); diff --git a/src/server/canonical-producer.ts b/src/server/canonical-producer.ts new file mode 100644 index 0000000..8d18555 --- /dev/null +++ b/src/server/canonical-producer.ts @@ -0,0 +1,139 @@ +import type { + ExternalOwnerTransactionAdapterV1, + RegisteredExternalProducerV1, +} from '@gnolith/taproot/search'; +import type { AuthorizationContext } from '../protocol/authorization.js'; +import { WorkshopError } from '../protocol/errors.js'; +import type { D1DatabaseLike, D1ResultLike } from './database.js'; + +export interface WorkshopSearchDomainV1 { + currentPredecessor( + sourceId: string, + context: AuthorizationContext, + ): Promise<{ eventId: string; sequence: number }>; + commitCanonicalMutation( + input: WorkshopCanonicalSearchMutationV1, + ): Promise; + adopt( + context: AuthorizationContext, + input: { cursor?: string | null; limit: number }, + ): Promise<{ enumerated: number; adopted: number; cursor: string | null }>; +} + +export interface WorkshopCanonicalSearchMutationV1 { + context: AuthorizationContext; + eventId: string; + sourceId: string; + operation: 'upsert' | 'delete'; + changeClass: string; + sourceRevision: number; + sourcePolicyRevision: number; + predecessor: { eventId: string; sequence: number } | null; + canonicalPostState: unknown; + statements: readonly { sql: string; values: readonly unknown[] }[]; +} + +export interface WorkshopCanonicalSearchMutationReceiptV1 { + eventId: string; + authorizationRevision: number; + searchGeneration: number; + results: readonly D1ResultLike[]; +} + +export interface WorkshopCanonicalOwnerAdapterControllerV1 { + readonly adapter: ExternalOwnerTransactionAdapterV1; + bind(registered: RegisteredExternalProducerV1): WorkshopSearchDomainV1; +} + +/** + * Bridges Workshop-owned SQL into Taproot's exact owner-token transaction + * boundary. Tokens are process-local identities and are consumed once. + */ +export function createWorkshopCanonicalOwnerAdapterV1( + db: D1DatabaseLike, +): WorkshopCanonicalOwnerAdapterControllerV1 { + const pending = new WeakMap(); + let bound = false; + const adapter: ExternalOwnerTransactionAdapterV1 = { + kind: 'taproot-external-owner-transaction-adapter-v1', + prepare(ownerMutationToken) { + if (!isObjectToken(ownerMutationToken)) throw invalidOwnerToken(); + const input = pending.get(ownerMutationToken); + if (!input) throw invalidOwnerToken(); + pending.delete(ownerMutationToken); + return Promise.resolve( + input.statements.map(({ sql, values }) => + db.prepare(sql).bind(...values), + ), + ); + }, + }; + + return { + adapter, + bind(registered) { + if (bound) + throw new WorkshopError( + 'validation_failed', + 'Workshop producer adapter is already bound', + 400, + ); + bound = true; + return { + async currentPredecessor(sourceId) { + const predecessor = await registered.currentPredecessor(sourceId); + if (!predecessor) + throw new WorkshopError( + 'forbidden', + 'Canonical predecessor is unavailable', + 403, + ); + return predecessor; + }, + async commitCanonicalMutation(input) { + const token = {}; + pending.set(token, input); + try { + const participant = await registered.issuer.seal({ + sourceId: input.sourceId, + sourceRevision: String(input.sourceRevision), + predecessor: input.predecessor, + ownerMutationToken: token, + }); + const receipt = await registered.commit(participant); + return { + eventId: receipt.eventId, + authorizationRevision: receipt.authorizationRevision, + searchGeneration: receipt.generation, + results: receipt.results as readonly D1ResultLike[], + }; + } finally { + pending.delete(token); + } + }, + adopt: (context, input) => registered.adopt(context, input), + }; + }, + }; +} + +export async function commitWorkshopCanonicalSearchMutationV1( + domain: WorkshopSearchDomainV1, + input: WorkshopCanonicalSearchMutationV1, +): Promise { + return domain.commitCanonicalMutation(input); +} + +function isObjectToken(value: unknown): value is object { + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ); +} + +function invalidOwnerToken(): WorkshopError { + return new WorkshopError( + 'validation_failed', + 'Workshop owner mutation token is invalid or already consumed', + 400, + ); +} diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..d35fbb8 --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,291 @@ +import { WorkshopError } from '../protocol/errors.js'; +import type { RemoteOAuthConfig } from './auth.js'; + +export interface ListenerConfig { + host: string; + port: number; + profile: 'container' | 'remote' | 'test'; +} + +export interface ProtectedSelectorConfig { + kind: 'file' | 'inherited-descriptor' | 'environment-selector'; + value: string | number; +} + +export interface WorkshopServiceConfig { + format: 'gnolith-workshop-config-v1'; + listener: ListenerConfig; + authentication: + | { + profile: 'local-bearer-v1'; + credentialStore: ProtectedSelectorConfig; + } + | (RemoteOAuthConfig & { + profile: 'remote-oauth-v1'; + metadata: ProtectedSelectorConfig; + }); + adapterModule: string; + corsOrigins?: readonly string[]; + apiPrefix?: '/api/v1'; + assetMount?: { + prefix: string; + manifest: ProtectedSelectorConfig; + root: ProtectedSelectorConfig; + }; + background?: { + intervalMs: number; + timeoutMs: number; + }; + shutdownTimeoutMs?: number; +} + +export function validateWorkshopServiceConfig( + value: unknown, +): WorkshopServiceConfig { + if (!isRecord(value) || value.format !== 'gnolith-workshop-config-v1') + throw invalid('Configuration format is invalid'); + exactKeys(value, [ + 'format', + 'listener', + 'authentication', + 'adapterModule', + 'corsOrigins', + 'apiPrefix', + 'assetMount', + 'background', + 'shutdownTimeoutMs', + ]); + const listener = validateListenerConfig(value.listener); + if (!isRecord(value.authentication)) + throw invalid('Authentication configuration is required'); + const profile = value.authentication.profile; + if (profile !== 'local-bearer-v1' && profile !== 'remote-oauth-v1') + throw invalid('Authentication profile is invalid'); + if ( + typeof value.adapterModule !== 'string' || + !value.adapterModule || + value.adapterModule.includes('\u0000') + ) + throw invalid('adapterModule is invalid'); + if (value.apiPrefix !== undefined && value.apiPrefix !== '/api/v1') + throw invalid('apiPrefix is invalid'); + const corsOrigins = value.corsOrigins; + if ( + corsOrigins !== undefined && + (!Array.isArray(corsOrigins) || + corsOrigins.some( + (origin) => + typeof origin !== 'string' || + new URL(origin).origin !== origin || + !['https:', 'http:'].includes(new URL(origin).protocol), + )) + ) + throw invalid('CORS origins are invalid'); + const authentication = + profile === 'local-bearer-v1' + ? localAuthentication(value.authentication) + : remoteAuthentication(value.authentication); + const background = value.background; + if (isRecord(background)) exactKeys(background, ['intervalMs', 'timeoutMs']); + if ( + background !== undefined && + (!isRecord(background) || + !boundedInteger(background.intervalMs, 100, 86_400_000) || + !boundedInteger(background.timeoutMs, 1, 86_400_000) || + Number(background.timeoutMs) > Number(background.intervalMs)) + ) + throw invalid('Background trigger configuration is invalid'); + const shutdownTimeoutMs = + value.shutdownTimeoutMs === undefined ? 30_000 : value.shutdownTimeoutMs; + if (!boundedInteger(shutdownTimeoutMs, 1, 300_000)) + throw invalid('Shutdown timeout is invalid'); + const assetMount = + value.assetMount === undefined + ? undefined + : validateAssetMount(value.assetMount); + return { + format: 'gnolith-workshop-config-v1', + listener, + authentication, + adapterModule: value.adapterModule, + ...(corsOrigins ? { corsOrigins: [...(corsOrigins as string[])] } : {}), + apiPrefix: '/api/v1', + ...(assetMount ? { assetMount } : {}), + ...(background + ? { + background: { + intervalMs: Number(background.intervalMs), + timeoutMs: Number(background.timeoutMs), + }, + } + : {}), + shutdownTimeoutMs: Number(shutdownTimeoutMs), + }; +} + +export function validateListenerConfig(value: unknown): ListenerConfig { + if ( + !isRecord(value) || + typeof value.host !== 'string' || + !value.host || + value.host.length > 255 || + !boundedInteger(value.port, 0, 65_535) || + !['container', 'remote', 'test'].includes(String(value.profile)) + ) + throw invalid('Listener configuration is invalid'); + exactKeys(value, ['host', 'port', 'profile']); + const profile = value.profile as ListenerConfig['profile']; + const host = value.host; + const port = Number(value.port); + if (profile === 'container' && (isLoopback(host) || port === 0)) + throw invalid( + 'Container profile requires an explicit non-loopback container-network bind', + ); + if (profile === 'remote' && port === 0) + throw invalid('Remote profile requires an explicit port'); + return { host, port, profile }; +} + +function localAuthentication( + value: Record, +): WorkshopServiceConfig['authentication'] { + exactKeys(value, ['profile', 'credentialStore']); + return { + profile: 'local-bearer-v1', + credentialStore: selector(value.credentialStore), + }; +} + +function remoteAuthentication( + value: Record, +): WorkshopServiceConfig['authentication'] { + const required = [ + 'issuer', + 'audience', + 'resource', + 'principalClaim', + 'workspaceClaim', + 'installationClaim', + 'credentialClaim', + ]; + if (required.some((key) => typeof value[key] !== 'string')) + throw invalid('Remote OAuth configuration is invalid'); + if (!boundedInteger(value.maxClockSkewSeconds, 0, 300)) + throw invalid('Remote OAuth clock skew is invalid'); + exactKeys(value, [ + 'profile', + ...required, + 'algorithms', + 'requiredScopes', + 'authorizationServers', + 'scopeCapabilityMap', + 'maxClockSkewSeconds', + 'metadata', + ]); + return { + profile: 'remote-oauth-v1', + issuer: String(value.issuer), + audience: String(value.audience), + algorithms: stringArray(value.algorithms), + requiredScopes: stringArray(value.requiredScopes), + resource: String(value.resource), + authorizationServers: stringArray(value.authorizationServers), + scopeCapabilityMap: stringMap(value.scopeCapabilityMap), + principalClaim: String(value.principalClaim), + workspaceClaim: String(value.workspaceClaim), + installationClaim: String(value.installationClaim), + credentialClaim: String(value.credentialClaim), + maxClockSkewSeconds: Number(value.maxClockSkewSeconds), + metadata: selector(value.metadata), + }; +} + +function validateAssetMount( + value: unknown, +): NonNullable { + if (!isRecord(value) || typeof value.prefix !== 'string') + throw invalid('Asset mount configuration is invalid'); + exactKeys(value, ['prefix', 'manifest', 'root']); + return { + prefix: value.prefix, + manifest: selector(value.manifest), + root: selector(value.root), + }; +} + +function selector(value: unknown): ProtectedSelectorConfig { + if ( + !isRecord(value) || + !['file', 'inherited-descriptor', 'environment-selector'].includes( + String(value.kind), + ) || + (typeof value.value !== 'string' && typeof value.value !== 'number') + ) + throw invalid('Protected selector is invalid'); + exactKeys(value, ['kind', 'value']); + return { + kind: value.kind as ProtectedSelectorConfig['kind'], + value: value.value, + }; +} + +function stringArray(value: unknown): string[] { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > 256 || + value.some( + (entry) => typeof entry !== 'string' || !entry || entry.length > 4096, + ) + ) + throw invalid('Expected a bounded nonempty string array'); + return [...new Set(value as string[])].sort(); +} + +function stringMap(value: unknown): Record { + if (!isRecord(value)) throw invalid('Expected a string map'); + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (!key || typeof entry !== 'string' || !entry) + throw invalid('Expected a string map'); + output[key] = entry; + } + return output; +} + +function isLoopback(host: string): boolean { + return ( + host === 'localhost' || + host === '::1' || + /^127(?:\.[0-9]{1,3}){3}$/u.test(host) + ); +} + +function boundedInteger( + value: unknown, + minimum: number, + maximum: number, +): boolean { + return ( + Number.isSafeInteger(value) && + Number(value) >= minimum && + Number(value) <= maximum + ); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function exactKeys( + value: Record, + allowed: readonly string[], +): void { + const accepted = new Set(allowed); + if (Object.keys(value).some((key) => !accepted.has(key))) + throw invalid('Configuration contains unknown fields'); +} + +function invalid(message: string): WorkshopError { + return new WorkshopError('validation_failed', message, 400); +} diff --git a/src/server/health.ts b/src/server/health.ts index 338a5e5..15148ad 100644 --- a/src/server/health.ts +++ b/src/server/health.ts @@ -111,7 +111,7 @@ export class HealthService { ]); const compatibility = installedPackageVersion !== null && - /^0\.4\./u.test(installedPackageVersion) && + /^0\.5\./u.test(installedPackageVersion) && hostCompatibility; const persistence = d1; const checks = { @@ -127,7 +127,7 @@ export class HealthService { return { status: Object.values(checks).every(Boolean) ? 'ok' : 'degraded', schemaVersion, - workshopVersion: '0.4.2', + workshopVersion: '0.5.0', checks, tables, indexes, diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts new file mode 100644 index 0000000..575cb0f --- /dev/null +++ b/src/server/lifecycle.ts @@ -0,0 +1,102 @@ +import { WorkshopError } from '../protocol/errors.js'; + +export interface BackgroundTrigger { + run(signal: AbortSignal): Promise; + checkpoint(signal: AbortSignal): Promise; +} + +export interface BoundedTriggerOptions { + trigger: BackgroundTrigger; + intervalMs: number; + timeoutMs: number; + onError?: (error: unknown) => void | Promise; +} + +export interface BoundedTriggerController { + readonly running: boolean; + start(): void; + invoke(): Promise; + stop(signal?: AbortSignal): Promise; +} + +export function createBoundedTrigger( + options: BoundedTriggerOptions, +): BoundedTriggerController { + if ( + !Number.isSafeInteger(options.intervalMs) || + options.intervalMs < 100 || + !Number.isSafeInteger(options.timeoutMs) || + options.timeoutMs < 1 || + options.timeoutMs > options.intervalMs + ) + throw new WorkshopError( + 'validation_failed', + 'Background trigger bounds are invalid', + 400, + ); + let timer: ReturnType | undefined; + let inFlight: Promise | undefined; + let running = false; + let stopping = false; + const abort = new AbortController(); + const invoke = async (): Promise => { + if (stopping || inFlight) return false; + const timeout = new AbortController(); + const timeoutId = setTimeout( + () => timeout.abort(new Error('background trigger timeout')), + options.timeoutMs, + ); + const signal = AbortSignal.any([abort.signal, timeout.signal]); + inFlight = options.trigger + .run(signal) + .catch(async (error) => { + try { + await options.onError?.(error); + } catch { + // Trigger error telemetry never changes lifecycle behavior. + } + }) + .finally(() => { + clearTimeout(timeoutId); + inFlight = undefined; + }); + await inFlight; + return true; + }; + return { + get running() { + return running; + }, + start() { + if (running || stopping) return; + running = true; + timer = setInterval(() => void invoke(), options.intervalMs); + timer.unref?.(); + }, + invoke, + async stop(signal = new AbortController().signal) { + if (stopping) { + await inFlight; + return; + } + stopping = true; + running = false; + if (timer) clearInterval(timer); + abort.abort(new Error('service stopping')); + await Promise.race([inFlight ?? Promise.resolve(), aborted(signal)]); + await options.trigger.checkpoint(signal); + }, + }; +} + +function aborted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); +} diff --git a/src/server/memories.ts b/src/server/memories.ts index 01a0117..f263b79 100644 --- a/src/server/memories.ts +++ b/src/server/memories.ts @@ -38,7 +38,7 @@ import { import { commitWorkshopCanonicalSearchMutationV1, type WorkshopSearchDomainV1, -} from './search.js'; +} from './canonical-producer.js'; import { isoDate, memorySlug, @@ -271,7 +271,7 @@ export class MemoryService { input: UpsertMemoryInput, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'memory-write'); + const context = await this.#context(authorization, 'memory:write'); const workspaceId = requireActiveWorkspace(context); const slug = memorySlug(slugValue); const description = requiredText( @@ -462,7 +462,7 @@ export class MemoryService { authorization: AuthorizationContext, idempotencyKey: string, ): Promise { - const context = await this.#context(authorization, 'memory-write'); + const context = await this.#context(authorization, 'memory:write'); const key = requiredText(idempotencyKey, 'idempotencyKey', 256); const workspaceId = requireActiveWorkspace(context); const storedIdempotencyKey = `${workspaceId}\u0000${context.principalId}\u0000${key}`; @@ -621,7 +621,7 @@ export class MemoryService { expectedRevision: number, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'memory-write'); + const context = await this.#context(authorization, 'memory:write'); if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw validation('expectedRevision must be a positive safe integer'); const slug = memorySlug(slugValue); @@ -761,16 +761,7 @@ export class MemoryService { slug: string, context: AuthorizationContext, ): Promise<{ eventId: string; sequence: number }> { - const row = await this.db - .prepare( - `SELECT current_event_id AS event_id, current_event_sequence AS sequence - FROM taproot_unified_search_source_registry - WHERE installation_id = ? AND source_kind = 'memory' AND source_id = ?`, - ) - .bind(context.installationId, slug) - .first<{ event_id: string; sequence: number }>(); - if (!row || !Number.isSafeInteger(Number(row.sequence))) throw denied(); - return { eventId: row.event_id, sequence: Number(row.sequence) }; + return this.#search().currentPredecessor(slug, context); } #revisionStatement( @@ -831,7 +822,7 @@ export class MemoryService { async #context( authorization: AuthorizationContext, - capability: 'read' | 'memory-write' = 'read', + capability: 'read' | 'memory:write' = 'read', ): Promise { const context = await requireCurrentAuthorization( this.options.authorization, diff --git a/src/server/prompts.ts b/src/server/prompts.ts index c740bc3..7bbeb86 100644 --- a/src/server/prompts.ts +++ b/src/server/prompts.ts @@ -38,7 +38,7 @@ import { import { commitWorkshopCanonicalSearchMutationV1, type WorkshopSearchDomainV1, -} from './search.js'; +} from './canonical-producer.js'; import { pageLimit, requiredText, validation } from './validation.js'; interface PromptRow { @@ -235,7 +235,7 @@ export class PromptService { input: CreatePromptInput, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'prompt-write'); + const context = await this.#context(authorization, 'prompt:write'); const workspaceId = requireActiveWorkspace(context); const now = this.#clock().toISOString(); const id = input.id ? requiredText(input.id, 'id', 256) : this.#createId(); @@ -330,7 +330,7 @@ export class PromptService { input: UpdatePromptInput, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'prompt-write'); + const context = await this.#context(authorization, 'prompt:write'); validateRevision(input.expectedRevision); const current = await this.#getForMutation(id, context); if (current.revision !== input.expectedRevision) @@ -457,7 +457,7 @@ export class PromptService { expectedRevision: number, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'prompt-write'); + const context = await this.#context(authorization, 'prompt:write'); validateRevision(expectedRevision); const current = await this.#getForMutation(id, context); if (current.revision !== expectedRevision) @@ -563,21 +563,7 @@ export class PromptService { id: string, context: AuthorizationContext, ): Promise<{ eventId: string; sequence: number }> { - const row = await this.db - .prepare( - `SELECT current_event_id AS search_event_id, - current_event_sequence AS search_event_sequence - FROM taproot_unified_search_source_registry - WHERE installation_id = ? AND source_kind = 'prompt' AND source_id = ?`, - ) - .bind(context.installationId, id) - .first>(); - if (!row?.search_event_id || row.search_event_sequence === null) - throw denied(); - return { - eventId: row.search_event_id, - sequence: row.search_event_sequence, - }; + return this.options.search.currentPredecessor(id, context); } async #row(idValue: string, context: AuthorizationContext) { @@ -626,7 +612,7 @@ export class PromptService { async #context( authorization: AuthorizationContext, - capability: 'read' | 'prompt-write' = 'read', + capability: 'read' | 'prompt:write' = 'read', ): Promise { const context = await requireCurrentAuthorization( this.options.authorization, diff --git a/src/server/search.ts b/src/server/search.ts index 511bab2..4b6664c 100644 --- a/src/server/search.ts +++ b/src/server/search.ts @@ -1,97 +1,107 @@ -import type { - AuthorizedSearchServiceV1, - ExternalSearchDomainMutationCoordinatorV1, - ExternalSearchLoadedSourceV1, - ExternalSearchProducerCallbacksV1, - ExternalSearchProducerDescriptorV1, - ExternalSearchProducerGuardV1, - SearchMaterializationAdminGuardV1, - SearchProjectionFieldV1, - SearchProjectionSegmentV1, - SearchRequest, - SearchPage, - SemanticSearchAdminV1, - TaprootHostWriteCapability, - TaprootWriteOptions, - UnifiedSearchFiltersV1, - UnifiedSearchReferenceV1, -} from '@gnolith/taproot'; +import type { TaprootHostWriteCapability } from '@gnolith/taproot'; +import { + canonicalSearchHashV1, + createAuthorizedSearchServiceV1, + createSearchMaterializationAdminGuardV1, + createSemanticSearchAdminV1, + registerExternalProducerKindV1, + registerExternalProducerV1, + type ExternalProducer, + type ExternalProjectedSearchDocumentV1, + type ExternalSearchKind, + type ProducerSource, + type SearchMaterializationAdminGuardV1, + type SearchRequest, + type SearchResultV1, + type SemanticSearchAdminV1, +} from '@gnolith/taproot/search'; import type { AuthorizationContext } from '../protocol/authorization.js'; import { WorkshopError } from '../protocol/errors.js'; -import type { D1DatabaseLike, D1ResultLike } from './database.js'; import { + authorizeRecord, normalizeVisibilityScope, - serializeVisibilityScope, type VisibilityScopeV1, } from './authorization.js'; +import { + createWorkshopCanonicalOwnerAdapterV1, + type WorkshopSearchDomainV1, +} from './canonical-producer.js'; +import type { D1DatabaseLike } from './database.js'; export type WorkshopSearchSourceKind = 'task' | 'memory' | 'prompt'; -export interface WorkshopSearchDomainV1 { - readonly coordinator: ExternalSearchDomainMutationCoordinatorV1; - readonly producer: ExternalSearchProducerGuardV1; - readonly descriptor: Readonly; -} - -interface ExternalAuthorization { - version: 1; - sourceId: string; - sourceRevision: string; - sourcePolicyRevision: number; - workspaceId: string | null; - ownerPrincipalId: string; - visibility: VisibilityScopeV1; -} - -export interface WorkshopCanonicalSearchMutationV1 { - context: AuthorizationContext; - eventId: string; - sourceId: string; - operation: 'upsert' | 'delete'; - changeClass: string; - sourceRevision: number; - sourcePolicyRevision: number; - predecessor: { eventId: string; sequence: number } | null; - canonicalPostState: unknown; - statements: readonly { sql: string; values: readonly unknown[] }[]; -} - -export interface WorkshopCanonicalSearchMutationReceiptV1 { - eventId: string; - authorizationRevision: number; - searchGeneration: number; - results: readonly D1ResultLike[]; +export interface WorkshopSearchIntegrationDomainV1 extends WorkshopSearchDomainV1 { + /** Compatibility spelling for the pre-0.5 Workshop test/host surface. */ + readonly producer: { + adoptLegacyPage( + context: AuthorizationContext, + input: { cursor?: string | null; limit: number }, + ): Promise<{ + enumerated: number; + adopted: number; + cursor: string | null; + complete: boolean; + }>; + }; } -export async function commitWorkshopCanonicalSearchMutationV1( - domain: WorkshopSearchDomainV1, - input: WorkshopCanonicalSearchMutationV1, -): Promise { - const sealed = await domain.coordinator.sealCanonicalMutation({ - ...input, - context: input.context, - sourceRevision: String(input.sourceRevision), - }); - const receipt = await domain.producer.commitCanonicalMutation(sealed); - return receipt; +export type WorkshopSearchRequestV1 = Omit & { + kinds?: readonly ( + | 'statement' + | 'item' + | 'task' + | 'memory' + | 'prompt' + | 'resource' + | 'annotation' + )[]; +}; + +export type WorkshopSearchKindV1 = + | 'statement' + | 'item' + | 'task' + | 'memory' + | 'prompt' + | 'resource' + | 'annotation'; + +export type WorkshopSearchResultV1 = Omit & { + kind: WorkshopSearchKindV1; +}; + +export interface WorkshopSearchPageV1 { + results: WorkshopSearchResultV1[]; + cursor?: string; +} + +export interface WorkshopAuthorizedSearchServiceV1 { + search( + request: WorkshopSearchRequestV1, + context: AuthorizationContext, + ): Promise; + hydrate( + result: WorkshopSearchResultV1, + context: AuthorizationContext, + ): Promise; } export interface WorkshopSearchIntegrationV1 { - readonly task: WorkshopSearchDomainV1; - readonly memory: WorkshopSearchDomainV1; - readonly prompt: WorkshopSearchDomainV1; - readonly service: AuthorizedSearchServiceV1; + readonly task: WorkshopSearchIntegrationDomainV1; + readonly memory: WorkshopSearchIntegrationDomainV1; + readonly prompt: WorkshopSearchIntegrationDomainV1; + readonly service: WorkshopAuthorizedSearchServiceV1; readonly materialization: SearchMaterializationAdminGuardV1; readonly semantic: SemanticSearchAdminV1; search( - request: SearchRequest, + request: WorkshopSearchRequestV1, context: AuthorizationContext, - ): Promise; + ): Promise; } export interface CreateWorkshopSearchIntegrationOptionsV1 { db: D1DatabaseLike; - taproot: TaprootWriteOptions; + taproot: { baseIri: string; clock?: () => Date }; hostCapability: TaprootHostWriteCapability; installationId: string; registrationContext: AuthorizationContext; @@ -99,98 +109,190 @@ export interface CreateWorkshopSearchIntegrationOptionsV1 { createId?: () => string; } -const CONTRACT_VERSION = 'taproot-external-search-producer-v1'; -const AUTHORIZATION_VERSION = 'workshop-cnf-policy-v1'; +const SEARCH_NAMESPACE = 'workshop'; export async function createWorkshopSearchIntegrationV1( options: CreateWorkshopSearchIntegrationOptionsV1, ): Promise { - const { - canonicalSearchHashV1, - createAuthorizedSearchServiceV1, - createExternalSearchDomainMutationCoordinatorV1, - createExternalSearchDomainPolicyAuthorityV1, - createExternalSearchProducerGuardV1, - createSearchMaterializationAdminGuardV1, - createSemanticSearchAdminV1, - } = await import('@gnolith/taproot'); - const db = options.db; - const materialization = await createSearchMaterializationAdminGuardV1( - db, - options.taproot, - options.hostCapability, - ); - await materialization.initialize(options.registrationContext); - const domains: Array< - readonly [WorkshopSearchSourceKind, WorkshopSearchDomainV1] - > = []; + const domains = new Map< + WorkshopSearchSourceKind, + WorkshopSearchIntegrationDomainV1 + >(); + const domainKinds = new Map(); for (const sourceKind of ['task', 'memory', 'prompt'] as const) { - const binding = { - domain: 'workshop', - sourceKind, - capability: capabilityFor(sourceKind), - changeClasses: changeClassesFor(sourceKind), - } as const; - const coordinator = await createExternalSearchDomainMutationCoordinatorV1( - db, - options.taproot, - options.hostCapability, - binding, - ); - const authority = await createExternalSearchDomainPolicyAuthorityV1( - db, - options.taproot, + const controller = createWorkshopCanonicalOwnerAdapterV1(options.db); + const registeredKind = await registerExternalProducerKindV1( + options.db, + { baseIri: options.taproot.baseIri }, options.hostCapability, - binding, + { + installationId: options.installationId, + namespace: SEARCH_NAMESPACE, + localName: sourceKind, + ownerDomain: 'workshop', + adapter: controller.adapter, + }, ); - const descriptor: ExternalSearchProducerDescriptorV1 = { - version: 1, + const fingerprint = await canonicalSearchHashV1({ + package: '@gnolith/workshop', + version: '0.5.0', sourceKind, - owningDomain: 'workshop', - producerFingerprint: await canonicalSearchHashV1({ - package: '@gnolith/workshop', - sourceKind, - contractVersion: CONTRACT_VERSION, - projectionVersion: `workshop-${sourceKind}-projection-v1`, - authorizationContractVersion: AUTHORIZATION_VERSION, - }), - contractVersion: CONTRACT_VERSION, projectionVersion: `workshop-${sourceKind}-projection-v1`, - authorizationContractVersion: AUTHORIZATION_VERSION, + }); + const producer: ExternalProducer = { + descriptor: { + registration: registeredKind.registration, + implementationVersion: '0.5.0', + fingerprint, + projectionVersion: `workshop-${sourceKind}-projection-v1`, + changeClasses: changeClassesFor(sourceKind), + }, + async enumerate(cursor, limit) { + const page = await enumerate( + options.db, + options.installationId, + sourceKind, + cursor, + limit, + ); + return { ids: page.sourceIds, cursor: page.nextCursor }; + }, + async loadExact(sourceId, sourceRevision) { + const source = await load( + options.db, + options.installationId, + sourceKind, + sourceId, + sourceRevision ?? null, + ); + if (!source) + throw new WorkshopError( + 'not_found', + 'Canonical source not found', + 404, + ); + return source; + }, + project(source) { + return Promise.resolve(project(sourceKind, source)); + }, + async authorize(reference, context) { + const source = await load( + options.db, + options.installationId, + sourceKind, + reference.sourceId, + reference.sourceRevision, + ); + if (!source) return false; + try { + authorizeSource(source, context); + return true; + } catch { + return false; + } + }, + async hydrate(reference, context) { + const source = await load( + options.db, + options.installationId, + sourceKind, + reference.sourceId, + reference.sourceRevision, + ); + if (!source) + throw new WorkshopError( + 'not_found', + 'Canonical source not found', + 404, + ); + authorizeSource(source, context); + return source.canonical; + }, }; - const callbacks = callbacksFor( + const registered = await registerExternalProducerV1( options.db, - options.installationId, - sourceKind, - ); - const producer = await createExternalSearchProducerGuardV1( - db, - options.taproot, + { + baseIri: options.taproot.baseIri, + ...(options.clock ? { clock: options.clock } : {}), + }, options.hostCapability, - descriptor, - callbacks, - authority, - coordinator, + producer, ); - domains.push([sourceKind, { coordinator, producer, descriptor }]); + const domain = controller.bind(registered); + domainKinds.set(sourceKind, registered.kind); + domains.set(sourceKind, { + ...domain, + producer: { + async adoptLegacyPage(context, input) { + const receipt = await domain.adopt(context, input); + return { ...receipt, complete: receipt.cursor === null }; + }, + }, + }); } - const byKind = Object.fromEntries(domains) as Record< - WorkshopSearchSourceKind, - WorkshopSearchDomainV1 - >; - const semantic = createSemanticSearchAdminV1(db, { + + const semantic = createSemanticSearchAdminV1(options.db, { installationId: options.installationId, ...(options.clock ? { clock: options.clock } : {}), ...(options.createId ? { createId: options.createId } : {}), }); - const service = createAuthorizedSearchServiceV1(db, { + const rawService = createAuthorizedSearchServiceV1(options.db, { installationId: options.installationId, semantic, }); + const materialization = await createSearchMaterializationAdminGuardV1( + options.db, + { + baseIri: options.taproot.baseIri, + ...(options.clock ? { clock: options.clock } : {}), + }, + options.hostCapability, + ); + await materialization.initialize(options.registrationContext); + const toTaprootRequest = ({ + kinds, + ...request + }: WorkshopSearchRequestV1): SearchRequest => ({ + ...request, + ...(kinds + ? { + kinds: kinds.map((kind) => + kind === 'task' || kind === 'memory' || kind === 'prompt' + ? requiredDomainKind(domainKinds, kind) + : kind, + ), + } + : {}), + }); + const service: WorkshopAuthorizedSearchServiceV1 = { + async search(request, context) { + const page = await rawService.search(toTaprootRequest(request), context); + return { + ...page, + results: page.results.map((result) => ({ + ...result, + kind: + typeof result.kind === 'string' + ? result.kind + : (result.kind.localName as WorkshopSearchKindV1), + })), + }; + }, + hydrate(result, context) { + const kind = + result.kind === 'task' || + result.kind === 'memory' || + result.kind === 'prompt' + ? requiredDomainKind(domainKinds, result.kind) + : result.kind; + return rawService.hydrate({ ...result, kind } as SearchResultV1, context); + }, + }; return { - task: byKind.task, - memory: byKind.memory, - prompt: byKind.prompt, + task: requiredDomain(domains, 'task'), + memory: requiredDomain(domains, 'memory'), + prompt: requiredDomain(domains, 'prompt'), service, semantic, materialization, @@ -198,47 +300,6 @@ export async function createWorkshopSearchIntegrationV1( }; } -function callbacksFor( - db: D1DatabaseLike, - installationId: string, - kind: WorkshopSearchSourceKind, -): ExternalSearchProducerCallbacksV1 { - return { - enumerateLegacyCurrent: ({ cursor, limit }) => - enumerate(db, installationId, kind, cursor, limit), - loadCurrent: ({ sourceId, expectedSourceRevision }) => - load(db, installationId, kind, sourceId, expectedSourceRevision), - projectCurrent: ({ source, authorization }) => - project(kind, source, authorization), - authorizeCurrentReference: async (authority, input) => { - assertAuthority(authority); - const source = await load( - db, - installationId, - kind, - input.sourceId, - input.sourceRevision, - ); - if (!source || source.sourcePolicyRevision !== input.sourcePolicyRevision) - throw denied(); - return authorizationOf(source); - }, - hydrateCurrentReference: async (authority, input) => { - assertAuthority(authority); - const source = await load( - db, - installationId, - kind, - input.sourceId, - input.sourceRevision, - ); - if (!source || source.sourcePolicyRevision !== input.sourcePolicyRevision) - throw denied(); - return source.canonical; - }, - }; -} - async function enumerate( db: D1DatabaseLike, installationId: string, @@ -246,19 +307,22 @@ async function enumerate( cursor: string | null, limit: number, ): Promise<{ sourceIds: readonly string[]; nextCursor: string | null }> { - if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw denied(); - const after = cursor ?? ''; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) + throw new WorkshopError( + 'validation_failed', + 'Search page limit is invalid', + ); const table = tableFor(kind); const id = idColumnFor(kind); - const deleted = kind === 'task' ? '1 = 1' : 'deleted_at IS NULL'; + const live = kind === 'task' ? '1 = 1' : 'deleted_at IS NULL'; const rows = await db .prepare( `SELECT ${id} AS id FROM ${table} - WHERE installation_id = ? AND ${id} > ? AND ${deleted} + WHERE installation_id = ? AND ${id} > ? AND ${live} AND visibility_scope IS NOT NULL AND authorization_revision IS NOT NULL ORDER BY ${id} ASC LIMIT ?`, ) - .bind(installationId, after, limit + 1) + .bind(installationId, cursor ?? '', limit + 1) .all<{ id: string }>(); const visible = rows.results.slice(0, limit); return { @@ -273,261 +337,140 @@ async function load( installationId: string, kind: WorkshopSearchSourceKind, sourceId: string, - expectedSourceRevision: string | null, -): Promise { - if (typeof sourceId !== 'string' || sourceId.length === 0) throw denied(); + expectedRevision: string | null, +): Promise { const table = tableFor(kind); const id = idColumnFor(kind); - const deleted = kind === 'task' ? '1 = 1' : 'deleted_at IS NULL'; + const live = kind === 'task' ? '1 = 1' : 'deleted_at IS NULL'; const row = await db .prepare( `SELECT * FROM ${table} - WHERE installation_id = ? AND ${id} = ? AND ${deleted}`, + WHERE installation_id = ? AND ${id} = ? AND ${live}`, ) .bind(installationId, sourceId) .first>(); if (!row) return null; - const revision = integer(row.revision, 'revision'); - if ( - expectedSourceRevision !== null && - expectedSourceRevision !== String(revision) - ) + const revision = integer(row.revision); + if (expectedRevision !== null && expectedRevision !== String(revision)) return null; - const canonical = canonicalOf(kind, row); return { sourceId, sourceRevision: String(revision), - sourcePolicyRevision: integer(row.policy_revision, 'policy_revision'), - canonical, + sourcePolicyRevision: integer(row.policy_revision), + canonical: canonicalRecord(kind, row), }; } -function authorizationOf( - source: ExternalSearchLoadedSourceV1, -): ExternalAuthorization { +function project( + kind: WorkshopSearchSourceKind, + source: ProducerSource, +): ExternalProjectedSearchDocumentV1 { const canonical = record(source.canonical); + const fields = + kind === 'task' + ? ['title', 'objective', 'description', 'prompt', 'result'] + : kind === 'memory' + ? ['title', 'description', 'content'] + : ['name', 'title', 'promptText', 'scope', 'role']; + const text = fields + .map((field) => canonical[field]) + .filter((value): value is string => typeof value === 'string' && !!value) + .join(' ') + // Taproot external projections are tokens: authored line breaks and other + // control characters must not cross the producer boundary. + .replace(/[\u0000-\u001f\u007f]+/gu, ' ') + .replace(/\s+/gu, ' ') + .trim(); + if (!text) + throw new WorkshopError('validation_failed', 'Source is not searchable'); return { - version: 1, - sourceId: source.sourceId, - sourceRevision: source.sourceRevision, - sourcePolicyRevision: source.sourcePolicyRevision, - workspaceId: nullableString(canonical.workspaceId), - ownerPrincipalId: string(canonical.ownerPrincipalId), - visibility: normalizeVisibilityScope( - canonical.visibility as VisibilityScopeV1, - ), + text, + ...(typeof canonical.title === 'string' + ? { title: canonical.title } + : typeof canonical.name === 'string' + ? { title: canonical.name } + : {}), + ...(typeof canonical.language === 'string' + ? { language: canonical.language } + : {}), }; } -function project( - kind: WorkshopSearchSourceKind, - source: ExternalSearchLoadedSourceV1, - authorization: ExternalAuthorization, -): Promise<{ - version: 1; - documents: Array<{ - documentSlot: string; - canonicalReference: UnifiedSearchReferenceV1; - filterMetadata: UnifiedSearchFiltersV1; - text: string; - segments: SearchProjectionSegmentV1[]; - }>; - replaceAll: true; - removeDocumentSlots: string[]; -}> { +function authorizeSource( + source: ProducerSource, + context: AuthorizationContext, +): void { const canonical = record(source.canonical); - const language = optionalString(canonical.language) ?? 'en'; - const fields: Array<[SearchProjectionFieldV1, string]> = []; - let filters: UnifiedSearchFiltersV1 = emptyFilters(language); - let reference: UnifiedSearchReferenceV1; - if (kind === 'task') { - reference = { kind, taskId: source.sourceId }; - push(fields, 'title', canonical.title); - push(fields, 'description', canonical.objective); - push(fields, 'description', canonical.description); - push(fields, 'prompt', canonical.prompt); - push(fields, 'description', stringify(canonical.constraints)); - push(fields, 'description', stringify(canonical.acceptanceCriteria)); - push(fields, 'description', stringify(canonical.relationships)); - const status = canonical.archivedAt - ? 'archived' - : canonical.completedAt - ? 'completed' - : canonical.claimed === true - ? 'claimed' - : 'unclaimed'; - push(fields, 'status', status); - push(fields, 'content', canonical.assignedPrincipalId); - push(fields, 'result', canonical.outcome); - filters = { - ...filters, - byKind: { task: { statuses: [status] } }, - }; - } else if (kind === 'memory') { - reference = { kind, memoryId: source.sourceId }; - push(fields, 'title', canonical.title); - push(fields, 'description', canonical.description); - push(fields, 'content', canonical.content); - push(fields, 'content', stringify(canonical.applicability)); - push(fields, 'content', stringify(canonical.provenance)); - } else { - reference = { kind, promptId: source.sourceId }; - push(fields, 'title', canonical.name); - push(fields, 'title', canonical.title); - push(fields, 'prompt', canonical.promptText); - push(fields, 'content', canonical.scope); - push(fields, 'content', canonical.role); - push(fields, 'content', stringify(canonical.variables)); - push(fields, 'status', canonical.active === true ? 'active' : 'inactive'); - push(fields, 'content', String(canonical.priority)); - push(fields, 'content', String(canonical.order)); - } - const { text, segments } = combine(source.sourceId, language, fields); - if (text.length === 0) throw denied(); - if ( - authorization.sourceId !== source.sourceId || - authorization.sourceRevision !== source.sourceRevision || - authorization.sourcePolicyRevision !== source.sourcePolicyRevision - ) - throw denied(); - return Promise.resolve({ - version: 1 as const, - documents: [ - { - documentSlot: 'canonical', - canonicalReference: reference, - filterMetadata: filters, - text, - segments, - }, - ], - replaceAll: true as const, - removeDocumentSlots: [], - }); + authorizeRecord( + { + installationId: string(canonical.installationId), + visibility: normalizeVisibilityScope( + canonical.visibility as VisibilityScopeV1, + ), + authorizationRevision: integer(canonical.authorizationRevision), + }, + context, + ); } -function canonicalOf( +function requiredDomainKind( + domains: Map, + kind: WorkshopSearchSourceKind, +): ExternalSearchKind { + const domain = requiredDomain(domains, kind); + return domain; +} + +function canonicalRecord( kind: WorkshopSearchSourceKind, row: Record, -) { - const shared = { - id: string(row[idColumnFor(kind)]), - revision: integer(row.revision, 'revision'), - policyRevision: integer(row.policy_revision, 'policy_revision'), - language: string(row.language), - attribution: json(row.attribution_json), - installationId: string(row.installation_id), - ownerPrincipalId: string(row.owner_principal_id), - workspaceId: string(row.workspace_id), - visibility: visibility(row.visibility_scope), - authorizationRevision: integer( - row.authorization_revision, - 'authorization_revision', - ), - createdAt: string(row.created_at), - updatedAt: string(row.updated_at), +): Record { + const common = { + installationId: row.installation_id, + ownerPrincipalId: row.owner_principal_id, + workspaceId: row.workspace_id, + visibility: json(row.visibility_scope), + authorizationRevision: row.authorization_revision, + revision: row.revision, + policyRevision: row.policy_revision, + language: row.language, }; if (kind === 'task') return { - ...shared, - title: string(row.title), - ...(optionalString(row.objective) - ? { objective: optionalString(row.objective) } - : {}), - description: string(row.description), - constraints: json(row.constraints_json), - acceptanceCriteria: json(row.acceptance_criteria_json), - relationships: json(row.relationships_json), - ...(optionalString(row.role) ? { role: optionalString(row.role) } : {}), - prompt: string(row.prompt), - claimed: integer(row.claimed, 'claimed') === 1, - ...(optionalString(row.claimed_at) - ? { claimedAt: normalizeDate(string(row.claimed_at)) } - : {}), - ...(optionalString(row.completed_at) - ? { completedAt: normalizeDate(string(row.completed_at)) } - : {}), - ...(optionalString(row.archived_at) - ? { archivedAt: normalizeDate(string(row.archived_at)) } - : {}), - ...(optionalString(row.assigned_principal_id) - ? { assignedPrincipalId: optionalString(row.assigned_principal_id) } - : {}), - ...(optionalString(row.result) - ? { result: optionalString(row.result) } - : {}), - ...(optionalString(row.outcome_kind) - ? { outcomeKind: optionalString(row.outcome_kind) } - : {}), - memorySlugs: json(row.memory_slugs), - contextQueries: json(row.context_queries), + ...common, + id: row.id, + title: row.title, + objective: row.objective, + description: row.description, + prompt: row.prompt, + result: row.result, }; - if (kind === 'memory') { - const { id, ...memoryShared } = shared; + if (kind === 'memory') return { - ...memoryShared, - slug: id, - title: string(row.title), - description: string(row.description), - content: string(row.content), - applicability: json(row.applicability_json), - provenance: json(row.provenance_json), + ...common, + slug: row.slug, + title: row.title, + description: row.description, + content: row.content, }; - } return { - ...shared, - name: string(row.name), - title: string(row.title), - promptText: string(row.prompt_text), - ...(optionalString(row.scope) ? { scope: optionalString(row.scope) } : {}), - ...(optionalString(row.role) ? { role: optionalString(row.role) } : {}), - variables: json(row.variables_json), - active: integer(row.active, 'active') === 1, - priority: integer(row.priority, 'priority'), - order: integer(row.sort_order, 'sort_order'), - ...(optionalString(row.deactivated_at) - ? { deactivatedAt: optionalString(row.deactivated_at) } - : {}), + ...common, + id: row.id, + name: row.name, + title: row.title, + promptText: row.prompt_text, + scope: row.scope, + role: row.role, }; } -function combine( - sourceId: string, - language: string, - fields: readonly [SearchProjectionFieldV1, string][], -): { text: string; segments: SearchProjectionSegmentV1[] } { - let text = ''; - const segments: SearchProjectionSegmentV1[] = []; - for (const [field, value] of fields) { - if (text) text += '\n'; - const start = text.length; - text += value; - segments.push({ - field, - sourceId, - language, - text: value, - documentStart: start, - documentEnd: text.length, - }); - } - return { text, segments }; -} - -function emptyFilters(language: string): UnifiedSearchFiltersV1 { - return { - languages: [language], - sourceRevisions: [], - byKind: {}, - }; -} - -function push( - fields: Array<[SearchProjectionFieldV1, string]>, - field: SearchProjectionFieldV1, - value: unknown, -): void { - if (typeof value === 'string' && value.trim()) fields.push([field, value]); +function requiredDomain( + domains: Map, + kind: WorkshopSearchSourceKind, +): T { + const domain = domains.get(kind); + if (!domain) throw new Error(`Missing Workshop ${kind} search domain`); + return domain; } function tableFor(kind: WorkshopSearchSourceKind): string { @@ -539,15 +482,7 @@ function tableFor(kind: WorkshopSearchSourceKind): string { } function idColumnFor(kind: WorkshopSearchSourceKind): string { - return kind === 'task' ? 'id' : kind === 'memory' ? 'slug' : 'id'; -} - -function capabilityFor(kind: WorkshopSearchSourceKind): string { - return kind === 'task' - ? 'task-write' - : kind === 'memory' - ? 'memory-write' - : 'prompt-write'; + return kind === 'memory' ? 'slug' : 'id'; } function changeClassesFor(kind: WorkshopSearchSourceKind): readonly string[] { @@ -561,63 +496,31 @@ function changeClassesFor(kind: WorkshopSearchSourceKind): readonly string[] { ]; } -function normalizeDate(value: string): string { - return new Date( - value.includes('T') ? value : `${value.replace(' ', 'T')}Z`, - ).toISOString(); +function integer(value: unknown): number { + const number = Number(value); + if (!Number.isSafeInteger(number)) + throw new WorkshopError('internal_error', 'Invalid canonical integer'); + return number; } -function visibility(value: unknown): VisibilityScopeV1 { - const parsed = json(value); - const normalized = normalizeVisibilityScope(parsed as VisibilityScopeV1); - if (serializeVisibilityScope(normalized) !== String(value)) throw denied(); - return normalized; +function string(value: unknown): string { + if (typeof value !== 'string' || !value) + throw new WorkshopError('internal_error', 'Invalid canonical string'); + return value; } function json(value: unknown): unknown { - if (typeof value !== 'string') throw denied(); + if (typeof value !== 'string') + throw new WorkshopError('internal_error', 'Invalid canonical JSON'); try { return JSON.parse(value); } catch { - throw denied(); + throw new WorkshopError('internal_error', 'Invalid canonical JSON'); } } function record(value: unknown): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) - throw denied(); + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new WorkshopError('internal_error', 'Invalid canonical record'); return value as Record; } - -function string(value: unknown): string { - if (typeof value !== 'string' || !value) throw denied(); - return value; -} - -function optionalString(value: unknown): string | undefined { - return typeof value === 'string' && value ? value : undefined; -} - -function nullableString(value: unknown): string | null { - return value === null || value === undefined ? null : string(value); -} - -function integer(value: unknown, field: string): number { - const number = Number(value); - if (!Number.isSafeInteger(number)) - throw new WorkshopError('internal_error', `Invalid canonical ${field}`); - return number; -} - -function stringify(value: unknown): string { - return typeof value === 'string' ? value : JSON.stringify(value); -} - -function assertAuthority(value: { readonly kind: string }): void { - if (value.kind !== 'taproot-external-search-domain-policy-authority-v1') - throw denied(); -} - -function denied(): WorkshopError { - return new WorkshopError('forbidden', 'Authorization denied'); -} diff --git a/src/server/service.ts b/src/server/service.ts new file mode 100644 index 0000000..0a3d587 --- /dev/null +++ b/src/server/service.ts @@ -0,0 +1,496 @@ +import { createServer, type Server } from 'node:http'; +import { Readable } from 'node:stream'; +import { + OPERATION_ROUTE_PREFIX, + operationCatalog, + type OperationName, +} from '../generated/operations.js'; +import type { + OperationRuntime, + WorkshopStatusSnapshot, +} from '../operations.js'; +import { WorkshopError, normalizeWorkshopError } from '../protocol/errors.js'; +import type { AuthenticationVerifier } from './auth.js'; +import type { VerifiedWaystoneAssets } from './assets.js'; +import type { ListenerConfig } from './config.js'; +import { validateListenerConfig } from './config.js'; +import type { BoundedTriggerController } from './lifecycle.js'; +import { createGnolithMcpServer } from '../mcp/server.js'; +import { + assertVerifiedWorkshopProducerBinding, + type VerifiedWorkshopProducerBinding, + type WorkshopProducerBindingExpectation, +} from '../producers.js'; + +export interface CloseableDependency { + close(): void | Promise; +} + +export interface WorkshopHttpServiceOptions { + version: string; + listener: ListenerConfig; + authentication: AuthenticationVerifier; + operations: OperationRuntime; + producerBinding: { + binding: VerifiedWorkshopProducerBinding; + expectation: WorkshopProducerBindingExpectation; + }; + preflight(signal: AbortSignal): Promise; + corsOrigins?: readonly string[]; + assets?: VerifiedWaystoneAssets; + trigger?: BoundedTriggerController; + dependencies?: readonly CloseableDependency[]; + shutdownTimeoutMs?: number; + logger?: { + info(event: Readonly>): void; + error(event: Readonly>): void; + }; +} + +export interface WorkshopHttpService { + readonly listener: ListenerConfig; + readonly fetch: (request: Request) => Promise; + start(): Promise<{ host: string; port: number }>; + stop(): Promise; +} + +export function createWorkshopHttpService( + options: WorkshopHttpServiceOptions, +): WorkshopHttpService { + const listener = validateListenerConfig(options.listener); + assertVerifiedWorkshopProducerBinding( + options.producerBinding.binding, + options.producerBinding.expectation, + ); + const origins = new Set(options.corsOrigins ?? []); + const mcp = createGnolithMcpServer({ + version: options.version, + operations: options.operations, + authenticate: (request) => options.authentication.authenticate(request), + allowedOrigins: [...origins], + challenge: options.authentication.challenge, + }); + let server: Server | undefined; + let started = false; + let draining = false; + let active = 0; + const drained = new Set<() => void>(); + const fetch = async (request: Request): Promise => { + if (draining && request.method !== 'GET') + return errorResponse( + new WorkshopError('dependency_unavailable', 'Service is draining', 503), + request, + origins, + ); + active += 1; + try { + const url = new URL(request.url); + if (url.pathname === '/health/live' && request.method === 'GET') + return Response.json( + { status: 'live' }, + { + headers: { + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }, + }, + ); + if ( + url.pathname === '/.well-known/oauth-protected-resource/mcp' && + request.method === 'GET' + ) { + const metadata = options.authentication.protectedResourceMetadata(); + return metadata + ? Response.json(metadata, { + headers: { 'cache-control': 'public, max-age=300' }, + }) + : new Response('Not found', { status: 404 }); + } + if (url.pathname === '/mcp') return mcp.handle(request); + if ( + url.pathname.startsWith(`${OPERATION_ROUTE_PREFIX}/`) && + request.method === 'OPTIONS' + ) { + const originFailure = validateOrigin(request, origins); + if (originFailure) return originFailure; + const headers = responseHeaders(request, origins); + headers.set('access-control-allow-methods', 'POST, OPTIONS'); + headers.set( + 'access-control-allow-headers', + 'authorization, content-type, x-request-id', + ); + headers.set('access-control-max-age', '600'); + return new Response(null, { status: 204, headers }); + } + if (url.pathname === '/health/ready' && request.method === 'GET') { + try { + const principal = await options.authentication.authenticate(request); + const value = await options.operations.call( + 'gnolith_status', + {}, + { + principal, + requestId: requestId(request), + signal: request.signal, + }, + ); + const ready = readinessFromStatus(value); + return Response.json(value, { + status: ready ? 200 : 503, + headers: responseHeaders(request, origins), + }); + } catch (error) { + return errorResponse(error, request, origins); + } + } + if ( + url.pathname.startsWith(`${OPERATION_ROUTE_PREFIX}/`) && + request.method === 'POST' + ) + return operationResponse(request, options, origins); + const assetResponse = await options.assets?.serve(url.pathname); + if (assetResponse) return assetResponse; + return new Response('Not found', { status: 404 }); + } finally { + active -= 1; + if (active === 0) { + for (const resolve of drained) resolve(); + drained.clear(); + } + } + }; + return { + listener, + fetch, + async start() { + if (started) + throw new WorkshopError('conflict', 'Service is already started', 409); + const preflight = await options.preflight(new AbortController().signal); + if (!startupReady(preflight)) + throw new WorkshopError( + 'dependency_unavailable', + 'Required migrations, authorization, canonical data, or producers are not ready; run the explicit migration/bootstrap workflow', + 503, + ); + server = createServer((incoming, outgoing) => { + void handleNodeExchange(incoming, outgoing, listener, fetch); + }); + server.requestTimeout = 35_000; + server.headersTimeout = 10_000; + server.keepAliveTimeout = 5_000; + await new Promise((resolve, reject) => { + server!.once('error', reject); + server!.listen(listener.port, listener.host, () => { + server!.off('error', reject); + resolve(); + }); + }); + started = true; + options.trigger?.start(); + const address = server.address(); + const port = + typeof address === 'object' && address ? address.port : listener.port; + options.logger?.info({ + event: 'service.started', + profile: listener.profile, + host: listener.host, + port, + }); + return { host: listener.host, port }; + }, + async stop() { + if (!started || !server) return; + draining = true; + const closingServer = server; + const closed = new Promise((resolve, reject) => { + closingServer.close((error) => + error && + (error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING' + ? reject(error) + : resolve(), + ); + }); + server.closeIdleConnections(); + const timeoutMs = options.shutdownTimeoutMs ?? 30_000; + const abort = new AbortController(); + const timeout = setTimeout( + () => abort.abort(new Error('shutdown timeout')), + timeoutMs, + ); + try { + await Promise.all([ + waitForDrain(() => active, drained, abort.signal), + ...(options.trigger ? [options.trigger.stop(abort.signal)] : []), + ]); + for (const dependency of options.dependencies ?? []) + await dependency.close(); + await Promise.race([closed, aborted(abort.signal)]); + } finally { + clearTimeout(timeout); + started = false; + server = undefined; + options.logger?.info({ event: 'service.stopped' }); + } + }, + }; +} + +function aborted(signal: AbortSignal): Promise { + return new Promise((_, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }); +} + +async function operationResponse( + request: Request, + options: WorkshopHttpServiceOptions, + origins: ReadonlySet, +): Promise { + const originFailure = validateOrigin(request, origins); + if (originFailure) return originFailure; + try { + const principal = await options.authentication.authenticate(request); + const name = new URL(request.url).pathname.slice( + OPERATION_ROUTE_PREFIX.length + 1, + ); + if (!operationCatalog.some((operation) => operation.name === name)) + return errorResponse( + new WorkshopError('not_found', 'Operation not found', 404), + request, + origins, + ); + const input = await boundedJson(request, 1024 * 1024); + const value = await options.operations.call(name as OperationName, input, { + principal, + requestId: requestId(request), + signal: request.signal, + }); + return Response.json(value, { + headers: responseHeaders(request, origins), + }); + } catch (error) { + return errorResponse(error, request, origins); + } +} + +function startupReady(status: WorkshopStatusSnapshot): boolean { + return ( + status.migrations.ready && + status.compatibility.diamond && + status.compatibility.taproot && + status.canonicalReady && + status.authorizationReady && + status.lexicalReady && + status.producers.ready && + status.blobReady + ); +} + +function readinessFromStatus(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + const status = value as Record; + const migration = status.migrationReadiness as + Record | undefined; + const producers = status.producers as Record | undefined; + return ( + migration?.ready === true && + status.canonicalReady === true && + status.authorizationReady === true && + status.lexicalReady === true && + producers?.ready === true && + status.blobReady === true + ); +} + +function responseHeaders( + request: Request, + origins: ReadonlySet, +): Headers { + const headers = new Headers({ + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'", + }); + const origin = request.headers.get('origin'); + if ( + origin && + (origin === new URL(request.url).origin || origins.has(origin)) + ) { + headers.set('access-control-allow-origin', origin); + headers.set('vary', 'Origin'); + } + return headers; +} + +function validateOrigin( + request: Request, + origins: ReadonlySet, +): Response | null { + const origin = request.headers.get('origin'); + if (origin && origin !== new URL(request.url).origin && !origins.has(origin)) + return Response.json( + { error: { code: 'forbidden', message: 'Origin is not allowed' } }, + { status: 403 }, + ); + return null; +} + +function errorResponse( + error: unknown, + request: Request, + origins: ReadonlySet, +): Response { + const normalized = normalizeWorkshopError(error); + const message = + normalized.code === 'internal_error' + ? 'Workshop operation failed' + : normalized.message; + return Response.json( + { + error: { + code: normalized.code, + message, + requestId: requestId(request), + ...(normalized.details ? { details: normalized.details } : {}), + }, + }, + { + status: normalized.status, + headers: responseHeaders(request, origins), + }, + ); +} + +async function boundedJson( + request: Request, + maxBytes: number, +): Promise> { + if ( + request.headers.get('content-type')?.split(';', 1)[0] !== 'application/json' + ) + throw new WorkshopError( + 'bad_request', + 'Request requires application/json', + 415, + ); + const bytes = new Uint8Array(await request.arrayBuffer()); + if (bytes.byteLength > maxBytes) + throw new WorkshopError('limit_exceeded', 'Request body is too large', 413); + try { + const value = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('object required'); + return value as Record; + } catch { + throw new WorkshopError('bad_request', 'Invalid JSON object body', 400); + } +} + +function requestId(request: Request): string { + const supplied = request.headers.get('x-request-id'); + return supplied && + supplied.length <= 256 && + !/[\u0000-\u001f\u007f]/u.test(supplied) + ? supplied + : crypto.randomUUID(); +} + +async function waitForDrain( + active: () => number, + drained: Set<() => void>, + signal: AbortSignal, +): Promise { + if (active() === 0) return; + await new Promise((resolve, reject) => { + const completed = () => { + signal.removeEventListener('abort', cancelled); + resolve(); + }; + const cancelled = () => { + drained.delete(completed); + reject(signal.reason); + }; + drained.add(completed); + signal.addEventListener('abort', cancelled, { once: true }); + }); +} + +function nodeRequest( + incoming: import('node:http').IncomingMessage, + listener: ListenerConfig, +): Request { + const origin = `http://${formatHost(listener.host)}:${listener.port}`; + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) { + if (Array.isArray(value)) { + for (const entry of value) headers.append(name, entry); + } else if (value !== undefined) headers.set(name, value); + } + const method = incoming.method ?? 'GET'; + return new Request(new URL(incoming.url ?? '/', origin), { + method, + headers, + ...(method === 'GET' || method === 'HEAD' + ? {} + : { + body: Readable.toWeb(incoming), + duplex: 'half', + }), + } as RequestInit); +} + +async function writeNodeResponse( + response: Response, + outgoing: import('node:http').ServerResponse, +): Promise { + outgoing.statusCode = response.status; + for (const [name, value] of response.headers) outgoing.setHeader(name, value); + if (!response.body) { + outgoing.end(); + return; + } + await new Promise((resolve, reject) => { + const stream = Readable.fromWeb(response.body as never); + stream.once('error', reject); + outgoing.once('error', reject); + outgoing.once('finish', resolve); + stream.pipe(outgoing); + }); +} + +async function handleNodeExchange( + incoming: import('node:http').IncomingMessage, + outgoing: import('node:http').ServerResponse, + listener: ListenerConfig, + fetchHandler: (request: Request) => Promise, +): Promise { + try { + const response = await fetchHandler(nodeRequest(incoming, listener)); + await writeNodeResponse(response, outgoing); + } catch (error) { + const normalized = normalizeWorkshopError(error); + outgoing.statusCode = normalized.status; + outgoing.setHeader('content-type', 'application/json'); + outgoing.end( + JSON.stringify({ + error: { + code: normalized.code, + message: + normalized.code === 'internal_error' + ? 'Workshop operation failed' + : normalized.message, + }, + }), + ); + } +} + +function formatHost(host: string): string { + return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host; +} diff --git a/src/server/tasks.ts b/src/server/tasks.ts index 0fac5a7..5739770 100644 --- a/src/server/tasks.ts +++ b/src/server/tasks.ts @@ -46,7 +46,7 @@ import type { ContextQueryValidator } from './sparql.js'; import { commitWorkshopCanonicalSearchMutationV1, type WorkshopSearchDomainV1, -} from './search.js'; +} from './canonical-producer.js'; import { contextQueries, isoDate, @@ -57,6 +57,11 @@ import { validation, } from './validation.js'; +const LEASE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const LEASE_DIGEST_PREFIX = new TextEncoder().encode( + 'gnolith-task-lease-v1\u0000', +); + interface TaskRow { id: string; title: string | null; @@ -90,6 +95,38 @@ interface TaskRow { authorization_revision: number | null; search_event_id: string | null; search_event_sequence: number | null; + lease_id: string | null; + lease_token_digest: string | null; + lease_owner_principal_id: string | null; + lease_expires_at: string | null; +} + +function base64url(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64url'); +} + +function decodeLeaseToken(value: string): Uint8Array { + if (!LEASE_TOKEN_PATTERN.test(value)) + throw new WorkshopError('validation_failed', 'Lease token is invalid', 400); + const bytes = Uint8Array.from(Buffer.from(value, 'base64url')); + if (bytes.byteLength !== 32) { + bytes.fill(0); + throw new WorkshopError('validation_failed', 'Lease token is invalid', 400); + } + return bytes; +} + +async function digestLeaseToken(token: Uint8Array): Promise { + const input = new Uint8Array(LEASE_DIGEST_PREFIX.length + token.length); + input.set(LEASE_DIGEST_PREFIX); + input.set(token, LEASE_DIGEST_PREFIX.length); + try { + return Buffer.from(await crypto.subtle.digest('SHA-256', input)).toString( + 'hex', + ); + } finally { + input.fill(0); + } } const AUTHORIZED_ID_BATCH = 80; @@ -104,6 +141,20 @@ export interface TaskServiceOptions { search?: WorkshopSearchDomainV1; } +export interface TaskLeaseClaim { + task: Task; + leaseId: string; + leaseToken: string; + expiresAt: string; +} + +export interface TaskLeaseReleaseReceipt { + taskId: string; + released: true; + revision: number; + replayed: boolean; +} + export class TaskService { readonly #limits: WorkshopLimits; readonly #clock: () => Date; @@ -290,7 +341,7 @@ export class TaskService { input: CreateTaskInput, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'task-write'); + const context = await this.#context(authorization, 'task:write'); const workspaceId = requireActiveWorkspace(context); const scope = normalizeVisibilityScope( input.visibility ?? defaultVisibility(context), @@ -409,8 +460,13 @@ export class TaskService { id: string, input: UpdateTaskInput, authorization: AuthorizationContext, + lease?: { + tokenDigest: string; + principalId: string; + expiresAfter: string; + }, ): Promise { - const context = await this.#context(authorization, 'task-write'); + const context = await this.#context(authorization, 'task:write'); const current = await this.#getForMutation(id, context); if (current.completedAt || current.archivedAt) { throw new WorkshopError( @@ -562,6 +618,7 @@ export class TaskService { visibility_scope = ?, authorization_revision = ?, policy_revision = ?, revision = ?, updated_at = ?, search_event_id = ?, search_event_sequence = NULL WHERE id = ? AND ${expected.column} = ? AND completed_at IS NULL AND archived_at IS NULL + ${lease ? 'AND lease_token_digest = ? AND lease_owner_principal_id = ? AND lease_expires_at > ?' : ''} AND ${predicate.sql} RETURNING *`, values: [ @@ -586,6 +643,9 @@ export class TaskService { eventId, id, expected.value, + ...(lease + ? [lease.tokenDigest, lease.principalId, lease.expiresAfter] + : []), ...predicate.values, ], }, @@ -604,6 +664,24 @@ export class TaskService { }); } + async updateWithLease( + id: string, + input: UpdateTaskInput, + leaseTokenValue: unknown, + authorization: AuthorizationContext, + ): Promise { + const context = await this.#context(authorization, 'task:write'); + const leaseToken = requiredText(leaseTokenValue, 'leaseToken', 4096); + const tokenBytes = decodeLeaseToken(leaseToken); + const tokenDigest = await digestLeaseToken(tokenBytes); + tokenBytes.fill(0); + return this.update(id, input, context, { + tokenDigest, + principalId: context.principalId, + expiresAfter: this.#clock().toISOString(), + }); + } + async archive( id: string, options: { @@ -612,7 +690,7 @@ export class TaskService { }, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'task-write'); + const context = await this.#context(authorization, 'task:write'); const current = await this.#getForMutation(id, context); const predicate = visibilitySql('workshop_tasks', context); const expected = revisionPrecondition(options); @@ -663,7 +741,7 @@ export class TaskService { } async claim(id: string, authorization: AuthorizationContext): Promise { - const context = await this.#context(authorization, 'task-write'); + const context = await this.#context(authorization, 'task:write'); const current = await this.#getForMutation(id, context); const predicate = visibilitySql('workshop_tasks', context); const now = this.#clock().toISOString(); @@ -711,14 +789,296 @@ export class TaskService { }); } + async claimLease( + idValue: unknown, + leaseSecondsValue: unknown, + authorization: AuthorizationContext, + ): Promise { + const id = requiredText(idValue, 'taskId', 256); + const leaseSeconds = + leaseSecondsValue === undefined ? 900 : Number(leaseSecondsValue); + if ( + !Number.isSafeInteger(leaseSeconds) || + leaseSeconds < 1 || + leaseSeconds > 86_400 + ) + throw new WorkshopError( + 'validation_failed', + 'leaseSeconds must be an integer between 1 and 86400', + 400, + ); + const context = await this.#context(authorization, 'task:write'); + const predicate = visibilitySql('workshop_tasks', context); + const now = this.#clock(); + const claimedAt = now.toISOString(); + const expiresAt = new Date( + now.getTime() + leaseSeconds * 1000, + ).toISOString(); + const leaseId = this.#createId(); + const tokenBytes = crypto.getRandomValues(new Uint8Array(32)); + const leaseToken = base64url(tokenBytes); + const tokenDigest = await digestLeaseToken(tokenBytes); + tokenBytes.fill(0); + const priorLease = await this.db + .prepare( + `SELECT lease_id, lease_expires_at FROM workshop_tasks + WHERE id = ? AND installation_id = ?`, + ) + .bind(id, context.installationId) + .first<{ lease_id: string | null; lease_expires_at: string | null }>(); + const eventId = this.#createId(); + const current = await this.#getForMutation(id, context); + const next: Task = { + ...current, + claimed: true, + claimedAt: nextTimestamp(claimedAt, current.updatedAt), + revision: current.revision + 1, + updatedAt: nextTimestamp(claimedAt, current.updatedAt), + authorizationRevision: context.authorizationRevision, + }; + const row = await this.#commitCanonical( + context, + next, + 'task.state', + await this.#predecessor(id, context), + { + sql: `UPDATE workshop_tasks + SET claimed = 1, claimed_at = ?, lease_id = ?, + lease_token_digest = ?, lease_owner_principal_id = ?, + lease_expires_at = ?, revision = revision + 1, + authorization_revision = ?, updated_at = ?, + search_event_id = ?, search_event_sequence = NULL + WHERE id = ? AND revision = ? AND completed_at IS NULL AND archived_at IS NULL + AND (lease_id IS NULL OR lease_expires_at <= ?) + AND ${predicate.sql} + RETURNING *`, + values: [ + next.claimedAt, + leaseId, + tokenDigest, + context.principalId, + expiresAt, + context.authorizationRevision, + next.updatedAt, + eventId, + id, + current.revision, + claimedAt, + ...predicate.values, + ], + }, + eventId, + [ + { + sql: `INSERT INTO workshop_task_lease_events + (event_id, installation_id, task_id, lease_id, actor_principal_id, + event_kind, task_revision, lease_expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + values: [ + eventId, + context.installationId, + id, + leaseId, + context.principalId, + priorLease?.lease_id && + priorLease.lease_expires_at && + priorLease.lease_expires_at <= claimedAt + ? 'reclaimed_expired' + : 'claimed', + next.revision, + expiresAt, + claimedAt, + ], + }, + ], + ); + if (!row) + throw new WorkshopError( + 'conflict', + 'Task is unavailable or has an active lease', + 409, + ); + return { task: toTask(row), leaseId, leaseToken, expiresAt }; + } + + async releaseLease( + input: { + taskId: unknown; + leaseToken: unknown; + expectedRevision: unknown; + idempotencyKey: unknown; + }, + authorization: AuthorizationContext, + ): Promise { + const taskId = requiredText(input.taskId, 'taskId', 256); + const leaseToken = requiredText(input.leaseToken, 'leaseToken', 4096); + const idempotencyKey = requiredText( + input.idempotencyKey, + 'idempotencyKey', + 256, + ); + const expectedRevision = Number(input.expectedRevision); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) + throw new WorkshopError( + 'validation_failed', + 'expectedRevision is invalid', + 400, + ); + const context = await this.#context(authorization, 'task:write'); + const replay = await this.db + .prepare( + `SELECT task_id, operation, expected_revision, receipt_json + FROM workshop_task_lease_receipts + WHERE installation_id = ? AND principal_id = ? AND idempotency_key = ?`, + ) + .bind(context.installationId, context.principalId, idempotencyKey) + .first<{ + task_id: string; + operation: string; + expected_revision: number; + receipt_json: string; + }>(); + if (replay) { + if ( + replay.task_id !== taskId || + replay.operation !== 'release' || + replay.expected_revision !== expectedRevision + ) + throw new WorkshopError( + 'conflict', + 'Idempotency key was used for another mutation', + 409, + ); + return { + ...(JSON.parse(replay.receipt_json) as TaskLeaseReleaseReceipt), + replayed: true, + }; + } + const tokenBytes = decodeLeaseToken(leaseToken); + const tokenDigest = await digestLeaseToken(tokenBytes); + tokenBytes.fill(0); + const now = this.#clock().toISOString(); + const predicate = visibilitySql('workshop_tasks', context); + const current = await this.#getForMutation(taskId, context); + if (current.revision !== expectedRevision) + throw new WorkshopError('conflict', 'Task revision changed', 409); + const priorLease = await this.db + .prepare( + `SELECT lease_id FROM workshop_tasks + WHERE id = ? AND lease_token_digest = ? AND lease_owner_principal_id = ?`, + ) + .bind(taskId, tokenDigest, context.principalId) + .first<{ lease_id: string }>(); + const eventId = this.#createId(); + const { claimedAt: _claimedAt, ...withoutClaimedAt } = current; + void _claimedAt; + const next: Task = { + ...withoutClaimedAt, + claimed: false, + revision: current.revision + 1, + updatedAt: nextTimestamp(now, current.updatedAt), + authorizationRevision: context.authorizationRevision, + }; + const receipt: TaskLeaseReleaseReceipt = { + taskId, + released: true, + revision: next.revision, + replayed: false, + }; + const row = await this.#commitCanonical( + context, + next, + 'task.state', + await this.#predecessor(taskId, context), + { + sql: `UPDATE workshop_tasks + SET claimed = 0, claimed_at = NULL, lease_id = NULL, + lease_token_digest = NULL, lease_owner_principal_id = NULL, + lease_expires_at = NULL, revision = revision + 1, + authorization_revision = ?, updated_at = ?, + search_event_id = ?, search_event_sequence = NULL + WHERE id = ? AND revision = ? AND lease_token_digest = ? + AND lease_owner_principal_id = ? AND lease_expires_at > ? + AND completed_at IS NULL AND archived_at IS NULL + AND ${predicate.sql} + RETURNING *`, + values: [ + context.authorizationRevision, + next.updatedAt, + eventId, + taskId, + expectedRevision, + tokenDigest, + context.principalId, + now, + ...predicate.values, + ], + }, + eventId, + [ + { + sql: `INSERT INTO workshop_task_lease_receipts + (installation_id, principal_id, idempotency_key, task_id, operation, + expected_revision, resulting_revision, receipt_json, created_at) + VALUES (?, ?, ?, ?, 'release', ?, ?, ?, ?)`, + values: [ + context.installationId, + context.principalId, + idempotencyKey, + taskId, + expectedRevision, + next.revision, + JSON.stringify(receipt), + now, + ], + }, + { + sql: `INSERT INTO workshop_task_lease_events + (event_id, installation_id, task_id, lease_id, actor_principal_id, + event_kind, task_revision, lease_expires_at, created_at) + VALUES (?, ?, ?, ?, ?, 'released', ?, NULL, ?)`, + values: [ + this.#createId(), + context.installationId, + taskId, + priorLease?.lease_id ?? taskId, + context.principalId, + next.revision, + now, + ], + }, + ], + ); + if (!row) + throw new WorkshopError( + 'conflict', + 'Lease is invalid, expired, not owned, or revision changed', + 409, + ); + return receipt; + } + async complete( id: string, resultValue: unknown, authorization: AuthorizationContext, retryOnConcurrentEdit = true, + lease?: { + tokenDigest: string; + principalId: string; + expiresAfter: string; + expectedRevision: number; + receipt?: { + idempotencyKey: string; + leaseId: string; + createdAt: string; + }; + }, ): Promise { - const context = await this.#context(authorization, 'task-write'); + const context = await this.#context(authorization, 'task:write'); const current = await this.#getForMutation(id, context); + if (lease && current.revision !== lease.expectedRevision) + throw new WorkshopError('conflict', 'Task revision changed', 409); const predicate = visibilitySql('workshop_tasks', context); const outcome = normalizeOutcome(resultValue); const result = requiredText( @@ -755,9 +1115,12 @@ export class TaskService { { sql: `UPDATE workshop_tasks SET claimed = 0, claimed_at = NULL, completed_at = ?, result = ?, outcome_kind = ?, + lease_id = NULL, lease_token_digest = NULL, + lease_owner_principal_id = NULL, lease_expires_at = NULL, revision = ?, authorization_revision = ?, updated_at = ?, search_event_id = ?, search_event_sequence = NULL WHERE id = ? AND revision = ? AND claimed = 1 AND completed_at IS NULL AND archived_at IS NULL + ${lease ? 'AND lease_token_digest = ? AND lease_owner_principal_id = ? AND lease_expires_at > ?' : ''} AND ${predicate.sql} RETURNING *`, values: [ next.completedAt, @@ -769,10 +1132,48 @@ export class TaskService { eventId, id, current.revision, + ...(lease + ? [lease.tokenDigest, lease.principalId, lease.expiresAfter] + : []), ...predicate.values, ], }, eventId, + lease?.receipt + ? [ + { + sql: `INSERT INTO workshop_task_lease_receipts + (installation_id, principal_id, idempotency_key, task_id, operation, + expected_revision, resulting_revision, receipt_json, created_at) + VALUES (?, ?, ?, ?, 'complete', ?, ?, ?, ?)`, + values: [ + context.installationId, + context.principalId, + lease.receipt.idempotencyKey, + id, + lease.expectedRevision, + next.revision, + JSON.stringify(next), + lease.receipt.createdAt, + ], + }, + { + sql: `INSERT INTO workshop_task_lease_events + (event_id, installation_id, task_id, lease_id, actor_principal_id, + event_kind, task_revision, lease_expires_at, created_at) + VALUES (?, ?, ?, ?, ?, 'completed', ?, NULL, ?)`, + values: [ + this.#createId(), + context.installationId, + id, + lease.receipt.leaseId, + context.principalId, + next.revision, + lease.receipt.createdAt, + ], + }, + ] + : [], ); } catch (error) { const retried = await this.#retryCompletion( @@ -803,12 +1204,98 @@ export class TaskService { ); } + async completeLease( + input: { + taskId: unknown; + leaseToken: unknown; + expectedRevision: unknown; + idempotencyKey: unknown; + result: unknown; + outcomeKind?: unknown; + }, + authorization: AuthorizationContext, + ): Promise { + const taskId = requiredText(input.taskId, 'taskId', 256); + const idempotencyKey = requiredText( + input.idempotencyKey, + 'idempotencyKey', + 256, + ); + const expectedRevision = Number(input.expectedRevision); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) + throw new WorkshopError( + 'validation_failed', + 'expectedRevision is invalid', + 400, + ); + const context = await this.#context(authorization, 'task:write'); + const replay = await this.db + .prepare( + `SELECT task_id, operation, expected_revision, receipt_json + FROM workshop_task_lease_receipts + WHERE installation_id = ? AND principal_id = ? AND idempotency_key = ?`, + ) + .bind(context.installationId, context.principalId, idempotencyKey) + .first<{ + task_id: string; + operation: string; + expected_revision: number; + receipt_json: string; + }>(); + if (replay) { + if ( + replay.task_id !== taskId || + replay.operation !== 'complete' || + replay.expected_revision !== expectedRevision + ) + throw new WorkshopError( + 'conflict', + 'Idempotency key was used for another mutation', + 409, + ); + return JSON.parse(replay.receipt_json) as Task; + } + const leaseToken = requiredText(input.leaseToken, 'leaseToken', 4096); + const tokenBytes = decodeLeaseToken(leaseToken); + const tokenDigest = await digestLeaseToken(tokenBytes); + tokenBytes.fill(0); + const now = this.#clock().toISOString(); + const priorLease = await this.db + .prepare( + `SELECT lease_id FROM workshop_tasks + WHERE id = ? AND lease_token_digest = ? AND lease_owner_principal_id = ?`, + ) + .bind(taskId, tokenDigest, context.principalId) + .first<{ lease_id: string }>(); + const task = await this.complete( + taskId, + { + result: input.result, + kind: input.outcomeKind ?? 'success', + }, + context, + false, + { + tokenDigest, + principalId: context.principalId, + expiresAfter: now, + expectedRevision, + receipt: { + idempotencyKey, + leaseId: priorLease?.lease_id ?? taskId, + createdAt: now, + }, + }, + ); + return task; + } + async resetAbandonedClaim( id: string, claimedBefore: string, authorization: AuthorizationContext, ): Promise { - const context = await this.#context(authorization, 'task-write'); + const context = await this.#context(authorization, 'task:write'); requireCapability(context, 'admin'); const current = await this.#getForMutation(id, context); const predicate = visibilitySql('workshop_tasks', context); @@ -920,16 +1407,8 @@ export class TaskService { id: string, context: AuthorizationContext, ): Promise<{ eventId: string; sequence: number }> { - const row = await this.db - .prepare( - `SELECT current_event_id AS event_id, current_event_sequence AS sequence - FROM taproot_unified_search_source_registry - WHERE installation_id = ? AND source_kind = 'task' AND source_id = ?`, - ) - .bind(context.installationId, id) - .first<{ event_id: string; sequence: number }>(); - if (!row || !Number.isSafeInteger(Number(row.sequence))) throw denied(); - return { eventId: row.event_id, sequence: Number(row.sequence) }; + if (!this.options.search) throw denied(); + return this.options.search.currentPredecessor(id, context); } async #commitCanonical( @@ -939,6 +1418,10 @@ export class TaskService { predecessor: { eventId: string; sequence: number } | null, mutation: { sql: string; values: readonly unknown[] }, eventId: string, + additionalStatements: readonly { + sql: string; + values: readonly unknown[]; + }[] = [], ): Promise { if (!this.options.search) throw new WorkshopError( @@ -972,6 +1455,7 @@ export class TaskService { next.updatedAt, ], }, + ...additionalStatements, ], }, ); @@ -1103,7 +1587,7 @@ export class TaskService { async #context( authorization: AuthorizationContext, - capability: 'read' | 'task-write' | 'admin' = 'read', + capability: 'read' | 'task:write' | 'admin' = 'read', ): Promise { const context = await requireCurrentAuthorization( this.options.authorization, diff --git a/src/site.ts b/src/site.ts deleted file mode 100644 index 4f1342b..0000000 --- a/src/site.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './site/handlers.js'; diff --git a/src/site/handlers.ts b/src/site/handlers.ts deleted file mode 100644 index 4b6cd25..0000000 --- a/src/site/handlers.ts +++ /dev/null @@ -1,591 +0,0 @@ -import { createWorkshopMcpServer } from '../mcp.js'; -import { createWorkshopToolDispatcher } from '../mcp/dispatcher.js'; -import { WorkshopError, normalizeWorkshopError } from '../protocol/errors.js'; -import type { WorkshopPrincipal } from '../protocol.js'; -import { - authorize, - requireCapability, - requireCurrentAuthorization, -} from '../server/authorization.js'; -import type { WorkshopRuntime } from '../server/context.js'; -import { HealthService } from '../server/health.js'; -import { resolveLimits } from '../server/limits.js'; -import { observeSafely } from '../server/observability.js'; - -export type WorkshopRouteHandler = (request: Request) => Promise; - -export function createTasksHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - if (request.method === 'GET') { - const actor = authorize(principal, 'read'); - return runtime.tasks.list(queryObject(request), actor); - } - if (request.method === 'POST') { - const actor = authorize(principal, 'task-write'); - return runtime.tasks.create( - (await json(request, runtime)) as never, - actor, - ); - } - throw methodNotAllowed('GET, POST'); - }); -} - -export function createTaskHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - const id = pathValue(request, 'tasks'); - if (request.method === 'GET') { - const actor = authorize(principal, 'read'); - return runtime.tasks.get(id, actor); - } - if (request.method === 'PATCH') { - const actor = authorize(principal, 'task-write'); - return runtime.tasks.update( - id, - (await json(request, runtime)) as never, - actor, - ); - } - if (request.method === 'DELETE') { - const actor = authorize(principal, 'task-write'); - const revision = request.headers.get('x-workshop-revision'); - return runtime.tasks.archive( - id, - { - ...(revision === null - ? { expectedUpdatedAt: requiredHeader(request, 'if-match') } - : { expectedRevision: Number(revision) }), - }, - actor, - ); - } - throw methodNotAllowed('GET, PATCH, DELETE'); - }); -} - -export function createTaskPacketHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'GET'); - const actor = authorize(principal, 'read'); - return runtime.packets.get( - pathValue(request, 'tasks'), - actor, - request.signal, - ); - }); -} - -export function createTaskHistoryHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'GET'); - const limit = queryLimit(request); - return runtime.tasks.history( - pathValue(request, 'tasks'), - authorize(principal, 'read'), - limit === undefined ? {} : { limit }, - ); - }); -} - -export function createTaskClaimHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'POST'); - const actor = authorize(principal, 'task-write'); - return runtime.tasks.claim(pathValue(request, 'tasks'), actor); - }); -} - -export function createTaskCompleteHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'POST'); - const actor = authorize(principal, 'task-write'); - const input = await json(request, runtime); - return runtime.tasks.complete( - pathValue(request, 'tasks'), - input.result, - actor, - ); - }); -} - -export function createMemoriesHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'GET'); - const actor = authorize(principal, 'read'); - return runtime.memories.list(queryObject(request), actor); - }); -} - -export function createMemoryHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - const slug = pathValue(request, 'memories'); - if (request.method === 'GET') { - const actor = authorize(principal, 'read'); - return runtime.memories.get(slug, actor); - } - if (request.method === 'PUT') { - const actor = authorize(principal, 'memory-write'); - return runtime.memories.upsert( - slug, - (await json(request, runtime)) as never, - actor, - ); - } - if (request.method === 'DELETE') { - await runtime.memories.delete( - slug, - Number(requiredHeader(request, 'x-workshop-revision')), - authorize(principal, 'memory-write'), - ); - return { deleted: true }; - } - throw methodNotAllowed('GET, PUT, DELETE'); - }); -} - -export function createMemoryHistoryHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'GET'); - const limit = queryLimit(request); - return runtime.memories.history( - pathValue(request, 'memories'), - authorize(principal, 'read'), - limit === undefined ? {} : { limit }, - ); - }); -} - -export function createPromptsHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - const prompts = requiredPrompts(runtime); - if (request.method === 'GET') - return prompts.list(queryObject(request), authorize(principal, 'read')); - if (request.method === 'POST') - return prompts.create( - (await json(request, runtime)) as never, - authorize(principal, 'prompt-write'), - ); - throw methodNotAllowed('GET, POST'); - }); -} - -export function createPromptHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - const prompts = requiredPrompts(runtime); - const id = pathValue(request, 'prompts'); - if (request.method === 'GET') - return prompts.get(id, authorize(principal, 'read')); - if (request.method === 'PATCH') - return prompts.update( - id, - (await json(request, runtime)) as never, - authorize(principal, 'prompt-write'), - ); - if (request.method === 'DELETE') { - await prompts.delete( - id, - Number(requiredHeader(request, 'x-workshop-revision')), - authorize(principal, 'prompt-write'), - ); - return { deleted: true }; - } - throw methodNotAllowed('GET, PATCH, DELETE'); - }); -} - -export function createPromptHistoryHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'GET'); - const limit = queryLimit(request); - return requiredPrompts(runtime).history( - pathValue(request, 'prompts'), - authorize(principal, 'read'), - limit === undefined ? {} : { limit }, - ); - }); -} - -export function createSearchHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - return route(runtime, async (request, principal) => { - only(request, 'POST'); - if (!runtime.search) - throw new WorkshopError( - 'internal_error', - 'Search integration is not registered', - ); - return runtime.search.search( - (await json(request, runtime)) as never, - authorize(principal, 'read'), - ); - }); -} - -export function createSearchAdminHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - const dispatcher = createWorkshopToolDispatcher(runtime); - return route(runtime, async (request, principal) => { - const actor = authorize(principal, 'search:admin'); - if (request.method === 'GET') { - if (!runtime.search) - throw new WorkshopError( - 'internal_error', - 'Search integration is not registered', - ); - return { - materialization: await runtime.search.materialization.health(actor), - semantic: await runtime.search.semantic.status(actor), - }; - } - only(request, 'POST'); - const result = await dispatcher.callTool( - { name: 'search_admin', arguments: await json(request, runtime) }, - { - principal: actor, - requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(), - signal: request.signal, - }, - ); - if (!result.ok) - throw new WorkshopError( - result.failure.error.code, - result.failure.error.message, - undefined, - result.failure.error.details, - ); - return result.value; - }); -} - -function requiredPrompts(runtime: WorkshopRuntime) { - if (!runtime.prompts) - throw new WorkshopError( - 'internal_error', - 'Prompt producer is not registered', - ); - return runtime.prompts; -} - -export function createWorkshopMcpHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - const server = createWorkshopMcpServer(runtime); - return (request) => server.handle(request); -} - -export function createWorkshopHealthHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - const health = healthService(runtime); - return async (request) => { - try { - only(request, 'GET'); - const body = await health.publicHealth(); - return Response.json(body, { - status: body.status === 'ok' ? 200 : 503, - headers: { 'cache-control': 'no-store' }, - }); - } catch (error) { - return errorResponse(error); - } - }; -} - -export function createWorkshopProbeHandler( - runtime: WorkshopRuntime, -): WorkshopRouteHandler { - const health = healthService(runtime); - return route(runtime, async (request, principal) => { - const actor = await requireCurrentAuthorization( - runtime.authorization, - authorize(principal, 'admin'), - ); - if (request.method === 'GET') return health.inspect(); - if (request.method !== 'POST') throw methodNotAllowed('GET, POST'); - requireCapability(actor, 'task-write'); - requireCapability(actor, 'memory-write'); - const key = crypto.randomUUID(); - const slug = `probe-${key}`; - const memory = await runtime.memories.upsert( - slug, - { - description: 'Temporary Workshop semantic probe', - content: 'Probe guidance', - }, - actor, - ); - const task = await runtime.tasks.create( - { - idempotencyKey: `probe:${key}`, - description: 'Temporary Workshop semantic probe', - prompt: 'Verify the Workshop lifecycle.', - memorySlugs: [slug], - }, - actor, - ); - const packet = await runtime.packets.get(task.id, actor, request.signal); - const claimed = await runtime.tasks.claim(task.id, actor); - const completed = await runtime.tasks.complete( - task.id, - 'Workshop lifecycle probe completed.', - actor, - ); - const archiveTask = await runtime.tasks.create( - { - idempotencyKey: `probe:${key}:archive`, - description: 'Temporary Workshop archive probe', - prompt: 'Verify the Workshop archive transition.', - }, - actor, - ); - const archived = await runtime.tasks.archive( - archiveTask.id, - { - expectedUpdatedAt: archiveTask.updatedAt, - }, - actor, - ); - return { memory, task, packet, claimed, completed, archiveTask, archived }; - }); -} - -function route( - runtime: WorkshopRuntime, - handler: ( - request: Request, - principal: WorkshopPrincipal | null, - ) => Promise, -): WorkshopRouteHandler { - return async (request) => { - if (request.method === 'OPTIONS') return preflight(request, runtime); - try { - validateOrigin(request, runtime); - const principal = await runtime.resolvePrincipal(request); - const body = await handler(request, principal); - return jsonResponse(body, 200, request, runtime); - } catch (error) { - const normalized = normalizeWorkshopError(error); - if ( - normalized.code === 'unauthenticated' || - normalized.code === 'forbidden' - ) { - await observeSafely(runtime.observe, { - operation: 'authorization.failure', - outcome: 'error', - durationMs: 0, - errorCode: normalized.code, - ...(request.headers.get('x-request-id') - ? { requestId: request.headers.get('x-request-id')! } - : {}), - }); - } - return errorResponse(error, request, runtime); - } - }; -} - -function healthService(runtime: WorkshopRuntime): HealthService { - return new HealthService({ - db: runtime.db, - diamondHealth: runtime.diamondHealth, - knowledge: runtime.knowledge, - constructMcp: () => createWorkshopMcpServer(runtime), - }); -} - -async function json( - request: Request, - runtime: WorkshopRuntime, -): Promise> { - const contentType = request.headers.get('content-type')?.split(';', 1)[0]; - if (contentType !== 'application/json') { - throw new WorkshopError('bad_request', 'Expected application/json', 415); - } - const max = resolveLimits(runtime.limits).maxMcpRequestBytes; - const declared = Number(request.headers.get('content-length')); - if (Number.isFinite(declared) && declared > max) { - throw new WorkshopError('limit_exceeded', 'Request body is too large'); - } - if (!request.body) - throw new WorkshopError('bad_request', 'Missing request body'); - const reader = request.body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - while (true) { - const next = await reader.read(); - if (next.done) break; - total += next.value.byteLength; - if (total > max) { - await reader.cancel(); - throw new WorkshopError('limit_exceeded', 'Request body is too large'); - } - chunks.push(next.value); - } - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - const text = new TextDecoder().decode(bytes); - try { - const parsed = JSON.parse(text) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('object required'); - } - return parsed as Record; - } catch { - throw new WorkshopError('bad_request', 'Invalid JSON object body'); - } -} - -function queryObject(request: Request): Record { - return Object.fromEntries(new URL(request.url).searchParams); -} - -function queryLimit(request: Request): number | undefined { - const value = new URL(request.url).searchParams.get('limit'); - return value === null ? undefined : Number(value); -} - -function pathValue(request: Request, segment: string): string { - const parts = new URL(request.url).pathname.split('/').filter(Boolean); - const index = parts.lastIndexOf(segment); - const value = parts[index + 1]; - if (!value) { - throw new WorkshopError( - 'bad_request', - `Missing path value after ${segment}`, - ); - } - return decodeURIComponent(value); -} - -function only(request: Request, method: string): void { - if (request.method !== method) throw methodNotAllowed(method); -} - -function requiredHeader(request: Request, name: string): string { - const value = request.headers.get(name)?.trim(); - if (!value) { - throw new WorkshopError( - 'bad_request', - `The ${name} header is required`, - 400, - { field: name }, - ); - } - return value; -} - -function methodNotAllowed(allow: string): WorkshopError { - return new WorkshopError( - 'bad_request', - `Method not allowed; use ${allow}`, - 405, - { - allow, - }, - ); -} - -function validateOrigin(request: Request, runtime: WorkshopRuntime): void { - const origin = request.headers.get('origin'); - if (!origin || origin === new URL(request.url).origin) return; - const allowed = - typeof runtime.corsOrigin === 'function' - ? runtime.corsOrigin(request) - : runtime.corsOrigin; - if (origin !== allowed) - throw new WorkshopError('forbidden', 'Origin is not allowed'); -} - -function cors(request: Request, runtime: WorkshopRuntime): HeadersInit { - const origin = request.headers.get('origin'); - const allowed = - typeof runtime.corsOrigin === 'function' - ? runtime.corsOrigin(request) - : runtime.corsOrigin; - return origin && - (origin === new URL(request.url).origin || origin === allowed) - ? { 'access-control-allow-origin': origin, vary: 'Origin' } - : {}; -} - -function preflight(request: Request, runtime: WorkshopRuntime): Response { - try { - validateOrigin(request, runtime); - return new Response(null, { - status: 204, - headers: { - ...cors(request, runtime), - 'access-control-allow-methods': - 'GET, POST, PUT, PATCH, DELETE, OPTIONS', - 'access-control-allow-headers': - 'Authorization, Content-Type, If-Match, X-Workshop-Revision, X-Request-Id', - }, - }); - } catch (error) { - return errorResponse(error, request, runtime); - } -} - -function jsonResponse( - body: unknown, - status: number, - request: Request, - runtime: WorkshopRuntime, -): Response { - return Response.json(body, { - status, - headers: { - ...cors(request, runtime), - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff', - }, - }); -} - -function errorResponse( - error: unknown, - request?: Request, - runtime?: WorkshopRuntime, -): Response { - const normalized = normalizeWorkshopError(error); - return Response.json( - { error: normalized.toJSON() }, - { - status: normalized.status, - headers: { - ...(request && runtime ? cors(request, runtime) : {}), - 'cache-control': 'no-store', - }, - }, - ); -} diff --git a/src/ui.ts b/src/ui.ts deleted file mode 100644 index e509b37..0000000 --- a/src/ui.ts +++ /dev/null @@ -1,4 +0,0 @@ -'use client'; - -export * from './ui/plugin.js'; -export * from './ui/components.js'; diff --git a/src/ui/components.tsx b/src/ui/components.tsx deleted file mode 100644 index 10d712c..0000000 --- a/src/ui/components.tsx +++ /dev/null @@ -1,17 +0,0 @@ -export * from './dashboard.js'; -export * from './tasks.js'; -export * from './task-packet.js'; -export * from './task-controls.js'; -export * from './memories.js'; -export * from './mcp-status.js'; -export * from './configuration.js'; -export * from './dashboard-screen.js'; -export * from './entity-tasks-screen.js'; -export * from './error-notice.js'; -export * from './loading-status.js'; -export * from './mcp-status-screen.js'; -export * from './memories-screen.js'; -export * from './task-workspace.js'; -export * from './tasks-screen.js'; -export * from './use-workshop-client.js'; -export type { ContextQuery } from '../protocol/tasks.js'; diff --git a/src/ui/configuration.ts b/src/ui/configuration.ts deleted file mode 100644 index 69080c5..0000000 --- a/src/ui/configuration.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { WorkshopClient } from '../protocol/client.js'; -import { hasWorkshopCapability, type WorkshopCapability } from '../protocol.js'; - -export type WorkshopClientSource = WorkshopClient | (() => WorkshopClient); - -export interface WorkshopMcpStatus { - status: 'connected' | 'unavailable' | 'unauthorized' | 'unknown'; - endpoint?: string; - detail?: string; -} - -export interface WorkshopUiOptions { - client?: WorkshopClientSource; - capabilities?: readonly WorkshopCapability[]; - loadMcpStatus?: () => Promise; -} - -export function hasUiCapability( - capabilities: readonly WorkshopCapability[], - capability: WorkshopCapability, -): boolean { - return hasWorkshopCapability(capabilities, capability); -} diff --git a/src/ui/dashboard-screen.tsx b/src/ui/dashboard-screen.tsx deleted file mode 100644 index dc36aba..0000000 --- a/src/ui/dashboard-screen.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useId } from 'react'; -import useSWR from 'swr'; -import type { WorkshopCapability } from '../protocol.js'; -import type { Memory } from '../protocol/memories.js'; -import type { Task } from '../protocol/tasks.js'; -import type { WorkshopClientSource } from './configuration.js'; -import { WorkshopDashboard } from './dashboard.js'; -import { WorkshopErrorNotice } from './error-notice.js'; -import { WorkshopLoadingStatus } from './loading-status.js'; -import { useWorkshopClient } from './use-workshop-client.js'; - -export function WorkshopDashboardScreen({ - client: clientSource, - capabilities, -}: { - client?: WorkshopClientSource; - capabilities: readonly WorkshopCapability[]; -}) { - const client = useWorkshopClient(clientSource); - const instance = useId(); - const canRead = capabilities.includes('read'); - const { data, error, isLoading } = useSWR< - { tasks: Task[]; memories: Memory[] }, - Error - >(canRead ? `workshop-dashboard:${instance}` : null, async () => { - const [tasks, memories] = await Promise.all([ - client.tasks.list({ limit: 200 }), - client.memories.list({ limit: 200 }), - ]); - return { tasks: tasks.items, memories: memories.items }; - }); - if (!canRead) { - return ( -

- Read access is required to load the Workshop dashboard. -

- ); - } - if (isLoading) - return ; - if (error) return ; - return ( - - ); -} diff --git a/src/ui/dashboard.tsx b/src/ui/dashboard.tsx deleted file mode 100644 index 885dcce..0000000 --- a/src/ui/dashboard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Memory } from '../protocol/memories.js'; -import type { Task } from '../protocol/tasks.js'; - -export function WorkshopDashboard({ - tasks, - memories, -}: { - tasks: readonly Task[]; - memories: readonly Memory[]; -}) { - const active = tasks.filter((task) => !task.completedAt && !task.archivedAt); - return ( -
-

Research Workshop

-
- - task.claimed).length} - /> - -
-
- ); -} - -function Stat({ label, value }: { label: string; value: number }) { - return ( -
- {value} - {label} -
- ); -} diff --git a/src/ui/entity-tasks-screen.tsx b/src/ui/entity-tasks-screen.tsx deleted file mode 100644 index 9d18bd5..0000000 --- a/src/ui/entity-tasks-screen.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { useId } from 'react'; -import useSWR from 'swr'; -import type { WorkshopCapability } from '../protocol.js'; -import type { TaskPage } from '../protocol/tasks.js'; -import type { WorkshopClientSource } from './configuration.js'; -import { hasUiCapability } from './configuration.js'; -import { WorkshopErrorNotice } from './error-notice.js'; -import { WorkshopLoadingStatus } from './loading-status.js'; -import { TaskList } from './tasks.js'; -import { useWorkshopClient } from './use-workshop-client.js'; - -export function WorkshopEntityTasksScreen({ - client: clientSource, - capabilities, - entityId, -}: { - client?: WorkshopClientSource; - capabilities: readonly WorkshopCapability[]; - entityId?: string; -}) { - const client = useWorkshopClient(clientSource); - const instance = useId(); - const tasks = useSWR( - entityId && hasUiCapability(capabilities, 'read') - ? `workshop-entity-tasks:${instance}:${entityId}` - : null, - () => client.tasks.search({ text: entityId ?? '', limit: 25 }), - ); - if (!entityId) { - return ( -

- Select an entity to inspect related tasks. -

- ); - } - if (!hasUiCapability(capabilities, 'read')) { - return ( -

- Read access is required to inspect related tasks. -

- ); - } - if (tasks.isLoading) - return ; - if (tasks.error) return ; - return ; -} diff --git a/src/ui/error-notice.tsx b/src/ui/error-notice.tsx deleted file mode 100644 index 8e630a3..0000000 --- a/src/ui/error-notice.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { WorkshopError } from '../protocol/errors.js'; - -export function WorkshopErrorNotice({ error }: { error: unknown }) { - const normalized = - error instanceof WorkshopError - ? error - : new WorkshopError( - 'internal_error', - error instanceof Error ? error.message : 'Workshop request failed', - ); - return ( -
- {normalized.code.replace(/_/gu, ' ')}:{' '} - {normalized.message} - {normalized.code === 'conflict' ? ( - Refresh current data before retrying. - ) : null} -
- ); -} diff --git a/src/ui/loading-status.tsx b/src/ui/loading-status.tsx deleted file mode 100644 index 248527a..0000000 --- a/src/ui/loading-status.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function WorkshopLoadingStatus({ label }: { label: string }) { - return ( -

- {label} -

- ); -} diff --git a/src/ui/mcp-status-screen.tsx b/src/ui/mcp-status-screen.tsx deleted file mode 100644 index 9f4ba7b..0000000 --- a/src/ui/mcp-status-screen.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useId } from 'react'; -import useSWR from 'swr'; -import type { WorkshopMcpStatus } from './configuration.js'; -import { WorkshopErrorNotice } from './error-notice.js'; -import { WorkshopLoadingStatus } from './loading-status.js'; -import { McpStatusPanel } from './mcp-status.js'; - -const unconfigured: WorkshopMcpStatus = { - status: 'unknown', - detail: 'The Site host has not configured an MCP status check.', -}; - -export function WorkshopMcpStatusScreen({ - loadStatus, -}: { - loadStatus?: () => Promise; -}) { - const instance = useId(); - const status = useSWR( - loadStatus ? `workshop-mcp-status:${instance}` : null, - async () => { - if (loadStatus === undefined) { - throw new Error('No MCP status loader is configured.'); - } - return loadStatus(); - }, - ); - if (!loadStatus) return ; - if (status.isLoading) - return ; - if (status.error) return ; - return ( -
- - -
- ); -} diff --git a/src/ui/mcp-status.tsx b/src/ui/mcp-status.tsx deleted file mode 100644 index 1b8b2e7..0000000 --- a/src/ui/mcp-status.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export function McpStatusPanel({ - status, - endpoint, - detail, -}: { - status: 'connected' | 'unavailable' | 'unauthorized' | 'unknown'; - endpoint?: string; - detail?: string; -}) { - return ( -
-

MCP endpoint

-

- - {status} - -

- {endpoint ? {endpoint} : null} - {detail ?

{detail}

: null} -
- ); -} diff --git a/src/ui/memories-screen.tsx b/src/ui/memories-screen.tsx deleted file mode 100644 index 678d156..0000000 --- a/src/ui/memories-screen.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { useDeferredValue, useId, useState } from 'react'; -import useSWR from 'swr'; -import type { WorkshopCapability } from '../protocol.js'; -import type { - Memory, - MemoryPage, - UpsertMemoryInput, -} from '../protocol/memories.js'; -import type { WorkshopClientSource } from './configuration.js'; -import { hasUiCapability } from './configuration.js'; -import { WorkshopErrorNotice } from './error-notice.js'; -import { WorkshopLoadingStatus } from './loading-status.js'; -import { MemoryEditor, MemoryList } from './memories.js'; -import { useWorkshopClient } from './use-workshop-client.js'; - -export function WorkshopMemoriesScreen({ - client: clientSource, - capabilities, -}: { - client?: WorkshopClientSource; - capabilities: readonly WorkshopCapability[]; -}) { - const client = useWorkshopClient(clientSource); - const instance = useId(); - const [text, setText] = useState(''); - const [creating, setCreating] = useState(false); - const [newSlug, setNewSlug] = useState(''); - const [selected, setSelected] = useState(); - const [actionError, setActionError] = useState(); - const [busy, setBusy] = useState(false); - const deferredText = useDeferredValue(text); - const canRead = hasUiCapability(capabilities, 'read'); - const canWrite = hasUiCapability(capabilities, 'memory-write'); - const memories = useSWR( - canRead ? `workshop-memories:${instance}:${deferredText}` : null, - () => - client.memories.list({ - ...(deferredText.trim() ? { text: deferredText.trim() } : {}), - limit: 100, - }), - ); - if (!canRead) { - return ( -

- Read access is required to browse Workshop memories. -

- ); - } - const save = async ( - slug: string, - input: UpsertMemoryInput, - expectedUpdatedAt?: string, - ) => { - setBusy(true); - setActionError(undefined); - try { - await client.memories.upsert(slug, { - ...input, - ...(expectedUpdatedAt ? { expectedUpdatedAt } : {}), - }); - setCreating(false); - setSelected(undefined); - setNewSlug(''); - await memories.mutate(); - } catch (error) { - setActionError(error); - } finally { - setBusy(false); - } - }; - return ( -
-
-

Reusable memories

- {canWrite ? ( - - ) : null} -
- {!canWrite ? ( -

- You have read-only access to reusable guidance. -

- ) : null} - {actionError ? : null} - {creating ? ( -
- - save(newSlug.trim(), input)} - /> -
- ) : null} - {selected ? ( -
-
-

Edit {selected.slug}

- -
- save(selected.slug, input, selected.updatedAt)} - /> -
- ) : null} - - {memories.isLoading ? ( - - ) : memories.error ? ( - - ) : ( - - )} -
- ); -} diff --git a/src/ui/memories.tsx b/src/ui/memories.tsx deleted file mode 100644 index f8decbd..0000000 --- a/src/ui/memories.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useState } from 'react'; -import type { Memory, UpsertMemoryInput } from '../protocol/memories.js'; -import { Empty, PermissionNotice, SafeText } from './primitives.js'; - -export function MemoryList({ - memories, - onSelect, -}: { - memories: readonly Memory[]; - onSelect?: (memory: Memory) => void; -}) { - return ( -
- {memories.length ? ( - memories.map((memory) => ( - - )) - ) : ( - No memories yet. - )} -
- ); -} - -export function MemoryCard({ - memory, - onSelect, -}: { - memory: Memory; - onSelect?: (memory: Memory) => void; -}) { - return ( -
-

{memory.slug}

-

{memory.description}

- {memory.content} - {onSelect ? ( - - ) : null} -
- ); -} - -export function MemoryEditor({ - initial, - allowed = true, - error, - busy = false, - onSave, -}: { - initial?: Partial; - allowed?: boolean; - error?: string; - busy?: boolean; - onSave?: (input: UpsertMemoryInput) => void | Promise; -}) { - const [description, setDescription] = useState(initial?.description ?? ''); - const [content, setContent] = useState(initial?.content ?? ''); - if (!allowed) return ; - return ( -
{ - event.preventDefault(); - void onSave?.({ - description: description.trim(), - content: content.trim(), - }); - }} - > - {error ? ( -
- {error} -
- ) : null} - -