Hetansh Waghela commited on
Commit
1b87b65
·
1 Parent(s): 2e15351

completing day 3

Browse files
apps/api/src/actions/actions.controller.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Get,
5
+ Headers,
6
+ HttpCode,
7
+ HttpStatus,
8
+ Inject,
9
+ NotFoundException,
10
+ Param,
11
+ Post,
12
+ } from "@nestjs/common";
13
+ import { eq, desc } from "drizzle-orm";
14
+ import { schema, type Database } from "@courtmitra/db";
15
+ import {
16
+ CreateActionPreviewInput,
17
+ GrantConsentInput,
18
+ PublicCasePageDto,
19
+ } from "@courtmitra/contracts";
20
+ import { parseOrThrow } from "../common/zod-validation.pipe.js";
21
+ import { DB } from "../db/db.module.js";
22
+ import { ActionsService } from "./actions.service.js";
23
+
24
+ @Controller()
25
+ export class ActionsController {
26
+ constructor(
27
+ @Inject(ActionsService) private readonly actions: ActionsService,
28
+ @Inject(DB) private readonly db: Database,
29
+ ) {}
30
+
31
+ @Post("api/missions/:missionId/actions/preview")
32
+ @HttpCode(HttpStatus.CREATED)
33
+ async preview(
34
+ @Param("missionId") missionId: string,
35
+ @Body() body: unknown,
36
+ @Headers("idempotency-key") idempotencyKey?: string,
37
+ ) {
38
+ const input = parseOrThrow(CreateActionPreviewInput, body);
39
+ return this.actions.previewAction(missionId, input, idempotencyKey);
40
+ }
41
+
42
+ @Post("api/actions/:actionId/consent")
43
+ async grantConsent(@Param("actionId") actionId: string) {
44
+ return this.actions.grantConsent(actionId);
45
+ }
46
+
47
+ @Post("api/actions/:actionId/execute")
48
+ @HttpCode(HttpStatus.ACCEPTED)
49
+ async execute(@Param("actionId") actionId: string) {
50
+ return this.actions.executeAction(actionId);
51
+ }
52
+
53
+ @Get("api/actions/:actionId")
54
+ async get(@Param("actionId") actionId: string) {
55
+ return this.actions.getAction(actionId);
56
+ }
57
+
58
+ @Get("api/public-pages/:slug")
59
+ async getPublicPage(@Param("slug") slug: string) {
60
+ const [pageRow] = await this.db
61
+ .select({
62
+ id: schema.publicCasePages.id,
63
+ missionId: schema.publicCasePages.missionId,
64
+ slug: schema.publicCasePages.slug,
65
+ visibility: schema.publicCasePages.visibility,
66
+ redactedSummary: schema.publicCasePages.redactedSummary,
67
+ createdAt: schema.publicCasePages.createdAt,
68
+ })
69
+ .from(schema.publicCasePages)
70
+ .where(eq(schema.publicCasePages.slug, slug))
71
+ .limit(1);
72
+
73
+ if (!pageRow) throw new NotFoundException(`Public page not found: ${slug}`);
74
+
75
+ const [missionRow] = await this.db
76
+ .select({
77
+ title: schema.missions.title,
78
+ issueDomain: schema.missions.issueDomain,
79
+ state: schema.missions.state,
80
+ })
81
+ .from(schema.missions)
82
+ .where(eq(schema.missions.id, pageRow.missionId))
83
+ .limit(1);
84
+
85
+ const latestActionRows = await this.db
86
+ .select({
87
+ id: schema.actions.id,
88
+ actionType: schema.actions.actionType,
89
+ title: schema.actions.title,
90
+ status: schema.actions.status,
91
+ proofState: schema.actions.proofState,
92
+ createdAt: schema.actions.createdAt,
93
+ })
94
+ .from(schema.actions)
95
+ .where(eq(schema.actions.missionId, pageRow.missionId))
96
+ .orderBy(desc(schema.actions.createdAt))
97
+ .limit(10);
98
+
99
+ const dto: PublicCasePageDto = {
100
+ id: pageRow.id,
101
+ missionId: pageRow.missionId,
102
+ slug: pageRow.slug,
103
+ visibility: pageRow.visibility,
104
+ redactedSummary: pageRow.redactedSummary,
105
+ createdAt: pageRow.createdAt.toISOString(),
106
+ };
107
+
108
+ return {
109
+ page: dto,
110
+ mission: missionRow
111
+ ? {
112
+ title: `User reports / unresolved / alleged issue`,
113
+ issueDomain: missionRow.issueDomain,
114
+ state: missionRow.state,
115
+ }
116
+ : null,
117
+ actions: latestActionRows.map((a) => ({
118
+ id: a.id,
119
+ actionType: a.actionType,
120
+ title: a.title,
121
+ status: a.status,
122
+ proofState: a.proofState,
123
+ createdAt: a.createdAt.toISOString(),
124
+ })),
125
+ };
126
+ }
127
+ }
apps/api/src/actions/actions.module.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from "@nestjs/common";
2
+ import { ActionsController } from "./actions.controller.js";
3
+ import { ActionsService } from "./actions.service.js";
4
+
5
+ @Module({
6
+ controllers: [ActionsController],
7
+ providers: [ActionsService],
8
+ })
9
+ export class ActionsModule {}
apps/api/src/actions/actions.service.ts ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
2
+ import { eq, and, desc, isNull } from "drizzle-orm";
3
+ import { schema, type Database } from "@courtmitra/db";
4
+ import {
5
+ computeConsentHash,
6
+ canonicalizeConsentPreview,
7
+ canExecute,
8
+ } from "@courtmitra/domain";
9
+ import { getCapabilities } from "@courtmitra/adapters";
10
+ import type {
11
+ ConsentPreview,
12
+ Action,
13
+ RoutePlanStepDto,
14
+ ActionPreviewDto,
15
+ ActionPreviewResponse,
16
+ ConsentGrantResponse,
17
+ ExecuteActionResponse,
18
+ ActionDetailDto,
19
+ ActionAttemptDto,
20
+ SubmissionDto,
21
+ ActionExecutionRefused,
22
+ CreateActionPreviewInput,
23
+ } from "@courtmitra/contracts";
24
+ import { DB } from "../db/db.module.js";
25
+ import { WorkflowService } from "../workflow/inngest.module.js";
26
+
27
+ @Injectable()
28
+ export class ActionsService {
29
+ constructor(
30
+ @Inject(DB) private readonly db: Database,
31
+ @Inject(WorkflowService) private readonly workflow: WorkflowService,
32
+ ) {}
33
+
34
+ async previewAction(
35
+ missionId: string,
36
+ input: CreateActionPreviewInput,
37
+ idempotencyKey?: string,
38
+ ): Promise<ActionPreviewDto> {
39
+ const [missionRow] = await this.db
40
+ .select({ id: schema.missions.id, userId: schema.missions.userId })
41
+ .from(schema.missions)
42
+ .where(eq(schema.missions.id, missionId))
43
+ .limit(1);
44
+ if (!missionRow) throw new NotFoundException(`Mission not found: ${missionId}`);
45
+
46
+ const [planRow] = await this.db
47
+ .select()
48
+ .from(schema.routePlans)
49
+ .where(
50
+ and(
51
+ eq(schema.routePlans.id, input.routePlanId),
52
+ eq(schema.routePlans.missionId, missionId),
53
+ isNull(schema.routePlans.supersededAt),
54
+ ),
55
+ )
56
+ .limit(1);
57
+ if (!planRow) throw new NotFoundException(`Route plan not found for mission: ${missionId}`);
58
+
59
+ const steps = planRow.steps as unknown as RoutePlanStepDto[];
60
+ const step = steps.find((s) => s.stepNo === input.stepNo);
61
+ if (!step) throw new NotFoundException(`Step ${input.stepNo} not found in route plan`);
62
+
63
+ const preview: ConsentPreview = {
64
+ actionType: step.actionType,
65
+ destination: input.destination,
66
+ public: false,
67
+ body: input.body,
68
+ attachments: input.attachments,
69
+ piiDisclosed: input.piiDisclosed,
70
+ risks: input.risks,
71
+ };
72
+
73
+ const hash = computeConsentHash(preview);
74
+
75
+ const canonicalized = canonicalizeConsentPreview(preview);
76
+
77
+ const [actionRow] = await this.db
78
+ .insert(schema.actions)
79
+ .values({
80
+ missionId,
81
+ routePlanId: input.routePlanId,
82
+ actionType: step.actionType,
83
+ title: step.title,
84
+ description: step.description,
85
+ inputPayload: canonicalized as Record<string, unknown>,
86
+ canonicalPayloadHash: hash,
87
+ consentRequired: true,
88
+ status: "preview_ready",
89
+ riskLevel: step.riskLevel,
90
+ createdBy: "user",
91
+ idempotencyKey: idempotencyKey ?? null,
92
+ })
93
+ .returning();
94
+
95
+ await this.db.insert(schema.missionStatusSnapshots).values({
96
+ missionId,
97
+ state: "actions_preview_ready",
98
+ substate: null,
99
+ headline: `Action preview ready: ${step.title}`,
100
+ blockedReason: null,
101
+ nextUserAction: "Review and approve the action to proceed.",
102
+ nextSystemAction: "Wait for user consent before execution.",
103
+ primaryButton: "Approve & send",
104
+ danger: false,
105
+ confidence: null,
106
+ });
107
+
108
+ const previewResponse: ActionPreviewResponse = {
109
+ actionId: actionRow!.id,
110
+ actionType: step.actionType,
111
+ destination: input.destination,
112
+ public: false,
113
+ bodyPreview: input.body.slice(0, 500),
114
+ attachments: input.attachments,
115
+ piiDisclosed: input.piiDisclosed,
116
+ risks: input.risks,
117
+ actionPreviewHash: hash,
118
+ proofStateAfter: "draft_only",
119
+ };
120
+
121
+ const action: Action = {
122
+ id: actionRow!.id,
123
+ missionId: actionRow!.missionId,
124
+ routePlanId: actionRow!.routePlanId,
125
+ actionType: actionRow!.actionType as Action["actionType"],
126
+ title: actionRow!.title,
127
+ description: actionRow!.description,
128
+ target: actionRow!.target as Record<string, unknown> | null,
129
+ inputPayload: actionRow!.inputPayload as Record<string, unknown> | null,
130
+ canonicalPayloadHash: actionRow!.canonicalPayloadHash,
131
+ consentRequired: actionRow!.consentRequired,
132
+ consentGrantId: actionRow!.consentGrantId,
133
+ status: actionRow!.status as Action["status"],
134
+ proofState: actionRow!.proofState as Action["proofState"],
135
+ proofRef: actionRow!.proofRef as Record<string, unknown> | null,
136
+ idempotencyKey: actionRow!.idempotencyKey,
137
+ riskLevel: actionRow!.riskLevel as Action["riskLevel"],
138
+ createdBy: actionRow!.createdBy as Action["createdBy"],
139
+ createdAt: actionRow!.createdAt.toISOString(),
140
+ updatedAt: actionRow!.updatedAt.toISOString(),
141
+ };
142
+
143
+ return {
144
+ action,
145
+ canonicalPayloadHash: hash,
146
+ preview: previewResponse,
147
+ };
148
+ }
149
+
150
+ async grantConsent(actionId: string): Promise<ConsentGrantResponse> {
151
+ const [actionRow] = await this.db
152
+ .select()
153
+ .from(schema.actions)
154
+ .where(eq(schema.actions.id, actionId))
155
+ .limit(1);
156
+ if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`);
157
+
158
+ const [missionRow] = await this.db
159
+ .select({ id: schema.missions.id, userId: schema.missions.userId })
160
+ .from(schema.missions)
161
+ .where(eq(schema.missions.id, actionRow.missionId))
162
+ .limit(1);
163
+ if (!missionRow) throw new NotFoundException(`Mission not found: ${actionRow.missionId}`);
164
+
165
+ const [existingGrant] = await this.db
166
+ .select()
167
+ .from(schema.consentGrants)
168
+ .where(
169
+ and(
170
+ eq(schema.consentGrants.missionId, actionRow.missionId),
171
+ eq(schema.consentGrants.actionPreviewHash, actionRow.canonicalPayloadHash ?? ""),
172
+ isNull(schema.consentGrants.revokedAt),
173
+ ),
174
+ )
175
+ .orderBy(desc(schema.consentGrants.grantedAt))
176
+ .limit(1);
177
+
178
+ if (existingGrant) {
179
+ // Link the existing consent to this action (it was created for a sibling action with the same hash).
180
+ await this.db
181
+ .update(schema.actions)
182
+ .set({ consentGrantId: existingGrant.id, status: "approved", updatedAt: new Date() })
183
+ .where(eq(schema.actions.id, actionId));
184
+
185
+ return {
186
+ id: existingGrant.id,
187
+ actionId: actionRow.id,
188
+ actionPreviewHash: existingGrant.actionPreviewHash,
189
+ grantedAt: existingGrant.grantedAt?.toISOString() ?? new Date().toISOString(),
190
+ };
191
+ }
192
+
193
+ const now = new Date();
194
+ const [grant] = await this.db
195
+ .insert(schema.consentGrants)
196
+ .values({
197
+ missionId: actionRow.missionId,
198
+ userId: missionRow.userId,
199
+ actionType: actionRow.actionType,
200
+ actionPreviewHash: actionRow.canonicalPayloadHash ?? "",
201
+ consentText: "I approve this action",
202
+ channel: "web",
203
+ grantedAt: now,
204
+ })
205
+ .returning();
206
+
207
+ await this.db
208
+ .update(schema.actions)
209
+ .set({
210
+ consentGrantId: grant!.id,
211
+ status: "approved",
212
+ updatedAt: new Date(),
213
+ })
214
+ .where(eq(schema.actions.id, actionId));
215
+
216
+ return {
217
+ id: grant!.id,
218
+ actionId: actionRow.id,
219
+ actionPreviewHash: grant!.actionPreviewHash,
220
+ grantedAt: grant!.grantedAt?.toISOString() ?? now.toISOString(),
221
+ };
222
+ }
223
+
224
+ async executeAction(
225
+ actionId: string,
226
+ ): Promise<ExecuteActionResponse | ActionExecutionRefused> {
227
+ const [actionRow] = await this.db
228
+ .select()
229
+ .from(schema.actions)
230
+ .where(eq(schema.actions.id, actionId))
231
+ .limit(1);
232
+ if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`);
233
+
234
+ if (actionRow.status === "succeeded" || actionRow.proofState === "sent_by_email") {
235
+ return {
236
+ refused: true,
237
+ reason: "This action has already been executed.",
238
+ code: "idempotency_key_used",
239
+ };
240
+ }
241
+
242
+ let consentGrant: { actionPreviewHash: string } | null = null;
243
+ if (actionRow.consentGrantId) {
244
+ const [grantRow] = await this.db
245
+ .select({ actionPreviewHash: schema.consentGrants.actionPreviewHash })
246
+ .from(schema.consentGrants)
247
+ .where(eq(schema.consentGrants.id, actionRow.consentGrantId))
248
+ .limit(1);
249
+ if (grantRow) {
250
+ consentGrant = grantRow;
251
+ }
252
+ }
253
+
254
+ const capabilities = getCapabilities();
255
+ const action: Action = {
256
+ id: actionRow.id,
257
+ missionId: actionRow.missionId,
258
+ routePlanId: actionRow.routePlanId,
259
+ actionType: actionRow.actionType as Action["actionType"],
260
+ title: actionRow.title,
261
+ description: actionRow.description,
262
+ target: null,
263
+ inputPayload: actionRow.inputPayload as Record<string, unknown> | null,
264
+ canonicalPayloadHash: actionRow.canonicalPayloadHash,
265
+ consentRequired: actionRow.consentRequired,
266
+ consentGrantId: actionRow.consentGrantId,
267
+ status: actionRow.status as Action["status"],
268
+ proofState: actionRow.proofState as Action["proofState"],
269
+ proofRef: null,
270
+ idempotencyKey: actionRow.idempotencyKey,
271
+ riskLevel: actionRow.riskLevel as Action["riskLevel"],
272
+ createdBy: actionRow.createdBy as Action["createdBy"],
273
+ createdAt: actionRow.createdAt.toISOString(),
274
+ updatedAt: actionRow.updatedAt.toISOString(),
275
+ };
276
+
277
+ const outgoingPreview: ConsentPreview = {
278
+ actionType: actionRow.actionType,
279
+ destination: (actionRow.inputPayload as Record<string, unknown>)?.["destination"] as string ?? "",
280
+ public: false,
281
+ body: (actionRow.inputPayload as Record<string, unknown>)?.["body"] as string ?? "",
282
+ attachments: (actionRow.inputPayload as Record<string, unknown>)?.["attachments"] as ConsentPreview["attachments"] ?? [],
283
+ piiDisclosed: (actionRow.inputPayload as Record<string, unknown>)?.["piiDisclosed"] as string[] ?? [],
284
+ risks: (actionRow.inputPayload as Record<string, unknown>)?.["risks"] as string[] ?? [],
285
+ };
286
+
287
+ const gateResult = canExecute(action, consentGrant, outgoingPreview, capabilities);
288
+ if (gateResult.refused) {
289
+ return gateResult;
290
+ }
291
+
292
+ // Delegate execution to the Inngest worker. We DO NOT change status here —
293
+ // the worker's executor sets it to "executing" inside its own transaction,
294
+ // and canExecute allows "executing" so retries work.
295
+ await this.workflow.send("action.execute_requested" as any, {
296
+ actionId: actionRow.id,
297
+ missionId: actionRow.missionId,
298
+ } as any);
299
+
300
+ return {
301
+ actionId: actionRow.id,
302
+ proofState: "draft_only",
303
+ status: "approved",
304
+ attempt: null,
305
+ };
306
+ }
307
+
308
+ async getAction(actionId: string): Promise<ActionDetailDto> {
309
+ const [actionRow] = await this.db
310
+ .select()
311
+ .from(schema.actions)
312
+ .where(eq(schema.actions.id, actionId))
313
+ .limit(1);
314
+ if (!actionRow) throw new NotFoundException(`Action not found: ${actionId}`);
315
+
316
+ const [latestAttempt] = await this.db
317
+ .select()
318
+ .from(schema.actionAttempts)
319
+ .where(eq(schema.actionAttempts.actionId, actionId))
320
+ .orderBy(desc(schema.actionAttempts.attemptNo))
321
+ .limit(1);
322
+
323
+ const submissionRows = await this.db
324
+ .select()
325
+ .from(schema.submissions)
326
+ .where(eq(schema.submissions.actionId, actionId))
327
+ .orderBy(desc(schema.submissions.createdAt));
328
+
329
+ const action: Action = {
330
+ id: actionRow.id,
331
+ missionId: actionRow.missionId,
332
+ routePlanId: actionRow.routePlanId,
333
+ actionType: actionRow.actionType as Action["actionType"],
334
+ title: actionRow.title,
335
+ description: actionRow.description,
336
+ target: actionRow.target as Record<string, unknown> | null,
337
+ inputPayload: actionRow.inputPayload as Record<string, unknown> | null,
338
+ canonicalPayloadHash: actionRow.canonicalPayloadHash,
339
+ consentRequired: actionRow.consentRequired,
340
+ consentGrantId: actionRow.consentGrantId,
341
+ status: actionRow.status as Action["status"],
342
+ proofState: actionRow.proofState as Action["proofState"],
343
+ proofRef: actionRow.proofRef as Record<string, unknown> | null,
344
+ idempotencyKey: actionRow.idempotencyKey,
345
+ riskLevel: actionRow.riskLevel as Action["riskLevel"],
346
+ createdBy: actionRow.createdBy as Action["createdBy"],
347
+ createdAt: actionRow.createdAt.toISOString(),
348
+ updatedAt: actionRow.updatedAt.toISOString(),
349
+ };
350
+
351
+ const latestAttemptDto: ActionAttemptDto | null = latestAttempt
352
+ ? {
353
+ id: latestAttempt.id,
354
+ adapterName: latestAttempt.adapterName,
355
+ attemptNo: latestAttempt.attemptNo,
356
+ status: latestAttempt.status,
357
+ errorCode: latestAttempt.errorCode,
358
+ errorMessage: latestAttempt.errorMessage,
359
+ startedAt: latestAttempt.startedAt?.toISOString() ?? null,
360
+ finishedAt: latestAttempt.finishedAt?.toISOString() ?? null,
361
+ }
362
+ : null;
363
+
364
+ const submissions: SubmissionDto[] = submissionRows.map((s) => ({
365
+ id: s.id,
366
+ proofState: s.proofState as SubmissionDto["proofState"],
367
+ externalReference: s.externalReference,
368
+ sentAt: s.sentAt?.toISOString() ?? null,
369
+ }));
370
+
371
+ return {
372
+ action,
373
+ latestAttempt: latestAttemptDto,
374
+ submissions,
375
+ };
376
+ }
377
+ }
apps/api/src/app.module.ts CHANGED
@@ -6,6 +6,7 @@ import { ConversationsModule } from "./conversations/conversations.module.js";
6
  import { WebhooksModule } from "./webhooks/webhooks.module.js";
