tina4-nodejs 3.13.39 → 3.13.40

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.
@@ -58,6 +58,16 @@ export { FakeData } from "./fakeData.js";
58
58
  export { seedTable, seedOrm, seedModels } from "./seeder.js";
59
59
  export type { SeedSummary, SeedOptions } from "./seeder.js";
60
60
 
61
+ // DocStore — pymongo-style document store with a zero-config SQLite (JSON1) fallback
62
+ export {
63
+ ObjectId, InvalidId, SqliteDatabase, SqliteCollection, Cursor,
64
+ getCollection, isServerless, resetDefaultStore,
65
+ encodeValue, decodeValue, compileFilter,
66
+ } from "./docstore.js";
67
+ export type {
68
+ InsertOneResult, InsertManyResult, UpdateResult, DeleteResult,
69
+ } from "./docstore.js";
70
+
61
71
  // Database adapters
62
72
  export { SQLiteAdapter } from "./adapters/sqlite.js";
63
73
  export { PostgresAdapter } from "./adapters/postgres.js";
@@ -12,10 +12,14 @@ interface OpenAPISpecInfo {
12
12
  interface OpenAPISpec {
13
13
  openapi: string;
14
14
  info: OpenAPISpecInfo;
15
+ servers?: { url: string }[];
15
16
  paths: Record<string, Record<string, unknown>>;
16
- components?: { schemas?: Record<string, unknown> };
17
+ components?: { schemas?: Record<string, unknown>; securitySchemes?: Record<string, unknown> };
18
+ tags?: { name: string }[];
17
19
  }
18
20
 
21
+ const WRITE_METHODS = new Set(["post", "put", "patch", "delete"]);
22
+
19
23
  export function generate(
20
24
  routes: RouteDefinition[],
21
25
  models: ModelDefinition[] = []
@@ -26,12 +30,16 @@ export function generate(
26
30
  description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
27
31
  };
28
32
 
29
- // Optional contact email surfaced in the OpenAPI `info.contact.email`
30
- // field when set. Matches the python `SWAGGER_CONTACT_EMAIL` convention.
33
+ // Optional contact email, plus name/url (the interface declares them; they
34
+ // were never populated before). Matches the python SWAGGER_CONTACT_* convention.
31
35
  const contactEmail = (process.env.TINA4_SWAGGER_CONTACT_EMAIL ?? "").trim();
32
- if (contactEmail.length > 0) {
33
- info.contact = { email: contactEmail };
34
- }
36
+ const contactName = (process.env.TINA4_SWAGGER_CONTACT_TEAM ?? "").trim();
37
+ const contactUrl = (process.env.TINA4_SWAGGER_CONTACT_URL ?? "").trim();
38
+ const contact: { name?: string; url?: string; email?: string } = {};
39
+ if (contactName.length > 0) contact.name = contactName;
40
+ if (contactUrl.length > 0) contact.url = contactUrl;
41
+ if (contactEmail.length > 0) contact.email = contactEmail;
42
+ if (Object.keys(contact).length > 0) info.contact = contact;
35
43
 
36
44
  // Optional license — accepts a plain SPDX identifier ("MIT", "Apache-2.0")
37
45
  // or a "Name|URL" pair. Empty string disables license output entirely.
@@ -44,8 +52,16 @@ export function generate(
44
52
  const spec: OpenAPISpec = {
45
53
  openapi: "3.0.3",
46
54
  info,
55
+ servers: resolveServers(),
47
56
  paths: {},
48
- components: { schemas: {} },
57
+ components: {
58
+ 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
+ },
64
+ },
49
65
  };
50
66
 
51
67
  // Generate schemas from models
@@ -54,6 +70,9 @@ export function generate(
54
70
  spec.components!.schemas![model.tableName] = schema;
55
71
  }
56
72
 
73
+ const usedTags: string[] = [];
74
+ const seenIds = new Set<string>();
75
+
57
76
  // Generate paths from routes
58
77
  for (const route of routes) {
59
78
  const openApiPath = patternToOpenAPI(route.pattern);
@@ -63,14 +82,23 @@ export function generate(
63
82
  spec.paths[openApiPath] = {};
64
83
  }
65
84
 
85
+ const tags = route.meta?.tags ?? inferTags(route.pattern);
86
+ for (const t of tags) {
87
+ if (!usedTags.includes(t)) usedTags.push(t);
88
+ }
89
+
66
90
  const operation: Record<string, unknown> = {
91
+ operationId: uniqueOperationId(method, openApiPath, seenIds),
67
92
  summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
68
- tags: route.meta?.tags ?? inferTags(route.pattern),
93
+ tags,
69
94
  responses: route.meta?.responses ?? {
70
95
  "200": { description: "Successful response" },
71
96
  },
72
97
  };
73
98
 
99
+ if (route.meta?.description) operation.description = route.meta.description;
100
+ if (route.meta?.deprecated) operation.deprecated = true;
101
+
74
102
  // Add path parameters
75
103
  const pathParams = extractPathParams(route.pattern);
76
104
  if (pathParams.length > 0) {
@@ -99,13 +127,13 @@ export function generate(
99
127
  if (method === "post" || method === "put") {
100
128
  const modelName = inferModelFromPath(route.pattern);
101
129
  if (modelName && models.some((m) => m.tableName === modelName)) {
130
+ const media: Record<string, unknown> = {
131
+ schema: { $ref: `#/components/schemas/${modelName}` },
132
+ };
133
+ if (route.meta?.example !== undefined) media.example = route.meta.example;
102
134
  operation.requestBody = {
103
135
  required: true,
104
- content: {
105
- "application/json": {
106
- schema: { $ref: `#/components/schemas/${modelName}` },
107
- },
108
- },
136
+ content: { "application/json": media },
109
137
  };
110
138
 
111
139
  // Add response schema
@@ -115,15 +143,47 @@ export function generate(
115
143
  : { "200": { description: "Updated", content: { "application/json": { schema: { $ref: `#/components/schemas/${modelName}` } } } } }),
116
144
  "422": { description: "Validation failed" },
117
145
  };
146
+ } else if (route.meta?.example !== undefined) {
147
+ // Non-model body with an explicit example.
148
+ operation.requestBody = {
149
+ content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } },
150
+ };
118
151
  }
119
152
  }
120
153
 
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: [] }];
159
+ const responses = operation.responses as Record<string, unknown>;
160
+ if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
161
+ }
162
+
121
163
  spec.paths[openApiPath][method] = operation;
