create-safest-tools 0.6.0 → 0.7.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.
Files changed (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. package/template/worker-configuration.d.ts +3 -2
@@ -0,0 +1 @@
1
+ {"release":"a5f252eca115ed4eac3c"}
@@ -0,0 +1,2 @@
1
+ .appeal-dialog>.detail-head{background:var(--paper,#fff)}.appeal-correction-card{border:1px solid var(--line,#d8dfdc);border-radius:12px;min-width:0;margin-block:16px;padding:16px}.appeal-correction-form{gap:16px;min-width:0;display:grid}.appeal-correction-form label:not(.appeal-correction-approval){gap:6px;font-size:.875rem;display:grid}.appeal-correction-form input:not([type=checkbox]),.appeal-correction-form select,.appeal-correction-form textarea{box-sizing:border-box;width:100%;min-width:0;max-width:100%;min-height:44px;font-size:1rem}.appeal-correction-form textarea{min-height:96px}.appeal-correction-approval{align-items:flex-start;gap:10px;font-weight:400;display:flex}.appeal-correction-approval input{flex:0 0 20px;width:20px;height:20px;margin-top:2px}.appeal-correction-card dd,.appeal-operation-reference{overflow-wrap:anywhere}.appeal-operation-reference{font-size:.875rem}.appeal-correction-progress h5{margin-block:16px 8px;font-size:1rem}@media (width<=600px){.appeal-correction-card{padding:12px}.appeal-correction-form button{white-space:normal;width:100%;min-height:44px}}.appeal-correction-card .appeal-target-summary{min-width:0;margin-block:16px}.appeal-correction-card .appeal-target-fields{margin:0;display:block}.appeal-correction-card .appeal-target-fields>div{min-width:0;margin-block:0 12px;display:block}.appeal-correction-card .appeal-target-fields dt{float:none;color:var(--muted,#5c6862);margin:0 0 4px;font-size:.8125rem;display:block}.appeal-correction-card .appeal-target-fields dd{overflow-wrap:anywhere;word-break:normal;min-width:0;max-width:100%;margin:0;font-size:.9375rem;line-height:1.5;display:block}.appeal-target-link{flex-wrap:wrap;align-items:baseline;gap:4px 10px;min-width:0;margin-block:12px;display:flex}.appeal-target-link a{align-items:center;min-height:44px;display:inline-flex}.appeal-target-link>span{color:var(--muted,#5c6862);overflow-wrap:anywhere;min-width:0;font-size:.8125rem}.appeal-target-facts summary,.appeal-operation-details summary{box-sizing:border-box;cursor:pointer;min-height:44px;margin-inline-start:1.1em;padding-block:10px;display:list-item}.appeal-target-facts[open] summary{margin-bottom:8px}.appeal-correction-action{margin-top:20px;font-size:1rem}.appeal-operation-details{margin-block:8px;font-size:.875rem}.appeal-operation-details p{margin-block:4px 12px}.case-escalations{min-width:0;margin-block:1rem;padding:1.25rem}.case-escalations .surface-head{flex-wrap:wrap;gap:1rem;margin-bottom:1rem;padding:0 0 1rem}.case-escalations p{color:var(--muted,#5c6862);line-height:1.5}.case-escalation-list{gap:1rem;display:grid}.case-escalation-item{border:1px solid var(--line,#d8dfda);overflow-wrap:anywhere;border-radius:.75rem;min-width:0;padding:1rem}.case-escalation-item-head{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.case-escalation-item h3{margin:0;font-size:1rem}.case-escalation-item-head p{margin:.3rem 0 0;font-size:.875rem}.case-escalation-item dl{grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;display:grid}.case-escalation-item dt{color:var(--muted,#5c6862);font-size:.8rem}.case-escalation-item dd{margin:.2rem 0 0;font-size:.9rem}.case-escalation-buttons{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.case-escalation-buttons button{white-space:normal;min-height:44px}.case-escalation-warning{border-inline-start:3px solid #af7820;padding-inline-start:.75rem}.case-escalation-settings{border:1px solid var(--line,#d8dfda);border-radius:.75rem;margin-block:1rem;padding:1rem}.case-escalation-settings label{gap:.35rem;min-width:0;font-size:.875rem;display:grid}.case-escalation-setting-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;margin-block:.75rem;display:grid}.case-escalation-settings select,.case-escalation-settings input,.case-escalation-settings textarea{width:100%;min-width:0;min-height:44px;font-size:1rem}.case-escalation-status{background:var(--brand-soft,#e8f1e9);border-radius:.5rem;padding:.75rem}@media (width<=560px){.case-escalations{padding:1rem}.case-escalation-setting-grid{grid-template-columns:minmax(0,1fr)}.case-escalation-buttons{align-items:stretch}.case-escalation-buttons button{flex:auto}}.integration-verification{min-width:0;padding:24px}.integration-verification>.surface-head{margin-bottom:20px;padding:0 0 20px}.verification-start{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;display:grid}.verification-start label,.verification-bind label,.verification-history{gap:8px;min-width:0;font-size:.875rem;font-weight:600;display:grid}.verification-start select,.verification-bind select,.verification-history select{width:100%;min-width:0;min-height:44px;font-size:1rem}.verification-start .form-help{grid-column:1/-1;margin:0}.verification-start button{justify-self:start}.verification-history,.verification-run{margin-top:24px}.verification-run{border-top:1px solid var(--border,#d8e0da);padding-top:20px}.verification-summary h3,.verification-summary p{overflow-wrap:anywhere;margin:0 0 8px}.verification-summary small{margin-bottom:16px;display:block}.verification-state{border:1px solid;border-radius:6px;margin:4px 0 12px;padding:4px 8px;display:inline-block}.verification-state-verified{color:#235a43}.verification-state-blocked,.verification-state-stale{color:#8a391c}.verification-bind{gap:12px;margin:16px 0;display:grid}.verification-bind p{margin:0}.verification-bind button{justify-self:start}.verification-evidence{gap:12px;margin:20px 0;padding:0;list-style:none;display:grid}.verification-evidence li{border:1px solid var(--border,#d8e0da);border-radius:8px;padding:14px}.verification-evidence li>div{flex-wrap:wrap;justify-content:space-between;gap:8px 16px;display:flex}.verification-evidence li span{font-size:.875em}.verification-evidence li p{overflow-wrap:anywhere;margin:8px 0 0}.verification-evidence li[data-state=complete]{border-left:4px solid #377457}.verification-evidence li[data-state=blocked]{border-left:4px solid #a64a25}@media (width<=600px){.integration-verification{padding:16px}.integration-verification>.surface-head{flex-direction:column;align-items:stretch;gap:16px}.verification-start{grid-template-columns:minmax(0,1fr)}.verification-start button,.verification-bind button{width:100%}.verification-evidence li{padding:12px}}
2
+ /*$vite$:1*/
@@ -137,3 +137,6 @@ body.compact .form-actions button { flex: 1; }
137
137
  .reference-card strong { width: calc(100% - 80px); }
138
138
  }
139
139
  @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } }
140
+
141
+ .participant-case-link { display: inline-flex; min-height: 44px; align-items: center; padding: 8px 16px; border: 1px solid currentColor; border-radius: 10px; color: inherit; font-weight: 700; text-underline-offset: 3px; }
142
+ .participant-case-link:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; }
@@ -364,6 +364,11 @@ async function submitReport() {
364
364
  elements.receipt_copy.textContent = state.mode === "anonymous"
365
365
  ? `We’ll send updates to ${elements.reporter_email.value.trim().toLowerCase()}. You can close this window now.`
366
366
  : "You can close this window now. Updates will be sent through the application’s notification channel.";
367
+ try {
368
+ const url = new URL(result.participant_url);
369
+ if (url.origin === location.origin && url.pathname === "/participant" && !url.search && /^#access=[a-f0-9]{64}$/.test(url.hash)) elements.participant_case.href = url.href;
370
+ } catch { /* A replay can recover access through the configured participant channel. */ }
371
+ if (!result.participant_url) elements.participant_case.textContent = "Get a private case link";
367
372
  elements.success_view.focus();
368
373
  post({ type: "safest-resolve:submitted", reference: result.reference || "", mode: state.mode });
369
374
  } catch (error) {
@@ -404,5 +409,18 @@ globalThis.addEventListener("message", (event) => {
404
409
  if (message.type === "safest-resolve:context-error") showFormError(typeof message.message === "string" ? message.message : "Secure reporting context was unavailable.");
405
410
  });
406
411
 
407
- new ResizeObserver(() => post({ type: "safest-resolve:resize", height: document.documentElement.scrollHeight })).observe(elements.report_card);
412
+ // Measure outside ResizeObserver delivery: the parent updates this frame's
413
+ // height in response, and WebKit can otherwise report a resize feedback loop.
414
+ let resizeFrame = 0;
415
+ let reportedHeight = -1;
416
+ new ResizeObserver(() => {
417
+ if (resizeFrame) return;
418
+ resizeFrame = requestAnimationFrame(() => {
419
+ resizeFrame = 0;
420
+ const height = document.documentElement.scrollHeight;
421
+ if (height === reportedHeight) return;
422
+ reportedHeight = height;
423
+ post({ type: "safest-resolve:resize", height });
424
+ });
425
+ }).observe(elements.report_card);
408
426
  void loadConfig();
@@ -84,6 +84,8 @@
84
84
  <p id="receipt-copy">You can close this window now. Updates will be sent through the application’s notification channel.</p>
85
85
  <div class="reference-card"><span>Your reference</span><strong id="receipt-reference"></strong><button id="copy-reference" type="button">Copy</button></div>
86
86
  <p class="notification-reminder"><span aria-hidden="true">●</span> Keep the reference in case the safety team asks for it.</p>
87
+ <p><a id="participant-case" class="participant-case-link" href="/participant" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Open your case</a></p>
88
+ <p class="notification-reminder">Your private case link lets you send more information or request an independent review. Keep it private.</p>
87
89
  <button id="finish-report" class="primary finish-button" type="button">Done</button>
88
90
  </section>
89
91
  </main>
@@ -1,6 +1,7 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
3
  <head>
4
+ <meta name="safest-release" content="a5f252eca115ed4eac3c" />
4
5
  <meta charset="UTF-8" />
5
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
7
  <meta name="description" content="Review reports, record decisions, send updates, and follow application actions and appeals on Cloudflare." />
@@ -9,7 +10,9 @@
9
10
  <title>Safest Resolve — Reports and workflows</title>
10
11
  <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
11
12
  <link rel="manifest" href="./manifest.webmanifest" />
12
- <link rel="stylesheet" href="./styles.css" />
13
+ <link rel="stylesheet" href="./styles.css?v=b18e3c241c3d5c5b9f42" />
14
+ <link rel="stylesheet" href="./workspace.css?v=2b40869062cdbab843d4" />
15
+ <link rel="stylesheet" href="./console/workspace-features.css?v=a8eb659dcde4ef39f5a9" />
13
16
  </head>
14
17
  <body class="auth-resolving">
15
18
  <svg class="svg-defs" aria-hidden="true">
@@ -73,7 +76,7 @@
73
76
 
74
77
  <div class="content">
75
78
  <section id="command" class="command-view" data-view="command">
76
- <div id="command-centre-root"><div class="skeleton-block" role="status" aria-label="Loading Command centre"></div></div>
79
+ <div id="command-centre-root"><div class="skeleton-block" role="status" aria-label="Loading Today"></div></div>
77
80
  </section>
78
81
 
79
82
  <section id="reports" class="queue-view" data-view="reports">
@@ -96,7 +99,7 @@
96
99
 
97
100
  <section id="profile" class="view-stack" data-view="profile" hidden><div id="profile-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading profile"></div></div></section>
98
101
 
99
- <section id="analytics" class="view-stack" data-view="analytics" hidden><div id="analytics-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading reports and outcomes"></div></div></section>
102
+ <section id="analytics" class="view-stack" data-view="analytics" hidden><div id="analytics-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading Insights"></div></div></section>
100
103
 
101
104
  <section id="appeals" class="view-stack" data-view="appeals" hidden><div id="appeal-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading appeals"></div></div></section>
102
105
 
@@ -124,8 +127,8 @@
124
127
  <div id="quality-dialog-root"></div>
125
128
 
126
129
  <div id="operations-dialog-root"></div>
127
- <script src="./customer-brand.js"></script>
128
- <script src="./widget.js"></script>
129
- <script src="./console/auth-shell.js" type="module"></script>
130
+ <script src="./customer-brand.js?v=fd02f0d24c8c8fd84cb7"></script>
131
+ <script src="./widget.js?v=7c87ca444a26368f65ec"></script>
132
+ <script src="./console/auth-shell.js?v=112db577467f923be438" type="module"></script>
130
133
  </body>
131
134
  </html>
@@ -0,0 +1,2 @@
1
+ :root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17251f;background:#f5f7f5;font-size:16px;line-height:1.55;color-scheme:light}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0}.site-header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px max(20px,env(safe-area-inset-left));border-bottom:1px solid #d6dfd8;background:white}.brand{font-weight:700}main{max-width:760px;margin:28px auto;padding:0 20px}section{background:#fff;border:1px solid #d6dfd8;border-radius:16px;padding:28px;margin-bottom:20px}h1{font-size:clamp(1.5rem,5vw,2rem);line-height:1.2;margin:8px 0 16px}h2{font-size:1.25rem;line-height:1.3;margin:0 0 12px}p{margin:12px 0}.eyebrow,.muted,time{color:#526259}.eyebrow{font-size:.85rem;overflow-wrap:anywhere}label{display:block;margin:18px 0 6px;font-weight:600}input,textarea,button{font:inherit}input,textarea{display:block;width:100%;border:1px solid #899a8e;background:white;color:#17251f;border-radius:8px;padding:12px}textarea{resize:vertical;min-height:140px}button{border:1px solid #174e38;background:#174e38;color:white;border-radius:8px;padding:12px 18px;min-height:44px;cursor:pointer;font-weight:650}form button{margin-top:16px}.secondary{background:white;color:#174e38}button:disabled{opacity:.6;cursor:wait}:focus-visible{outline:3px solid #357cd0;outline-offset:3px}.status{display:inline-block;border-radius:20px;background:#e5efe8;color:#174e38;padding:5px 12px;font-weight:600}.message,.appeal{border-left:3px solid #bccdc1;padding:4px 16px;margin:20px 0}.message.inbound{border-left-color:#327757}.message p,.appeal p{white-space:pre-wrap;overflow-wrap:anywhere}.message strong{display:block}time{font-size:.85rem}.notice,#feedback{border-radius:8px;background:#e9f0ec;padding:14px 16px}#feedback:empty{display:none}#feedback.error{background:#fff0e7;color:#792b10}footer{max-width:760px;margin:24px auto;padding:0 20px 32px;color:#526259;font-size:.875rem}@media(max-width:540px){.site-header{padding:14px 16px;align-items:flex-start}.site-header button{font-size:.8rem;padding:8px 10px;max-width:150px}main{margin-top:18px;padding:0 12px}section{padding:20px 16px;border-radius:12px}form button{width:100%}}
2
+ .section-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.section-actions h2{margin:0}.section-actions button{font-size:.9rem}#older-messages{margin-bottom:8px}
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="referrer" content="no-referrer"><title>Your safety case</title><link rel="stylesheet" href="/participant.css"><script src="/participant.js" defer></script></head>
3
+ <body><header class="site-header"><span class="brand">Your safety case</span><button id="sign-out" class="secondary" hidden>Close private session</button></header>
4
+ <main><p id="feedback" role="status" aria-live="polite">Opening your private case…</p>
5
+ <section id="access" hidden aria-labelledby="access-title"><h1 id="access-title">Return to your case</h1><p>Your case link is private. A report reference by itself cannot open a case.</p>
6
+ <h2>Request a new private link</h2><p>Use the reference and email you originally gave the safety team.</p>
7
+ <form id="recovery-form"><label for="reference">Report reference</label><input id="reference" name="reference" required maxlength="100" autocomplete="off" placeholder="RPT-…"><label for="email">Email on the case</label><input id="email" name="email" type="email" required maxlength="254" autocomplete="email"><button type="submit">Send a new link</button></form>
8
+ <p class="muted">For a report linked to your account with the service, return through that signed-in account to request a fresh case link. If you reported anonymously without an email, we cannot verify ownership using your reference alone. Use a still-open private session or contact the service where you reported the issue. Do not post your private link publicly.</p></section>
9
+ <div id="case" hidden><section aria-labelledby="case-title"><p class="eyebrow" id="case-reference"></p><h1 id="case-title">Your conversation with the safety team</h1><p><span id="case-status" class="status"></span></p><p id="audience-description" class="muted"></p><p id="anonymous-warning" class="notice" hidden>This browser session lasts up to seven days. There is no email on this case for access recovery. Keep this session open; closing it or clearing your browser data can permanently remove your access.</p></section>
10
+ <section aria-labelledby="messages-title"><div class="section-actions"><h2 id="messages-title">Conversation</h2><button id="refresh-case" class="secondary">Check for updates</button></div><button id="older-messages" class="secondary" hidden>Load older messages</button><div id="messages"></div></section>
11
+ <section aria-labelledby="reply-title"><h2 id="reply-title">Add information or ask a question</h2><p>This goes to the safety team. Asking a question does not submit an appeal.</p><form id="reply-form"><label for="reply">Your message</label><textarea id="reply" name="reply" rows="5" required minlength="3" maxlength="8000"></textarea><p id="reply-pending" class="notice" hidden>We could not confirm whether your message arrived. Retry the same message to check safely.</p><button type="submit">Send to safety team</button></form></section>
12
+ <section id="appeal-section" aria-labelledby="appeal-title"><h2 id="appeal-title">Request an independent appeal</h2><div id="appeals"></div><p id="appeal-unavailable" class="muted"></p><form id="appeal-form" hidden><p>Explain why you disagree with the decision and what should be reconsidered. A different reviewer must review your appeal. You can submit one appeal per decision.</p><label for="appeal-reason">Why are you appealing?</label><textarea id="appeal-reason" name="reason" rows="5" required minlength="3" maxlength="8000"></textarea><p id="appeal-pending" class="notice" hidden>We could not confirm whether your appeal arrived. Retry the same request to check safely.</p><button type="submit">Submit appeal</button></form></section></div>
13
+ </main><footer>Only your conversation is shown here. The safety team keeps other participants’ information private. <a href="/participant#recover">Request a new access link</a></footer></body></html>
@@ -0,0 +1,111 @@
1
+ (() => {
2
+ 'use strict';
3
+ const get = id => document.getElementById(id);
4
+ const token = new URLSearchParams(location.hash.slice(1)).get('access');
5
+ const recovering = location.hash === '#recover';
6
+ const invitationRequested = Boolean(location.hash) && !recovering;
7
+ // Remove the bearer before any application fetch, rendering, or user navigation.
8
+ if (location.hash) history.replaceState(null, '', location.pathname);
9
+ let csrf = ''; let current = null; let viewEpoch = 0;
10
+ const pending = { reply: null, appeal: null };
11
+ const retryAt = { reply: 0, appeal: 0 };
12
+ const messageHistory = new Map();
13
+ let olderCursor = null;
14
+ function feedback(message, error = false) { get('feedback').textContent = message; get('feedback').classList.toggle('error', error); }
15
+ async function api(path, options = {}) {
16
+ const response = await fetch(`/v1/participant/${path}`, { ...options, credentials: 'same-origin', cache: 'no-store', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf, ...options.headers } });
17
+ const data = await response.json();
18
+ if (!response.ok) { const error = new Error(data.error?.message || 'The request could not be completed. Your text is still here.'); error.status = response.status; error.code = data.error?.code; error.retryAfterSeconds = data.error?.details?.retryAfterSeconds; throw error; }
19
+ return data;
20
+ }
21
+ function draftKey(kind) { return `safest-participant:${current?.reference}:${current?.audience}:${kind}`; }
22
+ function saveDraft(kind) { if (!current) return; try { sessionStorage.setItem(draftKey(kind), JSON.stringify({ text: get(kind === 'reply' ? 'reply' : 'appeal-reason').value, pending: pending[kind], retryAt: retryAt[kind] })); } catch {} }
23
+ function restoreDraft(kind) { pending[kind] = null; retryAt[kind] = 0; get(kind === 'reply' ? 'reply' : 'appeal-reason').value = ''; try { const saved = JSON.parse(sessionStorage.getItem(draftKey(kind)) || 'null'); if (saved) { get(kind === 'reply' ? 'reply' : 'appeal-reason').value = saved.text || ''; pending[kind] = saved.pending || null; retryAt[kind] = Number(saved.retryAt) || 0; } } catch {} updatePending(kind); }
24
+ function updatePending(kind) { get(`${kind}-pending`).hidden = !pending[kind]; get(kind === 'reply' ? 'reply' : 'appeal-reason').readOnly = Boolean(pending[kind]); }
25
+ function paragraph(text) { const p = document.createElement('p'); p.textContent = text; return p; }
26
+ function date(value) { return new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); }
27
+ function showAccess(message) {
28
+ viewEpoch += 1;
29
+ current = null; csrf = ''; olderCursor = null; messageHistory.clear();
30
+ get('messages').replaceChildren(); get('appeals').replaceChildren();
31
+ for (const id of ['case-reference', 'case-status', 'audience-description']) get(id).textContent = '';
32
+ for (const kind of ['reply', 'appeal']) { pending[kind] = null; retryAt[kind] = 0; get(kind === 'reply' ? 'reply' : 'appeal-reason').value = ''; updatePending(kind); updateRateWait(kind); }
33
+ get('access').hidden = false; get('case').hidden = true; get('sign-out').hidden = true; feedback(message, Boolean(message));
34
+ }
35
+ // Opening a different fragment on the same /participant document does not
36
+ // restart scripts. Clear the old case immediately and perform a fresh exchange.
37
+ addEventListener('hashchange', () => { if (location.hash) { showAccess('Opening your private case…'); location.reload(); } });
38
+ function renderMessages() {
39
+ const messages = [...messageHistory.values()].sort((left, right) => left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
40
+ get('messages').replaceChildren(...messages.map(message => { const article = document.createElement('article'); article.className = `message ${message.direction}`; article.dataset.messageId = message.id; const who = document.createElement('strong'); who.textContent = message.direction === 'inbound' ? 'You' : 'Safety team'; const time = document.createElement('time'); time.dateTime = message.createdAt; time.textContent = date(message.createdAt); article.append(who, time, paragraph(message.body)); return article; }));
41
+ if (!messages.length) get('messages').append(paragraph('Your case is with the safety team. Their updates and your replies will appear here.'));
42
+ get('older-messages').hidden = !olderCursor;
43
+ }
44
+ function renderCase(data) {
45
+ const first = !current || current.reference !== data.reference || current.audience !== data.audience;
46
+ current = data; get('access').hidden = true; get('case').hidden = false; get('sign-out').hidden = false;
47
+ get('case-reference').textContent = data.reference; get('case-status').textContent = data.status;
48
+ get('audience-description').textContent = data.audience === 'reporter' ? 'You reported this issue. Your messages are shared with the safety team.' : 'This report involves you. Your messages are shared with the safety team.';
49
+ get('anonymous-warning').hidden = data.recoveryAvailable;
50
+ get('anonymous-warning').textContent = data.customerReentryAvailable
51
+ ? 'There is no email on this case. To return later, request a fresh case link through your signed-in account with the service where you reported the issue.'
52
+ : 'This browser session lasts up to seven days. There is no email on this case for access recovery. Keep this session open; closing it or clearing your browser data can permanently remove your access.';
53
+ if (first) messageHistory.clear();
54
+ // Preserve already loaded older messages while refreshing the latest page.
55
+ // With no overlap, restart paging at the newest page to fill the gap safely.
56
+ const overlaps = data.messages.some(message => messageHistory.has(message.id));
57
+ if (first || !overlaps) olderCursor = data.olderMessagesCursor;
58
+ for (const message of data.messages) messageHistory.set(message.id, message);
59
+ renderMessages();
60
+ const labels = { submitted: 'Appeal received', human_review: 'Under independent review', upheld: 'Decision upheld', modified: 'Decision modified', reversed: 'Decision reversed' };
61
+ get('appeals').replaceChildren(...data.appeals.map(appeal => { const article = document.createElement('article'); article.className = 'appeal'; const heading = document.createElement('strong'); heading.textContent = labels[appeal.state] || 'Appeal in progress'; article.append(heading, paragraph(appeal.reason)); if (appeal.completionState === 'waiting') article.append(paragraph('The appeal has been reviewed. The safety team is still completing the required follow-up.')); return article; }));
62
+ get('appeal-form').hidden = !data.canAppeal; get('appeal-unavailable').textContent = data.canAppeal ? '' : data.appeals.length ? 'Your appeal is recorded above. You can add information using the message form.' : 'You can request an appeal here after a decision has been recorded.';
63
+ if (first) { restoreDraft('reply'); restoreDraft('appeal'); }
64
+ }
65
+ async function refresh() { const epoch = viewEpoch; const result = await api('case'); if (epoch !== viewEpoch) return; csrf = result.csrfToken; renderCase(result.case); }
66
+ get('older-messages').addEventListener('click', async () => {
67
+ if (!olderCursor) return;
68
+ const button = get('older-messages'); button.disabled = true;
69
+ const reference = current?.reference; const audience = current?.audience;
70
+ const previousFirst = get('messages').firstElementChild;
71
+ const anchorId = previousFirst?.dataset.messageId;
72
+ const anchorTop = previousFirst?.getBoundingClientRect().top;
73
+ try {
74
+ const result = await api(`messages?before=${encodeURIComponent(olderCursor)}`);
75
+ if (current?.reference !== reference || current?.audience !== audience) return;
76
+ for (const message of result.messages) messageHistory.set(message.id, message);
77
+ olderCursor = result.olderMessagesCursor; renderMessages();
78
+ const anchor = [...get('messages').children].find(element => element.dataset.messageId === anchorId);
79
+ if (anchor && typeof anchorTop === 'number') scrollBy(0, anchor.getBoundingClientRect().top - anchorTop);
80
+ } catch (error) { feedback(error.message, true); } finally { button.disabled = false; }
81
+ });
82
+ get('refresh-case').addEventListener('click', async () => { const button = get('refresh-case'); button.disabled = true; try { await refresh(); feedback('Your conversation is up to date.'); } catch (error) { feedback(error.message, true); } finally { button.disabled = false; } });
83
+ function updateRateWait(kind) {
84
+ const button = get(`${kind}-form`).querySelector('button');
85
+ const remaining = Math.max(0, Math.ceil((retryAt[kind] - Date.now()) / 1000));
86
+ if (remaining) { button.disabled = true; button.dataset.rateWait = 'true'; button.textContent = `Try again in ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`; }
87
+ else if (button.dataset.rateWait) { delete button.dataset.rateWait; button.disabled = false; button.textContent = kind === 'reply' ? 'Send to safety team' : 'Submit appeal'; }
88
+ }
89
+ setInterval(() => { for (const kind of ['reply', 'appeal']) updateRateWait(kind); }, 1000);
90
+ for (const kind of ['reply', 'appeal']) {
91
+ const field = get(kind === 'reply' ? 'reply' : 'appeal-reason'); field.addEventListener('input', () => saveDraft(kind));
92
+ get(`${kind}-form`).addEventListener('submit', async event => {
93
+ event.preventDefault(); if (retryAt[kind] > Date.now()) return; const button = event.currentTarget.querySelector('button'); button.disabled = true;
94
+ if (!pending[kind]) pending[kind] = { key: crypto.randomUUID(), body: kind === 'reply' ? { body: field.value.trim() } : { reason: field.value.trim(), decisionId: current.appealDecisionId } };
95
+ saveDraft(kind); updatePending(kind);
96
+ try {
97
+ await api(kind === 'reply' ? 'messages' : 'appeals', { method: 'POST', headers: { 'idempotency-key': pending[kind].key }, body: JSON.stringify(pending[kind].body) });
98
+ pending[kind] = null; field.value = ''; saveDraft(kind); updatePending(kind);
99
+ await refresh(); feedback(kind === 'reply' ? 'Your message was received by the safety team.' : 'Your appeal was received. An independent reviewer will review it.'); get('feedback').scrollIntoView({ block: 'nearest' });
100
+ } catch (error) {
101
+ if (error.status && error.status < 500 && error.status !== 408) { pending[kind] = null; saveDraft(kind); updatePending(kind); }
102
+ if (error.status === 409) { try { await refresh(); } catch {} }
103
+ if (error.status === 429 && Number.isFinite(error.retryAfterSeconds)) { retryAt[kind] = Date.now() + error.retryAfterSeconds * 1000; saveDraft(kind); updateRateWait(kind); }
104
+ feedback(error.message, true);
105
+ } finally { button.disabled = retryAt[kind] > Date.now(); }
106
+ });
107
+ }
108
+ get('recovery-form').addEventListener('submit', async event => { event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; try { const result = await api('recovery', { method: 'POST', body: JSON.stringify({ reference: get('reference').value, email: get('email').value }) }); feedback(result.message); } catch (error) { feedback(error.message, true); } finally { button.disabled = false; } });
109
+ get('sign-out').addEventListener('click', async () => { if (!current?.recoveryAvailable && !current?.customerReentryAvailable && !confirm('There is no email for access recovery. Closing this session may permanently remove your access. Close it?')) return; try { await api('session', { method: 'DELETE' }); for (const kind of ['reply', 'appeal']) try { sessionStorage.removeItem(draftKey(kind)); } catch {} current = null; showAccess('Your private session is closed.'); } catch (error) { feedback(error.message, true); } });
110
+ (async () => { if (recovering) { showAccess('Request a new link using the email on your case.'); return; } try { if (invitationRequested) { const result = await api('exchange', { method: 'POST', body: JSON.stringify({ token: token || '' }) }); csrf = result.csrfToken; } await refresh(); feedback(''); } catch (error) { showAccess(invitationRequested ? error.message : 'Open the private link from your report receipt or safety team email, or request a new one below.'); } })();
111
+ })();
@@ -4,6 +4,8 @@ const formError = document.querySelector("#form-error");
4
4
  const idempotencyKey = crypto.randomUUID();
5
5
  let config;
6
6
  let turnstileToken = "";
7
+ let turnstileId = null;
8
+ let submitting = false;
7
9
 
8
10
  function option(value, label) {
9
11
  const element = document.createElement("option");
@@ -120,6 +122,7 @@ try {
120
122
  const result = await response.json().catch(() => ({}));
121
123
  if (!response.ok) throw new Error(result?.error?.message || "Public reporting is unavailable.");
122
124
  config = result;
125
+ if (config.reporter_notifications_available === false) throw new Error("Reporting is temporarily unavailable. Please contact the service owner or try again later.");
123
126
  globalThis.SafestBrand?.applyBrand(config.brand, { title: config.form.title || "Report a safety concern" });
124
127
  document.querySelector("#title").textContent = config.form.title;
125
128
  document.querySelector("#description").textContent = config.form.description;
@@ -128,7 +131,7 @@ try {
128
131
  document.querySelector("#reason").replaceChildren(...config.form.reasons.map((reason) => option(reason.code, reason.label)));
129
132
  document.querySelector("#answer-fields").replaceChildren(...(config.form.fields || []).map(renderField));
130
133
  const turnstile = await waitForTurnstile();
131
- turnstile.render("#turnstile-widget", {
134
+ turnstileId = turnstile.render("#turnstile-widget", {
132
135
  sitekey: config.turnstile_site_key,
133
136
  action: "report_submit",
134
137
  callback: (token) => { turnstileToken = token; },
@@ -142,6 +145,7 @@ try {
142
145
 
143
146
  form.addEventListener("submit", async (event) => {
144
147
  event.preventDefault();
148
+ if (submitting) return;
145
149
  formError.textContent = "";
146
150
  const button = form.querySelector("button");
147
151
  if (!turnstileToken) {
@@ -154,6 +158,8 @@ form.addEventListener("submit", async (event) => {
154
158
  const evidenceUrl = document.querySelector("#evidence-url").value.trim();
155
159
  const timestamp = new Date().toISOString();
156
160
  button.disabled = true;
161
+ submitting = true;
162
+ button.textContent = "Submitting…";
157
163
  try {
158
164
  const response = await fetch("/v1/reports/public", {
159
165
  method: "POST",
@@ -176,9 +182,23 @@ form.addEventListener("submit", async (event) => {
176
182
  form.hidden = true;
177
183
  document.querySelector("#receipt-reference").textContent = `Keep reference ${result.reference} for your records.`;
178
184
  document.querySelector("#receipt-notification").textContent = `Updates will be sent to ${document.querySelector("#reporter-email").value.trim().toLowerCase()}.`;
185
+ const caseLink = document.querySelector("#participant-case");
186
+ try {
187
+ const url = new URL(result.participant_url);
188
+ if (url.origin === location.origin && url.pathname === "/participant" && !url.search && /^#access=[a-f0-9]{64}$/.test(url.hash)) caseLink.href = url.href;
189
+ } catch { /* The access page offers email recovery when no new link was issued. */ }
190
+ if (!result.participant_url) caseLink.textContent = "Get a private case link";
179
191
  document.querySelector("#receipt").hidden = false;
192
+ const receipt = document.querySelector("#receipt h2");
193
+ receipt.tabIndex = -1;
194
+ receipt.focus();
180
195
  } catch (error) {
181
196
  formError.textContent = error instanceof Error ? error.message : "The report could not be accepted.";
182
197
  button.disabled = false;
198
+ button.textContent = "Submit report";
199
+ turnstileToken = "";
200
+ if (turnstileId !== null) globalThis.turnstile?.reset(turnstileId);
201
+ } finally {
202
+ submitting = false;
183
203
  }
184
204
  });
@@ -29,7 +29,7 @@
29
29
  <button class="primary" type="submit">Submit report</button>
30
30
  <p id="form-error" class="error" role="alert"></p>
31
31
  </form>
32
- <div id="receipt" class="receipt" hidden><h2>Report received</h2><p id="receipt-reference"></p><p id="receipt-notification"></p></div>
32
+ <div id="receipt" class="receipt" hidden><h2>Report received</h2><p id="receipt-reference"></p><p id="receipt-notification"></p><p><a id="participant-case" href="/participant" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Open your case</a></p><p>Use your private case link to send more information or request an independent review. Keep it private.</p></div>
33
33
  <p id="setup-error" class="error" role="alert"></p>
34
34
  </article>
35
35
  </main>