7
  import { EvidenceModule } from "./evidence/evidence.module.js";
8
  import { RoutePlansModule } from "./route-plans/route-plans.module.js";
 
9
  import { CapabilitiesController } from "./capabilities/capabilities.controller.js";
10
  import { HealthController } from "./health.controller.js";
11
 
@@ -18,6 +19,7 @@ import { HealthController } from "./health.controller.js";
18
  WebhooksModule,
19
  EvidenceModule,
20
  RoutePlansModule,
 
21
  ],
22
  controllers: [CapabilitiesController, HealthController],
23
  })
 
6
  import { WebhooksModule } from "./webhooks/webhooks.module.js";
7
  import { EvidenceModule } from "./evidence/evidence.module.js";
8
  import { RoutePlansModule } from "./route-plans/route-plans.module.js";
9
+ import { ActionsModule } from "./actions/actions.module.js";
10
  import { CapabilitiesController } from "./capabilities/capabilities.controller.js";
11
  import { HealthController } from "./health.controller.js";
12
 
 
19
  WebhooksModule,
20
  EvidenceModule,
21
  RoutePlansModule,
22
+ ActionsModule,
23
  ],
24
  controllers: [CapabilitiesController, HealthController],
25
  })
apps/api/src/workflow/inngest.module.ts CHANGED
@@ -23,7 +23,11 @@ export class WorkflowService {
23
  | "message.received"
24
  | "evidence.uploaded"
25
  | "mission.route_plan_requested"
26
- | "mission.facts_confirmed",
 
 
 
 
27
  data: Record<string, string | boolean>,
28
  ): Promise<void> {
29
  await this.inngest.send({ name, data });
 
23
  | "message.received"
24
  | "evidence.uploaded"
25
  | "mission.route_plan_requested"
26
+ | "mission.facts_confirmed"
27
+ | "action.execute_requested"
28
+ | "action.executed"
29
+ | "action.failed"
30
+ | "mission.followup_due",
31
  data: Record<string, string | boolean>,
32
  ): Promise<void> {
33
  await this.inngest.send({ name, data });
apps/web/src/app/missions/[id]/page.tsx CHANGED
@@ -2,13 +2,40 @@
2
 
3
  import { use, useCallback, useEffect, useState } from "react";
4
  import Link from "next/link";
5
- import type { Mission, MissionEvent, MissionStatusSnapshot } from "@courtmitra/contracts";
 
 
 
 
 
 
 
 
6
  import { api, type EvidenceItem, type FactsResponse, type RoutePlanDto } from "../../../lib/api";
7
 
8
  // ---------------------------------------------------------------------------
9
  // Support-level badge
10
  // ---------------------------------------------------------------------------
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  function SupportBadge({ level }: { level: string }) {
13
  const cls =
14
  level === "explicit"
@@ -476,6 +503,10 @@ interface RoutePlanPanelProps {
476
  triggering: boolean;
477
  triggerError: string | null;
478
  onTrigger: () => void;
 
 
 
 
479
  }
480
 
481
  function RoutePlanPanel({
@@ -484,8 +515,14 @@ function RoutePlanPanel({
484
  triggering,
485
  triggerError,
486
  onTrigger,
 
 
 
 
487
  }: RoutePlanPanelProps) {
488
  const [expandedStep, setExpandedStep] = useState<number | null>(null);
 
 
489
 
490
  if (loading) {
491
  return (
@@ -705,6 +742,127 @@ function RoutePlanPanel({
705
  Neutral guidance — not legal advice
706
  </div>
707
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  </div>
709
  )}
710
  </div>
@@ -732,6 +890,7 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
732
  const [error, setError] = useState<string | null>(null);
733
  const [confirming, setConfirming] = useState(false);
734
  const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
 
735
 
736
  const load = useCallback(async () => {
737
  setError(null);
@@ -798,6 +957,71 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
798
  }
799
  }
800
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  if (error) return <div className="container"><div className="error">{error}</div></div>;
802
  if (!mission) return <div className="container"><div className="muted">Loading…</div></div>;
803
 
@@ -912,6 +1136,10 @@ export default function MissionRoom({ params }: { params: Promise<{ id: string }
912
  triggering={triggering}
913
  triggerError={triggerError}
914
  onTrigger={() => void handleTriggerRoutePlan()}
 
 
 
 
915
  />
916
 
917
  {/* ---------------------------------------------------------------- */}
 
2
 
3
  import { use, useCallback, useEffect, useState } from "react";
4
  import Link from "next/link";
5
+ import type {
6
+ Mission,
7
+ MissionEvent,
8
+ MissionStatusSnapshot,
9
+ ActionPreviewDto,
10
+ ConsentGrantResponse,
11
+ ExecuteActionResponse,
12
+ CreateActionPreviewInput,
13
+ } from "@courtmitra/contracts";
14
  import { api, type EvidenceItem, type FactsResponse, type RoutePlanDto } from "../../../lib/api";
15
 
16
  // ---------------------------------------------------------------------------
17
  // Support-level badge
18
  // ---------------------------------------------------------------------------
19
 
20
+ type PerStepActionState = {
21
+ preview: ActionPreviewDto | null;
22
+ consent: ConsentGrantResponse | null;
23
+ execution: ExecuteActionResponse | null;
24
+ error: string | null;
25
+ loading: boolean;
26
+ };
27
+
28
+ function ProofStateBadge({ state }: { state: string | null }) {
29
+ if (!state) return null;
30
+ const cls =
31
+ state === "sent_by_email"
32
+ ? "badge badge-explicit"
33
+ : state === "failed"
34
+ ? "badge badge-contradicted"
35
+ : "badge badge-weak";
36
+ return <span className={cls}>{state}</span>;
37
+ }
38
+
39
  function SupportBadge({ level }: { level: string }) {
40
  const cls =
41
  level === "explicit"
 
503
  triggering: boolean;
504
  triggerError: string | null;
505
  onTrigger: () => void;
506
+ actionStates: Record<number, PerStepActionState>;
507
+ onPreviewAction: (stepNo: number, destination: string, body: string) => void;
508
+ onGrantConsent: (actionId: string) => void;
509
+ onExecuteAction: (actionId: string) => void;
510
  }
511
 
512
  function RoutePlanPanel({
 
515
  triggering,
516
  triggerError,
517
  onTrigger,
518
+ actionStates,
519
+ onPreviewAction,
520
+ onGrantConsent,
521
+ onExecuteAction,
522
  }: RoutePlanPanelProps) {
523
  const [expandedStep, setExpandedStep] = useState<number | null>(null);
524
+ const [destinationInputs, setDestinationInputs] = useState<Record<number, string>>({});
525
+ const [bodyInputs, setBodyInputs] = useState<Record<number, string>>({});
526
 
527
  if (loading) {
528
  return (
 
742
  Neutral guidance — not legal advice
743
  </div>
744
  )}
745
+
746
+ {/* Action preview / consent / send */}
747
+ <div style={{ marginTop: 16, borderTop: "1px solid var(--border)", paddingTop: 14 }}>
748
+ <div className="muted" style={{ fontSize: 12, marginBottom: 8 }}>
749
+ Action
750
+ </div>
751
+ <input
752
+ placeholder={`Email destination (default: ${step.authority ?? "authority"})`}
753
+ value={destinationInputs[step.stepNo] ?? ""}
754
+ onChange={(e) =>
755
+ setDestinationInputs((prev) => ({ ...prev, [step.stepNo]: e.target.value }))
756
+ }
757
+ style={{ marginBottom: 8 }}
758
+ />
759
+ <textarea
760
+ placeholder="Action body (will be filled from template)"
761
+ value={bodyInputs[step.stepNo] ?? ""}
762
+ onChange={(e) =>
763
+ setBodyInputs((prev) => ({ ...prev, [step.stepNo]: e.target.value }))
764
+ }
765
+ style={{ minHeight: 80, marginBottom: 8, fontSize: 13 }}
766
+ />
767
+ <button
768
+ className="btn-secondary btn-small"
769
+ disabled={actionStates[step.stepNo]?.loading}
770
+ onClick={(e) => {
771
+ e.stopPropagation();
772
+ onPreviewAction(
773
+ step.stepNo,
774
+ destinationInputs[step.stepNo] ?? step.authority ?? "",
775
+ bodyInputs[step.stepNo] ?? "",
776
+ );
777
+ }}
778
+ >
779
+ {actionStates[step.stepNo]?.loading ? "Generating…" : "Preview Action"}
780
+ </button>
781
+
782
+ {actionStates[step.stepNo]?.error && (
783
+ <div className="error" style={{ marginTop: 6 }}>
784
+ {actionStates[step.stepNo]!.error}
785
+ </div>
786
+ )}
787
+
788
+ {actionStates[step.stepNo]?.preview && !actionStates[step.stepNo]?.consent && (
789
+ <div
790
+ style={{
791
+ marginTop: 12,
792
+ padding: "10px 14px",
793
+ background: "var(--panel-2)",
794
+ border: "1px solid var(--border)",
795
+ borderRadius: 8,
796
+ }}
797
+ onClick={(e) => e.stopPropagation()}
798
+ >
799
+ <div className="muted" style={{ fontSize: 11, marginBottom: 6 }}>
800
+ Preview — hash:{" "}
801
+ {actionStates[step.stepNo]!.preview!.canonicalPayloadHash.slice(0, 16)}…
802
+ </div>
803
+ <pre
804
+ style={{
805
+ fontSize: 12,
806
+ whiteSpace: "pre-wrap",
807
+ wordBreak: "break-word",
808
+ maxHeight: 120,
809
+ overflow: "auto",
810
+ margin: 0,
811
+ color: "var(--muted)",
812
+ }}
813
+ >
814
+ {actionStates[step.stepNo]!.preview!.preview.bodyPreview}
815
+ </pre>
816
+ <label style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 10, cursor: "pointer" }}>
817
+ <input type="checkbox" id={`consent-${step.stepNo}`} style={{ width: "auto" }} />
818
+ <span style={{ fontSize: 13, color: "var(--text)" }}>
819
+ I approve this action
820
+ </span>
821
+ </label>
822
+ <button
823
+ style={{ marginTop: 8 }}
824
+ disabled={actionStates[step.stepNo]?.loading}
825
+ onClick={() => {
826
+ const cb = document.getElementById(`consent-${step.stepNo}`) as HTMLInputElement | null;
827
+ if (!cb?.checked) return;
828
+ onGrantConsent(actionStates[step.stepNo]!.preview!.action.id);
829
+ }}
830
+ >
831
+ Approve &amp; Send
832
+ </button>
833
+ </div>
834
+ )}
835
+
836
+ {actionStates[step.stepNo]?.consent && !actionStates[step.stepNo]?.execution && (
837
+ <div style={{ marginTop: 10 }} onClick={(e) => e.stopPropagation()}>
838
+ <button
839
+ disabled={actionStates[step.stepNo]?.loading}
840
+ onClick={() =>
841
+ onExecuteAction(actionStates[step.stepNo]!.preview!.action.id)
842
+ }
843
+ >
844
+ {actionStates[step.stepNo]?.loading ? "Sending…" : "Send Now"}
845
+ </button>
846
+ </div>
847
+ )}
848
+
849
+ {actionStates[step.stepNo]?.execution && (
850
+ <div style={{ marginTop: 8 }} onClick={(e) => e.stopPropagation()}>
851
+ <ProofStateBadge
852
+ state={
853
+ (actionStates[step.stepNo]!.execution as ExecuteActionResponse & { refused?: boolean }).refused
854
+ ? null
855
+ : (actionStates[step.stepNo]!.execution as ExecuteActionResponse).proofState
856
+ }
857
+ />
858
+ {actionStates[step.stepNo]!.execution && "refused" in actionStates[step.stepNo]!.execution! && (
859
+ <div className="error" style={{ marginTop: 4 }}>
860
+ {(actionStates[step.stepNo]!.execution as { reason?: string }).reason ?? "Execution refused"}
861
+ </div>
862
+ )}
863
+ </div>
864
+ )}
865
+ </div>
866
  </div>
867
  )}
868
  </div>
 
890
  const [error, setError] = useState<string | null>(null);
891
  const [confirming, setConfirming] = useState(false);
892
  const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
893
+ const [actionStates, setActionStates] = useState<Record<number, PerStepActionState>>({});
894
 
895
  const load = useCallback(async () => {
896
  setError(null);
 
957
  }
958
  }
959
 
960
+ function updateActionState(
961
+ stepNoOrActionId: number | string,
962
+ patch: Partial<PerStepActionState>,
963
+ ) {
964
+ setActionStates((prev) => {
965
+ const stepNo =
966
+ typeof stepNoOrActionId === "number"
967
+ ? stepNoOrActionId
968
+ : Number(
969
+ Object.keys(prev).find(
970
+ (k) => prev[Number(k)]?.preview?.action.id === stepNoOrActionId,
971
+ ),
972
+ );
973
+ return { ...prev, [stepNo]: { ...(prev[stepNo] ?? emptyActionState()), ...patch } };
974
+ });
975
+ }
976
+
977
+ function emptyActionState(): PerStepActionState {
978
+ return { preview: null, consent: null, execution: null, error: null, loading: false };
979
+ }
980
+
981
+ async function handlePreviewAction(stepNo: number, destination: string, body: string) {
982
+ if (!routePlan || !mission) return;
983
+ const step = routePlan.steps.find((s) => s.stepNo === stepNo);
984
+ if (!step) return;
985
+ const routePlanId = mission.currentRoutePlanId ?? routePlan.missionId;
986
+ updateActionState(stepNo, { preview: null, consent: null, execution: null, error: null, loading: true });
987
+ try {
988
+ const input: CreateActionPreviewInput = {
989
+ routePlanId,
990
+ stepNo,
991
+ destination,
992
+ subject: `Complaint regarding ${step.title}`,
993
+ body,
994
+ attachments: [],
995
+ piiDisclosed: [],
996
+ risks: [],
997
+ };
998
+ const result = await api.previewAction(id, input);
999
+ updateActionState(stepNo, { preview: result, loading: false });
1000
+ } catch (e) {
1001
+ updateActionState(stepNo, { error: String(e), loading: false });
1002
+ }
1003
+ }
1004
+
1005
+ async function handleGrantConsent(actionId: string) {
1006
+ updateActionState(actionId, { loading: true, error: null });
1007
+ try {
1008
+ const consent = await api.grantConsent(actionId);
1009
+ updateActionState(actionId, { consent, loading: false });
1010
+ } catch (e) {
1011
+ updateActionState(actionId, { error: String(e), loading: false });
1012
+ }
1013
+ }
1014
+
1015
+ async function handleExecuteAction(actionId: string) {
1016
+ updateActionState(actionId, { loading: true, error: null });
1017
+ try {
1018
+ const execution = await api.executeAction(actionId);
1019
+ updateActionState(actionId, { execution, loading: false });
1020
+ } catch (e) {
1021
+ updateActionState(actionId, { error: String(e), loading: false });
1022
+ }
1023
+ }
1024
+
1025
  if (error) return <div className="container"><div className="error">{error}</div></div>;
1026
  if (!mission) return <div className="container"><div className="muted">Loading…</div></div>;
1027
 
 
1136
  triggering={triggering}
1137
  triggerError={triggerError}
1138
  onTrigger={() => void handleTriggerRoutePlan()}
1139
+ actionStates={actionStates}
1140
+ onPreviewAction={(stepNo, dest, body) => void handlePreviewAction(stepNo, dest, body)}
1141
+ onGrantConsent={(actionId) => void handleGrantConsent(actionId)}
1142
+ onExecuteAction={(actionId) => void handleExecuteAction(actionId)}
1143
  />
1144
 
1145
  {/* ---------------------------------------------------------------- */}
apps/web/src/app/p/[slug]/page.tsx ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { notFound } from "next/navigation";
2
+ import type { Metadata } from "next";
3
+
4
+ const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
5
+
6
+ export const metadata: Metadata = {
7
+ robots: "noindex, nofollow",
8
+ title: "Case page — CourtMitra",
9
+ };
10
+
11
+ interface PublicPageResponse {
12
+ page: {
13
+ id: string;
14
+ missionId: string;
15
+ slug: string;
16
+ visibility: string;
17
+ redactedSummary: string | null;
18
+ createdAt: string;
19
+ };
20
+ mission: {
21
+ title: string;
22
+ issueDomain: string;
23
+ state: string;
24
+ } | null;
25
+ actions: Array<{
26
+ id: string;
27
+ actionType: string;
28
+ title: string;
29
+ status: string;
30
+ proofState: string | null;
31
+ createdAt: string;
32
+ }>;
33
+ }
34
+
35
+ function ProofStateBadge({ state }: { state: string | null }) {
36
+ if (!state) return null;
37
+ const cls =
38
+ state === "sent_by_email"
39
+ ? "badge badge-explicit"
40
+ : state === "failed"
41
+ ? "badge badge-contradicted"
42
+ : "badge badge-weak";
43
+ return <span className={cls}>{state}</span>;
44
+ }
45
+
46
+ export default async function PublicCasePage({
47
+ params,
48
+ }: {
49
+ params: Promise<{ slug: string }>;
50
+ }) {
51
+ const { slug } = await params;
52
+
53
+ let data: PublicPageResponse;
54
+ try {
55
+ const res = await fetch(`${API_BASE}/public-pages/${slug}`, {
56
+ cache: "no-store",
57
+ });
58
+ if (!res.ok) {
59
+ if (res.status === 404) notFound();
60
+ throw new Error(`Failed to load: ${res.status}`);
61
+ }
62
+ data = (await res.json()) as PublicPageResponse;
63
+ } catch {
64
+ notFound();
65
+ }
66
+
67
+ return (
68
+ <div className="container">
69
+ <meta name="robots" content="noindex,nofollow" />
70
+ <div className="header">
71
+ <div>
72
+ <div className="brand">Case page</div>
73
+ <div className="muted" style={{ marginTop: 4 }}>
74
+ CourtMitra — legal information &amp; workflow assistance only
75
+ </div>
76
+ </div>
77
+ </div>
78
+
79
+ <div className="card">
80
+ <div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
81
+ Status
82
+ </div>
83
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
84
+ <span className="pill domain">
85
+ {data.mission?.issueDomain ?? "unresolved"}
86
+ </span>
87
+ <span className="pill">{data.mission?.state ?? "unknown"}</span>
88
+ </div>
89
+ </div>
90
+
91
+ <div className="card">
92
+ <div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
93
+ Subject
94
+ </div>
95
+ <div style={{ lineHeight: 1.6, fontSize: 14 }}>
96
+ User reports / unresolved / alleged issue
97
+ </div>
98
+ </div>
99
+
100
+ <div className="card">
101
+ <div className="muted" style={{ fontSize: 12, marginBottom: 6 }}>
102
+ Summary
103
+ </div>
104
+ <div style={{ lineHeight: 1.6, fontSize: 14, whiteSpace: "pre-wrap" }}>
105
+ {data.page.redactedSummary ?? "No summary available."}
106
+ </div>
107
+ </div>
108
+
109
+ <h3 className="muted" style={{ marginTop: 24, marginBottom: 12 }}>
110
+ Actions
111
+ </h3>
112
+ {data.actions.length === 0 ? (
113
+ <div className="card">
114
+ <div className="empty-state">No actions recorded yet.</div>
115
+ </div>
116
+ ) : (
117
+ data.actions.map((action) => (
118
+ <div
119
+ key={action.id}
120
+ className="card"
121
+ style={{ marginBottom: 10 }}
122
+ >
123
+ <div style={{ fontWeight: 600, fontSize: 14 }}>{action.title}</div>
124
+ <div style={{ display: "flex", gap: 8, marginTop: 6, flexWrap: "wrap" }}>
125
+ <span className="pill">{action.actionType}</span>
126
+ <span className="pill">{action.status}</span>
127
+ <ProofStateBadge state={action.proofState} />
128
+ </div>
129
+ <div className="muted" style={{ marginTop: 6, fontSize: 12 }}>
130
+ {new Date(action.createdAt).toLocaleString()}
131
+ </div>
132
+ </div>
133
+ ))
134
+ )}
135
+
136
+ <div
137
+ style={{
138
+ marginTop: 32,
139
+ padding: "14px 18px",
140
+ background: "var(--panel-2)",
141
+ border: "1px solid var(--border)",
142
+ borderRadius: 10,
143
+ fontSize: 12,
144
+ color: "var(--muted)",
145
+ lineHeight: 1.7,
146
+ }}
147
+ >
148
+ Legal information &amp; workflow assistance only — not a lawyer-client
149
+ relationship. CourtMitra does not guarantee outcomes. Entity labels use
150
+ &ldquo;user reports / unresolved / alleged&rdquo; wording only.
151
+ </div>
152
+ </div>
153
+ );
154
+ }
apps/web/src/lib/api.ts CHANGED
@@ -7,6 +7,11 @@ import type {
7
  ParsedEvidenceDto,
8
  EvidenceClaimDto,
9
  RoutePlanDto,
 
 
 
 
 
10
  } from "@courtmitra/contracts";
11
 
12
  const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
@@ -116,4 +121,28 @@ export const api = {
116
  headers: { "idempotency-key": crypto.randomUUID() },
117
  body: JSON.stringify({}),
118
  }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  };
 
7
  ParsedEvidenceDto,
8
  EvidenceClaimDto,
9
  RoutePlanDto,
10
+ CreateActionPreviewInput,
11
+ ActionPreviewDto,
12
+ ConsentGrantResponse,
13
+ ExecuteActionResponse,
14
+ ActionDetailDto,
15
  } from "@courtmitra/contracts";
16
 
17
  const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001/api";
 
121
  headers: { "idempotency-key": crypto.randomUUID() },
122
  body: JSON.stringify({}),
123
  }),
124
+
125
+ // --- Action endpoints ---
126
+ previewAction: (missionId: string, input: CreateActionPreviewInput) =>
127
+ http<ActionPreviewDto>(`/missions/${missionId}/actions/preview`, {
128
+ method: "POST",
129
+ headers: { "idempotency-key": crypto.randomUUID() },
130
+ body: JSON.stringify(input),
131
+ }),
132
+
133
+ grantConsent: (actionId: string) =>
134
+ http<ConsentGrantResponse>(`/actions/${actionId}/consent`, {
135
+ method: "POST",
136
+ body: JSON.stringify({}),
137
+ }),
138
+
139
+ executeAction: (actionId: string) =>
140
+ http<ExecuteActionResponse>(`/actions/${actionId}/execute`, {
141
+ method: "POST",
142
+ headers: { "idempotency-key": crypto.randomUUID() },
143
+ body: JSON.stringify({}),
144
+ }),
145
+
146
+ getActionDetail: (actionId: string) =>
147
+ http<ActionDetailDto>(`/actions/${actionId}`),
148
  };
apps/worker/src/functions/action-execute.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Inngest, type InngestFunction } from "inngest";
2
+ import { db } from "../deps.js";
3
+ import { executeAction } from "../lib/action-executor.js";
4
+ import { createResendEmailAdapter, getCapabilities } from "@courtmitra/adapters";
5
+ import type { ExecuteActionResponse, ActionExecutionRefused } from "@courtmitra/contracts";
6
+
7
+ export function createActionExecute(inngest: Inngest): InngestFunction.Any {
8
+ return inngest.createFunction(
9
+ {
10
+ id: "action-execute",
11
+ name: "Execute Action",
12
+ triggers: [{ event: "action.execute_requested" }],
13
+ },
14
+ async ({ event, step }) => {
15
+ const { actionId, missionId } = event.data as {
16
+ actionId: string;
17
+ missionId: string;
18
+ };
19
+
20
+ const result = await step.run("execute-action", async () => {
21
+ const emailAdapter = createResendEmailAdapter();
22
+ const capabilities = getCapabilities();
23
+ return executeAction(actionId, { db, emailAdapter, capabilities });
24
+ });
25
+
26
+ await step.run("emit-execution-event", async () => {
27
+ if ("refused" in result && (result as ActionExecutionRefused).refused) {
28
+ await inngest.send({
29
+ name: "action.failed" as const,
30
+ data: { actionId, missionId, error: (result as ActionExecutionRefused).reason },
31
+ });
32
+ return;
33
+ }
34
+
35
+ const execResult = result as ExecuteActionResponse;
36
+ await inngest.send({
37
+ name: "action.executed" as const,
38
+ data: { actionId, missionId, proofState: execResult.proofState },
39
+ });
40
+ });
41
+
42
+ return result;
43
+ },
44
+ ) as InngestFunction.Any;
45
+ }
apps/worker/src/functions/followup-schedule.ts ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { eq, desc, and } from "drizzle-orm";
2
+ import { Inngest, type InngestFunction } from "inngest";
3
+ import { db } from "../deps.js";
4
+ import { schema } from "@courtmitra/db";
5
+
6
+ export function createFollowupSchedule(inngest: Inngest): InngestFunction.Any {
7
+ return inngest.createFunction(
8
+ {
9
+ id: "followup-schedule",
10
+ name: "Schedule Follow-up",
11
+ triggers: [{ event: "action.executed" }],
12
+ },
13
+ async ({ event, step }) => {
14
+ const { missionId, actionId, proofState } = event.data as {
15
+ missionId: string;
16
+ actionId: string;
17
+ proofState: string;
18
+ };
19
+
20
+ if (proofState !== "sent_by_email") {
21
+ return { skipped: true, reason: "proofState is not sent_by_email" };
22
+ }
23
+
24
+ const sleepUntil = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
25
+ await step.sleepUntil("wait-for-followup", sleepUntil);
26
+
27
+ await step.run("mark-followup-due", async () => {
28
+ const [followup] = await db
29
+ .select()
30
+ .from(schema.followups)
31
+ .where(
32
+ and(
33
+ eq(schema.followups.missionId, missionId),
34
+ eq(schema.followups.type, "email_followup"),
35
+ eq(schema.followups.status, "scheduled"),
36
+ ),
37
+ )
38
+ .orderBy(desc(schema.followups.createdAt))
39
+ .limit(1);
40
+
41
+ if (followup) {
42
+ await db
43
+ .update(schema.followups)
44
+ .set({ status: "due" })
45
+ .where(eq(schema.followups.id, followup.id));
46
+ }
47
+
48
+ await db.insert(schema.missionEvents).values({
49
+ missionId,
50
+ eventType: "followup_due",
51
+ actorType: "system",
52
+ payload: { actionId },
53
+ });
54
+ });
55
+
56
+ await step.run("emit-followup-event", async () => {
57
+ await inngest.send({
58
+ name: "mission.followup_due" as const,
59
+ data: { missionId, actionId },
60
+ });
61
+ });
62
+
63
+ return { missionId, actionId, followupScheduled: true };
64
+ },
65
+ ) as InngestFunction.Any;
66
+ }
apps/worker/src/inngest.ts CHANGED
@@ -2,6 +2,8 @@ import { Inngest, type InngestFunction } from "inngest";
2
  import { createRunIntake } from "./functions/intake.js";
