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