tina4-nodejs 3.13.134 → 3.13.136

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 CHANGED
@@ -13,13 +13,13 @@ Even if the skill text is not currently loaded, these are non-negotiable:
13
13
 
14
14
  The full discipline lives in `.claude/skills/tina4-maintainer/SKILL.md`; this block is the always-on floor.
15
15
 
16
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.134)
16
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.136)
17
17
 
18
18
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
19
19
 
20
20
  ## What This Project Is
21
21
 
22
- Tina4 for Node.js/TypeScript v3.13.134 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
22
+ Tina4 for Node.js/TypeScript v3.13.136 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
23
23
 
24
24
  The philosophy: zero ceremony, batteries included, file system as source of truth.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.134",
3
+ "version": "3.13.136",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - native TypeScript conventions and shared Tina4 contracts",
6
6
  "keywords": [
@@ -35062,6 +35062,13 @@ function toolbarJs() {
35062
35062
  el.className = 't4-ok';
35063
35063
  el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> &mdash; You are up to date!';
35064
35064
  }
35065
+ // A check that did not happen is not a clean bill of health. The server
35066
+ // sends latest: null when it could not reach the registry, and saying so is
35067
+ // the whole point -- "up to date" here would be a guess dressed as a fact.
35068
+ function couldNotCheck(el, why) {
35069
+ el.className = 't4-err';
35070
+ el.textContent = 'Could not check for updates' + (why ? ' (' + why + ')' : '');
35071
+ }
35065
35072
  function checkVersion() {
35066
35073
  if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
35067
35074
  modal.style.display = 'block';
@@ -35070,6 +35077,7 @@ function toolbarJs() {
35070
35077
  el.textContent = 'Checking for updates...';
35071
35078
  fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
35072
35079
  var latest = d.latest, current = d.current;
35080
+ if (!latest) { couldNotCheck(el, d.error); return; }
35073
35081
  if (latest === current) { upToDate(el, latest); return; }
35074
35082
  var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
35075
35083
  var isNewer = false, i, c, l;
@@ -36412,21 +36420,28 @@ var init_devAdmin = __esm({
36412
36420
  };
36413
36421
  handleVersionCheck = async (_req, res) => {
36414
36422
  const current = TINA4_VERSION;
36415
- let latest = current;
36423
+ const url = process.env.TINA4_VERSION_CHECK_URL || "https://registry.npmjs.org/tina4-nodejs/latest";
36424
+ const failed = (why) => res.json({ current, latest: null, error: why });
36425
+ let data;
36416
36426
  try {
36417
36427
  const controller = new AbortController();
36418
- const timeout = setTimeout(() => controller.abort(), 5e3);
36419
- const resp = await fetch("https://registry.npmjs.org/tina4-nodejs/latest", {
36420
- signal: controller.signal
36421
- });
36422
- clearTimeout(timeout);
36423
- if (resp.ok) {
36424
- const data = await resp.json();
36425
- if (typeof data.version === "string") latest = data.version;
36428
+ const timer = setTimeout(() => controller.abort(), 5e3);
36429
+ try {
36430
+ const resp = await fetch(url, {
36431
+ signal: controller.signal,
36432
+ headers: { "User-Agent": `tina4-nodejs/${current}` }
36433
+ });
36434
+ if (!resp.ok) return failed(`npm registry responded ${resp.status}`);
36435
+ data = await resp.json();
36436
+ } finally {
36437
+ clearTimeout(timer);
36426
36438
  }
36427
- } catch {
36439
+ } catch (exc) {
36440
+ return failed(exc instanceof Error && exc.message ? exc.message : String(exc));
36428
36441
  }
36429
- res.json({ current, latest });
36442
+ const latest = typeof data.version === "string" ? data.version : "";
36443
+ if (!latest) return failed("npm registry did not report a version");
36444
+ return res.json({ current, latest });
36430
36445
  };
36431
36446
  handleThoughts = (req2, res) => {
36432
36447
  const url = new URL(req2.url ?? "/", "http://localhost");
@@ -37940,13 +37955,37 @@ function swaggerEnabled() {
37940
37955
  return ["true", "1", "yes", "on"].includes(raw);
37941
37956
  }
37942
37957
  function createSwaggerRoutes(getSpec) {
37958
+ const serveUi = async (_req, res) => {
37959
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37960
+ };
37943
37961
  return [
37944
37962
  {
37945
37963
  method: "GET",
37946
37964
  pattern: "/swagger",
37947
- handler: async (_req, res) => {
37948
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37949
- }
37965
+ handler: serveUi
37966
+ },
37967
+ {
37968
+ // The trailing-slash form, registered rather than left to fall through.
37969
+ //
37970
+ // Matching "/foo/" against a "/foo" route is opt-in via
37971
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
37972
+ // this route and was answered by the framework-bundled
37973
+ // public/swagger/index.html instead. That mattered twice over. It used to
37974
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
37975
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
37976
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
37977
+ // cdnjs, while the page this handler renders loads from
37978
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
37979
+ // local mirror silently kept reaching cdnjs on this one path.
37980
+ //
37981
+ // Registering it keeps the fix inside swagger rather than changing how
37982
+ // every route treats trailing slashes, satisfies the shared contract that
37983
+ // already requires a 200 here, and matches python and ruby, which both
37984
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
37985
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
37986
+ method: "GET",
37987
+ pattern: "/swagger/",
37988
+ handler: serveUi
37950
37989
  },
37951
37990
  {
37952
37991
  method: "GET",
@@ -39650,7 +39689,7 @@ async function configureSwagger(router, ormDir, modelsDir) {
39650
39689
  try {
39651
39690
  const swagger = await Promise.resolve().then(() => (init_src2(), src_exports2));
39652
39691
  enabled = swagger.swaggerEnabled();
39653
- if (!swaggerAssetsEnabled) {
39692
+ if (!enabled) {
39654
39693
  throw new Error("__swagger_disabled__");
39655
39694
  }
39656
39695
  let modelDefs = [];
@@ -44258,6 +44297,9 @@ function decodeBase64Url(value, name) {
44258
44297
  }
44259
44298
  return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
44260
44299
  }
44300
+ function pad32(value) {
44301
+ return value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]);
44302
+ }
44261
44303
  function vapidPrivateKey(rawPrivate, rawPublic) {
44262
44304
  if (rawPublic.length !== 65 || rawPublic[0] !== 4) throw new PushError("P-256 public keys must be 65-byte uncompressed points");
44263
44305
  const x = encodeBase64Url(rawPublic.subarray(1, 33));
@@ -44266,7 +44308,7 @@ function vapidPrivateKey(rawPrivate, rawPublic) {
44266
44308
  key: {
44267
44309
  kty: "EC",
44268
44310
  crv: "P-256",
44269
- d: encodeBase64Url(rawPrivate),
44311
+ d: encodeBase64Url(pad32(rawPrivate)),
44270
44312
  x,
44271
44313
  y,
44272
44314
  ext: true
@@ -44360,8 +44402,10 @@ function generateVapidKeys() {
44360
44402
  const ecdh = createECDH(CURVE);
44361
44403
  ecdh.generateKeys();
44362
44404
  return {
44405
+ // getPublicKey keeps its 65-byte width; getPrivateKey drops a leading zero
44406
+ // byte, so the scalar must be padded to the fixed field width.
44363
44407
  publicKey: encodeBase64Url(ecdh.getPublicKey(void 0, "uncompressed")),
44364
- privateKey: encodeBase64Url(ecdh.getPrivateKey())
44408
+ privateKey: encodeBase64Url(pad32(ecdh.getPrivateKey()))
44365
44409
  };
44366
44410
  }
44367
44411
  var CURVE, RECORD_SIZE, MAX_PAYLOAD, PushError, Push;
@@ -44453,10 +44497,11 @@ var init_push = __esm({
44453
44497
  TTL: String(this.options.ttl ?? 60),
44454
44498
  ...this.options.urgency ? { Urgency: this.options.urgency } : {}
44455
44499
  },
44456
- // Node's fetch accepts Buffer at runtime, while the DOM declaration
44457
- // used by the published type build narrows BodyInit to ArrayBuffer
44458
- // backed views. Keep the binary payload intact and make that boundary
44459
- // explicit rather than converting the encrypted bytes to text.
44500
+ // Node's fetch accepts a Buffer at runtime. The typecheck build
44501
+ // (lib ES2022, no DOM) does not know the global `BodyInit` name, and
44502
+ // the DOM declaration narrows it to ArrayBuffer-backed views a
44503
+ // `Uint8Array` (which a Buffer is) satisfies both, so cast to that and
44504
+ // keep the encrypted bytes intact rather than converting them to text.
44460
44505
  body
44461
44506
  });
44462
44507
  } catch (error2) {
@@ -35041,6 +35041,13 @@ function toolbarJs() {
35041
35041
  el.className = 't4-ok';
35042
35042
  el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> &mdash; You are up to date!';
35043
35043
  }
35044
+ // A check that did not happen is not a clean bill of health. The server
35045
+ // sends latest: null when it could not reach the registry, and saying so is
35046
+ // the whole point -- "up to date" here would be a guess dressed as a fact.
35047
+ function couldNotCheck(el, why) {
35048
+ el.className = 't4-err';
35049
+ el.textContent = 'Could not check for updates' + (why ? ' (' + why + ')' : '');
35050
+ }
35044
35051
  function checkVersion() {
35045
35052
  if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
35046
35053
  modal.style.display = 'block';
@@ -35049,6 +35056,7 @@ function toolbarJs() {
35049
35056
  el.textContent = 'Checking for updates...';
35050
35057
  fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
35051
35058
  var latest = d.latest, current = d.current;
35059
+ if (!latest) { couldNotCheck(el, d.error); return; }
35052
35060
  if (latest === current) { upToDate(el, latest); return; }
35053
35061
  var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
35054
35062
  var isNewer = false, i, c, l;
@@ -36391,21 +36399,28 @@ var init_devAdmin = __esm({
36391
36399
  };
36392
36400
  handleVersionCheck = async (_req, res) => {
36393
36401
  const current = TINA4_VERSION;
36394
- let latest = current;
36402
+ const url = process.env.TINA4_VERSION_CHECK_URL || "https://registry.npmjs.org/tina4-nodejs/latest";
36403
+ const failed = (why) => res.json({ current, latest: null, error: why });
36404
+ let data;
36395
36405
  try {
36396
36406
  const controller = new AbortController();
36397
- const timeout = setTimeout(() => controller.abort(), 5e3);
36398
- const resp = await fetch("https://registry.npmjs.org/tina4-nodejs/latest", {
36399
- signal: controller.signal
36400
- });
36401
- clearTimeout(timeout);
36402
- if (resp.ok) {
36403
- const data = await resp.json();
36404
- if (typeof data.version === "string") latest = data.version;
36407
+ const timer = setTimeout(() => controller.abort(), 5e3);
36408
+ try {
36409
+ const resp = await fetch(url, {
36410
+ signal: controller.signal,
36411
+ headers: { "User-Agent": `tina4-nodejs/${current}` }
36412
+ });
36413
+ if (!resp.ok) return failed(`npm registry responded ${resp.status}`);
36414
+ data = await resp.json();
36415
+ } finally {
36416
+ clearTimeout(timer);
36405
36417
  }
36406
- } catch {
36418
+ } catch (exc) {
36419
+ return failed(exc instanceof Error && exc.message ? exc.message : String(exc));
36407
36420
  }
36408
- res.json({ current, latest });
36421
+ const latest = typeof data.version === "string" ? data.version : "";
36422
+ if (!latest) return failed("npm registry did not report a version");
36423
+ return res.json({ current, latest });
36409
36424
  };
36410
36425
  handleThoughts = (req2, res) => {
36411
36426
  const url = new URL(req2.url ?? "/", "http://localhost");
@@ -37919,13 +37934,37 @@ function swaggerEnabled() {
37919
37934
  return ["true", "1", "yes", "on"].includes(raw);
37920
37935
  }
37921
37936
  function createSwaggerRoutes(getSpec) {
37937
+ const serveUi = async (_req, res) => {
37938
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37939
+ };
37922
37940
  return [
37923
37941
  {
37924
37942
  method: "GET",
37925
37943
  pattern: "/swagger",
37926
- handler: async (_req, res) => {
37927
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
37928
- }
37944
+ handler: serveUi
37945
+ },
37946
+ {
37947
+ // The trailing-slash form, registered rather than left to fall through.
37948
+ //
37949
+ // Matching "/foo/" against a "/foo" route is opt-in via
37950
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
37951
+ // this route and was answered by the framework-bundled
37952
+ // public/swagger/index.html instead. That mattered twice over. It used to
37953
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
37954
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
37955
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
37956
+ // cdnjs, while the page this handler renders loads from
37957
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
37958
+ // local mirror silently kept reaching cdnjs on this one path.
37959
+ //
37960
+ // Registering it keeps the fix inside swagger rather than changing how
37961
+ // every route treats trailing slashes, satisfies the shared contract that
37962
+ // already requires a 200 here, and matches python and ruby, which both
37963
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
37964
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
37965
+ method: "GET",
37966
+ pattern: "/swagger/",
37967
+ handler: serveUi
37929
37968
  },
37930
37969
  {
37931
37970
  method: "GET",
@@ -39629,7 +39668,7 @@ async function configureSwagger(router, ormDir, modelsDir) {
39629
39668
  try {
39630
39669
  const swagger = await Promise.resolve().then(() => (init_src2(), src_exports2));
39631
39670
  enabled = swagger.swaggerEnabled();
39632
- if (!swaggerAssetsEnabled) {
39671
+ if (!enabled) {
39633
39672
  throw new Error("__swagger_disabled__");
39634
39673
  }
39635
39674
  let modelDefs = [];
@@ -44219,6 +44258,9 @@ function decodeBase64Url(value, name) {
44219
44258
  }
44220
44259
  return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
44221
44260
  }
44261
+ function pad32(value) {
44262
+ return value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]);
44263
+ }
44222
44264
  function vapidPrivateKey(rawPrivate, rawPublic) {
44223
44265
  if (rawPublic.length !== 65 || rawPublic[0] !== 4) throw new PushError("P-256 public keys must be 65-byte uncompressed points");
44224
44266
  const x = encodeBase64Url(rawPublic.subarray(1, 33));
@@ -44227,7 +44269,7 @@ function vapidPrivateKey(rawPrivate, rawPublic) {
44227
44269
  key: {
44228
44270
  kty: "EC",
44229
44271
  crv: "P-256",
44230
- d: encodeBase64Url(rawPrivate),
44272
+ d: encodeBase64Url(pad32(rawPrivate)),
44231
44273
  x,
44232
44274
  y,
44233
44275
  ext: true
@@ -44321,8 +44363,10 @@ function generateVapidKeys() {
44321
44363
  const ecdh = createECDH(CURVE);
44322
44364
  ecdh.generateKeys();
44323
44365
  return {
44366
+ // getPublicKey keeps its 65-byte width; getPrivateKey drops a leading zero
44367
+ // byte, so the scalar must be padded to the fixed field width.
44324
44368
  publicKey: encodeBase64Url(ecdh.getPublicKey(void 0, "uncompressed")),
44325
- privateKey: encodeBase64Url(ecdh.getPrivateKey())
44369
+ privateKey: encodeBase64Url(pad32(ecdh.getPrivateKey()))
44326
44370
  };
44327
44371
  }
44328
44372
  var CURVE, RECORD_SIZE, MAX_PAYLOAD, PushError, Push;
@@ -44414,10 +44458,11 @@ var init_push = __esm({
44414
44458
  TTL: String(this.options.ttl ?? 60),
44415
44459
  ...this.options.urgency ? { Urgency: this.options.urgency } : {}
44416
44460
  },
44417
- // Node's fetch accepts Buffer at runtime, while the DOM declaration
44418
- // used by the published type build narrows BodyInit to ArrayBuffer
44419
- // backed views. Keep the binary payload intact and make that boundary
44420
- // explicit rather than converting the encrypted bytes to text.
44461
+ // Node's fetch accepts a Buffer at runtime. The typecheck build
44462
+ // (lib ES2022, no DOM) does not know the global `BodyInit` name, and
44463
+ // the DOM declaration narrows it to ArrayBuffer-backed views a
44464
+ // `Uint8Array` (which a Buffer is) satisfies both, so cast to that and
44465
+ // keep the encrypted bytes intact rather than converting them to text.
44421
44466
  body
44422
44467
  });
44423
44468
  } catch (error2) {
@@ -71,7 +71,7 @@
71
71
 
72
72
  // Build a system
73
73
  const ui = SwaggerUIBundle({
74
- url: "{SWAGGER_ROUTE}/swagger.json",
74
+ url: "/swagger/openapi.json",
75
75
  dom_id: '#swagger-ui',
76
76
  deepLinking: true,
77
77
  presets: [
@@ -2069,24 +2069,48 @@ function handleGalleryDeploy(router: Router): RouteHandler {
2069
2069
  // Version check — proxy to npm registry to avoid browser CORS errors
2070
2070
  // ---------------------------------------------------------------------------
2071
2071
 
2072
- const handleVersionCheck: RouteHandler = async (_req, res) => {
2072
+ /**
2073
+ * Version check — a check that did not happen says so.
2074
+ *
2075
+ * This used to fall back to `latest = current` on any failure, and the toolbar
2076
+ * renders that as a green "You are up to date!" — so a developer several
2077
+ * releases behind, on a machine with no route out, was told the opposite of the
2078
+ * truth, and the toolbar's own "Could not check for updates" branch could never
2079
+ * fire because the failure arrived as a success. `latest` is `null` when the
2080
+ * check could not be made, and `error` says why. The registry URL is
2081
+ * `TINA4_VERSION_CHECK_URL` when set (a mirror, or a test's own server), else
2082
+ * npm. Mirrors Python `tina4_python.dev_admin._api_version_check`.
2083
+ */
2084
+ export const handleVersionCheck: RouteHandler = async (_req, res) => {
2073
2085
  const current = TINA4_VERSION;
2074
- let latest = current;
2086
+ const url =
2087
+ process.env.TINA4_VERSION_CHECK_URL ||
2088
+ "https://registry.npmjs.org/tina4-nodejs/latest";
2089
+ const failed = (why: string) => res.json({ current, latest: null, error: why });
2090
+
2091
+ let data: Record<string, unknown>;
2075
2092
  try {
2076
2093
  const controller = new AbortController();
2077
- const timeout = setTimeout(() => controller.abort(), 5000);
2078
- const resp = await fetch("https://registry.npmjs.org/tina4-nodejs/latest", {
2079
- signal: controller.signal,
2080
- });
2081
- clearTimeout(timeout);
2082
- if (resp.ok) {
2083
- const data = (await resp.json()) as Record<string, unknown>;
2084
- if (typeof data.version === "string") latest = data.version;
2094
+ const timer = setTimeout(() => controller.abort(), 5000);
2095
+ try {
2096
+ const resp = await fetch(url, {
2097
+ signal: controller.signal,
2098
+ headers: { "User-Agent": `tina4-nodejs/${current}` },
2099
+ });
2100
+ // Reaching the registry is not the same as a 200 with a body.
2101
+ if (!resp.ok) return failed(`npm registry responded ${resp.status}`);
2102
+ data = (await resp.json()) as Record<string, unknown>;
2103
+ } finally {
2104
+ clearTimeout(timer);
2085
2105
  }
2086
- } catch {
2087
- // Offline or timeout return current as latest
2106
+ } catch (exc) {
2107
+ // offline, timeout, DNS, unreadable body
2108
+ return failed(exc instanceof Error && exc.message ? exc.message : String(exc));
2088
2109
  }
2089
- res.json({ current, latest });
2110
+ // An answer we cannot read a version out of is the same lie by another route.
2111
+ const latest = typeof data.version === "string" ? data.version : "";
2112
+ if (!latest) return failed("npm registry did not report a version");
2113
+ return res.json({ current, latest });
2090
2114
  };
2091
2115
 
2092
2116
  // ---------------------------------------------------------------------------
@@ -3019,7 +3043,7 @@ function toolbarCss(): string {
3019
3043
  * starts when the toolbar's `data-reload` is "1" (reload not suppressed for this
3020
3044
  * request/port). Mirrors PHP DevAdmin::toolbarJs().
3021
3045
  */
3022
- function toolbarJs(): string {
3046
+ export function toolbarJs(): string {
3023
3047
  return `(function () {
3024
3048
  var bar = document.getElementById('tina4-dev-toolbar');
3025
3049
  if (!bar) { return; }
@@ -3029,6 +3053,13 @@ function toolbarJs(): string {
3029
3053
  el.className = 't4-ok';
3030
3054
  el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> &mdash; You are up to date!';
3031
3055
  }
3056
+ // A check that did not happen is not a clean bill of health. The server
3057
+ // sends latest: null when it could not reach the registry, and saying so is
3058
+ // the whole point -- "up to date" here would be a guess dressed as a fact.
3059
+ function couldNotCheck(el, why) {
3060
+ el.className = 't4-err';
3061
+ el.textContent = 'Could not check for updates' + (why ? ' (' + why + ')' : '');
3062
+ }
3032
3063
  function checkVersion() {
3033
3064
  if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
3034
3065
  modal.style.display = 'block';
@@ -3037,6 +3068,7 @@ function toolbarJs(): string {
3037
3068
  el.textContent = 'Checking for updates...';
3038
3069
  fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
3039
3070
  var latest = d.latest, current = d.current;
3071
+ if (!latest) { couldNotCheck(el, d.error); return; }
3040
3072
  if (latest === current) { upToDate(el, latest); return; }
3041
3073
  var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
3042
3074
  var isNewer = false, i, c, l;
@@ -69,6 +69,15 @@ function decodeBase64Url(value: string, name: string): Buffer {
69
69
  return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
70
70
  }
71
71
 
72
+ // Left-pad big-endian EC material to the fixed 32-byte P-256 field width.
73
+ // createECDH().getPrivateKey() and other raw EC outputs drop a leading zero
74
+ // byte, so a scalar whose top byte is zero comes back short (~0.5% of keys);
75
+ // the JWK `d` and the stored VAPID key are fixed-width, so a short value is a
76
+ // malformed key.
77
+ function pad32(value: Buffer): Buffer {
78
+ return value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]);
79
+ }
80
+
72
81
  function vapidPrivateKey(rawPrivate: Buffer, rawPublic: Buffer) {
73
82
  if (rawPublic.length !== 65 || rawPublic[0] !== 0x04) throw new PushError("P-256 public keys must be 65-byte uncompressed points");
74
83
  const x = encodeBase64Url(rawPublic.subarray(1, 33));
@@ -77,7 +86,7 @@ function vapidPrivateKey(rawPrivate: Buffer, rawPublic: Buffer) {
77
86
  key: {
78
87
  kty: "EC",
79
88
  crv: "P-256",
80
- d: encodeBase64Url(rawPrivate),
89
+ d: encodeBase64Url(pad32(rawPrivate)),
81
90
  x,
82
91
  y,
83
92
  ext: true,
@@ -180,8 +189,10 @@ export function generateVapidKeys(): { publicKey: string; privateKey: string } {
180
189
  const ecdh = createECDH(CURVE);
181
190
  ecdh.generateKeys();
182
191
  return {
192
+ // getPublicKey keeps its 65-byte width; getPrivateKey drops a leading zero
193
+ // byte, so the scalar must be padded to the fixed field width.
183
194
  publicKey: encodeBase64Url(ecdh.getPublicKey(undefined, "uncompressed")),
184
- privateKey: encodeBase64Url(ecdh.getPrivateKey()),
195
+ privateKey: encodeBase64Url(pad32(ecdh.getPrivateKey())),
185
196
  };
186
197
  }
187
198
 
@@ -259,11 +270,12 @@ export class Push {
259
270
  TTL: String(this.options.ttl ?? 60),
260
271
  ...(this.options.urgency ? { Urgency: this.options.urgency } : {}),
261
272
  },
262
- // Node's fetch accepts Buffer at runtime, while the DOM declaration
263
- // used by the published type build narrows BodyInit to ArrayBuffer
264
- // backed views. Keep the binary payload intact and make that boundary
265
- // explicit rather than converting the encrypted bytes to text.
266
- body: body as unknown as BodyInit,
273
+ // Node's fetch accepts a Buffer at runtime. The typecheck build
274
+ // (lib ES2022, no DOM) does not know the global `BodyInit` name, and
275
+ // the DOM declaration narrows it to ArrayBuffer-backed views a
276
+ // `Uint8Array` (which a Buffer is) satisfies both, so cast to that and
277
+ // keep the encrypted bytes intact rather than converting them to text.
278
+ body: body as unknown as Uint8Array,
267
279
  });
268
280
  } catch (error) {
269
281
  throw new PushError(`Web Push request failed: ${String(error)}`);
@@ -2598,8 +2598,13 @@ async function configureSwagger(router: Router, ormDir: string, modelsDir: strin
2598
2598
  // Single source of truth for BOTH the gated routes and the bundled
2599
2599
  // public/swagger assets (which static serving would otherwise expose).
2600
2600
  enabled = swagger.swaggerEnabled();
2601
- if (!swaggerAssetsEnabled) {
2602
- // Skip the rest of the swagger block when disabled.
2601
+ if (!enabled) {
2602
+ // Skip the rest of the swagger block when disabled. Gate on the LOCAL
2603
+ // `enabled` just read from swaggerEnabled(): the module-level
2604
+ // `swaggerAssetsEnabled` is only assigned from this function's RETURN
2605
+ // (see the call site), so inside here it is still its boot-time false --
2606
+ // reading it skipped the block even when swagger was enabled, so
2607
+ // /swagger/openapi.json 404'd on an enabled server.
2603
2608
  throw new Error("__swagger_disabled__");
2604
2609
  }
2605
2610
 
@@ -23330,6 +23330,13 @@ function toolbarJs() {
23330
23330
  el.className = 't4-ok';
23331
23331
  el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> &mdash; You are up to date!';
23332
23332
  }
23333
+ // A check that did not happen is not a clean bill of health. The server
23334
+ // sends latest: null when it could not reach the registry, and saying so is
23335
+ // the whole point -- "up to date" here would be a guess dressed as a fact.
23336
+ function couldNotCheck(el, why) {
23337
+ el.className = 't4-err';
23338
+ el.textContent = 'Could not check for updates' + (why ? ' (' + why + ')' : '');
23339
+ }
23333
23340
  function checkVersion() {
23334
23341
  if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
23335
23342
  modal.style.display = 'block';
@@ -23338,6 +23345,7 @@ function toolbarJs() {
23338
23345
  el.textContent = 'Checking for updates...';
23339
23346
  fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
23340
23347
  var latest = d.latest, current = d.current;
23348
+ if (!latest) { couldNotCheck(el, d.error); return; }
23341
23349
  if (latest === current) { upToDate(el, latest); return; }
23342
23350
  var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
23343
23351
  var isNewer = false, i, c, l;
@@ -24680,21 +24688,28 @@ var init_devAdmin = __esm({
24680
24688
  };
24681
24689
  handleVersionCheck = async (_req, res) => {
24682
24690
  const current = TINA4_VERSION;
24683
- let latest = current;
24691
+ const url = process.env.TINA4_VERSION_CHECK_URL || "https://registry.npmjs.org/tina4-nodejs/latest";
24692
+ const failed = (why) => res.json({ current, latest: null, error: why });
24693
+ let data;
24684
24694
  try {
24685
24695
  const controller = new AbortController();
24686
- const timeout = setTimeout(() => controller.abort(), 5e3);
24687
- const resp = await fetch("https://registry.npmjs.org/tina4-nodejs/latest", {
24688
- signal: controller.signal
24689
- });
24690
- clearTimeout(timeout);
24691
- if (resp.ok) {
24692
- const data = await resp.json();
24693
- if (typeof data.version === "string") latest = data.version;
24696
+ const timer = setTimeout(() => controller.abort(), 5e3);
24697
+ try {
24698
+ const resp = await fetch(url, {
24699
+ signal: controller.signal,
24700
+ headers: { "User-Agent": `tina4-nodejs/${current}` }
24701
+ });
24702
+ if (!resp.ok) return failed(`npm registry responded ${resp.status}`);
24703
+ data = await resp.json();
24704
+ } finally {
24705
+ clearTimeout(timer);
24694
24706
  }
24695
- } catch {
24707
+ } catch (exc) {
24708
+ return failed(exc instanceof Error && exc.message ? exc.message : String(exc));
24696
24709
  }
24697
- res.json({ current, latest });
24710
+ const latest = typeof data.version === "string" ? data.version : "";
24711
+ if (!latest) return failed("npm registry did not report a version");
24712
+ return res.json({ current, latest });
24698
24713
  };
24699
24714
  handleThoughts = (req2, res) => {
24700
24715
  const url = new URL(req2.url ?? "/", "http://localhost");
@@ -26208,13 +26223,37 @@ function swaggerEnabled() {
26208
26223
  return ["true", "1", "yes", "on"].includes(raw);
26209
26224
  }
26210
26225
  function createSwaggerRoutes(getSpec) {
26226
+ const serveUi = async (_req, res) => {
26227
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
26228
+ };
26211
26229
  return [
26212
26230
  {
26213
26231
  method: "GET",
26214
26232
  pattern: "/swagger",
26215
- handler: async (_req, res) => {
26216
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
26217
- }
26233
+ handler: serveUi
26234
+ },
26235
+ {
26236
+ // The trailing-slash form, registered rather than left to fall through.
26237
+ //
26238
+ // Matching "/foo/" against a "/foo" route is opt-in via
26239
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
26240
+ // this route and was answered by the framework-bundled
26241
+ // public/swagger/index.html instead. That mattered twice over. It used to
26242
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
26243
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
26244
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
26245
+ // cdnjs, while the page this handler renders loads from
26246
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
26247
+ // local mirror silently kept reaching cdnjs on this one path.
26248
+ //
26249
+ // Registering it keeps the fix inside swagger rather than changing how
26250
+ // every route treats trailing slashes, satisfies the shared contract that
26251
+ // already requires a 200 here, and matches python and ruby, which both
26252
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
26253
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
26254
+ method: "GET",
26255
+ pattern: "/swagger/",
26256
+ handler: serveUi
26218
26257
  },
26219
26258
  {
26220
26259
  method: "GET",
@@ -27918,7 +27957,7 @@ async function configureSwagger(router, ormDir, modelsDir) {
27918
27957
  try {
27919
27958
  const swagger = await Promise.resolve().then(() => (init_src(), src_exports));
27920
27959
  enabled = swagger.swaggerEnabled();
27921
- if (!swaggerAssetsEnabled) {
27960
+ if (!enabled) {
27922
27961
  throw new Error("__swagger_disabled__");
27923
27962
  }
27924
27963
  let modelDefs = [];
@@ -33039,6 +33078,9 @@ function decodeBase64Url(value, name) {
33039
33078
  }
33040
33079
  return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
33041
33080
  }
33081
+ function pad32(value) {
33082
+ return value.length >= 32 ? value : Buffer.concat([Buffer.alloc(32 - value.length), value]);
33083
+ }
33042
33084
  function vapidPrivateKey(rawPrivate, rawPublic) {
33043
33085
  if (rawPublic.length !== 65 || rawPublic[0] !== 4) throw new PushError("P-256 public keys must be 65-byte uncompressed points");
33044
33086
  const x = encodeBase64Url(rawPublic.subarray(1, 33));
@@ -33047,7 +33089,7 @@ function vapidPrivateKey(rawPrivate, rawPublic) {
33047
33089
  key: {
33048
33090
  kty: "EC",
33049
33091
  crv: "P-256",
33050
- d: encodeBase64Url(rawPrivate),
33092
+ d: encodeBase64Url(pad32(rawPrivate)),
33051
33093
  x,
33052
33094
  y,
33053
33095
  ext: true
@@ -33141,8 +33183,10 @@ function generateVapidKeys() {
33141
33183
  const ecdh = createECDH(CURVE);
33142
33184
  ecdh.generateKeys();
33143
33185
  return {
33186
+ // getPublicKey keeps its 65-byte width; getPrivateKey drops a leading zero
33187
+ // byte, so the scalar must be padded to the fixed field width.
33144
33188
  publicKey: encodeBase64Url(ecdh.getPublicKey(void 0, "uncompressed")),
33145
- privateKey: encodeBase64Url(ecdh.getPrivateKey())
33189
+ privateKey: encodeBase64Url(pad32(ecdh.getPrivateKey()))
33146
33190
  };
33147
33191
  }
33148
33192
  var CURVE, RECORD_SIZE, MAX_PAYLOAD, PushError, Push;
@@ -33234,10 +33278,11 @@ var init_push = __esm({
33234
33278
  TTL: String(this.options.ttl ?? 60),
33235
33279
  ...this.options.urgency ? { Urgency: this.options.urgency } : {}
33236
33280
  },
33237
- // Node's fetch accepts Buffer at runtime, while the DOM declaration
33238
- // used by the published type build narrows BodyInit to ArrayBuffer
33239
- // backed views. Keep the binary payload intact and make that boundary
33240
- // explicit rather than converting the encrypted bytes to text.
33281
+ // Node's fetch accepts a Buffer at runtime. The typecheck build
33282
+ // (lib ES2022, no DOM) does not know the global `BodyInit` name, and
33283
+ // the DOM declaration narrows it to ArrayBuffer-backed views a
33284
+ // `Uint8Array` (which a Buffer is) satisfies both, so cast to that and
33285
+ // keep the encrypted bytes intact rather than converting them to text.
33241
33286
  body
33242
33287
  });
33243
33288
  } catch (error2) {
@@ -521,13 +521,37 @@ function swaggerEnabled() {
521
521
  return ["true", "1", "yes", "on"].includes(raw);
522
522
  }
523
523
  function createSwaggerRoutes(getSpec) {
524
+ const serveUi = async (_req, res) => {
525
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
526
+ };
524
527
  return [
525
528
  {
526
529
  method: "GET",
527
530
  pattern: "/swagger",
528
- handler: async (_req, res) => {
529
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
530
- }
531
+ handler: serveUi
532
+ },
533
+ {
534
+ // The trailing-slash form, registered rather than left to fall through.
535
+ //
536
+ // Matching "/foo/" against a "/foo" route is opt-in via
537
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
538
+ // this route and was answered by the framework-bundled
539
+ // public/swagger/index.html instead. That mattered twice over. It used to
540
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
541
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
542
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
543
+ // cdnjs, while the page this handler renders loads from
544
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
545
+ // local mirror silently kept reaching cdnjs on this one path.
546
+ //
547
+ // Registering it keeps the fix inside swagger rather than changing how
548
+ // every route treats trailing slashes, satisfies the shared contract that
549
+ // already requires a 200 here, and matches python and ruby, which both
550
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
551
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
552
+ method: "GET",
553
+ pattern: "/swagger/",
554
+ handler: serveUi
531
555
  },
532
556
  {
533
557
  method: "GET",
@@ -56,13 +56,38 @@ export function swaggerEnabled(): boolean {
56
56
  export function createSwaggerRoutes(
57
57
  getSpec: () => unknown
58
58
  ): RouteDefinition[] {
59
+ const serveUi = async (_req: Tina4Request, res: Tina4Response): Promise<void> => {
60
+ res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
61
+ };
62
+
59
63
  return [
60
64
  {
61
65
  method: "GET",
62
66
  pattern: "/swagger",
63
- handler: async (_req: Tina4Request, res: Tina4Response) => {
64
- res.html(SWAGGER_UI_HTML("/swagger/openapi.json"));
65
- },
67
+ handler: serveUi,
68
+ },
69
+ {
70
+ // The trailing-slash form, registered rather than left to fall through.
71
+ //
72
+ // Matching "/foo/" against a "/foo" route is opt-in via
73
+ // TINA4_TRAILING_SLASH_REDIRECT and OFF by default, so /swagger/ missed
74
+ // this route and was answered by the framework-bundled
75
+ // public/swagger/index.html instead. That mattered twice over. It used to
76
+ // be a 200 carrying a permanently empty UI, because the bundled file asked
77
+ // for an unsubstituted {SWAGGER_ROUTE}/swagger.json -- fixed in that file.
78
+ // And it is a SECOND Swagger UI implementation: the bundled one hardcodes
79
+ // cdnjs, while the page this handler renders loads from
80
+ // TINA4_SWAGGER_UI_CDN, so an air-gapped deployment pointing that at a
81
+ // local mirror silently kept reaching cdnjs on this one path.
82
+ //
83
+ // Registering it keeps the fix inside swagger rather than changing how
84
+ // every route treats trailing slashes, satisfies the shared contract that
85
+ // already requires a 200 here, and matches python and ruby, which both
86
+ // serve /swagger and /swagger/ with no env var set. Excluded from the
87
+ // generated document by INTERNAL_PREFIXES like /swagger itself.
88
+ method: "GET",
89
+ pattern: "/swagger/",
90
+ handler: serveUi,
66
91
  },
67
92
  {
68
93
  method: "GET",
@@ -9,7 +9,7 @@
9
9
  * - System info (Node.js version, V8, memory, uptime, platform)
10
10
  */
11
11
  import type { Router } from "./router.js";
12
- import type { Tina4Request } from "./types.js";
12
+ import type { RouteHandler, Tina4Request } from "./types.js";
13
13
  /** Safe HTTP methods that never carry a state change — they skip the write gate. */
14
14
  export declare const DEV_SAFE_METHODS: Set<string>;
15
15
  /**
@@ -203,6 +203,19 @@ export declare class DevAdmin {
203
203
  * 4. Fallback `http://127.0.0.1:9145` — matches standalone `tina4 agent`.
204
204
  */
205
205
  export declare function supervisorBaseUrl(): string;
206
+ /**
207
+ * Version check — a check that did not happen says so.
208
+ *
209
+ * This used to fall back to `latest = current` on any failure, and the toolbar
210
+ * renders that as a green "You are up to date!" — so a developer several
211
+ * releases behind, on a machine with no route out, was told the opposite of the
212
+ * truth, and the toolbar's own "Could not check for updates" branch could never
213
+ * fire because the failure arrived as a success. `latest` is `null` when the
214
+ * check could not be made, and `error` says why. The registry URL is
215
+ * `TINA4_VERSION_CHECK_URL` when set (a mirror, or a test's own server), else
216
+ * npm. Mirrors Python `tina4_python.dev_admin._api_version_check`.
217
+ */
218
+ export declare const handleVersionCheck: RouteHandler;
206
219
  /**
207
220
  * Resolve a CodeMirror-friendly language id from a file path's basename.
208
221
  *
@@ -212,4 +225,13 @@ export declare function supervisorBaseUrl(): string;
212
225
  * - anything unknown → "text"
213
226
  */
214
227
  export declare function devAdminLanguage(rel: string): string;
228
+ /**
229
+ * JS for the injected dev toolbar — the version-check modal, the dashboard
230
+ * overlay, and the WebSocket-primary live reloader. Served as an external
231
+ * script so the toolbar carries no inline handlers or `<script>` and stays
232
+ * CSP-clean. Every interaction is wired via addEventListener. The reloader only
233
+ * starts when the toolbar's `data-reload` is "1" (reload not suppressed for this
234
+ * request/port). Mirrors PHP DevAdmin::toolbarJs().
235
+ */
236
+ export declare function toolbarJs(): string;
215
237
  export {};