3
  import { createParseEvidence } from "./functions/evidence.js";
4
  import { createRunRoutePlan } from "./functions/route-plan.js";
 
 
5
 
6
  /**
7
  * Inngest client. Phase-1 functions (intake, evidence parse, legal grounding,
@@ -15,4 +17,6 @@ export const functions: InngestFunction.Any[] = [
15
  createRunIntake(inngest),
16
  createParseEvidence(inngest),
17
  createRunRoutePlan(inngest),
 
 
18
  ];
 
2
  import { createRunIntake } from "./functions/intake.js";
3
  import { createParseEvidence } from "./functions/evidence.js";
4
  import { createRunRoutePlan } from "./functions/route-plan.js";
5
+ import { createActionExecute } from "./functions/action-execute.js";
6
+ import { createFollowupSchedule } from "./functions/followup-schedule.js";
7
 
8
  /**
9
  * Inngest client. Phase-1 functions (intake, evidence parse, legal grounding,
 
17
  createRunIntake(inngest),
18
  createParseEvidence(inngest),
19
  createRunRoutePlan(inngest),
20
+ createActionExecute(inngest),
21
+ createFollowupSchedule(inngest),
22
  ];
apps/worker/src/lib/action-executor.ts ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { eq, desc } from "drizzle-orm";
2
+ import { createHash } from "node:crypto";
3
+ import { schema } from "@courtmitra/db";
4
+ import { canExecute } from "@courtmitra/domain";
5
+ import type { Database } from "@courtmitra/db";
6
+ import type { SubmissionPort } from "@courtmitra/ports";
7
+ import type {
8
+ AdapterCapability,
9
+ ConsentPreview,
10
+ ExecuteActionResponse,
11
+ ActionExecutionRefused,
12
+ Action,
13
+ } from "@courtmitra/contracts";
14
+
15
+ function computeHash(obj: unknown): string {
16
+ return "sha256:" + createHash("sha256").update(JSON.stringify(obj)).digest("hex");
17
+ }
18
+
19
+ const STATE_RANK: Record<string, number> = {
20
+ created: 0,
21
+ intake_in_progress: 1,
22
+ needs_user_input: 2,
23
+ evidence_processing: 3,
24
+ evidence_ready: 4,
25
+ facts_pending_confirmation: 5,
26
+ facts_confirmed: 6,
27
+ legal_grounding_in_progress: 7,
28
+ route_plan_ready: 8,
29
+ actions_preview_ready: 9,
30
+ actions_awaiting_consent: 10,
31
+ action_executing: 11,
32
+ waiting_for_response: 12,
33
+ followup_due: 13,
34
+ escalation_ready: 14,
35
+ human_review_required: 15,
36
+ resolver_reply_received: 16,
37
+ resolution_claimed: 17,
38
+ resolved: 18,
39
+ closed_unresolved: 19,
40
+ paused: 20,
41
+ blocked: 21,
42
+ failed: 22,
43
+ };
44
+
45
+ export async function executeAction(
46
+ actionId: string,
47
+ opts: { db: Database; emailAdapter: SubmissionPort; capabilities: AdapterCapability[] },
48
+ ): Promise<ExecuteActionResponse | ActionExecutionRefused> {
49
+ const { db: txDb, emailAdapter, capabilities } = opts;
50
+
51
+ const [actionRow] = await txDb
52
+ .select()
53
+ .from(schema.actions)
54
+ .where(eq(schema.actions.id, actionId))
55
+ .limit(1);
56
+
57
+ if (!actionRow) {
58
+ return { refused: true, reason: "Action not found", code: "consent_not_found" } satisfies ActionExecutionRefused;
59
+ }
60
+
61
+ const action = actionRow as unknown as Action;
62
+
63
+ let consentGrant: { actionPreviewHash: string } | null = null;
64
+ if (action.consentGrantId) {
65
+ const [grantRow] = await txDb
66
+ .select({ actionPreviewHash: schema.consentGrants.actionPreviewHash })
67
+ .from(schema.consentGrants)
68
+ .where(eq(schema.consentGrants.id, action.consentGrantId))
69
+ .limit(1);
70
+
71
+ if (grantRow) {
72
+ consentGrant = grantRow;
73
+ }
74
+ }
75
+
76
+ const inputPayload = action.inputPayload ?? {};
77
+ const preview: ConsentPreview = {
78
+ actionType: action.actionType,
79
+ destination: (inputPayload as any)?.destination ?? "",
80
+ public: false,
81
+ body: (inputPayload as any)?.body ?? "",
82
+ attachments: (inputPayload as any)?.attachments ?? [],
83
+ piiDisclosed: (inputPayload as any)?.piiDisclosed ?? [],
84
+ risks: (inputPayload as any)?.risks ?? [],
85
+ };
86
+
87
+ const check = canExecute(action, consentGrant, preview, capabilities);
88
+ if (check.refused) {
89
+ return check satisfies ActionExecutionRefused;
90
+ }
91
+
92
+ const result = await txDb.transaction(async (tx) => {
93
+ const [attempt] = await tx
94
+ .insert(schema.actionAttempts)
95
+ .values({
96
+ actionId,
97
+ adapterName: "resend_email",
98
+ attemptNo: 1,
99
+ requestPayloadHash: computeHash(action.inputPayload),
100
+ status: "started",
101
+ startedAt: new Date(),
102
+ })
103
+ .returning({ id: schema.actionAttempts.id });
104
+
105
+ if (!attempt) {
106
+ throw new Error("Failed to create action attempt record");
107
+ }
108
+
109
+ const submissionResult = await emailAdapter.submit({
110
+ missionId: action.missionId,
111
+ actionId,
112
+ destination: (inputPayload as any)?.destination ?? "",
113
+ subject: (inputPayload as any)?.subject,
114
+ body: (inputPayload as any)?.body ?? "",
115
+ attachments: ((inputPayload as any)?.attachments ?? []).map((a: any) => ({
116
+ id: a.id ?? "",
117
+ storageKey: a.sha256 ?? "",
118
+ filename: a.id ?? "attachment",
119
+ })),
120
+ idempotencyKey: action.idempotencyKey ?? `action-${actionId}`,
121
+ });
122
+
123
+ const succeeded = submissionResult.proofState !== "failed";
124
+
125
+ await tx
126
+ .update(schema.actionAttempts)
127
+ .set({
128
+ status: succeeded ? "succeeded" : "failed",
129
+ responsePayload: submissionResult.proofRef,
130
+ providerReference: submissionResult.externalReference,
131
+ finishedAt: new Date(),
132
+ })
133
+ .where(eq(schema.actionAttempts.id, attempt.id));
134
+
135
+ await tx
136
+ .update(schema.actions)
137
+ .set({
138
+ proofState: submissionResult.proofState,
139
+ status: succeeded ? "succeeded" : "failed",
140
+ updatedAt: new Date(),
141
+ })
142
+ .where(eq(schema.actions.id, actionId));
143
+
144
+ await tx.insert(schema.submissions).values({
145
+ missionId: action.missionId,
146
+ actionId,
147
+ adapterName: "resend_email",
148
+ status: succeeded ? "sent" : "failed",
149
+ proofState: submissionResult.proofState,
150
+ externalReference: submissionResult.externalReference,
151
+ payloadHash: action.canonicalPayloadHash,
152
+ sentAt: new Date(),
153
+ });
154
+
155
+ await tx.insert(schema.missionEvents).values({
156
+ missionId: action.missionId,
157
+ eventType: succeeded ? "action_executed" : "action_execution_failed",
158
+ actorType: "system",
159
+ payload: {
160
+ actionId,
161
+ proofState: submissionResult.proofState,
162
+ externalReference: submissionResult.externalReference,
163
+ },
164
+ });
165
+
166
+ const newState = succeeded ? "waiting_for_response" : "failed";
167
+ const [latestSnapshot] = await tx
168
+ .select({ state: schema.missionStatusSnapshots.state })
169
+ .from(schema.missionStatusSnapshots)
170
+ .where(eq(schema.missionStatusSnapshots.missionId, action.missionId))
171
+ .orderBy(desc(schema.missionStatusSnapshots.createdAt))
172
+ .limit(1);
173
+
174
+ const currentRank = STATE_RANK[latestSnapshot?.state ?? ""] ?? -1;
175
+ const newRank = STATE_RANK[newState] ?? 0;
176
+
177
+ if (newRank >= currentRank) {
178
+ await tx.insert(schema.missionStatusSnapshots).values({
179
+ missionId: action.missionId,
180
+ state: newState,
181
+ headline: succeeded
182
+ ? `Action "${action.title}" sent — waiting for response.`
183
+ : `Action "${action.title}" failed to send.`,
184
+ nextUserAction: succeeded
185
+ ? "Check for reply on the sent complaint."
186
+ : "Review the error and retry.",
187
+ nextSystemAction: succeeded
188
+ ? "Schedule follow-up reminder."
189
+ : "None — action failed.",
190
+ primaryButton: succeeded ? "View sent action" : "Retry",
191
+ danger: !succeeded,
192
+ });
193
+ }
194
+
195
+ if (succeeded) {
196
+ await tx.insert(schema.followups).values({
197
+ missionId: action.missionId,
198
+ type: "email_followup",
199
+ dueAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
200
+ status: "scheduled",
201
+ payload: { actionId },
202
+ });
203
+ }
204
+
205
+ return {
206
+ actionId,
207
+ proofState: submissionResult.proofState,
208
+ status: succeeded ? "succeeded" : "failed",
209
+ attempt: {
210
+ id: attempt.id,
211
+ attemptNo: 1,
212
+ status: succeeded ? "succeeded" : "failed",
213
+ },
214
+ } satisfies ExecuteActionResponse;
215
+ });
216
+
217
+ return result;
218
+ }
packages/adapters/package.json CHANGED
@@ -21,6 +21,7 @@
21
  "@openrouter/ai-sdk-provider": "^2.9.0",
22
  "ai": "^6.0.191",
23
  "drizzle-orm": "^0.45.2",
 
24
  "zod": "^4.4.3"
25
  },
26
  "devDependencies": {
 
21
  "@openrouter/ai-sdk-provider": "^2.9.0",
22
  "ai": "^6.0.191",
23
  "drizzle-orm": "^0.45.2",
24
+ "resend": "^6.12.4",
25
  "zod": "^4.4.3"
26
  },
27
  "devDependencies": {
packages/adapters/src/__tests__/regex-redaction.test.ts ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import { createRegexRedaction, RegexRedactionAdapter } from "../redaction/regex.js";
3
+
4
+ describe("RegexRedactionAdapter", () => {
5
+ const adapter: RegexRedactionAdapter = createRegexRedaction();
6
+
7
+ // -----------------------------------------------------------------------
8
+ // Aadhaar
9
+ // -----------------------------------------------------------------------
10
+ it("detects Aadhaar with valid Verhoeff checksum", () => {
11
+ const spans = adapter.detect("My aadhaar is 1234 5678 9010");
12
+ const aadhaarSpans = spans.filter((s) => s.piiType === "AADHAAR");
13
+ expect(aadhaarSpans.length).toBe(1);
14
+ expect(aadhaarSpans[0]!.replacement).toBe("[PII:AADHAAR]");
15
+ });
16
+
17
+ it("does NOT detect Aadhaar with invalid Verhoeff checksum", () => {
18
+ const spans = adapter.detect("My aadhaar is 1234 5678 9012");
19
+ const aadhaarSpans = spans.filter((s) => s.piiType === "AADHAAR");
20
+ expect(aadhaarSpans.length).toBe(0);
21
+ });
22
+
23
+ it("detects compact Aadhaar (no spaces)", () => {
24
+ const spans = adapter.detect("Aadhaar: 123456789010");
25
+ const aadhaarSpans = spans.filter((s) => s.piiType === "AADHAAR");
26
+ expect(aadhaarSpans.length).toBe(1);
27
+ });
28
+
29
+ // -----------------------------------------------------------------------
30
+ // PAN
31
+ // -----------------------------------------------------------------------
32
+ it("detects PAN", () => {
33
+ const spans = adapter.detect("PAN: ABCPE1234F");
34
+ const panSpans = spans.filter((s) => s.piiType === "PAN");
35
+ expect(panSpans.length).toBe(1);
36
+ });
37
+
38
+ it("does NOT detect PAN with invalid 4th character", () => {
39
+ const spans = adapter.detect("PAN: ABCZE1234F");
40
+ const panSpans = spans.filter((s) => s.piiType === "PAN");
41
+ // Z is not in [ABCFGHLJPTK] → 4th char must be one of those
42
+ expect(panSpans.length).toBe(0);
43
+ });
44
+
45
+ // -----------------------------------------------------------------------
46
+ // Phone
47
+ // -----------------------------------------------------------------------
48
+ it("detects Indian phone with +91 prefix", () => {
49
+ const spans = adapter.detect("Call me at +919876543210");
50
+ const phoneSpans = spans.filter((s) => s.piiType === "PHONE_IN");
51
+ expect(phoneSpans.length).toBe(1);
52
+ });
53
+
54
+ it("detects Indian phone without prefix", () => {
55
+ const spans = adapter.detect("Call me at 9876543210");
56
+ const phoneSpans = spans.filter((s) => s.piiType === "PHONE_IN");
57
+ expect(phoneSpans.length).toBe(1);
58
+ });
59
+
60
+ it("does NOT detect invalid phone starting with 5", () => {
61
+ const spans = adapter.detect("Call me at 5123456789");
62
+ const phoneSpans = spans.filter((s) => s.piiType === "PHONE_IN");
63
+ expect(phoneSpans.length).toBe(0);
64
+ });
65
+
66
+ // -----------------------------------------------------------------------
67
+ // Email
68
+ // -----------------------------------------------------------------------
69
+ it("detects email address", () => {
70
+ const spans = adapter.detect("Email: test@example.com");
71
+ const emailSpans = spans.filter((s) => s.piiType === "EMAIL");
72
+ expect(emailSpans.length).toBe(1);
73
+ });
74
+
75
+ // -----------------------------------------------------------------------
76
+ // UPI VPA
77
+ // -----------------------------------------------------------------------
78
+ it("detects UPI VPA", () => {
79
+ const spans = adapter.detect("Pay to user@upi");
80
+ const upiSpans = spans.filter((s) => s.piiType === "UPI_VPA");
81
+ expect(upiSpans.length).toBe(1);
82
+ });
83
+
84
+ // -----------------------------------------------------------------------
85
+ // IFSC
86
+ // -----------------------------------------------------------------------
87
+ it("detects IFSC code", () => {
88
+ const spans = adapter.detect("IFSC: SBIN0123456");
89
+ const ifscSpans = spans.filter((s) => s.piiType === "IFSC");
90
+ expect(ifscSpans.length).toBe(1);
91
+ });
92
+
93
+ it("does NOT detect IFSC with non-zero 6th character", () => {
94
+ const spans = adapter.detect("IFSC: SBIN1123456");
95
+ const ifscSpans = spans.filter((s) => s.piiType === "IFSC");
96
+ expect(ifscSpans.length).toBe(0);
97
+ });
98
+
99
+ // -----------------------------------------------------------------------
100
+ // Bank account
101
+ // -----------------------------------------------------------------------
102
+ it("detects bank account number", () => {
103
+ const spans = adapter.detect("Account: 123456789012345678");
104
+ const bankSpans = spans.filter((s) => s.piiType === "BANK_ACCOUNT");
105
+ expect(bankSpans.length).toBe(1);
106
+ });
107
+
108
+ it("does NOT detect short number as bank account", () => {
109
+ const spans = adapter.detect("Number: 12345678");
110
+ const bankSpans = spans.filter((s) => s.piiType === "BANK_ACCOUNT");
111
+ expect(bankSpans.length).toBe(0);
112
+ });
113
+
114
+ // -----------------------------------------------------------------------
115
+ // redact() with mask mode (default)
116
+ // -----------------------------------------------------------------------
117
+ it("redacts Aadhaar in text", () => {
118
+ const result = adapter.redact("My aadhaar is 1234 5678 9010", "mask");
119
+ expect(result).toContain("[PII:AADHAAR]");
120
+ expect(result).not.toContain("1234 5678 9010");
121
+ });
122
+
123
+ it("redacts PAN in text", () => {
124
+ const result = adapter.redact("PAN: ABCPE1234F", "mask");
125
+ expect(result).toContain("[PII:PAN]");
126
+ expect(result).not.toContain("ABCPE1234F");
127
+ });
128
+
129
+ it("redacts multiple PII types in one string", () => {
130
+ const input = "Aadhaar: 1234 5678 9010, Email: test@example.com";
131
+ const result = adapter.redact(input, "mask");
132
+ expect(result).toContain("[PII:AADHAAR]");
133
+ expect(result).toContain("[PII:EMAIL]");
134
+ expect(result).not.toContain("1234 5678 9010");
135
+ expect(result).not.toContain("test@example.com");
136
+ });
137
+
138
+ it("redacts with remove mode", () => {
139
+ const result = adapter.redact("PAN: ABCPE1234F", "remove");
140
+ expect(result).not.toContain("ABCPE1234F");
141
+ expect(result).not.toContain("[PII:PAN]");
142
+ });
143
+
144
+ it("redacts with tag mode", () => {
145
+ const result = adapter.redact("PAN: ABCPE1234F", "tag");
146
+ expect(result).toContain("[PAN]");
147
+ expect(result).not.toContain("ABCPE1234F");
148
+ });
149
+
150
+ it("returns original text when no PII found", () => {
151
+ const input = "Hello, this is a normal message with no PII.";
152
+ const result = adapter.redact(input, "mask");
153
+ expect(result).toBe(input);
154
+ });
155
+ });
packages/adapters/src/index.ts CHANGED
@@ -37,6 +37,14 @@ export {
37
  // ---- Storage ------------------------------------------------------------
38
  export { createLocalFsStorage, LocalFsStorageAdapter } from "./storage/local-fs.js";
39
 
 
 
 
 
 
 
 
 
40
  // ---- Capability registry ------------------------------------------------
41
 
42
  /**
@@ -49,6 +57,8 @@ export function getCapabilities(): AdapterCapability[] {
49
  const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
50
  const openrouterKey = process.env["OPENROUTER_API_KEY"];
51
  const opencodeKey = process.env["OPENCODE_API_KEY"];
 
 
52
  const embeddingProvider = process.env["EMBEDDING_PROVIDER"] ?? process.env["AI_PROVIDER"] ?? "openrouter";
53
  const embeddingKeyAvailable = embeddingProvider === "opencode" ? !!opencodeKey : !!openrouterKey;
54
 
@@ -98,8 +108,8 @@ export function getCapabilities(): AdapterCapability[] {
98
  {
99
  adapter: "resend_email",
100
  action: "send_email",
101
- status: "dev_only",
102
- reason: "Resend adapter lands Day 4.",
103
  requiredEnv: ["RESEND_API_KEY", "RESEND_FROM"],
104
  fallbackAction: "draft",
105
  },
 
37
  // ---- Storage ------------------------------------------------------------
38
  export { createLocalFsStorage, LocalFsStorageAdapter } from "./storage/local-fs.js";
39
 
40
+ // ---- Email / submission -------------------------------------------------
41
+ export { createResendEmailAdapter } from "./submission/resend-email.js";
42
+ export type { ResendEmailAdapter } from "./submission/resend-email.js";
43
+
44
+ // ---- Redaction ----------------------------------------------------------
45
+ export { createRegexRedaction } from "./redaction/regex.js";
46
+ export type { RegexRedactionAdapter } from "./redaction/regex.js";
47
+
48
  // ---- Capability registry ------------------------------------------------
49
 
50
  /**
 
57
  const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
58
  const openrouterKey = process.env["OPENROUTER_API_KEY"];
59
  const opencodeKey = process.env["OPENCODE_API_KEY"];
60
+ const resendKey = process.env["RESEND_API_KEY"];
61
+ const resendFrom = process.env["RESEND_FROM"];
62
  const embeddingProvider = process.env["EMBEDDING_PROVIDER"] ?? process.env["AI_PROVIDER"] ?? "openrouter";
63
  const embeddingKeyAvailable = embeddingProvider === "opencode" ? !!opencodeKey : !!openrouterKey;
64
 
 
108
  {
109
  adapter: "resend_email",
110
  action: "send_email",
111
+ status: resendKey && resendFrom ? "available" : "blocked",
112
+ reason: resendKey && resendFrom ? undefined : "RESEND_API_KEY and RESEND_FROM must be set.",
113
  requiredEnv: ["RESEND_API_KEY", "RESEND_FROM"],
114
  fallbackAction: "draft",
115
  },
packages/adapters/src/redaction/regex.ts ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ RedactionPort,
3
+ RedactionSpan,
4
+ RedactionMode,
5
+ } from "@courtmitra/ports";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Verhoeff check-digit validation (Aadhaar)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const verhoeffD: number[][] = [
12
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
13
+ [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
14
+ [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
15
+ [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
16
+ [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
17
+ [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
18
+ [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
19
+ [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
20
+ [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
21
+ [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
22
+ ];
23
+
24
+ const verhoeffP: number[][] = [
25
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
26
+ [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
27
+ [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
28
+ [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
29
+ [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
30
+ [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
31
+ [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
32
+ [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
33
+ ];
34
+
35
+ function verhoeffValidate(digits: string): boolean {
36
+ let c = 0;
37
+ const n = digits.replace(/\s/g, "").split("").map(Number);
38
+ for (let i = n.length - 1; i >= 0; i--) {
39
+ const pos = n.length - 1 - i;
40
+ c = verhoeffD[c]![verhoeffP[pos % 8]![n[i]!]!]!;
41
+ }
42
+ return c === 0;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Regex patterns
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface PiiPattern {
50
+ type: string;
51
+ regex: RegExp;
52
+ validate?: (match: string) => boolean;
53
+ }
54
+
55
+ const PATTERNS: PiiPattern[] = [
56
+ {
57
+ type: "AADHAAR",
58
+ regex: /\b\d{4}\s?\d{4}\s?\d{4}\b/g,
59
+ validate: (m) => verhoeffValidate(m),
60
+ },
61
+ {
62
+ type: "PAN",
63
+ regex: /\b[A-Z]{3}[ABCFGHLJPTK][A-Z]\d{4}[A-Z]\b/g,
64
+ },
65
+ {
66
+ type: "EMAIL",
67
+ regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
68
+ },
69
+ {
70
+ type: "UPI_VPA",
71
+ regex: /[\w.-]+@[\w.-]+/g,
72
+ },
73
+ {
74
+ type: "IFSC",
75
+ regex: /\b[A-Z]{4}0[A-Z0-9]{6}\b/g,
76
+ },
77
+ {
78
+ type: "PHONE_IN",
79
+ regex: /(?<!\d)(?:\+?91[-.\s]?)?[6-9]\d{9}\b/g,
80
+ },
81
+ {
82
+ type: "BANK_ACCOUNT",
83
+ regex: /\b\d{9,18}\b/g,
84
+ },
85
+ ];
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Adapter
89
+ // ---------------------------------------------------------------------------
90
+
91
+ export class RegexRedactionAdapter implements RedactionPort {
92
+ detect(text: string): RedactionSpan[] {
93
+ const spans: RedactionSpan[] = [];
94
+ const seen = new Set<string>();
95
+
96
+ for (const pattern of PATTERNS) {
97
+ let match: RegExpExecArray | null;
98
+ const re = new RegExp(pattern.regex.source, "g");
99
+ while ((match = re.exec(text)) !== null) {
100
+ const value = match[0]!;
101
+ const key = `${match.index}:${value}`;
102
+ if (seen.has(key)) continue;
103
+ seen.add(key);
104
+
105
+ if (pattern.validate && !pattern.validate(value)) continue;
106
+
107
+ spans.push({
108
+ start: match.index,
109
+ end: match.index + value.length,
110
+ piiType: pattern.type,
111
+ replacement: `[PII:${pattern.type}]`,
112
+ });
113
+ }
114
+ }
115
+
116
+ spans.sort((a, b) => a.start - b.start);
117
+ return spans;
118
+ }
119
+
120
+ redact(text: string, mode: RedactionMode): string {
121
+ const spans = this.detect(text);
122
+ if (spans.length === 0) return text;
123
+
124
+ const mask = (span: RedactionSpan): string => {
125
+ switch (mode) {
126
+ case "remove":
127
+ return "";
128
+ case "tag":
129
+ return `[${span.piiType}]`;
130
+ default:
131
+ return `[PII:${span.piiType}]`;
132
+ }
133
+ };
134
+
135
+ const parts: string[] = [];
136
+ let cursor = 0;
137
+ for (const span of spans) {
138
+ if (span.start < cursor) continue;
139
+ if (span.start > cursor) {
140
+ parts.push(text.slice(cursor, span.start));
141
+ }
142
+ parts.push(mask(span));
143
+ cursor = span.end;
144
+ }
145
+ if (cursor < text.length) {
146
+ parts.push(text.slice(cursor));
147
+ }
148
+
149
+ return parts.join("");
150
+ }
151
+ }
152
+
153
+ export function createRegexRedaction(): RegexRedactionAdapter {
154
+ return new RegexRedactionAdapter();
155
+ }
packages/adapters/src/submission/resend-email.ts ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { Resend } from "resend";
5
+ import type {
6
+ SubmissionPort,
7
+ SubmissionRequest,
8
+ SubmissionResult,
9
+ EmailPort,
10
+ EmailSendRequest,
11
+ EmailSendResult,
12
+ } from "@courtmitra/ports";
13
+ import type { AdapterCapability } from "@courtmitra/contracts";
14
+
15
+ function getEnv(name: string): string | undefined {
16
+ return process.env[name];
17
+ }
18
+
19
+ function buildReplyToken(
20
+ actionId: string,
21
+ missionId: string,
22
+ nonce: string,
23
+ ): string {
24
+ const key = getEnv("TOKEN_ENCRYPTION_KEY");
25
+ if (!key) throw new Error("Missing TOKEN_ENCRYPTION_KEY for reply token generation");
26
+ return createHmac("sha256", key)
27
+ .update(`${actionId}:${missionId}:${nonce}`)
28
+ .digest("hex");
29
+ }
30
+
31
+ function tryReadAttachment(storageKey: string): Buffer | undefined {
32
+ try {
33
+ const storageDir = getEnv("LOCAL_STORAGE_DIR") ?? ".storage";
34
+ const absPath = resolve(storageDir, storageKey);
35
+ if (existsSync(absPath)) {
36
+ return readFileSync(absPath);
37
+ }
38
+ } catch {
39
+ /* best effort */
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ type SendResult = {
45
+ proofState: "sent_by_email" | "failed";
46
+ providerMessageId: string | null;
47
+ proofRef: Record<string, unknown>;
48
+ };
49
+
50
+ export class ResendEmailAdapter implements SubmissionPort, EmailPort {
51
+ private readonly resend: Resend;
52
+ private readonly from: string;
53
+ private readonly replyDomain: string;
54
+
55
+ constructor() {
56
+ const apiKey = getEnv("RESEND_API_KEY");
57
+ const from = getEnv("RESEND_FROM");
58
+ const replyDomain = getEnv("RESEND_REPLY_DOMAIN");
59
+ this.resend = apiKey ? new Resend(apiKey) : new Resend("placeholder");
60
+ this.from = from ?? "";
61
+ this.replyDomain = replyDomain ?? "";
62
+ }
63
+
64
+ capabilities(): AdapterCapability[] {
65
+ const key = getEnv("RESEND_API_KEY");
66
+ const from = getEnv("RESEND_FROM");
67
+ return [
68
+ {
69
+ adapter: "resend_email",
70
+ action: "send_email",
71
+ status: key && from ? "available" : "blocked",
72
+ reason:
73
+ key && from
74
+ ? undefined
75
+ : "RESEND_API_KEY and RESEND_FROM must be set.",
76
+ requiredEnv: ["RESEND_API_KEY", "RESEND_FROM"],
77
+ fallbackAction: "draft",
78
+ },
79
+ ];
80
+ }
81
+
82
+ async submit(req: SubmissionRequest): Promise<SubmissionResult> {
83
+ const result = await this.doSend({
84
+ to: req.destination,
85
+ subject: req.subject ?? "No subject",
86
+ body: req.body,
87
+ actionId: req.actionId,
88
+ missionId: req.missionId,
89
+ attachments: req.attachments.map((a) => ({
90
+ filename: a.filename,
91
+ storageKey: a.storageKey,
92
+ })),
93
+ });
94
+ return {
95
+ proofState: result.proofState,
96
+ externalReference: result.providerMessageId,
97
+ proofRef: result.proofRef,
98
+ };
99
+ }
100
+
101
+ async send(req: EmailSendRequest): Promise<EmailSendResult> {
102
+ const result = await this.doSend({
103
+ to: req.to,
104
+ subject: req.subject,
105
+ body: req.body,
106
+ actionId: req.replyTo ?? "email",
107
+ missionId: req.to,
108
+ attachments: req.attachments ?? [],
109
+ });
110
+ return {
111
+ proofState: result.proofState,
112
+ providerMessageId: result.providerMessageId,
113
+ proofRef: result.proofRef,
114
+ };
115
+ }
116
+
117
+ private async doSend(
118
+ req: {
119
+ to: string;
120
+ subject: string;
121
+ body: string;
122
+ actionId: string;
123
+ missionId: string;
124
+ attachments: { filename: string; storageKey: string }[];
125
+ },
126
+ ): Promise<SendResult> {
127
+ if (!this.from || !getEnv("RESEND_API_KEY")) {
128
+ return {
129
+ proofState: "failed",
130
+ providerMessageId: null,
131
+ proofRef: { error: "RESEND_API_KEY and RESEND_FROM must be set." },
132
+ };
133
+ }
134
+
135
+ const nonce = randomBytes(8).toString("hex");
136
+ let replyTo: string | undefined;
137
+ try {
138
+ const token = buildReplyToken(req.actionId, req.missionId, nonce);
139
+ if (this.replyDomain) {
140
+ replyTo = `reply+${token}@${this.replyDomain}`;
141
+ }
142
+ } catch {
143
+ /* send without reply-token */
144
+ }
145
+
146
+ const resolvedAttachments = req.attachments
147
+ .map((a) => {
148
+ const content = tryReadAttachment(a.storageKey);
149
+ return content ? { filename: a.filename, content } : null;
150
+ })
151
+ .filter(Boolean);
152
+
153
+ try {
154
+ const { data, error } = await this.resend.emails.send({
155
+ from: this.from,
156
+ to: [req.to],
157
+ subject: req.subject,
158
+ html: req.body,
159
+ replyTo,
160
+ attachments:
161
+ resolvedAttachments.length > 0
162
+ ? (resolvedAttachments as { filename: string; content: Buffer }[])
163
+ : undefined,
164
+ });
165
+ if (error) throw error;
166
+ return {
167
+ proofState: "sent_by_email",
168
+ providerMessageId: data?.id ?? null,
169
+ proofRef: { id: data?.id },
170
+ };
171
+ } catch (err) {
172
+ return {
173
+ proofState: "failed",
174
+ providerMessageId: null,
175
+ proofRef: {
176
+ error: err instanceof Error ? err.message : String(err),
177
+ },
178
+ };
179
+ }
180
+ }
181
+ }
182
+
183
+ export function createResendEmailAdapter(): ResendEmailAdapter {
184
+ return new ResendEmailAdapter();
185
+ }
packages/contracts/src/action.ts CHANGED
@@ -64,3 +64,143 @@ export const ActionPreviewResponse = z.object({
64
  proofStateAfter: ProofState,
65
  });
