@basaltkit/ai 1.2.0 → 1.4.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.
@@ -5,6 +5,12 @@
5
5
  export interface ProjectReader {
6
6
  read(relPath: string): string | null;
7
7
  exists(relPath: string): boolean;
8
+ /**
9
+ * Every file under `relDir`, recursively, as paths relative to the root.
10
+ * Optional: a reader without it simply skips the checks that scan the
11
+ * project's route files.
12
+ */
13
+ list?(relDir: string): string[];
8
14
  }
9
15
  /** Default reader backed by the filesystem. */
10
16
  export declare function nodeReader(root: string): ProjectReader;
@@ -64,8 +70,23 @@ export interface EnvFileInfo {
64
70
  /** Default value baked into `REDIS_URL`, if any. */
65
71
  redisUrlDefault: string | null;
66
72
  }
73
+ /**
74
+ * A route declared central (`meta.tenant: false`) whose permission names the
75
+ * platform plane (`can: 'platform:…'`), without `tenant: 'never'`. It still
76
+ * runs on a tenant host, against that tenant's storage.
77
+ */
78
+ export interface CentralPlatformRouteHint {
79
+ path: string;
80
+ /** 1-based line of the `meta` object. */
81
+ line: number;
82
+ }
67
83
  export interface ProjectContext {
68
84
  root: string;
85
+ /**
86
+ * Platform routes that do not refuse a tenant. Absent when the reader cannot
87
+ * list files.
88
+ */
89
+ centralPlatformRoutes?: CentralPlatformRouteHint[];
69
90
  /** All `@basaltkit/*` packages found in package.json deps. */
70
91
  installed: string[];
71
92
  stack: DetectedStack;
@@ -1,5 +1,7 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ /** Upper bound on the files a route scan reads — a doctor run stays cheap on a large repo. */
4
+ const MAX_SCANNED_FILES = 2000;
3
5
  /** Default reader backed by the filesystem. */
4
6
  export function nodeReader(root) {
5
7
  return {
@@ -12,13 +14,49 @@ export function nodeReader(root) {
12
14
  }
13
15
  },
14
16
  exists: (relPath) => existsSync(join(root, relPath)),
17
+ list(relDir) {
18
+ const out = [];
19
+ const walk = (dir) => {
20
+ let entries;
21
+ try {
22
+ entries = readdirSync(join(root, dir), { withFileTypes: true });
23
+ }
24
+ catch {
25
+ return;
26
+ }
27
+ for (const entry of entries) {
28
+ if (out.length >= MAX_SCANNED_FILES)
29
+ return;
30
+ if (entry.name === 'node_modules' || entry.name.startsWith('.'))
31
+ continue;
32
+ const rel = `${dir}/${entry.name}`;
33
+ if (entry.isDirectory())
34
+ walk(rel);
35
+ else if (entry.isFile())
36
+ out.push(rel);
37
+ }
38
+ };
39
+ walk(trimTrailingSlashes(relDir));
40
+ return out;
41
+ },
15
42
  };
16
43
  }
44
+ /** Drops trailing `/` characters in linear time (a `/\/+$/` regex backtracks on long runs of `/`). */
45
+ function trimTrailingSlashes(path) {
46
+ let end = path.length;
47
+ while (end > 0 && path.charCodeAt(end - 1) === 47)
48
+ end--;
49
+ return path.slice(0, end);
50
+ }
17
51
  /** In-memory reader for tests: keys are relative paths. */
18
52
  export function memoryReader(files) {
19
53
  return {
20
54
  read: (relPath) => files[relPath] ?? null,
21
55
  exists: (relPath) => relPath in files,
56
+ list: (relDir) => {
57
+ const prefix = `${trimTrailingSlashes(relDir)}/`;
58
+ return Object.keys(files).filter((path) => path.startsWith(prefix));
59
+ },
22
60
  };
23
61
  }
24
62
  const APP_CANDIDATES = ['src/app.ts', 'src/app.js', 'app.ts'];
