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
@@ -13,6 +13,7 @@ import { test, describe } from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
14
  import { createWebShieldTransport } from './shieldTransport.js';
15
15
  import { TransportUnavailableError } from '@astram/sdk';
16
+ import { createRefreshingHttp } from '@astram/frontend-core';
16
17
 
17
18
  /** A mock `http` whose `.get` resolves exactly the given `{ok, status, data}` result —
18
19
  * mirrors `createWebHttpClient`'s contract (never throws). */
@@ -50,7 +51,11 @@ describe('shieldTransport.fetchProjection — HTTP -> outcome classification', (
50
51
 
51
52
  test('a 503 (our own loadCoarseGrants outage mapping) -> throws TransportUnavailableError', async () => {
52
53
  const transport = createWebShieldTransport({
53
- http: mockHttp({ ok: false, status: 503, data: { message: 'permission facts store unavailable' } }),
54
+ http: mockHttp({
55
+ ok: false,
56
+ status: 503,
57
+ data: { message: 'permission facts store unavailable' },
58
+ }),
54
59
  getToken: () => 'tok',
55
60
  });
56
61
  await assert.rejects(
@@ -148,11 +153,10 @@ describe('shieldTransport — deferred capabilities stay fail-closed stubs (slic
148
153
  });
149
154
  assert.deepEqual(result, { challengeId: 'c1', satisfied: false });
150
155
  });
151
-
152
156
  });
153
157
 
154
158
  describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe (Phase E slice 2)', () => {
