paneltir 0.6.0

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,317 @@
1
+ /**
2
+ * Saves this project's board back to this project's repository.
3
+ *
4
+ * COPIED FROM PANELTIR, AND NOW YOURS. It points at your repo, commits with
5
+ * your token, and validates your board's shape — nothing here is shared with
6
+ * any other project that uses the kit.
7
+ *
8
+ * The private panel POSTs its current state here; this function commits it
9
+ * through the GitHub API, so every edit made from the board is a normal commit
10
+ * with a diff and a history. Pushing to the branch triggers a rebuild, which
11
+ * is what makes the change visible on the site.
12
+ *
13
+ * The session cookie is re-checked here rather than trusted from the edge:
14
+ * the middleware is a gate, not a guarantee, and this function writes to the
15
+ * repository.
16
+ *
17
+ * Environment (Vercel project settings):
18
+ * GH_TOKEN fine-grained token with contents: read and write on THIS
19
+ * project's repository only. Server-side only; never sent to
20
+ * the browser.
21
+ * ADMIN_PASSWORD this project's panel password, which also signs the session.
22
+ * SESSION_SECRET optional signing key; defaults to ADMIN_PASSWORD.
23
+ * PANEL_REPO required "owner/repo" — this project's repository.
24
+ * PANEL_FILE optional path to the board within it.
25
+ * PANEL_BRANCH optional, defaults to main.
26
+ */
27
+ import { SESSION_COOKIE, readCookie, verifyToken } from '../lib/session'
28
+
29
+ /**
30
+ * Where the board lives in this repository. Set PANEL_FILE to override; the
31
+ * default is only a guess at a sensible place, and a wrong path here fails
32
+ * loudly on the first save rather than quietly writing somewhere else.
33
+ */
34
+ const FILE_PATH = process.env.PANEL_FILE || 'src/data/panel-state.json'
35
+ const MAX_BODY_BYTES = 512 * 1024
36
+
37
+ interface VercelRequest {
38
+ method?: string
39
+ headers: Record<string, string | string[] | undefined>
40
+ body?: unknown
41
+ }
42
+
43
+ interface VercelResponse {
44
+ status: (code: number) => VercelResponse
45
+ json: (body: unknown) => void
46
+ setHeader: (name: string, value: string) => void
47
+ end: (body?: string) => void
48
+ }
49
+
50
+ function header(request: VercelRequest, name: string): string {
51
+ const value = request.headers[name]
52
+ return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
53
+ }
54
+
55
+ function authorized(request: VercelRequest): Promise<boolean> {
56
+ return verifyToken(readCookie(header(request, 'cookie'), SESSION_COOKIE))
57
+ }
58
+
59
+ /**
60
+ * Rejects anything that is not a board, before it reaches the repository.
61
+ *
62
+ * This is the same check the kit exports as `validateBoard`, written out here
63
+ * rather than imported. Two reasons, and both are about where this runs: a
64
+ * serverless function that pulls in the component library to check a JSON
65
+ * shape carries a React bundle it never renders, and this file is yours to
66
+ * edit — a board with a field of its own should be checkable without waiting
67
+ * for the kit. `npm run test:board` upstream puts the same boards to both and
68
+ * requires the same verdict, so the copy cannot quietly drift.
69
+ *
70
+ * ADAPT THIS TO YOUR BOARD. What is here checks the shape every Paneltir board
71
+ * shares. Whatever else yours relies on, check it here: this function is the
72
+ * only thing between a bad request and a commit, and a board that will not
73
+ * parse is a panel that will not open.
74
+ *
75
+ * It collects every problem instead of stopping at the first. Somebody looking
76
+ * at a rejected save wants the list.
77
+ */
78
+ const BOARD_VERSION = 2
79
+ const INTENTS = ['decide', 'explain', 'solve', 'do', 'cheap', 'safe', 'fast', 'askme', 'hold']
80
+ const LEVELS = ['high', 'medium', 'low']
81
+ const OWNERS = ['claude', 'you']
82
+
83
+ function isObject(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
85
+ }
86
+
87
+ function isText(value: unknown): boolean {
88
+ if (typeof value === 'string') return true
89
+ if (!isObject(value)) return false
90
+ return Object.values(value).every((entry) => typeof entry === 'string')
91
+ }
92
+
93
+ function invalidState(state: unknown): string | null {
94
+ const problems: string[] = []
95
+ const say = (at: string, says: string) => problems.push(at ? `${at}: ${says}` : says)
96
+
97
+ if (!isObject(state)) return 'the board is not a JSON object'
98
+
99
+ // Answered first: a board written for an older kit fails the shape check for
100
+ // reasons that are not faults, and saying "v" is the useful answer.
101
+ if (state.v !== BOARD_VERSION) {
102
+ return state.v === undefined
103
+ ? `v: no version. This panel reads v ${BOARD_VERSION}`
104
+ : `v: written for v ${JSON.stringify(state.v)}, and this panel reads v ${BOARD_VERSION}`
105
+ }
106
+
107
+ if (typeof state.project !== 'string') say('project', 'must be a string')
108
+ if (typeof state.updatedAt !== 'string') say('updatedAt', 'must be a string')
109
+
110
+ const columnIds = new Set<string>()
111
+ if (!Array.isArray(state.columns) || state.columns.length === 0) {
112
+ say('columns', 'must be a non-empty array — a board with no columns has nowhere to put a card')
113
+ } else {
114
+ state.columns.forEach((column, index) => {
115
+ const at = `columns[${index}]`
116
+ if (!isObject(column)) return say(at, 'must be an object')
117
+ if (typeof column.id !== 'string' || column.id === '') return say(`${at}.id`, 'must be a non-empty string')
118
+ if (columnIds.has(column.id)) say(`${at}.id`, `"${column.id}" is used by more than one column`)
119
+ columnIds.add(column.id)
120
+ if (!isText(column.title)) say(`${at}.title`, 'must be a string, or an object of language strings')
121
+ })
122
+ }
123
+
124
+ const areaIds = new Set<string>()
125
+ if (!Array.isArray(state.areas)) {
126
+ say('areas', 'must be an array')
127
+ } else {
128
+ state.areas.forEach((area, index) => {
129
+ if (!isObject(area) || typeof area.id !== 'string') return say(`areas[${index}].id`, 'must be a non-empty string')
130
+ areaIds.add(area.id)
131
+ })
132
+ }
133
+
134
+ const cardIds = new Set<string>()
135
+ if (!Array.isArray(state.cards)) {
136
+ say('cards', 'must be an array')
137
+ } else if (state.cards.length > 500) {
138
+ say('cards', 'too many cards')
139
+ } else {
140
+ state.cards.forEach((card, index) => {
141
+ const at = `cards[${index}]`
142
+ if (!isObject(card)) return say(at, 'must be an object')
143
+
144
+ if (typeof card.id !== 'string' || card.id === '') {
145
+ say(`${at}.id`, 'must be a non-empty string')
146
+ } else if (cardIds.has(card.id)) {
147
+ say(`${at}.id`, `"${card.id}" is used by more than one card`)
148
+ } else {
149
+ cardIds.add(card.id)
150
+ }
151
+
152
+ if (typeof card.column !== 'string') {
153
+ say(`${at}.column`, 'must be a string')
154
+ } else if (columnIds.size && !columnIds.has(card.column)) {
155
+ say(`${at}.column`, `"${card.column}" is not one of the board's columns, so this card is drawn nowhere`)
156
+ }
157
+
158
+ // A card whose area is missing or unknown is invisible behind every area
159
+ // filter: it is on the board and cannot be found from the filter row.
160
+ if (typeof card.area !== 'string') {
161
+ say(`${at}.area`, 'must be a string')
162
+ } else if (areaIds.size && !areaIds.has(card.area)) {
163
+ say(`${at}.area`, `"${card.area}" is not one of the board's areas`)
164
+ }
165
+
166
+ if (!isText(card.title)) say(`${at}.title`, 'must be a string, or an object of language strings')
167
+
168
+ for (const key of ['priority', 'risk']) {
169
+ const level = (card as Record<string, unknown>)[key]
170
+ if (level !== undefined && !LEVELS.includes(level as string)) {
171
+ say(`${at}.${key}`, `must be high, medium or low — found ${JSON.stringify(level)}`)
172
+ }
173
+ }
174
+
175
+ if (card.owner !== undefined && !OWNERS.includes(card.owner as string)) {
176
+ say(`${at}.owner`, `must be claude or you — found ${JSON.stringify(card.owner)}`)
177
+ }
178
+
179
+ if (card.intent !== undefined && card.intent !== null && !INTENTS.includes(card.intent as string)) {
180
+ say(`${at}.intent`, `${JSON.stringify(card.intent)} is not one of: ${INTENTS.join(', ')}`)
181
+ }
182
+
183
+ if (card.checks !== undefined && !Array.isArray(card.checks)) say(`${at}.checks`, 'must be an array')
184
+ if (card.notes !== undefined && !Array.isArray(card.notes)) say(`${at}.notes`, 'must be an array')
185
+ })
186
+ }
187
+
188
+ if (!Array.isArray(state.runs)) say('runs', 'must be an array')
189
+
190
+ return problems.length ? problems.join('; ') : null
191
+ }
192
+
193
+
194
+ /**
195
+ * What the panel needs, and what of it is actually configured.
196
+ *
197
+ * Reports only whether a variable is set, never its value: the panel needs to
198
+ * know it cannot save, not what the token is. Without this the first sign of a
199
+ * missing token is a failed save, which reads as the panel being broken rather
200
+ * than unfinished.
201
+ */
202
+ function configuration() {
203
+ const repo = process.env.PANEL_REPO
204
+ const missing: string[] = []
205
+ if (!process.env.GH_TOKEN) missing.push('GH_TOKEN')
206
+ if (!repo) missing.push('PANEL_REPO')
207
+ return {
208
+ ready: missing.length === 0,
209
+ missing,
210
+ // Safe to echo: these are addresses, not secrets, and seeing the wrong one
211
+ // is how a misconfigured target gets noticed before it is written to.
212
+ repo: repo ?? null,
213
+ branch: process.env.PANEL_BRANCH || 'main',
214
+ file: FILE_PATH,
215
+ }
216
+ }
217
+
218
+ export default async function handler(request: VercelRequest, response: VercelResponse) {
219
+ response.setHeader('Cache-Control', 'no-store')
220
+
221
+ if (request.method !== 'POST' && request.method !== 'GET') {
222
+ response.setHeader('Allow', 'GET, POST')
223
+ return response.status(405).json({ error: 'Use GET or POST.' })
224
+ }
225
+
226
+ // Authorised before anything is reported: what is configured is not public.
227
+ if (!(await authorized(request))) {
228
+ return response.status(401).json({ error: 'Sign in to continue.' })
229
+ }
230
+
231
+ // GET asks what is set up; only POST writes.
232
+ if (request.method === 'GET') return response.status(200).json(configuration())
233
+
234
+ const token = process.env.GH_TOKEN
235
+ if (!token) return response.status(503).json({ error: 'GH_TOKEN is not configured.' })
236
+
237
+ // No default: writing to the wrong repository is worse than not writing.
238
+ const repo = process.env.PANEL_REPO
239
+ if (!repo) return response.status(503).json({ error: 'PANEL_REPO is not configured.' })
240
+ const branch = process.env.PANEL_BRANCH || 'main'
241
+
242
+ const payload = (typeof request.body === 'string' ? safeParse(request.body) : request.body) as
243
+ | { state?: unknown; message?: unknown }
244
+ | undefined
245
+ if (!payload) return response.status(400).json({ error: 'Malformed body.' })
246
+
247
+ const problem = invalidState(payload.state)
248
+ if (problem) return response.status(400).json({ error: problem })
249
+
250
+ const content = `${JSON.stringify(payload.state, null, 2)}\n`
251
+ if (Buffer.byteLength(content, 'utf8') > MAX_BODY_BYTES) {
252
+ return response.status(413).json({ error: 'The state is too large.' })
253
+ }
254
+
255
+ const message =
256
+ typeof payload.message === 'string' && payload.message.trim()
257
+ ? payload.message.trim().slice(0, 120)
258
+ : 'chore(panel): update the board from the panel'
259
+
260
+ const api = `https://api.github.com/repos/${repo}/contents/${FILE_PATH}`
261
+ const githubHeaders = {
262
+ authorization: `Bearer ${token}`,
263
+ accept: 'application/vnd.github+json',
264
+ 'x-github-api-version': '2022-11-28',
265
+ 'user-agent': 'paneltir-panel',
266
+ }
267
+
268
+ try {
269
+ // The current blob sha is what makes the write a fast-forward: if someone
270
+ // else changed the file meanwhile, GitHub rejects it instead of silently
271
+ // overwriting their edit.
272
+ const current = await fetch(`${api}?ref=${encodeURIComponent(branch)}`, { headers: githubHeaders })
273
+ if (!current.ok && current.status !== 404) {
274
+ return response.status(502).json({ error: `GitHub answered ${current.status} when reading the file.` })
275
+ }
276
+ const sha = current.ok ? ((await current.json()) as { sha?: string }).sha : undefined
277
+
278
+ const write = await fetch(api, {
279
+ method: 'PUT',
280
+ headers: { ...githubHeaders, 'content-type': 'application/json' },
281
+ body: JSON.stringify({
282
+ message,
283
+ content: Buffer.from(content, 'utf8').toString('base64'),
284
+ branch,
285
+ ...(sha ? { sha } : {}),
286
+ }),
287
+ })
288
+
289
+ if (!write.ok) {
290
+ const detail = (await write.json().catch(() => ({}))) as { message?: string }
291
+ const conflict = write.status === 409 || write.status === 422
292
+ return response.status(conflict ? 409 : 502).json({
293
+ error: conflict
294
+ ? 'The file changed in the repository since this panel loaded. Reload and save again.'
295
+ : `GitHub answered ${write.status}.`,
296
+ detail: detail.message,
297
+ })
298
+ }
299
+
300
+ const result = (await write.json()) as { commit?: { sha?: string; html_url?: string } }
301
+ return response.status(200).json({
302
+ ok: true,
303
+ sha: result.commit?.sha,
304
+ url: result.commit?.html_url,
305
+ })
306
+ } catch (error) {
307
+ return response.status(502).json({ error: 'Could not reach GitHub.', detail: String(error) })
308
+ }
309
+ }
310
+
311
+ function safeParse(value: string): unknown {
312
+ try {
313
+ return JSON.parse(value)
314
+ } catch {
315
+ return undefined
316
+ }
317
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * This project's panel session: one signed cookie, verified the same way on
3
+ * the edge and in the serverless functions.
4
+ *
5
+ * COPIED FROM PANELTIR, AND NOW YOURS. Its password is this project's, its
6
+ * secret is this project's, and nothing here is shared with any other project
7
+ * that uses the kit.
8
+ *
9
+ * The panel used HTTP Basic auth, which works but hands the browser's own
10
+ * credential dialog to anyone opening /admin — an untitled grey box that
11
+ * cannot be branded, cannot say what it is protecting, and cannot report a
12
+ * wrong password as anything other than showing itself again. So the password
13
+ * is posted to /api/login instead and answered with this cookie.
14
+ *
15
+ * The cookie carries no data worth protecting — only an expiry and a
16
+ * signature over it — so there is nothing in it to decrypt or tamper into
17
+ * something useful. Everything else is derived server-side.
18
+ *
19
+ * Web Crypto is used rather than node:crypto because this module runs in both
20
+ * runtimes: the edge middleware verifies the same token the Node function
21
+ * issues. For the same reason the environment is read through `env()` rather
22
+ * than touching `process` directly: a bare `process.env.X` is a ReferenceError
23
+ * wherever `process` is not defined, and in middleware that reads as the whole
24
+ * route crashing rather than as a missing variable.
25
+ *
26
+ * Environment:
27
+ * ADMIN_PASSWORD the password. Required; without it the panel fails closed.
28
+ * SESSION_SECRET optional signing key. Defaults to ADMIN_PASSWORD, which
29
+ * means changing the password invalidates every session
30
+ * already issued — the behaviour you want from a password
31
+ * change.
32
+ */
33
+
34
+ export const SESSION_COOKIE = 'pt_session'
35
+ /** Eight hours: a working day, then the panel asks again. */
36
+ export const SESSION_MAX_AGE = 60 * 60 * 8
37
+ const VERSION = 'v1'
38
+
39
+ const encoder = new TextEncoder()
40
+
41
+ /**
42
+ * Reads one variable, surviving a runtime with no `process` at all.
43
+ *
44
+ * The names are written out as literal `process.env.X` accesses because that
45
+ * is the form build tools substitute at build time; reading them through a
46
+ * computed key would leave nothing to substitute.
47
+ */
48
+ export function env(name: 'ADMIN_PASSWORD' | 'SESSION_SECRET'): string | undefined {
49
+ try {
50
+ if (name === 'ADMIN_PASSWORD') return process.env.ADMIN_PASSWORD || undefined
51
+ return process.env.SESSION_SECRET || undefined
52
+ } catch {
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ function signingSecret(): string | null {
58
+ const password = env('ADMIN_PASSWORD')
59
+ if (!password) return null
60
+ return env('SESSION_SECRET') || password
61
+ }
62
+
63
+ async function hmac(secret: string, message: string): Promise<string> {
64
+ const key = await crypto.subtle.importKey(
65
+ 'raw',
66
+ encoder.encode(secret),
67
+ { name: 'HMAC', hash: 'SHA-256' },
68
+ false,
69
+ ['sign']
70
+ )
71
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
72
+ // base64url, so the value never needs escaping in a cookie.
73
+ let binary = ''
74
+ for (const byte of new Uint8Array(signature)) binary += String.fromCharCode(byte)
75
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
76
+ }
77
+
78
+ /** Compares two strings in constant time, so the response timing leaks nothing. */
79
+ export function safeEqual(a: string, b: string): boolean {
80
+ const left = encoder.encode(a)
81
+ const right = encoder.encode(b)
82
+ // Fold the length difference into the result instead of returning early.
83
+ let diff = left.length ^ right.length
84
+ const max = Math.max(left.length, right.length)
85
+ for (let i = 0; i < max; i += 1) diff |= (left[i] ?? 0) ^ (right[i] ?? 0)
86
+ return diff === 0
87
+ }
88
+
89
+ /** Issues a token that stops being valid on its own, without any server state. */
90
+ export async function issueToken(now = Date.now()): Promise<string | null> {
91
+ const secret = signingSecret()
92
+ if (!secret) return null
93
+ const expires = Math.floor(now / 1000) + SESSION_MAX_AGE
94
+ const body = `${VERSION}.${expires}`
95
+ return `${body}.${await hmac(secret, body)}`
96
+ }
97
+
98
+ export async function verifyToken(token: string | undefined, now = Date.now()): Promise<boolean> {
99
+ const secret = signingSecret()
100
+ if (!secret || !token) return false
101
+
102
+ const parts = token.split('.')
103
+ if (parts.length !== 3) return false
104
+ const [version, expires, signature] = parts
105
+ if (version !== VERSION) return false
106
+
107
+ const expiry = Number(expires)
108
+ if (!Number.isSafeInteger(expiry) || expiry * 1000 <= now) return false
109
+
110
+ // The signature is checked even when the expiry already failed above only in
111
+ // the sense that both paths are cheap; there is no secret in the expiry.
112
+ return safeEqual(signature, await hmac(secret, `${version}.${expires}`))
113
+ }
114
+
115
+ /** Reads one cookie out of a Cookie header, without a parser dependency. */
116
+ export function readCookie(header: string | null | undefined, name: string): string | undefined {
117
+ if (!header) return undefined
118
+ for (const pair of header.split(';')) {
119
+ const eq = pair.indexOf('=')
120
+ if (eq === -1) continue
121
+ if (pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim()
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ /**
127
+ * `Secure` is left off on localhost only: a secure cookie is never stored over
128
+ * plain http, which would make the panel impossible to sign into in local
129
+ * development while changing nothing about how it behaves in production.
130
+ */
131
+ export function sessionCookie(token: string, { secure = true } = {}): string {
132
+ return [
133
+ `${SESSION_COOKIE}=${token}`,
134
+ 'Path=/',
135
+ 'HttpOnly',
136
+ 'SameSite=Lax',
137
+ secure ? 'Secure' : null,
138
+ `Max-Age=${SESSION_MAX_AGE}`,
139
+ ]
140
+ .filter(Boolean)
141
+ .join('; ')
142
+ }
143
+
144
+ export function clearedCookie({ secure = true } = {}): string {
145
+ return [
146
+ `${SESSION_COOKIE}=`,
147
+ 'Path=/',
148
+ 'HttpOnly',
149
+ 'SameSite=Lax',
150
+ secure ? 'Secure' : null,
151
+ 'Max-Age=0',
152
+ ]
153
+ .filter(Boolean)
154
+ .join('; ')
155
+ }
156
+
157
+ /**
158
+ * Where to send someone after they sign in.
159
+ *
160
+ * Only a path on this site is ever accepted: anything absolute, protocol
161
+ * relative, or not starting with /admin falls back to the panel. Without this
162
+ * the login form would forward a visitor to any URL an attacker put in the
163
+ * query string, with the site's own name on the link.
164
+ */
165
+ export function safeNext(raw: string | null | undefined): string {
166
+ if (!raw) return '/admin'
167
+ if (!raw.startsWith('/admin')) return '/admin'
168
+ if (raw.startsWith('//') || raw.includes('\\')) return '/admin'
169
+ return raw
170
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Vercel Edge Middleware — the gate in front of this project's private panel.
3
+ *
4
+ * COPIED FROM PANELTIR, AND NOW YOURS. Unlike anything in node_modules, this
5
+ * file is meant to be read and edited: it holds this project's password rule,
6
+ * not the kit's. `npx paneltir init` will not overwrite it once it exists.
7
+ *
8
+ * It guards /admin, the panel's own JavaScript bundle (emitted under
9
+ * assets/panel/ for exactly this reason) and the write API, so the board
10
+ * cannot be downloaded or committed to without a session.
11
+ *
12
+ * What it does when there is no session depends on what was asked for: a page
13
+ * is sent to the sign-in screen, anything else gets a 401 it can read. A page
14
+ * navigation answered with 401 is how you end up looking at the browser's own
15
+ * grey credential box, which is the thing this replaced.
16
+ *
17
+ * ## Why this file imports nothing
18
+ *
19
+ * It used to read the session through `lib/session.ts`, which is also what the
20
+ * serverless functions use — one module, no duplication. That version answered
21
+ * MIDDLEWARE_INVOCATION_FAILED in production: not a thrown request, because
22
+ * the handler already catches those, but the module failing before any of its
23
+ * code ran. The import was the only thing at that level that could fail.
24
+ *
25
+ * So this file is deliberately standalone. It is the one file that runs in a
26
+ * runtime nobody here can inspect, under a build nobody here controls, and it
27
+ * is the gate on everything private — the last place worth being clever about
28
+ * sharing code. `npm run test:auth` asserts that its copy of the token check
29
+ * agrees with `lib/session.ts` on the same inputs, so the duplication is held
30
+ * honest by a test rather than by memory.
31
+ *
32
+ * Two rules hold whatever else happens:
33
+ *
34
+ * - it fails closed. Missing configuration, or a fault in this file, denies
35
+ * the panel; nothing makes it fall open.
36
+ * - it never throws. A middleware that throws takes the route down with a
37
+ * crash page that says nothing, and the panel's own door is the worst
38
+ * place to have to guess from.
39
+ */
40
+
41
+ /**
42
+ * What the gate covers. Adjust the last entry to wherever this project's build
43
+ * emits the private panel's own JavaScript: protecting the page but not its
44
+ * bundle leaves the board downloadable by anyone who reads the HTML.
45
+ */
46
+ export const config = {
47
+ matcher: ['/admin', '/admin/:path*', '/api/:path*', '/assets/panel/:path*'],
48
+ }
49
+
50
+ const SESSION_COOKIE = 'pt_session'
51
+ const VERSION = 'v1'
52
+
53
+ /**
54
+ * Reads one variable, surviving a runtime with no `process` at all.
55
+ *
56
+ * Written out as literal `process.env.X` accesses because that is the form
57
+ * build tools substitute at build time; a computed key would leave nothing to
58
+ * substitute. A bare access is a ReferenceError where `process` is undefined,
59
+ * which in middleware reads as the whole route crashing rather than as a
60
+ * missing variable — hence the catch.
61
+ */
62
+ function env(name: 'ADMIN_PASSWORD' | 'SESSION_SECRET'): string | undefined {
63
+ try {
64
+ if (name === 'ADMIN_PASSWORD') return process.env.ADMIN_PASSWORD || undefined
65
+ return process.env.SESSION_SECRET || undefined
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ /** Kept identical to lib/session.ts; test:auth asserts they agree. */
72
+ function signingSecret(): string | null {
73
+ const password = env('ADMIN_PASSWORD')
74
+ if (!password) return null
75
+ return env('SESSION_SECRET') || password
76
+ }
77
+
78
+ function safeEqual(a: string, b: string): boolean {
79
+ const encoder = new TextEncoder()
80
+ const left = encoder.encode(a)
81
+ const right = encoder.encode(b)
82
+ let diff = left.length ^ right.length
83
+ const max = Math.max(left.length, right.length)
84
+ for (let i = 0; i < max; i += 1) diff |= (left[i] ?? 0) ^ (right[i] ?? 0)
85
+ return diff === 0
86
+ }
87
+
88
+ async function hmac(secret: string, message: string): Promise<string> {
89
+ const encoder = new TextEncoder()
90
+ const key = await crypto.subtle.importKey(
91
+ 'raw',
92
+ encoder.encode(secret),
93
+ { name: 'HMAC', hash: 'SHA-256' },
94
+ false,
95
+ ['sign']
96
+ )
97
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
98
+ let binary = ''
99
+ for (const byte of new Uint8Array(signature)) binary += String.fromCharCode(byte)
100
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
101
+ }
102
+
103
+ async function verifyToken(token: string | undefined, now = Date.now()): Promise<boolean> {
104
+ const secret = signingSecret()
105
+ if (!secret || !token) return false
106
+
107
+ const parts = token.split('.')
108
+ if (parts.length !== 3) return false
109
+ const [version, expires, signature] = parts
110
+ if (version !== VERSION) return false
111
+
112
+ const expiry = Number(expires)
113
+ if (!Number.isSafeInteger(expiry) || expiry * 1000 <= now) return false
114
+
115
+ return safeEqual(signature, await hmac(secret, `${version}.${expires}`))
116
+ }
117
+
118
+ function readCookie(header: string | null | undefined, name: string): string | undefined {
119
+ if (!header) return undefined
120
+ for (const pair of header.split(';')) {
121
+ const eq = pair.indexOf('=')
122
+ if (eq === -1) continue
123
+ if (pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim()
124
+ }
125
+ return undefined
126
+ }
127
+
128
+ function closed(message: string, status = 503): Response {
129
+ return new Response(message, {
130
+ status,
131
+ headers: { 'Cache-Control': 'no-store', 'content-type': 'text/plain' },
132
+ })
133
+ }
134
+
135
+ async function gate(request: Request): Promise<Response | undefined> {
136
+ const url = new URL(request.url)
137
+ // Signing in cannot require being signed in.
138
+ if (url.pathname === '/api/login' || url.pathname === '/api/logout') return undefined
139
+
140
+ if (!env('ADMIN_PASSWORD')) {
141
+ return closed('The panel is not configured: ADMIN_PASSWORD is missing.')
142
+ }
143
+
144
+ const token = readCookie(request.headers.get('cookie'), SESSION_COOKIE)
145
+ if (await verifyToken(token)) return undefined
146
+
147
+ // A navigation asks for HTML first; fetch() from the panel asks for JSON.
148
+ if ((request.headers.get('accept') || '').includes('text/html')) {
149
+ const login = new URL('/login', url)
150
+ // So signing in returns to whatever was actually asked for.
151
+ login.searchParams.set('next', url.pathname + url.search)
152
+ // One header, built by hand: Response.redirect is not equally forgiving
153
+ // about being handed a URL rather than a string in every runtime.
154
+ return new Response(null, {
155
+ status: 302,
156
+ headers: { 'Cache-Control': 'no-store', location: login.toString() },
157
+ })
158
+ }
159
+
160
+ return new Response(JSON.stringify({ error: 'Sign in to continue.' }), {
161
+ status: 401,
162
+ headers: { 'Cache-Control': 'no-store', 'content-type': 'application/json' },
163
+ })
164
+ }
165
+
166
+ export default async function middleware(request: Request): Promise<Response | undefined> {
167
+ try {
168
+ return await gate(request)
169
+ } catch (error) {
170
+ // Named, not swallowed: an operator reading this should not have to open a
171
+ // log to find out which of the possible faults it is.
172
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
173
+ return closed(`The panel gate failed, so the panel is closed.\n\n${detail.slice(0, 300)}`)
174
+ }
175
+ }