@unson/brainbase-mcp 0.21.1 → 0.22.0

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.
Files changed (37) hide show
  1. package/dist/local-web-fetch-bridge.js +1 -1
  2. package/dist/local-web-host.d.ts +59 -0
  3. package/dist/local-web-host.js +171 -4
  4. package/dist/meeting-minutes-http.d.ts +22 -0
  5. package/dist/meeting-minutes-http.js +252 -0
  6. package/dist/meeting-minutes-lineage-adapters.d.ts +157 -0
  7. package/dist/meeting-minutes-lineage-adapters.js +410 -0
  8. package/dist/meeting-minutes-lineage-http.d.ts +20 -0
  9. package/dist/meeting-minutes-lineage-http.js +221 -0
  10. package/dist/meeting-minutes-lineage-lifecycle.d.ts +58 -0
  11. package/dist/meeting-minutes-lineage-lifecycle.js +146 -0
  12. package/dist/meeting-minutes-lineage.d.ts +307 -0
  13. package/dist/meeting-minutes-lineage.js +1030 -0
  14. package/dist/meeting-minutes-storage-controller.d.ts +182 -0
  15. package/dist/meeting-minutes-storage-controller.js +366 -0
  16. package/dist/meeting-minutes-storage-http.d.ts +25 -0
  17. package/dist/meeting-minutes-storage-http.js +312 -0
  18. package/dist/meeting-minutes-storage.d.ts +169 -0
  19. package/dist/meeting-minutes-storage.js +402 -0
  20. package/dist/meeting-minutes.d.ts +223 -0
  21. package/dist/meeting-minutes.js +1172 -0
  22. package/dist/world-extension.js +1 -1
  23. package/package.json +46 -3
  24. package/ui/brainbase-tokens.css +35 -35
  25. package/ui/local-web-shell.css +20 -17
  26. package/ui/local-web-shell.js +34 -1
  27. package/ui/meeting-minutes-lineage-http.js +247 -0
  28. package/ui/meeting-minutes-lineage.css +177 -0
  29. package/ui/meeting-minutes-lineage.js +485 -0
  30. package/ui/meeting-minutes-storage.css +170 -0
  31. package/ui/meeting-minutes-storage.js +388 -0
  32. package/ui/meeting-minutes.css +215 -0
  33. package/ui/meeting-minutes.js +622 -0
  34. package/ui/world/world-district.js +306 -87
  35. package/ui/world/world-scenery.js +208 -0
  36. package/ui/world/world-view.css +54 -0
  37. package/ui/world/world-view.js +65 -205
@@ -5,7 +5,7 @@ import { readBoundedBody } from './local-web-security.js';
5
5
  * read are forwarded; the Host, Origin and token were already checked on the
6
6
  * Node request, which stays the security boundary.
7
7
  */
