@bakery-framework/core 1.2.3 → 2.0.0-alpha.2

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.
@@ -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
+ }
@@ -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
- * There used to be two copies of this, and they had drifted into disagreeing
38
- * about *what* they tested: `initHostImportMaps` switched on the entry key,
39
- * `initImportMap` tested the entry value. `{ foo: './.server/client/utils' }`
40
- * was rewritten by one path and silently left alone by the other. One helper,
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
- * The `.server/client/utils` special cases both copies carried are gone rather
44
- * than reconciled. `.server/` is the pre-split layout: those two string
45
- * literals were the last references to it anywhere in `packages`, `apps`,
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
- function resolveDepModule(pkgData: PackageJson, baseMod: string): string {
89
- if (is.string(pkgData.browser)) return pkgData.browser as string
71
+ let installedCache: Promise<string[]> | null = null
90
72
 
91
- if (is.object(pkgData.browser)) {
92
- const cleanBase = baseMod.replace(/^\.\//, '')
93
- const browserField = pkgData.browser as MapOf<string>
94
- const lookupKeys = [baseMod, `./${cleanBase}`, cleanBase]
95
- const matchedOverride = lookupKeys.find(key => browserField[key])
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
- return matchedOverride ? browserField[matchedOverride] : baseMod
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 baseMod
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 imports = await Promise.all(
114
- Object.keys(deps).map(async dep => {
115
- const pkgData = await Try(
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<string>) {
153
- const newParams: MapOf<string> = {}
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
@@ -80,7 +80,7 @@ export const STREAM_THRESHOLD_BYTES = 64 * 1024
80
80
 
81
81
  export async function injectIfHtml(
82
82
  data: string | Response | Blob,
83
- params?: MapOf<string>,
83
+ params?: MapOf<unknown>,
84
84
  injects?: HtmlInjects,
85
85
  ): Promise<Response | null> {
86
86
  if (data instanceof Response && isInjected(data)) return data
@@ -127,7 +127,7 @@ export async function injectIfHtml(
127
127
  * streamed path's fallback both come through here. */
128
128
  function bufferedResponse(
129
129
  content: string,
130
- params: MapOf<string> | undefined,
130
+ params: MapOf<unknown> | undefined,
131
131
  injects: HtmlInjects | undefined,
132
132
  responseInit: ResponseInit & { headers?: any },
133
133
  ): Response {
@@ -182,7 +182,7 @@ function getConfigInjects() {
182
182
  * into a pull callback, where the AsyncLocalStorage host context that
183
183
  * `getConfigInjects` and `DOMTools.importMap` read may no longer be live.
184
184
  */
185
- function computeInjects(params: MapOf<string>, injects: HtmlInjects) {
185
+ function computeInjects(params: MapOf<unknown>, injects: HtmlInjects) {
186
186
  const configInjects = getConfigInjects()
187
187
  const paramsStr = DOMTools.params(params)
188
188
 
@@ -235,7 +235,7 @@ function rewriteFontsUrls(html: string): string {
235
235
 
236
236
  export function assembleHtml(
237
237
  content: string,
238
- params: MapOf<string> = {},
238
+ params: MapOf<unknown> = {},
239
239
  injects: HtmlInjects = {},
240
240
  ) {
241
241
  const frags = computeInjects(params, injects)
@@ -1,4 +1,5 @@
1
1
  export * from './body'
2
+ export * from './credential'
2
3
  export * from './csrf'
3
4
  export * from './dom'
4
5
  export * from './escape'
@@ -15,6 +15,22 @@ export function any<T = any>(value: any): T {
15
15
  return value
16
16
  }
17
17
 
18
+ /**
19
+ * A hex id of `length` characters, from `crypto.getRandomValues`.
20
+ *
21
+ * Isomorphic, and moving it here is what made it so: it lived in
22
+ * `client/utils.ts` as a browser global only, so the same call in a server
23
+ * block was a ReferenceError — reported from an app. `crypto` is a global in
24
+ * both runtimes; nothing here is browser-specific.
25
+ */
26
+ export function randomId(length = 8) {
27
+ const arr = new Uint8Array(Math.ceil(length / 2))
28
+ crypto.getRandomValues(arr)
29
+ return Array.from(arr, dec => dec.toString(16).padStart(2, '0'))
30
+ .join('')
31
+ .slice(0, length)
32
+ }
33
+
18
34
  export function repeat(n: number): number[]
19
35
  export function repeat<T>(n: number, fn: (i: number) => T): T[]
20
36
  export function repeat<T>(n: number, fn?: (i: number) => T): unknown[] {