@3sln/create-trove 0.0.2 → 0.0.4

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/src/render.js CHANGED
@@ -10,6 +10,8 @@
10
10
  // everywhere else it means a gitignored `.env`. `secret: true` on an entry is the whole
11
11
  // mechanism — there is no second list to keep in sync.
12
12
 
13
+ import { LOCAL_S3 } from './templates/localS3.js';
14
+ import { VAPID_SCRIPT } from './templates/vapid.js';
13
15
  const RULE = '─'.repeat(58);
14
16
 
15
17
  /** The exact version, not a range: the two packages are released together, and a drive
@@ -18,10 +20,20 @@ const pin = (version) => version;
18
20
 
19
21
  const isSet = (e) => !e.commented && e.value !== '' && e.value != null;
20
22
 
23
+ /** The value of one env key across every section, or undefined. */
24
+ const valueOf = (sections, key) =>
25
+ sections.flatMap((s) => s.entries).find((e) => e.key === key && isSet(e))?.value;
26
+
27
+ /** `cd` only when there is somewhere to go — scaffolding into `.` is already there. */
28
+ const enter = (plan) => (plan.inPlace ? '' : `cd ${plan.name} && `);
29
+
21
30
  export function renderProject(plan) {
22
31
  const files = [];
23
32
  const steps = [];
24
- const secrets = plan.sections.flatMap((s) => s.entries.filter((e) => e.secret && isSet(e)));
33
+ // Not `isSet`: leaving credentials blank is the common case at scaffold time — you
34
+ // rarely have R2 keys yet — and that is precisely when you need to be told which
35
+ // secrets the drivers you picked are going to want.
36
+ const secrets = plan.sections.flatMap((s) => s.entries.filter((e) => e.secret));
25
37
 
26
38
  if (plan.runtime === 'workers') renderWorkers(plan, files, steps, secrets);
27
39
  else renderServer(plan, files, steps);
@@ -101,7 +113,7 @@ import '@3sln/trove/server/adapters/${adapter}';
101
113
  files.push({ path: '.env', contents: envHeader(plan) + renderEnv(plan.sections) + serverVars(server) });
102
114
  files.push({ path: '.gitignore', contents: gitignore(['data/']) });
103
115
 
104
- steps.push({ cmd: `cd ${name} && npm install`, why: 'pulls @3sln/trove — the web app is already built inside it' });
116
+ steps.push({ cmd: `${enter(plan)}npm install`, why: 'pulls @3sln/trove — the web app is already built inside it' });
105
117
  steps.push({ cmd: `npm start`, why: `serves the API and the workbench on :${server?.port ?? '8787'}` });
106
118
  }
107
119
 
@@ -119,18 +131,55 @@ const envHeader = (plan) => `# Generated by create-trove for a ${plan.runtime} d
119
131
  function renderWorkers(plan, files, steps, secrets) {
120
132
  const { name, version, workers: w } = plan;
121
133
 
134
+ // A local run needs somewhere for bytes to go, and there is no local R2. When the
135
+ // drive is configured for S3 we ship a bucket that speaks the same API, because the
136
+ // alternative — TROVE_STORAGE=memory — looks like it works and is a trap: memory
137
+ // storage lives in ONE isolate while scans and reindexes run in the TroveTasks Durable
138
+ // Object, which is another, so every item fails to index and search stays empty.
139
+ const bucket = valueOf(plan.sections, 'TROVE_S3_BUCKET');
140
+ const localS3 = valueOf(plan.sections, 'TROVE_STORAGE') === 's3' && bucket;
141
+ // Push was configured if the section ran, whether or not a production key was pasted
142
+ // in — the point of `npm run vapid` is that it usually was not.
143
+ const push = plan.sections.some((sec) => sec.title === 'Push notifications' && !sec.skipped);
144
+
122
145
  files.push({
123
146
  path: 'package.json',
124
147
  contents: JSON.stringify({
125
148
  name,
126
149
  private: true,
127
150
  type: 'module',
128
- scripts: { dev: 'wrangler dev', deploy: 'wrangler deploy' },
151
+ scripts: {
152
+ dev: 'wrangler dev',
153
+ ...(localS3 ? { 'dev:s3': 'node dev/local-s3.js' } : {}),
154
+ ...(push ? { vapid: 'node dev/vapid.js' } : {}),
155
+ deploy: 'wrangler deploy',
156
+ },
129
157
  dependencies: { '@3sln/trove': pin(version) },
130
- devDependencies: { wrangler: '^3.90.0' },
158
+ devDependencies: { wrangler: '^4.0.0' },
131
159
  }, null, 2) + '\n',
132
160
  });
133
161
 
162
+ if (localS3) {
163
+ // The bucket name is baked in rather than passed through the environment, so the
164
+ // script stays `node dev/local-s3.js` on every platform — `BUCKET=x node …` is not
165
+ // a thing that runs on Windows.
166
+ files.push({
167
+ path: 'dev/local-s3.js',
168
+ contents: LOCAL_S3.replace(
169
+ "const BUCKET = process.env.BUCKET || 'trove';",
170
+ `const BUCKET = process.env.BUCKET || ${JSON.stringify(bucket)};`,
171
+ ),
172
+ });
173
+ }
174
+
175
+ // Key rotation, and the way to produce the production pair in the first place. It
176
+ // lives in the generated project rather than in the wizard because the wizard runs
177
+ // through `npm create`, before this project has a node_modules — a scaffolder cannot
178
+ // hand you a command that needs a package it has not installed yet.
179
+ if (push) {
180
+ files.push({ path: 'dev/vapid.js', contents: VAPID_SCRIPT });
181
+ }
182
+
134
183
  files.push({
135
184
  path: 'src/worker.js',
136
185
  contents: `// The Worker entry.
