handoff-relay 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2578 @@
1
+ // src/api/server.ts
2
+ import Fastify from "fastify";
3
+ import { z as z2 } from "zod";
4
+
5
+ // src/errors.ts
6
+ var RelayError = class extends Error {
7
+ code;
8
+ statusCode;
9
+ details;
10
+ constructor(code, message, statusCode = 400, details) {
11
+ super(message);
12
+ this.name = "RelayError";
13
+ this.code = code;
14
+ this.statusCode = statusCode;
15
+ this.details = details;
16
+ }
17
+ };
18
+ function relayError(code, message, statusCode = 400, details) {
19
+ return new RelayError(code, message, statusCode, details);
20
+ }
21
+ function isRelayError(error) {
22
+ return error instanceof RelayError;
23
+ }
24
+
25
+ // src/protocol/schema.ts
26
+ import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
27
+ import { z } from "zod";
28
+
29
+ // src/audit.ts
30
+ import { createHash, randomUUID } from "crypto";
31
+ function createAuditReceipt(input) {
32
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
33
+ const material = JSON.stringify({
34
+ action: input.action,
35
+ actor_member_id: input.actorMemberId,
36
+ packet_id: input.packetId,
37
+ workspace_id: input.workspaceId,
38
+ created_at: createdAt,
39
+ metadata: input.metadata ?? {}
40
+ });
41
+ return {
42
+ receipt_id: `rcp_${randomUUID()}`,
43
+ action: input.action,
44
+ actor_member_id: input.actorMemberId,
45
+ packet_id: input.packetId,
46
+ workspace_id: input.workspaceId,
47
+ created_at: createdAt,
48
+ metadata: input.metadata ?? {},
49
+ receipt_hash: `sha256:${createHash("sha256").update(material).digest("hex")}`
50
+ };
51
+ }
52
+
53
+ // src/protocol/schema.ts
54
+ var packetStatuses = [
55
+ "draft",
56
+ "pending_sender_approval",
57
+ "sent",
58
+ "delivered",
59
+ "viewed",
60
+ "accepted",
61
+ "clarification_requested",
62
+ "response_drafting",
63
+ "pending_recipient_approval",
64
+ "replied",
65
+ "hydrated",
66
+ "archived",
67
+ "declined",
68
+ "expired",
69
+ "superseded",
70
+ "closed_resolved",
71
+ "closed_unresolved"
72
+ ];
73
+ var packetTypes = ["ask", "share", "reply", "clarification"];
74
+ var confidenceLevels = ["low", "medium", "high"];
75
+ var auditReceiptSchema = z.object({
76
+ receipt_id: z.string().min(1),
77
+ action: z.string().min(1),
78
+ actor_member_id: z.string().min(1),
79
+ packet_id: z.string().min(1).optional(),
80
+ workspace_id: z.string().min(1),
81
+ created_at: z.string().datetime(),
82
+ metadata: z.record(z.string(), z.unknown()).default({}),
83
+ receipt_hash: z.string().min(1).optional()
84
+ });
85
+ var projectIdentitySchema = z.object({
86
+ repo_name: z.string().min(1),
87
+ git_remote_fingerprint: z.string().min(1).optional(),
88
+ branch: z.string().min(1).optional(),
89
+ commit_hash: z.string().min(1).optional()
90
+ }).strict();
91
+ var claimSchema = z.object({
92
+ claim_id: z.string().min(1),
93
+ text: z.string().min(1),
94
+ confidence: z.enum(confidenceLevels),
95
+ status: z.enum(["observed", "inferred", "suspected", "disproven", "superseded"]),
96
+ evidence_ids: z.array(z.string()).default([]),
97
+ needs_recheck: z.boolean().default(false)
98
+ }).strict();
99
+ var evidenceSchema = z.object({
100
+ evidence_id: z.string().min(1),
101
+ kind: z.enum([
102
+ "file_excerpt",
103
+ "command_output",
104
+ "test_failure",
105
+ "error_message",
106
+ "log_excerpt",
107
+ "diff_summary",
108
+ "ticket_link",
109
+ "pr_link",
110
+ "human_note"
111
+ ]),
112
+ label: z.string().min(1),
113
+ source: z.string().min(1),
114
+ excerpt: z.string().default(""),
115
+ hash: z.string().min(1),
116
+ captured_at: z.string().datetime(),
117
+ sensitivity: z.enum(["normal", "private", "secret_detected", "restricted"]).default("normal")
118
+ }).strict();
119
+ var redactionFindingSchema = z.object({
120
+ kind: z.enum([
121
+ "api_key",
122
+ "private_key",
123
+ "credential_url",
124
+ "env_secret",
125
+ "local_path",
126
+ "oversized_excerpt",
127
+ "user_pattern"
128
+ ]),
129
+ field: z.string().min(1),
130
+ evidence_id: z.string().optional(),
131
+ severity: z.enum(["warning", "block"]),
132
+ message: z.string().min(1),
133
+ preview: z.string().optional()
134
+ }).strict();
135
+ var redactionReportSchema = z.object({
136
+ blocked: z.boolean(),
137
+ findings: z.array(redactionFindingSchema).default([]),
138
+ warnings: z.array(redactionFindingSchema).default([])
139
+ }).strict();
140
+ var hydrationPolicySchema = z.object({
141
+ requires_recipient_approval: z.boolean().default(true),
142
+ requires_sender_approval_for_replies: z.boolean().default(true),
143
+ allow_raw_transcript: z.boolean().default(false),
144
+ max_characters: z.number().int().positive().default(8e3)
145
+ }).strict();
146
+ var packetSchema = z.object({
147
+ packet_id: z.string().min(1),
148
+ packet_type: z.enum(packetTypes),
149
+ workspace_id: z.string().min(1),
150
+ sender_member_id: z.string().min(1),
151
+ recipient_member_ids: z.array(z.string().min(1)).min(1),
152
+ parent_packet_id: z.string().min(1).optional(),
153
+ created_at: z.string().datetime(),
154
+ updated_at: z.string().datetime(),
155
+ expires_at: z.string().datetime().optional(),
156
+ recheck_by: z.string().datetime().optional(),
157
+ status: z.enum(packetStatuses),
158
+ project: projectIdentitySchema,
159
+ source_client: z.enum(["claude-code", "codex", "cursor", "generic", "other"]),
160
+ title: z.string().min(1).max(200),
161
+ summary: z.string().min(1).max(5e3),
162
+ question: z.string().optional(),
163
+ finding: z.string().optional(),
164
+ answer: z.string().optional(),
165
+ claims: z.array(claimSchema).default([]),
166
+ evidence: z.array(evidenceSchema).default([]),
167
+ files_or_symbols: z.array(z.string()).default([]),
168
+ commands_or_tests_run: z.array(z.string()).default([]),
169
+ what_was_tried: z.array(z.string()).default([]),
170
+ known_failures: z.array(z.string()).default([]),
171
+ current_hypothesis: z.string().default(""),
172
+ confidence: z.enum(confidenceLevels).default("medium"),
173
+ suggested_next_steps: z.array(z.string()).default([]),
174
+ redaction_report: redactionReportSchema,
175
+ hydration_policy: hydrationPolicySchema,
176
+ audit_receipt: auditReceiptSchema
177
+ }).strict().superRefine((packet, ctx) => {
178
+ if (packet.packet_type === "ask" && !packet.question?.trim()) {
179
+ ctx.addIssue({
180
+ code: "custom",
181
+ path: ["question"],
182
+ message: "Ask packets require a main question."
183
+ });
184
+ }
185
+ if (packet.packet_type === "share" && !packet.finding?.trim()) {
186
+ ctx.addIssue({
187
+ code: "custom",
188
+ path: ["finding"],
189
+ message: "Share packets require a finding."
190
+ });
191
+ }
192
+ if (packet.packet_type === "reply" && !packet.answer?.trim()) {
193
+ ctx.addIssue({
194
+ code: "custom",
195
+ path: ["answer"],
196
+ message: "Reply packets require an answer."
197
+ });
198
+ }
199
+ if (packet.packet_type === "clarification" && !packet.question?.trim()) {
200
+ ctx.addIssue({
201
+ code: "custom",
202
+ path: ["question"],
203
+ message: "Clarification packets require a question."
204
+ });
205
+ }
206
+ if (packet.packet_type !== "clarification" && !packet.expires_at && !packet.recheck_by) {
207
+ ctx.addIssue({
208
+ code: "custom",
209
+ path: ["expires_at"],
210
+ message: "Packets require expires_at or recheck_by."
211
+ });
212
+ }
213
+ });
214
+ var defaultContextBudget = {
215
+ summaryCharacters: 1200,
216
+ mainTextCharacters: 1200,
217
+ maxClaims: 10,
218
+ maxEvidence: 8,
219
+ maxExcerptCharacters: 2e3,
220
+ maxHydrationCharacters: 32e3
221
+ };
222
+ function hashExcerpt(excerpt) {
223
+ return `sha256:${createHash2("sha256").update(excerpt).digest("hex")}`;
224
+ }
225
+ function defaultExpiry() {
226
+ return new Date(Date.now() + 24 * 60 * 60 * 1e3).toISOString();
227
+ }
228
+ function normalizeEvidence(evidence, index) {
229
+ const excerpt = evidence.excerpt ?? "";
230
+ return evidenceSchema.parse({
231
+ evidence_id: evidence.evidence_id ?? `ev_${randomUUID2()}`,
232
+ kind: evidence.kind ?? "human_note",
233
+ label: evidence.label ?? `Evidence ${index + 1}`,
234
+ source: evidence.source ?? "handoff",
235
+ excerpt,
236
+ hash: evidence.hash ?? hashExcerpt(excerpt),
237
+ captured_at: evidence.captured_at ?? (/* @__PURE__ */ new Date()).toISOString(),
238
+ sensitivity: evidence.sensitivity ?? "normal"
239
+ });
240
+ }
241
+ function normalizeClaim(claim, index) {
242
+ return claimSchema.parse({
243
+ claim_id: claim.claim_id ?? `clm_${randomUUID2()}`,
244
+ text: claim.text ?? `Claim ${index + 1}`,
245
+ confidence: claim.confidence ?? "medium",
246
+ status: claim.status ?? "inferred",
247
+ evidence_ids: claim.evidence_ids ?? [],
248
+ needs_recheck: claim.needs_recheck ?? false
249
+ });
250
+ }
251
+ function buildPacketDraft(input) {
252
+ const now = (/* @__PURE__ */ new Date()).toISOString();
253
+ const packetId = `pkt_${randomUUID2()}`;
254
+ const receipt = createAuditReceipt({
255
+ action: "draft",
256
+ actorMemberId: input.sender_member_id,
257
+ packetId,
258
+ workspaceId: input.workspace_id,
259
+ metadata: {
260
+ source_client: input.source_client,
261
+ packet_type: input.packet_type
262
+ }
263
+ });
264
+ const packet = {
265
+ packet_id: packetId,
266
+ packet_type: input.packet_type,
267
+ workspace_id: input.workspace_id,
268
+ sender_member_id: input.sender_member_id,
269
+ recipient_member_ids: input.recipient_member_ids,
270
+ parent_packet_id: input.parent_packet_id,
271
+ created_at: now,
272
+ updated_at: now,
273
+ expires_at: input.expires_at ?? defaultExpiry(),
274
+ recheck_by: input.recheck_by,
275
+ status: input.status ?? "pending_sender_approval",
276
+ project: {
277
+ repo_name: input.project?.repo_name ?? "unknown-project",
278
+ git_remote_fingerprint: input.project?.git_remote_fingerprint,
279
+ branch: input.project?.branch,
280
+ commit_hash: input.project?.commit_hash
281
+ },
282
+ source_client: input.source_client,
283
+ title: input.title,
284
+ summary: input.summary,
285
+ question: input.question,
286
+ finding: input.finding,
287
+ answer: input.answer,
288
+ claims: input.claims?.map(normalizeClaim) ?? [],
289
+ evidence: input.evidence?.map(normalizeEvidence) ?? [],
290
+ files_or_symbols: input.files_or_symbols ?? [],
291
+ commands_or_tests_run: input.commands_or_tests_run ?? [],
292
+ what_was_tried: input.what_was_tried ?? [],
293
+ known_failures: input.known_failures ?? [],
294
+ current_hypothesis: input.current_hypothesis ?? "",
295
+ confidence: input.confidence ?? "medium",
296
+ suggested_next_steps: input.suggested_next_steps ?? [],
297
+ redaction_report: input.redaction_report ?? {
298
+ blocked: false,
299
+ findings: [],
300
+ warnings: []
301
+ },
302
+ hydration_policy: {
303
+ requires_recipient_approval: input.hydration_policy?.requires_recipient_approval ?? true,
304
+ requires_sender_approval_for_replies: input.hydration_policy?.requires_sender_approval_for_replies ?? true,
305
+ allow_raw_transcript: input.hydration_policy?.allow_raw_transcript ?? false,
306
+ max_characters: input.hydration_policy?.max_characters ?? 8e3
307
+ },
308
+ audit_receipt: receipt
309
+ };
310
+ return packetSchema.parse(packet);
311
+ }
312
+ function validateContextBudget(packet, limits = {}) {
313
+ const budget = { ...defaultContextBudget, ...limits };
314
+ const violations = [];
315
+ const mainText2 = packet.question ?? packet.finding ?? packet.answer ?? "";
316
+ const hydrationCharacters = packet.summary.length + mainText2.length + packet.claims.reduce((total, claim) => total + claim.text.length, 0) + packet.evidence.reduce((total, evidence) => total + evidence.excerpt.length, 0);
317
+ if (packet.summary.length > budget.summaryCharacters) {
318
+ violations.push(`summary exceeds ${budget.summaryCharacters} characters`);
319
+ }
320
+ if (mainText2.length > budget.mainTextCharacters) {
321
+ violations.push(`main packet text exceeds ${budget.mainTextCharacters} characters`);
322
+ }
323
+ if (packet.claims.length > budget.maxClaims) {
324
+ violations.push(`claims exceed ${budget.maxClaims} max`);
325
+ }
326
+ if (packet.evidence.length > budget.maxEvidence) {
327
+ violations.push(`evidence exceeds ${budget.maxEvidence} max`);
328
+ }
329
+ for (const evidence of packet.evidence) {
330
+ if (evidence.excerpt.length > budget.maxExcerptCharacters) {
331
+ violations.push(
332
+ `evidence excerpt ${evidence.evidence_id} exceeds ${budget.maxExcerptCharacters} characters`
333
+ );
334
+ }
335
+ }
336
+ if (hydrationCharacters > budget.maxHydrationCharacters) {
337
+ violations.push(`hydration payload exceeds ${budget.maxHydrationCharacters} characters`);
338
+ }
339
+ return { ok: violations.length === 0, violations };
340
+ }
341
+ function compressPacketToBudget(packet, limits = {}) {
342
+ const budget = { ...defaultContextBudget, ...limits };
343
+ const clippedEvidence = packet.evidence.slice(0, budget.maxEvidence).map((evidence) => {
344
+ if (evidence.excerpt.length <= budget.maxExcerptCharacters) {
345
+ return evidence;
346
+ }
347
+ return {
348
+ ...evidence,
349
+ excerpt: `${evidence.excerpt.slice(0, budget.maxExcerptCharacters - 80)}
350
+ [truncated by Handoff; hash preserved: ${evidence.hash}]`
351
+ };
352
+ });
353
+ return packetSchema.parse({
354
+ ...packet,
355
+ summary: packet.summary.slice(0, budget.summaryCharacters),
356
+ question: packet.question?.slice(0, budget.mainTextCharacters),
357
+ finding: packet.finding?.slice(0, budget.mainTextCharacters),
358
+ answer: packet.answer?.slice(0, budget.mainTextCharacters),
359
+ claims: packet.claims.slice(0, budget.maxClaims),
360
+ evidence: clippedEvidence,
361
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
362
+ });
363
+ }
364
+
365
+ // src/hydration.ts
366
+ function isStale(packet) {
367
+ const now = Date.now();
368
+ return Boolean(
369
+ packet.expires_at && Date.parse(packet.expires_at) < now || packet.recheck_by && Date.parse(packet.recheck_by) < now || packet.claims.some((claim) => claim.needs_recheck)
370
+ );
371
+ }
372
+ function mainText(packet) {
373
+ if (packet.packet_type === "ask") return packet.question ?? "";
374
+ if (packet.packet_type === "share") return packet.finding ?? "";
375
+ if (packet.packet_type === "reply") return packet.answer ?? "";
376
+ return packet.question ?? "";
377
+ }
378
+ function formatHydrationContext(packet, input) {
379
+ const maxCharacters = input.maxCharacters ?? packet.hydration_policy.max_characters;
380
+ const claims = packet.claims.map(
381
+ (claim) => `- [${claim.confidence}/${claim.status}] ${claim.text}${claim.needs_recheck ? " (needs recheck)" : ""}`
382
+ ).join("\n");
383
+ const evidence = packet.evidence.map(
384
+ (item) => `- ${item.label} (${item.kind}, ${item.sensitivity}, ${item.source}, ${item.hash})
385
+ ${item.excerpt}`
386
+ ).join("\n");
387
+ const staleWarning = isStale(packet) ? "\nSTALE OR RECHECK REQUIRED: verify dates, claims, and evidence before relying on this packet.\n" : "";
388
+ const context = `
389
+ Handoff Hydration Packet
390
+ ============================
391
+ ${staleWarning}
392
+ Title: ${packet.title}
393
+ Type: ${packet.packet_type}
394
+ Status at hydration: ${packet.status}
395
+
396
+ Main content:
397
+ ${mainText(packet)}
398
+
399
+ Summary:
400
+ ${packet.summary}
401
+
402
+ Claims:
403
+ ${claims || "- No explicit claims supplied."}
404
+
405
+ Evidence:
406
+ ${evidence || "- No evidence supplied."}
407
+
408
+ What was tried:
409
+ ${packet.what_was_tried.map((item) => `- ${item}`).join("\n") || "- Not supplied."}
410
+
411
+ Known failures:
412
+ ${packet.known_failures.map((item) => `- ${item}`).join("\n") || "- Not supplied."}
413
+
414
+ Current hypothesis:
415
+ ${packet.current_hypothesis || "Not supplied."}
416
+
417
+ Suggested next steps:
418
+ ${packet.suggested_next_steps.map((item) => `- ${item}`).join("\n") || "- Not supplied."}
419
+
420
+ Provenance:
421
+ - packet_id: ${packet.packet_id}
422
+ - workspace_id: ${packet.workspace_id}
423
+ - sender_member_id: ${packet.sender_member_id}
424
+ - recipient_member_ids: ${packet.recipient_member_ids.join(", ")}
425
+ - source_client: ${packet.source_client}
426
+ - project: ${packet.project.repo_name}${packet.project.branch ? ` on ${packet.project.branch}` : ""}
427
+ - created_at: ${packet.created_at}
428
+ - recheck_by: ${packet.recheck_by ?? "not set"}
429
+ - expires_at: ${packet.expires_at ?? "not set"}
430
+ `.trim();
431
+ const bounded = context.length > maxCharacters ? `${context.slice(0, Math.max(0, maxCharacters - 120))}
432
+ [truncated by Handoff hydration budget]` : context;
433
+ return {
434
+ context: bounded,
435
+ receipt: createAuditReceipt({
436
+ action: "hydrate",
437
+ actorMemberId: input.hydratedBy,
438
+ packetId: packet.packet_id,
439
+ workspaceId: packet.workspace_id,
440
+ metadata: {
441
+ client: input.client,
442
+ session_id: input.sessionId,
443
+ packet_type: packet.packet_type
444
+ }
445
+ })
446
+ };
447
+ }
448
+
449
+ // src/identity.ts
450
+ import { createHash as createHash3, randomBytes, randomUUID as randomUUID3 } from "crypto";
451
+ function createId(prefix) {
452
+ return `${prefix}_${randomUUID3()}`;
453
+ }
454
+ function createToken(prefix = "relay") {
455
+ return `${prefix}_${randomBytes(24).toString("base64url")}`;
456
+ }
457
+ function hashToken(token) {
458
+ return createHash3("sha256").update(token).digest("hex");
459
+ }
460
+ function normalizeHandle(handle) {
461
+ const trimmed = handle.trim().replace(/^@/, "").toLowerCase();
462
+ if (!/^[a-z][a-z0-9_-]{1,31}$/.test(trimmed)) {
463
+ throw new Error("Handle must start with a letter and contain only letters, numbers, _, or -");
464
+ }
465
+ return trimmed;
466
+ }
467
+
468
+ // src/protocol/state-machine.ts
469
+ var transitionRules = {
470
+ draft: [
471
+ { to: "pending_sender_approval", roles: ["sender"] },
472
+ { to: "archived", roles: ["sender", "admin"] }
473
+ ],
474
+ pending_sender_approval: [
475
+ { to: "sent", roles: ["sender"] },
476
+ { to: "replied", roles: ["recipient"], packetTypes: ["reply"] },
477
+ { to: "archived", roles: ["sender", "admin", "recipient"] }
478
+ ],
479
+ sent: [
480
+ { to: "delivered", roles: ["system"] },
481
+ { to: "expired", roles: ["system"] },
482
+ { to: "superseded", roles: ["sender", "admin"] }
483
+ ],
484
+ delivered: [
485
+ { to: "viewed", roles: ["recipient"] },
486
+ { to: "declined", roles: ["recipient"] },
487
+ { to: "archived", roles: ["recipient"] },
488
+ { to: "expired", roles: ["system"] }
489
+ ],
490
+ viewed: [
491
+ { to: "accepted", roles: ["recipient"] },
492
+ { to: "clarification_requested", roles: ["recipient"] },
493
+ { to: "declined", roles: ["recipient"] },
494
+ { to: "archived", roles: ["recipient"] },
495
+ { to: "hydrated", roles: ["recipient"], packetTypes: ["reply"] }
496
+ ],
497
+ accepted: [
498
+ { to: "hydrated", roles: ["recipient"] },
499
+ { to: "response_drafting", roles: ["recipient"], packetTypes: ["ask"] },
500
+ { to: "archived", roles: ["recipient", "sender"] }
501
+ ],
502
+ clarification_requested: [
503
+ { to: "pending_sender_approval", roles: ["sender"] },
504
+ { to: "declined", roles: ["recipient"] },
505
+ { to: "archived", roles: ["recipient", "sender"] }
506
+ ],
507
+ response_drafting: [
508
+ { to: "pending_recipient_approval", roles: ["recipient"] },
509
+ { to: "archived", roles: ["recipient"] }
510
+ ],
511
+ pending_recipient_approval: [
512
+ { to: "replied", roles: ["recipient"] },
513
+ { to: "archived", roles: ["recipient"] }
514
+ ],
515
+ replied: [
516
+ { to: "viewed", roles: ["recipient"], packetTypes: ["reply"] },
517
+ { to: "closed_resolved", roles: ["sender"] },
518
+ { to: "closed_unresolved", roles: ["sender"] },
519
+ { to: "archived", roles: ["recipient", "sender"] }
520
+ ],
521
+ hydrated: [
522
+ { to: "response_drafting", roles: ["recipient"], packetTypes: ["ask"] },
523
+ { to: "archived", roles: ["recipient", "sender"] },
524
+ { to: "closed_resolved", roles: ["sender"] },
525
+ { to: "closed_unresolved", roles: ["sender"] }
526
+ ],
527
+ archived: [],
528
+ declined: [{ to: "archived", roles: ["recipient", "sender"] }],
529
+ expired: [{ to: "archived", roles: ["recipient", "sender", "admin"] }],
530
+ superseded: [{ to: "archived", roles: ["recipient", "sender", "admin"] }],
531
+ closed_resolved: [{ to: "archived", roles: ["sender", "recipient"] }],
532
+ closed_unresolved: [{ to: "archived", roles: ["sender", "recipient"] }]
533
+ };
534
+ function assertTransition(input) {
535
+ const rules = transitionRules[input.from] ?? [];
536
+ const rule = rules.find(
537
+ (candidate) => candidate.to === input.to && candidate.roles.includes(input.actorRole) && (!candidate.packetTypes || !input.packetType || candidate.packetTypes.includes(input.packetType))
538
+ );
539
+ if (!rule) {
540
+ const allowed = rules.map((candidate) => `${candidate.to} by ${candidate.roles.join("/")}`).join(", ");
541
+ throw relayError(
542
+ "INVALID_STATE_TRANSITION",
543
+ `Invalid state transition from ${input.from} to ${input.to} by ${input.actorRole}. Allowed: ${allowed || "none"}`,
544
+ 409
545
+ );
546
+ }
547
+ }
548
+
549
+ // src/redaction.ts
550
+ var apiKeyPattern = /\b(?:sk-[A-Za-z0-9_-]{10,}|ghp_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|(?:api[_-]?key|access[_-]?token|secret|password)\s*=\s*[^\s"'`]+)\b/i;
551
+ var privateKeyPattern = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/;
552
+ var credentialUrlPattern = /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^@\s]+@/i;
553
+ var envSecretPattern = /(^|\n)\s*(?:[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z0-9_]*)\s*=\s*[^\s]+/i;
554
+ var localPathPattern = /(?:^|\s)(\/Users\/[^\s"'`]+|\/home\/[^\s"'`]+|[A-Za-z]:\\Users\\[^\s"'`]+)/;
555
+ function preview(value) {
556
+ return value.replace(/\s+/g, " ").slice(0, 80);
557
+ }
558
+ function scanText(text, field, evidenceId, options) {
559
+ const findings = [];
560
+ const warnings = [];
561
+ const addBlock = (kind, message) => {
562
+ findings.push({
563
+ kind,
564
+ field,
565
+ evidence_id: evidenceId,
566
+ severity: "block",
567
+ message,
568
+ preview: preview(text)
569
+ });
570
+ };
571
+ const addWarning = (kind, message) => {
572
+ warnings.push({
573
+ kind,
574
+ field,
575
+ evidence_id: evidenceId,
576
+ severity: "warning",
577
+ message,
578
+ preview: preview(text)
579
+ });
580
+ };
581
+ if (apiKeyPattern.test(text)) {
582
+ addBlock("api_key", "Secret-looking API key or token detected.");
583
+ }
584
+ if (privateKeyPattern.test(text)) {
585
+ addBlock("private_key", "Private key material detected.");
586
+ }
587
+ if (credentialUrlPattern.test(text)) {
588
+ addBlock("credential_url", "Credential-bearing URL detected.");
589
+ }
590
+ if (envSecretPattern.test(text)) {
591
+ addBlock("env_secret", ".env-like secret content detected.");
592
+ }
593
+ if (localPathPattern.test(text)) {
594
+ addWarning("local_path", "Local absolute path detected; review before sending.");
595
+ }
596
+ if (text.length > options.maxExcerptCharacters) {
597
+ addWarning(
598
+ "oversized_excerpt",
599
+ `Evidence excerpt exceeds ${options.maxExcerptCharacters} characters and should be compressed.`
600
+ );
601
+ }
602
+ for (const pattern of options.userPatterns ?? []) {
603
+ if (pattern.test(text)) {
604
+ addBlock("user_pattern", "User-defined restricted pattern detected.");
605
+ }
606
+ }
607
+ return { findings, warnings };
608
+ }
609
+ function scanPacketForRedactions(packet, options = {}) {
610
+ const mergedOptions = {
611
+ maxExcerptCharacters: options.maxExcerptCharacters ?? 2e3,
612
+ userPatterns: options.userPatterns
613
+ };
614
+ const findings = [];
615
+ const warnings = [];
616
+ const textFields = collectRedactionTextFields(packet);
617
+ for (const [field, value] of textFields) {
618
+ if (!value) continue;
619
+ const result = scanText(value, field, void 0, mergedOptions);
620
+ findings.push(...result.findings);
621
+ warnings.push(...result.warnings);
622
+ }
623
+ for (const [index, evidence] of packet.evidence.entries()) {
624
+ if (evidence.sensitivity === "secret_detected" || evidence.sensitivity === "restricted") {
625
+ findings.push({
626
+ kind: "user_pattern",
627
+ field: `evidence.${index}.sensitivity`,
628
+ evidence_id: evidence.evidence_id,
629
+ severity: "block",
630
+ message: `Evidence sensitivity ${evidence.sensitivity} cannot be sent without override.`
631
+ });
632
+ }
633
+ }
634
+ return {
635
+ blocked: findings.some((finding) => finding.severity === "block"),
636
+ findings,
637
+ warnings
638
+ };
639
+ }
640
+ function collectRedactionTextFields(packet) {
641
+ const fields = [
642
+ ["title", packet.title],
643
+ ["summary", packet.summary],
644
+ ["current_hypothesis", packet.current_hypothesis],
645
+ ...optionalField("question", packet.question),
646
+ ...optionalField("finding", packet.finding),
647
+ ...optionalField("answer", packet.answer)
648
+ ];
649
+ for (const [index, claim] of packet.claims.entries()) {
650
+ fields.push([`claims.${index}.text`, claim.text]);
651
+ }
652
+ pushArrayFields(fields, "files_or_symbols", packet.files_or_symbols);
653
+ pushArrayFields(fields, "commands_or_tests_run", packet.commands_or_tests_run);
654
+ pushArrayFields(fields, "what_was_tried", packet.what_was_tried);
655
+ pushArrayFields(fields, "known_failures", packet.known_failures);
656
+ pushArrayFields(fields, "suggested_next_steps", packet.suggested_next_steps);
657
+ for (const [index, evidence] of packet.evidence.entries()) {
658
+ fields.push([`evidence.${index}.label`, evidence.label]);
659
+ fields.push([`evidence.${index}.source`, evidence.source]);
660
+ fields.push([`evidence.${index}.excerpt`, evidence.excerpt]);
661
+ }
662
+ return fields;
663
+ }
664
+ function optionalField(field, value) {
665
+ return value ? [[field, value]] : [];
666
+ }
667
+ function pushArrayFields(fields, prefix, values) {
668
+ for (const [index, value] of values.entries()) {
669
+ fields.push([`${prefix}.${index}`, value]);
670
+ }
671
+ }
672
+
673
+ // src/service/relay-service.ts
674
+ function rowToMember(row, token, approvalSecret) {
675
+ return {
676
+ id: row.id,
677
+ workspace_id: row.workspace_id,
678
+ handle: row.handle,
679
+ display_name: row.display_name,
680
+ role: row.role,
681
+ status: row.status,
682
+ token,
683
+ approval_secret: approvalSecret,
684
+ created_at: row.created_at,
685
+ revoked_at: row.revoked_at ?? void 0
686
+ };
687
+ }
688
+ function rowToWorkspace(row) {
689
+ return {
690
+ id: row.id,
691
+ name: row.name,
692
+ admin_body_access: Boolean(row.admin_body_access),
693
+ created_at: row.created_at
694
+ };
695
+ }
696
+ function rowToInvite(row) {
697
+ return {
698
+ id: row.id,
699
+ workspace_id: row.workspace_id,
700
+ handle: row.handle,
701
+ token: row.token,
702
+ created_by_member_id: row.created_by_member_id,
703
+ expires_at: row.expires_at,
704
+ accepted_at: row.accepted_at ?? void 0,
705
+ created_at: row.created_at
706
+ };
707
+ }
708
+ function rowToProjectAlias(row) {
709
+ return {
710
+ id: row.id,
711
+ workspace_id: row.workspace_id,
712
+ canonical_project: row.canonical_project,
713
+ alias: row.alias,
714
+ created_by_member_id: row.created_by_member_id,
715
+ created_at: row.created_at
716
+ };
717
+ }
718
+ function normalizeProjectName(value) {
719
+ const normalized = value.trim().toLowerCase();
720
+ if (!normalized) {
721
+ throw relayError("INVALID_INPUT", "Project alias values cannot be empty.", 400);
722
+ }
723
+ return normalized;
724
+ }
725
+ function parseJson(value) {
726
+ return JSON.parse(value);
727
+ }
728
+ function rowToPacket(row) {
729
+ return packetSchema.parse({
730
+ packet_id: row.id,
731
+ packet_type: row.packet_type,
732
+ workspace_id: row.workspace_id,
733
+ sender_member_id: row.sender_member_id,
734
+ recipient_member_ids: parseJson(row.recipient_member_ids),
735
+ parent_packet_id: row.parent_packet_id ?? void 0,
736
+ created_at: row.created_at,
737
+ updated_at: row.updated_at,
738
+ expires_at: row.expires_at ?? void 0,
739
+ recheck_by: row.recheck_by ?? void 0,
740
+ status: row.status,
741
+ project: parseJson(row.project),
742
+ source_client: row.source_client,
743
+ title: row.title,
744
+ summary: row.summary,
745
+ question: row.question ?? void 0,
746
+ finding: row.finding ?? void 0,
747
+ answer: row.answer ?? void 0,
748
+ claims: parseJson(row.claims),
749
+ evidence: parseJson(row.evidence),
750
+ files_or_symbols: parseJson(row.files_or_symbols),
751
+ commands_or_tests_run: parseJson(row.commands_or_tests_run),
752
+ what_was_tried: parseJson(row.what_was_tried),
753
+ known_failures: parseJson(row.known_failures),
754
+ current_hypothesis: row.current_hypothesis,
755
+ confidence: row.confidence,
756
+ suggested_next_steps: parseJson(row.suggested_next_steps),
757
+ redaction_report: parseJson(row.redaction_report),
758
+ hydration_policy: parseJson(row.hydration_policy),
759
+ audit_receipt: parseJson(row.audit_receipt)
760
+ });
761
+ }
762
+ function packetParams(packet) {
763
+ return {
764
+ id: packet.packet_id,
765
+ workspace_id: packet.workspace_id,
766
+ packet_type: packet.packet_type,
767
+ sender_member_id: packet.sender_member_id,
768
+ recipient_member_ids: JSON.stringify(packet.recipient_member_ids),
769
+ parent_packet_id: packet.parent_packet_id ?? null,
770
+ status: packet.status,
771
+ title: packet.title,
772
+ summary: packet.summary,
773
+ question: packet.question ?? null,
774
+ finding: packet.finding ?? null,
775
+ answer: packet.answer ?? null,
776
+ project: JSON.stringify(packet.project),
777
+ source_client: packet.source_client,
778
+ claims: JSON.stringify(packet.claims),
779
+ evidence: JSON.stringify(packet.evidence),
780
+ files_or_symbols: JSON.stringify(packet.files_or_symbols),
781
+ commands_or_tests_run: JSON.stringify(packet.commands_or_tests_run),
782
+ what_was_tried: JSON.stringify(packet.what_was_tried),
783
+ known_failures: JSON.stringify(packet.known_failures),
784
+ current_hypothesis: packet.current_hypothesis,
785
+ confidence: packet.confidence,
786
+ suggested_next_steps: JSON.stringify(packet.suggested_next_steps),
787
+ redaction_report: JSON.stringify(packet.redaction_report),
788
+ hydration_policy: JSON.stringify(packet.hydration_policy),
789
+ audit_receipt: JSON.stringify(packet.audit_receipt),
790
+ expires_at: packet.expires_at ?? null,
791
+ recheck_by: packet.recheck_by ?? null,
792
+ created_at: packet.created_at,
793
+ updated_at: packet.updated_at
794
+ };
795
+ }
796
+ var RelayService = class {
797
+ constructor(db) {
798
+ this.db = db;
799
+ }
800
+ db;
801
+ close() {
802
+ this.db.close();
803
+ }
804
+ createWorkspace(input) {
805
+ const now = (/* @__PURE__ */ new Date()).toISOString();
806
+ const workspace = {
807
+ id: createId("wrk"),
808
+ name: input.name,
809
+ admin_body_access: input.adminBodyAccess ?? false,
810
+ created_at: now
811
+ };
812
+ const token = createToken("relay_member");
813
+ const approvalSecret = createToken("relay_approval_secret");
814
+ const admin = {
815
+ id: createId("mem"),
816
+ workspace_id: workspace.id,
817
+ handle: normalizeHandle(input.adminHandle),
818
+ display_name: input.adminName,
819
+ role: "admin",
820
+ status: "active",
821
+ token,
822
+ approval_secret: approvalSecret,
823
+ created_at: now
824
+ };
825
+ const transaction = this.db.transaction(() => {
826
+ this.db.prepare(
827
+ "INSERT INTO workspaces (id, name, admin_body_access, created_at) VALUES (?, ?, ?, ?)"
828
+ ).run(
829
+ workspace.id,
830
+ workspace.name,
831
+ workspace.admin_body_access ? 1 : 0,
832
+ workspace.created_at
833
+ );
834
+ this.db.prepare(
835
+ `INSERT INTO members
836
+ (id, workspace_id, handle, display_name, role, status, token_hash, approval_secret_hash, created_at)
837
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
838
+ ).run(
839
+ admin.id,
840
+ admin.workspace_id,
841
+ admin.handle,
842
+ admin.display_name,
843
+ admin.role,
844
+ admin.status,
845
+ hashToken(token),
846
+ hashToken(approvalSecret),
847
+ admin.created_at
848
+ );
849
+ });
850
+ transaction();
851
+ return { workspace, admin };
852
+ }
853
+ inviteMember(input) {
854
+ const admin = this.requireAdmin(input.adminToken, input.workspaceId);
855
+ const handle = normalizeHandle(input.handle);
856
+ const existing = this.findMemberByHandle(input.workspaceId, handle, true);
857
+ if (existing?.status === "revoked") {
858
+ throw relayError("INVALID_RECIPIENT", `Recipient @${handle} is revoked.`, 403);
859
+ }
860
+ if (existing) {
861
+ throw relayError("INVALID_INPUT", `Member @${handle} already exists in this workspace.`, 409);
862
+ }
863
+ const now = (/* @__PURE__ */ new Date()).toISOString();
864
+ const invite = {
865
+ id: createId("inv"),
866
+ workspace_id: input.workspaceId,
867
+ handle,
868
+ token: createToken("relay_invite"),
869
+ created_by_member_id: admin.id,
870
+ expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1e3).toISOString(),
871
+ created_at: now
872
+ };
873
+ this.db.prepare(
874
+ `INSERT INTO invites
875
+ (id, workspace_id, handle, token, created_by_member_id, expires_at, created_at)
876
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
877
+ ).run(
878
+ invite.id,
879
+ invite.workspace_id,
880
+ invite.handle,
881
+ invite.token,
882
+ invite.created_by_member_id,
883
+ invite.expires_at,
884
+ invite.created_at
885
+ );
886
+ return { invite };
887
+ }
888
+ acceptInvite(input) {
889
+ const inviteRow = this.db.prepare("SELECT * FROM invites WHERE token = ?").get(input.inviteToken);
890
+ if (!inviteRow) {
891
+ throw relayError("NOT_FOUND", "Invite not found.", 404);
892
+ }
893
+ if (inviteRow.accepted_at) {
894
+ throw relayError("INVALID_INPUT", "Invite has already been accepted.", 409);
895
+ }
896
+ if (Date.parse(inviteRow.expires_at) < Date.now()) {
897
+ throw relayError("INVALID_INPUT", "Invite has expired.", 410);
898
+ }
899
+ const workspace = this.getWorkspace(inviteRow.workspace_id);
900
+ const now = (/* @__PURE__ */ new Date()).toISOString();
901
+ const token = createToken("relay_member");
902
+ const approvalSecret = createToken("relay_approval_secret");
903
+ const member = {
904
+ id: createId("mem"),
905
+ workspace_id: inviteRow.workspace_id,
906
+ handle: inviteRow.handle,
907
+ display_name: input.displayName,
908
+ role: "member",
909
+ status: "active",
910
+ token,
911
+ approval_secret: approvalSecret,
912
+ created_at: now
913
+ };
914
+ const transaction = this.db.transaction(() => {
915
+ this.db.prepare(
916
+ `INSERT INTO members
917
+ (id, workspace_id, handle, display_name, role, status, token_hash, approval_secret_hash, created_at)
918
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
919
+ ).run(
920
+ member.id,
921
+ member.workspace_id,
922
+ member.handle,
923
+ member.display_name,
924
+ member.role,
925
+ member.status,
926
+ hashToken(token),
927
+ hashToken(approvalSecret),
928
+ member.created_at
929
+ );
930
+ this.db.prepare("UPDATE invites SET accepted_at = ? WHERE id = ?").run(now, inviteRow.id);
931
+ });
932
+ transaction();
933
+ return { member, workspace };
934
+ }
935
+ getInvite(input) {
936
+ const inviteRow = this.db.prepare("SELECT * FROM invites WHERE token = ?").get(input.inviteToken);
937
+ if (!inviteRow) {
938
+ throw relayError("NOT_FOUND", "Invite not found.", 404);
939
+ }
940
+ return {
941
+ invite: rowToInvite(inviteRow),
942
+ workspace: this.getWorkspace(inviteRow.workspace_id)
943
+ };
944
+ }
945
+ listMembers(input) {
946
+ this.requireMember(input.authToken, input.workspaceId);
947
+ return this.db.prepare("SELECT * FROM members WHERE workspace_id = ? ORDER BY handle").all(input.workspaceId).map((row) => rowToMember(row));
948
+ }
949
+ configureProjectAlias(input) {
950
+ const admin = this.requireAdmin(input.authToken, input.workspaceId);
951
+ const canonicalProject = normalizeProjectName(input.canonicalProject);
952
+ const alias = normalizeProjectName(input.alias);
953
+ const now = (/* @__PURE__ */ new Date()).toISOString();
954
+ const id = createId("pal");
955
+ this.db.prepare(
956
+ `INSERT INTO project_aliases
957
+ (id, workspace_id, canonical_project, alias, created_by_member_id, created_at)
958
+ VALUES (?, ?, ?, ?, ?, ?)
959
+ ON CONFLICT(workspace_id, alias) DO UPDATE SET
960
+ id = excluded.id,
961
+ canonical_project = excluded.canonical_project,
962
+ created_by_member_id = excluded.created_by_member_id,
963
+ created_at = excluded.created_at`
964
+ ).run(id, input.workspaceId, canonicalProject, alias, admin.id, now);
965
+ this.recordAudit({
966
+ action: "configure_project_alias",
967
+ actorMemberId: admin.id,
968
+ workspaceId: input.workspaceId,
969
+ metadata: { canonical_project: canonicalProject, alias }
970
+ });
971
+ return {
972
+ alias: {
973
+ id,
974
+ workspace_id: input.workspaceId,
975
+ canonical_project: canonicalProject,
976
+ alias,
977
+ created_by_member_id: admin.id,
978
+ created_at: now
979
+ }
980
+ };
981
+ }
982
+ listProjectAliases(input) {
983
+ this.requireMember(input.authToken, input.workspaceId);
984
+ return this.db.prepare(
985
+ `SELECT * FROM project_aliases
986
+ WHERE workspace_id = ?
987
+ ORDER BY canonical_project, alias`
988
+ ).all(input.workspaceId).map((row) => rowToProjectAlias(row));
989
+ }
990
+ revokeMember(input) {
991
+ const admin = this.requireAdmin(input.adminToken, input.workspaceId);
992
+ if (admin.id === input.memberId) {
993
+ throw relayError("INVALID_INPUT", "Admins cannot revoke themselves.", 400);
994
+ }
995
+ const member = this.getMember(input.memberId);
996
+ if (member.workspace_id !== input.workspaceId) {
997
+ throw relayError("FORBIDDEN", "Member belongs to a different workspace.", 403);
998
+ }
999
+ const revokedAt = (/* @__PURE__ */ new Date()).toISOString();
1000
+ this.db.prepare("UPDATE members SET status = ?, revoked_at = ? WHERE id = ?").run("revoked", revokedAt, input.memberId);
1001
+ this.recordAudit({
1002
+ action: "revoke",
1003
+ actorMemberId: admin.id,
1004
+ workspaceId: input.workspaceId,
1005
+ metadata: { revoked_member_id: input.memberId }
1006
+ });
1007
+ return { member: { ...member, status: "revoked", revoked_at: revokedAt } };
1008
+ }
1009
+ rotateMemberToken(input) {
1010
+ const member = this.authenticate(input.authToken);
1011
+ const token = createToken("relay_member");
1012
+ this.db.prepare("UPDATE members SET token_hash = ? WHERE id = ?").run(hashToken(token), member.id);
1013
+ this.recordAudit({
1014
+ action: "rotate_token",
1015
+ actorMemberId: member.id,
1016
+ workspaceId: member.workspace_id,
1017
+ metadata: {}
1018
+ });
1019
+ return { member, token };
1020
+ }
1021
+ rotateApprovalSecret(input) {
1022
+ const member = this.authenticate(input.authToken);
1023
+ this.requireApprovalSecret(member, input.approvalSecret);
1024
+ const approvalSecret = createToken("relay_approval_secret");
1025
+ const rotatedAt = (/* @__PURE__ */ new Date()).toISOString();
1026
+ const rotate = this.db.transaction(() => {
1027
+ this.db.prepare("UPDATE members SET approval_secret_hash = ? WHERE id = ?").run(hashToken(approvalSecret), member.id);
1028
+ const invalidated = this.db.prepare(
1029
+ `UPDATE approval_tokens
1030
+ SET consumed_at = ?
1031
+ WHERE actor_member_id = ?
1032
+ AND consumed_at IS NULL`
1033
+ ).run(rotatedAt, member.id);
1034
+ this.recordAudit({
1035
+ action: "rotate_approval_secret",
1036
+ actorMemberId: member.id,
1037
+ workspaceId: member.workspace_id,
1038
+ metadata: { invalidated_approval_tokens: invalidated.changes }
1039
+ });
1040
+ });
1041
+ rotate();
1042
+ return { member, approval_secret: approvalSecret };
1043
+ }
1044
+ createApprovalToken(input) {
1045
+ const actor = this.authenticate(input.authToken);
1046
+ this.requireApprovalSecret(actor, input.approvalSecret);
1047
+ const packet = this.getPacket(input.packetId);
1048
+ this.assertApprovalTokenAllowed(actor, packet, input.action);
1049
+ const token = createToken("relay_approval");
1050
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1051
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1e3).toISOString();
1052
+ this.db.prepare(
1053
+ `INSERT INTO approval_tokens
1054
+ (id, packet_id, workspace_id, actor_member_id, action, token_hash, expires_at, created_at)
1055
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1056
+ ).run(
1057
+ createId("apr"),
1058
+ packet.packet_id,
1059
+ packet.workspace_id,
1060
+ actor.id,
1061
+ input.action,
1062
+ hashToken(token),
1063
+ expiresAt,
1064
+ now
1065
+ );
1066
+ this.recordAudit({
1067
+ action: "approve",
1068
+ actorMemberId: actor.id,
1069
+ packetId: packet.packet_id,
1070
+ workspaceId: packet.workspace_id,
1071
+ metadata: { approval_action: input.action, token_created: true }
1072
+ });
1073
+ return {
1074
+ approval_token: token,
1075
+ action: input.action,
1076
+ packet_id: packet.packet_id,
1077
+ expires_at: expiresAt
1078
+ };
1079
+ }
1080
+ createAskDraft(input) {
1081
+ return this.createPacketDraft("ask", input, { question: input.question });
1082
+ }
1083
+ createShareDraft(input) {
1084
+ return this.createPacketDraft("share", input, { finding: input.finding });
1085
+ }
1086
+ updateDraft(input) {
1087
+ const actor = this.authenticate(input.authToken);
1088
+ const packet = this.getPacket(input.packetId);
1089
+ this.requireSender(actor, packet);
1090
+ if (!["ask", "share"].includes(packet.packet_type)) {
1091
+ throw relayError("INVALID_INPUT", "Only ask/share drafts can be edited before send.", 400);
1092
+ }
1093
+ if (packet.status !== "pending_sender_approval") {
1094
+ throw relayError(
1095
+ "INVALID_STATE_TRANSITION",
1096
+ "Only drafts pending sender approval can be edited.",
1097
+ 409
1098
+ );
1099
+ }
1100
+ const changedFields = Object.entries(input).filter(([key, value]) => !["authToken", "packetId"].includes(key) && value !== void 0).map(([key]) => key);
1101
+ const updated = this.preparePacket(
1102
+ packetSchema.parse({
1103
+ ...packet,
1104
+ title: input.title ?? packet.title,
1105
+ summary: input.summary ?? packet.summary,
1106
+ question: input.question ?? packet.question,
1107
+ finding: input.finding ?? packet.finding,
1108
+ claims: input.claims?.map(normalizeClaim) ?? packet.claims,
1109
+ evidence: input.evidence?.map(normalizeEvidence) ?? packet.evidence,
1110
+ files_or_symbols: input.filesOrSymbols ?? packet.files_or_symbols,
1111
+ commands_or_tests_run: input.commandsOrTestsRun ?? packet.commands_or_tests_run,
1112
+ what_was_tried: input.whatWasTried ?? packet.what_was_tried,
1113
+ known_failures: input.knownFailures ?? packet.known_failures,
1114
+ current_hypothesis: input.currentHypothesis ?? packet.current_hypothesis,
1115
+ confidence: input.confidence ?? packet.confidence,
1116
+ suggested_next_steps: input.suggestedNextSteps ?? packet.suggested_next_steps
1117
+ })
1118
+ );
1119
+ this.updatePacket(updated);
1120
+ this.recordAudit({
1121
+ action: "edit",
1122
+ actorMemberId: actor.id,
1123
+ packetId: updated.packet_id,
1124
+ workspaceId: updated.workspace_id,
1125
+ metadata: { changed_fields: changedFields }
1126
+ });
1127
+ return { id: updated.packet_id, packet: this.getPacket(updated.packet_id) };
1128
+ }
1129
+ approveAndSend(input) {
1130
+ const actor = this.authenticate(input.authToken);
1131
+ let packet = this.getPacket(input.packetId);
1132
+ this.requireSender(actor, packet);
1133
+ if (packet.packet_type === "reply") {
1134
+ return this.approveReply({
1135
+ authToken: input.authToken,
1136
+ replyPacketId: input.packetId,
1137
+ approvalToken: input.approvalToken
1138
+ });
1139
+ }
1140
+ this.consumeApprovalToken({
1141
+ actor,
1142
+ packet,
1143
+ action: "send",
1144
+ approvalToken: input.approvalToken
1145
+ });
1146
+ if (packet.redaction_report.blocked && !input.allowSecretOverride) {
1147
+ throw relayError(
1148
+ "REDACTION_BLOCKED",
1149
+ "Redaction blocked this packet. Remove secret-looking evidence or explicitly override.",
1150
+ 422,
1151
+ packet.redaction_report
1152
+ );
1153
+ }
1154
+ for (const recipientId of packet.recipient_member_ids) {
1155
+ const recipient = this.getMember(recipientId);
1156
+ if (recipient.status === "revoked") {
1157
+ throw relayError("INVALID_RECIPIENT", `Recipient @${recipient.handle} is revoked.`, 403);
1158
+ }
1159
+ }
1160
+ packet = this.transitionPacket(packet, "sent", actor, "sender");
1161
+ this.recordAudit({
1162
+ action: "approve",
1163
+ actorMemberId: actor.id,
1164
+ packetId: packet.packet_id,
1165
+ workspaceId: packet.workspace_id,
1166
+ metadata: { status: "sent" }
1167
+ });
1168
+ this.recordAudit({
1169
+ action: "send",
1170
+ actorMemberId: actor.id,
1171
+ packetId: packet.packet_id,
1172
+ workspaceId: packet.workspace_id,
1173
+ metadata: { recipient_member_ids: packet.recipient_member_ids }
1174
+ });
1175
+ packet = this.transitionPacket(packet, "delivered", actor, "system");
1176
+ for (const recipientId of packet.recipient_member_ids) {
1177
+ this.createNotification(packet, recipientId);
1178
+ }
1179
+ this.recordAudit({
1180
+ action: "deliver",
1181
+ actorMemberId: actor.id,
1182
+ packetId: packet.packet_id,
1183
+ workspaceId: packet.workspace_id,
1184
+ metadata: { recipient_member_ids: packet.recipient_member_ids }
1185
+ });
1186
+ return { id: packet.packet_id, packet: this.getPacket(packet.packet_id) };
1187
+ }
1188
+ listInbox(input) {
1189
+ const member = this.requireMember(input.authToken, input.workspaceId);
1190
+ return this.listWorkspacePackets(input.workspaceId).filter((packet) => packet.recipient_member_ids.includes(member.id)).filter(
1191
+ (packet) => ![
1192
+ "archived",
1193
+ "closed_resolved",
1194
+ "closed_unresolved",
1195
+ "declined",
1196
+ "expired",
1197
+ "superseded"
1198
+ ].includes(packet.status)
1199
+ );
1200
+ }
1201
+ viewPacket(input) {
1202
+ const actor = this.authenticate(input.authToken);
1203
+ let packet = this.getPacket(input.packetId);
1204
+ this.requireReadable(actor, packet);
1205
+ const role = this.actorRole(actor, packet);
1206
+ if (role === "recipient" && (packet.status === "delivered" || packet.status === "replied")) {
1207
+ packet = this.transitionPacket(packet, "viewed", actor, "recipient");
1208
+ }
1209
+ this.recordAudit({
1210
+ action: "view",
1211
+ actorMemberId: actor.id,
1212
+ packetId: packet.packet_id,
1213
+ workspaceId: packet.workspace_id,
1214
+ metadata: { status: packet.status }
1215
+ });
1216
+ return { id: packet.packet_id, packet };
1217
+ }
1218
+ getPacketForMember(input) {
1219
+ const actor = this.authenticate(input.authToken);
1220
+ const packet = this.getPacket(input.packetId);
1221
+ this.requireReadable(actor, packet);
1222
+ return { id: packet.packet_id, packet };
1223
+ }
1224
+ acceptPacket(input) {
1225
+ const actor = this.authenticate(input.authToken);
1226
+ const packet = this.getPacket(input.packetId);
1227
+ this.requireRecipient(actor, packet);
1228
+ const accepted = this.transitionPacket(packet, "accepted", actor, "recipient");
1229
+ this.recordAudit({
1230
+ action: "accept",
1231
+ actorMemberId: actor.id,
1232
+ packetId: packet.packet_id,
1233
+ workspaceId: packet.workspace_id,
1234
+ metadata: {}
1235
+ });
1236
+ return { id: accepted.packet_id, packet: accepted };
1237
+ }
1238
+ hydratePacket(input) {
1239
+ const actor = this.authenticate(input.authToken);
1240
+ let packet = this.getPacket(input.packetId);
1241
+ this.requireRecipient(actor, packet);
1242
+ this.consumeApprovalToken({
1243
+ actor,
1244
+ packet,
1245
+ action: "hydrate",
1246
+ approvalToken: input.approvalToken
1247
+ });
1248
+ const targetStatus = "hydrated";
1249
+ packet = this.transitionPacket(packet, targetStatus, actor, "recipient");
1250
+ const hydration = formatHydrationContext(packet, {
1251
+ hydratedBy: actor.id,
1252
+ client: input.client,
1253
+ sessionId: input.sessionId
1254
+ });
1255
+ this.insertAuditReceipt(hydration.receipt);
1256
+ this.db.prepare(
1257
+ `INSERT INTO hydration_receipts
1258
+ (receipt_id, packet_id, workspace_id, actor_member_id, client, session_id, context, created_at)
1259
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1260
+ ).run(
1261
+ hydration.receipt.receipt_id,
1262
+ packet.packet_id,
1263
+ packet.workspace_id,
1264
+ actor.id,
1265
+ input.client,
1266
+ input.sessionId ?? null,
1267
+ hydration.context,
1268
+ hydration.receipt.created_at
1269
+ );
1270
+ packet = this.updatePacket({ ...packet, audit_receipt: hydration.receipt });
1271
+ return { ...hydration, packet };
1272
+ }
1273
+ createReplyDraft(input) {
1274
+ const actor = this.authenticate(input.authToken);
1275
+ let original = this.getPacket(input.packetId);
1276
+ this.requireRecipient(actor, original);
1277
+ if (!["accepted", "hydrated"].includes(original.status)) {
1278
+ throw relayError(
1279
+ "INVALID_STATE_TRANSITION",
1280
+ "A recipient must accept or hydrate an ask before replying.",
1281
+ 409
1282
+ );
1283
+ }
1284
+ if (original.packet_type !== "ask") {
1285
+ throw relayError("INVALID_INPUT", "Replies can only be created for ask packets.", 400);
1286
+ }
1287
+ if (original.status === "accepted" || original.status === "hydrated") {
1288
+ original = this.transitionPacket(original, "response_drafting", actor, "recipient");
1289
+ }
1290
+ const packet = this.preparePacket(
1291
+ buildPacketDraft({
1292
+ packet_type: "reply",
1293
+ workspace_id: original.workspace_id,
1294
+ sender_member_id: actor.id,
1295
+ recipient_member_ids: [original.sender_member_id],
1296
+ parent_packet_id: original.packet_id,
1297
+ status: "pending_recipient_approval",
1298
+ title: input.title ?? `Reply: ${original.title}`,
1299
+ summary: input.summary,
1300
+ answer: input.answer,
1301
+ source_client: input.sourceClient,
1302
+ project: original.project,
1303
+ evidence: input.evidence,
1304
+ confidence: input.confidence
1305
+ })
1306
+ );
1307
+ this.insertPacket(packet);
1308
+ this.insertAuditReceipt(packet.audit_receipt);
1309
+ this.recordAudit({
1310
+ action: "reply",
1311
+ actorMemberId: actor.id,
1312
+ packetId: original.packet_id,
1313
+ workspaceId: original.workspace_id,
1314
+ metadata: { reply_packet_id: packet.packet_id, status: "pending_recipient_approval" }
1315
+ });
1316
+ return { id: packet.packet_id, packet };
1317
+ }
1318
+ approveReply(input) {
1319
+ const actor = this.authenticate(input.authToken);
1320
+ let reply = this.getPacket(input.replyPacketId);
1321
+ this.requireSender(actor, reply);
1322
+ if (reply.packet_type !== "reply") {
1323
+ throw relayError("INVALID_INPUT", "Packet is not a reply.", 400);
1324
+ }
1325
+ this.consumeApprovalToken({
1326
+ actor,
1327
+ packet: reply,
1328
+ action: "reply",
1329
+ approvalToken: input.approvalToken
1330
+ });
1331
+ reply = this.transitionPacket(reply, "replied", actor, "recipient");
1332
+ for (const recipientId of reply.recipient_member_ids) {
1333
+ this.createNotification(reply, recipientId);
1334
+ }
1335
+ this.recordAudit({
1336
+ action: "approve",
1337
+ actorMemberId: actor.id,
1338
+ packetId: reply.packet_id,
1339
+ workspaceId: reply.workspace_id,
1340
+ metadata: { reply_packet_id: reply.packet_id }
1341
+ });
1342
+ if (reply.parent_packet_id) {
1343
+ let parent = this.getPacket(reply.parent_packet_id);
1344
+ if (parent.status === "response_drafting") {
1345
+ parent = this.transitionPacket(parent, "pending_recipient_approval", actor, "recipient");
1346
+ }
1347
+ parent = this.transitionPacket(parent, "replied", actor, "recipient");
1348
+ this.recordAudit({
1349
+ action: "reply",
1350
+ actorMemberId: actor.id,
1351
+ packetId: parent.packet_id,
1352
+ workspaceId: parent.workspace_id,
1353
+ metadata: { reply_packet_id: reply.packet_id, status: "replied" }
1354
+ });
1355
+ }
1356
+ return { id: reply.packet_id, packet: this.getPacket(reply.packet_id) };
1357
+ }
1358
+ declinePacket(input) {
1359
+ const actor = this.authenticate(input.authToken);
1360
+ const packet = this.getPacket(input.packetId);
1361
+ this.requireRecipient(actor, packet);
1362
+ const declined = this.transitionPacket(packet, "declined", actor, "recipient");
1363
+ this.recordAudit({
1364
+ action: "decline",
1365
+ actorMemberId: actor.id,
1366
+ packetId: packet.packet_id,
1367
+ workspaceId: packet.workspace_id,
1368
+ metadata: { reason: input.reason }
1369
+ });
1370
+ return { id: declined.packet_id, packet: declined };
1371
+ }
1372
+ archivePacket(input) {
1373
+ const actor = this.authenticate(input.authToken);
1374
+ const packet = this.getPacket(input.packetId);
1375
+ this.requireReadable(actor, packet);
1376
+ const role = this.actorRole(actor, packet);
1377
+ const archived = this.transitionPacket(
1378
+ packet,
1379
+ "archived",
1380
+ actor,
1381
+ role === "admin" ? "admin" : role
1382
+ );
1383
+ this.recordAudit({
1384
+ action: "archive",
1385
+ actorMemberId: actor.id,
1386
+ packetId: packet.packet_id,
1387
+ workspaceId: packet.workspace_id,
1388
+ metadata: {}
1389
+ });
1390
+ return { id: archived.packet_id, packet: archived };
1391
+ }
1392
+ requestClarification(input) {
1393
+ const actor = this.authenticate(input.authToken);
1394
+ let original = this.getPacket(input.packetId);
1395
+ this.requireRecipient(actor, original);
1396
+ original = this.transitionPacket(original, "clarification_requested", actor, "recipient");
1397
+ this.recordAudit({
1398
+ action: "clarify",
1399
+ actorMemberId: actor.id,
1400
+ packetId: original.packet_id,
1401
+ workspaceId: original.workspace_id,
1402
+ metadata: { question: input.question, requested_evidence: input.requestedEvidence ?? [] }
1403
+ });
1404
+ const packet = this.preparePacket(
1405
+ buildPacketDraft({
1406
+ packet_type: "clarification",
1407
+ workspace_id: original.workspace_id,
1408
+ sender_member_id: actor.id,
1409
+ recipient_member_ids: [original.sender_member_id],
1410
+ parent_packet_id: original.packet_id,
1411
+ status: "delivered",
1412
+ title: `Clarification: ${original.title}`,
1413
+ summary: input.question,
1414
+ question: input.question,
1415
+ source_client: original.source_client,
1416
+ project: original.project,
1417
+ suggested_next_steps: input.requestedEvidence
1418
+ })
1419
+ );
1420
+ this.insertPacket(packet);
1421
+ this.insertAuditReceipt(packet.audit_receipt);
1422
+ this.createNotification(packet, original.sender_member_id);
1423
+ return { id: packet.packet_id, packet };
1424
+ }
1425
+ closePacket(input) {
1426
+ const actor = this.authenticate(input.authToken);
1427
+ const packet = this.getPacket(input.packetId);
1428
+ this.requireSender(actor, packet);
1429
+ const status = input.resolution === "resolved" ? "closed_resolved" : "closed_unresolved";
1430
+ const closed = this.transitionPacket(packet, status, actor, "sender");
1431
+ this.recordAudit({
1432
+ action: "close",
1433
+ actorMemberId: actor.id,
1434
+ packetId: packet.packet_id,
1435
+ workspaceId: packet.workspace_id,
1436
+ metadata: { resolution: input.resolution }
1437
+ });
1438
+ return { id: closed.packet_id, packet: closed };
1439
+ }
1440
+ searchPackets(input) {
1441
+ const actor = this.requireMember(input.authToken, input.workspaceId);
1442
+ const query = input.query?.toLowerCase() ?? "";
1443
+ const results = this.listWorkspacePackets(input.workspaceId).filter((packet) => {
1444
+ if (!this.canReadMetadata(actor, packet)) return false;
1445
+ if (!this.matchesPacketQueryFilters(actor, packet, input)) return false;
1446
+ if (!query) return true;
1447
+ const haystack = this.searchHaystackFor(actor, packet);
1448
+ return haystack.includes(query);
1449
+ });
1450
+ this.recordAudit({
1451
+ action: "search",
1452
+ actorMemberId: actor.id,
1453
+ workspaceId: input.workspaceId,
1454
+ metadata: { query, filters: this.auditFilterMetadata(input) }
1455
+ });
1456
+ return results.map((packet) => this.toSearchResult(actor, packet));
1457
+ }
1458
+ listHistory(input) {
1459
+ const actor = this.requireMember(input.authToken, input.workspaceId);
1460
+ const filter = input.filter ?? "all";
1461
+ const query = input.query?.toLowerCase() ?? "";
1462
+ const results = this.listWorkspacePackets(input.workspaceId).filter((packet) => {
1463
+ if (!this.canReadMetadata(actor, packet)) return false;
1464
+ if (!this.matchesHistoryFilter(actor, packet, filter)) return false;
1465
+ if (!this.matchesPacketQueryFilters(actor, packet, input)) return false;
1466
+ if (!query) return true;
1467
+ return this.searchHaystackFor(actor, packet).includes(query);
1468
+ });
1469
+ this.recordAudit({
1470
+ action: "search",
1471
+ actorMemberId: actor.id,
1472
+ workspaceId: input.workspaceId,
1473
+ metadata: { query, history_filter: filter, filters: this.auditFilterMetadata(input) }
1474
+ });
1475
+ return results.map((packet) => this.toSearchResult(actor, packet));
1476
+ }
1477
+ listAuditReceipts(input) {
1478
+ const actor = this.requireMember(input.authToken, input.workspaceId);
1479
+ if (input.packetId) {
1480
+ const packet = this.getPacket(input.packetId);
1481
+ if (!this.canReadMetadata(actor, packet)) {
1482
+ throw relayError("FORBIDDEN", "Packet audit metadata is not visible to this member.", 403);
1483
+ }
1484
+ } else if (actor.role !== "admin") {
1485
+ throw relayError("FORBIDDEN", "Only admins can view workspace audit logs.", 403);
1486
+ }
1487
+ const rows = input.packetId ? this.db.prepare(
1488
+ "SELECT * FROM audit_receipts WHERE workspace_id = ? AND packet_id = ? ORDER BY created_at"
1489
+ ).all(input.workspaceId, input.packetId) : this.db.prepare("SELECT * FROM audit_receipts WHERE workspace_id = ? ORDER BY created_at").all(input.workspaceId);
1490
+ return rows.map((row) => ({
1491
+ receipt_id: row.receipt_id,
1492
+ workspace_id: row.workspace_id,
1493
+ packet_id: row.packet_id ?? void 0,
1494
+ actor_member_id: row.actor_member_id,
1495
+ action: row.action,
1496
+ created_at: row.created_at,
1497
+ metadata: parseJson(row.metadata),
1498
+ receipt_hash: row.receipt_hash
1499
+ }));
1500
+ }
1501
+ getPacket(packetId) {
1502
+ const row = this.db.prepare("SELECT * FROM packets WHERE id = ?").get(packetId);
1503
+ if (!row) {
1504
+ throw relayError("NOT_FOUND", "Packet not found.", 404);
1505
+ }
1506
+ return rowToPacket(row);
1507
+ }
1508
+ createPacketDraft(packetType, input, main) {
1509
+ const actor = this.requireMember(input.authToken, input.workspaceId);
1510
+ const recipient = this.resolveRecipient(input.workspaceId, input.to);
1511
+ const project = input.project ? {
1512
+ ...input.project,
1513
+ repo_name: this.resolveCanonicalProjectName(
1514
+ input.workspaceId,
1515
+ input.project.repo_name ?? "unknown-project"
1516
+ )
1517
+ } : input.project;
1518
+ const base = buildPacketDraft({
1519
+ packet_type: packetType,
1520
+ workspace_id: input.workspaceId,
1521
+ sender_member_id: actor.id,
1522
+ recipient_member_ids: [recipient.id],
1523
+ title: input.title,
1524
+ summary: input.summary,
1525
+ source_client: input.sourceClient,
1526
+ project,
1527
+ evidence: input.evidence,
1528
+ claims: input.claims,
1529
+ files_or_symbols: input.filesOrSymbols,
1530
+ commands_or_tests_run: input.commandsOrTestsRun,
1531
+ what_was_tried: input.whatWasTried,
1532
+ known_failures: input.knownFailures,
1533
+ current_hypothesis: input.currentHypothesis,
1534
+ confidence: input.confidence,
1535
+ suggested_next_steps: input.suggestedNextSteps,
1536
+ ...main
1537
+ });
1538
+ const packet = this.preparePacket(base);
1539
+ this.insertPacket(packet);
1540
+ this.insertAuditReceipt(packet.audit_receipt);
1541
+ return { id: packet.packet_id, packet };
1542
+ }
1543
+ preparePacket(packet) {
1544
+ const redactionReport = scanPacketForRedactions(packet);
1545
+ let prepared = packetSchema.parse({
1546
+ ...packet,
1547
+ redaction_report: redactionReport,
1548
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1549
+ });
1550
+ const budget = validateContextBudget(prepared);
1551
+ if (!budget.ok) {
1552
+ prepared = compressPacketToBudget(prepared);
1553
+ prepared = packetSchema.parse({
1554
+ ...prepared,
1555
+ redaction_report: {
1556
+ ...prepared.redaction_report,
1557
+ warnings: [
1558
+ ...prepared.redaction_report.warnings,
1559
+ ...budget.violations.map((violation) => ({
1560
+ kind: "oversized_excerpt",
1561
+ field: "packet",
1562
+ severity: "warning",
1563
+ message: violation
1564
+ }))
1565
+ ]
1566
+ }
1567
+ });
1568
+ }
1569
+ return prepared;
1570
+ }
1571
+ authenticate(token) {
1572
+ if (!token) {
1573
+ throw relayError("AUTH_REQUIRED", "Missing Relay auth token.", 401);
1574
+ }
1575
+ const row = this.db.prepare("SELECT * FROM members WHERE token_hash = ?").get(hashToken(token));
1576
+ if (!row) {
1577
+ throw relayError("AUTH_REQUIRED", "Invalid Relay auth token.", 401);
1578
+ }
1579
+ const member = rowToMember(row);
1580
+ if (member.status === "revoked") {
1581
+ throw relayError("TOKEN_REVOKED", "This member token has been revoked.", 403);
1582
+ }
1583
+ return member;
1584
+ }
1585
+ requireMember(token, workspaceId) {
1586
+ const member = this.authenticate(token);
1587
+ if (member.workspace_id !== workspaceId) {
1588
+ throw relayError("FORBIDDEN", "Token belongs to a different workspace.", 403);
1589
+ }
1590
+ return member;
1591
+ }
1592
+ requireAdmin(token, workspaceId) {
1593
+ const member = this.requireMember(token, workspaceId);
1594
+ if (member.role !== "admin") {
1595
+ throw relayError("FORBIDDEN", "Workspace admin permission required.", 403);
1596
+ }
1597
+ return member;
1598
+ }
1599
+ requireApprovalSecret(member, approvalSecret) {
1600
+ if (!approvalSecret) {
1601
+ throw relayError(
1602
+ "FORBIDDEN",
1603
+ "A local approval secret is required to mint approval tokens.",
1604
+ 403
1605
+ );
1606
+ }
1607
+ const row = this.db.prepare("SELECT approval_secret_hash FROM members WHERE id = ?").get(member.id);
1608
+ if (!row?.approval_secret_hash || row.approval_secret_hash !== hashToken(approvalSecret)) {
1609
+ throw relayError("FORBIDDEN", "Invalid local approval secret.", 403);
1610
+ }
1611
+ }
1612
+ getWorkspace(workspaceId) {
1613
+ const row = this.db.prepare("SELECT * FROM workspaces WHERE id = ?").get(workspaceId);
1614
+ if (!row) {
1615
+ throw relayError("NOT_FOUND", "Workspace not found.", 404);
1616
+ }
1617
+ return rowToWorkspace(row);
1618
+ }
1619
+ getMember(memberId) {
1620
+ const row = this.db.prepare("SELECT * FROM members WHERE id = ?").get(memberId);
1621
+ if (!row) {
1622
+ throw relayError("NOT_FOUND", "Member not found.", 404);
1623
+ }
1624
+ return rowToMember(row);
1625
+ }
1626
+ findMemberByHandle(workspaceId, handle, includeRevoked = false) {
1627
+ const row = this.db.prepare("SELECT * FROM members WHERE workspace_id = ? AND handle = ?").get(workspaceId, normalizeHandle(handle));
1628
+ if (!row) return void 0;
1629
+ const member = rowToMember(row);
1630
+ if (!includeRevoked && member.status === "revoked") {
1631
+ return void 0;
1632
+ }
1633
+ return member;
1634
+ }
1635
+ resolveRecipient(workspaceId, handle) {
1636
+ const normalized = normalizeHandle(handle);
1637
+ const member = this.findMemberByHandle(workspaceId, normalized, true);
1638
+ if (!member) {
1639
+ throw relayError("INVALID_RECIPIENT", `Invalid recipient @${normalized}.`, 404);
1640
+ }
1641
+ if (member.status === "revoked") {
1642
+ throw relayError("INVALID_RECIPIENT", `Recipient @${normalized} is revoked.`, 403);
1643
+ }
1644
+ return member;
1645
+ }
1646
+ actorRole(actor, packet) {
1647
+ if (packet.sender_member_id === actor.id) return "sender";
1648
+ if (packet.recipient_member_ids.includes(actor.id)) return "recipient";
1649
+ if (actor.role === "admin" && actor.workspace_id === packet.workspace_id) return "admin";
1650
+ return "system";
1651
+ }
1652
+ canReadMetadata(actor, packet) {
1653
+ return actor.workspace_id === packet.workspace_id && actor.status === "active" && (packet.sender_member_id === actor.id || packet.recipient_member_ids.includes(actor.id) || actor.role === "admin");
1654
+ }
1655
+ canReadBody(actor, packet) {
1656
+ if (actor.workspace_id !== packet.workspace_id || actor.status !== "active") {
1657
+ return false;
1658
+ }
1659
+ if (packet.sender_member_id === actor.id || packet.recipient_member_ids.includes(actor.id)) {
1660
+ return true;
1661
+ }
1662
+ if (actor.role === "admin") {
1663
+ return this.getWorkspace(packet.workspace_id).admin_body_access;
1664
+ }
1665
+ return false;
1666
+ }
1667
+ requireReadable(actor, packet) {
1668
+ if (!this.canReadBody(actor, packet)) {
1669
+ if (this.canReadMetadata(actor, packet)) {
1670
+ throw relayError(
1671
+ "FORBIDDEN",
1672
+ "Workspace admin metadata access does not include packet body access by default.",
1673
+ 403
1674
+ );
1675
+ }
1676
+ throw relayError("FORBIDDEN", "Packet is not addressed to this member.", 403);
1677
+ }
1678
+ }
1679
+ searchHaystackFor(actor, packet) {
1680
+ const metadataFields = [
1681
+ packet.packet_id,
1682
+ packet.packet_type,
1683
+ packet.workspace_id,
1684
+ packet.sender_member_id,
1685
+ ...packet.recipient_member_ids,
1686
+ packet.status,
1687
+ packet.title,
1688
+ packet.summary,
1689
+ packet.project.repo_name,
1690
+ packet.project.branch,
1691
+ packet.project.commit_hash,
1692
+ packet.project.git_remote_fingerprint,
1693
+ packet.source_client,
1694
+ packet.created_at,
1695
+ packet.updated_at,
1696
+ packet.expires_at,
1697
+ packet.recheck_by
1698
+ ];
1699
+ const bodyFields = this.canReadBody(actor, packet) ? [
1700
+ packet.question,
1701
+ packet.finding,
1702
+ packet.answer,
1703
+ packet.current_hypothesis,
1704
+ ...packet.files_or_symbols,
1705
+ ...packet.commands_or_tests_run,
1706
+ ...packet.what_was_tried,
1707
+ ...packet.known_failures,
1708
+ ...packet.suggested_next_steps,
1709
+ ...packet.claims.map((claim) => claim.text),
1710
+ ...packet.evidence.map((item) => `${item.label} ${item.source} ${item.excerpt}`)
1711
+ ] : [];
1712
+ return [...metadataFields, ...bodyFields].filter(Boolean).join(" ").toLowerCase();
1713
+ }
1714
+ toSearchResult(actor, packet) {
1715
+ return {
1716
+ packet_id: packet.packet_id,
1717
+ packet_type: packet.packet_type,
1718
+ workspace_id: packet.workspace_id,
1719
+ sender_member_id: packet.sender_member_id,
1720
+ recipient_member_ids: packet.recipient_member_ids,
1721
+ status: packet.status,
1722
+ title: packet.title,
1723
+ summary: packet.summary,
1724
+ project: packet.project,
1725
+ source_client: packet.source_client,
1726
+ created_at: packet.created_at,
1727
+ updated_at: packet.updated_at,
1728
+ expires_at: packet.expires_at,
1729
+ recheck_by: packet.recheck_by,
1730
+ body_access: this.canReadBody(actor, packet)
1731
+ };
1732
+ }
1733
+ auditFilterMetadata(input) {
1734
+ return Object.fromEntries(
1735
+ Object.entries({
1736
+ project: input.project,
1737
+ sender: input.sender,
1738
+ recipient: input.recipient,
1739
+ status: input.status,
1740
+ file_or_symbol: input.fileOrSymbol,
1741
+ ticket_or_pr: input.ticketOrPr
1742
+ }).filter((entry) => typeof entry[1] === "string" && !!entry[1])
1743
+ );
1744
+ }
1745
+ matchesPacketQueryFilters(actor, packet, filters) {
1746
+ if (filters.project && this.resolveCanonicalProjectName(packet.workspace_id, packet.project.repo_name) !== this.resolveCanonicalProjectName(packet.workspace_id, filters.project)) {
1747
+ return false;
1748
+ }
1749
+ if (filters.sender && !this.memberSelectorMatches(packet.workspace_id, packet.sender_member_id, filters.sender)) {
1750
+ return false;
1751
+ }
1752
+ if (filters.recipient && !packet.recipient_member_ids.some(
1753
+ (memberId) => this.memberSelectorMatches(packet.workspace_id, memberId, filters.recipient)
1754
+ )) {
1755
+ return false;
1756
+ }
1757
+ if (filters.status && packet.status !== filters.status) {
1758
+ return false;
1759
+ }
1760
+ if (filters.fileOrSymbol) {
1761
+ if (!this.canReadBody(actor, packet)) return false;
1762
+ const needle = filters.fileOrSymbol.trim().toLowerCase();
1763
+ if (needle && !packet.files_or_symbols.some((entry) => entry.toLowerCase().includes(needle))) {
1764
+ return false;
1765
+ }
1766
+ }
1767
+ if (filters.ticketOrPr) {
1768
+ if (!this.canReadBody(actor, packet)) return false;
1769
+ const needle = filters.ticketOrPr.trim().toLowerCase();
1770
+ if (needle && !packet.evidence.filter((item) => item.kind === "ticket_link" || item.kind === "pr_link").some(
1771
+ (item) => [item.label, item.source, item.excerpt].join(" ").toLowerCase().includes(needle)
1772
+ )) {
1773
+ return false;
1774
+ }
1775
+ }
1776
+ return true;
1777
+ }
1778
+ resolveCanonicalProjectName(workspaceId, projectName) {
1779
+ const name = normalizeProjectName(projectName);
1780
+ const row = this.db.prepare(
1781
+ `SELECT canonical_project FROM project_aliases
1782
+ WHERE workspace_id = ? AND alias = ?`
1783
+ ).get(workspaceId, name);
1784
+ return row?.canonical_project ?? name;
1785
+ }
1786
+ memberSelectorMatches(workspaceId, memberId, selector) {
1787
+ const trimmed = selector.trim();
1788
+ if (!trimmed) return true;
1789
+ if (memberId === trimmed) return true;
1790
+ try {
1791
+ return this.findMemberByHandle(workspaceId, trimmed, true)?.id === memberId;
1792
+ } catch {
1793
+ return false;
1794
+ }
1795
+ }
1796
+ matchesHistoryFilter(actor, packet, filter) {
1797
+ if (filter === "all") return true;
1798
+ const terminalStatuses = [
1799
+ "archived",
1800
+ "closed_resolved",
1801
+ "closed_unresolved",
1802
+ "declined",
1803
+ "expired",
1804
+ "superseded"
1805
+ ];
1806
+ if (filter === "closed") {
1807
+ return terminalStatuses.includes(packet.status);
1808
+ }
1809
+ if (filter === "drafts") {
1810
+ return packet.sender_member_id === actor.id && ["pending_sender_approval", "pending_recipient_approval", "draft"].includes(packet.status);
1811
+ }
1812
+ if (filter === "sent") {
1813
+ return packet.sender_member_id === actor.id && packet.status !== "pending_sender_approval";
1814
+ }
1815
+ return !terminalStatuses.includes(packet.status) && !["draft", "pending_sender_approval", "pending_recipient_approval"].includes(packet.status);
1816
+ }
1817
+ requireSender(actor, packet) {
1818
+ if (packet.sender_member_id !== actor.id) {
1819
+ throw relayError("FORBIDDEN", "Only the packet sender can perform this action.", 403);
1820
+ }
1821
+ }
1822
+ requireRecipient(actor, packet) {
1823
+ if (!packet.recipient_member_ids.includes(actor.id)) {
1824
+ throw relayError("FORBIDDEN", "Packet is not addressed to this member.", 403);
1825
+ }
1826
+ }
1827
+ assertApprovalTokenAllowed(actor, packet, action) {
1828
+ if (action === "send") {
1829
+ this.requireSender(actor, packet);
1830
+ if (packet.packet_type === "reply") {
1831
+ throw relayError("INVALID_INPUT", "Use reply approval for reply packets.", 400);
1832
+ }
1833
+ if (packet.status !== "pending_sender_approval") {
1834
+ throw relayError(
1835
+ "INVALID_STATE_TRANSITION",
1836
+ "Only pending drafts can be approved for send.",
1837
+ 409
1838
+ );
1839
+ }
1840
+ return;
1841
+ }
1842
+ if (action === "reply") {
1843
+ this.requireSender(actor, packet);
1844
+ if (packet.packet_type !== "reply" || packet.status !== "pending_recipient_approval") {
1845
+ throw relayError(
1846
+ "INVALID_STATE_TRANSITION",
1847
+ "Only pending reply drafts can be approved.",
1848
+ 409
1849
+ );
1850
+ }
1851
+ return;
1852
+ }
1853
+ this.requireRecipient(actor, packet);
1854
+ if (packet.packet_type === "reply") {
1855
+ if (packet.status !== "viewed") {
1856
+ throw relayError(
1857
+ "INVALID_STATE_TRANSITION",
1858
+ "Replies must be viewed before hydration approval.",
1859
+ 409
1860
+ );
1861
+ }
1862
+ return;
1863
+ }
1864
+ if (packet.status !== "accepted") {
1865
+ throw relayError(
1866
+ "INVALID_STATE_TRANSITION",
1867
+ "Packets must be accepted before hydration approval.",
1868
+ 409
1869
+ );
1870
+ }
1871
+ }
1872
+ consumeApprovalToken(input) {
1873
+ if (!input.approvalToken) {
1874
+ throw relayError(
1875
+ "FORBIDDEN",
1876
+ `A human approval token is required to ${input.action} this packet.`,
1877
+ 403
1878
+ );
1879
+ }
1880
+ const row = this.db.prepare(
1881
+ `SELECT * FROM approval_tokens
1882
+ WHERE packet_id = ? AND actor_member_id = ? AND action = ? AND token_hash = ?`
1883
+ ).get(input.packet.packet_id, input.actor.id, input.action, hashToken(input.approvalToken));
1884
+ if (!row || row.consumed_at || Date.parse(row.expires_at) < Date.now()) {
1885
+ throw relayError("FORBIDDEN", "Invalid, expired, or consumed approval token.", 403);
1886
+ }
1887
+ this.assertApprovalTokenAllowed(input.actor, input.packet, input.action);
1888
+ this.db.prepare("UPDATE approval_tokens SET consumed_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), row.id);
1889
+ }
1890
+ transitionPacket(packet, status, actor, role) {
1891
+ assertTransition({
1892
+ from: packet.status,
1893
+ to: status,
1894
+ actorRole: role,
1895
+ packetType: packet.packet_type
1896
+ });
1897
+ return this.updatePacket({
1898
+ ...packet,
1899
+ status,
1900
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1901
+ });
1902
+ }
1903
+ insertPacket(packet) {
1904
+ this.db.prepare(
1905
+ `INSERT INTO packets
1906
+ (id, workspace_id, packet_type, sender_member_id, recipient_member_ids, parent_packet_id,
1907
+ status, title, summary, question, finding, answer, project, source_client, claims, evidence,
1908
+ files_or_symbols, commands_or_tests_run, what_was_tried, known_failures, current_hypothesis,
1909
+ confidence, suggested_next_steps, redaction_report, hydration_policy, audit_receipt,
1910
+ expires_at, recheck_by, created_at, updated_at)
1911
+ VALUES
1912
+ (@id, @workspace_id, @packet_type, @sender_member_id, @recipient_member_ids, @parent_packet_id,
1913
+ @status, @title, @summary, @question, @finding, @answer, @project, @source_client, @claims,
1914
+ @evidence, @files_or_symbols, @commands_or_tests_run, @what_was_tried, @known_failures,
1915
+ @current_hypothesis, @confidence, @suggested_next_steps, @redaction_report, @hydration_policy,
1916
+ @audit_receipt, @expires_at, @recheck_by, @created_at, @updated_at)`
1917
+ ).run(packetParams(packet));
1918
+ return packet;
1919
+ }
1920
+ updatePacket(packet) {
1921
+ this.db.prepare(
1922
+ `UPDATE packets SET
1923
+ recipient_member_ids = @recipient_member_ids,
1924
+ parent_packet_id = @parent_packet_id,
1925
+ status = @status,
1926
+ title = @title,
1927
+ summary = @summary,
1928
+ question = @question,
1929
+ finding = @finding,
1930
+ answer = @answer,
1931
+ project = @project,
1932
+ source_client = @source_client,
1933
+ claims = @claims,
1934
+ evidence = @evidence,
1935
+ files_or_symbols = @files_or_symbols,
1936
+ commands_or_tests_run = @commands_or_tests_run,
1937
+ what_was_tried = @what_was_tried,
1938
+ known_failures = @known_failures,
1939
+ current_hypothesis = @current_hypothesis,
1940
+ confidence = @confidence,
1941
+ suggested_next_steps = @suggested_next_steps,
1942
+ redaction_report = @redaction_report,
1943
+ hydration_policy = @hydration_policy,
1944
+ audit_receipt = @audit_receipt,
1945
+ expires_at = @expires_at,
1946
+ recheck_by = @recheck_by,
1947
+ updated_at = @updated_at
1948
+ WHERE id = @id`
1949
+ ).run(packetParams(packet));
1950
+ return packet;
1951
+ }
1952
+ listWorkspacePackets(workspaceId) {
1953
+ return this.db.prepare("SELECT * FROM packets WHERE workspace_id = ? ORDER BY created_at DESC").all(workspaceId).map(rowToPacket);
1954
+ }
1955
+ createNotification(packet, memberId) {
1956
+ this.db.prepare(
1957
+ `INSERT INTO notifications (id, packet_id, workspace_id, member_id, status, created_at)
1958
+ VALUES (?, ?, ?, ?, ?, ?)`
1959
+ ).run(
1960
+ createId("ntf"),
1961
+ packet.packet_id,
1962
+ packet.workspace_id,
1963
+ memberId,
1964
+ "unread",
1965
+ (/* @__PURE__ */ new Date()).toISOString()
1966
+ );
1967
+ }
1968
+ recordAudit(input) {
1969
+ const receipt = createAuditReceipt(input);
1970
+ this.insertAuditReceipt(receipt);
1971
+ if (input.packetId) {
1972
+ const packet = this.getPacket(input.packetId);
1973
+ this.updatePacket({ ...packet, audit_receipt: receipt });
1974
+ }
1975
+ return receipt;
1976
+ }
1977
+ insertAuditReceipt(receipt) {
1978
+ this.db.prepare(
1979
+ `INSERT OR REPLACE INTO audit_receipts
1980
+ (receipt_id, workspace_id, packet_id, actor_member_id, action, created_at, metadata, receipt_hash)
1981
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1982
+ ).run(
1983
+ receipt.receipt_id,
1984
+ receipt.workspace_id,
1985
+ receipt.packet_id ?? null,
1986
+ receipt.actor_member_id,
1987
+ receipt.action,
1988
+ receipt.created_at,
1989
+ JSON.stringify(receipt.metadata),
1990
+ receipt.receipt_hash
1991
+ );
1992
+ }
1993
+ };
1994
+
1995
+ // src/storage/database.ts
1996
+ import { mkdirSync } from "fs";
1997
+ import { dirname } from "path";
1998
+ import Database from "better-sqlite3";
1999
+ var schema = `
2000
+ CREATE TABLE IF NOT EXISTS workspaces (
2001
+ id TEXT PRIMARY KEY,
2002
+ name TEXT NOT NULL,
2003
+ admin_body_access INTEGER NOT NULL DEFAULT 0,
2004
+ created_at TEXT NOT NULL
2005
+ );
2006
+
2007
+ CREATE TABLE IF NOT EXISTS members (
2008
+ id TEXT PRIMARY KEY,
2009
+ workspace_id TEXT NOT NULL,
2010
+ handle TEXT NOT NULL,
2011
+ display_name TEXT NOT NULL,
2012
+ role TEXT NOT NULL,
2013
+ status TEXT NOT NULL,
2014
+ token_hash TEXT NOT NULL,
2015
+ approval_secret_hash TEXT NOT NULL DEFAULT '',
2016
+ created_at TEXT NOT NULL,
2017
+ revoked_at TEXT,
2018
+ UNIQUE(workspace_id, handle),
2019
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
2020
+ );
2021
+
2022
+ CREATE TABLE IF NOT EXISTS project_aliases (
2023
+ id TEXT PRIMARY KEY,
2024
+ workspace_id TEXT NOT NULL,
2025
+ canonical_project TEXT NOT NULL,
2026
+ alias TEXT NOT NULL,
2027
+ created_by_member_id TEXT NOT NULL,
2028
+ created_at TEXT NOT NULL,
2029
+ UNIQUE(workspace_id, alias),
2030
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id),
2031
+ FOREIGN KEY(created_by_member_id) REFERENCES members(id)
2032
+ );
2033
+
2034
+ CREATE INDEX IF NOT EXISTS project_alias_workspace_idx ON project_aliases(workspace_id);
2035
+
2036
+ CREATE TABLE IF NOT EXISTS invites (
2037
+ id TEXT PRIMARY KEY,
2038
+ workspace_id TEXT NOT NULL,
2039
+ handle TEXT NOT NULL,
2040
+ token TEXT NOT NULL UNIQUE,
2041
+ created_by_member_id TEXT NOT NULL,
2042
+ expires_at TEXT NOT NULL,
2043
+ accepted_at TEXT,
2044
+ created_at TEXT NOT NULL,
2045
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
2046
+ );
2047
+
2048
+ CREATE TABLE IF NOT EXISTS packets (
2049
+ id TEXT PRIMARY KEY,
2050
+ workspace_id TEXT NOT NULL,
2051
+ packet_type TEXT NOT NULL,
2052
+ sender_member_id TEXT NOT NULL,
2053
+ recipient_member_ids TEXT NOT NULL,
2054
+ parent_packet_id TEXT,
2055
+ status TEXT NOT NULL,
2056
+ title TEXT NOT NULL,
2057
+ summary TEXT NOT NULL,
2058
+ question TEXT,
2059
+ finding TEXT,
2060
+ answer TEXT,
2061
+ project TEXT NOT NULL,
2062
+ source_client TEXT NOT NULL,
2063
+ claims TEXT NOT NULL,
2064
+ evidence TEXT NOT NULL,
2065
+ files_or_symbols TEXT NOT NULL,
2066
+ commands_or_tests_run TEXT NOT NULL,
2067
+ what_was_tried TEXT NOT NULL,
2068
+ known_failures TEXT NOT NULL,
2069
+ current_hypothesis TEXT NOT NULL,
2070
+ confidence TEXT NOT NULL,
2071
+ suggested_next_steps TEXT NOT NULL,
2072
+ redaction_report TEXT NOT NULL,
2073
+ hydration_policy TEXT NOT NULL,
2074
+ audit_receipt TEXT NOT NULL,
2075
+ expires_at TEXT,
2076
+ recheck_by TEXT,
2077
+ created_at TEXT NOT NULL,
2078
+ updated_at TEXT NOT NULL,
2079
+ FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
2080
+ );
2081
+
2082
+ CREATE INDEX IF NOT EXISTS packets_workspace_idx ON packets(workspace_id);
2083
+ CREATE INDEX IF NOT EXISTS packets_sender_idx ON packets(sender_member_id);
2084
+ CREATE INDEX IF NOT EXISTS packets_status_idx ON packets(status);
2085
+
2086
+ CREATE TABLE IF NOT EXISTS audit_receipts (
2087
+ receipt_id TEXT PRIMARY KEY,
2088
+ workspace_id TEXT NOT NULL,
2089
+ packet_id TEXT,
2090
+ actor_member_id TEXT NOT NULL,
2091
+ action TEXT NOT NULL,
2092
+ created_at TEXT NOT NULL,
2093
+ metadata TEXT NOT NULL,
2094
+ receipt_hash TEXT NOT NULL
2095
+ );
2096
+
2097
+ CREATE INDEX IF NOT EXISTS audit_packet_idx ON audit_receipts(packet_id);
2098
+
2099
+ CREATE TABLE IF NOT EXISTS hydration_receipts (
2100
+ receipt_id TEXT PRIMARY KEY,
2101
+ packet_id TEXT NOT NULL,
2102
+ workspace_id TEXT NOT NULL,
2103
+ actor_member_id TEXT NOT NULL,
2104
+ client TEXT NOT NULL,
2105
+ session_id TEXT,
2106
+ context TEXT NOT NULL,
2107
+ created_at TEXT NOT NULL
2108
+ );
2109
+
2110
+ CREATE TABLE IF NOT EXISTS notifications (
2111
+ id TEXT PRIMARY KEY,
2112
+ packet_id TEXT NOT NULL,
2113
+ workspace_id TEXT NOT NULL,
2114
+ member_id TEXT NOT NULL,
2115
+ status TEXT NOT NULL,
2116
+ created_at TEXT NOT NULL,
2117
+ read_at TEXT
2118
+ );
2119
+
2120
+ CREATE TABLE IF NOT EXISTS approval_tokens (
2121
+ id TEXT PRIMARY KEY,
2122
+ packet_id TEXT NOT NULL,
2123
+ workspace_id TEXT NOT NULL,
2124
+ actor_member_id TEXT NOT NULL,
2125
+ action TEXT NOT NULL,
2126
+ token_hash TEXT NOT NULL UNIQUE,
2127
+ expires_at TEXT NOT NULL,
2128
+ consumed_at TEXT,
2129
+ created_at TEXT NOT NULL
2130
+ );
2131
+
2132
+ CREATE INDEX IF NOT EXISTS approval_packet_idx ON approval_tokens(packet_id, actor_member_id, action);
2133
+ `;
2134
+ function createRelayDatabase(path = ":memory:") {
2135
+ if (path !== ":memory:") {
2136
+ mkdirSync(dirname(path), { recursive: true });
2137
+ }
2138
+ const db = new Database(path);
2139
+ db.pragma("journal_mode = WAL");
2140
+ db.pragma("foreign_keys = ON");
2141
+ db.exec(schema);
2142
+ ensureColumn(db, "members", "approval_secret_hash", "TEXT NOT NULL DEFAULT ''");
2143
+ return db;
2144
+ }
2145
+ function ensureColumn(db, table, column, definition) {
2146
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all();
2147
+ if (!columns.some((entry) => entry.name === column)) {
2148
+ db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run();
2149
+ }
2150
+ }
2151
+
2152
+ // src/api/server.ts
2153
+ var sourceClients = ["claude-code", "codex", "cursor", "generic", "other"];
2154
+ function bearer(request) {
2155
+ const value = request.headers.authorization;
2156
+ if (typeof value !== "string" || !value.startsWith("Bearer ")) {
2157
+ return "";
2158
+ }
2159
+ return value.slice("Bearer ".length);
2160
+ }
2161
+ function buildApiServer(options) {
2162
+ const app = Fastify({ logger: false });
2163
+ const service = options.service;
2164
+ const packetQuerySchema = {
2165
+ project: z2.string().optional(),
2166
+ sender: z2.string().optional(),
2167
+ recipient: z2.string().optional(),
2168
+ status: z2.enum(packetStatuses).optional(),
2169
+ fileOrSymbol: z2.string().optional(),
2170
+ ticketOrPr: z2.string().optional()
2171
+ };
2172
+ app.get("/health", async () => ({
2173
+ name: "handoff",
2174
+ ok: true,
2175
+ pid: process.pid,
2176
+ server_id: process.env.HANDOFF_SERVER_ID,
2177
+ version: "0.1.0"
2178
+ }));
2179
+ app.setErrorHandler((error, _request, reply) => {
2180
+ if (isRelayError(error)) {
2181
+ reply.status(error.statusCode).send({
2182
+ error: {
2183
+ code: error.code,
2184
+ message: error.message,
2185
+ details: error.details
2186
+ }
2187
+ });
2188
+ return;
2189
+ }
2190
+ if (error instanceof z2.ZodError) {
2191
+ const unsupportedClient = error.issues.some(
2192
+ (issue) => issue.path.some((segment) => segment === "sourceClient")
2193
+ );
2194
+ reply.status(400).send({
2195
+ error: unsupportedClient ? {
2196
+ code: "UNSUPPORTED_CLIENT",
2197
+ message: `Unsupported source client. Supported clients: ${sourceClients.join(", ")}.`,
2198
+ details: { issues: error.issues }
2199
+ } : {
2200
+ code: "INVALID_INPUT",
2201
+ message: "Invalid Relay API request.",
2202
+ details: { issues: error.issues }
2203
+ }
2204
+ });
2205
+ return;
2206
+ }
2207
+ reply.status(500).send({
2208
+ error: {
2209
+ code: "INTERNAL_ERROR",
2210
+ message: error instanceof Error ? error.message : "Unknown internal error."
2211
+ }
2212
+ });
2213
+ });
2214
+ app.post("/workspaces", async (request) => {
2215
+ const body = z2.object({
2216
+ name: z2.string(),
2217
+ adminHandle: z2.string(),
2218
+ adminName: z2.string(),
2219
+ adminBodyAccess: z2.boolean().optional()
2220
+ }).parse(request.body);
2221
+ return service.createWorkspace(body);
2222
+ });
2223
+ app.post("/workspaces/:workspaceId/invites", async (request) => {
2224
+ const params = z2.object({ workspaceId: z2.string() }).parse(request.params);
2225
+ const body = z2.object({ handle: z2.string() }).parse(request.body);
2226
+ return service.inviteMember({
2227
+ adminToken: bearer(request),
2228
+ workspaceId: params.workspaceId,
2229
+ handle: body.handle
2230
+ });
2231
+ });
2232
+ app.post("/invites/:inviteToken/accept", async (request) => {
2233
+ const params = z2.object({ inviteToken: z2.string() }).parse(request.params);
2234
+ const body = z2.object({ displayName: z2.string() }).parse(request.body);
2235
+ return service.acceptInvite({ inviteToken: params.inviteToken, displayName: body.displayName });
2236
+ });
2237
+ app.get("/invite/:inviteToken", async (request, reply) => {
2238
+ const params = z2.object({ inviteToken: z2.string() }).parse(request.params);
2239
+ const invite = service.getInvite({ inviteToken: params.inviteToken });
2240
+ const host = request.headers.host ?? "127.0.0.1:3737";
2241
+ const protocol = typeof request.headers["x-forwarded-proto"] === "string" ? request.headers["x-forwarded-proto"] : "http";
2242
+ const link = `${protocol}://${host}/invite/${encodeURIComponent(params.inviteToken)}`;
2243
+ const joinCommand = `npx -y handoff-relay join ${link}`;
2244
+ let status = `Invite for @${invite.invite.handle} to join ${invite.workspace.name}.`;
2245
+ if (invite.invite.accepted_at) {
2246
+ status = `Invite for @${invite.invite.handle} has already been accepted.`;
2247
+ } else if (Date.parse(invite.invite.expires_at) < Date.now()) {
2248
+ status = `Invite for @${invite.invite.handle} has expired.`;
2249
+ }
2250
+ reply.type("text/plain").send(
2251
+ [
2252
+ "Handoff invite",
2253
+ "",
2254
+ status,
2255
+ `Expires: ${invite.invite.expires_at}`,
2256
+ "",
2257
+ "Join with:",
2258
+ joinCommand,
2259
+ "",
2260
+ "Opening this page does not accept the invite."
2261
+ ].join("\n")
2262
+ );
2263
+ });
2264
+ app.get("/members", async (request) => {
2265
+ const query = z2.object({ workspaceId: z2.string() }).parse(request.query);
2266
+ return service.listMembers({ authToken: bearer(request), workspaceId: query.workspaceId });
2267
+ });
2268
+ app.post("/workspaces/:workspaceId/project-aliases", async (request) => {
2269
+ const params = z2.object({ workspaceId: z2.string() }).parse(request.params);
2270
+ const body = z2.object({ canonicalProject: z2.string(), alias: z2.string() }).parse(request.body);
2271
+ return service.configureProjectAlias({
2272
+ authToken: bearer(request),
2273
+ workspaceId: params.workspaceId,
2274
+ canonicalProject: body.canonicalProject,
2275
+ alias: body.alias
2276
+ });
2277
+ });
2278
+ app.get("/workspaces/:workspaceId/project-aliases", async (request) => {
2279
+ const params = z2.object({ workspaceId: z2.string() }).parse(request.params);
2280
+ return service.listProjectAliases({
2281
+ authToken: bearer(request),
2282
+ workspaceId: params.workspaceId
2283
+ });
2284
+ });
2285
+ app.post("/members/:memberId/revoke", async (request) => {
2286
+ const params = z2.object({ memberId: z2.string() }).parse(request.params);
2287
+ const body = z2.object({ workspaceId: z2.string() }).parse(request.body);
2288
+ return service.revokeMember({
2289
+ adminToken: bearer(request),
2290
+ workspaceId: body.workspaceId,
2291
+ memberId: params.memberId
2292
+ });
2293
+ });
2294
+ app.post("/members/rotate-token", async (request) => {
2295
+ return service.rotateMemberToken({ authToken: bearer(request) });
2296
+ });
2297
+ app.post("/members/rotate-approval-secret", async (request) => {
2298
+ const body = z2.object({ approvalSecret: z2.string().optional() }).parse(request.body ?? {});
2299
+ return service.rotateApprovalSecret({
2300
+ authToken: bearer(request),
2301
+ approvalSecret: body.approvalSecret
2302
+ });
2303
+ });
2304
+ app.post("/packets/ask", async (request) => {
2305
+ const body = z2.object({
2306
+ workspaceId: z2.string(),
2307
+ to: z2.string(),
2308
+ question: z2.string(),
2309
+ title: z2.string(),
2310
+ summary: z2.string(),
2311
+ sourceClient: z2.enum(sourceClients).default("generic"),
2312
+ project: z2.record(z2.string(), z2.unknown()).optional(),
2313
+ claims: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2314
+ evidence: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2315
+ filesOrSymbols: z2.array(z2.string()).optional(),
2316
+ commandsOrTestsRun: z2.array(z2.string()).optional(),
2317
+ whatWasTried: z2.array(z2.string()).optional(),
2318
+ knownFailures: z2.array(z2.string()).optional(),
2319
+ currentHypothesis: z2.string().optional(),
2320
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
2321
+ suggestedNextSteps: z2.array(z2.string()).optional()
2322
+ }).parse(request.body);
2323
+ return service.createAskDraft({
2324
+ authToken: bearer(request),
2325
+ workspaceId: body.workspaceId,
2326
+ to: body.to,
2327
+ question: body.question,
2328
+ title: body.title,
2329
+ summary: body.summary,
2330
+ sourceClient: body.sourceClient,
2331
+ project: body.project,
2332
+ claims: body.claims,
2333
+ evidence: body.evidence,
2334
+ filesOrSymbols: body.filesOrSymbols,
2335
+ commandsOrTestsRun: body.commandsOrTestsRun,
2336
+ whatWasTried: body.whatWasTried,
2337
+ knownFailures: body.knownFailures,
2338
+ currentHypothesis: body.currentHypothesis,
2339
+ confidence: body.confidence,
2340
+ suggestedNextSteps: body.suggestedNextSteps
2341
+ });
2342
+ });
2343
+ app.post("/packets/share", async (request) => {
2344
+ const body = z2.object({
2345
+ workspaceId: z2.string(),
2346
+ to: z2.string(),
2347
+ finding: z2.string(),
2348
+ title: z2.string(),
2349
+ summary: z2.string(),
2350
+ sourceClient: z2.enum(sourceClients).default("generic"),
2351
+ project: z2.record(z2.string(), z2.unknown()).optional(),
2352
+ claims: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2353
+ evidence: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2354
+ filesOrSymbols: z2.array(z2.string()).optional(),
2355
+ commandsOrTestsRun: z2.array(z2.string()).optional(),
2356
+ whatWasTried: z2.array(z2.string()).optional(),
2357
+ knownFailures: z2.array(z2.string()).optional(),
2358
+ currentHypothesis: z2.string().optional(),
2359
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
2360
+ suggestedNextSteps: z2.array(z2.string()).optional()
2361
+ }).parse(request.body);
2362
+ return service.createShareDraft({
2363
+ authToken: bearer(request),
2364
+ workspaceId: body.workspaceId,
2365
+ to: body.to,
2366
+ finding: body.finding,
2367
+ title: body.title,
2368
+ summary: body.summary,
2369
+ sourceClient: body.sourceClient,
2370
+ project: body.project,
2371
+ claims: body.claims,
2372
+ evidence: body.evidence,
2373
+ filesOrSymbols: body.filesOrSymbols,
2374
+ commandsOrTestsRun: body.commandsOrTestsRun,
2375
+ whatWasTried: body.whatWasTried,
2376
+ knownFailures: body.knownFailures,
2377
+ currentHypothesis: body.currentHypothesis,
2378
+ confidence: body.confidence,
2379
+ suggestedNextSteps: body.suggestedNextSteps
2380
+ });
2381
+ });
2382
+ app.patch("/packets/:packetId/draft", async (request) => {
2383
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2384
+ const body = z2.object({
2385
+ title: z2.string().optional(),
2386
+ summary: z2.string().optional(),
2387
+ question: z2.string().optional(),
2388
+ finding: z2.string().optional(),
2389
+ claims: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2390
+ evidence: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2391
+ filesOrSymbols: z2.array(z2.string()).optional(),
2392
+ commandsOrTestsRun: z2.array(z2.string()).optional(),
2393
+ whatWasTried: z2.array(z2.string()).optional(),
2394
+ knownFailures: z2.array(z2.string()).optional(),
2395
+ currentHypothesis: z2.string().optional(),
2396
+ confidence: z2.enum(["low", "medium", "high"]).optional(),
2397
+ suggestedNextSteps: z2.array(z2.string()).optional()
2398
+ }).parse(request.body);
2399
+ return service.updateDraft({
2400
+ authToken: bearer(request),
2401
+ packetId: params.packetId,
2402
+ title: body.title,
2403
+ summary: body.summary,
2404
+ question: body.question,
2405
+ finding: body.finding,
2406
+ claims: body.claims,
2407
+ evidence: body.evidence,
2408
+ filesOrSymbols: body.filesOrSymbols,
2409
+ commandsOrTestsRun: body.commandsOrTestsRun,
2410
+ whatWasTried: body.whatWasTried,
2411
+ knownFailures: body.knownFailures,
2412
+ currentHypothesis: body.currentHypothesis,
2413
+ confidence: body.confidence,
2414
+ suggestedNextSteps: body.suggestedNextSteps
2415
+ });
2416
+ });
2417
+ app.post("/packets/:packetId/approval-token", async (request) => {
2418
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2419
+ const body = z2.object({
2420
+ action: z2.enum(["send", "reply", "hydrate"]),
2421
+ approvalSecret: z2.string().optional()
2422
+ }).parse(request.body);
2423
+ return service.createApprovalToken({
2424
+ authToken: bearer(request),
2425
+ approvalSecret: body.approvalSecret,
2426
+ packetId: params.packetId,
2427
+ action: body.action
2428
+ });
2429
+ });
2430
+ app.post("/packets/:packetId/approve", async (request) => {
2431
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2432
+ const body = z2.object({ allowSecretOverride: z2.boolean().optional(), approvalToken: z2.string().optional() }).parse(request.body ?? {});
2433
+ return service.approveAndSend({
2434
+ authToken: bearer(request),
2435
+ packetId: params.packetId,
2436
+ allowSecretOverride: body.allowSecretOverride,
2437
+ approvalToken: body.approvalToken
2438
+ });
2439
+ });
2440
+ app.get("/inbox", async (request) => {
2441
+ const query = z2.object({ workspaceId: z2.string() }).parse(request.query);
2442
+ return service.listInbox({ authToken: bearer(request), workspaceId: query.workspaceId });
2443
+ });
2444
+ app.get("/packets/:packetId/view", async (request) => {
2445
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2446
+ return service.viewPacket({ authToken: bearer(request), packetId: params.packetId });
2447
+ });
2448
+ app.get("/packets/:packetId/status", async (request) => {
2449
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2450
+ return service.getPacketForMember({ authToken: bearer(request), packetId: params.packetId });
2451
+ });
2452
+ app.post("/packets/:packetId/accept", async (request) => {
2453
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2454
+ return service.acceptPacket({ authToken: bearer(request), packetId: params.packetId });
2455
+ });
2456
+ app.post("/packets/:packetId/hydrate", async (request) => {
2457
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2458
+ const body = z2.object({
2459
+ client: z2.string().default("generic"),
2460
+ sessionId: z2.string().optional(),
2461
+ approvalToken: z2.string().optional()
2462
+ }).parse(request.body ?? {});
2463
+ return service.hydratePacket({
2464
+ authToken: bearer(request),
2465
+ packetId: params.packetId,
2466
+ client: body.client,
2467
+ sessionId: body.sessionId,
2468
+ approvalToken: body.approvalToken
2469
+ });
2470
+ });
2471
+ app.post("/packets/:packetId/reply", async (request) => {
2472
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2473
+ const body = z2.object({
2474
+ answer: z2.string(),
2475
+ summary: z2.string(),
2476
+ sourceClient: z2.enum(sourceClients).default("generic"),
2477
+ evidence: z2.array(z2.record(z2.string(), z2.unknown())).optional(),
2478
+ confidence: z2.enum(["low", "medium", "high"]).optional()
2479
+ }).parse(request.body);
2480
+ return service.createReplyDraft({
2481
+ authToken: bearer(request),
2482
+ packetId: params.packetId,
2483
+ answer: body.answer,
2484
+ summary: body.summary,
2485
+ sourceClient: body.sourceClient,
2486
+ evidence: body.evidence,
2487
+ confidence: body.confidence
2488
+ });
2489
+ });
2490
+ app.post("/packets/:packetId/clarify", async (request) => {
2491
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2492
+ const body = z2.object({
2493
+ question: z2.string(),
2494
+ requestedEvidence: z2.array(z2.string()).optional()
2495
+ }).parse(request.body);
2496
+ return service.requestClarification({
2497
+ authToken: bearer(request),
2498
+ packetId: params.packetId,
2499
+ question: body.question,
2500
+ requestedEvidence: body.requestedEvidence
2501
+ });
2502
+ });
2503
+ app.post("/packets/:packetId/decline", async (request) => {
2504
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2505
+ const body = z2.object({ reason: z2.string().optional() }).parse(request.body ?? {});
2506
+ return service.declinePacket({
2507
+ authToken: bearer(request),
2508
+ packetId: params.packetId,
2509
+ reason: body.reason
2510
+ });
2511
+ });
2512
+ app.post("/packets/:packetId/archive", async (request) => {
2513
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2514
+ return service.archivePacket({ authToken: bearer(request), packetId: params.packetId });
2515
+ });
2516
+ app.post("/packets/:packetId/close", async (request) => {
2517
+ const params = z2.object({ packetId: z2.string() }).parse(request.params);
2518
+ const body = z2.object({ resolution: z2.enum(["resolved", "unresolved"]) }).parse(request.body);
2519
+ return service.closePacket({
2520
+ authToken: bearer(request),
2521
+ packetId: params.packetId,
2522
+ resolution: body.resolution
2523
+ });
2524
+ });
2525
+ app.get("/search", async (request) => {
2526
+ const query = z2.object({ workspaceId: z2.string(), q: z2.string().optional(), ...packetQuerySchema }).parse(request.query);
2527
+ return service.searchPackets({
2528
+ authToken: bearer(request),
2529
+ workspaceId: query.workspaceId,
2530
+ query: query.q,
2531
+ project: query.project,
2532
+ sender: query.sender,
2533
+ recipient: query.recipient,
2534
+ status: query.status,
2535
+ fileOrSymbol: query.fileOrSymbol,
2536
+ ticketOrPr: query.ticketOrPr
2537
+ });
2538
+ });
2539
+ app.get("/history", async (request) => {
2540
+ const query = z2.object({
2541
+ workspaceId: z2.string(),
2542
+ filter: z2.enum(["all", "drafts", "sent", "open", "closed"]).optional(),
2543
+ q: z2.string().optional(),
2544
+ ...packetQuerySchema
2545
+ }).parse(request.query);
2546
+ return service.listHistory({
2547
+ authToken: bearer(request),
2548
+ workspaceId: query.workspaceId,
2549
+ filter: query.filter,
2550
+ query: query.q,
2551
+ project: query.project,
2552
+ sender: query.sender,
2553
+ recipient: query.recipient,
2554
+ status: query.status,
2555
+ fileOrSymbol: query.fileOrSymbol,
2556
+ ticketOrPr: query.ticketOrPr
2557
+ });
2558
+ });
2559
+ app.get("/audit", async (request) => {
2560
+ const query = z2.object({ workspaceId: z2.string(), packetId: z2.string().optional() }).parse(request.query);
2561
+ return service.listAuditReceipts({
2562
+ authToken: bearer(request),
2563
+ workspaceId: query.workspaceId,
2564
+ packetId: query.packetId
2565
+ });
2566
+ });
2567
+ return app;
2568
+ }
2569
+ async function startApiServer(input) {
2570
+ const service = new RelayService(createRelayDatabase(input.dbPath));
2571
+ const app = buildApiServer({ service });
2572
+ await app.listen({ host: input.host ?? "127.0.0.1", port: input.port ?? 3737 });
2573
+ return app;
2574
+ }
2575
+ export {
2576
+ buildApiServer,
2577
+ startApiServer
2578
+ };