122
164
  }
123
165
 
166
+ if (usedTags.length > 0) {
167
+ spec.tags = usedTags.map((name) => ({ name }));
168
+ }
169
+
124
170
  return spec;
125
171
  }
126
172
 
173
+ function routeRequiresAuth(route: RouteDefinition, method: string): boolean {
174
+ if (route.noAuth) return false;
175
+ if (WRITE_METHODS.has(method)) return true; // secure by default (router parity)
176
+ return route.secure === true;
177
+ }
178
+
179
+ function resolveServers(): { url: string }[] {
180
+ const raw = (process.env.TINA4_SWAGGER_SERVERS ?? "").trim();
181
+ const urls = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
182
+ if (urls.length > 0) return urls.map((url) => ({ url }));
183
+ const dev = (process.env.SWAGGER_DEV_URL ?? "").trim();
184
+ return dev.length > 0 ? [{ url: dev }] : [{ url: "/" }];
185
+ }
186
+
127
187
  function modelToSchema(model: ModelDefinition): Record<string, unknown> {
128
188
  const properties: Record<string, unknown> = {};
129
189
  const required: string[] = [];
@@ -171,8 +231,16 @@ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
171
231
  prop.type = "string";
172
232
  prop.format = "date-time";
173
233
  break;
234
+ case "foreignKey":
235
+ // A foreign-key column is an integer reference. Before, it had no case
236
+ // and produced an empty {} schema (audit P2).
237
+ prop.type = "integer";
238
+ break;
239
+ default:
240
+ prop.type = "string";
174
241
  }
175
242
 
243
+ if (def.default !== undefined) prop.default = def.default;
176
244
  if (def.primaryKey && def.autoIncrement) {
177
245
  prop.readOnly = true;
178
246
  }