8
- const FORWARDED_HEADERS = ['accept', 'content-type', 'if-match'];
8
+ const FORWARDED_HEADERS = ['accept', 'content-type', 'if-match', 'idempotency-key'];
9
9
  export async function nodeRequestToFetch(request, options) {
10
10
  const method = (request.method ?? 'GET').toUpperCase();
11
11
  const headers = new Headers();
@@ -1,6 +1,11 @@
1
1
  import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
2
  import { type ObjectiveDefinitionBuilder } from './foundation-http.js';
3
3
  import { JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
4
+ import { type MeetingMinutesLineageCorrectionHook } from './meeting-minutes-lineage-lifecycle.js';
5
+ import type { MinutesLineageActor, MinutesLineageStore } from './meeting-minutes-lineage.js';
6
+ import { type MeetingMinutesStore } from './meeting-minutes.js';
7
+ import { type FilesystemMeetingMinutesAdapterOptions } from './meeting-minutes-storage.js';
8
+ import { MeetingMinutesStorageController, type MeetingMinutesExternalSourceRegistry } from './meeting-minutes-storage-controller.js';
4
9
  /**
5
10
  * General local single-owner Web host (`brainbase web:serve`).
6
11
  *
@@ -19,6 +24,7 @@ export declare const LOCAL_WEB_DEFAULT_OWNER_ID = "self";
19
24
  export declare const LOCAL_WEB_STATUS_PATH = "/api/local/status";
20
25
  export declare const LOCAL_WEB_WORLD_MODEL_PREFIX = "/api/world-model";
21
26
  export declare const LOCAL_WEB_GRAPH_PREFIX = "/api/graph";
27
+ export declare const LOCAL_WEB_MEETING_MINUTES_PREFIX = "/api/meeting-minutes";
22
28
  /**
23
29
  * An Objective is a few KB of text and criteria. 64 KiB leaves room for long
24
30
  * Japanese text (about 20k characters) while keeping a loopback request that
@@ -41,6 +47,27 @@ export interface LocalWebModuleContext {
41
47
  readonly now: () => Date;
42
48
  /** The host's one listing cache for the judgment journal, shared by every screen that reads it. */
43
49
  readonly journalCache: JudgmentValueProofJournalCache;
50
+ /** The one host-owned native meeting-minutes service shared by modules/extensions. */
51
+ readonly meetingMinutesStore: MeetingMinutesStore;
52
+ /** Explicitly configured storage runtime. Native minutes remain the default. */
53
+ readonly meetingMinutesStorage?: LocalWebMeetingMinutesStorageRuntime;
54
+ /** Explicitly configured lineage runtime. No lineage provider is guessed. */
55
+ readonly meetingMinutesLineage?: LocalWebMeetingMinutesLineageRuntime;
56
+ }
57
+ export interface LocalWebMeetingMinutesStorageRuntime {
58
+ readonly defaultExternal?: Readonly<{
59
+ provider: string;
60
+ locator: string;
61
+ }>;
62
+ readonly controller: MeetingMinutesStorageController;
63
+ }
64
+ export interface LocalWebMeetingMinutesLineageRuntime {
65
+ readonly store: MinutesLineageStore;
66
+ /** Host-owned actor. Browser actor fields are never used as authority. */
67
+ readonly actor: MinutesLineageActor;
68
+ readonly correctionHook: MeetingMinutesLineageCorrectionHook;
69
+ readonly correctionReason: string;
70
+ readonly candidateDefaults?: Readonly<Record<string, unknown>>;
44
71
  }
45
72
  /**
46
73
  * Server side of one screen. The host has already rejected a non-loopback
@@ -75,8 +102,38 @@ export interface LocalWebHostOptions {
75
102
  /** Directory containing the packaged `ui/` files. */
76
103
  readonly uiDir?: string;
77
104
  readonly now?: () => Date;
105
+ /** Optional composition seam for organization adapters and lineage. */
106
+ readonly meetingMinutesStore?: MeetingMinutesStore;
107
+ /**
108
+ * Enables the storage controller only when a host supplies an explicit
109
+ * filesystem root and authorization policy. Browser input never supplies
110
+ * either value.
111
+ */
112
+ readonly meetingMinutesStorage?: LocalWebMeetingMinutesStorageOptions;
113
+ /** Enables lineage only with an organization supplied, real provider store. */
114
+ readonly meetingMinutesLineage?: LocalWebMeetingMinutesLineageOptions;
78
115
  readonly extensions?: readonly LocalWebExtension[];
79
116
  }
117
+ export interface LocalWebMeetingMinutesStorageOptions {
118
+ readonly filesystem: Pick<FilesystemMeetingMinutesAdapterOptions, 'root' | 'authorize' | 'provider' | 'max_bytes'>;
119
+ readonly externalRegistry?: MeetingMinutesExternalSourceRegistry;
120
+ /** Optional host-selected external source used by the shared UI placement selector. */
121
+ readonly defaultExternal?: Readonly<{
122
+ provider: string;
123
+ locator: string;
124
+ }>;
125
+ }
126
+ export interface LocalWebMeetingMinutesLineageOptions {
127
+ /** The actual provider-backed ledger shared by the host. */
128
+ readonly store: MinutesLineageStore;
129
+ /** Optional host-selected actor; defaults to the local single-owner actor. */
130
+ readonly actor?: MinutesLineageActor;
131
+ /** Optional hook seam for organizations; default writes to the supplied store. */
132
+ readonly correctionHook?: MeetingMinutesLineageCorrectionHook;
133
+ readonly correctionReason?: string;
134
+ /** Non-authoritative UI defaults for the candidate form. */
135
+ readonly candidateDefaults?: Readonly<Record<string, unknown>>;
136
+ }
80
137
  /** One organization term that names a field value, e.g. project.kind = product → プロダクト. */
