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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/config.mjs +13 -0
- package/template/README.md +2 -2
- package/template/console/reports/ReportDrawer.tsx +43 -3
- package/template/console/reports/controller.ts +36 -5
- package/template/console/shell/controller.ts +8 -3
- package/template/package.json +3 -2
- package/template/public/console/auth-shell.js +4335 -4250
- package/template/public/index.html +10 -2
- package/template/public/styles.css +75 -0
- package/template/reports.schema.json +1 -1
- package/template/scripts/reports-cloudflare-preflight.mjs +49 -0
- package/template/scripts/reports-deploy.mjs +6 -0
- package/template/scripts/reports-plan.mjs +13 -1
- package/template/src/index.ts +16 -0
- package/template/src/report-delivery.ts +27 -5
- package/template/src/report-email-inbound.ts +169 -0
- package/template/src/report-email-template.ts +53 -0
- package/template/src/report-email.ts +71 -2
- package/template/src/report-presence.ts +9 -5
- package/template/src/report-repository.ts +1 -1
- package/template/src/report-workflow-validation.ts +15 -1
|
@@ -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
|
-
|
|
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}.
|
|
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:
|
|
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,
|