create-astram-app 0.4.0 → 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 (87) hide show
  1. package/README.md +211 -38
  2. package/dist/add-frontend.js.map +1 -1
  3. package/dist/index.js +14 -9
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/template/README.md +51 -20
  7. package/template/frontend/README.md +21 -11
  8. package/template/frontend/index.html +16 -0
  9. package/template/frontend/package.json +1 -1
  10. package/template/frontend/src/App.jsx +95 -20
  11. package/template/frontend/src/auth/AuthContext.jsx +145 -21
  12. package/template/frontend/src/auth/routeGate.js +31 -0
  13. package/template/frontend/src/auth/routeGate.test.mjs +59 -0
  14. package/template/frontend/src/auth/sessionFlow.js +27 -0
  15. package/template/frontend/src/auth/sessionFlow.test.mjs +63 -0
  16. package/template/frontend/src/components/EntityList.jsx +1 -7
  17. package/template/frontend/src/components/MultiSelectChecklist.jsx +166 -0
  18. package/template/frontend/src/components/PageHeader.jsx +5 -4
  19. package/template/frontend/src/components/ShareDialog.jsx +3 -5
  20. package/template/frontend/src/components/access/CollapsibleGroup.jsx +4 -1
  21. package/template/frontend/src/components/ui/alert-dialog.tsx +27 -50
  22. package/template/frontend/src/components/ui/alert.tsx +17 -23
  23. package/template/frontend/src/components/ui/avatar.tsx +8 -20
  24. package/template/frontend/src/components/ui/badge.tsx +17 -26
  25. package/template/frontend/src/components/ui/button.tsx +22 -24
  26. package/template/frontend/src/components/ui/card.tsx +19 -36
  27. package/template/frontend/src/components/ui/checkbox.tsx +7 -10
  28. package/template/frontend/src/components/ui/date-field.tsx +26 -27
  29. package/template/frontend/src/components/ui/dialog.tsx +24 -40
  30. package/template/frontend/src/components/ui/dropdown-menu.tsx +25 -54
  31. package/template/frontend/src/components/ui/input-otp.tsx +1 -4
  32. package/template/frontend/src/components/ui/label.tsx +10 -12
  33. package/template/frontend/src/components/ui/searchable-select.tsx +1 -3
  34. package/template/frontend/src/components/ui/select.tsx +31 -43
  35. package/template/frontend/src/components/ui/separator.tsx +6 -6
  36. package/template/frontend/src/components/ui/sheet.tsx +29 -38
  37. package/template/frontend/src/components/ui/skeleton.tsx +3 -3
  38. package/template/frontend/src/components/ui/sonner.tsx +7 -7
  39. package/template/frontend/src/components/ui/table.tsx +56 -70
  40. package/template/frontend/src/components/ui/tooltip.tsx +7 -11
  41. package/template/frontend/src/config/branding.js +1 -4
  42. package/template/frontend/src/hooks/useMobile.tsx +4 -6
  43. package/template/frontend/src/index.css +9 -4
  44. package/template/frontend/src/pages/Dashboard.jsx +13 -7
  45. package/template/frontend/src/pages/ForgotPassword.jsx +9 -2
  46. package/template/frontend/src/pages/Login.jsx +85 -70
  47. package/template/frontend/src/pages/ResetPassword.jsx +9 -2
  48. package/template/frontend/src/pages/access/AccessForm.jsx +49 -15
  49. package/template/frontend/src/pages/access/AccessPage.jsx +10 -6
  50. package/template/frontend/src/pages/access/RecordPoliciesPage.jsx +9 -2
  51. package/template/frontend/src/pages/account/AccountLayout.jsx +11 -6
  52. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +2 -2
  53. package/template/frontend/src/pages/account/ReenrollPage.jsx +3 -5
  54. package/template/frontend/src/pages/account/TwoFactorPage.jsx +3 -8
  55. package/template/frontend/src/pages/companies/CompaniesPage.jsx +7 -9
  56. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +41 -8
  57. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +30 -7
  58. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +12 -3
  59. package/template/frontend/src/pages/roles/RolesPage.jsx +5 -9
  60. package/template/frontend/src/pages/service-accounts/KeyRevealDialog.jsx +104 -0
  61. package/template/frontend/src/pages/service-accounts/ServiceAccountAccessDialog.jsx +231 -0
  62. package/template/frontend/src/pages/service-accounts/ServiceAccountActionsMenu.jsx +56 -0
  63. package/template/frontend/src/pages/service-accounts/ServiceAccountsPage.jsx +368 -0
  64. package/template/frontend/src/pages/service-accounts/accessModel.js +158 -0
  65. package/template/frontend/src/pages/service-accounts/accessModel.test.mjs +189 -0
  66. package/template/frontend/src/pages/users/MembershipCard.jsx +3 -1
  67. package/template/frontend/src/pages/users/PostingRow.jsx +8 -14
  68. package/template/frontend/src/pages/users/UsersPage.jsx +7 -9
  69. package/template/frontend/src/platform/httpClient.js +5 -0
  70. package/template/frontend/src/platform/sessionStore.js +55 -13
  71. package/template/frontend/src/platform/sessionStore.test.mjs +89 -0
  72. package/template/frontend/src/platform/shieldTransport.js +6 -0
  73. package/template/frontend/src/platform/shieldTransport.test.mjs +84 -5
  74. package/template/frontend/src/shell/AppShell.jsx +10 -12
  75. package/template/frontend/src/shell/Sidebar.jsx +15 -3
  76. package/template/frontend/src/shell/nav.js +13 -3
  77. package/template/frontend/vite.config.js +19 -1
  78. package/template/server/_env.example +22 -1
  79. package/template/server/eslint.config.mjs +194 -0
  80. package/template/server/package.json +11 -11
  81. package/template/server/scripts/dev.mjs +17 -6
  82. package/template/server/scripts/sync-astram-guide.mjs +45 -19
  83. package/template/server/src/app.module.ts +11 -5
  84. package/template/server/src/health/demo.controller.ts +16 -12
  85. package/template/server/src/main.ts +23 -1
  86. package/template/server/test/enforcement.e2e.spec.ts +73 -6
  87. package/template/server/eslint.config.js +0 -95
