@fonoster/callrecord 0.0.0-stage → 0.27.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.
@@ -0,0 +1,135 @@
1
+ import { RecordIndexEntry } from "./indexEntry";
2
+ import { KeyCustody } from "./keys";
3
+ import { SigningKey } from "./sign";
4
+ import { FsWorkingStore, RecordStore } from "./storage";
5
+ import { RecordEntry, SealingVersion } from "./types";
6
+ /** Call-record open question O1: a record is sealed within 30 s of hangup. */
7
+ export declare const SEAL_DEADLINE_MS = 30000;
8
+ /** Parts a retention policy or a deletion request can name (call-record "Retention"). */
9
+ export declare const RECORD_PARTS: readonly ["recording", "transcript", "messages"];
10
+ export type RecordPart = (typeof RECORD_PARTS)[number];
11
+ /** A maintenance request named another workspace than the stored record's (RV-B6). */
12
+ export declare class WorkspaceMismatchError extends Error {
13
+ constructor(callRef: string);
14
+ }
15
+ /** One audited deletion (call-record "every deletion ... produces an audit entry"). */
16
+ export type AuditEntry = {
17
+ callRef: string;
18
+ workspaceRef: string | null;
19
+ action: "delete_part" | "erase";
20
+ part: RecordPart | "all";
21
+ reason: string;
22
+ actor: string;
23
+ at: string;
24
+ /** The version that records the deletion. */
25
+ version: number;
26
+ };
27
+ export type CallRecordWriterOptions = {
28
+ working: FsWorkingStore;
29
+ /** The region's record store. */
30
+ store: RecordStore;
31
+ /**
32
+ * A workspace's own bucket, if it configured one (design V2). When it fails at seal the record
33
+ * stays in the region store and the index says so (open question O7, interim).
34
+ */
35
+ workspaceStore?: (workspaceRef: string) => RecordStore | undefined;
36
+ /** A fixed key, or a custody that names the current key and signs (task 2.6). */
37
+ key: SigningKey | KeyCustody;
38
+ region: string;
39
+ /** Called after each sealed version: index it, send `call_record.sealed`. */
40
+ onSealed?: (entry: RecordIndexEntry) => void;
41
+ /**
42
+ * How often this writer marks its live calls' working records as alive (ST-5), so a writer
43
+ * sweeping the same directory leaves them alone. Well under the sweep's deadline.
44
+ */
45
+ heartbeatMs?: number;
46
+ };
47
+ /**
48
+ * Builds call records live and seals them at hangup (call-record "The record is built during the
49
+ * call and sealed at hangup"). It implements the gateway's CallRecordSink interface.
50
+ */
51
+ export declare class CallRecordWriter {
52
+ private readonly options;
53
+ /** Calls appended in this process and not closed yet; the sweep leaves them alone. */
54
+ private readonly active;
55
+ private readonly sealing;
56
+ private readonly custody;
57
+ /** Names this writer in seal locks on a shared working directory. */
58
+ private readonly owner;
59
+ private heartbeat?;
60
+ constructor(options: CallRecordWriterOptions);
61
+ get region(): string;
62
+ append(callRef: string, entry: RecordEntry): void;
63
+ /** Marks every live call of this writer as alive (see `heartbeatMs`). */
64
+ beat(): void;
65
+ /** Stops the heartbeat (shutdown; a writer that dies stops it too). */
66
+ stop(): void;
67
+ /** Where the recorder writes this call's tracks (removed once the record is sealed). */
68
+ recordingDir(callRef: string): string;
69
+ /** The call ended: seal its record in the background. */
70
+ close(callRef: string): void;
71
+ /** Resolves once every seal in progress has finished (tests, shutdown). */
72
+ flush(): Promise<void>;
73
+ /** The store a workspace's records go to. */
74
+ storeFor(workspaceRef?: string | null): RecordStore;
75
+ /**
76
+ * The store that holds an existing record. With its indexed location (RV-B10) that is the
77
+ * store at that location: a record whose workspace bucket failed at seal stays in the region
78
+ * store, and a workspace that configured a bucket later keeps its older records where they
79
+ * are. Without a location, the workspace's current store.
80
+ */
81
+ storeAt(location: string | undefined, workspaceRef?: string | null): RecordStore;
82
+ /** Seals a working record as version 1 and returns it; idempotent while in progress. */
83
+ seal(callRef: string): Promise<SealingVersion>;
84
+ /** The sealed version 1 another writer stored, once its seal lock is gone. */
85
+ private sealedByOther;
86
+ /**
87
+ * Seals under a lock in the working directory, so of all the writers that share it exactly one
88
+ * seals a call (its hangup on the owner, or a sweep elsewhere), once (ST-5).
89
+ */
90
+ private doSeal;
91
+ private sealLocked;
92
+ private sealed;
93
+ /**
94
+ * Seals records whose calls stopped appending more than `deadlineMs` ago without a hangup (a
95
+ * gateway that crashed). Returns the callRefs sealed.
96
+ */
97
+ sweep(now?: number, deadlineMs?: number): Promise<string[]>;
98
+ /** Adds a version derived from the current one (it references its predecessor). */
99
+ private newVersion;
100
+ /**
101
+ * Adds to a sealed record as a new version that references its predecessor (call-record
102
+ * "Additions after sealing create new versions"); the previous version is unchanged.
103
+ */
104
+ addVersion(callRef: string, additions: {
105
+ analysis?: Record<string, unknown>[];
106
+ }, workspaceRef?: string, location?: string): Promise<SealingVersion>;
107
+ /**
108
+ * Deletes parts of a record (retention policy or a request) and writes a version that records
109
+ * each removal (call-record "Retention follows workspace policy and deletions are audited").
110
+ * The recording and the messages are files; the transcript lives inside the vCons, so earlier
111
+ * versions' contents are deleted too and only their manifests remain.
112
+ */
113
+ deleteParts(callRef: string, requested: RecordPart[], by: {
114
+ reason: string;
115
+ actor: string;
116
+ workspaceRef?: string | null;
117
+ location?: string;
118
+ }): Promise<{
119
+ sealed: SealingVersion;
120
+ audit: AuditEntry[];
121
+ }>;
122
+ /**
123
+ * Erases one call (call-record "Erasing one call"): every file and version is deleted, and a
124
+ * signed tombstone version states when, why and by whom. It holds no call content.
125
+ */
126
+ erase(callRef: string, by: {
127
+ reason: string;
128
+ actor: string;
129
+ workspaceRef?: string | null;
130
+ location?: string;
131
+ }): Promise<{
132
+ audit: AuditEntry[];
133
+ version: number;
134
+ }>;
135
+ }
@@ -0,0 +1,545 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __rest = (this && this.__rest) || function (s, e) {
45
+ var t = {};
46
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
47
+ t[p] = s[p];
48
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
49
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
50
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
51
+ t[p[i]] = s[p[i]];
52
+ }
53
+ return t;
54
+ };
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.CallRecordWriter = exports.WorkspaceMismatchError = exports.RECORD_PARTS = exports.SEAL_DEADLINE_MS = void 0;
57
+ /**
58
+ * Copyright (C) 2025 by Fonoster Inc (https://fonoster.com)
59
+ * http://github.com/fonoster/fonoster
60
+ *
61
+ * This file is part of Fonoster
62
+ *
63
+ * Licensed under the MIT License (the "License");
64
+ * you may not use this file except in compliance with
65
+ * the License. You may obtain a copy of the License at
66
+ *
67
+ * https://opensource.org/licenses/MIT
68
+ *
69
+ * Unless required by applicable law or agreed to in writing, software
70
+ * distributed under the License is distributed on an "AS IS" BASIS,
71
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
72
+ * See the License for the specific language governing permissions and
73
+ * limitations under the License.
74
+ */
75
+ const crypto_1 = require("crypto");
76
+ const path = __importStar(require("path"));
77
+ const logger_1 = require("@fonoster/logger");
78
+ const indexEntry_1 = require("./indexEntry");
79
+ const keys_1 = require("./keys");
80
+ const recording_1 = require("./recording");
81
+ const sign_1 = require("./sign");
82
+ const vcon_1 = require("./vcon");
83
+ const logger = (0, logger_1.getLogger)({ service: "callrecord", filePath: __filename });
84
+ /** Call-record open question O1: a record is sealed within 30 s of hangup. */
85
+ exports.SEAL_DEADLINE_MS = 30000;
86
+ /** Parts a retention policy or a deletion request can name (call-record "Retention"). */
87
+ exports.RECORD_PARTS = ["recording", "transcript", "messages"];
88
+ /** A maintenance request named another workspace than the stored record's (RV-B6). */
89
+ class WorkspaceMismatchError extends Error {
90
+ constructor(callRef) {
91
+ super(`record ${callRef} belongs to another workspace`);
92
+ this.name = "WorkspaceMismatchError";
93
+ }
94
+ }
95
+ exports.WorkspaceMismatchError = WorkspaceMismatchError;
96
+ /**
97
+ * Refuses a change when the caller named a workspace that is not the stored record's. Callers
98
+ * that do not name one (in-region jobs) are not checked; maintenance requests always name one.
99
+ */
100
+ function assertSameWorkspace(callRef, stored, expected) {
101
+ if (expected === undefined)
102
+ return;
103
+ const actual = stored ? workspaceOfVcon(stored) : null;
104
+ if ((actual !== null && actual !== void 0 ? actual : null) !== (expected !== null && expected !== void 0 ? expected : null))
105
+ throw new WorkspaceMismatchError(callRef);
106
+ }
107
+ /** How long a sealer waits for another writer's seal of the same call. */
108
+ const OTHER_SEAL_WAIT_MS = 120000;
109
+ /**
110
+ * Builds call records live and seals them at hangup (call-record "The record is built during the
111
+ * call and sealed at hangup"). It implements the gateway's CallRecordSink interface.
112
+ */
113
+ class CallRecordWriter {
114
+ constructor(options) {
115
+ this.options = options;
116
+ /** Calls appended in this process and not closed yet; the sweep leaves them alone. */
117
+ this.active = new Set();
118
+ this.sealing = new Map();
119
+ /** Names this writer in seal locks on a shared working directory. */
120
+ this.owner = (0, crypto_1.randomUUID)();
121
+ this.custody = (0, keys_1.isKeyCustody)(options.key)
122
+ ? options.key
123
+ : new keys_1.StaticKeyCustody(options.key);
124
+ }
125
+ get region() {
126
+ return this.options.region;
127
+ }
128
+ append(callRef, entry) {
129
+ var _a, _b, _c, _d;
130
+ this.active.add(callRef);
131
+ this.options.working.append(callRef, entry);
132
+ (_a = this.heartbeat) !== null && _a !== void 0 ? _a : (this.heartbeat = setInterval(() => this.beat(), (_b = this.options.heartbeatMs) !== null && _b !== void 0 ? _b : 5000));
133
+ (_d = (_c = this.heartbeat).unref) === null || _d === void 0 ? void 0 : _d.call(_c);
134
+ }
135
+ /** Marks every live call of this writer as alive (see `heartbeatMs`). */
136
+ beat() {
137
+ for (const callRef of this.active)
138
+ this.options.working.touch(callRef);
139
+ if (!this.active.size && this.heartbeat) {
140
+ clearInterval(this.heartbeat);
141
+ this.heartbeat = undefined;
142
+ }
143
+ }
144
+ /** Stops the heartbeat (shutdown; a writer that dies stops it too). */
145
+ stop() {
146
+ if (this.heartbeat)
147
+ clearInterval(this.heartbeat);
148
+ this.heartbeat = undefined;
149
+ }
150
+ /** Where the recorder writes this call's tracks (removed once the record is sealed). */
151
+ recordingDir(callRef) {
152
+ return this.options.working.recordingDir(callRef);
153
+ }
154
+ /** The call ended: seal its record in the background. */
155
+ close(callRef) {
156
+ this.active.delete(callRef);
157
+ void this.seal(callRef).catch((e) => logger.error(`could not seal ${callRef}: ${e.message}`));
158
+ }
159
+ /** Resolves once every seal in progress has finished (tests, shutdown). */
160
+ flush() {
161
+ return __awaiter(this, void 0, void 0, function* () {
162
+ yield Promise.allSettled([...this.sealing.values()]);
163
+ });
164
+ }
165
+ /** The store a workspace's records go to. */
166
+ storeFor(workspaceRef) {
167
+ var _a, _b;
168
+ return ((workspaceRef && ((_b = (_a = this.options).workspaceStore) === null || _b === void 0 ? void 0 : _b.call(_a, workspaceRef))) ||
169
+ this.options.store);
170
+ }
171
+ /**
172
+ * The store that holds an existing record. With its indexed location (RV-B10) that is the
173
+ * store at that location: a record whose workspace bucket failed at seal stays in the region
174
+ * store, and a workspace that configured a bucket later keeps its older records where they
175
+ * are. Without a location, the workspace's current store.
176
+ */
177
+ storeAt(location, workspaceRef) {
178
+ var _a, _b;
179
+ if (!location)
180
+ return this.storeFor(workspaceRef);
181
+ const candidates = [
182
+ workspaceRef ? (_b = (_a = this.options).workspaceStore) === null || _b === void 0 ? void 0 : _b.call(_a, workspaceRef) : undefined,
183
+ this.options.store
184
+ ];
185
+ const found = candidates.find((s) => s && s.location === location);
186
+ if (!found)
187
+ throw new Error(`no record store at ${location}`);
188
+ return found;
189
+ }
190
+ /** Seals a working record as version 1 and returns it; idempotent while in progress. */
191
+ seal(callRef) {
192
+ const inProgress = this.sealing.get(callRef);
193
+ if (inProgress)
194
+ return inProgress;
195
+ const p = this.doSeal(callRef).finally(() => this.sealing.delete(callRef));
196
+ this.sealing.set(callRef, p);
197
+ return p;
198
+ }
199
+ /** The sealed version 1 another writer stored, once its seal lock is gone. */
200
+ sealedByOther(callRef) {
201
+ return __awaiter(this, void 0, void 0, function* () {
202
+ const { working } = this.options;
203
+ const end = Date.now() + OTHER_SEAL_WAIT_MS;
204
+ while (working.isSealLocked(callRef) && Date.now() < end)
205
+ yield new Promise((r) => setTimeout(r, 200));
206
+ const entries = working.readWorking(callRef);
207
+ const existing = yield this.storeFor(workspaceOf(entries)).getVersion(callRef);
208
+ if (!(existing === null || existing === void 0 ? void 0 : existing.signed) || !existing.redacted)
209
+ throw new Error(`${callRef} is being sealed by another writer`);
210
+ return {
211
+ version: existing.version,
212
+ signed: existing.signed,
213
+ redacted: existing.redacted,
214
+ parts: existing.manifest.parts.map((p) => (Object.assign(Object.assign({}, p), { stored: true })))
215
+ };
216
+ });
217
+ }
218
+ /**
219
+ * Seals under a lock in the working directory, so of all the writers that share it exactly one
220
+ * seals a call (its hangup on the owner, or a sweep elsewhere), once (ST-5).
221
+ */
222
+ doSeal(callRef) {
223
+ return __awaiter(this, void 0, void 0, function* () {
224
+ const { working } = this.options;
225
+ if (!working.lockSeal(callRef, this.owner))
226
+ return this.sealedByOther(callRef);
227
+ try {
228
+ return yield this.sealLocked(callRef);
229
+ }
230
+ finally {
231
+ working.unlockSeal(callRef, this.owner);
232
+ }
233
+ });
234
+ }
235
+ sealLocked(callRef) {
236
+ return __awaiter(this, void 0, void 0, function* () {
237
+ const { working, region } = this.options;
238
+ const entries = working.readWorking(callRef);
239
+ const workspaceRef = workspaceOf(entries);
240
+ let store = this.storeFor(workspaceRef);
241
+ const existing = yield store.getVersion(callRef);
242
+ if ((existing === null || existing === void 0 ? void 0 : existing.signed) && existing.redacted) {
243
+ working.discardWorking(callRef);
244
+ return {
245
+ version: existing.version,
246
+ signed: existing.signed,
247
+ redacted: existing.redacted,
248
+ parts: existing.manifest.parts.map((p) => (Object.assign(Object.assign({}, p), { stored: true })))
249
+ };
250
+ }
251
+ // Streamed to a file next to the tracks and uploaded from there (LO-B7); the working
252
+ // directory, file included, is discarded once the record is stored.
253
+ const recDir = working.recordingDir(callRef);
254
+ const rec = (0, recording_1.assembleRecordingFile)(recDir, path.join(recDir, recording_1.RECORDING_PART));
255
+ const built = (0, vcon_1.buildVcon)(callRef, entries, {
256
+ region,
257
+ recording: rec
258
+ ? {
259
+ part: {
260
+ name: recording_1.RECORDING_PART,
261
+ mimetype: "audio/x-wav",
262
+ file: rec.file,
263
+ size: rec.size,
264
+ content_hash: rec.content_hash
265
+ },
266
+ startedAt: rec.startedAt,
267
+ durationMs: rec.durationMs,
268
+ beep: (spans) => (Object.assign({ name: recording_1.REDACTED_RECORDING_PART, mimetype: "audio/x-wav" }, (0, recording_1.beepRecordingFile)(rec.file, path.join(recDir, recording_1.REDACTED_RECORDING_PART), spans)))
269
+ }
270
+ : undefined
271
+ });
272
+ const sealed = {
273
+ version: 1,
274
+ signed: yield (0, sign_1.signVconWith)(built.vcon, this.custody),
275
+ redacted: yield (0, sign_1.signVconWith)(built.redacted, this.custody),
276
+ parts: built.parts
277
+ };
278
+ try {
279
+ yield store.putSealed(callRef, sealed);
280
+ }
281
+ catch (e) {
282
+ if (store === this.options.store)
283
+ throw e;
284
+ logger.warn(`workspace bucket failed for ${callRef}, keeping it in the region store: ${e.message}`);
285
+ store = this.options.store;
286
+ yield store.putSealed(callRef, sealed);
287
+ }
288
+ working.discardWorking(callRef);
289
+ this.sealed(callRef, sealed, built.vcon, store);
290
+ return sealed;
291
+ });
292
+ }
293
+ sealed(callRef, sealed, vcon, store) {
294
+ var _a, _b;
295
+ try {
296
+ (_b = (_a = this.options).onSealed) === null || _b === void 0 ? void 0 : _b.call(_a, (0, indexEntry_1.indexEntry)(callRef, sealed, vcon, {
297
+ region: this.options.region,
298
+ location: store.location
299
+ }));
300
+ }
301
+ catch (e) {
302
+ logger.error(`onSealed failed for ${callRef}: ${e.message}`);
303
+ }
304
+ }
305
+ /**
306
+ * Seals records whose calls stopped appending more than `deadlineMs` ago without a hangup (a
307
+ * gateway that crashed). Returns the callRefs sealed.
308
+ */
309
+ sweep() {
310
+ return __awaiter(this, arguments, void 0, function* (now = Date.now(), deadlineMs = exports.SEAL_DEADLINE_MS) {
311
+ const sealed = [];
312
+ // Live calls of this writer first, so other writers see them alive.
313
+ this.beat();
314
+ for (const w of this.options.working.listWorking()) {
315
+ if (this.active.has(w.callRef) || this.sealing.has(w.callRef))
316
+ continue;
317
+ // Quiet for the deadline: neither appended to nor kept alive by its owner's heartbeat.
318
+ if (now - w.lastAppendAt < deadlineMs)
319
+ continue;
320
+ // Another writer is sealing it right now.
321
+ if (this.options.working.isSealLocked(w.callRef))
322
+ continue;
323
+ try {
324
+ yield this.seal(w.callRef);
325
+ sealed.push(w.callRef);
326
+ }
327
+ catch (e) {
328
+ logger.error(`could not seal ${w.callRef}: ${e.message}`);
329
+ }
330
+ }
331
+ return sealed;
332
+ });
333
+ }
334
+ /** Adds a version derived from the current one (it references its predecessor). */
335
+ newVersion(callRef_1, workspaceRef_1, change_1) {
336
+ return __awaiter(this, arguments, void 0, function* (callRef, workspaceRef, change, checkWorkspace = false, location) {
337
+ var _a;
338
+ const store = this.storeAt(location, workspaceRef);
339
+ // Without part contents: a new version shares the stored parts (no recording download).
340
+ const stored = yield store.getVersion(callRef);
341
+ if (!(stored === null || stored === void 0 ? void 0 : stored.signed) || !stored.redacted)
342
+ throw new Error(`no sealed record for ${callRef}`);
343
+ const current = {
344
+ version: stored.version,
345
+ signed: stored.signed,
346
+ redacted: stored.redacted,
347
+ parts: stored.manifest.parts.map((p) => (Object.assign(Object.assign({}, p), { stored: true })))
348
+ };
349
+ if (checkWorkspace)
350
+ assertSameWorkspace(callRef, (0, sign_1.decodeVcon)(current.signed), workspaceRef);
351
+ const next = (signed, redacted) => {
352
+ const prev = (0, sign_1.decodeVcon)(signed);
353
+ return Object.assign(Object.assign({}, change(prev, redacted)), { uuid: (0, crypto_1.randomUUID)(), updated_at: new Date().toISOString(), amended: {
354
+ uuid: prev.uuid,
355
+ content_hash: (0, sign_1.contentHash)(Buffer.from(JSON.stringify(prev)))
356
+ } });
357
+ };
358
+ const vcon = next(current.signed, false);
359
+ // draft-04 4.1: `redacted` and `amended` are mutually exclusive. The original carries the
360
+ // version chain (amended → its predecessor); the redacted copy points at this version's
361
+ // original only (LO-B8).
362
+ const _b = next(current.redacted, true), { amended: _amended } = _b, redactedNext = __rest(_b, ["amended"]);
363
+ void _amended;
364
+ const redactedVcon = Object.assign(Object.assign({}, redactedNext), { redacted: Object.assign(Object.assign({}, ((_a = (0, sign_1.decodeVcon)(current.redacted).redacted) !== null && _a !== void 0 ? _a : {})), { uuid: vcon.uuid, content_hash: (0, sign_1.contentHash)(Buffer.from(JSON.stringify(vcon))) }) });
365
+ const sealed = {
366
+ version: current.version + 1,
367
+ signed: yield (0, sign_1.signVconWith)(vcon, this.custody),
368
+ redacted: yield (0, sign_1.signVconWith)(redactedVcon, this.custody),
369
+ parts: current.parts
370
+ };
371
+ yield store.putSealed(callRef, sealed);
372
+ this.sealed(callRef, sealed, vcon, store);
373
+ return { sealed, previous: current };
374
+ });
375
+ }
376
+ /**
377
+ * Adds to a sealed record as a new version that references its predecessor (call-record
378
+ * "Additions after sealing create new versions"); the previous version is unchanged.
379
+ */
380
+ addVersion(callRef, additions, workspaceRef, location) {
381
+ return __awaiter(this, void 0, void 0, function* () {
382
+ const { sealed } = yield this.newVersion(callRef, workspaceRef, (v, redacted) => {
383
+ var _a;
384
+ return (Object.assign(Object.assign({}, v), { analysis: [
385
+ ...v.analysis,
386
+ // The redacted copy gets the additions redacted too (like the sealed analysis, R-B4).
387
+ // draft-04 2.3.2: an inline body needs its encoding.
388
+ ...((_a = additions.analysis) !== null && _a !== void 0 ? _a : [])
389
+ .map((a) => a.body !== undefined && a.body !== "" && !a.encoding
390
+ ? Object.assign(Object.assign({}, a), { encoding: typeof a.body === "string" ? "none" : "json" }) : a)
391
+ .map((a) => redacted ? (0, vcon_1.redactStrings)(a) : a)
392
+ ] }));
393
+ }, false, location);
394
+ return sealed;
395
+ });
396
+ }
397
+ /**
398
+ * Deletes parts of a record (retention policy or a request) and writes a version that records
399
+ * each removal (call-record "Retention follows workspace policy and deletions are audited").
400
+ * The recording and the messages are files; the transcript lives inside the vCons, so earlier
401
+ * versions' contents are deleted too and only their manifests remain.
402
+ */
403
+ deleteParts(callRef, requested, by) {
404
+ return __awaiter(this, void 0, void 0, function* () {
405
+ var _a, _b;
406
+ // messages.ndjson holds every session message, the caller's and the agent's words included:
407
+ // deleting the transcript deletes it too (RV-B11), and the audit says so.
408
+ const parts = requested.includes("transcript") && !requested.includes("messages")
409
+ ? [...requested, "messages"]
410
+ : requested;
411
+ const store = this.storeAt(by.location, by.workspaceRef);
412
+ const at = new Date().toISOString();
413
+ const deletion = (part) => ({
414
+ part,
415
+ at,
416
+ reason: by.reason,
417
+ actor: by.actor
418
+ });
419
+ // The recording is two files when the redacted copy has a beeped one (LO-B9).
420
+ const fileOf = {
421
+ recording: [recording_1.RECORDING_PART, recording_1.REDACTED_RECORDING_PART],
422
+ messages: ["messages.ndjson"]
423
+ };
424
+ const { sealed, previous } = yield this.newVersion(callRef, by.workspaceRef, (v) => {
425
+ var _a, _b;
426
+ const removedFile = (entry) => {
427
+ var _a;
428
+ const name = String((_a = entry.url) !== null && _a !== void 0 ? _a : "").replace(/^parts\//, "");
429
+ const part = parts.find((p) => { var _a; return (_a = fileOf[p]) === null || _a === void 0 ? void 0 : _a.includes(name); });
430
+ if (!part)
431
+ return entry;
432
+ const { url: _url } = entry, rest = __rest(entry, ["url"]);
433
+ void _url;
434
+ return Object.assign(Object.assign({}, rest), { meta: Object.assign(Object.assign({}, rest.meta), { deleted: deletion(part) }) });
435
+ };
436
+ let dialog = v.dialog.map(removedFile);
437
+ let analysis = v.analysis;
438
+ if (parts.includes("transcript")) {
439
+ dialog = dialog.filter((d) => d.type !== "text");
440
+ analysis = analysis.map((a) => a.type === "turn_decisions"
441
+ ? Object.assign(Object.assign({}, a), { body: [], meta: { deleted: deletion("transcript") } }) : a);
442
+ }
443
+ const f = ((_a = v.fonoster) !== null && _a !== void 0 ? _a : {});
444
+ return Object.assign(Object.assign({}, v), { dialog,
445
+ analysis, attachments: v.attachments.map(removedFile), fonoster: Object.assign(Object.assign({}, f), { deletions: [
446
+ ...((_b = f.deletions) !== null && _b !== void 0 ? _b : []),
447
+ ...parts.map(deletion)
448
+ ] }) });
449
+ }, true, by.location);
450
+ for (const part of parts)
451
+ for (const file of (_a = fileOf[part]) !== null && _a !== void 0 ? _a : [])
452
+ yield store.deletePart(callRef, file);
453
+ if (parts.includes("transcript"))
454
+ for (let v = 1; v <= previous.version; v++)
455
+ yield store.deleteVersionContents(callRef, v);
456
+ const workspaceRef = (_b = by.workspaceRef) !== null && _b !== void 0 ? _b : workspaceOfVcon((0, sign_1.decodeVcon)(sealed.signed));
457
+ return {
458
+ sealed: Object.assign(Object.assign({}, sealed), { parts: sealed.parts.filter((p) => !parts.some((part) => { var _a; return (_a = fileOf[part]) === null || _a === void 0 ? void 0 : _a.includes(p.name); })) }),
459
+ audit: parts.map((part) => ({
460
+ callRef,
461
+ workspaceRef,
462
+ action: "delete_part",
463
+ part,
464
+ reason: by.reason,
465
+ actor: by.actor,
466
+ at,
467
+ version: sealed.version
468
+ }))
469
+ };
470
+ });
471
+ }
472
+ /**
473
+ * Erases one call (call-record "Erasing one call"): every file and version is deleted, and a
474
+ * signed tombstone version states when, why and by whom. It holds no call content.
475
+ */
476
+ erase(callRef, by) {
477
+ return __awaiter(this, void 0, void 0, function* () {
478
+ var _a, _b;
479
+ const store = this.storeAt(by.location, by.workspaceRef);
480
+ const current = yield store.getVersion(callRef);
481
+ if (!current)
482
+ throw new Error(`no sealed record for ${callRef}`);
483
+ const at = new Date().toISOString();
484
+ const previous = current.signed ? (0, sign_1.decodeVcon)(current.signed) : undefined;
485
+ assertSameWorkspace(callRef, previous, by.workspaceRef);
486
+ const workspaceRef = (_a = by.workspaceRef) !== null && _a !== void 0 ? _a : (previous ? workspaceOfVcon(previous) : null);
487
+ yield store.deleteAll(callRef);
488
+ const tombstone = {
489
+ vcon: (_b = previous === null || previous === void 0 ? void 0 : previous.vcon) !== null && _b !== void 0 ? _b : "0.4.0",
490
+ uuid: (0, crypto_1.randomUUID)(),
491
+ created_at: at,
492
+ updated_at: at,
493
+ parties: [],
494
+ dialog: [],
495
+ analysis: [],
496
+ attachments: [],
497
+ fonoster: {
498
+ callRef,
499
+ region: this.options.region,
500
+ status: "erased",
501
+ workspaceRef,
502
+ erased: { at, reason: by.reason, actor: by.actor }
503
+ }
504
+ };
505
+ const version = current.version + 1;
506
+ const signed = yield (0, sign_1.signVconWith)(tombstone, this.custody);
507
+ const sealed = {
508
+ version,
509
+ signed,
510
+ redacted: signed,
511
+ parts: []
512
+ };
513
+ yield store.putSealed(callRef, sealed);
514
+ this.sealed(callRef, sealed, tombstone, store);
515
+ return {
516
+ version,
517
+ audit: [
518
+ {
519
+ callRef,
520
+ workspaceRef,
521
+ action: "erase",
522
+ part: "all",
523
+ reason: by.reason,
524
+ actor: by.actor,
525
+ at,
526
+ version
527
+ }
528
+ ]
529
+ };
530
+ });
531
+ }
532
+ }
533
+ exports.CallRecordWriter = CallRecordWriter;
534
+ function workspaceOf(entries) {
535
+ for (const e of entries) {
536
+ const w = e.message.workspaceRef;
537
+ if (typeof w === "string" && w)
538
+ return w;
539
+ }
540
+ return null;
541
+ }
542
+ function workspaceOfVcon(vcon) {
543
+ var _a, _b;
544
+ return ((_b = ((_a = vcon.fonoster) !== null && _a !== void 0 ? _a : {}).workspaceRef) !== null && _b !== void 0 ? _b : null);
545
+ }