bonescript-compiler 0.5.8 → 0.6.0
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/dist/ast.d.ts +2 -0
- package/dist/cli.js +52 -8
- package/dist/cli.js.map +1 -1
- package/dist/emit_admin.d.ts +5 -0
- package/dist/emit_admin.js +340 -35
- package/dist/emit_admin.js.map +1 -1
- package/dist/emit_audit.js +38 -4
- package/dist/emit_audit.js.map +1 -1
- package/dist/emit_capability.js +14 -0
- package/dist/emit_capability.js.map +1 -1
- package/dist/emit_full.js +10 -2
- package/dist/emit_full.js.map +1 -1
- package/dist/emit_maintenance.js +35 -3
- package/dist/emit_maintenance.js.map +1 -1
- package/dist/emit_runtime.d.ts +18 -1
- package/dist/emit_runtime.js +212 -32
- package/dist/emit_runtime.js.map +1 -1
- package/dist/emit_websocket.js +22 -2
- package/dist/emit_websocket.js.map +1 -1
- package/dist/emit_zod.js +12 -1
- package/dist/emit_zod.js.map +1 -1
- package/dist/formatter.d.ts +1 -0
- package/dist/formatter.js +10 -2
- package/dist/formatter.js.map +1 -1
- package/dist/ir.d.ts +2 -0
- package/dist/lexer.d.ts +1 -0
- package/dist/lexer.js +4 -0
- package/dist/lexer.js.map +1 -1
- package/dist/lowering.js +2 -0
- package/dist/lowering.js.map +1 -1
- package/dist/parse_decls.js +36 -1
- package/dist/parse_decls.js.map +1 -1
- package/dist/typechecker.js +9 -0
- package/dist/typechecker.js.map +1 -1
- package/package.json +1 -1
- package/src/ast.ts +2 -0
- package/src/cli.ts +58 -10
- package/src/emit_admin.ts +342 -35
- package/src/emit_audit.ts +40 -4
- package/src/emit_capability.ts +13 -0
- package/src/emit_full.ts +9 -2
- package/src/emit_maintenance.ts +35 -3
- package/src/emit_runtime.ts +224 -32
- package/src/emit_websocket.ts +22 -2
- package/src/emit_zod.ts +11 -1
- package/src/formatter.ts +9 -2
- package/src/ir.ts +2 -0
- package/src/lexer.ts +2 -0
- package/src/lowering.ts +5 -3
- package/src/parse_decls.ts +31 -1
- package/src/typechecker.ts +10 -0
package/src/emit_maintenance.ts
CHANGED
|
@@ -220,9 +220,29 @@ export function emitHealthChecks(system: IR.IRSystem): string {
|
|
|
220
220
|
lines.push(`});`);
|
|
221
221
|
lines.push(``);
|
|
222
222
|
|
|
223
|
-
// Metrics endpoint
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// Metrics endpoint — restricted to internal callers.
|
|
224
|
+
// Accepted: shared bearer in METRICS_TOKEN, or loopback / RFC1918 source IPs.
|
|
225
|
+
// External scrapers must inject the bearer; otherwise 403.
|
|
226
|
+
lines.push(`// Prometheus-style metrics — restricted to internal callers`);
|
|
227
|
+
lines.push(`function isInternalMetricsRequest(req: Request): boolean {`);
|
|
228
|
+
lines.push(` const expected = process.env.METRICS_TOKEN || "";`);
|
|
229
|
+
lines.push(` if (expected) {`);
|
|
230
|
+
lines.push(` const header = req.headers.authorization || "";`);
|
|
231
|
+
lines.push(` if (header.startsWith("Bearer ") && header.slice(7) === expected) return true;`);
|
|
232
|
+
lines.push(` }`);
|
|
233
|
+
lines.push(` const ip = (req.ip || "").replace(/^::ffff:/, "");`);
|
|
234
|
+
lines.push(` if (ip === "127.0.0.1" || ip === "::1") return true;`);
|
|
235
|
+
lines.push(` if (ip.startsWith("10.") || ip.startsWith("192.168.")) return true;`);
|
|
236
|
+
lines.push(` // RFC1918 172.16.0.0/12`);
|
|
237
|
+
lines.push(` const m = ip.match(/^172\\.(\\d{1,3})\\./);`);
|
|
238
|
+
lines.push(` if (m && +m[1] >= 16 && +m[1] <= 31) return true;`);
|
|
239
|
+
lines.push(` return false;`);
|
|
240
|
+
lines.push(`}`);
|
|
241
|
+
lines.push(`healthRouter.get("/metrics", (req: Request, res: Response) => {`);
|
|
242
|
+
lines.push(` if (!isInternalMetricsRequest(req)) {`);
|
|
243
|
+
lines.push(` res.status(403).json({ error: { code: "FORBIDDEN", message: "Metrics restricted to internal callers" } });`);
|
|
244
|
+
lines.push(` return;`);
|
|
245
|
+
lines.push(` }`);
|
|
226
246
|
lines.push(` res.type("text/plain").send(dumpMetrics());`);
|
|
227
247
|
lines.push(`});`);
|
|
228
248
|
lines.push(``);
|
|
@@ -356,6 +376,7 @@ interface Field {
|
|
|
356
376
|
name: string;
|
|
357
377
|
type: string;
|
|
358
378
|
nullable: boolean;
|
|
379
|
+
renamed_from?: string | null;
|
|
359
380
|
}
|
|
360
381
|
|
|
361
382
|
interface Model {
|
|
@@ -395,8 +416,18 @@ export function diffModels(oldModels: Model[], newModels: Model[]): string[] {
|
|
|
395
416
|
const oldFields = new Map(oldModel.fields.map(f => [f.name, f]));
|
|
396
417
|
const newFields = new Map(newModel.fields.map(f => [f.name, f]));
|
|
397
418
|
|
|
419
|
+
// Renames first — avoid double-counting as add+drop.
|
|
420
|
+
const renamedOld = new Set<string>();
|
|
421
|
+
for (const [fname, field] of newFields) {
|
|
422
|
+
if (field.renamed_from && oldFields.has(field.renamed_from) && !oldFields.has(fname)) {
|
|
423
|
+
statements.push(\`ALTER TABLE \${tableName} RENAME COLUMN \${field.renamed_from} TO \${fname};\`);
|
|
424
|
+
renamedOld.add(field.renamed_from);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
398
428
|
// New columns (backward-compatible)
|
|
399
429
|
for (const [fname, field] of newFields) {
|
|
430
|
+
if (field.renamed_from && renamedOld.has(field.renamed_from)) continue;
|
|
400
431
|
if (!oldFields.has(fname)) {
|
|
401
432
|
const sqlType = mapType(field.type);
|
|
402
433
|
const nullability = field.nullable ? "" : " NOT NULL DEFAULT (CASE WHEN false THEN NULL ELSE NULL END)";
|
|
@@ -406,6 +437,7 @@ export function diffModels(oldModels: Model[], newModels: Model[]): string[] {
|
|
|
406
437
|
|
|
407
438
|
// Removed columns (NOT auto-dropped — backward compat)
|
|
408
439
|
for (const [fname] of oldFields) {
|
|
440
|
+
if (renamedOld.has(fname)) continue;
|
|
409
441
|
if (!newFields.has(fname)) {
|
|
410
442
|
statements.push(\`-- WARNING: Column \${tableName}.\${fname} removed from schema. Run manually: ALTER TABLE \${tableName} DROP COLUMN \${fname};\`);
|
|
411
443
|
}
|
package/src/emit_runtime.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as IR from "./ir";
|
|
7
|
+
import { createHash } from "crypto";
|
|
7
8
|
import { emitCapabilityBody } from "./emit_capability";
|
|
8
9
|
|
|
9
10
|
function toSnakeCase(s: string): string {
|
|
@@ -47,28 +48,31 @@ export function emitPackageJson(system: IR.IRSystem): string {
|
|
|
47
48
|
migrate: "ts-node src/migrate.ts",
|
|
48
49
|
},
|
|
49
50
|
dependencies: {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
// Pinned to specific patch versions. Update consciously — a bump should
|
|
52
|
+
// be paired with a quick read of the release notes / advisories.
|
|
53
|
+
express: "4.22.2", // 4.22.x patches qs / path-to-regexp DoS
|
|
54
|
+
pg: "8.13.1",
|
|
55
|
+
ioredis: "5.4.1",
|
|
56
|
+
ws: "8.18.0", // 8.17.1 patched CVE-2024-37890 (DoS)
|
|
57
|
+
uuid: "10.0.0",
|
|
55
58
|
cors: "2.8.5",
|
|
56
|
-
helmet: "
|
|
57
|
-
"express-rate-limit": "7.
|
|
59
|
+
helmet: "8.0.0",
|
|
60
|
+
"express-rate-limit": "7.5.0",
|
|
58
61
|
jsonwebtoken: "9.0.2",
|
|
59
|
-
dotenv: "16.
|
|
62
|
+
dotenv: "16.4.7",
|
|
60
63
|
"node-cron": "3.0.3",
|
|
64
|
+
zod: "3.23.8",
|
|
61
65
|
},
|
|
62
66
|
devDependencies: {
|
|
63
67
|
"@types/express": "4.17.21",
|
|
64
|
-
"@types/node": "
|
|
65
|
-
"@types/pg": "8.10
|
|
66
|
-
"@types/ws": "8.5.
|
|
68
|
+
"@types/node": "20.14.0",
|
|
69
|
+
"@types/pg": "8.11.10",
|
|
70
|
+
"@types/ws": "8.5.13",
|
|
67
71
|
"@types/cors": "2.8.17",
|
|
68
|
-
"@types/jsonwebtoken": "9.0.
|
|
69
|
-
"@types/uuid": "
|
|
72
|
+
"@types/jsonwebtoken": "9.0.7",
|
|
73
|
+
"@types/uuid": "10.0.0",
|
|
70
74
|
"@types/node-cron": "3.0.11",
|
|
71
|
-
typescript: "5.
|
|
75
|
+
typescript: "5.6.3",
|
|
72
76
|
"ts-node": "10.9.2",
|
|
73
77
|
},
|
|
74
78
|
};
|
|
@@ -145,6 +149,7 @@ export function emitAuthMiddleware(system: IR.IRSystem): string {
|
|
|
145
149
|
return `// Generated by BoneScript compiler. DO NOT EDIT.
|
|
146
150
|
import { Request, Response, NextFunction } from "express";
|
|
147
151
|
import jwt from "jsonwebtoken";
|
|
152
|
+
import { v4 as uuid } from "uuid";
|
|
148
153
|
|
|
149
154
|
// JWT_SECRET must be set in production. The server will refuse to start without it
|
|
150
155
|
// when NODE_ENV is "production" to prevent accidental use of a weak fallback.
|
|
@@ -170,23 +175,45 @@ export interface AuthContext {
|
|
|
170
175
|
trace_id: string;
|
|
171
176
|
}
|
|
172
177
|
|
|
178
|
+
// Trace IDs are server-generated. We accept a client-supplied X-Trace-Id only
|
|
179
|
+
// if it parses as a UUID, so callers can correlate their own logs without
|
|
180
|
+
// being able to forge correlation IDs in audit / event records.
|
|
181
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
182
|
+
function resolveTraceId(req: Request): string {
|
|
183
|
+
const supplied = req.headers["x-trace-id"];
|
|
184
|
+
if (typeof supplied === "string" && UUID_RE.test(supplied)) return supplied;
|
|
185
|
+
return uuid();
|
|
186
|
+
}
|
|
187
|
+
|
|
173
188
|
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
174
189
|
const header = req.headers.authorization;
|
|
190
|
+
const traceId = resolveTraceId(req);
|
|
175
191
|
if (!header || !header.startsWith("Bearer ")) {
|
|
176
|
-
(req as any).auth = { authenticated: false, actor_id: null, trace_id:
|
|
192
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
177
193
|
next();
|
|
178
194
|
return;
|
|
179
195
|
}
|
|
180
196
|
try {
|
|
181
197
|
const token = header.slice(7);
|
|
182
|
-
|
|
198
|
+
// Pin algorithms to HS256 and require an expiration. Without pinning,
|
|
199
|
+
// jsonwebtoken would accept any algorithm including RS/HS confusion attacks.
|
|
200
|
+
// maxAge is a safety net even if exp is set far in the future.
|
|
201
|
+
const decoded = jwt.verify(token, JWT_SECRET, {
|
|
202
|
+
algorithms: ["HS256"],
|
|
203
|
+
maxAge: process.env.JWT_MAX_AGE || "1h",
|
|
204
|
+
}) as { sub?: unknown };
|
|
205
|
+
if (typeof decoded.sub !== "string" || decoded.sub.length === 0) {
|
|
206
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
207
|
+
next();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
183
210
|
(req as any).auth = {
|
|
184
211
|
authenticated: true,
|
|
185
212
|
actor_id: decoded.sub,
|
|
186
|
-
trace_id:
|
|
213
|
+
trace_id: traceId,
|
|
187
214
|
};
|
|
188
215
|
} catch {
|
|
189
|
-
(req as any).auth = { authenticated: false, actor_id: null, trace_id:
|
|
216
|
+
(req as any).auth = { authenticated: false, actor_id: null, trace_id: traceId };
|
|
190
217
|
}
|
|
191
218
|
next();
|
|
192
219
|
}
|
|
@@ -226,6 +253,9 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
226
253
|
}
|
|
227
254
|
lines.push(`import { logger } from "../logger";`);
|
|
228
255
|
lines.push(`import { counter } from "../metrics";`);
|
|
256
|
+
// Zod schemas for input validation. The Create / Update derivatives are
|
|
257
|
+
// generated alongside the full model schema in src/schemas.ts.
|
|
258
|
+
lines.push(`import { ${toPascalCase(entityModel.name)}CreateSchema, ${toPascalCase(entityModel.name)}UpdateSchema } from "../schemas";`);
|
|
229
259
|
lines.push(`import * as __algorithms from "../algorithms";`);
|
|
230
260
|
lines.push(`const { shortestPath, topologicalSort, binarySearch, bipartiteMatching, roundRobin, weightedAverage, percentile, rankBy, consistentHash } = __algorithms as any;`);
|
|
231
261
|
lines.push(``);
|
|
@@ -265,9 +295,15 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
265
295
|
lines.push(`// CREATE`);
|
|
266
296
|
const __crudMiddlewares = modRateLimit > 0 ? "__routeRateLimit, requireAuth" : "requireAuth";
|
|
267
297
|
lines.push(`${toCamelCase(routeBase)}Router.post("/", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
298
|
+
lines.push(` // Validate the request body against the generated Zod schema.`);
|
|
299
|
+
lines.push(` const __parsed = ${toPascalCase(entityModel.name)}CreateSchema.safeParse(req.body);`);
|
|
300
|
+
lines.push(` if (!__parsed.success) {`);
|
|
301
|
+
lines.push(` return res.status(400).json({ error: { code: "VALIDATION_FAILED", message: "Invalid request body", issues: __parsed.error.flatten() } });`);
|
|
302
|
+
lines.push(` }`);
|
|
303
|
+
lines.push(` const __body = __parsed.data;`);
|
|
268
304
|
lines.push(` try {`);
|
|
269
305
|
lines.push(` const id = uuid();`);
|
|
270
|
-
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } =
|
|
306
|
+
lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = __body as any;`);
|
|
271
307
|
if (mod.state_machines.length > 0) {
|
|
272
308
|
lines.push(` const state = ${mod.state_machines[0].entity.toUpperCase()}_INITIAL;`);
|
|
273
309
|
}
|
|
@@ -331,10 +367,35 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
331
367
|
lines.push(`});`);
|
|
332
368
|
lines.push(``);
|
|
333
369
|
|
|
334
|
-
// UPDATE — with state machine enforcement
|
|
370
|
+
// UPDATE — with state machine enforcement and column allow-list
|
|
371
|
+
// The allow-list is derived from the IR model so generated SQL never
|
|
372
|
+
// interpolates user-supplied keys as identifiers.
|
|
373
|
+
const updatableColumns = entityModel.fields
|
|
374
|
+
.filter(f => !["id", "created_at", "updated_at"].includes(f.name))
|
|
375
|
+
.map(f => f.name);
|
|
376
|
+
// Entities with a state machine carry a `state` column not present in the
|
|
377
|
+
// IR field list. Include it so PUT can drive transitions.
|
|
378
|
+
if (mod.state_machines.length > 0 && !updatableColumns.includes("state")) {
|
|
379
|
+
updatableColumns.push("state");
|
|
380
|
+
}
|
|
335
381
|
lines.push(`// UPDATE`);
|
|
382
|
+
lines.push(`const __${toCamelCase(routeBase)}Updatable = new Set<string>(${JSON.stringify(updatableColumns)});`);
|
|
336
383
|
lines.push(`${toCamelCase(routeBase)}Router.put("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
|
|
337
|
-
lines.push(`
|
|
384
|
+
lines.push(` // 1. Validate body shape and types via Zod (UpdateSchema is partial).`);
|
|
385
|
+
lines.push(` const __parsed = ${toPascalCase(entityModel.name)}UpdateSchema.safeParse(req.body);`);
|
|
386
|
+
lines.push(` if (!__parsed.success) {`);
|
|
387
|
+
lines.push(` return res.status(400).json({ error: { code: "VALIDATION_FAILED", message: "Invalid request body", issues: __parsed.error.flatten() } });`);
|
|
388
|
+
lines.push(` }`);
|
|
389
|
+
lines.push(` // 2. Reject unknown columns (defense in depth — Zod would already strip them).`);
|
|
390
|
+
lines.push(` const __unknown = Object.keys(req.body || {}).filter(k => !__${toCamelCase(routeBase)}Updatable.has(k));`);
|
|
391
|
+
lines.push(` if (__unknown.length > 0) {`);
|
|
392
|
+
lines.push(` return res.status(400).json({ error: { code: "UNKNOWN_FIELDS", message: \`Unknown fields: \${__unknown.join(", ")}\`, fields: __unknown } });`);
|
|
393
|
+
lines.push(` }`);
|
|
394
|
+
lines.push(` // 3. Use the Zod-parsed object as the update set.`);
|
|
395
|
+
lines.push(` const fields: Record<string, unknown> = __parsed.data as Record<string, unknown>;`);
|
|
396
|
+
lines.push(` if (Object.keys(fields).length === 0) {`);
|
|
397
|
+
lines.push(` return res.status(400).json({ error: { code: "NO_FIELDS", message: "No updatable fields supplied" } });`);
|
|
398
|
+
lines.push(` }`);
|
|
338
399
|
if (mod.state_machines.length > 0) {
|
|
339
400
|
const sm = mod.state_machines[0];
|
|
340
401
|
lines.push(` // State machine enforcement`);
|
|
@@ -347,8 +408,9 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
|
|
|
347
408
|
lines.push(` if (!tr.ok) return res.status(422).json({ error: { code: "INVALID_TRANSITION", message: \`Cannot transition from \${current.state} to \${fields.state}\` } });`);
|
|
348
409
|
lines.push(` }`);
|
|
349
410
|
}
|
|
350
|
-
lines.push(` const
|
|
351
|
-
lines.push(` const
|
|
411
|
+
lines.push(` const keys = Object.keys(fields);`);
|
|
412
|
+
lines.push(` const sets = keys.map((k, i) => \`\${k} = $\${i + 2}\`).join(", ");`);
|
|
413
|
+
lines.push(` const values = keys.map(k => fields[k]);`);
|
|
352
414
|
lines.push(` const sql = \`UPDATE ${tableName} SET \${sets}, updated_at = NOW() WHERE id = $1 RETURNING *\`;`);
|
|
353
415
|
lines.push(` const rows = await query(sql, [req.params.id, ...values]);`);
|
|
354
416
|
lines.push(` if (rows.length === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
|
|
@@ -597,6 +659,14 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
597
659
|
lines.push(`const httpServer = createServer(app);`);
|
|
598
660
|
lines.push(`const PORT = parseInt(process.env.PORT || "3000");`);
|
|
599
661
|
lines.push(``);
|
|
662
|
+
lines.push(`// Trust the immediate hop's X-Forwarded-* headers when running behind a`);
|
|
663
|
+
lines.push(`// load balancer / k8s ingress. Override with TRUST_PROXY=<n> for multi-hop`);
|
|
664
|
+
lines.push(`// (e.g. "2" for ingress + service mesh). Set to "false" to disable.`);
|
|
665
|
+
lines.push(`const __trust = process.env.TRUST_PROXY ?? "loopback, linklocal, uniquelocal";`);
|
|
666
|
+
lines.push(`if (__trust === "false") app.set("trust proxy", false);`);
|
|
667
|
+
lines.push(`else if (/^\\d+$/.test(__trust)) app.set("trust proxy", Number(__trust));`);
|
|
668
|
+
lines.push(`else app.set("trust proxy", __trust);`);
|
|
669
|
+
lines.push(``);
|
|
600
670
|
lines.push(`// Middleware`);
|
|
601
671
|
lines.push(`app.use(helmet());`);
|
|
602
672
|
lines.push(`// CORS: restrict to ALLOWED_ORIGINS env var (comma-separated). Defaults to same-origin only.`);
|
|
@@ -699,23 +769,145 @@ export function emitIndex(system: IR.IRSystem): string {
|
|
|
699
769
|
|
|
700
770
|
// ─── Migration Script ─────────────────────────────────────────────────────────
|
|
701
771
|
|
|
702
|
-
|
|
772
|
+
/**
|
|
773
|
+
* A migration block paired with a stable identifier and content checksum.
|
|
774
|
+
* - `id` is a human-readable, ordered name (e.g. "0001_create_sellers").
|
|
775
|
+
* - `checksum` is a sha256 of the SQL body — used by the ledger to detect
|
|
776
|
+
* tampering with already-applied migrations.
|
|
777
|
+
*/
|
|
778
|
+
export interface MigrationBlock {
|
|
779
|
+
id: string;
|
|
780
|
+
checksum: string;
|
|
781
|
+
sql: string;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Build deterministic migration blocks from raw schema strings.
|
|
786
|
+
* The order of `schemas` is preserved; each block gets a zero-padded prefix
|
|
787
|
+
* so lexicographic sort matches insertion order.
|
|
788
|
+
*/
|
|
789
|
+
export function buildMigrationBlocks(schemas: string[]): MigrationBlock[] {
|
|
790
|
+
const blocks: MigrationBlock[] = [];
|
|
791
|
+
schemas.forEach((sql, i) => {
|
|
792
|
+
const checksum = createHash("sha256").update(sql).digest("hex");
|
|
793
|
+
// Try to extract the module name from the schema header for a friendlier id.
|
|
794
|
+
const moduleMatch = sql.match(/^--\s*Module:\s*(\S+)/m);
|
|
795
|
+
const tableMatch = sql.match(/CREATE TABLE IF NOT EXISTS\s+(\w+)/i);
|
|
796
|
+
const slug = (tableMatch?.[1] || moduleMatch?.[1] || `block_${i}`)
|
|
797
|
+
.replace(/[^a-zA-Z0-9_]/g, "_")
|
|
798
|
+
.toLowerCase();
|
|
799
|
+
const id = `${String(i).padStart(4, "0")}_${slug}`;
|
|
800
|
+
blocks.push({ id, checksum, sql });
|
|
801
|
+
});
|
|
802
|
+
return blocks;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
export function emitMigration(_system: IR.IRSystem, schemas: string[]): string {
|
|
806
|
+
const blocks = buildMigrationBlocks(schemas);
|
|
807
|
+
|
|
703
808
|
const lines: string[] = [];
|
|
704
809
|
lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
|
|
810
|
+
lines.push(`// Migration runner with a schema_migrations ledger.`);
|
|
811
|
+
lines.push(`// Each block is hashed at compile time; runs are skipped if the same`);
|
|
812
|
+
lines.push(`// (id, checksum) pair has already been recorded as applied.`);
|
|
705
813
|
lines.push(`require("dotenv").config();`);
|
|
814
|
+
lines.push(`import * as fs from "fs";`);
|
|
815
|
+
lines.push(`import * as path from "path";`);
|
|
816
|
+
lines.push(`import { createHash } from "crypto";`);
|
|
706
817
|
lines.push(`import { pool } from "./db";`);
|
|
707
818
|
lines.push(``);
|
|
819
|
+
lines.push(`interface Block { id: string; checksum: string; sql: string; }`);
|
|
820
|
+
lines.push(``);
|
|
821
|
+
lines.push(`const GENERATED_BLOCKS: Block[] = [`);
|
|
822
|
+
for (const b of blocks) {
|
|
823
|
+
const escaped = b.sql.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
|
|
824
|
+
lines.push(` {`);
|
|
825
|
+
lines.push(` id: ${JSON.stringify(b.id)},`);
|
|
826
|
+
lines.push(` checksum: ${JSON.stringify(b.checksum)},`);
|
|
827
|
+
lines.push(` sql: \`${escaped}\`,`);
|
|
828
|
+
lines.push(` },`);
|
|
829
|
+
}
|
|
830
|
+
lines.push(`];`);
|
|
831
|
+
lines.push(``);
|
|
832
|
+
lines.push(`/**`);
|
|
833
|
+
lines.push(` * Pick up any hand-authored migrations dropped into migrations/_manual/.`);
|
|
834
|
+
lines.push(` * Files are picked up in lexicographic order; \`bonec diff --write\` writes`);
|
|
835
|
+
lines.push(` * them with a numeric prefix so insertion order is stable.`);
|
|
836
|
+
lines.push(` */`);
|
|
837
|
+
lines.push(`function loadManualBlocks(): Block[] {`);
|
|
838
|
+
lines.push(` const dir = path.resolve(__dirname, "..", "migrations", "_manual");`);
|
|
839
|
+
lines.push(` if (!fs.existsSync(dir)) return [];`);
|
|
840
|
+
lines.push(` return fs.readdirSync(dir)`);
|
|
841
|
+
lines.push(` .filter(f => f.endsWith(".sql"))`);
|
|
842
|
+
lines.push(` .sort()`);
|
|
843
|
+
lines.push(` .map(f => {`);
|
|
844
|
+
lines.push(` const sql = fs.readFileSync(path.join(dir, f), "utf-8");`);
|
|
845
|
+
lines.push(` return {`);
|
|
846
|
+
lines.push(` id: "manual_" + f.replace(/\\.sql$/, ""),`);
|
|
847
|
+
lines.push(` checksum: createHash("sha256").update(sql).digest("hex"),`);
|
|
848
|
+
lines.push(` sql,`);
|
|
849
|
+
lines.push(` };`);
|
|
850
|
+
lines.push(` });`);
|
|
851
|
+
lines.push(`}`);
|
|
852
|
+
lines.push(``);
|
|
853
|
+
lines.push(`const LEDGER_DDL = \`CREATE TABLE IF NOT EXISTS schema_migrations (`);
|
|
854
|
+
lines.push(` id VARCHAR PRIMARY KEY,`);
|
|
855
|
+
lines.push(` checksum VARCHAR NOT NULL,`);
|
|
856
|
+
lines.push(` applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),`);
|
|
857
|
+
lines.push(` applied_by VARCHAR NOT NULL DEFAULT current_user,`);
|
|
858
|
+
lines.push(` duration_ms INTEGER NOT NULL DEFAULT 0`);
|
|
859
|
+
lines.push(`);\`;`);
|
|
860
|
+
lines.push(``);
|
|
708
861
|
lines.push(`async function migrate() {`);
|
|
709
|
-
lines.push(` console.log("Running migrations...");`);
|
|
710
862
|
lines.push(` const client = await pool.connect();`);
|
|
863
|
+
lines.push(` let applied = 0, skipped = 0;`);
|
|
711
864
|
lines.push(` try {`);
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
lines.push(`
|
|
865
|
+
lines.push(` await client.query(LEDGER_DDL);`);
|
|
866
|
+
lines.push(``);
|
|
867
|
+
lines.push(` // Generated blocks come first (compile-time schema), then manual diffs.`);
|
|
868
|
+
lines.push(` const allBlocks: Block[] = [...GENERATED_BLOCKS, ...loadManualBlocks()];`);
|
|
869
|
+
lines.push(``);
|
|
870
|
+
lines.push(` // Load already-applied entries.`);
|
|
871
|
+
lines.push(` const existing = await client.query(`);
|
|
872
|
+
lines.push(` "SELECT id, checksum FROM schema_migrations"`);
|
|
873
|
+
lines.push(` );`);
|
|
874
|
+
lines.push(` const rows = existing.rows as Array<{ id: string; checksum: string }>;`);
|
|
875
|
+
lines.push(` const appliedById = new Map(rows.map(r => [r.id, r.checksum]));`);
|
|
876
|
+
lines.push(``);
|
|
877
|
+
lines.push(` for (const block of allBlocks) {`);
|
|
878
|
+
lines.push(` const prior = appliedById.get(block.id);`);
|
|
879
|
+
lines.push(` if (prior === block.checksum) {`);
|
|
880
|
+
lines.push(` skipped++;`);
|
|
881
|
+
lines.push(` continue;`);
|
|
882
|
+
lines.push(` }`);
|
|
883
|
+
lines.push(` if (prior && prior !== block.checksum) {`);
|
|
884
|
+
lines.push(` // Someone changed an already-applied migration. Refuse to proceed —`);
|
|
885
|
+
lines.push(` // an explicit migration file should be authored instead.`);
|
|
886
|
+
lines.push(` throw new Error(`);
|
|
887
|
+
lines.push(" `Migration ${block.id} was previously applied with a different ` +");
|
|
888
|
+
lines.push(" `checksum. Generate a new migration via 'bonec diff --write' ` +");
|
|
889
|
+
lines.push(" `instead of editing existing schemas.`");
|
|
890
|
+
lines.push(` );`);
|
|
891
|
+
lines.push(` }`);
|
|
892
|
+
lines.push(``);
|
|
893
|
+
lines.push(` const start = Date.now();`);
|
|
894
|
+
lines.push(` try {`);
|
|
895
|
+
lines.push(` await client.query("BEGIN");`);
|
|
896
|
+
lines.push(` await client.query(block.sql);`);
|
|
897
|
+
lines.push(` await client.query(`);
|
|
898
|
+
lines.push(` "INSERT INTO schema_migrations (id, checksum, duration_ms) VALUES ($1, $2, $3)",`);
|
|
899
|
+
lines.push(` [block.id, block.checksum, Date.now() - start]`);
|
|
900
|
+
lines.push(` );`);
|
|
901
|
+
lines.push(` await client.query("COMMIT");`);
|
|
902
|
+
lines.push(" console.log(` applied ${block.id} (${Date.now() - start}ms)`);");
|
|
903
|
+
lines.push(` applied++;`);
|
|
904
|
+
lines.push(` } catch (e) {`);
|
|
905
|
+
lines.push(` await client.query("ROLLBACK").catch(() => {});`);
|
|
906
|
+
lines.push(` throw e;`);
|
|
907
|
+
lines.push(` }`);
|
|
908
|
+
lines.push(` }`);
|
|
909
|
+
lines.push(``);
|
|
910
|
+
lines.push(" console.log(`Migrations complete: ${applied} applied, ${skipped} already up to date.`);");
|
|
719
911
|
lines.push(` } finally {`);
|
|
720
912
|
lines.push(` client.release();`);
|
|
721
913
|
lines.push(` await pool.end();`);
|
package/src/emit_websocket.ts
CHANGED
|
@@ -29,7 +29,23 @@ export function emitWebSocketServer(system: IR.IRSystem): string {
|
|
|
29
29
|
lines.push(`import { eventBus } from "./events";`);
|
|
30
30
|
lines.push(`import { logger } from "./logger";`);
|
|
31
31
|
lines.push(``);
|
|
32
|
-
|
|
32
|
+
// Use the same secret-loading rules as the HTTP middleware.
|
|
33
|
+
// Refuse to boot in production without JWT_SECRET; warn in dev.
|
|
34
|
+
lines.push(`const JWT_SECRET = (() => {`);
|
|
35
|
+
lines.push(` const secret = process.env.JWT_SECRET;`);
|
|
36
|
+
lines.push(` if (!secret) {`);
|
|
37
|
+
lines.push(` if (process.env.NODE_ENV === "production") {`);
|
|
38
|
+
lines.push(` console.error("[FATAL] JWT_SECRET environment variable is not set. Refusing to start in production.");`);
|
|
39
|
+
lines.push(` process.exit(1);`);
|
|
40
|
+
lines.push(` }`);
|
|
41
|
+
lines.push(` console.warn("[WARN] JWT_SECRET is not set. Using insecure default — do not use in production.");`);
|
|
42
|
+
lines.push(` return "bonescript-dev-secret-do-not-use-in-production";`);
|
|
43
|
+
lines.push(` }`);
|
|
44
|
+
lines.push(` if (secret.length < 32) {`);
|
|
45
|
+
lines.push(` console.warn("[WARN] JWT_SECRET is shorter than 32 characters. Use a longer secret in production.");`);
|
|
46
|
+
lines.push(` }`);
|
|
47
|
+
lines.push(` return secret;`);
|
|
48
|
+
lines.push(`})();`);
|
|
33
49
|
lines.push(``);
|
|
34
50
|
// Redis pub/sub for multi-instance support
|
|
35
51
|
lines.push(`// Redis pub/sub for multi-instance WebSocket broadcasting`);
|
|
@@ -117,7 +133,11 @@ export function emitWebSocketServer(system: IR.IRSystem): string {
|
|
|
117
133
|
lines.push(``);
|
|
118
134
|
lines.push(` let userId: string;`);
|
|
119
135
|
lines.push(` try {`);
|
|
120
|
-
lines.push(` const decoded = jwt.verify(token, JWT_SECRET
|
|
136
|
+
lines.push(` const decoded = jwt.verify(token, JWT_SECRET, {`);
|
|
137
|
+
lines.push(` algorithms: ["HS256"],`);
|
|
138
|
+
lines.push(` maxAge: process.env.JWT_MAX_AGE || "1h",`);
|
|
139
|
+
lines.push(` }) as { sub?: unknown };`);
|
|
140
|
+
lines.push(` if (typeof decoded.sub !== "string" || decoded.sub.length === 0) throw new Error("invalid sub");`);
|
|
121
141
|
lines.push(` userId = decoded.sub;`);
|
|
122
142
|
lines.push(` } catch {`);
|
|
123
143
|
lines.push(` socket.send(JSON.stringify({ type: "error", message: "Authentication failed" }));`);
|
package/src/emit_zod.ts
CHANGED
|
@@ -62,9 +62,13 @@ export function emitZodSchemas(system: IR.IRSystem): string {
|
|
|
62
62
|
lines.push(`import { z } from "zod";`);
|
|
63
63
|
lines.push("");
|
|
64
64
|
|
|
65
|
-
// Model schemas
|
|
65
|
+
// Model schemas — dedupe by name since the same entity can appear in both
|
|
66
|
+
// an api_service module and its backing data_store module.
|
|
67
|
+
const seenModels = new Set<string>();
|
|
66
68
|
for (const mod of system.modules) {
|
|
67
69
|
for (const model of mod.models) {
|
|
70
|
+
if (seenModels.has(model.name)) continue;
|
|
71
|
+
seenModels.add(model.name);
|
|
68
72
|
const schemaName = toPascalCase(model.name) + "Schema";
|
|
69
73
|
const typeName = toPascalCase(model.name);
|
|
70
74
|
|
|
@@ -79,6 +83,12 @@ export function emitZodSchemas(system: IR.IRSystem): string {
|
|
|
79
83
|
}
|
|
80
84
|
lines.push(`});`);
|
|
81
85
|
lines.push(`export type ${typeName} = z.infer<typeof ${schemaName}>;`);
|
|
86
|
+
// Derived schemas for CRUD validation. `omit` drops server-managed fields
|
|
87
|
+
// and `partial` makes every key optional for PUT.
|
|
88
|
+
const hasState = model.fields.some(f => f.name === "state");
|
|
89
|
+
const createPartial = hasState ? ".partial({ state: true })" : "";
|
|
90
|
+
lines.push(`export const ${toPascalCase(model.name)}CreateSchema = ${schemaName}.omit({ id: true, created_at: true, updated_at: true })${createPartial};`);
|
|
91
|
+
lines.push(`export const ${toPascalCase(model.name)}UpdateSchema = ${schemaName}.omit({ id: true, created_at: true, updated_at: true }).partial();`);
|
|
82
92
|
lines.push("");
|
|
83
93
|
}
|
|
84
94
|
}
|
package/src/formatter.ts
CHANGED
|
@@ -76,7 +76,7 @@ export class Formatter {
|
|
|
76
76
|
|
|
77
77
|
if (e.owns.length > 0) {
|
|
78
78
|
if (e.owns.length <= 2) {
|
|
79
|
-
const fields = e.owns.map(f =>
|
|
79
|
+
const fields = e.owns.map(f => this.formatField(f)).join(", ");
|
|
80
80
|
this.line(`owns: [${fields}]`);
|
|
81
81
|
} else {
|
|
82
82
|
this.line(`owns: [`);
|
|
@@ -84,7 +84,7 @@ export class Formatter {
|
|
|
84
84
|
for (let i = 0; i < e.owns.length; i++) {
|
|
85
85
|
const f = e.owns[i];
|
|
86
86
|
const comma = i < e.owns.length - 1 ? "," : "";
|
|
87
|
-
this.line(`${
|
|
87
|
+
this.line(`${this.formatField(f)}${comma}`);
|
|
88
88
|
}
|
|
89
89
|
this.indent--;
|
|
90
90
|
this.line(`]`);
|
|
@@ -264,6 +264,13 @@ export class Formatter {
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
private formatField(f: AST.FieldNode): string {
|
|
268
|
+
let s = `${f.name}: ${this.formatType(f.type)}`;
|
|
269
|
+
if (f.renamedFrom) s += ` @renamed_from(${f.renamedFrom})`;
|
|
270
|
+
if (f.sensitive) s += ` @sensitive`;
|
|
271
|
+
return s;
|
|
272
|
+
}
|
|
273
|
+
|
|
267
274
|
private formatExpr(e: AST.ExprNode): string {
|
|
268
275
|
switch (e.kind) {
|
|
269
276
|
case "Literal":
|
package/src/ir.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface IRField {
|
|
|
17
17
|
unique: boolean;
|
|
18
18
|
indexed: boolean;
|
|
19
19
|
default_value: string | null;
|
|
20
|
+
renamed_from?: string | null;
|
|
21
|
+
sensitive?: boolean;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
// ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬ Models ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬
|
package/src/lexer.ts
CHANGED
|
@@ -25,6 +25,7 @@ export enum TokenKind {
|
|
|
25
25
|
Arrow = "Arrow",
|
|
26
26
|
Pipe = "Pipe",
|
|
27
27
|
Semicolon = "Semicolon",
|
|
28
|
+
At = "At",
|
|
28
29
|
|
|
29
30
|
// Operators
|
|
30
31
|
Equals = "Equals",
|
|
@@ -519,6 +520,7 @@ export class Lexer {
|
|
|
519
520
|
case "?": this.advance(); return { kind: TokenKind.Question, value: "?", loc };
|
|
520
521
|
case "!": this.advance(); return { kind: TokenKind.Bang, value: "!", loc };
|
|
521
522
|
case ";": this.advance(); return { kind: TokenKind.Semicolon, value: ";", loc };
|
|
523
|
+
case "@": this.advance(); return { kind: TokenKind.At, value: "@", loc };
|
|
522
524
|
}
|
|
523
525
|
|
|
524
526
|
// String literal
|
package/src/lowering.ts
CHANGED
|
@@ -380,9 +380,9 @@ export class Lowering {
|
|
|
380
380
|
config: {
|
|
381
381
|
authenticated: entity.auth !== null && entity.auth !== "none",
|
|
382
382
|
auth_method: entity.auth || "none",
|
|
383
|
-
audit: policies.some(p => p.audit === true),
|
|
384
|
-
rate_limit: policies.length > 0 && policies[0].rateLimit ? policies[0].rateLimit.count : 0,
|
|
385
|
-
rate_limit_window_ms: policies.length > 0 && policies[0].rateLimit ? (parseDurationMs(String(policies[0].rateLimit.per)) || 60000) : 60000,
|
|
383
|
+
audit: policies.some(p => p.audit === true),
|
|
384
|
+
rate_limit: policies.length > 0 && policies[0].rateLimit ? policies[0].rateLimit.count : 0,
|
|
385
|
+
rate_limit_window_ms: policies.length > 0 && policies[0].rateLimit ? (parseDurationMs(String(policies[0].rateLimit.per)) || 60000) : 60000,
|
|
386
386
|
},
|
|
387
387
|
};
|
|
388
388
|
}
|
|
@@ -554,6 +554,8 @@ export class Lowering {
|
|
|
554
554
|
unique: false,
|
|
555
555
|
indexed: false,
|
|
556
556
|
default_value: f.defaultValue ? serializeExpr(f.defaultValue) : null,
|
|
557
|
+
renamed_from: f.renamedFrom ?? null,
|
|
558
|
+
sensitive: f.sensitive ?? false,
|
|
557
559
|
};
|
|
558
560
|
}
|
|
559
561
|
}
|
package/src/parse_decls.ts
CHANGED
|
@@ -33,7 +33,37 @@ export function parseField(s: TokenStream): AST.FieldNode {
|
|
|
33
33
|
const type = parseTypeExpr(s);
|
|
34
34
|
let defaultValue: AST.ExprNode | null = null;
|
|
35
35
|
if (s.match(TokenKind.Equals)) { defaultValue = parseExpr(s); }
|
|
36
|
-
|
|
36
|
+
// Optional annotations: @renamed_from(old_name), @sensitive
|
|
37
|
+
let renamedFrom: string | null = null;
|
|
38
|
+
let sensitive = false;
|
|
39
|
+
while (s.check(TokenKind.At)) {
|
|
40
|
+
s.advance();
|
|
41
|
+
const annoName = parseIdentOrKeyword(s);
|
|
42
|
+
if (annoName === "renamed_from") {
|
|
43
|
+
s.expect(TokenKind.LParen, "renamed_from(old_name)");
|
|
44
|
+
renamedFrom = parseIdentOrKeyword(s);
|
|
45
|
+
s.expect(TokenKind.RParen, "renamed_from close");
|
|
46
|
+
} else if (annoName === "sensitive") {
|
|
47
|
+
// Bare flag annotation. Optional empty parens for forward compat.
|
|
48
|
+
sensitive = true;
|
|
49
|
+
if (s.match(TokenKind.LParen)) {
|
|
50
|
+
s.expect(TokenKind.RParen, "sensitive close");
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
// Unknown annotation — accept and ignore for forward compat; consume
|
|
54
|
+
// an optional (...) payload so it parses cleanly.
|
|
55
|
+
if (s.match(TokenKind.LParen)) {
|
|
56
|
+
let depth = 1;
|
|
57
|
+
while (depth > 0 && !s.check(TokenKind.EOF)) {
|
|
58
|
+
if (s.check(TokenKind.LParen)) depth++;
|
|
59
|
+
else if (s.check(TokenKind.RParen)) depth--;
|
|
60
|
+
if (depth > 0) s.advance();
|
|
61
|
+
}
|
|
62
|
+
s.expect(TokenKind.RParen, "annotation close");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { kind: "Field", loc, name, type, defaultValue, renamedFrom, sensitive };
|
|
37
67
|
}
|
|
38
68
|
|
|
39
69
|
export function parseIdentList(s: TokenStream): string[] {
|
package/src/typechecker.ts
CHANGED
|
@@ -404,6 +404,16 @@ export class TypeChecker {
|
|
|
404
404
|
const path = expr.path;
|
|
405
405
|
if (path.length === 0) return null;
|
|
406
406
|
|
|
407
|
+
// Built-in: `caller` resolves to the authenticated actor's identity.
|
|
408
|
+
// `caller.id` is a uuid; bare `caller` is a record { id: uuid } for now.
|
|
409
|
+
if (path[0] === "caller") {
|
|
410
|
+
const callerType = record("Caller", new Map([
|
|
411
|
+
["id", prim("uuid")],
|
|
412
|
+
["actor_id", prim("uuid")],
|
|
413
|
+
]));
|
|
414
|
+
return this.resolveFieldPath(callerType, path.slice(1), expr);
|
|
415
|
+
}
|
|
416
|
+
|
|
407
417
|
// First segment: look up in context
|
|
408
418
|
let currentType = ctx.lookup(path[0]);
|
|
409
419
|
|