66
  export type ActionPreviewResponse = z.infer<typeof ActionPreviewResponse>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  proofStateAfter: ProofState,
65
  });
66
  export type ActionPreviewResponse = z.infer<typeof ActionPreviewResponse>;
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Day 4 — Action preview, consent, execution, followup, public page
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /** Input for POST /api/missions/:id/actions/preview — selects a route-plan step and fills the template. */
73
+ export const CreateActionPreviewInput = z.object({
74
+ routePlanId: Uuid,
75
+ stepNo: z.number().int().min(0),
76
+ destination: z.string().min(1),
77
+ subject: z.string().optional(),
78
+ body: z.string().min(1),
79
+ attachments: z.array(ConsentPreviewAttachment).default([]),
80
+ piiDisclosed: z.array(z.string()).default([]),
81
+ risks: z.array(z.string()).default([]),
82
+ });
83
+ export type CreateActionPreviewInput = z.infer<typeof CreateActionPreviewInput>;
84
+
85
+ /** Response for the preview endpoint — includes the created action row + metadata. */
86
+ export const ActionPreviewDto = z.object({
87
+ action: Action,
88
+ canonicalPayloadHash: z.string(),
89
+ preview: ActionPreviewResponse,
90
+ });
91
+ export type ActionPreviewDto = z.infer<typeof ActionPreviewDto>;
92
+
93
+ /** Empty body for POST /api/actions/:id/consent — consent is recorded server-side. */
94
+ export const GrantConsentInput = z.object({}).default({});
95
+ export type GrantConsentInput = z.infer<typeof GrantConsentInput>;
96
+
97
+ /** Response for consent grant. */
98
+ export const ConsentGrantResponse = z.object({
99
+ id: Uuid,
100
+ actionId: Uuid,
101
+ actionPreviewHash: z.string(),
102
+ grantedAt: IsoDateTime,
103
+ });
104
+ export type ConsentGrantResponse = z.infer<typeof ConsentGrantResponse>;
105
+
106
+ /** Response for POST /api/actions/:id/execute. */
107
+ export const ExecuteActionResponse = z.object({
108
+ actionId: Uuid,
109
+ proofState: ProofState,
110
+ status: ActionStatus,
111
+ attempt: z.object({
112
+ id: Uuid,
113
+ attemptNo: z.number().int(),
114
+ status: z.string(),
115
+ }).nullable(),
116
+ });
117
+ export type ExecuteActionResponse = z.infer<typeof ExecuteActionResponse>;
118
+
119
+ /** A single action_attempts row (read model). */
120
+ export const ActionAttemptDto = z.object({
121
+ id: Uuid,
122
+ adapterName: z.string(),
123
+ attemptNo: z.number().int(),
124
+ status: z.string(),
125
+ errorCode: z.string().nullable(),
126
+ errorMessage: z.string().nullable(),
127
+ startedAt: IsoDateTime.nullable(),
128
+ finishedAt: IsoDateTime.nullable(),
129
+ });
130
+ export type ActionAttemptDto = z.infer<typeof ActionAttemptDto>;
131
+
132
+ /** A single submissions row (read model). */
133
+ export const SubmissionDto = z.object({
134
+ id: Uuid,
135
+ proofState: ProofState,
136
+ externalReference: z.string().nullable(),
137
+ sentAt: IsoDateTime.nullable(),
138
+ });
139
+ export type SubmissionDto = z.infer<typeof SubmissionDto>;
140
+
141
+ /** Full action detail returned by GET /api/actions/:id. */
142
+ export const ActionDetailDto = z.object({
143
+ action: Action,
144
+ latestAttempt: ActionAttemptDto.nullable(),
145
+ submissions: z.array(SubmissionDto).default([]),
146
+ });
147
+ export type ActionDetailDto = z.infer<typeof ActionDetailDto>;
148
+
149
+ /** Email-specific send input (for adapters). */
150
+ export const EmailSendRequestDto = z.object({
151
+ to: z.string().email(),
152
+ subject: z.string().min(1),
153
+ body: z.string().min(1),
154
+ replyTo: z.string().optional(),
155
+ attachments: z.array(z.object({
156
+ filename: z.string(),
157
+ contentBase64: z.string().optional(),
158
+ contentType: z.string().optional(),
159
+ })).default([]),
160
+ });
161
+ export type EmailSendRequestDto = z.infer<typeof EmailSendRequestDto>;
162
+
163
+ /** Email send result (adapter output). */
164
+ export const EmailSendResultDto = z.object({
165
+ proofState: ProofState,
166
+ providerMessageId: z.string().nullable(),
167
+ proofRef: z.record(z.string(), z.unknown()),
168
+ });
169
+ export type EmailSendResultDto = z.infer<typeof EmailSendResultDto>;
170
+
171
+ /** Followup row (read model). */
172
+ export const FollowupDto = z.object({
173
+ id: Uuid,
174
+ missionId: Uuid,
175
+ type: z.string(),
176
+ dueAt: IsoDateTime,
177
+ status: z.string(),
178
+ payload: z.record(z.string(), z.unknown()).nullable(),
179
+ });
180
+ export type FollowupDto = z.infer<typeof FollowupDto>;
181
+
182
+ /** Public case page (read model). */
183
+ export const PublicCasePageDto = z.object({
184
+ id: Uuid,
185
+ missionId: Uuid,
186
+ slug: z.string(),
187
+ visibility: z.string(),
188
+ redactedSummary: z.string().nullable(),
189
+ createdAt: IsoDateTime,
190
+ });
191
+ export type PublicCasePageDto = z.infer<typeof PublicCasePageDto>;
192
+
193
+ /** Error details returned when action execution is refused. */
194
+ export const ActionExecutionRefused = z.object({
195
+ refused: z.literal(true),
196
+ reason: z.string(),
197
+ code: z.enum([
198
+ "consent_hash_mismatch",
199
+ "consent_not_found",
200
+ "idempotency_key_used",
201
+ "adapter_capability_blocked",
202
+ "action_not_approved",
203
+ "action_already_executed",
204
+ ]),
205
+ });
206
+ export type ActionExecutionRefused = z.infer<typeof ActionExecutionRefused>;
packages/domain/src/index.ts CHANGED
@@ -4,3 +4,6 @@ export {
4
  canonicalConsentJson,
5
  canonicalizeConsentPreview,
6
  } from "./policies/consent-hash.js";
 
 
 
 
