@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 +1 -1
- package/src/cli.js +1 -0
- package/src/index.js +2 -1
- package/src/plan.js +74 -7
- package/src/render.js +258 -13
- package/src/templates/localS3.js +259 -0
- package/src/templates/vapid.js +48 -0
- package/src/vapid.js +49 -0
package/package.json
CHANGED
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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: {
|
|
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: '^
|
|
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
|
-
|
|
150
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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({
|
|
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 ?? '
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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 ".
|
|
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
|
+
}
|