agent-inbox 0.2.4 → 0.2.5

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.
Files changed (138) hide show
  1. package/CLAUDE.md +1 -92
  2. package/README.md +6 -73
  3. package/dist/federation/connection-manager.d.ts +0 -8
  4. package/dist/federation/connection-manager.d.ts.map +1 -1
  5. package/dist/federation/connection-manager.js +0 -12
  6. package/dist/federation/connection-manager.js.map +1 -1
  7. package/dist/federation/delivery-queue.d.ts +3 -11
  8. package/dist/federation/delivery-queue.d.ts.map +1 -1
  9. package/dist/federation/delivery-queue.js +8 -38
  10. package/dist/federation/delivery-queue.js.map +1 -1
  11. package/dist/index.d.ts +0 -17
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +0 -98
  14. package/dist/index.js.map +1 -1
  15. package/dist/jsonrpc/mail-push-types.d.ts +22 -2
  16. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  17. package/dist/jsonrpc/mail-push-types.js +18 -1
  18. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  19. package/dist/jsonrpc/mail-push.d.ts +12 -1
  20. package/dist/jsonrpc/mail-push.d.ts.map +1 -1
  21. package/dist/jsonrpc/mail-push.js +13 -2
  22. package/dist/jsonrpc/mail-push.js.map +1 -1
  23. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  24. package/dist/jsonrpc/mail-server.js +42 -18
  25. package/dist/jsonrpc/mail-server.js.map +1 -1
  26. package/dist/router/message-router.d.ts +0 -15
  27. package/dist/router/message-router.d.ts.map +1 -1
  28. package/dist/router/message-router.js +3 -25
  29. package/dist/router/message-router.js.map +1 -1
  30. package/dist/storage/interface.d.ts +2 -9
  31. package/dist/storage/interface.d.ts.map +1 -1
  32. package/dist/storage/memory.d.ts +1 -4
  33. package/dist/storage/memory.d.ts.map +1 -1
  34. package/dist/storage/memory.js +6 -12
  35. package/dist/storage/memory.js.map +1 -1
  36. package/dist/storage/sqlite.d.ts +1 -6
  37. package/dist/storage/sqlite.d.ts.map +1 -1
  38. package/dist/storage/sqlite.js +6 -28
  39. package/dist/storage/sqlite.js.map +1 -1
  40. package/dist/types.d.ts +0 -79
  41. package/dist/types.d.ts.map +1 -1
  42. package/docs/DESIGN.md +0 -15
  43. package/package.json +3 -28
  44. package/rules/agent-inbox.md +0 -1
  45. package/src/federation/connection-manager.ts +0 -12
  46. package/src/federation/delivery-queue.ts +8 -38
  47. package/src/index.ts +0 -148
  48. package/src/jsonrpc/mail-push-types.ts +43 -2
  49. package/src/jsonrpc/mail-push.ts +34 -3
  50. package/src/jsonrpc/mail-server.ts +45 -26
  51. package/src/router/message-router.ts +4 -41
  52. package/src/storage/interface.ts +2 -11
  53. package/src/storage/memory.ts +8 -15
  54. package/src/storage/sqlite.ts +9 -36
  55. package/src/types.ts +0 -73
  56. package/test/load.test.ts +1 -1
  57. package/test/mail-push.test.ts +101 -1
  58. package/test/mail-server.test.ts +108 -0
  59. package/AGENTS.md +0 -18
  60. package/dist/federation/queue-store.d.ts +0 -42
  61. package/dist/federation/queue-store.d.ts.map +0 -1
  62. package/dist/federation/queue-store.js +0 -87
  63. package/dist/federation/queue-store.js.map +0 -1
  64. package/dist/index.d.mts +0 -2
  65. package/dist/index.mjs +0 -1
  66. package/dist/index.mjs.map +0 -1
  67. package/dist/mail/address-book.d.ts +0 -43
  68. package/dist/mail/address-book.d.ts.map +0 -1
  69. package/dist/mail/address-book.js +0 -95
  70. package/dist/mail/address-book.js.map +0 -1
  71. package/dist/mail/attachment-store.d.ts +0 -31
  72. package/dist/mail/attachment-store.d.ts.map +0 -1
  73. package/dist/mail/attachment-store.js +0 -74
  74. package/dist/mail/attachment-store.js.map +0 -1
  75. package/dist/mail/email-mapper.d.ts +0 -41
  76. package/dist/mail/email-mapper.d.ts.map +0 -1
  77. package/dist/mail/email-mapper.js +0 -216
  78. package/dist/mail/email-mapper.js.map +0 -1
  79. package/dist/mail/fs-attachment-store.d.ts +0 -38
  80. package/dist/mail/fs-attachment-store.d.ts.map +0 -1
  81. package/dist/mail/fs-attachment-store.js +0 -165
  82. package/dist/mail/fs-attachment-store.js.map +0 -1
  83. package/dist/mail/mail-gateway.d.ts +0 -114
  84. package/dist/mail/mail-gateway.d.ts.map +0 -1
  85. package/dist/mail/mail-gateway.js +0 -402
  86. package/dist/mail/mail-gateway.js.map +0 -1
  87. package/dist/mail/provider-transport.d.ts +0 -138
  88. package/dist/mail/provider-transport.d.ts.map +0 -1
  89. package/dist/mail/provider-transport.js +0 -434
  90. package/dist/mail/provider-transport.js.map +0 -1
  91. package/dist/mail/rate-limiter.d.ts +0 -20
  92. package/dist/mail/rate-limiter.d.ts.map +0 -1
  93. package/dist/mail/rate-limiter.js +0 -56
  94. package/dist/mail/rate-limiter.js.map +0 -1
  95. package/dist/mail/smtp-transport.d.ts +0 -141
  96. package/dist/mail/smtp-transport.d.ts.map +0 -1
  97. package/dist/mail/smtp-transport.js +0 -415
  98. package/dist/mail/smtp-transport.js.map +0 -1
  99. package/dist/mail/types.d.ts +0 -177
  100. package/dist/mail/types.d.ts.map +0 -1
  101. package/dist/mail/types.js +0 -11
  102. package/dist/mail/types.js.map +0 -1
  103. package/dist/router/destination.d.ts +0 -69
  104. package/dist/router/destination.d.ts.map +0 -1
  105. package/dist/router/destination.js +0 -106
  106. package/dist/router/destination.js.map +0 -1
  107. package/docs/MAIL-INTEROP-PLAN.md +0 -660
  108. package/renovate.json5 +0 -6
  109. package/src/federation/queue-store.ts +0 -124
  110. package/src/mail/address-book.ts +0 -111
  111. package/src/mail/attachment-store.ts +0 -90
  112. package/src/mail/email-mapper.ts +0 -288
  113. package/src/mail/fs-attachment-store.ts +0 -163
  114. package/src/mail/mail-gateway.ts +0 -505
  115. package/src/mail/provider-transport.ts +0 -577
  116. package/src/mail/rate-limiter.ts +0 -51
  117. package/src/mail/smtp-transport.ts +0 -589
  118. package/src/mail/types.ts +0 -221
  119. package/src/router/destination.ts +0 -140
  120. package/test/federation/delivery-queue-sqlite.test.ts +0 -158
  121. package/test/mail/address-book.test.ts +0 -111
  122. package/test/mail/attachment-store-contract.test.ts +0 -92
  123. package/test/mail/attachment-store.test.ts +0 -69
  124. package/test/mail/destination.test.ts +0 -115
  125. package/test/mail/dsn-parse.test.ts +0 -239
  126. package/test/mail/email-mapper.test.ts +0 -341
  127. package/test/mail/external-id.test.ts +0 -43
  128. package/test/mail/fs-attachment-store.test.ts +0 -134
  129. package/test/mail/full-flow-e2e.test.ts +0 -200
  130. package/test/mail/mail-gateway.test.ts +0 -419
  131. package/test/mail/mail-transport-contract.test.ts +0 -134
  132. package/test/mail/mock-mail.ts +0 -161
  133. package/test/mail/mock-postmark.ts +0 -66
  134. package/test/mail/provider-transport.test.ts +0 -381
  135. package/test/mail/rate-limiter.test.ts +0 -48
  136. package/test/mail/router-mail-integration.test.ts +0 -138
  137. package/test/mail/smtp-e2e.test.ts +0 -98
  138. package/test/mail/smtp-transport.test.ts +0 -138