4
  canonicalConsentJson,
5
  canonicalizeConsentPreview,
6
  } from "./policies/consent-hash.js";
7
+
8
+ export { canExecute } from "./policies/action-execution-policy.js";
9
+ export type { CanExecuteResult, ExecutionRefusal, ExecutionAllowance } from "./policies/action-execution-policy.js";
packages/domain/src/policies/__tests__/action-execution-policy.test.ts ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from "vitest";
2
+ import type { Action, ConsentPreview, AdapterCapability } from "@courtmitra/contracts";
3
+ import { computeConsentHash } from "../../policies/consent-hash.js";
4
+ import { canExecute } from "../../policies/action-execution-policy.js";
5
+
6
+ function makeAction(overrides: Partial<Action> = {}): Action {
7
+ return {
8
+ id: "00000000-0000-0000-0000-000000000001",
9
+ missionId: "00000000-0000-0000-0000-000000000002",
10
+ routePlanId: null,
11
+ actionType: "send_email",
12
+ title: "Test action",
13
+ description: null,
14
+ target: null,
15
+ inputPayload: null,
16
+ canonicalPayloadHash: null,
17
+ consentRequired: true,
18
+ consentGrantId: "00000000-0000-0000-0000-000000000010",
19
+ status: "approved",
20
+ proofState: "draft_only",
21
+ proofRef: null,
22
+ idempotencyKey: null,
23
+ riskLevel: "low",
24
+ createdBy: "ai",
25
+ createdAt: "2025-01-01T00:00:00+00:00",
26
+ updatedAt: "2025-01-01T00:00:00+00:00",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function makeConsentPreview(overrides: Partial<ConsentPreview> = {}): ConsentPreview {
32
+ return {
33
+ actionType: "send_email",
34
+ destination: "test@example.com",
35
+ public: false,
36
+ body: "Test body content",
37
+ attachments: [],
38
+ piiDisclosed: [],
39
+ risks: [],
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe("canExecute", () => {
45
+ it("refuses when consent is null", () => {
46
+ const action = makeAction();
47
+ const preview = makeConsentPreview();
48
+ const caps: AdapterCapability[] = [];
49
+
50
+ const result = canExecute(action, null, preview, caps);
51
+
52
+ expect(result.refused).toBe(true);
53
+ if (result.refused) {
54
+ expect(result.code).toBe("consent_not_found");
55
+ }
56
+ });
57
+
58
+ it("refuses when consent hash does not match", () => {
59
+ const action = makeAction();
60
+ const consentGrant = { actionPreviewHash: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" };
61
+ const preview = makeConsentPreview({ body: "Completely different body text" });
62
+ const caps: AdapterCapability[] = [];
63
+
64
+ const result = canExecute(action, consentGrant, preview, caps);
65
+
66
+ expect(result.refused).toBe(true);
67
+ if (result.refused) {
68
+ expect(result.code).toBe("consent_hash_mismatch");
69
+ }
70
+ });
71
+
72
+ it("refuses when action status is not approved", () => {
73
+ const action = makeAction({ status: "proposed" });
74
+ const preview = makeConsentPreview();
75
+ const hash = computeConsentHash(preview);
76
+ const consentGrant = { actionPreviewHash: hash };
77
+ const caps: AdapterCapability[] = [];
78
+
79
+ const result = canExecute(action, consentGrant, preview, caps);
80
+
81
+ expect(result.refused).toBe(true);
82
+ if (result.refused) {
83
+ expect(result.code).toBe("action_not_approved");
84
+ }
85
+ });
86
+
87
+ it("refuses when adapter capability is blocked", () => {
88
+ const action = makeAction({ actionType: "send_email" });
89
+ const preview = makeConsentPreview();
90
+ const hash = computeConsentHash(preview);
91
+ const consentGrant = { actionPreviewHash: hash };
92
+ const caps: AdapterCapability[] = [
93
+ {
94
+ adapter: "resend_email",
95
+ action: "send_email",
96
+ status: "blocked",
97
+ reason: "API key not set.",
98
+ fallbackAction: "draft",
99
+ },
100
+ ];
101
+
102
+ const result = canExecute(action, consentGrant, preview, caps);
103
+
104
+ expect(result.refused).toBe(true);
105
+ if (result.refused) {
106
+ expect(result.code).toBe("adapter_capability_blocked");
107
+ }
108
+ });
109
+
110
+ it("allows execution when all conditions pass", () => {
111
+ const action = makeAction({ actionType: "send_email" });
112
+ const preview = makeConsentPreview();
113
+ const hash = computeConsentHash(preview);
114
+ const consentGrant = { actionPreviewHash: hash };
115
+ const caps: AdapterCapability[] = [
116
+ {
117
+ adapter: "resend_email",
118
+ action: "send_email",
119
+ status: "available",
120
+ fallbackAction: "draft",
121
+ },
122
+ ];
123
+
124
+ const result = canExecute(action, consentGrant, preview, caps);
125
+
126
+ expect(result.refused).toBe(false);
127
+ });
128
+
129
+ it("allows execution with dev_only capability", () => {
130
+ const action = makeAction({ actionType: "send_email" });
131
+ const preview = makeConsentPreview();
132
+ const hash = computeConsentHash(preview);
133
+ const consentGrant = { actionPreviewHash: hash };
134
+ const caps: AdapterCapability[] = [
135
+ {
136
+ adapter: "resend_email",
137
+ action: "send_email",
138
+ status: "dev_only",
139
+ fallbackAction: "draft",
140
+ },
141
+ ];
142
+
143
+ const result = canExecute(action, consentGrant, preview, caps);
144
+
145
+ expect(result.refused).toBe(false);
146
+ });
147
+ });
packages/domain/src/policies/action-execution-policy.ts ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * ActionExecutionPolicy — pure rules for `canExecute`.
3
+ *
4
+ * Order: consent → idempotency → capability → rate-limit.
5
+ * Failure of any step returns `ActionExecutionRefused` with a typed reason.
6
+ * This is the invariant core of the Action spine (CLAUDE.md #2, #3, #5, #7).
7
+ */
8
+ import type {
9
+ Action,
10
+ AdapterCapability,
11
+ ActionExecutionRefused,
12
+ } from "@courtmitra/contracts";
13
+ import { consentHashMatches } from "./consent-hash.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Refusal result
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface ExecutionRefusal {
20
+ refused: true;
21
+ reason: string;
22
+ code: ActionExecutionRefused["code"];
23
+ }
24
+
25
+ export interface ExecutionAllowance {
26
+ refused: false;
27
+ }
28
+
29
+ export type CanExecuteResult = ExecutionRefusal | ExecutionAllowance;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Adapter capability check
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function adapterForAction(action: Action): string | null {
36
+ switch (action.actionType) {
37
+ case "send_email":
38
+ return "resend_email";
39
+ case "post_social":
40
+ return "social_publisher";
41
+ case "generate_packet":
42
+ return "packet_generator";
43
+ default:
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function capabilityIsAvailable(cap: AdapterCapability): boolean {
49
+ return cap.status === "available" || cap.status === "dev_only";
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Public gate — pure function, zero side-effects
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Evaluate whether an action may be executed.
58
+ *
59
+ * @param action — the Action row to execute.
60
+ * @param consentGrant — the consent grant (null if not yet granted).
61
+ * @param outgoingPreview — the ConsentPreview computed from the EXACT outgoing payload.
62
+ * @param allCapabilities — full capability registry (adapter list).
63
+ * @returns `{ refused: false }` or `{ refused: true, reason, code }`.
64
+ */
65
+ export function canExecute(
66
+ action: Action,
67
+ consentGrant: { actionPreviewHash: string } | null,
68
+ outgoingPreview: Parameters<typeof consentHashMatches>[1],
69
+ allCapabilities: AdapterCapability[],
70
+ ): CanExecuteResult {
71
+ // 1. Consent must exist
72
+ if (!consentGrant) {
73
+ return {
74
+ refused: true,
75
+ reason: "Consent has not been granted for this action.",
76
+ code: "consent_not_found",
77
+ };
78
+ }
79
+
80
+ // 2. Consent hash must match the outgoing payload (CLAUDE.md #5)
81
+ if (!consentHashMatches(consentGrant.actionPreviewHash, outgoingPreview)) {
82
+ return {
83
+ refused: true,
84
+ reason:
85
+ "The action draft has changed since consent was given — re-approval required.",
86
+ code: "consent_hash_mismatch",
87
+ };
88
+ }
89
+
90
+ // 3. Action must be in an executable status
91
+ // "executing" is allowed for retries — the worker sets it inside its tx.
92
+ const executableStatuses = new Set(["approved", "preview_ready", "executing"]);
93
+ if (!executableStatuses.has(action.status)) {
94
+ return {
95
+ refused: true,
96
+ reason: `Action status is "${action.status}", not "approved", "preview_ready", or "executing".`,
97
+ code: "action_not_approved",
98
+ };
99
+ }
100
+
101
+ // 4. Idempotency key must not already have been used (caller checks DB)
102
+ if (action.idempotencyKey && action.proofState === "sent_by_email") {
103
+ return {
104
+ refused: true,
105
+ reason: "This action has already been executed (idempotency key consumed).",
106
+ code: "idempotency_key_used",
107
+ };
108
+ }
109
+
110
+ // 5. Adapter capability must be available
111
+ const adapterName = adapterForAction(action);
112
+ if (adapterName) {
113
+ const matching = allCapabilities.find((c) => c.adapter === adapterName);
114
+ if (!matching || !capabilityIsAvailable(matching)) {
115
+ return {
116
+ refused: true,
117
+ reason: matching
118
+ ? `Adapter "${adapterName}" is not available (status: ${matching.status}). ${matching.reason ?? ""}`
119
+ : `No adapter found for "${adapterName}".`,
120
+ code: "adapter_capability_blocked",
121
+ };
122
+ }
123
+ }
124
+
125
+ return { refused: false };
126
+ }
packages/ports/src/index.ts CHANGED
@@ -157,6 +157,27 @@ export interface SubmissionPort extends CapabilityAware {
157
  submit(r: SubmissionRequest): Promise<SubmissionResult>;
158
  }
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  // --- Redaction ---------------------------------------------------------------
161
 
162
  export type RedactionMode = "mask" | "remove" | "tag";
 
157
  submit(r: SubmissionRequest): Promise<SubmissionResult>;
158
  }
159
 
160
+ // --- Email (dedicated port, separate from generic SubmissionPort) -----------
161
+
162
+ export interface EmailSendRequest {
163
+ to: string;
164
+ subject: string;
165
+ body: string;
166
+ replyTo?: string;
167
+ /** Attachments as base64-encoded content (referenced by storage key). */
168
+ attachments?: { filename: string; storageKey: string; contentType?: string }[];
169
+ }
170
+
171
+ export interface EmailSendResult {
172
+ proofState: ProofState;
173
+ providerMessageId: string | null;
174
+ proofRef: Record<string, unknown>;
175
+ }
176
+
177
+ export interface EmailPort extends CapabilityAware {
178
+ send(req: EmailSendRequest): Promise<EmailSendResult>;
179
+ }
180
+
181
  // --- Redaction ---------------------------------------------------------------
182
 
183
  export type RedactionMode = "mask" | "remove" | "tag";
pnpm-lock.yaml CHANGED
@@ -176,6 +176,9 @@ importers:
176
  drizzle-orm:
177
  specifier: ^0.45.2
178
  version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
 
 
 
179
  zod:
180
  specifier: ^4.4.3
181
  version: 4.4.3
@@ -1695,6 +1698,9 @@ packages:
1695
  '@rolldown/pluginutils@1.0.1':
1696
  resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
1697
 
 
 
 
1698
  '@standard-schema/spec@1.1.0':
1699
  resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
1700
 
@@ -2280,6 +2286,9 @@ packages:
2280
  fast-safe-stringify@2.1.1:
2281
  resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
2282
 
 
 
 
2283
  fast-uri@3.1.2:
2284
  resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
2285
 
@@ -2763,6 +2772,9 @@ packages:
2763
  resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
2764
  hasBin: true
2765
 
 
 
 
2766
  postcss@8.4.31:
2767
  resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
2768
  engines: {node: ^10 || ^12 || >=14}
@@ -2843,6 +2855,15 @@ packages:
2843
  resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
2844
  engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
2845
 
 
 
 
 
 
 
 
 
 
2846
  resolve-from@4.0.0:
2847
  resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2848
  engines: {node: '>=4'}
@@ -2936,6 +2957,9 @@ packages:
2936
  stackback@0.0.2:
2937
  resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
2938
 
 
 
 
2939
  std-env@4.1.0:
2940
  resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
2941
 
@@ -4548,6 +4572,8 @@ snapshots:
4548
 
4549
  '@rolldown/pluginutils@1.0.1': {}
4550
 
 
 
4551
  '@standard-schema/spec@1.1.0': {}
4552
 
4553
  '@swc/helpers@0.5.15':
@@ -5152,6 +5178,8 @@ snapshots:
5152
 
5153
  fast-safe-stringify@2.1.1: {}
5154
 
 
 
5155
  fast-uri@3.1.2: {}
5156
 
5157
  fastify-plugin@5.1.0: {}
@@ -5612,6 +5640,8 @@ snapshots:
5612
  sonic-boom: 4.2.1
5613
  thread-stream: 4.2.0
5614
 
 
 
5615
  postcss@8.4.31:
5616
  dependencies:
5617
  nanoid: 3.3.12
@@ -5691,6 +5721,11 @@ snapshots:
5691
  transitivePeerDependencies:
5692
  - supports-color
5693
 
 
 
 
 
 
5694
  resolve-from@4.0.0: {}
5695
 
5696
  resolve-pkg-maps@1.0.0: {}
@@ -5806,6 +5841,11 @@ snapshots:
5806
 
5807
  stackback@0.0.2: {}
5808
 
 
 
 
 
 
5809
  std-env@4.1.0: {}
5810
 
5811
  string-width@4.2.3:
 
176
  drizzle-orm:
177
  specifier: ^0.45.2
178
  version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)
179
+ resend:
180
+ specifier: ^6.12.4
181
+ version: 6.12.4
182
  zod:
183
  specifier: ^4.4.3
184
  version: 4.4.3
 
1698
  '@rolldown/pluginutils@1.0.1':
1699
  resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
1700
 
1701
+ '@stablelib/base64@1.0.1':
1702
+ resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
1703
+
1704
  '@standard-schema/spec@1.1.0':
1705
  resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
1706
 
 
2286
  fast-safe-stringify@2.1.1:
2287
  resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
2288
 
2289
+ fast-sha256@1.3.0:
2290
+ resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
2291
+
2292
  fast-uri@3.1.2:
2293
  resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
2294
 
 
2772
  resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
2773
  hasBin: true
2774
 
2775
+ postal-mime@2.7.4:
2776
+ resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
2777
+
2778
  postcss@8.4.31:
2779
  resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
2780
  engines: {node: ^10 || ^12 || >=14}
 
2855
  resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
2856
  engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
2857
 
2858
+ resend@6.12.4:
2859
+ resolution: {integrity: sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==}
2860
+ engines: {node: '>=20'}
2861
+ peerDependencies:
2862
+ '@react-email/render': '*'
2863
+ peerDependenciesMeta:
2864
+ '@react-email/render':
2865
+ optional: true
2866
+
2867
  resolve-from@4.0.0:
2868
  resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
2869
  engines: {node: '>=4'}
 
2957
  stackback@0.0.2:
2958
  resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
2959
 
2960
+ standardwebhooks@1.0.0:
2961
+ resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
2962
+
2963
  std-env@4.1.0:
2964
  resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
2965
 
 
4572
 
4573
  '@rolldown/pluginutils@1.0.1': {}
4574
 
4575
+ '@stablelib/base64@1.0.1': {}
4576
+
4577
  '@standard-schema/spec@1.1.0': {}
4578
 
4579
  '@swc/helpers@0.5.15':
 
5178
 
5179
  fast-safe-stringify@2.1.1: {}
5180
 
5181
+ fast-sha256@1.3.0: {}
5182
+
5183
  fast-uri@3.1.2: {}
5184
 
5185
  fastify-plugin@5.1.0: {}
 
5640
  sonic-boom: 4.2.1
5641
  thread-stream: 4.2.0
5642
 
5643
+ postal-mime@2.7.4: {}
5644
+
5645
  postcss@8.4.31:
5646
  dependencies:
5647
  nanoid: 3.3.12
 
5721
  transitivePeerDependencies:
5722
  - supports-color
5723
 
5724
+ resend@6.12.4:
5725
+ dependencies:
5726
+ postal-mime: 2.7.4
5727
+ standardwebhooks: 1.0.0
5728
+
5729
  resolve-from@4.0.0: {}
5730
 
5731
  resolve-pkg-maps@1.0.0: {}
 
5841
 
5842
  stackback@0.0.2: {}
5843
 
5844
+ standardwebhooks@1.0.0:
5845
+ dependencies:
5846
+ '@stablelib/base64': 1.0.1
5847
+ fast-sha256: 1.3.0
5848
+
5849
  std-env@4.1.0: {}
5850
 
5851
  string-width@4.2.3: