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
@@ -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
  // Tina4 Node.js
2
10
  //
3
11
  // Text folding + chunking for the Context subsystem.
@@ -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
  // Tina4 Node.js
2
10
  //
3
11
  // Context — a native, zero-dependency code/doc grounding index.
@@ -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 Dev Admin — Built-in development dashboard, zero dependencies.
3
11
  *
@@ -10,14 +18,14 @@
10
18
  */
11
19
 
12
20
  import { cpus as osCpus } from "node:os";
13
- import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync } from "node:fs";
14
- import { join, dirname, resolve, relative } from "node:path";
21
+ import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync, openSync, closeSync, fstatSync, fchmodSync, ftruncateSync, constants, realpathSync, lstatSync } from "node:fs";
22
+ import { join, dirname, resolve, relative, sep, basename } from "node:path";
15
23
  import { fileURLToPath } from "node:url";
16
24
  import type { Router } from "./router.js";
17
25
  import type { RouteHandler, Tina4Request } from "./types.js";
18
26
  import { DevMailbox } from "./devMailbox.js";
19
27
  import { isTruthy } from "./dotenv.js";
20
- import { fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
28
+ import { fullAnalysis, fileDetail, MetricsEngineError, metricsScanRoot } from "./metrics.js";
21
29
  import { registerFeedbackRoutes } from "./feedback.js";
22
30
  import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js";
23
31
  import { timingSafeEqual } from "node:crypto";
@@ -67,8 +75,8 @@ function escapeHtml(value: string): string {
67
75
  *
68
76
  * A drive-by CSRF is a BROWSER cross-origin request, and a modern browser always
69
77
  * sends `Sec-Fetch-Site` (and any browser sends `Origin` on a cross-origin POST):
70
- * - Sec-Fetch-Site present -> trust the browser's classification
71
- * (cross-site refused; same-origin / same-site / none ok).
78
+ * - Sec-Fetch-Site present -> only same-origin (the dashboard itself) and
79
+ * none (a typed URL) pass; same-site is a different origin (ADR-0082).
72
80
  * - else Origin present -> require its host to match the request Host.
73
81
  * - else neither header -> not a browser cross-origin request (curl, a test
74
82
  * client, a server-side caller); it cannot be a drive-by, so allow here and
@@ -77,13 +85,18 @@ function escapeHtml(value: string): string {
77
85
  export function devSameOriginOk(req: Tina4Request): boolean {
78
86
  const secFetchSite = (req.header("sec-fetch-site") ?? "").trim().toLowerCase();
79
87
  if (secFetchSite) {
80
- return secFetchSite === "same-origin" || secFetchSite === "same-site" || secFetchSite === "none";
88
+ if (secFetchSite !== "same-origin" && secFetchSite !== "none") return false;
81
89
  }
82
90
  const origin = (req.header("origin") ?? "").trim();
83
91
  if (origin) {
84
- const netloc = origin.includes("://") ? origin.split("://", 2)[1] : origin;
85
- const host = (req.header("host") ?? "").trim();
86
- return host !== "" && netloc.toLowerCase() === host.toLowerCase();
92
+ try {
93
+ const supplied = new URL(origin);
94
+ const host = (req.header("host") ?? "").trim();
95
+ const scheme = new URL(req.url ?? "http://localhost").protocol;
96
+ return host !== "" && !supplied.username && !supplied.password
97
+ && supplied.pathname === "/" && !supplied.search && !supplied.hash
98
+ && supplied.origin === new URL(`${scheme}//${host}`).origin;
99
+ } catch { return false; }
87
100
  }
88
101
  return true;
89
102
  }
@@ -98,14 +111,52 @@ export function devSameOriginOk(req: Tina4Request): boolean {
98
111
  * Reads the RAW socket peer (never X-Forwarded-For), exactly like the MCP gate.
99
112
  */
100
113
  export function devMutationDenial(req: Tina4Request): { status: number; error: string } | null {
114
+ return devRequestDenial(req);
115
+ }
116
+
117
+ /**
118
+ * True when a Host header names the loopback machine or the configured
119
+ * TINA4_HOST (ADR-0082), so a rebound DNS name cannot reach /__dev. A missing
120
+ * Host is not a browser request (every browser sends one); the peer gate
121
+ * governs it instead.
122
+ */
123
+ export function devHostAllowed(hostHeader: string | undefined | null): boolean {
124
+ const host = (hostHeader ?? "").trim().toLowerCase();
125
+ if (!host) return true;
126
+ let name: string;
127
+ if (host.startsWith("[")) {
128
+ const end = host.indexOf("]");
129
+ name = end < 0 ? host : host.slice(1, end);
130
+ } else if (host.split(":").length === 2) {
131
+ name = host.split(":")[0];
132
+ } else {
133
+ name = host;
134
+ }
135
+ const allowed = new Set(["localhost", "127.0.0.1", "::1"]);
136
+ const configured = (process.env.TINA4_HOST ?? "").trim().toLowerCase().replace(/^\[|\]$/g, "");
137
+ if (configured) allowed.add(configured);
138
+ return allowed.has(name);
139
+ }
140
+
141
+ /**
142
+ * Return `{status, error}` to REFUSE any /__dev request (read or write), or
143
+ * `null` to allow (ADR-0082): Host allow-list, then same-origin, then the
144
+ * loopback peer (the MCP surface keeps its own 404 gate for the peer part).
145
+ */
146
+ export function devRequestDenial(req: Tina4Request): { status: number; error: string } | null {
147
+ // Host and browser origin remain mandatory even with a valid token.
148
+ // Remote dashboards explicitly configure TINA4_HOST.
149
+ const path = (req.path ?? "").replace(/\/+/g, "/");
150
+ const isMcp = DEV_MCP_PREFIXES.some((prefix) => path === prefix || path.startsWith(prefix + "/"));
151
+ if (!devHostAllowed(req.header("host"))) {
152
+ return { status: 403, error: "dev-admin: refused (host not allowed)" };
153
+ }
101
154
  if (!devSameOriginOk(req)) {
102
155
  return { status: 403, error: "dev-admin: refused (cross-origin request)" };
103
156
  }
104
- const path = req.path ?? "";
105
- const isMcp = DEV_MCP_PREFIXES.some((prefix) => path.startsWith(prefix));
106
157
  if (!isMcp) {
107
158
  const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
108
- if (!(isLoopback(peer) || mcpTokenOk(req))) {
159
+ if (!(isLoopback(peer) || mcpTokenOk(req, true))) {
109
160
  return { status: 403, error: "dev-admin: refused (non-loopback peer)" };
110
161
  }
111
162
  }
@@ -605,7 +656,13 @@ export class DevAdmin {
605
656
  { method: "GET", pattern: "/__dev/api/metrics/file", handler: (req: any, res: any) => {
606
657
  const url = new URL(req.url ?? "/", "http://localhost");
607
658
  const p = (url.searchParams.get("path") || "").toString();
608
- try { res.json(fileDetail(p)); }
659
+ // ADR-0082: the path must resolve inside the project or the last scan
660
+ // root; an absolute path elsewhere is refused before the engine runs.
661
+ const scanRoot = metricsScanRoot();
662
+ let target: string | null = p ? safeJoin(process.cwd(), p, [scanRoot]) : "";
663
+ if (target === null && scanRoot) target = safeJoin(process.cwd(), join(scanRoot, p), [scanRoot]);
664
+ if (target === null) { res.status(403).json({ error: "Path outside project" }); return; }
665
+ try { res.json(fileDetail(target)); }
609
666
  catch (e) {
610
667
  if (e instanceof MetricsEngineError) {
611
668
  // A bad path is the caller's mistake (404); anything else is the
@@ -1264,6 +1321,12 @@ const handleTable: RouteHandler = async (req, res) => {
1264
1321
  try {
1265
1322
  const { getAdapter } = await import("../../orm/src/index.js");
1266
1323
  const db = getAdapter();
1324
+ // ADR-0082: only a name the database itself reports is accepted.
1325
+ const known = (db.getTables() as unknown[]).map((t) => String(t));
1326
+ if (!known.includes(name)) {
1327
+ res.json({ error: "unknown table" }, 404);
1328
+ return;
1329
+ }
1267
1330
  const columns = db.getColumns(name);
1268
1331
  if (!columns.length) {
1269
1332
  res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
@@ -1928,26 +1991,35 @@ const handleConnectionsSave: RouteHandler = (req, res) => {
1928
1991
  }
1929
1992
  try {
1930
1993
  const envPath = join(process.cwd(), ".env");
1931
- const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : [];
1932
- const keysFound: Record<string, boolean> = { TINA4_DATABASE_URL: false, TINA4_DATABASE_USERNAME: false, TINA4_DATABASE_PASSWORD: false };
1933
- const newLines: string[] = [];
1934
- for (const line of lines) {
1935
- const trimmed = line.trim();
1936
- if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
1937
- newLines.push(line);
1938
- continue;
1994
+ const fd = openSync(envPath, constants.O_RDWR | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0), 0o600);
1995
+ try {
1996
+ const info = fstatSync(fd);
1997
+ if (!info.isFile() || info.nlink !== 1) throw new Error("Refusing a non-regular or multiply-linked configuration file");
1998
+ fchmodSync(fd, 0o600);
1999
+ const lines = readFileSync(fd, "utf-8").split("\n");
2000
+ const keysFound: Record<string, boolean> = { TINA4_DATABASE_URL: false, TINA4_DATABASE_USERNAME: false, TINA4_DATABASE_PASSWORD: false };
2001
+ const newLines: string[] = [];
2002
+ for (const line of lines) {
2003
+ const trimmed = line.trim();
2004
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) {
2005
+ newLines.push(line);
2006
+ continue;
2007
+ }
2008
+ const key = trimmed.split("=", 1)[0].trim();
2009
+ if (key === "TINA4_DATABASE_URL") { newLines.push(`TINA4_DATABASE_URL=${url}`); keysFound.TINA4_DATABASE_URL = true; }
2010
+ else if (key === "TINA4_DATABASE_USERNAME") { newLines.push(`TINA4_DATABASE_USERNAME=${username}`); keysFound.TINA4_DATABASE_USERNAME = true; }
2011
+ else if (key === "TINA4_DATABASE_PASSWORD") { newLines.push(`TINA4_DATABASE_PASSWORD=${password}`); keysFound.TINA4_DATABASE_PASSWORD = true; }
2012
+ else { newLines.push(line); }
1939
2013
  }
1940
- const key = trimmed.split("=", 1)[0].trim();
1941
- if (key === "TINA4_DATABASE_URL") { newLines.push(`TINA4_DATABASE_URL=${url}`); keysFound.TINA4_DATABASE_URL = true; }
1942
- else if (key === "TINA4_DATABASE_USERNAME") { newLines.push(`TINA4_DATABASE_USERNAME=${username}`); keysFound.TINA4_DATABASE_USERNAME = true; }
1943
- else if (key === "TINA4_DATABASE_PASSWORD") { newLines.push(`TINA4_DATABASE_PASSWORD=${password}`); keysFound.TINA4_DATABASE_PASSWORD = true; }
1944
- else { newLines.push(line); }
1945
- }
1946
- const values: Record<string, string> = { TINA4_DATABASE_URL: url, TINA4_DATABASE_USERNAME: username, TINA4_DATABASE_PASSWORD: password };
1947
- for (const [key, found] of Object.entries(keysFound)) {
1948
- if (!found) newLines.push(`${key}=${values[key]}`);
2014
+ const values: Record<string, string> = { TINA4_DATABASE_URL: url, TINA4_DATABASE_USERNAME: username, TINA4_DATABASE_PASSWORD: password };
2015
+ for (const [key, found] of Object.entries(keysFound)) {
2016
+ if (!found) newLines.push(`${key}=${values[key]}`);
2017
+ }
2018
+ ftruncateSync(fd, 0);
2019
+ writeFileSync(fd, newLines.join("\n") + "\n");
2020
+ } finally {
2021
+ closeSync(fd);
1949
2022
  }
1950
- writeFileSync(envPath, newLines.join("\n") + "\n");
1951
2023
  res.json({ success: true });
1952
2024
  } catch (e: unknown) {
1953
2025
  const msg = e instanceof Error ? e.message : String(e);
@@ -2117,10 +2189,44 @@ export const handleVersionCheck: RouteHandler = async (_req, res) => {
2117
2189
  // Parity handlers — ported from Python tina4_python.dev_admin
2118
2190
  // ---------------------------------------------------------------------------
2119
2191
 
2120
- function safeJoin(projectRoot: string, rel: string): string | null {
2121
- const resolved = resolve(projectRoot, rel);
2122
- if (!resolved.startsWith(projectRoot)) return null;
2123
- return resolved;
2192
+ /**
2193
+ * Real path of `target`: the deepest existing ancestor is realpath'd (symlinks
2194
+ * followed the way the OS will open the file) and the not-yet-existing tail is
2195
+ * appended (a new file being saved).
2196
+ */
2197
+ function realPathOf(target: string): string {
2198
+ let existing = target;
2199
+ const tail: string[] = [];
2200
+ for (;;) {
2201
+ let present = false;
2202
+ try { lstatSync(existing); present = true; } catch { /* missing */ }
2203
+ if (present || dirname(existing) === existing) break;
2204
+ tail.unshift(basename(existing));
2205
+ existing = dirname(existing);
2206
+ }
2207
+ return join(realpathSync(existing), ...tail);
2208
+ }
2209
+
2210
+ /**
2211
+ * Resolve `rel` inside an allowed root (ADR-0082): `..` collapsed, symlinks
2212
+ * followed, and a trailing-separator root check so `/app` never contains
2213
+ * `/app-sibling`. Returns null when the path leaves every root.
2214
+ */
2215
+ function safeJoin(projectRoot: string, rel: string, extraRoots: string[] = []): string | null {
2216
+ try {
2217
+ const root = realPathOf(resolve(projectRoot));
2218
+ const resolved = realPathOf(resolve(root, rel));
2219
+ const roots = [root, ...extraRoots.filter(Boolean).map((r) => realPathOf(resolve(r)))];
2220
+ for (const r of roots) {
2221
+ if (resolved === r || resolved.startsWith(r.endsWith(sep) ? r : r + sep)) return resolved;
2222
+ }
2223
+ } catch { /* Broken links or inaccessible ancestors fail closed. */ }
2224
+ return null;
2225
+ }
2226
+
2227
+ /** Project-relative form of a resolved path, for the secret denylist. */
2228
+ function resolvedRelative(projectRoot: string, resolvedPath: string): string {
2229
+ return relative(realPathOf(resolve(projectRoot)), resolvedPath);
2124
2230
  }
2125
2231
 
2126
2232
  const handleThoughts: RouteHandler = (req, res) => {
@@ -2343,22 +2449,43 @@ export function devAdminLanguage(rel: string): string {
2343
2449
  return DEV_ADMIN_LANG_MAP[ext] ?? "text";
2344
2450
  }
2345
2451
 
2452
+ /** Read the checked regular file through the same descriptor. Final-component
2453
+ * symlink replacement is refused. Parent-directory resolution is not an atomic
2454
+ * openat walk; the development project directory must remain locally trusted.
2455
+ */
2456
+ function readDevFile(target: string): Buffer | null {
2457
+ let fd: number | undefined;
2458
+ try {
2459
+ fd = openSync(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
2460
+ if (!fstatSync(fd).isFile()) return null;
2461
+ return readFileSync(fd);
2462
+ } catch (error) {
2463
+ if (["ENOENT", "ENOTDIR", "ELOOP"].includes((error as NodeJS.ErrnoException).code ?? "")) return null;
2464
+ throw error;
2465
+ } finally {
2466
+ if (fd !== undefined) closeSync(fd);
2467
+ }
2468
+ }
2469
+
2346
2470
  const handleFileRead: RouteHandler = (req, res) => {
2347
2471
  const url = new URL(req.url ?? "/", "http://localhost");
2348
2472
  const rel = url.searchParams.get("path") ?? "";
2349
- // DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/).
2350
- if (isSecretPath(rel)) {
2351
- res.json({ error: "Refused: secret file", path: rel, content: "", language: "text", bytes: 0 }, 403);
2352
- return;
2353
- }
2354
2473
  const root = resolve(process.cwd());
2355
2474
  const target = safeJoin(root, rel);
2356
- if (!target || !existsSync(target) || !statSync(target).isFile()) {
2357
- res.json({ error: `File not found: ${rel}` }, 404);
2475
+ if (!target) {
2476
+ res.json({ error: "Path outside project", path: rel, content: "", language: "text", bytes: 0 }, 403);
2477
+ return;
2478
+ }
2479
+ // DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/),
2480
+ // judged on the RESOLVED path so `.env/.` or a symlink cannot slip past (ADR-0082).
2481
+ if (isSecretPath(rel) || isSecretPath(resolvedRelative(root, target))) {
2482
+ res.json({ error: "Refused: secret file", path: rel, content: "", language: "text", bytes: 0 }, 403);
2358
2483
  return;
2359
2484
  }
2360
2485
  try {
2361
- const content = readFileSync(target, "utf-8");
2486
+ const data = readDevFile(target);
2487
+ if (data === null) { res.json({ error: `File not found: ${rel}` }, 404); return; }
2488
+ const content = data.toString("utf-8");
2362
2489
  const path = relative(root, target);
2363
2490
  res.json({ path, content, language: devAdminLanguage(path), bytes: Buffer.byteLength(content, "utf-8") });
2364
2491
  } catch (e) {
@@ -2373,7 +2500,7 @@ const handleFileSave: RouteHandler = async (req, res) => {
2373
2500
  const root = resolve(process.cwd());
2374
2501
  const target = safeJoin(root, rel);
2375
2502
  if (!target) {
2376
- res.json({ error: `Path escapes project directory: ${rel}` }, 400);
2503
+ res.json({ error: `Path escapes project directory: ${rel}` }, 403);
2377
2504
  return;
2378
2505
  }
2379
2506
  try {
@@ -2393,20 +2520,20 @@ const handleFileSave: RouteHandler = async (req, res) => {
2393
2520
  const handleFileRaw: RouteHandler = (req, res) => {
2394
2521
  const url = new URL(req.url ?? "/", "http://localhost");
2395
2522
  const rel = url.searchParams.get("path") ?? "";
2396
- // DEVADMIN-DEC-03: never serve secret material (.env, keys, .git/, secrets/).
2397
- if (isSecretPath(rel)) {
2398
- res.json({ error: "Refused: secret file" }, 403);
2399
- return;
2400
- }
2401
2523
  const root = resolve(process.cwd());
2402
2524
  const target = safeJoin(root, rel);
2403
- if (!target || !existsSync(target) || !statSync(target).isFile()) {
2404
- res.raw.writeHead(404);
2405
- res.raw.end("Not found");
2525
+ if (!target) {
2526
+ res.json({ error: "Path outside project" }, 403);
2527
+ return;
2528
+ }
2529
+ // DEVADMIN-DEC-03: never serve secret material, judged on the RESOLVED path.
2530
+ if (isSecretPath(rel) || isSecretPath(resolvedRelative(root, target))) {
2531
+ res.json({ error: "Refused: secret file" }, 403);
2406
2532
  return;
2407
2533
  }
2408
2534
  try {
2409
- const buf = readFileSync(target);
2535
+ const buf = readDevFile(target);
2536
+ if (buf === null) { res.raw.writeHead(404); res.raw.end("Not found"); return; }
2410
2537
  const ext = target.slice(target.lastIndexOf(".") + 1).toLowerCase();
2411
2538
  const mime: Record<string, string> = {
2412
2539
  js: "application/javascript", ts: "text/plain", json: "application/json",
@@ -2544,19 +2671,18 @@ function mcpSecureEqual(expected: string, provided: string): boolean {
2544
2671
  }
2545
2672
 
2546
2673
  /**
2547
- * Whether the request carried a token matching TINA4_MCP_TOKEN (fallback
2548
- * TINA4_API_KEY). Transports: Authorization Bearer / X-MCP-Token / X-Api-Key.
2674
+ * MCP transport retains the documented API-key fallback. Non-MCP dev routes
2675
+ * pass dedicated=true and require TINA4_MCP_TOKEN (ADR-0082).
2549
2676
  * No configured token ⇒ a remote caller can never present a valid one.
2550
2677
  */
2551
- function mcpTokenOk(req: Tina4Request): boolean {
2552
- let expected = process.env.TINA4_MCP_TOKEN;
2553
- if (!expected) expected = process.env.TINA4_API_KEY;
2678
+ function mcpTokenOk(req: Tina4Request, dedicated = false): boolean {
2679
+ const expected = process.env.TINA4_MCP_TOKEN || (!dedicated ? process.env.TINA4_API_KEY : undefined);
2554
2680
  if (!expected) return false;
2555
2681
  let provided = "";
2556
2682
  const auth = req.header("authorization") ?? "";
2557
2683
  if (auth.toLowerCase().startsWith("bearer ")) provided = auth.slice(7).trim();
2558
2684
  if (!provided) provided = req.header("x-mcp-token") ?? "";
2559
- if (!provided) provided = req.header("x-api-key") ?? "";
2685
+ if (!dedicated && !provided) provided = req.header("x-api-key") ?? "";
2560
2686
  if (!provided) return false;
2561
2687
  return mcpSecureEqual(expected, provided);
2562
2688
  }
@@ -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 DevMailbox — File-backed development mailbox, zero dependencies.
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
  * The dispatch pipeline: the concerns of `dispatch`, named and extracted.
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 Live API RAG — `Docs` module.
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
  * Live Docs MCP discovery — writes `.tina4/mcp.json` so MCP-aware AI tools
3
11
  * (Claude Code, Cursor, etc.) auto-discover this server's docs MCP endpoint.
@@ -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
  // ── TS regex parser ──────────────────────────────────────────────────
2
10
  import { stripStrings } from "./docsScanner.js";
3
11
  import { matchMethodSignature, matchTopLevelFunction } from "./docsSignatures.js";
@@ -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
  * Replace string literals and template literal contents with spaces of equal
3
11
  * length so brace/paren scanning isn't fooled by characters inside strings.
@@ -85,6 +93,34 @@ function stripTemplate(source: string, start: number): { text: string; next: num
85
93
  return { text: out.join(""), next: i };
86
94
  }
87
95
 
96
+ // A slash starts a regexp where an expression may begin. Division follows a
97
+ // value (identifier, number, closing bracket/paren) and must remain visible.
98
+ function startsRegex(source: string, start: number): boolean {
99
+ let i = start - 1;
100
+ while (i >= 0 && /\s/.test(source[i])) i--;
101
+ if (i < 0 || "=(:,[!&|?;{}".includes(source[i])) return true;
102
+ const end = i + 1;
103
+ while (i >= 0 && /[A-Za-z_$]/.test(source[i])) i--;
104
+ return ["return", "throw", "case", "yield", "await"].includes(source.slice(i + 1, end));
105
+ }
106
+
107
+ function stripRegex(source: string, start: number): { text: string; next: number } | null {
108
+ let inClass = false;
109
+ for (let i = start + 1; i < source.length; i++) {
110
+ const c = source[i];
111
+ if (c === "\n" || c === "\r") return null;
112
+ if (c === "\\") { i++; continue; }
113
+ if (c === "[") inClass = true;
114
+ else if (c === "]") inClass = false;
115
+ else if (c === "/" && !inClass) {
116
+ let next = i + 1;
117
+ while (next < source.length && /[a-z]/i.test(source[next])) next++;
118
+ return { text: blankSegment(source, start, next), next };
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
88
124
  export function stripStrings(source: string): string {
89
125
  const out: string[] = [];
90
126
  let i = 0;
@@ -102,6 +138,14 @@ export function stripStrings(source: string): string {
102
138
  i = part.next;
103
139
  continue;
104
140
  }
141
+ if (c === "/" && startsRegex(source, i)) {
142
+ const part = stripRegex(source, i);
143
+ if (part) {
144
+ out.push(part.text);
145
+ i = part.next;
146
+ continue;
147
+ }
148
+ }
105
149
  if (c === '"' || c === "'") {
106
150
  const part = stripQuoted(source, i);
107
151
  out.push(part.text);
@@ -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
  interface MethodMatch {
2
10
  name: string;
3
11
  signature: string;
@@ -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
  import { readFileSync, existsSync, statSync } from "node:fs";
2
10
  import { resolve, join, dirname } from "node:path";
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
  * Typed environment-variable helpers — zero-deps.
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 Debug — Rich error overlay for development mode.
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 Events — Simple observer pattern for decoupled communication.
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
  // Tina4 FakeData — Fake data generation and database seeding, zero dependencies.
2
10
  // Instance-based with optional seeded PRNG for deterministic output.
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
  * Customer feedback widget — Tier 4 port from tina4-python.
3
11
  *