diff --git a/docs/specifications/service-layer-manifest.md b/docs/specifications/service-layer-manifest.md index 2f03843..39b94bc 100644 --- a/docs/specifications/service-layer-manifest.md +++ b/docs/specifications/service-layer-manifest.md @@ -53,7 +53,7 @@ The manifest guarantees every method has a recorded _intent_. It cannot, by itse 1. **Generate the MCP tool table from the manifest, don't hand-write it.** For every `mcp: true` entry, a thin generic wrapper (`(input) => serviceModules[module][method](actor, input)`) registers the tool — there is no second hand-written call site to drift, because there's only one. Reserve hand-written MCP tool definitions for the rare method that needs genuinely custom input shaping beyond what the service function's own parameter type already describes. 2. **A wiring-check test for the UI side.** SvelteKit's file-based routing means routes/actions can't be generated the same way (each often has form-specific validation, redirects, or multi-step flows). Instead, add a Tier A test (see [`e2e-testing.md`](./e2e-testing.md) §2) that walks `serviceSurfaces`, and for every `ui: true` entry, drives the real route/action through the test harness and asserts the underlying service function actually ran (e.g. by asserting its observable effect — the audit entry, the persisted state — the same way other Tier A tests already assert protocol-boundary correctness). This is the same "second, independent call observes the real effect" pattern already established for the MCP side; applying it to the manifest costs one parametrized test, not N bespoke ones. -Static typing alone gets you "nothing was forgotten from the list." It cannot get you "the thing on the list is wired correctly" — that residual has to be a test, and the manifest is what makes that test parametrized and complete instead of another hand-maintained list. +Static typing alone gets you "nothing was forgotten from the list." It cannot get you "the thing on the list is wired correctly" — that residual has to be a test, and the manifest is what makes that test parametrized and complete instead of another hand-maintained list. The implemented manifest also owns typed `mcpAdapterBindings` and `uiAdapterBindings`: tests prove both maps match the declared surfaces exactly (no missing adapter and no undeclared adapter). ## 4. What this fixes, concretely @@ -68,7 +68,7 @@ Additive, and sequenced strictly after `service-layer.md`'s M1 gives it somethin 1. Once `src/lib/services/documents.ts` exists (service-layer spec §5 step 1), add `src/lib/services/manifest.ts` covering just that module — `ServiceMethod` and `serviceSurfaces` don't need every aggregate populated on day one, only the ones that exist yet. 2. Regenerate the MCP tool registrations for `documents.*` from the manifest (§3.1) as part of the same work that points `create_document`'s handler at the new service function (service-layer spec §5 step 2) — this is the natural moment, since that handler is already being rewritten. 3. Add the one parametrized UI wiring-check test (§3.2) to the Tier A suite once `tests/e2e/harness.ts` exists (`e2e-testing.md` §3 / `phase-2-plan.md` M2). -4. Extend `serviceModules` / `serviceSurfaces` to `records`, `holds`, `collections`, `search` opportunistically, in step with `service-layer.md` §5 step 4's own opportunistic migration — the two migrations track each other module-by-module. +4. Extend `serviceModules` / `serviceSurfaces` to `records`, `holds`, `collections`, `search`, `spaces`, `tokens`, and audit history in step with the service migration — every exported use case has an explicit surface decision. ## 6. Testing implications diff --git a/docs/specifications/service-layer.md b/docs/specifications/service-layer.md index 6308603..52bca07 100644 --- a/docs/specifications/service-layer.md +++ b/docs/specifications/service-layer.md @@ -71,7 +71,7 @@ src/lib/services/ revokeToken(actor, tokenHash) → void ``` -`tokens.ts` and `spaces.ts` are UI-only (`settings/tokens`, `/api/spaces`) — no MCP tool exposes minting or revoking a token or creating a Space, so neither is registered in `services/manifest.ts`'s MCP/UI wiring table (that table's job is enforcing MCP-tool ↔ UI-surface parity, which doesn't apply to a use case with no MCP side at all; see `manifest.ts`'s existing `spaces` precedent). `tokens.ts#createToken` still validates `allowedSpaceIds` against the workspace's real Spaces before persisting (#188) — a crafted request could otherwise grant a token access to a Space id that merely happens to exist somewhere else, since Space membership alone later authorizes access (`tokenAllowsParent`). Reading the token list (`listTokens`) stays a plain, policy-free lookup called directly from the route, same precedent as `queryAuditLog`. +`tokens.ts` and `spaces.ts` are UI-only (`settings/tokens`, `/api/spaces`) — no MCP tool exposes minting or revoking a token or creating a Space. They are nevertheless registered in `services/manifest.ts` with `mcp: false, ui: true`, alongside token listing and audit-history listing, so adapter ownership is enforced without accidentally exposing them over MCP. `tokens.ts#createToken` still validates `allowedSpaceIds` against the workspace's real Spaces before persisting (#188) — a crafted request could otherwise grant a token access to a Space id that merely happens to exist somewhere else, since Space membership alone later authorizes access (`tokenAllowsParent`). Each function's first parameter is whatever identifies the caller for permission purposes — an `AccessToken` for MCP-originated calls, the fixed `CURRENT_USER` `ActorId` for Phase 0/1 UI calls (see `data-model.md` §1's `ActorId` union; this doesn't need to change). Where MCP and UI calls to the "same" use case need different permission rules (e.g. UI writes are currently unscoped, single-tenant; MCP writes are token-scoped), the service function is the one place that branches on that — not duplicated per adapter. diff --git a/src/lib/data/types.ts b/src/lib/data/types.ts index 1099762..4714390 100644 --- a/src/lib/data/types.ts +++ b/src/lib/data/types.ts @@ -7,7 +7,9 @@ export type ActorId = | { kind: 'agent'; agentId: string; name: string } | { kind: 'human-via-client'; userId: string; client: string }; // "Brylie · via Claude Desktop" -export type PropertyType = 'text' | 'number' | 'date' | 'select' | 'checkbox' | 'relation'; +/** Canonical runtime discriminator list shared by data validation and adapters. */ +export const propertyTypes = ['text', 'number', 'date', 'select', 'checkbox', 'relation'] as const; +export type PropertyType = (typeof propertyTypes)[number]; export type PropertyValue = | { type: 'text'; value: string } @@ -25,27 +27,30 @@ export interface PropertyDefinition { targetCollectionId?: string; // for 'relation' — which Collection its record-id values point into } -export type BlockType = - | 'paragraph' - | 'heading_1' - | 'heading_2' - | 'heading_3' - | 'heading_4' - | 'bulleted_list_item' - | 'numbered_list_item' - | 'to_do' - | 'quote' - | 'divider' - | 'callout' - | 'toggle' - | 'table' - | 'code' - | 'table_of_contents' - | 'synced_block' - | 'page_link' - | 'embed' - | 'collection_view' // embeds a Table/Board/Calendar view of a Collection inline in a Document — see collection-views.md - | 'child_pages'; // live listing of a Document's sub-pages (Confluence-style page tree) — issue #43 +/** Canonical runtime discriminator list for Document blocks. */ +export const blockTypes = [ + 'paragraph', + 'heading_1', + 'heading_2', + 'heading_3', + 'heading_4', + 'bulleted_list_item', + 'numbered_list_item', + 'to_do', + 'quote', + 'divider', + 'callout', + 'toggle', + 'table', + 'code', + 'table_of_contents', + 'synced_block', + 'page_link', + 'embed', + 'collection_view', + 'child_pages' +] as const; +export type BlockType = (typeof blockTypes)[number]; // "View" here means a Collection/database view (Table/Board/Calendar — a // rendering + configuration over a Collection's records), never an MVC-style diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index 11d010b..84083a3 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -5,44 +5,29 @@ import { verifyToken, type AccessToken } from './tokens'; import { serviceModules, serviceSurfaces, + mcpAdapterBindings, type ServiceMethod, PermissionDeniedError, HoldRequiredError } from '$lib/services'; -import type { BlockType, EmbeddedViewConfig } from '$lib/data/types'; +import { + blockTypes, + propertyTypes, + type BlockType, + type EmbeddedViewConfig +} from '$lib/data/types'; import { resolvePrimaryField } from '$lib/data/records'; const propertyValueSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('text'), value: z.string() }), - z.object({ type: z.literal('number'), value: z.number() }), - z.object({ type: z.literal('date'), value: z.string() }), - z.object({ type: z.literal('select'), value: z.string() }), - z.object({ type: z.literal('checkbox'), value: z.boolean() }), - z.object({ type: z.literal('relation'), value: z.array(z.string()) }) + z.object({ type: z.literal(propertyTypes[0]), value: z.string() }), + z.object({ type: z.literal(propertyTypes[1]), value: z.number() }), + z.object({ type: z.literal(propertyTypes[2]), value: z.string() }), + z.object({ type: z.literal(propertyTypes[3]), value: z.string() }), + z.object({ type: z.literal(propertyTypes[4]), value: z.boolean() }), + z.object({ type: z.literal(propertyTypes[5]), value: z.array(z.string()) }) ]); -const blockTypeSchema = z.enum([ - 'paragraph', - 'heading_1', - 'heading_2', - 'heading_3', - 'heading_4', - 'bulleted_list_item', - 'numbered_list_item', - 'to_do', - 'quote', - 'divider', - 'callout', - 'toggle', - 'table', - 'code', - 'table_of_contents', - 'synced_block', - 'page_link', - 'embed', - 'collection_view', - 'child_pages' -]); +const blockTypeSchema = z.enum(blockTypes); const childPagesDepthSchema = z.union([z.number().int().positive(), z.literal('unlimited')]); @@ -113,6 +98,9 @@ function registerFromManifest( `Service method "${method}" is not declared as an MCP tool with valid name and description in manifest` ); } + if (mcpAdapterBindings[method as keyof typeof mcpAdapterBindings] !== surface.mcpToolName) { + throw new Error(`MCP adapter binding for service method "${method}" is missing or mismatched`); + } const register = server.registerTool.bind(server) as ( name: string, config: { description?: string; inputSchema: Args }, diff --git a/src/lib/mcp/tokens.ts b/src/lib/mcp/tokens.ts index 3f6f7ca..c3cf430 100644 --- a/src/lib/mcp/tokens.ts +++ b/src/lib/mcp/tokens.ts @@ -1,130 +1,15 @@ -import { createHash, randomBytes } from 'node:crypto'; -import { desc, eq } from 'drizzle-orm'; -import { getDb } from '$lib/server/store.js'; -import { accessTokens } from '$lib/server/db/schema.js'; - -export interface AccessToken { - tokenHash: string; - clientLabel: string; - allowedDocumentIds: string[]; - allowedCollectionIds: string[]; - allowedSpaceIds: string[]; - createdAt: number; - revokedAt?: number; -} - -function rowToToken(row: typeof accessTokens.$inferSelect): AccessToken { - return { - tokenHash: row.tokenHash, - clientLabel: row.clientLabel, - allowedDocumentIds: row.allowedDocumentIds, - allowedCollectionIds: row.allowedCollectionIds, - allowedSpaceIds: row.allowedSpaceIds, - createdAt: row.createdAt, - revokedAt: row.revokedAt ?? undefined - }; -} - -/** Derives the stored lookup/comparison key for a bearer token — only this hash is ever persisted, never the raw token. */ -export function hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); -} - -/** Returns the raw bearer token once — only its hash is ever stored. */ -export function createToken(input: { - clientLabel: string; - allowedDocumentIds: string[]; - allowedCollectionIds: string[]; - allowedSpaceIds?: string[]; -}): { token: string; record: AccessToken } { - const token = `as_${randomBytes(24).toString('base64url')}`; - const record: AccessToken = { - tokenHash: hashToken(token), - clientLabel: input.clientLabel, - allowedDocumentIds: input.allowedDocumentIds, - allowedCollectionIds: input.allowedCollectionIds, - allowedSpaceIds: input.allowedSpaceIds ?? [], - createdAt: Date.now() - }; - - getDb().insert(accessTokens).values(record).run(); - - return { token, record }; -} - -/** Verifies a raw bearer token and returns its (non-revoked) record, or null. */ -export function verifyToken(token: string): AccessToken | null { - const row = getDb() - .select() - .from(accessTokens) - .where(eq(accessTokens.tokenHash, hashToken(token))) - .get(); - if (!row || row.revokedAt) return null; - return rowToToken(row); -} - -/** Lists all access tokens (including revoked ones), newest first, for the token-management UI. */ -export function listTokens(): AccessToken[] { - const rows = getDb().select().from(accessTokens).orderBy(desc(accessTokens.createdAt)).all(); - return rows.map(rowToToken); -} - -/** A team member can revoke their own client's connection at any time — no admin action required (PRD). */ -export function revokeToken(tokenHash: string): void { - getDb() - .update(accessTokens) - .set({ revokedAt: Date.now() }) - .where(eq(accessTokens.tokenHash, tokenHash)) - .run(); -} - -/** Persists an access grant for a newly created document to SQLite so subsequent tool calls succeed. */ -export function grantDocumentAccess(tokenHash: string, documentId: string): void { - const db = getDb(); - const row = db - .select({ allowedDocumentIds: accessTokens.allowedDocumentIds }) - .from(accessTokens) - .where(eq(accessTokens.tokenHash, tokenHash)) - .get(); - if (!row) return; - if (!row.allowedDocumentIds.includes(documentId)) { - db.update(accessTokens) - .set({ allowedDocumentIds: [...row.allowedDocumentIds, documentId] }) - .where(eq(accessTokens.tokenHash, tokenHash)) - .run(); - } -} - -/** Persists an access grant for a newly created collection to SQLite so subsequent tool calls succeed. */ -export function grantCollectionAccess(tokenHash: string, collectionId: string): void { - const db = getDb(); - const row = db - .select({ allowedCollectionIds: accessTokens.allowedCollectionIds }) - .from(accessTokens) - .where(eq(accessTokens.tokenHash, tokenHash)) - .get(); - if (!row) return; - if (!row.allowedCollectionIds.includes(collectionId)) { - db.update(accessTokens) - .set({ allowedCollectionIds: [...row.allowedCollectionIds, collectionId] }) - .where(eq(accessTokens.tokenHash, tokenHash)) - .run(); - } -} - /** - * True when `token` may access `parentId` — either because it's directly - * allowlisted (per-Document/per-Collection grant), or because `spaceId` (the - * record's own catalog Space, when the caller has it — see - * services/permissions.ts's resolveParentWorkspaceContext) is one of the - * token's Space-level grants (#6). Resolved live against the token's current - * `allowedSpaceIds`, not backfilled onto individual records, so a Space - * grant automatically covers content created in that Space later. + * Compatibility exports for MCP transport callers. Token persistence belongs + * to the neutral server store so application services do not depend on MCP. */ -export function tokenAllowsParent(token: AccessToken, parentId: string, spaceId?: string): boolean { - return ( - token.allowedDocumentIds.includes(parentId) || - token.allowedCollectionIds.includes(parentId) || - (spaceId !== undefined && token.allowedSpaceIds.includes(spaceId)) - ); -} +export { + createToken, + grantCollectionAccess, + grantDocumentAccess, + hashToken, + listTokens, + revokeToken, + tokenAllowsParent, + verifyToken, + type AccessToken +} from '$lib/server/token-store'; diff --git a/src/lib/server/token-store.ts b/src/lib/server/token-store.ts new file mode 100644 index 0000000..7d3f56b --- /dev/null +++ b/src/lib/server/token-store.ts @@ -0,0 +1,122 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { desc, eq } from 'drizzle-orm'; +import { getDb } from '$lib/server/store.js'; +import { accessTokens } from '$lib/server/db/schema.js'; + +/** Durable access-token record shared by application services and MCP transport. */ +export interface AccessToken { + tokenHash: string; + clientLabel: string; + allowedDocumentIds: string[]; + allowedCollectionIds: string[]; + allowedSpaceIds: string[]; + createdAt: number; + revokedAt?: number; +} + +function rowToToken(row: typeof accessTokens.$inferSelect): AccessToken { + return { + tokenHash: row.tokenHash, + clientLabel: row.clientLabel, + allowedDocumentIds: row.allowedDocumentIds, + allowedCollectionIds: row.allowedCollectionIds, + allowedSpaceIds: row.allowedSpaceIds, + createdAt: row.createdAt, + revokedAt: row.revokedAt ?? undefined + }; +} + +/** Derives the stored lookup/comparison key for a bearer token — only this hash is ever persisted. */ +export function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** Returns the raw bearer token once — only its hash is ever stored. */ +export function createToken(input: { + clientLabel: string; + allowedDocumentIds: string[]; + allowedCollectionIds: string[]; + allowedSpaceIds?: string[]; +}): { token: string; record: AccessToken } { + const token = `as_${randomBytes(24).toString('base64url')}`; + const record: AccessToken = { + tokenHash: hashToken(token), + clientLabel: input.clientLabel, + allowedDocumentIds: input.allowedDocumentIds, + allowedCollectionIds: input.allowedCollectionIds, + allowedSpaceIds: input.allowedSpaceIds ?? [], + createdAt: Date.now() + }; + + getDb().insert(accessTokens).values(record).run(); + return { token, record }; +} + +/** Verifies a raw bearer token and returns its non-revoked record, or null. */ +export function verifyToken(token: string): AccessToken | null { + const row = getDb() + .select() + .from(accessTokens) + .where(eq(accessTokens.tokenHash, hashToken(token))) + .get(); + if (!row || row.revokedAt) return null; + return rowToToken(row); +} + +/** Lists all access tokens (including revoked ones), newest first. */ +export function listTokens(): AccessToken[] { + return getDb() + .select() + .from(accessTokens) + .orderBy(desc(accessTokens.createdAt)) + .all() + .map(rowToToken); +} + +/** Revokes an existing access token. */ +export function revokeToken(tokenHash: string): void { + getDb() + .update(accessTokens) + .set({ revokedAt: Date.now() }) + .where(eq(accessTokens.tokenHash, tokenHash)) + .run(); +} + +/** Persists an access grant for a newly created document. */ +export function grantDocumentAccess(tokenHash: string, documentId: string): void { + const db = getDb(); + const row = db + .select({ allowedDocumentIds: accessTokens.allowedDocumentIds }) + .from(accessTokens) + .where(eq(accessTokens.tokenHash, tokenHash)) + .get(); + if (!row || row.allowedDocumentIds.includes(documentId)) return; + db.update(accessTokens) + .set({ allowedDocumentIds: [...row.allowedDocumentIds, documentId] }) + .where(eq(accessTokens.tokenHash, tokenHash)) + .run(); +} + +/** Persists an access grant for a newly created collection. */ +export function grantCollectionAccess(tokenHash: string, collectionId: string): void { + const db = getDb(); + const row = db + .select({ allowedCollectionIds: accessTokens.allowedCollectionIds }) + .from(accessTokens) + .where(eq(accessTokens.tokenHash, tokenHash)) + .get(); + if (!row || row.allowedCollectionIds.includes(collectionId)) return; + db.update(accessTokens) + .set({ allowedCollectionIds: [...row.allowedCollectionIds, collectionId] }) + .where(eq(accessTokens.tokenHash, tokenHash)) + .run(); +} + +/** True when a token directly or Space-grant accesses a parent record. */ +export function tokenAllowsParent(token: AccessToken, parentId: string, spaceId?: string): boolean { + return ( + token.allowedDocumentIds.includes(parentId) || + token.allowedCollectionIds.includes(parentId) || + (spaceId !== undefined && token.allowedSpaceIds.includes(spaceId)) + ); +} diff --git a/src/lib/services/collections.ts b/src/lib/services/collections.ts index 1617553..8cfeba2 100644 --- a/src/lib/services/collections.ts +++ b/src/lib/services/collections.ts @@ -20,7 +20,7 @@ import { reserveCollectionLocator, resolveShardForParent } from '$lib/server/catalog'; -import { grantCollectionAccess, tokenAllowsParent } from '$lib/mcp/tokens'; +import { grantCollectionAccess, tokenAllowsParent } from '$lib/server/token-store'; import type { CollectionMeta, PropertyDefinition, WorkspaceRecord } from '$lib/data/types'; import { nanoid } from 'nanoid'; import { diff --git a/src/lib/services/documents.ts b/src/lib/services/documents.ts index 5323723..3df51f5 100644 --- a/src/lib/services/documents.ts +++ b/src/lib/services/documents.ts @@ -25,7 +25,7 @@ import { reserveDocumentLocator, resolveShardForParent } from '$lib/server/catalog'; -import { grantDocumentAccess, tokenAllowsParent } from '$lib/mcp/tokens'; +import { grantDocumentAccess, tokenAllowsParent } from '$lib/server/token-store'; import { richTextToMarkdown } from '$lib/mcp/markdown-transcode'; import { resolveInternalLinkTarget, type InternalLinkTarget } from '$lib/data/links'; import type { diff --git a/src/lib/services/holds.ts b/src/lib/services/holds.ts index a83fad5..be130f0 100644 --- a/src/lib/services/holds.ts +++ b/src/lib/services/holds.ts @@ -2,7 +2,7 @@ import { resolveWorkspaceContext } from '$lib/server/workspace-store'; import { clientIdForToken, releaseAgentHold, requestAgentHold } from '$lib/server/holds'; import { getRecord } from '$lib/data/records'; import { logAudit } from '$lib/server/audit'; -import { tokenAllowsParent } from '$lib/mcp/tokens'; +import { tokenAllowsParent } from '$lib/server/token-store'; import { resolveShardForParent } from '$lib/server/catalog'; import { actorForCaller, diff --git a/src/lib/services/manifest.test.ts b/src/lib/services/manifest.test.ts new file mode 100644 index 0000000..73fb3ad --- /dev/null +++ b/src/lib/services/manifest.test.ts @@ -0,0 +1,59 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createMcpServer } from '$lib/mcp/server'; +import { + mcpAdapterBindings, + serviceModules, + serviceSurfaces, + uiAdapterBindings, + type ServiceMethod +} from './manifest'; + +describe('service surface manifest', () => { + const alphabetically = (values: T[]) => + values.toSorted((a, b) => a.localeCompare(b)); + + it('declares every callable service method exactly once', () => { + const callableMethods = Object.entries(serviceModules).flatMap(([moduleName, module]) => + Object.entries(module) + .filter( + ([, value]) => + typeof value === 'function' && + !Function.prototype.toString.call(value).startsWith('class ') + ) + .map(([methodName]) => `${moduleName}.${methodName}`) + ); + expect(alphabetically(Object.keys(serviceSurfaces))).toEqual(alphabetically(callableMethods)); + }); + + it('requires one and only one adapter binding for each declared surface', () => { + const methods = Object.keys(serviceSurfaces) as ServiceMethod[]; + const expectedMcp = alphabetically(methods.filter((method) => serviceSurfaces[method].mcp)); + const expectedUi = alphabetically(methods.filter((method) => serviceSurfaces[method].ui)); + + expect(alphabetically(Object.keys(mcpAdapterBindings))).toEqual(expectedMcp); + expect(alphabetically(Object.keys(uiAdapterBindings))).toEqual(expectedUi); + for (const method of expectedMcp) { + expect(mcpAdapterBindings[method as keyof typeof mcpAdapterBindings]).toBe( + serviceSurfaces[method].mcpToolName + ); + } + }); + + it('registers every declared MCP adapter at runtime', () => { + const serverWithTools = createMcpServer() as unknown as { + _registeredTools: Record; + }; + + expect(alphabetically(Object.keys(serverWithTools._registeredTools))).toEqual( + alphabetically(Object.values(mcpAdapterBindings)) + ); + }); + + it('binds every declared UI adapter to an existing route module', () => { + for (const [method, routeModule] of Object.entries(uiAdapterBindings)) { + expect(existsSync(resolve(process.cwd(), routeModule)), method).toBe(true); + } + }); +}); diff --git a/src/lib/services/manifest.ts b/src/lib/services/manifest.ts index 56a1fcc..4d00fa0 100644 --- a/src/lib/services/manifest.ts +++ b/src/lib/services/manifest.ts @@ -3,8 +3,20 @@ import * as records from './records'; import * as holds from './holds'; import * as collections from './collections'; import * as search from './search'; +import * as spaces from './spaces'; +import * as tokens from './tokens'; +import * as audit from './audit'; -export const serviceModules = { documents, records, holds, collections, search } as const; +export const serviceModules = { + documents, + records, + holds, + collections, + search, + spaces, + tokens, + audit +} as const; export type ServiceModuleName = keyof typeof serviceModules; @@ -119,5 +131,59 @@ export const serviceSurfaces: Record = mcpToolName: 'search_workspace', mcpDescription: 'Search all Documents and Collections the caller has access to, returning matching record IDs and short snippets.' - } + }, + + 'spaces.createSpace': { mcp: false, ui: true }, + 'spaces.listSpaces': { mcp: false, ui: true }, + 'tokens.createToken': { mcp: false, ui: true }, + 'tokens.revokeToken': { mcp: false, ui: true }, + 'tokens.listTokens': { mcp: false, ui: true }, + 'audit.listAuditHistory': { mcp: false, ui: true } }; + +/** + * Concrete adapter ownership. These maps are deliberately separate from + * `serviceSurfaces`: a declaration without an adapter is a test failure, and + * an adapter that has not declared its service method is equally invalid. + */ +export const mcpAdapterBindings = { + 'documents.createDocument': 'create_document', + 'documents.moveDocument': 'move_document', + 'documents.deleteDocument': 'delete_document', + 'documents.getDocument': 'get_document', + 'documents.listDocuments': 'list_documents', + 'records.createRecord': 'create_record', + 'records.writeRecord': 'write_record', + 'records.deleteRecord': 'delete_record', + 'holds.holdRecords': 'hold_records', + 'holds.releaseRecords': 'release_records', + 'collections.listCollections': 'list_collections', + 'collections.queryCollection': 'query_collection', + 'search.searchWorkspace': 'search_workspace' +} as const satisfies Partial>; + +export const uiAdapterBindings = { + 'documents.createDocument': 'src/routes/api/documents/+server.ts', + 'documents.deleteDocument': 'src/routes/api/documents/[id]/+server.ts', + 'documents.updateDocumentTitle': 'src/routes/space/[spaceId]/doc/[id]/+page.server.ts', + 'documents.getDocument': 'src/routes/space/[spaceId]/doc/[id]/+page.server.ts', + 'documents.listDocuments': 'src/routes/+layout.server.ts', + 'records.createRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', + 'records.writeRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', + 'records.deleteRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', + 'records.getRecord': 'src/routes/space/[spaceId]/doc/[id]/+page.server.ts', + 'holds.holdRecords': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', + 'holds.releaseRecords': 'src/routes/space/[spaceId]/doc/[id]/+page.svelte', + 'collections.createCollection': 'src/routes/api/collections/+server.ts', + 'collections.listCollections': 'src/routes/+layout.server.ts', + 'collections.queryCollection': 'src/routes/space/[spaceId]/table/[id]/+page.server.ts', + 'collections.deleteCollection': 'src/routes/api/collections/[id]/+server.ts', + 'collections.updateCollectionTitle': 'src/routes/space/[spaceId]/table/[id]/+page.svelte', + 'search.searchWorkspace': 'src/routes/space/[spaceId]/+page.server.ts', + 'spaces.createSpace': 'src/routes/api/spaces/+server.ts', + 'spaces.listSpaces': 'src/routes/+layout.server.ts', + 'tokens.createToken': 'src/routes/settings/tokens/+page.server.ts', + 'tokens.revokeToken': 'src/routes/settings/tokens/+page.server.ts', + 'tokens.listTokens': 'src/routes/settings/tokens/+page.server.ts', + 'audit.listAuditHistory': 'src/routes/audit/+page.server.ts' +} as const satisfies Partial>; diff --git a/src/lib/services/permissions.ts b/src/lib/services/permissions.ts index ec14a77..09285a6 100644 --- a/src/lib/services/permissions.ts +++ b/src/lib/services/permissions.ts @@ -1,7 +1,7 @@ import type { ActorId } from '$lib/data/types'; import { resolveWorkspaceContext, type WorkspaceContext } from '$lib/server/workspace-store'; import { getRecord } from '$lib/data/records'; -import { tokenAllowsParent, type AccessToken } from '$lib/mcp/tokens'; +import { tokenAllowsParent, type AccessToken } from '$lib/server/token-store'; import { logAudit } from '$lib/server/audit'; import { resolveShardForParent, resolveShardForRecord } from '$lib/server/catalog'; diff --git a/src/lib/services/records.ts b/src/lib/services/records.ts index 3dcc715..bc084d9 100644 --- a/src/lib/services/records.ts +++ b/src/lib/services/records.ts @@ -17,7 +17,7 @@ import { logAudit } from '$lib/server/audit'; import { reserveRecordLocator, releaseRecordLocator } from '$lib/server/catalog'; import { markdownToRichText } from '$lib/mcp/markdown-transcode'; import { yTextToRichText } from '$lib/data/richtext'; -import { tokenAllowsParent } from '$lib/mcp/tokens'; +import { tokenAllowsParent } from '$lib/server/token-store'; import type { BlockType, ChildPagesDepth, diff --git a/src/lib/services/search.ts b/src/lib/services/search.ts index 549120d..0dee542 100644 --- a/src/lib/services/search.ts +++ b/src/lib/services/search.ts @@ -11,7 +11,7 @@ import { listCatalogDocuments, resolveShardForParent } from '$lib/server/catalog'; -import { tokenAllowsParent } from '$lib/mcp/tokens'; +import { tokenAllowsParent } from '$lib/server/token-store'; import { richTextToMarkdown } from '$lib/mcp/markdown-transcode'; import { actorForCaller, isAccessToken, type CallerIdentity } from './permissions'; diff --git a/src/lib/services/spaces.ts b/src/lib/services/spaces.ts index 6e684ab..6626882 100644 --- a/src/lib/services/spaces.ts +++ b/src/lib/services/spaces.ts @@ -1,5 +1,8 @@ import { resolveWorkspaceContext } from '$lib/server/workspace-store'; -import { createSpace as catalogCreateSpace } from '$lib/server/catalog'; +import { + createSpace as catalogCreateSpace, + listSpaces as catalogListSpaces +} from '$lib/server/catalog'; import { logAudit } from '$lib/server/audit'; import type { SpaceMeta } from '$lib/data/types'; import { actorForCaller, type CallerIdentity } from './permissions'; @@ -22,3 +25,9 @@ export function createSpace(caller: CallerIdentity, name: string): SpaceMeta { logAudit({ actor, action: 'create_space', targetRecordId: space.id }); return space; } + +/** Returns the workspace's catalog Spaces through the application boundary. */ +export function listSpaces(): SpaceMeta[] { + const { workspaceId } = resolveWorkspaceContext(); + return catalogListSpaces(workspaceId); +} diff --git a/src/lib/services/tokens.ts b/src/lib/services/tokens.ts index ea2cbba..8b1d134 100644 --- a/src/lib/services/tokens.ts +++ b/src/lib/services/tokens.ts @@ -2,9 +2,10 @@ import { resolveWorkspaceContext } from '$lib/server/workspace-store'; import { listSpaces } from '$lib/server/catalog'; import { createToken as storeCreateToken, + listTokens as storeListTokens, revokeToken as storeRevokeToken, type AccessToken -} from '$lib/mcp/tokens'; +} from '$lib/server/token-store'; import { logAudit } from '$lib/server/audit'; import { actorForCaller, type CallerIdentity } from './permissions'; @@ -55,3 +56,8 @@ export function revokeToken(caller: CallerIdentity, tokenHash: string): void { storeRevokeToken(tokenHash); logAudit({ actor, action: 'revoke_token', targetRecordId: tokenHash }); } + +/** Returns token metadata for the settings surface; raw token values are never persisted. */ +export function listTokens(): AccessToken[] { + return storeListTokens(); +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 10079bc..6318c9c 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,5 +1,4 @@ -import { listDocuments, listCollections } from '$lib/services'; -import { listSpaces } from '$lib/server/catalog'; +import { listDocuments, listCollections, listSpaces } from '$lib/services'; import { resolveWorkspaceContext } from '$lib/server/workspace-store'; import type { LayoutServerLoad } from './$types'; @@ -21,10 +20,10 @@ import type { LayoutServerLoad } from './$types'; * deferred scope) — those pages always show the default. */ export const load: LayoutServerLoad = ({ params, locals }) => { - const { workspaceId, defaultSpaceId } = resolveWorkspaceContext(); + const { defaultSpaceId } = resolveWorkspaceContext(); const activeSpaceId = params.spaceId ?? defaultSpaceId; return { - spaces: listSpaces(workspaceId), + spaces: listSpaces(), activeSpaceId, documents: listDocuments(locals.requestContext.caller, activeSpaceId), collections: listCollections(locals.requestContext.caller, activeSpaceId) diff --git a/src/routes/settings/tokens/+page.server.ts b/src/routes/settings/tokens/+page.server.ts index 434577e..c0e529d 100644 --- a/src/routes/settings/tokens/+page.server.ts +++ b/src/routes/settings/tokens/+page.server.ts @@ -1,8 +1,5 @@ import { fail } from '@sveltejs/kit'; -import { resolveWorkspaceContext } from '$lib/server/workspace-store'; -import { listCollections, listDocuments } from '$lib/services'; -import { listSpaces } from '$lib/server/catalog'; -import { listTokens } from '$lib/mcp/tokens'; +import { listCollections, listDocuments, listSpaces, listTokens } from '$lib/services'; import { createToken, revokeToken, UnknownSpaceError } from '$lib/services/tokens'; import { formString } from '$lib/server/form-data'; import type { Actions, PageServerLoad } from './$types'; @@ -18,12 +15,11 @@ import type { Actions, PageServerLoad } from './$types'; * policy-free lookup called directly, same precedent as `spaces.ts`'s `createSpace` comment. */ export const load: PageServerLoad = ({ locals }) => { - const { workspaceId } = resolveWorkspaceContext(); return { tokens: listTokens(), documents: listDocuments(locals.requestContext.caller), collections: listCollections(locals.requestContext.caller), - spaces: listSpaces(workspaceId) + spaces: listSpaces() }; }; diff --git a/tests/e2e/tier-a.test.ts b/tests/e2e/tier-a.test.ts index 75954e2..ebe3a29 100644 --- a/tests/e2e/tier-a.test.ts +++ b/tests/e2e/tier-a.test.ts @@ -829,6 +829,51 @@ describe('Tier A: Protocol-Level MCP & Yjs E2E Parity', () => { expect(log.some((e) => e.action === 'search_workspace')).toBe(true); break; } + case 'spaces.createSpace': { + const space = serviceModules.spaces.createSpace(human, 'Manifest Wiring Space'); + expect(space.id).toBeDefined(); + const log = queryAuditLog().filter((e) => e.targetRecordId === space.id); + expect(log.some((e) => e.action === 'create_space')).toBe(true); + break; + } + case 'spaces.listSpaces': { + const spaces = serviceModules.spaces.listSpaces(); + expect(Array.isArray(spaces)).toBe(true); + break; + } + case 'tokens.createToken': { + const { record } = serviceModules.tokens.createToken(human, { + clientLabel: 'Manifest Wiring Token', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [] + }); + const log = queryAuditLog().filter((e) => e.targetRecordId === record.tokenHash); + expect(log.some((e) => e.action === 'create_token')).toBe(true); + break; + } + case 'tokens.revokeToken': { + const { record } = serviceModules.tokens.createToken(human, { + clientLabel: 'Manifest Wiring Token To Revoke', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [] + }); + serviceModules.tokens.revokeToken(human, record.tokenHash); + const log = queryAuditLog().filter((e) => e.targetRecordId === record.tokenHash); + expect(log.some((e) => e.action === 'revoke_token')).toBe(true); + break; + } + case 'tokens.listTokens': { + const tokens = serviceModules.tokens.listTokens(); + expect(Array.isArray(tokens)).toBe(true); + break; + } + case 'audit.listAuditHistory': { + const history = serviceModules.audit.listAuditHistory(); + expect(history.length).toBeGreaterThan(0); + break; + } default: throw new Error(`Unhandled ui: true manifest entry: ${method}`); }