@@ -146,16 +195,27 @@ export { default, TroveTasks } from '@3sln/trove/server/adapters/worker.js';
146
195
  files.push({ path: 'wrangler.toml', contents: wranglerToml(plan) });
147
196
  files.push({ path: '.gitignore', contents: gitignore(['.wrangler/']) });
148
197
 
149
- const devVars = renderEnv(plan.sections, { secretsOnly: true });
150
- if (devVars.trim()) {
198
+ // `.dev.vars.example` rather than `.dev.vars`: the real file is gitignored, so
199
+ // generating it produces something the next person on the project cannot see. The
200
+ // example is committed and says what to copy it to.
201
+ const devVars = devVarsExample(plan, { localS3, bucket });
202
+ if (devVars) files.push({ path: '.dev.vars.example', contents: devVars });
203
+
204
+ // And the real thing, gitignored, when there were answers worth keeping out of it.
205
+ // Without this the credentials someone just typed would be discarded — the example
206
+ // cannot hold them — and a local run against the real services would mean entering
207
+ // them a second time.
208
+ // A generated dev key counts: it is a value that exists nowhere else, so without
209
+ // this the pair minted a moment ago would be described and then thrown away.
210
+ const answered = secrets.some(isSet) || Boolean(plan.devVapid);
211
+ if (devVars && answered) {
151
212
  files.push({
152
213
  path: '.dev.vars',
153
- contents: `# Credentials for \`wrangler dev\`. Gitignored, and NOT uploaded by \`wrangler deploy\` —\n`
154
- + `# the deployed Worker reads these from secrets, which the README lists as commands.\n\n${devVars}`,
214
+ contents: devVarsExample(plan, { localS3, bucket, withSecrets: true }),
155
215
  });
156
216
  }
157
217
 
158
- steps.push({ cmd: `cd ${name} && npm install`, why: 'wrangler, and @3sln/trove for the app assets' });
218
+ steps.push({ cmd: `${enter(plan)}npm install`, why: 'wrangler, and @3sln/trove for the app assets' });
159
219
  if (w?.d1) {
160
220
  steps.push({
161
221
  cmd: `npx wrangler d1 create ${w.d1.name}`,
@@ -171,14 +231,149 @@ export { default, TroveTasks } from '@3sln/trove/server/adapters/worker.js';
171
231
  why: 'semantic search — Vectorize is the only vector store that runs here',
172
232
  });
173
233
  }
174
- const bucket = plan.sections.flatMap((s) => s.entries).find((e) => e.key === 'TROVE_S3_BUCKET' && isSet(e));
175
- if (bucket) steps.push({ cmd: `npx wrangler r2 bucket create ${bucket.value}`, why: 'object bytes' });
234
+ if (bucket) steps.push({ cmd: `npx wrangler r2 bucket create ${bucket}`, why: 'object bytes' });
176
235
  for (const s of secrets) {
177
- steps.push({ cmd: `npx wrangler secret put ${s.key}`, why: 'not in wrangler.toml — secrets never belong in a committed file' });
236
+ steps.push({
237
+ cmd: `npx wrangler secret put ${s.key}`,
238
+ why: isSet(s) ? 'in .dev.vars for local runs; set it here for the deployed Worker' : 'required by the drivers you chose',
239
+ });
178
240
  }
179
241
  steps.push({ cmd: 'npx wrangler deploy', why: '' });
180
242
  }
181
243
 
