tina4-nodejs 3.13.41 → 3.13.42

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.42)
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.42",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -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";