create-safest-tools 0.4.2 → 0.5.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.
@@ -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,