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 +10 -1
- package/package.json +1 -1
- package/packages/core/src/types.ts +22 -0
- package/packages/swagger/src/generator.ts +223 -13
- package/packages/swagger/src/index.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
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
|
@@ -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:
|
|
168
|
+
openapi: resolveOpenApiVersion(),
|
|
54
169
|
info,
|
|
55
170
|
servers: resolveServers(),
|
|
56
171
|
paths: {},
|
|
57
172
|
components: {
|
|
58
173
|
schemas: {},
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
127
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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";
|