tina4-nodejs 3.13.41 → 3.13.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.41)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.43)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
@@ -300,6 +300,15 @@ Auto-generates OpenAPI 3.0.3 docs.
300
300
  - `TINA4_SWAGGER_UI_CDN` - base URL for the Swagger UI assets (`swagger-ui.css` + `swagger-ui-bundle.js`). Defaults to the public CDN (`https://unpkg.com/swagger-ui-dist@5`); point it at a self-hosted mirror for air-gapped deployments.
301
301
  - Info block: `TINA4_SWAGGER_TITLE`, `TINA4_SWAGGER_VERSION`, `TINA4_SWAGGER_DESCRIPTION`, `TINA4_SWAGGER_CONTACT_EMAIL`, `TINA4_SWAGGER_CONTACT_TEAM`, `TINA4_SWAGGER_CONTACT_URL`, `TINA4_SWAGGER_LICENSE`.
302
302
 
303
+ **Configurability (v3.13.42):**
304
+ - `TINA4_SWAGGER_OPENAPI` - OpenAPI version (default `3.0.3`); `3.1`/`3.1.0` emits `3.1.0`.
305
+ - `TINA4_SWAGGER_BEARER_FORMAT` - `bearerFormat` on the built-in `bearerAuth` scheme (default `JWT`; use `opaque` for `sk_live_` keys).
306
+ - `TINA4_SWAGGER_API_KEY_NAME` / `TINA4_SWAGGER_API_KEY_IN` - when the name is set, emit an `apiKeyAuth` scheme; `_IN` is `header` (default) / `query` / `cookie`.
307
+ - `TINA4_SWAGGER_DEFAULT_SCHEME` - scheme a secured route uses when its `meta` declares no `security` (default `bearerAuth`).
308
+ - `TINA4_SWAGGER_INCLUDE` / `TINA4_SWAGGER_EXCLUDE` - comma-separated path-prefix allow-list / deny-list (`/swagger` + `/__dev` always excluded).
309
+
310
+ **Per-route security + reusable schemas (v3.13.42).** A route's `meta` may carry `security` (a scheme name, a `{name: [scopes]}` map, a list of maps for OR, or the string `"public"` to force `security: []`), a sibling `scopes` array, and `requestSchema` / `responseSchemas` referencing schemas registered with `addSchema(name, schema)`. Register arbitrary schemes (including `oauth2` with scopes) via `addSecurityScheme(name, definition)`; `resetRegistry()` clears both. All three are exported from `@tina4/swagger`. Scopes are kept spec-valid: only `oauth2`/`openIdConnect` carry them, `http`/`apiKey` get `[]`.
311
+
303
312
  ### @tina4/frond (`packages/frond/`)
304
313
  Built-in zero-dependency Twig-compatible template engine (the only template engine; there is no `twig` npm dependency).
305
314
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.41",
6
+ "version": "3.13.43",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -81,9 +81,17 @@ export interface ProcessOptions {
81
81
  maxJobs?: number;
82
82
  maxRetries?: number;
83
83
  batchSize?: number;
84
+ /**
85
+ * Override the queue's topic for this drain (parity with Python's
86
+ * process(handler, topic=...)). When set, process() retargets the queue so
87
+ * pop() reads the requested topic instead of the construction-time one.
88
+ */
89
+ topic?: string;
84
90
  }
85
91
 
86
92
  export interface ConsumeOptions {
93
+ /** Topic to consume (defaults to the constructor topic). */
94
+ topic?: string;
87
95
  batchSize?: number;
88
96
  pollInterval?: number;
89
97
  iterations?: number;
@@ -95,6 +103,18 @@ export interface QueueBackendInterface {
95
103
  pop(queue: string): QueueJob | null;
96
104
  size(queue: string): number;
97
105
  clear(queue: string): void;
106
+ // Optional full lifecycle. Reservation-based backends (MongoDB) implement
107
+ // these so complete()/fail() ack the ACTIVE store — without complete(), a
108
+ // reserved Mongo job is re-delivered after the visibility window. Backends
109
+ // that auto-ack on pop (RabbitMQ no-ack) or delegate to the broker (Kafka
110
+ // offsets) omit them, and the Queue keeps its prior behaviour for them.
111
+ complete?(queue: string, id: string): void;
112
+ fail?(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number): void;
113
+ retry?(queue: string, id: string, delaySeconds?: number): void;
114
+ deadLetters?(queue: string, maxRetries?: number): QueueJob[];
115
+ failed?(queue: string, maxRetries?: number): QueueJob[];
116
+ retryFailed?(queue: string, maxRetries?: number): number;
117
+ purge?(queue: string, status?: string): number;
98
118
  }
99
119
 
100
120
  // ── Queue ────────────────────────────────────────────────────
@@ -152,6 +172,22 @@ export class Queue {
152
172
  }
153
173
  }