@@ -1,341 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { AddressBook } from "../../src/mail/address-book.js";
3
- import {
4
- inboundMailToMessage,
5
- messageToOutboundMail,
6
- UnknownRecipientError,
7
- type InboundMapContext,
8
- type OutboundMapContext,
9
- } from "../../src/mail/email-mapper.js";
10
- import { makeInboundMail } from "./mock-mail.js";
11
- import type { Message } from "../../src/types.js";
12
-
13
- function addressBook(): AddressBook {
14
- return new AddressBook({
15
- localDomains: ["example.com"],
16
- mappings: [
17
- { agentId: "alice", address: "alice@example.com" },
18
- { agentId: "bob", address: "bob@example.com" },
19
- ],
20
- });
21
- }
22
-
23
- function inCtx(over: Partial<InboundMapContext> = {}): InboundMapContext {
24
- return { addressBook: addressBook(), defaultScope: "default", ...over };
25
- }
26
-
27
- function outCtx(over: Partial<OutboundMapContext> = {}): OutboundMapContext {
28
- return { addressBook: addressBook(), ...over };
29
- }
30
-
31
- describe("inboundMailToMessage", () => {
32
- it("maps a basic inbound email to a Message", () => {
33
- const msg = inboundMailToMessage(
34
- makeInboundMail({
35
- from: { address: "ext@other.org", name: "Ext" },
36
- to: [{ address: "alice@example.com" }],
37
- subject: "hi",
38
- text: "body text",
39
- headers: { messageId: "<abc@other.org>", raw: {} },
40
- }),
41
- inCtx()
42
- );
43
- expect(msg.sender_id).toBe("ext@other.org");
44
- expect(msg.recipients).toEqual([{ agent_id: "alice", kind: "to" }]);
45
- expect(msg.subject).toBe("hi");
46
- expect(msg.content).toEqual({ type: "text", text: "body text" });
47
- expect(msg.metadata.rfcMessageId).toBe("<abc@other.org>");
48
- expect(msg.metadata.envelopeFrom).toBe("ext@example.org");
49
- });
50
-
51
- it("resolves cc recipients and preserves kind", () => {
52
- const msg = inboundMailToMessage(
53
- makeInboundMail({
54
- to: [{ address: "alice@example.com" }],
55
- cc: [{ address: "bob@example.com" }],
56
- }),
57
- inCtx()
58
- );
59
- expect(msg.recipients).toContainEqual({ agent_id: "alice", kind: "to" });
60
- expect(msg.recipients).toContainEqual({ agent_id: "bob", kind: "cc" });
61
- });
62
-
63
- it("derives scope from plus-addressing on the matched to recipient", () => {
64
- const msg = inboundMailToMessage(
65
- makeInboundMail({ to: [{ address: "alice+project-x@example.com" }] }),
66
- inCtx()
67
- );
68
- expect(msg.scope).toBe("project-x");
69
- expect(msg.recipients[0].agent_id).toBe("alice");
70
- });
71
-
72
- it("skips unresolvable recipients but keeps resolvable ones", () => {
73
- const msg = inboundMailToMessage(
74
- makeInboundMail({
75
- to: [
76
- { address: "alice@example.com" },
77
- { address: "stranger@elsewhere.net" },
78
- ],
79
- }),
80
- inCtx()
81
- );
82
- expect(msg.recipients).toEqual([{ agent_id: "alice", kind: "to" }]);
83
- });
84
-
85
- it("throws UnknownRecipientError when nothing resolves", () => {
86
- expect(() =>
87
- inboundMailToMessage(
88
- makeInboundMail({ to: [{ address: "nobody@elsewhere.net" }] }),
89
- inCtx()
90
- )
91
- ).toThrow(UnknownRecipientError);
92
- });
93
-
94
- it("uses catch-all when configured and mailbox is unknown", () => {
95
- const ab = new AddressBook({
96
- localDomains: ["example.com"],
97
- mappings: [],
98
- catchAllAgentId: "triage",
99
- });
100
- const msg = inboundMailToMessage(
101
- makeInboundMail({ to: [{ address: "whoever@example.com" }] }),
102
- inCtx({ addressBook: ab })
103
- );
104
- expect(msg.recipients[0].agent_id).toBe("triage");
105
- });
106
-
107
- it("derives a stable thread_tag from the References root", () => {
108
- const a = inboundMailToMessage(
109
- makeInboundMail({
110
- to: [{ address: "alice@example.com" }],
111
- headers: {
112
- messageId: "<m2@x>",
113
- references: ["<root@x>", "<m1@x>"],
114
- raw: {},
115
- },
116
- }),
117
- inCtx()
118
- );
119
- const b = inboundMailToMessage(
120
- makeInboundMail({
121
- to: [{ address: "alice@example.com" }],
122
- headers: {
123
- messageId: "<m3@x>",
124
- references: ["<root@x>"],
125
- raw: {},
126
- },
127
- }),
128
- inCtx()
129
- );
130
- expect(a.thread_tag).toBeTruthy();
131
- expect(a.thread_tag).toBe(b.thread_tag); // same root → same thread
132
- });
133
-
134
- it("resolves in_reply_to to an inbox id when the RFC id is known", () => {
135
- const msg = inboundMailToMessage(
136
- makeInboundMail({
137
- to: [{ address: "alice@example.com" }],
138
- headers: { messageId: "<reply@x>", inReplyTo: "<parent@x>", raw: {} },
139
- }),
140
- inCtx({
141
- lookupInboxIdByRfcMessageId: (id) =>
142
- id === "<parent@x>" ? "inbox-parent-123" : undefined,
143
- })
144
- );
145
- expect(msg.in_reply_to).toBe("inbox-parent-123");
146
- expect(msg.metadata.rfcInReplyTo).toBe("<parent@x>");
147
- });
148
-
149
- it("maps X-Priority / Importance headers to importance", () => {
150
- const high = inboundMailToMessage(
151
- makeInboundMail({
152
- to: [{ address: "alice@example.com" }],
153
- headers: { raw: { "x-priority": "1" } },
154
- }),
155
- inCtx()
156
- );
157
- expect(high.importance).toBe("high");
158
- const low = inboundMailToMessage(
159
- makeInboundMail({
160
- to: [{ address: "alice@example.com" }],
161
- headers: { raw: { importance: "low" } },
162
- }),
163
- inCtx()
164
- );
165
- expect(low.importance).toBe("low");
166
- });
167
-
168
- it("retains an inbound bounce in metadata", () => {
169
- const msg = inboundMailToMessage(
170
- makeInboundMail({
171
- to: [{ address: "alice@example.com" }],
172
- bounce: {
173
- action: "failed",
174
- recipient: "gone@nowhere.net",
175
- status: "5.1.1",
176
- originalMessageId: "<orig@example.com>",
177
- },
178
- }),
179
- inCtx()
180
- );
181
- expect(msg.metadata.bounce).toMatchObject({ action: "failed" });
182
- });
183
- });
184
-
185
- describe("messageToOutboundMail", () => {
186
- function baseMessage(over: Partial<Message> = {}): Message {
187
- return {
188
- id: "01ABCDEF",
189
- scope: "default",
190
- sender_id: "alice",
191
- recipients: [{ agent_id: "ext@other.org", kind: "to" }],
192
- subject: "subject",
193
- content: { type: "text", text: "hello world" },
194
- importance: "normal",
195
- metadata: {},
196
- created_at: new Date().toISOString(),
197
- ...over,
198
- };
199
- }
200
-
201
- it("renders a Message into an OutboundMail", () => {
202
- const out = messageToOutboundMail(baseMessage(), outCtx());
203
- expect(out.idempotencyKey).toBe("01ABCDEF");
204
- expect(out.from).toEqual({ address: "alice@example.com" });
205
- expect(out.to).toEqual([{ address: "ext@other.org" }]);
206
- expect(out.text).toBe("hello world");
207
- expect(out.headers.messageId).toBe("<01ABCDEF@example.com>");
208
- });
209
-
210
- it("falls back to <agentId>@primaryDomain for an unmapped sender", () => {
211
- const out = messageToOutboundMail(
212
- baseMessage({ sender_id: "ghost" }),
213
- outCtx()
214
- );
215
- expect(out.from).toEqual({ address: "ghost@example.com" });
216
- });
217
-
218
- it("splits recipients by kind into to/cc/bcc", () => {
219
- const out = messageToOutboundMail(
220
- baseMessage({
221
- recipients: [
222
- { agent_id: "a@x.org", kind: "to" },
223
- { agent_id: "b@x.org", kind: "cc" },
224
- { agent_id: "c@x.org", kind: "bcc" },
225
- ],
226
- }),
227
- outCtx()
228
- );
229
- expect(out.to).toEqual([{ address: "a@x.org" }]);
230
- expect(out.cc).toEqual([{ address: "b@x.org" }]);
231
- expect(out.bcc).toEqual([{ address: "c@x.org" }]);
232
- });
233
-
234
- it("builds In-Reply-To / References from a known parent", () => {
235
- const out = messageToOutboundMail(
236
- baseMessage({ in_reply_to: "inbox-parent" }),
237
- outCtx({
238
- lookupRfcMessageId: (id) =>
239
- id === "inbox-parent" ? "<parent@other.org>" : undefined,
240
- lookupReferences: () => ["<root@other.org>"],
241
- })
242
- );
243
- expect(out.headers.inReplyTo).toBe("<parent@other.org>");
244
- expect(out.headers.references).toEqual([
245
- "<root@other.org>",
246
- "<parent@other.org>",
247
- ]);
248
- });
249
-
250
- it("synthesizes a parent Message-ID when the RFC id is unknown", () => {
251
- const out = messageToOutboundMail(
252
- baseMessage({ in_reply_to: "inbox-parent" }),
253
- outCtx()
254
- );
255
- expect(out.headers.inReplyTo).toBe("<inbox-parent@example.com>");
256
- });
257
-
258
- it("carries html body from metadata but does NOT leak arbitrary metadata to the backend", () => {
259
- const out = messageToOutboundMail(
260
- baseMessage({
261
- metadata: { htmlBody: "<p>hi</p>", rfcMessageId: "<x@y>", userTag: "internal" },
262
- }),
263
- outCtx()
264
- );
265
- expect(out.html).toBe("<p>hi</p>");
266
- // Internal metadata must not be forwarded to the external mail provider.
267
- expect(out.backendHints).toBeUndefined();
268
- });
269
-
270
- it("forwards only an explicit mailBackendHints object to the backend", () => {
271
- const out = messageToOutboundMail(
272
- baseMessage({
273
- metadata: { secret: "no", mailBackendHints: { tag: "newsletter" } },
274
- }),
275
- outCtx()
276
- );
277
- expect(out.backendHints).toEqual({ tag: "newsletter" });
278
- });
279
-
280
- it("validates and filters malformed attachment metadata", () => {
281
- const out = messageToOutboundMail(
282
- baseMessage({
283
- metadata: {
284
- mailAttachments: [
285
- { contentRef: "sha256:a", contentType: "text/plain", sizeBytes: 3, filename: "ok.txt" },
286
- { bogus: true }, // dropped — missing contentRef/contentType
287
- "not-an-object", // dropped
288
- ],
289
- },
290
- }),
291
- outCtx()
292
- );
293
- expect(out.attachments).toEqual([
294
- { contentRef: "sha256:a", contentType: "text/plain", sizeBytes: 3, filename: "ok.txt" },
295
- ]);
296
- });
297
- });
298
-
299
- describe("round-trip", () => {
300
- it("preserves subject, body, and threading across inbound→outbound reply", () => {
301
- const ab = addressBook();
302
- // Inbound arrives for alice.
303
- const inbound = inboundMailToMessage(
304
- makeInboundMail({
305
- from: { address: "ext@other.org" },
306
- to: [{ address: "alice@example.com" }],
307
- subject: "Question",
308
- text: "what is the status?",
309
- headers: { messageId: "<q1@other.org>", raw: {} },
310
- }),
311
- { addressBook: ab }
312
- );
313
-
314
- // Alice replies: a new message in_reply_to the inbound one, to the sender.
315
- const reply: Message = {
316
- id: "REPLY1",
317
- scope: inbound.scope,
318
- sender_id: "alice",
319
- recipients: [{ agent_id: "ext@other.org", kind: "to" }],
320
- subject: "Re: Question",
321
- content: { type: "text", text: "all good" },
322
- in_reply_to: inbound.id,
323
- importance: "normal",
324
- metadata: {},
325
- created_at: new Date().toISOString(),
326
- };
327
-
328
- const out = messageToOutboundMail(reply, {
329
- addressBook: ab,
330
- // The reply's parent is the inbound message; its recorded RFC id:
331
- lookupRfcMessageId: (id) =>
332
- id === inbound.id ? (inbound.metadata.rfcMessageId as string) : undefined,
333
- });
334
-
335
- expect(out.to).toEqual([{ address: "ext@other.org" }]);
336
- expect(out.subject).toBe("Re: Question");
337
- expect(out.text).toBe("all good");
338
- expect(out.headers.inReplyTo).toBe("<q1@other.org>");
339
- expect(out.headers.references).toEqual(["<q1@other.org>"]);
340
- });
341
- });
@@ -1,43 +0,0 @@
1
- import { describe, it, expect, afterEach } from "vitest";
2
- import Database from "better-sqlite3";
3
- import { InMemoryStorage } from "../../src/storage/memory.js";
4
- import { SqliteStorage } from "../../src/storage/sqlite.js";
5
- import type { Storage } from "../../src/storage/interface.js";
6
-
7
- const dbs: Database.Database[] = [];
8
- afterEach(() => {
9
- while (dbs.length) dbs.pop()!.close();
10
- });
11
-
12
- function sqlite(): Storage {
13
- const db = new Database(":memory:");
14
- dbs.push(db);
15
- return new SqliteStorage({ db });
16
- }
17
-
18
- const impls: Array<[string, () => Storage]> = [
19
- ["InMemoryStorage", () => new InMemoryStorage()],
20
- ["SqliteStorage", () => sqlite()],
21
- ];
22
-
23
- describe.each(impls)("external-id mapping (%s)", (_name, make) => {
24
- it("records and looks up an external id", () => {
25
- const s = make();
26
- s.recordExternalId("<abc@x>", "msg-1");
27
- expect(s.getMessageIdByExternalId("<abc@x>")).toBe("msg-1");
28
- expect(s.hasSeenExternalId("<abc@x>")).toBe(true);
29
- });
30
-
31
- it("reports unseen ids", () => {
32
- const s = make();
33
- expect(s.hasSeenExternalId("<nope@x>")).toBe(false);
34
- expect(s.getMessageIdByExternalId("<nope@x>")).toBeUndefined();
35
- });
36
-
37
- it("is idempotent: first mapping wins", () => {
38
- const s = make();
39
- s.recordExternalId("<dup@x>", "msg-1");
40
- s.recordExternalId("<dup@x>", "msg-2");
41
- expect(s.getMessageIdByExternalId("<dup@x>")).toBe("msg-1");
42
- });
43
- });
@@ -1,134 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import * as fs from "node:fs/promises";
3
- import * as os from "node:os";
4
- import * as path from "node:path";
5
- import { FsAttachmentStore } from "../../src/mail/fs-attachment-store.js";
6
-
7
- describe("FsAttachmentStore", () => {
8
- let dir: string;
9
- let store: FsAttachmentStore;
10
-
11
- beforeEach(async () => {
12
- dir = await fs.mkdtemp(path.join(os.tmpdir(), "att-"));
13
- store = new FsAttachmentStore(dir);
14
- });
15
-
16
- afterEach(async () => {
17
- await fs.rm(dir, { recursive: true, force: true });
18
- });
19
-
20
- it("stores and retrieves bytes by ref", async () => {
21
- const bytes = Buffer.from("hello filesystem attachment");
22
- const ref = await store.put(bytes, { contentType: "text/plain" });
23
- const got = await store.get(ref);
24
- expect(got.equals(bytes)).toBe(true);
25
- });
26
-
27
- it("produces content-addressed (sha256:) refs", async () => {
28
- const ref = await store.put(Buffer.from("x"), { contentType: "text/plain" });
29
- expect(ref).toMatch(/^sha256:[0-9a-f]{64}$/);
30
- });
31
-
32
- it("dedups identical bytes to the same ref and a single file", async () => {
33
- const a = await store.put(Buffer.from("same"), { contentType: "text/plain" });
34
- const b = await store.put(Buffer.from("same"), { contentType: "text/plain" });
35
- expect(a).toBe(b);
36
- // One shard dir, one file in it.
37
- const shards = await fs.readdir(dir);
38
- expect(shards).toHaveLength(1);
39
- const files = await fs.readdir(path.join(dir, shards[0]));
40
- expect(files.filter((f) => !f.includes(".tmp-"))).toHaveLength(1);
41
- });
42
-
43
- it("gives different refs for different bytes", async () => {
44
- const a = await store.put(Buffer.from("one"), { contentType: "text/plain" });
45
- const b = await store.put(Buffer.from("two"), { contentType: "text/plain" });
46
- expect(a).not.toBe(b);
47
- });
48
-
49
- it("shards files by the first byte of the hash", async () => {
50
- const bytes = Buffer.from("shard me");
51
- const ref = await store.put(bytes, { contentType: "text/plain" });
52
- const hex = ref.slice("sha256:".length);
53
- const expected = path.join(dir, hex.slice(0, 2), hex);
54
- await expect(fs.access(expected)).resolves.toBeUndefined();
55
- });
56
-
57
- it("deletes by ref", async () => {
58
- const ref = await store.put(Buffer.from("gone"), { contentType: "text/plain" });
59
- await store.delete(ref);
60
- await expect(store.get(ref)).rejects.toThrow(/not found/);
61
- });
62
-
63
- it("delete is a no-op for a missing ref", async () => {
64
- const ref = "sha256:" + "a".repeat(64);
65
- await expect(store.delete(ref)).resolves.toBeUndefined();
66
- });
67
-
68
- it("throws a clear error on a missing ref", async () => {
69
- await expect(store.get("sha256:" + "b".repeat(64))).rejects.toThrow(/not found/);
70
- });
71
-
72
- it("rejects malformed / path-traversal refs", async () => {
73
- await expect(store.get("sha256:../../etc/passwd")).rejects.toThrow(/Invalid attachment ref/);
74
- await expect(store.get("../../etc/passwd")).rejects.toThrow(/Invalid attachment ref/);
75
- await expect(store.delete("sha256:..")).rejects.toThrow(/Invalid attachment ref/);
76
- await expect(store.get("sha256:ABC")).rejects.toThrow(/Invalid attachment ref/);
77
- });
78
-
79
- it("persists across store instances over the same directory", async () => {
80
- const ref = await store.put(Buffer.from("durable"), { contentType: "text/plain" });
81
- const reopened = new FsAttachmentStore(dir);
82
- const got = await reopened.get(ref);
83
- expect(got.toString()).toBe("durable");
84
- });
85
-
86
- it("handles concurrent puts of identical bytes without corruption", async () => {
87
- const bytes = Buffer.from("racy-content");
88
- const refs = await Promise.all(
89
- Array.from({ length: 8 }, () => store.put(bytes, { contentType: "text/plain" }))
90
- );
91
- expect(new Set(refs).size).toBe(1);
92
- expect((await store.get(refs[0])).equals(bytes)).toBe(true);
93
- });
94
-
95
- it("round-trips binary content exactly", async () => {
96
- const bytes = Buffer.from([0x00, 0xff, 0x10, 0x7f, 0x80, 0x00, 0x01]);
97
- const ref = await store.put(bytes, { contentType: "application/octet-stream" });
98
- expect((await store.get(ref)).equals(bytes)).toBe(true);
99
- });
100
-
101
- describe("gc", () => {
102
- it("removes orphan files not in the live set", async () => {
103
- const keep = await store.put(Buffer.from("keep"), { contentType: "text/plain" });
104
- const drop = await store.put(Buffer.from("drop"), { contentType: "text/plain" });
105
-
106
- // minAgeMs: 0 so just-written files are eligible (the default 1h grace
107
- // period protects in-flight attachments from a concurrent gc).
108
- const removed = await store.gc(new Set([keep]), 0);
109
- expect(removed).toBe(1);
110
- await expect(store.get(keep)).resolves.toBeTruthy();
111
- await expect(store.get(drop)).rejects.toThrow(/not found/);
112
- });
113
-
114
- it("does not delete recently-written orphans within the grace period", async () => {
115
- const drop = await store.put(Buffer.from("fresh-orphan"), { contentType: "text/plain" });
116
- // Default grace period (1h) — the just-written file is too new to GC.
117
- const removed = await store.gc(new Set());
118
- expect(removed).toBe(0);
119
- await expect(store.get(drop)).resolves.toBeTruthy();
120
- });
121
-
122
- it("ignores stray non-directory entries at the root", async () => {
123
- await store.put(Buffer.from("real"), { contentType: "text/plain" });
124
- await fs.writeFile(path.join(dir, "stray.txt"), "junk");
125
- // Should not throw on the stray file.
126
- await expect(store.gc(new Set(), 0)).resolves.toBeTypeOf("number");
127
- });
128
-
129
- it("returns 0 when the directory does not exist yet", async () => {
130
- const fresh = new FsAttachmentStore(path.join(dir, "does-not-exist"));
131
- expect(await fresh.gc(new Set())).toBe(0);
132
- });
133
- });
134
- });