@3sln/create-trove 0.0.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/create-trove",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "description": "Scaffold a Trove deployment — Bun, Node, or Cloudflare Workers — with the bindings, environment and secrets each one needs.",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -158,6 +158,7 @@ async function main() {
158
158
  let rendered;
159
159
  try {
160
160
  plan = await askPlan(prompter, { name, version, runtime: opts.runtime });
161
+ plan.inPlace = target === process.cwd();
161
162
  rendered = renderProject(plan);
162
163
  } catch (err) {
163
164
  // A bad --set value: name the key rather than making someone map a stack trace
package/src/index.js CHANGED
@@ -35,13 +35,14 @@ export { createPrompter, presetPrompter, recordingPrompter, scripted } from './p
35
35
  /** Somewhere for headings to go that is not a caller's stdout. */
36
36
  const SILENT = { write() {}, isTTY: false };
37
37
 
38
- export async function createProject({ name, version, runtime, answers = {}, prompter } = {}) {
38
+ export async function createProject({ name, version, runtime, answers = {}, prompter, inPlace = false } = {}) {
39
39
  // Silent unless the caller hands over a prompter that wants to talk. A library that
40
40
  // prints section headings to stdout cannot be used inside anything that emits
41
41
  // structured output — which is most of what would want to call this.
42
42
  const base = prompter ?? createPrompter({ assumeDefaults: true, output: SILENT });
43
43
  const preset = presetPrompter(answers, base);
44
44
  const plan = await askPlan(preset, { name, version, runtime: runtime ?? undefined });
45
+ plan.inPlace = inPlace;
45
46
  const { files, steps } = renderProject(plan);
46
47
  return { plan, files, steps, unused: preset.unused() };
47
48
  }
package/src/plan.js CHANGED
@@ -17,8 +17,18 @@
17
17
  // step on Workers and an untracked `.env` line elsewhere — it never lands in a file
18
18
  // that belongs in version control. `secret: true` on an entry is what carries that.
19
19
 
20
+ import { generateVapidKeys } from './vapid.js';
21
+
20
22
  export const RUNTIMES = ['bun', 'node', 'workers'];
21
23
 
24
+ // LocalHashEmbedding's dimension (see core/src/search/embeddings.js). Not a default
25
+ // anyone should be asked to confirm: with the built-in embedding this IS the number, and
26
+ // a Vectorize index created at any other size accepts the deploy and then rejects every
27
+ // vector write — so search returns nothing, forever, without a single error anywhere a
28
+ // user would look. The wizard knows which embedding was chosen, so it derives this
29
+ // rather than asking a question whose wrong answer is invisible.
30
+ export const BUILTIN_EMBEDDING_DIM = '256';
31
+
22
32
  /** An environment/config entry. `secret` keeps it out of anything committed. */
23
33
  const entry = (key, value, { comment, secret = false, commented = false } = {}) =>
24
34
  ({ key, value, comment, secret, commented });
@@ -35,9 +45,13 @@ const placeholder = (key, comment) => entry(key, '', { comment, commented: true
35
45
  * @param {string} opts.version the @3sln/trove version to pin (this package's own —
36
46
  * the two are released together, so they are the same number by construction)
37
47
  * @param {string} [opts.runtime] pre-answered by --runtime
48
+ * @param {() => Promise<{publicKey: string, privateKey: string}>} [opts.generateKeys]
49
+ * how a local VAPID pair is minted. Injected so this stays the pure, transcript-driven
50
+ * function it is elsewhere — a real key pair is random, and a test asserting on the
51
+ * plan cannot assert on randomness.
38
52
  * @returns {Promise<object>} the plan
39
53
  */
40
- export async function askPlan(prompter, { name, version, runtime: preset }) {
54
+ export async function askPlan(prompter, { name, version, runtime: preset, generateKeys = generateVapidKeys }) {
41
55
  const runtime = preset ?? await prompter.choice('Where will this run?', [
42
56
  { value: 'bun', label: 'Bun', hint: 'recommended for self-hosting' },
43
57
  { value: 'node', label: 'Node', hint: 'identical behaviour, a little slower' },
@@ -45,7 +59,11 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
45
59
  ], { key: 'runtime', default: 'bun' });
46
60
 
47
61
  const isWorkers = runtime === 'workers';
48
- const plan = { name, version, runtime, sections: [], workers: null, server: null, skipped: [], warnings: [] };
62
+ const plan = {
63
+ name, version, runtime, sections: [], workers: null, server: null, skipped: [], warnings: [],
64
+ // Overwritten only if an HTTP embedding names its own size.
65
+ embeddingDim: BUILTIN_EMBEDDING_DIM,
66
+ };
49
67
 
50
68
  const add = (title, entries, { skipped = false } = {}) => {
51
69
  plan.sections.push({ title, entries, skipped });
@@ -135,7 +153,8 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
135
153
  entries.push(entry('TROVE_EMBEDDINGS_URL', await prompter.text(' Embeddings URL', { key: 'search.embeddingsUrl', default: 'https://api.openai.com/v1/embeddings' })));
136
154
  entries.push(entry('TROVE_EMBEDDINGS_API_KEY', await prompter.text(' API key', { key: 'search.embeddingsApiKey', default: '' }), { secret: true }));
137
155
  entries.push(entry('TROVE_EMBEDDINGS_MODEL', await prompter.text(' Model', { key: 'search.embeddingsModel', default: 'text-embedding-3-small' })));
138
- entries.push(entry('TROVE_EMBEDDINGS_DIM', await prompter.text(' Dimensions', { key: 'search.embeddingsDim', default: '1536' }),
156
+ plan.embeddingDim = await prompter.text(' Dimensions', { key: 'search.embeddingsDim', default: '1536' });
157
+ entries.push(entry('TROVE_EMBEDDINGS_DIM', plan.embeddingDim,
139
158
  { comment: 'must match the model, and changing it means a reindex' }));
140
159
  }
141
160
 
@@ -229,6 +248,50 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
229
248
  ], { skipped: true });
230
249
  }
231
250
 
251
+ // --- notifications ---------------------------------------------------------
252
+ // Off by default, and harmless to decline: mentions reach the in-app inbox either
253
+ // way. What VAPID adds is the ping — a browser waking a service worker while the
254
+ // drive is closed. The push carries no text (the worker fetches the inbox over its
255
+ // own authenticated connection), so declining costs a banner and nothing else.
256
+ if (await prompter.section('Push notifications', { key: 'notify.enabled',
257
+ blurb: 'Web push when someone @mentions you. The in-app inbox works without it.',
258
+ default: false,
259
+ })) {
260
+ // A LOCAL pair, minted here, written only to the gitignored .dev.vars. VAPID keys
261
+ // are self-issued — no account, no network — so unlike an R2 credential there is
262
+ // nothing to go and fetch, and making the developer find a way to produce a P-256
263
+ // point before they can try the feature is friction with nothing on the other side
264
+ // of it.
265
+ //
266
+ // Separate from production on purpose. A key identifies an application server, and
267
+ // these are two different servers; keeping them apart also means the value sitting
268
+ // on a laptop is worth nothing if it leaks.
269
+ plan.devVapid = await generateKeys();
270
+
271
+ // The production PUBLIC key only. The private half is never asked for: the answer
272
+ // would be written to disk by a program whose whole job is writing files, and a
273
+ // production signing key has no business in a scaffolder's output. It goes straight
274
+ // from `npm run vapid` into `wrangler secret put`, and the step for that is emitted
275
+ // whether or not this is filled in — see the blank-secret entry below.
276
+ const publicKey = await prompter.text(' Production public key', { key: 'notify.publicKey', default: '',
277
+ hint: 'leave blank — `npm run vapid` prints a pair once the project is installed',
278
+ });
279
+ add('Push notifications', [
280
+ entry('TROVE_VAPID_PUBLIC_KEY', publicKey,
281
+ { comment: 'must be the pair of the TROVE_VAPID_PRIVATE_KEY secret' }),
282
+ entry('TROVE_VAPID_PRIVATE_KEY', '', { secret: true }),
283
+ entry('TROVE_VAPID_SUBJECT', await prompter.text(' Contact subject', { key: 'notify.subject', default: 'mailto:admin@example.com',
284
+ hint: 'mailto: or https URL — how a push service reaches you about your own traffic',
285
+ })),
286
+ ]);
287
+ } else {
288
+ add('Push notifications', [
289
+ placeholder('TROVE_VAPID_PUBLIC_KEY', 'both keys set enables web push; the inbox works either way'),
290
+ placeholder('TROVE_VAPID_PRIVATE_KEY', 'a credential — set it with `wrangler secret put`, not here'),
291
+ placeholder('TROVE_VAPID_SUBJECT', 'mailto: or https URL; defaults to mailto:admin@example.com'),
292
+ ], { skipped: true });
293
+ }
294
+
232
295
  // --- branding --------------------------------------------------------------
233
296
  // The manifest is generated from configuration rather than served from a file, so
234
297
  // this is the one place a self-hoster gets to put their own name on the thing their
@@ -262,7 +325,7 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
262
325
 
263
326
  // --- runtime specifics -----------------------------------------------------
264
327
  if (isWorkers) {
265
- plan.workers = await askWorkers(prompter);
328
+ plan.workers = await askWorkers(prompter, { embeddingDim: plan.embeddingDim });
266
329
  } else {
267
330
  plan.server = { port: '8787', host: '0.0.0.0' };
268
331
  if (await prompter.section('Server', { key: 'server.enabled', blurb: 'Port and bind address.', default: false })) {
@@ -283,10 +346,12 @@ export async function askPlan(prompter, { name, version, runtime: preset }) {
283
346
  * the single most common way a first Workers deploy fails, and it fails at request time
284
347
  * rather than at deploy time.
285
348
  */
286
- async function askWorkers(prompter) {
349
+ async function askWorkers(prompter, { embeddingDim }) {
287
350
  const w = {
288
351
  d1: null, pluginDb: null, vectorize: null, ai: false, tasks: true,
289
- compatibilityDate: '2024-09-23',
352
+ // nodejs_compat v2 needs 2024-09-23 or later; that exact floor was also the hardcoded
353
+ // value, which made it two years stale on the day it shipped.
354
+ compatibilityDate: '2026-07-01',
290
355
  };
291
356
 
292
357
  if (await prompter.section('D1 (metadata)', { key: 'workers.d1.enabled',
@@ -309,7 +374,9 @@ async function askWorkers(prompter) {
309
374
  })) {
310
375
  w.vectorize = {
311
376
  index: await prompter.text(' Index name', { key: 'workers.vectorize.index', default: 'trove' }),
312
- dimensions: await prompter.text(' Dimensions', { key: 'workers.vectorize.dimensions', default: '1536', hint: 'must match your embedding model' }),
377
+ // Derived, not asked. It has exactly one correct value the dimension of the
378
+ // embedding chosen above — and getting it wrong fails silently.
379
+ dimensions: embeddingDim,
313
380
  metric: await prompter.choice(' Distance metric', [
314
381
  { value: 'cosine', label: 'cosine' },
315
382
  { value: 'euclidean', label: 'euclidean' },
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
+ `;
package/src/vapid.js ADDED
@@ -0,0 +1,49 @@
1
+ // A VAPID key pair, made locally.
2
+ //
3
+ // This is a copy of `generateVapidKeys` from @3sln/trove/core, and the duplication is
4
+ // deliberate: create-trove has no dependencies and runs through `npm create`, BEFORE
5
+ // the project it is writing has a node_modules. Pointing someone at a function inside a
6
+ // package they have not installed yet is not a hint, it is a dead end — which is what
7
+ // the first version of the push question did.
8
+ //
9
+ // The format is fixed by RFC 8292 and the Push API, not by us, so this cannot drift in
10
+ // any interesting way. `test/vapid.test.js` checks the pair against core's own
11
+ // implementation anyway, because "cannot drift" is a claim and that test is a fact.
12
+ //
13
+ // Worth being clear about what a VAPID key IS, because it decides how it should be
14
+ // handled: it identifies this application server to a push service. It is self-issued —
15
+ // no account, no registration, no network — which is what makes generating one here
16
+ // legitimate where minting an R2 access key would not be. The public half goes to
17
+ // browsers as `applicationServerKey` and ends up baked into every subscription made
18
+ // against it; the private half signs the JWT that authorises each push.
19
+
20
+ /** base64url, no padding. */
21
+ const b64url = (bytes) => btoa(String.fromCharCode(...bytes))
22
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
23
+
24
+ const unb64url = (s) => {
25
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
26
+ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
27
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
28
+ };
29
+
30
+ /**
31
+ * @returns {Promise<{publicKey: string, privateKey: string}>} both base64url.
32
+ * `publicKey` is the uncompressed EC point (0x04 || X || Y, 65 bytes) that
33
+ * PushManager.subscribe() wants; `privateKey` is the raw 32-byte scalar.
34
+ */
35
+ export async function generateVapidKeys() {
36
+ const pair = await crypto.subtle.generateKey(
37
+ { name: 'ECDSA', namedCurve: 'P-256' },
38
+ true,
39
+ ['sign', 'verify'],
40
+ );
41
+ const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey);
42
+ const x = unb64url(jwk.x);
43
+ const y = unb64url(jwk.y);
44
+ const point = new Uint8Array(65);
45
+ point[0] = 0x04;
46
+ point.set(x, 1);
47
+ point.set(y, 33);
48
+ return { publicKey: b64url(point), privateKey: jwk.d };
49
+ }