@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
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { MeetingMinutesReadResult, MeetingMinutesStorageAdapter, MeetingMinutesStorageCapabilities, MeetingMinutesStorageRequestContext, MeetingMinutesRegisterResult } from './meeting-minutes-storage.js';
|
|
2
|
+
/** Provider-independent storage placement and re-import boundary. */
|
|
3
|
+
export declare const MEETING_MINUTES_STORAGE_CONTROLLER_CONTRACT_VERSION: "brainbase.meeting-minutes-storage-controller.v1";
|
|
4
|
+
export interface MeetingMinutesControllerSourceRef {
|
|
5
|
+
readonly provider: string;
|
|
6
|
+
readonly locator: string;
|
|
7
|
+
readonly revision: string;
|
|
8
|
+
readonly digest: string;
|
|
9
|
+
/** Adapter provenance is retained with the source ref, but is not part of identity. */
|
|
10
|
+
readonly provenance?: Readonly<Record<string, unknown>>;
|
|
11
|
+
}
|
|
12
|
+
export interface MeetingMinutesControllerRequestContext extends MeetingMinutesStorageRequestContext {
|
|
13
|
+
}
|
|
14
|
+
export interface MeetingMinutesControllerMeetingRecord {
|
|
15
|
+
readonly meeting_id: string;
|
|
16
|
+
}
|
|
17
|
+
export interface MeetingMinutesControllerDocumentRecord {
|
|
18
|
+
readonly minutes_id: string;
|
|
19
|
+
readonly meeting_id: string;
|
|
20
|
+
readonly current_version_id: string;
|
|
21
|
+
readonly revision: number;
|
|
22
|
+
}
|
|
23
|
+
export interface MeetingMinutesControllerVersionRecord {
|
|
24
|
+
readonly version_id: string;
|
|
25
|
+
readonly minutes_id: string;
|
|
26
|
+
readonly meeting_id: string;
|
|
27
|
+
readonly predecessor_version_id?: string;
|
|
28
|
+
readonly body?: string;
|
|
29
|
+
readonly body_digest?: string;
|
|
30
|
+
readonly source_ref?: MeetingMinutesControllerSourceRef;
|
|
31
|
+
}
|
|
32
|
+
export interface MeetingMinutesControllerDetail {
|
|
33
|
+
readonly meeting: MeetingMinutesControllerMeetingRecord;
|
|
34
|
+
readonly minutes: readonly MeetingMinutesControllerDocumentRecord[];
|
|
35
|
+
readonly versions: readonly MeetingMinutesControllerVersionRecord[];
|
|
36
|
+
readonly snapshot_revision: number;
|
|
37
|
+
}
|
|
38
|
+
export interface MeetingMinutesControllerCreateMeetingInput {
|
|
39
|
+
readonly title: string;
|
|
40
|
+
readonly scheduled_at?: string;
|
|
41
|
+
readonly ended_at?: string;
|
|
42
|
+
readonly participant_ids?: readonly string[];
|
|
43
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
44
|
+
readonly minutes_title?: string;
|
|
45
|
+
readonly minutes_metadata?: Readonly<Record<string, unknown>>;
|
|
46
|
+
/** Optional durable retry receipt supplied by an HTTP host. */
|
|
47
|
+
readonly idempotency_key?: string;
|
|
48
|
+
readonly idempotency_fingerprint?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface MeetingMinutesControllerCreateMinutesInput {
|
|
51
|
+
readonly title?: string;
|
|
52
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
53
|
+
readonly expected_revision: number;
|
|
54
|
+
readonly idempotency_key?: string;
|
|
55
|
+
readonly idempotency_fingerprint?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface MeetingMinutesControllerSaveVersionInput {
|
|
58
|
+
readonly minutes_id: string;
|
|
59
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
60
|
+
readonly expected_revision: number;
|
|
61
|
+
readonly predecessor_version_id?: string;
|
|
62
|
+
readonly idempotency_key?: string;
|
|
63
|
+
readonly idempotency_fingerprint?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface MeetingMinutesControllerRebindInput extends MeetingMinutesControllerSaveVersionInput {
|
|
66
|
+
readonly meeting_id: string;
|
|
67
|
+
}
|
|
68
|
+
export interface MeetingMinutesControllerConfirmVersionInput {
|
|
69
|
+
readonly meeting_id: string;
|
|
70
|
+
readonly minutes_id: string;
|
|
71
|
+
readonly version_id: string;
|
|
72
|
+
readonly expected_revision: number;
|
|
73
|
+
readonly idempotency_key?: string;
|
|
74
|
+
readonly idempotency_fingerprint?: string;
|
|
75
|
+
}
|
|
76
|
+
/** This port is structurally compatible with `MeetingMinutesStore`. */
|
|
77
|
+
export interface MeetingMinutesControllerCorePort {
|
|
78
|
+
create_meeting(input: MeetingMinutesControllerCreateMeetingInput & {
|
|
79
|
+
readonly initial_body?: string;
|
|
80
|
+
readonly source_ref?: MeetingMinutesControllerSourceRef;
|
|
81
|
+
}, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
82
|
+
create_minutes(meeting_id: string, input: MeetingMinutesControllerCreateMinutesInput & {
|
|
83
|
+
readonly body?: string;
|
|
84
|
+
readonly source_ref?: MeetingMinutesControllerSourceRef;
|
|
85
|
+
}, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
86
|
+
get_meeting(meeting_id: string, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
87
|
+
get_version(meeting_id: string, minutes_id: string, version_id: string, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerVersionRecord>;
|
|
88
|
+
save_version(input: MeetingMinutesControllerSaveVersionInput & {
|
|
89
|
+
readonly body?: string;
|
|
90
|
+
readonly source_ref?: MeetingMinutesControllerSourceRef;
|
|
91
|
+
}, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
92
|
+
/** Optional on older ports; required when confirmation is exposed. */
|
|
93
|
+
confirm_version?(input: MeetingMinutesControllerConfirmVersionInput, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
94
|
+
}
|
|
95
|
+
export interface MeetingMinutesExternalAdapterBinding {
|
|
96
|
+
/** Must equal the provider returned by `register` and stored in source_ref. */
|
|
97
|
+
readonly provider: string;
|
|
98
|
+
readonly adapter: MeetingMinutesStorageAdapter;
|
|
99
|
+
}
|
|
100
|
+
export interface MeetingMinutesExternalSourceIdentity {
|
|
101
|
+
readonly meeting_id: string;
|
|
102
|
+
readonly minutes_id: string;
|
|
103
|
+
readonly version_id: string;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The host can back this registry with the same durable registry passed to the
|
|
107
|
+
* core's `external_source_hook`. The in-memory implementation is intentionally
|
|
108
|
+
* process-scoped and is suitable only for an explicitly short-lived host.
|
|
109
|
+
*/
|
|
110
|
+
export interface MeetingMinutesExternalSourceRegistry {
|
|
111
|
+
find_existing(source_ref: MeetingMinutesControllerSourceRef): Promise<MeetingMinutesExternalSourceIdentity | undefined>;
|
|
112
|
+
register(source_ref: MeetingMinutesControllerSourceRef, identity: MeetingMinutesExternalSourceIdentity): Promise<void>;
|
|
113
|
+
}
|
|
114
|
+
export declare class InMemoryMeetingMinutesExternalSourceRegistry implements MeetingMinutesExternalSourceRegistry {
|
|
115
|
+
private readonly entries;
|
|
116
|
+
find_existing(source_ref: MeetingMinutesControllerSourceRef): Promise<MeetingMinutesExternalSourceIdentity | undefined>;
|
|
117
|
+
register(source_ref: MeetingMinutesControllerSourceRef, identity: MeetingMinutesExternalSourceIdentity): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
export type MeetingMinutesStoragePlacement = {
|
|
120
|
+
readonly kind: 'native';
|
|
121
|
+
readonly body?: string;
|
|
122
|
+
} | {
|
|
123
|
+
readonly kind: 'external';
|
|
124
|
+
readonly provider: string;
|
|
125
|
+
readonly locator: string;
|
|
126
|
+
};
|
|
127
|
+
export type MeetingMinutesStorageReadStatus = 'native' | 'available' | 'denied' | 'unavailable' | 'historical_unavailable';
|
|
128
|
+
export interface MeetingMinutesResolvedVersion {
|
|
129
|
+
readonly version: MeetingMinutesControllerVersionRecord;
|
|
130
|
+
readonly placement: 'native' | 'external' | 'unknown';
|
|
131
|
+
readonly source_status: MeetingMinutesStorageReadStatus;
|
|
132
|
+
readonly body?: string;
|
|
133
|
+
readonly source_result?: MeetingMinutesReadResult;
|
|
134
|
+
readonly capabilities?: MeetingMinutesStorageCapabilities;
|
|
135
|
+
readonly reason?: string;
|
|
136
|
+
}
|
|
137
|
+
export interface MeetingMinutesStorageControllerOptions {
|
|
138
|
+
readonly core: MeetingMinutesControllerCorePort;
|
|
139
|
+
readonly external_adapters?: readonly MeetingMinutesExternalAdapterBinding[];
|
|
140
|
+
readonly external_registry?: MeetingMinutesExternalSourceRegistry;
|
|
141
|
+
}
|
|
142
|
+
export type MeetingMinutesStorageControllerErrorCode = 'invalid_configuration' | 'source_denied' | 'source_unavailable' | 'source_changed' | 'idempotency_conflict' | 'storage_unavailable';
|
|
143
|
+
export declare class MeetingMinutesStorageControllerError extends Error {
|
|
144
|
+
readonly code: MeetingMinutesStorageControllerErrorCode;
|
|
145
|
+
readonly source_result?: MeetingMinutesReadResult | MeetingMinutesRegisterResult;
|
|
146
|
+
constructor(code: MeetingMinutesStorageControllerErrorCode, message: string, source_result?: MeetingMinutesReadResult | MeetingMinutesRegisterResult);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Service boundary for native and external minutes placement.
|
|
150
|
+
*
|
|
151
|
+
* It owns placement selection, adapter registration, source re-read and
|
|
152
|
+
* deduplication orchestration. The core remains the owner of Brainbase IDs,
|
|
153
|
+
* ACLs, CAS revisions and immutable version records.
|
|
154
|
+
*/
|
|
155
|
+
export declare class MeetingMinutesStorageController {
|
|
156
|
+
readonly contract_version: "brainbase.meeting-minutes-storage-controller.v1";
|
|
157
|
+
private readonly core;
|
|
158
|
+
private readonly adapters;
|
|
159
|
+
private readonly registry;
|
|
160
|
+
constructor(options: MeetingMinutesStorageControllerOptions);
|
|
161
|
+
create_meeting(input: MeetingMinutesControllerCreateMeetingInput, placement: MeetingMinutesStoragePlacement, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
162
|
+
create_minutes(meetingId: string, input: MeetingMinutesControllerCreateMinutesInput, placement: MeetingMinutesStoragePlacement, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
163
|
+
save_version(input: MeetingMinutesControllerSaveVersionInput, placement: MeetingMinutesStoragePlacement, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
164
|
+
/** Read native bodies directly and external bodies through the current ACL. */
|
|
165
|
+
get_version(meetingId: string, minutesId: string, versionId: string, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesResolvedVersion>;
|
|
166
|
+
/**
|
|
167
|
+
* Move the current minutes document to an explicit placement. The core's
|
|
168
|
+
* minutes_id remains the identity; this creates an immutable successor
|
|
169
|
+
* version and never writes the external provider.
|
|
170
|
+
*/
|
|
171
|
+
rebind(input: MeetingMinutesControllerRebindInput, placement: MeetingMinutesStoragePlacement, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
172
|
+
/**
|
|
173
|
+
* Confirm only an exact, currently readable external version. The core
|
|
174
|
+
* receipt is written after the adapter has re-read the source, so an ACL
|
|
175
|
+
* revocation, source change, or missing historical revision cannot be
|
|
176
|
+
* turned into a confirmation by reusing a newer body.
|
|
177
|
+
*/
|
|
178
|
+
confirm_version(input: MeetingMinutesControllerConfirmVersionInput, context: MeetingMinutesControllerRequestContext): Promise<MeetingMinutesControllerDetail>;
|
|
179
|
+
private registerExternal;
|
|
180
|
+
private findExisting;
|
|
181
|
+
private recoverIdempotency;
|
|
182
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** Provider-independent storage placement and re-import boundary. */
|
|
3
|
+
export const MEETING_MINUTES_STORAGE_CONTROLLER_CONTRACT_VERSION = 'brainbase.meeting-minutes-storage-controller.v1';
|
|
4
|
+
export class InMemoryMeetingMinutesExternalSourceRegistry {
|
|
5
|
+
entries = new Map();
|
|
6
|
+
async find_existing(source_ref) {
|
|
7
|
+
const identity = this.entries.get(sourceKey(source_ref));
|
|
8
|
+
return identity ? { ...identity } : undefined;
|
|
9
|
+
}
|
|
10
|
+
async register(source_ref, identity) {
|
|
11
|
+
const key = sourceKey(source_ref);
|
|
12
|
+
const existing = this.entries.get(key);
|
|
13
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(identity)) {
|
|
14
|
+
throw new MeetingMinutesStorageControllerError('idempotency_conflict', 'The external source is already bound to another minutes identity');
|
|
15
|
+
}
|
|
16
|
+
this.entries.set(key, { ...identity });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class MeetingMinutesStorageControllerError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
source_result;
|
|
22
|
+
constructor(code, message, source_result) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'MeetingMinutesStorageControllerError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.source_result = source_result;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return value !== null && typeof value === 'object';
|
|
31
|
+
}
|
|
32
|
+
function requiredText(value, label) {
|
|
33
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
34
|
+
throw new MeetingMinutesStorageControllerError('invalid_configuration', `${label} is required`);
|
|
35
|
+
}
|
|
36
|
+
return value.trim();
|
|
37
|
+
}
|
|
38
|
+
function sourceKey(source) {
|
|
39
|
+
return JSON.stringify([source.provider, source.locator, source.revision, source.digest]);
|
|
40
|
+
}
|
|
41
|
+
function sourceFromReference(reference) {
|
|
42
|
+
const provenance = isRecord(reference.provenance) ? Object.freeze({ ...reference.provenance }) : undefined;
|
|
43
|
+
return {
|
|
44
|
+
provider: requiredText(reference.provider, 'source_ref.provider'),
|
|
45
|
+
locator: requiredText(reference.locator, 'source_ref.locator'),
|
|
46
|
+
revision: requiredText(reference.revision, 'source_ref.revision'),
|
|
47
|
+
digest: requiredText(reference.digest, 'source_ref.digest'),
|
|
48
|
+
...(provenance ? { provenance } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function coreErrorCode(error) {
|
|
52
|
+
return isRecord(error) && typeof error.code === 'string' ? error.code : undefined;
|
|
53
|
+
}
|
|
54
|
+
function sourceStatus(result) {
|
|
55
|
+
if (result.status === 'available')
|
|
56
|
+
return 'available';
|
|
57
|
+
if (result.status === 'denied')
|
|
58
|
+
return 'denied';
|
|
59
|
+
if (result.status === 'integrity_mismatch')
|
|
60
|
+
return 'historical_unavailable';
|
|
61
|
+
return 'unavailable';
|
|
62
|
+
}
|
|
63
|
+
function sourceFailureCode(result) {
|
|
64
|
+
if (result.status === 'denied')
|
|
65
|
+
return 'source_denied';
|
|
66
|
+
if (result.status === 'integrity_mismatch')
|
|
67
|
+
return 'source_changed';
|
|
68
|
+
return 'source_unavailable';
|
|
69
|
+
}
|
|
70
|
+
function sourceFailureMessage(result) {
|
|
71
|
+
if ('reason' in result && typeof result.reason === 'string' && result.reason.trim())
|
|
72
|
+
return result.reason;
|
|
73
|
+
if (result.status === 'integrity_mismatch')
|
|
74
|
+
return 'The external source changed and its historical revision is unavailable';
|
|
75
|
+
return 'The external source could not be resolved';
|
|
76
|
+
}
|
|
77
|
+
function versionFor(detail, minutesId, versionId) {
|
|
78
|
+
const version = detail.versions.find((candidate) => candidate.minutes_id === minutesId && candidate.version_id === versionId);
|
|
79
|
+
if (!version)
|
|
80
|
+
throw new MeetingMinutesStorageControllerError('storage_unavailable', 'The core did not return the requested minutes version');
|
|
81
|
+
return version;
|
|
82
|
+
}
|
|
83
|
+
function documentFor(detail, minutesId) {
|
|
84
|
+
const document = detail.minutes.find((candidate) => candidate.minutes_id === minutesId);
|
|
85
|
+
if (!document)
|
|
86
|
+
throw new MeetingMinutesStorageControllerError('storage_unavailable', 'The core did not return the requested minutes document');
|
|
87
|
+
return document;
|
|
88
|
+
}
|
|
89
|
+
function identityForMeeting(detail) {
|
|
90
|
+
const document = detail.minutes.find((candidate) => candidate.meeting_id === detail.meeting.meeting_id);
|
|
91
|
+
if (!document)
|
|
92
|
+
throw new MeetingMinutesStorageControllerError('storage_unavailable', 'The core did not return a minutes document for the new meeting');
|
|
93
|
+
return {
|
|
94
|
+
meeting_id: detail.meeting.meeting_id,
|
|
95
|
+
minutes_id: document.minutes_id,
|
|
96
|
+
version_id: document.current_version_id,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function identityForCreatedMinutes(before, after, meetingId) {
|
|
100
|
+
const beforeIds = new Set(before.minutes.map((candidate) => candidate.minutes_id));
|
|
101
|
+
const document = after.minutes.find((candidate) => candidate.meeting_id === meetingId && !beforeIds.has(candidate.minutes_id));
|
|
102
|
+
if (!document)
|
|
103
|
+
throw new MeetingMinutesStorageControllerError('storage_unavailable', 'The core did not return the newly created minutes document');
|
|
104
|
+
return { meeting_id: meetingId, minutes_id: document.minutes_id, version_id: document.current_version_id };
|
|
105
|
+
}
|
|
106
|
+
function identityForSavedVersion(detail, minutesId) {
|
|
107
|
+
const document = documentFor(detail, minutesId);
|
|
108
|
+
return { meeting_id: document.meeting_id, minutes_id: minutesId, version_id: document.current_version_id };
|
|
109
|
+
}
|
|
110
|
+
function withSource(input, source) {
|
|
111
|
+
return { ...input, source_ref: source };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Service boundary for native and external minutes placement.
|
|
115
|
+
*
|
|
116
|
+
* It owns placement selection, adapter registration, source re-read and
|
|
117
|
+
* deduplication orchestration. The core remains the owner of Brainbase IDs,
|
|
118
|
+
* ACLs, CAS revisions and immutable version records.
|
|
119
|
+
*/
|
|
120
|
+
export class MeetingMinutesStorageController {
|
|
121
|
+
contract_version = MEETING_MINUTES_STORAGE_CONTROLLER_CONTRACT_VERSION;
|
|
122
|
+
core;
|
|
123
|
+
adapters;
|
|
124
|
+
registry;
|
|
125
|
+
constructor(options) {
|
|
126
|
+
if (!options || !options.core || typeof options.core.create_meeting !== 'function'
|
|
127
|
+
|| typeof options.core.create_minutes !== 'function' || typeof options.core.get_meeting !== 'function'
|
|
128
|
+
|| typeof options.core.get_version !== 'function' || typeof options.core.save_version !== 'function') {
|
|
129
|
+
throw new MeetingMinutesStorageControllerError('invalid_configuration', 'A compatible meeting minutes core port is required');
|
|
130
|
+
}
|
|
131
|
+
const entries = new Map();
|
|
132
|
+
for (const binding of options.external_adapters ?? []) {
|
|
133
|
+
const provider = requiredText(binding?.provider, 'external adapter provider');
|
|
134
|
+
if (!binding.adapter || typeof binding.adapter.register !== 'function' || typeof binding.adapter.read !== 'function') {
|
|
135
|
+
throw new MeetingMinutesStorageControllerError('invalid_configuration', `Adapter ${provider} does not implement register/read`);
|
|
136
|
+
}
|
|
137
|
+
if (entries.has(provider))
|
|
138
|
+
throw new MeetingMinutesStorageControllerError('invalid_configuration', `Adapter ${provider} is configured more than once`);
|
|
139
|
+
entries.set(provider, binding);
|
|
140
|
+
}
|
|
141
|
+
this.core = options.core;
|
|
142
|
+
this.adapters = entries;
|
|
143
|
+
this.registry = options.external_registry ?? new InMemoryMeetingMinutesExternalSourceRegistry();
|
|
144
|
+
}
|
|
145
|
+
async create_meeting(input, placement, context) {
|
|
146
|
+
if (placement.kind === 'native') {
|
|
147
|
+
return this.core.create_meeting({ ...input, initial_body: placement.body ?? '' }, context);
|
|
148
|
+
}
|
|
149
|
+
const registered = await this.registerExternal(placement, context);
|
|
150
|
+
const existing = await this.findExisting(registered.source, context);
|
|
151
|
+
if (existing)
|
|
152
|
+
return existing.detail;
|
|
153
|
+
try {
|
|
154
|
+
const detail = await this.core.create_meeting({ ...input, source_ref: registered.source }, context);
|
|
155
|
+
await this.registry.register(registered.source, identityForMeeting(detail));
|
|
156
|
+
return detail;
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
const recovered = await this.recoverIdempotency(registered.source, context, error);
|
|
160
|
+
if (recovered)
|
|
161
|
+
return recovered;
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async create_minutes(meetingId, input, placement, context) {
|
|
166
|
+
const before = await this.core.get_meeting(meetingId, context);
|
|
167
|
+
if (placement.kind === 'native') {
|
|
168
|
+
return this.core.create_minutes(meetingId, { ...input, body: placement.body ?? '' }, context);
|
|
169
|
+
}
|
|
170
|
+
const registered = await this.registerExternal(placement, context);
|
|
171
|
+
const existing = await this.findExisting(registered.source, context);
|
|
172
|
+
if (existing) {
|
|
173
|
+
if (existing.identity.meeting_id !== meetingId) {
|
|
174
|
+
throw new MeetingMinutesStorageControllerError('idempotency_conflict', 'The external source is already bound to another meeting');
|
|
175
|
+
}
|
|
176
|
+
return existing.detail;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const detail = await this.core.create_minutes(meetingId, { ...input, source_ref: registered.source }, context);
|
|
180
|
+
await this.registry.register(registered.source, identityForCreatedMinutes(before, detail, meetingId));
|
|
181
|
+
return detail;
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
const recovered = await this.recoverIdempotency(registered.source, context, error);
|
|
185
|
+
if (recovered)
|
|
186
|
+
return recovered;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async save_version(input, placement, context) {
|
|
191
|
+
if (placement.kind === 'native') {
|
|
192
|
+
return this.core.save_version({ ...input, body: placement.body ?? '' }, context);
|
|
193
|
+
}
|
|
194
|
+
const registered = await this.registerExternal(placement, context);
|
|
195
|
+
const existing = await this.findExisting(registered.source, context);
|
|
196
|
+
if (existing) {
|
|
197
|
+
if (existing.identity.minutes_id !== input.minutes_id) {
|
|
198
|
+
throw new MeetingMinutesStorageControllerError('idempotency_conflict', 'The external source is already bound to another minutes document');
|
|
199
|
+
}
|
|
200
|
+
return existing.detail;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const detail = await this.core.save_version({ ...input, source_ref: registered.source }, context);
|
|
204
|
+
await this.registry.register(registered.source, identityForSavedVersion(detail, input.minutes_id));
|
|
205
|
+
return detail;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
const recovered = await this.recoverIdempotency(registered.source, context, error, input.minutes_id);
|
|
209
|
+
if (recovered)
|
|
210
|
+
return recovered;
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Read native bodies directly and external bodies through the current ACL. */
|
|
215
|
+
async get_version(meetingId, minutesId, versionId, context) {
|
|
216
|
+
const version = await this.core.get_version(meetingId, minutesId, versionId, context);
|
|
217
|
+
if (version.body !== undefined && version.source_ref === undefined) {
|
|
218
|
+
return {
|
|
219
|
+
version,
|
|
220
|
+
placement: 'native',
|
|
221
|
+
source_status: 'native',
|
|
222
|
+
body: version.body,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (!version.source_ref) {
|
|
226
|
+
return {
|
|
227
|
+
version,
|
|
228
|
+
placement: 'unknown',
|
|
229
|
+
source_status: 'unavailable',
|
|
230
|
+
reason: 'The core version has neither a native body nor an external source reference',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
const binding = this.adapters.get(version.source_ref.provider);
|
|
234
|
+
if (!binding) {
|
|
235
|
+
return {
|
|
236
|
+
version,
|
|
237
|
+
placement: 'external',
|
|
238
|
+
source_status: 'unavailable',
|
|
239
|
+
reason: `No adapter is configured for provider ${version.source_ref.provider}`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
let result;
|
|
243
|
+
try {
|
|
244
|
+
result = binding.adapter.read_source
|
|
245
|
+
? await binding.adapter.read_source({ source_ref: version.source_ref, request_context: context })
|
|
246
|
+
: {
|
|
247
|
+
status: 'unavailable',
|
|
248
|
+
reason: 'The configured adapter cannot resolve a persisted source reference',
|
|
249
|
+
capabilities: binding.adapter.capabilities,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return {
|
|
254
|
+
version,
|
|
255
|
+
placement: 'external',
|
|
256
|
+
source_status: 'unavailable',
|
|
257
|
+
capabilities: binding.adapter.capabilities,
|
|
258
|
+
reason: 'The configured adapter could not read the external source',
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (result.status === 'available') {
|
|
262
|
+
return {
|
|
263
|
+
version: withSource(version, sourceFromReference(result.reference)),
|
|
264
|
+
placement: 'external',
|
|
265
|
+
source_status: 'available',
|
|
266
|
+
body: result.body,
|
|
267
|
+
source_result: result,
|
|
268
|
+
capabilities: result.capabilities,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
version,
|
|
273
|
+
placement: 'external',
|
|
274
|
+
source_status: sourceStatus(result),
|
|
275
|
+
source_result: result,
|
|
276
|
+
capabilities: result.capabilities,
|
|
277
|
+
reason: sourceFailureMessage(result),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Move the current minutes document to an explicit placement. The core's
|
|
282
|
+
* minutes_id remains the identity; this creates an immutable successor
|
|
283
|
+
* version and never writes the external provider.
|
|
284
|
+
*/
|
|
285
|
+
async rebind(input, placement, context) {
|
|
286
|
+
if (placement.kind === 'external')
|
|
287
|
+
return this.save_version(input, placement, context);
|
|
288
|
+
const current = await this.core.get_meeting(requiredText(input.meeting_id, 'meeting_id'), context);
|
|
289
|
+
const document = documentFor(current, input.minutes_id);
|
|
290
|
+
const currentVersion = versionFor(current, input.minutes_id, document.current_version_id);
|
|
291
|
+
let body = placement.body;
|
|
292
|
+
if (body === undefined && currentVersion.body !== undefined)
|
|
293
|
+
body = currentVersion.body;
|
|
294
|
+
if (body === undefined && currentVersion.source_ref) {
|
|
295
|
+
const resolved = await this.get_version(current.meeting.meeting_id, input.minutes_id, currentVersion.version_id, context);
|
|
296
|
+
if (resolved.source_status !== 'available' || resolved.body === undefined) {
|
|
297
|
+
throw new MeetingMinutesStorageControllerError(resolved.source_status === 'denied' ? 'source_denied' : 'source_unavailable', resolved.reason ?? 'The external source cannot be read for native rebind', resolved.source_result);
|
|
298
|
+
}
|
|
299
|
+
body = resolved.body;
|
|
300
|
+
}
|
|
301
|
+
if (body === undefined) {
|
|
302
|
+
throw new MeetingMinutesStorageControllerError('source_unavailable', 'The current minutes version has no readable body');
|
|
303
|
+
}
|
|
304
|
+
return this.core.save_version({ ...input, body }, context);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Confirm only an exact, currently readable external version. The core
|
|
308
|
+
* receipt is written after the adapter has re-read the source, so an ACL
|
|
309
|
+
* revocation, source change, or missing historical revision cannot be
|
|
310
|
+
* turned into a confirmation by reusing a newer body.
|
|
311
|
+
*/
|
|
312
|
+
async confirm_version(input, context) {
|
|
313
|
+
if (typeof this.core.confirm_version !== 'function') {
|
|
314
|
+
throw new MeetingMinutesStorageControllerError('invalid_configuration', 'The core does not implement confirm_version');
|
|
315
|
+
}
|
|
316
|
+
const version = await this.core.get_version(input.meeting_id, input.minutes_id, input.version_id, context);
|
|
317
|
+
if (version.source_ref) {
|
|
318
|
+
const resolved = await this.get_version(input.meeting_id, input.minutes_id, input.version_id, context);
|
|
319
|
+
if (resolved.source_status !== 'available' || typeof resolved.body !== 'string') {
|
|
320
|
+
const code = resolved.source_status === 'denied' ? 'source_denied'
|
|
321
|
+
: resolved.source_status === 'historical_unavailable' ? 'source_changed' : 'source_unavailable';
|
|
322
|
+
throw new MeetingMinutesStorageControllerError(code, resolved.reason ?? 'The exact external minutes version is not currently readable', resolved.source_result);
|
|
323
|
+
}
|
|
324
|
+
const digest = `sha256:${createHash('sha256').update(resolved.body, 'utf8').digest('hex')}`;
|
|
325
|
+
if (digest !== version.source_ref.digest) {
|
|
326
|
+
throw new MeetingMinutesStorageControllerError('source_changed', 'The exact external minutes digest does not match the stored revision', resolved.source_result);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return this.core.confirm_version(input, context);
|
|
330
|
+
}
|
|
331
|
+
async registerExternal(placement, context) {
|
|
332
|
+
const provider = requiredText(placement.provider, 'external placement provider');
|
|
333
|
+
const locator = requiredText(placement.locator, 'external placement locator');
|
|
334
|
+
const binding = this.adapters.get(provider);
|
|
335
|
+
if (!binding)
|
|
336
|
+
throw new MeetingMinutesStorageControllerError('invalid_configuration', `No adapter is configured for provider ${provider}`);
|
|
337
|
+
let result;
|
|
338
|
+
try {
|
|
339
|
+
result = await binding.adapter.register({ locator, request_context: context });
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
throw new MeetingMinutesStorageControllerError('source_unavailable', 'The configured adapter could not register the external source');
|
|
343
|
+
}
|
|
344
|
+
if (result.status !== 'registered') {
|
|
345
|
+
throw new MeetingMinutesStorageControllerError(sourceFailureCode(result), sourceFailureMessage(result), result);
|
|
346
|
+
}
|
|
347
|
+
if (result.reference.provider !== provider) {
|
|
348
|
+
throw new MeetingMinutesStorageControllerError('storage_unavailable', 'The adapter returned a provider different from its binding');
|
|
349
|
+
}
|
|
350
|
+
return { source: sourceFromReference(result.reference), capabilities: result.capabilities };
|
|
351
|
+
}
|
|
352
|
+
async findExisting(source, context) {
|
|
353
|
+
const identity = await this.registry.find_existing(source);
|
|
354
|
+
if (!identity)
|
|
355
|
+
return undefined;
|
|
356
|
+
return { identity, detail: await this.core.get_meeting(identity.meeting_id, context) };
|
|
357
|
+
}
|
|
358
|
+
async recoverIdempotency(source, context, error, minutesId) {
|
|
359
|
+
if (coreErrorCode(error) !== 'idempotency_conflict')
|
|
360
|
+
throw error;
|
|
361
|
+
const identity = await this.registry.find_existing(source);
|
|
362
|
+
if (!identity || (minutesId && identity.minutes_id !== minutesId))
|
|
363
|
+
return undefined;
|
|
364
|
+
return this.core.get_meeting(identity.meeting_id, context);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type MeetingMinutesDetail, type MeetingMinutesRequestContext, type MeetingMinutesStore } from './meeting-minutes.js';
|
|
3
|
+
import { MeetingMinutesStorageController } from './meeting-minutes-storage-controller.js';
|
|
4
|
+
/** HTTP boundary for host supplied native/external minutes placement. */
|
|
5
|
+
export declare const MEETING_MINUTES_STORAGE_HTTP_VERSION: "brainbase.meeting-minutes-storage-http.v1";
|
|
6
|
+
export declare const MEETING_MINUTES_STORAGE_HTTP_PREFIX: "/api/meeting-minutes";
|
|
7
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
8
|
+
export interface MeetingMinutesStorageHttpOptions {
|
|
9
|
+
/** The same core store passed to MeetingMinutesStorageController. */
|
|
10
|
+
readonly store: MeetingMinutesStore;
|
|
11
|
+
readonly controller: MeetingMinutesStorageController;
|
|
12
|
+
/** The host supplies the trusted principal; request JSON is never used. */
|
|
13
|
+
readonly resolveContext: (request: IncomingMessage) => MaybePromise<MeetingMinutesRequestContext>;
|
|
14
|
+
/** Called only after a placement save has returned the core's readback detail. */
|
|
15
|
+
readonly afterSave?: (input: {
|
|
16
|
+
readonly before: MeetingMinutesDetail;
|
|
17
|
+
readonly after: MeetingMinutesDetail;
|
|
18
|
+
readonly context: MeetingMinutesRequestContext;
|
|
19
|
+
}) => MaybePromise<unknown>;
|
|
20
|
+
readonly basePath?: string;
|
|
21
|
+
readonly bodyLimitBytes?: number;
|
|
22
|
+
}
|
|
23
|
+
export type MeetingMinutesStorageHttpHandler = (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
|
|
24
|
+
export declare function createMeetingMinutesStorageHttpHandler(options: MeetingMinutesStorageHttpOptions): MeetingMinutesStorageHttpHandler;
|
|
25
|
+
export {};
|