your-attention 0.1.0 → 0.1.1
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 +2 -0
- package/bin/your-attention.js +6 -2
- package/dist/src/adapters/files.js +66 -0
- package/dist/src/adapters/mdns.js +53 -0
- package/dist/src/adapters/mermaid.js +50 -0
- package/dist/src/adapters/openui-body.js +40 -0
- package/dist/src/adapters/openui-library.js +3 -0
- package/dist/src/adapters/sqlite.js +68 -0
- package/dist/src/cli.js +814 -0
- package/dist/src/client.js +459 -0
- package/dist/src/cloudflare/collector.js +53 -0
- package/dist/src/cloudflare/inbox-object.js +338 -0
- package/dist/src/cloudflare/storage.js +151 -0
- package/dist/src/cloudflare/worker.js +189 -0
- package/dist/src/core/crypto.js +23 -0
- package/dist/src/core/diagrams.js +225 -0
- package/dist/src/core/expr.js +125 -0
- package/dist/src/core/file-links.js +48 -0
- package/dist/src/core/hooks.js +74 -0
- package/dist/src/core/http.js +651 -0
- package/dist/src/core/inbox.js +2463 -0
- package/dist/src/core/pin.js +234 -0
- package/dist/src/core/sealed.js +167 -0
- package/dist/src/core/session-link.js +13 -0
- package/dist/src/core/socket.js +88 -0
- package/dist/src/core/storage.js +102 -0
- package/dist/src/core/summary.js +149 -0
- package/dist/src/core/telemetry.js +221 -0
- package/dist/src/core/types.js +470 -0
- package/dist/src/core/version.js +2 -0
- package/dist/src/core/widgets.js +128 -0
- package/dist/src/extensions/triage.js +232 -0
- package/dist/src/integrations/claude/ask-hook.js +84 -0
- package/dist/src/integrations/claude/channel-flag.js +102 -0
- package/dist/src/integrations/claude/channel.js +276 -0
- package/dist/src/integrations/claude/session-name.js +32 -0
- package/dist/src/integrations/claude/turn-end-hook.js +84 -0
- package/dist/src/integrations/herdr/approve.js +71 -0
- package/dist/src/integrations/herdr/blocked.js +210 -0
- package/dist/src/integrations/herdr/dialog.js +64 -0
- package/dist/src/integrations/herdr/focus.js +31 -0
- package/dist/src/integrations/herdr/place.js +22 -0
- package/dist/src/integrations/images.js +43 -0
- package/dist/src/integrations/opencode/install.js +24 -0
- package/dist/src/integrations/opencode/plugin.js +157 -0
- package/dist/src/integrations/pi/extension.js +166 -0
- package/dist/src/integrations/run-tool.js +157 -0
- package/dist/src/integrations/secrets.js +63 -0
- package/dist/src/integrations/tags.js +27 -0
- package/dist/src/integrations/tools.js +147 -0
- package/dist/src/node/home.js +20 -0
- package/dist/src/node/install.js +225 -0
- package/dist/src/node/links.js +249 -0
- package/dist/src/node/pin-prompt.js +125 -0
- package/dist/src/node/server.js +320 -0
- package/dist/src/node/service.js +74 -0
- package/dist/src/node/telemetry.js +127 -0
- package/dist/src/owner-watch.js +49 -0
- package/dist/src/tui/api.js +203 -0
- package/dist/src/tui/app.js +586 -0
- package/dist/src/tui/body.js +727 -0
- package/dist/src/tui/cardview.js +453 -0
- package/dist/src/tui/doc.js +157 -0
- package/dist/src/tui/edit.js +119 -0
- package/dist/src/tui/input.js +192 -0
- package/dist/src/tui/md.js +196 -0
- package/dist/src/tui/screen.js +225 -0
- package/dist/src/tui/terminal.js +130 -0
- package/dist/src/tui/text.js +103 -0
- package/dist/src/tui/theme.js +105 -0
- package/dist/src/tui/view.js +314 -0
- package/dist/src/watch.js +104 -0
- package/dist/web/age.js +16 -0
- package/dist/web/answers.js +485 -0
- package/dist/web/app.js +739 -0
- package/dist/web/body-faults.js +69 -0
- package/dist/web/components.js +923 -0
- package/dist/web/copy.js +118 -0
- package/dist/web/details.js +59 -0
- package/dist/web/env.js +97 -0
- package/dist/web/favicon-waiting.svg +1 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +16 -0
- package/dist/web/library-spec.js +33 -0
- package/dist/web/markdown.js +142 -0
- package/dist/web/paste-images.js +193 -0
- package/dist/web/pin-key.js +125 -0
- package/dist/web/pin.js +126 -0
- package/dist/web/queue.js +97 -0
- package/dist/web/sealed.js +13 -0
- package/dist/web/search.js +70 -0
- package/dist/web/stories/cards.stories.js +144 -0
- package/dist/web/stories/page.stories.js +54 -0
- package/dist/web/stories/render.js +16 -0
- package/dist/web/style.css +417 -0
- package/package.json +4 -2
- package/src/core/version.ts +1 -1
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// The HTTP protocol, with fetch Request and Response only, so the same handler
|
|
2
|
+
// runs on Node, Bun, Deno and Workers. SPEC.md lists each route.
|
|
3
|
+
import { randomId } from './crypto.js';
|
|
4
|
+
import { fileLinkPaths, folderFilePath } from './file-links.js';
|
|
5
|
+
import { parseSessionLink } from './session-link.js';
|
|
6
|
+
|
|
7
|
+
import { Inbox, InboxError, MAX_ATTACHMENT_BYTES, attachmentType, publicCard, } from './inbox.js';
|
|
8
|
+
import { UNLOCK_HEADER, UNLOCK_IDLE_MS, pinKeyError, validKey, } from './pin.js';
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
import { PROTOCOL_VERSION } from './types.js';
|
|
12
|
+
|
|
13
|
+
export const COOKIE = 'ya_token';
|
|
14
|
+
/** Cookie-authenticated writes must carry this header, which a cross-site form cannot set. */
|
|
15
|
+
export const CSRF_HEADER = 'x-your-attention';
|
|
16
|
+
/** Marks a reply the message store must not keep, such as one with a secret the inbox returns once. */
|
|
17
|
+
const ONCE_HEADER = 'x-your-attention-once';
|
|
18
|
+
/** A client-chosen id that makes a write idempotent across links and retries. */
|
|
19
|
+
export const MESSAGE_HEADER = 'x-message-id';
|
|
20
|
+
/** File types the page shows in place. Any other file is served as a download. */
|
|
21
|
+
const INLINE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'application/json', 'text/html']);
|
|
22
|
+
/** The type of a local file, by its extension. Text that a browser would download, such as Markdown and CSV, shows as plain text. */
|
|
23
|
+
const LOCAL_TYPES = {
|
|
24
|
+
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
|
|
25
|
+
md: 'text/plain; charset=utf-8', markdown: 'text/plain; charset=utf-8', csv: 'text/plain; charset=utf-8', json: 'application/json; charset=utf-8',
|
|
26
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
|
|
27
|
+
css: 'text/css; charset=utf-8', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
|
|
28
|
+
};
|
|
29
|
+
/** The policy of a served file. A folder ticket's page also draws the images and fonts it embeds, and the files of its folder. */
|
|
30
|
+
const FILE_POLICY = "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'";
|
|
31
|
+
const FOLDER_POLICY = "default-src 'none'; img-src 'self' data: blob:; font-src 'self' data: blob:; style-src 'self' 'unsafe-inline'";
|
|
32
|
+
|
|
33
|
+
/** The headers of a served file, shown in place or downloaded. Nothing served this way runs scripts. */
|
|
34
|
+
function fileHeaders(type , inline , name , cache , policy = FILE_POLICY) {
|
|
35
|
+
return {
|
|
36
|
+
'content-type': type,
|
|
37
|
+
'content-disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
38
|
+
// Browsers refuse to show a PDF in a sandboxed document, so a PDF gets the other limits without sandbox.
|
|
39
|
+
'content-security-policy': `${type === 'application/pdf' ? '' : 'sandbox; '}${policy}`,
|
|
40
|
+
'x-content-type-options': 'nosniff',
|
|
41
|
+
'cache-control': cache,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A folder ticket: the address of a file a card names, and with an HTML file, the files in its folder and below it. */
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
/** The largest body each kind of route reads. A card post carries its files as data: URLs. */
|
|
78
|
+
const BODY_LIMIT = { open: 4096, card: 16_000_000, other: 1_000_000 };
|
|
79
|
+
const KEY_HELP = 'key must be the PIN derived with PBKDF2-SHA256 and the salt and iterations from GET /v1/pin, 32 bytes in lowercase hex';
|
|
80
|
+
|
|
81
|
+
const json = (data , status = 200, headers = {}) =>
|
|
82
|
+
new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers } });
|
|
83
|
+
|
|
84
|
+
/** Read a JSON body of at most `max` bytes. A body that says or turns out to be larger is refused before it is all in memory. */
|
|
85
|
+
async function readJson (req , max = BODY_LIMIT.other) {
|
|
86
|
+
const tooLarge = () => new InboxError(413, `body over ${max} bytes`);
|
|
87
|
+
if (Number(req.headers.get('content-length') ?? 0) > max) throw tooLarge();
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
if (req.body) {
|
|
91
|
+
const reader = req.body.getReader();
|
|
92
|
+
for (;;) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done) break;
|
|
95
|
+
size += value.byteLength;
|
|
96
|
+
if (size > max) { await reader.cancel(); throw tooLarge(); }
|
|
97
|
+
chunks.push(value);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const bytes = new Uint8Array(size);
|
|
101
|
+
let at = 0;
|
|
102
|
+
for (const c of chunks) { bytes.set(c, at); at += c.byteLength; }
|
|
103
|
+
const text = new TextDecoder().decode(bytes);
|
|
104
|
+
try {
|
|
105
|
+
return (text ? JSON.parse(text) : {}) ;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new InboxError(400, 'body is not JSON');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cookie(req , name ) {
|
|
112
|
+
for (const part of (req.headers.get('cookie') ?? '').split(';')) {
|
|
113
|
+
const [k, ...v] = part.trim().split('=');
|
|
114
|
+
if (k === name) return decodeURIComponent(v.join('='));
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A server-sent event stream that calls `stop` when the client goes away. `end` closes it from the inbox side. */
|
|
120
|
+
function eventStream(start , heartbeatMs ) {
|
|
121
|
+
let stop = () => {};
|
|
122
|
+
let beat ;
|
|
123
|
+
const enc = new TextEncoder();
|
|
124
|
+
const body = new ReadableStream ({
|
|
125
|
+
start(controller) {
|
|
126
|
+
const send = (event , data ) => {
|
|
127
|
+
try {
|
|
128
|
+
controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
129
|
+
} catch {
|
|
130
|
+
stop();
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const end = () => {
|
|
134
|
+
if (beat !== undefined) clearInterval(beat);
|
|
135
|
+
stop();
|
|
136
|
+
try { controller.close(); } catch {}
|
|
137
|
+
};
|
|
138
|
+
stop = start(send, end);
|
|
139
|
+
beat = setInterval(() => send('beat', { type: 'beat', at: Date.now() }), heartbeatMs);
|
|
140
|
+
},
|
|
141
|
+
cancel() {
|
|
142
|
+
if (beat !== undefined) clearInterval(beat);
|
|
143
|
+
stop();
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
return new Response(body, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' } });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function createHandler(inbox , options = {}) {
|
|
150
|
+
const heartbeatMs = options.heartbeatMs ?? 2000;
|
|
151
|
+
/** Whether this inbox serves the files a card names: it has a file system, and the card came from this machine. */
|
|
152
|
+
const servesFiles = (card ) => !!options.localFiles && card.origin?.host === options.localFiles.host;
|
|
153
|
+
/** The folder tickets the inbox gave out. A ticket in memory ends when the inbox stops, and the page asks for a new one. */
|
|
154
|
+
const tickets = new Map ();
|
|
155
|
+
|
|
156
|
+
/** Whether the card's text or recommendation contains a file: address whose decoded path is `path`. */
|
|
157
|
+
const names = (card , path ) => [card.text, card.recommendation].some((t) => fileLinkPaths(t ?? '').includes(path));
|
|
158
|
+
|
|
159
|
+
/** Whether a ticket still opens files: its card is in the inbox, not cleared, and still names the file, its token is not
|
|
160
|
+
* signed out, and with a PIN, the unlock it was asked under is live. A ticket from before the PIN has no unlock, so it ends. */
|
|
161
|
+
function ticketLive(t ) {
|
|
162
|
+
let card ;
|
|
163
|
+
try {
|
|
164
|
+
card = inbox.getCard(t.card);
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
if (card.archivedAt || !names(card, t.file) || !inbox.hasToken(t.token)) return false;
|
|
169
|
+
return !t.gated || !inbox.pin.isSet() || inbox.pin.live(t.unlock);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The folder ticket for a file the card names, the one this caller already has or a new one. */
|
|
173
|
+
function folderTicket(card , file , me , gate ) {
|
|
174
|
+
for (const [id, t] of tickets) if (!ticketLive(t)) tickets.delete(id);
|
|
175
|
+
for (const [id, t] of tickets) {
|
|
176
|
+
if (t.card === card && t.file === file && t.token === me.token && t.gated === gate.gated && t.unlock === gate.unlock) return { ...t, id };
|
|
177
|
+
}
|
|
178
|
+
const name = file.split('/').pop() ?? '';
|
|
179
|
+
const folder = /\.html?$/i.test(name) ? file.slice(0, file.length - name.length - 1) || '/' : undefined;
|
|
180
|
+
const t = { card, file, name, folder, token: me.token, gated: gate.gated, unlock: gate.unlock };
|
|
181
|
+
const id = randomId(24);
|
|
182
|
+
tickets.set(id, t);
|
|
183
|
+
return { ...t, id };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** GET /v1/files/<ticket>/<path>: the file a ticket opens. The ticket is the credential, since a sandboxed page has an
|
|
187
|
+
* opaque origin and loads its images and fonts without the cookie. */
|
|
188
|
+
async function ticketFile(pathname ) {
|
|
189
|
+
const local = options.localFiles;
|
|
190
|
+
if (!local) return json({ error: 'this inbox serves no local files' }, 404);
|
|
191
|
+
const [id = '', ...segments] = pathname.split('/').slice(3);
|
|
192
|
+
const t = tickets.get(id);
|
|
193
|
+
if (!t || !ticketLive(t)) {
|
|
194
|
+
tickets.delete(id);
|
|
195
|
+
throw new InboxError(403, 'this file link has ended: open the file again from its card');
|
|
196
|
+
}
|
|
197
|
+
const rest = segments.join('/');
|
|
198
|
+
let named ;
|
|
199
|
+
try {
|
|
200
|
+
named = decodeURIComponent(rest);
|
|
201
|
+
} catch {}
|
|
202
|
+
// The file the card names opens by its own path. Every other file must be in the folder of an HTML file.
|
|
203
|
+
const path = named === t.name ? t.file : t.folder !== undefined ? folderFilePath(t.folder, rest) : undefined;
|
|
204
|
+
if (path === undefined) throw new InboxError(403, t.folder === undefined ? 'this link opens one file only' : 'this address leaves the folder of the file the card names');
|
|
205
|
+
const file = await local.file(path, path === t.file ? undefined : t.folder);
|
|
206
|
+
if (!file) throw new InboxError(404, 'no such file');
|
|
207
|
+
if (file.size > MAX_ATTACHMENT_BYTES) throw new InboxError(413, `the file is ${file.size} bytes, over the limit of ${MAX_ATTACHMENT_BYTES}`);
|
|
208
|
+
const bytes = await file.read();
|
|
209
|
+
// A file can grow between the size check and the read.
|
|
210
|
+
if (bytes.byteLength > MAX_ATTACHMENT_BYTES) throw new InboxError(413, `the file is over the limit of ${MAX_ATTACHMENT_BYTES} bytes`);
|
|
211
|
+
const base = path.split('/').pop() ?? '';
|
|
212
|
+
const type = LOCAL_TYPES[/\.([^.]+)$/.exec(base)?.[1]?.toLowerCase() ?? ''];
|
|
213
|
+
const headers = fileHeaders(type ?? 'application/octet-stream', !!type, base, 'no-store', FOLDER_POLICY);
|
|
214
|
+
// The address carries the ticket, so a link the page follows must not send it on.
|
|
215
|
+
headers['referrer-policy'] = 'no-referrer';
|
|
216
|
+
// A font loads in CORS mode, and the page's opaque origin is never the inbox's.
|
|
217
|
+
if (type?.startsWith('font/')) headers['access-control-allow-origin'] = '*';
|
|
218
|
+
return new Response(bytes, { headers });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The file a card names at `name`, or the refusal. The agent that posted the card chose what it exposes: the inbox
|
|
222
|
+
* serves a file only when the card came from this machine and its text or recommendation names that exact path. */
|
|
223
|
+
async function cardFile(card , name ) {
|
|
224
|
+
const local = options.localFiles;
|
|
225
|
+
if (!local) throw new InboxError(404, 'this inbox serves no local files');
|
|
226
|
+
if (!servesFiles(card)) throw new InboxError(403, 'this card came from another machine');
|
|
227
|
+
if (!names(card, name)) throw new InboxError(403, 'the card does not name this file');
|
|
228
|
+
const file = await local.file(name);
|
|
229
|
+
if (!file) throw new InboxError(404, 'no such file');
|
|
230
|
+
if (file.size > MAX_ATTACHMENT_BYTES) throw new InboxError(413, `the file is ${file.size} bytes, over the limit of ${MAX_ATTACHMENT_BYTES}`);
|
|
231
|
+
return file;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function caller(req ) {
|
|
235
|
+
const auth = req.headers.get('authorization');
|
|
236
|
+
if (auth?.startsWith('Bearer ')) {
|
|
237
|
+
const token = await inbox.authenticate(auth.slice(7).trim());
|
|
238
|
+
return token ? { token, viaCookie: false } : undefined;
|
|
239
|
+
}
|
|
240
|
+
const token = await inbox.authenticate(cookie(req, COOKIE));
|
|
241
|
+
return token ? { token, viaCookie: true } : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function route(req ) {
|
|
245
|
+
const url = new URL(req.url);
|
|
246
|
+
if (req.method === 'GET' && url.pathname.startsWith('/v1/files/')) return ticketFile(url.pathname);
|
|
247
|
+
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
248
|
+
if (parts[0] !== 'v1') return json({ error: 'not found' }, 404);
|
|
249
|
+
const [, a, b, c] = parts;
|
|
250
|
+
const method = req.method;
|
|
251
|
+
|
|
252
|
+
// Routes open to any client on the network.
|
|
253
|
+
// `checks` says what the inbox checks itself. A posting client checks the rest before it posts.
|
|
254
|
+
if (method === 'GET' && a === 'info') return json({ name: 'your-attention', protocol: PROTOCOL_VERSION, id: inbox.id(), pair: '/v1/pair', checks: inbox.checks() });
|
|
255
|
+
if (method === 'POST' && a === 'pair' && !b) {
|
|
256
|
+
if (inbox.storageFull()) return json({ error: 'this inbox is full' }, 507);
|
|
257
|
+
const body = await readJson (req, BODY_LIMIT.open);
|
|
258
|
+
const p = inbox.requestPairing(String(body.name ?? ''), body.role ?? 'agent');
|
|
259
|
+
return json({ id: p.id, code: p.code }, 201);
|
|
260
|
+
}
|
|
261
|
+
if (method === 'GET' && a === 'pair' && b) return json(inbox.collectPairing(b));
|
|
262
|
+
if (method === 'POST' && a === 'login') {
|
|
263
|
+
const body = await readJson (req, BODY_LIMIT.open);
|
|
264
|
+
// A sign-in code from the phone dialog becomes the device's own owner token.
|
|
265
|
+
const token = body.code !== undefined ? (await inbox.redeemSignInCode(body.code)).secret : body.token;
|
|
266
|
+
const t = await inbox.authenticate(token);
|
|
267
|
+
if (!t || t.role !== 'owner') return json({ error: 'not an owner token' }, 401);
|
|
268
|
+
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
269
|
+
return json({ ok: true }, 200, { 'set-cookie': `${COOKIE}=${encodeURIComponent(String(token))}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000${secure}` });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const who = await caller(req);
|
|
273
|
+
if (!who) return json({ error: 'pair first: POST /v1/pair' }, 401);
|
|
274
|
+
if (who.viaCookie && method !== 'GET' && req.headers.get(CSRF_HEADER) !== '1') return json({ error: `missing ${CSRF_HEADER} header` }, 403);
|
|
275
|
+
// An EventSource cannot send headers, so the page's event stream carries its unlock token in the address.
|
|
276
|
+
const unlockSecret = req.headers.get(UNLOCK_HEADER) ?? (method === 'GET' && a === 'events' ? url.searchParams.get('unlock') : null);
|
|
277
|
+
const gate = await inbox.pinGate(who.token, who.viaCookie, unlockSecret);
|
|
278
|
+
// A locked caller may read the PIN state, unlock, and fetch a file by its id, which only an unlocked page can list.
|
|
279
|
+
const pinRoute = a === 'pin' || a === 'unlock' || a === 'lock';
|
|
280
|
+
const fileRoute = method === 'GET' && a === 'attachments';
|
|
281
|
+
if (gate.locked && !pinRoute && !fileRoute) return json({ error: 'this inbox is locked: type its PIN to open it', locked: true }, 423);
|
|
282
|
+
const me = { token: who.token.id, role: who.token.role };
|
|
283
|
+
// A full inbox takes the writes that free space or answer cards, and the reply route, which writes nothing, and refuses the rest.
|
|
284
|
+
const frees = method === 'DELETE' || pinRoute || (method === 'POST' && (a === 'reset' || a === 'archive' || a === 'logout' || a === 'person' || c === 'close' || c === 'dismiss' || c === 'answer' || c === 'reply' || c === 'file-ticket'));
|
|
285
|
+
if (method !== 'GET' && !frees && inbox.storageFull()) return json({ error: 'this inbox is full: remove cards, templates or widgets to make room' }, 507);
|
|
286
|
+
// A message that arrives over two links, or again after a retry, is applied once: the inbox
|
|
287
|
+
// keeps the first result for each x-message-id, per client.
|
|
288
|
+
// A PIN response carries an unlock token, and a file ticket works once, so the inbox never keeps either to replay.
|
|
289
|
+
const messageId = method === 'GET' || pinRoute || c === 'file-ticket' ? null : req.headers.get(MESSAGE_HEADER);
|
|
290
|
+
if (messageId !== null) {
|
|
291
|
+
if (!/^[\w-]{8,64}$/.test(messageId)) return json({ error: `${MESSAGE_HEADER} must be 8 to 64 letters, digits, dashes or underscores` }, 400);
|
|
292
|
+
const key = `${me.token}:${messageId}`;
|
|
293
|
+
const seen = inbox.recallMessage(key);
|
|
294
|
+
if (seen?.status === 0) return json({ error: 'a message with this id is still being applied; send it again shortly' }, 409);
|
|
295
|
+
if (seen) return new Response(seen.body, { status: seen.status, headers: { 'content-type': 'application/json', [`${MESSAGE_HEADER}-replayed`]: '1' } });
|
|
296
|
+
// Reserve the id before the await, so the same message arriving over another link at once waits.
|
|
297
|
+
inbox.rememberMessage(key, 0, '');
|
|
298
|
+
let res ;
|
|
299
|
+
try {
|
|
300
|
+
res = await routeAuthed(req, url, me, gate);
|
|
301
|
+
} catch (e) {
|
|
302
|
+
inbox.forgetMessage(key);
|
|
303
|
+
throw e;
|
|
304
|
+
}
|
|
305
|
+
// A reply that carries a secret shown once is never kept to replay. A retry of that request makes a new subscription.
|
|
306
|
+
if (res.status < 500 && !res.headers.has(ONCE_HEADER) && (res.headers.get('content-type') ?? '').startsWith('application/json')) inbox.rememberMessage(key, res.status, await res.clone().text());
|
|
307
|
+
else inbox.forgetMessage(key);
|
|
308
|
+
return res;
|
|
309
|
+
}
|
|
310
|
+
if (pinRoute) {
|
|
311
|
+
if (me.role !== 'owner') return json({ error: 'only the owner can do this' }, 403);
|
|
312
|
+
return pinRoutes(req, url, me, gate, unlockSecret);
|
|
313
|
+
}
|
|
314
|
+
return routeAuthed(req, url, me, gate);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** A wrong PIN, or a lockout and how long it lasts. */
|
|
318
|
+
function refused(r ) {
|
|
319
|
+
if ('triesLeft' in r) return json({ error: `wrong PIN: ${r.triesLeft} ${r.triesLeft === 1 ? 'try' : 'tries'} left`, triesLeft: r.triesLeft }, 401);
|
|
320
|
+
const seconds = Math.max(1, Math.ceil((r.lockedUntil - Date.now()) / 1000));
|
|
321
|
+
const wait = seconds < 90 ? `${seconds} seconds` : seconds < 5400 ? `${Math.ceil(seconds / 60)} minutes` : `${Math.ceil(seconds / 3600)} hours`;
|
|
322
|
+
return json({ error: `too many wrong PINs: try again in ${wait}`, lockedUntil: r.lockedUntil }, 429, { 'retry-after': String(seconds) });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function pinRoutes(req , url , me , gate , unlockSecret ) {
|
|
326
|
+
const [, a, b] = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
327
|
+
const method = req.method;
|
|
328
|
+
const pin = inbox.pin;
|
|
329
|
+
const status = () => ({ ...pin.status(), unlocked: !gate.locked, exempt: !gate.gated });
|
|
330
|
+
if (a === 'pin' && !b && method === 'GET') return json(status());
|
|
331
|
+
if (a === 'unlock' && !b && method === 'POST') {
|
|
332
|
+
if (!pin.isSet()) return json({ error: 'this inbox has no PIN' }, 409);
|
|
333
|
+
const body = (await readJson (req, BODY_LIMIT.open)) ?? {};
|
|
334
|
+
if (!validKey(body.key)) return json({ error: KEY_HELP }, 400);
|
|
335
|
+
const r = await pin.unlock(body.key, me.token);
|
|
336
|
+
return r.ok ? json({ unlock: r.token, idleMs: UNLOCK_IDLE_MS }) : refused(r);
|
|
337
|
+
}
|
|
338
|
+
if (a === 'lock' && !b && method === 'POST') {
|
|
339
|
+
if (unlockSecret) await pin.lock(unlockSecret);
|
|
340
|
+
return json({ ok: true });
|
|
341
|
+
}
|
|
342
|
+
if (a === 'pin' && b === 'never-ask' && method === 'PUT') {
|
|
343
|
+
if (gate.locked) return json({ error: 'this inbox is locked: type its PIN to open it', locked: true }, 423);
|
|
344
|
+
const body = (await readJson (req, BODY_LIMIT.open)) ?? {};
|
|
345
|
+
if (typeof body.neverAsk !== 'boolean') return json({ error: 'neverAsk must be true or false' }, 400);
|
|
346
|
+
pin.setNeverAsk(body.neverAsk);
|
|
347
|
+
return json(status());
|
|
348
|
+
}
|
|
349
|
+
if (a === 'pin' && !b && (method === 'PUT' || method === 'DELETE')) {
|
|
350
|
+
const body = (await readJson (req, BODY_LIMIT.open)) ?? {};
|
|
351
|
+
const error = method === 'PUT' ? pinKeyError(body, pin.iterations) : undefined;
|
|
352
|
+
if (error) return json({ error }, 400);
|
|
353
|
+
// The page proves it knows the PIN it changes. A token kept on this machine, such as the owner-token file, resets a forgotten one.
|
|
354
|
+
if (pin.isSet() && gate.gated) {
|
|
355
|
+
if (!validKey(body.current)) return json({ error: 'send the current PIN\'s key to change it, or reset it on the computer that runs the inbox: your-attention pin reset' }, 403);
|
|
356
|
+
const r = await pin.check(body.current);
|
|
357
|
+
if (!r.ok) return refused(r);
|
|
358
|
+
}
|
|
359
|
+
if (method === 'PUT') await pin.setKey({ key: String(body.key), salt: String(body.salt), iterations: Number(body.iterations) });
|
|
360
|
+
else pin.clear();
|
|
361
|
+
// The tab that changed the PIN stays unlocked. Every other tab types the new one.
|
|
362
|
+
const unlock = method === 'PUT' && gate.gated ? await pin.issue(me.token) : undefined;
|
|
363
|
+
return json({ ...status(), unlocked: true, ...(unlock ? { unlock } : {}) });
|
|
364
|
+
}
|
|
365
|
+
return json({ error: 'not found' }, 404);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A revised card, with the warning for the agent when the person won't see the revision. */
|
|
369
|
+
const withWarning = (card ) => {
|
|
370
|
+
const warning = inbox.revisionWarning(card.id);
|
|
371
|
+
return warning ? { ...card, warning } : card;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
async function routeAuthed(req , url , me , gate ) {
|
|
375
|
+
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
376
|
+
const [, a, b, c] = parts;
|
|
377
|
+
const method = req.method;
|
|
378
|
+
const owner = () => {
|
|
379
|
+
if (me.role !== 'owner') throw new InboxError(403, 'only the owner can do this');
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
if (method === 'GET' && a === 'state') { owner(); return json(inbox.summaryState()); }
|
|
383
|
+
if (method === 'POST' && a === 'archive' && !b) return json(inbox.archive(me));
|
|
384
|
+
if (method === 'POST' && a === 'sign-in-codes' && !b) return json(inbox.createSignInCode(me), 201);
|
|
385
|
+
if (method === 'GET' && a === 'addresses' && !b) { owner(); return json({ addresses: options.networkAddresses?.() ?? [] }); }
|
|
386
|
+
if (method === 'POST' && a === 'focus' && !b) {
|
|
387
|
+
owner();
|
|
388
|
+
const { href } = (await readJson (req, BODY_LIMIT.open)) ?? {};
|
|
389
|
+
if (typeof href !== 'string' || !parseSessionLink(href)) return json({ error: 'not a session link' }, 400);
|
|
390
|
+
if (!options.focusSession) return json({ error: 'this inbox cannot open sessions' }, 501);
|
|
391
|
+
try { await options.focusSession(href); } catch { return json({ error: 'herdr could not open that session' }, 502); }
|
|
392
|
+
return json({ ok: true });
|
|
393
|
+
}
|
|
394
|
+
if (method === 'POST' && a === 'reset' && !b) {
|
|
395
|
+
// A reset cannot be undone, so the request must say so.
|
|
396
|
+
if ((await readJson (req)).confirm !== 'reset') throw new InboxError(400, 'send {"confirm": "reset"} to remove every card');
|
|
397
|
+
return json(await inbox.reset(me));
|
|
398
|
+
}
|
|
399
|
+
// The page probes this address with a plain request to learn whether a socket is served here, since a browser
|
|
400
|
+
// reports no status for an upgrade that fails. 200 says yes, and a build without sockets never answers this.
|
|
401
|
+
if (method === 'GET' && a === 'ws' && !c && req.headers.get('upgrade') === null) return json({ webSocket: true });
|
|
402
|
+
if (options.streams === false && method === 'GET' && (a === 'events' || a === 'stream' || a === 'poll')) {
|
|
403
|
+
return json({ error: 'this inbox serves events over the WebSocket at /v1/ws only' }, 410);
|
|
404
|
+
}
|
|
405
|
+
if (method === 'GET' && a === 'events') {
|
|
406
|
+
owner();
|
|
407
|
+
return eventStream((send, end) => {
|
|
408
|
+
send('beat', { type: 'beat', at: Date.now() });
|
|
409
|
+
return inbox.subscribePage((ev) => send(ev.type, ev), url.searchParams.get('viewer') ?? undefined, gate.gated ? { unlock: gate.unlock, close: end } : undefined);
|
|
410
|
+
}, heartbeatMs);
|
|
411
|
+
}
|
|
412
|
+
if (method === 'GET' && a === 'poll') {
|
|
413
|
+
// Long-poll for clients that cannot keep a stream: returns at the first answer, or after `wait` seconds.
|
|
414
|
+
const agent = url.searchParams.get('agent');
|
|
415
|
+
if (!agent) return json({ error: 'agent is required' }, 400);
|
|
416
|
+
const wait = Math.min(Math.max(Number(url.searchParams.get('wait') ?? 25), 0), 55) * 1000;
|
|
417
|
+
const events = [];
|
|
418
|
+
let wake = () => {};
|
|
419
|
+
const woken = new Promise ((r) => { wake = r; });
|
|
420
|
+
const channelOff = url.searchParams.get('channel') === 'off';
|
|
421
|
+
const disconnect = inbox.connect(me, agent, (ev) => { if (ev.type !== 'hello') { events.push(ev); wake(); } }, url.searchParams.get('instance') ?? undefined, { channelOff });
|
|
422
|
+
let timer ;
|
|
423
|
+
// A client that drops its connection ends the wait at once, so the inbox lets go of its subscriber and its timer.
|
|
424
|
+
const gone = req.signal.aborted ? Promise.resolve() : new Promise ((r) => req.signal.addEventListener('abort', () => r(), { once: true }));
|
|
425
|
+
if (!events.length) await Promise.race([woken, gone, new Promise ((r) => { timer = setTimeout(r, wait); })]);
|
|
426
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
427
|
+
disconnect();
|
|
428
|
+
return json({ events });
|
|
429
|
+
}
|
|
430
|
+
if (a === 'person') {
|
|
431
|
+
if (me.role === 'agent') throw new InboxError(403, 'only the owner and extensions read where the person is');
|
|
432
|
+
if (method === 'GET') return json(inbox.person());
|
|
433
|
+
if (method === 'POST' && b) { owner(); return json(inbox.reportViewer(b, await readJson (req))); }
|
|
434
|
+
}
|
|
435
|
+
if (method === 'GET' && a === 'stream') {
|
|
436
|
+
const agent = url.searchParams.get('agent');
|
|
437
|
+
if (!agent) return json({ error: 'agent is required' }, 400);
|
|
438
|
+
return eventStream((send) => inbox.connect(me, agent, (ev) => send(ev.type, ev), url.searchParams.get('instance') ?? undefined, { channelOff: url.searchParams.get('channel') === 'off' }), heartbeatMs);
|
|
439
|
+
}
|
|
440
|
+
if (method === 'GET' && a === 'search') return json(inbox.search(url.searchParams.get('q') ?? '', Math.min(Number(url.searchParams.get('limit') ?? 20) || 20, 100), url.searchParams.get('answered') === '1'));
|
|
441
|
+
if (method === 'GET' && a === 'unread') return json(inbox.unread(me, url.searchParams.get('agent') ?? undefined));
|
|
442
|
+
if (method === 'GET' && a === 'dead-letters') return json(inbox.deadLetters());
|
|
443
|
+
if (a === 'templates') {
|
|
444
|
+
if (method === 'GET' && !b) return json(inbox.listTemplates());
|
|
445
|
+
if (method === 'GET' && b) return json(inbox.getTemplate(b));
|
|
446
|
+
if (method === 'POST' && !b) {
|
|
447
|
+
if (me.role === 'extension') throw new InboxError(403, 'extensions cannot change templates');
|
|
448
|
+
const t = await readJson (req);
|
|
449
|
+
return json(inbox.saveTemplate(String(t.name ?? ''), String(t.source ?? ''), t.feedback), 201);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (a === 'widgets') {
|
|
453
|
+
if (method === 'GET' && !b) return json(inbox.listWidgets());
|
|
454
|
+
if (method === 'GET' && b) return json(inbox.getWidget(b));
|
|
455
|
+
if (method === 'POST' && !b) {
|
|
456
|
+
if (me.role === 'extension') throw new InboxError(403, 'extensions cannot change widgets');
|
|
457
|
+
const w = await readJson (req);
|
|
458
|
+
return json(inbox.saveWidget(String(w.name ?? ''), String(w.source ?? ''), w.feedback, { when: w.when, response: w.response }), 201);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (a === 'priorities') {
|
|
462
|
+
if (method === 'GET') return json(inbox.priorities());
|
|
463
|
+
if (method === 'PUT') {
|
|
464
|
+
if (me.role === 'extension') throw new InboxError(403, 'extensions cannot change priority levels');
|
|
465
|
+
// The owner changes them at once. An agent proposes them, and the person approves on a system card.
|
|
466
|
+
if (me.role === 'owner') return json(inbox.setPriorities(await readJson (req)));
|
|
467
|
+
return json({ proposed: publicCard(inbox.proposePriorities(await readJson (req))) }, 202);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (a === 'guidance') {
|
|
471
|
+
if (method === 'GET') return json({ ...inbox.guidance(), widgets: inbox.widgetCatalog(), tags: inbox.tagsInUse() });
|
|
472
|
+
if (method === 'POST') {
|
|
473
|
+
const g = await readJson (req);
|
|
474
|
+
return json(publicCard(inbox.proposeGuidance(String(g.text ?? ''), String(g.message ?? ''), g.agent ? { caller: me, agent: String(g.agent) } : undefined)), 201);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (a === 'telemetry' && !b && (method === 'GET' || method === 'PUT')) {
|
|
478
|
+
owner();
|
|
479
|
+
const t = options.telemetry;
|
|
480
|
+
if (!t) return json({ error: 'this inbox sends no usage counts' }, 404);
|
|
481
|
+
if (method === 'PUT') {
|
|
482
|
+
const body = (await readJson (req, BODY_LIMIT.open)) ?? {};
|
|
483
|
+
for (const k of ['enabled', 'notified', 'resetId'] ) {
|
|
484
|
+
if (body[k] !== undefined && typeof body[k] !== 'boolean') throw new InboxError(400, `${k} must be true or false`);
|
|
485
|
+
}
|
|
486
|
+
const enabled = body.enabled ;
|
|
487
|
+
if (enabled === false && inbox.usage.settings().alwaysOn) throw new InboxError(400, 'a hosted inbox always sends usage counts');
|
|
488
|
+
// Turning it on or off means the person read what it sends.
|
|
489
|
+
inbox.usage.update({ enabled, notified: enabled !== undefined || body.notified === true ? true : undefined, resetId: body.resetId === true });
|
|
490
|
+
}
|
|
491
|
+
const s = inbox.usage.settings();
|
|
492
|
+
const blocked = t.blocked?.() ?? null;
|
|
493
|
+
// A laptop offers the counts once. A hosted inbox tells the person once that they are always on.
|
|
494
|
+
return json({ ...s, notice: !s.notified && !blocked, blocked, source: t.source, destination: t.destination, next: inbox.usage.report(inbox.usage.today(), t.source) });
|
|
495
|
+
}
|
|
496
|
+
if (a === 'settings') {
|
|
497
|
+
if (method === 'GET') return json(inbox.settings());
|
|
498
|
+
if (method === 'PUT') { owner(); return json(inbox.setSettings(await readJson (req))); }
|
|
499
|
+
}
|
|
500
|
+
if (method === 'GET' && a === 'extensions' && b === 'stream') {
|
|
501
|
+
const reg = {
|
|
502
|
+
name: url.searchParams.get('name') ?? '',
|
|
503
|
+
hooks: (url.searchParams.get('hooks') ?? '').split(',').filter(Boolean) ,
|
|
504
|
+
timeoutMs: Number(url.searchParams.get('timeoutMs') ?? 0) || undefined,
|
|
505
|
+
policy: (url.searchParams.get('policy') ?? 'fail-open') ,
|
|
506
|
+
};
|
|
507
|
+
// Validate before the stream opens, so a bad registration gets a plain error.
|
|
508
|
+
inbox.checkExtension(me, reg);
|
|
509
|
+
return eventStream((send) => inbox.connectExtension(me, reg, (ev) => send(ev.type, ev)), heartbeatMs);
|
|
510
|
+
}
|
|
511
|
+
if (method === 'POST' && a === 'hooks' && b) {
|
|
512
|
+
const v = await readJson (req);
|
|
513
|
+
return json(publicCard(inbox.hookVerdict(me, b, v.verdict , String(v.reason ?? ''))));
|
|
514
|
+
}
|
|
515
|
+
if (a === 'webhooks') {
|
|
516
|
+
owner();
|
|
517
|
+
if (method === 'GET') return json(inbox.listWebhooks());
|
|
518
|
+
if (method === 'POST') {
|
|
519
|
+
const w = await readJson (req);
|
|
520
|
+
const secret = randomId(24);
|
|
521
|
+
const hook = inbox.addWebhook(String(w.hook ?? ''), String(w.url ?? ''), secret);
|
|
522
|
+
return json({ ...hook, secret }, 201);
|
|
523
|
+
}
|
|
524
|
+
if (method === 'DELETE' && b) { inbox.deleteWebhook(b); return json({ ok: true }); }
|
|
525
|
+
}
|
|
526
|
+
if (a === 'subscriptions') {
|
|
527
|
+
if (method === 'GET' && !b) return json(inbox.listSubscriptions(me));
|
|
528
|
+
if (method === 'DELETE' && b) { inbox.deleteSubscription(me, b); return json({ ok: true }); }
|
|
529
|
+
}
|
|
530
|
+
if (method === 'GET' && a === 'attachments' && b) {
|
|
531
|
+
const { file, bytes } = await inbox.attachment(me, b);
|
|
532
|
+
// Only types a browser renders go inline, HTML in a sandbox that runs no script. Everything else downloads.
|
|
533
|
+
const type = attachmentType(file);
|
|
534
|
+
const inline = INLINE_TYPES.has(type) && url.searchParams.get('download') !== '1';
|
|
535
|
+
return new Response(bytes, { headers: fileHeaders(INLINE_TYPES.has(type) ? type : 'application/octet-stream', inline, file.name, 'private, max-age=86400') });
|
|
536
|
+
}
|
|
537
|
+
if (method === 'GET' && a === 'tokens') { owner(); return json(inbox.listTokens()); }
|
|
538
|
+
if (method === 'DELETE' && a === 'tokens' && b) { owner(); inbox.revokeToken(b); return json({ ok: true }); }
|
|
539
|
+
|
|
540
|
+
// The default tags an agent's cards inherit. A token reads and writes only its own, and the owner lists them all.
|
|
541
|
+
if (a === 'tags' && !b) {
|
|
542
|
+
if (method === 'GET') {
|
|
543
|
+
if (me.role === 'owner') return json({ defaults: inbox.defaultTagsList(me, url.searchParams.get('agent') ?? undefined) });
|
|
544
|
+
if (me.role !== 'agent') throw new InboxError(403, 'only an agent reads its own default tags');
|
|
545
|
+
const agent = url.searchParams.get('agent');
|
|
546
|
+
if (!agent) return json({ error: 'agent is required' }, 400);
|
|
547
|
+
return json(inbox.defaultTags(me, agent));
|
|
548
|
+
}
|
|
549
|
+
if (method === 'PUT') return json(inbox.setDefaultTags(me, (await readJson(req)) ?? {}));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (method === 'POST' && a === 'preview' && !b) return json(inbox.preview(me, await readJson (req)));
|
|
553
|
+
if (a === 'cards') {
|
|
554
|
+
if (method === 'POST' && !b) {
|
|
555
|
+
const input = await readJson (req, BODY_LIMIT.card);
|
|
556
|
+
// A system card from a client asks the inbox for something, and the answer says what the inbox did.
|
|
557
|
+
if (input?.kind === 'system') {
|
|
558
|
+
const done = inbox.systemRequest(me, input);
|
|
559
|
+
return 'secret' in done ? json(done, 201, { [ONCE_HEADER]: '1' }) : json(done, 200);
|
|
560
|
+
}
|
|
561
|
+
const card = await inbox.postCardWithFiles(me, input);
|
|
562
|
+
// The reply names the tags the card took from the agent's defaults, so the agent knows what it inherited.
|
|
563
|
+
return json({ ...withWarning(publicCard(card)), inheritedTags: inbox.inheritedTags(me, input) }, 201);
|
|
564
|
+
}
|
|
565
|
+
if (method === 'GET' && !b) {
|
|
566
|
+
// ?ids= looks up the cards a text links to, cleared ones included, in one request.
|
|
567
|
+
const ids = url.searchParams.get('ids');
|
|
568
|
+
if (ids !== null) return json({ cards: inbox.linkedCards(me, ids.split(',').filter(Boolean)) });
|
|
569
|
+
return json(inbox.listCards(me));
|
|
570
|
+
}
|
|
571
|
+
if (!b) return json({ error: 'not found' }, 404);
|
|
572
|
+
if (method === 'GET' && !c) {
|
|
573
|
+
const card = inbox.getCard(b);
|
|
574
|
+
if (me.role !== 'owner' && card.token !== me.token) throw new InboxError(403, 'another client owns this card');
|
|
575
|
+
const detail = inbox.cardDetail(b, me.role === 'owner');
|
|
576
|
+
return json(me.role === 'owner' && servesFiles(card) ? { ...detail, card: { ...detail.card, localFiles: true } } : detail);
|
|
577
|
+
}
|
|
578
|
+
if (method === 'GET' && c === 'file') {
|
|
579
|
+
owner();
|
|
580
|
+
// With a PIN, a cookie alone never opens a file: the tab needs a ticket from an unlocked page.
|
|
581
|
+
if (gate.gated && inbox.pin.isSet()) throw new InboxError(403, 'open this file from its card');
|
|
582
|
+
const name = url.searchParams.get('path') ?? '';
|
|
583
|
+
await cardFile(inbox.getCard(b), name);
|
|
584
|
+
// The file opens at its folder ticket's address, where the relative addresses of its images and styles resolve.
|
|
585
|
+
const t = folderTicket(b, name, me, gate);
|
|
586
|
+
return new Response(null, { status: 303, headers: { location: `../../files/${t.id}/${encodeURIComponent(t.name)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
587
|
+
}
|
|
588
|
+
if (method !== 'POST') return json({ error: 'not found' }, 404);
|
|
589
|
+
const body = await readJson (req, c === 'answer' ? BODY_LIMIT.card : BODY_LIMIT.other);
|
|
590
|
+
switch (c) {
|
|
591
|
+
case 'file-ticket': {
|
|
592
|
+
owner();
|
|
593
|
+
const path = String(body['path'] ?? '');
|
|
594
|
+
await cardFile(inbox.getCard(b), path);
|
|
595
|
+
const t = folderTicket(b, path, me, gate);
|
|
596
|
+
return json({ ticket: t.id, name: t.name }, 201);
|
|
597
|
+
}
|
|
598
|
+
case 'answer': {
|
|
599
|
+
owner();
|
|
600
|
+
const saved = await inbox.answer(b, {
|
|
601
|
+
images: body['images'] ,
|
|
602
|
+
values: body['values'] ,
|
|
603
|
+
comment: body['comment'] ,
|
|
604
|
+
delivery: body['delivery'] ,
|
|
605
|
+
version: body['version'] ,
|
|
606
|
+
});
|
|
607
|
+
// The owner's answer types each approval it turned on into its pane, and the reply says how each went.
|
|
608
|
+
return json((await inbox.sendApprovals(me, b)) ?? saved);
|
|
609
|
+
}
|
|
610
|
+
case 'close':
|
|
611
|
+
return json(publicCard(inbox.close(me, b, body['kind'] , String(body['outcome'] ?? ''), body['ifUnanswered'] === true)));
|
|
612
|
+
case 'dismiss':
|
|
613
|
+
owner();
|
|
614
|
+
return json(publicCard(inbox.dismiss(me, b, { values: body['values'], comment: body['comment'] })));
|
|
615
|
+
case 'reply':
|
|
616
|
+
// Agents no longer reply on a card: a follow-up is a new card that follows this one.
|
|
617
|
+
return json({ error: `agent replies are gone: post a new card with card_post and follows: "${b}"` }, 410);
|
|
618
|
+
case 'ack':
|
|
619
|
+
return json(inbox.ack(me, b));
|
|
620
|
+
case 'patch':
|
|
621
|
+
return json(withWarning(publicCard(await inbox.patchCardChecked(me, b, body['edits'] , body['field'] ))));
|
|
622
|
+
case 'status':
|
|
623
|
+
return json(publicCard(inbox.setStatus(me, b, String(body['status'] ?? ''))));
|
|
624
|
+
case 'claim':
|
|
625
|
+
return json(publicCard(inbox.claim(me, b, String(body['agent'] ?? ''))));
|
|
626
|
+
case 'draw-fault':
|
|
627
|
+
return json(publicCard(inbox.drawFault(me, b, body)));
|
|
628
|
+
case 'force':
|
|
629
|
+
owner(); return json(inbox.force(b));
|
|
630
|
+
case 'release':
|
|
631
|
+
owner(); return json(publicCard(inbox.release(b)));
|
|
632
|
+
case 'open': {
|
|
633
|
+
owner();
|
|
634
|
+
if (!options.openOrigin) return json({ error: 'this inbox cannot open sessions' }, 501);
|
|
635
|
+
await options.openOrigin(inbox.getCard(b));
|
|
636
|
+
return json({ ok: true });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return json({ error: 'not found' }, 404);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return async (req) => {
|
|
644
|
+
try {
|
|
645
|
+
return await route(req);
|
|
646
|
+
} catch (e) {
|
|
647
|
+
if (e instanceof InboxError) return json({ error: e.message }, e.status);
|
|
648
|
+
return json({ error: 'internal error' }, 500);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|