154
174
 
175
+ /**
176
+ * Point this queue at ``topic`` in place.
177
+ *
178
+ * produce()/consume()/process() call this so a topic argument actually
179
+ * changes which topic is read or written. Without it the argument was
180
+ * accepted but ignored on the read path — pop() always used the
181
+ * construction-time topic, so consume("other") silently drained the wrong
182
+ * queue. The lite + external backends are topic-per-call (every push/pop/size
183
+ * takes the queue name), so changing this.topic retargets all of them; the
184
+ * job lifecycle (complete()/fail()/retry()) routes by the job's own .topic, so
185
+ * it is unaffected. Mirrors Python's Queue._retarget().
186
+ */
187
+ private retarget(topic: string): void {
188
+ this.topic = topic;
189
+ }
190
+
155
191
  // ── Unified API (topic-aware) ────────────────────────────────
156
192
 
157
193
  /**
@@ -197,6 +233,12 @@ export class Queue {
197
233
  handler: (job: QueueJob | QueueJob[]) => Promise<void> | void,
198
234
  options?: ProcessOptions,
199
235
  ): void {
236
+ // Honour an explicit topic: retarget so pop() drains the requested topic
237
+ // (parity with Python's process(handler, topic=...)). Without this the
238
+ // argument was accepted but ignored on the read path.
239
+ if (options?.topic !== undefined) {
240
+ this.retarget(options.topic);
241
+ }
200
242
  const queue = this.topic;
201
243
  const opts = options;
202
244
 
@@ -275,6 +317,9 @@ export class Queue {
275
317
  * auto-retry lifecycle; dead-lettered jobs are returned by deadLetters().
276
318
  */
277
319
  failed(): QueueJob[] {
320
+ if (this.externalBackend?.failed) {
321
+ return this.externalBackend.failed(this.topic, this._maxRetries);
322
+ }
278
323
  return this.liteBackend.failed(this.topic, this._maxRetries);
279
324
  }
280
325
 
