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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { sha256 } from "./report-crypto";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { ParticipantAudience } from "./report-types";
|
|
5
|
+
|
|
6
|
+
export interface ParticipantIdentity {
|
|
7
|
+
participantId: string; reportId: string; audience: ParticipantAudience;
|
|
8
|
+
reference: string | null; contactCiphertext: string | null; contactMode: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ParticipantSession extends ParticipantIdentity { tokenHash: string; csrfHash: string; grantId: string; }
|
|
11
|
+
const identityColumns = `p.id AS participantId, p.report_id AS reportId, p.audience,
|
|
12
|
+
p.reference, p.contact_ciphertext AS contactCiphertext, p.contact_mode AS contactMode`;
|
|
13
|
+
const accessError = () => new ApiError(401, "participant_access_unavailable", "This private link or session is unavailable. Request a new link using the email on your case.");
|
|
14
|
+
const now = () => new Date().toISOString();
|
|
15
|
+
const after = (seconds: number) => new Date(Date.now() + seconds * 1000).toISOString();
|
|
16
|
+
|
|
17
|
+
export async function participantIdentityFingerprint(identity: ParticipantIdentity): Promise<string> {
|
|
18
|
+
return sha256(canonicalJson(identity));
|
|
19
|
+
}
|
|
20
|
+
export async function participantGrantToken(secret:string,grantId:string):Promise<string> {
|
|
21
|
+
return signature(secret,`participant-link-v1:${grantId}`);
|
|
22
|
+
}
|
|
23
|
+
async function signature(secret: string, value: string): Promise<string> {
|
|
24
|
+
if (new TextEncoder().encode(secret).length < 32) throw new Error("Participant access requires the configured signing secret.");
|
|
25
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
26
|
+
return Array.from(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))), b => b.toString(16).padStart(2, "0")).join("");
|
|
27
|
+
}
|
|
28
|
+
function randomSecret(): string {
|
|
29
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
|
|
30
|
+
}
|
|
31
|
+
export async function issueParticipantAccessLink(
|
|
32
|
+
env: Pick<Env, "DB" | "CONTEXT_SIGNING_SECRET" | "PUBLIC_BASE_URL">,
|
|
33
|
+
input: { reportId: string; audience: ParticipantAudience; deliveryKey: string; expectedIdentity?: { reference: string | null; contactCiphertext: string | null } },
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
const base = new URL(env.PUBLIC_BASE_URL);
|
|
36
|
+
if (base.protocol !== "https:" && !(base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))) throw new Error("Participant links require a secure public address.");
|
|
37
|
+
const identity = await env.DB.prepare(`SELECT ${identityColumns} FROM report_participants p WHERE p.report_id = ?1 AND p.audience = ?2 AND p.reference_redacted_at IS NULL`).bind(input.reportId, input.audience).first<ParticipantIdentity>();
|
|
38
|
+
if (!identity) throw accessError();
|
|
39
|
+
if (input.expectedIdentity && (identity.reference !== input.expectedIdentity.reference || identity.contactCiphertext !== input.expectedIdentity.contactCiphertext)) throw accessError();
|
|
40
|
+
const identityFingerprint = await participantIdentityFingerprint(identity);
|
|
41
|
+
const id = crypto.randomUUID();
|
|
42
|
+
const token = await participantGrantToken(env.CONTEXT_SIGNING_SECRET,id);
|
|
43
|
+
await env.DB.prepare(`INSERT OR IGNORE INTO participant_access_grants (id,participant_id,delivery_key,identity_fingerprint,token_hash,created_at,expires_at)
|
|
44
|
+
SELECT ?1,?2,?3,?4,?5,?6,?7 FROM report_participants WHERE id=?2 AND reference_redacted_at IS NULL AND reference IS ?8 AND contact_ciphertext IS ?9 AND contact_mode=?10`)
|
|
45
|
+
.bind(id,identity.participantId,input.deliveryKey,identityFingerprint,await sha256(token),now(),after(30*86400),identity.reference,identity.contactCiphertext,identity.contactMode).run();
|
|
46
|
+
const grant = await env.DB.prepare(`SELECT id,participant_id AS participantId,identity_fingerprint AS fingerprint FROM participant_access_grants WHERE delivery_key=?1`).bind(input.deliveryKey).first<{ id:string; participantId:string; fingerprint:string }>();
|
|
47
|
+
if (!grant || grant.participantId !== identity.participantId || grant.fingerprint !== identityFingerprint) throw accessError();
|
|
48
|
+
return `${base.origin}/participant#access=${await participantGrantToken(env.CONTEXT_SIGNING_SECRET,grant.id)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function participantCsrf(secret: string, sessionToken: string): Promise<string> {
|
|
52
|
+
return signature(secret, `participant-csrf-v1:${sessionToken}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function lookupParticipantAccessLink(
|
|
56
|
+
env: Pick<Env, "DB" | "CONTEXT_SIGNING_SECRET" | "PUBLIC_BASE_URL">,
|
|
57
|
+
input: {reportId:string;audience:ParticipantAudience;deliveryKey:string;expectedIdentity:{reference:string|null;contactCiphertext:string|null}},
|
|
58
|
+
): Promise<string|null> {
|
|
59
|
+
const identity=await env.DB.prepare(`SELECT ${identityColumns} FROM report_participants p WHERE p.report_id=?1 AND p.audience=?2 AND p.reference_redacted_at IS NULL`).bind(input.reportId,input.audience).first<ParticipantIdentity>();
|
|
60
|
+
if(!identity||identity.reference!==input.expectedIdentity.reference||identity.contactCiphertext!==input.expectedIdentity.contactCiphertext)throw accessError();
|
|
61
|
+
const grant=await env.DB.prepare(`SELECT id,participant_id AS participantId,identity_fingerprint AS fingerprint FROM participant_access_grants WHERE delivery_key=?1`).bind(input.deliveryKey).first<{id:string;participantId:string;fingerprint:string}>();
|
|
62
|
+
if(!grant)return null;
|
|
63
|
+
if(grant.participantId!==identity.participantId||grant.fingerprint!==await participantIdentityFingerprint(identity))throw accessError();
|
|
64
|
+
return `${new URL(env.PUBLIC_BASE_URL).origin}/participant#access=${await participantGrantToken(env.CONTEXT_SIGNING_SECRET,grant.id)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function exchangeParticipantAccess(env: Pick<Env,"DB"|"CONTEXT_SIGNING_SECRET">, token: string, existingSessionToken?:string): Promise<{ sessionToken: string; csrfToken: string }> {
|
|
68
|
+
if (!/^[0-9a-f]{64}$/u.test(token)) throw accessError();
|
|
69
|
+
const tokenHash = await sha256(token);
|
|
70
|
+
const grant = await env.DB.prepare(`SELECT g.id,g.consumed_at AS consumedAt,g.identity_fingerprint AS fingerprint,${identityColumns} FROM participant_access_grants g
|
|
71
|
+
JOIN report_participants p ON p.id=g.participant_id WHERE g.token_hash=?1 AND g.expires_at>?2 AND g.revoked_at IS NULL AND p.reference_redacted_at IS NULL`).bind(tokenHash,now()).first<ParticipantIdentity & {id:string;fingerprint:string;consumedAt:string|null}>();
|
|
72
|
+
if (!grant || grant.fingerprint !== await participantIdentityFingerprint({participantId:grant.participantId,reportId:grant.reportId,audience:grant.audience,reference:grant.reference,contactCiphertext:grant.contactCiphertext,contactMode:grant.contactMode})) throw accessError();
|
|
73
|
+
if(grant.consumedAt){
|
|
74
|
+
// Reopening a consumed invitation may resume only the exact session that
|
|
75
|
+
// came from that grant. A different case/audience/grant is never a fallback.
|
|
76
|
+
if(!existingSessionToken)throw accessError();
|
|
77
|
+
const existing=await loadParticipantSession(env.DB,existingSessionToken);
|
|
78
|
+
if(existing.grantId!==grant.id)throw accessError();
|
|
79
|
+
return{sessionToken:existingSessionToken,csrfToken:await participantCsrf(env.CONTEXT_SIGNING_SECRET,existingSessionToken)};
|
|
80
|
+
}
|
|
81
|
+
const sessionToken=randomSecret(); const csrfToken=await participantCsrf(env.CONTEXT_SIGNING_SECRET,sessionToken); const at=now();
|
|
82
|
+
const result=await env.DB.batch([
|
|
83
|
+
env.DB.prepare(`UPDATE participant_access_grants SET consumed_at=?2 WHERE id=?1 AND consumed_at IS NULL AND revoked_at IS NULL AND expires_at>?2
|
|
84
|
+
AND EXISTS (SELECT 1 FROM report_participants p WHERE p.id=participant_access_grants.participant_id AND p.reference_redacted_at IS NULL AND p.reference IS ?3 AND p.contact_ciphertext IS ?4 AND p.contact_mode=?5)`)
|
|
85
|
+
.bind(grant.id,at,grant.reference,grant.contactCiphertext,grant.contactMode),
|
|
86
|
+
env.DB.prepare(`INSERT INTO participant_sessions(token_hash,grant_id,csrf_hash,created_at,expires_at) SELECT ?1,?2,?3,?4,?5 WHERE changes()=1`)
|
|
87
|
+
.bind(await sha256(sessionToken),grant.id,await sha256(csrfToken),at,after(7*86400)),
|
|
88
|
+
]);
|
|
89
|
+
if (result[0]?.meta.changes!==1) throw accessError();
|
|
90
|
+
return {sessionToken,csrfToken};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function loadParticipantSession(db: D1Database, token: string): Promise<ParticipantSession> {
|
|
94
|
+
if (!/^[0-9a-f]{64}$/u.test(token)) throw accessError();
|
|
95
|
+
const tokenHash=await sha256(token);
|
|
96
|
+
const row=await db.prepare(`SELECT s.token_hash AS tokenHash,s.csrf_hash AS csrfHash,s.grant_id AS grantId,g.identity_fingerprint AS fingerprint,${identityColumns}
|
|
97
|
+
FROM participant_sessions s JOIN participant_access_grants g ON g.id=s.grant_id JOIN report_participants p ON p.id=g.participant_id
|
|
98
|
+
WHERE s.token_hash=?1 AND s.expires_at>?2 AND s.revoked_at IS NULL AND g.revoked_at IS NULL AND g.expires_at>?2 AND p.reference_redacted_at IS NULL`)
|
|
99
|
+
.bind(tokenHash,now()).first<ParticipantSession & {fingerprint:string}>();
|
|
100
|
+
if (!row || row.fingerprint !== await participantIdentityFingerprint({participantId:row.participantId,reportId:row.reportId,audience:row.audience,reference:row.reference,contactCiphertext:row.contactCiphertext,contactMode:row.contactMode})) throw accessError();
|
|
101
|
+
return row;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** First statement in every participant write batch. CHECK failure rolls back all effects. */
|
|
105
|
+
export function participantMutationGuard(db:D1Database,session:ParticipantSession,id:string):D1PreparedStatement {
|
|
106
|
+
return db.prepare(`INSERT INTO participant_mutation_guards(id,authorized) VALUES (?1, EXISTS (
|
|
107
|
+
SELECT 1 FROM participant_sessions s JOIN participant_access_grants g ON g.id=s.grant_id JOIN report_participants p ON p.id=g.participant_id
|
|
108
|
+
WHERE s.token_hash=?2 AND s.expires_at>?3 AND s.revoked_at IS NULL AND g.revoked_at IS NULL AND g.expires_at>?3
|
|
109
|
+
AND p.id=?4 AND p.reference_redacted_at IS NULL AND p.reference IS ?5 AND p.contact_ciphertext IS ?6 AND p.contact_mode=?7))`)
|
|
110
|
+
.bind(id,session.tokenHash,now(),session.participantId,session.reference,session.contactCiphertext,session.contactMode);
|
|
111
|
+
}
|
|
112
|
+
export async function commitParticipantMutation(db:D1Database,session:ParticipantSession,statements:D1PreparedStatement[]):Promise<D1Result[]> {
|
|
113
|
+
const id=crypto.randomUUID();
|
|
114
|
+
try { return await db.batch([participantMutationGuard(db,session,id),...statements,db.prepare(`DELETE FROM participant_mutation_guards WHERE id=?1`).bind(id)]); }
|
|
115
|
+
catch(error) {
|
|
116
|
+
if (error instanceof Error && error.message.includes("valid_participant_session")) throw accessError();
|
|
117
|
+
if (error instanceof Error && (error.message.includes("participant_message_rate") || error.message.includes("participant_appeal_rate"))) {
|
|
118
|
+
const message=error.message.includes("participant_message_rate");
|
|
119
|
+
const windowSeconds=message?300:3600;
|
|
120
|
+
const cutoff=new Date(Date.now()-windowSeconds*1000).toISOString();
|
|
121
|
+
const earliest=message
|
|
122
|
+
? await db.prepare(`SELECT MIN(created_at) AS earliest FROM case_messages WHERE report_id=?1 AND audience=?2 AND sender_id=?3 AND direction='inbound' AND created_at>?4`).bind(session.reportId,session.audience,session.participantId,cutoff).first<{earliest:string|null}>()
|
|
123
|
+
: await db.prepare(`SELECT MIN(submitted_at) AS earliest FROM appeals WHERE report_id=?1 AND appellant_audience=?2 AND submitted_at>?3`).bind(session.reportId,session.audience,cutoff).first<{earliest:string|null}>();
|
|
124
|
+
const retryAfterSeconds=Math.max(1,Math.ceil(((earliest?.earliest?new Date(earliest.earliest).valueOf():Date.now())+windowSeconds*1000-Date.now())/1000));
|
|
125
|
+
throw new ApiError(429,"participant_rate_limited",`You have sent several ${message?"messages":"appeals"} recently. Your text has been kept. Try again in ${retryAfterSeconds<60?`${retryAfterSeconds} seconds`:`${Math.ceil(retryAfterSeconds/60)} minutes`}.`,{retryAfterSeconds});
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export async function revokeParticipantAccess(db:D1Database,participantId:string):Promise<void> {
|
|
131
|
+
await db.prepare(`UPDATE participant_access_grants SET revoked_at=?2 WHERE participant_id=?1 AND revoked_at IS NULL`).bind(participantId,now()).run();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Bounded maintenance. Grant identity survives expiry for immutable delivery retries. */
|
|
135
|
+
export async function pruneParticipantAccess(db:D1Database):Promise<{sessions:number;recoveryBuckets:number;grants:number}> {
|
|
136
|
+
const at=now();
|
|
137
|
+
const result=await db.batch([
|
|
138
|
+
db.prepare(`DELETE FROM participant_sessions WHERE token_hash IN (
|
|
139
|
+
SELECT s.token_hash FROM participant_sessions s JOIN participant_access_grants g ON g.id=s.grant_id
|
|
140
|
+
JOIN report_participants p ON p.id=g.participant_id WHERE s.expires_at<=?1 OR s.revoked_at IS NOT NULL
|
|
141
|
+
OR g.expires_at<=?1 OR g.revoked_at IS NOT NULL OR p.reference_redacted_at IS NOT NULL LIMIT 1000)`).bind(at),
|
|
142
|
+
db.prepare(`DELETE FROM participant_recovery_limits WHERE key_hash IN (SELECT key_hash FROM participant_recovery_limits WHERE next_allowed_at<=?1 LIMIT 1000)`).bind(at),
|
|
143
|
+
db.prepare(`DELETE FROM participant_access_grants WHERE id IN (
|
|
144
|
+
SELECT g.id FROM participant_access_grants g JOIN report_participants p ON p.id=g.participant_id
|
|
145
|
+
WHERE p.reference_redacted_at IS NOT NULL LIMIT 1000)`),
|
|
146
|
+
]);
|
|
147
|
+
return{sessions:result[0]?.meta.changes??0,recoveryBuckets:result[1]?.meta.changes??0,grants:result[2]?.meta.changes??0};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Insert immediately after the new message/appeal, inside its guarded transaction. */
|
|
151
|
+
export function participantRateGuard(db:D1Database,session:ParticipantSession,kind:"message"|"appeal",insertedId:string):D1PreparedStatement[] {
|
|
152
|
+
const id=crypto.randomUUID();
|
|
153
|
+
const statement=kind==="message"
|
|
154
|
+
? db.prepare(`INSERT INTO participant_rate_guards(id,message_allowed) VALUES(?1,
|
|
155
|
+
NOT EXISTS(SELECT 1 FROM case_messages WHERE id=?2) OR
|
|
156
|
+
(SELECT COUNT(*) FROM (SELECT id FROM case_messages WHERE report_id=?3 AND audience=?4 AND sender_id=?5 AND direction='inbound' AND created_at>?6 LIMIT 11))<=10)`)
|
|
157
|
+
.bind(id,insertedId,session.reportId,session.audience,session.participantId,new Date(Date.now()-300000).toISOString())
|
|
158
|
+
: db.prepare(`INSERT INTO participant_rate_guards(id,appeal_allowed) VALUES(?1,
|
|
159
|
+
NOT EXISTS(SELECT 1 FROM appeals WHERE id=?2) OR
|
|
160
|
+
(SELECT COUNT(*) FROM (SELECT id FROM appeals WHERE report_id=?3 AND appellant_audience=?4 AND submitted_at>?5 LIMIT 4))<=3)`)
|
|
161
|
+
.bind(id,insertedId,session.reportId,session.audience,new Date(Date.now()-3600000).toISOString());
|
|
162
|
+
return[statement,db.prepare(`DELETE FROM participant_rate_guards WHERE id=?1`).bind(id)];
|
|
163
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { canonicalJson } from "./audit";
|
|
2
|
+
import { commitParticipantMutation, participantRateGuard, type ParticipantSession } from "./participant-access";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
|
|
5
|
+
const stateLabels:Record<string,string>={received:"Report received",human_review:"Under review",ai_review:"Under review",awaiting_reporter:"More information requested",resolved_by_human:"Review completed",resolved_by_ai:"Review completed",human_review_requested:"Further review requested",appealed:"Appeal in progress",closed:"Completed"};
|
|
6
|
+
export interface ParticipantMessage {id:string;body:string;direction:"inbound"|"outbound";createdAt:string;}
|
|
7
|
+
export async function loadParticipantMessages(db:D1Database,session:ParticipantSession,before?:string):Promise<{messages:ParticipantMessage[];olderMessagesCursor:string|null}> {
|
|
8
|
+
// Cursor IDs are resolved only inside the same participant's audience. An ID
|
|
9
|
+
// from another case/audience cannot expose either its content or timestamp.
|
|
10
|
+
const cursor=before?await db.prepare(`SELECT id,created_at AS createdAt FROM case_messages WHERE id=?1 AND report_id=?2 AND audience=?3 AND direction IN ('inbound','outbound')`).bind(before,session.reportId,session.audience).first<{id:string;createdAt:string}>():null;
|
|
11
|
+
if(before&&!cursor)throw new ApiError(400,"participant_history_cursor_unavailable","This history position is no longer available. Refresh the case to load its available messages.");
|
|
12
|
+
const result=await db.prepare(`SELECT id,body,direction,created_at AS createdAt FROM case_messages
|
|
13
|
+
WHERE report_id=?1 AND audience=?2 AND redacted_at IS NULL AND direction IN ('inbound','outbound')
|
|
14
|
+
AND (?3 IS NULL OR created_at<?3 OR (created_at=?3 AND id<?4)) ORDER BY created_at DESC,id DESC LIMIT 51`)
|
|
15
|
+
.bind(session.reportId,session.audience,cursor?.createdAt??null,cursor?.id??null).all<ParticipantMessage>();
|
|
16
|
+
const messages=result.results.slice(0,50).reverse();
|
|
17
|
+
return{messages,olderMessagesCursor:result.results.length>50?messages[0]!.id:null};
|
|
18
|
+
}
|
|
19
|
+
export async function loadParticipantCase(db:D1Database,session:ParticipantSession):Promise<Record<string,unknown>> {
|
|
20
|
+
const report=await db.prepare(`SELECT public_reference AS reference,state,submitted_at AS submittedAt FROM reports WHERE id=?1`).bind(session.reportId).first<{reference:string;state:string;submittedAt:string}>();
|
|
21
|
+
if (!report) throw new ApiError(404,"participant_case_unavailable","This case is no longer available.");
|
|
22
|
+
// Only explicitly addressed content is projected. Never expose decisions.rationale,
|
|
23
|
+
// internal messages, operational timelines, identities or the other audience.
|
|
24
|
+
const [messages,appeals,decision]=await Promise.all([
|
|
25
|
+
loadParticipantMessages(db,session),
|
|
26
|
+
db.prepare(`SELECT a.id,a.reason,a.state,a.submitted_at AS submittedAt,ad.completion_state AS completionState
|
|
27
|
+
FROM appeals a LEFT JOIN appeal_decisions ad ON ad.appeal_id=a.id WHERE a.report_id=?1 AND a.appellant_audience=?2 AND a.reason_redacted_at IS NULL ORDER BY a.submitted_at,a.id`).bind(session.reportId,session.audience).all(),
|
|
28
|
+
db.prepare(`SELECT d.id FROM decisions d WHERE d.report_id=?1 AND d.sensitive_redacted_at IS NULL ORDER BY d.created_at DESC,d.id DESC LIMIT 1`).bind(session.reportId).first<{id:string}>(),
|
|
29
|
+
]);
|
|
30
|
+
const already=decision ? await db.prepare(`SELECT id FROM appeals WHERE decision_id=?1 AND appellant_audience=?2`).bind(decision.id,session.audience).first() : null;
|
|
31
|
+
const ownAppealPending=appeals.results.some(a=>a.state==="submitted"||a.state==="human_review"||a.completionState==="waiting");
|
|
32
|
+
const customerReentry=session.reference?await db.prepare(`SELECT 1 AS available FROM reports r JOIN application_integrations i ON i.id=r.integration_id
|
|
33
|
+
WHERE r.id=?1 AND i.enabled=1 AND i.channel_mode='contextual' AND EXISTS(SELECT 1 FROM json_each(i.scopes_json) WHERE value='mint_context_tokens')`).bind(session.reportId).first():null;
|
|
34
|
+
return {reference:report.reference,status:ownAppealPending?"Appeal in progress":report.state==="appealed"?"Under review":stateLabels[report.state]??"Under review",submittedAt:report.submittedAt,audience:session.audience,...messages,appeals:appeals.results,
|
|
35
|
+
canAppeal:Boolean(decision&&!already),appealDecisionId:decision&&!already?decision.id:null,
|
|
36
|
+
recoveryAvailable:Boolean(session.contactCiphertext&&session.contactMode==="customer_notification"),customerReentryAvailable:Boolean(customerReentry)};
|
|
37
|
+
}
|
|
38
|
+
function requestText(value:string):string { const text=value.trim();if(text.length<3||text.length>8000)throw new ApiError(400,"participant_text_invalid","Enter between 3 and 8,000 characters.");return text; }
|
|
39
|
+
function requestKey(value:string):string {if(!/^[A-Za-z0-9._:-]{8,160}$/u.test(value))throw new ApiError(400,"participant_request_key_invalid","The request key is invalid. Refresh and try again.");return value;}
|
|
40
|
+
|
|
41
|
+
export async function addParticipantInformation(db:D1Database,session:ParticipantSession,input:{body:string;idempotencyKey:string}):Promise<{messageId:string;idempotentReplay:boolean}> {
|
|
42
|
+
const body=requestText(input.body);const key=`participant:${session.participantId}:message:${requestKey(input.idempotencyKey)}`;
|
|
43
|
+
const existing=await db.prepare(`SELECT id,body FROM case_messages WHERE report_id=?1 AND idempotency_key=?2`).bind(session.reportId,key).first<{id:string;body:string}>();
|
|
44
|
+
if(existing){if(existing.body!==body)throw new ApiError(409,"idempotency_key_reused","This request key has already been used with different text.");return{messageId:existing.id,idempotentReplay:true};}
|
|
45
|
+
const id=crypto.randomUUID();const at=new Date().toISOString();
|
|
46
|
+
const result=await commitParticipantMutation(db,session,[
|
|
47
|
+
db.prepare(`INSERT OR IGNORE INTO case_messages(id,report_id,audience,direction,sender_type,sender_id,body,automated,delivery_state,created_at,idempotency_key,audience_checked_at)
|
|
48
|
+
VALUES(?1,?2,?3,'inbound',?3,?4,?5,0,'not_required',?6,?7,?6)`).bind(id,session.reportId,session.audience,session.participantId,body,at,key),
|
|
49
|
+
...participantRateGuard(db,session,"message",id),
|
|
50
|
+
db.prepare(`INSERT INTO report_state_history(id,report_id,from_state,to_state,reason_code,actor_type,actor_id,created_at)
|
|
51
|
+
SELECT ?1,id,state,CASE WHEN state='awaiting_reporter' THEN 'human_review' ELSE 'human_review_requested' END,'participant_information_received','participant',?3,?4 FROM reports
|
|
52
|
+
WHERE id=?2 AND state IN ('awaiting_reporter','resolved_by_human','resolved_by_ai','closed') AND EXISTS(SELECT 1 FROM case_messages WHERE id=?5)`).bind(crypto.randomUUID(),session.reportId,session.participantId,at,id),
|
|
53
|
+
db.prepare(`UPDATE reports SET state=CASE WHEN state='awaiting_reporter' THEN 'human_review' WHEN state IN ('resolved_by_human','resolved_by_ai','closed') THEN 'human_review_requested' ELSE state END,updated_at=?2 WHERE id=?1 AND EXISTS(SELECT 1 FROM case_messages WHERE id=?3)`).bind(session.reportId,at,id),
|
|
54
|
+
db.prepare(`INSERT INTO audit_events(id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
|
|
55
|
+
SELECT ?1,?2,'report.participant_information_received','participant',?3,'report',?4,?5,?6 WHERE EXISTS(SELECT 1 FROM case_messages WHERE id=?7)`)
|
|
56
|
+
.bind(crypto.randomUUID(),`audit:${key}`,session.participantId,session.reportId,canonicalJson({audience:session.audience,messageId:id,source:"participant_portal"}),at,id),
|
|
57
|
+
]);
|
|
58
|
+
if(result[1]?.meta.changes!==1)return addParticipantInformation(db,session,input);
|
|
59
|
+
return {messageId:id,idempotentReplay:false};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function submitParticipantAppeal(db:D1Database,session:ParticipantSession,input:{reason:string;decisionId:string;idempotencyKey:string}):Promise<{appealId:string;idempotentReplay:boolean}> {
|
|
63
|
+
const reason=requestText(input.reason);const key=`participant:${session.participantId}:appeal:${requestKey(input.idempotencyKey)}`;
|
|
64
|
+
const existing=await db.prepare(`SELECT id,reason,decision_id AS decisionId FROM appeals WHERE report_id=?1 AND idempotency_key=?2 AND appellant_audience=?3`).bind(session.reportId,key,session.audience).first<{id:string;reason:string;decisionId:string}>();
|
|
65
|
+
if(existing){if(existing.reason!==reason||existing.decisionId!==input.decisionId)throw new ApiError(409,"idempotency_key_reused","This appeal request was already submitted with different details.");return{appealId:existing.id,idempotentReplay:true};}
|
|
66
|
+
const decision=await db.prepare(`SELECT id FROM decisions WHERE report_id=?1 AND sensitive_redacted_at IS NULL ORDER BY created_at DESC,id DESC LIMIT 1`).bind(session.reportId).first<{id:string}>();
|
|
67
|
+
if(!decision||decision.id!==input.decisionId)throw new ApiError(409,"appeal_decision_changed","The decision changed. Refresh the case before requesting an appeal.");
|
|
68
|
+
const queue=await db.prepare(`SELECT q.id FROM queue_definitions q JOIN reports r ON r.installation_id=q.installation_id WHERE r.id=?1 AND q.queue_kind='appeal' AND q.enabled=1 AND q.status='active' ORDER BY q.position,q.id LIMIT 1`).bind(session.reportId).first<{id:string}>();
|
|
69
|
+
if(!queue)throw new ApiError(409,"appeal_queue_unavailable","Appeal requests are temporarily unavailable. Your text is still here; contact the safety team or try again later.");
|
|
70
|
+
const id=crypto.randomUUID();const at=new Date().toISOString();
|
|
71
|
+
await commitParticipantMutation(db,session,[
|
|
72
|
+
db.prepare(`INSERT OR IGNORE INTO appeals(id,report_id,decision_id,appellant_audience,reason,state,queue_id,submitted_at,idempotency_key)
|
|
73
|
+
SELECT ?1,?2,?3,?4,?5,'submitted',?6,?7,?8 WHERE ?3=(SELECT id FROM decisions WHERE report_id=?2 AND sensitive_redacted_at IS NULL ORDER BY created_at DESC,id DESC LIMIT 1)
|
|
74
|
+
AND EXISTS(SELECT 1 FROM queue_definitions WHERE id=?6 AND queue_kind='appeal' AND enabled=1 AND status='active')`).bind(id,session.reportId,decision.id,session.audience,reason,queue.id,at,key),
|
|
75
|
+
...participantRateGuard(db,session,"appeal",id),
|
|
76
|
+
db.prepare(`INSERT INTO report_state_history(id,report_id,from_state,to_state,reason_code,actor_type,actor_id,created_at)
|
|
77
|
+
SELECT ?1,id,state,'appealed','participant_appeal_submitted','participant',?3,?4 FROM reports WHERE id=?2 AND EXISTS(SELECT 1 FROM appeals WHERE id=?5)`).bind(crypto.randomUUID(),session.reportId,session.participantId,at,id),
|
|
78
|
+
db.prepare(`UPDATE reports SET state='appealed',updated_at=?2 WHERE id=?1 AND EXISTS(SELECT 1 FROM appeals WHERE id=?3)`).bind(session.reportId,at,id),
|
|
79
|
+
db.prepare(`INSERT INTO audit_events(id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
|
|
80
|
+
SELECT ?1,?2,'appeal.recorded','participant',?3,'appeal',?4,?5,?6 WHERE EXISTS(SELECT 1 FROM appeals WHERE id=?4)`)
|
|
81
|
+
.bind(crypto.randomUUID(),`appeal-recorded:${id}`,session.participantId,id,canonicalJson({reportId:session.reportId,decisionId:decision.id,audience:session.audience,source:"participant_portal"}),at),
|
|
82
|
+
]);
|
|
83
|
+
const saved=await db.prepare(`SELECT id,idempotency_key AS key,reason FROM appeals WHERE decision_id=?1 AND appellant_audience=?2`).bind(decision.id,session.audience).first<{id:string;key:string;reason:string}>();
|
|
84
|
+
if(!saved||saved.key!==key)throw new ApiError(409,"appeal_already_recorded","An appeal already exists for this decision. Refresh to see its progress.");
|
|
85
|
+
if(saved.reason!==reason)throw new ApiError(409,"idempotency_key_reused","This appeal request was already submitted with different details.");
|
|
86
|
+
return {appealId:saved.id,idempotentReplay:saved.id!==id};
|
|
87
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {canonicalJson} from "./audit";
|
|
2
|
+
import {requireIntegration} from "./report-auth";
|
|
3
|
+
import {secretsEqual,sha256} from "./report-crypto";
|
|
4
|
+
import {ApiError,apiErrorResponse,json,methodNotAllowed,readJsonObject} from "./report-http";
|
|
5
|
+
import {lookupParticipantAccessLink,participantGrantToken,participantIdentityFingerprint,type ParticipantIdentity} from "./participant-access";
|
|
6
|
+
|
|
7
|
+
const unavailable=()=>new ApiError(403,"participant_access_unavailable","This participant cannot access that case through this integration.");
|
|
8
|
+
const conflict=()=>new ApiError(409,"idempotency_key_reused","This request key was already used for a different participant or case.");
|
|
9
|
+
function field(input:Record<string,unknown>,name:string,maximum:number):string {
|
|
10
|
+
const value=input[name];
|
|
11
|
+
if(typeof value!=="string"||!value.trim()||value.length>maximum)throw new ApiError(400,"participant_access_input_invalid",`${name} is required and must contain at most ${maximum} characters.`);
|
|
12
|
+
return value.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Customer-server endpoint. The customer derives participant_reference from its own authenticated session. */
|
|
16
|
+
export async function handleCustomerParticipantAccess(request:Request,env:Env):Promise<Response> {
|
|
17
|
+
try{
|
|
18
|
+
if(request.method!=="POST")return methodNotAllowed(["POST"]);
|
|
19
|
+
const integration=await requireIntegration(request,env);
|
|
20
|
+
if(!integration.scopes.includes("mint_context_tokens"))throw new ApiError(403,"integration_scope_denied","The integration cannot mint participant access links.");
|
|
21
|
+
if(integration.channelMode!=="contextual")throw new ApiError(403,"integration_mode_denied","This reporting channel does not accept authenticated participant identity.");
|
|
22
|
+
let base:URL;
|
|
23
|
+
try{base=new URL(env.PUBLIC_BASE_URL);}catch{throw new ApiError(503,"participant_public_url_invalid","The private case address is not configured correctly.");}
|
|
24
|
+
if(base.protocol!=="https:"&&!(base.protocol==="http:"&&["localhost","127.0.0.1","[::1]"].includes(base.hostname)))throw new ApiError(503,"participant_public_url_invalid","The private case address must use HTTPS.");
|
|
25
|
+
const input=await readJsonObject(request,4096);
|
|
26
|
+
if(Object.keys(input).some(key=>!["report_reference","audience","participant_reference"].includes(key)))throw new ApiError(400,"participant_access_input_invalid","Only report_reference, audience and participant_reference are accepted.");
|
|
27
|
+
const reportReference=field(input,"report_reference",100);const participantReference=field(input,"participant_reference",500);
|
|
28
|
+
if(input.audience!=="reporter"&&input.audience!=="affected_user")throw new ApiError(400,"participant_access_input_invalid","audience must be reporter or affected_user.");
|
|
29
|
+
const audience=input.audience;
|
|
30
|
+
const requestKey=request.headers.get("idempotency-key")?.trim()??"";
|
|
31
|
+
if(!/^[A-Za-z0-9._:-]{8,160}$/u.test(requestKey))throw new ApiError(400,"participant_request_key_invalid","Provide an Idempotency-Key containing 8–160 letters, digits, periods, underscores, colons or hyphens.");
|
|
32
|
+
const identity=await env.DB.prepare(`SELECT p.id AS participantId,p.report_id AS reportId,p.audience,p.reference,p.contact_ciphertext AS contactCiphertext,p.contact_mode AS contactMode
|
|
33
|
+
FROM report_participants p JOIN reports r ON r.id=p.report_id WHERE r.public_reference=?1 AND r.integration_id=?2 AND p.audience=?3 AND p.reference=?4 AND p.reference_redacted_at IS NULL`)
|
|
34
|
+
.bind(reportReference,integration.id,audience,participantReference).first<ParticipantIdentity>();
|
|
35
|
+
if(!identity)throw unavailable();
|
|
36
|
+
const fingerprint=await participantIdentityFingerprint(identity);
|
|
37
|
+
const deliveryKey=`customer-access:${integration.id}:${requestKey}`;
|
|
38
|
+
const existing=await env.DB.prepare(`SELECT participant_id AS participantId,identity_fingerprint AS fingerprint FROM participant_access_grants WHERE delivery_key=?1`).bind(deliveryKey).first<{participantId:string;fingerprint:string}>();
|
|
39
|
+
if(existing&&(existing.participantId!==identity.participantId||existing.fingerprint!==fingerprint))throw conflict();
|
|
40
|
+
|
|
41
|
+
// Capture how the existing authenticator accepted this credential. At commit,
|
|
42
|
+
// recheck current managed-key status or the same deployment key ID as well as
|
|
43
|
+
// integration mode, enabled state, scope and exact case/participant identity.
|
|
44
|
+
const keyRow=await env.DB.prepare(`SELECT key_id AS keyId FROM application_integrations WHERE id=?1`).bind(integration.id).first<{keyId:string}>();
|
|
45
|
+
if(!keyRow)throw unavailable();
|
|
46
|
+
const suppliedKey=/^Bearer\s+([^\s]+)$/iu.exec(request.headers.get("authorization")??"")?.[1]??"";
|
|
47
|
+
let environmentKey:unknown;
|
|
48
|
+
try{const configured:unknown=JSON.parse(env.INTEGRATION_API_KEYS_JSON??"{}");if(configured&&typeof configured==="object"&&!Array.isArray(configured))environmentKey=(configured as Record<string,unknown>)[keyRow.keyId];}catch{/* managed keys remain supported */}
|
|
49
|
+
const environmentValid=typeof environmentKey==="string"&&environmentKey.length>=16&&await secretsEqual(environmentKey,suppliedKey);
|
|
50
|
+
const suppliedHash=await sha256(`integration-key-v1:${suppliedKey}`);
|
|
51
|
+
const id=crypto.randomUUID();const guardId=crypto.randomUUID();const at=new Date().toISOString();const token=await participantGrantToken(env.CONTEXT_SIGNING_SECRET,id);
|
|
52
|
+
const cutoff=new Date(Date.now()-600000).toISOString();
|
|
53
|
+
try{
|
|
54
|
+
await env.DB.batch([
|
|
55
|
+
env.DB.prepare(`INSERT INTO participant_mutation_guards(id,authorized) VALUES(?1,EXISTS(
|
|
56
|
+
SELECT 1 FROM report_participants p JOIN reports r ON r.id=p.report_id JOIN application_integrations i ON i.id=r.integration_id
|
|
57
|
+
WHERE p.id=?2 AND r.public_reference=?3 AND r.integration_id=?4 AND p.audience=?5 AND p.reference=?6
|
|
58
|
+
AND p.reference_redacted_at IS NULL AND p.contact_ciphertext IS ?7 AND p.contact_mode=?8
|
|
59
|
+
AND i.enabled=1 AND i.channel_mode='contextual' AND EXISTS(SELECT 1 FROM json_each(i.scopes_json) WHERE value='mint_context_tokens')
|
|
60
|
+
AND ((?9=1 AND i.key_id=?10) OR EXISTS(SELECT 1 FROM integration_api_keys k WHERE k.integration_id=i.id AND k.key_hash=?11
|
|
61
|
+
AND k.status='active' AND k.valid_from<=?12 AND (k.expires_at IS NULL OR k.expires_at>?12)))))`)
|
|
62
|
+
.bind(guardId,identity.participantId,reportReference,integration.id,audience,participantReference,identity.contactCiphertext,identity.contactMode,environmentValid?1:0,keyRow.keyId,suppliedHash,at),
|
|
63
|
+
env.DB.prepare(`INSERT OR IGNORE INTO participant_access_grants(id,participant_id,delivery_key,identity_fingerprint,token_hash,created_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7)`)
|
|
64
|
+
.bind(id,identity.participantId,deliveryKey,fingerprint,await sha256(token),at,new Date(Date.now()+30*86400000).toISOString()),
|
|
65
|
+
env.DB.prepare(`INSERT INTO participant_rate_guards(id,message_allowed) VALUES(?1,NOT EXISTS(SELECT 1 FROM participant_access_grants WHERE id=?2) OR
|
|
66
|
+
(SELECT COUNT(*) FROM (SELECT id FROM participant_access_grants WHERE participant_id=?3 AND delivery_key LIKE 'customer-access:%' AND created_at>?4 LIMIT 11))<=10)`)
|
|
67
|
+
.bind(guardId,id,identity.participantId,cutoff),
|
|
68
|
+
env.DB.prepare(`INSERT INTO audit_events(id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
|
|
69
|
+
SELECT ?1,?2,'participant_access.customer_link_issued','integration',?3,'report',?4,?5,?6 WHERE EXISTS(SELECT 1 FROM participant_access_grants WHERE id=?7)`)
|
|
70
|
+
.bind(crypto.randomUUID(),`participant-customer-access:${id}`,integration.id,identity.reportId,canonicalJson({participantId:identity.participantId,audience,grantId:id}),at,id),
|
|
71
|
+
env.DB.prepare(`DELETE FROM participant_rate_guards WHERE id=?1`).bind(guardId),
|
|
72
|
+
env.DB.prepare(`DELETE FROM participant_mutation_guards WHERE id=?1`).bind(guardId),
|
|
73
|
+
]);
|
|
74
|
+
}catch(error){
|
|
75
|
+
if(error instanceof Error&&error.message.includes("valid_participant_session"))throw unavailable();
|
|
76
|
+
if(error instanceof Error&&error.message.includes("participant_message_rate")){
|
|
77
|
+
const row=await env.DB.prepare(`SELECT MIN(created_at) AS earliest FROM participant_access_grants WHERE participant_id=?1 AND delivery_key LIKE 'customer-access:%' AND created_at>?2`).bind(identity.participantId,cutoff).first<{earliest:string|null}>();
|
|
78
|
+
const seconds=Math.max(1,Math.ceil(((row?.earliest?new Date(row.earliest).valueOf():Date.now())+600000-Date.now())/1000));
|
|
79
|
+
throw new ApiError(429,"participant_access_throttled",`Several private links were requested recently. Try again in ${Math.ceil(seconds/60)} minutes.`,{retryAfterSeconds:seconds});
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
const saved=await env.DB.prepare(`SELECT participant_id AS participantId,identity_fingerprint AS fingerprint FROM participant_access_grants WHERE delivery_key=?1`).bind(deliveryKey).first<{participantId:string;fingerprint:string}>();
|
|
84
|
+
if(!saved||saved.participantId!==identity.participantId||saved.fingerprint!==fingerprint)throw conflict();
|
|
85
|
+
const participantUrl=await lookupParticipantAccessLink(env,{reportId:identity.reportId,audience,deliveryKey,expectedIdentity:{reference:identity.reference,contactCiphertext:identity.contactCiphertext}});
|
|
86
|
+
if(!participantUrl)throw unavailable();
|
|
87
|
+
return json({participant_url:participantUrl});
|
|
88
|
+
}catch(error){
|
|
89
|
+
const response=apiErrorResponse(error);
|
|
90
|
+
if(error instanceof ApiError&&error.status===429&&typeof error.details?.retryAfterSeconds==="number")response.headers.set("retry-after",String(error.details.retryAfterSeconds));
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sha256, decryptReportContact, secretsEqual } from "./report-crypto";
|
|
2
|
+
import { reporterEmailConfigured } from "./report-email";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import type { ParticipantIdentity } from "./participant-access";
|
|
5
|
+
|
|
6
|
+
/** Generic responses prevent case/contact enumeration. The throttle hashes include a secret pepper. */
|
|
7
|
+
export async function requestParticipantRecovery(env:Env,input:{reference:string;email:string;clientAddress:string}):Promise<void> {
|
|
8
|
+
const at=new Date().toISOString();const next=new Date(Date.now()+10*60000).toISOString();
|
|
9
|
+
const email=input.email.trim().toLowerCase();const reference=input.reference.trim();
|
|
10
|
+
const clientKey=await sha256(`participant-recovery-client:${env.RATE_LIMIT_PEPPER}:${input.clientAddress}`);
|
|
11
|
+
const client=await env.DB.prepare(`INSERT INTO participant_recovery_limits(key_hash,next_allowed_at) VALUES(?1,?2)
|
|
12
|
+
ON CONFLICT(key_hash) DO UPDATE SET next_allowed_at=excluded.next_allowed_at WHERE participant_recovery_limits.next_allowed_at<=?3`)
|
|
13
|
+
.bind(clientKey,new Date(Date.now()+30000).toISOString(),at).run();
|
|
14
|
+
if(client.meta.changes!==1)throw new ApiError(429,"participant_recovery_throttled","Please wait a moment before requesting another link.");
|
|
15
|
+
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)||reference.length>100||!reporterEmailConfigured(env))return;
|
|
16
|
+
const key=await sha256(`participant-recovery:${env.RATE_LIMIT_PEPPER}:${reference}:${email}`);
|
|
17
|
+
const claimed=await env.DB.prepare(`INSERT INTO participant_recovery_limits(key_hash,next_allowed_at) VALUES(?1,?2)
|
|
18
|
+
ON CONFLICT(key_hash) DO UPDATE SET next_allowed_at=excluded.next_allowed_at WHERE participant_recovery_limits.next_allowed_at<=?3`).bind(key,next,at).run();
|
|
19
|
+
if(claimed.meta.changes!==1)return;
|
|
20
|
+
const rows=await env.DB.prepare(`SELECT p.id AS participantId,p.report_id AS reportId,p.audience,p.reference,p.contact_ciphertext AS contactCiphertext,p.contact_mode AS contactMode,r.installation_id AS installationId
|
|
21
|
+
FROM report_participants p JOIN reports r ON r.id=p.report_id WHERE r.public_reference=?1 AND p.reference_redacted_at IS NULL
|
|
22
|
+
AND p.contact_mode='customer_notification' AND p.contact_ciphertext IS NOT NULL`).bind(reference).all<ParticipantIdentity & {installationId:string}>();
|
|
23
|
+
for(const identity of rows.results){
|
|
24
|
+
let storedEmail:string;
|
|
25
|
+
try{storedEmail=(await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY,identity.contactCiphertext!,identity.reportId,identity.audience)).trim().toLowerCase();}catch{continue;}
|
|
26
|
+
if(!await secretsEqual(email,storedEmail))continue;
|
|
27
|
+
const id=crypto.randomUUID();const deliveryId=crypto.randomUUID();
|
|
28
|
+
const recipientHash=`sha256:${await sha256(`email-recipient-v1:${env.RATE_LIMIT_PEPPER}:${storedEmail}`)}`;
|
|
29
|
+
const result=await env.DB.batch([
|
|
30
|
+
env.DB.prepare(`INSERT INTO case_messages(id,report_id,audience,direction,sender_type,sender_id,body,automated,delivery_state,created_at,idempotency_key,audience_checked_at)
|
|
31
|
+
SELECT ?1,report_id,audience,'outbound','system','participant-access','You requested a private link to this case. Use the Open your case button in this email. If you did not request it, you can ignore this message.',1,'queued',?3,?4,?3
|
|
32
|
+
FROM report_participants WHERE id=?2 AND reference_redacted_at IS NULL AND reference IS ?5 AND contact_ciphertext IS ?6 AND contact_mode='customer_notification'`)
|
|
33
|
+
.bind(id,identity.participantId,at,`participant-recovery:${id}`,identity.reference,identity.contactCiphertext),
|
|
34
|
+
env.DB.prepare(`INSERT INTO email_deliveries(id,installation_id,message_id,audience,recipient_hash,provider,state,created_at,updated_at)
|
|
35
|
+
SELECT ?1,?2,?3,?4,?5,'cloudflare_email','pending',?6,?6 WHERE EXISTS(SELECT 1 FROM case_messages WHERE id=?3)`)
|
|
36
|
+
.bind(deliveryId,identity.installationId,id,identity.audience,recipientHash,at),
|
|
37
|
+
]);
|
|
38
|
+
if(result[0]?.meta.changes===1){
|
|
39
|
+
// The outbox is authoritative. Scheduled reconciliation retries a lost wake-up.
|
|
40
|
+
try{await env.DELIVERY_JOBS.send({version:1,jobId:`email:${deliveryId}`,type:"deliver_email",outboxId:deliveryId});}catch{/* durable outbox remains pending */}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { apiErrorResponse, ApiError, json, methodNotAllowed, readJsonObject } from "./report-http";
|
|
2
|
+
import { sha256, secretsEqual } from "./report-crypto";
|
|
3
|
+
import { exchangeParticipantAccess, loadParticipantSession, participantCsrf } from "./participant-access";
|
|
4
|
+
import { addParticipantInformation, loadParticipantCase, loadParticipantMessages, submitParticipantAppeal } from "./participant-case";
|
|
5
|
+
import { requestParticipantRecovery } from "./participant-recovery";
|
|
6
|
+
|
|
7
|
+
const cookieName="safest_participant";
|
|
8
|
+
const secureHeaders={"cache-control":"no-store","referrer-policy":"no-referrer","x-content-type-options":"nosniff","x-frame-options":"DENY","content-security-policy":"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"};
|
|
9
|
+
function protect(response:Response):Response {const headers=new Headers(response.headers);for(const[k,v]of Object.entries(secureHeaders))headers.set(k,v);return new Response(response.body,{status:response.status,headers});}
|
|
10
|
+
function sessionToken(request:Request):string {return request.headers.get("cookie")?.split(";").map(v=>v.trim()).find(v=>v.startsWith(`${cookieName}=`))?.slice(cookieName.length+1)??"";}
|
|
11
|
+
function cookie(request:Request,token:string,maxAge:number):string {return `${cookieName}=${token}; Path=/v1/participant; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${new URL(request.url).protocol==="https:"?"; Secure":""}`;}
|
|
12
|
+
function requireOrigin(request:Request,env:Env):void {
|
|
13
|
+
if(request.headers.get("origin")!==new URL(env.PUBLIC_BASE_URL).origin||new URL(request.url).origin!==new URL(env.PUBLIC_BASE_URL).origin)throw new ApiError(403,"participant_origin_denied","Open the case using its original private link.");
|
|
14
|
+
}
|
|
15
|
+
function text(input:Record<string,unknown>,key:string):string {return typeof input[key]==="string"?input[key]:"";}
|
|
16
|
+
|
|
17
|
+
export async function handleParticipantRequest(request:Request,env:Env,onChanged?:(reportId:string)=>void):Promise<Response|null> {
|
|
18
|
+
const path=new URL(request.url).pathname;
|
|
19
|
+
if(path!=="/participant"&&path!=="/participant.html"&&!path.startsWith("/v1/participant/"))return null;
|
|
20
|
+
try{
|
|
21
|
+
if(path==="/participant"||path==="/participant.html"){
|
|
22
|
+
if(!["GET","HEAD"].includes(request.method))return protect(methodNotAllowed(["GET","HEAD"]));
|
|
23
|
+
// Assets' automatic HTML handling serves participant.html at the clean path;
|
|
24
|
+
// requesting the .html name can redirect back here indefinitely.
|
|
25
|
+
return protect(await env.ASSETS.fetch(new Request(new URL("/participant",request.url),request)));
|
|
26
|
+
}
|
|
27
|
+
if(!["GET","POST","DELETE"].includes(request.method))return protect(methodNotAllowed(["GET","POST","DELETE"]));
|
|
28
|
+
if(request.method!=="GET")requireOrigin(request,env);
|
|
29
|
+
if(path==="/v1/participant/exchange"&&request.method==="POST"){
|
|
30
|
+
const input=await readJsonObject(request,2048);const exchanged=await exchangeParticipantAccess(env,text(input,"token"),sessionToken(request));
|
|
31
|
+
return protect(json({csrfToken:exchanged.csrfToken},{headers:{"set-cookie":cookie(request,exchanged.sessionToken,7*86400)}}));
|
|
32
|
+
}
|
|
33
|
+
if(path==="/v1/participant/recovery"&&request.method==="POST"){
|
|
34
|
+
const input=await readJsonObject(request,2048);
|
|
35
|
+
await requestParticipantRecovery(env,{reference:text(input,"reference"),email:text(input,"email"),clientAddress:request.headers.get("cf-connecting-ip")??"local"});
|
|
36
|
+
return protect(json({message:"If those details match an available case and email delivery is configured, a new private link will be sent to the email already on the case. If it does not arrive, contact the service where you reported the issue."},{status:202}));
|
|
37
|
+
}
|
|
38
|
+
const token=sessionToken(request);const session=await loadParticipantSession(env.DB,token);
|
|
39
|
+
if(request.method!=="GET"){
|
|
40
|
+
const supplied=request.headers.get("x-csrf-token")??"";
|
|
41
|
+
if(!await secretsEqual(await sha256(supplied),session.csrfHash))throw new ApiError(403,"participant_csrf_denied","Your session changed. Refresh the case and try again.");
|
|
42
|
+
}
|
|
43
|
+
if(path==="/v1/participant/session"&&request.method==="DELETE"){
|
|
44
|
+
await env.DB.prepare(`UPDATE participant_sessions SET revoked_at=?2 WHERE token_hash=?1`).bind(session.tokenHash,new Date().toISOString()).run();
|
|
45
|
+
return protect(json({signedOut:true},{headers:{"set-cookie":cookie(request,"",0)}}));
|
|
46
|
+
}
|
|
47
|
+
if(path==="/v1/participant/case"&&request.method==="GET")return protect(json({case:await loadParticipantCase(env.DB,session),csrfToken:await participantCsrf(env.CONTEXT_SIGNING_SECRET,token)}));
|
|
48
|
+
if(path==="/v1/participant/messages"&&request.method==="GET"){
|
|
49
|
+
const before=new URL(request.url).searchParams.get("before")??undefined;
|
|
50
|
+
if(before&&before.length>200)throw new ApiError(400,"participant_history_cursor_invalid","The history position is invalid. Refresh the case.");
|
|
51
|
+
return protect(json(await loadParticipantMessages(env.DB,session,before)));
|
|
52
|
+
}
|
|
53
|
+
if(path==="/v1/participant/messages"&&request.method==="POST"){
|
|
54
|
+
const input=await readJsonObject(request,36*1024);const result=await addParticipantInformation(env.DB,session,{body:text(input,"body"),idempotencyKey:request.headers.get("idempotency-key")??""});
|
|
55
|
+
onChanged?.(session.reportId);return protect(json(result,{status:result.idempotentReplay?200:201}));
|
|
56
|
+
}
|
|
57
|
+
if(path==="/v1/participant/appeals"&&request.method==="POST"){
|
|
58
|
+
const input=await readJsonObject(request,36*1024);const result=await submitParticipantAppeal(env.DB,session,{reason:text(input,"reason"),decisionId:text(input,"decisionId"),idempotencyKey:request.headers.get("idempotency-key")??""});
|
|
59
|
+
onChanged?.(session.reportId);return protect(json(result,{status:result.idempotentReplay?200:201}));
|
|
60
|
+
}
|
|
61
|
+
return protect(json({error:{code:"not_found",message:"This page does not exist."}},{status:404}));
|
|
62
|
+
}catch(error){
|
|
63
|
+
const response=apiErrorResponse(error);
|
|
64
|
+
if(error instanceof ApiError&&error.status===429&&typeof error.details?.retryAfterSeconds==="number")response.headers.set("retry-after",String(error.details.retryAfterSeconds));
|
|
65
|
+
return protect(response);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -69,8 +69,28 @@ const methodSet = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
|
|
|
69
69
|
const connectionKinds = new Set<ConnectionKind>(["webhook", "external_api", "customer_worker", "ai_provider", "notification", "email"]);
|
|
70
70
|
const credentialStrategies = new Set<CredentialStrategy>(["none", "static_header", "bearer", "hmac", "oauth2_client_credentials", "oauth2_authorization_code", "mutual_tls", "service_binding"]);
|
|
71
71
|
const componentKinds = new Set<ComponentKind>(["built_in", "webhook_enrichment", "api_enrichment", "workers_ai", "specialist_agent", "action", "message"]);
|
|
72
|
+
const customerComponentKinds = new Set<ComponentKind>(["webhook_enrichment", "api_enrichment", "workers_ai", "action"]);
|
|
72
73
|
const implementationKinds = new Set<ComponentVersionInput["implementationKind"]>(["built_in", "webhook", "external_api", "workers_ai", "specialist_agent", "message_template"]);
|
|
73
74
|
|
|
75
|
+
const implementationsByComponentKind: Record<ComponentKind, ReadonlySet<ComponentVersionInput["implementationKind"]>> = {
|
|
76
|
+
built_in: new Set(["built_in"]),
|
|
77
|
+
webhook_enrichment: new Set(["webhook"]),
|
|
78
|
+
api_enrichment: new Set(["external_api"]),
|
|
79
|
+
workers_ai: new Set(["workers_ai"]),
|
|
80
|
+
specialist_agent: new Set(["specialist_agent"]),
|
|
81
|
+
action: new Set(["webhook", "external_api"]),
|
|
82
|
+
message: new Set(["message_template"]),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function assertComponentRuntimeMatchesKind(
|
|
86
|
+
kind: ComponentKind,
|
|
87
|
+
implementationKind: ComponentVersionInput["implementationKind"],
|
|
88
|
+
): void {
|
|
89
|
+
if (!implementationsByComponentKind[kind].has(implementationKind)) {
|
|
90
|
+
throw new ApiError(400, "component_implementation_kind_mismatch", "The component implementation does not match its declared kind.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
function object(value: unknown, name: string): Record<string, unknown> {
|
|
75
95
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ApiError(400, "invalid_registry_configuration", `${name} must be an object.`);
|
|
76
96
|
return value as Record<string, unknown>;
|
|
@@ -345,10 +365,13 @@ export function parseComponentCreate(value: unknown): ComponentCreateInput {
|
|
|
345
365
|
const key = text(input.key, "component.key", 2, 100);
|
|
346
366
|
if (!keyPattern.test(key)) throw new ApiError(400, "invalid_component_key", "component.key must be lower-case kebab-case.");
|
|
347
367
|
if (typeof input.kind !== "string" || !componentKinds.has(input.kind as ComponentKind)) throw new ApiError(400, "invalid_registry_configuration", "component.kind is unsupported.");
|
|
368
|
+
if (!customerComponentKinds.has(input.kind as ComponentKind)) throw new ApiError(400, "component_kind_reserved", "This component kind is reserved for product-installed capabilities.");
|
|
348
369
|
if (input.effect_class !== "read_only" && input.effect_class !== "side_effecting") throw new ApiError(400, "invalid_registry_configuration", "effect_class is unsupported.");
|
|
349
|
-
if (input.owner_kind !== "
|
|
370
|
+
if (input.owner_kind !== "customer") throw new ApiError(400, "component_owner_kind_reserved", "Customer-created components must use customer ownership.");
|
|
350
371
|
if (input.kind === "action" && input.effect_class !== "side_effecting") throw new ApiError(400, "action_effect_class_required", "Action components must be side_effecting.");
|
|
351
372
|
if (input.kind !== "action" && input.effect_class === "side_effecting") throw new ApiError(400, "component_effect_mismatch", "Only action components may be side_effecting.");
|
|
373
|
+
const version = parseComponentVersion(input.version, input.effect_class);
|
|
374
|
+
assertComponentRuntimeMatchesKind(input.kind as ComponentKind, version.implementationKind);
|
|
352
375
|
return {
|
|
353
376
|
key,
|
|
354
377
|
name: text(input.name, "component.name", 2, 160),
|
|
@@ -356,7 +379,7 @@ export function parseComponentCreate(value: unknown): ComponentCreateInput {
|
|
|
356
379
|
kind: input.kind as ComponentKind,
|
|
357
380
|
effectClass: input.effect_class,
|
|
358
381
|
ownerKind: input.owner_kind,
|
|
359
|
-
version
|
|
382
|
+
version,
|
|
360
383
|
};
|
|
361
384
|
}
|
|
362
385
|
|
|
@@ -3,11 +3,13 @@ import { ApiError } from "./report-http";
|
|
|
3
3
|
import type { OperatorSession } from "./report-types";
|
|
4
4
|
import { validateJsonSchemaValue } from "./workflow-expressions";
|
|
5
5
|
import type { JsonObject, JsonValue } from "./workflow-platform-types";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
import {
|
|
7
|
+
assertComponentRuntimeMatchesKind,
|
|
8
|
+
type ComponentKind,
|
|
9
|
+
type ComponentCreateInput,
|
|
10
|
+
type ComponentVersionInput,
|
|
11
|
+
type ConnectionCreateInput,
|
|
12
|
+
type ConnectionVersionInput,
|
|
11
13
|
} from "./platform-registry-validation";
|
|
12
14
|
|
|
13
15
|
interface MutationRow {
|
|
@@ -615,10 +617,11 @@ export async function publishComponentVersion(
|
|
|
615
617
|
componentId: String(existingMutation.componentId), versionId: String(existingMutation.versionId),
|
|
616
618
|
version: Number(existingMutation.version), idempotentReplay: true,
|
|
617
619
|
};
|
|
618
|
-
const definition = await db.prepare(`SELECT status, effect_class FROM component_definitions WHERE id = ?1`)
|
|
619
|
-
.bind(componentId).first<{ status: string; effect_class: string }>();
|
|
620
|
+
const definition = await db.prepare(`SELECT status, effect_class, component_kind FROM component_definitions WHERE id = ?1`)
|
|
621
|
+
.bind(componentId).first<{ status: string; effect_class: string; component_kind: ComponentKind }>();
|
|
620
622
|
if (!definition) throw new ApiError(404, "component_not_found", "The component does not exist.");
|
|
621
623
|
if (definition.status === "archived") throw new ApiError(409, "component_archived", "Archived components cannot receive versions.");
|
|
624
|
+
assertComponentRuntimeMatchesKind(definition.component_kind, input.implementationKind);
|
|
622
625
|
if (!fixtureEvidence(input)) throw new ApiError(409, "component_fixture_gate_not_met", "Passing fixture evidence is required before component publication.");
|
|
623
626
|
if (definition.effect_class === "side_effecting" && !["human", "bounded_auto"].includes(String(input.approvalPolicy.mode))) throw new ApiError(409, "action_approval_policy_required", "A side-effecting version must preserve its approval policy.");
|
|
624
627
|
await requireComponentConnection(db, input);
|