@lolkda/dsh-prompt-manager 3.0.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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +606 -0
  3. package/client/client.js +2320 -0
  4. package/cordis.patch.yml +20 -0
  5. package/environment.md +24 -0
  6. package/lib/entries.js +303 -0
  7. package/lib/entries.js.map +1 -0
  8. package/lib/guard.js +134 -0
  9. package/lib/guard.js.map +1 -0
  10. package/lib/index.js +959 -0
  11. package/lib/index.js.map +1 -0
  12. package/lib/net.js +179 -0
  13. package/lib/net.js.map +1 -0
  14. package/lib/pack.js +327 -0
  15. package/lib/pack.js.map +1 -0
  16. package/lib/probe.js +251 -0
  17. package/lib/probe.js.map +1 -0
  18. package/lib/routes.js +718 -0
  19. package/lib/routes.js.map +1 -0
  20. package/lib/scripts.js +803 -0
  21. package/lib/scripts.js.map +1 -0
  22. package/lib/source.js +308 -0
  23. package/lib/source.js.map +1 -0
  24. package/lib/store.js +223 -0
  25. package/lib/store.js.map +1 -0
  26. package/lib/subscriptions.js +269 -0
  27. package/lib/subscriptions.js.map +1 -0
  28. package/lib/sync.js +646 -0
  29. package/lib/sync.js.map +1 -0
  30. package/lib/types/entries.d.ts +194 -0
  31. package/lib/types/entries.d.ts.map +1 -0
  32. package/lib/types/guard.d.ts +63 -0
  33. package/lib/types/guard.d.ts.map +1 -0
  34. package/lib/types/index.d.ts +176 -0
  35. package/lib/types/index.d.ts.map +1 -0
  36. package/lib/types/net.d.ts +81 -0
  37. package/lib/types/net.d.ts.map +1 -0
  38. package/lib/types/pack.d.ts +298 -0
  39. package/lib/types/pack.d.ts.map +1 -0
  40. package/lib/types/probe.d.ts +150 -0
  41. package/lib/types/probe.d.ts.map +1 -0
  42. package/lib/types/routes.d.ts +85 -0
  43. package/lib/types/routes.d.ts.map +1 -0
  44. package/lib/types/scripts.d.ts +455 -0
  45. package/lib/types/scripts.d.ts.map +1 -0
  46. package/lib/types/source.d.ts +194 -0
  47. package/lib/types/source.d.ts.map +1 -0
  48. package/lib/types/store.d.ts +140 -0
  49. package/lib/types/store.d.ts.map +1 -0
  50. package/lib/types/subscriptions.d.ts +204 -0
  51. package/lib/types/subscriptions.d.ts.map +1 -0
  52. package/lib/types/sync.d.ts +248 -0
  53. package/lib/types/sync.d.ts.map +1 -0
  54. package/package.json +100 -0
