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.
- package/CLAUDE_MD_SNIPPET.md +66 -0
- package/INSTALL.md +261 -0
- package/LICENSE +90 -0
- package/README.md +404 -0
- package/bin/paneltir.mjs +331 -0
- package/dist/index.d.ts +1489 -0
- package/dist/index.js +3754 -0
- package/dist/style.css +1973 -0
- package/dist/style.d.ts +2 -0
- package/fingerprint.json +7 -0
- package/package.json +94 -0
- package/renovate.json.example +13 -0
- package/template/.claude/skills/panel/SKILL.md +155 -0
- package/template/api/login.ts +63 -0
- package/template/api/logout.ts +31 -0
- package/template/api/panel-state.ts +317 -0
- package/template/lib/session.ts +170 -0
- package/template/middleware.ts +175 -0
- package/template/src/data/panel-state.json +83 -0
|
@@ -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
|
+
}
|