bonescript-compiler 0.5.7 → 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 (71) hide show
  1. package/dist/ast.d.ts +2 -0
  2. package/dist/cli.js +53 -9
  3. package/dist/cli.js.map +1 -1
  4. package/dist/emit_admin.d.ts +5 -0
  5. package/dist/emit_admin.js +341 -35
  6. package/dist/emit_admin.js.map +1 -1
  7. package/dist/emit_audit.js +40 -6
  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_cron.d.ts +6 -0
  12. package/dist/emit_cron.js +66 -0
  13. package/dist/emit_cron.js.map +1 -0
  14. package/dist/emit_full.d.ts +6 -1
  15. package/dist/emit_full.js +38 -7
  16. package/dist/emit_full.js.map +1 -1
  17. package/dist/emit_graphql.d.ts +6 -0
  18. package/dist/emit_graphql.js +140 -0
  19. package/dist/emit_graphql.js.map +1 -0
  20. package/dist/emit_maintenance.js +35 -3
  21. package/dist/emit_maintenance.js.map +1 -1
  22. package/dist/emit_notify.d.ts +6 -0
  23. package/dist/emit_notify.js +85 -0
  24. package/dist/emit_notify.js.map +1 -0
  25. package/dist/emit_runtime.d.ts +18 -1
  26. package/dist/emit_runtime.js +217 -32
  27. package/dist/emit_runtime.js.map +1 -1
  28. package/dist/emit_websocket.js +22 -2
  29. package/dist/emit_websocket.js.map +1 -1
  30. package/dist/emit_zod.js +12 -1
  31. package/dist/emit_zod.js.map +1 -1
  32. package/dist/formatter.d.ts +1 -0
  33. package/dist/formatter.js +10 -2
  34. package/dist/formatter.js.map +1 -1
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +7 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/ir.d.ts +2 -0
  39. package/dist/lexer.d.ts +1 -0
  40. package/dist/lexer.js +4 -0
  41. package/dist/lexer.js.map +1 -1
  42. package/dist/lowering.js +2 -0
  43. package/dist/lowering.js.map +1 -1
  44. package/dist/parse_decls.js +36 -1
  45. package/dist/parse_decls.js.map +1 -1
  46. package/dist/scaffold.js +3 -1
  47. package/dist/scaffold.js.map +1 -1
  48. package/dist/typechecker.js +9 -0
  49. package/dist/typechecker.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/ast.ts +2 -0
  52. package/src/cli.ts +59 -11
  53. package/src/emit_admin.ts +343 -35
  54. package/src/emit_audit.ts +42 -6
  55. package/src/emit_capability.ts +13 -0
  56. package/src/emit_cron.ts +70 -0
  57. package/src/emit_full.ts +45 -7
  58. package/src/emit_graphql.ts +161 -0
  59. package/src/emit_maintenance.ts +35 -3
  60. package/src/emit_notify.ts +88 -0
  61. package/src/emit_runtime.ts +229 -32
  62. package/src/emit_websocket.ts +22 -2
  63. package/src/emit_zod.ts +11 -1
  64. package/src/formatter.ts +9 -2
  65. package/src/index.ts +3 -0
  66. package/src/ir.ts +2 -0
  67. package/src/lexer.ts +2 -0
  68. package/src/lowering.ts +5 -3
  69. package/src/parse_decls.ts +31 -1
  70. package/src/scaffold.ts +3 -1
  71. package/src/typechecker.ts +10 -0
@@ -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,26 +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",
63
+ "node-cron": "3.0.3",
64
+ zod: "3.23.8",
60
65
  },
61
66
  devDependencies: {
62
67
  "@types/express": "4.17.21",
63
- "@types/node": "18.19.0",
64
- "@types/pg": "8.10.9",
65
- "@types/ws": "8.5.10",
68
+ "@types/node": "20.14.0",
69
+ "@types/pg": "8.11.10",
70
+ "@types/ws": "8.5.13",
66
71
  "@types/cors": "2.8.17",
67
- "@types/jsonwebtoken": "9.0.5",
68
- "@types/uuid": "9.0.7",
69
- typescript: "5.3.3",
72
+ "@types/jsonwebtoken": "9.0.7",
73
+ "@types/uuid": "10.0.0",
74
+ "@types/node-cron": "3.0.11",
75
+ typescript: "5.6.3",
70
76
  "ts-node": "10.9.2",
71
77
  },
