feat: scope + localOnly annotations on all 30 operations

Add AuthInfo, scope ('read'|'write'|'admin'), and localOnly fields to
Operation interface. Per-operation audit:
- 14 read ops, 9 write ops, 2 admin ops, 4 admin+localOnly ops
- sync_brain, file_upload, file_list, file_url: admin + localOnly
- Scope enforcement happens in serve-http.ts before handler dispatch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-13 23:51:24 -07:00
co-authored by Claude Opus 4.6
parent e43b2256be
commit 937986cdd2
+44
View File
@@ -56,11 +56,19 @@ export interface Logger {
error(msg: string): void;
}
export interface AuthInfo {
token: string;
clientId: string;
scopes: string[];
expiresAt?: number;
}
export interface OperationContext {
engine: BrainEngine;
config: GBrainConfig;
logger: Logger;
dryRun: boolean;
auth?: AuthInfo;
}
export interface Operation {
@@ -69,6 +77,8 @@ export interface Operation {
params: Record<string, ParamDef>;
handler: (ctx: OperationContext, params: Record<string, unknown>) => Promise<unknown>;
mutating?: boolean;
scope?: 'read' | 'write' | 'admin';
localOnly?: boolean;
cliHints?: {
name?: string;
positional?: string[];
@@ -110,6 +120,7 @@ const get_page: Operation = {
const tags = await ctx.engine.getTags(page.slug);
return { ...page, tags, ...(resolved_slug ? { resolved_slug } : {}) };
},
scope: 'read',
cliHints: { name: 'get', positional: ['slug'] },
};
@@ -121,6 +132,7 @@ const put_page: Operation = {
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug };
const result = await importFromContent(ctx.engine, p.slug as string, p.content as string);
@@ -136,6 +148,7 @@ const delete_page: Operation = {
slug: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'delete_page', slug: p.slug };
await ctx.engine.deletePage(p.slug as string);
@@ -165,6 +178,7 @@ const list_pages: Operation = {
updated_at: pg.updated_at,
}));
},
scope: 'read',
cliHints: { name: 'list' },
};
@@ -180,6 +194,7 @@ const search: Operation = {
handler: async (ctx, p) => {
return ctx.engine.searchKeyword(p.query as string, { limit: (p.limit as number) || 20 });
},
scope: 'read',
cliHints: { name: 'search', positional: ['query'] },
};
@@ -199,6 +214,7 @@ const query: Operation = {
expandFn: expand ? expandQuery : undefined,
});
},
scope: 'read',
cliHints: { name: 'query', positional: ['query'] },
};
@@ -212,6 +228,7 @@ const add_tag: Operation = {
tag: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_tag', slug: p.slug, tag: p.tag };
await ctx.engine.addTag(p.slug as string, p.tag as string);
@@ -228,6 +245,7 @@ const remove_tag: Operation = {
tag: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'remove_tag', slug: p.slug, tag: p.tag };
await ctx.engine.removeTag(p.slug as string, p.tag as string);
@@ -245,6 +263,7 @@ const get_tags: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getTags(p.slug as string);
},
scope: 'read',
cliHints: { name: 'tags', positional: ['slug'] },
};
@@ -260,6 +279,7 @@ const add_link: Operation = {
context: { type: 'string', description: 'Context for the link' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to };
await ctx.engine.addLink(
@@ -279,6 +299,7 @@ const remove_link: Operation = {
to: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'remove_link', from: p.from, to: p.to };
await ctx.engine.removeLink(p.from as string, p.to as string);
@@ -296,6 +317,7 @@ const get_links: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getLinks(p.slug as string);
},
scope: 'read',
};
const get_backlinks: Operation = {
@@ -307,6 +329,7 @@ const get_backlinks: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getBacklinks(p.slug as string);
},
scope: 'read',
cliHints: { name: 'backlinks', positional: ['slug'] },
};
@@ -320,6 +343,7 @@ const traverse_graph: Operation = {
handler: async (ctx, p) => {
return ctx.engine.traverseGraph(p.slug as string, (p.depth as number) || 5);
},
scope: 'read',
cliHints: { name: 'graph', positional: ['slug'] },
};
@@ -336,6 +360,7 @@ const add_timeline_entry: Operation = {
source: { type: 'string' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
await ctx.engine.addTimelineEntry(p.slug as string, {
@@ -358,6 +383,7 @@ const get_timeline: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getTimeline(p.slug as string);
},
scope: 'read',
cliHints: { name: 'timeline', positional: ['slug'] },
};
@@ -370,6 +396,7 @@ const get_stats: Operation = {
handler: async (ctx) => {
return ctx.engine.getStats();
},
scope: 'admin',
cliHints: { name: 'stats' },
};
@@ -380,6 +407,7 @@ const get_health: Operation = {
handler: async (ctx) => {
return ctx.engine.getHealth();
},
scope: 'admin',
cliHints: { name: 'health' },
};
@@ -392,6 +420,7 @@ const get_versions: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getVersions(p.slug as string);
},
scope: 'read',
cliHints: { name: 'history', positional: ['slug'] },
};
@@ -403,6 +432,7 @@ const revert_version: Operation = {
version_id: { type: 'number', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'revert_version', slug: p.slug, version_id: p.version_id };
await ctx.engine.createVersion(p.slug as string);
@@ -425,6 +455,8 @@ const sync_brain: Operation = {
no_embed: { type: 'boolean', description: 'Skip embedding generation' },
},
mutating: true,
scope: 'admin',
localOnly: true,
handler: async (ctx, p) => {
const { performSync } = await import('../commands/sync.ts');
return performSync(ctx.engine, {
@@ -449,6 +481,7 @@ const put_raw_data: Operation = {
data: { type: 'object', required: true, description: 'Raw data object' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'put_raw_data', slug: p.slug, source: p.source };
await ctx.engine.putRawData(p.slug as string, p.source as string, p.data as object);
@@ -466,6 +499,7 @@ const get_raw_data: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined);
},
scope: 'read',
};
// --- Resolution & Chunks ---
@@ -479,6 +513,7 @@ const resolve_slugs: Operation = {
handler: async (ctx, p) => {
return ctx.engine.resolveSlugs(p.partial as string);
},
scope: 'read',
};
const get_chunks: Operation = {
@@ -490,6 +525,7 @@ const get_chunks: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getChunks(p.slug as string);
},
scope: 'read',
};
// --- Ingest Log ---
@@ -504,6 +540,7 @@ const log_ingest: Operation = {
summary: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'log_ingest' };
await ctx.engine.logIngest({
@@ -525,6 +562,7 @@ const get_ingest_log: Operation = {
handler: async (ctx, p) => {
return ctx.engine.getIngestLog({ limit: (p.limit as number) || 20 });
},
scope: 'read',
};
// --- File Operations ---
@@ -535,6 +573,8 @@ const file_list: Operation = {
params: {
slug: { type: 'string', description: 'Filter by page slug' },
},
scope: 'admin',
localOnly: true,
handler: async (_ctx, p) => {
const sql = db.getConnection();
const slug = p.slug as string | undefined;
@@ -553,6 +593,8 @@ const file_upload: Operation = {
page_slug: { type: 'string', description: 'Associate with page' },
},
mutating: true,
scope: 'admin',
localOnly: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'file_upload', path: p.path };
@@ -623,6 +665,8 @@ const file_url: Operation = {
params: {
storage_path: { type: 'string', required: true },
},
scope: 'admin',
localOnly: true,
handler: async (_ctx, p) => {
const sql = db.getConnection();
const rows = await sql`SELECT storage_path, mime_type, size_bytes FROM files WHERE storage_path = ${p.storage_path as string}`;