@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.
- package/dist/local-web-fetch-bridge.js +1 -1
- package/dist/local-web-host.d.ts +59 -0
- package/dist/local-web-host.js +171 -4
- package/dist/meeting-minutes-http.d.ts +22 -0
- package/dist/meeting-minutes-http.js +252 -0
- package/dist/meeting-minutes-lineage-adapters.d.ts +157 -0
- package/dist/meeting-minutes-lineage-adapters.js +410 -0
- package/dist/meeting-minutes-lineage-http.d.ts +20 -0
- package/dist/meeting-minutes-lineage-http.js +221 -0
- package/dist/meeting-minutes-lineage-lifecycle.d.ts +58 -0
- package/dist/meeting-minutes-lineage-lifecycle.js +146 -0
- package/dist/meeting-minutes-lineage.d.ts +307 -0
- package/dist/meeting-minutes-lineage.js +1030 -0
- package/dist/meeting-minutes-storage-controller.d.ts +182 -0
- package/dist/meeting-minutes-storage-controller.js +366 -0
- package/dist/meeting-minutes-storage-http.d.ts +25 -0
- package/dist/meeting-minutes-storage-http.js +312 -0
- package/dist/meeting-minutes-storage.d.ts +169 -0
- package/dist/meeting-minutes-storage.js +402 -0
- package/dist/meeting-minutes.d.ts +223 -0
- package/dist/meeting-minutes.js +1172 -0
- package/dist/world-extension.js +1 -1
- package/package.json +46 -3
- package/ui/brainbase-tokens.css +35 -35
- package/ui/local-web-shell.css +20 -17
- package/ui/local-web-shell.js +34 -1
- package/ui/meeting-minutes-lineage-http.js +247 -0
- package/ui/meeting-minutes-lineage.css +177 -0
- package/ui/meeting-minutes-lineage.js +485 -0
- package/ui/meeting-minutes-storage.css +170 -0
- package/ui/meeting-minutes-storage.js +388 -0
- package/ui/meeting-minutes.css +215 -0
- package/ui/meeting-minutes.js +622 -0
- package/ui/world/world-district.js +306 -87
- package/ui/world/world-scenery.js +208 -0
- package/ui/world/world-view.css +54 -0
- 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();
|
package/dist/local-web-host.d.ts
CHANGED
|
@@ -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,
|
package/dist/local-web-host.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|