create-safest-tools 0.4.2 → 0.5.1

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,169 @@
1
+ import PostalMime from "postal-mime";
2
+ import { canonicalJson } from "./audit";
3
+ import { decryptReportContact, secretsEqual, sha256 } from "./report-crypto";
4
+ import { reporterEmailFrom, verifyReporterReplyToken } from "./report-email";
5
+ import type { ReportState } from "./report-types";
6
+
7
+ const MAX_INBOUND_BYTES = 5 * 1024 * 1024;
8
+ const MAX_MESSAGE_LENGTH = 8_000;
9
+
10
+ interface ReplyReportRow {
11
+ id: string;
12
+ state: ReportState;
13
+ participantId: string;
14
+ contactCiphertext: string | null;
15
+ }
16
+
17
+ function now(): string {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function htmlToText(value: string): string {
22
+ return value
23
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/giu, "")
24
+ .replace(/<\s*br\s*\/?>/giu, "\n")
25
+ .replace(/<\/(p|div|li|tr|h[1-6])\s*>/giu, "\n")
26
+ .replace(/<[^>]+>/gu, "")
27
+ .replaceAll("&nbsp;", " ")
28
+ .replaceAll("&amp;", "&")
29
+ .replaceAll("&lt;", "<")
30
+ .replaceAll("&gt;", ">")
31
+ .replaceAll("&quot;", '"')
32
+ .replaceAll("&#39;", "'");
33
+ }
34
+
35
+ export function readableReplyBody(text: string): string {
36
+ const normalized = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
37
+ const kept: string[] = [];
38
+ for (const line of normalized.split("\n")) {
39
+ const trimmed = line.trim();
40
+ if (/^On .+wrote:$/iu.test(trimmed)
41
+ || /^-{2,}\s*(?:Original Message|Forwarded message)\s*-*$/iu.test(trimmed)
42
+ || (/^From:\s+/iu.test(trimmed) && kept.length > 0)) break;
43
+ if (/^>/u.test(trimmed)) continue;
44
+ kept.push(line.replace(/[ \t]+$/u, ""));
45
+ }
46
+ return kept.join("\n").replace(/\n{3,}/gu, "\n\n").trim().slice(0, MAX_MESSAGE_LENGTH);
47
+ }
48
+
49
+ function replyTokenFromRecipient(recipient: string, baseAddress: string): string | null {
50
+ const recipientAt = recipient.lastIndexOf("@");
51
+ const baseAt = baseAddress.lastIndexOf("@");
52
+ if (recipientAt < 1 || baseAt < 1) return null;
53
+ const local = recipient.slice(0, recipientAt).toLowerCase();
54
+ const domain = recipient.slice(recipientAt + 1).toLowerCase();
55
+ const baseLocal = baseAddress.slice(0, baseAt).toLowerCase();
56
+ const baseDomain = baseAddress.slice(baseAt + 1).toLowerCase();
57
+ if (domain !== baseDomain || !local.startsWith(`${baseLocal}+`)) return null;
58
+ return local.slice(baseLocal.length + 1);
59
+ }
60
+
61
+ async function recordUnmatchedEmail(db: D1Database, message: ForwardableEmailMessage, reason: string): Promise<void> {
62
+ const timestamp = now();
63
+ const fingerprint = await sha256(`inbound-email:${reason}:${message.to.toLowerCase()}:${message.from.toLowerCase()}:${message.headers.get("message-id") ?? ""}`);
64
+ await db.prepare(`
65
+ INSERT INTO operations_incidents (
66
+ id, installation_id, incident_key, severity, category, state, title, summary,
67
+ entity_type, entity_id, occurrence_count, first_seen_at, last_seen_at, metadata_json
68
+ ) VALUES (?1, 'default', ?2, 'low', 'inbound_email_unmatched', 'open',
69
+ 'Reporter email could not be matched', ?3, 'inbound_email', ?4, 1, ?5, ?5, ?6)
70
+ ON CONFLICT(installation_id, incident_key) DO UPDATE SET
71
+ state = 'open', occurrence_count = operations_incidents.occurrence_count + 1,
72
+ last_seen_at = excluded.last_seen_at, metadata_json = excluded.metadata_json,
73
+ resolved_by = NULL, resolved_at = NULL, resolution_summary = NULL
74
+ `).bind(
75
+ crypto.randomUUID(), `inbound-email:${fingerprint}`, `An inbound reporter email was not attached to a case (${reason}).`,
76
+ fingerprint.slice(0, 100), timestamp,
77
+ canonicalJson({ reason, recipientDomain: message.to.split("@").at(-1)?.toLowerCase() ?? "", rawSize: message.rawSize }),
78
+ ).run();
79
+ }
80
+
81
+ function nextState(state: ReportState): ReportState {
82
+ if (state === "awaiting_reporter") return "human_review";
83
+ if (["resolved_by_human", "resolved_by_ai", "closed"].includes(state)) return "human_review_requested";
84
+ return state;
85
+ }
86
+
87
+ export async function receiveReporterEmail(
88
+ message: ForwardableEmailMessage,
89
+ env: Env,
90
+ ): Promise<{ reportId: string | null; stored: boolean }> {
91
+ if (message.rawSize > MAX_INBOUND_BYTES) {
92
+ message.setReject("Reply is too large. Please send a text-only reply under 5 MB.");
93
+ return { reportId: null, stored: false };
94
+ }
95
+ const baseAddress = reporterEmailFrom(env);
96
+ const token = replyTokenFromRecipient(message.to, baseAddress);
97
+ const claim = token ? await verifyReporterReplyToken(token, env.CONTEXT_SIGNING_SECRET) : null;
98
+ if (!claim) {
99
+ await recordUnmatchedEmail(env.DB, message, token ? "invalid_or_expired_reply_token" : "reply_token_missing");
100
+ return { reportId: null, stored: false };
101
+ }
102
+ const report = await env.DB.prepare(`
103
+ SELECT r.id, r.state, p.id AS participantId, p.contact_ciphertext AS contactCiphertext
104
+ FROM reports r
105
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
106
+ WHERE r.public_reference = ?1
107
+ LIMIT 1
108
+ `).bind(claim.publicReference).first<ReplyReportRow>();
109
+ if (!report?.contactCiphertext) {
110
+ await recordUnmatchedEmail(env.DB, message, "report_or_reporter_contact_missing");
111
+ return { reportId: null, stored: false };
112
+ }
113
+ const expectedSender = (await decryptReportContact(
114
+ env.CREDENTIAL_ENCRYPTION_KEY, report.contactCiphertext, report.id, "reporter",
115
+ )).trim().toLowerCase();
116
+ if (!await secretsEqual(message.from.trim().toLowerCase(), expectedSender)) {
117
+ await recordUnmatchedEmail(env.DB, message, "sender_mismatch");
118
+ return { reportId: null, stored: false };
119
+ }
120
+ const parsed = await PostalMime.parse(message.raw, {
121
+ maxNestingDepth: 15,
122
+ maxHeadersSize: 256 * 1024,
123
+ maxRfc822NestingDepth: 3,
124
+ });
125
+ const body = readableReplyBody(parsed.text || (parsed.html ? htmlToText(parsed.html) : ""));
126
+ if (!body) {
127
+ await recordUnmatchedEmail(env.DB, message, "empty_reply_body");
128
+ return { reportId: report.id, stored: false };
129
+ }
130
+ const fingerprint = await sha256(canonicalJson({
131
+ messageId: parsed.messageId || message.headers.get("message-id") || "",
132
+ from: message.from.toLowerCase(),
133
+ reportId: report.id,
134
+ body,
135
+ }));
136
+ const idempotencyKey = `inbound-email:${fingerprint}`;
137
+ const existing = await env.DB.prepare(`SELECT id FROM case_messages WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1`)
138
+ .bind(report.id, idempotencyKey).first<{ id: string }>();
139
+ if (existing) return { reportId: report.id, stored: false };
140
+ const receivedAt = now();
141
+ const targetState = nextState(report.state);
142
+ const messageId = crypto.randomUUID();
143
+ const statements: D1PreparedStatement[] = [
144
+ env.DB.prepare(`
145
+ INSERT OR IGNORE INTO case_messages (
146
+ id, report_id, audience, direction, sender_type, sender_id, body,
147
+ automated, delivery_state, created_at, idempotency_key, audience_checked_at
148
+ ) VALUES (?1, ?2, 'reporter', 'inbound', 'reporter', ?3, ?4, 0, 'not_required', ?5, ?6, ?5)
149
+ `).bind(messageId, report.id, report.participantId, body, receivedAt, idempotencyKey),
150
+ env.DB.prepare(`UPDATE reports SET state = ?2, updated_at = ?3 WHERE id = ?1`).bind(report.id, targetState, receivedAt),
151
+ env.DB.prepare(`
152
+ INSERT OR IGNORE INTO audit_events (
153
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
154
+ ) VALUES (?1, ?2, 'report.reporter_reply_received', 'participant', ?3, 'report', ?4, ?5, ?6)
155
+ `).bind(
156
+ crypto.randomUUID(), `audit:${idempotencyKey}`, report.participantId, report.id,
157
+ canonicalJson({ messageId, previousState: report.state, nextState: targetState, source: "email" }), receivedAt,
158
+ ),
159
+ ];
160
+ if (targetState !== report.state) {
161
+ statements.push(env.DB.prepare(`
162
+ INSERT INTO report_state_history (
163
+ id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
164
+ ) VALUES (?1, ?2, ?3, ?4, 'reporter_reply_received', 'participant', ?5, ?6)
165
+ `).bind(crypto.randomUUID(), report.id, report.state, targetState, report.participantId, receivedAt));
166
+ }
167
+ await env.DB.batch(statements);
168
+ return { reportId: report.id, stored: true };
169
+ }
@@ -0,0 +1,53 @@
1
+ import type { WorkspaceBrand } from "./workspace-branding";
2
+
3
+ function escapeHtml(value: string): string {
4
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
5
+ .replaceAll('"', "&quot;").replaceAll("'", "&#039;");
6
+ }
7
+
8
+ function kind(templateVersionId: string | null): "receipt" | "followup" | "outcome" {
9
+ if (templateVersionId?.includes("received")) return "receipt";
10
+ if (templateVersionId?.includes("outcome")) return "outcome";
11
+ return "followup";
12
+ }
13
+
14
+ export function renderReporterEmail(input: {
15
+ brand: WorkspaceBrand;
16
+ body: string;
17
+ reportReference: string;
18
+ templateVersionId: string | null;
19
+ subjectPrefix: string;
20
+ }): { subject: string; text: string; html: string } {
21
+ const messageKind = kind(input.templateVersionId);
22
+ const heading = messageKind === "receipt"
23
+ ? "We received your report"
24
+ : messageKind === "outcome" ? "An outcome is ready" : "A message from the safety team";
25
+ const preview = messageKind === "receipt"
26
+ ? `Your report ${input.reportReference} is now with the safety team.`
27
+ : `There is an update for report ${input.reportReference}.`;
28
+ const subject = messageKind === "receipt"
29
+ ? `${input.brand.organization_name} received your report — ${input.reportReference}`
30
+ : `${input.subjectPrefix} — ${input.reportReference}`;
31
+ const messageHtml = escapeHtml(input.body).replaceAll("\n", "<br />");
32
+ const logo = input.brand.logo_url
33
+ ? `<img src="${escapeHtml(input.brand.logo_url)}" width="40" alt="${escapeHtml(input.brand.logo_alt || input.brand.organization_name)}" style="display:block;max-width:120px;max-height:40px;border:0" />`
34
+ : `<span style="display:inline-block;width:36px;height:36px;line-height:36px;border-radius:10px;background:${input.brand.primary_color};color:${input.brand.primary_contrast_color};font-size:18px;font-weight:700;text-align:center">${escapeHtml(input.brand.organization_name.slice(0, 1).toUpperCase())}</span>`;
35
+ const html = `<!doctype html>
36
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(subject)}</title></head>
37
+ <body style="margin:0;padding:0;background:${input.brand.background_color};color:${input.brand.text_color};font-family:Arial,Helvetica,sans-serif">
38
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0">${escapeHtml(preview)}</div>
39
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:${input.brand.background_color}"><tr><td align="center" style="padding:32px 16px">
40
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background:${input.brand.surface_color};border:1px solid ${input.brand.border_color};border-radius:${Math.min(input.brand.border_radius, 16)}px;overflow:hidden">
41
+ <tr><td style="height:6px;background:${input.brand.primary_color};font-size:0">&nbsp;</td></tr>
42
+ <tr><td style="padding:28px 32px 12px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td>${logo}</td><td align="right" style="color:${input.brand.muted_color};font-size:13px;font-weight:700">${escapeHtml(input.brand.organization_name)}</td></tr></table></td></tr>
43
+ <tr><td style="padding:12px 32px 8px"><h1 style="margin:0;font-size:26px;line-height:1.25;color:${input.brand.text_color};font-family:Georgia,'Times New Roman',serif">${escapeHtml(heading)}</h1></td></tr>
44
+ <tr><td style="padding:12px 32px"><div style="padding:20px;border:1px solid ${input.brand.border_color};border-radius:${Math.min(input.brand.border_radius, 14)}px;background:${input.brand.background_color};font-size:16px;line-height:1.6;color:${input.brand.text_color}">${messageHtml}</div></td></tr>
45
+ <tr><td style="padding:10px 32px 24px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="color:${input.brand.muted_color};font-size:13px">Report reference</td><td align="right" style="color:${input.brand.text_color};font-size:13px;font-weight:700">${escapeHtml(input.reportReference)}</td></tr></table></td></tr>
46
+ <tr><td style="padding:20px 32px;border-top:1px solid ${input.brand.border_color};color:${input.brand.muted_color};font-size:12px;line-height:1.55">Reply directly to this email to contact the safety team. Keep the reply address unchanged so your message reaches the correct case. This message contains only the information needed for your report update.</td></tr>
47
+ </table></td></tr></table></body></html>`;
48
+ return {
49
+ subject,
50
+ html,
51
+ text: `${heading}\n\n${input.body}\n\nReport reference: ${input.reportReference}\n\nReply directly to this email to contact the safety team. Keep the reply address unchanged so your message reaches the correct case.`,
52
+ };
53
+ }
@@ -10,10 +10,13 @@ export type AuthenticationEmailBinding = {
10
10
 
11
11
  export type ReporterEmailBinding = {
12
12
  send(message: {
13
- from: string;
14
- to: string;
13
+ from: string | { email: string; name?: string };
14
+ to: string | { email: string; name?: string };
15
15
  subject: string;
16
16
  text: string;
17
+ html?: string;
18
+ replyTo?: string | { email: string; name?: string };
19
+ headers?: Record<string, string>;
17
20
  }): Promise<{ messageId?: string }>;
18
21
  };
19
22
 
@@ -59,6 +62,72 @@ export function reporterEmailConfigured(env: unknown): boolean {
59
62
  return Boolean(reporterEmailBinding(env) && reporterEmailFrom(env));
60
63
  }
61
64
 
65
+ const replyTokenPattern = /^r1\.([a-z0-9_-]{16})\.([0-9a-f]{8})\.([0-9a-f]{24})$/u;
66
+ const encoder = new TextEncoder();
67
+
68
+ function hex(bytes: Uint8Array): string {
69
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
70
+ }
71
+
72
+ function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean {
73
+ let difference = left.byteLength ^ right.byteLength;
74
+ const length = Math.max(left.byteLength, right.byteLength);
75
+ for (let index = 0; index < length; index += 1) difference |= (left[index] ?? 0) ^ (right[index] ?? 0);
76
+ return difference === 0;
77
+ }
78
+
79
+ async function replySignature(secret: string, payload: string): Promise<Uint8Array> {
80
+ if (encoder.encode(secret).byteLength < 32) throw new Error("Reporter reply signing requires a 32-byte signing secret.");
81
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
82
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(`reporter-email-reply:${payload}`))).slice(0, 12);
83
+ }
84
+
85
+ export async function reporterReplyToken(input: {
86
+ publicReference: string;
87
+ expiresAt: number;
88
+ signingSecret: string;
89
+ }): Promise<string> {
90
+ const reference = input.publicReference.replace(/^RPT-/u, "").toLowerCase();
91
+ if (!/^[a-z0-9_-]{16}$/u.test(reference)) throw new Error("The report reference cannot be used for an email reply token.");
92
+ const expiry = input.expiresAt.toString(16).padStart(8, "0");
93
+ if (!/^[0-9a-f]{8}$/u.test(expiry)) throw new Error("The reporter reply expiry is outside the supported range.");
94
+ const payload = `r1.${reference}.${expiry}`;
95
+ return `${payload}.${hex(await replySignature(input.signingSecret, payload))}`;
96
+ }
97
+
98
+ export async function verifyReporterReplyToken(
99
+ token: string,
100
+ signingSecret: string,
101
+ currentTime = Math.floor(Date.now() / 1_000),
102
+ ): Promise<{ publicReference: string; expiresAt: number } | null> {
103
+ const match = replyTokenPattern.exec(token);
104
+ if (!match) return null;
105
+ const expiry = Number.parseInt(match[2]!, 16);
106
+ const supplied = Uint8Array.from(match[3]!.match(/.{2}/gu) ?? [], (pair) => Number.parseInt(pair, 16));
107
+ if (!expiry || expiry <= currentTime) return null;
108
+ const payload = `r1.${match[1]}.${match[2]}`;
109
+ const expected = await replySignature(signingSecret, payload);
110
+ if (!constantTimeEqual(supplied, expected)) return null;
111
+ return { publicReference: `RPT-${match[1]!.toUpperCase()}`, expiresAt: expiry };
112
+ }
113
+
114
+ export async function reporterReplyAddress(input: {
115
+ fromAddress: string;
116
+ publicReference: string;
117
+ signingSecret: string;
118
+ expiresAt: number;
119
+ }): Promise<string> {
120
+ const separator = input.fromAddress.lastIndexOf("@");
121
+ if (separator < 1) throw new Error("The reporter email address is invalid.");
122
+ const local = input.fromAddress.slice(0, separator);
123
+ const domain = input.fromAddress.slice(separator + 1);
124
+ const token = await reporterReplyToken(input);
125
+ if (local.length + token.length + 1 > 64) {
126
+ throw new Error("The reporter email local part is too long for secure reply addresses.");
127
+ }
128
+ return `${local.toLowerCase()}+${token}@${domain.toLowerCase()}`;
129
+ }
130
+
62
131
  export type ParticipantDelivery = {
63
132
  channel: "email" | "customer_webhook" | null;
64
133
  state: "queued" | "suppressed";
@@ -282,10 +282,6 @@ export class WorkspacePresence extends DurableObject<Env> {
282
282
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value));
283
283
  }
