@webjsdev/server 0.8.14 → 0.8.16

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/index.js CHANGED
@@ -46,6 +46,7 @@ export { revalidateTag, revalidateTags } from './src/cache-tags.js';
46
46
  export { revalidatePath, revalidateAll } from './src/html-cache.js';
47
47
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
48
48
  export { broadcast } from './src/broadcast.js';
49
+ export { diskStore, setFileStore, getFileStore, generateKey, signedUrl, verifySignedUrl, assertSafeKey, DEFAULT_UPLOAD_DIR } from './src/file-storage.js';
49
50
  export { json, readBody } from './src/json.js';
50
51
  export { sitemap, sitemapIndex } from './src/sitemap.js';
51
52
  export { attachWebSocket } from './src/websocket.js';
package/package.json CHANGED
@@ -1,17 +1,28 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.14",
3
+ "version": "0.8.16",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
7
+ "types": "./index.d.ts",
7
8
  "exports": {
8
- ".": "./index.js",
9
- "./check": "./src/check.js",
10
- "./testing": "./src/testing.js",
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ },
13
+ "./check": {
14
+ "types": "./src/check.d.ts",
15
+ "default": "./src/check.js"
16
+ },
17
+ "./testing": {
18
+ "types": "./src/testing.d.ts",
19
+ "default": "./src/testing.js"
20
+ },
11
21
  "./webjs-config.schema.json": "./webjs-config.schema.json"
12
22
  },
