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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/specifications/service-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/lib/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
120 changes: 120 additions & 0 deletions src/lib/services/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
57 changes: 57 additions & 0 deletions src/lib/services/tokens.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
64 changes: 33 additions & 31 deletions src/routes/settings/tokens/+page.server.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand All @@ -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 };
}
};
39 changes: 32 additions & 7 deletions src/routes/settings/tokens/page.server.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof load>[0] {
return { locals: { requestContext: resolveRequestContext() } } as unknown as Parameters<
typeof load
>[0];
}

function formEvent(
fields: Record<string, string | string[]>
): Parameters<typeof actions.create>[0] {
Expand All @@ -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<typeof actions.create>[0];
return {
request: { formData: async () => formData },
locals: { requestContext: resolveRequestContext() }
} as unknown as Parameters<typeof actions.create>[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<typeof load>[0]) as unknown as {
const result = load(loadEvent()) as unknown as {
documents: { title: string }[];
tokens: unknown[];
};
Expand All @@ -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] })
Expand Down
Loading