284
284
 
285
- private broadcast(value: unknown): void {
286
- for (const ws of this.ctx.getWebSockets()) this.send(ws, value);
287
- }
288
-
289
285
  private async broadcastSnapshot(): Promise<void> {
290
286
  const snapshots = new Map<string, AnalystPresence[]>();
291
287
  for (const ws of this.ctx.getWebSockets()) {
@@ -327,7 +323,15 @@ export class WorkspacePresence extends DurableObject<Env> {
327
323
  }
328
324
 
329
325
  async publish(event: { type: "claim_changed" | "reports_changed"; reportId?: string }): Promise<void> {
330
- this.broadcast({ type: event.type, report_id: null, sent_at: now() });
326
+ const queue = event.reportId
327
+ ? await this.env.DB.prepare(`SELECT queue_id AS queueId FROM reports WHERE id = ?1 LIMIT 1`)
328
+ .bind(event.reportId).first<{ queueId: string | null }>()
329
+ : null;
330
+ for (const ws of this.ctx.getWebSockets()) {
331
+ const scope = attachment(ws.deserializeAttachment())?.allowedQueueIds;
332
+ const canNameReport = Boolean(event.reportId && (scope === null || queue?.queueId && scope?.includes(queue.queueId)));
333
+ this.send(ws, { type: event.type, report_id: canNameReport ? event.reportId : null, sent_at: now() });
334
+ }
331
335
  }
332
336
 
333
337
  async webSocketMessage(ws: WebSocket, value: string | ArrayBuffer): Promise<void> {
@@ -281,7 +281,7 @@ export async function createReport(
281
281
  const receiptMessageId = crypto.randomUUID();
282
282
  const receiptNoticeId = crypto.randomUUID();
283
283
  const receiptDeliveryId = crypto.randomUUID();
284
- const receiptBody = `We received report ${publicReference}. The safety team will send updates through your configured notification channel.`;
284
+ const receiptBody = `We received your report ${publicReference}. It is now with the safety team. We will email you when there is an update, and you can reply directly to any report email.`;
285
285
  const directEmail = Boolean(submission.reporter.email && options.emailEnabled);
286
286
  const webhookNotification = Boolean(!directEmail && options.notificationEnabled
287
287
  && (submission.reporter.email || submission.reporter.reference));
@@ -1,6 +1,14 @@
1
1
  import { ApiError } from "./report-http";
2
2
 
3
3
  const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u;
4
+ export const HUMAN_DECISION_CODES = [
5
+ "violation_confirmed",
6
+ "no_violation_found",
7
+ "insufficient_information",
8
+ "out_of_scope",
9
+ "duplicate",
10
+ ] as const;
11
+ const humanDecisionCodes = new Set<string>(HUMAN_DECISION_CODES);
4
12
 
5
13
  function known(input: Record<string, unknown>, allowed: readonly string[]): void {
6
14
  const unknown = Object.keys(input).filter((key) => !allowed.includes(key));
@@ -79,7 +87,13 @@ export function parseDecision(body: Record<string, unknown>, header: string | nu
79
87
  : id(body.action_code, "action_code", 100);
80
88
  return {
81
89
  idempotencyKey: idempotency(body, header),
82
- decisionCode: id(body.decision_code, "decision_code", 100),
90
+ decisionCode: (() => {
91
+ const value = id(body.decision_code, "decision_code", 100);
92
+ if (!humanDecisionCodes.has(value)) {
93
+ throw new ApiError(400, "decision_code_not_allowed", "Select one of the supported human decision outcomes.", { field: "decision_code" });
94
+ }
95
+ return value;
96
+ })(),
83
97
  policyCode: id(body.policy_code, "policy_code", 100),
84
98
  rationale: text(body.rationale, "rationale", 8_000),
85
99
  evidenceReferences,