@@ -58,7 +96,52 @@ export function detectProject(root, reader = nodeReader(root)) {
58
96
  const env = detectEnvFile(reader);
59
97
  const prisma = detectPrisma(reader);
60
98
  const stack = buildStack(installed, app, prisma);
61
- return { root, installed, stack, prisma, app, server, env };
99
+ const centralPlatformRoutes = detectCentralPlatformRoutes(reader);
100
+ return {
101
+ root,
102
+ installed,
103
+ stack,
104
+ prisma,
105
+ app,
106
+ server,
107
+ env,
108
+ ...(centralPlatformRoutes ? { centralPlatformRoutes } : {}),
109
+ };
110
+ }
111
+ /**
112
+ * Finds `meta: { … }` literals with `tenant: false` and a `can` naming
113
+ * `platform:…`. A regex over flat object literals, not a parser: it sees
114
+ * `meta: { tenant: false, can: 'platform:plans' }` written inline, which is
115
+ * how routes are declared; meta built elsewhere and spread in goes unseen.
116
+ */
117
+ function detectCentralPlatformRoutes(reader) {
118
+ if (!reader.list)
119
+ return undefined;
120
+ const files = reader
121
+ .list('src')
122
+ .filter((path) => /\.[cm]?[jt]s$/.test(path) && !/\.d\.ts$|\.(test|spec)\.[cm]?[jt]s$/.test(path))
123
+ .slice(0, MAX_SCANNED_FILES);
124
+ const hits = [];
125
+ for (const path of files) {
126
+ const content = reader.read(path);
127
+ if (!content || !content.includes('platform:'))
128
+ continue;
129
+ // Same length as the source, comments and string text blanked: a match
130
+ // must start in code, so a commented-out route never counts.
131
+ const code = codeOnly(content);
132
+ for (const match of content.matchAll(/\bmeta\s*:\s*\{([^{}]*)\}/g)) {
133
+ const index = match.index ?? 0;
134
+ if (code.slice(index, index + 4) !== 'meta')
135
+ continue;
136
+ const body = match[1] ?? '';
137
+ if (!/\btenant\s*:\s*false\b/.test(body))
138
+ continue;
139
+ if (!/\bcan\s*:\s*\[?\s*['"`]platform:/.test(body))
140
+ continue;
141
+ hits.push({ path, line: content.slice(0, index).split('\n').length });
142
+ }
143
+ }
144
+ return hits;
62
145
  }
63
146
  function detectInstalled(reader) {
64
147
  const raw = reader.read('package.json');
@@ -84,9 +167,13 @@ function firstExisting(reader, candidates) {
84
167
  return null;
85
168
  }
86
169
  function detectAppFile(reader) {
87
- const found = firstExisting(reader, APP_CANDIDATES);
88
- if (!found)
170
+ const raw = firstExisting(reader, APP_CANDIDATES);
171
+ if (!raw)
89
172
  return null;
173
+ // Match against CODE only: a commented-out `// tenantMembershipPlugin()` (or the
174
+ // name inside a string) must never count as a registration — that silenced
175
+ // security rules for exactly the apps that had disabled the guard.
176
+ const found = { path: raw.path, content: codeOnly(raw.content) };
90
177
  const plugins = PLUGIN_FACTORIES.filter((factory) => new RegExp(`\\b${factory}\\s*\\(`).test(found.content));
91
178
  const fastifyLoggerConfigured = /fastify\s*:\s*\{[^}]*\blogger\b/.test(found.content);
92
179
  const memorySources = [
@@ -104,6 +191,95 @@ function detectAppFile(reader) {
104
191
  ];
105
192
  return { path: found.path, plugins, fastifyLoggerConfigured, memorySources, pluginCalls };
106
193
  }
194
+ /**
195
+ * The source with comments removed and string/template-literal TEXT blanked
196
+ * (quotes and `${…}` expressions are kept, newlines preserved), so detection
197
+ * regexes only ever see code. A lexer-lite, not a parser: regex literals are
198
+ * not special-cased, which is fine for app composition files.
199
+ */
200
+ function codeOnly(source) {
201
+ let out = '';
202
+ let i = 0;
203
+ // Stack of open template literals; each entry is the `${` brace depth inside it.
204
+ const templates = [];
205
+ const blank = (ch) => (ch === '\n' ? '\n' : ' ');
206
+ const inTemplateText = () => templates.length > 0 && templates[templates.length - 1] === -1;
207
+ while (i < source.length) {
208
+ const ch = source[i];
209
+ const next = source[i + 1];
210
+ if (inTemplateText()) {
211
+ if (ch === '\\') {
212
+ out += ' ';
213
+ i += 2;
214
+ }
215
+ else if (ch === '`') {
216
+ templates.pop();
217
+ out += ch;
218
+ i++;
219
+ }
220
+ else if (ch === '$' && next === '{') {
221
+ templates[templates.length - 1] = 0;
222
+ out += '${';
223
+ i += 2;
224
+ }
225
+ else {
226
+ out += blank(ch);
227
+ i++;
228
+ }
229
+ continue;
230
+ }
231
+ if (ch === '/' && next === '/') {
232
+ while (i < source.length && source[i] !== '\n') {
233
+ out += ' ';
234
+ i++;
235
+ }
236
+ }
237
+ else if (ch === '/' && next === '*') {
238
+ const end = source.indexOf('*/', i + 2);
239
+ const stop = end === -1 ? source.length : end + 2;
240
+ for (; i < stop; i++)
241
+ out += blank(source[i]);
242
+ }
243
+ else if (ch === "'" || ch === '"') {
244
+ out += ch;
245
+ i++;
246
+ while (i < source.length && source[i] !== ch && source[i] !== '\n') {
247
+ if (source[i] === '\\') {
248
+ out += ' ';
249
+ i++;
250
+ }
251
+ if (i < source.length)
252
+ out += blank(source[i]);
253
+ i++;
254
+ }
255
+ if (i < source.length) {
256
+ out += source[i];
257
+ i++;
258
+ }
259
+ }
260
+ else if (ch === '`') {
261
+ templates.push(-1);
262
+ out += ch;
263
+ i++;
264
+ }
265
+ else {
266
+ if (templates.length > 0) {
267
+ const top = templates.length - 1;
268
+ if (ch === '{')
269
+ templates[top] = templates[top] + 1;
270
+ else if (ch === '}') {
271
+ if (templates[top] === 0)
272
+ templates[top] = -1;
273
+ else
274
+ templates[top] = templates[top] - 1;
275
+ }
276
+ }
277
+ out += ch;
278
+ i++;
279
+ }
280
+ }
281
+ return out;
282
+ }
107
283
  function detectServerFile(reader) {
108
284
  const found = firstExisting(reader, SERVER_CANDIDATES);
109
285
  if (!found)
@@ -109,27 +109,56 @@ export const DEFAULT_RULES = [
109
109
  check(ctx) {
110
110
  if (!ctx.app || !ctx.stack.tenancy || !ctx.stack.auth)
111
111
  return null;
112
- const teamsAvailable = ctx.app.pluginCalls.includes('teamsPlugin') || ctx.installed.includes('@basaltkit/teams');
113
- if (!teamsAvailable)
114
- return null;
115
112
  if (ctx.app.pluginCalls.includes('tenantMembershipPlugin'))
116
113
  return null;
114
+ // Fire whether or not @basaltkit/teams is installed: tenancy + auth with no
115
+ // membership guard is the vulnerable shape regardless — staying silent
116
+ // because the package providing the fix is absent hid it in fresh apps.
117
+ const teamsInstalled = ctx.app.pluginCalls.includes('teamsPlugin') || ctx.installed.includes('@basaltkit/teams');
117
118
  return {
118
119
  id: 'missing-tenant-membership',
119
120
  title: 'Tenant is resolved from the request but membership is never enforced',
120
121
  severity: 'error',
121
122
  category: 'tenancy',
122
- detected: `${ctx.app.path} wires tenancy + auth (+ teams) but no tenantMembershipPlugin`,
123
- recommended: 'register tenantMembershipPlugin() so every authenticated, tenant-scoped request verifies the user belongs to the resolved tenant',
123
+ detected: `${ctx.app.path} wires tenancy + auth${teamsInstalled ? ' (+ teams)' : ''} but no tenantMembershipPlugin`,
124
+ recommended: teamsInstalled
125
+ ? 'register tenantMembershipPlugin() so every authenticated, tenant-scoped request verifies the user belongs to the resolved tenant'
126
+ : 'install @basaltkit/teams and register teamsPlugin() + tenantMembershipPlugin() so every authenticated, tenant-scoped request verifies the user belongs to the resolved tenant',
124
127
  reason: 'Resolvers take the tenant from client input (x-tenant-id / Host). Without a ' +
125
128
  'membership check, any authenticated user can act on another tenant just by ' +
126
129
  'changing that header — a cross-tenant data breach. Tenant resolution is ' +
127
130
  'identification, never authorization.',
128
- fix: "tenantMembershipPlugin() // central routes opt out with meta: { central: true }",
131
+ fix: teamsInstalled
132
+ ? 'tenantMembershipPlugin() // central routes opt out with meta: { central: true }'
133
+ : 'pnpm add @basaltkit/teams\n// then: teamsPlugin(), tenantMembershipPlugin() // central routes opt out with meta: { central: true }',
129
134
  docs: '/guide/security',
130
135
  };
131
136
  },
132
137
  }),
138
+ defineRule({
139
+ id: 'platform-route-accepts-tenant',
140
+ category: 'tenancy',
141
+ check(ctx) {
142
+ const hits = ctx.centralPlatformRoutes ?? [];
143
+ if (hits.length === 0)
144
+ return null;
145
+ const where = hits.map((hit) => `${hit.path}:${hit.line}`).join(', ');
146
+ return {
147
+ id: 'platform-route-accepts-tenant',
148
+ title: "Platform route declares tenant: false instead of tenant: 'never'",
149
+ severity: 'warning',
150
+ category: 'tenancy',
151
+ detected: `platform routes still served on tenant hosts: ${where}`,
152
+ recommended: "declare meta: { tenant: 'never' } on routes of the central plane",
153
+ reason: '`tenant: false` lifts the tenant requirement but still resolves one: ' +
154
+ 'acme.<host>/platform/… runs inside Acme, against its database, and a ' +
155
+ "tenant owner holding '*' satisfies 'platform:*'. `tenant: 'never'` answers " +
156
+ 'a tenant host with the plain 404 before any guard runs.',
157
+ fix: "meta: { tenant: 'never', auth: true, can: 'platform:plans.manage' }",
158
+ docs: '/guide/multi-tenant-pattern',
159
+ };
160
+ },
161
+ }),
133
162
  defineRule({
134
163
  id: 'tenant-scoping-missing',
135
164
  category: 'tenancy',
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { fetchWithRetry, type RetryOptions } from './provider/http.js';
6
6
  export { globalSseFetch, parseSseContent, type SseFetch, type SseResponse } from './provider/sse.js';
7
7
  export { createProvider, providerEnvFrom, providerEnvFromProcess, type CreateProviderOptions, type ProviderEnv, type ProviderName, } from './provider/factory.js';
8
8
  export { runGeneration, generateText, withAbort, throwIfAborted, abortError, isAbortError, type OnProgress, type WorkflowProgress, type WorkflowRunOptions, } from './generate.js';
9
- export { detectProject, nodeReader, memoryReader, type AppFileInfo, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
9
+ export { detectProject, nodeReader, memoryReader, type AppFileInfo, type CentralPlatformRouteHint, type DetectedStack, type EnvFileInfo, type PrismaInfo, type PrismaModel, type ProjectContext, type ProjectReader, type ServerFileInfo, } from './context/project.js';
10
10
  export { defineRule, type Diagnostic, type DiagnosticCategory, type DoctorRule, type Severity, } from './doctor/types.js';
11
11
  export { DEFAULT_RULES } from './doctor/rules.js';
12
12
  export { hasErrors, runDoctor } from './doctor/run.js';
@@ -18,6 +18,7 @@ export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
18
18
  export { renderPlan } from './plan/render.js';
19
19
  export type { ArchitecturePlan, PlanEntity, PlanField, PlanRelation, PlanStep, PlanStepKind, } from './plan/types.js';
20
20
  export { runMake } from './make/make.js';
21
+ export { assertSafePlan, UnsafePlanError } from './plan/identifiers.js';
21
22
  export { renderMakeResult } from './make/render.js';
22
23
  export { verifyProject, type VerifyResult } from './make/verify.js';
23
24
  export { canonicalType, domainFields, injectPrismaFields, injectZodFields, prismaType, zodForField, zodValidator, type CanonicalType, } from './make/fields.js';
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ export { BASALT_KNOWLEDGE } from './plan/knowledge.js';
24
24
  export { renderPlan } from './plan/render.js';
25
25
  // Implementation (spec §4 Implement) — execute a plan via the generator + review gate.
26
26
  export { runMake } from './make/make.js';
27
+ export { assertSafePlan, UnsafePlanError } from './plan/identifiers.js';
27
28
  export { renderMakeResult } from './make/render.js';
28
29
  export { verifyProject } from './make/verify.js';
29
30
  export { canonicalType, domainFields, injectPrismaFields, injectZodFields, prismaType, zodForField, zodValidator, } from './make/fields.js';
@@ -11,6 +11,12 @@ export declare function prismaType(t: CanonicalType): string;
11
11
  * ISO string, matching the mapper.
12
12
  */
13
13
  export declare function zodValidator(t: CanonicalType, create: boolean): string;
14
+ /**
15
+ * A single-quoted TS string literal for arbitrary text. Plan enum values are
16
+ * untrusted (LLM output / MCP client input): escaping via JSON.stringify means
17
+ * quotes, backslashes and line breaks can never end the literal early.
18
+ */
19
+ export declare function tsStringLiteral(value: string): string;
14
20
  /** Zod validator for a field, honoring an enum (`z.enum([...])`) over its base type. */
15
21
  export declare function zodForField(field: PlanField, create: boolean): string;
16
22
  /** The entity's own fields, minus reserved/base columns and duplicates. */
@@ -55,10 +55,24 @@ export function zodValidator(t, create) {
55
55
  function usable(field) {
56
56
  return field.name.trim() !== '' && !RESERVED.has(field.name.toLowerCase());
57
57
  }
58
+ /**
59
+ * A single-quoted TS string literal for arbitrary text. Plan enum values are
60
+ * untrusted (LLM output / MCP client input): escaping via JSON.stringify means
61
+ * quotes, backslashes and line breaks can never end the literal early.
62
+ */
63
+ export function tsStringLiteral(value) {
64
+ const escaped = JSON.stringify(String(value))
65
+ .slice(1, -1)
66
+ .replace(/\\"/g, '"')
67
+ .replace(/'/g, "\\'")
68
+ .replace(/\u2028/g, '\\u2028')
69
+ .replace(/\u2029/g, '\\u2029');
70
+ return `'${escaped}'`;
71
+ }
58
72
  /** Zod validator for a field, honoring an enum (`z.enum([...])`) over its base type. */
59
73
  export function zodForField(field, create) {
60
74
  if (field.enum && field.enum.length > 0) {
61
- return `z.enum([${field.enum.map((v) => `'${v}'`).join(', ')}])`;
75
+ return `z.enum([${field.enum.map(tsStringLiteral).join(', ')}])`;
62
76
  }
63
77
  return zodValidator(canonicalType(field.type), create);
64
78
  }
package/dist/make/make.js CHANGED
@@ -5,6 +5,7 @@ import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { unifiedDiff } from './diff.js';
7
7
  import { domainFields, injectPrismaFields, injectZodFields } from './fields.js';
8
+ import { assertSafePlan } from '../plan/identifiers.js';
8
9
  import { externalRelationTargets, injectPrismaRelations, inverseRelationLines, relationFieldLines, relationForeignKeys, } from './relations.js';
9
10
  import { injectOpenApiMeta } from './openapi.js';
10
11
  import { renderPermissionsFile } from './permissions.js';
@@ -22,6 +23,9 @@ import { injectAuditPlugin, injectAuditService, injectPermissionGuards } from '.
22
23
  * touches the database, never deletes.
23
24
  */
24
25
  export async function runMake(ctx, plan, options = {}) {
26
+ // The plan is untrusted (LLM / MCP client): refuse any name that could
27
+ // inject code into the generated sources before generating anything.
28
+ assertSafePlan(plan);
25
29
  const entities = plan.entities.filter((e) => e.name.trim() !== '');
26
30
  if (entities.length === 0) {
27
31
  throw new Error('ai:make — the plan has no entity to generate.');
@@ -36,7 +40,13 @@ export async function runMake(ctx, plan, options = {}) {
36
40
  const gen = findGeneratorCommand(plan, entity.name);
37
41
  const prisma = options.prisma ?? gen?.prisma ?? prismaDefault;
38
42
  const softDelete = options.softDelete ?? gen?.softDelete ?? false;
39
- const generated = generateResource(entity.name, { prisma, softDelete });
43
+ // Routes are authenticated by the generator's secure default. A
44
+ // tenant-scoped entity gets the generator's tenant-owned in-memory
45
+ // repository (requireTenantId()); with Prisma, augmentFiles below replaces
46
+ // the repository and injects tenantId + @@index itself, so the generator's
47
+ // tenant output is not requested there (it would duplicate the index).
48
+ const tenant = ctx.stack.tenancy && entity.tenantScoped && !prisma;
49
+ const generated = generateResource(entity.name, { prisma, softDelete, tenant });
40
50
  const { files, augmented, guarded, audited } = augmentFiles(generated, entity, plan, ctx, { prisma, softDelete });
41
51
  const build = {
42
52
  name: entity.name,
@@ -1,5 +1,5 @@
1
1
  import { names } from '@basaltkit/generator/resource';
2
- import { canonicalType, domainFields } from './fields.js';
2
+ import { canonicalType, domainFields, tsStringLiteral } from './fields.js';
3
3
  /** TypeScript type of a Prisma row column for the mapper's parameter. */
4
4
  function rowType(t) {
5
5
  switch (t) {
@@ -38,7 +38,7 @@ export function renderPrismaRepository(name, fields, options) {
38
38
  }
39
39
  // Enum columns are stored as String; the mapper narrows them to the union.
40
40
  const colType = (f) => (f.values ? 'string' : rowType(f.canon));
41
- const colExpr = (f) => f.values ? `r.${f.name} as ${f.values.map((v) => `'${v}'`).join(' | ')}` : mapExpr(f.name, f.canon);
41
+ const colExpr = (f) => f.values ? `r.${f.name} as ${f.values.map(tsStringLiteral).join(' | ')}` : mapExpr(f.name, f.canon);
42
42
  const rowTypeEntries = [
43
43
  'id: string',
44
44
  ...bodyFields.map((f) => `${f.name}: ${colType(f)}`),
@@ -0,0 +1,22 @@
1
+ import type { ArchitecturePlan } from './types.js';
2
+ /**
3
+ * A plan is untrusted input — an LLM output, or a plan object an MCP client
4
+ * hands to `basalt_make`. Every name in it is interpolated into generated
5
+ * TypeScript/Prisma source, so names are restricted to shapes that can never
6
+ * break out of their syntactic position (identifier, string literal).
7
+ */
8
+ /** Field and relation names — emitted verbatim as TS properties and Prisma columns. */
9
+ export declare const PLAN_IDENTIFIER: RegExp;
10
+ /** Entity / related-model names — normalized by the generator's `names()` (words → PascalCase). */
11
+ export declare const PLAN_ENTITY_NAME: RegExp;
12
+ /** Audit event names (`invoice.created`, `billing:paid`) — emitted inside string literals. */
13
+ export declare const PLAN_EVENT_NAME: RegExp;
14
+ export declare class UnsafePlanError extends Error {
15
+ constructor(message: string);
16
+ }
17
+ /**
18
+ * Throws {@link UnsafePlanError} if any name in the plan could inject code into
19
+ * the generated sources. Enum VALUES are free text: they are always emitted as
20
+ * escaped string literals (see `tsStringLiteral`), never raw.
21
+ */
22
+ export declare function assertSafePlan(plan: ArchitecturePlan): void;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A plan is untrusted input — an LLM output, or a plan object an MCP client
3
+ * hands to `basalt_make`. Every name in it is interpolated into generated
4
+ * TypeScript/Prisma source, so names are restricted to shapes that can never
5
+ * break out of their syntactic position (identifier, string literal).
6
+ */
7
+ /** Field and relation names — emitted verbatim as TS properties and Prisma columns. */
8
+ export const PLAN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+ /** Entity / related-model names — normalized by the generator's `names()` (words → PascalCase). */
10
+ export const PLAN_ENTITY_NAME = /^[A-Za-z][A-Za-z0-9 _-]{0,99}$/;
11
+ /** Audit event names (`invoice.created`, `billing:paid`) — emitted inside string literals. */
12
+ export const PLAN_EVENT_NAME = /^[A-Za-z0-9_]+(?:[.:-][A-Za-z0-9_]+)*$/;
13
+ export class UnsafePlanError extends Error {
14
+ constructor(message) {
15
+ super(`ai:make — unsafe plan: ${message}`);
16
+ this.name = 'UnsafePlanError';
17
+ }
18
+ }
19
+ /**
20
+ * Throws {@link UnsafePlanError} if any name in the plan could inject code into
21
+ * the generated sources. Enum VALUES are free text: they are always emitted as
22
+ * escaped string literals (see `tsStringLiteral`), never raw.
23
+ */
24
+ export function assertSafePlan(plan) {
25
+ const entities = plan.entities;
26
+ if (!Array.isArray(entities))
27
+ throw new UnsafePlanError('`entities` must be an array.');
28
+ for (const entity of plan.entities) {
29
+ if (typeof entity.name !== 'string' || !PLAN_ENTITY_NAME.test(entity.name)) {
30
+ throw new UnsafePlanError(`entity name ${JSON.stringify(entity.name)} is not a valid identifier.`);
31
+ }
32
+ for (const field of entity.fields ?? []) {
33
+ if (typeof field.name !== 'string' || !PLAN_IDENTIFIER.test(field.name)) {
34
+ throw new UnsafePlanError(`field name ${JSON.stringify(field.name)} on ${entity.name} is not a valid identifier.`);
35
+ }
36
+ if (field.enum !== undefined && !(Array.isArray(field.enum) && field.enum.every((v) => typeof v === 'string'))) {
37
+ throw new UnsafePlanError(`enum of ${entity.name}.${field.name} must be an array of strings.`);
38
+ }
39
+ }
40
+ for (const relation of entity.relations ?? []) {
41
+ if (typeof relation.name !== 'string' || !PLAN_IDENTIFIER.test(relation.name)) {
42
+ throw new UnsafePlanError(`relation name ${JSON.stringify(relation.name)} on ${entity.name} is not a valid identifier.`);
43
+ }
44
+ if (typeof relation.model !== 'string' || !PLAN_ENTITY_NAME.test(relation.model)) {
45
+ throw new UnsafePlanError(`relation model ${JSON.stringify(relation.model)} on ${entity.name} is not a valid identifier.`);
46
+ }
47
+ }
48
+ }
49
+ for (const event of plan.auditEvents ?? []) {
50
+ if (typeof event !== 'string' || !PLAN_EVENT_NAME.test(event)) {
51
+ throw new UnsafePlanError(`audit event ${JSON.stringify(event)} is not a valid event name.`);
52
+ }
53
+ }
54
+ }
@@ -10,6 +10,7 @@
10
10
  * tolerant coercion — the model's raw text is never fed to `.parse()` directly).
11
11
  */
12
12
  import { z } from 'zod';
13
+ import { PLAN_ENTITY_NAME, PLAN_EVENT_NAME, PLAN_IDENTIFIER } from '../plan/identifiers.js';
13
14
  /** Bump when the serialized shape of a plan changes incompatibly. */
14
15
  export const PLAN_SCHEMA_VERSION = 1;
15
16
  /** Bump when the serialized shape of a make result changes incompatibly. */
@@ -103,17 +104,20 @@ export const AnalysisReportSchema = z.object({
103
104
  diagnostics: z.array(DiagnosticSchema),
104
105
  });
105
106
  // --- ArchitecturePlan (plan/types.ts) --------------------------------------
107
+ // Names are interpolated into generated source: only plain identifiers are
108
+ // accepted (see plan/identifiers.ts). Enum values are free text, always
109
+ // emitted as escaped string literals.
106
110
  export const PlanFieldSchema = z.object({
107
- name: z.string(),
111
+ name: z.string().regex(PLAN_IDENTIFIER),
108
112
  type: z.string(),
109
113
  enum: z.array(z.string()).optional(),
110
114
  });
111
115
  export const PlanRelationSchema = z.object({
112
- name: z.string(),
113
- model: z.string(),
116
+ name: z.string().regex(PLAN_IDENTIFIER),
117
+ model: z.string().regex(PLAN_ENTITY_NAME),
114
118
  });
115
119
  export const PlanEntitySchema = z.object({
116
- name: z.string(),
120
+ name: z.string().regex(PLAN_ENTITY_NAME),
117
121
  fields: z.array(PlanFieldSchema),
118
122
  tenantScoped: z.boolean(),
119
123
  relations: z.array(PlanRelationSchema).optional(),
@@ -145,7 +149,7 @@ export const ArchitecturePlanSchema = z.object({
145
149
  entities: z.array(PlanEntitySchema),
146
150
  steps: z.array(PlanStepSchema),
147
151
  permissions: z.array(z.string()),
148
- auditEvents: z.array(z.string()),
152
+ auditEvents: z.array(z.string().regex(PLAN_EVENT_NAME)),
149
153
  tenantScoped: z.boolean(),
150
154
  warnings: z.array(z.string()),
151
155
  });
@@ -16,4 +16,5 @@ export type { ArchitecturePlan, PlanEntity, PlanField, PlanRelation, PlanStep, P
16
16
  export { buildReviewContext, parseReview, reviewImplementation, type ReviewOptions, } from './review/review.js';
17
17
  export type { AgentReview, ReviewIssue } from './review/types.js';
18
18
  export { runMake } from './make/make.js';
19
+ export { assertSafePlan, UnsafePlanError } from './plan/identifiers.js';
19
20
  export type { MakeOptions, MakeResult, MakePreview, FilePreview, ResourceBuild, SchemaMerge, Migration, ReviewResult, ReviewItem, ReviewStatus, } from './make/types.js';
package/dist/workflows.js CHANGED
@@ -21,3 +21,4 @@ export { buildReviewContext, parseReview, reviewImplementation, } from './review
21
21
  // dry-run detects clashes and attaches diffs; writes never touch the DB unless
22
22
  // `migrate` is set. Framework-free via `@basaltkit/generator/resource`.
23
23
  export { runMake } from './make/make.js';
24
+ export { assertSafePlan, UnsafePlanError } from './plan/identifiers.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/ai",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },
@@ -30,14 +30,14 @@
30
30
  "dist"
31
31
  ],
32
32
  "dependencies": {
33
- "zod": "^4.5.4",
34
- "@basaltkit/cli": "^1.2.4",
35
- "@basaltkit/generator": "^1.3.0"
33
+ "zod": "^4.6.5",
34
+ "@basaltkit/generator": "^1.6.0",
35
+ "@basaltkit/cli": "^1.4.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@types/node": "^26.4.0",
38
+ "@types/node": "^26.6.1",
39
39
  "typescript": "^7.0.2",
40
- "vitest": "^4.1.11",
40
+ "vitest": "^5.0.1",
41
41
  "@basaltkit/tsconfig": "^0.24.0"
42
42
  },
43
43
  "publishConfig": {