tina4-nodejs 3.13.62 → 3.13.64
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/package.json
CHANGED
package/packages/cli/src/bin.ts
CHANGED
|
@@ -35,14 +35,25 @@ const HELP = `
|
|
|
35
35
|
|
|
36
36
|
Generators:
|
|
37
37
|
tina4nodejs generate model <Name> [--fields "name:string,price:float"]
|
|
38
|
-
tina4nodejs generate route <name> [--model Name]
|
|
39
|
-
tina4nodejs generate crud <Name> [--fields "..."]
|
|
38
|
+
tina4nodejs generate route <name> [--model Name] [--public] Writes secure by default; --public opens them
|
|
39
|
+
tina4nodejs generate crud <Name> [--fields "..."] [--public] Model + migration + routes + form + view + test
|
|
40
40
|
tina4nodejs generate migration <description>
|
|
41
41
|
tina4nodejs generate middleware <Name>
|
|
42
42
|
tina4nodejs generate test <name>
|
|
43
43
|
tina4nodejs generate form <Name> [--fields "..."] Form template with inputs matching model fields
|
|
44
44
|
tina4nodejs generate view <Name> [--fields "..."] List + detail templates for viewing records
|
|
45
|
-
tina4nodejs generate auth Login/register
|
|
45
|
+
tina4nodejs generate auth Login/register routes (public) + User model + templates
|
|
46
|
+
tina4nodejs generate service <Name> [--every 5m | --cron "..."] Scheduled ServiceRunner task (src/services/)
|
|
47
|
+
tina4nodejs generate queue <topic> Producer + consumer daemon worker (src/services/)
|
|
48
|
+
tina4nodejs generate validator <Name> Request-body Validator (src/validators/)
|
|
49
|
+
tina4nodejs generate seeder <Model> FakeData + seedOrm seeder (src/seeds/)
|
|
50
|
+
tina4nodejs generate websocket <path> websocket() handler (src/routes/)
|
|
51
|
+
tina4nodejs generate listener <event> Events.on(event) listener (src/listeners/)
|
|
52
|
+
|
|
53
|
+
Scaffolding-first: logic-shaped generators (route without --model, service,
|
|
54
|
+
queue, validator, seeder, websocket, listener) emit real wiring + an AI-FILL
|
|
55
|
+
placeholder (throws until filled); CRUD-shaped ones emit working code. Writes
|
|
56
|
+
are secure by default — use --public to open them.
|
|
46
57
|
|
|
47
58
|
Field types: string, int, float, bool, text, datetime
|
|
48
59
|
Table names: singular by default (Product → product)
|
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI command: generate — Rich scaffolding
|
|
3
|
-
*
|
|
2
|
+
* CLI command: generate — Rich, scaffolding-first code generation.
|
|
3
|
+
*
|
|
4
|
+
* Two shapes of generator:
|
|
5
|
+
* • CRUD-shaped (model, crud, migration, middleware, form, view, test, auth):
|
|
6
|
+
* emit WORKING code — the boilerplate IS the feature. A working extension
|
|
7
|
+
* point is flagged with a light `EXTEND:` marker (no throw).
|
|
8
|
+
* • LOGIC-shaped (custom route body, service, queue, validator, seeder,
|
|
9
|
+
* websocket, listener): emit the real WIRING (imports + registration +
|
|
10
|
+
* signature) plus a single greppable `AI-FILL` fill-spec that ends in
|
|
11
|
+
* `throw new Error("… not implemented")`, so an unfilled scaffold fails LOUD.
|
|
12
|
+
*
|
|
13
|
+
* Secure by default: the router gates every scaffolded WRITE (POST/PUT/DELETE)
|
|
14
|
+
* behind a Bearer token automatically; reads (GET) are public automatically.
|
|
15
|
+
* `--public` re-adds the opt-out (`export const secure = false;`) on the WRITE
|
|
16
|
+
* method files only — mirroring the AutoCrud `public` opt-in.
|
|
4
17
|
*
|
|
5
18
|
* Usage:
|
|
6
19
|
* tina4nodejs generate model Product --fields "name:string,price:float"
|
|
7
|
-
* tina4nodejs generate route products --model Product
|
|
8
|
-
* tina4nodejs generate crud Product --fields "name:string,price:float"
|
|
20
|
+
* tina4nodejs generate route products --model Product [--public]
|
|
21
|
+
* tina4nodejs generate crud Product --fields "name:string,price:float" [--public]
|
|
9
22
|
* tina4nodejs generate migration create_product
|
|
10
23
|
* tina4nodejs generate middleware Auth
|
|
11
24
|
* tina4nodejs generate test products --model Product
|
|
12
25
|
* tina4nodejs generate form Product --fields "name:string,price:float"
|
|
13
26
|
* tina4nodejs generate view Product --fields "name:string,price:float"
|
|
14
27
|
* tina4nodejs generate auth
|
|
28
|
+
* tina4nodejs generate service Cleanup --every 5m | --cron "0 3 * * *"
|
|
29
|
+
* tina4nodejs generate queue order-emails
|
|
30
|
+
* tina4nodejs generate validator CreateUser
|
|
31
|
+
* tina4nodejs generate seeder Product
|
|
32
|
+
* tina4nodejs generate websocket chat
|
|
33
|
+
* tina4nodejs generate listener user.created
|
|
15
34
|
*/
|
|
16
|
-
import { existsSync, mkdirSync,
|
|
35
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
36
|
import { join, resolve } from "node:path";
|
|
18
37
|
|
|
19
38
|
// ── Field type mapping ──────────────────────────────────────────────
|
|
@@ -72,6 +91,15 @@ function toCamel(name: string): string {
|
|
|
72
91
|
return name.charAt(0).toLowerCase() + name.slice(1);
|
|
73
92
|
}
|
|
74
93
|
|
|
94
|
+
/** slug-of-anything → PascalCase (order-emails → OrderEmails). */
|
|
95
|
+
export function toPascal(name: string): string {
|
|
96
|
+
return name
|
|
97
|
+
.split(/[^0-9a-zA-Z]+/)
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
76
104
|
if (!fieldsStr || !fieldsStr.trim()) return [];
|
|
77
105
|
const result: Array<[string, string]> = [];
|
|
@@ -79,7 +107,7 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
|
79
107
|
const trimmed = part.trim();
|
|
80
108
|
if (trimmed.includes(":")) {
|
|
81
109
|
const [fname, ftype] = trimmed.split(":", 2);
|
|
82
|
-
result.push([fname.trim(), ftype.trim().toLowerCase()]);
|
|
110
|
+
if (fname.trim()) result.push([fname.trim(), ftype.trim().toLowerCase()]);
|
|
83
111
|
} else if (trimmed) {
|
|
84
112
|
result.push([trimmed, "string"]);
|
|
85
113
|
}
|
|
@@ -88,8 +116,11 @@ export function parseFields(fieldsStr: string): Array<[string, string]> {
|
|
|
88
116
|
}
|
|
89
117
|
|
|
90
118
|
export function parseCliArgs(args: string[]): { flags: Record<string, string | boolean>; positional: string[] } {
|
|
91
|
-
// Boolean-only flags that never take a value argument
|
|
92
|
-
const booleanFlags = new Set([
|
|
119
|
+
// Boolean-only flags that never take a value argument.
|
|
120
|
+
const booleanFlags = new Set([
|
|
121
|
+
"no-browser", "no-reload", "production", "managed", "all", "clear",
|
|
122
|
+
"public", "no-migration",
|
|
123
|
+
]);
|
|
93
124
|
|
|
94
125
|
const flags: Record<string, string | boolean> = {};
|
|
95
126
|
const positional: string[] = [];
|
|
@@ -115,6 +146,59 @@ export function parseCliArgs(args: string[]): { flags: Record<string, string | b
|
|
|
115
146
|
return { flags, positional };
|
|
116
147
|
}
|
|
117
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Parse a `--every` duration ("5m", "30s", "2h", "1d", or bare seconds) → seconds.
|
|
151
|
+
* Falls back to 60s on an empty/unparseable value so a scaffold always has a
|
|
152
|
+
* valid ServiceRunner interval.
|
|
153
|
+
*/
|
|
154
|
+
export function parseEvery(every: string | boolean | undefined): number {
|
|
155
|
+
if (!every || every === true) return 60;
|
|
156
|
+
const s = String(every).trim().toLowerCase();
|
|
157
|
+
const units: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
158
|
+
const unit = s.slice(-1);
|
|
159
|
+
if (unit in units) {
|
|
160
|
+
const n = parseFloat(s.slice(0, -1));
|
|
161
|
+
return Number.isFinite(n) ? Math.max(1, Math.round(n * units[unit])) : 60;
|
|
162
|
+
}
|
|
163
|
+
const n = parseFloat(s);
|
|
164
|
+
return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 60;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The canonical AI-FILL placeholder for a LOGIC-shaped stub — a tight, grounded
|
|
169
|
+
* fill-spec (not a vague `// TODO`) so a coding agent (or dev) completes it
|
|
170
|
+
* correctly. `throw new Error(...)` makes an unfilled scaffold fail LOUD; the
|
|
171
|
+
* greppable `AI-FILL` banner lets a human/agent jump to every gap. `use` names
|
|
172
|
+
* only REAL tina4-nodejs symbols (verified in source).
|
|
173
|
+
*/
|
|
174
|
+
export function aiFill(
|
|
175
|
+
fn: string,
|
|
176
|
+
spec: { intent: string; given?: string; use: string; ret?: string; ground: string; raise: string },
|
|
177
|
+
indent = " ",
|
|
178
|
+
): string {
|
|
179
|
+
const rule = (label: string) => "─".repeat(Math.max(4, 46 - label.length));
|
|
180
|
+
const lines = [`${indent}// ─── AI-FILL: ${fn} ${rule(fn)}`];
|
|
181
|
+
lines.push(`${indent}// Intent: ${spec.intent}`);
|
|
182
|
+
if (spec.given) lines.push(`${indent}// Given: ${spec.given}`);
|
|
183
|
+
lines.push(`${indent}// Use: ${spec.use}`);
|
|
184
|
+
if (spec.ret) lines.push(`${indent}// Return: ${spec.ret}`);
|
|
185
|
+
lines.push(`${indent}// Ground: ${spec.ground}`);
|
|
186
|
+
lines.push(`${indent}throw new Error(${JSON.stringify(spec.raise)}); // remove when implemented`);
|
|
187
|
+
lines.push(`${indent}// ${"─".repeat(52)}`);
|
|
188
|
+
return lines.join("\n") + "\n";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The lighter EXTEND marker for CRUD-shaped WORKING code — no throw (the
|
|
193
|
+
* boilerplate IS the feature); just a greppable hint at the natural extension
|
|
194
|
+
* point (custom validation / business rules / authorization).
|
|
195
|
+
*/
|
|
196
|
+
export function extend(note: string, hint = "", indent = " "): string {
|
|
197
|
+
let out = `${indent}// ─── EXTEND: ${note} ${"─".repeat(Math.max(4, 46 - note.length))}\n`;
|
|
198
|
+
if (hint) out += `${indent}// ${hint}\n`;
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
118
202
|
function timestamp(): string {
|
|
119
203
|
const now = new Date();
|
|
120
204
|
return (
|
|
@@ -131,13 +215,19 @@ function isoNow(): string {
|
|
|
131
215
|
return new Date().toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
|
|
132
216
|
}
|
|
133
217
|
|
|
218
|
+
const GENERATORS =
|
|
219
|
+
"model, route, crud, migration, middleware, test, form, view, auth, " +
|
|
220
|
+
"service, queue, validator, seeder, websocket, listener";
|
|
221
|
+
|
|
134
222
|
// ── Main entry point ────────────────────────────────────────────────
|
|
135
223
|
|
|
136
224
|
export async function generate(what: string, name: string, extraArgs: string[] = []): Promise<void> {
|
|
137
225
|
if (!what) {
|
|
138
226
|
console.error(" Usage: tina4nodejs generate <what> <name> [options]");
|
|
139
|
-
console.error(
|
|
227
|
+
console.error(` Generators: ${GENERATORS}`);
|
|
140
228
|
console.error(' Options: --fields "name:string,price:float" --model ModelName');
|
|
229
|
+
console.error(" --public open a route's writes (default: secure)");
|
|
230
|
+
console.error(' --every 5m | --cron "…" service schedule');
|
|
141
231
|
process.exit(1);
|
|
142
232
|
}
|
|
143
233
|
|
|
@@ -178,9 +268,27 @@ export async function generate(what: string, name: string, extraArgs: string[] =
|
|
|
178
268
|
case "auth":
|
|
179
269
|
generateAuth(flags);
|
|
180
270
|
break;
|
|
271
|
+
case "service":
|
|
272
|
+
generateService(name, flags);
|
|
273
|
+
break;
|
|
274
|
+
case "queue":
|
|
275
|
+
generateQueue(name, flags);
|
|
276
|
+
break;
|
|
277
|
+
case "validator":
|
|
278
|
+
generateValidator(name, flags);
|
|
279
|
+
break;
|
|
280
|
+
case "seeder":
|
|
281
|
+
generateSeeder(name, flags);
|
|
282
|
+
break;
|
|
283
|
+
case "websocket":
|
|
284
|
+
generateWebsocket(name, flags);
|
|
285
|
+
break;
|
|
286
|
+
case "listener":
|
|
287
|
+
generateListener(name, flags);
|
|
288
|
+
break;
|
|
181
289
|
default:
|
|
182
290
|
console.error(` Unknown generator: ${what}`);
|
|
183
|
-
console.error(
|
|
291
|
+
console.error(` Available: ${GENERATORS}`);
|
|
184
292
|
process.exit(1);
|
|
185
293
|
}
|
|
186
294
|
}
|
|
@@ -208,7 +316,9 @@ function generateModel(name: string, flags: Record<string, string | boolean>): v
|
|
|
208
316
|
}
|
|
209
317
|
fieldLines.push(` created_at: { type: "datetime" as const },`);
|
|
210
318
|
|
|
211
|
-
|
|
319
|
+
// BaseModel is exported from tina4-nodejs/orm (NOT the core "tina4-nodejs"
|
|
320
|
+
// entry) — importing it from the core entry yields `undefined` at runtime.
|
|
321
|
+
const content = `import { BaseModel } from "tina4-nodejs/orm";
|
|
212
322
|
|
|
213
323
|
export default class ${name} extends BaseModel {
|
|
214
324
|
static tableName = "${table}";
|
|
@@ -227,34 +337,52 @@ ${fieldLines.join("\n")}
|
|
|
227
337
|
}
|
|
228
338
|
|
|
229
339
|
// ── Route ───────────────────────────────────────────────────────────
|
|
340
|
+
//
|
|
341
|
+
// Secure by default. The router marks POST/PUT/DELETE `secure: true` unless the
|
|
342
|
+
// route def opts out; route discovery reads a module's `export const secure`
|
|
343
|
+
// into that def. So a scaffolded write is Bearer-gated with NOTHING emitted;
|
|
344
|
+
// `--public` emits `export const secure = false;` on the write files only.
|
|
345
|
+
|
|
346
|
+
/** The opt-out line for a WRITE method file when --public is set (else ""). */
|
|
347
|
+
function secureOptOut(isPublic: boolean): string {
|
|
348
|
+
return isPublic ? `export const secure = false;\n\n` : "";
|
|
349
|
+
}
|
|
230
350
|
|
|
231
351
|
function generateRoute(name: string, flags: Record<string, string | boolean>): void {
|
|
232
352
|
const routePath = name.replace(/^\//, "");
|
|
233
353
|
const singular = routePath.endsWith("s") ? routePath.slice(0, -1) : routePath;
|
|
234
354
|
const model = flags.model as string | undefined;
|
|
355
|
+
const isPublic = Boolean(flags.public);
|
|
235
356
|
const base = resolve("src/routes/api", routePath);
|
|
236
357
|
const idDir = join(base, "[id]");
|
|
237
358
|
ensureDir(base);
|
|
238
359
|
ensureDir(idDir);
|
|
239
360
|
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
361
|
+
const table = model ? toTableName(model) : "";
|
|
362
|
+
// Model import path is RELATIVE to the route file's directory. Files directly
|
|
363
|
+
// under src/routes/api/<name>/ are 3 levels above src/models/; the [id]/ files
|
|
364
|
+
// are one deeper (4 levels).
|
|
365
|
+
const modelImportBase = model ? `import ${model} from "../../../models/${model}.js";\n` : "";
|
|
366
|
+
const modelImportId = model ? `import ${model} from "../../../../models/${model}.js";\n` : "";
|
|
367
|
+
|
|
368
|
+
const writeDoc = isPublic
|
|
369
|
+
? "Public (--public): no token required."
|
|
370
|
+
: "Secure by default: requires a Bearer token (use --public to open).";
|
|
243
371
|
|
|
244
|
-
// GET list
|
|
372
|
+
// ── GET list (public) ──────────────────────────────────────────────
|
|
245
373
|
if (model) {
|
|
246
374
|
writeFileSafe(
|
|
247
375
|
join(base, "get.ts"),
|
|
248
376
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
249
|
-
${
|
|
377
|
+
${modelImportBase}
|
|
250
378
|
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
251
379
|
|
|
252
380
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
253
|
-
const page = parseInt(req.query
|
|
254
|
-
const limit = parseInt(req.query
|
|
381
|
+
const page = parseInt(req.query.page as string) || 1;
|
|
382
|
+
const limit = parseInt(req.query.limit as string) || 20;
|
|
255
383
|
const offset = (page - 1) * limit;
|
|
256
|
-
const
|
|
257
|
-
res.json({ data:
|
|
384
|
+
const rows = await ${model}.select("SELECT * FROM ${table} LIMIT ? OFFSET ?", [limit, offset]);
|
|
385
|
+
res.json({ data: rows.map((r) => r.toObject()), page, limit });
|
|
258
386
|
}
|
|
259
387
|
`,
|
|
260
388
|
);
|
|
@@ -266,24 +394,31 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
266
394
|
export const meta = { summary: "List all ${routePath}", tags: ["${routePath}"] };
|
|
267
395
|
|
|
268
396
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
269
|
-
|
|
270
|
-
}
|
|
397
|
+
${aiFill(`list_${routePath}`, {
|
|
398
|
+
intent: `return the ${routePath} collection (add pagination if it grows)`,
|
|
399
|
+
given: "req.query -> filters/paging",
|
|
400
|
+
use: `Model.select("SELECT … LIMIT ? OFFSET ?", [limit, offset]) then r.toObject()`,
|
|
401
|
+
ret: "res.json({ data: rows })",
|
|
402
|
+
ground: `tina4_context("list ORM records with pagination", "nodejs") · skill tina4-developer-nodejs`,
|
|
403
|
+
raise: `${routePath} list not implemented`,
|
|
404
|
+
})}}
|
|
271
405
|
`,
|
|
272
406
|
);
|
|
273
407
|
}
|
|
274
408
|
|
|
275
|
-
// POST create
|
|
409
|
+
// ── POST create (secure by default; --public opens it) ─────────────
|
|
276
410
|
if (model) {
|
|
277
411
|
writeFileSafe(
|
|
278
412
|
join(base, "post.ts"),
|
|
279
413
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
280
|
-
${
|
|
281
|
-
export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
414
|
+
${modelImportBase}${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
282
415
|
|
|
416
|
+
// ${writeDoc}
|
|
283
417
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
284
|
-
|
|
418
|
+
${extend("validate / business rules before persist",
|
|
419
|
+
`e.g. reject invalid input; ground: tina4_context("validate before create", "nodejs")`)} const item = new ${model}(req.body as Record<string, unknown>);
|
|
285
420
|
await item.save();
|
|
286
|
-
res.json({ data: item.
|
|
421
|
+
res.json({ data: item.toObject() }, 201);
|
|
287
422
|
}
|
|
288
423
|
`,
|
|
289
424
|
);
|
|
@@ -292,31 +427,38 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
292
427
|
join(base, "post.ts"),
|
|
293
428
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
294
429
|
|
|
295
|
-
export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
430
|
+
${secureOptOut(isPublic)}export const meta = { summary: "Create a new ${singular}", tags: ["${routePath}"] };
|
|
296
431
|
|
|
432
|
+
// ${writeDoc}
|
|
297
433
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
298
|
-
|
|
299
|
-
}
|
|
434
|
+
${aiFill(`create_${singular}`, {
|
|
435
|
+
intent: `validate the body and persist a new ${singular}`,
|
|
436
|
+
given: "req.body -> the posted fields",
|
|
437
|
+
use: "new Model(req.body).save() then item.toObject() (import your model)",
|
|
438
|
+
ret: "res.json({ data: item }, 201)",
|
|
439
|
+
ground: `tina4_context("create ORM record and return 201", "nodejs") · skill tina4-developer-nodejs`,
|
|
440
|
+
raise: `create ${singular} not implemented`,
|
|
441
|
+
})}}
|
|
300
442
|
`,
|
|
301
443
|
);
|
|
302
444
|
}
|
|
303
445
|
|
|
304
|
-
// GET by id
|
|
446
|
+
// ── GET by id (public) ─────────────────────────────────────────────
|
|
305
447
|
if (model) {
|
|
306
448
|
writeFileSafe(
|
|
307
449
|
join(idDir, "get.ts"),
|
|
308
450
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
309
|
-
${
|
|
451
|
+
${modelImportId}
|
|
310
452
|
export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
|
|
311
453
|
|
|
312
454
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
313
455
|
const { id } = req.params;
|
|
314
|
-
const item = await ${model}.selectOne("SELECT * FROM ${
|
|
456
|
+
const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
315
457
|
if (!item) {
|
|
316
458
|
res.json({ error: "Not found" }, 404);
|
|
317
459
|
return;
|
|
318
460
|
}
|
|
319
|
-
res.json({ data: item });
|
|
461
|
+
res.json({ data: item.toObject() });
|
|
320
462
|
}
|
|
321
463
|
`,
|
|
322
464
|
);
|
|
@@ -328,31 +470,37 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
328
470
|
export const meta = { summary: "Get a ${singular} by ID", tags: ["${routePath}"] };
|
|
329
471
|
|
|
330
472
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
473
|
+
${aiFill(`get_${singular}`, {
|
|
474
|
+
intent: `fetch one ${singular} by id`,
|
|
475
|
+
given: "req.params.id -> the record id",
|
|
476
|
+
use: `Model.selectOne("SELECT … WHERE id = ?", [req.params.id])`,
|
|
477
|
+
ret: "res.json({ data: item }) or res.json({ error: 'Not found' }, 404)",
|
|
478
|
+
ground: `tina4_context("find ORM record by id", "nodejs") · skill tina4-developer-nodejs`,
|
|
479
|
+
raise: `get ${singular} not implemented`,
|
|
480
|
+
})}}
|
|
334
481
|
`,
|
|
335
482
|
);
|
|
336
483
|
}
|
|
337
484
|
|
|
338
|
-
// PUT by id
|
|
485
|
+
// ── PUT by id (secure by default; --public opens it) ───────────────
|
|
339
486
|
if (model) {
|
|
340
487
|
writeFileSafe(
|
|
341
488
|
join(idDir, "put.ts"),
|
|
342
489
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
343
|
-
${
|
|
344
|
-
export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
490
|
+
${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
345
491
|
|
|
492
|
+
// ${writeDoc}
|
|
346
493
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
347
494
|
const { id } = req.params;
|
|
348
|
-
const item = await ${model}.selectOne("SELECT * FROM ${
|
|
495
|
+
const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
349
496
|
if (!item) {
|
|
350
497
|
res.json({ error: "Not found" }, 404);
|
|
351
498
|
return;
|
|
352
499
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
500
|
+
${extend("guard which fields / who may update",
|
|
501
|
+
`e.g. enforce ownership; ground: tina4_context("authorize update", "nodejs")`)} Object.assign(item, req.body as Record<string, unknown>);
|
|
502
|
+
await item.save();
|
|
503
|
+
res.json({ data: item.toObject() });
|
|
356
504
|
}
|
|
357
505
|
`,
|
|
358
506
|
);
|
|
@@ -361,33 +509,38 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
361
509
|
join(idDir, "put.ts"),
|
|
362
510
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
363
511
|
|
|
364
|
-
export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
512
|
+
${secureOptOut(isPublic)}export const meta = { summary: "Update a ${singular} by ID", tags: ["${routePath}"] };
|
|
365
513
|
|
|
514
|
+
// ${writeDoc}
|
|
366
515
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
516
|
+
${aiFill(`update_${singular}`, {
|
|
517
|
+
intent: `load, mutate and save an existing ${singular}`,
|
|
518
|
+
given: "req.params.id -> id; req.body -> changed fields",
|
|
519
|
+
use: "Model.selectOne(…) then Object.assign(item, req.body) then item.save()",
|
|
520
|
+
ret: "res.json({ data: item }) or 404",
|
|
521
|
+
ground: `tina4_context("update ORM record", "nodejs") · skill tina4-developer-nodejs`,
|
|
522
|
+
raise: `update ${singular} not implemented`,
|
|
523
|
+
})}}
|
|
370
524
|
`,
|
|
371
525
|
);
|
|
372
526
|
}
|
|
373
527
|
|
|
374
|
-
// DELETE by id
|
|
528
|
+
// ── DELETE by id (secure by default; --public opens it) ────────────
|
|
375
529
|
if (model) {
|
|
376
530
|
writeFileSafe(
|
|
377
531
|
join(idDir, "delete.ts"),
|
|
378
532
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
379
|
-
${
|
|
380
|
-
export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
533
|
+
${modelImportId}${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
381
534
|
|
|
535
|
+
// ${writeDoc}
|
|
382
536
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
383
537
|
const { id } = req.params;
|
|
384
|
-
const item = await ${model}.selectOne("SELECT * FROM ${
|
|
538
|
+
const item = await ${model}.selectOne("SELECT * FROM ${table} WHERE id = ?", [id]);
|
|
385
539
|
if (!item) {
|
|
386
540
|
res.json({ error: "Not found" }, 404);
|
|
387
541
|
return;
|
|
388
542
|
}
|
|
389
|
-
|
|
390
|
-
await record.delete();
|
|
543
|
+
await item.delete();
|
|
391
544
|
res.json({ message: "deleted", id });
|
|
392
545
|
}
|
|
393
546
|
`,
|
|
@@ -397,12 +550,18 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
397
550
|
join(idDir, "delete.ts"),
|
|
398
551
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
399
552
|
|
|
400
|
-
export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
553
|
+
${secureOptOut(isPublic)}export const meta = { summary: "Delete a ${singular} by ID", tags: ["${routePath}"] };
|
|
401
554
|
|
|
555
|
+
// ${writeDoc}
|
|
402
556
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
557
|
+
${aiFill(`delete_${singular}`, {
|
|
558
|
+
intent: `delete a ${singular} by id`,
|
|
559
|
+
given: "req.params.id -> id",
|
|
560
|
+
use: "Model.selectOne(…) then item.delete()",
|
|
561
|
+
ret: "res.json({ message: 'deleted', id }) or 404",
|
|
562
|
+
ground: `tina4_context("delete ORM record", "nodejs") · skill tina4-developer-nodejs`,
|
|
563
|
+
raise: `delete ${singular} not implemented`,
|
|
564
|
+
})}}
|
|
406
565
|
`,
|
|
407
566
|
);
|
|
408
567
|
}
|
|
@@ -413,13 +572,15 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
413
572
|
function generateCrud(name: string, flags: Record<string, string | boolean>): void {
|
|
414
573
|
const table = toTableName(name);
|
|
415
574
|
const routeName = toPlural(table);
|
|
575
|
+
const isPublic = Boolean(flags.public);
|
|
416
576
|
|
|
417
577
|
console.log(`\n Generating CRUD for ${name}...\n`);
|
|
418
578
|
|
|
419
579
|
// 1. Model + migration
|
|
420
580
|
generateModel(name, flags);
|
|
421
581
|
|
|
422
|
-
// 2. Routes with model
|
|
582
|
+
// 2. Routes with model — secure by default; thread --public through so
|
|
583
|
+
// `generate crud X --public` opens the writes (mirrors AutoCrud public).
|
|
423
584
|
generateRoute(routeName, { ...flags, model: name });
|
|
424
585
|
|
|
425
586
|
// 3. Form
|
|
@@ -428,8 +589,8 @@ function generateCrud(name: string, flags: Record<string, string | boolean>): vo
|
|
|
428
589
|
// 4. View (list + detail)
|
|
429
590
|
generateView(name, flags);
|
|
430
591
|
|
|
431
|
-
// 5. Test
|
|
432
|
-
generateTest(routeName, {
|
|
592
|
+
// 5. Test — real secure-by-default boot-gate (reads public, writes gated).
|
|
593
|
+
generateTest(routeName, { model: name, "secure-writes": true, public: isPublic });
|
|
433
594
|
|
|
434
595
|
console.log(`\n CRUD generation complete for ${name}.`);
|
|
435
596
|
console.log(" Run: tina4nodejs migrate");
|
|
@@ -507,9 +668,8 @@ function generateMigration(
|
|
|
507
668
|
|
|
508
669
|
// ── Middleware ───────────────────────────────────────────────────────
|
|
509
670
|
|
|
510
|
-
function generateMiddleware(name: string,
|
|
671
|
+
function generateMiddleware(name: string, _flags: Record<string, string | boolean>): void {
|
|
511
672
|
const snake = toSnake(name);
|
|
512
|
-
const camel = toCamel(name);
|
|
513
673
|
const dir = resolve("src/middleware");
|
|
514
674
|
ensureDir(dir);
|
|
515
675
|
const path = join(dir, `${snake}.ts`);
|
|
@@ -560,10 +720,70 @@ function generateTest(name: string, flags: Record<string, string | boolean>): vo
|
|
|
560
720
|
ensureDir(dir);
|
|
561
721
|
const path = join(dir, `${snake}.test.ts`);
|
|
562
722
|
|
|
723
|
+
// Secure-by-default CRUD gate test (emitted by `generate crud`): a REAL
|
|
724
|
+
// boot-gate through TestClient over the generated file-based routes and a
|
|
725
|
+
// real SQLite DB — reads public, writes gated (or open under --public). No
|
|
726
|
+
// mocks: real Router, real auth gate, real DB. Grounded on
|
|
727
|
+
// test/testClientAuth.test.ts + test/autoCrud.test.ts.
|
|
728
|
+
if (model && flags["secure-writes"]) {
|
|
729
|
+
const isPublic = Boolean(flags.public);
|
|
730
|
+
const posture = isPublic ? "open (--public)" : "gated";
|
|
731
|
+
const writeCase = isPublic
|
|
732
|
+
? ` // --public opened the write: an anonymous POST creates -> 201.
|
|
733
|
+
assert("anonymous POST is public -> 201",
|
|
734
|
+
(await client.post("/api/${snake}", { json: { name: "test" } })).status === 201);`
|
|
735
|
+
: ` // Secure by default: a tokenless POST is rejected with 401.
|
|
736
|
+
assert("anonymous POST is gated -> 401",
|
|
737
|
+
(await client.post("/api/${snake}", { json: { name: "test" } })).status === 401);
|
|
738
|
+
// A valid Bearer token passes the gate and creates -> 201.
|
|
739
|
+
const token = getToken({ userId: 1 });
|
|
740
|
+
assert("authenticated POST creates -> 201",
|
|
741
|
+
(await client.post("/api/${snake}", { json: { name: "test" }, headers: { authorization: \`Bearer \${token}\` } })).status === 201);`;
|
|
742
|
+
|
|
743
|
+
const content = `/**
|
|
744
|
+
* ${name} CRUD — reads public, writes ${posture} (secure by default).
|
|
745
|
+
*
|
|
746
|
+
* Real end-to-end via TestClient: no mocks — real Router, real auth gate, real
|
|
747
|
+
* JWT, real SQLite DB + table. Run with: npx tsx tests/${snake}.test.ts
|
|
748
|
+
*/
|
|
749
|
+
import { dirname, resolve } from "node:path";
|
|
750
|
+
import { fileURLToPath } from "node:url";
|
|
751
|
+
import { Router, TestClient, getToken, discoverRoutes } from "tina4-nodejs";
|
|
752
|
+
import { initDatabase } from "tina4-nodejs/orm";
|
|
753
|
+
import ${model} from "../src/models/${model}.js";
|
|
754
|
+
|
|
755
|
+
process.env.TINA4_SECRET = process.env.TINA4_SECRET ?? "test-secret";
|
|
756
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
757
|
+
|
|
758
|
+
let pass = 0;
|
|
759
|
+
let fail = 0;
|
|
760
|
+
function assert(label: string, ok: boolean): void {
|
|
761
|
+
if (ok) { pass++; console.log(\` PASS \${label}\`); }
|
|
762
|
+
else { fail++; console.log(\` FAIL \${label}\`); }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
await initDatabase({ url: "sqlite:///data/test_${snake}.db" });
|
|
766
|
+
await ${model}.createTable();
|
|
767
|
+
|
|
768
|
+
const router = new Router();
|
|
769
|
+
for (const def of await discoverRoutes(resolve(here, "../src/routes"))) router.addRoute(def);
|
|
770
|
+
const client = new TestClient(router);
|
|
771
|
+
|
|
772
|
+
// Reads are public.
|
|
773
|
+
assert("GET list is public -> 200", (await client.get("/api/${snake}")).status === 200);
|
|
774
|
+
${writeCase}
|
|
775
|
+
|
|
776
|
+
console.log(\`\\nResults: \${pass} passed, \${fail} failed\`);
|
|
777
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
778
|
+
`;
|
|
779
|
+
writeFileSafe(path, content);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
563
783
|
let content: string;
|
|
564
784
|
|
|
565
785
|
if (model) {
|
|
566
|
-
content = `import { tests, assertTrue
|
|
786
|
+
content = `import { tests, assertTrue } from "tina4-nodejs";
|
|
567
787
|
|
|
568
788
|
/**
|
|
569
789
|
* Tests for ${name} CRUD operations.
|
|
@@ -603,10 +823,12 @@ const delete${model} = tests(
|
|
|
603
823
|
// TODO: implement delete test
|
|
604
824
|
return true;
|
|
605
825
|
});
|
|
826
|
+
|
|
827
|
+
void [list${model}s, get${model}, create${model}, update${model}, delete${model}];
|
|
606
828
|
`;
|
|
607
829
|
} else {
|
|
608
830
|
const titleName = name.charAt(0).toUpperCase() + name.slice(1);
|
|
609
|
-
content = `import { tests, assertTrue
|
|
831
|
+
content = `import { tests, assertTrue } from "tina4-nodejs";
|
|
610
832
|
|
|
611
833
|
/**
|
|
612
834
|
* Tests for ${name}.
|
|
@@ -618,10 +840,13 @@ const test${titleName} = tests(
|
|
|
618
840
|
// TODO: implement test
|
|
619
841
|
return true;
|
|
620
842
|
});
|
|
843
|
+
|
|
844
|
+
void test${titleName};
|
|
621
845
|
`;
|
|
622
846
|
}
|
|
623
847
|
|
|
624
848
|
writeFileSafe(path, content);
|
|
849
|
+
void singular;
|
|
625
850
|
}
|
|
626
851
|
|
|
627
852
|
// ── Form ────────────────────────────────────────────────────────────
|
|
@@ -774,15 +999,18 @@ function generateView(name: string, flags: Record<string, string | boolean>): vo
|
|
|
774
999
|
writeFileSafe(detailPath, detailContent);
|
|
775
1000
|
}
|
|
776
1001
|
|
|
777
|
-
// ── Auth
|
|
1002
|
+
// ── Auth (login/register stay PUBLIC) ───────────────────────────────
|
|
778
1003
|
|
|
779
|
-
function generateAuth(
|
|
1004
|
+
function generateAuth(_flags: Record<string, string | boolean>): void {
|
|
780
1005
|
console.log("\n Generating authentication scaffolding...\n");
|
|
781
1006
|
|
|
782
1007
|
// 1. User model + migration
|
|
783
1008
|
generateModel("User", { fields: "email:string,password:string,role:string" });
|
|
784
1009
|
|
|
785
|
-
// 2. Auth routes (file-based)
|
|
1010
|
+
// 2. Auth routes (file-based). register + login are genuinely public — the
|
|
1011
|
+
// user has no token yet — so they opt out of the write gate with
|
|
1012
|
+
// `export const secure = false;`. `me` is a GET (public route) that
|
|
1013
|
+
// authenticates the Bearer itself.
|
|
786
1014
|
const registerDir = resolve("src/routes/api/auth/register");
|
|
787
1015
|
const loginDir = resolve("src/routes/api/auth/login");
|
|
788
1016
|
const meDir = resolve("src/routes/api/auth/me");
|
|
@@ -790,47 +1018,53 @@ function generateAuth(flags: Record<string, string | boolean>): void {
|
|
|
790
1018
|
ensureDir(loginDir);
|
|
791
1019
|
ensureDir(meDir);
|
|
792
1020
|
|
|
793
|
-
// POST /api/auth/register
|
|
1021
|
+
// POST /api/auth/register (public)
|
|
794
1022
|
writeFileSafe(
|
|
795
1023
|
join(registerDir, "post.ts"),
|
|
796
1024
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1025
|
+
import { hashPassword } from "tina4-nodejs";
|
|
797
1026
|
import User from "../../../../models/User.js";
|
|
798
1027
|
|
|
1028
|
+
// Public: registration mints an account for a user who has no token yet.
|
|
1029
|
+
export const secure = false;
|
|
1030
|
+
|
|
799
1031
|
export const meta = { summary: "Register a new user", tags: ["auth"] };
|
|
800
1032
|
|
|
801
1033
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
802
|
-
const { email, password } = req.body
|
|
1034
|
+
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
803
1035
|
|
|
804
1036
|
if (!email || !password) {
|
|
805
1037
|
res.json({ error: "Email and password required" }, 400);
|
|
806
1038
|
return;
|
|
807
1039
|
}
|
|
808
1040
|
|
|
809
|
-
// Check if user exists
|
|
810
1041
|
const existing = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
|
|
811
1042
|
if (existing) {
|
|
812
1043
|
res.json({ error: "Email already registered" }, 409);
|
|
813
1044
|
return;
|
|
814
1045
|
}
|
|
815
1046
|
|
|
816
|
-
|
|
817
|
-
const user = new User({ email, password, role: "user" });
|
|
1047
|
+
const user = new User({ email, password: hashPassword(password), role: "user" });
|
|
818
1048
|
await user.save();
|
|
819
|
-
res.json({ message: "Registered", id: user.
|
|
1049
|
+
res.json({ message: "Registered", id: user.toObject().id }, 201);
|
|
820
1050
|
}
|
|
821
1051
|
`,
|
|
822
1052
|
);
|
|
823
1053
|
|
|
824
|
-
// POST /api/auth/login
|
|
1054
|
+
// POST /api/auth/login (public)
|
|
825
1055
|
writeFileSafe(
|
|
826
1056
|
join(loginDir, "post.ts"),
|
|
827
1057
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1058
|
+
import { checkPassword, getToken } from "tina4-nodejs";
|
|
828
1059
|
import User from "../../../../models/User.js";
|
|
829
1060
|
|
|
1061
|
+
// Public: login authenticates by password and mints the token.
|
|
1062
|
+
export const secure = false;
|
|
1063
|
+
|
|
830
1064
|
export const meta = { summary: "Login and receive JWT token", tags: ["auth"] };
|
|
831
1065
|
|
|
832
1066
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
833
|
-
const { email, password } = req.body
|
|
1067
|
+
const { email, password } = (req.body ?? {}) as { email?: string; password?: string };
|
|
834
1068
|
|
|
835
1069
|
if (!email || !password) {
|
|
836
1070
|
res.json({ error: "Email and password required" }, 400);
|
|
@@ -838,38 +1072,33 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
838
1072
|
}
|
|
839
1073
|
|
|
840
1074
|
const user = await User.selectOne("SELECT * FROM user WHERE email = ?", [email]);
|
|
841
|
-
if (!user) {
|
|
1075
|
+
if (!user || !checkPassword(password, user.toObject().password as string)) {
|
|
842
1076
|
res.json({ error: "Invalid credentials" }, 401);
|
|
843
1077
|
return;
|
|
844
1078
|
}
|
|
845
1079
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
return;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
res.json({ message: "Logged in", email: (user as any).email });
|
|
1080
|
+
const data = user.toObject();
|
|
1081
|
+
const token = getToken({ userId: data.id, email: data.email, role: data.role });
|
|
1082
|
+
res.json({ token });
|
|
853
1083
|
}
|
|
854
1084
|
`,
|
|
855
1085
|
);
|
|
856
1086
|
|
|
857
|
-
// GET /api/auth/me
|
|
1087
|
+
// GET /api/auth/me (public route; authenticates the Bearer itself)
|
|
858
1088
|
writeFileSafe(
|
|
859
1089
|
join(meDir, "get.ts"),
|
|
860
1090
|
`import type { Tina4Request, Tina4Response } from "tina4-nodejs";
|
|
1091
|
+
import { authenticateRequest } from "tina4-nodejs";
|
|
861
1092
|
|
|
862
1093
|
export const meta = { summary: "Get current authenticated user", tags: ["auth"] };
|
|
863
1094
|
|
|
864
1095
|
export default async function (req: Tina4Request, res: Tina4Response) {
|
|
865
|
-
const
|
|
866
|
-
if (!
|
|
1096
|
+
const payload = authenticateRequest(req.headers as Record<string, string | string[] | undefined>);
|
|
1097
|
+
if (!payload) {
|
|
867
1098
|
res.json({ error: "Unauthorized" }, 401);
|
|
868
1099
|
return;
|
|
869
1100
|
}
|
|
870
|
-
|
|
871
|
-
// In production, decode JWT and look up user
|
|
872
|
-
res.json({ message: "Authenticated user profile" });
|
|
1101
|
+
res.json({ user: payload });
|
|
873
1102
|
}
|
|
874
1103
|
`,
|
|
875
1104
|
);
|
|
@@ -934,7 +1163,330 @@ export default async function (req: Tina4Request, res: Tina4Response) {
|
|
|
934
1163
|
|
|
935
1164
|
console.log("\n Authentication scaffolding complete.");
|
|
936
1165
|
console.log(" Run: tina4nodejs migrate");
|
|
937
|
-
console.log(" POST /api/auth/register — create account");
|
|
938
|
-
console.log(" POST /api/auth/login — get JWT token");
|
|
1166
|
+
console.log(" POST /api/auth/register — create account (public)");
|
|
1167
|
+
console.log(" POST /api/auth/login — get JWT token (public)");
|
|
939
1168
|
console.log(" GET /api/auth/me — get profile (requires token)");
|
|
940
1169
|
}
|
|
1170
|
+
|
|
1171
|
+
// ── Service (scheduled background task — ServiceRunner) ──────────────
|
|
1172
|
+
//
|
|
1173
|
+
// Grounded on packages/core/src/service.ts: ServiceRunner.discover() imports
|
|
1174
|
+
// each src/services/*.ts and reads `mod.default ?? mod` for
|
|
1175
|
+
// { name, handler, interval?/timing?/daemon? }. handler receives a
|
|
1176
|
+
// ServiceContext ({ running, lastRun, name }).
|
|
1177
|
+
|
|
1178
|
+
function generateService(name: string, flags: Record<string, string | boolean>): void {
|
|
1179
|
+
const snake = toSnake(name);
|
|
1180
|
+
const camel = toCamel(toPascal(name)) || snake;
|
|
1181
|
+
const cron = flags.cron;
|
|
1182
|
+
const dir = resolve("src/services");
|
|
1183
|
+
ensureDir(dir);
|
|
1184
|
+
const path = join(dir, `${snake}.ts`);
|
|
1185
|
+
|
|
1186
|
+
let scheduleField: string;
|
|
1187
|
+
let note: string;
|
|
1188
|
+
if (cron && cron !== true) {
|
|
1189
|
+
scheduleField = ` timing: ${JSON.stringify(String(cron))},`;
|
|
1190
|
+
note = `cron '${cron}'`;
|
|
1191
|
+
} else {
|
|
1192
|
+
const seconds = parseEvery(flags.every);
|
|
1193
|
+
scheduleField = ` interval: ${seconds},`;
|
|
1194
|
+
note = `every ${seconds}s`;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const body = aiFill(`${camel}Task`, {
|
|
1198
|
+
intent: "do the scheduled work for this service",
|
|
1199
|
+
given: "context -> ServiceContext (.name, .running, .lastRun)",
|
|
1200
|
+
use: "your ORM / Api / Messenger code (re-run on schedule)",
|
|
1201
|
+
ground: `tina4_context("background service scheduled task", "nodejs") · skill tina4-developer-nodejs`,
|
|
1202
|
+
raise: `service ${snake} not implemented`,
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
const content = `import type { ServiceContext } from "tina4-nodejs";
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* ${name} background service — runs ${note} via ServiceRunner.
|
|
1209
|
+
*
|
|
1210
|
+
* Wire a runner once (e.g. in app.ts) to actually run it — \`tina4nodejs serve\`
|
|
1211
|
+
* does NOT auto-start services:
|
|
1212
|
+
*
|
|
1213
|
+
* import { ServiceRunner } from "tina4-nodejs";
|
|
1214
|
+
* await ServiceRunner.discover("src/services"); // registers this default export
|
|
1215
|
+
* ServiceRunner.start();
|
|
1216
|
+
*/
|
|
1217
|
+
|
|
1218
|
+
export async function ${camel}Task(context: ServiceContext): Promise<void> {
|
|
1219
|
+
${body}}
|
|
1220
|
+
|
|
1221
|
+
// Discovered by ServiceRunner.discover("src/services") — it reads name/handler
|
|
1222
|
+
// (+ interval or timing) off this default export.
|
|
1223
|
+
export default {
|
|
1224
|
+
name: "${snake}",
|
|
1225
|
+
handler: ${camel}Task,
|
|
1226
|
+
${scheduleField}
|
|
1227
|
+
};
|
|
1228
|
+
`;
|
|
1229
|
+
|
|
1230
|
+
writeFileSafe(path, content);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// ── Queue (producer + consumer worker) ──────────────────────────────
|
|
1234
|
+
//
|
|
1235
|
+
// Grounded on packages/core/src/queue.ts + job.ts: new Queue({ topic }),
|
|
1236
|
+
// queue.produce(topic, payload) -> id, `for await (const job of
|
|
1237
|
+
// queue.consume(topic))` yields QueueJob ({ payload, complete(), fail() }).
|
|
1238
|
+
// The consumer is wired as a ServiceRunner daemon (it owns its own loop).
|
|
1239
|
+
|
|
1240
|
+
function generateQueue(name: string, _flags: Record<string, string | boolean>): void {
|
|
1241
|
+
const topic = name.replace(/^\//, "");
|
|
1242
|
+
const slug = toSnake(topic.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "topic";
|
|
1243
|
+
const pascal = toPascal(topic) || "Topic";
|
|
1244
|
+
const dir = resolve("src/services"); // consumer runs as a daemon service
|
|
1245
|
+
ensureDir(dir);
|
|
1246
|
+
const path = join(dir, `${slug}_consumer.ts`);
|
|
1247
|
+
|
|
1248
|
+
const body = aiFill(`handle${pascal}`, {
|
|
1249
|
+
intent: `process ONE ${topic} job payload`,
|
|
1250
|
+
given: "payload -> the produced job data (job.payload)",
|
|
1251
|
+
use: "your ORM / Messenger code; return to ack (job.complete), throw to nack (job.fail)",
|
|
1252
|
+
ground: `tina4_context("process a queue job", "nodejs") · skill tina4-developer-nodejs`,
|
|
1253
|
+
raise: `queue ${topic} handler not implemented`,
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
const content = `import { Queue } from "tina4-nodejs";
|
|
1257
|
+
import type { ServiceContext } from "tina4-nodejs";
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* ${topic} queue — producer + consumer worker.
|
|
1261
|
+
*
|
|
1262
|
+
* Produce from anywhere: publish${pascal}({ ... })
|
|
1263
|
+
* The consumer is a long-running worker wired as a ServiceRunner daemon:
|
|
1264
|
+
* await ServiceRunner.discover("src/services"); ServiceRunner.start();
|
|
1265
|
+
*/
|
|
1266
|
+
|
|
1267
|
+
/** Enqueue a ${topic} job for the worker below to process. Returns the job id. */
|
|
1268
|
+
export function publish${pascal}(payload: Record<string, unknown>): string {
|
|
1269
|
+
return new Queue({ topic: "${topic}" }).produce("${topic}", payload);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Process ONE ${topic} job payload. */
|
|
1273
|
+
export async function handle${pascal}(payload: unknown): Promise<void> {
|
|
1274
|
+
${body}}
|
|
1275
|
+
|
|
1276
|
+
/** Long-running ${topic} worker — consume() yields jobs; ack/nack each. */
|
|
1277
|
+
export async function consume${pascal}(_context?: ServiceContext): Promise<void> {
|
|
1278
|
+
const queue = new Queue({ topic: "${topic}" });
|
|
1279
|
+
for await (const job of queue.consume("${topic}")) {
|
|
1280
|
+
const one = Array.isArray(job) ? job[0] : job;
|
|
1281
|
+
try {
|
|
1282
|
+
await handle${pascal}(one.payload);
|
|
1283
|
+
one.complete(); // ack — remove from the queue
|
|
1284
|
+
} catch (err) {
|
|
1285
|
+
one.fail(String(err)); // nack — retry / dead-letter
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Discovered by ServiceRunner.discover("src/services"); daemon:true because
|
|
1291
|
+
// consume${pascal} owns its own loop.
|
|
1292
|
+
export default {
|
|
1293
|
+
name: "${topic}-consumer",
|
|
1294
|
+
handler: consume${pascal},
|
|
1295
|
+
daemon: true,
|
|
1296
|
+
};
|
|
1297
|
+
`;
|
|
1298
|
+
|
|
1299
|
+
writeFileSafe(path, content);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ── Validator (request-body Validator) ──────────────────────────────
|
|
1303
|
+
//
|
|
1304
|
+
// Grounded on packages/core/src/validator.ts: new Validator(data) is chainable
|
|
1305
|
+
// (.required/.email/.minLength/.integer/.inList) and exposes .isValid()/.errors().
|
|
1306
|
+
|
|
1307
|
+
function generateValidator(name: string, _flags: Record<string, string | boolean>): void {
|
|
1308
|
+
const dir = resolve("src/validators");
|
|
1309
|
+
ensureDir(dir);
|
|
1310
|
+
const path = join(dir, `${toSnake(name)}.ts`);
|
|
1311
|
+
|
|
1312
|
+
const body = aiFill(`validate${toPascal(name)}`, {
|
|
1313
|
+
intent: `declare the validation rules for a ${name} payload`,
|
|
1314
|
+
given: "validator -> Validator(data) (chainable)",
|
|
1315
|
+
use: `validator.required("name").email("email").minLength("name", 2).integer("age")`,
|
|
1316
|
+
ret: "the same validator (caller checks .isValid() / .errors())",
|
|
1317
|
+
ground: `tina4_context("validate request body with Validator", "nodejs") · skill tina4-developer-nodejs`,
|
|
1318
|
+
raise: `validator ${toSnake(name)} not implemented`,
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
const content = `import { Validator } from "tina4-nodejs";
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Validate a ${name} payload. Returns a Validator (chainable rules).
|
|
1325
|
+
*
|
|
1326
|
+
* Usage in a route:
|
|
1327
|
+
* const v = validate${toPascal(name)}(req.body as Record<string, unknown>);
|
|
1328
|
+
* if (!v.isValid()) return res.json({ error: v.errors()[0]?.message }, 400);
|
|
1329
|
+
*/
|
|
1330
|
+
export function validate${toPascal(name)}(data: Record<string, unknown>): Validator {
|
|
1331
|
+
const validator = new Validator(data);
|
|
1332
|
+
${body} return validator;
|
|
1333
|
+
}
|
|
1334
|
+
`;
|
|
1335
|
+
|
|
1336
|
+
writeFileSafe(path, content);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// ── Seeder (FakeData + seedOrm) ─────────────────────────────────────
|
|
1340
|
+
//
|
|
1341
|
+
// Grounded on packages/orm/src/seeder.ts (seedOrm(model, count, overrides) —
|
|
1342
|
+
// overrides may be static values OR (fake) => value callables) and
|
|
1343
|
+
// packages/cli/src/commands/seed.ts (runs each src/seeds/*.ts as a script).
|
|
1344
|
+
|
|
1345
|
+
function generateSeeder(name: string, _flags: Record<string, string | boolean>): void {
|
|
1346
|
+
const table = toTableName(name);
|
|
1347
|
+
const dir = resolve("src/seeds");
|
|
1348
|
+
ensureDir(dir);
|
|
1349
|
+
const path = join(dir, `${table}_seeder.ts`);
|
|
1350
|
+
|
|
1351
|
+
const body = aiFill("fieldOverrides", {
|
|
1352
|
+
intent: `map ${name} fields to fake-data generators (only those needing a specific shape)`,
|
|
1353
|
+
given: "fake -> FakeData instance",
|
|
1354
|
+
use: "fake.email() / fake.name() / fake.integer(1, 99) / fake.company()",
|
|
1355
|
+
ret: `{ email: (f) => f.email(), status: "active" } (Record<string, unknown>)`,
|
|
1356
|
+
ground: `tina4_context("seed ORM model with FakeData", "nodejs") · skill tina4-developer-nodejs`,
|
|
1357
|
+
raise: `seeder ${name} overrides not implemented`,
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
const content = `import { pathToFileURL } from "node:url";
|
|
1361
|
+
import { FakeData, seedOrm, initDatabase } from "tina4-nodejs/orm";
|
|
1362
|
+
import ${name} from "../models/${name}.js";
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Seeder for ${name} — run with: tina4nodejs seed
|
|
1366
|
+
*
|
|
1367
|
+
* seedOrm auto-fills every field by type/name; override the ones that need a
|
|
1368
|
+
* specific shape below. Each callable receives a FakeData instance.
|
|
1369
|
+
*/
|
|
1370
|
+
export function fieldOverrides(fake: FakeData): Record<string, unknown> {
|
|
1371
|
+
${body}}
|
|
1372
|
+
|
|
1373
|
+
/** Seed rows. Invoked when this file is run directly by \`tina4nodejs seed\`. */
|
|
1374
|
+
export async function run(): Promise<void> {
|
|
1375
|
+
await initDatabase({ url: process.env.TINA4_DATABASE_URL ?? "sqlite:///data/app.db" });
|
|
1376
|
+
const summary = await seedOrm(${name} as never, 20, fieldOverrides(new FakeData()));
|
|
1377
|
+
console.log(\`Seeded \${summary.seeded} ${name} row(s), \${summary.failed} failed\`);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Only seed when executed as a script (\`tina4nodejs seed\` runs it via tsx) —
|
|
1381
|
+
// importing this module (e.g. in a test) must NOT trigger seeding.
|
|
1382
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1383
|
+
await run();
|
|
1384
|
+
}
|
|
1385
|
+
`;
|
|
1386
|
+
|
|
1387
|
+
writeFileSafe(path, content);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// ── WebSocket route ─────────────────────────────────────────────────
|
|
1391
|
+
//
|
|
1392
|
+
// Grounded on packages/core/src/router.ts (websocket(path, handler).secure())
|
|
1393
|
+
// + types.ts (handler is (connection, event, data)) + websocketConnection.ts
|
|
1394
|
+
// (.send/.sendJson/.broadcast/.close/.params). Node has NO file-based WS
|
|
1395
|
+
// auto-discovery — the module must be imported to register (see the doc block).
|
|
1396
|
+
|
|
1397
|
+
function generateWebsocket(name: string, _flags: Record<string, string | boolean>): void {
|
|
1398
|
+
const raw = name.trim();
|
|
1399
|
+
const wsPath = raw.startsWith("/") ? raw : "/ws/" + raw.replace(/^\/+/, "");
|
|
1400
|
+
let slug = toSnake(raw.replace(/^\/+|\/+$/g, "").replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "ws";
|
|
1401
|
+
const base = slug.startsWith("ws_") ? slug.slice(3) : slug;
|
|
1402
|
+
const handlerName = `${toCamel(toPascal(base))}Ws`;
|
|
1403
|
+
const dir = resolve("src/routes");
|
|
1404
|
+
ensureDir(dir);
|
|
1405
|
+
const path = join(dir, `ws_${base}.ts`);
|
|
1406
|
+
|
|
1407
|
+
const body = aiFill(handlerName, {
|
|
1408
|
+
intent: `handle an inbound "message" frame on ${wsPath}`,
|
|
1409
|
+
given: "data -> the message payload (string); connection -> WebSocketConnection",
|
|
1410
|
+
use: "connection.broadcast(data) or connection.sendJson({ ... })",
|
|
1411
|
+
ground: `tina4_context("websocket broadcast message", "nodejs") · skill tina4-developer-nodejs`,
|
|
1412
|
+
raise: `websocket ${wsPath} not implemented`,
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
const content = `import { websocket } from "tina4-nodejs";
|
|
1416
|
+
import type { WebSocketConnection } from "tina4-nodejs";
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* ${wsPath} WebSocket route.
|
|
1420
|
+
*
|
|
1421
|
+
* Registered on import by websocket(). Node has NO file-based WS
|
|
1422
|
+
* auto-discovery, so IMPORT this module once from app.ts to activate it (add
|
|
1423
|
+
* \`.secure()\` to require a JWT on the upgrade):
|
|
1424
|
+
*
|
|
1425
|
+
* import "./src/routes/ws_${base}.js";
|
|
1426
|
+
*
|
|
1427
|
+
* The server invokes the handler as (connection, event, data) for each event:
|
|
1428
|
+
* "open" (connect), "message" (inbound frame), "close" (disconnect).
|
|
1429
|
+
*/
|
|
1430
|
+
export async function ${handlerName}(
|
|
1431
|
+
connection: WebSocketConnection,
|
|
1432
|
+
event: "open" | "message" | "close",
|
|
1433
|
+
data: string,
|
|
1434
|
+
): Promise<void> {
|
|
1435
|
+
if (event === "open") {
|
|
1436
|
+
connection.sendJson({ type: "welcome" });
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
if (event === "close") {
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
// event === "message"
|
|
1443
|
+
${body}}
|
|
1444
|
+
|
|
1445
|
+
websocket("${wsPath}", ${handlerName});
|
|
1446
|
+
`;
|
|
1447
|
+
|
|
1448
|
+
writeFileSafe(path, content);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
// ── Event listener ──────────────────────────────────────────────────
|
|
1452
|
+
//
|
|
1453
|
+
// Grounded on packages/core/src/events.ts: Events.on(event, cb) registers,
|
|
1454
|
+
// Events.emit(event, ...args) fires, Events.listeners(event) reads them. Node
|
|
1455
|
+
// has NO src/listeners/ auto-discovery — import the module to register.
|
|
1456
|
+
|
|
1457
|
+
function generateListener(name: string, _flags: Record<string, string | boolean>): void {
|
|
1458
|
+
const event = name.trim();
|
|
1459
|
+
const slug = toSnake(event.replace(/[^0-9a-zA-Z]+/g, "_")).replace(/^_+|_+$/g, "") || "event";
|
|
1460
|
+
const handlerName = `on${toPascal(slug)}`;
|
|
1461
|
+
const dir = resolve("src/listeners");
|
|
1462
|
+
ensureDir(dir);
|
|
1463
|
+
const path = join(dir, `${slug}.ts`);
|
|
1464
|
+
|
|
1465
|
+
const body = aiFill(handlerName, {
|
|
1466
|
+
intent: `react to the '${event}' event`,
|
|
1467
|
+
given: `args -> whatever Events.emit("${event}", ...args) passed`,
|
|
1468
|
+
use: "your app code — Messenger().send(...), an ORM write, or Events.emit(...) a follow-up",
|
|
1469
|
+
ground: `tina4_context("event listener reaction", "nodejs") · skill tina4-developer-nodejs`,
|
|
1470
|
+
raise: `listener ${event} not implemented`,
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
const content = `import { Events } from "tina4-nodejs";
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Listener for the '${event}' event.
|
|
1477
|
+
*
|
|
1478
|
+
* Registered on import by Events.on(). Node has NO src/listeners/
|
|
1479
|
+
* auto-discovery, so IMPORT this module once from app.ts to activate it:
|
|
1480
|
+
*
|
|
1481
|
+
* import "./src/listeners/${slug}.js";
|
|
1482
|
+
*
|
|
1483
|
+
* Fires when something calls Events.emit("${event}", ...args).
|
|
1484
|
+
*/
|
|
1485
|
+
export function ${handlerName}(...args: unknown[]): void {
|
|
1486
|
+
${body}}
|
|
1487
|
+
|
|
1488
|
+
Events.on("${event}", ${handlerName});
|
|
1489
|
+
`;
|
|
1490
|
+
|
|
1491
|
+
writeFileSafe(path, content);
|
|
1492
|
+
}
|
|
@@ -64,8 +64,16 @@ export async function discoverRoutes(routesDir: string): Promise<RouteDefinition
|
|
|
64
64
|
|
|
65
65
|
const meta: RouteMeta | undefined = mod.meta;
|
|
66
66
|
const template: string | undefined = typeof mod.template === "string" ? mod.template : undefined;
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Auth opt-outs a route file can export (parity with the imperative
|
|
68
|
+
// `.noAuth()` / AutoCrud `secure: false`). A generated public write file
|
|
69
|
+
// (`generate route … --public`, or the always-public auth login/register)
|
|
70
|
+
// does `export const secure = false;`; without threading it here the
|
|
71
|
+
// router would keep its secure-by-default write gate and the opt-out
|
|
72
|
+
// would be inert. Only booleans are honoured — anything else is ignored.
|
|
73
|
+
const secure: boolean | undefined = typeof mod.secure === "boolean" ? mod.secure : undefined;
|
|
74
|
+
const noAuth: boolean | undefined = typeof mod.noAuth === "boolean" ? mod.noAuth : undefined;
|
|
75
|
+
|
|
76
|
+
definitions.push({ method, pattern, handler, filePath, meta, template, secure, noAuth });
|
|
69
77
|
_seenFiles.add(filePath);
|
|
70
78
|
_seenMtimes.set(filePath, currentMtime);
|
|
71
79
|
registeredFromThisScan++;
|
|
@@ -14,28 +14,47 @@ import { validate } from "./validation.js";
|
|
|
14
14
|
* PUT /api/{table}/{id} — update record
|
|
15
15
|
* DELETE /api/{table}/{id} — delete record
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* Options accepted by the AutoCrud registration API.
|
|
19
|
+
*
|
|
20
|
+
* `public` is the cross-backend escape hatch (parity with python's `public=True`
|
|
21
|
+
* and php's `bool $public`). Write routes (POST/PUT/DELETE) are secure-by-default
|
|
22
|
+
* — the router gates them unless a def sets `secure: false`. Set `public: true`
|
|
23
|
+
* to open the generated write routes explicitly. Reads (GET) are always public.
|
|
24
|
+
*/
|
|
25
|
+
export interface AutoCrudOptions {
|
|
26
|
+
/** When true, the generated write routes (POST/PUT/DELETE) are OPEN (secure:false). Default false → secure. */
|
|
27
|
+
public?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export class AutoCrud {
|
|
18
31
|
private static registered: Map<string, DiscoveredModel> = new Map();
|
|
32
|
+
/** tableName -> public-writes flag (default secure); mirrors php's `$this->public`. */
|
|
33
|
+
private static publicWrites: Map<string, boolean> = new Map();
|
|
19
34
|
|
|
20
35
|
/**
|
|
21
36
|
* Register a model for auto-CRUD.
|
|
37
|
+
*
|
|
38
|
+
* @param options.public If true, the generated write routes are OPEN (no auth).
|
|
39
|
+
* Default (false) keeps them secure-by-default, matching the framework's write gate.
|
|
22
40
|
*/
|
|
23
|
-
static register(model: DiscoveredModel, prefix: string = "/api"): void {
|
|
41
|
+
static register(model: DiscoveredModel, prefix: string = "/api", options: AutoCrudOptions = {}): void {
|
|
24
42
|
const tableName = model.definition.tableName;
|
|
25
43
|
if (!tableName) {
|
|
26
44
|
throw new Error(`AutoCrud: model has no tableName set.`);
|
|
27
45
|
}
|
|
28
46
|
AutoCrud.registered.set(tableName, model);
|
|
47
|
+
AutoCrud.publicWrites.set(tableName, options.public === true);
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
/**
|
|
32
51
|
* Discover models from the provided array and register them.
|
|
33
52
|
* (In Node.js, models are discovered by the server and passed in.)
|
|
34
53
|
*/
|
|
35
|
-
static discover(discoveredModels: DiscoveredModel[], prefix: string = "/api"): string[] {
|
|
54
|
+
static discover(discoveredModels: DiscoveredModel[], prefix: string = "/api", options: AutoCrudOptions = {}): string[] {
|
|
36
55
|
const names: string[] = [];
|
|
37
56
|
for (const model of discoveredModels) {
|
|
38
|
-
AutoCrud.register(model, prefix);
|
|
57
|
+
AutoCrud.register(model, prefix, options);
|
|
39
58
|
names.push(model.definition.tableName);
|
|
40
59
|
}
|
|
41
60
|
return names;
|
|
@@ -53,14 +72,20 @@ export class AutoCrud {
|
|
|
53
72
|
*/
|
|
54
73
|
static clear(): void {
|
|
55
74
|
AutoCrud.registered.clear();
|
|
75
|
+
AutoCrud.publicWrites.clear();
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
/**
|
|
59
|
-
* Generate route definitions for all registered models
|
|
79
|
+
* Generate route definitions for all registered models, honouring each
|
|
80
|
+
* model's per-table `public` flag (set at register/discover time).
|
|
60
81
|
*/
|
|
61
82
|
static generateRoutes(): RouteDefinition[] {
|
|
62
|
-
const
|
|
63
|
-
|
|
83
|
+
const routes: RouteDefinition[] = [];
|
|
84
|
+
for (const [tableName, model] of AutoCrud.registered) {
|
|
85
|
+
const isPublic = AutoCrud.publicWrites.get(tableName) === true;
|
|
86
|
+
routes.push(...generateCrudRoutes([model], { public: isPublic }));
|
|
87
|
+
}
|
|
88
|
+
return routes;
|
|
64
89
|
}
|
|
65
90
|
}
|
|
66
91
|
|
|
@@ -80,9 +105,18 @@ export function crudEligibleModels(models: DiscoveredModel[]): DiscoveredModel[]
|
|
|
80
105
|
/**
|
|
81
106
|
* Generate CRUD route definitions for the given models.
|
|
82
107
|
* (Standalone function for backward compatibility.)
|
|
108
|
+
*
|
|
109
|
+
* @param options.public When true, the generated write routes (POST/PUT/DELETE)
|
|
110
|
+
* opt OUT of the router's secure-by-default write gate (`secure: false`) — the
|
|
111
|
+
* cross-backend escape hatch (parity with python `public=True` / php `$public`).
|
|
112
|
+
* Default (false) leaves `secure` unset so the router gates writes (secure:true).
|
|
113
|
+
* GET routes are unaffected (reads are already public).
|
|
83
114
|
*/
|
|
84
|
-
export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[] {
|
|
115
|
+
export function generateCrudRoutes(models: DiscoveredModel[], options: AutoCrudOptions = {}): RouteDefinition[] {
|
|
85
116
|
const routes: RouteDefinition[] = [];
|
|
117
|
+
// Only writes are gated; `public` flips them open. Spread this into POST/PUT/
|
|
118
|
+
// DELETE defs so the default path leaves `secure` unset (router → secure:true).
|
|
119
|
+
const writeSecurity = options.public === true ? { secure: false as const } : {};
|
|
86
120
|
|
|
87
121
|
for (const { definition } of models) {
|
|
88
122
|
const { tableName, fields, softDelete, tableFilter, fieldMapping } = definition;
|
|
@@ -167,10 +201,11 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
167
201
|
},
|
|
168
202
|
});
|
|
169
203
|
|
|
170
|
-
// POST /api/{table} -- Create
|
|
204
|
+
// POST /api/{table} -- Create (secure-by-default; secure:false only when public)
|
|
171
205
|
routes.push({
|
|
172
206
|
method: "POST",
|
|
173
207
|
pattern: basePath,
|
|
208
|
+
...writeSecurity,
|
|
174
209
|
meta: {
|
|
175
210
|
summary: `Create ${tableName}`,
|
|
176
211
|
tags: [tableName],
|
|
@@ -228,10 +263,11 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
228
263
|
},
|
|
229
264
|
});
|
|
230
265
|
|
|
231
|
-
// PUT /api/{table}/:id -- Update
|
|
266
|
+
// PUT /api/{table}/:id -- Update (secure-by-default; secure:false only when public)
|
|
232
267
|
routes.push({
|
|
233
268
|
method: "PUT",
|
|
234
269
|
pattern: `${basePath}/{id}`,
|
|
270
|
+
...writeSecurity,
|
|
235
271
|
meta: {
|
|
236
272
|
summary: `Update ${tableName}`,
|
|
237
273
|
tags: [tableName],
|
|
@@ -275,10 +311,11 @@ export function generateCrudRoutes(models: DiscoveredModel[]): RouteDefinition[]
|
|
|
275
311
|
},
|
|
276
312
|
});
|
|
277
313
|
|
|
278
|
-
// DELETE /api/{table}/:id -- Delete (
|
|
314
|
+
// DELETE /api/{table}/:id -- Delete (secure-by-default; secure:false only when public)
|
|
279
315
|
routes.push({
|
|
280
316
|
method: "DELETE",
|
|
281
317
|
pattern: `${basePath}/{id}`,
|
|
318
|
+
...writeSecurity,
|
|
282
319
|
meta: {
|
|
283
320
|
summary: `Delete ${tableName}`,
|
|
284
321
|
tags: [tableName],
|
|
@@ -47,6 +47,7 @@ export {
|
|
|
47
47
|
} from "./migration.js";
|
|
48
48
|
export type { MigrationResult, MigrationStatus } from "./migration.js";
|
|
49
49
|
export { AutoCrud, generateCrudRoutes, crudEligibleModels } from "./autoCrud.js";
|
|
50
|
+
export type { AutoCrudOptions } from "./autoCrud.js";
|
|
50
51
|
export { buildQuery, parseQueryString } from "./query.js";
|
|
51
52
|
export { validate } from "./validation.js";
|
|
52
53
|
export type { ValidationError } from "./validation.js";
|