@bakery-framework/core 2.0.0-alpha.3 → 2.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.0.0-alpha.3",
3
+ "version": "2.0.0-alpha.5",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -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 url: URL = (req as any).__parsedUrl || new URL(req.url)
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
- ((req as any).__parsedUrl || new URL(req.url)).search
34
+ parsedUrl(req).search
34
35
  break
35
36
  }
36
37
 
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: URL = (req as any).__parsedUrl || new URL(req.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 parses and attaches this before calling in; the fallback keeps
89
- // direct callers (tests, embedders) working.
90
- const url: URL = (req as any).__parsedUrl || new URL(req.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
@@ -0,0 +1,129 @@
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 `!== false` rather than a plain truthiness test, which is the one
87
+ // place a bare `PROD` gate would be *weaker* than `!DEV`. The flag does not
88
+ // exist until `core/init.ts` has run, and `if (undefined)` falls straight
89
+ // through to the loopback check — so an uninitialised process would open the
90
+ // door that an initialised production one keeps shut. `init.ts` defines
91
+ // `PROD` as a real boolean (`accessor(!isDev && !hasDevWorkerArg)`), so
92
+ // "explicitly false" is a statement only a booted development server can
93
+ // make. Unset is not evidence of development; it is no evidence at all.
94
+ //
95
+ // `isLoopback` would very likely deny on its own there, having no config or
96
+ // server to read an address from. That is a second line of defence, not this
97
+ // one's excuse: a guard should not depend on another guard's failure mode.
98
+ if (import.meta.env.PROD !== false) return false
99
+ return isLoopback(req)
100
+ }
101
+
102
+ /** The configured predicate, or the fail-closed default. */
103
+ export function resolveAuthorize(fn?: AuthorizeFn): AuthorizeFn {
104
+ return fn ?? defaultAuthorize
105
+ }
106
+
107
+ /**
108
+ * Run a predicate without letting a broken one grant access. Guard semantics
109
+ * per convention 2: the *authorizer* may throw or answer nonsense, and the
110
+ * answer to any indeterminate state is denial.
111
+ */
112
+ export async function isAuthorized(
113
+ authorize: AuthorizeFn,
114
+ req: Request,
115
+ ): Promise<boolean> {
116
+ try {
117
+ // DECISION (divergence 1): `=== true`, never `Boolean(...)`. The predicate
118
+ // comes from application code and `AuthorizeFn`'s return type is only
119
+ // advice — an untyped, transpiled or `as any` predicate can hand back
120
+ // anything. `Boolean` admits every truthy non-boolean, so a check that
121
+ // answers with a status string denies on `""` and *grants* on `"no"`, and
122
+ // one that answers with a count grants on any non-zero. Admission is the
123
+ // expensive direction to get wrong; require the exact affirmative.
124
+ return (await authorize(req)) === true
125
+ } catch {
126
+ // A predicate that throws is indeterminate, and indeterminate is denied.
127
+ return false
128
+ }
129
+ }
@@ -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 url = (req as any).__parsedUrl || new URL(req.url)
22
- const searchParams = url.searchParams
22
+ const searchParams = parsedUrl(req).searchParams
23
23
  return Object.fromEntries(searchParams.entries())
24
24
  }
25
25
 
@@ -1,3 +1,4 @@
1
+ export * from './authorize'
1
2
  export * from './body'
2
3
  export * from './credential'
3
4
  export * from './csrf'
@@ -8,3 +9,4 @@ export * from './html'
8
9
  export * from './ip'
9
10
  export * from './response'
10
11
  export * from './sse'
12
+ export * from './url'
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The request's parsed URL, parsed at most once.
3
+ *
4
+ * Why memoize at all: `new URL` measures ~1.7µs, and the router, the body
5
+ * parser and the proxy handler all want the same parse of the same request —
6
+ * so the parse was hoisted into the server's `fetch` in `packages/cli/src/worker.ts`
7
+ * and shared.
8
+ *
9
+ * Why a `WeakMap` rather than the property it replaces. The memo used to be
10
+ * `(req as any).__parsedUrl`: two writers, six readers across three packages,
11
+ * every reader spelled `(req as any).__parsedUrl || new URL(req.url)` so that
12
+ * it silently re-parsed whenever the writer had not run first. That is a
13
+ * writer/reader ordering hazard with no way to observe it going wrong — a
14
+ * missed write costs microseconds, not correctness, so nothing ever fails.
15
+ * One function collapses both roles: there is no ordering left to get wrong,
16
+ * no `any` cast anywhere, and no writer to forget. The map also keeps the
17
+ * framework from mutating a `Request` object it does not own — a caller's
18
+ * `Request` goes in and comes back unchanged — and holds its keys weakly, so
19
+ * an entry dies with the request rather than needing eviction (convention 6).
20
+ *
21
+ * There is deliberately no setter. Nothing outside this module needs to say
22
+ * what a request's URL is; `req.url` already does.
23
+ */
24
+
25
+ const cache = new WeakMap<Request, URL>()
26
+
27
+ /** The parsed `req.url`, cached per request. */
28
+ export function parsedUrl(req: Request): URL {
29
+ const cached = cache.get(req)
30
+ if (cached) return cached
31
+
32
+ const url = new URL(req.url)
33
+ cache.set(req, url)
34
+ return url
35
+ }