155
- test('is implemented (opts this transport into the SDK\'s isVersionAware duck-type)', () => {
159
+ test("is implemented (opts this transport into the SDK's isVersionAware duck-type)", () => {
156
160
  const transport = createWebShieldTransport({ http: mockHttp({}), getToken: () => 'tok' });
157
161
  assert.equal(typeof transport.currentVersion, 'function');
158
162
  });
@@ -186,7 +190,11 @@ describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe
186
190
 
187
191
  test('a 503 (permission-version store outage) -> resolves +Infinity, does NOT throw', async () => {
188
192
  const transport = createWebShieldTransport({
189
- http: mockHttp({ ok: false, status: 503, data: { message: 'permission version store unavailable' } }),
193
+ http: mockHttp({
194
+ ok: false,
195
+ status: 503,
196
+ data: { message: 'permission version store unavailable' },
197
+ }),
190
198
  getToken: () => 'tok',
191
199
  });
192
200
  const version = await transport.currentVersion();
@@ -236,10 +244,81 @@ describe('shieldTransport.currentVersion — ADR-0011 bump-driven refetch probe
236
244
  // SDK's client.ts calls currentVersion() completely unguarded (no try/catch at
237
245
  // its call site), so a throw here would escape getAuthFacts uncaught.
238
246
  const transport = createWebShieldTransport({
239
- http: { get: async () => { throw new Error('boom'); } },
247
+ http: {
248
+ get: async () => {
249
+ throw new Error('boom');
250
+ },
251
+ },
240
252
  getToken: () => 'tok',
241
253
  });
242
254
  const version = await transport.currentVersion();
243
255
  assert.equal(version, Number.POSITIVE_INFINITY);
244
256
  });
245
257
  });
258
+
259
+ describe('shieldTransport over the refreshing http client (#12 wiring, as in AuthContext)', () => {
260
+ const fake = (label) => `fake-${label}`;
261
+ const OLD = fake('old-access');
262
+ const NEW = fake('new-access');
263
+
264
+ function setup(refreshResult) {
265
+ let stored = {
266
+ session: { sessionId: 's1' },
267
+ tokens: { accessToken: OLD, refreshToken: fake('rt') },
268
+ };
269
+ const store = { load: () => stored, save: (s) => (stored = s) };
270
+ const raw = {
271
+ get: async (path, opts) =>
272
+ opts?.bearerToken === NEW
273
+ ? { ok: true, status: 200, data: { principalId: 'p1', facts: [], projectedAt: 1 } }
274
+ : { ok: false, status: 401, data: null },
275
+ };
276
+ let expired = 0;
277
+ let current = OLD;
278
+ const http = createRefreshingHttp({
279
+ http: raw,
280
+ store,
281
+ locks: null,
282
+ refresh: async () => refreshResult,
283
+ onRefreshed: (next) => (current = next.tokens.accessToken),
284
+ onSessionExpired: () => (expired += 1),
285
+ });
286
+ const transport = createWebShieldTransport({ http, getToken: () => current });
287
+ return { transport, store: () => stored, expired: () => expired };
288
+ }
289
+
290
+ test('an expired token is refreshed and the projection fetched — never a transient 401', async () => {
291
+ const t = setup({
292
+ ok: true,
293
+ session: { sessionId: 's1' },
294
+ tokens: { accessToken: NEW, refreshToken: fake('rt2') },
295
+ });
296
+ const projection = await t.transport.fetchProjection('p1');
297
+ assert.deepEqual(projection, { principalId: 'p1', facts: [], projectedAt: 1 });
298
+ assert.equal(t.store().tokens.accessToken, NEW);
299
+ });
300
+
301
+ test('a rejected refresh clears the session and surfaces as transient (no cached deny)', async () => {
302
+ const t = setup({ ok: false, status: 401, error: 'Session expired. Please sign in again.' });
303
+ await assert.rejects(
304
+ () => t.transport.fetchProjection('p1'),
305
+ (e) => e instanceof TransportUnavailableError,
306
+ );
307
+ assert.equal(t.store(), null);
308
+ assert.equal(t.expired(), 1);
309
+ });
310
+
311
+ test('a refresh outage (503) keeps the session and surfaces as transient', async () => {
312
+ const t = setup({
313
+ ok: false,
314
+ status: 503,
315
+ error: 'Authentication is temporarily unavailable. Please try again.',
316
+ });
317
+ await assert.rejects(
318
+ () => t.transport.fetchProjection('p1'),
319
+ (e) => e instanceof TransportUnavailableError,
320
+ );
321
+ assert.equal(t.store().tokens.accessToken, OLD);
322
+ assert.equal(t.expired(), 0);
323
+ });
324
+ });
@@ -6,16 +6,7 @@
6
6
  import { useCallback, useEffect, useState } from 'react';
7
7
  import { useLocation, useNavigate } from 'react-router-dom';
8
8
  import { saveReturnPath } from '@/platform/returnPathStore';
9
- import {
10
- Bell,
11
- HelpCircle,
12
- LogOut,
13
- Menu,
14
- PanelLeft,
15
- Search,
16
- Settings,
17
- X,
18
- } from 'lucide-react';
9
+ import { Bell, HelpCircle, LogOut, Menu, PanelLeft, Search, Settings, X } from 'lucide-react';
19
10
  import { useAuth } from '@/auth/AuthContext';
20
11
  import { Button } from '@/components/ui/button';
21
12
  import { Input } from '@/components/ui/input';
@@ -103,7 +94,11 @@ export default function AppShell({ children, title: _title, section: _section })
103
94
  const companyName = profile?.company?.name || '';
104
95
  // Prefer the part before "@" for a cleaner label + initials.
105
96
  const shortName = displayName.includes('@') ? displayName.split('@')[0] : displayName;
106
- const initials = shortName.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
97
+ const initials =
98
+ shortName
99
+ .replace(/[^a-zA-Z0-9]/g, '')
100
+ .slice(0, 2)
101
+ .toUpperCase() || 'U';
107
102
 
108
103
  return (
109
104
  <div className="flex h-screen overflow-hidden bg-background">
@@ -221,7 +216,10 @@ export default function AppShell({ children, title: _title, section: _section })
221
216
  <ThemeToggle />
222
217
  </div>
223
218
  <DropdownMenuSeparator />
224
- <DropdownMenuItem onClick={onLogout} className="text-destructive focus:text-destructive">
219
+ <DropdownMenuItem
220
+ onClick={onLogout}
221
+ className="text-destructive focus:text-destructive"
222
+ >
225
223
  <LogOut className="mr-2 size-4" />
226
224
  Sign out
227
225
  </DropdownMenuItem>
@@ -21,9 +21,17 @@ import { NAV_SECTIONS } from './nav';
21
21
  function initialsOf(name) {
22
22
  if (!name) return 'U';
23
23
  const clean = name.includes('@') ? name.split('@')[0] : name;
24
- const parts = clean.trim().split(/[\s._-]+/).filter(Boolean);
24
+ const parts = clean
25
+ .trim()
26
+ .split(/[\s._-]+/)
27
+ .filter(Boolean);
25
28
  const letters = parts.length >= 2 ? parts[0][0] + parts[1][0] : clean.slice(0, 2);
26
- return letters.replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || 'U';
29
+ return (
30
+ letters
31
+ .replace(/[^a-zA-Z0-9]/g, '')
32
+ .slice(0, 2)
33
+ .toUpperCase() || 'U'
34
+ );
27
35
  }
28
36
 
29
37
  /**
@@ -114,7 +122,11 @@ function BrandTile({ collapsed }) {
114
122
  // Theme-adaptive logo: the COLOR logo on the light rail, the WHITE variant on
115
123
  // the dark rail (the sidebar surface now follows the theme).
116
124
  <>
117
- <img src={branding.logo.light} alt={branding.appName} className="h-7 w-auto dark:hidden" />
125
+ <img
126
+ src={branding.logo.light}
127
+ alt={branding.appName}
128
+ className="h-7 w-auto dark:hidden"
129
+ />
118
130
  <img
119
131
  src={branding.logo.dark}
120
132
  alt={branding.appName}
@@ -16,6 +16,9 @@
16
16
  * via `GET access/role-grants` + `GET permission-catalog`, BOTH gated
17
17
  * `role:read` — "managing/reading grants IS role administration")
18
18
  * - Audit → `identity:role:read` (audit.controller.ts `GET audit`)
19
+ * - Service accounts → `identity:service_account:read`
20
+ * (service-account.controller.ts `GET service-accounts`; create/edit are
21
+ * service_account:create|update, keys are service_account:manage_keys)
19
22
  * - Cross-tenant → `identity:role:read` (cross-tenant-grant.controller.ts `GET
20
23
  * cross-tenant-grants` / `GET .../inbox` / `GET .../offerable-types`,
21
24
  * all gated `role:read` — reading/managing cross-tenant grants IS role
@@ -24,6 +27,7 @@
24
27
  * caller may reach (no admin-only list route backs them).
25
28
  */
26
29
  import {
30
+ Bot,
27
31
  Building2,
28
32
  Handshake,
29
33
  History,
@@ -82,6 +86,14 @@ export const NAV_SECTIONS = [
82
86
  matchPrefix: true,
83
87
  gate: 'identity:role:read',
84
88
  },
89
+ {
90
+ icon: Bot,
91
+ label: 'Service accounts',
92
+ path: '/service-accounts',
93
+ badge: null,
94
+ matchPrefix: true,
95
+ gate: 'identity:service_account:read',
96
+ },
85
97
  {
86
98
  icon: Handshake,
87
99
  label: 'Cross-tenant',
@@ -104,8 +116,6 @@ export const NAV_SECTIONS = [
104
116
  label: 'Settings',
105
117
  // `/account` redirects to `/account/profile`; `matchPrefix` keeps the nav item
106
118
  // highlighted across ALL /account/* sub-tabs (profile / password / 2fa).
107
- items: [
108
- { icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true },
109
- ],
119
+ items: [{ icon: UserCog, label: 'Account', path: '/account', badge: null, matchPrefix: true }],
110
120
  },
111
121
  ];
@@ -3,6 +3,24 @@ import react from '@vitejs/plugin-react';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import { fileURLToPath, URL } from 'node:url';
5
5
 
6
+ /**
7
+ * Dev-server only: relax the Content-Security-Policy in index.html just enough for Vite.
8
+ * `@vitejs/plugin-react` injects an inline React-refresh preamble script, and HMR talks over
9
+ * a websocket. `vite build` / `vite preview` never run this, so the built app ships the
10
+ * strict policy unchanged.
11
+ */
12
+ function devCsp() {
13
+ return {
14
+ name: 'astram-dev-csp',
15
+ apply: 'serve',
16
+ transformIndexHtml(html) {
17
+ return html
18
+ .replace("script-src 'self'", "script-src 'self' 'unsafe-inline'")
19
+ .replace("connect-src 'self'", "connect-src 'self' ws: wss:");
20
+ },
21
+ };
22
+ }
23
+
6
24
  /**
7
25
  * __APP_NAME__ frontend — Vite config (shadcn/ui + Tailwind v4).
8
26
  *
@@ -11,7 +29,7 @@ import { fileURLToPath, URL } from 'node:url';
11
29
  * `/auth/login`. `@` aliases `src/` (the shadcn import convention).
12
30
  */
13
31
  export default defineConfig({
14
- plugins: [react(), tailwindcss()],
32
+ plugins: [react(), tailwindcss(), devCsp()],
15
33
  resolve: {
16
34
  alias: {
17
35
  '@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -7,7 +7,20 @@
7
7
  # HTTP port the app listens on (default: 3000).
8
8
  PORT=3000
9
9
 
10
- # Runtime environment: development | production | test (default: development).
10
+ # Reverse-proxy hop count in front of this app (read by src/main.ts → Express
11
+ # `trust proxy`). 0 = no proxy (app exposed directly); 1 = behind the Astram Traefik.
12
+ # Must be the REAL count: too high lets clients spoof their IP via X-Forwarded-For.
13
+ # Unset → `trust proxy` stays unset and the framework's per-IP limiters (login,
14
+ # forgot-password, reset-password) are OFF (boot warning); per-account limits still apply.
15
+ # TRUST_PROXY=1
16
+
17
+ # Runtime environment: development | production | test.
18
+ # `npm run dev` sets NODE_ENV=development itself when your shell has not set it.
19
+ # With auth on and no `emailSender`, the framework uses its dev email logger (prints
20
+ # password-reset links to stdout) ONLY when NODE_ENV is exactly development or test;
21
+ # any other value, INCLUDING unset, fails boot until you pass
22
+ # ShieldModule.forRoot({ auth: true, emailSender: <your sender> }) or
23
+ # `emailSender: 'disabled'` (no reset emails; forgot-password still answers 204).
11
24
  NODE_ENV=development
12
25
 
13
26
  # --- ADR-0018 connection-pool budget (all optional; framework defaults apply) ---
@@ -38,7 +51,15 @@ NODE_ENV=development
38
51
  #
39
52
  # Redis + refresh-token secret:
40
53
  # REDIS_URL=redis://127.0.0.1:6379
54
+ # Redis command timeout (ms). A Redis outage then fails requests closed within this bound instead
55
+ # of hanging (default 2000).
56
+ # REDIS_COMMAND_TIMEOUT_MS=2000
41
57
  # REFRESH_HMAC_KEY=change-me-to-a-long-random-secret # REQUIRED when auth is on
58
+ #
59
+ # Per-client-IP login failure limit (default 50 failures per 15 min). Enforced ONLY when
60
+ # TRUST_PROXY (above) is set, so the client IP is real rather than the proxy's.
61
+ # LOGIN_IP_MAX_FAILURES=50
62
+ # LOGIN_IP_WINDOW_MS=900000
42
63
 
43
64
  # --- Migrations (optional; @astram/identity's migrations run automatically) ---
44
65
  # APP_MIGRATION_DIR=migrations # your app's own migrations, run after identity's
@@ -0,0 +1,194 @@
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 any
15
+ // app file under `src/**` imports a raw DB symbol or calls a raw DB method, so an
16
+ // off-road attempt is a red build, not a silent success. Only the composition root
17
+ // (`src/app.module.ts`, `src/main.ts`) and tests (`*.spec.ts`) are exempt: they wire the
18
+ // framework (e.g. `createIdentityAuthzPool()` for ShieldModule.forRoot) and never hold
19
+ // feature logic. Keep raw-DB wiring there; a `src/lib/db.ts` helper is linted like a feature.
20
+ // `scripts/` (dev runner, guide sync) is build tooling that never ships in `dist/` or runs
21
+ // in the request path, so it is not walled.
22
+ //
23
+ // Some framework packages also export their internal adapters for composition and tests
24
+ // (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, …). Those are banned by NAME below, so an
25
+ // import from the package root is caught, not only a deep `dist/` path.
26
+ //
27
+ // If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
28
+ // around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
29
+ // framework — the capability must be added to @astram/engine (the gap-flag loop).
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+
32
+ import js from '@eslint/js';
33
+ import tseslint from 'typescript-eslint';
34
+
35
+ /** Raw DB packages a feature must never import directly. */
36
+ const FORBIDDEN_DB_IMPORTS = [
37
+ {
38
+ name: 'mariadb',
39
+ message: 'Wall: features never open a DB pool — use the RecordEngine (@astram/engine).',
40
+ },
41
+ {
42
+ name: 'mysql',
43
+ message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
44
+ },
45
+ {
46
+ name: 'mysql2',
47
+ message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
48
+ },
49
+ { name: 'typeorm', message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).' },
50
+ {
51
+ name: '@nestjs/typeorm',
52
+ message: 'Wall: no ORM in features — use the RecordEngine (@astram/engine).',
53
+ },
54
+ {
55
+ name: 'pg',
56
+ message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
57
+ },
58
+ {
59
+ name: 'knex',
60
+ message: 'Wall: no query builder in features — use the RecordEngine (@astram/engine).',
61
+ },
62
+ ];
63
+
64
+ /** Driver/ORM SUBPATHS (`paths` above matches exact specifiers only). */
65
+ const FORBIDDEN_DB_SUBPATHS = {
66
+ group: [
67
+ 'mariadb/*',
68
+ 'mysql/*',
69
+ 'mysql2/*',
70
+ 'typeorm/*',
71
+ '@nestjs/typeorm/*',
72
+ 'pg/*',
73
+ 'pg-*',
74
+ 'knex/*',
75
+ ],
76
+ message: 'Wall: features never open a DB driver — use the RecordEngine (@astram/engine).',
77
+ };
78
+
79
+ /**
80
+ * Named imports that hand a feature raw DB access even from an ALLOWED package.
81
+ * `SHIELD_DB_POOL` is exported from `@astram/guard` (a package a feature legitimately
82
+ * imports for other reasons), so it must be blocked by NAME on that path — the engine
83
+ * owns the pool; feature scope must never import the pool token. The same goes for the
84
+ * composition-only factories/adapters of `@astram/identity` and `@astram/engine`.
85
+ */
86
+ const FORBIDDEN_DB_NAMED_FROM = [
87
+ {
88
+ name: '@astram/guard',
89
+ importNames: ['SHIELD_DB_POOL'],
90
+ message:
91
+ 'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
92
+ },
93
+ {
94
+ name: '@astram/identity',
95
+ importNames: ['createIdentityAuthzPool'],
96
+ message:
97
+ 'Wall: createIdentityAuthzPool is a raw pool for app.module.ts wiring only — features use the RecordEngine (@astram/engine).',
98
+ },
99
+ {
100
+ name: '@astram/engine',
101
+ importNames: ['MariaDbUnitOfWork', 'MariaDbAuditSink', 'RecordEngineImpl', 'TableRegistry'],
102
+ message:
103
+ 'Wall: engine internals (unit of work, audit sink, impl, registry) bypass the gate and audit — inject RECORD_ENGINE / RecordsService instead.',
104
+ },
105
+ ];
106
+
107
+ /** Every name above, for the syntax ban that also catches `ns.X`, `require(…).X`, `(await import(…)).X`. */
108
+ const FORBIDDEN_FRAMEWORK_NAMES = FORBIDDEN_DB_NAMED_FROM.flatMap((e) => e.importNames);
109
+
110
+ /** Driver specifiers for dynamic `import()` / `require()` (static imports use the lists above). */
111
+ const DRIVER_SPECIFIER = '/^(mariadb|mysql2?|pg|typeorm|knex|@nestjs.typeorm)(\\W|$)/';
112
+
113
+ export default tseslint.config(
114
+ js.configs.recommended,
115
+ ...tseslint.configs.recommended,
116
+ {
117
+ ignores: ['dist/**', 'node_modules/**'],
118
+ },
119
+ // ── The wall: all app code except the composition root and tests ──────────
120
+ {
121
+ files: ['src/**/*.{ts,mts,cts}'],
122
+ ignores: ['src/app.module.ts', 'src/main.ts', '**/*.spec.ts'],
123
+ rules: {
124
+ 'no-restricted-imports': [
125
+ 'error',
126
+ {
127
+ paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
128
+ patterns: [
129
+ FORBIDDEN_DB_SUBPATHS,
130
+ {
131
+ group: ['**/adapters/mariadb-*', '@astram/*/dist/**', '@astram/*/src/**'],
132
+ message:
133
+ 'Wall: framework internals (pool/uow/audit adapters, deep dist/src paths) are off-limits — features use the RecordEngine facade only.',
134
+ },
135
+ ],
136
+ },
137
+ ],
138
+ // Block raw query-runner / repository calls even if a pool were obtained.
139
+ 'no-restricted-syntax': [
140
+ 'error',
141
+ {
142
+ selector: "CallExpression[callee.property.name='createQueryRunner']",
143
+ message: 'Wall: no QueryRunner in features — use the RecordEngine (@astram/engine).',
144
+ },
145
+ {
146
+ selector: "CallExpression[callee.property.name='getRepository']",
147
+ message:
148
+ 'Wall: no repository access in features — use the RecordEngine (@astram/engine).',
149
+ },
150
+ {
151
+ selector: "CallExpression[callee.property.name='createPool']",
152
+ message: 'Wall: features never create a DB pool — use the RecordEngine (@astram/engine).',
153
+ },
154
+ {
155
+ selector: "CallExpression[callee.property.name='query']",
156
+ message: 'Wall: no raw .query() in app code — use the RecordEngine (@astram/engine).',
157
+ },
158
+ {
159
+ selector: "CallExpression[callee.property.name='getConnection']",
160
+ message:
161
+ 'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
162
+ },
163
+ {
164
+ selector: `ImportExpression[source.value=${DRIVER_SPECIFIER}]`,
165
+ message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
166
+ },
167
+ {
168
+ selector: `CallExpression[callee.name='require'][arguments.0.value=${DRIVER_SPECIFIER}]`,
169
+ message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
170
+ },
171
+ // The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
172
+ // `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
173
+ // (A named import is already reported by no-restricted-imports, so it is skipped here.)
174
+ ...FORBIDDEN_FRAMEWORK_NAMES.map((name) => ({
175
+ selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
176
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
177
+ })),
178
+ // …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
179
+ // `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
180
+ // module specifiers themselves never match.)
181
+ ...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
182
+ {
183
+ selector: `Literal[value='${name}']`,
184
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
185
+ },
186
+ {
187
+ selector: `TemplateLiteral[expressions.length=0] > TemplateElement[value.cooked='${name}']`,
188
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
189
+ },
190
+ ]),
191
+ ],
192
+ },
193
+ },
194
+ );
@@ -14,27 +14,27 @@
14
14
  "migration:run": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate",
15
15
  "migration:status": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:status",
16
16
  "migration:revert": "node --env-file-if-exists=.env node_modules/@astram/migrate/dist/cli.js migrate:revert",
17
- "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
17
+ "lint": "eslint \"src/**/*.{ts,mts,cts}\" \"test/**/*.ts\"",
18
18
  "guide:sync": "node scripts/sync-astram-guide.mjs",
19
19
  "postinstall": "node scripts/sync-astram-guide.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@astram/core": "^1.2.0",
23
- "@astram/engine": "^0.2.0",
24
- "@astram/guard": "^0.6.0",
25
- "@astram/guide": "^0.1.0",
26
- "@astram/identity": "^0.3.1",
27
- "@astram/migrate": "^0.2.0",
22
+ "@astram/core": "^1.4.0",
23
+ "@astram/engine": "^0.4.0",
24
+ "@astram/guard": "^0.8.0",
25
+ "@astram/guide": "^0.2.0",
26
+ "@astram/identity": "^0.5.0",
27
+ "@astram/migrate": "^0.3.0",
28
28
  "@astram/sdk": "^0.2.0",
29
- "@nestjs/common": "^11.0.0",
30
- "@nestjs/core": "^11.0.0",
31
- "@nestjs/platform-express": "^11.0.0",
29
+ "@nestjs/common": "^11.2.7",
30
+ "@nestjs/core": "^11.2.7",
31
+ "@nestjs/platform-express": "^11.2.7",
32
32
  "reflect-metadata": "^0.2.2",
33
33
  "rxjs": "^7.8.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "@eslint/js": "^9.0.0",
37
- "@nestjs/testing": "^11.0.0",
37
+ "@nestjs/testing": "^11.2.7",
38
38
  "@types/express": "^5.0.0",
39
39
  "@types/node": "^22.0.0",
40
40
  "@types/supertest": "^6.0.2",
@@ -10,6 +10,12 @@
10
10
  * hot-reload AND correct DI. Same compiler as the production build, no extra deps.
11
11
  *
12
12
  * `.env` is loaded by the node child via --env-file-if-exists.
13
+ *
14
+ * NODE_ENV: the child runs with NODE_ENV=development unless your shell already set it.
15
+ * `npm run dev` is a dev box by definition, and @astram/guard keys dev-only defaults
16
+ * off the RAW NODE_ENV (e.g. with `auth: true` and no `emailSender`, the dev email
17
+ * logger that prints reset links is used ONLY under development/test; an unset
18
+ * NODE_ENV fails boot). `npm start` does NOT set it — production must configure a sender.
13
19
  */
14
20
 
15
21
  import { spawn } from 'node:child_process';
@@ -20,7 +26,13 @@ mkdirSync('dist', { recursive: true });
20
26
  // 1) tsc in watch mode → dist/ (emits decorator metadata; the whole point).
21
27
  const tsc = spawn(
22
28
  process.execPath,
23
- [new URL('../node_modules/typescript/bin/tsc', import.meta.url).pathname, '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput'],
29
+ [
30
+ new URL('../node_modules/typescript/bin/tsc', import.meta.url).pathname,
31
+ '-w',
32
+ '-p',
33
+ 'tsconfig.build.json',
34
+ '--preserveWatchOutput',
35
+ ],
24
36
  { stdio: 'inherit' },
25
37
  );
26
38
 
@@ -33,11 +45,10 @@ const startNode = () => {
33
45
  return;
34
46
  }
35
47
  started = true;
36
- const node = spawn(
37
- process.execPath,
38
- ['--env-file-if-exists=.env', '--watch', 'dist/main.js'],
39
- { stdio: 'inherit' },
40
- );
48
+ const node = spawn(process.execPath, ['--env-file-if-exists=.env', '--watch', 'dist/main.js'], {
49
+ stdio: 'inherit',
50
+ env: { ...process.env, NODE_ENV: process.env.NODE_ENV || 'development' },
51
+ });
41
52
  node.on('exit', (code) => {
42
53
  tsc.kill();
43
54
  process.exit(code ?? 0);