@basaltkit/ai 1.3.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');
@@ -135,6 +135,30 @@ export const DEFAULT_RULES = [
135
135
  };
136
136
  },
137
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
+ }),
138
162
  defineRule({
139
163
  id: 'tenant-scoping-missing',
140
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/ai",
3
- "version": "1.3.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.5",
35
- "@basaltkit/generator": "^1.4.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.5.1",
38
+ "@types/node": "^26.6.1",
39
39
  "typescript": "^7.0.2",
40
- "vitest": "^5.0.0",
40
+ "vitest": "^5.0.1",
41
41
  "@basaltkit/tsconfig": "^0.24.0"
42
42
  },
43
43
  "publishConfig": {