paneltir 0.6.0 → 0.7.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/INSTALL.md +2 -2
- package/README.md +1 -1
- package/bin/paneltir.mjs +6 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +59 -32
- package/fingerprint.json +3 -3
- package/package.json +1 -1
- package/template/api/panel-state.ts +139 -3
package/INSTALL.md
CHANGED
|
@@ -19,7 +19,7 @@ identity** — never another project's.
|
|
|
19
19
|
npm install paneltir
|
|
20
20
|
|
|
21
21
|
# or, without npm access, straight from the repository:
|
|
22
|
-
# npm install github:daifukus/paneltir#v0.
|
|
22
|
+
# npm install github:daifukus/paneltir#v0.7.0
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
(Use the tag you were given; never install without pinning a version.)
|
|
@@ -181,7 +181,7 @@ identity** — never another project's.
|
|
|
181
181
|
|
|
182
182
|
```bash
|
|
183
183
|
npx paneltir version # version, fingerprint, where it came from
|
|
184
|
-
npx paneltir check 0.
|
|
184
|
+
npx paneltir check 0.7.0 # exits non-zero if that is not what is installed
|
|
185
185
|
npx paneltir board # read the board and say what is wrong with it
|
|
186
186
|
```
|
|
187
187
|
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ tag rather than a branch, so an unfinished push cannot reach a project:
|
|
|
54
54
|
npm install paneltir
|
|
55
55
|
|
|
56
56
|
# or, without npm access, straight from the repository:
|
|
57
|
-
# npm install github:daifukus/paneltir#v0.
|
|
57
|
+
# npm install github:daifukus/paneltir#v0.7.0
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
Installing from Git runs the `prepare` script, which builds `dist/`, so the
|
package/bin/paneltir.mjs
CHANGED
|
@@ -239,7 +239,12 @@ function version() {
|
|
|
239
239
|
console.log(`paneltir v${pkg.version}`)
|
|
240
240
|
console.log(`fingerprint ${fingerprint.hash}`)
|
|
241
241
|
console.log(`${fingerprint.fileCount} tracked files`)
|
|
242
|
-
|
|
242
|
+
// It used to print "installed from github:daifukus/paneltir#vX.Y.Z", which
|
|
243
|
+
// was true while that was the only way in and became a guess the moment the
|
|
244
|
+
// package went to npm. A copy on disk does not carry where it was fetched
|
|
245
|
+
// from — npm records that in the consumer's lockfile, not here — so the
|
|
246
|
+
// line is gone rather than wrong. The version and the fingerprint are facts;
|
|
247
|
+
// that was not one.
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1115,6 +1115,8 @@ interface UiStrings {
|
|
|
1115
1115
|
all: string;
|
|
1116
1116
|
board: string;
|
|
1117
1117
|
analysis: string;
|
|
1118
|
+
/** The Revisions tab. Short, because it sits beside Board and Analysis. */
|
|
1119
|
+
history: string;
|
|
1118
1120
|
cardDetail: string;
|
|
1119
1121
|
panelSettings: string;
|
|
1120
1122
|
title: string;
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var PANELTIR_VERSION = "0.
|
|
3
|
-
var PANELTIR_FINGERPRINT = "sha256:
|
|
2
|
+
var PANELTIR_VERSION = "0.7.0";
|
|
3
|
+
var PANELTIR_FINGERPRINT = "sha256:406af8c690cfa9b2452360712afff3f3fe9605a7bb0aa834238e5187acfc37c0";
|
|
4
4
|
var PANELTIR_FILE_COUNT = 151;
|
|
5
5
|
var paneltirBuild = {
|
|
6
6
|
version: PANELTIR_VERSION,
|
|
@@ -1355,6 +1355,7 @@ var UI = {
|
|
|
1355
1355
|
all: "All",
|
|
1356
1356
|
board: "Board",
|
|
1357
1357
|
analysis: "Analysis",
|
|
1358
|
+
history: "Revisions",
|
|
1358
1359
|
cardDetail: "Card",
|
|
1359
1360
|
panelSettings: "Panel settings",
|
|
1360
1361
|
title: "Title",
|
|
@@ -1615,6 +1616,7 @@ var UI = {
|
|
|
1615
1616
|
all: "Todo",
|
|
1616
1617
|
board: "Tablero",
|
|
1617
1618
|
analysis: "An\xE1lisis",
|
|
1619
|
+
history: "Revisiones",
|
|
1618
1620
|
cardDetail: "Tarjeta",
|
|
1619
1621
|
panelSettings: "Ajustes del panel",
|
|
1620
1622
|
title: "T\xEDtulo",
|
|
@@ -2651,6 +2653,20 @@ function applyMove(cards, move, visible) {
|
|
|
2651
2653
|
// src/panel/PanelApp.tsx
|
|
2652
2654
|
import { Fragment as Fragment5, jsx as jsx19, jsxs as jsxs17 } from "react/jsx-runtime";
|
|
2653
2655
|
var IMPORTED = "imported";
|
|
2656
|
+
function parseJson(body) {
|
|
2657
|
+
try {
|
|
2658
|
+
const parsed = JSON.parse(body);
|
|
2659
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
2660
|
+
} catch {
|
|
2661
|
+
return {};
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
function saveError(status, data, body) {
|
|
2665
|
+
if (typeof data.error === "string" && data.error) return data.error;
|
|
2666
|
+
const code = body.match(/\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/)?.[0];
|
|
2667
|
+
const id = body.match(/\bID:\s*([\w:.-]+)/i)?.[1];
|
|
2668
|
+
return [`HTTP ${status}`, code, id && `id ${id}`].filter(Boolean).join(" \u2014 ");
|
|
2669
|
+
}
|
|
2654
2670
|
var PRIORITY_TONE = {
|
|
2655
2671
|
high: "danger",
|
|
2656
2672
|
medium: "warning",
|
|
@@ -3025,10 +3041,11 @@ function PanelApp({
|
|
|
3025
3041
|
credentials: "same-origin",
|
|
3026
3042
|
body: JSON.stringify({ state: next, message: "chore(panel): update the board from the panel" })
|
|
3027
3043
|
});
|
|
3028
|
-
const
|
|
3044
|
+
const body = await response.text().catch(() => "");
|
|
3045
|
+
const data = parseJson(body);
|
|
3029
3046
|
if (!response.ok) {
|
|
3030
3047
|
const conflict = response.status === 409;
|
|
3031
|
-
throw Object.assign(new Error(
|
|
3048
|
+
throw Object.assign(new Error(saveError(response.status, data, body)), { conflict });
|
|
3032
3049
|
}
|
|
3033
3050
|
setSavedSnapshot(JSON.stringify(state));
|
|
3034
3051
|
setSave({
|
|
@@ -3078,7 +3095,7 @@ function PanelApp({
|
|
|
3078
3095
|
),
|
|
3079
3096
|
status,
|
|
3080
3097
|
controls: /* @__PURE__ */ jsxs17(Fragment5, { children: [
|
|
3081
|
-
(initialState.analysis || hasMarketplace) && /* @__PURE__ */ jsxs17("span", { className: "pt-views", children: [
|
|
3098
|
+
(initialState.analysis || hasMarketplace || showHistory) && /* @__PURE__ */ jsxs17("span", { className: "pt-views", children: [
|
|
3082
3099
|
/* @__PURE__ */ jsx19(
|
|
3083
3100
|
"button",
|
|
3084
3101
|
{
|
|
@@ -3108,6 +3125,16 @@ function PanelApp({
|
|
|
3108
3125
|
onClick: () => setView("marketplace"),
|
|
3109
3126
|
children: ui.marketplace
|
|
3110
3127
|
}
|
|
3128
|
+
),
|
|
3129
|
+
showHistory && /* @__PURE__ */ jsx19(
|
|
3130
|
+
"button",
|
|
3131
|
+
{
|
|
3132
|
+
type: "button",
|
|
3133
|
+
className: `pt-lang${view === "history" ? " is-on" : ""}`,
|
|
3134
|
+
"aria-pressed": view === "history",
|
|
3135
|
+
onClick: () => setView("history"),
|
|
3136
|
+
children: ui.history
|
|
3137
|
+
}
|
|
3111
3138
|
)
|
|
3112
3139
|
] }),
|
|
3113
3140
|
showLanguage && LANGS.map((code) => /* @__PURE__ */ jsx19(
|
|
@@ -3335,33 +3362,6 @@ function PanelApp({
|
|
|
3335
3362
|
)
|
|
3336
3363
|
}
|
|
3337
3364
|
),
|
|
3338
|
-
showHistory && /* @__PURE__ */ jsxs17("section", { className: "pt-panel-page pt-panel-section", id: "revisions", children: [
|
|
3339
|
-
/* @__PURE__ */ jsxs17("div", { children: [
|
|
3340
|
-
/* @__PURE__ */ jsx19("p", { className: "pt-eyebrow", children: ui.historyEyebrow }),
|
|
3341
|
-
/* @__PURE__ */ jsx19("h2", { className: "pt-h2", children: ui.historyTitle }),
|
|
3342
|
-
/* @__PURE__ */ jsx19("p", { className: "pt-sub", children: ui.historyIntro })
|
|
3343
|
-
] }),
|
|
3344
|
-
state.runs.length === 0 ? /* @__PURE__ */ jsx19("p", { className: "pt-panel-text", children: ui.historyEmpty }) : /* @__PURE__ */ jsx19("ol", { className: "pt-runs", children: [...state.runs].reverse().map((run) => /* @__PURE__ */ jsxs17("li", { className: "pt-run pt-hud", children: [
|
|
3345
|
-
/* @__PURE__ */ jsxs17("p", { className: "pt-run__meta", children: [
|
|
3346
|
-
run.date,
|
|
3347
|
-
" \xB7 ",
|
|
3348
|
-
run.by === "claude" ? "Claude" : ui.ownerYou,
|
|
3349
|
-
run.fingerprint ? ` \xB7 ${run.fingerprint.replace("sha256:", "").slice(0, 12)}` : ""
|
|
3350
|
-
] }),
|
|
3351
|
-
/* @__PURE__ */ jsx19("p", { className: "pt-panel-text", children: t(run.summary) }),
|
|
3352
|
-
run.cards.length > 0 && /* @__PURE__ */ jsxs17("p", { className: "pt-run__cards", children: [
|
|
3353
|
-
/* @__PURE__ */ jsxs17("span", { children: [
|
|
3354
|
-
ui.historyTouched,
|
|
3355
|
-
":"
|
|
3356
|
-
] }),
|
|
3357
|
-
" ",
|
|
3358
|
-
run.cards.map((id) => {
|
|
3359
|
-
const card = state.cards.find((candidate) => candidate.id === id);
|
|
3360
|
-
return card ? t(card.title) || ui.untitled : id;
|
|
3361
|
-
}).join(" \xB7 ")
|
|
3362
|
-
] })
|
|
3363
|
-
] }, run.id)) })
|
|
3364
|
-
] }),
|
|
3365
3365
|
extras,
|
|
3366
3366
|
capabilities && capabilities.length > 0 && /* @__PURE__ */ jsxs17("section", { className: "pt-panel-page pt-panel-section", id: "functions", children: [
|
|
3367
3367
|
/* @__PURE__ */ jsxs17("div", { children: [
|
|
@@ -3380,6 +3380,33 @@ function PanelApp({
|
|
|
3380
3380
|
] }, capability.id)) })
|
|
3381
3381
|
] })
|
|
3382
3382
|
] }),
|
|
3383
|
+
view === "history" && showHistory && /* @__PURE__ */ jsxs17("section", { className: "pt-panel-page pt-panel-section", id: "revisions", children: [
|
|
3384
|
+
/* @__PURE__ */ jsxs17("div", { children: [
|
|
3385
|
+
/* @__PURE__ */ jsx19("p", { className: "pt-eyebrow", children: ui.historyEyebrow }),
|
|
3386
|
+
/* @__PURE__ */ jsx19("h2", { className: "pt-h2", children: ui.historyTitle }),
|
|
3387
|
+
/* @__PURE__ */ jsx19("p", { className: "pt-sub", children: ui.historyIntro })
|
|
3388
|
+
] }),
|
|
3389
|
+
state.runs.length === 0 ? /* @__PURE__ */ jsx19("p", { className: "pt-panel-text", children: ui.historyEmpty }) : /* @__PURE__ */ jsx19("ol", { className: "pt-runs", children: [...state.runs].reverse().map((run) => /* @__PURE__ */ jsxs17("li", { className: "pt-run pt-hud", children: [
|
|
3390
|
+
/* @__PURE__ */ jsxs17("p", { className: "pt-run__meta", children: [
|
|
3391
|
+
run.date,
|
|
3392
|
+
" \xB7 ",
|
|
3393
|
+
run.by === "claude" ? "Claude" : ui.ownerYou,
|
|
3394
|
+
run.fingerprint ? ` \xB7 ${run.fingerprint.replace("sha256:", "").slice(0, 12)}` : ""
|
|
3395
|
+
] }),
|
|
3396
|
+
/* @__PURE__ */ jsx19("p", { className: "pt-panel-text", children: t(run.summary) }),
|
|
3397
|
+
run.cards.length > 0 && /* @__PURE__ */ jsxs17("p", { className: "pt-run__cards", children: [
|
|
3398
|
+
/* @__PURE__ */ jsxs17("span", { children: [
|
|
3399
|
+
ui.historyTouched,
|
|
3400
|
+
":"
|
|
3401
|
+
] }),
|
|
3402
|
+
" ",
|
|
3403
|
+
run.cards.map((id) => {
|
|
3404
|
+
const card = state.cards.find((candidate) => candidate.id === id);
|
|
3405
|
+
return card ? t(card.title) || ui.untitled : id;
|
|
3406
|
+
}).join(" \xB7 ")
|
|
3407
|
+
] })
|
|
3408
|
+
] }, run.id)) })
|
|
3409
|
+
] }),
|
|
3383
3410
|
/* @__PURE__ */ jsx19(
|
|
3384
3411
|
CardSheet,
|
|
3385
3412
|
{
|
package/fingerprint.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paneltir",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"hash": "sha256:
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"hash": "sha256:406af8c690cfa9b2452360712afff3f3fe9605a7bb0aa834238e5187acfc37c0",
|
|
5
5
|
"fileCount": 151,
|
|
6
|
-
"generatedAt": "2026-09-
|
|
6
|
+
"generatedAt": "2026-09-07T12:33:30.026Z"
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paneltir",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A React UI kit for admin panels: page frame, a board with touch-ready drag and drop, detail sheets, and a whole panel in one component. Structure and behaviour are fixed; colour and brand belong to each project.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "DFKlabs (https://DFKlabs.com)",
|
|
@@ -24,7 +24,98 @@
|
|
|
24
24
|
* PANEL_FILE optional path to the board within it.
|
|
25
25
|
* PANEL_BRANCH optional, defaults to main.
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The session check, carried here rather than imported.
|
|
29
|
+
*
|
|
30
|
+
* This is the rule `middleware.ts` lives by, and it reached the functions the
|
|
31
|
+
* hard way: every function under `api/` that imported anything answered
|
|
32
|
+
* FUNCTION_INVOCATION_FAILED on every request, while the middleware, which
|
|
33
|
+
* imports nothing, never failed once. A wrapper added to catch and name the
|
|
34
|
+
* fault changed nothing, which is the answer — the module never runs, so there
|
|
35
|
+
* is no handler for a `try` to protect.
|
|
36
|
+
*
|
|
37
|
+
* It is a copy of `lib/session.ts`, and `npm run test:auth` holds every copy to
|
|
38
|
+
* the same answer over the same tokens. Edit this file freely; if you change
|
|
39
|
+
* the check, change it everywhere the test names.
|
|
40
|
+
*/
|
|
41
|
+
const SESSION_COOKIE = 'pt_session'
|
|
42
|
+
const VERSION = 'v1'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads one variable, surviving a runtime with no `process` at all.
|
|
46
|
+
*
|
|
47
|
+
* Written out as literal `process.env.X` accesses because that is the form
|
|
48
|
+
* build tools substitute at build time; a computed key would leave nothing to
|
|
49
|
+
* substitute. A bare access is a ReferenceError where `process` is undefined,
|
|
50
|
+
* which in middleware reads as the whole route crashing rather than as a
|
|
51
|
+
* missing variable — hence the catch.
|
|
52
|
+
*/
|
|
53
|
+
function env(name: 'ADMIN_PASSWORD' | 'SESSION_SECRET'): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
if (name === 'ADMIN_PASSWORD') return process.env.ADMIN_PASSWORD || undefined
|
|
56
|
+
return process.env.SESSION_SECRET || undefined
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Kept identical to lib/session.ts; test:auth asserts they agree. */
|
|
63
|
+
function signingSecret(): string | null {
|
|
64
|
+
const password = env('ADMIN_PASSWORD')
|
|
65
|
+
if (!password) return null
|
|
66
|
+
return env('SESSION_SECRET') || password
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeEqual(a: string, b: string): boolean {
|
|
70
|
+
const encoder = new TextEncoder()
|
|
71
|
+
const left = encoder.encode(a)
|
|
72
|
+
const right = encoder.encode(b)
|
|
73
|
+
let diff = left.length ^ right.length
|
|
74
|
+
const max = Math.max(left.length, right.length)
|
|
75
|
+
for (let i = 0; i < max; i += 1) diff |= (left[i] ?? 0) ^ (right[i] ?? 0)
|
|
76
|
+
return diff === 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function hmac(secret: string, message: string): Promise<string> {
|
|
80
|
+
const encoder = new TextEncoder()
|
|
81
|
+
const key = await crypto.subtle.importKey(
|
|
82
|
+
'raw',
|
|
83
|
+
encoder.encode(secret),
|
|
84
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
85
|
+
false,
|
|
86
|
+
['sign']
|
|
87
|
+
)
|
|
88
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message))
|
|
89
|
+
let binary = ''
|
|
90
|
+
for (const byte of new Uint8Array(signature)) binary += String.fromCharCode(byte)
|
|
91
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function verifyToken(token: string | undefined, now = Date.now()): Promise<boolean> {
|
|
95
|
+
const secret = signingSecret()
|
|
96
|
+
if (!secret || !token) return false
|
|
97
|
+
|
|
98
|
+
const parts = token.split('.')
|
|
99
|
+
if (parts.length !== 3) return false
|
|
100
|
+
const [version, expires, signature] = parts
|
|
101
|
+
if (version !== VERSION) return false
|
|
102
|
+
|
|
103
|
+
const expiry = Number(expires)
|
|
104
|
+
if (!Number.isSafeInteger(expiry) || expiry * 1000 <= now) return false
|
|
105
|
+
|
|
106
|
+
return safeEqual(signature, await hmac(secret, `${version}.${expires}`))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readCookie(header: string | null | undefined, name: string): string | undefined {
|
|
110
|
+
if (!header) return undefined
|
|
111
|
+
for (const pair of header.split(';')) {
|
|
112
|
+
const eq = pair.indexOf('=')
|
|
113
|
+
if (eq === -1) continue
|
|
114
|
+
if (pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim()
|
|
115
|
+
}
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
28
119
|
|
|
29
120
|
/**
|
|
30
121
|
* Where the board lives in this repository. Set PANEL_FILE to override; the
|
|
@@ -215,7 +306,31 @@ function configuration() {
|
|
|
215
306
|
}
|
|
216
307
|
}
|
|
217
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Nothing here may reach the platform as an uncaught throw.
|
|
311
|
+
*
|
|
312
|
+
* `authorized()` runs before the try that wraps the network calls, so anything
|
|
313
|
+
* it raised left the platform to answer with its own generic 500 and the
|
|
314
|
+
* SHOUTING_CODE FUNCTION_INVOCATION_FAILED — a code that names the shape of
|
|
315
|
+
* the fault and never the fault, which is how three separate fixes were aimed
|
|
316
|
+
* at a cause nobody had seen. It is the rule `middleware.ts` already lives by:
|
|
317
|
+
* it never throws, because a throw is answered by a page that names nothing.
|
|
318
|
+
*
|
|
319
|
+
* The detail is safe to return: this endpoint is behind the session, and the
|
|
320
|
+
* GitHub branch below already answers with `detail` for the same reason.
|
|
321
|
+
*/
|
|
218
322
|
export default async function handler(request: VercelRequest, response: VercelResponse) {
|
|
323
|
+
try {
|
|
324
|
+
return await handleRequest(request, response)
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return response.status(500).json({
|
|
327
|
+
error: 'The panel API failed before it could answer.',
|
|
328
|
+
detail: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleRequest(request: VercelRequest, response: VercelResponse) {
|
|
219
334
|
response.setHeader('Cache-Control', 'no-store')
|
|
220
335
|
|
|
221
336
|
if (request.method !== 'POST' && request.method !== 'GET') {
|
|
@@ -248,7 +363,8 @@ export default async function handler(request: VercelRequest, response: VercelRe
|
|
|
248
363
|
if (problem) return response.status(400).json({ error: problem })
|
|
249
364
|
|
|
250
365
|
const content = `${JSON.stringify(payload.state, null, 2)}\n`
|
|
251
|
-
|
|
366
|
+
const bytes = new TextEncoder().encode(content)
|
|
367
|
+
if (bytes.length > MAX_BODY_BYTES) {
|
|
252
368
|
return response.status(413).json({ error: 'The state is too large.' })
|
|
253
369
|
}
|
|
254
370
|
|
|
@@ -280,7 +396,7 @@ export default async function handler(request: VercelRequest, response: VercelRe
|
|
|
280
396
|
headers: { ...githubHeaders, 'content-type': 'application/json' },
|
|
281
397
|
body: JSON.stringify({
|
|
282
398
|
message,
|
|
283
|
-
content:
|
|
399
|
+
content: base64(bytes),
|
|
284
400
|
branch,
|
|
285
401
|
...(sha ? { sha } : {}),
|
|
286
402
|
}),
|
|
@@ -308,6 +424,26 @@ export default async function handler(request: VercelRequest, response: VercelRe
|
|
|
308
424
|
}
|
|
309
425
|
}
|
|
310
426
|
|
|
427
|
+
/**
|
|
428
|
+
* Base64 without `Buffer`.
|
|
429
|
+
*
|
|
430
|
+
* `Buffer` was the only runtime-specific global left on this path, and it sat
|
|
431
|
+
* on the POST side alone — so a function running anywhere but Node answered a
|
|
432
|
+
* save with a platform 500 while every GET on the same file kept working, and
|
|
433
|
+
* the panel could only report the number. `TextEncoder` and `btoa` exist in
|
|
434
|
+
* every runtime this can land in.
|
|
435
|
+
*
|
|
436
|
+
* Chunked because `String.fromCharCode(...bytes)` spreads one argument per
|
|
437
|
+
* byte: a board is far past the call-argument limit and it would throw.
|
|
438
|
+
*/
|
|
439
|
+
function base64(bytes: Uint8Array): string {
|
|
440
|
+
let binary = ''
|
|
441
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
442
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
|
|
443
|
+
}
|
|
444
|
+
return btoa(binary)
|
|
445
|
+
}
|
|
446
|
+
|
|
311
447
|
function safeParse(value: string): unknown {
|
|
312
448
|
try {
|
|
313
449
|
return JSON.parse(value)
|