create-safest-tools 0.2.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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { canonicalJson } from "./audit";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { OperatorSession } from "./report-types";
|
|
5
|
+
|
|
6
|
+
export type AnalystActivity = "online" | "viewing" | "editing" | "away";
|
|
7
|
+
|
|
8
|
+
export interface AnalystProfile {
|
|
9
|
+
actorId: string;
|
|
10
|
+
displayName: string;
|
|
11
|
+
email: string | null;
|
|
12
|
+
avatarUrl: string | null;
|
|
13
|
+
avatarColor: string;
|
|
14
|
+
roles: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AnalystPresence extends AnalystProfile {
|
|
18
|
+
activity: AnalystActivity;
|
|
19
|
+
activeReportId: string | null;
|
|
20
|
+
connectedAt: string;
|
|
21
|
+
lastSeenAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PresenceAttachment {
|
|
25
|
+
version: 1;
|
|
26
|
+
actorId: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
activity: AnalystActivity;
|
|
29
|
+
activeReportId: string | null;
|
|
30
|
+
allowedQueueIds: string[] | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PresenceRow {
|
|
34
|
+
actor_id: string;
|
|
35
|
+
display_name: string;
|
|
36
|
+
email: string | null;
|
|
37
|
+
avatar_url: string | null;
|
|
38
|
+
avatar_color: string;
|
|
39
|
+
roles_json: string;
|
|
40
|
+
activity_state: AnalystActivity;
|
|
41
|
+
active_report_id: string | null;
|
|
42
|
+
connected_at: string;
|
|
43
|
+
last_seen_at: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const colors = ["#315d4d", "#5d4f82", "#8a5a3b", "#2f657a", "#7a4b57", "#59683d", "#6b5635", "#3f5f88"];
|
|
47
|
+
const activities = new Set<AnalystActivity>(["online", "viewing", "editing", "away"]);
|
|
48
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
|
49
|
+
|
|
50
|
+
function validActorId(value: string): boolean {
|
|
51
|
+
return value.length > 0 && value.length <= 200 && !/[\u0000-\u001f\u007f]/u.test(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function now(): string {
|
|
55
|
+
return new Date().toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function subtractSeconds(timestamp: string, seconds: number): string {
|
|
59
|
+
return new Date(Date.parse(timestamp) - seconds * 1_000).toISOString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function addMinutes(timestamp: string, minutes: number): string {
|
|
63
|
+
return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function analystAvatarColor(actorId: string): string {
|
|
67
|
+
let hash = 2_166_136_261;
|
|
68
|
+
for (const character of actorId) {
|
|
69
|
+
hash ^= character.codePointAt(0) ?? 0;
|
|
70
|
+
hash = Math.imul(hash, 16_777_619);
|
|
71
|
+
}
|
|
72
|
+
return colors[(hash >>> 0) % colors.length] ?? colors[0]!;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseRoles(value: string): string[] {
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = JSON.parse(value);
|
|
78
|
+
return Array.isArray(parsed) ? parsed.filter((role): role is string => typeof role === "string").slice(0, 20) : [];
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function profileFromRow(row: PresenceRow): AnalystProfile {
|
|
85
|
+
return {
|
|
86
|
+
actorId: row.actor_id,
|
|
87
|
+
displayName: row.display_name,
|
|
88
|
+
email: row.email,
|
|
89
|
+
avatarUrl: row.avatar_url,
|
|
90
|
+
avatarColor: row.avatar_color,
|
|
91
|
+
roles: parseRoles(row.roles_json),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function recordAnalystAuthentication(db: D1Database, session: OperatorSession): Promise<AnalystProfile> {
|
|
96
|
+
const authenticatedAt = now();
|
|
97
|
+
const avatarColor = analystAvatarColor(session.actor.id);
|
|
98
|
+
const rolesJson = canonicalJson(session.roles);
|
|
99
|
+
await db.prepare(`
|
|
100
|
+
INSERT INTO analyst_profiles (
|
|
101
|
+
actor_id, display_name, email, avatar_url, avatar_color, roles_json,
|
|
102
|
+
created_at, updated_at, last_authenticated_at
|
|
103
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7, ?7)
|
|
104
|
+
ON CONFLICT(actor_id) DO UPDATE SET
|
|
105
|
+
display_name = excluded.display_name,
|
|
106
|
+
email = excluded.email,
|
|
107
|
+
avatar_url = excluded.avatar_url,
|
|
108
|
+
avatar_color = excluded.avatar_color,
|
|
109
|
+
roles_json = excluded.roles_json,
|
|
110
|
+
updated_at = excluded.updated_at,
|
|
111
|
+
last_authenticated_at = excluded.last_authenticated_at
|
|
112
|
+
WHERE analyst_profiles.display_name != excluded.display_name
|
|
113
|
+
OR COALESCE(analyst_profiles.email, '') != COALESCE(excluded.email, '')
|
|
114
|
+
OR COALESCE(analyst_profiles.avatar_url, '') != COALESCE(excluded.avatar_url, '')
|
|
115
|
+
OR analyst_profiles.roles_json != excluded.roles_json
|
|
116
|
+
OR analyst_profiles.last_authenticated_at <= ?8
|
|
117
|
+
`).bind(
|
|
118
|
+
session.actor.id, session.displayName, session.email ?? null, session.avatarUrl ?? null,
|
|
119
|
+
avatarColor, rolesJson, authenticatedAt, subtractSeconds(authenticatedAt, 3_600),
|
|
120
|
+
).run();
|
|
121
|
+
return {
|
|
122
|
+
actorId: session.actor.id,
|
|
123
|
+
displayName: session.displayName,
|
|
124
|
+
email: session.email ?? null,
|
|
125
|
+
avatarUrl: session.avatarUrl ?? null,
|
|
126
|
+
avatarColor,
|
|
127
|
+
roles: session.roles,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function listAnalystPresence(
|
|
132
|
+
db: D1Database,
|
|
133
|
+
staleAfterSeconds = 45,
|
|
134
|
+
allowedQueueIds?: string[] | null,
|
|
135
|
+
): Promise<AnalystPresence[]> {
|
|
136
|
+
const cutoff = subtractSeconds(now(), Math.max(15, Math.min(300, staleAfterSeconds)));
|
|
137
|
+
const rows = await db.prepare(`
|
|
138
|
+
WITH ranked_sessions AS (
|
|
139
|
+
SELECT s.*,
|
|
140
|
+
ROW_NUMBER() OVER (
|
|
141
|
+
PARTITION BY s.actor_id
|
|
142
|
+
ORDER BY CASE s.activity_state WHEN 'editing' THEN 0 WHEN 'viewing' THEN 1 WHEN 'online' THEN 2 ELSE 3 END,
|
|
143
|
+
s.last_seen_at DESC, s.id DESC
|
|
144
|
+
) AS rank
|
|
145
|
+
FROM analyst_sessions s
|
|
146
|
+
WHERE s.last_seen_at > ?1
|
|
147
|
+
)
|
|
148
|
+
SELECT p.actor_id, p.display_name, p.email, p.avatar_url, p.avatar_color, p.roles_json,
|
|
149
|
+
s.activity_state,
|
|
150
|
+
CASE WHEN ?2 = 1 OR s.active_report_id IS NULL OR EXISTS (
|
|
151
|
+
SELECT 1 FROM reports r
|
|
152
|
+
WHERE r.id = s.active_report_id AND r.queue_id IN (SELECT value FROM json_each(?3))
|
|
153
|
+
) THEN s.active_report_id ELSE NULL END AS active_report_id,
|
|
154
|
+
s.connected_at, s.last_seen_at
|
|
155
|
+
FROM ranked_sessions s
|
|
156
|
+
JOIN analyst_profiles p ON p.actor_id = s.actor_id
|
|
157
|
+
WHERE s.rank = 1
|
|
158
|
+
ORDER BY CASE s.activity_state WHEN 'editing' THEN 0 WHEN 'viewing' THEN 1 WHEN 'online' THEN 2 ELSE 3 END,
|
|
159
|
+
p.display_name, p.actor_id
|
|
160
|
+
`).bind(
|
|
161
|
+
cutoff,
|
|
162
|
+
allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
|
|
163
|
+
canonicalJson(allowedQueueIds ?? []),
|
|
164
|
+
).all<PresenceRow>();
|
|
165
|
+
return rows.results.map((row) => ({
|
|
166
|
+
...profileFromRow(row),
|
|
167
|
+
activity: row.activity_state,
|
|
168
|
+
activeReportId: row.active_report_id,
|
|
169
|
+
connectedAt: row.connected_at,
|
|
170
|
+
lastSeenAt: row.last_seen_at,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function touchAnalystSession(
|
|
175
|
+
db: D1Database,
|
|
176
|
+
input: {
|
|
177
|
+
sessionId: string;
|
|
178
|
+
actorId: string;
|
|
179
|
+
activity: AnalystActivity;
|
|
180
|
+
activeReportId: string | null;
|
|
181
|
+
allowedQueueIds?: string[] | null;
|
|
182
|
+
claimTtlMinutes: number;
|
|
183
|
+
},
|
|
184
|
+
): Promise<{ claimRenewed: boolean; activeReportId: string | null }> {
|
|
185
|
+
const seenAt = now();
|
|
186
|
+
let activeReportId = input.activeReportId;
|
|
187
|
+
if (activeReportId && input.allowedQueueIds !== null && input.allowedQueueIds !== undefined) {
|
|
188
|
+
const accessible = input.allowedQueueIds.length
|
|
189
|
+
? await db.prepare(`
|
|
190
|
+
SELECT id FROM reports
|
|
191
|
+
WHERE id = ?1 AND queue_id IN (SELECT value FROM json_each(?2)) LIMIT 1
|
|
192
|
+
`).bind(activeReportId, canonicalJson(input.allowedQueueIds)).first()
|
|
193
|
+
: null;
|
|
194
|
+
if (!accessible) activeReportId = null;
|
|
195
|
+
}
|
|
196
|
+
const claimTtl = Math.max(1, Math.min(1_440, input.claimTtlMinutes));
|
|
197
|
+
const renewBefore = addMinutes(seenAt, Math.max(1, Math.floor(claimTtl / 2)));
|
|
198
|
+
const expiresAt = addMinutes(seenAt, claimTtl);
|
|
199
|
+
const results = await db.batch([
|
|
200
|
+
db.prepare(`
|
|
201
|
+
INSERT INTO analyst_sessions (
|
|
202
|
+
id, actor_id, activity_state, active_report_id, connected_at, last_seen_at
|
|
203
|
+
) VALUES (
|
|
204
|
+
?1, ?2, ?3,
|
|
205
|
+
CASE WHEN ?4 IS NOT NULL AND EXISTS (SELECT 1 FROM reports WHERE id = ?4) THEN ?4 ELSE NULL END,
|
|
206
|
+
?5, ?5
|
|
207
|
+
)
|
|
208
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
209
|
+
activity_state = excluded.activity_state,
|
|
210
|
+
active_report_id = excluded.active_report_id,
|
|
211
|
+
last_seen_at = excluded.last_seen_at
|
|
212
|
+
WHERE analyst_sessions.actor_id = excluded.actor_id
|
|
213
|
+
`).bind(input.sessionId, input.actorId, input.activity, activeReportId, seenAt),
|
|
214
|
+
db.prepare(`
|
|
215
|
+
UPDATE review_claims
|
|
216
|
+
SET expires_at = ?3
|
|
217
|
+
WHERE report_id = ?1 AND reviewer_id = ?2 AND expires_at > ?4 AND expires_at < ?5
|
|
218
|
+
AND ?6 = 'editing'
|
|
219
|
+
`).bind(activeReportId, input.actorId, expiresAt, seenAt, renewBefore, input.activity),
|
|
220
|
+
]);
|
|
221
|
+
return { claimRenewed: (results[1]?.meta.changes ?? 0) > 0, activeReportId };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function removeAnalystSession(db: D1Database, sessionId: string, actorId: string): Promise<void> {
|
|
225
|
+
await db.prepare(`DELETE FROM analyst_sessions WHERE id = ?1 AND actor_id = ?2`).bind(sessionId, actorId).run();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function pruneStaleAnalystSessions(db: D1Database, staleAfterSeconds = 300): Promise<number> {
|
|
229
|
+
const result = await db.prepare(`DELETE FROM analyst_sessions WHERE last_seen_at <= ?1`)
|
|
230
|
+
.bind(subtractSeconds(now(), Math.max(60, Math.min(3_600, staleAfterSeconds)))).run();
|
|
231
|
+
return result.meta.changes ?? 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function attachment(value: unknown): PresenceAttachment | null {
|
|
235
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
236
|
+
const candidate = value as Partial<PresenceAttachment>;
|
|
237
|
+
if (candidate.version !== 1 || typeof candidate.actorId !== "string" || typeof candidate.sessionId !== "string"
|
|
238
|
+
|| !activities.has(candidate.activity as AnalystActivity)
|
|
239
|
+
|| candidate.activeReportId !== null && typeof candidate.activeReportId !== "string"
|
|
240
|
+
|| candidate.allowedQueueIds !== null && (!Array.isArray(candidate.allowedQueueIds)
|
|
241
|
+
|| candidate.allowedQueueIds.length > 100
|
|
242
|
+
|| candidate.allowedQueueIds.some((queueId) => typeof queueId !== "string" || queueId.length > 100))) return null;
|
|
243
|
+
return candidate as PresenceAttachment;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function queueScope(value: string | null): string[] | null {
|
|
247
|
+
if (!value || value.length > 12_000) return [];
|
|
248
|
+
try {
|
|
249
|
+
const parsed: unknown = JSON.parse(value);
|
|
250
|
+
if (parsed === null) return null;
|
|
251
|
+
if (!Array.isArray(parsed) || parsed.length > 100
|
|
252
|
+
|| parsed.some((queueId) => typeof queueId !== "string" || queueId.length > 100)) return [];
|
|
253
|
+
return parsed;
|
|
254
|
+
} catch {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function websocketMessage(value: string | ArrayBuffer): { type: "heartbeat" | "focus_report" | "blur_report"; reportId: string | null; activity: AnalystActivity } | null {
|
|
260
|
+
if (typeof value !== "string" || value.length > 4_096) return null;
|
|
261
|
+
try {
|
|
262
|
+
const parsed: unknown = JSON.parse(value);
|
|
263
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
264
|
+
const input = parsed as Record<string, unknown>;
|
|
265
|
+
if (input.type === "heartbeat") {
|
|
266
|
+
const activity = activities.has(input.activity as AnalystActivity) ? input.activity as AnalystActivity : "online";
|
|
267
|
+
const reportId = typeof input.report_id === "string" && input.report_id.length <= 100 ? input.report_id : null;
|
|
268
|
+
return { type: "heartbeat", reportId, activity };
|
|
269
|
+
}
|
|
270
|
+
if (input.type === "focus_report" && typeof input.report_id === "string" && input.report_id.length <= 100) {
|
|
271
|
+
return { type: "focus_report", reportId: input.report_id, activity: input.editing === true ? "editing" : "viewing" };
|
|
272
|
+
}
|
|
273
|
+
if (input.type === "blur_report") return { type: "blur_report", reportId: null, activity: "online" };
|
|
274
|
+
return null;
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export class WorkspacePresence extends DurableObject<Env> {
|
|
281
|
+
private send(ws: WebSocket, value: unknown): void {
|
|
282
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(value));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private broadcast(value: unknown): void {
|
|
286
|
+
for (const ws of this.ctx.getWebSockets()) this.send(ws, value);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async broadcastSnapshot(): Promise<void> {
|
|
290
|
+
const snapshots = new Map<string, AnalystPresence[]>();
|
|
291
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
292
|
+
const scope = attachment(ws.deserializeAttachment())?.allowedQueueIds ?? [];
|
|
293
|
+
const key = canonicalJson(scope);
|
|
294
|
+
let analysts = snapshots.get(key);
|
|
295
|
+
if (!analysts) {
|
|
296
|
+
analysts = await listAnalystPresence(this.env.DB, 45, scope);
|
|
297
|
+
snapshots.set(key, analysts);
|
|
298
|
+
}
|
|
299
|
+
this.send(ws, { type: "presence_snapshot", analysts, sent_at: now() });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async fetch(request: Request): Promise<Response> {
|
|
304
|
+
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
305
|
+
return new Response("WebSocket upgrade required", { status: 426, headers: { upgrade: "websocket" } });
|
|
306
|
+
}
|
|
307
|
+
const actorId = request.headers.get("x-safest-actor-id") ?? "";
|
|
308
|
+
const sessionId = request.headers.get("x-safest-session-id") ?? "";
|
|
309
|
+
const allowedQueueIds = queueScope(request.headers.get("x-safest-queue-scope"));
|
|
310
|
+
if (!validActorId(actorId) || !uuid.test(sessionId)) {
|
|
311
|
+
return new Response("Invalid presence session", { status: 400 });
|
|
312
|
+
}
|
|
313
|
+
await touchAnalystSession(this.env.DB, {
|
|
314
|
+
sessionId, actorId, activity: "online", activeReportId: null,
|
|
315
|
+
allowedQueueIds,
|
|
316
|
+
claimTtlMinutes: Number.parseInt(this.env.CLAIM_TTL_MINUTES, 10) || 15,
|
|
317
|
+
});
|
|
318
|
+
const pair = new WebSocketPair();
|
|
319
|
+
const [client, server] = Object.values(pair);
|
|
320
|
+
const state: PresenceAttachment = {
|
|
321
|
+
version: 1, actorId, sessionId, activity: "online", activeReportId: null, allowedQueueIds,
|
|
322
|
+
};
|
|
323
|
+
server.serializeAttachment(state);
|
|
324
|
+
this.ctx.acceptWebSocket(server);
|
|
325
|
+
await this.broadcastSnapshot();
|
|
326
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async publish(event: { type: "claim_changed" | "reports_changed"; reportId?: string }): Promise<void> {
|
|
330
|
+
this.broadcast({ type: event.type, report_id: null, sent_at: now() });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async webSocketMessage(ws: WebSocket, value: string | ArrayBuffer): Promise<void> {
|
|
334
|
+
const current = attachment(ws.deserializeAttachment());
|
|
335
|
+
const message = websocketMessage(value);
|
|
336
|
+
if (!current || !message) {
|
|
337
|
+
this.send(ws, { type: "error", code: "invalid_presence_message" });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const next: PresenceAttachment = {
|
|
341
|
+
...current,
|
|
342
|
+
activity: message.activity,
|
|
343
|
+
activeReportId: message.reportId,
|
|
344
|
+
};
|
|
345
|
+
const result = await touchAnalystSession(this.env.DB, {
|
|
346
|
+
sessionId: next.sessionId,
|
|
347
|
+
actorId: next.actorId,
|
|
348
|
+
activity: next.activity,
|
|
349
|
+
activeReportId: next.activeReportId,
|
|
350
|
+
allowedQueueIds: next.allowedQueueIds,
|
|
351
|
+
claimTtlMinutes: Number.parseInt(this.env.CLAIM_TTL_MINUTES, 10) || 15,
|
|
352
|
+
});
|
|
353
|
+
next.activeReportId = result.activeReportId;
|
|
354
|
+
ws.serializeAttachment(next);
|
|
355
|
+
this.send(ws, { type: "heartbeat_ack", sent_at: now(), claim_renewed: result.claimRenewed });
|
|
356
|
+
if (message.type !== "heartbeat" || result.claimRenewed) await this.broadcastSnapshot();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
360
|
+
const current = attachment(ws.deserializeAttachment());
|
|
361
|
+
if (!current) return;
|
|
362
|
+
await removeAnalystSession(this.env.DB, current.sessionId, current.actorId);
|
|
363
|
+
await this.broadcastSnapshot();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async webSocketError(ws: WebSocket): Promise<void> {
|
|
367
|
+
const current = attachment(ws.deserializeAttachment());
|
|
368
|
+
if (!current) return;
|
|
369
|
+
await removeAnalystSession(this.env.DB, current.sessionId, current.actorId);
|
|
370
|
+
await this.broadcastSnapshot();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function parsePresenceHeartbeat(value: Record<string, unknown>): {
|
|
375
|
+
sessionId: string;
|
|
376
|
+
activity: AnalystActivity;
|
|
377
|
+
activeReportId: string | null;
|
|
378
|
+
} {
|
|
379
|
+
const allowed = new Set(["session_id", "activity", "active_report_id"]);
|
|
380
|
+
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
|
381
|
+
throw new ApiError(400, "invalid_presence_heartbeat", "The presence heartbeat contains unsupported fields.");
|
|
382
|
+
}
|
|
383
|
+
if (typeof value.session_id !== "string" || !uuid.test(value.session_id)) {
|
|
384
|
+
throw new ApiError(400, "invalid_presence_heartbeat", "session_id must be a UUID.");
|
|
385
|
+
}
|
|
386
|
+
if (typeof value.activity !== "string" || !activities.has(value.activity as AnalystActivity)) {
|
|
387
|
+
throw new ApiError(400, "invalid_presence_heartbeat", "activity is invalid.");
|
|
388
|
+
}
|
|
389
|
+
if (value.active_report_id !== null && value.active_report_id !== undefined
|
|
390
|
+
&& (typeof value.active_report_id !== "string" || value.active_report_id.length > 100)) {
|
|
391
|
+
throw new ApiError(400, "invalid_presence_heartbeat", "active_report_id is invalid.");
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
sessionId: value.session_id,
|
|
395
|
+
activity: value.activity as AnalystActivity,
|
|
396
|
+
activeReportId: typeof value.active_report_id === "string" ? value.active_report_id : null,
|
|
397
|
+
};
|
|
398
|
+
}
|