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.
Files changed (51) hide show
  1. package/dist/ast.d.ts +2 -0
  2. package/dist/cli.js +52 -8
  3. package/dist/cli.js.map +1 -1
  4. package/dist/emit_admin.d.ts +5 -0
  5. package/dist/emit_admin.js +340 -35
  6. package/dist/emit_admin.js.map +1 -1
  7. package/dist/emit_audit.js +38 -4
  8. package/dist/emit_audit.js.map +1 -1
  9. package/dist/emit_capability.js +14 -0
  10. package/dist/emit_capability.js.map +1 -1
  11. package/dist/emit_full.js +10 -2
  12. package/dist/emit_full.js.map +1 -1
  13. package/dist/emit_maintenance.js +35 -3
  14. package/dist/emit_maintenance.js.map +1 -1
  15. package/dist/emit_runtime.d.ts +18 -1
  16. package/dist/emit_runtime.js +212 -32
  17. package/dist/emit_runtime.js.map +1 -1
  18. package/dist/emit_websocket.js +22 -2
  19. package/dist/emit_websocket.js.map +1 -1
  20. package/dist/emit_zod.js +12 -1
  21. package/dist/emit_zod.js.map +1 -1
  22. package/dist/formatter.d.ts +1 -0
  23. package/dist/formatter.js +10 -2
  24. package/dist/formatter.js.map +1 -1
  25. package/dist/ir.d.ts +2 -0
  26. package/dist/lexer.d.ts +1 -0
  27. package/dist/lexer.js +4 -0
  28. package/dist/lexer.js.map +1 -1
  29. package/dist/lowering.js +2 -0
  30. package/dist/lowering.js.map +1 -1
  31. package/dist/parse_decls.js +36 -1
  32. package/dist/parse_decls.js.map +1 -1
  33. package/dist/typechecker.js +9 -0
  34. package/dist/typechecker.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/ast.ts +2 -0
  37. package/src/cli.ts +58 -10
  38. package/src/emit_admin.ts +342 -35
  39. package/src/emit_audit.ts +40 -4
  40. package/src/emit_capability.ts +13 -0
  41. package/src/emit_full.ts +9 -2
  42. package/src/emit_maintenance.ts +35 -3
  43. package/src/emit_runtime.ts +224 -32
  44. package/src/emit_websocket.ts +22 -2
  45. package/src/emit_zod.ts +11 -1
  46. package/src/formatter.ts +9 -2
  47. package/src/ir.ts +2 -0
  48. package/src/lexer.ts +2 -0
  49. package/src/lowering.ts +5 -3
  50. package/src/parse_decls.ts +31 -1
  51. package/src/typechecker.ts +10 -0
@@ -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
- lines.push(`// Prometheus-style metrics`);
225
- lines.push(`healthRouter.get("/metrics", (_req: Request, res: Response) => {`);
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
  }
@@ -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
- express: "4.18.2",
51
- pg: "8.11.3",
52
- ioredis: "5.3.2",
53
- ws: "8.16.0",
54
- uuid: "9.0.0",
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: "7.1.0",
57
- "express-rate-limit": "7.1.5",
59
+ helmet: "8.0.0",
60
+ "express-rate-limit": "7.5.0",
58
61
  jsonwebtoken: "9.0.2",
59
- dotenv: "16.3.1",
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": "18.19.0",
65
- "@types/pg": "8.10.9",
66
- "@types/ws": "8.5.10",
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.5",
69
- "@types/uuid": "9.0.7",
72
+ "@types/jsonwebtoken": "9.0.7",
73
+ "@types/uuid": "10.0.0",
70
74
  "@types/node-cron": "3.0.11",
71
- typescript: "5.3.3",
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: req.headers["x-trace-id"] as string || "" };
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
- const decoded = jwt.verify(token, JWT_SECRET) as { sub: string };
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: req.headers["x-trace-id"] as string || "",
213
+ trace_id: traceId,
187
214
  };
188
215
  } catch {
189
- (req as any).auth = { authenticated: false, actor_id: null, trace_id: req.headers["x-trace-id"] as string || "" };
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(", ")} } = req.body;`);
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(` const fields = { ...req.body };`);
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 sets = Object.keys(fields).map((k, i) => \`\${k} = $\${i + 2}\`).join(", ");`);
351
- lines.push(` const values = Object.values(fields);`);
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
- export function emitMigration(system: IR.IRSystem, schemas: string[]): string {
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
- for (const schema of schemas) {
714
- const escaped = schema.replace(/`/g, "\\`").replace(/\$/g, "\\$");
715
- lines.push(` await client.query(\`${escaped}\`);`);
716
- }
717
-
718
- lines.push(` console.log("Migrations complete.");`);
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();`);
@@ -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
- lines.push(`const JWT_SECRET = process.env.JWT_SECRET || "bonescript-dev-secret-change-in-production";`);
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) as { sub: string };`);
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 => `${f.name}: ${this.formatType(f.type)}`).join(", ");
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(`${f.name}: ${this.formatType(f.type)}${comma}`);
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
  }
@@ -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
- return { kind: "Field", loc, name, type, defaultValue };
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[] {
@@ -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