13
23
  "files": [
14
24
  "index.js",
25
+ "index.d.ts",
15
26
  "src",
16
27
  "webjs-config.schema.json",
17
28
  "README.md"
package/src/actions.js CHANGED
@@ -151,9 +151,17 @@ async function rpcResponse(payload, init = {}) {
151
151
  *
152
152
  * @param {string} appDir
153
153
  * @param {boolean} dev
154
+ * @param {{ skipExposeLoad?: boolean }} [opts]
155
+ * `skipExposeLoad: true` builds the (load-free) `fileToHash` / `hashToFile`
156
+ * maps WITHOUT importing any `expose()`-referencing module, so `httpRoutes`
157
+ * stays empty. A read-only introspection caller (the MCP `list_actions` tool,
158
+ * #262) uses this to derive RPC endpoint hashes without running a server
159
+ * module's top-level side effects (Prisma init, DB connect) or risking a
160
+ * stray stdout write. The request pipeline keeps the default (loads expose
161
+ * routes, which the router must know before a request can hit them).
154
162
  * @returns {Promise<ActionIndex>}
155
163
  */
156
- export async function buildActionIndex(appDir, dev) {
164
+ export async function buildActionIndex(appDir, dev, opts = {}) {
157
165
  /** @type {Map<string,string>} */
158
166
  const hashToFile = new Map();
159
167
  /** @type {Map<string,string>} */
@@ -190,6 +198,9 @@ export async function buildActionIndex(appDir, dev) {
190
198
  // mention in a comment or string only over-matches, costing one harmless
191
199
  // extra module load; the common pure-RPC file never names `expose` and so
192
200
  // still defers entirely.
201
+ // A read-only caller (MCP introspection) only needs the file -> hash maps
202
+ // above, so skip the expose-load entirely (no module side effects).
203
+ if (opts.skipExposeLoad) continue;
193
204
  let src = '';
194
205
  try { src = await readFile(file, 'utf8'); } catch {}
195
206
  if (!/\bexpose\b/.test(src)) continue;
package/src/check.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Type overlay for `@webjsdev/server/check` (the convention validator backing
3
+ * `webjs check`, exported on its own subpath so the CLI can load it without
4
+ * booting the full server).
5
+ *
6
+ * The runtime is packages/server/src/check.js (JSDoc-annotated JavaScript);
7
+ * this overlay exists so a TypeScript consumer's `import { checkConventions }
8
+ * from '@webjsdev/server/check'` resolves precise types instead of emitting
9
+ * TS7016. Zero runtime cost.
10
+ */
11
+
12
+ /** A single convention violation reported by `checkConventions`. */
13
+ export interface Violation {
14
+ /** The rule name that fired (e.g. `no-browser-globals-in-render`). */
15
+ rule: string;
16
+ /** The offending file path. */
17
+ file: string;
18
+ /** What is wrong. */
19
+ message: string;
20
+ /** How to fix it. */
21
+ fix: string;
22
+ }
23
+
24
+ /** A rule's name + human description, for help text / docs. */
25
+ export interface RuleDescriptor {
26
+ name: string;
27
+ description: string;
28
+ }
29
+
30
+ /** All available correctness rules with descriptions. */
31
+ export declare const RULES: RuleDescriptor[];
32
+
33
+ /**
34
+ * Run every correctness check against an app directory, returning the list of
35
+ * violations (empty when the app is clean). Correctness-only; report-only, no
36
+ * autofix.
37
+ */
38
+ export declare function checkConventions(appDir: string): Promise<Violation[]>;
@@ -0,0 +1,466 @@
1
+ /**
2
+ * File storage primitive: a pluggable `FileStore` with a streaming local-disk
3
+ * default adapter (issue #247).
4
+ *
5
+ * webjs already round-trips a native `File` / `Blob` / `FormData` over the RPC
6
+ * wire (the serializer), but nothing decided WHERE the bytes land. This module
7
+ * is that answer. The model mirrors `cache.js` exactly: a documented interface,
8
+ * a default adapter (`diskStore`), and a module singleton (`setFileStore` /
9
+ * `getFileStore`) so an app swaps the backend in one call without touching any
10
+ * call site.
11
+ *
12
+ * ```js
13
+ * import { getFileStore, generateKey } from '@webjsdev/server';
14
+ * const key = generateKey(file.name); // opaque, traversal-safe
15
+ * await getFileStore().put(key, file); // streams to disk
16
+ * const handle = await getFileStore().get(key); // { body, size, contentType }
17
+ * ```
18
+ *
19
+ * Design notes:
20
+ *
21
+ * - **Streaming write.** `put` never buffers the whole file. It pipes
22
+ * `file.stream()` -> `Readable.fromWeb` -> `createWriteStream` via
23
+ * `node:stream/promises.pipeline`, so a large upload uses constant memory.
24
+ * The upstream body-size cap (#237, `maxMultipartBytes`) bounds the size
25
+ * BEFORE the bytes ever reach the store, so the store does not re-implement
26
+ * a limit; it only stays streaming.
27
+ * - **Traversal-safe keys.** Every key is resolved to an absolute path under
28
+ * `dir` and rejected if it escapes, using the same `resolve` +
29
+ * `startsWith(dir + sep)` containment guard the `/public/*` serve path uses
30
+ * in dev.js. A key with `..`, an absolute path, a leading slash, a NUL, or a
31
+ * backslash is rejected BEFORE any fs operation.
32
+ * - **S3-pluggability.** The interface operates on web-standard objects only
33
+ * (`File` / `Blob` / `ReadableStream` / `Uint8Array` in, a `{ body, size,
34
+ * contentType }` handle out), so an S3 / R2 / GCS adapter is a drop-in: it
35
+ * implements the same `put` / `get` / `delete` / `url` and the call sites do
36
+ * not change. webjs ships no S3 SDK; see the JSDoc on `FileStore` below.
37
+ *
38
+ * @module file-storage
39
+ */
40
+
41
+ import { createWriteStream, createReadStream } from 'node:fs';
42
+ import { mkdir, stat, unlink } from 'node:fs/promises';
43
+ import { dirname, join, resolve, sep, extname } from 'node:path';
44
+ import { Readable } from 'node:stream';
45
+ import { pipeline } from 'node:stream/promises';
46
+ import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
47
+
48
+ /**
49
+ * @typedef {Object} StoredObjectHandle
50
+ * @property {ReadableStream | import('node:stream').Readable} body
51
+ * The stored object's bytes as a STREAM (never the whole buffer), so a
52
+ * serving route can do `new Response(handle.body, { headers })` without
53
+ * reading the file into memory.
54
+ * @property {number} size Byte length of the stored object.
55
+ * @property {string} contentType The recorded MIME type (`application/octet-stream` when unknown).
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} PutResult
60
+ * @property {string} key The (validated) key the object was stored under.
61
+ * @property {number} size Byte length written.
62
+ * @property {string} contentType The recorded MIME type.
63
+ */
64
+
65
+ /**
66
+ * The pluggable file-storage interface. The default `diskStore` implements it
67
+ * for the local filesystem; an S3-compatible adapter (R2, GCS, MinIO) is a
68
+ * drop-in replacement because every method operates on web-standard objects and
69
+ * the call sites only ever touch this surface.
70
+ *
71
+ * An S3 adapter would implement the SAME four methods:
72
+ * - `put(key, file)`: `PutObject` (stream the body), returning `{ key, size, contentType }`.
73
+ * - `get(key)`: `GetObject`, returning `{ body, size, contentType }` (the SDK's response stream) or `null`.
74
+ * - `delete(key)`: `DeleteObject` (idempotent).
75
+ * - `url(key)`: the object / CDN URL (`https://cdn.example.com/<key>`).
76
+ * Because the shape is identical, `setFileStore(s3Store({ ... }))` switches the
77
+ * whole app with no call-site change.
78
+ *
79
+ * @typedef {Object} FileStore
80
+ * @property {(key: string, file: Blob | File | ReadableStream | Uint8Array, opts?: { contentType?: string }) => Promise<PutResult>} put
81
+ * Stream the bytes to storage under `key`. Captures size + content-type.
82
+ * @property {(key: string) => Promise<StoredObjectHandle | null>} get
83
+ * Return a streaming handle for the stored object, or `null` when absent.
84
+ * @property {(key: string) => Promise<void>} delete
85
+ * Remove the stored object. Idempotent (a missing key is not an error).
86
+ * @property {(key: string) => string} url
87
+ * The URL at which the stored object is served (`<baseUrl>/<key>` for `diskStore`).
88
+ * @property {(key: string) => Promise<boolean>} [has]
89
+ * Whether the key exists (optional).
90
+ */
91
+
92
+ /** Default storage root, relative to the app's cwd. gitignore-friendly. */
93
+ export const DEFAULT_UPLOAD_DIR = '.webjs/uploads';
94
+
95
+ /** Reserved suffix for the per-object content-type sidecar (`<key>.meta`). */
96
+ const META_SUFFIX = '.meta';
97
+
98
+ /**
99
+ * The extension whitelist for `generateKey`. Only these (lowercased) extensions
100
+ * are preserved on a generated key; anything else yields an extensionless key.
101
+ * The point is that a generated key is opaque + safe, NOT that it round-trips an
102
+ * arbitrary filename. This is a conservative set of common, inert media / doc
103
+ * extensions; an app needing more can pass its own key.
104
+ */
105
+ const SAFE_EXTENSIONS = new Set([
106
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'ico', 'bmp',
107
+ 'pdf', 'txt', 'md', 'csv', 'json', 'xml',
108
+ 'mp3', 'wav', 'ogg', 'mp4', 'webm', 'mov', 'm4a',
109
+ 'zip', 'gz',
110
+ 'woff', 'woff2', 'ttf', 'otf',
111
+ 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
112
+ ]);
113
+
114
+ /**
115
+ * Validate a storage key and reject anything that could escape the root or is
116
+ * otherwise unsafe. Throws on a bad key BEFORE any filesystem operation; this is
117
+ * the security-critical seam. Mirrors the `/public/*` containment guard
118
+ * (`resolve` + `startsWith(root + sep)`) in dev.js.
119
+ *
120
+ * Rejected: an empty / non-string key, a key with a NUL byte, an absolute path,
121
+ * a leading slash or backslash, any `..` segment, any backslash (Windows
122
+ * separator), and ANY key that does not resolve to a path strictly under `dir`.
123
+ *
124
+ * @param {string} dir The (already absolute) storage root.
125
+ * @param {string} key The caller-supplied key.
126
+ * @returns {string} The absolute path the key resolves to, guaranteed under `dir`.
127
+ */
128
+ export function assertSafeKey(dir, key) {
129
+ if (typeof key !== 'string' || key.length === 0) {
130
+ throw new Error('file-storage: key must be a non-empty string');
131
+ }
132
+ // A NUL byte truncates a C string, so a key like `a\0.txt` can desync the
133
+ // path the JS layer validated from the path the syscall opens. Reject it.
134
+ if (key.includes('\0')) {
135
+ throw new Error('file-storage: key must not contain a NUL byte');
136
+ }
137
+ // A backslash is a path separator on Windows and a traversal vector
138
+ // (`..\\win`), so reject it outright rather than reason about platform.
139
+ if (key.includes('\\')) {
140
+ throw new Error('file-storage: key must not contain a backslash');
141
+ }
142
+ // An absolute key or a leading slash would escape `dir` under join/resolve.
143
+ if (key.startsWith('/') || /^[A-Za-z]:/.test(key)) {
144
+ throw new Error('file-storage: key must be relative (no leading slash or drive)');
145
+ }
146
+ // Reject any `..` traversal segment up front for a clear error, even though
147
+ // the containment check below would also catch it.
148
+ const segments = key.split('/');
149
+ if (segments.some((s) => s === '..')) {
150
+ throw new Error('file-storage: key must not contain a ".." segment');
151
+ }
152
+ // The `.meta` suffix is RESERVED for the content-type sidecar `put` writes
153
+ // (`<key>.meta`). Rejecting it eliminates the only namespace collision: a user
154
+ // storing both `foo` and `foo.meta` would otherwise have `foo`'s sidecar
155
+ // clobber the `foo.meta` object. `generateKey` never produces a `.meta` key.
156
+ if (key.endsWith(META_SUFFIX)) {
157
+ throw new Error(`file-storage: key must not end in "${META_SUFFIX}" (reserved for metadata)`);
158
+ }
159
+ // Final containment guard: resolve to an absolute path and confirm it stays
160
+ // strictly under `dir`. This is the authoritative check (the rules above are
161
+ // fast clear-error rejections; this one cannot be fooled).
162
+ const abs = resolve(dir, key);
163
+ const root = dir.endsWith(sep) ? dir : dir + sep;
164
+ if (abs !== dir && !abs.startsWith(root)) {
165
+ throw new Error('file-storage: key resolves outside the storage root');
166
+ }
167
+ // `abs === dir` means the key resolved to the root directory itself (e.g. an
168
+ // empty-ish key), which is not a file path.
169
+ if (abs === dir || abs === dir.replace(/[\\/]+$/, '')) {
170
+ throw new Error('file-storage: key must name a file, not the storage root');
171
+ }
172
+ return abs;
173
+ }
174
+
175
+ /**
176
+ * Normalize an arbitrary input (a `Blob` / `File` / `ReadableStream` /
177
+ * `Uint8Array`) into a Node `Readable` stream plus an optional content type,
178
+ * WITHOUT buffering the whole thing. This keeps the write path streaming.
179
+ *
180
+ * @param {Blob | File | ReadableStream | Uint8Array} file
181
+ * @returns {{ stream: import('node:stream').Readable, contentType: string | null }}
182
+ */
183
+ function toNodeStream(file) {
184
+ // A Blob / File exposes a web ReadableStream via `.stream()`; converting it
185
+ // with Readable.fromWeb keeps it streaming (no `.arrayBuffer()`).
186
+ if (file && typeof file.stream === 'function' && typeof file.size === 'number') {
187
+ const contentType = typeof file.type === 'string' && file.type ? file.type : null;
188
+ return { stream: Readable.fromWeb(file.stream()), contentType };
189
+ }
190
+ // A web ReadableStream directly.
191
+ if (file && typeof file.getReader === 'function') {
192
+ return { stream: Readable.fromWeb(/** @type {ReadableStream} */ (file)), contentType: null };
193
+ }
194
+ // A Uint8Array / Buffer: wrap as a single-chunk readable. This DOES hold the
195
+ // bytes the caller already has in memory, but it does not COPY-buffer them
196
+ // (the caller already chose an in-memory input), and the write still streams.
197
+ if (file instanceof Uint8Array) {
198
+ return { stream: Readable.from([Buffer.from(file.buffer, file.byteOffset, file.byteLength)]), contentType: null };
199
+ }
200
+ throw new Error('file-storage: put() expects a Blob, File, ReadableStream, or Uint8Array');
201
+ }
202
+
203
+ /**
204
+ * Local-disk file store (the default adapter). Streams writes, never buffering
205
+ * the whole file. `dir` is created on demand.
206
+ *
207
+ * @param {{ dir?: string, baseUrl?: string }} [opts]
208
+ * `dir` is the storage root (default `<cwd>/.webjs/uploads`). `baseUrl` is the
209
+ * path prefix `url(key)` returns (default `/uploads`); the app serves that
210
+ * prefix from a `route.{js,ts}` handler that streams `get(key)`.
211
+ * @returns {FileStore}
212
+ */
213
+ export function diskStore(opts = {}) {
214
+ const dir = resolve(opts.dir || join(process.cwd(), DEFAULT_UPLOAD_DIR));
215
+ // Normalize baseUrl to `/prefix` with no trailing slash.
216
+ let baseUrl = opts.baseUrl == null ? '/uploads' : String(opts.baseUrl);
217
+ if (!baseUrl.startsWith('/')) baseUrl = '/' + baseUrl;
218
+ baseUrl = baseUrl.replace(/\/+$/, '');
219
+
220
+ return {
221
+ async put(key, file, putOpts = {}) {
222
+ const abs = assertSafeKey(dir, key);
223
+ const { stream, contentType: detected } = toNodeStream(file);
224
+ const contentType = putOpts.contentType || detected || 'application/octet-stream';
225
+ // Ensure the parent directory exists (supports nested keys like `a/b/c.png`).
226
+ await mkdir(dirname(abs), { recursive: true });
227
+ // Streaming write: pipe the source through to disk. `pipeline` handles
228
+ // backpressure + teardown, so memory stays bounded regardless of size.
229
+ try {
230
+ await pipeline(stream, createWriteStream(abs));
231
+ } catch (err) {
232
+ // A mid-stream failure (source error, disk full) leaves a truncated
233
+ // file. Remove it so a later read never serves a partial object under a
234
+ // key the caller never received, then re-throw.
235
+ try { await unlink(abs); } catch {}
236
+ throw err;
237
+ }
238
+ const { size } = await stat(abs);
239
+ // Record the content type alongside the bytes so `get` can return it. A
240
+ // sidecar `<file>.meta` JSON keeps the object itself byte-for-byte the
241
+ // upload (so it serves cleanly) while still capturing the type.
242
+ try {
243
+ const metaAbs = abs + META_SUFFIX;
244
+ await pipeline(
245
+ Readable.from([JSON.stringify({ contentType })]),
246
+ createWriteStream(metaAbs),
247
+ );
248
+ } catch {
249
+ // A meta-write failure is non-fatal: `get` falls back to octet-stream.
250
+ }
251
+ return { key, size, contentType };
252
+ },
253
+
254
+ async get(key) {
255
+ const abs = assertSafeKey(dir, key);
256
+ let info;
257
+ try {
258
+ info = await stat(abs);
259
+ } catch {
260
+ return null;
261
+ }
262
+ if (!info.isFile()) return null;
263
+ let contentType = 'application/octet-stream';
264
+ try {
265
+ const metaAbs = abs + META_SUFFIX;
266
+ const metaInfo = await stat(metaAbs);
267
+ if (metaInfo.isFile()) {
268
+ // The meta sidecar is tiny (a JSON object with a content type), so a
269
+ // small read here is fine and does not touch the object's bytes.
270
+ const { readFile } = await import('node:fs/promises');
271
+ const parsed = JSON.parse(await readFile(metaAbs, 'utf8'));
272
+ if (parsed && typeof parsed.contentType === 'string') contentType = parsed.contentType;
273
+ }
274
+ } catch {
275
+ // No / unreadable meta: keep the octet-stream default.
276
+ }
277
+ // The body is a STREAM, so a serving route streams it to the client
278
+ // without reading the whole file into memory.
279
+ return {
280
+ body: Readable.toWeb(createReadStream(abs)),
281
+ size: info.size,
282
+ contentType,
283
+ };
284
+ },
285
+
286
+ async delete(key) {
287
+ const abs = assertSafeKey(dir, key);
288
+ // Idempotent: a missing file is not an error.
289
+ try { await unlink(abs); } catch {}
290
+ try { await unlink(abs + META_SUFFIX); } catch {}
291
+ },
292
+
293
+ async has(key) {
294
+ const abs = assertSafeKey(dir, key);
295
+ try {
296
+ const info = await stat(abs);
297
+ return info.isFile();
298
+ } catch {
299
+ return false;
300
+ }
301
+ },
302
+
303
+ url(key) {
304
+ // Validate the key so `url()` cannot mint a traversal path either.
305
+ assertSafeKey(dir, key);
306
+ return `${baseUrl}/${key}`;
307
+ },
308
+ };
309
+ }
310
+
311
+ /** @type {FileStore | null} */
312
+ let _defaultFileStore = null;
313
+
314
+ /**
315
+ * Get the default file store. A `diskStore` rooted at `<cwd>/.webjs/uploads`
316
+ * unless explicitly set via `setFileStore()`. No auto-detection: the app
317
+ * decides. (Add the uploads directory to `.gitignore`.)
318
+ *
319
+ * @returns {FileStore}
320
+ */
321
+ export function getFileStore() {
322
+ if (!_defaultFileStore) _defaultFileStore = diskStore();
323
+ return _defaultFileStore;
324
+ }
325
+
326
+ /**
327
+ * Set the default file store. Call at app startup to point uploads at a custom
328
+ * directory or an S3-compatible backend:
329
+ *
330
+ * ```js
331
+ * import { setFileStore, diskStore } from '@webjsdev/server';
332
+ * setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
333
+ * ```
334
+ *
335
+ * @param {FileStore} store
336
+ */
337
+ export function setFileStore(store) {
338
+ _defaultFileStore = store;
339
+ }
340
+
341
+ /**
342
+ * Generate a random, opaque, traversal-safe storage key, preserving a sanitized
343
+ * (whitelisted) extension from `filename` when present. This is the RECOMMENDED
344
+ * way to derive a key: never trust a user-supplied filename as a key directly.
345
+ *
346
+ * A malicious `filename` (`'../../x.sh'`, `'a/b.png'`, `'evil\0.png'`) yields a
347
+ * fully opaque `<uuid>` or `<uuid>.<ext>` key with no path component and only a
348
+ * whitelisted extension, so the result is always safe to pass to `put`.
349
+ *
350
+ * @param {string} [filename] The original filename (its extension may be preserved).
351
+ * @returns {string} A `<crypto.randomUUID()>` key, with a `.<ext>` suffix when the
352
+ * original extension is whitelisted.
353
+ */
354
+ export function generateKey(filename) {
355
+ const id = randomUUID();
356
+ if (typeof filename !== 'string' || !filename) return id;
357
+ // Take only the basename's extension; `extname` ignores directory parts, so a
358
+ // path-y filename cannot inject a separator. Strip the leading dot, lowercase,
359
+ // and accept ONLY a whitelisted alnum extension.
360
+ const ext = extname(filename).replace(/^\./, '').toLowerCase();
361
+ if (ext && /^[a-z0-9]+$/.test(ext) && SAFE_EXTENSIONS.has(ext)) {
362
+ return `${id}.${ext}`;
363
+ }
364
+ return id;
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Signed URLs (HMAC-SHA256 over key + expiry, base64url). A serving route can
369
+ // gate access without a session lookup: it verifies the signature, then streams
370
+ // `get(key)`. Minimal + standards-based; no library.
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /** base64url-encode a Buffer (no padding, URL-safe alphabet). */
374
+ function b64url(buf) {
375
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
376
+ }
377
+
378
+ /** Compute the HMAC-SHA256 signature (base64url) over `key.exp`. */
379
+ function signKeyExp(key, exp, secret) {
380
+ return b64url(createHmac('sha256', secret).update(`${key}.${exp}`).digest());
381
+ }
382
+
383
+ /**
384
+ * Mint a signed, expiring URL for a stored object. The signature covers the
385
+ * exact key AND the expiry, so neither can be tampered with. The returned string
386
+ * is `<base>?key=<key>&exp=<unixSeconds>&sig=<base64url>` (base defaults to
387
+ * `getFileStore().url(key)`, so it lands on the app's serving route).
388
+ *
389
+ * @param {string} key The storage key.
390
+ * @param {{ secret: string, expiresIn?: number, base?: string }} opts
391
+ * `secret` signs the URL; `expiresIn` is seconds from now (default 3600);
392
+ * `base` is the URL path the query is appended to (default `getFileStore().url(key)`).
393
+ * @returns {string} The signed URL.
394
+ */
395
+ export function signedUrl(key, opts) {
396
+ if (!opts || !opts.secret) throw new Error('signedUrl: a secret is required');
397
+ if (typeof key !== 'string' || !key) throw new Error('signedUrl: a key is required');
398
+ // Default to 1 hour ONLY when expiresIn is omitted. An explicit value is
399
+ // honored literally, so `0` / a negative number fails CLOSED (the minted exp
400
+ // is at or before now, so the URL is already expired) instead of silently
401
+ // granting a 1-hour URL.
402
+ const expiresIn = typeof opts.expiresIn === 'number' && Number.isFinite(opts.expiresIn)
403
+ ? opts.expiresIn
404
+ : 3600;
405
+ const exp = Math.floor(Date.now() / 1000) + Math.floor(expiresIn);
406
+ const sig = signKeyExp(key, exp, opts.secret);
407
+ const base = opts.base != null ? opts.base : getFileStore().url(key);
408
+ const sep2 = base.includes('?') ? '&' : '?';
409
+ return `${base}${sep2}key=${encodeURIComponent(key)}&exp=${exp}&sig=${sig}`;
410
+ }
411
+
412
+ /**
413
+ * Verify a signed URL (or its parsed params). Returns `{ valid, key, reason }`.
414
+ * Fails on a wrong secret (signature mismatch), an expired URL, a tampered key,
415
+ * or missing params. The comparison is constant-time.
416
+ *
417
+ * @param {string | URL | URLSearchParams | { key?: string, exp?: string | number, sig?: string }} input
418
+ * A full URL string, a `URL`, a `URLSearchParams`, or a `{ key, exp, sig }` object.
419
+ * @param {string} secret The same secret passed to `signedUrl`.
420
+ * @returns {{ valid: boolean, key: string | null, reason?: string }}
421
+ */
422
+ export function verifySignedUrl(input, secret) {
423
+ if (!secret) return { valid: false, key: null, reason: 'no secret' };
424
+ let key = null;
425
+ let exp = null;
426
+ let sig = null;
427
+ try {
428
+ if (typeof input === 'string') {
429
+ // Parse against a dummy base so a bare path string works too.
430
+ const u = new URL(input, 'http://x.invalid');
431
+ key = u.searchParams.get('key');
432
+ exp = u.searchParams.get('exp');
433
+ sig = u.searchParams.get('sig');
434
+ } else if (input instanceof URL) {
435
+ key = input.searchParams.get('key');
436
+ exp = input.searchParams.get('exp');
437
+ sig = input.searchParams.get('sig');
438
+ } else if (input instanceof URLSearchParams) {
439
+ key = input.get('key');
440
+ exp = input.get('exp');
441
+ sig = input.get('sig');
442
+ } else if (input && typeof input === 'object') {
443
+ key = input.key != null ? String(input.key) : null;
444
+ exp = input.exp != null ? String(input.exp) : null;
445
+ sig = input.sig != null ? String(input.sig) : null;
446
+ }
447
+ } catch {
448
+ return { valid: false, key: null, reason: 'unparseable' };
449
+ }
450
+ if (!key || !exp || !sig) return { valid: false, key: null, reason: 'missing params' };
451
+ const expNum = Number(exp);
452
+ if (!Number.isFinite(expNum)) return { valid: false, key: null, reason: 'bad expiry' };
453
+ // `>=` so an exp at the current second is expired: a URL minted with
454
+ // `expiresIn: 0` (exp === now) fails closed instead of being valid for the
455
+ // remainder of the current second.
456
+ if (Math.floor(Date.now() / 1000) >= expNum) return { valid: false, key, reason: 'expired' };
457
+ // Recompute the expected signature over the SAME key + exp and compare in
458
+ // constant time. A tampered key changes the expected signature, so it fails.
459
+ const expected = signKeyExp(key, expNum, secret);
460
+ const a = Buffer.from(sig);
461
+ const b = Buffer.from(expected);
462
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
463
+ return { valid: false, key, reason: 'signature mismatch' };
464
+ }
465
+ return { valid: true, key };
466
+ }