72
78
  };
@@ -143,6 +149,7 @@ export function emitAuthMiddleware(system: IR.IRSystem): string {
143
149
  return `// Generated by BoneScript compiler. DO NOT EDIT.
144
150
  import { Request, Response, NextFunction } from "express";
145
151
  import jwt from "jsonwebtoken";
152
+ import { v4 as uuid } from "uuid";
146
153
 
147
154
  // JWT_SECRET must be set in production. The server will refuse to start without it
148
155
  // when NODE_ENV is "production" to prevent accidental use of a weak fallback.
@@ -168,23 +175,45 @@ export interface AuthContext {
168
175
  trace_id: string;
169
176
  }
170
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
+
171
188
  export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
172
189
  const header = req.headers.authorization;
190
+ const traceId = resolveTraceId(req);
173
191
  if (!header || !header.startsWith("Bearer ")) {
174
- (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 };
175
193
  next();
176
194
  return;
177
195
  }
178
196
  try {
179
197
  const token = header.slice(7);
180
- 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
+ }
181
210
  (req as any).auth = {
182
211
  authenticated: true,
183
212
  actor_id: decoded.sub,
184
- trace_id: req.headers["x-trace-id"] as string || "",
213
+ trace_id: traceId,
185
214
  };
186
215
  } catch {
187
- (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 };
188
217
  }
189
218
  next();
190
219
  }
@@ -224,6 +253,9 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
224
253
  }
225
254
  lines.push(`import { logger } from "../logger";`);
226
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";`);
227
259
  lines.push(`import * as __algorithms from "../algorithms";`);
228
260
  lines.push(`const { shortestPath, topologicalSort, binarySearch, bipartiteMatching, roundRobin, weightedAverage, percentile, rankBy, consistentHash } = __algorithms as any;`);
229
261
  lines.push(``);
@@ -263,9 +295,15 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
263
295
  lines.push(`// CREATE`);
264
296
  const __crudMiddlewares = modRateLimit > 0 ? "__routeRateLimit, requireAuth" : "requireAuth";
265
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;`);
266
304
  lines.push(` try {`);
267
305
  lines.push(` const id = uuid();`);
268
- lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = req.body;`);
306
+ lines.push(` const { ${insertFields.map(f => f.name).join(", ")} } = __body as any;`);
269
307
  if (mod.state_machines.length > 0) {
270
308
  lines.push(` const state = ${mod.state_machines[0].entity.toUpperCase()}_INITIAL;`);
271
309
  }
@@ -329,10 +367,35 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
329
367
  lines.push(`});`);
330
368
  lines.push(``);
331
369
 
332
- // 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
+ }
333
381
  lines.push(`// UPDATE`);
382
+ lines.push(`const __${toCamelCase(routeBase)}Updatable = new Set<string>(${JSON.stringify(updatableColumns)});`);
334
383
  lines.push(`${toCamelCase(routeBase)}Router.put("/:id", ${__crudMiddlewares}, async (req: Request, res: Response) => {`);
335
- 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(` }`);
336
399
  if (mod.state_machines.length > 0) {
337
400
  const sm = mod.state_machines[0];
338
401
  lines.push(` // State machine enforcement`);
@@ -345,8 +408,9 @@ export function emitEntityRouter(mod: IR.IRModule, system: IR.IRSystem): string
345
408
  lines.push(` if (!tr.ok) return res.status(422).json({ error: { code: "INVALID_TRANSITION", message: \`Cannot transition from \${current.state} to \${fields.state}\` } });`);
346
409
  lines.push(` }`);
347
410
  }
348
- lines.push(` const sets = Object.keys(fields).map((k, i) => \`\${k} = $\${i + 2}\`).join(", ");`);
349
- 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]);`);
350
414
  lines.push(` const sql = \`UPDATE ${tableName} SET \${sets}, updated_at = NOW() WHERE id = $1 RETURNING *\`;`);