@@ -17,9 +17,16 @@
17
17
  *
18
18
  * The app root is the monorepo root (one level up from server/), because CLAUDE.md +
19
19
  * .claude/ live at the app root and Claude walks UP the tree from wherever you work.
20
+ *
21
+ * SAFETY (it runs on every install, including in CI and Docker builds):
22
+ * - It only writes when that root is recognisably an Astram app (has `CLAUDE.md` or
23
+ * `.astram/`). A `server/` copied alone (e.g. `COPY server/ /app` in a Dockerfile, or
24
+ * a standalone clone) is skipped, so it never writes to `/` or to `~/.claude/rules/`.
25
+ * - `ASTRAM_GUIDE_SYNC=0` skips it (CI, production images).
26
+ * - It never fails the install: any error is reported and the exit code stays 0.
20
27
  */
21
28
 
22
- import { cpSync, mkdirSync, writeFileSync } from 'node:fs';
29
+ import { cpSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
23
30
  import { dirname, join, resolve } from 'node:path';
24
31
  import { fileURLToPath } from 'node:url';
25
32
  import { createRequire } from 'node:module';
@@ -29,7 +36,20 @@ const here = dirname(fileURLToPath(import.meta.url)); // server/scripts
29
36
  const appRoot = resolve(here, '..', '..'); // monorepo root
30
37
  const rulesDir = join(appRoot, '.claude', 'rules');
31
38
 
39
+ function log(msg) {
40
+ process.stdout.write(`[sync-astram-guide] ${msg}\n`);
41
+ }
42
+
32
43
  function main() {
44
+ if (process.env.ASTRAM_GUIDE_SYNC === '0') {
45
+ log('ASTRAM_GUIDE_SYNC=0 — skipped.');
46
+ return 0;
47
+ }
48
+ if (!existsSync(join(appRoot, 'CLAUDE.md')) && !existsSync(join(appRoot, '.astram'))) {
49
+ log(`${appRoot} has no CLAUDE.md or .astram/ — not an Astram app root, skipped.`);
50
+ return 0;
51
+ }
52
+
33
53
  let guide;
34
54
  try {
35
55
  guide = require('@astram/guide');
@@ -40,24 +60,30 @@ function main() {
40
60
  return 0;
41
61
  }
42
62
 
43
- mkdirSync(rulesDir, { recursive: true });
44
- // Copy the framework docs into .claude/rules/ (overwrite — they are the framework's
45
- // to own; on upgrade the newer versions replace the older ones cleanly).
46
- cpSync(guide.FRAMEWORK_RULES_PATH, join(rulesDir, 'astram-framework-rules.md'));
47
- cpSync(guide.CAPABILITIES_PATH, join(rulesDir, 'astram-framework-capabilities.md'));
48
-
49
- // Stamp the synced version so a human/AI can see which framework version's rules
50
- // are present (and notice when an upgrade changed them).
51
- writeFileSync(
52
- join(rulesDir, '.astram-guide-version'),
53
- `${guide.version}\n`,
54
- 'utf8',
55
- );
56
-
57
- process.stdout.write(
58
- `[sync-astram-guide] framework rules synced (v${guide.version}) → .claude/rules/\n`,
59
- );
63
+ try {
64
+ mkdirSync(rulesDir, { recursive: true });
65
+ // Copy the framework docs into .claude/rules/ (overwrite — they are the framework's
66
+ // to own; on upgrade the newer versions replace the older ones cleanly).
67
+ cpSync(guide.FRAMEWORK_RULES_PATH, join(rulesDir, 'astram-framework-rules.md'));
68
+ cpSync(guide.CAPABILITIES_PATH, join(rulesDir, 'astram-framework-capabilities.md'));
69
+
70
+ // Stamp the synced version so a human/AI can see which framework version's rules
71
+ // are present (and notice when an upgrade changed them).
72
+ writeFileSync(join(rulesDir, '.astram-guide-version'), `${guide.version}\n`, 'utf8');
73
+ } catch (err) {
74
+ // Best-effort: a read-only or odd filesystem must never fail `npm install`.
75
+ const reason = err && typeof err === 'object' && 'code' in err ? err.code : 'error';
76
+ log(`could not sync into ${rulesDir} (${reason}) — skipped. Run \`npm run guide:sync\` later.`);
77
+ return 0;
78
+ }
79
+
80
+ log(`framework rules synced (v${guide.version}) → .claude/rules/`);
60
81
  return 0;
61
82
  }
62
83
 
63
- process.exitCode = main();
84
+ try {
85
+ process.exitCode = main();
86
+ } catch {
87
+ log('unexpected error — skipped.');
88
+ process.exitCode = 0;
89
+ }
@@ -17,12 +17,16 @@ import { DemoController } from './health/demo.controller';
17
17
  * 2. Import that module here (add it to `imports`) or list its controller in
18
18
  * `controllers`.
19
19
  * 3. Classify every route: `@Public()` for open routes, `@Classified(...)` for
20
- * guarded ones. Anything left unclassified is DENIED (403) by design.
20
+ * guarded ones. Anything left unclassified is DENIED by design: the global guard
21
+ * answers 403, and once `IdentityFeatureModule` is wired the app refuses to BOOT
22
+ * (`verifyGatable` aborts on any unclassified route).
21
23
  *
22
- * `HealthController` + `DemoController` below are starter routes that prove the
23
- * framework enforces from your side (health/@Public → 200, unclassified → 403).
24
- * Keep the health probes; the demo controller is illustrative — delete it once
25
- * you've seen enforcement work.
24
+ * `HealthController` + `DemoController` below are starter routes, all `@Public()`
25
+ * (`/health`, `/demo/public` → 200). The scaffold ships NO unclassified route (the
26
+ * demo one is commented out, so `/demo/unclassified` is a 404); `npm test`
27
+ * (`test/enforcement.e2e.spec.ts`) proves deny-by-default with a test-only
28
+ * controller. Keep the health probes; the demo controller is illustrative — delete
29
+ * it once you've seen enforcement work.
26
30
  *
27
31
  * ── OPTIONAL: real authentication (login/MFA/refresh/logout) ──────────────────
28
32
  * The framework ships a complete Auth surface you can turn on WITHOUT writing any
@@ -32,6 +36,8 @@ import { DemoController } from './health/demo.controller';
32
36
  * 2. Provide the DB/Redis/HMAC config in `.env` (see the AUTH block in
33
37
  * `.env.example`) — auth needs a real MariaDB + Redis and a refresh HMAC key.
34
38
  * 3. Create a user in the `shield_identity.identity_credential` table.
39
+ * 4. Outside development/test (`NODE_ENV`), `auth: true` also requires an
40
+ * `emailSender` (or `emailSender: 'disabled'`) — otherwise boot fails closed.
35
41
  * You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
36
42
  * no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
37
43
  * boot; turning it on requires the config above, by design — fail-closed.)
@@ -8,14 +8,18 @@ import { Public } from '@astram/guard';
8
8
  * deny-by-default global guard.
9
9
  *
10
10
  * Deny-by-default has TWO layers, and they differ in WHEN they bite:
11
- * 1. **Boot-time (fail-closed, strongest):** the identity boot check (`verifyGatable`)
12
- * scans every route and ABORTS BOOT if any non-`@Public` route lacks `@Classified`.
13
- * A forgotten declaration doesn't just get denied at request time — the app won't
14
- * start. That is why the illustrative "unclassified route" below is a COMMENT, not a
15
- * live handler: a live one would prevent this app from booting. Uncomment it only to
16
- * SEE the boot abort, then re-comment (or classify it).
17
- * 2. **Request-time (per-request):** even absent the boot check, the global guard denies
18
- * an unclassified route with 403.
11
+ * 1. **Request-time (always on):** the global guard wired by `ShieldModule.forRoot()`
12
+ * denies any route that is neither `@Public()` nor `@Classified(...)` with 403,
13
+ * before its handler runs.
14
+ * 2. **Boot-time (once `IdentityFeatureModule` is wired):** the identity boot check
15
+ * (`verifyGatable`) scans every route and ABORTS BOOT if any non-`@Public` route
16
+ * lacks `@Classified`. A forgotten declaration then means the app won't start.
17
+ *
18
+ * The illustrative "unclassified route" below is a COMMENT, so this scaffold ships no
19
+ * unclassified route and `GET /demo/unclassified` is a 404. Uncomment it to see the
20
+ * denial: 403 with the default `ShieldModule.forRoot()`, a boot abort once
21
+ * `IdentityFeatureModule` is imported. Then re-comment it (or classify it).
22
+ * `test/enforcement.e2e.spec.ts` proves both layers with a test-only controller.
19
23
  *
20
24
  * This controller is illustrative — delete it once you've seen enforcement work; a real
21
25
  * feature classifies every route with `@Public()` or `@Classified(...)`.
@@ -28,10 +32,10 @@ export class DemoController {
28
32
  return { message: 'public route reachable — explicit @Public() opt-out' };
29
33
  }
30
34
 
31
- // Intentionally NO @Public() / @Classified(). Kept as a COMMENT because the boot-time
32
- // `verifyGatable` (fail-closed) aborts boot on any unclassified route — a live handler
33
- // here would stop the app from starting. Uncomment to witness the boot abort, then
34
- // re-comment or classify it.
35
+ // Intentionally NO @Public() / @Classified(). Kept as a COMMENT so the scaffold ships no
36
+ // unclassified route: live, it is denied 403 by the guard, and with IdentityFeatureModule
37
+ // wired `verifyGatable` aborts boot. Uncomment to witness the denial, then re-comment or
38
+ // classify it.
35
39
  // @Get('unclassified')
36
40
  // unclassifiedRoute(): { message: string } {
37
41
  // return { message: 'this body must never be reached — the guard denies first' };
@@ -1,5 +1,6 @@
1
1
  import 'reflect-metadata';
2
2
  import { NestFactory } from '@nestjs/core';
3
+ import type { NestExpressApplication } from '@nestjs/platform-express';
3
4
  import { loadAppConfig } from '@astram/guard';
4
5
  import { AppModule } from './app.module';
5
6
 
@@ -14,11 +15,32 @@ import { AppModule } from './app.module';
14
15
  */
15
16
  export async function bootstrap(): Promise<void> {
16
17
  const config = loadAppConfig();
17
- const app = await NestFactory.create(AppModule, { bufferLogs: false });
18
+ const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: false });
19
+ // TRUST_PROXY = the number of reverse-proxy hops in front of this app: 0 = no proxy
20
+ // (exposed directly), 1 = behind the Astram Traefik. It must be your REAL hop count:
21
+ // too high and a client can spoof its IP via X-Forwarded-For; too low and every client
22
+ // shares the proxy's IP. Unset → `trust proxy` stays unset and @astram/guard turns its
23
+ // per-IP limiters (login, forgot-password, reset-password) OFF, with a boot warning;
24
+ // the per-account limits stay on.
25
+ const trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
26
+ if (trustProxy !== undefined) app.set('trust proxy', trustProxy);
18
27
  await app.listen(config.port);