@@ -288,6 +333,10 @@ export class Queue {
288
333
  retry(jobId?: string, delaySeconds?: number): boolean {
289
334
  if (jobId) {
290
335
  // Retry a specific job by ID
336
+ if (this.externalBackend?.retry) {
337
+ this.externalBackend.retry(this.topic, jobId, delaySeconds);
338
+ return true;
339
+ }
291
340
  return this.liteBackend.retry(this.topic, jobId, delaySeconds);
292
341
  }
293
342
  // Retry all dead-letter jobs
@@ -295,8 +344,12 @@ export class Queue {
295
344
  if (deadJobs.length === 0) return false;
296
345
  let retried = false;
297
346
  for (const job of deadJobs) {
298
- const ok = this.liteBackend.retry(this.topic, job.id, delaySeconds);
299
- if (ok) retried = true;
347
+ if (this.externalBackend?.retry) {
348
+ this.externalBackend.retry(this.topic, job.id, delaySeconds);
349
+ retried = true;
350
+ } else if (this.liteBackend.retry(this.topic, job.id, delaySeconds)) {
351
+ retried = true;
352
+ }
300
353
  }
301
354
  return retried;
302
355
  }
@@ -305,6 +358,9 @@ export class Queue {
305
358
  * Get dead letter jobs — failed jobs that exceeded max retries.
306
359
  */
307
360
  deadLetters(maxRetries?: number): QueueJob[] {
361
+ if (this.externalBackend?.deadLetters) {
362
+ return this.externalBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
363
+ }
308
364
  return this.liteBackend.deadLetters(this.topic, maxRetries ?? this._maxRetries);
309
365
  }
310
366
 
@@ -312,6 +368,9 @@ export class Queue {
312
368
  * Delete messages by status (e.g. "completed", "failed", "dead").
313
369
  */
314
370
  purge(status: string, maxRetries?: number): number {
371
+ if (this.externalBackend?.purge) {
372
+ return this.externalBackend.purge(this.topic, status);
373
+ }
315
374
  return this.liteBackend.purge(this.topic, status, maxRetries ?? this._maxRetries);
316
375
  }
317
376
 
@@ -319,17 +378,27 @@ export class Queue {
319
378
  * Re-queue failed jobs that haven't exceeded max retries back to pending.
320
379
  */
321
380
  retryFailed(maxRetries?: number): number {
381
+ if (this.externalBackend?.retryFailed) {
382
+ return this.externalBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
383
+ }
322
384
  return this.liteBackend.retryFailed(this.topic, maxRetries ?? this._maxRetries);
323
385
  }
324
386
 
325
387
  /**
326
388
  * Produce a message onto a topic. Convenience wrapper around push().
389
+ *
390
+ * Retargets to the requested topic (restoring the prior one afterwards) so it
391
+ * shares the same retarget path consume()/process() use — keeping produce and
392
+ * consume symmetric on the same topic argument.
327
393
  */
328
394
  produce(topic: string, payload: unknown, priority: number = 0, delay: number = 0): string {
329
- if (this.externalBackend) {
330
- return this.externalBackend.push(topic, payload, delay);
395
+ const previous = this.topic;
396
+ this.retarget(topic);
397
+ try {
398
+ return this.push(payload, delay, priority);
399
+ } finally {
400
+ this.retarget(previous);
331
401
  }
332
- return this.liteBackend.push(topic, payload, delay, priority);
333
402
  }
334
403
 
335
404
  /**
@@ -368,7 +437,9 @@ export class Queue {
368
437
 
369
438
  if (topicOrOptions !== null && typeof topicOrOptions === "object") {
370
439
  const opts = topicOrOptions as ConsumeOptions;
371
- q = this.topic;
440
+ // Honour opts.topic (parity with the string-arg form) — previously the
441
+ // options-object form ignored it and always drained the constructor topic.
442
+ q = opts.topic ?? this.topic;
372
443
  resolvedId = opts.id;
373
444
  resolvedPollInterval = opts.pollInterval ?? 1000;
374
445
  resolvedIterations = opts.iterations ?? 0;
@@ -381,6 +452,12 @@ export class Queue {
381
452
  resolvedBatchSize = batchSize;
382
453
  }
383
454
 
455
+ // Honour the topic argument: point the queue (and the backend pop()/
456
+ // popById() route through) at it. Previously the resolved topic was
457
+ // computed but never used — pop()/popById() read this.topic, so
458
+ // consume("other") silently drained the construction-time topic.
459
+ this.retarget(q);
460
+
384
461
  if (resolvedId !== undefined) {
385
462
  const raw = this.popById(resolvedId);
386
463
  if (raw) yield createJob(raw as any, this);
@@ -453,6 +530,10 @@ export class Queue {
453
530
  * after retryBackoff seconds) or dead-letter (attempts >= maxRetries).
454
531
  */
455
532
  _failJob(queue: string, job: QueueJob, error: string, maxRetries: number): void {
533
+ if (this.externalBackend?.fail) {
534
+ this.externalBackend.fail(queue, job.id, error, maxRetries, this._retryBackoff);
535
+ return;
536
+ }
456
537
  this.liteBackend.failJob(queue, job, error, maxRetries, this._retryBackoff);
457
538
  }
458
539
 
@@ -460,15 +541,26 @@ export class Queue {
460
541
  * Re-queue a job back to the main queue directory with incremented attempts.
461
542
  */
462
543
  _retryJob(queue: string, job: QueueJob, delaySeconds?: number): void {
544
+ if (this.externalBackend?.retry) {
545
+ this.externalBackend.retry(queue, job.id, delaySeconds);
546
+ return;
547
+ }
463
548
  this.liteBackend.retryJob(queue, job, delaySeconds);
464
549
  }
465
550
 
466
551
  /**
467
- * Acknowledge a completed job — drop its reservation record so the visibility
468
- * reclaim never re-delivers it. No-op for external backends (they ack their
469
- * own way; lite-backend reservations are file records).
552
+ * Acknowledge a completed job — drop its reservation so the visibility reclaim
553
+ * never re-delivers it. Routes to the active backend: a reservation-based
554
+ * external backend (MongoDB) acks there (without this its reserved doc would
555
+ * be re-delivered after the visibility window); RabbitMQ (no-ack on get) and
556
+ * Kafka (offset-based) expose no complete(), so the lite path is used and is a
557
+ * harmless no-op for them since they already acked/own redelivery.
470
558
  */
471
559
  _completeJob(queue: string, job: QueueJob): void {
560
+ if (this.externalBackend?.complete) {
561
+ this.externalBackend.complete(queue, job.id);
562
+ return;
563
+ }
472
564
  this.liteBackend.completeJob(queue, job);
473
565
  }
474
566
  }
@@ -229,12 +229,20 @@ export class MongoBackend implements QueueBackend {
229
229
  if (result && result.value) {
230
230
  // findOneAndUpdate returns { value: doc } in older drivers
231
231
  const doc = result.value;
232
+ // Carry the framework topic on the job so complete()/fail()
233
+ // route the ack/requeue back to THIS topic's docs (the Mongo
234
+ // internal queue field is dropped from the returned shape).
235
+ doc.topic = queueName;
232
236
  delete doc._id;
233
237
  delete doc.queue;
234
238
  process.stdout.write(JSON.stringify(doc));
235
239
  } else if (result && result._id) {
236
240
  // Some driver versions return the doc directly
237
241
  const doc = { ...result };
242
+ // Carry the framework topic on the job so complete()/fail()
243
+ // route the ack/requeue back to THIS topic's docs (the Mongo
244
+ // internal queue field is dropped from the returned shape).
245
+ doc.topic = queueName;
238
246
  delete doc._id;
239
247
  delete doc.queue;
240
248
  process.stdout.write(JSON.stringify(doc));
@@ -253,6 +261,95 @@ export class MongoBackend implements QueueBackend {
253
261
  await col.deleteMany({ queue: queueName });
254
262
  process.stdout.write("__CLEARED__");
255
263
  }
264
+ else if (operation === "complete") {
265
+ // Ack a finished job so the reclaim never re-delivers it. data = job id.
266
+ // (The pop reserved this doc; without this it stays reserved and is
267
+ // re-delivered after the visibility window — the redelivery bug.)
268
+ await col.updateOne(
269
+ { queue: queueName, id: data },
270
+ { $set: { status: "completed", completedAt: new Date().toISOString(), reservedAt: null } },
271
+ );
272
+ process.stdout.write("__OK__");
273
+ }
274
+ else if (operation === "fail") {
275
+ // Requeue while retries remain (reset availableAt -> visible again),
276
+ // else dead-letter. Atomic decision in Mongo. data = JSON
277
+ // { id, error, maxRetries, retryBackoff }.
278
+ const info = JSON.parse(data);
279
+ const now = new Date().toISOString();
280
+ const doc = await col.findOne({ queue: queueName, id: info.id });
281
+ if (doc) {
282
+ const attempts = (doc.attempts || 0) + 1;
283
+ if (attempts >= info.maxRetries) {
284
+ await col.insertOne({
285
+ ...doc, _id: undefined, attempts,
286
+ status: "dead", queue: queueName + ".dead_letter", error: info.error,
287
+ });
288
+ await col.deleteOne({ _id: doc._id, queue: queueName });
289
+ } else {
290
+ const avail = info.retryBackoff > 0
291
+ ? new Date(Date.now() + info.retryBackoff * 1000).toISOString()
292
+ : now;
293
+ await col.updateOne(
294
+ { _id: doc._id, queue: queueName },
295
+ { $set: { status: "pending", availableAt: avail, reservedAt: null, error: info.error },
296
+ $inc: { attempts: 1 } },
297
+ );
298
+ }
299
+ }
300
+ process.stdout.write("__OK__");
301
+ }
302
+ else if (operation === "retry") {
303
+ // Explicit manual re-queue (always re-enqueues). data = JSON
304
+ // { id, delaySeconds }.
305
+ const info = JSON.parse(data);
306
+ const avail = info.delaySeconds > 0
307
+ ? new Date(Date.now() + info.delaySeconds * 1000).toISOString()
308
+ : new Date().toISOString();
309
+ await col.updateOne(
310
+ { queue: queueName, id: info.id },
311
+ { $set: { status: "pending", availableAt: avail, reservedAt: null }, $inc: { attempts: 1 } },
312
+ );
313
+ process.stdout.write("__OK__");
314
+ }
315
+ else if (operation === "deadLetters") {
316
+ const docs = await col.find({ queue: queueName + ".dead_letter" }).toArray();
317
+ const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
318
+ process.stdout.write(JSON.stringify(out));
319
+ }
320
+ else if (operation === "failed") {
321
+ const docs = await col
322
+ .find({ queue: queueName, status: "failed", attempts: { $lt: maxRetries } })
323
+ .toArray();
324
+ const out = docs.map((d) => { delete d._id; delete d.queue; return d; });
325
+ process.stdout.write(JSON.stringify(out));
326
+ }
327
+ else if (operation === "retryFailed") {
328
+ // Revive dead-lettered jobs under the (possibly raised) limit back to
329
+ // the main queue as pending. data = the max-retries limit.
330
+ const mr = data ? Number(data) : maxRetries;
331
+ const now = new Date().toISOString();
332
+ let revived = 0;
333
+ while (true) {
334
+ const doc = await col.findOneAndUpdate(
335
+ { queue: queueName + ".dead_letter", attempts: { $lt: mr } },
336
+ { $set: { status: "pending", availableAt: now, reservedAt: null, queue: queueName, error: null } },
337
+ { returnDocument: "after" },
338
+ );
339
+ const updated = doc && doc.value ? doc.value : (doc && doc._id ? doc : null);
340
+ if (!updated) break;
341
+ revived++;
342
+ }
343
+ process.stdout.write(String(revived));
344
+ }
345
+ else if (operation === "purge") {
346
+ // Delete docs by status (default: all for the topic). data = JSON { status }.
347
+ const info = data ? JSON.parse(data) : {};
348
+ const filter = { queue: queueName };
349
+ if (info.status) filter.status = info.status;
350
+ const res = await col.deleteMany(filter);
351
+ process.stdout.write(String(res.deletedCount || 0));
352
+ }
256
353
  } catch (err) {
257
354
  process.stderr.write(err.message || String(err));
258
355
  process.exit(1);
@@ -328,4 +425,50 @@ export class MongoBackend implements QueueBackend {
328
425
  clear(queue: string): void {
329
426
  this.execSync("clear", queue);
330
427
  }
428
+
429
+ /**
430
+ * Acknowledge a completed job — drop its reservation so the reclaim never
431
+ * re-delivers it. Without this a Mongo-popped job stayed reserved and was
432
+ * re-delivered after the visibility window (the redelivery bug).
433
+ */
434
+ complete(queue: string, id: string): void {
435
+ this.execSync("complete", queue, id);
436
+ }
437
+
438
+ /**
439
+ * Record a failed attempt: requeue (reset availableAt, ++attempts) while
440
+ * retries remain, else dead-letter. Mirrors the file/lite backend.
441
+ */
442
+ fail(queue: string, id: string, error: string, maxRetries: number, retryBackoff: number = 0): void {
443
+ this.execSync("fail", queue, JSON.stringify({ id, error, maxRetries, retryBackoff }));
444
+ }
445
+
446
+ /** Explicit manual re-queue (always re-enqueues regardless of the retry limit). */
447
+ retry(queue: string, id: string, delaySeconds: number = 0): void {
448
+ this.execSync("retry", queue, JSON.stringify({ id, delaySeconds }));
449
+ }
450
+
451
+ /** Jobs that exceeded max retries (the `<queue>.dead_letter` collection topic). */
452
+ deadLetters(queue: string, maxRetries?: number): QueueJob[] {
453
+ const out = this.execSync("deadLetters", queue, String(maxRetries ?? this.maxRetries));
454
+ try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
455
+ }
456
+
457
+ /** Jobs that failed but are still eligible for retry (status=failed, attempts < max). */
458
+ failed(queue: string, maxRetries?: number): QueueJob[] {
459
+ const out = this.execSync("failed", queue, String(maxRetries ?? this.maxRetries));
460
+ try { return JSON.parse(out) as QueueJob[]; } catch { return []; }
461
+ }
462
+
463
+ /** Revive dead-lettered jobs under the (possibly raised) limit. Returns count revived. */
464
+ retryFailed(queue: string, maxRetries?: number): number {
465
+ const out = this.execSync("retryFailed", queue, String(maxRetries ?? this.maxRetries));
466
+ return parseInt(out, 10) || 0;
467
+ }
468
+
469
+ /** Remove jobs by status (default: every doc for the topic). Returns count removed. */
470
+ purge(queue: string, status?: string): number {
471
+ const out = this.execSync("purge", queue, JSON.stringify({ status: status ?? "" }));
472
+ return parseInt(out, 10) || 0;
473
+ }
331
474
  }
@@ -138,6 +138,28 @@ export interface RouteMeta {
138
138
  example?: unknown;
139
139
  /** Marks the operation deprecated in the spec. */
140
140
  deprecated?: boolean;
141
+ /**
142
+ * Per-route security requirement (v3.13.42). Overrides the default scheme.
143
+ * Accepted forms (normalized by the generator into a security-requirement list):
144
+ * "bearerAuth" -> [{ bearerAuth: [] }]
145
+ * "public" | "none" | [] -> [] (explicitly no auth)
146
+ * { apiKeyAuth: [] } -> [{ apiKeyAuth: [] }] (AND within one map)
147
+ * [{ oauth2: ["read"] }, { bearerAuth: [] }] -> verbatim (OR across maps)
148
+ */
149
+ security?: string | string[] | Record<string, string[]> | Array<Record<string, string[]>>;
150
+ /** Scopes for a single named scheme passed as `security: "oauth2"` + `scopes: [...]`. */
151
+ scopes?: string[];
152
+ /**
153
+ * Reference a registered component schema as the request body (v3.13.42):
154
+ * requestSchema: "CreateUser" OR { name: "CreateUser", contentType: "application/json" }
155
+ * Emits `$ref: #/components/schemas/CreateUser` and lands the schema in components.schemas.
156
+ */
157
+ requestSchema?: string | { name: string; contentType?: string };
158
+ /**
159
+ * Reference registered component schemas as response bodies, keyed by status (v3.13.42):
160
+ * responseSchemas: { 200: "User", 201: { name: "User", isList: true } }
161
+ */
162
+ responseSchemas?: Record<string, string | { name: string; isList?: boolean }>;
141
163
  }
142
164
 
143
165
  export interface Tina4Config {
@@ -20,6 +20,120 @@ interface OpenAPISpec {
20
20
 
21
21
  const WRITE_METHODS = new Set(["post", "put", "patch", "delete"]);
22
22
 
23
+ // ── Configuration registries (v3.13.42) ───────────────────────────
24
+ // Process-wide registries for security schemes and reusable component schemas
25
+ // declared programmatically (addSecurityScheme / addSchema). Kept module-level so
26
+ // app bootstrap can register before any generate() call; resetRegistry() clears
27
+ // them (tests). Parity with Python's Swagger.add_security_scheme/add_schema/reset_registry.
28
+ const registeredSchemes: Record<string, Record<string, unknown>> = {};
29
+ const registeredSchemas: Record<string, Record<string, unknown>> = {};
30
+
31
+ /**
32
+ * Register a named OpenAPI security scheme (e.g. an oauth2 scheme with scopes,
33
+ * or a custom apiKey). Call at app bootstrap, before generate(). A registered
34
+ * scheme may override the built-in bearerAuth.
35
+ */
36
+ export function addSecurityScheme(name: string, definition: Record<string, unknown>): void {
37
+ registeredSchemes[name] = definition;
38
+ }
39
+
40
+ /**
41
+ * Register a reusable component schema, referenceable via meta.requestSchema /
42
+ * meta.responseSchemas or a raw $ref.
43
+ */
44
+ export function addSchema(name: string, schema: Record<string, unknown>): void {
45
+ registeredSchemas[name] = schema;
46
+ }
47
+
48
+ /** Clear the security-scheme and schema registries (test helper). */
49
+ export function resetRegistry(): void {
50
+ for (const k of Object.keys(registeredSchemes)) delete registeredSchemes[k];
51
+ for (const k of Object.keys(registeredSchemas)) delete registeredSchemas[k];
52
+ }
53
+
54
+ /** Resolve TINA4_SWAGGER_OPENAPI to a concrete version. Default 3.0.3; "3.1"/"3.1.0" -> "3.1.0". */
55
+ function resolveOpenApiVersion(): string {
56
+ const v = (process.env.TINA4_SWAGGER_OPENAPI ?? "").trim();
57
+ if (!v) return "3.0.3";
58
+ if (v === "3.1" || v === "3.1.0") return "3.1.0";
59
+ if (v === "3.0" || v === "3.0.3") return "3.0.3";
60
+ return v; // honour an explicit full version verbatim
61
+ }
62
+
63
+ /** Comma-separated env value -> clean list. */
64
+ function csv(val: string | undefined): string[] {
65
+ return (val ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
66
+ }
67
+
68
+ /** Resolve components.securitySchemes from defaults + env + registry. */
69
+ function resolveSecuritySchemes(): Record<string, Record<string, unknown>> {
70
+ const bearerFormat = process.env.TINA4_SWAGGER_BEARER_FORMAT ?? "JWT";
71
+ const schemes: Record<string, Record<string, unknown>> = {
72
+ bearerAuth: { type: "http", scheme: "bearer", bearerFormat },
73
+ };
74
+ const apiKeyName = (process.env.TINA4_SWAGGER_API_KEY_NAME ?? "").trim();
75
+ if (apiKeyName.length > 0) {
76
+ const rawIn = process.env.TINA4_SWAGGER_API_KEY_IN ?? "header";
77
+ const apiKeyIn = ["header", "query", "cookie"].includes(rawIn) ? rawIn : "header";
78
+ schemes.apiKeyAuth = { type: "apiKey", name: apiKeyName, in: apiKeyIn };
79
+ }
80
+ // Registered schemes win (let an app override bearerAuth or add oauth2).
81
+ for (const [name, def] of Object.entries(registeredSchemes)) {
82
+ schemes[name] = def;
83
+ }
84
+ return schemes;
85
+ }
86
+
87
+ /**
88
+ * Normalize a meta.security value (+ optional scopes) into an OpenAPI
89
+ * security-requirement list. Mirrors Python's _normalize_security.
90
+ */
91
+ function normalizeSecurity(
92
+ value: NonNullable<unknown> | undefined,
93
+ scopes: string[] | undefined
94
+ ): Array<Record<string, string[]>> {
95
+ if ((value === "public" || value === "none" || value === undefined || value === null) && (!scopes || scopes.length === 0)) {
96
+ return [];
97
+ }
98
+ if (typeof value === "string") {
99
+ return [{ [value]: [...(scopes ?? [])] }];
100
+ }
101
+ if (Array.isArray(value)) {
102
+ if (value.length === 0) return [];
103
+ return value.map((req) => normalizeRequirementMap(req as Record<string, string[]>));
104
+ }
105
+ if (value !== null && typeof value === "object") {
106
+ return [normalizeRequirementMap(value as Record<string, string[]>)];
107
+ }
108
+ return [];
109
+ }
110
+
111
+ function normalizeRequirementMap(req: Record<string, string[]>): Record<string, string[]> {
112
+ const out: Record<string, string[]> = {};
113
+ for (const [k, v] of Object.entries(req)) out[k] = [...(v ?? [])];
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Keep a security-requirement list spec-valid: scopes are allowed only on
119
+ * oauth2/openIdConnect schemes; everything else gets an empty array (OpenAPI
120
+ * requires it). Mirrors Python's _sanitize_security.
121
+ */
122
+ function sanitizeSecurity(
123
+ reqs: Array<Record<string, string[]>>,
124
+ schemes: Record<string, Record<string, unknown>>
125
+ ): Array<Record<string, string[]>> {
126
+ const scopeOk = new Set(["oauth2", "openIdConnect"]);
127
+ return reqs.map((req) => {
128
+ const clean: Record<string, string[]> = {};
129
+ for (const [name, scopes] of Object.entries(req)) {
130
+ const stype = (schemes[name] as Record<string, unknown> | undefined)?.type;
131
+ clean[name] = scopeOk.has(stype as string) ? [...scopes] : [];
132
+ }
133
+ return clean;
134
+ });
135
+ }
136
+
23
137
  export function generate(
24
138
  routes: RouteDefinition[],
25
139
  models: ModelDefinition[] = []
@@ -49,21 +163,31 @@ export function generate(
49
163
  info.license = url ? { name, url } : { name };
50
164
  }
51
165
 
166
+ const schemes = resolveSecuritySchemes();
52
167
  const spec: OpenAPISpec = {
53
- openapi: "3.0.3",
168
+ openapi: resolveOpenApiVersion(),
54
169
  info,
55
170
  servers: resolveServers(),
56
171
  paths: {},
57
172
  components: {
58
173
  schemas: {},
59
- // bearerAuth was never defined before secured routes were documented
60
- // identically to public ones (audit P1). Define it once and reference it.
61
- securitySchemes: {
62
- bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
63
- },
174
+ // Configurable security schemes (v3.13.42): bearerFormat via env, optional
175
+ // apiKey scheme, plus any programmatically-registered schemes (which may
176
+ // override bearerAuth — e.g. an oauth2 scheme with scopes).
177
+ securitySchemes: schemes,
64
178
  },
65
179
  };
66
180
 
181
+ // Default scheme secured routes use when no explicit meta.security is set.
182
+ const defaultScheme = process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth";
183
+
184
+ // Path filters (comma-separated raw-path prefixes).
185
+ const includePrefixes = csv(process.env.TINA4_SWAGGER_INCLUDE);
186
+ const excludePrefixes = csv(process.env.TINA4_SWAGGER_EXCLUDE);
187
+
188
+ // Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
189
+ const refSchemas = new Set<string>();
190
+
67
191
  // Generate schemas from models
68
192
  for (const model of models) {
69
193
  const schema = modelToSchema(model);
@@ -75,6 +199,7 @@ export function generate(
75
199
 
76
200
  // Generate paths from routes
77
201
  for (const route of routes) {
202
+ if (!isIncludedPath(route.pattern, includePrefixes, excludePrefixes)) continue;
78
203
  const openApiPath = patternToOpenAPI(route.pattern);
79
204
  const method = route.method.toLowerCase();
80
205
 
@@ -123,8 +248,20 @@ export function generate(
123
248
  }
124
249
  }
125
250
 
126
- // Add request body for POST/PUT
127
- if (method === "post" || method === "put") {
251
+ // Request body a registered custom schema $ref (meta.requestSchema) wins,
252
+ // else the inferred-from-model body (POST/PUT to a resource), else an
253
+ // example-only body.
254
+ const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
255
+ if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
256
+ refSchemas.add(reqSchemaRef.name);
257
+ const media: Record<string, unknown> = {
258
+ schema: { $ref: `#/components/schemas/${reqSchemaRef.name}` },
259
+ };
260
+ if (route.meta?.example !== undefined) media.example = route.meta.example;
261
+ operation.requestBody = {
262
+ content: { [reqSchemaRef.contentType]: media },
263
+ };
264
+ } else if (method === "post" || method === "put") {
128
265
  const modelName = inferModelFromPath(route.pattern);
129
266
  if (modelName && models.some((m) => m.tableName === modelName)) {
130
267
  const media: Record<string, unknown> = {
@@ -151,11 +288,35 @@ export function generate(
151
288
  }
152
289
  }
153
290
 
154
- // Security a secured route emits operation.security + 401. Mirrors the
155
- // router's enforcement (writes secure by default unless noAuth; GET secure
156
- // only when marked). Before, no route ever got a security requirement.
157
- if (routeRequiresAuth(route, method)) {
158
- operation.security = [{ bearerAuth: [] }];
291
+ // Registered response schemas ($ref) explicit and authoritative, keyed by status.
292
+ const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
293
+ if (respSchemas.length > 0) {
294
+ const responses = operation.responses as Record<string, unknown>;
295
+ for (const { status, name, isList } of respSchemas) {
296
+ refSchemas.add(name);
297
+ const sref = `#/components/schemas/${name}`;
298
+ const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
299
+ responses[status] = {
300
+ description: status.startsWith("2") ? "Successful response" : "Response",
301
+ content: { "application/json": { schema } },
302
+ };
303
+ }
304
+ }
305
+
306
+ // Security (v3.13.42) — explicit meta.security wins (empty list = explicitly
307
+ // public); otherwise a secured route gets the default scheme. Scopes are kept
308
+ // valid (only oauth2/openIdConnect carry them).
309
+ const hasExplicitSecurity =
310
+ route.meta?.security !== undefined || (route.meta?.scopes !== undefined && route.meta.scopes.length > 0);
311
+ if (hasExplicitSecurity) {
312
+ const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
313
+ operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
314
+ if (normalized.length > 0) {
315
+ const responses = operation.responses as Record<string, unknown>;
316
+ if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
317
+ }
318
+ } else if (routeRequiresAuth(route, method)) {
319
+ operation.security = sanitizeSecurity([{ [defaultScheme]: [] }], schemes);
159
320
  const responses = operation.responses as Record<string, unknown>;
160
321
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
161
322
  }
@@ -163,6 +324,16 @@ export function generate(
163
324
  spec.paths[openApiPath][method] = operation;
164
325
  }
165
326
 
327
+ // Registered component schemas referenced via meta.requestSchema/responseSchemas.
328
+ if (refSchemas.size > 0) {
329
+ const schemas = spec.components!.schemas!;
330
+ for (const name of refSchemas) {
331
+ if (name in registeredSchemas && !(name in schemas)) {
332
+ schemas[name] = registeredSchemas[name];
333
+ }
334
+ }
335
+ }
336
+
166
337
  if (usedTags.length > 0) {
167
338
  spec.tags = usedTags.map((name) => ({ name }));
168
339
  }
@@ -176,6 +347,45 @@ function routeRequiresAuth(route: RouteDefinition, method: string): boolean {
176
347
  return route.secure === true;
177
348
  }
178
349
 
350
+ /**
351
+ * Path-filter a raw route pattern. Framework internals (/swagger, /__dev) are
352
+ * ALWAYS excluded; then TINA4_SWAGGER_INCLUDE (allow-list) / _EXCLUDE apply.
353
+ * Mirrors Python's _included.
354
+ */
355
+ function isIncludedPath(rawPath: string, include: string[], exclude: string[]): boolean {
356
+ for (const internal of ["/swagger", "/__dev"]) {
357
+ if (rawPath === internal || rawPath.startsWith(internal + "/")) return false;
358
+ }
359
+ if (include.length > 0 && !include.some((p) => rawPath === p || rawPath.startsWith(p))) {
360
+ return false;
361
+ }
362
+ if (exclude.some((p) => rawPath === p || rawPath.startsWith(p))) return false;
363
+ return true;
364
+ }
365
+
366
+ function parseRequestSchema(
367
+ spec: string | { name: string; contentType?: string } | undefined
368
+ ): { name: string; contentType: string } | null {
369
+ if (spec === undefined) return null;
370
+ if (typeof spec === "string") return { name: spec, contentType: "application/json" };
371
+ return { name: spec.name, contentType: spec.contentType ?? "application/json" };
372
+ }
373
+
374
+ function parseResponseSchemas(
375
+ spec: Record<string, string | { name: string; isList?: boolean }> | undefined
376
+ ): Array<{ status: string; name: string; isList: boolean }> {
377
+ if (!spec) return [];
378
+ const out: Array<{ status: string; name: string; isList: boolean }> = [];
379
+ for (const [status, value] of Object.entries(spec)) {
380
+ if (typeof value === "string") {
381
+ out.push({ status, name: value, isList: false });
382
+ } else {
383
+ out.push({ status, name: value.name, isList: value.isList === true });
384
+ }
385
+ }
386
+ return out;
387
+ }
388
+
179
389
  function resolveServers(): { url: string }[] {
180
390
  const raw = (process.env.TINA4_SWAGGER_SERVERS ?? "").trim();
181
391
  const urls = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
@@ -1,2 +1,2 @@
1
- export { generate } from "./generator.js";
1
+ export { generate, addSecurityScheme, addSchema, resetRegistry } from "./generator.js";
2
2
  export { createSwaggerRoutes, swaggerEnabled } from "./ui.js";