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 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.6.0
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.6.0 # exits non-zero if that is not what is installed
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.6.0
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
- console.log(`installed from github:daifukus/paneltir#v${pkg.version}`)
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.6.0";
3
- var PANELTIR_FINGERPRINT = "sha256:cb1a1df3cac0d3cc775fbaa7b9f385d6d35e22cecbe86cc0a1da4338cf43de37";
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 data = await response.json().catch(() => ({}));
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(data.error || `HTTP ${response.status}`), { conflict });
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.6.0",
4
- "hash": "sha256:cb1a1df3cac0d3cc775fbaa7b9f385d6d35e22cecbe86cc0a1da4338cf43de37",
3
+ "version": "0.7.0",
4
+ "hash": "sha256:406af8c690cfa9b2452360712afff3f3fe9605a7bb0aa834238e5187acfc37c0",
5
5
  "fileCount": 151,
6
- "generatedAt": "2026-09-06T10:08:28.981Z"
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.6.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
- import { SESSION_COOKIE, readCookie, verifyToken } from '../lib/session'
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
- if (Buffer.byteLength(content, 'utf8') > MAX_BODY_BYTES) {
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: Buffer.from(content, 'utf8').toString('base64'),
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)