351
415
  lines.push(` const rows = await query(sql, [req.params.id, ...values]);`);
352
416
  lines.push(` if (rows.length === 0) return res.status(404).json({ error: { code: "NOT_FOUND", message: "Not found" } });`);
@@ -576,6 +640,7 @@ export function emitIndex(system: IR.IRSystem): string {
576
640
  );
577
641
  if (hasBatch) {
578
642
  lines.push(`import { startBatchWorker } from "./batch";`);
643
+ lines.push(`import { startCronJobs } from "./cron";`);
579
644
  }
580
645
  if (hasWebSocket) {
581
646
  lines.push(`import { setupWebSocketServer } from "./websocket";`);
@@ -594,6 +659,14 @@ export function emitIndex(system: IR.IRSystem): string {
594
659
  lines.push(`const httpServer = createServer(app);`);
595
660
  lines.push(`const PORT = parseInt(process.env.PORT || "3000");`);
596
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(``);
597
670
  lines.push(`// Middleware`);
598
671
  lines.push(`app.use(helmet());`);
599
672
  lines.push(`// CORS: restrict to ALLOWED_ORIGINS env var (comma-separated). Defaults to same-origin only.`);
@@ -653,6 +726,8 @@ export function emitIndex(system: IR.IRSystem): string {
653
726
  if (hasBatch) {
654
727
  lines.push(` // Start batch executor`);
655
728
  lines.push(` startBatchWorker();`);
729
+ lines.push(` // Start cron jobs`);
730
+ lines.push(` startCronJobs();`);
656
731
  }
657
732
  lines.push(` console.log(\`[${system.name}] Running on port \${PORT}\`);`);
658
733
  lines.push(` console.log(\` HTTP routes:\`);`);
@@ -694,23 +769,145 @@ export function emitIndex(system: IR.IRSystem): string {
694
769
 
695
770
  // ─── Migration Script ─────────────────────────────────────────────────────────
696
771
 
697
- 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
+
698
808
  const lines: string[] = [];
699
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.`);
700
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";`);
701
817
  lines.push(`import { pool } from "./db";`);
702
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(``);
703
861
  lines.push(`async function migrate() {`);
704
- lines.push(` console.log("Running migrations...");`);
705
862
  lines.push(` const client = await pool.connect();`);
863
+ lines.push(` let applied = 0, skipped = 0;`);
706
864
  lines.push(` try {`);
707
-
708
- for (const schema of schemas) {
709
- const escaped = schema.replace(/`/g, "\\`").replace(/\$/g, "\\$");
710
- lines.push(` await client.query(\`${escaped}\`);`);
711
- }
712
-
713
- 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.`);");
714
911
  lines.push(` } finally {`);
715
912
  lines.push(` client.release();`);
716
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/index.ts CHANGED
@@ -48,6 +48,9 @@ export { emitPostmanCollection } from "./emit_postman";
48
48
  export { emitSeedFile } from "./emit_seed";
49
49
  export { emitAuditSchema, emitAuditMiddleware } from "./emit_audit";
50
50
  export { emitAdminPanel } from "./emit_admin";
51
+ export { emitNotifyService } from "./emit_notify";
52
+ export { emitCronJobs } from "./emit_cron";
53
+ export { emitGraphQLSchema } from "./emit_graphql";
51
54
 
52
55
  /**
53
56
  * Convenience function: compile a .bone source string to files.
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[] {
package/src/scaffold.ts CHANGED
@@ -15,7 +15,9 @@ export type ScaffoldDomain =
15
15
  | "realtime_collaboration";
16
16
 
17
17
  const TEMPLATES: Record<ScaffoldDomain, string> = {
18
- multiplayer_game: `system MyGame {
18
+ multiplayer_game: `// Compile with: bonec compile {name}.bone --target nakama
19
+ // for Nakama TypeScript runtime output instead of Express/PostgreSQL
20
+ system MyGame {
19
21
  domain: multiplayer_game
20
22
 
21
23
  entity Player {
@@ -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