diff --git a/docs/specifications/service-layer.md b/docs/specifications/service-layer.md index f09e2fd..6308603 100644 --- a/docs/specifications/service-layer.md +++ b/docs/specifications/service-layer.md @@ -66,8 +66,13 @@ src/lib/services/ collections.ts createCollection(actor, input) → CollectionMeta queryCollection(actor, collectionId, filter?) → WorkspaceRecord[] search.ts searchWorkspace(actor, query) → { recordId, snippet }[] + spaces.ts createSpace(actor, name) → SpaceMeta + tokens.ts createToken(actor, input) → { token, record } + 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`. + 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. ## 4. What this fixes, concretely diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index aded4d0..0d5cfe0 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -5,5 +5,6 @@ export * from './holds'; export * from './collections'; export * from './search'; export * from './spaces'; +export * from './tokens'; export * from './audit'; export * from './manifest'; diff --git a/src/lib/services/tokens.test.ts b/src/lib/services/tokens.test.ts new file mode 100644 index 0000000..9a12885 --- /dev/null +++ b/src/lib/services/tokens.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { createToken, revokeToken, UnknownSpaceError } from './tokens'; +import { CURRENT_USER } from '$lib/server/current-user'; +import { createToken as createRawToken, listTokens } from '$lib/mcp/tokens'; +import { queryAuditLog } from '$lib/server/audit'; +import { createSpace } from '$lib/server/catalog'; +import { resolveWorkspaceContext } from '$lib/server/workspace-store'; + +describe('service layer: createToken (#188)', () => { + it('mints a token and logs exactly one create_token audit entry attributed to a human caller', () => { + const before = queryAuditLog().filter((a) => a.action === 'create_token').length; + + const { token, record } = createToken(CURRENT_USER, { + clientLabel: 'Test Client', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [] + }); + + expect(token).toMatch(/^as_/); + expect(listTokens().some((t) => t.tokenHash === record.tokenHash)).toBe(true); + + const entries = queryAuditLog().filter((a) => a.action === 'create_token'); + expect(entries).toHaveLength(before + 1); + expect(entries[0].targetRecordId).toBe(record.tokenHash); + expect(entries[0].actor).toMatchObject({ kind: 'human' }); + }); + + it('attributes the audit entry to the underlying human when the caller is a token', () => { + const { record: callerToken } = createRawToken({ + clientLabel: 'Token Minter Bot', + allowedDocumentIds: [], + allowedCollectionIds: [] + }); + + const { record } = createToken(callerToken, { + clientLabel: 'Agent-Minted Client', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [] + }); + + const entry = queryAuditLog().find( + (a) => a.action === 'create_token' && a.targetRecordId === record.tokenHash + ); + expect(entry).toBeDefined(); + expect(entry?.actor).toMatchObject({ kind: 'human-via-client', client: 'Token Minter Bot' }); + }); + + it('grants access to a real Space', () => { + const { workspaceId } = resolveWorkspaceContext(); + const space = createSpace(workspaceId, 'Real Space For Token Test'); + + const { record } = createToken(CURRENT_USER, { + clientLabel: 'Space Grant Client', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [space.id] + }); + + expect(record.allowedSpaceIds).toEqual([space.id]); + }); + + it('rejects a Space id that does not belong to this workspace, without persisting a token', () => { + const before = listTokens().length; + + expect(() => + createToken(CURRENT_USER, { + clientLabel: 'Space Spoofer', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: ['not-a-real-space-id'] + }) + ).toThrow(UnknownSpaceError); + + expect(listTokens()).toHaveLength(before); + }); +}); + +describe('service layer: revokeToken (#188)', () => { + it('revokes a token and logs exactly one revoke_token audit entry attributed to a human caller', () => { + const { record } = createToken(CURRENT_USER, { + clientLabel: 'To Revoke', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [] + }); + const before = queryAuditLog().filter((a) => a.action === 'revoke_token').length; + + revokeToken(CURRENT_USER, record.tokenHash); + + expect(listTokens().find((t) => t.tokenHash === record.tokenHash)?.revokedAt).toBeDefined(); + const entries = queryAuditLog().filter((a) => a.action === 'revoke_token'); + expect(entries).toHaveLength(before + 1); + expect(entries[0].targetRecordId).toBe(record.tokenHash); + expect(entries[0].actor).toMatchObject({ kind: 'human' }); + }); + + it('attributes the revoke audit entry to the underlying human when the caller is a token', () => { + const { record: callerToken } = createRawToken({ + clientLabel: 'Token Revoker Bot', + allowedDocumentIds: [], + allowedCollectionIds: [] + }); + const { record } = createToken(CURRENT_USER, { + clientLabel: 'Revoked By Agent', + allowedDocumentIds: [], + allowedCollectionIds: [], + allowedSpaceIds: [] + }); + + revokeToken(callerToken, record.tokenHash); + + const entry = queryAuditLog().find( + (a) => a.action === 'revoke_token' && a.targetRecordId === record.tokenHash + ); + expect(entry).toBeDefined(); + expect(entry?.actor).toMatchObject({ kind: 'human-via-client', client: 'Token Revoker Bot' }); + }); +}); diff --git a/src/lib/services/tokens.ts b/src/lib/services/tokens.ts new file mode 100644 index 0000000..ea2cbba --- /dev/null +++ b/src/lib/services/tokens.ts @@ -0,0 +1,57 @@ +import { resolveWorkspaceContext } from '$lib/server/workspace-store'; +import { listSpaces } from '$lib/server/catalog'; +import { + createToken as storeCreateToken, + revokeToken as storeRevokeToken, + type AccessToken +} from '$lib/mcp/tokens'; +import { logAudit } from '$lib/server/audit'; +import { actorForCaller, type CallerIdentity } from './permissions'; + +/** Thrown when a token-creation request names a Space id that isn't a real Space in this workspace. */ +export class UnknownSpaceError extends Error { + constructor(spaceId: string) { + super(`${spaceId} is not a Space in this workspace.`); + this.name = 'UnknownSpaceError'; + } +} + +export interface CreateTokenInput { + clientLabel: string; + allowedDocumentIds: string[]; + allowedCollectionIds: string[]; + allowedSpaceIds: string[]; +} + +/** + * Creates a new access token — the service-layer wrapper around `mcp/tokens.ts`'s + * `createToken` (validate → mutate → audit, in one place, per `service-layer.md`). + * Phase 0 permission: any caller may mint a token, matching `createSpace`/`createDocument`'s + * "single-tenant, no membership model yet" posture. `allowedSpaceIds` is validated against the + * workspace's real Spaces before persisting — 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`). + */ +export function createToken( + caller: CallerIdentity, + input: CreateTokenInput +): { token: string; record: AccessToken } { + const { workspaceId } = resolveWorkspaceContext(); + const actor = actorForCaller(caller); + + const knownSpaceIds = new Set(listSpaces(workspaceId).map((space) => space.id)); + for (const spaceId of input.allowedSpaceIds) { + if (!knownSpaceIds.has(spaceId)) throw new UnknownSpaceError(spaceId); + } + + const result = storeCreateToken(input); + logAudit({ actor, action: 'create_token', targetRecordId: result.record.tokenHash }); + return result; +} + +/** Revokes an existing access token — the service-layer wrapper around `mcp/tokens.ts`'s `revokeToken` (mutate → audit). */ +export function revokeToken(caller: CallerIdentity, tokenHash: string): void { + const actor = actorForCaller(caller); + storeRevokeToken(tokenHash); + logAudit({ actor, action: 'revoke_token', targetRecordId: tokenHash }); +} diff --git a/src/routes/settings/tokens/+page.server.ts b/src/routes/settings/tokens/+page.server.ts index 68a49ce..434577e 100644 --- a/src/routes/settings/tokens/+page.server.ts +++ b/src/routes/settings/tokens/+page.server.ts @@ -1,27 +1,34 @@ import { fail } from '@sveltejs/kit'; import { resolveWorkspaceContext } from '$lib/server/workspace-store'; -import { listCollections, listDocuments } from '$lib/data/records'; +import { listCollections, listDocuments } from '$lib/services'; import { listSpaces } from '$lib/server/catalog'; -import { createToken, listTokens, revokeToken } from '$lib/mcp/tokens'; -import { logAudit } from '$lib/server/audit'; +import { listTokens } from '$lib/mcp/tokens'; +import { createToken, revokeToken, UnknownSpaceError } from '$lib/services/tokens'; import { formString } from '$lib/server/form-data'; import type { Actions, PageServerLoad } from './$types'; -const CURRENT_USER = { kind: 'human', userId: 'local' } as const; - -/** Loads existing access tokens plus every Document/Collection/Space so the token-management UI can render its allowlist pickers. */ -export const load: PageServerLoad = () => { - const { doc, workspaceId } = resolveWorkspaceContext(); +/** + * Loads existing access tokens plus every Document/Collection/Space so the token-management UI + * can render its allowlist pickers. + * + * Routed through the service layer, not the bare CRDT primitives directly (#188): since + * #113/#120, service-created Documents/Collections live in their own shards and are discovered + * through the catalog plus shard-aware service queries, so a raw default-`Y.Doc` read would + * silently omit normal current content from the grant picker. `listTokens` stays a plain, + * 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(doc), - collections: listCollections(doc), + documents: listDocuments(locals.requestContext.caller), + collections: listCollections(locals.requestContext.caller), spaces: listSpaces(workspaceId) }; }; export const actions: Actions = { - create: async ({ request }) => { + create: async ({ request, locals }) => { const data = await request.formData(); const clientLabel = formString(data.get('clientLabel')).trim(); if (!clientLabel) return fail(400, { error: 'Client label is required' }); @@ -30,33 +37,28 @@ export const actions: Actions = { const allowedCollectionIds = data.getAll('collectionIds').map(String); const allowedSpaceIds = data.getAll('spaceIds').map(String); - // spaceIds comes directly from the request — validate every submitted id - // actually belongs to this workspace before it's persisted onto the - // token, since Space membership later authorizes access on its own - // (tokenAllowsParent). A crafted request could otherwise grant a token - // access to a Space id that merely happens to exist somewhere. - const { workspaceId } = resolveWorkspaceContext(); - const knownSpaceIds = new Set(listSpaces(workspaceId).map((space) => space.id)); - if (!allowedSpaceIds.every((spaceId) => knownSpaceIds.has(spaceId))) { - return fail(400, { error: 'Invalid Space selection' }); + let token: string; + try { + ({ token } = createToken(locals.requestContext.caller, { + clientLabel, + allowedDocumentIds, + allowedCollectionIds, + allowedSpaceIds + })); + } catch (err) { + if (err instanceof UnknownSpaceError) { + return fail(400, { error: 'Invalid Space selection' }); + } + throw err; } - const { token, record } = createToken({ - clientLabel, - allowedDocumentIds, - allowedCollectionIds, - allowedSpaceIds - }); - logAudit({ actor: CURRENT_USER, action: 'create_token', targetRecordId: record.tokenHash }); - return { createdToken: token, clientLabel }; }, - revoke: async ({ request }) => { + revoke: async ({ request, locals }) => { const data = await request.formData(); const tokenHash = formString(data.get('tokenHash')); if (!tokenHash) return fail(400, { error: 'Missing token' }); - revokeToken(tokenHash); - logAudit({ actor: CURRENT_USER, action: 'revoke_token', targetRecordId: tokenHash }); + revokeToken(locals.requestContext.caller, tokenHash); return { revoked: true }; } }; diff --git a/src/routes/settings/tokens/page.server.test.ts b/src/routes/settings/tokens/page.server.test.ts index 4910d89..ad0b4ea 100644 --- a/src/routes/settings/tokens/page.server.test.ts +++ b/src/routes/settings/tokens/page.server.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it } from 'vitest'; import { load, actions } from './+page.server'; -import { createDocument } from '$lib/data/records'; +import { createCollection, createDocument } from '$lib/services'; +import { CURRENT_USER } from '$lib/server/current-user'; +import { resolveRequestContext } from '$lib/server/request-context'; import { resolveWorkspaceContext } from '$lib/server/workspace-store'; import { createSpace } from '$lib/server/catalog'; import { listTokens } from '$lib/mcp/tokens'; +function loadEvent(): Parameters[0] { + return { locals: { requestContext: resolveRequestContext() } } as unknown as Parameters< + typeof load + >[0]; +} + function formEvent( fields: Record ): Parameters[0] { @@ -13,15 +21,17 @@ function formEvent( if (Array.isArray(value)) value.forEach((v) => formData.append(key, v)); else formData.set(key, value); } - return { request: { formData: async () => formData } } as Parameters[0]; + return { + request: { formData: async () => formData }, + locals: { requestContext: resolveRequestContext() } + } as unknown as Parameters[0]; } describe('routes/settings/tokens/+page.server', () => { it('load() lists tokens, documents, and collections', () => { - const { doc } = resolveWorkspaceContext(); - createDocument(doc, { title: 'Doc for tokens page' }); + createDocument(CURRENT_USER, { title: 'Doc for tokens page' }); - const result = load(undefined as unknown as Parameters[0]) as unknown as { + const result = load(loadEvent()) as unknown as { documents: { title: string }[]; tokens: unknown[]; }; @@ -30,14 +40,29 @@ describe('routes/settings/tokens/+page.server', () => { expect(Array.isArray(result.tokens)).toBe(true); }); + it('load() lists Documents and Collections created via the service layer, each in their own real shard (#188)', () => { + const shardedDoc = createDocument(CURRENT_USER, { title: 'Sharded Doc for tokens page' }); + const shardedCol = createCollection(CURRENT_USER, { + title: 'Sharded Collection for tokens page', + schema: [] + }); + + const result = load(loadEvent()) as unknown as { + documents: { id: string; title: string }[]; + collections: { id: string; title: string }[]; + }; + + expect(result.documents.some((d) => d.id === shardedDoc.id)).toBe(true); + expect(result.collections.some((c) => c.id === shardedCol.id)).toBe(true); + }); + it('create action fails on a blank clientLabel', async () => { const result = await actions.create(formEvent({ clientLabel: ' ' })); expect(result).toEqual({ status: 400, data: { error: 'Client label is required' } }); }); it('create action mints a scoped token and logs the grant', async () => { - const { doc } = resolveWorkspaceContext(); - const docMeta = createDocument(doc, { title: 'Scoped Doc' }); + const docMeta = createDocument(CURRENT_USER, { title: 'Scoped Doc' }); const result = (await actions.create( formEvent({ clientLabel: 'Test Client', documentIds: [docMeta.id] })