@unson/brainbase-mcp 0.21.2 → 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/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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { assertSameOrigin, LocalWebHttpError, readJsonObjectBody, requestUrl, writeHttpError, writeJson, } from './local-web-security.js';
|
|
2
|
+
import { MeetingMinutesLineageError, } from './meeting-minutes-lineage.js';
|
|
3
|
+
/** HTTP seam mounted beside the native `/api/meeting-minutes` routes. */
|
|
4
|
+
export const MEETING_MINUTES_LINEAGE_HTTP_VERSION = 'brainbase.meeting-minutes-lineage-http.v1';
|
|
5
|
+
export const MEETING_MINUTES_LINEAGE_HTTP_PREFIX = '/api/meeting-minutes-lineage';
|
|
6
|
+
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
7
|
+
export function createMeetingMinutesLineageHttpHandler(options) {
|
|
8
|
+
if (!options?.store || typeof options.store.readByMinutesVersion !== 'function'
|
|
9
|
+
|| typeof options.store.readByTarget !== 'function')
|
|
10
|
+
throw new TypeError('a complete lineage store is required');
|
|
11
|
+
if (typeof options.resolveAccess !== 'function')
|
|
12
|
+
throw new TypeError('resolveAccess is required');
|
|
13
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
14
|
+
const bodyLimitBytes = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
|
|
15
|
+
if (!Number.isInteger(bodyLimitBytes) || bodyLimitBytes <= 0)
|
|
16
|
+
throw new TypeError('bodyLimitBytes must be a positive integer');
|
|
17
|
+
return async (request, response) => {
|
|
18
|
+
const path = requestUrl(request).pathname;
|
|
19
|
+
if (path !== basePath && !path.startsWith(`${basePath}/`))
|
|
20
|
+
return false;
|
|
21
|
+
try {
|
|
22
|
+
const access = await options.resolveAccess(request);
|
|
23
|
+
if (!access || typeof access.principal !== 'string' || access.principal.trim() === '') {
|
|
24
|
+
throw new LocalWebHttpError(403, 'authorization_denied', 'A trusted principal is required');
|
|
25
|
+
}
|
|
26
|
+
const route = routeFor(path, basePath, request.method);
|
|
27
|
+
if (!route) {
|
|
28
|
+
writeJson(response, 404, { error: { code: 'not_found', message: 'Lineage route was not found' } });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const body = request.method === 'GET' ? undefined : await readJsonObjectBody(request, bodyLimitBytes);
|
|
32
|
+
if (request.method !== 'GET')
|
|
33
|
+
assertSameOrigin(request);
|
|
34
|
+
if (route.write) {
|
|
35
|
+
if (options.assertWriteAllowed)
|
|
36
|
+
await options.assertWriteAllowed(request, access);
|
|
37
|
+
}
|
|
38
|
+
const actor = route.write
|
|
39
|
+
? await (options.resolveActor?.(request, access) ?? { type: 'person', id: access.principal })
|
|
40
|
+
: undefined;
|
|
41
|
+
const result = await execute(route, body, access, actor);
|
|
42
|
+
writeJson(response, result.statusCode, result.body);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof LocalWebHttpError)
|
|
46
|
+
writeHttpError(response, error);
|
|
47
|
+
else if (error instanceof MeetingMinutesLineageError) {
|
|
48
|
+
const status = lineageStatus(error.code);
|
|
49
|
+
writeJson(response, status, { error: { code: error.code, message: publicLineageMessage(error) } });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
writeJson(response, 500, { error: { code: 'internal_error', message: 'Meeting minutes lineage request failed' } });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
};
|
|
57
|
+
async function execute(route, body, access, actor) {
|
|
58
|
+
switch (route.kind) {
|
|
59
|
+
case 'candidate_read': {
|
|
60
|
+
requireMethod(route.method, 'GET');
|
|
61
|
+
const candidate = await options.store.readCandidate(route.candidateId, access);
|
|
62
|
+
if (!candidate)
|
|
63
|
+
return { statusCode: 404, body: { error: { code: 'not_found', message: 'Candidate was not found' } } };
|
|
64
|
+
return { statusCode: 200, body: candidate };
|
|
65
|
+
}
|
|
66
|
+
case 'by_version': {
|
|
67
|
+
requireMethod(route.method, 'POST');
|
|
68
|
+
return { statusCode: 200, body: await options.store.readByMinutesVersion(requireBody(body).reference, access) };
|
|
69
|
+
}
|
|
70
|
+
case 'by_target': {
|
|
71
|
+
requireMethod(route.method, 'POST');
|
|
72
|
+
return { statusCode: 200, body: await options.store.readByTarget(requireBody(body).target, access) };
|
|
73
|
+
}
|
|
74
|
+
case 'candidate_create': {
|
|
75
|
+
requireMethod(route.method, 'POST');
|
|
76
|
+
const value = requireBody(body);
|
|
77
|
+
const request = {
|
|
78
|
+
id: value.id,
|
|
79
|
+
idempotencyKey: stringValue(value.idempotencyKey ?? value.idempotency_key, 'idempotencyKey'),
|
|
80
|
+
actor: requireTrustedActor(actor),
|
|
81
|
+
evidence: value.evidence,
|
|
82
|
+
kind: value.kind,
|
|
83
|
+
proposal: value.proposal,
|
|
84
|
+
epistemicStatus: value.epistemicStatus,
|
|
85
|
+
access,
|
|
86
|
+
};
|
|
87
|
+
const candidate = await options.store.createCandidate(request);
|
|
88
|
+
return { statusCode: 201, body: candidate };
|
|
89
|
+
}
|
|
90
|
+
case 'candidate_confirm': {
|
|
91
|
+
requireMethod(route.method, 'POST');
|
|
92
|
+
const value = requireBody(body);
|
|
93
|
+
const request = {
|
|
94
|
+
id: value.id,
|
|
95
|
+
idempotencyKey: stringValue(value.idempotencyKey ?? value.idempotency_key, 'idempotencyKey'),
|
|
96
|
+
candidateId: route.candidateId,
|
|
97
|
+
actor: requireTrustedActor(actor),
|
|
98
|
+
evidenceDigest: value.evidenceDigest,
|
|
99
|
+
access,
|
|
100
|
+
};
|
|
101
|
+
return { statusCode: 201, body: await options.store.confirmCandidate(request) };
|
|
102
|
+
}
|
|
103
|
+
case 'candidate_adopt': {
|
|
104
|
+
requireMethod(route.method, 'POST');
|
|
105
|
+
const value = requireBody(body);
|
|
106
|
+
const request = {
|
|
107
|
+
id: value.id,
|
|
108
|
+
idempotencyKey: stringValue(value.idempotencyKey ?? value.idempotency_key, 'idempotencyKey'),
|
|
109
|
+
candidateId: route.candidateId,
|
|
110
|
+
actor: requireTrustedActor(actor),
|
|
111
|
+
access,
|
|
112
|
+
};
|
|
113
|
+
const adoption = route.targetKind === 'judgment'
|
|
114
|
+
? await options.store.adoptJudgment(request)
|
|
115
|
+
: await options.store.adoptTask(request);
|
|
116
|
+
return { statusCode: 201, body: adoption };
|
|
117
|
+
}
|
|
118
|
+
case 'correction': {
|
|
119
|
+
requireMethod(route.method, 'POST');
|
|
120
|
+
const value = requireBody(body);
|
|
121
|
+
const request = {
|
|
122
|
+
id: value.id,
|
|
123
|
+
idempotencyKey: stringValue(value.idempotencyKey ?? value.idempotency_key, 'idempotencyKey'),
|
|
124
|
+
previousVersion: value.previousVersion,
|
|
125
|
+
replacementVersion: value.replacementVersion,
|
|
126
|
+
actor: requireTrustedActor(actor),
|
|
127
|
+
reason: value.reason,
|
|
128
|
+
access,
|
|
129
|
+
};
|
|
130
|
+
return { statusCode: 201, body: await options.store.markCorrection(request) };
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
throw new LocalWebHttpError(404, 'not_found', 'Lineage route was not found');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function routeFor(pathname, basePath, method) {
|
|
138
|
+
if (pathname === `${basePath}/by-version`)
|
|
139
|
+
return { kind: 'by_version', method: method ?? '', write: false };
|
|
140
|
+
if (pathname === `${basePath}/by-target`)
|
|
141
|
+
return { kind: 'by_target', method: method ?? '', write: false };
|
|
142
|
+
if (pathname === `${basePath}/candidates`)
|
|
143
|
+
return { kind: 'candidate_create', method: method ?? '', write: true };
|
|
144
|
+
if (pathname === `${basePath}/corrections`)
|
|
145
|
+
return { kind: 'correction', method: method ?? '', write: true };
|
|
146
|
+
if (!pathname.startsWith(`${basePath}/candidates/`))
|
|
147
|
+
return undefined;
|
|
148
|
+
const segments = pathname.slice(`${basePath}/candidates/`.length).split('/').map(decodePathSegment);
|
|
149
|
+
if (segments.some((segment) => segment === undefined || segment === ''))
|
|
150
|
+
return undefined;
|
|
151
|
+
const candidateId = segments[0];
|
|
152
|
+
if (segments.length === 1)
|
|
153
|
+
return { kind: 'candidate_read', method: method ?? '', candidateId, write: false };
|
|
154
|
+
if (segments.length === 2 && segments[1] === 'confirm')
|
|
155
|
+
return { kind: 'candidate_confirm', method: method ?? '', candidateId, write: true };
|
|
156
|
+
if (segments.length === 3 && segments[1] === 'adopt' && (segments[2] === 'judgment' || segments[2] === 'task')) {
|
|
157
|
+
return { kind: 'candidate_adopt', method: method ?? '', candidateId, targetKind: segments[2], write: true };
|
|
158
|
+
}
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
function normalizeBasePath(path) {
|
|
162
|
+
const value = (path ?? MEETING_MINUTES_LINEAGE_HTTP_PREFIX).trim().replace(/\/+$/u, '');
|
|
163
|
+
if (!value.startsWith('/') || value === '')
|
|
164
|
+
throw new TypeError('basePath must be an absolute path');
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
function decodePathSegment(value) {
|
|
168
|
+
try {
|
|
169
|
+
const decoded = decodeURIComponent(value);
|
|
170
|
+
return decoded === '' || decoded.includes('/') ? undefined : decoded;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function requireBody(body) {
|
|
177
|
+
if (!body)
|
|
178
|
+
throw new LocalWebHttpError(400, 'invalid_json', 'Request body is required');
|
|
179
|
+
return body;
|
|
180
|
+
}
|
|
181
|
+
function requireMethod(actual, expected) {
|
|
182
|
+
if (actual !== expected)
|
|
183
|
+
throw new LocalWebHttpError(405, 'method_not_allowed', `Use ${expected}`);
|
|
184
|
+
}
|
|
185
|
+
function stringValue(value, label) {
|
|
186
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
187
|
+
throw new LocalWebHttpError(422, 'invalid_input', `${label} is required`);
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
function requireTrustedActor(value) {
|
|
191
|
+
if (!value || (value.type !== 'person' && value.type !== 'agent' && value.type !== 'service' && value.type !== 'system')
|
|
192
|
+
|| typeof value.id !== 'string' || value.id.trim() === '') {
|
|
193
|
+
throw new LocalWebHttpError(403, 'authorization_denied', 'A trusted actor is required');
|
|
194
|
+
}
|
|
195
|
+
return { type: value.type, id: value.id.trim() };
|
|
196
|
+
}
|
|
197
|
+
function lineageStatus(code) {
|
|
198
|
+
switch (code) {
|
|
199
|
+
case 'invalid_input': return 422;
|
|
200
|
+
case 'not_found':
|
|
201
|
+
case 'source_not_found':
|
|
202
|
+
case 'target_unavailable': return 404;
|
|
203
|
+
case 'authorization_denied': return 403;
|
|
204
|
+
case 'revision_conflict': return 409;
|
|
205
|
+
case 'provider_unavailable': return 503;
|
|
206
|
+
case 'integrity_mismatch':
|
|
207
|
+
case 'corrupt_record':
|
|
208
|
+
case 'readback_mismatch':
|
|
209
|
+
case 'source_unavailable': return 500;
|
|
210
|
+
default: return 500;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function publicLineageMessage(error) {
|
|
214
|
+
if (error.code === 'provider_unavailable' || error.code === 'source_unavailable' || error.code === 'target_unavailable') {
|
|
215
|
+
return 'Meeting minutes lineage provider is unavailable';
|
|
216
|
+
}
|
|
217
|
+
if (error.code === 'corrupt_record' || error.code === 'integrity_mismatch' || error.code === 'readback_mismatch') {
|
|
218
|
+
return 'Meeting minutes lineage record could not be verified';
|
|
219
|
+
}
|
|
220
|
+
return error.message;
|
|
221
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type NativeMeetingMinutesVersionRecord } from './meeting-minutes-lineage-adapters.js';
|
|
2
|
+
import { type MinutesLineageAccess, type MinutesLineageActor, type MinutesLineageCorrection, type MinutesLineageStore, type MinutesVersionReference } from './meeting-minutes-lineage.js';
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle bridge between the native minutes save transaction and the
|
|
5
|
+
* lineage correction ledger. The native store remains the owner of version
|
|
6
|
+
* records; this module only records the relationship after a successful
|
|
7
|
+
* save.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MEETING_MINUTES_LINEAGE_LIFECYCLE_CONTRACT_VERSION: "brainbase.meeting-minutes-lineage-lifecycle.v1";
|
|
10
|
+
export interface MeetingMinutesLineageVersionSavedEvent {
|
|
11
|
+
/** The exact version that the saved version supersedes. */
|
|
12
|
+
readonly previousVersion: NativeMeetingMinutesVersionRecord;
|
|
13
|
+
/** The exact version returned by the native save readback. */
|
|
14
|
+
readonly replacementVersion: NativeMeetingMinutesVersionRecord;
|
|
15
|
+
readonly access: MinutesLineageAccess;
|
|
16
|
+
readonly actor: MinutesLineageActor;
|
|
17
|
+
readonly reason: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The retry payload intentionally contains no resolved access context. A
|
|
21
|
+
* host must re-resolve current authorization before retrying a failed
|
|
22
|
+
* lineage write; it must never replay a stale authorization decision.
|
|
23
|
+
*/
|
|
24
|
+
export interface MeetingMinutesLineageCorrectionRetry {
|
|
25
|
+
readonly id: string;
|
|
26
|
+
readonly idempotencyKey: string;
|
|
27
|
+
readonly previousVersion: MinutesVersionReference;
|
|
28
|
+
readonly replacementVersion: MinutesVersionReference;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
}
|
|
31
|
+
export interface MeetingMinutesLineageCorrectionHookSuccess {
|
|
32
|
+
readonly status: 'recorded';
|
|
33
|
+
readonly correction: MinutesLineageCorrection;
|
|
34
|
+
readonly retry: MeetingMinutesLineageCorrectionRetry;
|
|
35
|
+
}
|
|
36
|
+
export interface MeetingMinutesLineageCorrectionHookFailure {
|
|
37
|
+
readonly status: 'failed';
|
|
38
|
+
readonly retry: MeetingMinutesLineageCorrectionRetry;
|
|
39
|
+
readonly error: {
|
|
40
|
+
readonly code: string;
|
|
41
|
+
readonly message: string;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export type MeetingMinutesLineageCorrectionHookResult = MeetingMinutesLineageCorrectionHookSuccess | MeetingMinutesLineageCorrectionHookFailure;
|
|
45
|
+
export interface MeetingMinutesLineageCorrectionHook {
|
|
46
|
+
/**
|
|
47
|
+
* Call only after the native minutes save has read back successfully.
|
|
48
|
+
* Failure is returned as data so the host can show a retry action while
|
|
49
|
+
* keeping the saved version, adopted target, and Task state unchanged.
|
|
50
|
+
*/
|
|
51
|
+
onVersionSaved(event: MeetingMinutesLineageVersionSavedEvent): Promise<MeetingMinutesLineageCorrectionHookResult>;
|
|
52
|
+
}
|
|
53
|
+
export interface MeetingMinutesLineageCorrectionHookOptions {
|
|
54
|
+
readonly store: Pick<MinutesLineageStore, 'markCorrection'>;
|
|
55
|
+
/** Organizations may preserve provider-specific source provenance here. */
|
|
56
|
+
readonly referenceFor?: (record: NativeMeetingMinutesVersionRecord) => MinutesVersionReference;
|
|
57
|
+
}
|
|
58
|
+
export declare function createMeetingMinutesLineageCorrectionHook(options: MeetingMinutesLineageCorrectionHookOptions): MeetingMinutesLineageCorrectionHook;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { nativeMeetingMinutesVersionReference, } from './meeting-minutes-lineage-adapters.js';
|
|
3
|
+
import { MeetingMinutesLineageError, } from './meeting-minutes-lineage.js';
|
|
4
|
+
/**
|
|
5
|
+
* Lifecycle bridge between the native minutes save transaction and the
|
|
6
|
+
* lineage correction ledger. The native store remains the owner of version
|
|
7
|
+
* records; this module only records the relationship after a successful
|
|
8
|
+
* save.
|
|
9
|
+
*/
|
|
10
|
+
export const MEETING_MINUTES_LINEAGE_LIFECYCLE_CONTRACT_VERSION = 'brainbase.meeting-minutes-lineage-lifecycle.v1';
|
|
11
|
+
export function createMeetingMinutesLineageCorrectionHook(options) {
|
|
12
|
+
if (!options?.store || typeof options.store.markCorrection !== 'function') {
|
|
13
|
+
throw new TypeError('store.markCorrection is required');
|
|
14
|
+
}
|
|
15
|
+
const referenceFor = options.referenceFor ?? nativeMeetingMinutesVersionReference;
|
|
16
|
+
return {
|
|
17
|
+
async onVersionSaved(event) {
|
|
18
|
+
const retry = buildRetry(event, referenceFor);
|
|
19
|
+
try {
|
|
20
|
+
validateVersionSavedEvent(event, retry);
|
|
21
|
+
const correction = await options.store.markCorrection({
|
|
22
|
+
id: retry.id,
|
|
23
|
+
idempotencyKey: retry.idempotencyKey,
|
|
24
|
+
previousVersion: retry.previousVersion,
|
|
25
|
+
replacementVersion: retry.replacementVersion,
|
|
26
|
+
actor: event.actor,
|
|
27
|
+
reason: retry.reason,
|
|
28
|
+
access: event.access,
|
|
29
|
+
});
|
|
30
|
+
return { status: 'recorded', correction, retry };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return { status: 'failed', retry, error: publicLifecycleError(error) };
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function buildRetry(event, referenceFor) {
|
|
39
|
+
// Build the key from both full exact references, rather than a transport
|
|
40
|
+
// request key. A host retry after a network failure therefore cannot
|
|
41
|
+
// create a second correction by choosing a different request key.
|
|
42
|
+
let previousVersion;
|
|
43
|
+
let replacementVersion;
|
|
44
|
+
try {
|
|
45
|
+
previousVersion = referenceFor(event.previousVersion);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
previousVersion = invalidReference('previous');
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
replacementVersion = referenceFor(event.replacementVersion);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
replacementVersion = invalidReference('replacement');
|
|
55
|
+
}
|
|
56
|
+
const pairDigest = digestJson({ previousVersion, replacementVersion });
|
|
57
|
+
const idempotencyKey = `meeting-minutes:correction:${pairDigest}`;
|
|
58
|
+
return {
|
|
59
|
+
id: idempotencyKey,
|
|
60
|
+
idempotencyKey,
|
|
61
|
+
previousVersion,
|
|
62
|
+
replacementVersion,
|
|
63
|
+
reason: typeof event?.reason === 'string' ? event.reason : '',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function validateVersionSavedEvent(event, retry) {
|
|
67
|
+
if (!event || typeof event !== 'object')
|
|
68
|
+
throw new MeetingMinutesLineageError('invalid_input', 'A saved version event is required');
|
|
69
|
+
if (!event.previousVersion || !event.replacementVersion) {
|
|
70
|
+
throw new MeetingMinutesLineageError('invalid_input', 'Both previous and replacement versions are required');
|
|
71
|
+
}
|
|
72
|
+
requireRecordString(event.previousVersion.meeting_id, 'previousVersion.meeting_id');
|
|
73
|
+
requireRecordString(event.previousVersion.minutes_id, 'previousVersion.minutes_id');
|
|
74
|
+
requireRecordString(event.previousVersion.version_id, 'previousVersion.version_id');
|
|
75
|
+
requireRecordString(event.replacementVersion.meeting_id, 'replacementVersion.meeting_id');
|
|
76
|
+
requireRecordString(event.replacementVersion.minutes_id, 'replacementVersion.minutes_id');
|
|
77
|
+
requireRecordString(event.replacementVersion.version_id, 'replacementVersion.version_id');
|
|
78
|
+
if (event.previousVersion.meeting_id !== event.replacementVersion.meeting_id
|
|
79
|
+
|| event.previousVersion.minutes_id !== event.replacementVersion.minutes_id) {
|
|
80
|
+
throw new MeetingMinutesLineageError('invalid_input', 'A correction must use versions from the same minutes document');
|
|
81
|
+
}
|
|
82
|
+
if (event.previousVersion.version_id === event.replacementVersion.version_id) {
|
|
83
|
+
throw new MeetingMinutesLineageError('invalid_input', 'A correction must point to different exact versions');
|
|
84
|
+
}
|
|
85
|
+
if (event.replacementVersion.predecessor_version_id !== event.previousVersion.version_id) {
|
|
86
|
+
throw new MeetingMinutesLineageError('invalid_input', 'The replacement version must directly supersede the previous version');
|
|
87
|
+
}
|
|
88
|
+
if (!event.access || typeof event.access.principal !== 'string' || event.access.principal.trim() === '') {
|
|
89
|
+
throw new MeetingMinutesLineageError('authorization_denied', 'A current lineage access context is required');
|
|
90
|
+
}
|
|
91
|
+
if (!event.actor || typeof event.actor.id !== 'string' || event.actor.id.trim() === '') {
|
|
92
|
+
throw new MeetingMinutesLineageError('invalid_input', 'A saved version actor is required');
|
|
93
|
+
}
|
|
94
|
+
if (!event.reason || typeof event.reason !== 'string' || event.reason.trim() === '') {
|
|
95
|
+
throw new MeetingMinutesLineageError('invalid_input', 'A correction reason is required');
|
|
96
|
+
}
|
|
97
|
+
if (!isValidReference(retry.previousVersion) || !isValidReference(retry.replacementVersion)) {
|
|
98
|
+
throw new MeetingMinutesLineageError('integrity_mismatch', 'Saved versions did not produce exact lineage references');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function isValidReference(value) {
|
|
102
|
+
return Boolean(value && typeof value.meetingId === 'string' && value.meetingId.trim()
|
|
103
|
+
&& typeof value.minutesId === 'string' && value.minutesId.trim()
|
|
104
|
+
&& typeof value.versionId === 'string' && value.versionId.trim()
|
|
105
|
+
&& (value.contentDigest === null || typeof value.contentDigest === 'string')
|
|
106
|
+
&& value.provenance && typeof value.provenance.providerKind === 'string'
|
|
107
|
+
&& typeof value.provenance.providerId === 'string');
|
|
108
|
+
}
|
|
109
|
+
function invalidReference(label) {
|
|
110
|
+
return {
|
|
111
|
+
meetingId: '',
|
|
112
|
+
minutesId: '',
|
|
113
|
+
versionId: `invalid-${label}`,
|
|
114
|
+
contentDigest: null,
|
|
115
|
+
locator: null,
|
|
116
|
+
provenance: { providerKind: '', providerId: '' },
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function requireRecordString(value, label) {
|
|
120
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
121
|
+
throw new MeetingMinutesLineageError('invalid_input', `${label} is required`);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function publicLifecycleError(error) {
|
|
126
|
+
if (error instanceof MeetingMinutesLineageError) {
|
|
127
|
+
return { code: error.code, message: error.message };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
code: 'provider_unavailable',
|
|
131
|
+
message: 'The minutes version was saved, but its lineage correction could not be recorded. Retry after the lineage provider is available.',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function digestJson(value) {
|
|
135
|
+
return `sha256:${createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex')}`;
|
|
136
|
+
}
|
|
137
|
+
function canonicalJson(value) {
|
|
138
|
+
if (value === null || typeof value !== 'object')
|
|
139
|
+
return JSON.stringify(value);
|
|
140
|
+
if (Array.isArray(value))
|
|
141
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
142
|
+
return `{${Object.entries(value)
|
|
143
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
144
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
|
|
145
|
+
.join(',')}}`;
|
|
146
|
+
}
|