package/lib/routes.js ADDED
@@ -0,0 +1,718 @@
1
+ /**
2
+ * The browser-facing half of the prompt store: one prefix route carrying the
3
+ * body files the settings page edits.
4
+ *
5
+ * Bodies cannot ride the settings transport, because they are markdown files a
6
+ * person also edits directly. This route is therefore the only write path from
7
+ * the page, and it is fenced twice: loopback peers only, and same-origin
8
+ * requests only for anything that mutates. A stale editor is refused with 409
9
+ * through the hash the page read, so two open drafts cannot silently overwrite
10
+ * each other.
11
+ *
12
+ * Everything else the page edits — the entry index, the presets, the
13
+ * subscriptions, the outbound settings — is a settings field, and this route
14
+ * only fills the gaps that transport leaves: an id nobody holds, a body file, a
15
+ * script on disk, a source's upstream check.
16
+ *
17
+ * @module @lolkda/dsh-prompt-manager/routes
18
+ */
19
+ import { DEFAULT_SOURCE_REF, entryIdFor, isEntryId, MAX_BODY_BYTES, MAX_ENTRIES, MAX_PRESETS, } from './entries.js';
20
+ import { bodyHash, PromptStore, PromptStoreError } from './store.js';
21
+ import { malformedReferences } from './guard.js';
22
+ import { isRepo, isRef, isSourceId, MAX_SOURCES, normalizeMirror, sourceSlug } from './source.js';
23
+ import { CheckError } from './sync.js';
24
+ import { FetchFailure } from './net.js';
25
+ import { ScriptError } from './scripts.js';
26
+ import { MAX_PACK_BYTES, parsePack } from './pack.js';
27
+ /** The single prefix every route below lives under. */
28
+ export const ROUTE_PREFIX = '/dsh-prompt-manager';
29
+ /** Slack over a body limit, for JSON escaping and envelope overhead. */
30
+ const JSON_SLACK = 8192;
31
+ /** Slack over the body limit for JSON escaping overhead. */
32
+ const READ_LIMIT = MAX_BODY_BYTES * 2 + JSON_SLACK;
33
+ /** Largest accepted title on the id-allocation route. */
34
+ const TITLE_LIMIT = 4096;
35
+ /**
36
+ * Register the prompt-store route.
37
+ *
38
+ * A composition without a web server (the TUI and SDK profiles) simply gets no
39
+ * route; the settings page then reports the store as unreachable.
40
+ *
41
+ * @param ctx - the plugin context whose `webServer` service is injected.
42
+ * @param host - body resolution, id allocation, and logging.
43
+ */
44
+ export function installPromptRoutes(ctx, host) {
45
+ ctx.inject(['webServer'], (scoped) => {
46
+ const webServer = scoped.webServer;
47
+ if (webServer === undefined)
48
+ return;
49
+ try {
50
+ const off = webServer.register({ kind: 'prefix', path: ROUTE_PREFIX, handler: createHandler(host) });
51
+ ctx.effect(() => off, 'dsh-prompt-manager: prompt store route');
52
+ }
53
+ catch (error) {
54
+ host.warn(`cannot register ${ROUTE_PREFIX}: ${messageOf(error)}`);
55
+ }
56
+ });
57
+ }
58
+ /**
59
+ * Build the request handler.
60
+ * @param host - body resolution, id allocation, and logging.
61
+ * @returns the handler registered on {@link ROUTE_PREFIX}.
62
+ */
63
+ function createHandler(host) {
64
+ return async (request, response) => {
65
+ try {
66
+ if (!isLoopback(request)) {
67
+ sendJson(response, 403, { error: 'the prompt store is reachable from loopback clients only' });
68
+ return;
69
+ }
70
+ // The peer address alone does not settle where the request came from: a
71
+ // page served by a name that resolves to this machine reaches the same
72
+ // socket, and presents a `Host` and a matching `Origin` of its own. Only
73
+ // loopback host names answer here, so a rebound name cannot get in.
74
+ if (!isLoopbackHost(request.headers.host)) {
75
+ sendJson(response, 403, {
76
+ error: 'the prompt store answers loopback host names only (127.0.0.1, localhost, [::1])',
77
+ code: 'host-not-loopback',
78
+ });
79
+ return;
80
+ }
81
+ const method = request.method ?? 'GET';
82
+ if (method !== 'GET' && method !== 'HEAD' && !sameOrigin(request)) {
83
+ sendJson(response, 403, { error: 'cross-origin writes are refused' });
84
+ return;
85
+ }
86
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
87
+ const rest = url.pathname.slice(ROUTE_PREFIX.length).replace(/^\/+/, '');
88
+ const slash = rest.indexOf('/');
89
+ const head = slash < 0 ? rest : rest.slice(0, slash);
90
+ const tail = slash < 0 ? '' : rest.slice(slash + 1);
91
+ if (head === 'status' && method === 'GET') {
92
+ sendJson(response, 200, {
93
+ ...host.store.status(),
94
+ maxEntries: MAX_ENTRIES,
95
+ variables: Object.fromEntries(host.variables().map((variable) => [variable.name, variable.value])),
96
+ });
97
+ return;
98
+ }
99
+ if (head === 'id' && method === 'POST') {
100
+ const payload = asRecord(await readJsonBody(request));
101
+ const title = typeof payload?.['title'] === 'string' ? payload['title'] : '';
102
+ if (title.trim().length === 0) {
103
+ sendJson(response, 400, { error: 'title must be a non-empty string' });
104
+ return;
105
+ }
106
+ sendJson(response, 200, { id: host.idFor(title.slice(0, TITLE_LIMIT)) });
107
+ return;
108
+ }
109
+ if (head === 'preset' && tail === 'id' && method === 'POST') {
110
+ await handlePresetId(host, request, response);
111
+ return;
112
+ }
113
+ if (head === 'body' && tail.length > 0) {
114
+ // The id grammar is checked here, so every method answers the same way
115
+ // for an id that could never address a body file.
116
+ const id = decodeId(tail);
117
+ if (id === undefined || !isEntryId(id)) {
118
+ sendJson(response, 400, { error: `the entry id is not a valid id: ${JSON.stringify(tail)}`, code: 'invalid-id' });
119
+ return;
120
+ }
121
+ await handleBody(host, method, id, request, response);
122
+ return;
123
+ }
124
+ if (head === 'sources') {
125
+ await handleSources(host, method, tail, request, response);
126
+ return;
127
+ }
128
+ if (head === 'variables') {
129
+ await handleVariables(host, method, tail, request, response);
130
+ return;
131
+ }
132
+ if (head === 'script' && tail.length > 0) {
133
+ const name = decodeId(tail);
134
+ if (name === undefined || !isEntryId(name)) {
135
+ sendJson(response, 400, { error: `the script name is not a usable name: ${JSON.stringify(tail)}`, code: 'invalid-id' });
136
+ return;
137
+ }
138
+ await handleScript(host, method, name, request, response);
139
+ return;
140
+ }
141
+ if (head === 'pack' && tail.length > 0) {
142
+ await handlePack(host, method, tail, request, response);
143
+ return;
144
+ }
145
+ sendJson(response, 404, { error: 'unknown prompt route' });
146
+ }
147
+ catch (error) {
148
+ handleFailure(host, error, response);
149
+ }
150
+ };
151
+ }
152
+ /**
153
+ * Allocate an id for a preset the settings page is about to write.
154
+ *
155
+ * The preset list itself rides the settings namespace, like the entry index, so
156
+ * this route contributes the one thing the page cannot derive: an id nobody
157
+ * holds. The cap is checked here for the same reason the source route checks
158
+ * its own — a page that has run out of room should be told before it writes a
159
+ * list the host would only narrow away.
160
+ *
161
+ * @param host - consulted for the preset ids in force.
162
+ * @param request - the request, read for its JSON body.
163
+ * @param response - the response to answer on.
164
+ */
165
+ async function handlePresetId(host, request, response) {
166
+ const payload = asRecord(await readJsonBody(request));
167
+ const title = typeof payload?.['title'] === 'string' ? payload['title'] : '';
168
+ if (title.trim().length === 0) {
169
+ sendJson(response, 400, { error: 'title must be a non-empty string' });
170
+ return;
171
+ }
172
+ const taken = host.presetIds();
173
+ if (taken.length >= MAX_PRESETS) {
174
+ sendJson(response, 409, {
175
+ error: `最多 ${String(MAX_PRESETS)} 个组合,先删掉一个`,
176
+ code: 'too-many-presets',
177
+ });
178
+ return;
179
+ }
180
+ sendJson(response, 200, { id: entryIdFor(title.slice(0, TITLE_LIMIT), taken) });
181
+ }
182
+ /**
183
+ * Serve the two preset-pack routes.
184
+ *
185
+ * Export answers with a file the browser saves; import reads one back. Both are
186
+ * deliberately narrow: export never writes anything, and import is the only
187
+ * route here that creates entries from a document that came from somewhere else,
188
+ * so it validates the whole pack before it touches a single file.
189
+ *
190
+ * @param host - consulted for packs and asked to carry out an import.
191
+ * @param method - HTTP method of the request.
192
+ * @param tail - everything after `pack/`: `export` or `import`.
193
+ * @param request - the request; the export reads its query, the import its body.
194
+ * @param response - the response to answer on.
195
+ */
196
+ async function handlePack(host, method, tail, request, response) {
197
+ if (tail === 'export') {
198
+ if (method !== 'GET') {
199
+ sendJson(response, 405, { error: `method ${method} is not allowed on the pack export` });
200
+ return;
201
+ }
202
+ const presetId = new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('preset') ?? '';
203
+ if (presetId.trim().length === 0) {
204
+ sendJson(response, 400, { error: '导出要在查询串里点名一个组合:/pack/export?preset=<组合 id>', code: 'missing-preset' });
205
+ return;
206
+ }
207
+ const pack = host.packFor(presetId.trim());
208
+ if (pack === undefined) {
209
+ sendJson(response, 404, { error: `没有这个组合:${presetId}`, code: 'unknown-preset' });
210
+ return;
211
+ }
212
+ // A name the browser can use when the address is opened directly; a page
213
+ // that fetches the URL and saves the blob names the file itself.
214
+ response.setHeader('content-disposition', `attachment; filename="prompt-manager-pack-${presetId}.json"`);
215
+ sendJson(response, 200, pack);
216
+ return;
217
+ }
218
+ if (tail === 'import') {
219
+ if (method !== 'POST') {
220
+ sendJson(response, 405, { error: `method ${method} is not allowed on the pack import` });
221
+ return;
222
+ }
223
+ const parsed = parsePack(await readJsonBody(request, MAX_PACK_BYTES + JSON_SLACK));
224
+ if (!parsed.ok) {
225
+ sendJson(response, 400, { error: parsed.message, code: parsed.code });
226
+ return;
227
+ }
228
+ const outcome = await host.importPack(parsed.pack);
229
+ if (!outcome.ok) {
230
+ sendJson(response, 400, { error: outcome.message, code: outcome.code });
231
+ return;
232
+ }
233
+ sendJson(response, 200, outcome.report);
234
+ return;
235
+ }
236
+ sendJson(response, 404, { error: `unknown pack route: ${JSON.stringify(tail)}` });
237
+ }
238
+ /**
239
+ * Serve the source routes: list, add, check, apply, revert, and forget a source.
240
+ *
241
+ * @param host - the subscription engine.
242
+ * @param method - HTTP method of the request.
243
+ * @param tail - everything after `sources/`: empty, `<slug>`, or `<slug>/<action>`.
244
+ * @param request - the request, read for a JSON body on add and apply.
245
+ * @param response - the response to answer on.
246
+ */
247
+ async function handleSources(host, method, tail, request, response) {
248
+ if (tail.length === 0) {
249
+ if (method === 'POST') {
250
+ await handleSourceCreate(host, request, response);
251
+ return;
252
+ }
253
+ if (method !== 'GET') {
254
+ sendJson(response, 405, { error: `method ${method} is not allowed on the source list` });
255
+ return;
256
+ }
257
+ sendJson(response, 200, { sources: host.subscriptions.list() });
258
+ return;
259
+ }
260
+ const slash = tail.indexOf('/');
261
+ const slug = decodeId(slash < 0 ? tail : tail.slice(0, slash));
262
+ const action = slash < 0 ? '' : tail.slice(slash + 1);
263
+ if (slug === undefined || !isSourceId(slug)) {
264
+ sendJson(response, 400, { error: 'the source id is not a valid slug' });
265
+ return;
266
+ }
267
+ if (method === 'DELETE' && action.length === 0) {
268
+ sendJson(response, 200, await host.subscriptions.remove(slug));
269
+ return;
270
+ }
271
+ if (method === 'POST' && action === 'check') {
272
+ sendJson(response, 200, await host.subscriptions.check(slug));
273
+ return;
274
+ }
275
+ if (method === 'POST' && action === 'apply') {
276
+ const payload = asRecord(await readJsonBody(request));
277
+ const raw = payload?.['files'];
278
+ const files = Array.isArray(raw) ? raw.filter((value) => typeof value === 'string') : undefined;
279
+ sendJson(response, 200, await host.subscriptions.apply(slug, files));
280
+ return;
281
+ }
282
+ if (method === 'POST' && action === 'revert') {
283
+ sendJson(response, 200, await host.subscriptions.revert(slug));
284
+ return;
285
+ }
286
+ sendJson(response, 405, { error: `unsupported source route: ${method} ${action}` });
287
+ }
288
+ /**
289
+ * Validate a repository the settings page wants to subscribe to, and hand back
290
+ * the slug no other source holds.
291
+ *
292
+ * The page stays the writer — sources ride the same settings namespace as the
293
+ * entry index, and the page already writes that — so this route contributes the
294
+ * two things the page cannot work out on its own: a slug that is still free, and
295
+ * the shape checks a hand-typed repository, ref, and mirror need before the
296
+ * engine turns them into outbound requests.
297
+ *
298
+ * @param host - the subscription engine, consulted for the sources in force.
299
+ * @param request - the request, read for its JSON body.
300
+ * @param response - the response to answer on.
301
+ */
302
+ async function handleSourceCreate(host, request, response) {
303
+ const payload = asRecord(await readJsonBody(request));
304
+ if (payload === undefined) {
305
+ sendJson(response, 400, { error: 'the request body must be a JSON object', code: 'invalid-body' });
306
+ return;
307
+ }
308
+ const repo = typeof payload['repo'] === 'string' ? payload['repo'].trim() : '';
309
+ if (!isRepo(repo)) {
310
+ sendJson(response, 400, {
311
+ error: `repo must be owner/name, got ${JSON.stringify(payload['repo'] ?? null)}`,
312
+ code: 'invalid-repo',
313
+ });
314
+ return;
315
+ }
316
+ const rawRef = payload['ref'];
317
+ const ref = rawRef === undefined || rawRef === null || (typeof rawRef === 'string' && rawRef.trim().length === 0)
318
+ ? DEFAULT_SOURCE_REF
319
+ : typeof rawRef === 'string'
320
+ ? rawRef.trim()
321
+ : undefined;
322
+ if (ref === undefined || !isRef(ref)) {
323
+ sendJson(response, 400, {
324
+ error: `ref must be a branch, tag, or commit, got ${JSON.stringify(rawRef ?? null)}`,
325
+ code: 'invalid-ref',
326
+ });
327
+ return;
328
+ }
329
+ const rawMirror = payload['mirror'];
330
+ if (rawMirror !== undefined && rawMirror !== null && typeof rawMirror !== 'string') {
331
+ sendJson(response, 400, { error: 'mirror must be a string when present', code: 'invalid-mirror' });
332
+ return;
333
+ }
334
+ const mirror = normalizeMirror(typeof rawMirror === 'string' ? rawMirror : '');
335
+ if (mirror === undefined) {
336
+ sendJson(response, 400, {
337
+ error: 'mirror must be an https origin without credentials, query, or fragment',
338
+ code: 'invalid-mirror',
339
+ });
340
+ return;
341
+ }
342
+ const configured = host.subscriptions.list();
343
+ const duplicate = configured.find((source) => source.repo === repo && source.ref === ref);
344
+ if (duplicate !== undefined) {
345
+ sendJson(response, 409, {
346
+ error: `${repo}@${ref} is already subscribed as ${duplicate.id}`,
347
+ code: 'duplicate-source',
348
+ });
349
+ return;
350
+ }
351
+ if (configured.length >= MAX_SOURCES) {
352
+ sendJson(response, 409, {
353
+ error: `at most ${String(MAX_SOURCES)} sources are supported`,
354
+ code: 'too-many-sources',
355
+ });
356
+ return;
357
+ }
358
+ sendJson(response, 200, {
359
+ id: slugFor(repo, configured.map((source) => source.id)),
360
+ repo,
361
+ ref,
362
+ mirror,
363
+ });
364
+ }
365
+ /**
366
+ * Allocate a source slug no configured source holds.
367
+ * @param repo - a validated `owner/name`.
368
+ * @param taken - slugs already in force.
369
+ * @returns a slug inside the source-id grammar, at most 64 characters.
370
+ */
371
+ function slugFor(repo, taken) {
372
+ const base = sourceSlug(repo);
373
+ const used = new Set(taken);
374
+ if (!used.has(base))
375
+ return base;
376
+ for (let suffix = 2; suffix < 1000; suffix += 1) {
377
+ const candidate = `${base.slice(0, 60)}-${String(suffix)}`;
378
+ if (!used.has(candidate))
379
+ return candidate;
380
+ }
381
+ return `${base.slice(0, 50)}-${String(Date.now())}`;
382
+ }
383
+ /**
384
+ * Serve the variable routes: the list of what the prompt can interpolate, a
385
+ * test run, and a refresh.
386
+ *
387
+ * A test run is deliberately the same execution a saved script gets, down to
388
+ * running from a real file, so what the page reports is what saving would
389
+ * produce. What it does *not* do is register anything: a draft that is only
390
+ * being tried out cannot change the prompt.
391
+ *
392
+ * @param host - the script engine and the variable list.
393
+ * @param method - HTTP method of the request.
394
+ * @param tail - empty, `run`, or `refresh`.
395
+ * @param request - the request, read for a JSON body on `run`.
396
+ * @param response - the response to answer on.
397
+ */
398
+ async function handleVariables(host, method, tail, request, response) {
399
+ if (tail.length === 0) {
400
+ if (method !== 'GET') {
401
+ sendJson(response, 405, { error: `method ${method} is not allowed on the variable list` });
402
+ return;
403
+ }
404
+ sendJson(response, 200, {
405
+ dir: host.scripts.dir,
406
+ variables: host.variables(),
407
+ scripts: host.scripts.list(),
408
+ });
409
+ return;
410
+ }
411
+ if (method !== 'POST') {
412
+ sendJson(response, 405, { error: `method ${method} is not allowed on ${tail}` });
413
+ return;
414
+ }
415
+ if (tail === 'run') {
416
+ const payload = asRecord(await readJsonBody(request));
417
+ const name = typeof payload?.['name'] === 'string' ? payload['name'].trim() : '';
418
+ const source = payload?.['source'];
419
+ if (source !== undefined) {
420
+ if (typeof source !== 'string') {
421
+ sendJson(response, 400, { error: 'source must be a string when present' });
422
+ return;
423
+ }
424
+ sendJson(response, 200, await host.scripts.runSource(name.length > 0 ? name : 'draft', source));
425
+ return;
426
+ }
427
+ if (name.length === 0) {
428
+ sendJson(response, 400, { error: 'send either name (a saved script) or source (a draft)' });
429
+ return;
430
+ }
431
+ sendJson(response, 200, await host.scripts.run(name));
432
+ return;
433
+ }
434
+ if (tail === 'refresh') {
435
+ sendJson(response, 200, { reports: await host.scripts.refresh() });
436
+ return;
437
+ }
438
+ sendJson(response, 404, { error: `unknown variable route: ${tail}` });
439
+ }
440
+ /**
441
+ * Serve one script: read its source, save a new one, or forget it.
442
+ *
443
+ * A save validates, runs, and only then writes: the file is put in place by the
444
+ * same atomic write a prompt body uses, and a script whose output or variable
445
+ * names are unusable never becomes a file at all.
446
+ *
447
+ * @param host - the script engine.
448
+ * @param method - HTTP method of the request.
449
+ * @param name - decoded script name.
450
+ * @param request - the request, read for a JSON body on PUT.
451
+ * @param response - the response to answer on.
452
+ */
453
+ async function handleScript(host, method, name, request, response) {
454
+ if (method === 'GET') {
455
+ const stored = host.scripts.read(name);
456
+ if (stored === undefined) {
457
+ sendJson(response, 404, { error: `没有这个脚本:${name}`, code: 'unknown-script' });
458
+ return;
459
+ }
460
+ sendJson(response, 200, { name, source: stored.source, sha1: stored.sha1 });
461
+ return;
462
+ }
463
+ if (method === 'PUT') {
464
+ const payload = asRecord(await readJsonBody(request));
465
+ const source = payload?.['source'];
466
+ if (typeof source !== 'string') {
467
+ sendJson(response, 400, { error: 'source must be a string' });
468
+ return;
469
+ }
470
+ const fence = payload?.['fileSha1'];
471
+ if (fence !== undefined && fence !== null && typeof fence !== 'string') {
472
+ sendJson(response, 400, { error: 'fileSha1 must be a string when present' });
473
+ return;
474
+ }
475
+ const saved = await host.scripts.save(name, source, fence === undefined || fence === null ? { kind: 'absent' } : { kind: 'sha1', sha1: fence });
476
+ sendJson(response, 200, saved);
477
+ return;
478
+ }
479
+ if (method === 'DELETE') {
480
+ sendJson(response, 200, { name, removed: host.scripts.remove(name) });
481
+ return;
482
+ }
483
+ sendJson(response, 405, { error: `method ${method} is not allowed on a script` });
484
+ }
485
+ /**
486
+ * Serve one entry's body: read, write, or restore the bundled default.
487
+ * @param host - body resolution and the store.
488
+ * @param method - HTTP method of the request.
489
+ * @param id - decoded entry id, already checked against the id grammar.
490
+ * @param request - the request, read for a JSON body on PUT.
491
+ * @param response - the response to answer on.
492
+ */
493
+ async function handleBody(host, method, id, request, response) {
494
+ if (method === 'GET') {
495
+ sendJson(response, 200, viewOf(host, id));
496
+ return;
497
+ }
498
+ if (method === 'PUT') {
499
+ if (host.describe(id).source === 'subscribed') {
500
+ sendJson(response, 403, { error: 'a subscribed body is read-only; fork it into a local entry first' });
501
+ return;
502
+ }
503
+ const payload = asRecord(await readJsonBody(request));
504
+ const body = typeof payload?.['body'] === 'string' ? payload['body'] : undefined;
505
+ if (body === undefined) {
506
+ sendJson(response, 400, { error: 'body must be a string' });
507
+ return;
508
+ }
509
+ const fence = payload?.['fileSha1'];
510
+ if (fence !== undefined && fence !== null && typeof fence !== 'string') {
511
+ sendJson(response, 400, { error: 'fileSha1 must be a string when present' });
512
+ return;
513
+ }
514
+ // A reference the registry cannot even read as a variable name makes every
515
+ // later model step fail, so it never reaches a file. A well-formed name that
516
+ // happens to be unregistered is not refused here: another row may register
517
+ // it, and the assembly guard covers the rest.
518
+ const malformed = malformedReferences(body);
519
+ if (malformed.length > 0) {
520
+ sendJson(response, 422, {
521
+ error: `正文里有非法引用,注册表只认 {{名字}} 这样的简单引用:${malformed.join(' ')}(保存它会让之后每一步组装都失败)`,
522
+ code: 'malformed-reference',
523
+ references: malformed,
524
+ });
525
+ return;
526
+ }
527
+ host.store.write(id, body, fence === undefined || fence === null
528
+ ? { kind: 'absent' }
529
+ : { kind: 'sha1', sha1: fence });
530
+ sendJson(response, 200, viewOf(host, id));
531
+ return;
532
+ }
533
+ if (method === 'DELETE') {
534
+ const removed = host.store.remove(id);
535
+ sendJson(response, 200, { ...viewOf(host, id), removed });
536
+ return;
537
+ }
538
+ sendJson(response, 405, { error: `method ${method} is not allowed on a prompt body` });
539
+ }
540
+ /**
541
+ * The page's view of one entry body.
542
+ * @param host - body resolution and the store.
543
+ * @param id - entry id.
544
+ * @returns effective body, its source and hash, and the override file's hash when present.
545
+ */
546
+ function viewOf(host, id) {
547
+ const described = host.describe(id);
548
+ const stored = host.store.has(id) ? host.store.read(id) : undefined;
549
+ return {
550
+ id,
551
+ body: described.text,
552
+ source: described.source,
553
+ sha1: hashOf(described.text),
554
+ fileSha1: stored?.sha1 ?? null,
555
+ };
556
+ }
557
+ /**
558
+ * sha1 of a body, mirroring {@link PromptStore.read}.
559
+ * @param body - the body to hash.
560
+ * @returns a lowercase hex digest.
561
+ */
562
+ function hashOf(body) {
563
+ return bodyHash(body);
564
+ }
565
+ /** Report a failed request with the status its reason deserves. */
566
+ function handleFailure(host, error, response) {
567
+ if (error instanceof PromptStoreError) {
568
+ const status = error.code === 'conflict' ? 409 : error.code === 'invalid-id' || error.code === 'too-large' ? 400 : 500;
569
+ sendJson(response, status, { error: error.message, code: error.code });
570
+ return;
571
+ }
572
+ if (error instanceof ScriptError) {
573
+ const status = error.reason === 'unknown-script'
574
+ ? 404
575
+ : error.reason === 'conflict'
576
+ ? 409
577
+ : error.reason === 'invalid-output' || error.reason === 'too-many'
578
+ ? 422
579
+ : 400;
580
+ sendJson(response, status, { error: error.message, code: error.reason, report: error.report });
581
+ return;
582
+ }
583
+ if (error instanceof CheckError) {
584
+ const status = error.reason === 'unknown-source'
585
+ ? 404
586
+ : error.reason === 'nothing-staged' || error.reason === 'disabled'
587
+ ? 409
588
+ : error.reason === 'manifest'
589
+ ? 422
590
+ : error.reason === 'mirror' || error.reason === 'network'
591
+ ? 502
592
+ : 400;
593
+ sendJson(response, status, { error: error.message, code: error.reason });
594
+ return;
595
+ }
596
+ if (error instanceof FetchFailure) {
597
+ sendJson(response, 502, { error: error.message, code: error.reason });
598
+ return;
599
+ }
600
+ host.warn(`prompt route failed: ${messageOf(error)}`);
601
+ sendJson(response, 500, { error: messageOf(error) });
602
+ }
603
+ /**
604
+ * Loopback-peer check: the prompt store is a local file surface.
605
+ * @param request - the incoming request.
606
+ * @returns `true` when the peer address is the local machine.
607
+ */
608
+ function isLoopback(request) {
609
+ const address = request.socket?.remoteAddress ?? '';
610
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
611
+ }
612
+ /**
613
+ * Loopback-host check: which name the client used to reach this socket.
614
+ *
615
+ * A page served from a name that resolves to this machine — the classic
616
+ * DNS-rebinding setup — arrives from a loopback peer and presents a `Host` and
617
+ * a matching `Origin` of its own, which the peer check and the same-origin
618
+ * check both accept. Requiring a loopback host name closes that door.
619
+ *
620
+ * @param host - the `Host` header value.
621
+ * @returns `true` when it names the local machine.
622
+ */
623
+ function isLoopbackHost(host) {
624
+ if (host === undefined)
625
+ return false;
626
+ let name;
627
+ try {
628
+ name = new URL(`http://${host}`).hostname;
629
+ }
630
+ catch {
631
+ return false;
632
+ }
633
+ const bare = name.startsWith('[') && name.endsWith(']') ? name.slice(1, -1) : name;
634
+ return bare === '127.0.0.1' || bare === 'localhost' || bare === '::1' || bare.startsWith('127.');
635
+ }
636
+ /**
637
+ * Same-origin check for mutating routes.
638
+ * @param request - the incoming request.
639
+ * @returns `true` when the Origin header names the Host.
640
+ */
641
+ function sameOrigin(request) {
642
+ const origin = request.headers.origin;
643
+ const host = request.headers.host;
644
+ if (origin === undefined || host === undefined)
645
+ return false;
646
+ try {
647
+ return new URL(origin).host === host;
648
+ }
649
+ catch {
650
+ return false;
651
+ }
652
+ }
653
+ /**
654
+ * Read a JSON request body under a size limit.
655
+ * @param request - the incoming request.
656
+ * @param limit - largest accepted body in bytes; the pack route raises it,
657
+ * because one pack may carry what the entry route carries in fifty writes.
658
+ * @returns the parsed JSON value, or `undefined` when the body is not JSON.
659
+ */
660
+ async function readJsonBody(request, limit = READ_LIMIT) {
661
+ const chunks = [];
662
+ let size = 0;
663
+ for await (const chunk of request) {
664
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
665
+ size += buffer.length;
666
+ if (size > limit)
667
+ throw new PromptStoreError('too-large', `request body is ${String(size)} bytes; the limit is ${String(limit)}`);
668
+ chunks.push(buffer);
669
+ }
670
+ if (size === 0)
671
+ return undefined;
672
+ try {
673
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
674
+ }
675
+ catch {
676
+ throw new PromptStoreError('invalid-id', 'request body is not valid JSON');
677
+ }
678
+ }
679
+ /**
680
+ * Narrow a parsed JSON value to a record.
681
+ * @param value - the parsed value.
682
+ * @returns the record, or `undefined` when the value is not a plain object.
683
+ */
684
+ function asRecord(value) {
685
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
686
+ ? value
687
+ : undefined;
688
+ }
689
+ /**
690
+ * Percent-decode one path segment.
691
+ * @param segment - the raw segment.
692
+ * @returns the decoded id, or `undefined` when decoding fails.
693
+ */
694
+ function decodeId(segment) {
695
+ try {
696
+ return decodeURIComponent(segment);
697
+ }
698
+ catch {
699
+ return undefined;
700
+ }
701
+ }
702
+ /** Write a JSON payload with no-store caching. */
703
+ function sendJson(response, status, payload) {
704
+ response.writeHead(status, {
705
+ 'cache-control': 'no-store',
706
+ 'content-type': 'application/json; charset=utf-8',
707
+ });
708
+ response.end(JSON.stringify(payload));
709
+ }
710
+ /**
711
+ * Message text of an unknown thrown value.
712
+ * @param error - the caught value.
713
+ * @returns a human-facing message.
714
+ */
715
+ function messageOf(error) {
716
+ return error instanceof Error ? error.message : String(error);
717
+ }
718
+ //# sourceMappingURL=routes.js.map