tina4-nodejs 3.13.94 → 3.13.96

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 (123) hide show
  1. package/CLAUDE.md +158 -30
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/packages/cli/dist/bin.js +30911 -28444
  5. package/packages/cli/src/commands/metrics.ts +17 -11
  6. package/packages/cli/src/commands/serve.ts +10 -9
  7. package/packages/core/dist/index.js +30810 -28261
  8. package/packages/core/public/css/tina4.min.css +1 -1
  9. package/packages/core/src/ai.ts +7 -1
  10. package/packages/core/src/auth.ts +191 -39
  11. package/packages/core/src/background.ts +19 -19
  12. package/packages/core/src/cache.ts +492 -49
  13. package/packages/core/src/devAdmin.ts +79 -32
  14. package/packages/core/src/dispatchPipeline.ts +285 -0
  15. package/packages/core/src/dotenv.ts +185 -40
  16. package/packages/core/src/index.ts +6 -7
  17. package/packages/core/src/logger.ts +257 -36
  18. package/packages/core/src/mcp.ts +1 -1
  19. package/packages/core/src/messenger.ts +294 -106
  20. package/packages/core/src/metrics.ts +199 -961
  21. package/packages/core/src/middleware.ts +390 -123
  22. package/packages/core/src/queue.ts +188 -32
  23. package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
  24. package/packages/core/src/queueBackends/liteBackend.ts +13 -0
  25. package/packages/core/src/queueBackends/mongoBackend.ts +101 -9
  26. package/packages/core/src/queueBackends/rabbitmqBackend.ts +22 -4
  27. package/packages/core/src/rateLimiter.ts +10 -5
  28. package/packages/core/src/request.ts +34 -16
  29. package/packages/core/src/response.ts +46 -1
  30. package/packages/core/src/router.ts +29 -4
  31. package/packages/core/src/server.ts +886 -421
  32. package/packages/core/src/session.ts +244 -27
  33. package/packages/core/src/sessionHandlers/databaseHandler.ts +338 -48
  34. package/packages/core/src/sessionHandlers/memcachedHandler.ts +181 -0
  35. package/packages/core/src/sessionHandlers/mongoClient.ts +293 -208
  36. package/packages/core/src/sessionHandlers/mongoHandler.ts +88 -8
  37. package/packages/core/src/sessionHandlers/respClient.ts +16 -147
  38. package/packages/core/src/sessionHandlers/sqlClient.ts +290 -0
  39. package/packages/core/src/sessionHandlers/syncBridge.ts +190 -0
  40. package/packages/core/src/sessionHandlers/syncSocket.ts +236 -0
  41. package/packages/core/src/testClient.ts +18 -5
  42. package/packages/core/src/trustedProxy.ts +249 -0
  43. package/packages/core/src/types.ts +29 -5
  44. package/packages/core/src/websocket.ts +66 -0
  45. package/packages/orm/dist/index.js +22717 -20168
  46. package/packages/orm/src/adapters/firebird.ts +183 -56
  47. package/packages/orm/src/adapters/mongodb.ts +25 -4
  48. package/packages/orm/src/adapters/mssql.ts +114 -29
  49. package/packages/orm/src/adapters/mysql.ts +103 -40
  50. package/packages/orm/src/adapters/odbc.ts +44 -21
  51. package/packages/orm/src/adapters/postgres.ts +118 -26
  52. package/packages/orm/src/adapters/sqlDialect.ts +120 -0
  53. package/packages/orm/src/adapters/sqlite.ts +60 -24
  54. package/packages/orm/src/autoCrud.ts +12 -10
  55. package/packages/orm/src/baseModel.ts +135 -40
  56. package/packages/orm/src/cachedDatabase.ts +43 -19
  57. package/packages/orm/src/connectTimeout.ts +265 -0
  58. package/packages/orm/src/database.ts +241 -197
  59. package/packages/orm/src/databaseResult.ts +51 -28
  60. package/packages/orm/src/databaseUrl.ts +484 -0
  61. package/packages/orm/src/docstore.ts +386 -145
  62. package/packages/orm/src/index.ts +13 -6
  63. package/packages/orm/src/migration.ts +44 -11
  64. package/packages/orm/src/model.ts +4 -0
  65. package/packages/orm/src/queryBuilder.ts +47 -6
  66. package/packages/orm/src/sqlTranslator.ts +310 -4
  67. package/packages/orm/src/types.ts +21 -77
  68. package/packages/swagger/dist/index.js +78 -20
  69. package/packages/swagger/src/generator.ts +172 -29
  70. package/types/core/src/ai.d.ts +1 -1
  71. package/types/core/src/auth.d.ts +28 -5
  72. package/types/core/src/background.d.ts +3 -3
  73. package/types/core/src/cache.d.ts +15 -12
  74. package/types/core/src/dispatchPipeline.d.ts +117 -0
  75. package/types/core/src/dotenv.d.ts +38 -16
  76. package/types/core/src/index.d.ts +6 -9
  77. package/types/core/src/logger.d.ts +93 -16
  78. package/types/core/src/messenger.d.ts +47 -6
  79. package/types/core/src/metrics.d.ts +25 -61
  80. package/types/core/src/middleware.d.ts +134 -11
  81. package/types/core/src/queue.d.ts +54 -5
  82. package/types/core/src/queueBackends/kafkaBackend.d.ts +1 -1
  83. package/types/core/src/queueBackends/liteBackend.d.ts +9 -0
  84. package/types/core/src/queueBackends/mongoBackend.d.ts +24 -2
  85. package/types/core/src/queueBackends/rabbitmqBackend.d.ts +3 -3
  86. package/types/core/src/router.d.ts +14 -3
  87. package/types/core/src/server.d.ts +15 -4
  88. package/types/core/src/session.d.ts +87 -2
  89. package/types/core/src/sessionHandlers/databaseHandler.d.ts +60 -5
  90. package/types/core/src/sessionHandlers/memcachedHandler.d.ts +60 -0
  91. package/types/core/src/sessionHandlers/mongoClient.d.ts +16 -5
  92. package/types/core/src/sessionHandlers/mongoHandler.d.ts +51 -3
  93. package/types/core/src/sessionHandlers/respClient.d.ts +2 -2
  94. package/types/core/src/sessionHandlers/sqlClient.d.ts +39 -0
  95. package/types/core/src/sessionHandlers/syncBridge.d.ts +91 -0
  96. package/types/core/src/sessionHandlers/syncSocket.d.ts +49 -0
  97. package/types/core/src/trustedProxy.d.ts +44 -0
  98. package/types/core/src/types.d.ts +28 -5
  99. package/types/core/src/websocket.d.ts +26 -0
  100. package/types/orm/src/adapters/firebird.d.ts +55 -10
  101. package/types/orm/src/adapters/mongodb.d.ts +2 -2
  102. package/types/orm/src/adapters/mssql.d.ts +18 -11
  103. package/types/orm/src/adapters/mysql.d.ts +11 -10
  104. package/types/orm/src/adapters/odbc.d.ts +9 -12
  105. package/types/orm/src/adapters/postgres.d.ts +11 -10
  106. package/types/orm/src/adapters/sqlDialect.d.ts +71 -0
  107. package/types/orm/src/adapters/sqlite.d.ts +15 -3
  108. package/types/orm/src/baseModel.d.ts +45 -9
  109. package/types/orm/src/cachedDatabase.d.ts +18 -5
  110. package/types/orm/src/connectTimeout.d.ts +100 -0
  111. package/types/orm/src/database.d.ts +78 -28
  112. package/types/orm/src/databaseResult.d.ts +29 -15
  113. package/types/orm/src/databaseUrl.d.ts +125 -0
  114. package/types/orm/src/docstore.d.ts +102 -43
  115. package/types/orm/src/index.d.ts +6 -4
  116. package/types/orm/src/migration.d.ts +4 -3
  117. package/types/orm/src/queryBuilder.d.ts +23 -3
  118. package/types/orm/src/sqlTranslator.d.ts +126 -2
  119. package/types/orm/src/types.d.ts +21 -38
  120. package/packages/core/src/scss.ts +0 -623
  121. package/packages/core/src/sessionHandlers/redisHandler.ts +0 -219
  122. package/types/core/src/scss.d.ts +0 -19
  123. package/types/core/src/sessionHandlers/redisHandler.d.ts +0 -60
