agentfax 0.0.2 → 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/README.md +125 -4
- package/SPEC.md +188 -0
- package/dist/agent-section.d.ts +2 -0
- package/dist/agent-section.js +59 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +10 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +202 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +109 -0
- package/dist/fax.d.ts +168 -0
- package/dist/fax.js +50 -0
- package/dist/html-to-text.d.ts +1 -0
- package/dist/html-to-text.js +60 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/page.d.ts +11 -0
- package/dist/page.js +152 -0
- package/dist/reply-link.d.ts +4 -0
- package/dist/reply-link.js +33 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +250 -0
- package/dist/sqlite.d.ts +2 -0
- package/dist/sqlite.js +104 -0
- package/dist/store.d.ts +47 -0
- package/dist/store.js +39 -0
- package/dist/token.d.ts +2 -0
- package/dist/token.js +17 -0
- package/package.json +59 -11
- package/spec/fax.schema.json +202 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { bodyLimit } from 'hono/body-limit';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { renderAgentSection } from './agent-section.js';
|
|
5
|
+
import { QuestionSchema } from './fax.js';
|
|
6
|
+
import { htmlToText } from './html-to-text.js';
|
|
7
|
+
import { CLIENT_JS } from './client.js';
|
|
8
|
+
import { faxPage, inboxPage, problemPage, receiptPage, stamp } from './page.js';
|
|
9
|
+
import { hashToken, newToken } from './token.js';
|
|
10
|
+
const SPEC_URL = 'https://github.com/sptr11/agentfax/blob/main/SPEC.md';
|
|
11
|
+
const TOKEN = /^[A-Za-z0-9]{22}$/;
|
|
12
|
+
const MAX_ANSWER = 5000;
|
|
13
|
+
const MAX_REPLY_BODY = 64 * 1024;
|
|
14
|
+
const CreateFax = z
|
|
15
|
+
.object({
|
|
16
|
+
from: z.string().min(1).max(100),
|
|
17
|
+
title: z.string().min(1).max(200),
|
|
18
|
+
note: z.string().max(2000).optional(),
|
|
19
|
+
html: z.string().min(1).max(2_000_000).optional(),
|
|
20
|
+
text: z.string().min(1).max(100_000).optional(),
|
|
21
|
+
questions: z.array(QuestionSchema).max(20).default([]),
|
|
22
|
+
ack: z.boolean().optional(),
|
|
23
|
+
to: z.array(z.string().min(1).max(100)).min(1).max(50).optional(),
|
|
24
|
+
expiresInDays: z.number().int().min(1).max(365).default(30),
|
|
25
|
+
})
|
|
26
|
+
.superRefine((f, ctx) => {
|
|
27
|
+
if (!f.html && !f.text)
|
|
28
|
+
ctx.addIssue({ code: 'custom', path: ['html'], message: 'send html or text' });
|
|
29
|
+
const ids = f.questions.map((q) => q.id);
|
|
30
|
+
if (new Set(ids).size !== ids.length)
|
|
31
|
+
ctx.addIssue({ code: 'custom', path: ['questions'], message: 'question ids must be unique' });
|
|
32
|
+
});
|
|
33
|
+
const ReplyBody = z.object({ answers: z.record(z.string(), z.unknown()).default({}), message: z.string().optional(), ack: z.boolean().optional() });
|
|
34
|
+
// Sender content runs here. Opaque origin even when opened directly, no network, no forms, only framed by us.
|
|
35
|
+
const VIEW_CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; connect-src 'none'; form-action 'none'; frame-ancestors 'self'; base-uri 'none'";
|
|
36
|
+
// Appended to sender HTML in the view. Reports content height to the fax page, which sizes the frame.
|
|
37
|
+
// postMessage isn't a network call, so connect-src 'none' still holds.
|
|
38
|
+
const RESIZE = `<script>(function(){var last=0;function send(){var b=document.body;if(!b)return;var r=document.createRange();r.selectNodeContents(b);var cs=getComputedStyle(b);var h=Math.ceil(r.getBoundingClientRect().bottom+window.scrollY+parseFloat(cs.paddingBottom)+parseFloat(cs.marginBottom));if(Math.abs(h-last)<2)return;last=h;parent.postMessage({ type: 'agentfax:height', height: h }, '*')}addEventListener('load',send);if(window.ResizeObserver)new ResizeObserver(send).observe(document.body||document.documentElement);send()})()</script>`;
|
|
39
|
+
const PAGE_CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
40
|
+
const PRIVATE = { 'cache-control': 'no-store', 'x-robots-tag': 'noindex', 'referrer-policy': 'no-referrer' };
|
|
41
|
+
// The fax page can't use no-referrer: a plain form POST would then carry Origin: null, which the reply route rejects.
|
|
42
|
+
// same-origin still never sends the link to other sites.
|
|
43
|
+
const PAGE_HEADERS = { ...PRIVATE, 'referrer-policy': 'same-origin', 'content-security-policy': PAGE_CSP };
|
|
44
|
+
function safeEqual(a, b) {
|
|
45
|
+
if (a.length !== b.length)
|
|
46
|
+
return false;
|
|
47
|
+
let diff = 0;
|
|
48
|
+
for (let i = 0; i < a.length; i++)
|
|
49
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
50
|
+
return diff === 0;
|
|
51
|
+
}
|
|
52
|
+
function splitJson(raw) {
|
|
53
|
+
return raw.endsWith('.json') ? { token: raw.slice(0, -5), json: true } : { token: raw, json: false };
|
|
54
|
+
}
|
|
55
|
+
export function agentfax(opts) {
|
|
56
|
+
const { store } = opts;
|
|
57
|
+
const now = opts.now ?? (() => new Date());
|
|
58
|
+
const about = opts.about ?? SPEC_URL;
|
|
59
|
+
const authorize = opts.authorize ??
|
|
60
|
+
(async (req) => {
|
|
61
|
+
const auth = req.headers.get('authorization') ?? '';
|
|
62
|
+
return opts.senderKey && safeEqual(auth, `Bearer ${opts.senderKey}`) ? 'default' : null;
|
|
63
|
+
});
|
|
64
|
+
const recent = new Map();
|
|
65
|
+
const limits = { personal: opts.repliesPerMinute?.personal ?? 10, shared: opts.repliesPerMinute?.shared ?? 60 };
|
|
66
|
+
const publicUrl = () => (typeof opts.publicUrl === 'function' ? opts.publicUrl() : opts.publicUrl);
|
|
67
|
+
const base = (c) => (publicUrl() ?? new URL(c.req.url).origin).replace(/\/+$/, '');
|
|
68
|
+
const wantsJson = (c, suffix) => suffix || (c.req.header('accept') ?? '').includes('application/json');
|
|
69
|
+
const fail = (c, json, status, error, message, issues) => json ? c.json({ error, message, ...(issues ? { issues } : {}) }, status) : c.html(problemPage(message, message), status, PRIVATE);
|
|
70
|
+
const gone = (c, json, fax) => {
|
|
71
|
+
const { from, title } = fax.data;
|
|
72
|
+
if (json)
|
|
73
|
+
return c.json({ error: 'gone', message: fax.revokedAt ? 'The sender withdrew this fax.' : 'This fax has expired.' }, 410);
|
|
74
|
+
const [heading, when] = fax.revokedAt ? ['This fax was withdrawn', 'was withdrawn'] : ['This fax has expired', `was available until ${stamp(fax.expiresAt).split(' ').slice(0, 3).join(' ')}`];
|
|
75
|
+
return c.html(problemPage(heading, `${from}’s fax “${title}” ${when}. Ask ${from} to send it again if you still need it.`), 410, PRIVATE);
|
|
76
|
+
};
|
|
77
|
+
const isLive = (fax) => !fax.revokedAt && new Date(fax.expiresAt) > now();
|
|
78
|
+
function toFax(record, url, origin, recipient) {
|
|
79
|
+
const d = record.data;
|
|
80
|
+
const expectsReply = d.questions.length > 0 || d.ack;
|
|
81
|
+
return {
|
|
82
|
+
agentfax: '0.1',
|
|
83
|
+
title: d.title,
|
|
84
|
+
from: { name: d.from, origin },
|
|
85
|
+
...(recipient ? { to: recipient } : {}),
|
|
86
|
+
...(d.note ? { note: d.note } : {}),
|
|
87
|
+
content: { text: d.text, ...(d.html ? { view: `${url}/view` } : {}) },
|
|
88
|
+
questions: d.questions,
|
|
89
|
+
...(d.ack ? { ack: true } : {}),
|
|
90
|
+
...(expectsReply ? { reply: { link: `${url}#`, format: 'id=value pairs, URL-encoded, joined with &' } } : {}),
|
|
91
|
+
expires: record.expiresAt,
|
|
92
|
+
about,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function resolve(token) {
|
|
96
|
+
return TOKEN.test(token) ? store.findLink(await hashToken(token)) : null;
|
|
97
|
+
}
|
|
98
|
+
const app = new Hono();
|
|
99
|
+
app.get('/robots.txt', (c) => c.text('User-agent: *\nAllow: /\n'));
|
|
100
|
+
app.get('/assets/fax.js', (c) => c.body(CLIENT_JS, 200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=3600' }));
|
|
101
|
+
app.post('/api/faxes', async (c) => {
|
|
102
|
+
const senderId = await authorize(c.req.raw);
|
|
103
|
+
if (!senderId)
|
|
104
|
+
return fail(c, true, 401, 'unauthorized', 'Send your sender key as a Bearer token.');
|
|
105
|
+
const parsed = CreateFax.safeParse(await c.req.json().catch(() => null));
|
|
106
|
+
if (!parsed.success)
|
|
107
|
+
return fail(c, true, 422, 'invalid_fax', "The fax didn't validate.", parsed.error.issues);
|
|
108
|
+
const f = parsed.data;
|
|
109
|
+
const created = now();
|
|
110
|
+
const id = `fx_${newToken(16)}`;
|
|
111
|
+
const recipients = f.to ?? [null];
|
|
112
|
+
const tokens = recipients.map(() => newToken());
|
|
113
|
+
const inbox = newToken();
|
|
114
|
+
const record = {
|
|
115
|
+
id,
|
|
116
|
+
senderId,
|
|
117
|
+
data: { from: f.from, title: f.title, ...(f.note ? { note: f.note } : {}), ...(f.html ? { html: f.html } : {}), text: f.text ?? htmlToText(f.html), questions: f.questions, ack: f.ack ?? false },
|
|
118
|
+
createdAt: created.toISOString(),
|
|
119
|
+
expiresAt: new Date(created.getTime() + f.expiresInDays * 86_400_000).toISOString(),
|
|
120
|
+
revokedAt: null,
|
|
121
|
+
};
|
|
122
|
+
const links = await Promise.all(tokens.map(async (t, i) => ({ tokenHash: await hashToken(t), faxId: id, recipient: recipients[i] ?? null })));
|
|
123
|
+
await store.createFax(record, links, await hashToken(inbox));
|
|
124
|
+
const b = base(c);
|
|
125
|
+
return c.json({ id, links: tokens.map((t, i) => ({ to: recipients[i] ?? null, url: `${b}/f/${t}` })), inbox: `${b}/i/${inbox}`, expires: record.expiresAt }, 201);
|
|
126
|
+
});
|
|
127
|
+
app.delete('/api/faxes/:id', async (c) => {
|
|
128
|
+
const senderId = await authorize(c.req.raw);
|
|
129
|
+
if (!senderId)
|
|
130
|
+
return fail(c, true, 401, 'unauthorized', 'Send your sender key as a Bearer token.');
|
|
131
|
+
const ok = await store.revoke(c.req.param('id'), senderId, now().toISOString());
|
|
132
|
+
return ok ? c.body(null, 204) : fail(c, true, 404, 'not_found', 'No fax with that id.');
|
|
133
|
+
});
|
|
134
|
+
app.get('/f/:token/view', async (c) => {
|
|
135
|
+
const found = await resolve(c.req.param('token'));
|
|
136
|
+
if (!found || !isLive(found.fax) || !found.fax.data.html)
|
|
137
|
+
return c.text('Not found', 404);
|
|
138
|
+
return c.body(found.fax.data.html + RESIZE, 200, { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': VIEW_CSP, ...PRIVATE });
|
|
139
|
+
});
|
|
140
|
+
app.get('/f/:token', async (c) => {
|
|
141
|
+
const { token, json: suffix } = splitJson(c.req.param('token'));
|
|
142
|
+
const json = wantsJson(c, suffix);
|
|
143
|
+
const found = await resolve(token);
|
|
144
|
+
if (!found)
|
|
145
|
+
return fail(c, json, 404, 'not_found', 'No fax here. Check the link.');
|
|
146
|
+
if (!isLive(found.fax))
|
|
147
|
+
return gone(c, json, found.fax);
|
|
148
|
+
const b = base(c);
|
|
149
|
+
const url = `${b}/f/${token}`;
|
|
150
|
+
const fax = toFax(found.fax, url, b, found.link.recipient);
|
|
151
|
+
if (json)
|
|
152
|
+
return c.json(fax, 200, { ...PRIVATE, 'access-control-allow-origin': '*' });
|
|
153
|
+
return c.html(faxPage(fax, `/f/${token}`, url, renderAgentSection(fax, url), Boolean(found.fax.data.html), found.fax.createdAt), 200, PAGE_HEADERS);
|
|
154
|
+
});
|
|
155
|
+
app.post('/f/:token/reply', bodyLimit({ maxSize: MAX_REPLY_BODY, onError: (c) => c.json({ error: 'too_large', message: 'Replies are limited to 64 KB.' }, 413) }), async (c) => {
|
|
156
|
+
const token = c.req.param('token');
|
|
157
|
+
const json = !(c.req.header('accept') ?? '').includes('text/html');
|
|
158
|
+
const found = await resolve(token);
|
|
159
|
+
if (!found)
|
|
160
|
+
return fail(c, json, 404, 'not_found', 'No fax here. Check the link.');
|
|
161
|
+
if (c.req.header('origin') !== new URL(base(c)).origin)
|
|
162
|
+
return fail(c, json, 403, 'wrong_origin', 'Replies are sent from the fax page.');
|
|
163
|
+
const { fax, link } = found;
|
|
164
|
+
if (!isLive(fax))
|
|
165
|
+
return gone(c, json, fax);
|
|
166
|
+
const { questions, ack: takesAck } = fax.data;
|
|
167
|
+
// Every attempt counts, valid or not, so invalid spam isn't free.
|
|
168
|
+
const t = now().getTime();
|
|
169
|
+
const times = (recent.get(link.tokenHash) ?? []).filter((x) => t - x < 60_000);
|
|
170
|
+
if (times.length >= (link.recipient ? limits.personal : limits.shared))
|
|
171
|
+
return fail(c, json, 429, 'too_many_replies', 'Too many replies from this link. Try again in a minute.');
|
|
172
|
+
times.push(t);
|
|
173
|
+
recent.set(link.tokenHash, times);
|
|
174
|
+
let body;
|
|
175
|
+
if ((c.req.header('content-type') ?? '').includes('application/json')) {
|
|
176
|
+
const parsed = ReplyBody.safeParse(await c.req.json().catch(() => null));
|
|
177
|
+
if (!parsed.success)
|
|
178
|
+
return fail(c, json, 422, 'invalid_reply', 'Send { answers, ack }.');
|
|
179
|
+
body = parsed.data;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const form = await c.req.parseBody();
|
|
183
|
+
const { ack, message, ...answers } = form;
|
|
184
|
+
body = { answers, ...(typeof message === 'string' ? { message } : {}), ack: ack !== undefined };
|
|
185
|
+
}
|
|
186
|
+
const issues = [];
|
|
187
|
+
const answers = {};
|
|
188
|
+
const byId = new Map(questions.map((q) => [q.id, q]));
|
|
189
|
+
for (const [id, raw] of Object.entries(body.answers)) {
|
|
190
|
+
const q = byId.get(id);
|
|
191
|
+
if (!q) {
|
|
192
|
+
issues.push(`${id}: not a question on this fax`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (typeof raw !== 'string') {
|
|
196
|
+
issues.push(`${id}: must be text`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const v = raw.trim();
|
|
200
|
+
if (!v)
|
|
201
|
+
continue;
|
|
202
|
+
if (v.length > MAX_ANSWER)
|
|
203
|
+
issues.push(`${id}: over ${MAX_ANSWER} characters`);
|
|
204
|
+
else if (q.type === 'choice' && !q.options.includes(v))
|
|
205
|
+
issues.push(`${id}: must be one of ${q.options.join(', ')}`);
|
|
206
|
+
else if (q.type === 'number' && !Number.isFinite(Number(v)))
|
|
207
|
+
issues.push(`${id}: must be a number`);
|
|
208
|
+
else
|
|
209
|
+
answers[id] = v;
|
|
210
|
+
}
|
|
211
|
+
const message = (body.message ?? '').trim();
|
|
212
|
+
// A message in the reply box stands in for the answers, so required questions only apply without one.
|
|
213
|
+
if (!message)
|
|
214
|
+
for (const q of questions)
|
|
215
|
+
if (q.required && !answers[q.id] && !issues.some((i) => i.startsWith(`${q.id}:`)))
|
|
216
|
+
issues.push(`${q.id}: required`);
|
|
217
|
+
if (message.length > MAX_ANSWER)
|
|
218
|
+
issues.push(`message: over ${MAX_ANSWER} characters`);
|
|
219
|
+
const acked = Boolean(body.ack && takesAck);
|
|
220
|
+
if (!questions.length && !acked && !message) {
|
|
221
|
+
if (!takesAck)
|
|
222
|
+
return fail(c, json, 409, 'no_replies', 'Write a message to send.');
|
|
223
|
+
issues.push('ack or message: required');
|
|
224
|
+
}
|
|
225
|
+
if (issues.length) {
|
|
226
|
+
if (json)
|
|
227
|
+
return fail(c, json, 422, 'invalid_reply', 'Some answers need fixing.', issues);
|
|
228
|
+
return c.html(problemPage('Some answers need fixing', 'Nothing was sent. Go back, fix these, and send again.', { href: `/f/${token}`, label: '← Back to the fax' }, issues), 422, PRIVATE);
|
|
229
|
+
}
|
|
230
|
+
await store.saveReply({ id: `rp_${newToken(16)}`, faxId: fax.id, linkHash: link.tokenHash, recipient: link.recipient, answers, ...(message ? { message } : {}), ack: acked, createdAt: now().toISOString() }, link.recipient ? 'replace' : 'append');
|
|
231
|
+
if (json)
|
|
232
|
+
return c.json({ ok: true }, 201);
|
|
233
|
+
const b = base(c);
|
|
234
|
+
const view = toFax(fax, `${b}/f/${token}`, b, link.recipient);
|
|
235
|
+
return c.html(receiptPage(view, fax.createdAt, answers, message, acked, now().toISOString()), 201, PRIVATE);
|
|
236
|
+
});
|
|
237
|
+
app.get('/i/:token', async (c) => {
|
|
238
|
+
const { token, json: suffix } = splitJson(c.req.param('token'));
|
|
239
|
+
const json = wantsJson(c, suffix);
|
|
240
|
+
const found = TOKEN.test(token) ? await store.findInbox(await hashToken(token)) : null;
|
|
241
|
+
if (!found)
|
|
242
|
+
return fail(c, json, 404, 'not_found', 'No inbox here. Check the link.');
|
|
243
|
+
if (json) {
|
|
244
|
+
const replies = found.replies.map((r) => ({ to: r.recipient, answers: r.answers, ...(r.message ? { message: r.message } : {}), ack: r.ack, at: r.createdAt }));
|
|
245
|
+
return c.json({ title: found.fax.data.title, replies }, 200, PRIVATE);
|
|
246
|
+
}
|
|
247
|
+
return c.html(inboxPage(found.fax.data.title, found.replies), 200, PRIVATE);
|
|
248
|
+
});
|
|
249
|
+
return app;
|
|
250
|
+
}
|
package/dist/sqlite.d.ts
ADDED
package/dist/sqlite.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
const SCHEMA = `
|
|
3
|
+
CREATE TABLE IF NOT EXISTS faxes (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
sender_id TEXT NOT NULL,
|
|
6
|
+
data TEXT NOT NULL,
|
|
7
|
+
inbox_hash TEXT NOT NULL UNIQUE,
|
|
8
|
+
created_at TEXT NOT NULL,
|
|
9
|
+
expires_at TEXT NOT NULL,
|
|
10
|
+
revoked_at TEXT
|
|
11
|
+
);
|
|
12
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
13
|
+
token_hash TEXT PRIMARY KEY,
|
|
14
|
+
fax_id TEXT NOT NULL REFERENCES faxes(id),
|
|
15
|
+
recipient TEXT
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS replies (
|
|
18
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
id TEXT NOT NULL UNIQUE,
|
|
20
|
+
fax_id TEXT NOT NULL REFERENCES faxes(id),
|
|
21
|
+
link_hash TEXT NOT NULL,
|
|
22
|
+
recipient TEXT,
|
|
23
|
+
answers TEXT NOT NULL,
|
|
24
|
+
message TEXT,
|
|
25
|
+
ack INTEGER NOT NULL,
|
|
26
|
+
created_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS replies_fax ON replies(fax_id);
|
|
29
|
+
`;
|
|
30
|
+
const toFax = (r) => ({
|
|
31
|
+
id: r.id,
|
|
32
|
+
senderId: r.sender_id,
|
|
33
|
+
data: JSON.parse(r.data),
|
|
34
|
+
createdAt: r.created_at,
|
|
35
|
+
expiresAt: r.expires_at,
|
|
36
|
+
revokedAt: r.revoked_at,
|
|
37
|
+
});
|
|
38
|
+
export function sqliteStore(path) {
|
|
39
|
+
const db = new DatabaseSync(path);
|
|
40
|
+
db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;');
|
|
41
|
+
db.exec(SCHEMA);
|
|
42
|
+
// Databases created before replies had a message column.
|
|
43
|
+
if (!db.prepare('PRAGMA table_info(replies)').all().some((c) => c.name === 'message'))
|
|
44
|
+
db.exec('ALTER TABLE replies ADD COLUMN message TEXT');
|
|
45
|
+
const insertFax = db.prepare('INSERT INTO faxes (id, sender_id, data, inbox_hash, created_at, expires_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
46
|
+
const insertLink = db.prepare('INSERT INTO links (token_hash, fax_id, recipient) VALUES (?, ?, ?)');
|
|
47
|
+
const linkWithFax = db.prepare('SELECT l.token_hash, l.fax_id, l.recipient, f.* FROM links l JOIN faxes f ON f.id = l.fax_id WHERE l.token_hash = ?');
|
|
48
|
+
const faxByInbox = db.prepare('SELECT * FROM faxes WHERE inbox_hash = ?');
|
|
49
|
+
const repliesByFax = db.prepare('SELECT * FROM replies WHERE fax_id = ? ORDER BY seq');
|
|
50
|
+
const deleteRepliesForLink = db.prepare('DELETE FROM replies WHERE link_hash = ?');
|
|
51
|
+
const insertReply = db.prepare('INSERT INTO replies (id, fax_id, link_hash, recipient, answers, message, ack, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
|
52
|
+
const revokeFax = db.prepare('UPDATE faxes SET revoked_at = ? WHERE id = ? AND sender_id = ?');
|
|
53
|
+
const tx = (fn) => {
|
|
54
|
+
db.exec('BEGIN');
|
|
55
|
+
try {
|
|
56
|
+
fn();
|
|
57
|
+
db.exec('COMMIT');
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
db.exec('ROLLBACK');
|
|
61
|
+
throw e;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
async createFax(fax, links, inboxHash) {
|
|
66
|
+
tx(() => {
|
|
67
|
+
insertFax.run(fax.id, fax.senderId, JSON.stringify(fax.data), inboxHash, fax.createdAt, fax.expiresAt, fax.revokedAt);
|
|
68
|
+
for (const l of links)
|
|
69
|
+
insertLink.run(l.tokenHash, l.faxId, l.recipient);
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
async findLink(tokenHash) {
|
|
73
|
+
const r = linkWithFax.get(tokenHash);
|
|
74
|
+
return r ? { fax: toFax(r), link: { tokenHash: r.token_hash, faxId: r.fax_id, recipient: r.recipient } } : null;
|
|
75
|
+
},
|
|
76
|
+
async findInbox(inboxHash) {
|
|
77
|
+
const f = faxByInbox.get(inboxHash);
|
|
78
|
+
if (!f)
|
|
79
|
+
return null;
|
|
80
|
+
const rows = repliesByFax.all(f.id);
|
|
81
|
+
const replies = rows.map((r) => ({
|
|
82
|
+
id: r.id,
|
|
83
|
+
faxId: r.fax_id,
|
|
84
|
+
linkHash: r.link_hash,
|
|
85
|
+
recipient: r.recipient,
|
|
86
|
+
answers: JSON.parse(r.answers),
|
|
87
|
+
...(r.message ? { message: r.message } : {}),
|
|
88
|
+
ack: r.ack === 1,
|
|
89
|
+
createdAt: r.created_at,
|
|
90
|
+
}));
|
|
91
|
+
return { fax: toFax(f), replies };
|
|
92
|
+
},
|
|
93
|
+
async saveReply(reply, mode) {
|
|
94
|
+
tx(() => {
|
|
95
|
+
if (mode === 'replace')
|
|
96
|
+
deleteRepliesForLink.run(reply.linkHash);
|
|
97
|
+
insertReply.run(reply.id, reply.faxId, reply.linkHash, reply.recipient, JSON.stringify(reply.answers), reply.message ?? null, reply.ack ? 1 : 0, reply.createdAt);
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
async revoke(faxId, senderId, at) {
|
|
101
|
+
return Number(revokeFax.run(at, faxId, senderId).changes) > 0;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Question } from './fax.js';
|
|
2
|
+
export type StoredFax = {
|
|
3
|
+
from: string;
|
|
4
|
+
title: string;
|
|
5
|
+
note?: string;
|
|
6
|
+
html?: string;
|
|
7
|
+
text: string;
|
|
8
|
+
questions: Question[];
|
|
9
|
+
ack: boolean;
|
|
10
|
+
};
|
|
11
|
+
export type FaxRecord = {
|
|
12
|
+
id: string;
|
|
13
|
+
senderId: string;
|
|
14
|
+
data: StoredFax;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
revokedAt: string | null;
|
|
18
|
+
};
|
|
19
|
+
export type LinkRecord = {
|
|
20
|
+
tokenHash: string;
|
|
21
|
+
faxId: string;
|
|
22
|
+
recipient: string | null;
|
|
23
|
+
};
|
|
24
|
+
export type ReplyRecord = {
|
|
25
|
+
id: string;
|
|
26
|
+
faxId: string;
|
|
27
|
+
linkHash: string;
|
|
28
|
+
recipient: string | null;
|
|
29
|
+
answers: Record<string, string>;
|
|
30
|
+
message?: string;
|
|
31
|
+
ack: boolean;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
};
|
|
34
|
+
export interface Store {
|
|
35
|
+
createFax(fax: FaxRecord, links: LinkRecord[], inboxHash: string): Promise<void>;
|
|
36
|
+
findLink(tokenHash: string): Promise<{
|
|
37
|
+
fax: FaxRecord;
|
|
38
|
+
link: LinkRecord;
|
|
39
|
+
} | null>;
|
|
40
|
+
findInbox(inboxHash: string): Promise<{
|
|
41
|
+
fax: FaxRecord;
|
|
42
|
+
replies: ReplyRecord[];
|
|
43
|
+
} | null>;
|
|
44
|
+
saveReply(reply: ReplyRecord, mode: 'replace' | 'append'): Promise<void>;
|
|
45
|
+
revoke(faxId: string, senderId: string, at: string): Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
export declare function memoryStore(): Store;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function memoryStore() {
|
|
2
|
+
const faxes = new Map();
|
|
3
|
+
const links = new Map();
|
|
4
|
+
const inboxes = new Map();
|
|
5
|
+
const replies = [];
|
|
6
|
+
const copy = (x) => structuredClone(x);
|
|
7
|
+
return {
|
|
8
|
+
async createFax(fax, newLinks, inboxHash) {
|
|
9
|
+
faxes.set(fax.id, copy(fax));
|
|
10
|
+
for (const l of newLinks)
|
|
11
|
+
links.set(l.tokenHash, copy(l));
|
|
12
|
+
inboxes.set(inboxHash, fax.id);
|
|
13
|
+
},
|
|
14
|
+
async findLink(tokenHash) {
|
|
15
|
+
const link = links.get(tokenHash);
|
|
16
|
+
const fax = link && faxes.get(link.faxId);
|
|
17
|
+
return link && fax ? { fax: copy(fax), link: copy(link) } : null;
|
|
18
|
+
},
|
|
19
|
+
async findInbox(inboxHash) {
|
|
20
|
+
const fax = faxes.get(inboxes.get(inboxHash) ?? '');
|
|
21
|
+
return fax ? { fax: copy(fax), replies: copy(replies.filter((r) => r.faxId === fax.id)) } : null;
|
|
22
|
+
},
|
|
23
|
+
async saveReply(reply, mode) {
|
|
24
|
+
if (mode === 'replace') {
|
|
25
|
+
const i = replies.findIndex((r) => r.linkHash === reply.linkHash);
|
|
26
|
+
if (i >= 0)
|
|
27
|
+
replies.splice(i, 1);
|
|
28
|
+
}
|
|
29
|
+
replies.push(copy(reply));
|
|
30
|
+
},
|
|
31
|
+
async revoke(faxId, senderId, at) {
|
|
32
|
+
const fax = faxes.get(faxId);
|
|
33
|
+
if (!fax || fax.senderId !== senderId)
|
|
34
|
+
return false;
|
|
35
|
+
fax.revokedAt = at;
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
package/dist/token.d.ts
ADDED
package/dist/token.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
2
|
+
// 22 base62 characters ≈ 131 bits. Rejection sampling keeps every character uniform.
|
|
3
|
+
export function newToken(length = 22) {
|
|
4
|
+
let out = '';
|
|
5
|
+
while (out.length < length) {
|
|
6
|
+
for (const b of crypto.getRandomValues(new Uint8Array(length * 2))) {
|
|
7
|
+
if (b < 248 && out.length < length)
|
|
8
|
+
out += ALPHABET[b % 62];
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
// Tokens are stored only as hashes, so a leaked database doesn't leak working links.
|
|
14
|
+
export async function hashToken(token) {
|
|
15
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
|
|
16
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
17
|
+
}
|
package/package.json
CHANGED
|
@@ -1,24 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentfax",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "The fax machine, rebuilt for agents.
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The fax machine, rebuilt for agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
7
7
|
"ai",
|
|
8
8
|
"handoff",
|
|
9
|
-
"protocol"
|
|
9
|
+
"protocol",
|
|
10
|
+
"mcp",
|
|
11
|
+
"llm"
|
|
10
12
|
],
|
|
13
|
+
"homepage": "https://github.com/sptr11/agentfax#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/sptr11/agentfax/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/sptr11/agentfax.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.13"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"agentfax": "./dist/bin.js"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./hono": {
|
|
35
|
+
"types": "./dist/server.d.ts",
|
|
36
|
+
"default": "./dist/server.js"
|
|
37
|
+
},
|
|
38
|
+
"./sqlite": {
|
|
39
|
+
"types": "./dist/sqlite.d.ts",
|
|
40
|
+
"default": "./dist/sqlite.js"
|
|
41
|
+
},
|
|
42
|
+
"./schema.json": "./spec/fax.schema.json"
|
|
43
|
+
},
|
|
11
44
|
"files": [
|
|
45
|
+
"dist",
|
|
46
|
+
"spec",
|
|
47
|
+
"SPEC.md",
|
|
12
48
|
"README.md",
|
|
13
49
|
"LICENSE"
|
|
14
50
|
],
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
"
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"schema": "tsx scripts/emit-schema.ts",
|
|
55
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/bin.js",
|
|
56
|
+
"e2e": "playwright test",
|
|
57
|
+
"prepack": "npm run build"
|
|
19
58
|
},
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
"
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@hono/node-server": "^2.1.5",
|
|
61
|
+
"hono": "^4.13.13",
|
|
62
|
+
"zod": "^4.6.7"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@playwright/test": "^1.64.1",
|
|
66
|
+
"@types/node": "^26.6.5",
|
|
67
|
+
"ajv": "^8.20.0",
|
|
68
|
+
"tsx": "^4.23.15",
|
|
69
|
+
"typescript": "^7.0.2",
|
|
70
|
+
"vitest": "^5.0.5"
|
|
23
71
|
}
|
|
24
|
-
}
|
|
72
|
+
}
|