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