tina4-nodejs 3.13.137 → 3.13.139

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.
Files changed (255) hide show
  1. package/CLAUDE.md +33 -3
  2. package/COMMERCIAL-LICENSE.md +8 -0
  3. package/LICENSE +373 -0
  4. package/NOTICE +11 -0
  5. package/README.md +24 -2
  6. package/package.json +44 -6
  7. package/packages/cli/dist/bin.js +19979 -18102
  8. package/packages/cli/src/bin.ts +8 -0
  9. package/packages/cli/src/commands/build.ts +8 -0
  10. package/packages/cli/src/commands/generate.ts +8 -0
  11. package/packages/cli/src/commands/init.ts +8 -0
  12. package/packages/cli/src/commands/lint.ts +8 -0
  13. package/packages/cli/src/commands/migrate.ts +8 -0
  14. package/packages/cli/src/commands/migrateCreate.ts +8 -0
  15. package/packages/cli/src/commands/migrateRollback.ts +8 -0
  16. package/packages/cli/src/commands/migrateStatus.ts +8 -0
  17. package/packages/cli/src/commands/queue.ts +8 -0
  18. package/packages/cli/src/commands/routes.ts +8 -0
  19. package/packages/cli/src/commands/seed.ts +8 -0
  20. package/packages/cli/src/commands/serve.ts +11 -0
  21. package/packages/cli/src/commands/test.ts +8 -0
  22. package/packages/core/dist/index.js +19976 -18097
  23. package/packages/core/gallery/auth/src/routes/api/gallery/auth/login/post.ts +8 -0
  24. package/packages/core/gallery/auth/src/routes/api/gallery/auth/verify/get.ts +8 -0
  25. package/packages/core/gallery/auth/src/routes/gallery/auth/get.ts +8 -0
  26. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/get.ts +8 -0
  27. package/packages/core/gallery/database/src/routes/api/gallery/db/notes/post.ts +8 -0
  28. package/packages/core/gallery/database/src/routes/api/gallery/db/tables/get.ts +8 -0
  29. package/packages/core/gallery/error-overlay/src/routes/api/gallery/crash/get.ts +8 -0
  30. package/packages/core/gallery/orm/src/routes/api/gallery/products/get.ts +8 -0
  31. package/packages/core/gallery/orm/src/routes/api/gallery/products/post.ts +8 -0
  32. package/packages/core/gallery/queue/src/lib/queueDb.ts +8 -0
  33. package/packages/core/gallery/queue/src/routes/api/gallery/queue/consume/post.ts +8 -0
  34. package/packages/core/gallery/queue/src/routes/api/gallery/queue/fail/post.ts +8 -0
  35. package/packages/core/gallery/queue/src/routes/api/gallery/queue/produce/post.ts +8 -0
  36. package/packages/core/gallery/queue/src/routes/api/gallery/queue/retry/post.ts +8 -0
  37. package/packages/core/gallery/queue/src/routes/api/gallery/queue/status/get.ts +8 -0
  38. package/packages/core/gallery/queue/src/routes/gallery/queue/get.ts +8 -0
  39. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/get.ts +8 -0
  40. package/packages/core/gallery/rest-api/src/routes/api/gallery/hello/post.ts +8 -0
  41. package/packages/core/gallery/templates/src/routes/gallery/page/get.ts +8 -0
  42. package/packages/core/gallery/templates/src/templates/gallery_page.twig +8 -0
  43. package/packages/core/public/THIRD-PARTY-NOTICES.md +55 -0
  44. package/packages/core/public/bundled-component-provenance.json +48 -0
  45. package/packages/core/public/js/tina4js.min.js +13 -3
  46. package/packages/core/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  47. package/packages/core/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  48. package/packages/core/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  49. package/packages/core/public/licenses/tina4-css-MIT.txt +21 -0
  50. package/packages/core/public/licenses/tina4-dev-admin-components.json +240 -0
  51. package/packages/core/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  52. package/packages/core/public/licenses/tina4-js-MIT.txt +20 -0
  53. package/packages/core/src/__feedback/widget.js +8 -0
  54. package/packages/core/src/_missing.ts +8 -0
  55. package/packages/core/src/ai.ts +8 -0
  56. package/packages/core/src/aiClient.ts +8 -0
  57. package/packages/core/src/api.ts +48 -7
  58. package/packages/core/src/auth.ts +94 -14
  59. package/packages/core/src/authGate.ts +35 -20
  60. package/packages/core/src/background.ts +8 -0
  61. package/packages/core/src/cache.ts +43 -4
  62. package/packages/core/src/constants.ts +8 -0
  63. package/packages/core/src/container.ts +8 -0
  64. package/packages/core/src/context/chunker.ts +8 -0
  65. package/packages/core/src/context/index.ts +8 -0
  66. package/packages/core/src/devAdmin.ts +185 -59
  67. package/packages/core/src/devMailbox.ts +8 -0
  68. package/packages/core/src/dispatchPipeline.ts +8 -0
  69. package/packages/core/src/docs.ts +8 -0
  70. package/packages/core/src/docsAutoDiscovery.ts +8 -0
  71. package/packages/core/src/docsParser.ts +8 -0
  72. package/packages/core/src/docsScanner.ts +44 -0
  73. package/packages/core/src/docsSignatures.ts +8 -0
  74. package/packages/core/src/dotenv.ts +8 -0
  75. package/packages/core/src/env.ts +8 -0
  76. package/packages/core/src/errorOverlay.ts +8 -0
  77. package/packages/core/src/events.ts +8 -0
  78. package/packages/core/src/fakeData.ts +8 -0
  79. package/packages/core/src/feedback.ts +8 -0
  80. package/packages/core/src/graphql.ts +108 -5
  81. package/packages/core/src/health.ts +8 -0
  82. package/packages/core/src/htmlElement.ts +8 -0
  83. package/packages/core/src/i18n.ts +8 -0
  84. package/packages/core/src/index.ts +9 -0
  85. package/packages/core/src/job.ts +16 -1
  86. package/packages/core/src/logger.ts +8 -0
  87. package/packages/core/src/mcp.test.ts +8 -0
  88. package/packages/core/src/mcp.ts +11 -1
  89. package/packages/core/src/messenger.ts +143 -86
  90. package/packages/core/src/metrics.ts +13 -0
  91. package/packages/core/src/middleware.ts +1181 -1097
  92. package/packages/core/src/mqtt.ts +8 -0
  93. package/packages/core/src/mqttMessage.ts +8 -0
  94. package/packages/core/src/optionalPackage.ts +48 -0
  95. package/packages/core/src/plan.ts +8 -0
  96. package/packages/core/src/portTakeover.ts +8 -0
  97. package/packages/core/src/projectIndex.ts +8 -0
  98. package/packages/core/src/projectIndexExtractors.ts +8 -0
  99. package/packages/core/src/projectIndexStorage.ts +8 -0
  100. package/packages/core/src/push.ts +24 -0
  101. package/packages/core/src/queue.ts +23 -2
  102. package/packages/core/src/queueBackends/kafkaBackend.ts +8 -0
  103. package/packages/core/src/queueBackends/liteBackend.ts +23 -0
  104. package/packages/core/src/queueBackends/mongoBackend.ts +61 -14
  105. package/packages/core/src/queueBackends/rabbitmqBackend.ts +8 -0
  106. package/packages/core/src/rateLimiter.ts +8 -0
  107. package/packages/core/src/request.ts +59 -10
  108. package/packages/core/src/response.ts +79 -5
  109. package/packages/core/src/routeDiscovery.ts +8 -0
  110. package/packages/core/src/router.ts +8 -0
  111. package/packages/core/src/server.ts +79 -25
  112. package/packages/core/src/service.ts +8 -0
  113. package/packages/core/src/session.ts +8 -0
  114. package/packages/core/src/sessionHandlers/childError.ts +8 -0
  115. package/packages/core/src/sessionHandlers/databaseHandler.ts +68 -3
  116. package/packages/core/src/sessionHandlers/memcachedHandler.ts +8 -0
  117. package/packages/core/src/sessionHandlers/mongoClient.ts +8 -0
  118. package/packages/core/src/sessionHandlers/mongoHandler.ts +8 -0
  119. package/packages/core/src/sessionHandlers/respClient.ts +8 -0
  120. package/packages/core/src/sessionHandlers/sqlClient.ts +8 -0
  121. package/packages/core/src/sessionHandlers/syncBridge.ts +8 -0
  122. package/packages/core/src/sessionHandlers/syncSocket.ts +8 -0
  123. package/packages/core/src/sessionHandlers/valkeyHandler.ts +8 -0
  124. package/packages/core/src/sso.ts +20 -4
  125. package/packages/core/src/ssoIdentity.ts +19 -0
  126. package/packages/core/src/ssrf.ts +221 -0
  127. package/packages/core/src/static.ts +8 -0
  128. package/packages/core/src/test.ts +8 -0
  129. package/packages/core/src/testClient.ts +11 -0
  130. package/packages/core/src/testing.ts +8 -0
  131. package/packages/core/src/transport.ts +369 -0
  132. package/packages/core/src/trustedProxy.ts +8 -0
  133. package/packages/core/src/types.ts +13 -0
  134. package/packages/core/src/validator.ts +8 -0
  135. package/packages/core/src/version.ts +8 -0
  136. package/packages/core/src/websocket.ts +12 -2
  137. package/packages/core/src/websocketBackplane.ts +49 -32
  138. package/packages/core/src/websocketConnection.ts +8 -0
  139. package/packages/core/src/wsdl.ts +197 -115
  140. package/packages/core/templates/errors/302.twig +8 -0
  141. package/packages/core/templates/errors/401.twig +8 -0
  142. package/packages/core/templates/errors/403.twig +8 -0
  143. package/packages/core/templates/errors/404.twig +8 -0
  144. package/packages/core/templates/errors/500.twig +8 -0
  145. package/packages/core/templates/errors/502.twig +8 -0
  146. package/packages/core/templates/errors/503.twig +8 -0
  147. package/packages/core/templates/errors/base.twig +8 -0
  148. package/packages/frond/dist/index.js +157 -17
  149. package/packages/frond/src/engine.ts +186 -17
  150. package/packages/frond/src/index.ts +8 -0
  151. package/packages/orm/dist/index.js +8641 -6765
  152. package/packages/orm/src/adapters/firebird.ts +50 -3
  153. package/packages/orm/src/adapters/mongodb.ts +8 -0
  154. package/packages/orm/src/adapters/mssql.ts +29 -7
  155. package/packages/orm/src/adapters/mysql.ts +17 -1
  156. package/packages/orm/src/adapters/odbc.ts +11 -0
  157. package/packages/orm/src/adapters/pg-types.d.ts +8 -0
  158. package/packages/orm/src/adapters/postgres.ts +34 -8
  159. package/packages/orm/src/adapters/sqlDialect.ts +57 -0
  160. package/packages/orm/src/adapters/sqlite.ts +26 -0
  161. package/packages/orm/src/autoCrud.ts +78 -36
  162. package/packages/orm/src/baseModel.ts +61 -29
  163. package/packages/orm/src/cachedDatabase.ts +67 -1
  164. package/packages/orm/src/connectTimeout.ts +8 -0
  165. package/packages/orm/src/database.ts +649 -401
  166. package/packages/orm/src/databaseResult.ts +8 -0
  167. package/packages/orm/src/databaseUrl.ts +8 -0
  168. package/packages/orm/src/docstore.ts +23 -1
  169. package/packages/orm/src/fakeData.ts +8 -0
  170. package/packages/orm/src/graph/adapters/arango.ts +8 -0
  171. package/packages/orm/src/graph/adapters/bolt.ts +8 -0
  172. package/packages/orm/src/graph/adapters/ultipa.ts +8 -0
  173. package/packages/orm/src/graph/connectTimeout.ts +8 -0
  174. package/packages/orm/src/graph/errors.ts +8 -0
  175. package/packages/orm/src/graph/graphAdapter.ts +8 -0
  176. package/packages/orm/src/graph/graphDatabase.ts +8 -0
  177. package/packages/orm/src/graph/graphUrl.ts +10 -1
  178. package/packages/orm/src/graph/shapes.ts +8 -0
  179. package/packages/orm/src/index.ts +147 -139
  180. package/packages/orm/src/migration.ts +8 -0
  181. package/packages/orm/src/model.ts +8 -0
  182. package/packages/orm/src/modelCollection.ts +8 -0
  183. package/packages/orm/src/point.ts +8 -0
  184. package/packages/orm/src/query.ts +149 -33
  185. package/packages/orm/src/queryBuilder.ts +8 -0
  186. package/packages/orm/src/realtime/index.ts +8 -0
  187. package/packages/orm/src/realtime/models/attachment.ts +8 -0
  188. package/packages/orm/src/realtime/models/channel.ts +8 -0
  189. package/packages/orm/src/realtime/models/channelMember.ts +8 -0
  190. package/packages/orm/src/realtime/models/message.ts +8 -0
  191. package/packages/orm/src/realtime/models/workspace.ts +8 -0
  192. package/packages/orm/src/realtime/realtime.ts +8 -0
  193. package/packages/orm/src/realtime/storage.ts +43 -12
  194. package/packages/orm/src/seeder.ts +8 -0
  195. package/packages/orm/src/seederTable.ts +8 -0
  196. package/packages/orm/src/seederTypes.ts +8 -0
  197. package/packages/orm/src/sqlTranslator.ts +185 -8
  198. package/packages/orm/src/types.ts +15 -0
  199. package/packages/orm/src/validation.ts +8 -0
  200. package/packages/swagger/src/generator.ts +8 -0
  201. package/packages/swagger/src/index.ts +8 -0
  202. package/packages/swagger/src/ui.ts +8 -0
  203. package/types/core/src/api.d.ts +6 -0
  204. package/types/core/src/auth.d.ts +30 -0
  205. package/types/core/src/background.d.ts +0 -16
  206. package/types/core/src/cache.d.ts +6 -0
  207. package/types/core/src/devAdmin.d.ts +18 -12
  208. package/types/core/src/docs.d.ts +0 -17
  209. package/types/core/src/errorOverlay.d.ts +0 -24
  210. package/types/core/src/events.d.ts +0 -12
  211. package/types/core/src/feedback.d.ts +0 -23
  212. package/types/core/src/graphql.d.ts +25 -21
  213. package/types/core/src/htmlElement.d.ts +0 -15
  214. package/types/core/src/index.d.ts +1 -0
  215. package/types/core/src/job.d.ts +1 -0
  216. package/types/core/src/messenger.d.ts +12 -1
  217. package/types/core/src/metrics.d.ts +2 -0
  218. package/types/core/src/middleware.d.ts +45 -1
  219. package/types/core/src/mqtt.d.ts +0 -33
  220. package/types/core/src/optionalPackage.d.ts +25 -0
  221. package/types/core/src/plan.d.ts +0 -10
  222. package/types/core/src/projectIndex.d.ts +0 -10
  223. package/types/core/src/push.d.ts +5 -0
  224. package/types/core/src/queue.d.ts +7 -1
  225. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  226. package/types/core/src/queueBackends/mongoBackend.d.ts +10 -1
  227. package/types/core/src/request.d.ts +12 -0
  228. package/types/core/src/response.d.ts +13 -0
  229. package/types/core/src/server.d.ts +19 -0
  230. package/types/core/src/sessionHandlers/childError.d.ts +0 -15
  231. package/types/core/src/sessionHandlers/databaseHandler.d.ts +25 -1
  232. package/types/core/src/sso.d.ts +1 -0
  233. package/types/core/src/ssoIdentity.d.ts +10 -0
  234. package/types/core/src/ssrf.d.ts +19 -0
  235. package/types/core/src/transport.d.ts +28 -0
  236. package/types/core/src/trustedProxy.d.ts +0 -16
  237. package/types/core/src/types.d.ts +5 -0
  238. package/types/core/src/websocketBackplane.d.ts +1 -0
  239. package/types/core/src/wsdl.d.ts +8 -22
  240. package/types/frond/src/engine.d.ts +7 -1
  241. package/types/orm/src/adapters/firebird.d.ts +12 -0
  242. package/types/orm/src/adapters/mssql.d.ts +11 -0
  243. package/types/orm/src/adapters/mysql.d.ts +2 -0
  244. package/types/orm/src/adapters/postgres.d.ts +11 -0
  245. package/types/orm/src/adapters/sqlDialect.d.ts +25 -0
  246. package/types/orm/src/adapters/sqlite.d.ts +7 -0
  247. package/types/orm/src/baseModel.d.ts +5 -0
  248. package/types/orm/src/cachedDatabase.d.ts +27 -0
  249. package/types/orm/src/database.d.ts +46 -7
  250. package/types/orm/src/graph/graphUrl.d.ts +0 -8
  251. package/types/orm/src/index.d.ts +1 -1
  252. package/types/orm/src/query.d.ts +48 -2
  253. package/types/orm/src/realtime/storage.d.ts +2 -1
  254. package/types/orm/src/sqlTranslator.d.ts +70 -18
  255. package/types/orm/src/types.d.ts +6 -0
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  (function(){"use strict";(()=>{try{const t=window.location.pathname||"";return t.startsWith("/__dev")||t.startsWith("/__feedback")}catch{return!1}})()?console.info("tina4-feedback-widget: skipping on developer path"):window.__tina4FeedbackLoaded?console.warn("tina4-feedback-widget already loaded; skipping"):(window.__tina4FeedbackLoaded=!0,b());function b(){const c=(getComputedStyle(document.documentElement).getPropertyValue("--primary")||"").trim()||"#3b82f6";h(c);const l=m();document.body.appendChild(l);let e=null,u;const r=[];l.addEventListener("click",()=>{if(e){e.remove(),e=null,l.style.display="";return}e=g(),document.body.appendChild(e),l.style.display="none",setTimeout(()=>e?.querySelector("textarea")?.focus(),0)});function g(){const o=document.createElement("div");o.className="tina4-fb-modal",o.innerHTML=`
2
10
  <div class="tina4-fb-head">
3
11
  <span class="tina4-fb-title">Tell us what's not working</span>
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Last-resort finder for `@tina4/core/<subpath>` under Node's ESM resolver.
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 AI — Install AI coding assistant context files.
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /** Zero-dependency app-facing AI client (ADR-0053 + ADR-0060). */
2
10
  import http, { type IncomingMessage } from "node:http";
3
11
  import https from "node:https";
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 API Client — HTTP client using Node.js built-in modules only.
3
11
  *
@@ -21,6 +29,7 @@ import { promises as fsp, createWriteStream } from "node:fs";
21
29
  import { basename } from "node:path";
22
30
  import { pipeline } from "node:stream/promises";
23
31
  import { TINA4_VERSION } from "./version.js";
32
+ import { guardUrl } from "./ssrf.js";
24
33
 
25
34
  export interface ApiResult {
26
35
  http_code: number | null;
@@ -221,6 +230,11 @@ export interface ApiOptions {
221
230
  * persisted; scoped to this instance.
222
231
  */
223
232
  cookies?: boolean;
233
+ /**
234
+ * SSRF guard (ADR-0084): an explicit allow-list of hosts / host:port / CIDRs
235
+ * that bypass the private-address refusal even with the opt-out off.
236
+ */
237
+ allowHosts?: string[];
224
238
  }
225
239
 
226
240
  /** True when two URLs share scheme + host + (effective) port. */
@@ -422,6 +436,7 @@ export class Api {
422
436
  private transportFn?: ApiTransport;
423
437
  private cookiesEnabled: boolean;
424
438
  private cookies: Record<string, string>;
439
+ private allowHosts: string[];
425
440
 
426
441
  /**
427
442
  * Construct an Api client.
@@ -468,6 +483,7 @@ export class Api {
468
483
  this.transportFn = undefined;
469
484
  this.cookiesEnabled = false;
470
485
  this.cookies = {};
486
+ this.allowHosts = [];
471
487
 
472
488
  // Options-bag form — second arg is an object literal
473
489
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
@@ -479,6 +495,7 @@ export class Api {
479
495
  this.retryBackoff = opts.retryBackoff ?? 0.5;
480
496
  this.transportFn = opts.transport;
481
497
  this.cookiesEnabled = opts.cookies ?? false;
498
+ this.allowHosts = opts.allowHosts ?? [];
482
499
 
483
500
  // Bearer wins over basic-auth when both are passed
484
501
  if (opts.bearerToken != null) {
@@ -667,7 +684,7 @@ export class Api {
667
684
  url += (url.includes("?") ? "&" : "?") + qs;
668
685
  }
669
686
 
670
- const { headers, data } = this.buildRequest("GET", "application/json", undefined);
687
+ const { headers, data } = this.buildRequest("GET", "application/json", undefined, undefined, url);
671
688
 
672
689
  // An injected transport can't stream (it returns a buffered result), so
673
690
  // write its body out; only the real network path streams chunk-by-chunk.
@@ -760,7 +777,7 @@ export class Api {
760
777
  const url = this.buildUrl(path);
761
778
  const method = (opts.method ?? "GET").toUpperCase();
762
779
  const contentType = opts.contentType ?? "application/json";
763
- const { headers, data } = this.buildRequest(method, contentType, opts.body, opts.headers);
780
+ const { headers, data } = this.buildRequest(method, contentType, opts.body, opts.headers, url);
764
781
  const totalSec = this.streamSeconds(opts.timeout, "TINA4_API_TIMEOUT", this.timeout);
765
782
  const connectSec = this.streamSeconds(opts.connectTimeout, "TINA4_API_CONNECT_TIMEOUT", 10);
766
783
  const opened = await this.openStreamRequest(method, url, headers, data, connectSec);
@@ -834,13 +851,19 @@ export class Api {
834
851
  * body-phase timeout is applied by the caller (streamBytes) via
835
852
  * `res.destroy()`.
836
853
  */
837
- private openStreamRequest(
854
+ private async openStreamRequest(
838
855
  method: string,
839
856
  url: string,
840
857
  headers: Record<string, string>,
841
858
  data: Buffer | undefined,
842
859
  connectSec: number,
843
860
  ): Promise<{ res: http.IncomingMessage }> {
861
+ // SSRF guard (ADR-0084): refuse a private/internal target before connect.
862
+ try {
863
+ await guardUrl(url, this.allowHosts);
864
+ } catch (err) {
865
+ throw new ApiStreamError(err instanceof Error ? err.message : String(err), null);
866
+ }
844
867
  return new Promise((resolve, reject) => {
845
868
  let parsed: URL;
846
869
  try {
@@ -904,14 +927,21 @@ export class Api {
904
927
  contentType: string,
905
928
  body: unknown,
906
929
  extraHeaders?: Record<string, string>,
930
+ targetUrl?: string,
907
931
  ): { headers: Record<string, string>; data: Buffer | undefined } {
908
932
  const headers: Record<string, string> = { "User-Agent": `Tina4/${TINA4_VERSION}`, ...this.headers };
909
- if (this.authHeader) {
933
+ // Attach the configured Authorization / Cookie ONLY when the request
934
+ // target is same-origin as the configured base. A path that is itself
935
+ // an absolute off-origin URL (e.g. get("http://evil/x")) otherwise
936
+ // leaks the bearer token / session cookie to an attacker-chosen host —
937
+ // the same cross-origin strip already applied to followed redirects.
938
+ const sameOriginAsBase = targetUrl === undefined || sameOrigin(targetUrl, this.baseUrl);
939
+ if (this.authHeader && sameOriginAsBase) {
910
940
  headers["Authorization"] = this.authHeader;
911
941
  }
912
942
 
913
943
  // Cookie jar: attach the accumulated Cookie header when enabled.
914
- if (this.cookiesEnabled) {
944
+ if (this.cookiesEnabled && sameOriginAsBase) {
915
945
  const cookieHeader = this.cookieHeader();
916
946
  if (cookieHeader) {
917
947
  headers["Cookie"] = cookieHeader;
@@ -943,6 +973,9 @@ export class Api {
943
973
  Object.assign(headers, extraHeaders);
944
974
  }
945
975
 
976
+ if (targetUrl !== undefined && !sameOrigin(targetUrl, this.baseUrl)) {
977
+ for (const name of STRIP_ON_CROSS_ORIGIN) deleteHeaderCaseInsensitive(headers, name);
978
+ }
946
979
  return { headers, data };
947
980
  }
948
981
 
@@ -985,7 +1018,7 @@ export class Api {
985
1018
  contentType: string = "application/json",
986
1019
  extraHeaders?: Record<string, string>,
987
1020
  ): Promise<ApiResult> {
988
- const { headers, data } = this.buildRequest(method, contentType, body, extraHeaders);
1021
+ const { headers, data } = this.buildRequest(method, contentType, body, extraHeaders, url);
989
1022
 
990
1023
  // A user-injected transport fully replaces the network call.
991
1024
  if (this.transportFn) {
@@ -1037,13 +1070,21 @@ export class Api {
1037
1070
  * bearer token / session cookie never leaks to a host you didn't
1038
1071
  * authenticate to.
1039
1072
  */
1040
- private performRequest(
1073
+ private async performRequest(
1041
1074
  method: string,
1042
1075
  url: string,
1043
1076
  headers: Record<string, string>,
1044
1077
  data: Buffer | undefined,
1045
1078
  redirectsLeft: number,
1046
1079
  ): Promise<NetworkResult> {
1080
+ // SSRF guard (ADR-0084): re-validated per hop (this method recurses on a
1081
+ // redirect), so a hop to a private/internal address is refused even when
1082
+ // the initial URL was public.
1083
+ try {
1084
+ await guardUrl(url, this.allowHosts);
1085
+ } catch (err) {
1086
+ return { kind: "error", error: err instanceof Error ? err.message : String(err) };
1087
+ }
1047
1088
  return new Promise<NetworkResult>((resolve) => {
1048
1089
  let parsed: URL;
1049
1090
  try {
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 Auth — Zero-dependency JWT, password hashing, and auth middleware.
3
11
  *
@@ -12,7 +20,7 @@
12
20
  * checkPassword("secret123", hash); // true
13
21
  */
14
22
  import { createHmac, createSign, createVerify, pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
15
- import { appendFileSync, existsSync, readFileSync } from "node:fs";
23
+ import { appendFileSync, existsSync, readFileSync, openSync, closeSync, fstatSync, fchmodSync, constants } from "node:fs";
16
24
  import { join } from "node:path";
17
25
  import type { Middleware, Tina4Request, Tina4Response } from "./types.js";
18
26
  import { isTruthy } from "./dotenv.js";
@@ -74,6 +82,58 @@ function _warnBlankSecret(): void {
74
82
  void _logWarning(BLANK_SECRET_WARNING);
75
83
  }
76
84
 
85
+ // ── Auth-token rules (ADR-0079) ────────────────────────────────────
86
+
87
+ /**
88
+ * Token purposes that are never an identity (ADR-0079 s1). Frond's formToken()
89
+ * signs a JWT with the same TINA4_SECRET as an auth token, marked
90
+ * `type: "form"`. A form token proves where a write came from, never who the
91
+ * caller is, so every identity gate refuses these purposes. validToken itself
92
+ * stays purpose-neutral: the CSRF middleware needs it for form tokens.
93
+ */
94
+ export const NON_IDENTITY_TOKEN_TYPES: readonly string[] = ["form"];
95
+
96
+ /**
97
+ * Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size, RFC
98
+ * 7518 s3.2. A blank key is the shortest case - anyone can reproduce it.
99
+ */
100
+ export const MIN_SECRET_BYTES = 32;
101
+
102
+ /**
103
+ * True when a VERIFIED payload may stand for a caller's identity. A payload
104
+ * whose `type` is a reserved non-identity purpose (a Frond form token) is
105
+ * refused; application payloads are otherwise untouched.
106
+ */
107
+ export function isIdentityPayload(payload: Record<string, unknown> | null | undefined): payload is Record<string, unknown> {
108
+ return payload != null && !NON_IDENTITY_TOKEN_TYPES.includes(payload.type as string);
109
+ }
110
+
111
+ /** The actionable error for a weak HMAC key, or null when the key is usable. */
112
+ export function insecureSecretMessage(secret: string | undefined | null): string | null {
113
+ const length = Buffer.byteLength(secret ?? "", "utf8");
114
+ if (length >= MIN_SECRET_BYTES) return null;
115
+ const what = length === 0 ? "is not set" : `is ${length} bytes`;
116
+ return `Auth: TINA4_SECRET ${what}; an HMAC JWT secret must be at least ${MIN_SECRET_BYTES} bytes. ` +
117
+ "Generate one with `openssl rand -hex 32` and set TINA4_SECRET in your environment or .env.";
118
+ }
119
+
120
+ /**
121
+ * Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
122
+ * Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short one
123
+ * is refused. Dev with a blank secret has already had one minted into
124
+ * .env.local by ensureDevSecret(). RS256 keys are PEMs and are not measured.
125
+ *
126
+ * @throws Error with the actionable message naming TINA4_SECRET.
127
+ */
128
+ export function requireBootSecret(): void {
129
+ const algorithm = (process.env.TINA4_JWT_ALGORITHM ?? "").trim() || "HS256";
130
+ if (RSA_SIGN_ALGORITHMS.has(algorithm)) return;
131
+ const secret = process.env.TINA4_SECRET ?? "";
132
+ if (secret === "" && _isDev()) return;
133
+ const message = insecureSecretMessage(secret);
134
+ if (message) throw new Error(message);
135
+ }
136
+
77
137
  /**
78
138
  * Ensure a usable TINA4_SECRET exists. Run ONCE at server boot, after env load
79
139
  * and before auth is used. Mirrors Python's `ensure_dev_secret()`.
@@ -107,14 +167,19 @@ export function ensureDevSecret(cwd?: string): string | null {
107
167
  const baseDir = cwd ?? process.cwd();
108
168
  const envLocalPath = join(baseDir, ".env.local");
109
169
  try {
110
- // If the file exists and its content doesn't end in a newline, prepend one
111
- // so the new key lands on its own line.
112
- let prefix = "";
113
- if (existsSync(envLocalPath)) {
114
- const existing = readFileSync(envLocalPath, "utf-8");
115
- if (existing.length > 0 && !existing.endsWith("\n")) prefix = "\n";
170
+ // Read and append through one descriptor so replacing a pathname cannot
171
+ // redirect either operation to an unrelated file.
172
+ const fd = openSync(envLocalPath, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), 0o600);
173
+ try {
174
+ const info = fstatSync(fd);
175
+ if (!info.isFile() || info.nlink !== 1) throw new Error("Refusing a non-regular or multiply-linked configuration file");
176
+ fchmodSync(fd, 0o600);
177
+ const existing = readFileSync(fd, "utf-8");
178
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
179
+ appendFileSync(fd, `${prefix}TINA4_SECRET=${newSecret}\n`);
180
+ } finally {
181
+ closeSync(fd);
116
182
  }
117
- appendFileSync(envLocalPath, `${prefix}TINA4_SECRET=${newSecret}\n`);
118
183
  void _logInfo("Auth: generated a development secret, saved to .env.local (gitignored)");
119
184
  } catch {
120
185
  // Keep the in-memory secret for this run; warn but never crash boot.
@@ -447,6 +512,14 @@ export function validToken(token: string, secret?: string, algorithm?: string):
447
512
  const header = JSON.parse(base64urlDecode(h).toString()) as Record<string, unknown>;
448
513
  if (header.alg !== resolvedAlgorithm) return null;
449
514
 
515
+ // A blank or short HMAC key verifies tokens anyone can mint, so the token is
516
+ // REJECTED (fail closed with a 401, never a 500) and the operator is told
517
+ // why. ADR-0079 s2.
518
+ if (HMAC_DIGESTS.has(resolvedAlgorithm)) {
519
+ const weak = insecureSecretMessage(resolvedSecret);
520
+ if (weak) { void _logWarning(weak); return null; }
521
+ }
522
+
450
523
  const signingInput = `${h}.${p}`;
451
524
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
452
525
  return null;
@@ -509,6 +582,8 @@ function sign(input: string, secret: string, algorithm: string): string {
509
582
  // the header is the one that actually produced this signature.
510
583
  const digest = HMAC_DIGESTS.get(algorithm);
511
584
  if (digest) {
585
+ const weak = insecureSecretMessage(secret);
586
+ if (weak) throw new Error(weak);
512
587
  return base64urlEncode(createHmac(digest, secret).update(input).digest());
513
588
  }
514
589
  const rsaAlgorithm = RSA_SIGN_ALGORITHMS.get(algorithm);
@@ -609,12 +684,14 @@ export function authMiddleware(secret?: string, algorithm?: string): Middleware
609
684
  }
610
685
 
611
686
  const token = authHeader.slice(7);
612
- if (!validToken(token, secret, algorithm)) {
687
+ const payload = validToken(token, secret, algorithm);
688
+ // Invalid, expired, or a form token (not an identity, ADR-0079 s1).
689
+ if (!isIdentityPayload(payload)) {
613
690
  res({ error: "Unauthorized" }, 401);
614
691
  return;
615
692
  }
616
693
 
617
- (req as any).auth = getPayload(token);
694
+ (req as any).auth = payload;
618
695
  next();
619
696
  };
620
697
  }
@@ -635,11 +712,12 @@ export function refreshToken(
635
712
  token: string,
636
713
  expiresIn: number = 60,
637
714
  ): string | null {
638
- if (!validToken(token)) return null;
639
-
640
- const payload = getPayload(token);
715
+ const payload = validToken(token);
641
716
  if (!payload) return null;
642
717
 
718
+ // Refresh preserves PURPOSE: a form token comes back as a form token (CSRF
719
+ // rotation) and so can still never pass an identity gate. The route gate
720
+ // issues a FreshToken only for an identity token. ADR-0079 s1.
643
721
  // Strip standard timing claims so getToken sets fresh ones
644
722
  const { iat: _iat, exp: _exp, ...claims } = payload;
645
723
  return getToken(claims, expiresIn);
@@ -673,7 +751,9 @@ export function authenticateRequest(
673
751
  // algorithm= silently got the env values instead, and `algorithm` defaulted to
674
752
  // the literal "HS256" which additionally shadowed TINA4_JWT_ALGORITHM. Python,
675
753
  // PHP and Ruby all honour these; Node was the last one that did not.
676
- if (validToken(token, secret, algorithm)) return getPayload(token);
754
+ const payload = validToken(token, secret, algorithm);
755
+ // A form token is not an identity (ADR-0079 s1).
756
+ if (payload) return isIdentityPayload(payload) ? payload : null;
677
757
 
678
758
  // Fallback: treat Bearer value as API key
679
759
  if (validateApiKey(token)) {
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Secure-by-default route auth gate — shared by the live server dispatch and the
3
11
  * in-process TestClient so the test surface enforces the IDENTICAL gate as
@@ -9,7 +17,8 @@
9
17
  * verification layer lied. This is the same defect fixed in tina4-python under
10
18
  * #PY2; extracting the gate here lets both callers share one implementation.
11
19
  */
12
- import { validToken, getPayload, refreshToken } from "./auth.js";
20
+ import { validToken, refreshToken, isIdentityPayload } from "./auth.js";
21
+ import { liveSessionIdentity } from "./ssoIdentity.js";
13
22
  import type { Tina4Request, Tina4Response } from "./types.js";
14
23
 
15
24
  /** Just the auth-relevant fields of a matched route. */
@@ -44,52 +53,58 @@ export function enforceRouteAuth(
44
53
  return false;
45
54
  }
46
55
 
56
+ // Token slots in priority order: Bearer header, body formToken (frond.js
57
+ // puts the auth token it received as a FreshToken there), session token.
58
+ // Each slot is accepted only when it carries an IDENTITY token: a Frond
59
+ // form token (`type: "form"`) proves where a write came from, not who sent
60
+ // it, so it is skipped and the search moves on (ADR-0079 s1).
47
61
  const authHeader = req.headers.authorization ?? "";
48
62
  const headerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
49
63
 
50
- let resolvedToken = "";
64
+ let payload: Record<string, unknown> | null = null;
51
65
  let tokenSource: "header" | "body" | "session" | "" = "";
66
+ let resolvedToken = "";
52
67
 
53
68
  // Priority 1: Authorization Bearer header
54
- if (headerToken && validToken(headerToken)) {
55
- resolvedToken = headerToken;
56
- tokenSource = "header";
69
+ if (headerToken) {
70
+ const candidate = validToken(headerToken);
71
+ if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "header"; resolvedToken = headerToken; }
57
72
  }
58
73
 
59
74
  // Priority 2: formToken from request body
60
- if (!resolvedToken) {
75
+ if (!payload) {
61
76
  const bodyToken = (req.body as Record<string, unknown>)?.formToken as string | undefined;
62
- if (bodyToken && validToken(bodyToken)) {
63
- resolvedToken = bodyToken;
64
- tokenSource = "body";
77
+ if (bodyToken) {
78
+ const candidate = validToken(bodyToken);
79
+ if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "body"; resolvedToken = bodyToken; }
65
80
  }
66
81
  }
67
82
 
68
- // Priority 3: Session token
69
- if (!resolvedToken) {
70
- const sso = (req as any).session?.get?.("_tina4_sso") as Record<string, any> | undefined;
71
- const identity = sso?.identity;
72
- if (identity?.issuer && identity?.subject) {
83
+ // Priority 3: Session — a provider-verified SSO identity while it is live
84
+ // (ADR-0079 s5), then the session token.
85
+ if (!payload) {
86
+ const identity = liveSessionIdentity((req as any).session?.get?.("_tina4_sso"));
87
+ if (identity) {
73
88
  req.user = identity;
74
89
  // RBAC guards apply to the SSO identity too (Feature 138).
75
90
  return rbacForbidden(match, identity, res);
76
91
  }
77
92
  const sessionToken = (req as any).session?.get?.("token") as string | undefined;
78
- if (sessionToken && validToken(sessionToken)) {
79
- resolvedToken = sessionToken;
80
- tokenSource = "session";
93
+ if (sessionToken) {
94
+ const candidate = validToken(sessionToken);
95
+ if (isIdentityPayload(candidate)) { payload = candidate; tokenSource = "session"; resolvedToken = sessionToken; }
81
96
  }
82
97
  }
83
98
 
84
- if (!resolvedToken) {
99
+ if (!payload) {
85
100
  res.raw.writeHead(401, { "Content-Type": "application/json" });
86
101
  res.raw.end(JSON.stringify({ error: "Unauthorized" }));
87
102
  return true;
88
103
  }
89
104
 
90
- req.user = getPayload(resolvedToken) ?? {};
105
+ req.user = payload;
91
106
 
92
- // When a body formToken validates, return a FreshToken header with a refreshed JWT
107
+ // When a body token authenticates, return a FreshToken header with a refreshed JWT
93
108
  if (tokenSource === "body") {
94
109
  const fresh = refreshToken(resolvedToken);
95
110
  if (fresh) {
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Background tasks — periodic callbacks that run alongside the HTTP server.
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Multi-backend response cache for GET requests.
3
11
  *
@@ -56,6 +64,7 @@ import * as fs from "node:fs";
56
64
  import * as path from "node:path";
57
65
  import * as crypto from "node:crypto";
58
66
  import * as net from "node:net";
67
+ import { optionalPackageMissing } from "./optionalPackage.js";
59
68
 
60
69
  // ── Credential parsing (WHATWG URL or TINA4_CACHE_USERNAME / _PASSWORD) ──
61
70
 
@@ -181,6 +190,12 @@ interface CacheBackend {
181
190
  /** One-time async connect/probe. Resolves once the backend has decided
182
191
  * availability; createBackend() awaits this before falling back to file. */
183
192
  ready?(): Promise<void>;
193
+ /**
194
+ * When unavailable because the app has not installed the backend's driver
195
+ * (an optional peer, ADR-0067): the actionable install message, appended to
196
+ * the fallback warning. null for every other cause.
197
+ */
198
+ missingDriver?(): string | null;
184
199
  }
185
200
 
186
201
  // ── Memory backend ────────────────────────────────────────────────
@@ -1141,6 +1156,7 @@ class MongoBackend implements CacheBackend {
1141
1156
  private misses = 0;
1142
1157
  private maxEntries: number;
1143
1158
  private available = false;
1159
+ private missingDriverMessage: string | null = null;
1144
1160
  private readyPromise: Promise<void>;
1145
1161
  // The mongodb driver types aren't depended on at compile time (optional dep).
1146
1162
  private client: any = null;
@@ -1178,8 +1194,15 @@ class MongoBackend implements CacheBackend {
1178
1194
  try {
1179
1195
  // Dynamic import so @tina4/core has no hard dependency on `mongodb` and an
1180
1196
  // absent driver degrades to file-fallback (never an import-time crash).
1181
- const mod: any = await import("mongodb").catch(() => null);
1182
- if (!mod || !mod.MongoClient) { this.available = false; return; }
1197
+ let importError: any = null;
1198
+ const mod: any = await import("mongodb").catch((error) => { importError = error; return null; });
1199
+ if (!mod || !mod.MongoClient) {
1200
+ this.available = false;
1201
+ if (importError?.code === "ERR_MODULE_NOT_FOUND") {
1202
+ this.missingDriverMessage = optionalPackageMissing("the mongodb cache backend", ["mongodb"]).message;
1203
+ }
1204
+ return;
1205
+ }
1183
1206
  const client = new mod.MongoClient(this.url, { serverSelectionTimeoutMS: 4000 });
1184
1207
  await client.connect();
1185
1208
  await client.db(this.dbName).command({ ping: 1 });
@@ -1200,6 +1223,10 @@ class MongoBackend implements CacheBackend {
1200
1223
  return this.readyPromise;
1201
1224
  }
1202
1225
 
1226
+ missingDriver(): string | null {
1227
+ return this.missingDriverMessage;
1228
+ }
1229
+
1203
1230
  async isAvailable(): Promise<boolean> {
1204
1231
  await this.readyPromise;
1205
1232
  return this.available;
@@ -1291,6 +1318,7 @@ class DatabaseBackend implements CacheBackend {
1291
1318
  private misses = 0;
1292
1319
  private maxEntries: number;
1293
1320
  private available = false;
1321
+ private missingDriverMessage: string | null = null;
1294
1322
  private readyPromise: Promise<void>;
1295
1323
  // @tina4/orm Database instance (optional dep, loaded dynamically).
1296
1324
  private db: any = null;
@@ -1324,8 +1352,13 @@ class DatabaseBackend implements CacheBackend {
1324
1352
  try { db.commit(); } catch { /* sqlite autocommits DDL */ }
1325
1353
  this.db = db;
1326
1354
  this.available = true;
1327
- } catch {
1355
+ } catch (error) {
1328
1356
  this.available = false;
1357
+ // The ORM adapters already phrase a missing driver as the install
1358
+ // command; carry THAT through, and nothing else (a connect error can
1359
+ // name the host and user).
1360
+ const message = error instanceof Error ? error.message : String(error);
1361
+ if (/npm install \S/.test(message)) this.missingDriverMessage = message;
1329
1362
  } finally {
1330
1363
  if (prevAuto === undefined) delete process.env.TINA4_AUTO_CACHING; else process.env.TINA4_AUTO_CACHING = prevAuto;
1331
1364
  if (prevDb === undefined) delete process.env.TINA4_DB_CACHE; else process.env.TINA4_DB_CACHE = prevDb;
@@ -1336,6 +1369,10 @@ class DatabaseBackend implements CacheBackend {
1336
1369
  return this.readyPromise;
1337
1370
  }
1338
1371
 
1372
+ missingDriver(): string | null {
1373
+ return this.missingDriverMessage;
1374
+ }
1375
+
1339
1376
  async isAvailable(): Promise<boolean> {
1340
1377
  await this.readyPromise;
1341
1378
  return this.available;
@@ -1531,9 +1568,11 @@ export async function createBackend(config?: {
1531
1568
  // degrading to a no-op cache. Mirrors the Python master.
1532
1569
  if (typeof backend.isAvailable === "function" && !(await backend.isAvailable())) {
1533
1570
  // eslint-disable-next-line no-console
1571
+ const installHint = backend.missingDriver?.() ?? null;
1534
1572
  console.warn(
1535
1573
  `[tina4] Cache backend '${backendName}' is unavailable ` +
1536
- `(driver missing or service unreachable) — falling back to 'file'.`,
1574
+ `(driver missing or service unreachable) — falling back to 'file'.` +
1575
+ (installHint ? ` ${installHint}` : ""),
1537
1576
  );
1538
1577
  return new FileBackend(cacheDir(), maxEntries);
1539
1578
  }
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Tina4 Constants — HTTP status codes and content types.
3
11
  *
@@ -1,3 +1,11 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
1
9
  /**
2
10
  * Lightweight dependency injection container.
3
11
  *