19
28
  console.log(`[__APP_NAME__] listening on :${config.port} (${config.environment})`);
20
29
  }
21
30
 
31
+ /**
32
+ * Parse TRUST_PROXY: unset/blank → `undefined` (leave Express's default); a non-negative
33
+ * integer → that hop count; anything else fails boot (a guessed value could let clients
34
+ * spoof their IP).
35
+ */
36
+ export function parseTrustProxy(raw: string | undefined): number | undefined {
37
+ if (raw === undefined || raw.trim() === '') return undefined;
38
+ if (!/^\d+$/.test(raw.trim())) {
39
+ throw new Error(`TRUST_PROXY must be a non-negative integer hop count (got "${raw}")`);
40
+ }
41
+ return Number(raw.trim());
42
+ }
43
+
22
44
  // Only auto-start when run directly (not when imported by tests).
23
45
  if (process.env.NODE_ENV !== 'test') {
24
46
  void bootstrap();
@@ -1,5 +1,13 @@
1
- import type { INestApplication } from '@nestjs/common';
1
+ import { Controller, Get, type INestApplication, type ModuleMetadata } from '@nestjs/common';
2
+ import { DiscoveryModule } from '@nestjs/core';
2
3
  import { Test } from '@nestjs/testing';
4
+ import { checkCompleteness, createModuleRegistry } from '@astram/core';
5
+ import {
6
+ CatalogGatabilityError,
7
+ CatalogReconciler,
8
+ OperationInventoryScanner,
9
+ ShieldModule,
10
+ } from '@astram/guard';
3
11
  import request from 'supertest';
4
12
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
13
  import { AppModule } from '../src/app.module';
@@ -46,11 +54,70 @@ describe('__APP_NAME__ — framework enforcement + health (e2e)', () => {
46
54
  message: expect.stringContaining('public route reachable'),
47
55
  });
48
56
  });
