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