@@ -180,6 +248,22 @@ function fieldToSchemaProperty(def: FieldDefinition): Record<string, unknown> {
180
248
  return prop;
181
249
  }
182
250
 
251
+ function inferSchema(value: unknown): Record<string, unknown> {
252
+ if (Array.isArray(value)) {
253
+ return { type: "array", items: value.length > 0 ? inferSchema(value[0]) : {} };
254
+ }
255
+ if (value !== null && typeof value === "object") {
256
+ const properties: Record<string, unknown> = {};
257
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
258
+ properties[k] = inferSchema(v);
259
+ }
260
+ return { type: "object", properties };
261
+ }
262
+ if (typeof value === "boolean") return { type: "boolean" };
263
+ if (typeof value === "number") return { type: Number.isInteger(value) ? "integer" : "number" };
264
+ return { type: "string" };
265
+ }
266
+
183
267
  function patternToOpenAPI(pattern: string): string {
184
268
  return pattern.replace(/\[\.\.\.(\w+)\]/g, "{$1}").replace(/\[(\w+)\]/g, "{$1}");
185
269
  }
@@ -207,8 +291,27 @@ function inferTags(pattern: string): string[] {
207
291
  function inferModelFromPath(pattern: string): string | null {
208
292
  const parts = pattern.split("/").filter(Boolean);
209
293
  const apiIndex = parts.indexOf("api");
210
- if (apiIndex !== -1 && parts[apiIndex + 1]) {
211
- return parts[apiIndex + 1];
212
- }
294
+ if (apiIndex === -1 || !parts[apiIndex + 1]) return null;
295
+ const candidate = parts[apiIndex + 1];
296
+ // Only a SIMPLE resource binds a model: /api/<model> or /api/<model>/[id].
297
+ // A deeper nested path (/api/users/[id]/comments) must NOT attach the parent
298
+ // resource's body/schema to the sub-resource endpoint (audit P2).
299
+ const rest = parts.slice(apiIndex + 2);
300
+ if (rest.length === 0) return candidate;
301
+ if (rest.length === 1 && /^[[{]\.{0,3}\w+[\]}]$/.test(rest[0])) return candidate;
213
302
  return null;
214
303
  }
304
+
305
+ function uniqueOperationId(method: string, openApiPath: string, seen: Set<string>): string {
306
+ const base = (method + openApiPath.replace(/[/{}]/g, "_"))
307
+ .replace(/_+/g, "_")
308
+ .replace(/_$/, "");
309
+ let oid = base;
310
+ let n = 2;
311
+ while (seen.has(oid)) {
312
+ oid = `${base}_${n}`;
313
+ n += 1;
314
+ }
315
+ seen.add(oid);
316
+ return oid;
317
+ }
@@ -1,11 +1,19 @@
1
1
  import type { Tina4Request, Tina4Response, RouteDefinition } from "@tina4/core";
2
2
 
3
+ // The UI assets load from a CDN by default (a documented architecture decision —
4
+ // we don't vendor ~1.4MB of swagger-ui-dist, to stay small). Air-gapped
5
+ // deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror (a base URL
6
+ // serving swagger-ui.css + swagger-ui-bundle.js).
7
+ function swaggerUiCdn(): string {
8
+ return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://unpkg.com/swagger-ui-dist@5").replace(/\/+$/, "");
9
+ }
10
+
3
11
  const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
4
12
  <html lang="en">
5
13
  <head>
6
14
  <meta charset="UTF-8">
7
15
  <title>Tina4 API Documentation</title>
8
- <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
16
+ <link rel="stylesheet" href="${swaggerUiCdn()}/swagger-ui.css">
9
17
  <style>
10
18
  body { margin: 0; background: #fafafa; }
11
19
  .topbar { display: none !important; }
@@ -13,7 +21,7 @@ const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
13
21
  </head>
14
22
  <body>
15
23
  <div id="swagger-ui"></div>
16
- <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
24
+ <script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
17
25
  <script>
18
26
  SwaggerUIBundle({
19
27
  url: "${specUrl}",