57
+ });
58
+
59
+ /**
60
+ * A deliberately UNCLASSIFIED controller (no @Public(), no @Classified()). It lives
61
+ * only in this test file — never in src/ — because a live unclassified route would
62
+ * abort a real boot (see demo.controller.ts). Used to prove both deny-by-default layers.
63
+ */
64
+ @Controller('probe')
65
+ class UnclassifiedProbeController {
66
+ @Get('unclassified')
67
+ unclassifiedRoute(): { message: string } {
68
+ return { message: 'this body must never be reached — the guard denies first' };
69
+ }
70
+ }
71
+
72
+ /** Reflect every mounted route of `imports`/`controllers` into an OperationInventory. */
73
+ async function scanRoutes(opts: Pick<ModuleMetadata, 'imports' | 'controllers'>) {
74
+ const moduleRef = await Test.createTestingModule({
75
+ imports: [DiscoveryModule, ...(opts.imports ?? [])],
76
+ controllers: opts.controllers ?? [],
77
+ providers: [OperationInventoryScanner],
78
+ }).compile();
79
+ const inventory = moduleRef.get(OperationInventoryScanner).scan('app');
80
+ await moduleRef.close();
81
+ return inventory;
82
+ }
49
83
 
50
- it('GET /demo/unclassified is DENIED (403) — deny-by-default (ADR-0002)', async () => {
51
- const res = await request(app.getHttpServer()).get('/demo/unclassified');
52
- expect(res.status).toBe(403);
53
- // The guard denies BEFORE the handler body runs.
54
- expect(res.body.message).toContain('no access classification');
84
+ describe('__APP_NAME__ — deny-by-default (ADR-0002)', () => {
85
+ it('BOOT layer: an unclassified route ABORTS boot via verifyGatable (fail-closed)', async () => {
86
+ const inventory = await scanRoutes({ controllers: [UnclassifiedProbeController] });
87
+ // The same check the identity boot provider runs: a forgotten declaration means the
88
+ // app refuses to start, not that the route silently serves traffic.
89
+ expect(() => new CatalogReconciler().verifyGatable(inventory, createModuleRegistry())).toThrow(
90
+ CatalogGatabilityError,
91
+ );
92
+ expect(() => new CatalogReconciler().verifyGatable(inventory, createModuleRegistry())).toThrow(
93
+ /UnclassifiedProbeController#unclassifiedRoute/,
94
+ );
95
+ });
96
+
97
+ it('BOOT layer: every route in YOUR AppModule is @Public() or @Classified() (none stranded)', async () => {
98
+ // Fails the moment a route is added without a classification — the same routes the
99
+ // boot check would strand. (Registering @Classified keys is the identity module's job,
100
+ // so only the "forgotten declaration" half is asserted here.)
101
+ const inventory = await scanRoutes({ imports: [AppModule] });
102
+ expect(inventory.operations.length).toBeGreaterThan(0);
103
+ const { strandedOperations } = checkCompleteness(inventory, createModuleRegistry());
104
+ expect(strandedOperations.map((o) => o.operationId)).toEqual([]);
105
+ });
106
+
107
+ it('REQUEST layer: an unclassified route is DENIED (403) before its handler runs', async () => {
108
+ const moduleRef = await Test.createTestingModule({
109
+ imports: [ShieldModule.forRoot()],
110
+ controllers: [UnclassifiedProbeController],
111
+ }).compile();
112
+ const probeApp = moduleRef.createNestApplication();
113
+ await probeApp.init();
114
+ try {
115
+ const res = await request(probeApp.getHttpServer()).get('/probe/unclassified');
116
+ expect(res.status).toBe(403);
117
+ // The guard denies BEFORE the handler body runs.
118
+ expect(res.body.message).toContain('no access classification');
119
+ } finally {
120
+ await probeApp.close();
121
+ }
55
122
  });
56
123
  });
@@ -1,95 +0,0 @@
1
- // ESLint flat config for an Astram consumer app.
2
- //
3
- // ─────────────────────────────────────────────────────────────────────────────
4
- // THE PHYSICAL WALL — backstop (do not remove).
5
- //
6
- // The Astram paved road is the ONE way features touch data: inject the framework's
7
- // RecordEngine (from @astram/engine) and go through the pipe (permission gate +
8
- // audit are mandatory, cross-tenant isolation is enforced). A feature must NEVER
9
- // reach the database directly — a raw mariadb/typeorm pool, a DataSource, a
10
- // QueryRunner, or a repository `.save()`/`.query()` skips the security layer
11
- // entirely (this is exactly how astram-org's ORS got bypassed 165 times).
12
- //
13
- // The PRIMARY wall is structural: the framework never hands feature scope a pool
14
- // (EngineModule seals it). THIS lint rule is the BACKSTOP: it fails the build if a
15
- // file under `src/features/**` imports a raw DB symbol or calls a raw DB method, so
16
- // an off-road attempt is a red build, not a silent success.
17
- //
18
- // If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
19
- // around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
20
- // framework — the capability must be added to @astram/engine (the gap-flag loop).
21
- // ─────────────────────────────────────────────────────────────────────────────
22
-
23
- import js from '@eslint/js';
24
- import tseslint from 'typescript-eslint';
25
-
26
- /** Raw DB packages a feature must never import directly. */
27
- const FORBIDDEN_DB_IMPORTS = [
28
- { name: 'mariadb', message: 'Wall: features never open a DB pool — use the RecordEngine (@astram/engine).' },
29
- { name: 'mysql', message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).' },
30
- { name: 'mysql2', message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).' },
31
- { name: 'typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
32
- { name: '@nestjs/typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
33
- { name: 'pg', message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).' },
34
- { name: 'knex', message: 'Wall: no query builder in features — use the RecordEngine (@astram/engine).' },
35
- ];
36
-
37
- /**
38
- * Named imports that hand a feature raw DB access even from an ALLOWED package.
39
- * `SHIELD_DB_POOL` is exported from `@astram/guard` (a package a feature legitimately
40
- * imports for other reasons), so it must be blocked by NAME on that path — the engine
41
- * owns the pool; feature scope must never import the pool token.
42
- */
43
- const FORBIDDEN_DB_NAMED_FROM = [
44
- {
45
- name: '@astram/guard',
46
- importNames: ['SHIELD_DB_POOL'],
47
- message: 'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
48
- },
49
- ];
50
-
51
- export default tseslint.config(
52
- js.configs.recommended,
53
- ...tseslint.configs.recommended,
54
- {
55
- ignores: ['dist/**', 'node_modules/**'],
56
- },
57
- // ── The wall: scoped to feature code ──────────────────────────────────────
58
- {
59
- files: ['src/features/**/*.ts'],
60
- rules: {
61
- 'no-restricted-imports': [
62
- 'error',
63
- {
64
- paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
65
- patterns: [
66
- {
67
- group: ['**/adapters/mariadb-*', '@astram/engine/dist/adapters/*'],
68
- message:
69
- 'Wall: the engine adapters (pool/uow/audit) are framework-internal — features use the RecordEngine facade only.',
70
- },
71
- ],
72
- },
73
- ],
74
- // Block raw query-runner / repository calls even if a pool were obtained.
75
- 'no-restricted-syntax': [
76
- 'error',
77
- {
78
- selector:
79
- "CallExpression[callee.property.name='createQueryRunner']",
80
- message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
81
- },
82
- {
83
- selector:
84
- "CallExpression[callee.property.name='getRepository']",
85
- message: 'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
86
- },
87
- {
88
- selector:
89
- "CallExpression[callee.property.name='createPool']",
90
- message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
91
- },
92
- ],
93
- },
94
- },
95
- );