@basaltkit/ai 0.11.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/analyze/run.d.ts +19 -0
- package/dist/analyze/run.js +58 -0
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +272 -0
- package/dist/context/project.d.ts +78 -0
- package/dist/context/project.js +202 -0
- package/dist/doctor/fixes.d.ts +31 -0
- package/dist/doctor/fixes.js +82 -0
- package/dist/doctor/rules.d.ts +7 -0
- package/dist/doctor/rules.js +230 -0
- package/dist/doctor/run.d.ts +9 -0
- package/dist/doctor/run.js +16 -0
- package/dist/doctor/types.d.ts +27 -0
- package/dist/doctor/types.js +3 -0
- package/dist/index.d.ts +35 -791
- package/dist/index.js +40 -2389
- package/dist/make/fields.d.ts +35 -0
- package/dist/make/fields.js +139 -0
- package/dist/make/make.d.ts +14 -0
- package/dist/make/make.js +316 -0
- package/dist/make/openapi.d.ts +9 -0
- package/dist/make/openapi.js +36 -0
- package/dist/make/permissions.d.ts +13 -0
- package/dist/make/permissions.js +38 -0
- package/dist/make/relations.d.ts +23 -0
- package/dist/make/relations.js +62 -0
- package/dist/make/render.d.ts +4 -0
- package/dist/make/render.js +66 -0
- package/dist/make/repository.d.ts +16 -0
- package/dist/make/repository.js +176 -0
- package/dist/make/schema.d.ts +22 -0
- package/dist/make/schema.js +54 -0
- package/dist/make/types.d.ts +77 -0
- package/dist/make/types.js +1 -0
- package/dist/make/verify.d.ts +12 -0
- package/dist/make/verify.js +19 -0
- package/dist/make/wire.d.ts +23 -0
- package/dist/make/wire.js +76 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +39 -0
- package/dist/plan/knowledge.d.ts +9 -0
- package/dist/plan/knowledge.js +50 -0
- package/dist/plan/plan.d.ts +15 -0
- package/dist/plan/plan.js +146 -0
- package/dist/plan/render.d.ts +4 -0
- package/dist/plan/render.js +55 -0
- package/dist/plan/types.d.ts +45 -0
- package/dist/plan/types.js +1 -0
- package/dist/provider/anthropic.d.ts +26 -0
- package/dist/provider/anthropic.js +72 -0
- package/dist/provider/factory.d.ts +27 -0
- package/dist/provider/factory.js +58 -0
- package/dist/provider/http.d.ts +24 -0
- package/dist/provider/http.js +36 -0
- package/dist/provider/ollama.d.ts +22 -0
- package/dist/provider/ollama.js +42 -0
- package/dist/provider/openai.d.ts +48 -0
- package/dist/provider/openai.js +94 -0
- package/dist/provider/sse.d.ts +28 -0
- package/dist/provider/sse.js +57 -0
- package/dist/provider/types.d.ts +49 -0
- package/dist/provider/types.js +9 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +70 -0
- package/dist/review/knowledge.d.ts +6 -0
- package/dist/review/knowledge.js +25 -0
- package/dist/review/render.d.ts +4 -0
- package/dist/review/render.js +12 -0
- package/dist/review/review.d.ts +17 -0
- package/dist/review/review.js +80 -0
- package/dist/review/types.d.ts +12 -0
- package/dist/review/types.js +1 -0
- package/package.json +10 -11
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProjectContext } from '../context/project.js';
|
|
2
|
+
import type { LineWriter } from '../render.js';
|
|
3
|
+
export interface FileEdit {
|
|
4
|
+
/** Path relative to the project root. */
|
|
5
|
+
path: string;
|
|
6
|
+
before: string;
|
|
7
|
+
after: string;
|
|
8
|
+
}
|
|
9
|
+
/** Returns the edits a fix would make, `[]` if there's nothing to change, or `null` if it can't apply. */
|
|
10
|
+
export type FixApplier = (read: (rel: string) => string | null, ctx: ProjectContext) => FileEdit[] | null;
|
|
11
|
+
/**
|
|
12
|
+
* Auto-fixers for doctor rules — only the ones with a safe, precise edit. Rules
|
|
13
|
+
* that need a judgement call (tenant scoping, choosing a durable store) have no
|
|
14
|
+
* entry and report "no auto-fix — apply manually".
|
|
15
|
+
*/
|
|
16
|
+
export declare const FIXES: Record<string, FixApplier>;
|
|
17
|
+
export type FixStatus = 'ready' | 'noop' | 'unfixable';
|
|
18
|
+
export interface FixOutcome {
|
|
19
|
+
id: string;
|
|
20
|
+
status: FixStatus;
|
|
21
|
+
edits: FileEdit[];
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
/** IDs of rules that have an auto-fixer. */
|
|
25
|
+
export declare function fixableIds(): string[];
|
|
26
|
+
/** Compute (but don't write) the edits for a rule's fix. */
|
|
27
|
+
export declare function planFix(id: string, ctx: ProjectContext, read: (rel: string) => string | null): FixOutcome;
|
|
28
|
+
/** Write a fix's edits to disk. */
|
|
29
|
+
export declare function applyFixEdits(edits: FileEdit[], baseDir: string): Promise<void>;
|
|
30
|
+
/** Render fix outcomes with a line-level diff. */
|
|
31
|
+
export declare function renderFixes(outcomes: FixOutcome[], io: LineWriter): void;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Auto-fixers for doctor rules — only the ones with a safe, precise edit. Rules
|
|
5
|
+
* that need a judgement call (tenant scoping, choosing a durable store) have no
|
|
6
|
+
* entry and report "no auto-fix — apply manually".
|
|
7
|
+
*/
|
|
8
|
+
export const FIXES = {
|
|
9
|
+
'fastify-logger-off': (read, ctx) => {
|
|
10
|
+
if (!ctx.app)
|
|
11
|
+
return null;
|
|
12
|
+
const content = read(ctx.app.path);
|
|
13
|
+
if (content === null)
|
|
14
|
+
return null;
|
|
15
|
+
if (/fastify\s*:\s*\{[^}]*\blogger\b/.test(content))
|
|
16
|
+
return []; // already configured
|
|
17
|
+
const after = content.replace(/fastifyPlugin\(\s*\{/, (m) => `${m} fastify: { logger: process.env.NODE_ENV !== 'production' },`);
|
|
18
|
+
return after === content ? [] : [{ path: ctx.app.path, before: content, after }];
|
|
19
|
+
},
|
|
20
|
+
'insecure-app-secret': (read, ctx) => {
|
|
21
|
+
if (!ctx.env)
|
|
22
|
+
return null;
|
|
23
|
+
const value = ctx.env.appSecretDefault;
|
|
24
|
+
if (!value || !/change|me|example|secret|placeholder|xxx/i.test(value))
|
|
25
|
+
return [];
|
|
26
|
+
const content = read(ctx.env.path);
|
|
27
|
+
if (content === null)
|
|
28
|
+
return null;
|
|
29
|
+
const after = content.replace(/^(\s*)APP_SECRET:\s*z\.string\(\)[^\n]*$/m, '$1APP_SECRET: z.string().min(32),');
|
|
30
|
+
return after === content ? [] : [{ path: ctx.env.path, before: content, after }];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
/** IDs of rules that have an auto-fixer. */
|
|
34
|
+
export function fixableIds() {
|
|
35
|
+
return Object.keys(FIXES);
|
|
36
|
+
}
|
|
37
|
+
/** Compute (but don't write) the edits for a rule's fix. */
|
|
38
|
+
export function planFix(id, ctx, read) {
|
|
39
|
+
const applier = FIXES[id];
|
|
40
|
+
if (!applier) {
|
|
41
|
+
return { id, status: 'unfixable', edits: [], message: 'no auto-fix — apply manually (see `basalt ai:doctor`)' };
|
|
42
|
+
}
|
|
43
|
+
const edits = applier(read, ctx);
|
|
44
|
+
if (edits === null)
|
|
45
|
+
return { id, status: 'unfixable', edits: [], message: 'target file not found' };
|
|
46
|
+
const changed = edits.filter((e) => e.before !== e.after);
|
|
47
|
+
if (changed.length === 0)
|
|
48
|
+
return { id, status: 'noop', edits: [], message: 'nothing to change (already fixed?)' };
|
|
49
|
+
return { id, status: 'ready', edits: changed, message: `edits ${changed.map((e) => e.path).join(', ')}` };
|
|
50
|
+
}
|
|
51
|
+
/** Write a fix's edits to disk. */
|
|
52
|
+
export async function applyFixEdits(edits, baseDir) {
|
|
53
|
+
for (const edit of edits)
|
|
54
|
+
await writeFile(join(baseDir, edit.path), edit.after);
|
|
55
|
+
}
|
|
56
|
+
/** Render fix outcomes with a line-level diff. */
|
|
57
|
+
export function renderFixes(outcomes, io) {
|
|
58
|
+
const mark = { ready: '✓', noop: '•', unfixable: '⚠' };
|
|
59
|
+
for (const outcome of outcomes) {
|
|
60
|
+
io.log(`${mark[outcome.status]} ${outcome.id} — ${outcome.message}`);
|
|
61
|
+
for (const edit of outcome.edits) {
|
|
62
|
+
io.log(` ${edit.path}:`);
|
|
63
|
+
for (const [removed, added] of lineDiff(edit.before, edit.after)) {
|
|
64
|
+
if (removed !== undefined)
|
|
65
|
+
io.log(` - ${removed.trim()}`);
|
|
66
|
+
if (added !== undefined)
|
|
67
|
+
io.log(` + ${added.trim()}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Positional line diff — the auto-fixes edit lines in place, keeping the line count. */
|
|
73
|
+
function lineDiff(before, after) {
|
|
74
|
+
const b = before.split('\n');
|
|
75
|
+
const a = after.split('\n');
|
|
76
|
+
const out = [];
|
|
77
|
+
for (let i = 0; i < Math.max(b.length, a.length); i++) {
|
|
78
|
+
if (b[i] !== a[i])
|
|
79
|
+
out.push([b[i], a[i]]);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type DoctorRule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Built-in diagnostic rules. Each is pure over a {@link ProjectContext}, so they
|
|
4
|
+
* run offline with no API key. New rules are added here (or supplied to
|
|
5
|
+
* {@link runDoctor}).
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_RULES: DoctorRule[];
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { defineRule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Built-in diagnostic rules. Each is pure over a {@link ProjectContext}, so they
|
|
4
|
+
* run offline with no API key. New rules are added here (or supplied to
|
|
5
|
+
* {@link runDoctor}).
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_RULES = [
|
|
8
|
+
// ── observability ─────────────────────────────────────────────────────────
|
|
9
|
+
defineRule({
|
|
10
|
+
id: 'prisma-lazy-boot',
|
|
11
|
+
category: 'observability',
|
|
12
|
+
check(ctx) {
|
|
13
|
+
if (!ctx.stack.orm || !ctx.server)
|
|
14
|
+
return null;
|
|
15
|
+
if (ctx.server.connectsAtBoot)
|
|
16
|
+
return null;
|
|
17
|
+
return {
|
|
18
|
+
id: 'prisma-lazy-boot',
|
|
19
|
+
title: 'Database connection is not validated at boot',
|
|
20
|
+
severity: 'warning',
|
|
21
|
+
category: 'observability',
|
|
22
|
+
detected: `${ctx.server.path} never calls prisma.$connect() before listen()`,
|
|
23
|
+
recommended: "await the Prisma client's $connect() right after boot()",
|
|
24
|
+
reason: 'Prisma connects lazily on the first query, so an unreachable database ' +
|
|
25
|
+
'(wrong host/port/credentials) does NOT crash at startup — it fails ' +
|
|
26
|
+
'silently on the first request instead.',
|
|
27
|
+
fix: [
|
|
28
|
+
'const app = await buildApp({ /* … */ }).boot()',
|
|
29
|
+
"await app.container.get(PRISMA).$connect() // fail loudly if the DB is unreachable",
|
|
30
|
+
].join('\n'),
|
|
31
|
+
docs: '/guide/observability',
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
defineRule({
|
|
36
|
+
id: 'fastify-logger-off',
|
|
37
|
+
category: 'observability',
|
|
38
|
+
check(ctx) {
|
|
39
|
+
if (ctx.stack.http !== 'fastify' || !ctx.app)
|
|
40
|
+
return null;
|
|
41
|
+
if (ctx.app.fastifyLoggerConfigured)
|
|
42
|
+
return null;
|
|
43
|
+
return {
|
|
44
|
+
id: 'fastify-logger-off',
|
|
45
|
+
title: "Fastify's request logger is disabled",
|
|
46
|
+
severity: 'warning',
|
|
47
|
+
category: 'observability',
|
|
48
|
+
detected: `fastifyPlugin in ${ctx.app.path} has no fastify.logger option`,
|
|
49
|
+
recommended: "enable Fastify's logger (at least in development)",
|
|
50
|
+
reason: "Unhandled request errors are logged via request.log.error, but Fastify's " +
|
|
51
|
+
'built-in logger defaults to OFF — so 500s are returned to the client while ' +
|
|
52
|
+
'the terminal stays silent.',
|
|
53
|
+
fix: "fastifyPlugin({ routes: [...], fastify: { logger: env.NODE_ENV === 'development' } })",
|
|
54
|
+
docs: '/guide/observability',
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
// ── security ──────────────────────────────────────────────────────────────
|
|
59
|
+
defineRule({
|
|
60
|
+
id: 'insecure-app-secret',
|
|
61
|
+
category: 'security',
|
|
62
|
+
check(ctx) {
|
|
63
|
+
const value = ctx.env?.appSecretDefault;
|
|
64
|
+
if (!value)
|
|
65
|
+
return null;
|
|
66
|
+
if (!/change|me|example|secret|placeholder|xxx/i.test(value))
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
id: 'insecure-app-secret',
|
|
70
|
+
title: 'APP_SECRET has an insecure default',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
category: 'security',
|
|
73
|
+
detected: `${ctx.env?.path} defaults APP_SECRET to "${value}"`,
|
|
74
|
+
recommended: 'require APP_SECRET from the environment with no fallback in production',
|
|
75
|
+
reason: 'A committed default secret lets anyone forge sessions/JWTs if it ever ' +
|
|
76
|
+
'reaches a deployed environment. Secrets must come from the environment.',
|
|
77
|
+
fix: "APP_SECRET: z.string().min(32) // no .default() — fail fast if unset",
|
|
78
|
+
docs: '/guide/security',
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
defineRule({
|
|
83
|
+
id: 'missing-security-plugin',
|
|
84
|
+
category: 'security',
|
|
85
|
+
check(ctx) {
|
|
86
|
+
if (!ctx.app)
|
|
87
|
+
return null;
|
|
88
|
+
if (ctx.app.pluginCalls.includes('securityPlugin'))
|
|
89
|
+
return null;
|
|
90
|
+
return {
|
|
91
|
+
id: 'missing-security-plugin',
|
|
92
|
+
title: 'No securityPlugin — responses ship without secure headers',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
category: 'security',
|
|
95
|
+
detected: `${ctx.app.path} registers no securityPlugin()`,
|
|
96
|
+
recommended: 'add securityPlugin() so secure response headers are set, and enable rate limiting + a CORS allow-list for production',
|
|
97
|
+
reason: 'Without it the API returns no security headers (HSTS, X-Frame-Options, ' +
|
|
98
|
+
'nosniff, …) and applies no rate limiting — a fresh deploy is unprotected ' +
|
|
99
|
+
'at the edge. The security primitives exist but are off until wired.',
|
|
100
|
+
fix: 'securityPlugin({ /* headers on by default */ rateLimit: { limit: 120, windowMs: 60_000 } })',
|
|
101
|
+
docs: '/guide/security',
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
// ── tenancy ───────────────────────────────────────────────────────────────
|
|
106
|
+
defineRule({
|
|
107
|
+
id: 'missing-tenant-membership',
|
|
108
|
+
category: 'tenancy',
|
|
109
|
+
check(ctx) {
|
|
110
|
+
if (!ctx.app || !ctx.stack.tenancy || !ctx.stack.auth)
|
|
111
|
+
return null;
|
|
112
|
+
const teamsAvailable = ctx.app.pluginCalls.includes('teamsPlugin') || ctx.installed.includes('@basaltkit/teams');
|
|
113
|
+
if (!teamsAvailable)
|
|
114
|
+
return null;
|
|
115
|
+
if (ctx.app.pluginCalls.includes('tenantMembershipPlugin'))
|
|
116
|
+
return null;
|
|
117
|
+
return {
|
|
118
|
+
id: 'missing-tenant-membership',
|
|
119
|
+
title: 'Tenant is resolved from the request but membership is never enforced',
|
|
120
|
+
severity: 'error',
|
|
121
|
+
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',
|
|
124
|
+
reason: 'Resolvers take the tenant from client input (x-tenant-id / Host). Without a ' +
|
|
125
|
+
'membership check, any authenticated user can act on another tenant just by ' +
|
|
126
|
+
'changing that header — a cross-tenant data breach. Tenant resolution is ' +
|
|
127
|
+
'identification, never authorization.',
|
|
128
|
+
fix: "tenantMembershipPlugin() // central routes opt out with meta: { central: true }",
|
|
129
|
+
docs: '/guide/security',
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
defineRule({
|
|
134
|
+
id: 'tenant-scoping-missing',
|
|
135
|
+
category: 'tenancy',
|
|
136
|
+
check(ctx) {
|
|
137
|
+
if (!ctx.stack.tenancy || !ctx.prisma)
|
|
138
|
+
return null;
|
|
139
|
+
const unscoped = ctx.prisma.models.filter((model) => !model.tenantScoped && !GLOBAL_MODELS.has(model.name));
|
|
140
|
+
if (unscoped.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
const names = unscoped.map((m) => m.name).join(', ');
|
|
143
|
+
return {
|
|
144
|
+
id: 'tenant-scoping-missing',
|
|
145
|
+
title: 'Tenant-scoped app has models without a tenantId',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
category: 'tenancy',
|
|
148
|
+
detected: `models without a tenantId column: ${names}`,
|
|
149
|
+
recommended: 'add a tenantId to tenant-owned models so queries can never leak across tenants',
|
|
150
|
+
reason: 'With tenancy enabled, a model that lacks tenantId cannot be isolated by ' +
|
|
151
|
+
'the tenant-scoping layer — a query can return rows from other tenants.',
|
|
152
|
+
fix: 'tenantId String // + @@index([tenantId]); scope every query by the current tenant',
|
|
153
|
+
docs: '/guide/tenancy',
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
// ── durability ────────────────────────────────────────────────────────────
|
|
158
|
+
defineRule({
|
|
159
|
+
id: 'memory-sources-in-use',
|
|
160
|
+
category: 'durability',
|
|
161
|
+
check(ctx) {
|
|
162
|
+
const sources = ctx.app?.memorySources ?? [];
|
|
163
|
+
if (sources.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
return {
|
|
166
|
+
id: 'memory-sources-in-use',
|
|
167
|
+
title: 'Non-durable in-memory sources are wired',
|
|
168
|
+
severity: 'info',
|
|
169
|
+
category: 'durability',
|
|
170
|
+
detected: `${ctx.app?.path} uses ${sources.join(', ')}`,
|
|
171
|
+
recommended: 'replace in-memory sources with database-backed ones before production',
|
|
172
|
+
reason: 'In-memory sources reset on every restart and are not shared across ' +
|
|
173
|
+
'instances — fine for local dev, data loss in production.',
|
|
174
|
+
docs: '/guide/tenancy',
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
defineRule({
|
|
179
|
+
id: 'redis-localhost-default',
|
|
180
|
+
category: 'config',
|
|
181
|
+
check(ctx) {
|
|
182
|
+
const value = ctx.env?.redisUrlDefault;
|
|
183
|
+
if (!value || !/localhost|127\.0\.0\.1/.test(value))
|
|
184
|
+
return null;
|
|
185
|
+
if (!ctx.stack.queue && !ctx.stack.subscriptions)
|
|
186
|
+
return null;
|
|
187
|
+
return {
|
|
188
|
+
id: 'redis-localhost-default',
|
|
189
|
+
title: 'Redis URL defaults to localhost',
|
|
190
|
+
severity: 'info',
|
|
191
|
+
category: 'config',
|
|
192
|
+
detected: `${ctx.env?.path} defaults REDIS_URL to "${value}"`,
|
|
193
|
+
recommended: 'set REDIS_URL from the environment in every non-local deployment',
|
|
194
|
+
reason: 'A localhost default silently falls back to a dev-only queue/dedupe backend ' +
|
|
195
|
+
'in production if the env var is missing.',
|
|
196
|
+
docs: '/guide/configuration',
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
defineRule({
|
|
201
|
+
id: 'in-memory-security-store',
|
|
202
|
+
category: 'security',
|
|
203
|
+
check(ctx) {
|
|
204
|
+
const SECURITY_STORES = {
|
|
205
|
+
MemoryPasskeyStore: 'WebAuthn passkeys',
|
|
206
|
+
MemoryWebAuthnChallengeStore: 'WebAuthn challenges',
|
|
207
|
+
MemoryAccessStore: 'roles & permissions',
|
|
208
|
+
MemoryDomainStore: 'verified custom domains',
|
|
209
|
+
};
|
|
210
|
+
const hit = (ctx.app?.memorySources ?? []).find((source) => source in SECURITY_STORES);
|
|
211
|
+
if (!hit)
|
|
212
|
+
return null;
|
|
213
|
+
return {
|
|
214
|
+
id: 'in-memory-security-store',
|
|
215
|
+
title: 'Security state is kept in an in-memory store',
|
|
216
|
+
severity: 'warning',
|
|
217
|
+
category: 'security',
|
|
218
|
+
detected: `${hit} holds ${SECURITY_STORES[hit]} in memory.`,
|
|
219
|
+
recommended: 'Back it with a durable store (database/Redis) in production.',
|
|
220
|
+
reason: 'In-memory security stores are wiped on restart and are not shared across ' +
|
|
221
|
+
'instances — passkeys/permissions/verified domains would silently reset or ' +
|
|
222
|
+
'diverge per replica, which can lock users out or bypass authorization.',
|
|
223
|
+
fix: `// swap ${hit} for a durable implementation of the same interface`,
|
|
224
|
+
docs: '/guide/security',
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
}),
|
|
228
|
+
];
|
|
229
|
+
/** Models that legitimately have no tenantId (platform-global tables). */
|
|
230
|
+
const GLOBAL_MODELS = new Set(['Tenant', 'User', 'Plan', 'WebhookEvent', 'Migration']);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProjectContext } from '../context/project.js';
|
|
2
|
+
import type { Diagnostic, DoctorRule } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Run every rule against the project context and return the diagnostics that
|
|
5
|
+
* fired, most severe first.
|
|
6
|
+
*/
|
|
7
|
+
export declare function runDoctor(ctx: ProjectContext, rules?: DoctorRule[]): Diagnostic[];
|
|
8
|
+
/** True when any diagnostic is an error — lets CI gate on `ai:doctor`. */
|
|
9
|
+
export declare function hasErrors(diagnostics: Diagnostic[]): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DEFAULT_RULES } from './rules.js';
|
|
2
|
+
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
3
|
+
/**
|
|
4
|
+
* Run every rule against the project context and return the diagnostics that
|
|
5
|
+
* fired, most severe first.
|
|
6
|
+
*/
|
|
7
|
+
export function runDoctor(ctx, rules = DEFAULT_RULES) {
|
|
8
|
+
return rules
|
|
9
|
+
.map((rule) => rule.check(ctx))
|
|
10
|
+
.filter((d) => d !== null)
|
|
11
|
+
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
12
|
+
}
|
|
13
|
+
/** True when any diagnostic is an error — lets CI gate on `ai:doctor`. */
|
|
14
|
+
export function hasErrors(diagnostics) {
|
|
15
|
+
return diagnostics.some((d) => d.severity === 'error');
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ProjectContext } from '../context/project.js';
|
|
2
|
+
export type Severity = 'error' | 'warning' | 'info';
|
|
3
|
+
export type DiagnosticCategory = 'security' | 'database' | 'observability' | 'tenancy' | 'performance' | 'durability' | 'config';
|
|
4
|
+
export interface Diagnostic {
|
|
5
|
+
/** Stable slug — also the (future) `basalt ai:fix <id>` target. */
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
severity: Severity;
|
|
9
|
+
category: DiagnosticCategory;
|
|
10
|
+
/** What the project currently does. */
|
|
11
|
+
detected: string;
|
|
12
|
+
/** What it should do instead. */
|
|
13
|
+
recommended: string;
|
|
14
|
+
/** Why it matters. */
|
|
15
|
+
reason: string;
|
|
16
|
+
/** Suggested manual fix (code snippet or command). Auto-fix arrives in a later phase. */
|
|
17
|
+
fix?: string;
|
|
18
|
+
/** Related docs path. */
|
|
19
|
+
docs?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DoctorRule {
|
|
22
|
+
id: string;
|
|
23
|
+
category: DiagnosticCategory;
|
|
24
|
+
/** Returns a diagnostic when the rule fires, else `null`. */
|
|
25
|
+
check(ctx: ProjectContext): Diagnostic | null;
|
|
26
|
+
}
|
|
27
|
+
export declare function defineRule(rule: DoctorRule): DoctorRule;
|