244
+ /**
245
+ * The local-development overrides, as a committed example.
246
+ *
247
+ * `wrangler dev` reads `.dev.vars`, and values there beat `[vars]` in wrangler.toml.
248
+ * That is the only lever a local run has, and it needs one: a scaffolded Workers drive
249
+ * points at three things a laptop does not have. Without these overrides `npm run dev`
250
+ * builds, boots, serves the web app — and then answers every API route with a 500,
251
+ * which is a poor first five minutes and reads as a broken scaffold rather than a
252
+ * missing account.
253
+ *
254
+ * Each block is emitted only when the configuration actually needs it, so nobody is
255
+ * handed an override for a service they did not choose.
256
+ */
257
+ function devVarsExample(plan, { localS3, bucket, withSecrets = false }) {
258
+ const { sections, workers: w } = plan;
259
+ const L = [];
260
+ const supplied = (key) => valueOf(sections, key);
261
+ const rule = (title) => {
262
+ L.push(`# ${RULE}`, `# ${title}`, `# ${RULE}`);
263
+ };
264
+
265
+ const auth = valueOf(sections, 'TROVE_AUTH');
266
+ const needsIdentityOverride = auth && auth !== 'anonymous';
267
+ const secrets = sections.flatMap((s) => s.entries.filter((e) => e.secret));
268
+ if (!needsIdentityOverride && !localS3 && !w?.vectorize && !plan.devVapid && !secrets.length) return null;
269
+
270
+ L.push(withSecrets
271
+ ? '# Local settings for `wrangler dev`. Gitignored, and NOT uploaded by `wrangler deploy` —'
272
+ : '# Copy to .dev.vars (gitignored) for `wrangler dev`. NOT uploaded by `wrangler deploy` —');
273
+ L.push('# the deployed Worker reads its credentials from secrets; see README.md for the commands.');
274
+ L.push('#');
275
+ L.push('# Values here OVERRIDE [vars] in wrangler.toml for local runs. That is what lets a');
276
+ L.push('# local drive work without a Cloudflare account: there is no local R2, no local');
277
+ L.push('# Vectorize, and nothing in front of `wrangler dev` to authenticate anyone.');
278
+ L.push('');
279
+
280
+ if (needsIdentityOverride) {
281
+ rule('Identity — local only');
282
+ L.push(`# TROVE_AUTH is "${auth}" in wrangler.toml, which verifies a token nothing issues`);
283
+ L.push('# locally — every request would be rejected. Locally you are one anonymous user,');
284
+ L.push('# and an admin, so the workbench is actually usable.');
285
+ L.push('TROVE_AUTH=anonymous');
286
+ L.push('TROVE_AUTH_REQUIRED=false');
287
+ L.push('TROVE_ADMINS=anonymous');
288
+ L.push('');
289
+ }
290
+
291
+ if (localS3) {
292
+ rule('Object storage — local only');
293
+ L.push('# `npm run dev:s3` serves a bucket on :9000 over the same S3 API R2 speaks, so a');
294
+ L.push('# local run exercises the real path: SigV4, presigned PUTs, multipart, ranges.');
295
+ L.push('#');
296
+ L.push('# Do NOT replace this with TROVE_STORAGE=memory. Memory storage lives in one');
297
+ L.push('# isolate, and scans and reindexes run in the TroveTasks Durable Object, which is');
298
+ L.push('# another — every item fails to index with "Object not found" and search quietly');
299
+ L.push('# returns nothing.');
300
+ L.push(`TROVE_S3_BUCKET=${bucket}`);
301
+ L.push('TROVE_S3_ENDPOINT=http://127.0.0.1:9000');
302
+ L.push('# Virtual-host style would need a subdomain of localhost to resolve, which is not');
303
+ L.push('# dependable; path style keeps it on 127.0.0.1.');
304
+ L.push('TROVE_S3_PATH_STYLE=true');
305
+ L.push('# dev/local-s3.js does not verify signatures. These only have to be non-empty so');
306
+ L.push('# the SigV4 signer has something to sign with.');
307
+ // Real credentials, when they were supplied, but only into the gitignored file. The
308
+ // committed example gets the throwaway pair — see below.
309
+ const id = (withSecrets && supplied('TROVE_S3_ACCESS_KEY_ID')) || 'local';
310
+ const key = (withSecrets && supplied('TROVE_S3_SECRET_ACCESS_KEY')) || 'local-secret';
311
+ L.push(`TROVE_S3_ACCESS_KEY_ID=${id}`);
312
+ L.push(`TROVE_S3_SECRET_ACCESS_KEY=${key}`);
313
+ L.push('');
314
+ }
315
+
316
+ if (plan.devVapid) {
317
+ rule('Push notifications — local only');
318
+ L.push('# A local key pair, generated when this project was scaffolded. Production uses');
319
+ L.push('# a different one: a VAPID key identifies an application server, and these are');
320
+ L.push('# two servers — so this value leaking costs nothing, and a browser that');
321
+ L.push('# subscribed to your laptop is not subscribed to production.');
322
+ L.push('#');
323
+ L.push('# Only in .dev.vars, never in the committed example: it is still a private key,');
324
+ L.push('# and one shared by every clone of the repo is one nobody can reason about.');
325
+ L.push('# `npm run vapid` mints another.');
326
+ if (withSecrets) {
327
+ L.push(`TROVE_VAPID_PUBLIC_KEY=${plan.devVapid.publicKey}`);
328
+ L.push(`TROVE_VAPID_PRIVATE_KEY=${plan.devVapid.privateKey}`);
329
+ } else {
330
+ L.push('# TROVE_VAPID_PUBLIC_KEY= # run `npm run vapid` and paste the pair here');
331
+ L.push('# TROVE_VAPID_PRIVATE_KEY=');
332
+ }
333
+ L.push('');
334
+ }
335
+
336
+ if (w?.vectorize) {
337
+ rule('Semantic search — local only');
338
+ L.push('# Vectorize has no local emulation: every call fails with "Binding VECTORIZE needs');
339
+ L.push('# to be run remotely". An explicit TROVE_VECTOR beats the binding, so this swaps in');
340
+ L.push('# the in-process store — the same code path a drive with no Vectorize would take.');
341
+ L.push('# To exercise the real index instead, log in and add `remote = true` to [[vectorize]].');
342
+ L.push('TROVE_VECTOR=memory');
343
+ L.push('');
344
+ }
345
+
346
+ // Whatever a local block already set is NOT repeated below. Listing a key twice in one
347
+ // dotenv file is a trap: the commented copy reads like the place to put your real
348
+ // credential, and uncommenting it silently points local runs at a bucket that is not
349
+ // the one `npm run dev:s3` is serving.
350
+ const overridden = new Set([
351
+ ...(localS3 ? ['TROVE_S3_ACCESS_KEY_ID', 'TROVE_S3_SECRET_ACCESS_KEY'] : []),
352
+ // The local pair above already set this one. Repeating it, commented, reads as the
353
+ // place to paste the PRODUCTION key — which is a value this file should never hold
354
+ // and which the wizard deliberately never asks for.
355
+ ...(plan.devVapid ? ['TROVE_VAPID_PRIVATE_KEY'] : []),
356
+ ]);
357
+ const remaining = secrets.filter((e) => !overridden.has(e.key));
358
+ if (remaining.length) {
359
+ rule('Credentials');
360
+ L.push('# Only needed for a local run that talks to the real service. The deployed Worker');
361
+ L.push('# reads these from secrets, not from this file — leaving them blank is fine.');
362
+ for (const e of remaining) {
363
+ // A value only ever reaches the gitignored file. `.dev.vars.example` is committed,
364
+ // so it carries the KEY and nothing else — writing an answered credential into it
365
+ // would put the secret in version control, which is the one rule this whole module
366
+ // is built around.
367
+ const value = withSecrets && isSet(e) ? e.value : '';
368
+ const comment = e.comment ? ` # ${e.comment}` : '';
369
+ L.push(value ? `${e.key}=${value}${comment}` : `# ${e.key}=${comment}`);
370
+ }
371
+ L.push('');
372
+ }
373
+
374
+ return L.join('\n');
375
+ }
376
+
182
377
  function wranglerToml(plan) {
183
378
  const { workers: w, sections } = plan;
184
379
  const q = (v) => JSON.stringify(String(v));
@@ -189,7 +384,12 @@ function wranglerToml(plan) {
189
384
  L.push('');
190
385
  L.push('name = ' + q(plan.name));
191
386
  L.push('main = "src/worker.js"');
192
- L.push(`compatibility_date = ${q(w?.compatibilityDate ?? '2024-09-23')}`);
387
+ L.push(`compatibility_date = ${q(w?.compatibilityDate ?? '2026-07-01')}`);
388
+ L.push('');
389
+ L.push('# core/index.js re-exports FilesystemStorage, which imports node:fs, node:path and');
390
+ L.push('# node:stream at the top level — so they are in the bundle whether or not a Workers');
391
+ L.push('# deployment could ever use that backend. Without this the build does not link.');
392
+ L.push('compatibility_flags = ["nodejs_compat"]');
193
393
  L.push('');
194
394
 
195
395
  L.push('# The built web app, served straight from the installed package — no build step');
@@ -275,6 +475,13 @@ function wranglerToml(plan) {
275
475
  L.push('');
276
476
  }
277
477
 
478
+ L.push('# Maintenance. A timer registered inside a request does not outlive the request, so');
479
+ L.push('# on Workers there is no periodic work at all without a cron: expired uploads are');
480
+ L.push('# never swept, trash retention never applies, and collection scans never advance.');
481
+ L.push('# Each firing runs one time-boxed slice (TROVE_CRON_BUDGET_MS, default 20s).');
482
+ L.push('[triggers]');
483
+ L.push('crons = ["*/5 * * * *"]');
484
+ L.push('');
278
485
  L.push('[vars]');
279
486
  L.push('# Configuration only. Credentials are secrets — see README.md.');
280
487
  for (const section of sections) {
@@ -311,6 +518,44 @@ function readme(plan, steps) {
311
518
  L.push('```');
312
519
  L.push('');
313
520
 
521
+ if (plan.runtime === 'workers') {
522
+ const localS3 = plan.sections.flatMap((s) => s.entries)
523
+ .some((e) => e.key === 'TROVE_STORAGE' && e.value === 's3' && !e.commented);
524
+
525
+ // A generated `.dev.vars` holds the credentials that were just answered; telling
526
+ // someone to copy the example over it would throw them away on the first read of
527
+ // this file.
528
+ const hasDevVars = plan.sections.flatMap((s) => s.entries).some((e) => e.secret && isSet(e));
529
+
530
+ L.push('## Local development');
531
+ L.push('');
532
+ L.push('None of the account setup above is needed to run this locally.');
533
+ L.push('');
534
+ L.push('```sh');
535
+ if (hasDevVars) L.push('# .dev.vars is already written, with the credentials you gave — it is gitignored');
536
+ else L.push('cp .dev.vars.example .dev.vars # local identity, storage and search');
537
+ if (localS3) L.push('npm run dev:s3 # terminal 1 — a local S3 bucket on :9000');
538
+ L.push(`npm run dev # terminal ${localS3 ? '2' : '1'} — the Worker on :8787`);
539
+ L.push('```');
540
+ L.push('');
541
+ L.push('`.dev.vars` overrides `[vars]` for local runs only and is never uploaded by');
542
+ L.push('`wrangler deploy`. Without it the Worker builds and serves the web app, then answers');
543
+ L.push('every API route with a 500 — it is pointed at services a laptop does not have.');
544
+ L.push('');
545
+ L.push('What a local run does **not** cover:');
546
+ L.push('');
547
+ L.push('- **Vectorize** has no local emulation, so `.dev.vars` swaps in the in-process vector');
548
+ L.push(' store. Same search code, different index. Add `remote = true` to `[[vectorize]]` to');
549
+ L.push(' use the real one.');
550
+ L.push('- **Authorisation.** Locally you are one anonymous admin, so nothing exercises the');
551
+ L.push(' identity driver or the collection grants.');
552
+ if (localS3) {
553
+ L.push('- **`dev/local-s3.js` does not verify signatures** and keeps objects in memory. It is');
554
+ L.push(' a development bucket, bound to 127.0.0.1, and nothing more.');
555
+ }
556
+ L.push('');
557
+ }
558
+
314
559
  if (plan.warnings.length) {
315
560
  const kind = (w) => (typeof w === 'string' ? w : w.kind);
316
561
  const has = (k) => plan.warnings.some((w) => kind(w) === k);
@@ -0,0 +1,259 @@
1
+ // The local S3 bucket that goes into a scaffolded Workers project, as source.
2
+ //
3
+ // Vendored as a string rather than pulled from npm on purpose. The only maintained
4
+ // options that mock S3 mock the AWS SDK CLIENT, which is no use here — Trove signs its
5
+ // own requests with SigV4 and talks to the endpoint over fetch, so what a local run
6
+ // needs is a SERVER. The one package that is a server (s3rver) last shipped in 2021 and
7
+ // brings four advisories, three of them high, into a project that otherwise has none.
8
+ //
9
+ // It is a template, so it lives here as text. Kept in its own module to stay out of
10
+ // render.js, which is otherwise readable end to end.
11
+
12
+ /* eslint-disable */
13
+ export const LOCAL_S3 = `// A tiny S3-compatible server, for local development only.
14
+ //
15
+ // WHY THIS EXISTS
16
+ //
17
+ // On Workers the object store is R2 reached over the S3 API, and there is no local R2.
18
+ // \`TROVE_STORAGE=memory\` gets \`wrangler dev\` running, but it is not the same drive in
19
+ // one important way: memory storage lives INSIDE ONE ISOLATE, and scans and reindexes
20
+ // run in the TroveTasks Durable Object, which is a different isolate with a different
21
+ // memory. Every indexed item comes back "Object not found" and search stays empty —
22
+ // a failure that exists only because of the stand-in, and that would send you hunting
23
+ // for a bug in the indexer.
24
+ //
25
+ // Pointing TROVE_S3_ENDPOINT at this process instead gives every isolate one shared
26
+ // bucket over HTTP, which is what R2 is. It exercises the real code path: SigV4
27
+ // signing, multipart uploads, ranged reads, ListObjectsV2 paging.
28
+ //
29
+ // WHAT IT IS NOT
30
+ //
31
+ // It does not verify signatures. It accepts whatever Authorization header it is sent
32
+ // and serves the request. That is fine for a bucket of test files on loopback and
33
+ // unacceptable anywhere else, so it binds to 127.0.0.1 and refuses to start otherwise.
34
+ // Objects are held in memory and vanish when it stops.
35
+ //
36
+ // node dev/local-s3.js # or: npm run dev:s3
37
+ //
38
+ // Run it alongside \`npm run dev\`, with the settings in .dev.vars.example.
39
+
40
+ import { createServer } from 'node:http';
41
+ import { createHash } from 'node:crypto';
42
+
43
+ const PORT = Number(process.env.PORT || 9000);
44
+ const HOST = '127.0.0.1';
45
+ const BUCKET = process.env.BUCKET || 'trove';
46
+
47
+ /** key -> { body: Buffer, contentType, modifiedAt, etag } */
48
+ const objects = new Map();
49
+ /** uploadId -> { key, contentType, parts: Map<number, Buffer> } */
50
+ const uploads = new Map();
51
+
52
+ const md5 = (buf) => createHash('md5').update(buf).digest('hex');
53
+ const quoted = (etag) => \`"\${etag}"\`;
54
+ const xmlEscape = (s) => String(s)
55
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
57
+
58
+ const readBody = (req) => new Promise((resolve, reject) => {
59
+ const chunks = [];
60
+ req.on('data', (c) => chunks.push(c));
61
+ req.on('end', () => resolve(Buffer.concat(chunks)));
62
+ req.on('error', reject);
63
+ });
64
+
65
+ function sendXml(res, status, xml) {
66
+ const body = \`<?xml version="1.0" encoding="UTF-8"?>\\n\${xml}\`;
67
+ res.writeHead(status, { 'content-type': 'application/xml', 'content-length': Buffer.byteLength(body) });
68
+ res.end(body);
69
+ }
70
+
71
+ function sendError(res, status, code, message) {
72
+ sendXml(res, status, \`<Error><Code>\${code}</Code><Message>\${xmlEscape(message)}</Message></Error>\`);
73
+ }
74
+
75
+ /**
76
+ * ListObjectsV2.
77
+ *
78
+ * Paged on a continuation token that is just the key to resume after — the contract
79
+ * the client relies on is that the token is opaque and stable, not how it is built.
80
+ */
81
+ function listObjects(res, query) {
82
+ const prefix = query.get('prefix') || '';
83
+ const maxKeys = Math.min(Number(query.get('max-keys') || 1000), 1000);
84
+ const after = query.get('continuation-token');
85
+
86
+ let keys = [...objects.keys()].filter((k) => k.startsWith(prefix)).sort();
87
+ if (after) keys = keys.filter((k) => k > after);
88
+
89
+ const page = keys.slice(0, maxKeys);
90
+ const truncated = keys.length > page.length;
91
+
92
+ const contents = page.map((key) => {
93
+ const o = objects.get(key);
94
+ return \`<Contents>\`
95
+ + \`<Key>\${xmlEscape(key)}</Key>\`
96
+ + \`<LastModified>\${new Date(o.modifiedAt).toISOString()}</LastModified>\`
97
+ // Escaped, exactly as S3 does it: the quotes around an ETag come back as &quot;.
98
+ + \`<ETag>\${xmlEscape(quoted(o.etag))}</ETag>\`
99
+ + \`<Size>\${o.body.length}</Size>\`
100
+ + \`<StorageClass>STANDARD</StorageClass>\`
101
+ + \`</Contents>\`;
102
+ }).join('');
103
+
104
+ sendXml(res, 200,
105
+ \`<ListBucketResult>\`
106
+ + \`<Name>\${xmlEscape(BUCKET)}</Name>\`
107
+ + \`<Prefix>\${xmlEscape(prefix)}</Prefix>\`
108
+ + \`<KeyCount>\${page.length}</KeyCount>\`
109
+ + \`<MaxKeys>\${maxKeys}</MaxKeys>\`
110
+ + \`<IsTruncated>\${truncated}</IsTruncated>\`
111
+ + (truncated ? \`<NextContinuationToken>\${xmlEscape(page[page.length - 1])}</NextContinuationToken>\` : '')
112
+ + contents
113
+ + \`</ListBucketResult>\`);
114
+ }
115
+
116
+ /** GET/HEAD an object, honouring a single byte range. */
117
+ function getObject(req, res, key, head) {
118
+ const o = objects.get(key);
119
+ if (!o) return sendError(res, 404, 'NoSuchKey', 'The specified key does not exist.');
120
+
121
+ const headers = {
122
+ 'content-type': o.contentType || 'application/octet-stream',
123
+ etag: quoted(o.etag),
124
+ 'last-modified': new Date(o.modifiedAt).toUTCString(),
125
+ 'accept-ranges': 'bytes',
126
+ };
127
+
128
+ const range = /^bytes=(\\d*)-(\\d*)$/.exec(req.headers.range || '');
129
+ if (range) {
130
+ const total = o.body.length;
131
+ // A suffix range ("bytes=-500") counts back from the end; the other two forms
132
+ // count forward, with an absent end meaning "to the last byte".
133
+ let start = range[1] === '' ? total - Number(range[2]) : Number(range[1]);
134
+ let end = range[1] === '' ? total - 1 : (range[2] === '' ? total - 1 : Number(range[2]));
135
+ start = Math.max(0, start);
136
+ end = Math.min(total - 1, end);
137
+ if (start > end) {
138
+ res.writeHead(416, { 'content-range': \`bytes */\${total}\` });
139
+ return res.end();
140
+ }
141
+ const slice = o.body.subarray(start, end + 1);
142
+ res.writeHead(206, {
143
+ ...headers,
144
+ 'content-range': \`bytes \${start}-\${end}/\${total}\`,
145
+ 'content-length': slice.length,
146
+ });
147
+ return res.end(head ? undefined : slice);
148
+ }
149
+
150
+ res.writeHead(200, { ...headers, 'content-length': o.body.length });
151
+ return res.end(head ? undefined : o.body);
152
+ }
153
+
154
+ async function handle(req, res) {
155
+ const url = new URL(req.url, \`http://\${HOST}:\${PORT}\`);
156
+ const query = url.searchParams;
157
+
158
+ // Path-style addressing: /<bucket>/<key...>. Virtual-host style would need
159
+ // <bucket>.localhost to resolve, which it does not reliably — hence
160
+ // TROVE_S3_PATH_STYLE=true in .dev.vars.example.
161
+ const segments = url.pathname.replace(/^\\//, '').split('/');
162
+ const bucket = segments.shift();
163
+ const key = decodeURIComponent(segments.join('/'));
164
+
165
+ if (bucket !== BUCKET) {
166
+ return sendError(res, 404, 'NoSuchBucket', \`No bucket named "\${bucket}".\`);
167
+ }
168
+
169
+ // --- bucket-level ---------------------------------------------------------
170
+ if (!key) {
171
+ if (req.method === 'GET' && query.get('list-type') === '2') return listObjects(res, query);
172
+ if (req.method === 'HEAD') { res.writeHead(200); return res.end(); }
173
+ return sendError(res, 400, 'InvalidRequest', 'Only ListObjectsV2 is supported at the bucket level.');
174
+ }
175
+
176
+ // --- multipart ------------------------------------------------------------
177
+ if (req.method === 'POST' && query.has('uploads')) {
178
+ const uploadId = \`mp_\${md5(key + Date.now() + Math.random())}\`;
179
+ uploads.set(uploadId, { key, contentType: req.headers['content-type'], parts: new Map() });
180
+ return sendXml(res, 200,
181
+ \`<InitiateMultipartUploadResult>\`
182
+ + \`<Bucket>\${xmlEscape(BUCKET)}</Bucket><Key>\${xmlEscape(key)}</Key>\`
183
+ + \`<UploadId>\${uploadId}</UploadId>\`
184
+ + \`</InitiateMultipartUploadResult>\`);
185
+ }
186
+
187
+ if (req.method === 'PUT' && query.has('uploadId')) {
188
+ const upload = uploads.get(query.get('uploadId'));
189
+ if (!upload) return sendError(res, 404, 'NoSuchUpload', 'Unknown uploadId.');
190
+ const body = await readBody(req);
191
+ upload.parts.set(Number(query.get('partNumber')), body);
192
+ res.writeHead(200, { etag: quoted(md5(body)), 'content-length': 0 });
193
+ return res.end();
194
+ }
195
+
196
+ if (req.method === 'POST' && query.has('uploadId')) {
197
+ const uploadId = query.get('uploadId');
198
+ const upload = uploads.get(uploadId);
199
+ if (!upload) return sendError(res, 404, 'NoSuchUpload', 'Unknown uploadId.');
200
+ // The client's list is authoritative about ORDER; it sorts by part number before
201
+ // sending, and a part it never mentions is not part of the object.
202
+ const body = await readBody(req);
203
+ const numbers = [...body.toString().matchAll(/<PartNumber>(\\d+)<\\/PartNumber>/g)].map((m) => Number(m[1]));
204
+ const missing = numbers.filter((n) => !upload.parts.has(n));
205
+ if (missing.length) return sendError(res, 400, 'InvalidPart', \`No such part(s): \${missing.join(', ')}\`);
206
+ const assembled = Buffer.concat(numbers.map((n) => upload.parts.get(n)));
207
+ uploads.delete(uploadId);
208
+ // A real multipart ETag is "<md5-of-part-md5s>-<count>"; the shape matters to the
209
+ // scanner's change detection, so it is reproduced rather than faked as a plain md5.
210
+ const etag = \`\${md5(Buffer.concat(numbers.map((n) => Buffer.from(md5(upload.parts.get(n)), 'hex'))))}-\${numbers.length}\`;
211
+ objects.set(upload.key, {
212
+ body: assembled, contentType: upload.contentType, modifiedAt: Date.now(), etag,
213
+ });
214
+ return sendXml(res, 200,
215
+ \`<CompleteMultipartUploadResult>\`
216
+ + \`<Bucket>\${xmlEscape(BUCKET)}</Bucket><Key>\${xmlEscape(upload.key)}</Key>\`
217
+ + \`<ETag>\${xmlEscape(quoted(etag))}</ETag>\`
218
+ + \`</CompleteMultipartUploadResult>\`);
219
+ }
220
+
221
+ if (req.method === 'DELETE' && query.has('uploadId')) {
222
+ uploads.delete(query.get('uploadId'));
223
+ res.writeHead(204);
224
+ return res.end();
225
+ }
226
+
227
+ // --- single object --------------------------------------------------------
228
+ if (req.method === 'PUT') {
229
+ const body = await readBody(req);
230
+ const etag = md5(body);
231
+ objects.set(key, {
232
+ body, contentType: req.headers['content-type'], modifiedAt: Date.now(), etag,
233
+ });
234
+ res.writeHead(200, { etag: quoted(etag), 'content-length': 0 });
235
+ return res.end();
236
+ }
237
+
238
+ if (req.method === 'GET') return getObject(req, res, key, false);
239
+ if (req.method === 'HEAD') return getObject(req, res, key, true);
240
+
241
+ if (req.method === 'DELETE') {
242
+ objects.delete(key);
243
+ res.writeHead(204);
244
+ return res.end();
245
+ }
246
+
247
+ return sendError(res, 405, 'MethodNotAllowed', \`\${req.method} is not supported.\`);
248
+ }
249
+
250
+ createServer((req, res) => {
251
+ handle(req, res).catch((err) => {
252
+ console.error('[local-s3]', err);
253
+ if (!res.headersSent) sendError(res, 500, 'InternalError', err.message);
254
+ else res.end();
255
+ });
256
+ }).listen(PORT, HOST, () => {
257
+ console.log(\`[local-s3] bucket "\${BUCKET}" on http://\${HOST}:\${PORT} — in memory, signatures NOT checked\`);
258
+ });
259
+ `;
@@ -0,0 +1,48 @@
1
+ // The key-rotation script that goes into a scaffolded project, as source.
2
+ //
3
+ // Vendored as text for the same reason as the local bucket: it is a template. It uses
4
+ // @3sln/trove/core, which the generated project has and the WIZARD does not — the
5
+ // wizard runs through `npm create`, before there is a node_modules to import from.
6
+ // That is why the first version of the push question pointed at a function nobody
7
+ // could call yet.
8
+
9
+ /* eslint-disable */
10
+ export const VAPID_SCRIPT = `// Mint a VAPID key pair.
11
+ //
12
+ // npm run vapid
13
+ //
14
+ // A pair identifies THIS application server to a push service. It is self-issued — no
15
+ // account, no registration, no network — so there is nothing to fetch and nothing to
16
+ // pay for. The two halves go to different places and only one of them is a secret,
17
+ // which is what the output below is really about.
18
+ //
19
+ // Rotating invalidates every existing subscription: a browser subscribes against a
20
+ // specific public key, so after a rotation each client re-subscribes on its next load.
21
+ // That is free on a drive nobody has subscribed to yet and disruptive on one people
22
+ // use, so it is worth doing once, at the start.
23
+ import { generateVapidKeys } from '@3sln/trove/core';
24
+
25
+ const { publicKey, privateKey } = await generateVapidKeys();
26
+
27
+ console.log(\`
28
+ A new VAPID pair. The halves belong in two different places.
29
+
30
+ PUBLIC — not a secret. Browsers receive it as applicationServerKey, so it is public
31
+ by construction. It goes in wrangler.toml under [vars]:
32
+
33
+ TROVE_VAPID_PUBLIC_KEY = "\${publicKey}"
34
+
35
+ PRIVATE — signs the JWT that authorises each push. It should never be written to a
36
+ file this project tracks:
37
+
38
+ npx wrangler secret put TROVE_VAPID_PRIVATE_KEY
39
+
40
+ \${privateKey}
41
+
42
+ They are a PAIR. Setting one without the other leaves the drive unable to push, and it
43
+ fails at the push service as a rejected signature rather than as anything logged here.
44
+
45
+ For local development put BOTH halves in .dev.vars, which is gitignored — a local drive
46
+ is a different application server from the deployed one, and should not share its key.
47
+ \`);
48
+ `;