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.
- package/CLAUDE.md +38 -2
- package/package.json +1 -1
- package/packages/core/src/auth.ts +4 -1
- package/packages/core/src/devAdmin.ts +56 -3
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/mcp.ts +100 -24
- package/packages/core/src/server.ts +7 -2
- package/packages/core/src/sessionHandlers/mongoHandler.ts +2 -0
- package/packages/core/src/types.ts +4 -0
- package/packages/orm/src/docstore.ts +819 -0
- package/packages/orm/src/index.ts +10 -0
- package/packages/swagger/src/generator.ts +119 -16
- package/packages/swagger/src/ui.ts +10 -2
|
@@ -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
|
|
30
|
-
//
|
|
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
|
-
|
|
33
|
-
|
|
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: {
|
|
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
|
|
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
|
|
211
|
-
|
|
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="
|
|
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="
|
|
24
|
+
<script src="${swaggerUiCdn()}/swagger-ui-bundle.js"></script>
|
|
17
25
|
<script>
|
|
18
26
|
SwaggerUIBundle({
|
|
19
27
|
url: "${specUrl}",
|