@@ -18,6 +18,18 @@ export declare class MiddlewareChain {
18
18
  */
19
19
  run(req: Tina4Request, res: Tina4Response): Promise<boolean>;
20
20
  }
21
+ /**
22
+ * True when a middleware spec is a CLASS (the beforeX/afterX convention)
23
+ * rather than a plain `(req, res, next)` middleware function.
24
+ *
25
+ * A class's `prototype` property is non-writable by the language spec
26
+ * (ClassDefinitionEvaluation); an ordinary function's is writable, and an
27
+ * arrow function, async function or bound function has no `prototype` at all.
28
+ * That is a language-level distinction rather than a name or source-string
29
+ * sniff, so a class named `cors` and a function named `Cors` both classify
30
+ * correctly.
31
+ */
32
+ export declare function isMiddlewareClass(spec: unknown): boolean;
21
33
  export declare class MiddlewareRunner {
22
34
  /** Globally registered middleware classes (parity with PHP/Ruby/Python orchestrators). */
23
35
  private static globalMiddleware;
@@ -28,18 +40,58 @@ export declare class MiddlewareRunner {
28
40
  */
29
41
  static use(cls: any): void;
30
42
  /** Return the list of globally registered middleware classes. */
43
+ /**
44
+ * Global middleware that runs BEFORE route matching.
45
+ *
46
+ * A middleware opts in with `static preMatch = true`. Everything else stays
47
+ * where it has always run - after matching - so this is additive and no
48
+ * existing middleware changes behaviour.
49
+ *
50
+ * The two groups need opposite things. CORS must run before matching so its
51
+ * headers survive a short-circuited 401/403; a browser shown a 401 without
52
+ * them reports a CORS error and the real status never reaches the developer.
53
+ * CSRF must run AFTER, because it reads the matched route's metadata to
54
+ * honour a route marked noAuth - PHP shipped exactly that bypass as dead
55
+ * code once, because the metadata was not assigned yet.
56
+ *
57
+ * NOT named `beforeMatch` - hook discovery treats every `before*` static as
58
+ * a middleware hook and would call the flag itself with (req, res).
59
+ */
60
+ static partitionByMatchPhase(all: any[]): {
61
+ pre: any[];
62
+ post: any[];
63
+ };
31
64
  static getGlobal(): any[];
32
65
  /** Clear all globally registered middleware (primarily for tests). */
33
66
  static reset(): void;
34
67
  /**
35
- * Discover the before-prefixed / after-prefixed method names on a
36
- * middleware class in DEFINITION order (M1).
37
- * `Object.getOwnPropertyNames` returns a class's own
38
- * static method names in source-declaration order we deliberately do NOT
39
- * sort() them, so within a class the hooks run in the order they were
40
- * written (parity with Python walking __dict__, PHP get_class_methods, Ruby
68
+ * Discover the before-prefixed / after-prefixed hook names on a middleware
69
+ * class, INHERITED HOOKS INCLUDED, base class first (M1).
70
+ *
71
+ * `Object.getOwnPropertyNames` returns a class's OWN statics only, in
72
+ * source-declaration order. On its own that silently DROPPED every hook a
73
+ * subclass inherited: for `class Sub extends Base` with `static beforeBase`
74
+ * on the base, discovery returned only ["beforeSub"] even though
75
+ * `Sub.beforeBase` is a live function — so a shared base middleware simply
76
+ * never ran, with no error. Python returns ['before_base','before_sub'] and
77
+ * Ruby [:before_base,:before_sub]; Node was the only one of the four that
78
+ * lost hooks.
79
+ *
80
+ * So walk the prototype chain (the STATIC side: Sub -> Base -> ...) and emit
81
+ * base-class hooks BEFORE the subclass's own, de-duping an override to its
82
+ * first (base) position. That is exactly Python's `_discover_methods`
83
+ * walking `reversed(__mro__)` over each `__dict__`, and Ruby's
84
+ * `discover_methods` walking `ancestors.reverse_each`.
85
+ *
86
+ * Within one class the order is still source-declaration order — we
87
+ * deliberately do NOT sort(), so hooks run in the order they were written
88
+ * (parity with Python walking __dict__, PHP get_class_methods, Ruby
41
89
  * instance_methods(false)). Cross-class order is the natural iteration of
42
90
  * the registered classes = REGISTRATION order.
91
+ *
92
+ * The chain walk stops at Function.prototype / Object.prototype, so the
93
+ * built-in members are never scanned. A plain object registered as
94
+ * middleware still works: its own keys are level 0.
43
95
  */
44
96
  private static methodNames;
45
97
  /**
@@ -63,6 +115,10 @@ export declare class MiddlewareRunner {
63
115
  * a synchronous hook that returns an array is harmless (the array resolves
64
116
  * immediately), so existing sync hooks keep working unchanged.
65
117
  *
118
+ * RETURN VALUE — see `interpretHookResult` for the one table every hook at
119
+ * every scope obeys. A returned Response object is the PRIMARY
120
+ * short-circuit; `false` is a deny.
121
+ *
66
122
  * Returns [req, res, shouldContinue].
67
123
  */
68
124
  static runBefore(classes: any[], req: Tina4Request, res: Tina4Response): Promise<[Tina4Request, Tina4Response, boolean]>;
@@ -84,12 +140,18 @@ export declare class MiddlewareRunner {
84
140
  * ASYNC — each hook is awaited (e.g. the responseCache after-hook awaiting
85
141
  * `backend.set`). Awaiting a synchronous hook is harmless, so existing sync
86
142
  * after-hooks keep working unchanged.
143
+ *
144
+ * RETURN VALUE — the SAME table as runBefore (`interpretHookResult`): the
145
+ * contract is one table for every hook at every scope. There is nothing left
146
+ * to skip after the handler, so a short-circuit here ends the after chain.
147
+ * A THROW is different and unchanged: it is logged, becomes a clean 500, and
148
+ * the remaining after hooks still run.
87
149
  */
88
150
  static runAfter(classes: any[], req: Tina4Request, res: Tina4Response): Promise<[Tina4Request, Tina4Response]>;
89
151
  }
90
152
  /** Configuration for the CORS middleware */
91
153
  export interface CorsConfig {
92
- /** Allowed origins. Default: "*" (or TINA4_CORS_ORIGINS env, comma-separated) */
154
+ /** Allowed origins. Default: NONE (deny) — or TINA4_CORS_ORIGINS env, comma-separated. "*" allows any. */
93
155
  origins?: string | string[];
94
156
  /** Allowed methods. Default: standard REST methods (or TINA4_CORS_METHODS env) */
95
157
  methods?: string | string[];
@@ -97,22 +159,79 @@ export interface CorsConfig {
97
159
  headers?: string | string[];
98
160
  /** Access-Control-Max-Age in seconds. Default: 86400 (or TINA4_CORS_MAX_AGE env) */
99
161
  maxAge?: number;
162
+ /** Send Access-Control-Allow-Credentials. Default: false (or TINA4_CORS_CREDENTIALS env). Never sent with a wildcard origin. */
163
+ credentials?: boolean;
164
+ }
165
+ /** Reset the CORS warn-once ledger. Test seam. */
166
+ export declare function resetCorsWarnings(): void;
167
+ /**
168
+ * The resolved CORS policy — ONE implementation of the rules.
169
+ *
170
+ * Both the function middleware `cors()` and the class middleware
171
+ * `CorsMiddleware` build one of these and apply what it returns. They used to
172
+ * be two independent implementations that had already drifted: `cors()` never
173
+ * read TINA4_CORS_CREDENTIALS at all, so the DEFAULT always-on pipeline
174
+ * silently ignored a documented env var (measured 2026-07-31). One feature,
175
+ * one code path.
176
+ *
177
+ * DENY BY DEFAULT (ADR-0018). With no origins configured, NO
178
+ * Access-Control-Allow-Origin is emitted and the browser's own CORS check
179
+ * blocks the cross-origin request. "*" still works, it just has to be asked for.
180
+ *
181
+ * CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's
182
+ * CORS check treats "*" as a literal (not a wildcard) once the request's
183
+ * credentials mode is "include", so ACAO: * with
184
+ * Access-Control-Allow-Credentials: true is rejected by every browser.
185
+ *
186
+ * VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin,
187
+ * i.e. whenever an allow-list is configured — on a MISS as well as a match.
188
+ * RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST
189
+ * NOT use this response to satisfy a later request unless the later request
190
+ * has the same values for the listed header fields as the original request".
191
+ * The miss case matters most: without it a shared cache can store the no-ACAO
192
+ * response for origin B and serve it to origin A. A constant "*" genuinely
193
+ * does not vary and gets no Vary, which would only fragment a CDN's cache.
194
+ *
195
+ * Access-Control-Allow-Methods / -Allow-Headers are static configured lists
196
+ * here, never derived from the request's Access-Control-Request-* headers, so
197
+ * those field names do NOT belong in Vary.
198
+ */
199
+ export declare class CorsPolicy {
200
+ readonly allowedOrigins: string[];
201
+ readonly allowedMethods: string;
202
+ readonly allowedHeaders: string;
203
+ readonly maxAge: number;
204
+ readonly credentials: boolean;
205
+ constructor(config?: CorsConfig);
206
+ /** Whether an operator has actually declared a CORS policy. */
207
+ isConfigured(): boolean;
208
+ /** The origin to send in Access-Control-Allow-Origin, or undefined for none. */
209
+ resolveOrigin(requestOrigin: string): string | undefined;
210
+ /**
211
+ * The CORS headers for a request origin. `isPreflight` adds Max-Age, which
212
+ * the Fetch Standard only defines for a preflight response.
213
+ */
214
+ headersFor(requestOrigin: string, isPreflight: boolean): Record<string, string>;
100
215
  }
101
216
  /**
102
217
  * Built-in CORS middleware (function form).
103
- * Reads configuration from env vars if not provided:
218
+ *
219
+ * A thin adapter over CorsPolicy — see that class for the rules and the
220
+ * standards behind them. Reads configuration from env vars when not provided:
104
221
  * TINA4_CORS_ORIGINS — comma-separated list of allowed origins, or "*"
105
222
  * TINA4_CORS_METHODS — comma-separated list of allowed methods
106
223
  * TINA4_CORS_HEADERS — comma-separated list of allowed headers
107
224
  * TINA4_CORS_MAX_AGE — preflight cache duration in seconds
225
+ * TINA4_CORS_CREDENTIALS — send Access-Control-Allow-Credentials
108
226
  *
109
- * Preflight (OPTIONS) returns 204 with appropriate headers.
110
- * Supports wildcard ("*") and specific origin matching.
227
+ * A real preflight is answered 204. The status is the same whether the origin
228
+ * was allowed or denied the browser does the blocking.
111
229
  */
112
230
  export declare function cors(config?: CorsConfig): Middleware;
113
231
  /**
114
232
  * Class-based CORS middleware using the before/after convention.
115
- * Wraps the same CORS logic as the `cors()` function middleware.
233
+ *
234
+ * The same CorsPolicy as `cors()` — one implementation, one set of semantics.
116
235
  *
117
236
  * Usage:
118
237
  * Router.use(CorsMiddleware);
@@ -121,6 +240,10 @@ export declare class CorsMiddleware {
121
240
  static beforeCors(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response];
122
241
  /**
123
242
  * Check if a request is an OPTIONS preflight.
243
+ *
244
+ * NOTE: returns true for ANY OPTIONS, with no Origin check, so the name
245
+ * overstates what it tests. The real short-circuit uses isCorsPreflight().
246
+ * Kept because existing tests pin this meaning.
124
247
  */
125
248
  static isPreflight(method: string): boolean;
126
249
  }
@@ -19,12 +19,23 @@ export interface QueueConfig {
19
19
  * re-enqueuing, or dead-lettering past maxRetries (at-least-once delivery).
20
20
  * Falls back to TINA4_QUEUE_VISIBILITY_TIMEOUT, else 300 (5 min). <= 0
21
21
  * disables the reclaim (a reservation then lasts until the consumer acks —
22
- * the old at-most-once behaviour). File + MongoDB backends only;
23
- * RabbitMQ/Kafka delegate visibility to the broker. Parity with Python's
22
+ * the old at-most-once behaviour). File + MongoDB backends, which are the
23
+ * only backends Node offers (ADR-0022). Parity with Python's
24
24
  * visibility_timeout.
25
25
  */
26
26
  visibilityTimeout?: number;
27
27
  }
28
+ /**
29
+ * Where the file-backed queue stores its jobs — `TINA4_QUEUE_PATH`, else
30
+ * `data/queue` (relative to the working directory).
31
+ *
32
+ * Exported because it is the ONE answer to "where do the queue files live",
33
+ * and anything that reads the store directly (the dev-admin queue panel) must
34
+ * ask here rather than re-deriving it. The dev admin hardcoded
35
+ * `cwd/data/queue/<topic>` and so listed a DIFFERENT directory from the one
36
+ * `Queue.size()` counted the moment `TINA4_QUEUE_PATH` was set.
37
+ */
38
+ export declare function queueBasePath(): string;
28
39
  export interface ProcessOptions {
29
40
  pollInterval?: number;
30
41
  maxJobs?: number;
@@ -46,10 +57,21 @@ export interface ConsumeOptions {
46
57
  id?: string;
47
58
  }
48
59
  export interface QueueBackendInterface {
49
- push(queue: string, payload: unknown, delay?: number): string;
60
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
50
61
  pop(queue: string): QueueJob | null;
51
62
  size(queue: string): number;
52
63
  clear(queue: string): void;
64
+ /**
65
+ * Release whatever connection the backend holds, and be safe to call twice.
66
+ *
67
+ * REQUIRED, not optional, and deliberately so: it mirrors PHP's
68
+ * Tina4\Queue\QueueBackend, where close() has always been part of the
69
+ * interface. Optional would reintroduce exactly the bug this closes — a
70
+ * caller feature-detecting `backend.close?.()` silently skips the backend
71
+ * that forgot to implement it, which is how tina4-ruby's lite backend went
72
+ * un-closed by every `respond_to?(:close)` guard in its tree.
73
+ */
74
+ close(): void;
53
75
  complete?(queue: string, id: string): void;
54
76
  fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
55
77
  retry?(queue: string, id: string, delaySeconds?: number): void;
@@ -71,9 +93,11 @@ export declare class Queue {
71
93
  * Unified Queue constructor.
72
94
  *
73
95
  * Accepts either:
74
- * - new Queue({ topic: "tasks", backend: "rabbitmq" })
75
- * - new Queue("rabbitmq", { path: "data/queue" }) // legacy
96
+ * - new Queue({ topic: "tasks", backend: "mongodb" })
97
+ * - new Queue("mongodb", { path: "data/queue" }) // legacy
76
98
  * - new Queue() // file backend, default topic
99
+ *
100
+ * Throws on backend "rabbitmq" or "kafka" (ADR-0022).
77
101
  */
78
102
  constructor(backendOrConfig?: string | QueueConfig, config?: QueueConfig);
79
103
  /**
@@ -120,6 +144,31 @@ export declare class Queue {
120
144
  * Remove all jobs from this queue's topic. Returns the number cleared.
121
145
  */
122
146
  clear(): number;
147
+ /**
148
+ * Release the backend's connection and free its resources.
149
+ *
150
+ * MEASURED 2026-08-04: close() was absent on the top-level Queue in ALL FOUR
151
+ * frameworks, and in Node it was absent on every backend class too — so an
152
+ * application had no way at all to hand a queue's client back. Same class of
153
+ * leak as ADR-0025 corollary 4 (client-lifecycle-is-bounded).
154
+ *
155
+ * Safe on EVERY backend: the file backend holds no connection and closes as a
156
+ * documented no-op, so a TINA4_QUEUE_BACKEND change never turns a working
157
+ * shutdown path into an error. Idempotent — each backend drops its handles on
158
+ * the first call, so a second call finds nothing to close and returns.
159
+ *
160
+ * HONEST CAVEAT specific to Node: neither backend it can reach holds a
161
+ * connection between calls today. The Mongo backend runs each operation in
162
+ * its own child process (ADR-0022), which closes its own client before it
163
+ * exits, and rabbitmq/kafka are refused outright at construction. So this
164
+ * releases nothing YET — it is here for the contract, and because the day the
165
+ * persistent-connection rewrite lands the client is released here with no
166
+ * change at any call site. Python, PHP and Ruby release a REAL client through
167
+ * the identically-named method.
168
+ *
169
+ * Treat the queue as spent afterwards and build a new one to keep working.
170
+ */
171
+ close(): void;
123
172
  /**
124
173
  * Get jobs that failed at least once but are still being retried
125
174
  * (0 < attempts < maxRetries). These live in the pending queue under the
@@ -44,7 +44,7 @@ export interface KafkaClientConfig extends KafkaSecurityConfig {
44
44
  */
45
45
  export declare function kafkaSecurityConfig(env?: NodeJS.ProcessEnv): KafkaSecurityConfig;
46
46
  export interface QueueBackend {
47
- push(queue: string, payload: unknown, delay?: number): string;
47
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
48
48
  pop(queue: string): QueueJob | null;
49
49
  size(queue: string): number;
50
50
  clear(queue: string): void;
@@ -20,6 +20,15 @@ export declare class LiteBackend {
20
20
  private nowIso;
21
21
  private futureIso;
22
22
  private nextPrefix;
23
+ /**
24
+ * No-op: the file backend holds no connection to release.
25
+ *
26
+ * It exists so `Queue.close()` can call ONE method on every backend instead
27
+ * of testing for it, and so switching TINA4_QUEUE_BACKEND to "file" never
28
+ * turns a working close() into "backend.close is not a function". Idempotent
29
+ * by construction — there is nothing to drop.
30
+ */
31
+ close(): void;
23
32
  push(queue: string, payload: unknown, delay?: number, priority?: number): string;
24
33
  /**
25
34
  * Return [filename, jobData] for every pending, non-delayed job, ordered by
@@ -18,10 +18,12 @@ export interface MongoConfig {
18
18
  maxRetries?: number;
19
19
  }
20
20
  export interface QueueBackend {
21
- push(queue: string, payload: unknown, delay?: number): string;
21
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
22
22
  pop(queue: string): QueueJob | null;
23
23
  size(queue: string): number;
24
24
  clear(queue: string): void;
25
+ /** Release whatever connection the backend holds. Must be idempotent. */
26
+ close(): void;
25
27
  }
26
28
  /**
27
29
  * MongoDB queue backend using the `mongodb` npm package.
@@ -69,7 +71,8 @@ export declare class MongoBackend implements QueueBackend {
69
71
  * Execute a MongoDB operation synchronously via a child process.
70
72
  */
71
73
  private execSync;
72
- push(queue: string, payload: unknown, delay?: number): string;
74
+ popById(queue: string, id: string): QueueJob | null;
75
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
73
76
  pop(queue: string): QueueJob | null;
74
77
  size(queue: string): number;
75
78
  clear(queue: string): void;
@@ -94,4 +97,23 @@ export declare class MongoBackend implements QueueBackend {
94
97
  retryFailed(queue: string, maxRetries?: number): number;
95
98
  /** Remove jobs by status (default: every doc for the topic). Returns count removed. */
96
99
  purge(queue: string, status?: string): number;
100
+ /**
101
+ * Release the MongoDB connection. Idempotent — a second call is a no-op.
102
+ *
103
+ * HONEST CAVEAT, and it is the whole reason ADR-0022 exists: THIS backend
104
+ * holds no connection between calls to release. Every operation runs in its
105
+ * own child process (see execSync/buildScript), and that child's `finally`
106
+ * already does `await client.close()` before it exits — so the pool it opened
107
+ * is gone by the time the method returns. Unlike tina4-python, tina4-php and
108
+ * tina4-ruby, whose Mongo/broker backends hold a long-lived client that this
109
+ * method genuinely hands back, Node has nothing to give back.
110
+ *
111
+ * It is implemented anyway, and required by the QueueBackend interface,
112
+ * because the CONTRACT is what matters: `Queue.close()` must be callable on
113
+ * every backend in every framework, and the day the persistent-connection
114
+ * rewrite lands (ADR-0022's tracked fix) the client goes here with no change
115
+ * at any call site. A method that is a no-op today and correct forever beats
116
+ * a missing method the caller has to feature-detect.
117
+ */
118
+ close(): void;
97
119
  }
@@ -16,12 +16,12 @@ export interface RabbitMQConfig {
16
16
  * Parse an AMQP URL (amqp://[user:pass@]host[:port][/vhost]) into a partial
17
17
  * RabbitMQConfig. Mirrors the Python/PHP/Ruby `parse_amqp_url` semantics:
18
18
  * strips a leading amqp:// or amqps:// scheme, splits optional credentials,
19
- * and prepends a leading "/" to the vhost when missing. Only fields present
20
- * in the URL are populated.
19
+ * and reads the path segment as the URL-decoded vhost name. Only fields
20
+ * present in the URL are populated.
21
21
  */
22
22
  export declare function parseAmqpUrl(url: string): RabbitMQConfig;
23
23
  export interface QueueBackend {
24
- push(queue: string, payload: unknown, delay?: number): string;
24
+ push(queue: string, payload: unknown, delay?: number, priority?: number): string;
25
25
  pop(queue: string): QueueJob | null;
26
26
  size(queue: string): number;
27
27
  clear(queue: string): void;
@@ -317,10 +317,21 @@ export declare class RouteGroup {
317
317
  */
318
318
  export declare function resolveStringMiddleware(spec: string): Promise<Middleware>;
319
319
  /**
320
- * Run per-route middleware chain, then call the handler.
320
+ * Run the per-route middleware chain. Returns false when it short-circuited
321
+ * and the handler must be skipped.
321
322
  *
322
- * Accepts middleware functions and/or string specs (e.g. "ResponseCache:300").
323
- * Each spec is resolved to a middleware function just before it runs.
323
+ * Accepts middleware functions, middleware CLASSES, and string specs
324
+ * (e.g. "ResponseCache:300"). A function or string spec is resolved and
325
+ * invoked as `mw(req, res, next)` exactly as before.
326
+ *
327
+ * A CLASS runs its beforeX hooks through the SAME `MiddlewareRunner.runBefore`
328
+ * and the SAME return-value table as global middleware — no parallel runner.
329
+ * Its afterX hooks run with the global after pass once the handler is done
330
+ * (server.ts / testClient.ts append the route's classes to that list), because
331
+ * "after" means after the handler, not after this function. Every spec used to
332
+ * be invoked as `mw(req, res, next)`, which for a class throws "Class
333
+ * constructor cannot be invoked without 'new'", so a class attached per-route
334
+ * was inert. Python and PHP both ran per-route class hooks already.
324
335
  */
325
336
  export declare function runRouteMiddlewares(middlewares: MiddlewareSpec[], req: Tina4Request, res: Tina4Response): Promise<boolean>;
326
337
  /**
@@ -1,6 +1,21 @@
1
1
  import { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Tina4Config } from "./types.js";
3
3
  import { Router } from "./router.js";
4
+ /** How long a graceful shutdown waits for in-flight requests, in seconds. */
5
+ export declare const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 30;
6
+ /**
7
+ * Resolve the shutdown budget from `TINA4_SHUTDOWN_TIMEOUT` (seconds).
8
+ *
9
+ * 30s matches Kubernetes' default `terminationGracePeriodSeconds` and
10
+ * Gunicorn's `graceful_timeout`, so the drain finishes just BEFORE the
11
+ * orchestrator's SIGKILL rather than being truncated by it. Same env var and
12
+ * same default as tina4-ruby's `Tina4::Shutdown`.
13
+ *
14
+ * A non-numeric or negative value falls back to the default rather than
15
+ * silently disabling the drain - a typo must not turn shutdown into a
16
+ * zero-second force-kill.
17
+ */
18
+ export declare function shutdownTimeoutSeconds(): number;
4
19
  /**
5
20
  * Build the startup banner's optional surface lines (issue #99).
6
21
  *
@@ -105,10 +120,6 @@ export declare function resetTemplateCache(): void;
105
120
  * The whole feature can be turned off with `TINA4_TEMPLATE_ROUTING=off`.
106
121
  */
107
122
  export declare function resolveTemplate(pathname: string, templatesDir: string): string | null;
108
- /**
109
- * Start the Tina4 HTTP server.
110
- * Thin wrapper around startServer() for cross-framework parity with PHP and Ruby.
111
- */
112
123
  export declare function start(config?: Tina4Config): Promise<{
113
124
  close: () => void;
114
125
  router: Router;
@@ -1,5 +1,5 @@
1
1
  export interface SessionConfig {
2
- /** Session backend type: "file", "redis", "valkey", "mongo", "database" (or "db") */
2
+ /** Session backend type: "file", "redis", "valkey", "mongo", "memcached", "database" (or "db") */
3
3
  backend?: string;
4
4
  /** File storage path (default: "data/sessions") */
5
5
  path?: string;
@@ -21,6 +21,21 @@ interface SessionData {
21
21
  _accessed: number;
22
22
  [key: string]: unknown;
23
23
  }
24
+ /**
25
+ * Is `sessionId` a well-formed opaque session identifier?
26
+ *
27
+ * Callers pass UNTRUSTED input here (the session cookie is attacker-chosen), so
28
+ * anything that is not a string of the opaque alphabet is rejected.
29
+ */
30
+ export declare function isValidSessionId(sessionId: unknown): boolean;
31
+ /**
32
+ * Every accepted backend name, aliases included. Byte-identical membership in
33
+ * all four frameworks. Written once here so the switch below and the error
34
+ * message cannot disagree.
35
+ */
36
+ export declare const VALID_SESSION_BACKENDS: readonly ["file", "filesystem", "redis", "valkey", "mongodb", "mongo", "memcached", "memcache", "database", "db"];
37
+ /** Canonical name of each backend, for the error message (aliases omitted). */
38
+ export declare const CANONICAL_SESSION_BACKENDS: readonly ["file", "redis", "valkey", "mongodb", "memcached", "database"];
24
39
  /**
25
40
  * Base interface for session storage backends.
26
41
  * Implementations must provide read, write, and destroy.
@@ -36,6 +51,29 @@ export declare class FileSessionHandler implements SessionHandler {
36
51
  private storagePath;
37
52
  constructor(storagePath?: string);
38
53
  private ensureDir;
54
+ /**
55
+ * Derive the file backing a session id. TWO independent guards, both required.
56
+ *
57
+ * This is the one place a session id becomes a filesystem path, and it used to
58
+ * interpolate the id RAW: `join(storagePath, "../../OUTSIDE/appconfig.json")`
59
+ * left the session directory entirely, so a cookie could read an existing
60
+ * .json from anywhere on disk into `session.all()` and then OVERWRITE it on
61
+ * save. Reproduced on Node 24.9.0 / macOS.
62
+ *
63
+ * 1. VALIDATE — a malformed id is refused outright. It throws rather than
64
+ * returning null so a hostile id can never be mistaken for an ordinary
65
+ * cache miss (the Session layer catches it, logs it, and degrades).
66
+ * 2. HASH — the filename is a SHA-256 of the id, matching the Python master
67
+ * (`hashlib.sha256(session_id.encode()).hexdigest()`). A hex digest cannot
68
+ * contain a separator or a dot, so even an id that somehow passed the
69
+ * validator can only ever name a file inside `storagePath`.
70
+ *
71
+ * DEPLOY NOTE: hashing CHANGES the filename for every id, so existing on-disk
72
+ * sessions are orphaned and every logged-in user is logged out ONCE on the
73
+ * deploy that ships this. That is accepted: under strict session mode an old
74
+ * cookie is discarded on a read miss anyway, so the sessions were going to be
75
+ * dropped regardless.
76
+ */
39
77
  private filePath;
40
78
  read(sessionId: string): SessionData | null;
41
79
  write(sessionId: string, data: SessionData, ttl?: number): void;
@@ -97,13 +135,28 @@ export declare class Session {
97
135
  * frameworks. Strict mode is the escape hatch (same as events/seeding).
98
136
  */
99
137
  private strict;
138
+ /**
139
+ * True when the LAST backend read RAISED rather than returning a miss.
140
+ * Lets `start()` tell "no such session" from "the store is unreachable".
141
+ */
142
+ private lastReadFailed;
100
143
  constructor(backend?: string, config?: SessionConfig);
101
144
  /**
102
145
  * Use a custom session handler (for advanced use cases).
103
146
  */
104
147
  setHandler(handler: SessionHandler): void;
105
148
  private logBackendError;
106
- /** Read through the backend; on FAILURE log + degrade to empty (or re-throw under strict). */
149
+ /**
150
+ * Read through the backend; on FAILURE log + degrade to empty (or re-throw
151
+ * under strict).
152
+ *
153
+ * Sets {@link lastReadFailed} so `start()` can tell "the store answered, and
154
+ * has no such session" from "the store did not answer at all". Strict mode
155
+ * must discard an id only on the first: treating an outage as an unknown id
156
+ * rotates the session id on EVERY request for the whole outage, logging the
157
+ * entire userbase out over one Redis blip and orphaning their stored
158
+ * sessions. The policy is log-loud + degrade, never rotate.
159
+ */
107
160
  private safeRead;
108
161
  /** Write through the backend; on FAILURE log + return false (or re-throw under strict). */
109
162
  private safeWrite;
@@ -111,6 +164,26 @@ export declare class Session {
111
164
  private safeDestroy;
112
165
  /**
113
166
  * Start or resume a session.
167
+ *
168
+ * `sessionId` is UNTRUSTED — it arrives from the session cookie, which the
169
+ * client fully controls. An id that is not a well-formed opaque identifier is
170
+ * DISCARDED and a fresh one minted, never adopted: adopting it let a cookie
171
+ * steer a filesystem path (a `tina4_session=../../OUTSIDE/appconfig` cookie
172
+ * read an existing .json from outside the session directory into
173
+ * `session.all()`, then OVERWROTE it on save) and let an attacker pre-plant a
174
+ * session id that survived the victim's login (session fixation).
175
+ *
176
+ * The check runs BEFORE the read, so a hostile id never reaches a handler at
177
+ * all. A legitimate id from any of the four frameworks passes unchanged.
178
+ *
179
+ * STRICT SESSION MODE (deliberate, and Node is the family reference for it):
180
+ * an id that is WELL-FORMED but UNKNOWN to the backend is also discarded and
181
+ * a fresh one minted — the `if (loaded)` below only adopts an id the store
182
+ * actually knows. That is OWASP's strict mode and PHP's own
183
+ * `session.use_strict_mode=1` default, and it is what stops an attacker
184
+ * planting a session id that survives the victim's login. The validation
185
+ * above sits IN FRONT of it; neither replaces the other.
186
+ *
114
187
  * @param sessionId - Existing session ID to resume (optional)
115
188
  * @returns The session ID
116
189
  */
@@ -232,6 +305,18 @@ export declare function isSecureScheme(forwardedProto?: string, socketEncrypted?
232
305
  * `session.session_cookie_name()`.
233
306
  */
234
307
  export declare function sessionCookieName(): string;
308
+ /**
309
+ * Is TINA4_SESSION_STRICT on? The single source of truth for the flag.
310
+ *
311
+ * Module level, not a Session field, because the REQUEST PATH has to be able to
312
+ * consult it when a Session could not be constructed at all - a handler whose
313
+ * constructor raises, or a refused TINA4_SESSION_BACKEND, both fail BEFORE
314
+ * there is an object to ask. That gap is why strict mode used to be inert on
315
+ * the request path. Parity with Python `session.session_strict_mode()`.
316
+ *
317
+ * TINA4_SESSION_STRICT re-throw instead of degrading (default: false)
318
+ */
319
+ export declare function sessionStrictMode(): boolean;
235
320
  /**
236
321
  * Build the `Set-Cookie` header value for a Tina4 session. Centralised so
237
322
  * the auto-cookie path in server.ts and `Session.cookieHeader()` agree on