@appwarden/middleware 3.14.1 → 3.15.1

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,851 @@
1
+ #!/usr/bin/env node
2
+ "use strict"
3
+
4
+ const fs = require("fs")
5
+ const path = require("path")
6
+ const acorn = require("acorn")
7
+ const { z } = require("zod")
8
+
9
+ const CONFIG_PATH = ".appwarden/linked/middleware.json"
10
+ const GITIGNORE_WARNING =
11
+ "Warning: .appwarden/linked/ is not gitignored. Add it to .gitignore to avoid checking generated config into version control."
12
+ const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB
13
+ const MAX_GITIGNORE_SIZE = 128 * 1024 // 128 KB
14
+ const MAX_REMOTE_RESPONSE_SIZE = 1 * 1024 * 1024 // 1 MB
15
+ const ALLOWED_FRAMEWORKS = [
16
+ "astro",
17
+ "react-router",
18
+ "tanstack-start",
19
+ "nextjs-cloudflare",
20
+ "vercel",
21
+ ]
22
+ const ALLOWED_REMOTE_CONFIG_KEYS = [
23
+ "lockPageSlug",
24
+ "debug",
25
+ "contentSecurityPolicy",
26
+ "appwardenApiHostname",
27
+ ]
28
+
29
+ const ALLOWED_API_HOSTNAMES = ["api.appwarden.io", "staging-api.appwarden.io"]
30
+
31
+ const CSP_ENFORCED = "content-security-policy"
32
+ const CSP_REPORT_ONLY = "content-security-policy-report-only"
33
+
34
+ function isCspHeader(name) {
35
+ return name === CSP_ENFORCED || name === CSP_REPORT_ONLY
36
+ }
37
+
38
+ function isGlobalRoute(route) {
39
+ if (typeof route !== "string") return false
40
+ const trimmed = route.trim()
41
+ // Cloudflare catch-all routes
42
+ if (trimmed === "/*" || trimmed === "/") return true
43
+ // Next.js / Vercel catch-all patterns
44
+ if (trimmed === "/(.*)" || trimmed === "/:path*" || trimmed === "/:path+")
45
+ return true
46
+ return false
47
+ }
48
+
49
+ const BuildOutputSchema = z.object({
50
+ lockPageSlug: z
51
+ .string()
52
+ .refine(
53
+ (val) =>
54
+ !val.includes("://") && !val.startsWith("//") && !val.includes("\\"),
55
+ {
56
+ message: "lockPageSlug must be a relative path",
57
+ },
58
+ ),
59
+ debug: z.boolean().optional(),
60
+ appwardenApiHostname: z.string().optional(),
61
+ contentSecurityPolicy: z
62
+ .object({
63
+ mode: z.enum(["disabled", "enforced", "report-only"]),
64
+ directives: z.record(
65
+ z.union([z.string(), z.array(z.string()), z.boolean()]),
66
+ ),
67
+ })
68
+ .optional(),
69
+ })
70
+
71
+ const CYAN = "\x1b[36m"
72
+ const YELLOW = "\x1b[33m"
73
+
74
+ const print = (...args) => console.log(`${CYAN}[appwarden]`, ...args)
75
+ const warn = (...args) => console.warn(`${YELLOW}[appwarden]`, ...args)
76
+
77
+ function parseArgs() {
78
+ const args = process.argv.slice(2)
79
+ const result = {
80
+ framework: null,
81
+ staging: false,
82
+ cwd: process.cwd(),
83
+ fqdn: null,
84
+ }
85
+ for (const arg of args) {
86
+ const frameworkMatch = arg.match(/^--framework=(.+)$/)
87
+ if (frameworkMatch) {
88
+ const fw = frameworkMatch[1]
89
+ if (ALLOWED_FRAMEWORKS.includes(fw)) {
90
+ result.framework = fw
91
+ } else {
92
+ warn(`Ignoring unsupported framework: ${fw}`)
93
+ }
94
+ }
95
+ if (arg === "--staging") result.staging = true
96
+
97
+ const cwdMatch = arg.match(/^--cwd=(.+)$/)
98
+ if (cwdMatch) {
99
+ const resolved = path.resolve(cwdMatch[1])
100
+ const relativeToProcess = path.relative(process.cwd(), resolved)
101
+ if (
102
+ relativeToProcess.startsWith("..") ||
103
+ path.isAbsolute(relativeToProcess)
104
+ ) {
105
+ warn(
106
+ `Ignoring --cwd that escapes process working directory: ${cwdMatch[1]}`,
107
+ )
108
+ } else {
109
+ result.cwd = resolved
110
+ }
111
+ }
112
+
113
+ const fqdnMatch = arg.match(/^--fqdn=(.+)$/)
114
+ if (fqdnMatch) result.fqdn = fqdnMatch[1]
115
+ }
116
+ return result
117
+ }
118
+
119
+ function detectFramework(cwd, explicit) {
120
+ if (explicit) return explicit
121
+ const pkgPath = path.join(cwd, "package.json")
122
+ const pkgContent = safeReadFile(pkgPath, cwd)
123
+ if (!pkgContent) return null
124
+ try {
125
+ const pkg = JSON.parse(pkgContent)
126
+ const deps = {
127
+ ...pkg.dependencies,
128
+ ...pkg.devDependencies,
129
+ ...pkg.peerDependencies,
130
+ }
131
+ if (deps["astro"]) return "astro"
132
+ if (deps["@react-router/cloudflare"]) return "react-router"
133
+ if (
134
+ deps["@tanstack/start"] ||
135
+ deps["@tanstack/react-start"] ||
136
+ deps["@tanstack/solid-start"] ||
137
+ deps["@tanstack/vue-start"] ||
138
+ deps["@tanstack/svelte-start"]
139
+ )
140
+ return "tanstack-start"
141
+ if (deps["@opennextjs/cloudflare"]) return "nextjs-cloudflare"
142
+ if (deps["next"]) return "vercel"
143
+ return null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+
149
+ function findNextConfig(cwd) {
150
+ // Only .js/.mjs because extractHeadersFromNextConfig uses plain Acorn,
151
+ // which cannot parse TypeScript syntax.
152
+ const candidates = ["next.config.js", "next.config.mjs"]
153
+ for (const c of candidates) {
154
+ const p = path.join(cwd, c)
155
+ if (fs.existsSync(p)) return p
156
+ }
157
+ return null
158
+ }
159
+
160
+ function extractHeadersFromNextConfig(source) {
161
+ let ast
162
+ try {
163
+ ast = acorn.parse(source, {
164
+ ecmaVersion: 2023,
165
+ sourceType: "module",
166
+ allowReturnOutsideFunction: true,
167
+ })
168
+ } catch {
169
+ try {
170
+ ast = acorn.parse(source, {
171
+ ecmaVersion: 2023,
172
+ sourceType: "script",
173
+ allowReturnOutsideFunction: true,
174
+ })
175
+ } catch {
176
+ return []
177
+ }
178
+ }
179
+
180
+ const result = []
181
+ const visited = new Set()
182
+ const MAX_WALK_DEPTH = 500
183
+
184
+ function walk(node, inHeadersFn, depth = 0) {
185
+ if (!node || typeof node !== "object") return
186
+ if (visited.has(node)) return
187
+ if (depth > MAX_WALK_DEPTH) return
188
+ visited.add(node)
189
+
190
+ const headersArray =
191
+ inHeadersFn &&
192
+ node.type === "ReturnStatement" &&
193
+ node.argument &&
194
+ node.argument.type === "ArrayExpression"
195
+ ? node.argument
196
+ : inHeadersFn && node.type === "ArrayExpression"
197
+ ? node
198
+ : null
199
+
200
+ if (headersArray) {
201
+ for (const el of headersArray.elements || []) {
202
+ if (!el || el.type !== "ObjectExpression") continue
203
+ let sourceValue = null
204
+ for (const prop of el.properties || []) {
205
+ if (
206
+ prop.type === "Property" &&
207
+ prop.key &&
208
+ (prop.key.name === "source" || prop.key.value === "source") &&
209
+ prop.value &&
210
+ prop.value.type === "Literal"
211
+ ) {
212
+ sourceValue = prop.value.value
213
+ break
214
+ }
215
+ }
216
+ if (sourceValue && !isGlobalRoute(sourceValue)) {
217
+ warn(
218
+ `Skipping route-specific CSP from Next.js source: ${sourceValue}`,
219
+ )
220
+ continue
221
+ }
222
+ for (const prop of el.properties || []) {
223
+ if (
224
+ prop.type === "Property" &&
225
+ prop.key &&
226
+ (prop.key.name === "headers" || prop.key.value === "headers") &&
227
+ prop.value &&
228
+ prop.value.type === "ArrayExpression"
229
+ ) {
230
+ for (const h of prop.value.elements || []) {
231
+ if (!h || h.type !== "ObjectExpression") continue
232
+ let key = null
233
+ let value = null
234
+ for (const hp of h.properties || []) {
235
+ if (hp.type !== "Property" || !hp.key) continue
236
+ const kName = hp.key.name || hp.key.value
237
+ if (
238
+ kName === "key" &&
239
+ hp.value &&
240
+ hp.value.type === "Literal"
241
+ ) {
242
+ key = hp.value.value
243
+ }
244
+ if (
245
+ kName === "value" &&
246
+ hp.value &&
247
+ hp.value.type === "Literal"
248
+ ) {
249
+ value = hp.value.value
250
+ }
251
+ }
252
+ if (key !== null && value !== null) {
253
+ result.push({ key, value })
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ return
260
+ }
261
+
262
+ if (
263
+ node.type === "FunctionDeclaration" ||
264
+ node.type === "FunctionExpression" ||
265
+ node.type === "ArrowFunctionExpression"
266
+ ) {
267
+ const isHeaders = node.id && node.id.name === "headers"
268
+ const newInHeaders = inHeadersFn || isHeaders
269
+ for (const k of Object.keys(node)) {
270
+ if (k === "id" || k === "params") continue
271
+ walk(node[k], newInHeaders, depth + 1)
272
+ }
273
+ return
274
+ }
275
+
276
+ if (
277
+ node.type === "Property" &&
278
+ node.key &&
279
+ (node.key.name === "headers" || node.key.value === "headers")
280
+ ) {
281
+ if (
282
+ node.value &&
283
+ (node.value.type === "FunctionExpression" ||
284
+ node.value.type === "ArrowFunctionExpression")
285
+ ) {
286
+ walk(node.value, true, depth + 1)
287
+ return
288
+ }
289
+ }
290
+
291
+ for (const k of Object.keys(node)) {
292
+ if (k === "parent") continue
293
+ walk(node[k], inHeadersFn, depth + 1)
294
+ }
295
+ }
296
+
297
+ walk(ast, false)
298
+ return result
299
+ }
300
+
301
+ function splitCspTokens(value) {
302
+ const tokens = []
303
+ let current = ""
304
+ let inQuotes = false
305
+ for (let i = 0; i < value.length; i++) {
306
+ const ch = value[i]
307
+ if (ch === "'") {
308
+ inQuotes = !inQuotes
309
+ current += ch
310
+ continue
311
+ }
312
+ if (!inQuotes && /\s/.test(ch)) {
313
+ if (current) {
314
+ tokens.push(current)
315
+ current = ""
316
+ }
317
+ continue
318
+ }
319
+ current += ch
320
+ }
321
+ if (current) tokens.push(current)
322
+ return tokens
323
+ }
324
+
325
+ function parseCspHeaderValue(value) {
326
+ const directives = Object.create(null)
327
+ if (!value || typeof value !== "string") return directives
328
+ const parts = value.split(/;\s*/)
329
+ for (const part of parts) {
330
+ const trimmed = part.trim()
331
+ if (!trimmed) continue
332
+ const firstSpace = trimmed.indexOf(" ")
333
+ const name = firstSpace === -1 ? trimmed : trimmed.slice(0, firstSpace)
334
+ const directiveValue =
335
+ firstSpace === -1 ? "" : trimmed.slice(firstSpace + 1).trim()
336
+ if (!directiveValue) {
337
+ directives[name] = true
338
+ } else {
339
+ directives[name] = splitCspTokens(directiveValue).map((token) => {
340
+ // Normalize quoted nonce placeholders so downstream makeCSPHeader
341
+ // does not produce double-quoted nonce sources.
342
+ if (token === "'{{nonce}}'" || token === '"{{nonce}}"') {
343
+ return "{{nonce}}"
344
+ }
345
+ return token
346
+ })
347
+ }
348
+ }
349
+ return directives
350
+ }
351
+
352
+ function parseCloudflareHeaders(content) {
353
+ const lines = content.split(/\r?\n/)
354
+ const result = []
355
+ let currentRoute = null
356
+ for (const line of lines) {
357
+ const trimmed = line.trim()
358
+ if (!trimmed || trimmed.startsWith("#")) continue
359
+ if (!line.startsWith(" ") && !line.startsWith("\t")) {
360
+ // This is a route line, not an indented header
361
+ currentRoute = trimmed
362
+ continue
363
+ }
364
+ const colonIdx = trimmed.indexOf(":")
365
+ if (colonIdx === -1) continue
366
+ const name = trimmed.slice(0, colonIdx).trim().toLowerCase()
367
+ const value = trimmed.slice(colonIdx + 1).trim()
368
+ if (isCspHeader(name)) {
369
+ if (currentRoute && !isGlobalRoute(currentRoute)) {
370
+ warn(
371
+ `Skipping route-specific CSP from non-global route: ${currentRoute}`,
372
+ )
373
+ continue
374
+ }
375
+ result.push({ key: name, value })
376
+ }
377
+ }
378
+ return result
379
+ }
380
+
381
+ function pushCspHeaders(headers, rawHeaders) {
382
+ for (const h of rawHeaders || []) {
383
+ const key = (h.key || "").toLowerCase()
384
+ if (isCspHeader(key)) {
385
+ headers.push({ key: h.key, value: h.value })
386
+ }
387
+ }
388
+ }
389
+
390
+ function safeReadFile(filePath, cwd) {
391
+ if (!filePath) return null
392
+ try {
393
+ const resolved = fs.realpathSync(filePath)
394
+ const root = path.resolve(cwd)
395
+ const relative = path.relative(root, resolved)
396
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
397
+ warn(`Skipping file outside project: ${filePath}`)
398
+ return null
399
+ }
400
+ const fd = fs.openSync(resolved, "r")
401
+ try {
402
+ const stats = fs.fstatSync(fd)
403
+ if (!stats.isFile()) {
404
+ warn(`Skipping non-file path: ${filePath}`)
405
+ return null
406
+ }
407
+ if (stats.size > MAX_FILE_SIZE) {
408
+ warn(`Skipping oversized file: ${filePath}`)
409
+ return null
410
+ }
411
+ return fs.readFileSync(fd, "utf-8")
412
+ } finally {
413
+ fs.closeSync(fd)
414
+ }
415
+ } catch {
416
+ return null
417
+ }
418
+ }
419
+
420
+ function safeWriteFile(filePath, content, cwd) {
421
+ try {
422
+ const root = fs.realpathSync(path.resolve(cwd))
423
+ const target = path.resolve(filePath)
424
+ const relative = path.relative(root, target)
425
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
426
+ warn(`Refusing to write outside project: ${filePath}`)
427
+ return false
428
+ }
429
+
430
+ // Walk up the path and verify no existing component is a symlink
431
+ let current = target
432
+ while (current !== root && current !== path.dirname(current)) {
433
+ if (fs.existsSync(current)) {
434
+ const stat = fs.lstatSync(current)
435
+ if (stat.isSymbolicLink()) {
436
+ warn(`Refusing to write through symlink: ${current}`)
437
+ return false
438
+ }
439
+ }
440
+ current = path.dirname(current)
441
+ }
442
+
443
+ const dir = path.dirname(target)
444
+ if (!fs.existsSync(dir)) {
445
+ fs.mkdirSync(dir, { recursive: true })
446
+ }
447
+
448
+ // Re-verify directory path after mkdir to catch symlink races in parent dirs
449
+ const resolvedDir = fs.realpathSync(dir)
450
+ const dirRelative = path.relative(root, resolvedDir)
451
+ if (dirRelative.startsWith("..") || path.isAbsolute(dirRelative)) {
452
+ warn(`Refusing to write outside project: ${dir}`)
453
+ return false
454
+ }
455
+
456
+ // Atomic write: temp file in same directory, then rename
457
+ const tempFile = path.join(
458
+ dir,
459
+ `.appwarden-link-tmp-${process.hrtime.bigint().toString(36)}.json`,
460
+ )
461
+
462
+ fs.writeFileSync(tempFile, content, { mode: 0o600 })
463
+ fs.renameSync(tempFile, target)
464
+
465
+ // Final verification: ensure the written file is a regular file inside the
466
+ // project. The nlink > 1 check specifically defends against a race where an
467
+ // attacker creates a hard link to the temp file in the same directory between
468
+ // writeFileSync and renameSync; it does not protect against a pre-existing
469
+ // hard-linked target because rename atomically swaps inodes.
470
+ const finalStat = fs.lstatSync(target)
471
+ if (!finalStat.isFile() || finalStat.nlink > 1) {
472
+ warn(`Refusing to write through link: ${target}`)
473
+ return false
474
+ }
475
+
476
+ const finalResolved = fs.realpathSync(target)
477
+ const finalRelative = path.relative(root, finalResolved)
478
+ if (finalRelative.startsWith("..") || path.isAbsolute(finalRelative)) {
479
+ warn(`Refusing to write outside project: ${target}`)
480
+ return false
481
+ }
482
+
483
+ return true
484
+ } catch (err) {
485
+ warn(`Failed to write config: ${err.message}`)
486
+ return false
487
+ }
488
+ }
489
+
490
+ function extractLocalHeadersConfig(cwd, framework) {
491
+ const headers = []
492
+ const nextConfig = findNextConfig(cwd)
493
+ const nextConfigSource = safeReadFile(nextConfig, cwd)
494
+ if (nextConfigSource) {
495
+ headers.push(...extractHeadersFromNextConfig(nextConfigSource))
496
+ }
497
+ const vercelJson = path.join(cwd, "vercel.json")
498
+ const vercelJsonContent = safeReadFile(vercelJson, cwd)
499
+ if (vercelJsonContent) {
500
+ try {
501
+ const vj = JSON.parse(vercelJsonContent)
502
+ for (const rule of [...(vj.headers || []), ...(vj.routes || [])]) {
503
+ const route = rule.source || rule.src || ""
504
+ if (route && !isGlobalRoute(route)) {
505
+ warn(`Skipping route-specific CSP from vercel.json rule: ${route}`)
506
+ continue
507
+ }
508
+ // Legacy vercel.json routes[] may use an object map for headers
509
+ let ruleHeaders = rule.headers
510
+ if (ruleHeaders && !Array.isArray(ruleHeaders)) {
511
+ ruleHeaders = Object.entries(ruleHeaders).map(([key, value]) => ({
512
+ key,
513
+ value,
514
+ }))
515
+ }
516
+ pushCspHeaders(headers, ruleHeaders)
517
+ }
518
+ } catch {}
519
+ }
520
+ const publicHeaders = path.join(cwd, "public", "_headers")
521
+ const staticHeaders = path.join(cwd, "static", "_headers")
522
+ const distHeaders = path.join(cwd, "dist", "_headers")
523
+ const buildHeaders = path.join(cwd, "build", "_headers")
524
+ const nextHeaders = path.join(cwd, ".next", "_headers")
525
+ for (const hp of [
526
+ publicHeaders,
527
+ staticHeaders,
528
+ distHeaders,
529
+ buildHeaders,
530
+ nextHeaders,
531
+ ]) {
532
+ const content = safeReadFile(hp, cwd)
533
+ if (content) {
534
+ headers.push(...parseCloudflareHeaders(content))
535
+ }
536
+ }
537
+ if (!headers.length) return null
538
+ const enforced = headers.find((h) => h.key.toLowerCase() === CSP_ENFORCED)
539
+ const reportOnly = headers.find(
540
+ (h) => h.key.toLowerCase() === CSP_REPORT_ONLY,
541
+ )
542
+ if (enforced) {
543
+ return { mode: "enforced", directives: parseCspHeaderValue(enforced.value) }
544
+ }
545
+ if (reportOnly) {
546
+ return {
547
+ mode: "report-only",
548
+ directives: parseCspHeaderValue(reportOnly.value),
549
+ }
550
+ }
551
+ return null
552
+ }
553
+
554
+ function isAllowedApiHostname(value) {
555
+ try {
556
+ const url = new URL(value)
557
+ return (
558
+ url.protocol === "https:" && ALLOWED_API_HOSTNAMES.includes(url.hostname)
559
+ )
560
+ } catch {
561
+ return false
562
+ }
563
+ }
564
+
565
+ function mapKeys(obj, transform) {
566
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
567
+ return obj
568
+ }
569
+ const result = Object.create(null)
570
+ for (const [key, value] of Object.entries(obj)) {
571
+ result[transform(key)] = value
572
+ }
573
+ return result
574
+ }
575
+
576
+ const normalizeRemoteConfigKeys = (obj) =>
577
+ mapKeys(obj, (key) => key.replace(/-([a-z])/g, (_, ch) => ch.toUpperCase()))
578
+
579
+ const kebabCaseDirectives = (obj) =>
580
+ mapKeys(obj, (key) => key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase())
581
+
582
+ function sanitizeRemoteConfig(data, depth = 0) {
583
+ if (depth > 5) {
584
+ warn("Remote config nested too deeply. Truncating.")
585
+ return null
586
+ }
587
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
588
+ warn("Remote config response was not a valid object. Ignoring.")
589
+ return null
590
+ }
591
+ const safe = Object.create(null)
592
+ for (const key of Object.keys(data)) {
593
+ if (key === "__proto__" || key === "constructor" || key === "prototype") {
594
+ continue
595
+ }
596
+ // Top-level keys must be explicitly allowlisted
597
+ if (depth === 0 && !ALLOWED_REMOTE_CONFIG_KEYS.includes(key)) {
598
+ warn(`Ignoring unexpected remote config key: ${key}`)
599
+ continue
600
+ }
601
+ const val = data[key]
602
+ // Reject untrusted API hostnames to prevent token exfiltration
603
+ if (
604
+ key === "appwardenApiHostname" &&
605
+ typeof val === "string" &&
606
+ !isAllowedApiHostname(val)
607
+ ) {
608
+ warn(`Ignoring invalid appwardenApiHostname: ${val}`)
609
+ continue
610
+ }
611
+ // Only accept primitive values, arrays of strings, and plain objects
612
+ if (val === null || val === undefined) {
613
+ safe[key] = val
614
+ } else if (
615
+ typeof val === "string" ||
616
+ typeof val === "boolean" ||
617
+ typeof val === "number"
618
+ ) {
619
+ safe[key] = val
620
+ } else if (Array.isArray(val) && val.every((v) => typeof v === "string")) {
621
+ safe[key] = val
622
+ } else if (typeof val === "object") {
623
+ // Recursively sanitize nested objects (e.g. contentSecurityPolicy.directives)
624
+ const nested = sanitizeRemoteConfig(val, depth + 1)
625
+ if (nested) safe[key] = nested
626
+ }
627
+ }
628
+ return safe
629
+ }
630
+
631
+ async function fetchRemoteConfig(apiToken, apiHostname, fqdn) {
632
+ if (!apiToken || !fqdn) return null
633
+ if (
634
+ typeof fqdn !== "string" ||
635
+ !fqdn.trim() ||
636
+ /[\/\\]/.test(fqdn) ||
637
+ fqdn.includes("..")
638
+ ) {
639
+ warn("Invalid FQDN provided. Skipping.")
640
+ return null
641
+ }
642
+
643
+ const url = new URL("/v1/appwarden/config", apiHostname)
644
+ url.searchParams.set("fqdn", fqdn)
645
+ const controller = new AbortController()
646
+ const timeout = setTimeout(() => controller.abort(), 10_000)
647
+ const href = url.toString()
648
+ try {
649
+ const res = await fetch(href, {
650
+ headers: { Authorization: apiToken },
651
+ signal: controller.signal,
652
+ })
653
+ if (!res.ok) {
654
+ warn(`fetch failed: ${href} ${res.status} ${res.statusText}`)
655
+ return null
656
+ }
657
+ const contentLength = res.headers.get("content-length")
658
+ if (
659
+ contentLength &&
660
+ parseInt(contentLength, 10) > MAX_REMOTE_RESPONSE_SIZE
661
+ ) {
662
+ warn(`Remote response too large: ${contentLength} bytes`)
663
+ return null
664
+ }
665
+ let text = ""
666
+ let bytes = 0
667
+ const reader = res.body.getReader()
668
+ const decoder = new TextDecoder("utf-8")
669
+ while (true) {
670
+ const { done, value } = await reader.read()
671
+ if (done) break
672
+ bytes += value.length
673
+ if (bytes > MAX_REMOTE_RESPONSE_SIZE) {
674
+ warn("Remote response body too large")
675
+ try {
676
+ await reader.cancel()
677
+ } catch {}
678
+ return null
679
+ }
680
+ text += decoder.decode(value, { stream: true })
681
+ }
682
+ text += decoder.decode()
683
+
684
+ const data = JSON.parse(text)
685
+
686
+ // The API returns { content: [{ url, options }] } — extract the config object
687
+ let config = null
688
+ if (data && Array.isArray(data.content)) {
689
+ const entry = data.content.find(
690
+ (item) =>
691
+ item.url === fqdn ||
692
+ item.url === `www.${fqdn}` ||
693
+ fqdn === `www.${item.url}`,
694
+ )
695
+ config = entry?.options ?? null
696
+ } else if (data && typeof data === "object" && !Array.isArray(data)) {
697
+ // Fallback: API returned a flat object directly
698
+ config = data
699
+ }
700
+
701
+ if (config) {
702
+ config = normalizeRemoteConfigKeys(config)
703
+ // Normalize API-specific CSP keys into the middleware shape
704
+ if (config.cspMode || config.cspDirectives) {
705
+ config.contentSecurityPolicy = {
706
+ mode: config.cspMode || "report-only",
707
+ directives: kebabCaseDirectives(config.cspDirectives) || {},
708
+ }
709
+ delete config.cspMode
710
+ delete config.cspDirectives
711
+ }
712
+ }
713
+
714
+ return sanitizeRemoteConfig(config)
715
+ } catch (err) {
716
+ warn(`fetch error: ${href} ${err.message}`)
717
+ return null
718
+ } finally {
719
+ clearTimeout(timeout)
720
+ }
721
+ }
722
+
723
+ function mergeConfigs(remote, localHeaders) {
724
+ const merged = Object.create(null)
725
+ if (remote && typeof remote === "object") {
726
+ for (const key of Object.keys(remote)) {
727
+ merged[key] = remote[key]
728
+ }
729
+ }
730
+ if (localHeaders) {
731
+ merged.contentSecurityPolicy = localHeaders
732
+ }
733
+ return merged
734
+ }
735
+
736
+ function checkGitignore(cwd) {
737
+ if (process.env.APPWARDEN_SKIP_GITIGNORE_CHECK === "1") return true
738
+ let current = path.resolve(cwd)
739
+ const root = path.parse(current).root
740
+ while (current !== root) {
741
+ const gitignorePath = path.join(current, ".gitignore")
742
+ try {
743
+ const resolved = fs.realpathSync(gitignorePath)
744
+ const resolvedDir = fs.realpathSync(current)
745
+ if (path.dirname(resolved) !== resolvedDir) {
746
+ warn(`Skipping suspicious gitignore symlink: ${resolved}`)
747
+ return false
748
+ }
749
+ if (!resolved.endsWith(path.sep + ".gitignore")) {
750
+ warn(`Skipping suspicious gitignore symlink: ${resolved}`)
751
+ return false
752
+ }
753
+ const fd = fs.openSync(resolved, "r")
754
+ try {
755
+ const stats = fs.fstatSync(fd)
756
+ if (!stats.isFile()) {
757
+ warn(`Skipping non-file .gitignore: ${resolved}`)
758
+ return false
759
+ }
760
+ if (stats.size > MAX_GITIGNORE_SIZE) {
761
+ warn(`Skipping oversized .gitignore: ${resolved}`)
762
+ return false
763
+ }
764
+ const content = fs.readFileSync(fd, "utf-8")
765
+ if (content.includes(CONFIG_PATH) || content.includes(".appwarden/")) {
766
+ return true
767
+ }
768
+ } finally {
769
+ fs.closeSync(fd)
770
+ }
771
+ } catch (err) {
772
+ if (err.code === "ENOENT") {
773
+ // .gitignore doesn't exist in this directory; continue searching up
774
+ } else {
775
+ return false
776
+ }
777
+ }
778
+ const parent = path.dirname(current)
779
+ if (parent === current) break
780
+ current = parent
781
+ }
782
+ return false
783
+ }
784
+
785
+ async function main() {
786
+ if (process.env.APPWARDEN_SKIP_POSTBUILD === "1") {
787
+ print("APPWARDEN_SKIP_POSTBUILD=1 detected, skipping.")
788
+ process.exit(0)
789
+ }
790
+ const args = parseArgs()
791
+ const cwd = args.cwd
792
+ const framework = detectFramework(cwd, args.framework)
793
+ if (!framework) {
794
+ warn("Could not detect framework. Use --framework=<name> to specify.")
795
+ } else {
796
+ print(`Detected framework: ${framework}`)
797
+ }
798
+ const apiToken = process.env.APPWARDEN_API_TOKEN || null
799
+ const apiHostname = args.staging
800
+ ? "https://staging-api.appwarden.io"
801
+ : "https://api.appwarden.io"
802
+ const fqdn = args.fqdn || process.env.APPWARDEN_FQDN || null
803
+ if (args.staging) {
804
+ print("Using staging API hostname")
805
+ }
806
+ if (apiToken) {
807
+ print(`Using provided Appwarden API token`)
808
+ } else {
809
+ print("APPWARDEN_API_TOKEN not set. Using local headers and defaults only.")
810
+ }
811
+ const localHeaders = extractLocalHeadersConfig(cwd, framework)
812
+ if (localHeaders) {
813
+ print("Found local CSP headers configuration.")
814
+ }
815
+ const remote = await fetchRemoteConfig(apiToken, apiHostname, fqdn)
816
+ if (remote) {
817
+ print("Fetched remote Appwarden configuration.")
818
+ }
819
+ const merged = mergeConfigs(remote, localHeaders)
820
+ if (!merged.lockPageSlug && merged.lockPageSlug !== "") {
821
+ merged.lockPageSlug = ""
822
+ }
823
+ const outDir = path.join(cwd, ".appwarden", "linked")
824
+ const outPath = path.join(outDir, "middleware.json")
825
+ const safeConfig = { ...merged }
826
+ delete safeConfig.appwardenApiToken
827
+ const validation = BuildOutputSchema.safeParse(safeConfig)
828
+ if (!validation.success) {
829
+ warn("Generated config failed validation:", validation.error.message)
830
+ process.exit(1)
831
+ }
832
+ const writeOk = safeWriteFile(
833
+ outPath,
834
+ JSON.stringify(safeConfig, null, 2) + "\n",
835
+ cwd,
836
+ )
837
+ if (writeOk) {
838
+ print(`Wrote merged configuration to ${outPath}`)
839
+ } else {
840
+ warn(`Failed to write merged configuration to ${outPath}`)
841
+ process.exit(1)
842
+ }
843
+ if (!checkGitignore(cwd)) {
844
+ warn(GITIGNORE_WARNING)
845
+ }
846
+ }
847
+
848
+ main().catch((err) => {
849
+ console.error("[appwarden] Fatal error:", err)
850
+ process.exit(1)
851
+ })