@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,1172 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
/** Provider-independent native meeting and minutes lifecycle. */
|
|
6
|
+
export const MEETING_MINUTES_CONTRACT_VERSION = 'brainbase.meeting-minutes.v1';
|
|
7
|
+
export const MEETING_MINUTES_SNAPSHOT_VERSION = 1;
|
|
8
|
+
export const MEETING_MINUTES_SIDECAR = 'meeting-minutes.json';
|
|
9
|
+
export const MEETING_MINUTES_BODY_DIGEST_ALGORITHM = 'sha256';
|
|
10
|
+
export class MeetingMinutesError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'MeetingMinutesError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const EMPTY_SNAPSHOT = Object.freeze({
|
|
19
|
+
schema_version: MEETING_MINUTES_SNAPSHOT_VERSION,
|
|
20
|
+
revision: 0,
|
|
21
|
+
meetings: Object.freeze([]),
|
|
22
|
+
documents: Object.freeze([]),
|
|
23
|
+
versions: Object.freeze([]),
|
|
24
|
+
idempotency: Object.freeze([])
|
|
25
|
+
});
|
|
26
|
+
const LOCK_WAIT_MS = 10;
|
|
27
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
28
|
+
const LOCK_STALE_MS = 30_000;
|
|
29
|
+
function isRecord(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
function cloneJson(value) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return value;
|
|
35
|
+
return JSON.parse(JSON.stringify(value));
|
|
36
|
+
}
|
|
37
|
+
function deepFreeze(value) {
|
|
38
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
39
|
+
Object.freeze(value);
|
|
40
|
+
for (const child of Object.values(value))
|
|
41
|
+
deepFreeze(child);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function freezeClone(value) {
|
|
46
|
+
return deepFreeze(cloneJson(value));
|
|
47
|
+
}
|
|
48
|
+
function invalid(message) {
|
|
49
|
+
return new MeetingMinutesError('invalid_input', message);
|
|
50
|
+
}
|
|
51
|
+
function nonEmptyString(value, label) {
|
|
52
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
53
|
+
throw invalid(`${label} is required`);
|
|
54
|
+
return value.trim();
|
|
55
|
+
}
|
|
56
|
+
function optionalString(value, label) {
|
|
57
|
+
if (value === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
return nonEmptyString(value, label);
|
|
60
|
+
}
|
|
61
|
+
function validDateString(value, label) {
|
|
62
|
+
const text = optionalString(value, label);
|
|
63
|
+
if (text === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (!Number.isFinite(Date.parse(text)))
|
|
66
|
+
throw invalid(`${label} must be a valid date-time`);
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
function assertPrincipal(context) {
|
|
70
|
+
return nonEmptyString(context?.principal_id, 'principal_id');
|
|
71
|
+
}
|
|
72
|
+
function assertJsonValue(value, path, seen = new Set()) {
|
|
73
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
74
|
+
return;
|
|
75
|
+
if (typeof value === 'number') {
|
|
76
|
+
if (!Number.isFinite(value))
|
|
77
|
+
throw invalid(`${path} must contain finite numbers`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value !== 'object')
|
|
81
|
+
throw invalid(`${path} must be JSON data`);
|
|
82
|
+
if (seen.has(value))
|
|
83
|
+
throw invalid(`${path} must not contain circular data`);
|
|
84
|
+
seen.add(value);
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
value.forEach((item, index) => assertJsonValue(item, `${path}[${index}]`, seen));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
for (const [key, child] of Object.entries(value)) {
|
|
90
|
+
if (key.trim() === '')
|
|
91
|
+
throw invalid(`${path} contains an empty key`);
|
|
92
|
+
assertJsonValue(child, `${path}.${key}`, seen);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
seen.delete(value);
|
|
96
|
+
}
|
|
97
|
+
function metadata(value, label) {
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
return Object.freeze({});
|
|
100
|
+
if (!isRecord(value))
|
|
101
|
+
throw invalid(`${label} must be an object`);
|
|
102
|
+
assertJsonValue(value, label);
|
|
103
|
+
return freezeClone(value);
|
|
104
|
+
}
|
|
105
|
+
function participantIds(value) {
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
return Object.freeze([]);
|
|
108
|
+
if (!Array.isArray(value))
|
|
109
|
+
throw invalid('participant_ids must be an array');
|
|
110
|
+
const ids = value.map((item) => nonEmptyString(item, 'participant_ids item'));
|
|
111
|
+
if (new Set(ids).size !== ids.length)
|
|
112
|
+
throw invalid('participant_ids must not contain duplicates');
|
|
113
|
+
return Object.freeze(ids);
|
|
114
|
+
}
|
|
115
|
+
function sourceRef(value, label = 'source_ref') {
|
|
116
|
+
if (value === undefined)
|
|
117
|
+
return undefined;
|
|
118
|
+
if (!isRecord(value))
|
|
119
|
+
throw invalid(`${label} must be an object`);
|
|
120
|
+
assertJsonValue(value, label);
|
|
121
|
+
const provider = nonEmptyString(value.provider, `${label}.provider`);
|
|
122
|
+
const locator = nonEmptyString(value.locator, `${label}.locator`);
|
|
123
|
+
const revision = nonEmptyString(value.revision, `${label}.revision`);
|
|
124
|
+
const digest = nonEmptyString(value.digest, `${label}.digest`);
|
|
125
|
+
return freezeClone({ ...value, provider, locator, revision, digest });
|
|
126
|
+
}
|
|
127
|
+
function contentInput(body, source, label) {
|
|
128
|
+
const hasBody = body !== undefined;
|
|
129
|
+
const hasSource = source !== undefined;
|
|
130
|
+
if (hasBody === hasSource)
|
|
131
|
+
throw invalid(`${label} must provide exactly one of body or source_ref`);
|
|
132
|
+
if (hasBody && typeof body !== 'string')
|
|
133
|
+
throw invalid(`${label}.body must be a string`);
|
|
134
|
+
const sourceRefValue = hasSource ? sourceRef(source, `${label}.source_ref`) : undefined;
|
|
135
|
+
return hasBody ? { body: body } : { source_ref: sourceRefValue };
|
|
136
|
+
}
|
|
137
|
+
function isoNow(now) {
|
|
138
|
+
const value = now();
|
|
139
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime()))
|
|
140
|
+
throw new MeetingMinutesError('storage_unavailable', 'The clock returned an invalid date');
|
|
141
|
+
return value.toISOString();
|
|
142
|
+
}
|
|
143
|
+
function digestBody(body) {
|
|
144
|
+
return `sha256:${createHash('sha256').update(body, 'utf8').digest('hex')}`;
|
|
145
|
+
}
|
|
146
|
+
function id(prefix, idFactory) {
|
|
147
|
+
const suffix = nonEmptyString(idFactory(), 'generated id');
|
|
148
|
+
return `${prefix}_${suffix}`;
|
|
149
|
+
}
|
|
150
|
+
function aclFor(ownerId) {
|
|
151
|
+
return freezeClone({ owner_id: ownerId, reader_ids: [], writer_ids: [] });
|
|
152
|
+
}
|
|
153
|
+
function defaultAuthorizer() {
|
|
154
|
+
return {
|
|
155
|
+
can_read(resource, context) {
|
|
156
|
+
if (!('acl' in resource))
|
|
157
|
+
return false;
|
|
158
|
+
return resource.acl.owner_id === context.principal_id
|
|
159
|
+
|| resource.acl.reader_ids.includes(context.principal_id);
|
|
160
|
+
},
|
|
161
|
+
can_write(resource, context) {
|
|
162
|
+
if (!('acl' in resource))
|
|
163
|
+
return false;
|
|
164
|
+
return resource.acl.owner_id === context.principal_id
|
|
165
|
+
|| resource.acl.writer_ids.includes(context.principal_id);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function snapshotMutable(snapshot) {
|
|
170
|
+
return cloneJson(snapshot);
|
|
171
|
+
}
|
|
172
|
+
function idempotencyToken(input) {
|
|
173
|
+
if (!isRecord(input))
|
|
174
|
+
throw invalid('idempotency input must be an object');
|
|
175
|
+
const keyValue = input.idempotency_key;
|
|
176
|
+
const fingerprintValue = input.idempotency_fingerprint;
|
|
177
|
+
if (keyValue === undefined && fingerprintValue === undefined)
|
|
178
|
+
return undefined;
|
|
179
|
+
if (keyValue === undefined || fingerprintValue === undefined) {
|
|
180
|
+
throw invalid('idempotency_key and idempotency_fingerprint must be provided together');
|
|
181
|
+
}
|
|
182
|
+
const key = nonEmptyString(keyValue, 'idempotency_key');
|
|
183
|
+
if (key.length > 200)
|
|
184
|
+
throw invalid('idempotency_key must be at most 200 characters');
|
|
185
|
+
const fingerprint = nonEmptyString(fingerprintValue, 'idempotency_fingerprint');
|
|
186
|
+
if (fingerprint.length > 512)
|
|
187
|
+
throw invalid('idempotency_fingerprint must be at most 512 characters');
|
|
188
|
+
return { key, fingerprint };
|
|
189
|
+
}
|
|
190
|
+
function findIdempotencyRecord(snapshot, principalId, key) {
|
|
191
|
+
return snapshot.idempotency.find((candidate) => candidate.principal_id === principalId && candidate.key === key);
|
|
192
|
+
}
|
|
193
|
+
function addIdempotencyRecord(snapshot, token, operation, identity, principalId) {
|
|
194
|
+
if (!token)
|
|
195
|
+
return;
|
|
196
|
+
snapshot.idempotency.push({
|
|
197
|
+
key: token.key,
|
|
198
|
+
principal_id: principalId,
|
|
199
|
+
fingerprint: token.fingerprint,
|
|
200
|
+
operation,
|
|
201
|
+
...identity
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function assertSnapshotShape(value) {
|
|
205
|
+
if (!isRecord(value) || value.schema_version !== MEETING_MINUTES_SNAPSHOT_VERSION
|
|
206
|
+
|| !Number.isInteger(value.revision) || value.revision < 0
|
|
207
|
+
|| !Array.isArray(value.meetings) || !Array.isArray(value.documents) || !Array.isArray(value.versions)
|
|
208
|
+
|| (value.idempotency !== undefined && !Array.isArray(value.idempotency))) {
|
|
209
|
+
throw new MeetingMinutesError('corrupt_record', 'Meeting minutes snapshot has an invalid top-level shape');
|
|
210
|
+
}
|
|
211
|
+
const meetings = value.meetings;
|
|
212
|
+
const documents = value.documents;
|
|
213
|
+
const versions = value.versions;
|
|
214
|
+
const idempotency = (value.idempotency ?? []);
|
|
215
|
+
const ids = new Set();
|
|
216
|
+
const checkId = (candidate, label) => {
|
|
217
|
+
// Provider-independent IDs may be generated by an organization adapter;
|
|
218
|
+
// only reject empty/control-like values here. The native generator still
|
|
219
|
+
// uses the stable meeting_/minutes_/version_ prefixes.
|
|
220
|
+
if (typeof candidate !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(candidate)) {
|
|
221
|
+
throw new MeetingMinutesError('corrupt_record', `${label} is invalid`);
|
|
222
|
+
}
|
|
223
|
+
if (ids.has(candidate))
|
|
224
|
+
throw new MeetingMinutesError('corrupt_record', `Duplicate ${label} ${candidate}`);
|
|
225
|
+
ids.add(candidate);
|
|
226
|
+
return candidate;
|
|
227
|
+
};
|
|
228
|
+
const checkAcl = (value, label) => {
|
|
229
|
+
if (!isRecord(value) || typeof value.owner_id !== 'string' || !Array.isArray(value.reader_ids) || !Array.isArray(value.writer_ids)
|
|
230
|
+
|| value.reader_ids.some((item) => typeof item !== 'string') || value.writer_ids.some((item) => typeof item !== 'string')) {
|
|
231
|
+
throw new MeetingMinutesError('corrupt_record', `${label} is invalid`);
|
|
232
|
+
}
|
|
233
|
+
return freezeClone(value);
|
|
234
|
+
};
|
|
235
|
+
const checkMeta = (value, label) => {
|
|
236
|
+
if (!isRecord(value))
|
|
237
|
+
throw new MeetingMinutesError('corrupt_record', `${label} is invalid`);
|
|
238
|
+
try {
|
|
239
|
+
assertJsonValue(value, label);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
throw new MeetingMinutesError('corrupt_record', error instanceof Error ? error.message : `${label} is invalid`);
|
|
243
|
+
}
|
|
244
|
+
return freezeClone(value);
|
|
245
|
+
};
|
|
246
|
+
const checkDate = (candidate, label) => {
|
|
247
|
+
if (typeof candidate !== 'string' || !Number.isFinite(Date.parse(candidate))) {
|
|
248
|
+
throw new MeetingMinutesError('corrupt_record', `${label} is invalid`);
|
|
249
|
+
}
|
|
250
|
+
return candidate;
|
|
251
|
+
};
|
|
252
|
+
const meetingIds = new Set();
|
|
253
|
+
for (const raw of meetings) {
|
|
254
|
+
if (!isRecord(raw))
|
|
255
|
+
throw new MeetingMinutesError('corrupt_record', 'Meeting record is invalid');
|
|
256
|
+
const meetingId = checkId(raw.meeting_id, 'meeting_id');
|
|
257
|
+
meetingIds.add(meetingId);
|
|
258
|
+
if (typeof raw.title !== 'string' || !Array.isArray(raw.participant_ids)
|
|
259
|
+
|| raw.participant_ids.some((item) => typeof item !== 'string') || !Number.isInteger(raw.revision) || raw.revision < 1) {
|
|
260
|
+
throw new MeetingMinutesError('corrupt_record', `Meeting ${meetingId} is invalid`);
|
|
261
|
+
}
|
|
262
|
+
checkMeta(raw.metadata, `Meeting ${meetingId}.metadata`);
|
|
263
|
+
checkAcl(raw.acl, `Meeting ${meetingId}.acl`);
|
|
264
|
+
checkDate(raw.created_at, `Meeting ${meetingId}.created_at`);
|
|
265
|
+
if (typeof raw.created_actor_id !== 'string' || !raw.created_actor_id.trim())
|
|
266
|
+
throw new MeetingMinutesError('corrupt_record', `Meeting ${meetingId}.created_actor_id is invalid`);
|
|
267
|
+
if (raw.scheduled_at !== undefined)
|
|
268
|
+
checkDate(raw.scheduled_at, `Meeting ${meetingId}.scheduled_at`);
|
|
269
|
+
if (raw.ended_at !== undefined)
|
|
270
|
+
checkDate(raw.ended_at, `Meeting ${meetingId}.ended_at`);
|
|
271
|
+
}
|
|
272
|
+
const documentIds = new Set();
|
|
273
|
+
const documentById = new Map();
|
|
274
|
+
for (const raw of documents) {
|
|
275
|
+
if (!isRecord(raw))
|
|
276
|
+
throw new MeetingMinutesError('corrupt_record', 'Minutes document is invalid');
|
|
277
|
+
const minutesId = checkId(raw.minutes_id, 'minutes_id');
|
|
278
|
+
documentIds.add(minutesId);
|
|
279
|
+
documentById.set(minutesId, raw);
|
|
280
|
+
if (typeof raw.meeting_id !== 'string' || !meetingIds.has(raw.meeting_id)
|
|
281
|
+
|| typeof raw.title !== 'string' || !Array.isArray(raw.version_ids) || raw.version_ids.some((item) => typeof item !== 'string')
|
|
282
|
+
|| typeof raw.current_version_id !== 'string' || !raw.version_ids.includes(raw.current_version_id)
|
|
283
|
+
|| !Number.isInteger(raw.revision) || raw.revision < 1) {
|
|
284
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes document ${minutesId} is invalid`);
|
|
285
|
+
}
|
|
286
|
+
checkMeta(raw.metadata, `Minutes document ${minutesId}.metadata`);
|
|
287
|
+
checkAcl(raw.acl, `Minutes document ${minutesId}.acl`);
|
|
288
|
+
checkDate(raw.created_at, `Minutes document ${minutesId}.created_at`);
|
|
289
|
+
checkDate(raw.updated_at, `Minutes document ${minutesId}.updated_at`);
|
|
290
|
+
if (typeof raw.created_actor_id !== 'string' || !raw.created_actor_id.trim())
|
|
291
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes document ${minutesId}.created_actor_id is invalid`);
|
|
292
|
+
}
|
|
293
|
+
const versionIds = new Set();
|
|
294
|
+
const versionById = new Map();
|
|
295
|
+
const predecessorById = new Map();
|
|
296
|
+
for (const raw of versions) {
|
|
297
|
+
if (!isRecord(raw))
|
|
298
|
+
throw new MeetingMinutesError('corrupt_record', 'Minutes version is invalid');
|
|
299
|
+
const versionId = checkId(raw.version_id, 'version_id');
|
|
300
|
+
versionIds.add(versionId);
|
|
301
|
+
versionById.set(versionId, raw);
|
|
302
|
+
if (typeof raw.minutes_id !== 'string' || !documentIds.has(raw.minutes_id)
|
|
303
|
+
|| typeof raw.meeting_id !== 'string' || !meetingIds.has(raw.meeting_id)
|
|
304
|
+
|| typeof raw.created_actor_id !== 'string' || !raw.created_actor_id.trim()) {
|
|
305
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId} is invalid`);
|
|
306
|
+
}
|
|
307
|
+
const doc = documentById.get(raw.minutes_id);
|
|
308
|
+
if (doc?.meeting_id !== raw.meeting_id)
|
|
309
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId} has a mismatched meeting_id`);
|
|
310
|
+
checkDate(raw.created_at, `Minutes version ${versionId}.created_at`);
|
|
311
|
+
if (raw.predecessor_version_id !== undefined && typeof raw.predecessor_version_id !== 'string') {
|
|
312
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId}.predecessor_version_id is invalid`);
|
|
313
|
+
}
|
|
314
|
+
if (typeof raw.predecessor_version_id === 'string')
|
|
315
|
+
predecessorById.set(versionId, raw.predecessor_version_id);
|
|
316
|
+
if (raw.confirmation !== undefined) {
|
|
317
|
+
if (!isRecord(raw.confirmation) || raw.confirmation.version_id !== versionId
|
|
318
|
+
|| typeof raw.confirmation.actor_id !== 'string' || !raw.confirmation.actor_id.trim()) {
|
|
319
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId}.confirmation is invalid`);
|
|
320
|
+
}
|
|
321
|
+
checkDate(raw.confirmation.confirmed_at, `Minutes version ${versionId}.confirmation.confirmed_at`);
|
|
322
|
+
}
|
|
323
|
+
const hasBody = raw.body !== undefined;
|
|
324
|
+
const hasSource = raw.source_ref !== undefined;
|
|
325
|
+
if (hasBody && hasSource || !hasBody && !hasSource) {
|
|
326
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId} must contain native body or external source_ref`);
|
|
327
|
+
}
|
|
328
|
+
if (hasBody && (typeof raw.body_digest !== 'string' || raw.body_digest !== digestBody(raw.body))) {
|
|
329
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId}.body_digest is invalid`);
|
|
330
|
+
}
|
|
331
|
+
if (!hasBody && raw.body_digest !== undefined) {
|
|
332
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId}.body_digest is invalid`);
|
|
333
|
+
}
|
|
334
|
+
if (hasSource)
|
|
335
|
+
sourceRef(raw.source_ref, `Minutes version ${versionId}.source_ref`);
|
|
336
|
+
}
|
|
337
|
+
for (const raw of versions) {
|
|
338
|
+
if (!isRecord(raw))
|
|
339
|
+
continue;
|
|
340
|
+
if (raw.predecessor_version_id !== undefined) {
|
|
341
|
+
const predecessor = raw.predecessor_version_id;
|
|
342
|
+
if (!versionIds.has(predecessor)) {
|
|
343
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${raw.version_id}.predecessor_version_id is invalid`);
|
|
344
|
+
}
|
|
345
|
+
const predecessorRecord = versionById.get(predecessor);
|
|
346
|
+
if (predecessorRecord?.minutes_id !== raw.minutes_id || predecessorRecord?.meeting_id !== raw.meeting_id) {
|
|
347
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${raw.version_id}.predecessor_version_id references another document`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// A predecessor chain must terminate at the first version. Keep this
|
|
352
|
+
// explicit even though the ordered document check below also rejects a
|
|
353
|
+
// cycle, so malformed snapshots cannot rely on array ordering to hide one.
|
|
354
|
+
for (const start of predecessorById.keys()) {
|
|
355
|
+
const visited = new Set();
|
|
356
|
+
let current = start;
|
|
357
|
+
while (current !== undefined && predecessorById.has(current)) {
|
|
358
|
+
if (visited.has(current))
|
|
359
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${start}.predecessor_version_id contains a cycle`);
|
|
360
|
+
visited.add(current);
|
|
361
|
+
current = predecessorById.get(current);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const versionReferenceCount = new Map();
|
|
365
|
+
for (const raw of documents) {
|
|
366
|
+
if (!isRecord(raw))
|
|
367
|
+
throw new MeetingMinutesError('corrupt_record', 'Minutes document is invalid');
|
|
368
|
+
const minutesId = raw.minutes_id;
|
|
369
|
+
const idsForDocument = raw.version_ids;
|
|
370
|
+
if (new Set(idsForDocument).size !== idsForDocument.length || idsForDocument.some((versionId) => !versionIds.has(versionId))) {
|
|
371
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes document ${minutesId}.version_ids is invalid`);
|
|
372
|
+
}
|
|
373
|
+
for (const versionId of idsForDocument) {
|
|
374
|
+
const version = versionById.get(versionId);
|
|
375
|
+
if (version?.minutes_id !== minutesId)
|
|
376
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes document ${minutesId} references another document's version`);
|
|
377
|
+
versionReferenceCount.set(versionId, (versionReferenceCount.get(versionId) ?? 0) + 1);
|
|
378
|
+
if (version.predecessor_version_id !== undefined) {
|
|
379
|
+
const predecessorIndex = idsForDocument.indexOf(version.predecessor_version_id);
|
|
380
|
+
const versionIndex = idsForDocument.indexOf(versionId);
|
|
381
|
+
if (predecessorIndex !== versionIndex - 1) {
|
|
382
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId} does not point to the immediately preceding version`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const versionId of versionIds) {
|
|
388
|
+
if (versionReferenceCount.get(versionId) !== 1) {
|
|
389
|
+
throw new MeetingMinutesError('corrupt_record', `Minutes version ${versionId} is not attached to exactly one document`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const idempotencyKeys = new Set();
|
|
393
|
+
for (const raw of idempotency) {
|
|
394
|
+
if (!isRecord(raw))
|
|
395
|
+
throw new MeetingMinutesError('corrupt_record', 'Idempotency record is invalid');
|
|
396
|
+
const key = raw.key;
|
|
397
|
+
const principalId = raw.principal_id;
|
|
398
|
+
const fingerprint = raw.fingerprint;
|
|
399
|
+
const operation = raw.operation;
|
|
400
|
+
const meetingId = raw.meeting_id;
|
|
401
|
+
if (typeof key !== 'string' || key.trim() === '' || key.length > 200
|
|
402
|
+
|| typeof principalId !== 'string' || principalId.trim() === ''
|
|
403
|
+
|| typeof fingerprint !== 'string' || fingerprint.trim() === '' || fingerprint.length > 512
|
|
404
|
+
|| (operation !== 'create_meeting' && operation !== 'create_minutes' && operation !== 'save_version' && operation !== 'confirm_version')
|
|
405
|
+
|| typeof meetingId !== 'string' || !meetingIds.has(meetingId)) {
|
|
406
|
+
throw new MeetingMinutesError('corrupt_record', 'Idempotency record is invalid');
|
|
407
|
+
}
|
|
408
|
+
const keyIdentity = `${principalId}\u0000${key}`;
|
|
409
|
+
if (idempotencyKeys.has(keyIdentity))
|
|
410
|
+
throw new MeetingMinutesError('corrupt_record', `Duplicate idempotency key ${key}`);
|
|
411
|
+
idempotencyKeys.add(keyIdentity);
|
|
412
|
+
const minutesId = raw.minutes_id;
|
|
413
|
+
const versionId = raw.version_id;
|
|
414
|
+
if (operation === 'create_meeting') {
|
|
415
|
+
if (minutesId !== undefined || versionId !== undefined) {
|
|
416
|
+
throw new MeetingMinutesError('corrupt_record', 'create_meeting idempotency record has unexpected target ids');
|
|
417
|
+
}
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (typeof minutesId !== 'string' || !documentIds.has(minutesId)
|
|
421
|
+
|| documentById.get(minutesId)?.meeting_id !== meetingId) {
|
|
422
|
+
throw new MeetingMinutesError('corrupt_record', 'Idempotency record minutes_id is invalid');
|
|
423
|
+
}
|
|
424
|
+
if (operation === 'create_minutes') {
|
|
425
|
+
if (versionId !== undefined)
|
|
426
|
+
throw new MeetingMinutesError('corrupt_record', 'create_minutes idempotency record has an unexpected version_id');
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (typeof versionId !== 'string' || !versionIds.has(versionId)
|
|
430
|
+
|| versionById.get(versionId)?.minutes_id !== minutesId
|
|
431
|
+
|| versionById.get(versionId)?.meeting_id !== meetingId) {
|
|
432
|
+
throw new MeetingMinutesError('corrupt_record', 'Idempotency record version_id is invalid');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return freezeClone({
|
|
436
|
+
schema_version: MEETING_MINUTES_SNAPSHOT_VERSION,
|
|
437
|
+
revision: value.revision,
|
|
438
|
+
meetings: meetings,
|
|
439
|
+
documents: documents,
|
|
440
|
+
versions: versions,
|
|
441
|
+
idempotency: idempotency
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
class LocalMeetingMinutesStorage {
|
|
445
|
+
filePath;
|
|
446
|
+
lockPath;
|
|
447
|
+
constructor(dataDir) {
|
|
448
|
+
this.filePath = join(dataDir, MEETING_MINUTES_SIDECAR);
|
|
449
|
+
this.lockPath = `${this.filePath}.lock`;
|
|
450
|
+
}
|
|
451
|
+
async read() {
|
|
452
|
+
let raw;
|
|
453
|
+
try {
|
|
454
|
+
raw = await readFile(this.filePath, 'utf8');
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (error.code === 'ENOENT')
|
|
458
|
+
return EMPTY_SNAPSHOT;
|
|
459
|
+
throw new MeetingMinutesError('storage_unavailable', `Could not read ${this.filePath}`);
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
return assertSnapshotShape(JSON.parse(raw));
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (error instanceof MeetingMinutesError)
|
|
466
|
+
throw error;
|
|
467
|
+
throw new MeetingMinutesError('corrupt_record', `Could not parse ${this.filePath}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async write(next, expected_revision) {
|
|
471
|
+
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
472
|
+
const owner = await acquireLock(this.lockPath);
|
|
473
|
+
try {
|
|
474
|
+
const current = await this.read();
|
|
475
|
+
if (current.revision !== expected_revision)
|
|
476
|
+
throw new MeetingMinutesError('revision_conflict', 'Meeting minutes changed while saving');
|
|
477
|
+
if (next.revision !== expected_revision + 1)
|
|
478
|
+
throw new MeetingMinutesError('invalid_input', 'The next snapshot revision is invalid');
|
|
479
|
+
const normalized = assertSnapshotShape(next);
|
|
480
|
+
const temporary = `${this.filePath}.tmp-${randomUUID()}`;
|
|
481
|
+
try {
|
|
482
|
+
await writeFile(temporary, `${JSON.stringify(normalized)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
483
|
+
await rename(temporary, this.filePath);
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
await releaseLock(this.lockPath, owner).catch(() => undefined);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function acquireLock(lockPath) {
|
|
495
|
+
const started = Date.now();
|
|
496
|
+
const owner = { token: randomUUID(), pid: process.pid, hostname: hostname() };
|
|
497
|
+
for (;;) {
|
|
498
|
+
let created = false;
|
|
499
|
+
try {
|
|
500
|
+
await mkdir(lockPath, { recursive: false });
|
|
501
|
+
created = true;
|
|
502
|
+
await writeFile(join(lockPath, 'owner'), `${JSON.stringify(owner)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
503
|
+
return owner;
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (created)
|
|
507
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
508
|
+
if (error.code !== 'EEXIST')
|
|
509
|
+
throw new MeetingMinutesError('storage_unavailable', 'Could not acquire meeting minutes storage lock');
|
|
510
|
+
await quarantineDeadLock(lockPath);
|
|
511
|
+
if (Date.now() - started > LOCK_TIMEOUT_MS)
|
|
512
|
+
throw new MeetingMinutesError('storage_unavailable', 'Timed out waiting for meeting minutes storage lock');
|
|
513
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function quarantineDeadLock(lockPath) {
|
|
518
|
+
try {
|
|
519
|
+
const lockStat = await stat(lockPath);
|
|
520
|
+
if (Date.now() - lockStat.mtimeMs <= LOCK_STALE_MS)
|
|
521
|
+
return;
|
|
522
|
+
const owner = await readLockOwner(lockPath);
|
|
523
|
+
// Missing, malformed, foreign-host, and live owner metadata is never
|
|
524
|
+
// safe to steal. A stale lock is removable only after the recorded PID is
|
|
525
|
+
// confirmed dead on this host.
|
|
526
|
+
if (!owner || owner.hostname !== hostname() || isProcessAlive(owner.pid))
|
|
527
|
+
return;
|
|
528
|
+
const quarantine = `${lockPath}.stale-${randomUUID()}`;
|
|
529
|
+
try {
|
|
530
|
+
await rename(lockPath, quarantine);
|
|
531
|
+
await rm(quarantine, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
if (error.code !== 'ENOENT')
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
if (error.code !== 'ENOENT')
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async function readLockOwner(lockPath) {
|
|
544
|
+
try {
|
|
545
|
+
const value = JSON.parse(await readFile(join(lockPath, 'owner'), 'utf8'));
|
|
546
|
+
if (typeof value.token === 'string' && value.token.length > 0
|
|
547
|
+
&& typeof value.pid === 'number' && Number.isInteger(value.pid) && value.pid > 0
|
|
548
|
+
&& typeof value.hostname === 'string' && value.hostname.length > 0) {
|
|
549
|
+
return value;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
// Incomplete or malformed owner metadata is left for bounded retry.
|
|
554
|
+
}
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
function isProcessAlive(pid) {
|
|
558
|
+
try {
|
|
559
|
+
process.kill(pid, 0);
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
return error.code !== 'ESRCH';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
async function releaseLock(lockPath, owner) {
|
|
567
|
+
const current = await readLockOwner(lockPath);
|
|
568
|
+
if (current?.token !== owner.token)
|
|
569
|
+
return;
|
|
570
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
571
|
+
}
|
|
572
|
+
function defaultStorage(options) {
|
|
573
|
+
if (options.storage)
|
|
574
|
+
return options.storage;
|
|
575
|
+
if (!options.data_dir)
|
|
576
|
+
throw new TypeError('data_dir is required when storage is not provided');
|
|
577
|
+
return new LocalMeetingMinutesStorage(options.data_dir);
|
|
578
|
+
}
|
|
579
|
+
async function authorized(authorizer, operation, resource, context) {
|
|
580
|
+
const check = operation === 'read' ? authorizer.can_read : authorizer.can_write;
|
|
581
|
+
if (typeof check !== 'function' || !(await check(resource, context))) {
|
|
582
|
+
throw new MeetingMinutesError('authorization_denied', 'The current principal cannot access this record');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function findMeeting(snapshot, meetingId) {
|
|
586
|
+
const meeting = snapshot.meetings.find((candidate) => candidate.meeting_id === meetingId);
|
|
587
|
+
if (!meeting)
|
|
588
|
+
throw new MeetingMinutesError('not_found', `Meeting ${meetingId} was not found`);
|
|
589
|
+
return meeting;
|
|
590
|
+
}
|
|
591
|
+
function findDocument(snapshot, minutesId) {
|
|
592
|
+
const document = snapshot.documents.find((candidate) => candidate.minutes_id === minutesId);
|
|
593
|
+
if (!document)
|
|
594
|
+
throw new MeetingMinutesError('not_found', `Minutes document ${minutesId} was not found`);
|
|
595
|
+
return document;
|
|
596
|
+
}
|
|
597
|
+
function detail(snapshot, meeting) {
|
|
598
|
+
const minutes = snapshot.documents.filter((candidate) => candidate.meeting_id === meeting.meeting_id);
|
|
599
|
+
const minuteIds = new Set(minutes.flatMap((candidate) => candidate.version_ids));
|
|
600
|
+
const versions = snapshot.versions.filter((candidate) => minuteIds.has(candidate.version_id));
|
|
601
|
+
return freezeClone({ meeting, minutes, versions, snapshot_revision: snapshot.revision });
|
|
602
|
+
}
|
|
603
|
+
function summary(snapshot, meeting) {
|
|
604
|
+
return freezeClone({ meeting, minutes: snapshot.documents.filter((candidate) => candidate.meeting_id === meeting.meeting_id) });
|
|
605
|
+
}
|
|
606
|
+
function ensureRevision(actual, expected) {
|
|
607
|
+
if (!Number.isInteger(expected) || expected < 1)
|
|
608
|
+
throw invalid('expected_revision must be a positive integer');
|
|
609
|
+
if (actual !== expected)
|
|
610
|
+
throw new MeetingMinutesError('revision_conflict', `Expected revision ${expected}, current revision is ${actual}`);
|
|
611
|
+
}
|
|
612
|
+
function sameExternalSource(left, right) {
|
|
613
|
+
return left.provider === right.provider
|
|
614
|
+
&& left.locator === right.locator
|
|
615
|
+
&& left.revision === right.revision
|
|
616
|
+
&& left.digest === right.digest;
|
|
617
|
+
}
|
|
618
|
+
function findExternalSourceIdentity(snapshot, source) {
|
|
619
|
+
const match = snapshot.versions.find((candidate) => candidate.source_ref !== undefined && sameExternalSource(candidate.source_ref, source));
|
|
620
|
+
if (!match)
|
|
621
|
+
return undefined;
|
|
622
|
+
return { meeting_id: match.meeting_id, minutes_id: match.minutes_id, version_id: match.version_id };
|
|
623
|
+
}
|
|
624
|
+
export function createMeetingMinutesStore(options) {
|
|
625
|
+
const storage = defaultStorage(options);
|
|
626
|
+
const now = options.now ?? (() => new Date());
|
|
627
|
+
const idFactory = options.id_factory ?? randomUUID;
|
|
628
|
+
// A supplied partial authorizer is fail-closed for the omitted operation.
|
|
629
|
+
// The built-in authorizer is the only implementation whose explicit read
|
|
630
|
+
// and write rules are both allowed by default.
|
|
631
|
+
const suppliedAuthorizer = options.authorizer;
|
|
632
|
+
const authorizer = suppliedAuthorizer === undefined
|
|
633
|
+
? defaultAuthorizer()
|
|
634
|
+
: {
|
|
635
|
+
can_read: suppliedAuthorizer.can_read ?? (() => false),
|
|
636
|
+
can_write: suppliedAuthorizer.can_write ?? (() => false),
|
|
637
|
+
};
|
|
638
|
+
const externalSourceHook = options.external_source_hook;
|
|
639
|
+
async function readSnapshot() {
|
|
640
|
+
try {
|
|
641
|
+
return assertSnapshotShape(await storage.read());
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
if (error instanceof MeetingMinutesError)
|
|
645
|
+
throw error;
|
|
646
|
+
throw new MeetingMinutesError('storage_unavailable', 'Could not read meeting minutes storage');
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async function writeSnapshot(next, expectedRevision) {
|
|
650
|
+
const normalized = assertSnapshotShape(next);
|
|
651
|
+
try {
|
|
652
|
+
await storage.write(normalized, expectedRevision);
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
if (error instanceof MeetingMinutesError)
|
|
656
|
+
throw error;
|
|
657
|
+
throw new MeetingMinutesError('storage_unavailable', 'Could not write meeting minutes storage');
|
|
658
|
+
}
|
|
659
|
+
const readback = await readSnapshot();
|
|
660
|
+
// Another writer may have committed immediately after our atomic rename.
|
|
661
|
+
// The storage CAS already established that our write won; callers verify
|
|
662
|
+
// their exact record below, so a newer aggregate revision is acceptable.
|
|
663
|
+
if (readback.revision < normalized.revision)
|
|
664
|
+
throw new MeetingMinutesError('readback_mismatch', 'Meeting minutes storage revision did not read back');
|
|
665
|
+
return readback;
|
|
666
|
+
}
|
|
667
|
+
async function assertExternalUnique(snapshot, source) {
|
|
668
|
+
// The native snapshot is the canonical duplicate boundary. An adapter
|
|
669
|
+
// index can accelerate cross-store lookup, but it cannot replace this
|
|
670
|
+
// exact source identity check.
|
|
671
|
+
const local = findExternalSourceIdentity(snapshot, source);
|
|
672
|
+
if (local)
|
|
673
|
+
throw new MeetingMinutesError('idempotency_conflict', `External source is already registered as ${local.version_id}`);
|
|
674
|
+
if (!externalSourceHook)
|
|
675
|
+
return;
|
|
676
|
+
try {
|
|
677
|
+
const existing = await externalSourceHook.find_existing(source);
|
|
678
|
+
if (existing)
|
|
679
|
+
throw new MeetingMinutesError('idempotency_conflict', `External source is already registered as ${existing.version_id}`);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
// The hook is an auxiliary index. A temporarily unavailable index must
|
|
683
|
+
// not turn a successfully writable native source into a retry hazard.
|
|
684
|
+
if (error instanceof MeetingMinutesError && error.code === 'idempotency_conflict')
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async function registerExternal(source, identity) {
|
|
689
|
+
if (!source || !externalSourceHook)
|
|
690
|
+
return;
|
|
691
|
+
try {
|
|
692
|
+
await externalSourceHook.register(source, identity);
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
// Canonical persistence already succeeded. This best-effort index must
|
|
696
|
+
// never make the caller retry a write that is already durable.
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
async function list_meetings(context) {
|
|
700
|
+
assertPrincipal(context);
|
|
701
|
+
const snapshot = await readSnapshot();
|
|
702
|
+
const output = [];
|
|
703
|
+
for (const meeting of snapshot.meetings) {
|
|
704
|
+
if (!(await canReadMeeting(snapshot, meeting, context)))
|
|
705
|
+
continue;
|
|
706
|
+
output.push(summary(snapshot, meeting));
|
|
707
|
+
}
|
|
708
|
+
return freezeClone(output);
|
|
709
|
+
}
|
|
710
|
+
async function canReadMeeting(snapshot, meeting, context) {
|
|
711
|
+
if (!(await allowed(authorizer, 'read', meeting, context)))
|
|
712
|
+
return false;
|
|
713
|
+
const documents = snapshot.documents.filter((candidate) => candidate.meeting_id === meeting.meeting_id);
|
|
714
|
+
for (const document of documents) {
|
|
715
|
+
if (!(await allowed(authorizer, 'read', document, context)))
|
|
716
|
+
return false;
|
|
717
|
+
const documentVersionIds = new Set(document.version_ids);
|
|
718
|
+
for (const version of snapshot.versions) {
|
|
719
|
+
if (version.meeting_id !== meeting.meeting_id || version.minutes_id !== document.minutes_id
|
|
720
|
+
|| !documentVersionIds.has(version.version_id))
|
|
721
|
+
continue;
|
|
722
|
+
// Versions do not own a second ACL. Pass the document ACL alongside
|
|
723
|
+
// the immutable version so adapters can apply version-specific rules
|
|
724
|
+
// without changing the returned canonical record shape.
|
|
725
|
+
const versionResource = { ...version, acl: document.acl };
|
|
726
|
+
if (!(await allowed(authorizer, 'read', versionResource, context)))
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return true;
|
|
731
|
+
}
|
|
732
|
+
async function detailForCaller(snapshot, meeting, context) {
|
|
733
|
+
if (!(await canReadMeeting(snapshot, meeting, context))) {
|
|
734
|
+
throw new MeetingMinutesError('authorization_denied', 'The current principal cannot read this meeting');
|
|
735
|
+
}
|
|
736
|
+
return detail(snapshot, meeting);
|
|
737
|
+
}
|
|
738
|
+
async function allowed(access, operation, resource, context) {
|
|
739
|
+
try {
|
|
740
|
+
await authorized(access, operation, resource, context);
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
if (error instanceof MeetingMinutesError && error.code === 'authorization_denied')
|
|
745
|
+
return false;
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async function replayIdempotency(snapshot, record, context) {
|
|
750
|
+
const meeting = findMeeting(snapshot, record.meeting_id);
|
|
751
|
+
// A receipt is safe to replay only while the principal can still read the
|
|
752
|
+
// current target. This check deliberately runs after every restart and
|
|
753
|
+
// every retry; the receipt itself is never an authorization grant.
|
|
754
|
+
return detailForCaller(snapshot, meeting, context);
|
|
755
|
+
}
|
|
756
|
+
async function resolveIdempotency(snapshot, principal, input, operation, context, target = {}) {
|
|
757
|
+
const token = idempotencyToken(input);
|
|
758
|
+
if (!token)
|
|
759
|
+
return {};
|
|
760
|
+
const record = findIdempotencyRecord(snapshot, principal, token.key);
|
|
761
|
+
if (!record)
|
|
762
|
+
return { token };
|
|
763
|
+
if (record.operation !== operation || record.fingerprint !== token.fingerprint
|
|
764
|
+
|| (target.meeting_id !== undefined && record.meeting_id !== target.meeting_id)
|
|
765
|
+
|| (target.minutes_id !== undefined && record.minutes_id !== target.minutes_id)) {
|
|
766
|
+
throw new MeetingMinutesError('idempotency_conflict', 'Idempotency-Key was reused with a different request');
|
|
767
|
+
}
|
|
768
|
+
return { token, replay: await replayIdempotency(snapshot, record, context) };
|
|
769
|
+
}
|
|
770
|
+
async function replayAfterConflict(error, token, principal, operation, context, target = {}) {
|
|
771
|
+
if (!token || !(error instanceof MeetingMinutesError) || error.code !== 'revision_conflict')
|
|
772
|
+
throw error;
|
|
773
|
+
const latest = await readSnapshot();
|
|
774
|
+
const record = findIdempotencyRecord(latest, principal, token.key);
|
|
775
|
+
if (!record)
|
|
776
|
+
return undefined;
|
|
777
|
+
if (record.operation !== operation || record.fingerprint !== token.fingerprint
|
|
778
|
+
|| (target.meeting_id !== undefined && record.meeting_id !== target.meeting_id)
|
|
779
|
+
|| (target.minutes_id !== undefined && record.minutes_id !== target.minutes_id)) {
|
|
780
|
+
throw new MeetingMinutesError('idempotency_conflict', 'Idempotency-Key was reused with a different request');
|
|
781
|
+
}
|
|
782
|
+
return replayIdempotency(latest, record, context);
|
|
783
|
+
}
|
|
784
|
+
async function create_meeting(input, context) {
|
|
785
|
+
const principal = assertPrincipal(context);
|
|
786
|
+
if (!isRecord(input))
|
|
787
|
+
throw invalid('meeting input must be an object');
|
|
788
|
+
const current = await readSnapshot();
|
|
789
|
+
const idempotency = await resolveIdempotency(current, principal, input, 'create_meeting', context);
|
|
790
|
+
if (idempotency.replay)
|
|
791
|
+
return idempotency.replay;
|
|
792
|
+
const title = nonEmptyString(input.title, 'title');
|
|
793
|
+
const scheduledAt = validDateString(input.scheduled_at, 'scheduled_at');
|
|
794
|
+
const endedAt = validDateString(input.ended_at, 'ended_at');
|
|
795
|
+
const participantIds = participantIdsInput(input.participant_ids);
|
|
796
|
+
const meetingMetadata = metadata(input.metadata, 'metadata');
|
|
797
|
+
const minutesTitle = input.minutes_title === undefined ? '議事録' : nonEmptyString(input.minutes_title, 'minutes_title');
|
|
798
|
+
const minutesMetadata = metadata(input.minutes_metadata, 'minutes_metadata');
|
|
799
|
+
const source = sourceRef(input.source_ref);
|
|
800
|
+
const body = input.initial_body === undefined && source === undefined ? '' : input.initial_body;
|
|
801
|
+
if (source && input.initial_body !== undefined)
|
|
802
|
+
throw invalid('initial_body and source_ref are mutually exclusive');
|
|
803
|
+
if (body !== undefined && typeof body !== 'string')
|
|
804
|
+
throw invalid('initial_body must be a string');
|
|
805
|
+
if (source)
|
|
806
|
+
await assertExternalUnique(current, source);
|
|
807
|
+
const timestamp = isoNow(now);
|
|
808
|
+
const meetingId = id('meeting', idFactory);
|
|
809
|
+
const minutesId = id('minutes', idFactory);
|
|
810
|
+
const versionId = id('version', idFactory);
|
|
811
|
+
const acl = aclFor(principal);
|
|
812
|
+
const meeting = freezeClone({
|
|
813
|
+
meeting_id: meetingId,
|
|
814
|
+
title,
|
|
815
|
+
...(scheduledAt ? { scheduled_at: scheduledAt } : {}),
|
|
816
|
+
...(endedAt ? { ended_at: endedAt } : {}),
|
|
817
|
+
participant_ids: participantIds,
|
|
818
|
+
metadata: meetingMetadata,
|
|
819
|
+
acl,
|
|
820
|
+
created_at: timestamp,
|
|
821
|
+
created_actor_id: principal,
|
|
822
|
+
revision: 1
|
|
823
|
+
});
|
|
824
|
+
const version = freezeClone({
|
|
825
|
+
version_id: versionId,
|
|
826
|
+
minutes_id: minutesId,
|
|
827
|
+
meeting_id: meetingId,
|
|
828
|
+
created_at: timestamp,
|
|
829
|
+
created_actor_id: principal,
|
|
830
|
+
...(body === undefined ? {} : { body, body_digest: digestBody(body) }),
|
|
831
|
+
...(source ? { source_ref: source } : {})
|
|
832
|
+
});
|
|
833
|
+
const document = freezeClone({
|
|
834
|
+
minutes_id: minutesId,
|
|
835
|
+
meeting_id: meetingId,
|
|
836
|
+
title: minutesTitle,
|
|
837
|
+
metadata: minutesMetadata,
|
|
838
|
+
acl,
|
|
839
|
+
version_ids: Object.freeze([versionId]),
|
|
840
|
+
current_version_id: versionId,
|
|
841
|
+
created_at: timestamp,
|
|
842
|
+
created_actor_id: principal,
|
|
843
|
+
updated_at: timestamp,
|
|
844
|
+
revision: 1
|
|
845
|
+
});
|
|
846
|
+
await authorized(authorizer, 'write', meeting, context);
|
|
847
|
+
await authorized(authorizer, 'write', document, context);
|
|
848
|
+
const next = snapshotMutable(current);
|
|
849
|
+
next.revision += 1;
|
|
850
|
+
next.meetings.push(meeting);
|
|
851
|
+
next.documents.push(document);
|
|
852
|
+
next.versions.push(version);
|
|
853
|
+
addIdempotencyRecord(next, idempotency.token, 'create_meeting', { meeting_id: meetingId }, principal);
|
|
854
|
+
let readback;
|
|
855
|
+
try {
|
|
856
|
+
readback = await writeSnapshot(next, current.revision);
|
|
857
|
+
}
|
|
858
|
+
catch (error) {
|
|
859
|
+
const replay = await replayAfterConflict(error, idempotency.token, principal, 'create_meeting', context);
|
|
860
|
+
if (replay)
|
|
861
|
+
return replay;
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
864
|
+
const saved = readback.meetings.find((candidate) => candidate.meeting_id === meetingId);
|
|
865
|
+
const savedVersion = readback.versions.find((candidate) => candidate.version_id === versionId);
|
|
866
|
+
if (!saved || !savedVersion || savedVersion.version_id !== versionId
|
|
867
|
+
|| (body !== undefined && (savedVersion.body !== body || savedVersion.body_digest !== digestBody(body)))
|
|
868
|
+
|| (source && JSON.stringify(savedVersion.source_ref) !== JSON.stringify(source))) {
|
|
869
|
+
throw new MeetingMinutesError('readback_mismatch', 'Created meeting minutes did not read back');
|
|
870
|
+
}
|
|
871
|
+
await registerExternal(source, { meeting_id: meetingId, minutes_id: minutesId, version_id: versionId });
|
|
872
|
+
return detailForCaller(readback, saved, context);
|
|
873
|
+
}
|
|
874
|
+
async function create_minutes(meeting_id, input, context) {
|
|
875
|
+
const principal = assertPrincipal(context);
|
|
876
|
+
if (!isRecord(input))
|
|
877
|
+
throw invalid('minutes document input must be an object');
|
|
878
|
+
const meetingId = nonEmptyString(meeting_id, 'meeting_id');
|
|
879
|
+
const snapshot = await readSnapshot();
|
|
880
|
+
const idempotency = await resolveIdempotency(snapshot, principal, input, 'create_minutes', context, { meeting_id: meetingId });
|
|
881
|
+
if (idempotency.replay)
|
|
882
|
+
return idempotency.replay;
|
|
883
|
+
const title = input.title === undefined ? '議事録' : nonEmptyString(input.title, 'title');
|
|
884
|
+
const documentMetadata = metadata(input.metadata, 'metadata');
|
|
885
|
+
const content = contentInput(input.body, input.source_ref, 'minutes');
|
|
886
|
+
const source = content.source_ref;
|
|
887
|
+
if (source)
|
|
888
|
+
await assertExternalUnique(snapshot, source);
|
|
889
|
+
const meeting = findMeeting(snapshot, meetingId);
|
|
890
|
+
await authorized(authorizer, 'write', meeting, context);
|
|
891
|
+
ensureRevision(snapshot.revision, input.expected_revision);
|
|
892
|
+
const timestamp = isoNow(now);
|
|
893
|
+
const minutesId = id('minutes', idFactory);
|
|
894
|
+
const versionId = id('version', idFactory);
|
|
895
|
+
const version = freezeClone({
|
|
896
|
+
version_id: versionId,
|
|
897
|
+
minutes_id: minutesId,
|
|
898
|
+
meeting_id: meetingId,
|
|
899
|
+
created_at: timestamp,
|
|
900
|
+
created_actor_id: principal,
|
|
901
|
+
...(content.body === undefined ? {} : { body: content.body, body_digest: digestBody(content.body) }),
|
|
902
|
+
...(source ? { source_ref: source } : {})
|
|
903
|
+
});
|
|
904
|
+
const document = freezeClone({
|
|
905
|
+
minutes_id: minutesId,
|
|
906
|
+
meeting_id: meetingId,
|
|
907
|
+
title,
|
|
908
|
+
metadata: documentMetadata,
|
|
909
|
+
acl: meeting.acl,
|
|
910
|
+
version_ids: Object.freeze([versionId]),
|
|
911
|
+
current_version_id: versionId,
|
|
912
|
+
created_at: timestamp,
|
|
913
|
+
created_actor_id: principal,
|
|
914
|
+
updated_at: timestamp,
|
|
915
|
+
revision: 1
|
|
916
|
+
});
|
|
917
|
+
const next = snapshotMutable(snapshot);
|
|
918
|
+
next.revision += 1;
|
|
919
|
+
next.documents.push(document);
|
|
920
|
+
next.versions.push(version);
|
|
921
|
+
addIdempotencyRecord(next, idempotency.token, 'create_minutes', { meeting_id: meetingId, minutes_id: minutesId }, principal);
|
|
922
|
+
let readback;
|
|
923
|
+
try {
|
|
924
|
+
readback = await writeSnapshot(next, snapshot.revision);
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
const replay = await replayAfterConflict(error, idempotency.token, principal, 'create_minutes', context, { meeting_id: meetingId });
|
|
928
|
+
if (replay)
|
|
929
|
+
return replay;
|
|
930
|
+
throw error;
|
|
931
|
+
}
|
|
932
|
+
const savedMeeting = findMeeting(readback, meetingId);
|
|
933
|
+
const savedDocument = findDocument(readback, minutesId);
|
|
934
|
+
const savedVersion = readback.versions.find((candidate) => candidate.version_id === versionId);
|
|
935
|
+
if (!savedVersion || savedDocument.current_version_id !== versionId
|
|
936
|
+
|| (content.body !== undefined && (savedVersion.body !== content.body || savedVersion.body_digest !== digestBody(content.body)))
|
|
937
|
+
|| (source && JSON.stringify(savedVersion.source_ref) !== JSON.stringify(source))) {
|
|
938
|
+
throw new MeetingMinutesError('readback_mismatch', 'Created minutes document did not read back');
|
|
939
|
+
}
|
|
940
|
+
await registerExternal(source, { meeting_id: meetingId, minutes_id: minutesId, version_id: versionId });
|
|
941
|
+
return detailForCaller(readback, savedMeeting, context);
|
|
942
|
+
}
|
|
943
|
+
async function get_meeting(meeting_id, context) {
|
|
944
|
+
assertPrincipal(context);
|
|
945
|
+
const snapshot = await readSnapshot();
|
|
946
|
+
const meeting = findMeeting(snapshot, nonEmptyString(meeting_id, 'meeting_id'));
|
|
947
|
+
return detailForCaller(snapshot, meeting, context);
|
|
948
|
+
}
|
|
949
|
+
async function get_version(meeting_id, minutes_id, version_id, context) {
|
|
950
|
+
const current = await get_meeting(meeting_id, context);
|
|
951
|
+
const document = current.minutes.find((candidate) => candidate.minutes_id === minutes_id);
|
|
952
|
+
if (!document)
|
|
953
|
+
throw new MeetingMinutesError('not_found', `Minutes document ${minutes_id} was not found`);
|
|
954
|
+
const version = current.versions.find((candidate) => candidate.version_id === version_id && candidate.minutes_id === minutes_id);
|
|
955
|
+
if (!version)
|
|
956
|
+
throw new MeetingMinutesError('not_found', `Minutes version ${version_id} was not found`);
|
|
957
|
+
return freezeClone(version);
|
|
958
|
+
}
|
|
959
|
+
async function save_version(input, context) {
|
|
960
|
+
const principal = assertPrincipal(context);
|
|
961
|
+
if (!isRecord(input))
|
|
962
|
+
throw invalid('minutes input must be an object');
|
|
963
|
+
const minutesId = nonEmptyString(input.minutes_id, 'minutes_id');
|
|
964
|
+
const snapshot = await readSnapshot();
|
|
965
|
+
const idempotency = await resolveIdempotency(snapshot, principal, input, 'save_version', context, { minutes_id: minutesId });
|
|
966
|
+
if (idempotency.replay)
|
|
967
|
+
return idempotency.replay;
|
|
968
|
+
const nextMetadata = input.metadata === undefined ? undefined : metadata(input.metadata, 'metadata');
|
|
969
|
+
const content = contentInput(input.body, input.source_ref, 'minutes');
|
|
970
|
+
const source = content.source_ref;
|
|
971
|
+
if (source)
|
|
972
|
+
await assertExternalUnique(snapshot, source);
|
|
973
|
+
const document = findDocument(snapshot, minutesId);
|
|
974
|
+
const meeting = findMeeting(snapshot, document.meeting_id);
|
|
975
|
+
await authorized(authorizer, 'write', meeting, context);
|
|
976
|
+
await authorized(authorizer, 'write', document, context);
|
|
977
|
+
ensureRevision(document.revision, input.expected_revision);
|
|
978
|
+
const predecessor = input.predecessor_version_id === undefined ? document.current_version_id : nonEmptyString(input.predecessor_version_id, 'predecessor_version_id');
|
|
979
|
+
if (predecessor !== document.current_version_id)
|
|
980
|
+
throw invalid('predecessor_version_id must be the current version');
|
|
981
|
+
const timestamp = isoNow(now);
|
|
982
|
+
const versionId = id('version', idFactory);
|
|
983
|
+
const version = freezeClone({
|
|
984
|
+
version_id: versionId,
|
|
985
|
+
minutes_id: document.minutes_id,
|
|
986
|
+
meeting_id: document.meeting_id,
|
|
987
|
+
predecessor_version_id: predecessor,
|
|
988
|
+
created_at: timestamp,
|
|
989
|
+
created_actor_id: principal,
|
|
990
|
+
...(content.body === undefined ? {} : { body: content.body, body_digest: digestBody(content.body) }),
|
|
991
|
+
...(source ? { source_ref: source } : {})
|
|
992
|
+
});
|
|
993
|
+
const next = snapshotMutable(snapshot);
|
|
994
|
+
next.revision += 1;
|
|
995
|
+
const nextDocument = freezeClone({
|
|
996
|
+
...document,
|
|
997
|
+
...(nextMetadata === undefined ? {} : { metadata: nextMetadata }),
|
|
998
|
+
version_ids: [...document.version_ids, versionId],
|
|
999
|
+
current_version_id: versionId,
|
|
1000
|
+
updated_at: timestamp,
|
|
1001
|
+
revision: document.revision + 1
|
|
1002
|
+
});
|
|
1003
|
+
next.documents = next.documents.map((candidate) => candidate.minutes_id === document.minutes_id ? nextDocument : candidate);
|
|
1004
|
+
next.versions.push(version);
|
|
1005
|
+
addIdempotencyRecord(next, idempotency.token, 'save_version', {
|
|
1006
|
+
meeting_id: meeting.meeting_id,
|
|
1007
|
+
minutes_id: document.minutes_id,
|
|
1008
|
+
version_id: versionId
|
|
1009
|
+
}, principal);
|
|
1010
|
+
let readback;
|
|
1011
|
+
try {
|
|
1012
|
+
readback = await writeSnapshot(next, snapshot.revision);
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
const replay = await replayAfterConflict(error, idempotency.token, principal, 'save_version', context, { minutes_id: minutesId });
|
|
1016
|
+
if (replay)
|
|
1017
|
+
return replay;
|
|
1018
|
+
throw error;
|
|
1019
|
+
}
|
|
1020
|
+
const savedMeeting = findMeeting(readback, meeting.meeting_id);
|
|
1021
|
+
const savedDocument = findDocument(readback, document.minutes_id);
|
|
1022
|
+
const savedVersion = readback.versions.find((candidate) => candidate.version_id === versionId);
|
|
1023
|
+
if (!savedVersion
|
|
1024
|
+
|| (content.body !== undefined && (savedVersion.body !== content.body || savedVersion.body_digest !== digestBody(content.body)))
|
|
1025
|
+
|| (source && JSON.stringify(savedVersion.source_ref) !== JSON.stringify(source))
|
|
1026
|
+
|| savedDocument.current_version_id !== versionId || savedDocument.revision !== document.revision + 1) {
|
|
1027
|
+
throw new MeetingMinutesError('readback_mismatch', 'Saved minutes version did not read back');
|
|
1028
|
+
}
|
|
1029
|
+
await registerExternal(source, { meeting_id: meeting.meeting_id, minutes_id: document.minutes_id, version_id: versionId });
|
|
1030
|
+
return detailForCaller(readback, savedMeeting, context);
|
|
1031
|
+
}
|
|
1032
|
+
async function confirm_version(input, context) {
|
|
1033
|
+
const principal = assertPrincipal(context);
|
|
1034
|
+
if (!isRecord(input))
|
|
1035
|
+
throw invalid('confirmation input must be an object');
|
|
1036
|
+
const minutesId = nonEmptyString(input.minutes_id, 'minutes_id');
|
|
1037
|
+
const versionId = nonEmptyString(input.version_id, 'version_id');
|
|
1038
|
+
const snapshot = await readSnapshot();
|
|
1039
|
+
const idempotency = await resolveIdempotency(snapshot, principal, input, 'confirm_version', context, { minutes_id: minutesId });
|
|
1040
|
+
if (idempotency.replay)
|
|
1041
|
+
return idempotency.replay;
|
|
1042
|
+
const document = findDocument(snapshot, minutesId);
|
|
1043
|
+
const meeting = findMeeting(snapshot, document.meeting_id);
|
|
1044
|
+
await authorized(authorizer, 'write', meeting, context);
|
|
1045
|
+
await authorized(authorizer, 'write', document, context);
|
|
1046
|
+
const version = snapshot.versions.find((candidate) => candidate.version_id === versionId && candidate.minutes_id === minutesId);
|
|
1047
|
+
if (!version)
|
|
1048
|
+
throw new MeetingMinutesError('not_found', `Minutes version ${versionId} was not found`);
|
|
1049
|
+
ensureRevision(document.revision, input.expected_revision);
|
|
1050
|
+
if (version.confirmation) {
|
|
1051
|
+
if (!idempotency.token)
|
|
1052
|
+
return detailForCaller(snapshot, meeting, context);
|
|
1053
|
+
const next = snapshotMutable(snapshot);
|
|
1054
|
+
next.revision += 1;
|
|
1055
|
+
addIdempotencyRecord(next, idempotency.token, 'confirm_version', {
|
|
1056
|
+
meeting_id: meeting.meeting_id,
|
|
1057
|
+
minutes_id: minutesId,
|
|
1058
|
+
version_id: versionId
|
|
1059
|
+
}, principal);
|
|
1060
|
+
let readback;
|
|
1061
|
+
try {
|
|
1062
|
+
readback = await writeSnapshot(next, snapshot.revision);
|
|
1063
|
+
}
|
|
1064
|
+
catch (error) {
|
|
1065
|
+
const replay = await replayAfterConflict(error, idempotency.token, principal, 'confirm_version', context, { minutes_id: minutesId });
|
|
1066
|
+
if (replay)
|
|
1067
|
+
return replay;
|
|
1068
|
+
throw error;
|
|
1069
|
+
}
|
|
1070
|
+
return detailForCaller(readback, findMeeting(readback, meeting.meeting_id), context);
|
|
1071
|
+
}
|
|
1072
|
+
const timestamp = isoNow(now);
|
|
1073
|
+
const confirmed = freezeClone({
|
|
1074
|
+
...version,
|
|
1075
|
+
confirmation: { version_id: versionId, actor_id: principal, confirmed_at: timestamp }
|
|
1076
|
+
});
|
|
1077
|
+
const next = snapshotMutable(snapshot);
|
|
1078
|
+
next.revision += 1;
|
|
1079
|
+
next.versions = next.versions.map((candidate) => candidate.version_id === versionId ? confirmed : candidate);
|
|
1080
|
+
next.documents = next.documents.map((candidate) => candidate.minutes_id === minutesId
|
|
1081
|
+
? { ...candidate, updated_at: timestamp, revision: candidate.revision + 1 }
|
|
1082
|
+
: candidate);
|
|
1083
|
+
addIdempotencyRecord(next, idempotency.token, 'confirm_version', {
|
|
1084
|
+
meeting_id: meeting.meeting_id,
|
|
1085
|
+
minutes_id: minutesId,
|
|
1086
|
+
version_id: versionId
|
|
1087
|
+
}, principal);
|
|
1088
|
+
let readback;
|
|
1089
|
+
try {
|
|
1090
|
+
readback = await writeSnapshot(next, snapshot.revision);
|
|
1091
|
+
}
|
|
1092
|
+
catch (error) {
|
|
1093
|
+
const replay = await replayAfterConflict(error, idempotency.token, principal, 'confirm_version', context, { minutes_id: minutesId });
|
|
1094
|
+
if (replay)
|
|
1095
|
+
return replay;
|
|
1096
|
+
throw error;
|
|
1097
|
+
}
|
|
1098
|
+
const savedMeeting = findMeeting(readback, meeting.meeting_id);
|
|
1099
|
+
const saved = readback.versions.find((candidate) => candidate.version_id === versionId);
|
|
1100
|
+
if (!saved?.confirmation || saved.confirmation.version_id !== versionId || saved.confirmation.actor_id !== principal
|
|
1101
|
+
|| saved.confirmation.confirmed_at !== timestamp)
|
|
1102
|
+
throw new MeetingMinutesError('readback_mismatch', 'Confirmation did not read back');
|
|
1103
|
+
return detailForCaller(readback, savedMeeting, context);
|
|
1104
|
+
}
|
|
1105
|
+
return { list_meetings, create_meeting, create_minutes, get_meeting, get_version, save_version, confirm_version };
|
|
1106
|
+
}
|
|
1107
|
+
function participantIdsInput(value) {
|
|
1108
|
+
return participantIds(value);
|
|
1109
|
+
}
|
|
1110
|
+
/** Exposes the native file adapter for consumers that need an explicit port. */
|
|
1111
|
+
export function createMeetingMinutesStorage(data_dir) {
|
|
1112
|
+
return new LocalMeetingMinutesStorage(nonEmptyString(data_dir, 'data_dir'));
|
|
1113
|
+
}
|
|
1114
|
+
/** Stable body digest used by version readback and external source references. */
|
|
1115
|
+
export function meetingMinutesBodyDigest(body) {
|
|
1116
|
+
if (typeof body !== 'string')
|
|
1117
|
+
throw invalid('body must be a string');
|
|
1118
|
+
return digestBody(body);
|
|
1119
|
+
}
|
|
1120
|
+
function exactSourceMatches(reference, version) {
|
|
1121
|
+
if (version.body !== undefined)
|
|
1122
|
+
return version.body_digest === reference.contentDigest;
|
|
1123
|
+
const source = version.source_ref;
|
|
1124
|
+
if (!source || source.digest !== reference.contentDigest || source.locator !== reference.locator)
|
|
1125
|
+
return false;
|
|
1126
|
+
const provenance = reference.provenance;
|
|
1127
|
+
if (!isRecord(provenance))
|
|
1128
|
+
return false;
|
|
1129
|
+
const sourceProvenance = isRecord(source.provenance) ? source.provenance : {};
|
|
1130
|
+
const providerId = typeof sourceProvenance.providerId === 'string' ? sourceProvenance.providerId : source.provider;
|
|
1131
|
+
const providerKind = typeof sourceProvenance.providerKind === 'string' ? sourceProvenance.providerKind : source.provider;
|
|
1132
|
+
const revision = typeof sourceProvenance.revision === 'string' ? sourceProvenance.revision : source.revision;
|
|
1133
|
+
const digest = typeof sourceProvenance.digest === 'string' ? sourceProvenance.digest : source.digest;
|
|
1134
|
+
return provenance.providerId === providerId
|
|
1135
|
+
&& provenance.providerKind === providerKind
|
|
1136
|
+
&& provenance.revision === revision
|
|
1137
|
+
&& provenance.digest === digest;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Adapt one native store to the exact-version port. Every call delegates to
|
|
1141
|
+
* `get_version`, so current ACL is evaluated by the same store instance that
|
|
1142
|
+
* owns the native snapshot. The caller's reference is returned only after all
|
|
1143
|
+
* stable source identity fields match the persisted version.
|
|
1144
|
+
*/
|
|
1145
|
+
export function createMeetingMinutesVersionPort(store) {
|
|
1146
|
+
if (!store || typeof store.get_version !== 'function')
|
|
1147
|
+
throw new TypeError('store.get_version is required');
|
|
1148
|
+
return {
|
|
1149
|
+
async readExact(reference, access) {
|
|
1150
|
+
if (!reference || !isRecord(reference) || !access || typeof access.principal !== 'string' || !access.principal.trim()) {
|
|
1151
|
+
throw new MeetingMinutesError('invalid_input', 'An exact minutes reference and access principal are required');
|
|
1152
|
+
}
|
|
1153
|
+
const meetingId = nonEmptyString(reference.meetingId, 'reference.meetingId');
|
|
1154
|
+
const minutesId = nonEmptyString(reference.minutesId, 'reference.minutesId');
|
|
1155
|
+
const versionId = nonEmptyString(reference.versionId, 'reference.versionId');
|
|
1156
|
+
if (reference.contentDigest !== null && typeof reference.contentDigest !== 'string') {
|
|
1157
|
+
throw new MeetingMinutesError('invalid_input', 'reference.contentDigest must be a string or null');
|
|
1158
|
+
}
|
|
1159
|
+
try {
|
|
1160
|
+
const version = await store.get_version(meetingId, minutesId, versionId, { principal_id: access.principal });
|
|
1161
|
+
if (!exactSourceMatches(reference, version))
|
|
1162
|
+
return null;
|
|
1163
|
+
return { reference };
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
if (error instanceof MeetingMinutesError && error.code === 'not_found')
|
|
1167
|
+
return null;
|
|
1168
|
+
throw error;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
}
|