@bakery-framework/core 1.2.3 → 2.0.0-alpha.11
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 +2 -2
- package/src/cache/shared-db.ts +7 -9
- package/src/client/globals.d.ts +1 -1
- package/src/client/utils.ts +41 -9
- package/src/compiler/compiler.ts +458 -39
- package/src/compiler/prompt-tracker.ts +19 -2
- package/src/compiler/tsconfig-sync.ts +116 -45
- package/src/core/bakery.ts +11 -12
- package/src/core/context.ts +42 -8
- package/src/core/index.ts +18 -0
- package/src/core/init.ts +40 -31
- package/src/global.d.ts +24 -6
- package/src/handlers/assets/nm.ts +74 -15
- package/src/handlers/assets/static.ts +53 -10
- package/src/handlers/core/$base.ts +71 -8
- package/src/handlers/core/$dynamic.ts +21 -10
- package/src/handlers/core/$error.ts +10 -1
- package/src/handlers/core/$registry.ts +31 -2
- package/src/handlers/core/$routing.ts +82 -11
- package/src/handlers/routes/api.ts +2 -2
- package/src/handlers/routes/proxy.ts +2 -1
- package/src/logger/serve-log.ts +21 -0
- package/src/plugins/types.ts +7 -5
- package/src/router.ts +5 -5
- package/src/session.ts +0 -3
- package/src/shared.d.ts +7 -0
- package/src/types.d.ts +11 -0
- package/src/utils/fs.ts +29 -0
- package/src/utils/http/authorize.ts +136 -0
- package/src/utils/http/body.ts +2 -2
- package/src/utils/http/credential.ts +70 -0
- package/src/utils/http/dom.ts +73 -64
- package/src/utils/http/etag.ts +27 -0
- package/src/utils/http/html.ts +16 -5
- package/src/utils/http/index.ts +3 -0
- package/src/utils/http/url.ts +35 -0
- package/src/utils/isomorphic/misc.ts +16 -0
|
@@ -2,6 +2,7 @@ import { Bakery } from '../../core/bakery'
|
|
|
2
2
|
import { handlerLog } from '../../logger/serve-log'
|
|
3
3
|
import { FileSystem } from '../../utils/fs'
|
|
4
4
|
import { checkCsrf, response } from '../../utils/http'
|
|
5
|
+
import { parsedUrl } from '../../utils/http/url'
|
|
5
6
|
import type { Handler } from '../core/$base'
|
|
6
7
|
import { bustInDev, DynamicHandler } from '../core/$dynamic'
|
|
7
8
|
import { ErrorHandler } from '../core/$error'
|
|
@@ -29,8 +30,7 @@ export class ApiHandler extends DynamicHandler {
|
|
|
29
30
|
static async handle(path: string, req: Request) {
|
|
30
31
|
// State-changing methods must be same-origin. SameSite=Lax alone does not
|
|
31
32
|
// cover this: a cross-site form POST is a CORS-simple request.
|
|
32
|
-
const
|
|
33
|
-
const csrf = checkCsrf(req, url)
|
|
33
|
+
const csrf = checkCsrf(req, parsedUrl(req))
|
|
34
34
|
if (csrf) return response.json.error(403, csrf) as unknown as Response
|
|
35
35
|
|
|
36
36
|
const info = await this.resolveRoute(path)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Bakery } from '../../core/bakery'
|
|
2
2
|
import { handlerLog } from '../../logger'
|
|
3
3
|
import { response } from '../../utils/http'
|
|
4
|
+
import { parsedUrl } from '../../utils/http/url'
|
|
4
5
|
import { Handler } from '../core/$base'
|
|
5
6
|
|
|
6
7
|
export class ProxyHandler extends Handler {
|
|
@@ -30,7 +31,7 @@ export class ProxyHandler extends Handler {
|
|
|
30
31
|
baseTarget +
|
|
31
32
|
(trailingPath.startsWith('/') ? '' : '/') +
|
|
32
33
|
trailingPath +
|
|
33
|
-
(
|
|
34
|
+
parsedUrl(req).search
|
|
34
35
|
break
|
|
35
36
|
}
|
|
36
37
|
|
package/src/logger/serve-log.ts
CHANGED
|
@@ -27,6 +27,12 @@ const serveMsgs = {
|
|
|
27
27
|
'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
|
|
28
28
|
TSCONFIG_PROJECTS_WRITTEN:
|
|
29
29
|
'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
|
|
30
|
+
// One-time repair. Previous releases wired the generated projects into the
|
|
31
|
+
// app's tsconfig.json as `references`, which broke `tsc -p <app>`
|
|
32
|
+
// (TS6305/6306/6310). Named so the rewrite of a tracked file comes with a
|
|
33
|
+
// line saying why it happened.
|
|
34
|
+
TSCONFIG_REFERENCES_REMOVED:
|
|
35
|
+
'I Removed generated %yreferences%* from %ytsconfig.json%* — the %y.cache/tsconfig/%* projects are standalone, and referencing them broke %ytsc -p%*',
|
|
30
36
|
// A plugin asking for a project name that is taken. Named rather than
|
|
31
37
|
// silent: the symptom otherwise is one plugin's types quietly not applying,
|
|
32
38
|
// discovered much later and blamed on the wrong thing.
|
|
@@ -98,6 +104,21 @@ const handlerMsgs = {
|
|
|
98
104
|
PROXY_REQ: 'I Proxying %y{path}%* -> %b{target}%*',
|
|
99
105
|
MIDDLEWARE_ERR: 'E Middleware error: %r{error}%*',
|
|
100
106
|
BUNDLE_ERR: 'E Failed to bundle module (%y{file}%*): %r{error}%*',
|
|
107
|
+
// **Keep each message one unbroken string literal.** Splitting a long one into
|
|
108
|
+
// `'…' + '…'` types as `string`, so `as const` preserves nothing,
|
|
109
|
+
// `messageLogger` extracts no `{placeholder}`, and every call site fails with
|
|
110
|
+
// "Expected 0 arguments, but got 1". Wrapping the value onto its own line, as
|
|
111
|
+
// below, is fine; joining with `+` is not.
|
|
112
|
+
BUNDLE_SIDE_EFFECTS_REPAIRED:
|
|
113
|
+
'I %y{file}%* tree-shook to an empty export list because its package declares %ysideEffects: false%*; re-bundled through a re-export shim, which keeps the code.',
|
|
114
|
+
BUNDLE_EMPTY_EXPORTS:
|
|
115
|
+
'E %y{file}%* bundled to an export list with no code behind it — every name it exports is undefined, so it is rejected rather than served. The bundler reported success, and re-bundling through a re-export shim did not recover it. Import the specific module you need instead of the package root.',
|
|
116
|
+
BUNDLE_CJS_INTEROP:
|
|
117
|
+
'I Generated named exports for the CommonJS package %y{file}%*, so a named import of it works in the browser.',
|
|
118
|
+
BUNDLE_CJS_PROBED:
|
|
119
|
+
'I Could not read %y{file}%* export names statically, so they were probed by importing it in a short-lived child process.',
|
|
120
|
+
BUNDLE_CJS_DEFAULT_ONLY:
|
|
121
|
+
'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault%* — a named import from it fails in the browser with "does not provide an export named …", and nothing fails here. Import the default and read the property off it, or use an ESM build.',
|
|
101
122
|
} as const
|
|
102
123
|
|
|
103
124
|
export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
|
package/src/plugins/types.ts
CHANGED
|
@@ -6,11 +6,13 @@ export type ValidResponses = Handler.Response
|
|
|
6
6
|
/**
|
|
7
7
|
* A TypeScript project a plugin contributes to the app.
|
|
8
8
|
*
|
|
9
|
-
* Written to `.cache/tsconfig/<name>.json` on every dev boot
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* Written to `.cache/tsconfig/<name>.json` on every dev boot, as a standalone
|
|
10
|
+
* project invoked directly (`vue-tsc -p .cache/tsconfig/vue.json`). It is
|
|
11
|
+
* deliberately *not* referenced from the app's root `tsconfig.json` — a
|
|
12
|
+
* `references` entry to an unbuilt `noEmit` project makes `tsc -p <app>` fail
|
|
13
|
+
* (TS6305/6306/6310; see `compiler/tsconfig-sync.ts`). Regenerated rather
|
|
14
|
+
* than committed: it is derived from the plugin list and the app's config, and
|
|
15
|
+
* `.cache/` is the disposable half of the two runtime directories.
|
|
14
16
|
*/
|
|
15
17
|
export interface PluginTsProject {
|
|
16
18
|
/** File name under `.cache/tsconfig/`, and the project's identity. */
|
package/src/router.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
withStatus,
|
|
20
20
|
} from './utils/http'
|
|
21
21
|
import { applyCors, preflightResponse } from './utils/http/cors'
|
|
22
|
+
import { parsedUrl } from './utils/http/url'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Resolve a WebSocket upgrade, refusing cross-origin handshakes first.
|
|
@@ -39,7 +40,7 @@ export async function upgradeWebsocket(
|
|
|
39
40
|
req: Request,
|
|
40
41
|
path: string,
|
|
41
42
|
): Promise<boolean | undefined> {
|
|
42
|
-
const url
|
|
43
|
+
const url = parsedUrl(req)
|
|
43
44
|
const denied = checkWebSocketOrigin(req, url)
|
|
44
45
|
if (denied) {
|
|
45
46
|
serveLog.WEBSOCKET_ERR({
|
|
@@ -85,10 +86,9 @@ export function handleRequest(
|
|
|
85
86
|
req: Request,
|
|
86
87
|
): Handler.Response | MixedPromise<symbol>
|
|
87
88
|
export async function handleRequest(req: Request) {
|
|
88
|
-
// worker.ts
|
|
89
|
-
// direct
|
|
90
|
-
const url
|
|
91
|
-
;(req as any).__parsedUrl = url
|
|
89
|
+
// Shared with worker.ts, which has already asked for the same parse; a
|
|
90
|
+
// direct caller (test, embedder) gets it parsed here instead.
|
|
91
|
+
const url = parsedUrl(req)
|
|
92
92
|
const path = url.pathname
|
|
93
93
|
|
|
94
94
|
// One read of the config getter, not three: `Bakery.serveRoot` walks
|
package/src/session.ts
CHANGED
|
@@ -12,9 +12,6 @@ import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
|
|
|
12
12
|
*/
|
|
13
13
|
export const RESERVED_SESSION_PREFIX = '__bakery.'
|
|
14
14
|
|
|
15
|
-
/** Marks a session as having passed the DASHPASS check. */
|
|
16
|
-
export const DASHPASS_SESSION_KEY = `${RESERVED_SESSION_PREFIX}dashpass`
|
|
17
|
-
|
|
18
15
|
export function isReservedSessionKey(key: string): boolean {
|
|
19
16
|
return key.startsWith(RESERVED_SESSION_PREFIX)
|
|
20
17
|
}
|
package/src/shared.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ import type { MapOf } from './types'
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
declare global {
|
|
28
|
+
/**
|
|
29
|
+
* Bound by `client/utils.ts` in the browser and `core/init.ts` on the
|
|
30
|
+
* server — the same isomorphic implementation either side, so code moving
|
|
31
|
+
* between an SFC browser script and a server block keeps the name.
|
|
32
|
+
*/
|
|
33
|
+
var randomId: typeof import('./utils/isomorphic/misc').randomId
|
|
34
|
+
|
|
28
35
|
/** The one JSON envelope — see convention 7. */
|
|
29
36
|
type JsonResponse<T = any> = {
|
|
30
37
|
time: number
|
package/src/types.d.ts
CHANGED
|
@@ -65,6 +65,17 @@ export type Match<D extends symbol> = {
|
|
|
65
65
|
*/
|
|
66
66
|
export type RouteBody<P = {}> = P & MapOf<any>
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* What one dynamic route segment binds: `[id]` a string, `[...rest]` and
|
|
70
|
+
* `[...rest!]` an array of the remaining segments (`[]` for the bare
|
|
71
|
+
* directory under the `!` form).
|
|
72
|
+
*
|
|
73
|
+
* Exists so a route over mixed or unknown segments can say
|
|
74
|
+
* `defineRoute<MapOf<RouteParam>>` instead of hand-writing the union — and so
|
|
75
|
+
* the union has one definition to change if it ever grows.
|
|
76
|
+
*/
|
|
77
|
+
export type RouteParam = string | string[]
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* What a route module may actually return — read off `processResponse`
|
|
70
81
|
* (`router.ts`) and `ApiHandler.handle`: a `Response`, a `BunFile` (streamed
|
package/src/utils/fs.ts
CHANGED
|
@@ -395,6 +395,35 @@ export namespace FileSystem {
|
|
|
395
395
|
let curr = safeResolve(pathToCheck)
|
|
396
396
|
const normalizedRoot = safeResolve(root)
|
|
397
397
|
|
|
398
|
+
// A path outside `root` is forbidden, and this is the clause that says so.
|
|
399
|
+
//
|
|
400
|
+
// The walk below is bounded by `curr.startsWith(normalizedRoot)`, so an
|
|
401
|
+
// out-of-root path skipped the loop body entirely and fell through to
|
|
402
|
+
// `return false` — "not forbidden". That is a guard failing *open* on the
|
|
403
|
+
// one input it most needs to refuse, and it made every caller that relied
|
|
404
|
+
// on this for containment (all of them: `constants.ts` said so in as many
|
|
405
|
+
// words) incapable of catching an escape.
|
|
406
|
+
//
|
|
407
|
+
// Not theoretical. `$routing.ts` builds its route globs with `\*` to mean a
|
|
408
|
+
// literal asterisk; on Windows a backslash is a path *separator*, so Bun
|
|
409
|
+
// read the pattern as drive-absolute, ignored `cwd`, and matched files at
|
|
410
|
+
// `C:\`. `getRoute` resolved one, asked this function, was told "allowed",
|
|
411
|
+
// and returned an `Info` pointing six levels above the serve root. The glob
|
|
412
|
+
// is fixed too, but the glob was only how it was reached — a resolved file
|
|
413
|
+
// outside the root has to be refused here whatever produced it.
|
|
414
|
+
//
|
|
415
|
+
// The separator suffix matters: a bare `startsWith` would also accept a
|
|
416
|
+
// sibling directory whose name merely begins with the root's. A root that
|
|
417
|
+
// is already a filesystem root ends in `/` and must not gain a second one —
|
|
418
|
+
// `C://` matches nothing, which would make every path under `C:/` read as
|
|
419
|
+
// an escape. `fs.test.ts` drives both through its root corpus.
|
|
420
|
+
const rootPrefix = normalizedRoot.endsWith('/')
|
|
421
|
+
? normalizedRoot
|
|
422
|
+
: `${normalizedRoot}/`
|
|
423
|
+
if (curr !== normalizedRoot && !curr.startsWith(rootPrefix)) {
|
|
424
|
+
return true
|
|
425
|
+
}
|
|
426
|
+
|
|
398
427
|
const store = hostStore.getStore()
|
|
399
428
|
const seen = store ? (store.forbiddenProbes ??= new Map()) : null
|
|
400
429
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-predicate half of plugin authorization.
|
|
3
|
+
*
|
|
4
|
+
* One implementation, deliberately in core, for the same reason
|
|
5
|
+
* `credential.ts` is here: the dashboard, db-explorer and analytics plugins
|
|
6
|
+
* each need this guard, and per-plugin copies of security code drift. These
|
|
7
|
+
* three had already drifted — one coerced its predicate's return with
|
|
8
|
+
* `Boolean`, one gated on `!DEV` where the others read `PROD`, one swallowed a
|
|
9
|
+
* `getClientIp` throw into an empty string and carried on. Each divergence is
|
|
10
|
+
* resolved below toward the fail-closed reading, and each is marked as a
|
|
11
|
+
* decision rather than left to look like a style choice.
|
|
12
|
+
*
|
|
13
|
+
* It owns *only* the predicate — no logins, no sessions, no backoff. The shared
|
|
14
|
+
* key path is `credential.ts`; a plugin that offers both doors composes them.
|
|
15
|
+
*
|
|
16
|
+
* `getClientIp` is imported from `./ip` directly, never through this
|
|
17
|
+
* directory's barrel or `@bakery-framework/core`: a core module that reaches for a
|
|
18
|
+
* barrel closes an import cycle, which is how 67 tests once failed with
|
|
19
|
+
* `ReferenceError: Cannot access 'Logger' before initialization`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { getClientIp } from './ip'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A host application's access predicate. Returning `true` admits; returning
|
|
26
|
+
* anything else, or throwing, denies (convention 2).
|
|
27
|
+
*
|
|
28
|
+
* The framework authenticates nobody. The application, which already knows who
|
|
29
|
+
* its users are, supplies this.
|
|
30
|
+
*/
|
|
31
|
+
export type AuthorizeFn = (req: Request) => boolean | Promise<boolean>
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Addresses only, never hostnames. Module-private: neither plugin copy
|
|
35
|
+
* exported it, and the membership test is the whole of the useful surface.
|
|
36
|
+
*
|
|
37
|
+
* `'localhost'` used to be a member, back when the request's *hostname* was
|
|
38
|
+
* compared against this set as well. A peer address is never the string
|
|
39
|
+
* `localhost`, and accepting it meant `X-Forwarded-For: localhost` counted as
|
|
40
|
+
* loopback under `trustProxy` — and, worse, that `new URL(req.url).hostname`
|
|
41
|
+
* did too. Bun builds that from the client's own `Host` header and
|
|
42
|
+
* `DEFAULT_HOST` is 0.0.0.0, so any peer on the LAN could send
|
|
43
|
+
* `Host: localhost` and be handed a database browser.
|
|
44
|
+
*/
|
|
45
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
46
|
+
|
|
47
|
+
/** True when the request came from this machine. */
|
|
48
|
+
export function isLoopback(req: Request): boolean {
|
|
49
|
+
// The peer address is the only evidence here the client does not choose.
|
|
50
|
+
//
|
|
51
|
+
// DECISION (divergence 3): a throw returns `false` immediately rather than
|
|
52
|
+
// falling through with `ip = ''`. `getClientIp` reads config and the live
|
|
53
|
+
// server, either of which may be absent (tests, early boot). The two spell
|
|
54
|
+
// the same answer today, because `LOOPBACK.has('')` is false — but only by
|
|
55
|
+
// coincidence, and the fall-through invites a later edit that adds a second
|
|
56
|
+
// source of evidence below this line and silently consults it on the
|
|
57
|
+
// indeterminate path. Returning here says the answer is settled: no address
|
|
58
|
+
// means no evidence, and per convention 2 an indeterminate answer is a
|
|
59
|
+
// denial, not a reason to ask something the requester controls.
|
|
60
|
+
try {
|
|
61
|
+
return LOOPBACK.has(getClientIp(req))
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The default when an application configures no predicate: loopback in
|
|
69
|
+
* development, nobody in production. Forgetting to configure a plugin
|
|
70
|
+
* therefore cannot expose it to the internet.
|
|
71
|
+
*/
|
|
72
|
+
export function defaultAuthorize(req: Request): boolean {
|
|
73
|
+
// DECISION (divergence 2): gate on `PROD`, not `!DEV`, and read it at call
|
|
74
|
+
// time — the mode flags are accessors on `process.env` (`core/init.ts`), so
|
|
75
|
+
// they are process state and tests flip them.
|
|
76
|
+
//
|
|
77
|
+
// The two spellings look interchangeable because `init.ts` derives them as
|
|
78
|
+
// complements (`PROD = !isDev && !--dev-worker`). They are not, because they
|
|
79
|
+
// are *independently settable* accessors and the test fixtures set them one
|
|
80
|
+
// at a time: `asDev` flips `DEV` alone and leaves the ambient `PROD` — which
|
|
81
|
+
// under `bun test` is `true` — in place. In that state `!DEV` admits a
|
|
82
|
+
// loopback caller and `PROD` denies, so `PROD` is both the fail-closed
|
|
83
|
+
// reading and the one that literally names the condition it means, rather
|
|
84
|
+
// than inferring production from the absence of development.
|
|
85
|
+
//
|
|
86
|
+
// Hence a test for the *positive* development marker rather than a truthiness
|
|
87
|
+
// test on `PROD`, which is the one place a bare `PROD` gate would be weaker
|
|
88
|
+
// than `!DEV`. The flags do not exist until `core/init.ts` has run, and
|
|
89
|
+
// `if (undefined)` falls straight through to the loopback check — so an
|
|
90
|
+
// uninitialised process would open the door an initialised production one
|
|
91
|
+
// keeps shut. Unset is not evidence of development; it is no evidence at all.
|
|
92
|
+
//
|
|
93
|
+
// This read `PROD !== false` while the flags were real booleans. Bun 1.4
|
|
94
|
+
// rejects accessor descriptors on `process.env`, so they are `'1'`/`''`
|
|
95
|
+
// strings now (see `core/init.ts`) — and `!== false` is true for *every*
|
|
96
|
+
// string, including the `''` a development server sets, so the gate would
|
|
97
|
+
// have denied on loopback in development while still looking correct.
|
|
98
|
+
// `DEV === '1'` is the same statement in the encoding that survives: only a
|
|
99
|
+
// booted development server sets it, and production (`''`) and never-booted
|
|
100
|
+
// (`undefined`) both fail it.
|
|
101
|
+
//
|
|
102
|
+
// `isLoopback` would very likely deny on its own there, having no config or
|
|
103
|
+
// server to read an address from. That is a second line of defence, not this
|
|
104
|
+
// one's excuse: a guard should not depend on another guard's failure mode.
|
|
105
|
+
if (import.meta.env.DEV !== '1') return false
|
|
106
|
+
return isLoopback(req)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The configured predicate, or the fail-closed default. */
|
|
110
|
+
export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
|
|
111
|
+
return fn ?? defaultAuthorize
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run a predicate without letting a broken one grant access. Guard semantics
|
|
116
|
+
* per convention 2: the *authorizer* may throw or answer nonsense, and the
|
|
117
|
+
* answer to any indeterminate state is denial.
|
|
118
|
+
*/
|
|
119
|
+
export async function isAuthorized(
|
|
120
|
+
authorize: AuthorizeFn,
|
|
121
|
+
req: Request,
|
|
122
|
+
): Promise<boolean> {
|
|
123
|
+
try {
|
|
124
|
+
// DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
|
|
125
|
+
// comes from application code and `AuthorizeFn`'s return type is only
|
|
126
|
+
// advice — an untyped, transpiled or `as any` predicate can hand back
|
|
127
|
+
// anything. `Boolean` admits every truthy non-boolean, so a check that
|
|
128
|
+
// answers with a status string denies on `""` and *grants* on `"no"`, and
|
|
129
|
+
// one that answers with a count grants on any non-zero. Admission is the
|
|
130
|
+
// expensive direction to get wrong; require the exact affirmative.
|
|
131
|
+
return (await authorize(req)) === true
|
|
132
|
+
} catch {
|
|
133
|
+
// A predicate that throws is indeterminate, and indeterminate is denied.
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/utils/http/body.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MapOf } from '../../types'
|
|
2
|
+
import { parsedUrl } from './url'
|
|
2
3
|
|
|
3
4
|
export async function processBody(req: Request): Promise<MapOf<any>> {
|
|
4
5
|
const getParsedBody = async (): Promise<MapOf<any>> => {
|
|
@@ -18,8 +19,7 @@ export async function processBody(req: Request): Promise<MapOf<any>> {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function getBodyFromURI(req: Request): MapOf<any> {
|
|
21
|
-
const
|
|
22
|
-
const searchParams = url.searchParams
|
|
22
|
+
const searchParams = parsedUrl(req).searchParams
|
|
23
23
|
return Object.fromEntries(searchParams.entries())
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-credential check for plugin surfaces (`plugin({ credential:
|
|
3
|
+
* import.meta.env.SOME_KEY })`).
|
|
4
|
+
*
|
|
5
|
+
* One implementation, deliberately in core: the db-explorer and analytics
|
|
6
|
+
* plugins each need it and per-plugin copies of security code drift. It owns
|
|
7
|
+
* *only* the comparison — no logins, no sessions, no backoff.
|
|
8
|
+
*
|
|
9
|
+
* A credential is presented three ways, in this order of preference:
|
|
10
|
+
*
|
|
11
|
+
* - `x-<name>` header (scriptable, never logged by default)
|
|
12
|
+
* - `Authorization: Bearer <credential>`
|
|
13
|
+
* - `?<name>=<credential>` query, for a human opening a URL in a browser
|
|
14
|
+
* who cannot set a header — the caller is expected to strip it from the
|
|
15
|
+
* URL client-side; it *does* reach server logs, which is documented, and
|
|
16
|
+
* the header forms exist for anything automated.
|
|
17
|
+
*
|
|
18
|
+
* An empty or missing configured credential **disables** this path rather
|
|
19
|
+
* than matching everything: `credential: import.meta.env.KEY` with the
|
|
20
|
+
* variable unset must mean "off", not "open".
|
|
21
|
+
*
|
|
22
|
+
* The compare is constant-time. A plain `===` on a secret is a timing oracle,
|
|
23
|
+
* and `timingSafeEqual` costs one line — but it throws on length mismatch, so
|
|
24
|
+
* the length is checked first (which leaks only the length, never the bytes).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
28
|
+
|
|
29
|
+
export function credentialMatches(
|
|
30
|
+
configured: string | undefined,
|
|
31
|
+
presented: string | null | undefined,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!configured || !presented) return false
|
|
34
|
+
|
|
35
|
+
const a = Buffer.from(configured)
|
|
36
|
+
const b = Buffer.from(presented)
|
|
37
|
+
if (a.length !== b.length) return false
|
|
38
|
+
return timingSafeEqual(a, b)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pull a presented credential off a request: the `x-<name>` header, a Bearer
|
|
43
|
+
* token, or the `<name>` query parameter, in that order. Returns `null` when
|
|
44
|
+
* none is present. `name` is the header/query key — `db-key`, `analytics-key`.
|
|
45
|
+
*/
|
|
46
|
+
export function readCredential(req: Request, name: string): string | null {
|
|
47
|
+
const header = req.headers.get(`x-${name}`)
|
|
48
|
+
if (header) return header
|
|
49
|
+
|
|
50
|
+
const bearer = req.headers
|
|
51
|
+
.get('authorization')
|
|
52
|
+
?.replace(/^Bearer\s+/i, '')
|
|
53
|
+
.trim()
|
|
54
|
+
if (bearer) return bearer
|
|
55
|
+
|
|
56
|
+
return new URL(req.url).searchParams.get(name)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole check in one call: does the request present `configured` under
|
|
61
|
+
* `name`? `false` for an unset credential or an absent presentation.
|
|
62
|
+
*/
|
|
63
|
+
export function requestHasCredential(
|
|
64
|
+
req: Request,
|
|
65
|
+
configured: string | undefined,
|
|
66
|
+
name: string,
|
|
67
|
+
): boolean {
|
|
68
|
+
if (!configured) return false
|
|
69
|
+
return credentialMatches(configured, readCredential(req, name))
|
|
70
|
+
}
|
package/src/utils/http/dom.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises'
|
|
1
2
|
import { LRUCache } from '../../cache/lru'
|
|
2
3
|
import { Bakery, hostStore } from '../../core/bakery'
|
|
3
4
|
import type { MapOf } from '../../types'
|
|
@@ -16,16 +17,6 @@ export function clearHeadBodyCache() {
|
|
|
16
17
|
|
|
17
18
|
export { headBodyCache }
|
|
18
19
|
|
|
19
|
-
type PackageJson = {
|
|
20
|
-
name: string
|
|
21
|
-
version: string
|
|
22
|
-
main?: string
|
|
23
|
-
module?: string
|
|
24
|
-
browser?: string | MapOf<string>
|
|
25
|
-
dependencies?: MapOf<string>
|
|
26
|
-
devDependencies?: MapOf<string>
|
|
27
|
-
}
|
|
28
|
-
|
|
29
20
|
let depMap = ''
|
|
30
21
|
|
|
31
22
|
const hostDepMaps = new Map<string, string>()
|
|
@@ -34,22 +25,14 @@ const hostDepMaps = new Map<string, string>()
|
|
|
34
25
|
* Normalise one import-map entry, for both the process-level map and the
|
|
35
26
|
* per-host maps.
|
|
36
27
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* two callers, so they cannot disagree again.
|
|
28
|
+
* One helper, two callers, deliberately: they used to be separate copies that
|
|
29
|
+
* had drifted into testing different things — one switched on the entry key,
|
|
30
|
+
* the other on the entry value — so the same input normalised two ways
|
|
31
|
+
* depending on which path saw it.
|
|
42
32
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `docs` or `tests`, and the directory they name no longer exists, so a value
|
|
47
|
-
* pointing at it is a broken path either way. It normalises as an ordinary
|
|
48
|
-
* relative specifier now.
|
|
49
|
-
*
|
|
50
|
-
* `@client/utils` stays, and stays keyed on the *key*: it is a live alias — it
|
|
51
|
-
* is the default `importMap` entry in `core/config.ts` — and the browser
|
|
52
|
-
* runtime is served from a fixed URL, so the target is not the app's to choose.
|
|
33
|
+
* `@client/utils` is keyed on the *key*: it is a live alias, the default
|
|
34
|
+
* `importMap` entry in `core/config.ts`, and the browser runtime is served from
|
|
35
|
+
* a fixed URL, so the target is not the app's to choose.
|
|
53
36
|
*/
|
|
54
37
|
function normalizeImportEntry(key: string, value: unknown): [string, string] {
|
|
55
38
|
const cleanKey = key.replace(/\*$/, '')
|
|
@@ -85,53 +68,79 @@ export function initHostImportMaps() {
|
|
|
85
68
|
}
|
|
86
69
|
}
|
|
87
70
|
|
|
88
|
-
|
|
89
|
-
if (is.string(pkgData.browser)) return pkgData.browser as string
|
|
71
|
+
let installedCache: Promise<string[]> | null = null
|
|
90
72
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Every installed package, top level and scoped, as `name` and `name/`.
|
|
75
|
+
*
|
|
76
|
+
* Read from `node_modules` rather than from `dependencies`, and the difference
|
|
77
|
+
* is the whole point: a package can be installed and imported without being
|
|
78
|
+
* declared — a transitive one, or a dependency someone forgot to add — and the
|
|
79
|
+
* browser's failure for a specifier the map misses names its own rule rather
|
|
80
|
+
* than the missing entry: *"Failed to resolve module specifier 'pkg'. Relative
|
|
81
|
+
* references must start with either "/", "./", or "../"."*
|
|
82
|
+
*
|
|
83
|
+
* Cheap — one `readdir` per scope, no `package.json` reads — and memoised per
|
|
84
|
+
* process besides: the import map reads it at boot, `bundleModule` on every
|
|
85
|
+
* bundle (as its `external` list), and a `readdir` sweep per bundle is pure
|
|
86
|
+
* waste. A dev restart is a new process, so an install still shows up.
|
|
87
|
+
*/
|
|
88
|
+
export function installedPackages(): Promise<string[]> {
|
|
89
|
+
installedCache ??= readInstalledPackages()
|
|
90
|
+
return installedCache
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function readInstalledPackages(): Promise<string[]> {
|
|
94
|
+
const root = fs.resolve(fs.cwd, 'node_modules')
|
|
95
|
+
const [err, entries] = await Try.catch(() => readdir(root))
|
|
96
|
+
if (err || !entries) return []
|
|
97
|
+
|
|
98
|
+
const names: string[] = []
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
// `.bin`, `.cache` and friends are not packages.
|
|
101
|
+
if (entry.startsWith('.')) continue
|
|
96
102
|
|
|
97
|
-
|
|
103
|
+
if (!entry.startsWith('@')) {
|
|
104
|
+
names.push(entry)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const [scopeErr, scoped] = await Try.catch(() =>
|
|
109
|
+
readdir(`${root}/${entry}`),
|
|
110
|
+
)
|
|
111
|
+
if (scopeErr || !scoped) continue
|
|
112
|
+
for (const name of scoped) {
|
|
113
|
+
if (!name.startsWith('.')) names.push(`${entry}/${name}`)
|
|
114
|
+
}
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
return
|
|
117
|
+
return names
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Build the browser import map.
|
|
122
|
+
*
|
|
123
|
+
* **Resolution happens at the other end of the URL, and rewriting happens
|
|
124
|
+
* nowhere.** An entry maps a package to `/_nm/<name>`; `NMHandler` hands that to
|
|
125
|
+
* `Bun.build`, which applies real browser resolution — `exports` maps,
|
|
126
|
+
* conditions, the `browser` field. Naming an entry file here instead would mean
|
|
127
|
+
* reimplementing all of that, badly.
|
|
128
|
+
*
|
|
129
|
+
* Covering every installed package is what removes the need for a compile-time
|
|
130
|
+
* rewrite of bare specifiers, and the map reaches code the compiler never sees:
|
|
131
|
+
* an inline `<script type="module">` in an `.html` page arrives at the browser
|
|
132
|
+
* with its imports intact.
|
|
133
|
+
*
|
|
134
|
+
* App-declared `importMap` entries are applied last and win. One of them,
|
|
135
|
+
* `@client/utils`, does not point into `node_modules` at all.
|
|
136
|
+
*/
|
|
103
137
|
export async function initImportMap() {
|
|
104
|
-
const pkgContent = await Try(() =>
|
|
105
|
-
Bun.file(fs.resolve(fs.cwd, 'package.json')).json(),
|
|
106
|
-
)
|
|
107
|
-
const pkg: any = pkgContent || {}
|
|
108
138
|
const map = Bakery.config.importMap || {}
|
|
109
|
-
const deps = pkg.dependencies || {}
|
|
110
|
-
|
|
111
139
|
const resolvedMap: MapOf<string> = {}
|
|
112
140
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
Bun.file(`./node_modules/${dep}/package.json`).json(),
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
return { dep, pkgData: pkgData as PackageJson | null }
|
|
120
|
-
}),
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
for (const { dep, pkgData } of imports) {
|
|
124
|
-
if (!pkgData) continue
|
|
125
|
-
|
|
126
|
-
const actualName = pkgData.name || dep
|
|
127
|
-
resolvedMap[`${actualName}/`] = `/_nm/${actualName}/`
|
|
128
|
-
|
|
129
|
-
const baseMod = pkgData.module || pkgData.main || 'index.js'
|
|
130
|
-
const mod = resolveDepModule(pkgData, baseMod)
|
|
131
|
-
|
|
132
|
-
const finalMod = typeof mod === 'string' ? mod : 'index.js'
|
|
133
|
-
resolvedMap[actualName] =
|
|
134
|
-
`/_nm/${actualName}/${finalMod.replace(/^\.\//, '')}`
|
|
141
|
+
for (const name of await installedPackages()) {
|
|
142
|
+
resolvedMap[`${name}/`] = `/_nm/${name}/`
|
|
143
|
+
resolvedMap[name] = `/_nm/${name}`
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
for (const [k, v] of Object.entries(map)) {
|
|
@@ -149,8 +158,8 @@ export namespace DOMTools {
|
|
|
149
158
|
return `<script type="importmap">${map}</script>`
|
|
150
159
|
}
|
|
151
160
|
|
|
152
|
-
export function params(params: MapOf<
|
|
153
|
-
const newParams: MapOf<
|
|
161
|
+
export function params(params: MapOf<unknown>) {
|
|
162
|
+
const newParams: MapOf<unknown> = {}
|
|
154
163
|
|
|
155
164
|
for (const [k, v] of Object.entries(params)) {
|
|
156
165
|
if (k.startsWith('$$')) continue
|
package/src/utils/http/etag.ts
CHANGED
|
@@ -231,6 +231,33 @@ export namespace ETag {
|
|
|
231
231
|
if (conditionalRes) return conditionalRes
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// Range handling itself lives in Bun.serve, not here: any Response whose
|
|
235
|
+
// body is a *path-backed* BunFile is sliced by the runtime — 206 with
|
|
236
|
+
// Content-Range on a satisfiable single range, 416 past EOF — including
|
|
237
|
+
// the negotiated compressed variants above (the range then addresses the
|
|
238
|
+
// encoded bytes, which is what RFC 9110 says a range means). What the
|
|
239
|
+
// runtime does not do is advertise: a plain 200 or a HEAD said nothing,
|
|
240
|
+
// so players that probe HEAD for `Accept-Ranges` before attempting seeks
|
|
241
|
+
// never tried. Advertised here because this is the one funnel every
|
|
242
|
+
// file-serving handler's BunFile passes through, and only here — an
|
|
243
|
+
// in-memory Blob or a `sendText` string body ignores Range entirely
|
|
244
|
+
// (served whole, 200), which is why `.name` gates the claim.
|
|
245
|
+
//
|
|
246
|
+
// Skipped when Bun's own range path is about to answer (a GET carrying
|
|
247
|
+
// `Range`): that path appends its own `Accept-Ranges: bytes` to the
|
|
248
|
+
// 206/416, and setting it here too emitted `bytes, bytes`. The trade,
|
|
249
|
+
// measured on Bun 1.4.0: a GET whose Range is malformed or multipart is
|
|
250
|
+
// served whole with no advertisement from either side — acceptable,
|
|
251
|
+
// since a client that already sent `Range` is not the one this probe
|
|
252
|
+
// header exists for. HEAD ignores `Range` and gets the header even when
|
|
253
|
+
// one is present.
|
|
254
|
+
if (
|
|
255
|
+
resolvedFile.name &&
|
|
256
|
+
!(req && req.method === 'GET' && req.headers.has('range'))
|
|
257
|
+
) {
|
|
258
|
+
headers['Accept-Ranges'] = 'bytes'
|
|
259
|
+
}
|
|
260
|
+
|
|
234
261
|
return new Response(resolvedFile, { headers })
|
|
235
262
|
}
|
|
236
263
|
|