81
138
  export interface GraphVocabularyTerm {
82
139
  readonly id: string;
@@ -97,6 +154,8 @@ export declare function isTrustedLocalWrite(request: IncomingMessage, token: str
97
154
  export declare function readLocalGraphState(dataDir: string): Promise<LocalGraphState>;
98
155
  export declare function createLocalStatusModule(context: LocalWebModuleContext): LocalWebModule;
99
156
  export declare function createValueProofModule(context: LocalWebModuleContext): LocalWebModule;
157
+ /** Native meeting minutes use the host's authenticated local single-owner principal. */
158
+ export declare function createMeetingMinutesModule(context: LocalWebModuleContext): LocalWebModule;
100
159
  /**
101
160
  * Trusted authority fields for a single owner. The owner writes through the
102
161
  * launch-token browser session, so a new Objective is private to the owner,
@@ -9,6 +9,13 @@ import { createFoundationHttpRouter, createObjectiveFoundationRoute, FOUNDATION_
9
9
  import { createFoundationRevisionStore, FoundationStoreError } from './foundation-store.js';
10
10
  import { createGraphWebHttpHandler } from './graph-web-http.js';
11
11
  import { defaultJudgmentJournalRoot, JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
12
+ import { createMeetingMinutesHttpHandler } from './meeting-minutes-http.js';
13
+ import { createMeetingMinutesLineageHttpHandler } from './meeting-minutes-lineage-http.js';
14
+ import { createMeetingMinutesLineageCorrectionHook, } from './meeting-minutes-lineage-lifecycle.js';
15
+ import { createMeetingMinutesStore } from './meeting-minutes.js';
16
+ import { createFilesystemMeetingMinutesAdapter, } from './meeting-minutes-storage.js';
17
+ import { MeetingMinutesStorageController, } from './meeting-minutes-storage-controller.js';
18
+ import { createMeetingMinutesStorageHttpHandler } from './meeting-minutes-storage-http.js';
12
19
  import { nodeRequestToFetch, writeFetchResponse } from './local-web-fetch-bridge.js';
13
20
  import { assertSameOrigin, isLoopbackHost, LOCAL_WEB_CONTENT_SECURITY_POLICY, LocalWebHttpError, readJsonObjectBody, requestUrl, tokenMatches, writeHttpError, writeJson } from './local-web-security.js';
14
21
  import { resolveDataDir } from './paths.js';
@@ -33,6 +40,7 @@ export const LOCAL_WEB_DEFAULT_OWNER_ID = 'self';
33
40
  export const LOCAL_WEB_STATUS_PATH = '/api/local/status';
34
41
  export const LOCAL_WEB_WORLD_MODEL_PREFIX = '/api/world-model';
35
42
  export const LOCAL_WEB_GRAPH_PREFIX = '/api/graph';
43
+ export const LOCAL_WEB_MEETING_MINUTES_PREFIX = '/api/meeting-minutes';
36
44
  /**
37
45
  * An Objective is a few KB of text and criteria. 64 KiB leaves room for long
38
46
  * Japanese text (about 20k characters) while keeping a loopback request that
@@ -171,6 +179,147 @@ export function createValueProofModule(context) {
171
179
  handle: handler
172
180
  };
173
181
  }
182
+ /** Native meeting minutes use the host's authenticated local single-owner principal. */
183
+ export function createMeetingMinutesModule(context) {
184
+ const lineage = context.meetingMinutesLineage;
185
+ const afterSave = lineage
186
+ ? async ({ before, after, context: saveContext }) => {
187
+ const pair = savedLineageVersionPair(before, after);
188
+ if (!pair)
189
+ return undefined;
190
+ return lineage.correctionHook.onVersionSaved({
191
+ previousVersion: pair.previousVersion,
192
+ replacementVersion: pair.replacementVersion,
193
+ access: { principal: saveContext.principal_id },
194
+ actor: lineage.actor,
195
+ reason: lineage.correctionReason,
196
+ });
197
+ }
198
+ : undefined;
199
+ const handler = context.meetingMinutesStorage
200
+ ? createMeetingMinutesStorageHttpHandler({
201
+ store: context.meetingMinutesStore,
202
+ controller: context.meetingMinutesStorage.controller,
203
+ basePath: LOCAL_WEB_MEETING_MINUTES_PREFIX,
204
+ resolveContext: () => ({ principal_id: LOCAL_WEB_DEFAULT_OWNER_ID }),
205
+ ...(afterSave ? { afterSave } : {}),
206
+ })
207
+ : createMeetingMinutesHttpHandler({
208
+ store: context.meetingMinutesStore,
209
+ basePath: LOCAL_WEB_MEETING_MINUTES_PREFIX,
210
+ resolveContext: () => ({ principal_id: LOCAL_WEB_DEFAULT_OWNER_ID }),
211
+ ...(afterSave ? { afterSave } : {}),
212
+ });
213
+ const lineageHandler = lineage
214
+ ? createMeetingMinutesLineageHttpHandler({
215
+ store: lineage.store,
216
+ basePath: '/api/meeting-minutes-lineage',
217
+ resolveAccess: () => ({ principal: LOCAL_WEB_DEFAULT_OWNER_ID }),
218
+ resolveActor: () => lineage.actor,
219
+ assertWriteAllowed: (request) => {
220
+ const rejected = rejectUntrustedWrite(request, context.token);
221
+ if (rejected)
222
+ throw rejected;
223
+ },
224
+ })
225
+ : undefined;
226
+ return {
227
+ id: 'meeting-minutes',
228
+ uiFiles: [
229
+ 'meeting-minutes.js',
230
+ 'meeting-minutes.css',
231
+ 'meeting-minutes-storage.js',
232
+ 'meeting-minutes-storage.css',
233
+ 'meeting-minutes-lineage.js',
234
+ 'meeting-minutes-lineage.css',
235
+ 'meeting-minutes-lineage-http.js',
236
+ ],
237
+ async handle(request, response) {
238
+ if (await handler(request, response))
239
+ return true;
240
+ return lineageHandler ? lineageHandler(request, response) : false;
241
+ }
242
+ };
243
+ }
244
+ function savedLineageVersionPair(before, after) {
245
+ for (const beforeDocument of before.minutes) {
246
+ const afterDocument = after.minutes.find((candidate) => candidate.minutes_id === beforeDocument.minutes_id);
247
+ if (!afterDocument || afterDocument.current_version_id === beforeDocument.current_version_id)
248
+ continue;
249
+ const previous = before.versions.find((candidate) => candidate.version_id === beforeDocument.current_version_id);
250
+ const replacement = after.versions.find((candidate) => candidate.version_id === afterDocument.current_version_id);
251
+ if (!previous || !replacement)
252
+ continue;
253
+ return {
254
+ previousVersion: nativeLineageVersion(previous),
255
+ replacementVersion: nativeLineageVersion(replacement),
256
+ };
257
+ }
258
+ return undefined;
259
+ }
260
+ function nativeLineageVersion(version) {
261
+ return {
262
+ version_id: version.version_id,
263
+ minutes_id: version.minutes_id,
264
+ meeting_id: version.meeting_id,
265
+ ...(version.predecessor_version_id ? { predecessor_version_id: version.predecessor_version_id } : {}),
266
+ ...(version.body_digest ? { body_digest: version.body_digest } : {}),
267
+ ...(version.source_ref ? { source_ref: version.source_ref } : {}),
268
+ };
269
+ }
270
+ function createLocalWebMeetingMinutesStorageRuntime(options, core) {
271
+ if (!options.filesystem || typeof options.filesystem !== 'object') {
272
+ throw new TypeError('meetingMinutesStorage.filesystem is required');
273
+ }
274
+ const provider = typeof options.filesystem.provider === 'string' && options.filesystem.provider.trim()
275
+ ? options.filesystem.provider.trim()
276
+ : 'filesystem';
277
+ const adapter = createFilesystemMeetingMinutesAdapter({ ...options.filesystem, provider });
278
+ const defaultExternal = options.defaultExternal;
279
+ if (defaultExternal !== undefined) {
280
+ if (typeof defaultExternal.provider !== 'string' || !defaultExternal.provider.trim()
281
+ || typeof defaultExternal.locator !== 'string' || !defaultExternal.locator.trim()) {
282
+ throw new TypeError('meetingMinutesStorage.defaultExternal provider and locator are required');
283
+ }
284
+ if (defaultExternal.provider.trim() !== provider) {
285
+ throw new TypeError('meetingMinutesStorage.defaultExternal provider must match the configured filesystem provider');
286
+ }
287
+ }
288
+ return {
289
+ controller: new MeetingMinutesStorageController({
290
+ core,
291
+ external_adapters: [{ provider, adapter }],
292
+ external_registry: options.externalRegistry,
293
+ }),
294
+ ...(defaultExternal
295
+ ? { defaultExternal: Object.freeze({ provider: defaultExternal.provider.trim(), locator: defaultExternal.locator.trim() }) }
296
+ : {}),
297
+ };
298
+ }
299
+ function createLocalWebMeetingMinutesLineageRuntime(options) {
300
+ if (!options.store || typeof options.store.readByMinutesVersion !== 'function'
301
+ || typeof options.store.createCandidate !== 'function'
302
+ || typeof options.store.markCorrection !== 'function') {
303
+ throw new TypeError('meetingMinutesLineage.store must be a complete lineage store');
304
+ }
305
+ const actor = options.actor ?? { type: 'person', id: LOCAL_WEB_DEFAULT_OWNER_ID };
306
+ if (!actor || !['person', 'agent', 'service', 'system'].includes(actor.type)
307
+ || typeof actor.id !== 'string' || actor.id.trim() === '') {
308
+ throw new TypeError('meetingMinutesLineage.actor must be a trusted actor');
309
+ }
310
+ const correctionReason = options.correctionReason?.trim() || '議事録版を修正しました';
311
+ const correctionHook = options.correctionHook ?? createMeetingMinutesLineageCorrectionHook({ store: options.store });
312
+ if (!correctionHook || typeof correctionHook.onVersionSaved !== 'function') {
313
+ throw new TypeError('meetingMinutesLineage.correctionHook must implement onVersionSaved');
314
+ }
315
+ return {
316
+ store: options.store,
317
+ actor: { type: actor.type, id: actor.id.trim() },
318
+ correctionHook,
319
+ correctionReason,
320
+ ...(options.candidateDefaults ? { candidateDefaults: options.candidateDefaults } : {}),
321
+ };
322
+ }
174
323
  const NOTHING_UNREADABLE = Object.freeze({ count: 0, codes: [] });
175
324
  function isFoundationDenial(error) {
176
325
  return error instanceof FoundationStoreError && (error.code === 'authorization_denied' || error.code === 'scope_violation');
@@ -709,6 +858,7 @@ export function defaultLocalWebModules(context) {
709
858
  return [
710
859
  createLocalStatusModule(context),
711
860
  createValueProofModule(context),
861
+ createMeetingMinutesModule(context),
712
862
  createObjectiveFoundationModule(context),
713
863
  createWorldModelReadModule(context),
714
864
  createGraphWebModule(context)
@@ -734,7 +884,7 @@ ${stylesheets.map((path) => `<link rel="stylesheet" href="${escapeAttribute(path
734
884
  </html>
735
885
  `;
736
886
  }
737
- function bootstrapJs(extensions) {
887
+ function bootstrapJs(extensions, meetingMinutesStorage, meetingMinutesLineage) {
738
888
  const imports = extensions.map((extension, index) => `import { screen as extensionScreen${index} } from '/ui/extensions/${extension.id}/${extension.screenEntry}';`).join('\n');
739
889
  const leading = extensions.flatMap((extension, index) => (extension.navPosition === 'first' ? [`extensionScreen${index}`] : [])).join(', ');
740
890
  const trailing = extensions.flatMap((extension, index) => (extension.navPosition === 'first' ? [] : [`extensionScreen${index}`])).join(', ');
@@ -755,6 +905,8 @@ const shell = createLocalWebShell({
755
905
  root: document.getElementById('brainbase-local-web'),
756
906
  token: meta('brainbase-web-token'),
757
907
  initialScreen: screenFromHash(),
908
+ meetingMinutesStorage: ${JSON.stringify(meetingMinutesStorage ?? null)},
909
+ meetingMinutesLineage: ${JSON.stringify(meetingMinutesLineage ?? null)},
758
910
  screens: ${leading ? `[${leading}, ...LOCAL_WEB_SCREENS${trailing ? `, ${trailing}` : ''}]` : '[...LOCAL_WEB_SCREENS, ...extensionScreens]'}
759
911
  });
760
912
  addEventListener('hashchange', () => shell.show(screenFromHash()));
@@ -771,12 +923,23 @@ export function createLocalWebHost(options = {}) {
771
923
  const dataDir = resolveDataDir(options.dataDir);
772
924
  const journalRoot = options.journalRoot ?? defaultJudgmentJournalRoot(dataDir);
773
925
  const uiDir = options.uiDir ?? fileURLToPath(new URL('../ui/', import.meta.url));
926
+ const now = options.now ?? (() => new Date());
927
+ const meetingMinutesStore = options.meetingMinutesStore ?? createMeetingMinutesStore({ data_dir: dataDir, now });
928
+ const meetingMinutesStorage = options.meetingMinutesStorage
929
+ ? createLocalWebMeetingMinutesStorageRuntime(options.meetingMinutesStorage, meetingMinutesStore)
930
+ : undefined;
931
+ const meetingMinutesLineage = options.meetingMinutesLineage
932
+ ? createLocalWebMeetingMinutesLineageRuntime(options.meetingMinutesLineage)
933
+ : undefined;
774
934
  const context = {
775
935
  dataDir,
776
936
  journalRoot,
777
937
  token,
778
- now: options.now ?? (() => new Date()),
779
- journalCache: new JudgmentValueProofJournalCache()
938
+ now,
939
+ journalCache: new JudgmentValueProofJournalCache(),
940
+ meetingMinutesStore,
941
+ ...(meetingMinutesStorage ? { meetingMinutesStorage } : {}),
942
+ ...(meetingMinutesLineage ? { meetingMinutesLineage } : {})
780
943
  };
781
944
  const modules = defaultLocalWebModules(context);
782
945
  const extensions = [...options.extensions ?? []];
@@ -853,7 +1016,11 @@ export function createLocalWebHost(options = {}) {
853
1016
  }
854
1017
  if (path === '/app.js') {
855
1018
  response.setHeader('Content-Type', 'text/javascript; charset=utf-8');
856
- response.end(bootstrapJs(extensions));
1019
+ response.end(bootstrapJs(extensions, meetingMinutesStorage
1020
+ ? { defaultExternal: meetingMinutesStorage.defaultExternal }
1021
+ : undefined, meetingMinutesLineage
1022
+ ? { candidateDefaults: meetingMinutesLineage.candidateDefaults }
1023
+ : undefined));
857
1024
  return;
858
1025
  }
859
1026
  const asset = uiFiles.get(path);
@@ -0,0 +1,22 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { type MeetingMinutesDetail, type MeetingMinutesRequestContext, type MeetingMinutesStore } from './meeting-minutes.js';
3
+ /** HTTP contract shared by the local and organization meeting-minutes hosts. */
4
+ export declare const MEETING_MINUTES_HTTP_VERSION: "brainbase.meeting-minutes-http.v1";
5
+ export declare const MEETING_MINUTES_HTTP_PREFIX: "/api/meeting-minutes";
6
+ type MaybePromise<T> = T | Promise<T>;
7
+ export interface MeetingMinutesHttpOptions {
8
+ readonly store: MeetingMinutesStore;
9
+ /** The host supplies the trusted principal; request JSON is never used. */
10
+ readonly resolveContext: (request: IncomingMessage) => MaybePromise<MeetingMinutesRequestContext>;
11
+ /** Called only after a save has returned the core's readback detail. */
12
+ readonly afterSave?: (input: {
13
+ readonly before: MeetingMinutesDetail;
14
+ readonly after: MeetingMinutesDetail;
15
+ readonly context: MeetingMinutesRequestContext;
16
+ }) => MaybePromise<unknown>;
17
+ readonly basePath?: string;
18
+ readonly bodyLimitBytes?: number;
19
+ }
20
+ export type MeetingMinutesHttpHandler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
21
+ export declare function createMeetingMinutesHttpHandler(options: MeetingMinutesHttpOptions): MeetingMinutesHttpHandler;
22
+ export {};
@@ -0,0 +1,252 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { MeetingMinutesError } from './meeting-minutes.js';
3
+ import { LocalWebHttpError, readJsonObjectBody, requestUrl, writeHttpError, writeJson } from './local-web-security.js';
4
+ /** HTTP contract shared by the local and organization meeting-minutes hosts. */
5
+ export const MEETING_MINUTES_HTTP_VERSION = 'brainbase.meeting-minutes-http.v1';
6
+ export const MEETING_MINUTES_HTTP_PREFIX = '/api/meeting-minutes';
7
+ const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
8
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
9
+ function normalizeBasePath(path) {
10
+ const value = (path ?? MEETING_MINUTES_HTTP_PREFIX).trim().replace(/\/+$/u, '');
11
+ if (!value.startsWith('/') || value === '')
12
+ throw new TypeError('basePath must be an absolute path');
13
+ return value;
14
+ }
15
+ function decodePathSegment(value) {
16
+ try {
17
+ const decoded = decodeURIComponent(value);
18
+ return decoded === '' || decoded.includes('/') ? undefined : decoded;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ function routeFor(pathname, basePath) {
25
+ if (pathname === basePath)
26
+ return { kind: 'collection' };
27
+ if (!pathname.startsWith(`${basePath}/`))
28
+ return undefined;
29
+ const rawSegments = pathname.slice(basePath.length + 1).split('/');
30
+ if (rawSegments.some((segment) => segment === ''))
31
+ return { kind: 'meeting' };
32
+ const segments = rawSegments.map(decodePathSegment);
33
+ if (segments.some((segment) => segment === undefined))
34
+ return { kind: 'meeting' };
35
+ const [meetingId, noun, minutesId, action, versionId] = segments;
36
+ if (!meetingId)
37
+ return { kind: 'meeting' };
38
+ if (segments.length === 1)
39
+ return { kind: 'meeting', meetingId };
40
+ if (segments.length === 2 && noun === 'minutes')
41
+ return { kind: 'minutes', meetingId };
42
+ if (segments.length === 3 && noun === 'minutes' && minutesId)
43
+ return { kind: 'save', meetingId, minutesId };
44
+ if (segments.length === 4 && noun === 'minutes' && minutesId && action === 'confirm') {
45
+ return { kind: 'confirm', meetingId, minutesId };
46
+ }
47
+ if (segments.length === 5 && noun === 'minutes' && minutesId && action === 'versions' && versionId) {
48
+ return { kind: 'version', meetingId, minutesId, versionId };
49
+ }
50
+ return { kind: 'meeting' };
51
+ }
52
+ function canonicalJson(value) {
53
+ if (value === null || typeof value !== 'object')
54
+ return JSON.stringify(value);
55
+ if (Array.isArray(value))
56
+ return `[${value.map(canonicalJson).join(',')}]`;
57
+ const entries = Object.entries(value)
58
+ .sort(([left], [right]) => left.localeCompare(right))
59
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`);
60
+ return `{${entries.join(',')}}`;
61
+ }
62
+ function headerValue(request, name) {
63
+ const value = request.headers[name.toLowerCase()];
64
+ return typeof value === 'string' ? value : undefined;
65
+ }
66
+ function idempotencyKey(request) {
67
+ const value = headerValue(request, 'idempotency-key');
68
+ if (value === undefined)
69
+ return undefined;
70
+ const key = value.trim();
71
+ if (key === '' || key.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
72
+ throw new LocalWebHttpError(422, 'invalid_idempotency_key', 'Idempotency-Key must be 1–200 characters');
73
+ }
74
+ return key;
75
+ }
76
+ function requestFingerprint(value) {
77
+ return `sha256:${createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex')}`;
78
+ }
79
+ function idempotencyFields(key, fingerprint) {
80
+ if (key === undefined || fingerprint === undefined)
81
+ return {};
82
+ return { idempotency_key: key, idempotency_fingerprint: fingerprint };
83
+ }
84
+ function statusForDomainError(error) {
85
+ switch (error.code) {
86
+ case 'invalid_input': return 422;
87
+ case 'not_found': return 404;
88
+ case 'revision_conflict':
89
+ case 'idempotency_conflict': return 409;
90
+ case 'authorization_denied': return 403;
91
+ case 'corrupt_record':
92
+ case 'storage_unavailable':
93
+ case 'readback_mismatch': return 500;
94
+ }
95
+ }
96
+ function publicDomainMessage(error) {
97
+ if (error.code === 'corrupt_record')
98
+ return 'Meeting minutes storage contains an invalid record';
99
+ if (error.code === 'storage_unavailable')
100
+ return 'Meeting minutes storage is unavailable';
101
+ if (error.code === 'readback_mismatch')
102
+ return 'Meeting minutes storage did not confirm the saved record';
103
+ return error.message;
104
+ }
105
+ function operationError(error) {
106
+ if (error instanceof MeetingMinutesError) {
107
+ return {
108
+ statusCode: statusForDomainError(error),
109
+ body: { error: { code: error.code, message: publicDomainMessage(error) } }
110
+ };
111
+ }
112
+ return { statusCode: 500, body: { error: { code: 'internal_error', message: 'Meeting minutes request failed' } } };
113
+ }
114
+ function hasOwn(body, key) {
115
+ return Object.prototype.hasOwnProperty.call(body, key);
116
+ }
117
+ function createInput(body, key, fingerprint) {
118
+ return {
119
+ title: body.title,
120
+ scheduled_at: body.scheduled_at,
121
+ ended_at: body.ended_at,
122
+ participant_ids: body.participant_ids,
123
+ metadata: body.metadata,
124
+ minutes_title: body.minutes_title,
125
+ minutes_metadata: body.minutes_metadata,
126
+ initial_body: body.initial_body,
127
+ source_ref: body.source_ref,
128
+ ...idempotencyFields(key, fingerprint)
129
+ };
130
+ }
131
+ function createMinutesInput(body, key, fingerprint) {
132
+ return {
133
+ title: body.title,
134
+ metadata: body.metadata,
135
+ ...(hasOwn(body, 'body') ? { body: body.body } : {}),
136
+ ...(hasOwn(body, 'source_ref') ? { source_ref: body.source_ref } : {}),
137
+ expected_revision: body.expected_revision,
138
+ ...idempotencyFields(key, fingerprint)
139
+ };
140
+ }
141
+ function saveInput(body, minutesId, key, fingerprint) {
142
+ return {
143
+ minutes_id: minutesId ?? body.minutes_id,
144
+ ...(hasOwn(body, 'body') ? { body: body.body } : {}),
145
+ ...(hasOwn(body, 'source_ref') ? { source_ref: body.source_ref } : {}),
146
+ metadata: body.metadata,
147
+ expected_revision: body.expected_revision,
148
+ predecessor_version_id: body.predecessor_version_id,
149
+ ...idempotencyFields(key, fingerprint)
150
+ };
151
+ }
152
+ function confirmInput(body, minutesId, key, fingerprint) {
153
+ return {
154
+ minutes_id: minutesId,
155
+ version_id: body.version_id,
156
+ expected_revision: body.expected_revision,
157
+ ...idempotencyFields(key, fingerprint)
158
+ };
159
+ }
160
+ export function createMeetingMinutesHttpHandler(options) {
161
+ if (!options || !options.store || typeof options.resolveContext !== 'function') {
162
+ throw new TypeError('store and resolveContext are required');
163
+ }
164
+ const basePath = normalizeBasePath(options.basePath);
165
+ const bodyLimitBytes = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
166
+ if (!Number.isInteger(bodyLimitBytes) || bodyLimitBytes <= 0)
167
+ throw new TypeError('bodyLimitBytes must be a positive integer');
168
+ async function execute(route, body, context, key, fingerprint) {
169
+ try {
170
+ switch (route.kind) {
171
+ case 'collection':
172
+ if (body === undefined)
173
+ return { statusCode: 200, body: { state: 'ready', meetings: await options.store.list_meetings(context), absence_confirmed: true } };
174
+ return { statusCode: 201, body: await options.store.create_meeting(createInput(body, key, fingerprint), context) };
175
+ case 'meeting':
176
+ return { statusCode: 200, body: await options.store.get_meeting(route.meetingId ?? '', context) };
177
+ case 'minutes': {
178
+ if (body === undefined)
179
+ throw new MeetingMinutesError('invalid_input', 'Request body is required');
180
+ return { statusCode: 201, body: await options.store.create_minutes(route.meetingId ?? '', createMinutesInput(body, key, fingerprint), context) };
181
+ }
182
+ case 'save': {
183
+ if (body === undefined)
184
+ throw new MeetingMinutesError('invalid_input', 'Request body is required');
185
+ const before = await options.store.get_meeting(route.meetingId ?? '', context);
186
+ if (!before.minutes.some((candidate) => candidate.minutes_id === route.minutesId)) {
187
+ throw new MeetingMinutesError('not_found', `Minutes document ${route.minutesId} was not found`);
188
+ }
189
+ const after = await options.store.save_version(saveInput(body, route.minutesId, key, fingerprint), context);
190
+ const lineage = options.afterSave ? await options.afterSave({ before, after, context }) : undefined;
191
+ return { statusCode: 201, body: lineage === undefined ? after : { ...after, lineage } };
192
+ }
193
+ case 'confirm': {
194
+ if (body === undefined)
195
+ throw new MeetingMinutesError('invalid_input', 'Request body is required');
196
+ const detail = await options.store.get_meeting(route.meetingId ?? '', context);
197
+ const document = detail.minutes.find((candidate) => candidate.minutes_id === route.minutesId);
198
+ if (!document)
199
+ throw new MeetingMinutesError('not_found', `Minutes document ${route.minutesId} was not found`);
200
+ if (!detail.versions.some((candidate) => candidate.version_id === body.version_id && candidate.minutes_id === route.minutesId)) {
201
+ throw new MeetingMinutesError('not_found', `Minutes version ${String(body.version_id)} was not found`);
202
+ }
203
+ return { statusCode: 200, body: await options.store.confirm_version(confirmInput(body, document.minutes_id, key, fingerprint), context) };
204
+ }
205
+ case 'version':
206
+ return {
207
+ statusCode: 200,
208
+ body: await options.store.get_version(route.meetingId ?? '', route.minutesId ?? '', route.versionId ?? '', context)
209
+ };
210
+ }
211
+ }
212
+ catch (error) {
213
+ return operationError(error);
214
+ }
215
+ }
216
+ return async (request, response) => {
217
+ const path = requestUrl(request).pathname;
218
+ const route = routeFor(path, basePath);
219
+ if (!route)
220
+ return false;
221
+ const method = (request.method ?? 'GET').toUpperCase();
222
+ const allowed = route.kind === 'collection' ? ['GET', 'POST']
223
+ : route.kind === 'meeting' || route.kind === 'version' ? ['GET']
224
+ : ['POST'];
225
+ if (!allowed.includes(method)) {
226
+ response.setHeader('Allow', allowed.join(', '));
227
+ writeJson(response, 405, { error: { code: 'method_not_allowed', message: `Use ${allowed.join(' or ')}` } });
228
+ return true;
229
+ }
230
+ try {
231
+ const context = await options.resolveContext(request);
232
+ if (!context || typeof context.principal_id !== 'string' || context.principal_id.trim() === '') {
233
+ throw new LocalWebHttpError(401, 'authorization_required', 'A trusted meeting-minutes principal is required');
234
+ }
235
+ const body = method === 'POST' ? await readJsonObjectBody(request, bodyLimitBytes) : undefined;
236
+ const key = method === 'POST' ? idempotencyKey(request) : undefined;
237
+ const fingerprint = key === undefined ? undefined : requestFingerprint({ method, path, body: body ?? null });
238
+ // Idempotency is persisted by the domain store in the same CAS snapshot
239
+ // as the mutation. Keeping no result cache here ensures retries after a
240
+ // restart, and retries after ACL changes, are reauthorized by the store.
241
+ const result = await execute(route, body, context, key, fingerprint);
242
+ writeJson(response, result.statusCode, result.body);
243
+ }
244
+ catch (error) {
245
+ if (error instanceof LocalWebHttpError)
246
+ writeHttpError(response, error);
247
+ else
248
+ writeJson(response, 500, { error: { code: 'internal_error', message: 'Meeting minutes request failed' } });
249
+ }
250
+ return true;
251
+ };
252
+ }