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