@redaksjon/protokoll 1.0.26 → 1.0.27-dev.20260305054841.9de8b06

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
3
  import { scryptSync, timingSafeEqual, randomUUID, createHash } from 'node:crypto';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
5
  import { Command } from 'commander';
5
6
  import { Hono } from 'hono';
6
7
  import { serve } from '@hono/node-server';
@@ -11,17 +12,17 @@ import { bodyLimit } from 'hono/body-limit';
11
12
  import { join, isAbsolute, relative, extname, basename, resolve, dirname } from 'node:path';
12
13
  import * as fs from 'node:fs/promises';
13
14
  import { readFile, mkdir, access } from 'node:fs/promises';
15
+ import { tmpdir, homedir } from 'node:os';
14
16
  import { glob } from 'glob';
17
+ import yaml__default, { load } from 'js-yaml';
15
18
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
19
  import { ListRootsRequestSchema, ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js';
17
20
  import * as Cardigantime from '@utilarium/cardigantime';
18
21
  import Logging from '@fjell/logging';
19
22
  import { z } from 'zod';
20
- import { m as markTranscriptIndexDirtyForStorage, j as getOutputDirectory, k as getOutputStorage, D as DEFAULT_CONFIG_FILE, s as setRoots, l as getServerConfig, n as getStorageConfig, o as getContext, c as configureEngineLoggingBridge, p as createQuietLogger, t as tools, h as handleToolCall, a as handleListResources, b as handleReadResource, g as getPrompts, d as getPrompt, q as isInitialized, e as initializeServerConfig, f as getCachedRoots, r as setWorkerInstance } from '../engineLogging.js';
21
- import { tmpdir } from 'node:os';
23
+ import { m as markTranscriptIndexDirtyForStorage, j as getOutputDirectory, k as getOutputStorage, D as DEFAULT_CONFIG_FILE, s as setRoots, l as getServerConfig, n as getStorageConfig, o as getContext, c as configureEngineLoggingBridge, p as createQuietLogger, t as tools, h as handleToolCall, a as handleListResources, q as parseUri, r as handleReadTranscript, u as readTranscriptsListResource, b as handleReadResource, g as getPrompts, d as getPrompt, v as isInitialized, e as initializeServerConfig, f as getCachedRoots, w as setWorkerInstance } from '../engineLogging.js';
22
24
  import { Transcript, Weighting, Pipeline } from '@redaksjon/protokoll-engine';
23
25
  import { PklTranscript } from '@redaksjon/protokoll-format';
24
- import yaml__default from 'js-yaml';
25
26
  import 'node:fs';
26
27
  import 'node:url';
27
28
  import '@redaksjon/context';
@@ -916,7 +917,8 @@ function parseKeys(value) {
916
917
  user_id: userId,
917
918
  secret_hash: secretHash,
918
919
  enabled: asBooleanWithDefault(obj.enabled, true),
919
- expires_at: asString(obj.expires_at) ?? void 0
920
+ expires_at: asString(obj.expires_at) ?? void 0,
921
+ allowed_projects: asStringArray(obj.allowed_projects)
920
922
  };
921
923
  });
922
924
  }
@@ -1068,7 +1070,8 @@ function createRbacAuthorizer(config) {
1068
1070
  context: {
1069
1071
  user_id: user.user_id,
1070
1072
  roles: [...user.roles],
1071
- key_id: key.key_id
1073
+ key_id: key.key_id,
1074
+ allowed_projects: key.allowed_projects && key.allowed_projects.length > 0 ? [...key.allowed_projects] : void 0
1072
1075
  }
1073
1076
  };
1074
1077
  },
@@ -1231,6 +1234,164 @@ function buildEnvStorageConfig() {
1231
1234
  if (hasGcsValue) storage.gcs = gcs;
1232
1235
  return storage;
1233
1236
  }
1237
+ function readAbsolutePath(value) {
1238
+ if (typeof value !== "string") return void 0;
1239
+ const trimmed = value.trim();
1240
+ if (!trimmed.startsWith("/")) return void 0;
1241
+ return trimmed;
1242
+ }
1243
+ function readAbsolutePathList(value) {
1244
+ if (!Array.isArray(value)) return [];
1245
+ return value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.startsWith("/"));
1246
+ }
1247
+ function commonAncestor(paths) {
1248
+ if (paths.length === 0) {
1249
+ return null;
1250
+ }
1251
+ const normalized = paths.map((pathValue) => resolve(pathValue));
1252
+ const [first, ...rest] = normalized;
1253
+ let current = first;
1254
+ while (current !== dirname(current)) {
1255
+ const matches = rest.every((entry) => entry === current || entry.startsWith(`${current}/`));
1256
+ if (matches) {
1257
+ return current;
1258
+ }
1259
+ current = dirname(current);
1260
+ }
1261
+ return rest.every((entry) => entry === current || entry.startsWith(`${current}/`)) ? current : null;
1262
+ }
1263
+ function hasStorageConfig(config) {
1264
+ return typeof config.storage === "object" && config.storage !== null;
1265
+ }
1266
+ async function mergeStorageFromCanonicalConfig(config, explicitConfigPath) {
1267
+ if (hasStorageConfig(config)) {
1268
+ return config;
1269
+ }
1270
+ const roots = [
1271
+ readAbsolutePath(config.inputDirectory),
1272
+ readAbsolutePath(config.outputDirectory),
1273
+ readAbsolutePath(config.processedDirectory),
1274
+ ...readAbsolutePathList(config.contextDirectories)
1275
+ ].filter((value) => Boolean(value));
1276
+ if (roots.length === 0) {
1277
+ return config;
1278
+ }
1279
+ const ancestor = commonAncestor(roots.map((value) => dirname(value)));
1280
+ if (!ancestor) {
1281
+ return config;
1282
+ }
1283
+ const canonicalConfigPath = resolve(ancestor, DEFAULT_CONFIG_FILE);
1284
+ if (explicitConfigPath && resolve(explicitConfigPath) === canonicalConfigPath) {
1285
+ return config;
1286
+ }
1287
+ try {
1288
+ const rawCanonicalConfig = await readFile(canonicalConfigPath, "utf8");
1289
+ const parsed = load(rawCanonicalConfig);
1290
+ if (!parsed || typeof parsed !== "object") {
1291
+ return config;
1292
+ }
1293
+ const candidate = parsed;
1294
+ if (!hasStorageConfig(candidate)) {
1295
+ return config;
1296
+ }
1297
+ lifecycleLogger.info("startup.storage.recovered_from_canonical_config", {
1298
+ canonicalConfigPath,
1299
+ inferredFromRoots: roots
1300
+ });
1301
+ return {
1302
+ ...config,
1303
+ storage: candidate.storage
1304
+ };
1305
+ } catch {
1306
+ return config;
1307
+ }
1308
+ }
1309
+ function exportStorageConfigToEnv(config) {
1310
+ const storage = config.storage && typeof config.storage === "object" ? config.storage : null;
1311
+ if (!storage) {
1312
+ return;
1313
+ }
1314
+ const backend = typeof storage.backend === "string" ? storage.backend.trim() : "";
1315
+ if (backend !== "gcs") {
1316
+ return;
1317
+ }
1318
+ const gcs = storage.gcs && typeof storage.gcs === "object" ? storage.gcs : {};
1319
+ const setIfMissing = (name, value) => {
1320
+ if (typeof process.env[name] === "string" && process.env[name].trim().length > 0) {
1321
+ return;
1322
+ }
1323
+ if (typeof value === "string" && value.trim().length > 0) {
1324
+ process.env[name] = value.trim();
1325
+ }
1326
+ };
1327
+ setIfMissing("PROTOKOLL_STORAGE_BACKEND", "gcs");
1328
+ setIfMissing("PROTOKOLL_STORAGE_GCS_PROJECT_ID", gcs.projectId);
1329
+ setIfMissing("PROTOKOLL_STORAGE_GCS_INPUT_URI", gcs.inputUri);
1330
+ setIfMissing("PROTOKOLL_STORAGE_GCS_OUTPUT_URI", gcs.outputUri);
1331
+ setIfMissing("PROTOKOLL_STORAGE_GCS_CONTEXT_URI", gcs.contextUri);
1332
+ setIfMissing("PROTOKOLL_STORAGE_GCS_INPUT_BUCKET", gcs.inputBucket);
1333
+ setIfMissing("PROTOKOLL_STORAGE_GCS_INPUT_PREFIX", gcs.inputPrefix);
1334
+ setIfMissing("PROTOKOLL_STORAGE_GCS_OUTPUT_BUCKET", gcs.outputBucket);
1335
+ setIfMissing("PROTOKOLL_STORAGE_GCS_OUTPUT_PREFIX", gcs.outputPrefix);
1336
+ setIfMissing("PROTOKOLL_STORAGE_GCS_CONTEXT_BUCKET", gcs.contextBucket);
1337
+ setIfMissing("PROTOKOLL_STORAGE_GCS_CONTEXT_PREFIX", gcs.contextPrefix);
1338
+ setIfMissing("PROTOKOLL_STORAGE_GCS_CREDENTIALS_FILE", gcs.credentialsFile);
1339
+ }
1340
+ function readCredentialsEnvPath() {
1341
+ const candidates = [
1342
+ process.env.PROTOKOLL_STORAGE_GCS_CREDENTIALS_FILE,
1343
+ process.env.PROTOKOLL_LOCAL_GCS_CREDENTIALS_FILE,
1344
+ process.env.GOOGLE_APPLICATION_CREDENTIALS
1345
+ ];
1346
+ for (const candidate of candidates) {
1347
+ if (typeof candidate === "string" && candidate.trim().length > 0) {
1348
+ const trimmed = candidate.trim();
1349
+ return trimmed.startsWith("~/") ? resolve(homedir(), trimmed.slice(2)) : trimmed;
1350
+ }
1351
+ }
1352
+ return void 0;
1353
+ }
1354
+ async function injectLocalGcsCredentialsIfMissing(config) {
1355
+ const storage = config.storage && typeof config.storage === "object" ? config.storage : null;
1356
+ if (!storage) {
1357
+ return config;
1358
+ }
1359
+ if (storage.backend !== "gcs") {
1360
+ return config;
1361
+ }
1362
+ const gcs = storage.gcs && typeof storage.gcs === "object" ? storage.gcs : {};
1363
+ const existingCredentials = readNonEmptyString(gcs.credentialsFile);
1364
+ if (existingCredentials) {
1365
+ return config;
1366
+ }
1367
+ const explicitFromEnv = readCredentialsEnvPath();
1368
+ const defaultLocalPath = resolve(homedir(), ".ssh/gcp-discursive-riotplan-service.json");
1369
+ const credentialCandidates = [
1370
+ explicitFromEnv,
1371
+ defaultLocalPath
1372
+ ].filter((value) => Boolean(value));
1373
+ for (const candidate of credentialCandidates) {
1374
+ try {
1375
+ await access(candidate);
1376
+ lifecycleLogger.info("startup.storage.gcs.credentials.injected", {
1377
+ path: candidate,
1378
+ source: candidate === explicitFromEnv ? "env" : "local_default"
1379
+ });
1380
+ return {
1381
+ ...config,
1382
+ storage: {
1383
+ ...storage,
1384
+ gcs: {
1385
+ ...gcs,
1386
+ credentialsFile: candidate
1387
+ }
1388
+ }
1389
+ };
1390
+ } catch {
1391
+ }
1392
+ }
1393
+ return config;
1394
+ }
1234
1395
  function describeRawStorageConfig(config) {
1235
1396
  const lines = [];
1236
1397
  const rawStorage = config.storage;
@@ -1373,6 +1534,269 @@ function getAuthorizationHeaderSummary(c) {
1373
1534
  if (hasApiKey) return "x-api-key";
1374
1535
  return "none";
1375
1536
  }
1537
+ const authContextStore = new AsyncLocalStorage();
1538
+ function getActiveAuthContext() {
1539
+ return authContextStore.getStore() ?? null;
1540
+ }
1541
+ function normalizeAllowedProjects(auth) {
1542
+ if (!auth?.allowed_projects || auth.allowed_projects.length === 0) {
1543
+ return [];
1544
+ }
1545
+ return auth.allowed_projects.map((value) => value.trim()).filter(Boolean);
1546
+ }
1547
+ function isProjectAllowed(projectId, allowedProjects) {
1548
+ if (!projectId) return false;
1549
+ const normalized = projectId.trim().toLowerCase();
1550
+ return allowedProjects.some((allowed) => allowed.toLowerCase() === normalized);
1551
+ }
1552
+ function hasProjectScope(auth) {
1553
+ return normalizeAllowedProjects(auth).length > 0;
1554
+ }
1555
+ function asRecord(value) {
1556
+ return value && typeof value === "object" ? value : {};
1557
+ }
1558
+ function extractTranscriptRefs(args) {
1559
+ const refs = [];
1560
+ const directKeys = ["transcriptPath", "sourceTranscriptPath", "targetTranscriptPath"];
1561
+ for (const key of directKeys) {
1562
+ const value = args[key];
1563
+ if (typeof value === "string" && value.trim().length > 0) {
1564
+ refs.push(value.trim());
1565
+ }
1566
+ }
1567
+ const listValue = args.transcriptPaths;
1568
+ if (Array.isArray(listValue)) {
1569
+ for (const entry of listValue) {
1570
+ if (typeof entry === "string" && entry.trim().length > 0) {
1571
+ refs.push(entry.trim());
1572
+ }
1573
+ }
1574
+ }
1575
+ return refs;
1576
+ }
1577
+ async function enforceProjectScopeForTool(toolName, args, authContext) {
1578
+ const allowedProjects = normalizeAllowedProjects(authContext);
1579
+ if (allowedProjects.length === 0) {
1580
+ return args;
1581
+ }
1582
+ const scopedArgs = asRecord(args);
1583
+ const contextDirectory = typeof scopedArgs.contextDirectory === "string" ? scopedArgs.contextDirectory : void 0;
1584
+ if (toolName === "protokoll_list_transcripts") {
1585
+ const entityType = typeof scopedArgs.entityType === "string" ? scopedArgs.entityType.trim() : "";
1586
+ const entityId = typeof scopedArgs.entityId === "string" ? scopedArgs.entityId.trim() : "";
1587
+ if (entityType && entityType !== "project") {
1588
+ throw new Error('This API key is project-scoped. Use entityType="project" with an allowed entityId.');
1589
+ }
1590
+ if (entityId) {
1591
+ if (!isProjectAllowed(entityId, allowedProjects)) {
1592
+ throw new Error(`Project-scoped key cannot access project "${entityId}".`);
1593
+ }
1594
+ return {
1595
+ ...scopedArgs,
1596
+ entityType: "project",
1597
+ entityId
1598
+ };
1599
+ }
1600
+ if (allowedProjects.length === 1) {
1601
+ return {
1602
+ ...scopedArgs,
1603
+ entityType: "project",
1604
+ entityId: allowedProjects[0]
1605
+ };
1606
+ }
1607
+ throw new Error('This API key is scoped to multiple projects. Provide entityType="project" and an allowed entityId.');
1608
+ }
1609
+ if (toolName === "protokoll_create_note" || toolName === "protokoll_process_audio") {
1610
+ const providedProject = typeof scopedArgs.projectId === "string" ? scopedArgs.projectId.trim() : "";
1611
+ if (providedProject) {
1612
+ if (!isProjectAllowed(providedProject, allowedProjects)) {
1613
+ throw new Error(`Project-scoped key cannot write to project "${providedProject}".`);
1614
+ }
1615
+ return args;
1616
+ }
1617
+ if (allowedProjects.length === 1) {
1618
+ return {
1619
+ ...scopedArgs,
1620
+ projectId: allowedProjects[0]
1621
+ };
1622
+ }
1623
+ throw new Error("This API key is scoped to multiple projects. Provide an explicit allowed projectId.");
1624
+ }
1625
+ if (toolName === "protokoll_add_term") {
1626
+ const provided = Array.isArray(scopedArgs.projects) ? scopedArgs.projects.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
1627
+ if (provided.length > 0) {
1628
+ for (const projectId of provided) {
1629
+ if (!isProjectAllowed(projectId, allowedProjects)) {
1630
+ throw new Error(`Project-scoped key cannot attach term to project "${projectId}".`);
1631
+ }
1632
+ }
1633
+ return args;
1634
+ }
1635
+ if (allowedProjects.length === 1) {
1636
+ return {
1637
+ ...scopedArgs,
1638
+ projects: [allowedProjects[0]]
1639
+ };
1640
+ }
1641
+ throw new Error("This API key is scoped to multiple projects. Provide explicit allowed projects when creating a term.");
1642
+ }
1643
+ if (toolName === "protokoll_edit_term") {
1644
+ const replaceProjects = Array.isArray(scopedArgs.projects) ? scopedArgs.projects.filter((value) => typeof value === "string" && value.trim().length > 0) : null;
1645
+ const addProjects = Array.isArray(scopedArgs.add_projects) ? scopedArgs.add_projects.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
1646
+ const removeProjects = Array.isArray(scopedArgs.remove_projects) ? scopedArgs.remove_projects.filter((value) => typeof value === "string" && value.trim().length > 0) : [];
1647
+ const validateProjectList = (values, action) => {
1648
+ for (const projectId of values) {
1649
+ if (!isProjectAllowed(projectId, allowedProjects)) {
1650
+ throw new Error(`Project-scoped key cannot ${action} project "${projectId}".`);
1651
+ }
1652
+ }
1653
+ };
1654
+ if (replaceProjects) validateProjectList(replaceProjects, "set");
1655
+ if (addProjects.length > 0) validateProjectList(addProjects, "add");
1656
+ if (removeProjects.length > 0) validateProjectList(removeProjects, "remove");
1657
+ return args;
1658
+ }
1659
+ if (toolName === "protokoll_add_person" || toolName === "protokoll_add_company") {
1660
+ if (allowedProjects.length !== 1) {
1661
+ throw new Error("This API key is scoped to multiple projects. Person/company creation currently requires a single allowed project.");
1662
+ }
1663
+ return args;
1664
+ }
1665
+ if (toolName === "protokoll_edit_project" || toolName === "protokoll_update_project") {
1666
+ const projectId = typeof scopedArgs.id === "string" ? scopedArgs.id.trim() : "";
1667
+ if (!projectId || !isProjectAllowed(projectId, allowedProjects)) {
1668
+ throw new Error("Project-scoped key can only modify its allowed project.");
1669
+ }
1670
+ return args;
1671
+ }
1672
+ if (toolName === "protokoll_add_project" || toolName === "protokoll_delete_entity") {
1673
+ throw new Error("Project-scoped keys cannot create or delete projects/entities.");
1674
+ }
1675
+ if (toolName === "protokoll_get_entity") {
1676
+ const entityType = typeof scopedArgs.entityType === "string" ? scopedArgs.entityType.trim() : "";
1677
+ const entityId = typeof scopedArgs.entityId === "string" ? scopedArgs.entityId.trim() : "";
1678
+ if (entityType !== "project") {
1679
+ throw new Error("Project-scoped keys can only read project entities from context.");
1680
+ }
1681
+ if (!isProjectAllowed(entityId, allowedProjects)) {
1682
+ throw new Error(`Project-scoped key cannot access project "${entityId}".`);
1683
+ }
1684
+ return args;
1685
+ }
1686
+ if (toolName === "protokoll_list_projects") {
1687
+ return args;
1688
+ }
1689
+ if (toolName === "protokoll_context_status" || toolName === "protokoll_list_people" || toolName === "protokoll_list_terms" || toolName === "protokoll_list_companies" || toolName === "protokoll_search_context") {
1690
+ throw new Error(`Project-scoped key cannot call ${toolName}. Use project-scoped transcript/note tools and explicit project lookups.`);
1691
+ }
1692
+ if (toolName === "protokoll_edit_transcript") {
1693
+ const requestedProject = typeof scopedArgs.projectId === "string" ? scopedArgs.projectId.trim() : "";
1694
+ if (requestedProject && !isProjectAllowed(requestedProject, allowedProjects)) {
1695
+ throw new Error(`Project-scoped key cannot move transcripts to project "${requestedProject}".`);
1696
+ }
1697
+ }
1698
+ if (toolName === "protokoll_get_transcript_by_uuid") {
1699
+ const uuid = typeof scopedArgs.uuid === "string" ? scopedArgs.uuid.trim() : "";
1700
+ if (uuid) {
1701
+ const readResult = await handleReadTranscript({ transcriptPath: uuid, contextDirectory });
1702
+ const metadata = asRecord(readResult.metadata);
1703
+ const projectId = typeof metadata.projectId === "string" ? metadata.projectId.trim() : "";
1704
+ if (!isProjectAllowed(projectId, allowedProjects)) {
1705
+ throw new Error(`Project-scoped key cannot access transcript from project "${projectId || "unassigned"}".`);
1706
+ }
1707
+ }
1708
+ return args;
1709
+ }
1710
+ const transcriptRefs = extractTranscriptRefs(scopedArgs);
1711
+ if (transcriptRefs.length === 0) {
1712
+ return args;
1713
+ }
1714
+ for (const transcriptRef of transcriptRefs) {
1715
+ const readResult = await handleReadTranscript({
1716
+ transcriptPath: transcriptRef,
1717
+ contextDirectory
1718
+ });
1719
+ const metadata = asRecord(readResult.metadata);
1720
+ const projectId = typeof metadata.projectId === "string" ? metadata.projectId.trim() : "";
1721
+ if (!isProjectAllowed(projectId, allowedProjects)) {
1722
+ throw new Error(`Project-scoped key cannot access transcript from project "${projectId || "unassigned"}".`);
1723
+ }
1724
+ }
1725
+ return args;
1726
+ }
1727
+ function filterProjectScopedToolResult(toolName, result, authContext) {
1728
+ const allowedProjects = normalizeAllowedProjects(authContext);
1729
+ if (allowedProjects.length === 0) {
1730
+ return result;
1731
+ }
1732
+ if (toolName === "protokoll_list_projects") {
1733
+ const payload = asRecord(result);
1734
+ const projectsRaw = payload.projects;
1735
+ const projects = Array.isArray(projectsRaw) ? projectsRaw.filter((project) => {
1736
+ const item = asRecord(project);
1737
+ const id = typeof item.id === "string" ? item.id.trim() : "";
1738
+ return isProjectAllowed(id, allowedProjects);
1739
+ }) : [];
1740
+ return {
1741
+ ...payload,
1742
+ projects,
1743
+ total: projects.length,
1744
+ count: projects.length
1745
+ };
1746
+ }
1747
+ return result;
1748
+ }
1749
+ async function postProcessProjectScopedCreate(toolName, args, result, authContext) {
1750
+ const allowedProjects = normalizeAllowedProjects(authContext);
1751
+ if (allowedProjects.length !== 1) {
1752
+ return;
1753
+ }
1754
+ const scopedProjectId = allowedProjects[0];
1755
+ const payload = asRecord(result);
1756
+ const entity = asRecord(payload.entity);
1757
+ const entityId = typeof entity.id === "string" ? entity.id.trim() : "";
1758
+ if (!entityId) {
1759
+ return;
1760
+ }
1761
+ if (toolName === "protokoll_add_person") {
1762
+ await handleToolCall("protokoll_edit_project", {
1763
+ id: scopedProjectId,
1764
+ add_associated_people: [entityId],
1765
+ contextDirectory: asRecord(args).contextDirectory
1766
+ });
1767
+ }
1768
+ if (toolName === "protokoll_add_company") {
1769
+ await handleToolCall("protokoll_edit_project", {
1770
+ id: scopedProjectId,
1771
+ add_associated_companies: [entityId],
1772
+ contextDirectory: asRecord(args).contextDirectory
1773
+ });
1774
+ }
1775
+ }
1776
+ function filterProjectEntitiesResourceJson(contents, allowedProjects) {
1777
+ if (!contents.text) {
1778
+ return contents;
1779
+ }
1780
+ try {
1781
+ const parsed = JSON.parse(contents.text);
1782
+ const entities = Array.isArray(parsed.entities) ? parsed.entities : [];
1783
+ const filtered = entities.filter((entity) => {
1784
+ const item = asRecord(entity);
1785
+ const id = typeof item.id === "string" ? item.id.trim() : "";
1786
+ return isProjectAllowed(id, allowedProjects);
1787
+ });
1788
+ return {
1789
+ ...contents,
1790
+ text: JSON.stringify({
1791
+ ...parsed,
1792
+ entities: filtered,
1793
+ count: filtered.length
1794
+ }, null, 2)
1795
+ };
1796
+ } catch {
1797
+ return contents;
1798
+ }
1799
+ }
1376
1800
  async function configureRbacIfSecured(config) {
1377
1801
  if (rbacReloadTimer) {
1378
1802
  clearInterval(rbacReloadTimer);
@@ -1580,12 +2004,18 @@ function createMcpServer() {
1580
2004
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1581
2005
  const { name, arguments: args } = request.params;
1582
2006
  const startedAt = Date.now();
2007
+ const authContext = getActiveAuthContext();
1583
2008
  requestLogger.info("tool.call.start", {
1584
2009
  toolName: name,
1585
- args: summarizeToolArgs(args)
2010
+ args: summarizeToolArgs(args),
2011
+ userId: authContext?.user_id ?? null,
2012
+ keyId: authContext?.key_id ?? null
1586
2013
  });
1587
2014
  try {
1588
- const result = await handleToolCall(name, args);
2015
+ const scopedArgs = await enforceProjectScopeForTool(name, args, authContext);
2016
+ const toolResult = await handleToolCall(name, scopedArgs);
2017
+ await postProcessProjectScopedCreate(name, scopedArgs, toolResult, authContext);
2018
+ const result = filterProjectScopedToolResult(name, toolResult, authContext);
1589
2019
  sendEntityChangeNotifications(name, args).catch((err) => {
1590
2020
  rootLogger.error("entity.notification.dispatch_error", {
1591
2021
  toolName: name,
@@ -1622,12 +2052,99 @@ function createMcpServer() {
1622
2052
  }
1623
2053
  });
1624
2054
  server.setRequestHandler(ListResourcesRequestSchema, async () => {
1625
- return handleListResources();
2055
+ const listed = await handleListResources();
2056
+ const authContext = getActiveAuthContext();
2057
+ if (!hasProjectScope(authContext)) {
2058
+ return listed;
2059
+ }
2060
+ const filteredResources = listed.resources.filter((resource) => {
2061
+ try {
2062
+ const parsed = parseUri(resource.uri);
2063
+ if (parsed.resourceType === "entities-list") {
2064
+ return parsed.entityType === "project";
2065
+ }
2066
+ if (parsed.resourceType === "entity") {
2067
+ return parsed.entityType === "project" && isProjectAllowed(parsed.entityId, normalizeAllowedProjects(authContext));
2068
+ }
2069
+ if (parsed.resourceType === "audio-inbound" || parsed.resourceType === "audio-processed") {
2070
+ return false;
2071
+ }
2072
+ return true;
2073
+ } catch {
2074
+ return true;
2075
+ }
2076
+ });
2077
+ return {
2078
+ ...listed,
2079
+ resources: filteredResources
2080
+ };
1626
2081
  });
1627
2082
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1628
2083
  const { uri } = request.params;
2084
+ const authContext = getActiveAuthContext();
2085
+ const allowedProjects = normalizeAllowedProjects(authContext);
2086
+ if (allowedProjects.length > 0) {
2087
+ const parsed = parseUri(uri);
2088
+ if (parsed.resourceType === "audio-inbound" || parsed.resourceType === "audio-processed" || parsed.resourceType === "config") {
2089
+ throw new Error(`Project-scoped key cannot access ${parsed.resourceType} resources`);
2090
+ }
2091
+ if (parsed.resourceType === "entity") {
2092
+ const entity = parsed;
2093
+ if (entity.entityType !== "project") {
2094
+ throw new Error("Project-scoped key can only read project entities");
2095
+ }
2096
+ if (!isProjectAllowed(entity.entityId, allowedProjects)) {
2097
+ throw new Error(`Project-scoped key cannot access project "${entity.entityId}"`);
2098
+ }
2099
+ }
2100
+ if (parsed.resourceType === "entities-list") {
2101
+ const entityList = parsed;
2102
+ if (entityList.entityType !== "project") {
2103
+ throw new Error("Project-scoped key can only list project entities");
2104
+ }
2105
+ }
2106
+ if (parsed.resourceType === "transcript") {
2107
+ const transcriptUri = parsed;
2108
+ const transcriptResult = await handleReadTranscript({
2109
+ transcriptPath: transcriptUri.transcriptPath
2110
+ });
2111
+ const metadata = asRecord(transcriptResult.metadata);
2112
+ const projectId = typeof metadata.projectId === "string" ? metadata.projectId.trim() : "";
2113
+ if (!isProjectAllowed(projectId, allowedProjects)) {
2114
+ throw new Error(`Project-scoped key cannot access transcript from project "${projectId || "unassigned"}"`);
2115
+ }
2116
+ }
2117
+ if (parsed.resourceType === "transcripts-list") {
2118
+ const list = parsed;
2119
+ const requestedProjectId = typeof list.projectId === "string" ? list.projectId.trim() : "";
2120
+ if (requestedProjectId.length > 0 && !isProjectAllowed(requestedProjectId, allowedProjects)) {
2121
+ throw new Error(`Project-scoped key cannot list transcripts for project "${requestedProjectId}"`);
2122
+ }
2123
+ if (requestedProjectId.length === 0) {
2124
+ if (allowedProjects.length !== 1) {
2125
+ throw new Error("Project-scoped key with multiple projects must specify projectId in transcript list resource");
2126
+ }
2127
+ const contents = await readTranscriptsListResource({
2128
+ directory: list.directory,
2129
+ startDate: list.startDate,
2130
+ endDate: list.endDate,
2131
+ limit: list.limit,
2132
+ offset: list.offset,
2133
+ projectId: allowedProjects[0]
2134
+ });
2135
+ return { contents: [contents] };
2136
+ }
2137
+ }
2138
+ }
1629
2139
  try {
1630
2140
  const contents = await handleReadResource(uri);
2141
+ if (allowedProjects.length > 0) {
2142
+ const parsed = parseUri(uri);
2143
+ if (parsed.resourceType === "entities-list") {
2144
+ const filtered = filterProjectEntitiesResourceJson(contents, allowedProjects);
2145
+ return { contents: [filtered] };
2146
+ }
2147
+ }
1631
2148
  return { contents: [contents] };
1632
2149
  } catch (error) {
1633
2150
  const message = error instanceof Error ? error.message : String(error);
@@ -1806,7 +2323,19 @@ app.post(
1806
2323
  const rawTitle = body["title"];
1807
2324
  const rawProject = body["project"];
1808
2325
  const title = typeof rawTitle === "string" && rawTitle.trim() ? rawTitle.trim() : void 0;
1809
- const project = typeof rawProject === "string" && rawProject.trim() ? rawProject.trim() : void 0;
2326
+ let project = typeof rawProject === "string" && rawProject.trim() ? rawProject.trim() : void 0;
2327
+ const authContext = c.get(AUTH_CONTEXT_KEY);
2328
+ if (hasProjectScope(authContext ?? null)) {
2329
+ const allowedProjects = normalizeAllowedProjects(authContext ?? null);
2330
+ if (!project && allowedProjects.length === 1) {
2331
+ project = allowedProjects[0];
2332
+ }
2333
+ if (!project || !isProjectAllowed(project, allowedProjects)) {
2334
+ return c.json({
2335
+ error: "Project-scoped key cannot upload outside allowed projects"
2336
+ }, 403);
2337
+ }
2338
+ }
1810
2339
  await mkdir(outputDir, { recursive: true });
1811
2340
  const { uuid, filePath } = await createUploadTranscript({
1812
2341
  audioFile: basename(uploadObjectPath),
@@ -1862,6 +2391,14 @@ app.get("/audio/:uuid", async (c) => {
1862
2391
  const transcript = PklTranscript.open(filePath, { readOnly: true });
1863
2392
  const metadata = transcript.metadata;
1864
2393
  await transcript.close();
2394
+ const authContext = c.get(AUTH_CONTEXT_KEY);
2395
+ if (hasProjectScope(authContext ?? null)) {
2396
+ const allowedProjects = normalizeAllowedProjects(authContext ?? null);
2397
+ const projectId = typeof metadata.projectId === "string" ? metadata.projectId : null;
2398
+ if (!isProjectAllowed(projectId, allowedProjects)) {
2399
+ return c.json({ error: "Project-scoped key cannot access this transcript audio" }, 403);
2400
+ }
2401
+ }
1865
2402
  if (!metadata.audioHash) {
1866
2403
  return c.json({ error: "No audio file associated with this transcript" }, 404);
1867
2404
  }
@@ -1928,7 +2465,8 @@ app.get("/auth/whoami", async (c) => {
1928
2465
  return c.json({
1929
2466
  user_id: auth.user_id,
1930
2467
  roles: auth.roles,
1931
- key_id: auth.key_id
2468
+ key_id: auth.key_id,
2469
+ allowed_projects: auth.allowed_projects ?? []
1932
2470
  });
1933
2471
  });
1934
2472
  app.get("/admin/ping", async (c) => {
@@ -2154,7 +2692,11 @@ app.post("/mcp", async (c) => {
2154
2692
  rpcId: jsonRpcMessage.id ?? null,
2155
2693
  elapsedMs: Date.now() - requestStartedAt
2156
2694
  });
2157
- const response = await session.transport.handleRequest(c);
2695
+ const requestAuthContext = c.get(AUTH_CONTEXT_KEY) ?? null;
2696
+ const response = await authContextStore.run(
2697
+ requestAuthContext,
2698
+ () => session.transport.handleRequest(c)
2699
+ );
2158
2700
  requestLogger.debug("complete", {
2159
2701
  method: "POST",
2160
2702
  route: "/mcp",
@@ -2280,6 +2822,9 @@ async function main() {
2280
2822
  await cardigantime.configure(program);
2281
2823
  program.parse();
2282
2824
  const args = program.opts();
2825
+ const securedFromCli = program.getOptionValueSource("secured") === "cli" ? Boolean(args.secured) : void 0;
2826
+ const debugFromCli = program.getOptionValueSource("debug") === "cli" ? Boolean(args.debug) : void 0;
2827
+ const verboseFromCli = program.getOptionValueSource("verbose") === "cli" ? Boolean(args.verbose) : void 0;
2283
2828
  if (args.cwd) {
2284
2829
  process.chdir(resolve(args.cwd));
2285
2830
  }
@@ -2305,7 +2850,7 @@ async function main() {
2305
2850
  process.env.PROTOKOLL_CONFIG = configPath;
2306
2851
  process.env.WORKSPACE_ROOT = dirname(configPath);
2307
2852
  }
2308
- const cardigantimeConfig = await cardigantime.read({
2853
+ let cardigantimeConfig = await cardigantime.read({
2309
2854
  ...args,
2310
2855
  inputDirectory: args.inputDirectory ?? process.env.PROTOKOLL_INPUT_DIRECTORY,
2311
2856
  outputDirectory: args.outputDirectory ?? process.env.PROTOKOLL_OUTPUT_DIRECTORY,
@@ -2315,15 +2860,23 @@ async function main() {
2315
2860
  classifyModel: args.classifyModel ?? process.env.PROTOKOLL_CLASSIFY_MODEL,
2316
2861
  composeModel: args.composeModel ?? process.env.PROTOKOLL_COMPOSE_MODEL,
2317
2862
  transcriptionModel: args.transcriptionModel ?? process.env.PROTOKOLL_TRANSCRIPTION_MODEL,
2318
- secured: args.secured ?? parseBooleanEnv(process.env.PROTOKOLL_HTTP_SECURED),
2863
+ secured: securedFromCli ?? parseBooleanEnv(process.env.PROTOKOLL_HTTP_SECURED),
2319
2864
  rbacUsersPath: args.rbacUsersPath ?? process.env.RBAC_USERS_PATH,
2320
2865
  rbacKeysPath: args.rbacKeysPath ?? process.env.RBAC_KEYS_PATH,
2321
2866
  rbacPolicyPath: args.rbacPolicyPath ?? process.env.RBAC_POLICY_PATH,
2322
2867
  rbacReloadSeconds: args.rbacReloadSeconds ?? process.env.RBAC_RELOAD_SECONDS,
2323
- debug: args.debug ?? parseBooleanEnv(process.env.PROTOKOLL_DEBUG),
2324
- verbose: args.verbose ?? parseBooleanEnv(process.env.PROTOKOLL_VERBOSE),
2868
+ debug: debugFromCli ?? parseBooleanEnv(process.env.PROTOKOLL_DEBUG),
2869
+ verbose: verboseFromCli ?? parseBooleanEnv(process.env.PROTOKOLL_VERBOSE),
2325
2870
  storage: buildEnvStorageConfig()
2326
2871
  });
2872
+ cardigantimeConfig = await mergeStorageFromCanonicalConfig(
2873
+ cardigantimeConfig,
2874
+ args.config
2875
+ );
2876
+ cardigantimeConfig = await injectLocalGcsCredentialsIfMissing(
2877
+ cardigantimeConfig
2878
+ );
2879
+ exportStorageConfigToEnv(cardigantimeConfig);
2327
2880
  configureHttpLogLevel(cardigantimeConfig.debug === true);
2328
2881
  if (!args.config) {
2329
2882
  const resolvedConfigDirs = cardigantimeConfig.resolvedConfigDirs;