@basaltkit/ai 0.9.2 → 0.11.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.
package/dist/index.d.ts CHANGED
@@ -275,6 +275,8 @@ interface AppFileInfo {
275
275
  fastifyLoggerConfigured: boolean;
276
276
  /** In-memory (non-durable) sources still wired, e.g. `MemoryTenantSource`. */
277
277
  memorySources: string[];
278
+ /** Every `<name>Plugin(` factory call in the app file (unfiltered by capability). */
279
+ pluginCalls: string[];
278
280
  }
279
281
  interface ServerFileInfo {
280
282
  path: string;
package/dist/index.js CHANGED
@@ -364,10 +364,22 @@ function detectAppFile(reader) {
364
364
  (factory) => new RegExp(`\\b${factory}\\s*\\(`).test(found.content)
365
365
  );
366
366
  const fastifyLoggerConfigured = /fastify\s*:\s*\{[^}]*\blogger\b/.test(found.content);
367
- const memorySources = ["MemoryTenantSource", "MemoryUserSource"].filter(
367
+ const memorySources = [
368
+ "MemoryTenantSource",
369
+ "MemoryUserSource",
370
+ // Security-critical in-memory stores: auth/authorization/domain state that
371
+ // must survive restarts and be shared across instances in production.
372
+ "MemoryPasskeyStore",
373
+ "MemoryWebAuthnChallengeStore",
374
+ "MemoryAccessStore",
375
+ "MemoryDomainStore"
376
+ ].filter(
368
377
  (source) => found.content.includes(source)
369
378
  );
370
- return { path: found.path, plugins, fastifyLoggerConfigured, memorySources };
379
+ const pluginCalls = [
380
+ ...new Set(Array.from(found.content.matchAll(/\b([A-Za-z][A-Za-z0-9]*Plugin)\s*\(/g), (m) => m[1]))
381
+ ];
382
+ return { path: found.path, plugins, fastifyLoggerConfigured, memorySources, pluginCalls };
371
383
  }
372
384
  function detectServerFile(reader) {
373
385
  const found = firstExisting(reader, SERVER_CANDIDATES);
@@ -527,7 +539,47 @@ var DEFAULT_RULES = [
527
539
  };
528
540
  }
529
541
  }),
542
+ defineRule({
543
+ id: "missing-security-plugin",
544
+ category: "security",
545
+ check(ctx) {
546
+ if (!ctx.app) return null;
547
+ if (ctx.app.pluginCalls.includes("securityPlugin")) return null;
548
+ return {
549
+ id: "missing-security-plugin",
550
+ title: "No securityPlugin \u2014 responses ship without secure headers",
551
+ severity: "warning",
552
+ category: "security",
553
+ detected: `${ctx.app.path} registers no securityPlugin()`,
554
+ recommended: "add securityPlugin() so secure response headers are set, and enable rate limiting + a CORS allow-list for production",
555
+ reason: "Without it the API returns no security headers (HSTS, X-Frame-Options, nosniff, \u2026) and applies no rate limiting \u2014 a fresh deploy is unprotected at the edge. The security primitives exist but are off until wired.",
556
+ fix: "securityPlugin({ /* headers on by default */ rateLimit: { limit: 120, windowMs: 60_000 } })",
557
+ docs: "/guide/security"
558
+ };
559
+ }
560
+ }),
530
561
  // ── tenancy ───────────────────────────────────────────────────────────────
562
+ defineRule({
563
+ id: "missing-tenant-membership",
564
+ category: "tenancy",
565
+ check(ctx) {
566
+ if (!ctx.app || !ctx.stack.tenancy || !ctx.stack.auth) return null;
567
+ const teamsAvailable = ctx.app.pluginCalls.includes("teamsPlugin") || ctx.installed.includes("@basaltkit/teams");
568
+ if (!teamsAvailable) return null;
569
+ if (ctx.app.pluginCalls.includes("tenantMembershipPlugin")) return null;
570
+ return {
571
+ id: "missing-tenant-membership",
572
+ title: "Tenant is resolved from the request but membership is never enforced",
573
+ severity: "error",
574
+ category: "tenancy",
575
+ detected: `${ctx.app.path} wires tenancy + auth (+ teams) but no tenantMembershipPlugin`,
576
+ recommended: "register tenantMembershipPlugin() so every authenticated, tenant-scoped request verifies the user belongs to the resolved tenant",
577
+ reason: "Resolvers take the tenant from client input (x-tenant-id / Host). Without a membership check, any authenticated user can act on another tenant just by changing that header \u2014 a cross-tenant data breach. Tenant resolution is identification, never authorization.",
578
+ fix: "tenantMembershipPlugin() // central routes opt out with meta: { central: true }",
579
+ docs: "/guide/security"
580
+ };
581
+ }
582
+ }),
531
583
  defineRule({
532
584
  id: "tenant-scoping-missing",
533
585
  category: "tenancy",
@@ -588,6 +640,31 @@ var DEFAULT_RULES = [
588
640
  docs: "/guide/configuration"
589
641
  };
590
642
  }
643
+ }),
644
+ defineRule({
645
+ id: "in-memory-security-store",
646
+ category: "security",
647
+ check(ctx) {
648
+ const SECURITY_STORES = {
649
+ MemoryPasskeyStore: "WebAuthn passkeys",
650
+ MemoryWebAuthnChallengeStore: "WebAuthn challenges",
651
+ MemoryAccessStore: "roles & permissions",
652
+ MemoryDomainStore: "verified custom domains"
653
+ };
654
+ const hit = (ctx.app?.memorySources ?? []).find((source) => source in SECURITY_STORES);
655
+ if (!hit) return null;
656
+ return {
657
+ id: "in-memory-security-store",
658
+ title: "Security state is kept in an in-memory store",
659
+ severity: "warning",
660
+ category: "security",
661
+ detected: `${hit} holds ${SECURITY_STORES[hit]} in memory.`,
662
+ recommended: "Back it with a durable store (database/Redis) in production.",
663
+ reason: "In-memory security stores are wiped on restart and are not shared across instances \u2014 passkeys/permissions/verified domains would silently reset or diverge per replica, which can lock users out or bypass authorization.",
664
+ fix: `// swap ${hit} for a durable implementation of the same interface`,
665
+ docs: "/guide/security"
666
+ };
667
+ }
591
668
  })
592
669
  ];
593
670
  var GLOBAL_MODELS = /* @__PURE__ */ new Set(["Tenant", "User", "Plan", "WebhookEvent", "Migration"]);
@@ -2007,7 +2084,7 @@ function renderDoctor(diagnostics, io) {
2007
2084
  }
2008
2085
  if (d.docs) {
2009
2086
  io.log("");
2010
- io.log(` Docs: https://basalt.dev${d.docs}`);
2087
+ io.log(` Docs: https://basaltkit.dev${d.docs}`);
2011
2088
  }
2012
2089
  io.log("");
2013
2090
  io.log(" " + "\u2500".repeat(60));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/ai",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "AI-native developer experience for Basalt: a provider-agnostic engine plus `basalt ai` CLI that understands the project's stack, analyzes it, and diagnoses issues — following the framework's own conventions, not inventing new ones.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/cli": "^1.0.0",
17
+ "@basaltkit/cli": "^1.2.0",
18
18
  "@basaltkit/generator": "^1.1.1"
19
19
  },
20
20
  "devDependencies": {