create-astram-app 0.6.0 → 0.7.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/README.md CHANGED
@@ -96,7 +96,7 @@ Redis). Its header comment shows the full wiring. In short:
96
96
 
97
97
  ```ts
98
98
  const rootIds: string[] = [/* platform-admin user uuids, read from your config */];
99
- const authzPool = createIdentityAuthzPool(); // from @astram/identity
99
+ const authzPool = createIdentityAuthzPool(); // from @astram/identity/internal; never export it
100
100
 
101
101
  imports: [
102
102
  ShieldModule.forRoot({
@@ -132,6 +132,16 @@ With `auth: true`, check every item before the first production deploy.
132
132
  requests fail closed (401) and refresh answers 503, within `REDIS_COMMAND_TIMEOUT_MS` (default
133
133
  2000). The app's Redis user must be allowed `EVAL` (the shipped `infra/redis` ACL allows it;
134
134
  check your production ACL).
135
+ - **A custom `credentialResolver`** (`ShieldModule.forRoot({ credentialResolver })`) replaces the
136
+ framework's resolver, and with it three login checks that you then own:
137
+ - **MFA enforcement:** return `requiredFactors: ['totp']` for every user with a confirmed MFA
138
+ device. Return `[]` and that user signs in with the password alone.
139
+ - **Disabled accounts:** return `null` for an account that must not sign in (the built-in
140
+ resolver does this for `shield_app.users.u_status` other than `active`).
141
+ - **Lockout keying:** the per-account lockout is keyed on the `id` you return, so return the
142
+ same stable id for every spelling of the user's login hint.
143
+ The authenticator's other protections (decoy verify, no account enumeration, no secret leaks,
144
+ TOTP limiter and replay guard) still apply.
135
145
  - **`rootIds`.** Pass the same platform-administrator user uuids to `ShieldModule.forRoot` and
136
146
  `IdentityFeatureModule.forRoot`, from config. Without them nobody logs in as root and the
137
147
  identity module logs a boot warning. The generated `app.module.ts` does not pass them yet; add
@@ -156,6 +166,41 @@ With `auth: true`, check every item before the first production deploy.
156
166
  in-flight `/auth/refresh` per session). Concurrent refreshes of the same token can trip reuse
157
167
  detection, which revokes the session (the user must sign in again).
158
168
 
169
+ ## Upgrading from 0.6.x
170
+
171
+ create-astram-app 0.7 (2026-10-09) pairs with a framework release that moves raw pools off the
172
+ package entry points (breaking: `@astram/engine` 0.5, `@astram/identity` 0.6, `@astram/guard` 0.9)
173
+ and ships the follow-up security fixes (see each package's CHANGELOG).
174
+
175
+ **1. Bump the ranges** in `server/package.json` to `@astram/engine` ^0.5.0,
176
+ `@astram/identity` ^0.6.0, `@astram/guard` ^0.9.0, `@astram/migrate` ^0.3.1, `@astram/guide` ^0.2.1,
177
+ and `frontend/package.json` to `@astram/frontend-core` ^0.5.1, then `npm install` in both. No new
178
+ migrations.
179
+
180
+ **2. Change these files** (or copy them from a fresh scaffold and merge by hand):
181
+
182
+ | File | Change |
183
+ |---|---|
184
+ | `server/src/app.module.ts` | `import { createIdentityAuthzPool } from '@astram/identity/internal'` (it is no longer exported from `@astram/identity`). Keep the pool inside this file: do not export it. |
185
+ | `server/eslint.config.mjs`, `lint` script in `server/package.json` | the wall also lints `.js`/`.mjs`/`.cjs` under `src/`, rejects `@astram/*/internal` outside `app.module.ts`/`main.ts` (specs included), `x['query']()` and destructured `{ query }`, re-exports from `app.module.ts`/`main.ts`, and features importing either file |
186
+
187
+ **Breaking changes:** `MariaDbUnitOfWork` moved from `@astram/engine` to `@astram/engine/internal`;
188
+ `createIdentityAuthzPool` from `@astram/identity` to `@astram/identity/internal`;
189
+ `PersistenceModule` and `SHIELD_DB_POOL` from `@astram/guard` to `@astram/guard/internal`. Only the
190
+ composition root (`app.module.ts`, `main.ts`) and tests that compose the framework should import
191
+ them. The wider lint wall may flag existing code that destructures `query` (for example
192
+ `const { query } = req`); read request query parameters with `@Query()` instead.
193
+
194
+ **Behaviour changes:** deleting a role or a company now needs authority over everyone who holds
195
+ it (403 otherwise). Deleting a role removes its grants and takes it off service accounts.
196
+ Deleting a company disables its service accounts, revokes their keys and ends cross-tenant grants
197
+ naming it. An API key whose company no longer exists is rejected. Removing an MFA device,
198
+ regenerating recovery codes and changing the password are rate-limited per account; change-password
199
+ shares the login lockout. Admin role assignment treats permission keys the server does not know as
200
+ not held, and `IdentityFeatureModule.forRoot` and `buildIdentityRecordAuthorization` must receive
201
+ the same `extraObjects` (boot fails otherwise). The frontend refresh call times out after 15 s
202
+ (treated as an outage, the user stays signed in).
203
+
159
204
  ## Upgrading from 0.5.x
160
205
 
161
206
  The generated app owns its files, and the scaffolder never overwrites them, so an upgrade has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.5.0",
15
+ "@astram/frontend-core": "^0.5.1",
16
16
  "@astram/sdk": "^0.2.0",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -77,12 +77,14 @@ export function AuthProvider({ children }) {
77
77
  // cross-tab `navigator.locks` section that re-reads localStorage first so two tabs
78
78
  // never spend the same refresh token), then retries the request once. A rejected
79
79
  // refresh clears the session (→ login); a 503 keeps it (the server did not spend the
80
- // token; the next request retries). The refresh call itself uses the RAW client.
80
+ // token; the next request retries). The refresh call itself uses the RAW client, and
81
+ // forwards the wrapper's abort signal so a hung refresh times out (an outage, not a
82
+ // logout) instead of holding the cross-tab lock.
81
83
  const refresher = createAuthService(rawHttp);
82
84
  const http = createRefreshingHttp({
83
85
  http: rawHttp,
84
86
  store,
85
- refresh: (refreshToken) => refresher.refresh(refreshToken),
87
+ refresh: (refreshToken, opts) => refresher.refresh(refreshToken, opts),
86
88
  onRefreshed: (next) => sessionEventsRef.current.refreshed(next),
87
89
  onSessionExpired: () => sessionEventsRef.current.expired(),
88
90
  });
@@ -22,6 +22,14 @@ async function toResponse(res) {
22
22
  return { ok: res.ok, status: res.status, data };
23
23
  }
24
24
 
25
+ /**
26
+ * Forward the caller's abort signal to fetch (a stale search, or the refresh timeout in
27
+ * `createRefreshingHttp`). An abort rejects fetch, which every method maps to status 0.
28
+ */
29
+ function withSignal(opts) {
30
+ return opts && opts.signal ? { signal: opts.signal } : {};
31
+ }
32
+
25
33
  /**
26
34
  * The app-wide enforced-re-enroll interceptor. When a request is rejected with a 403
27
35
  * whose body is EXACTLY `{ error: 'reenrollment_required' }` (the server's re-enroll
@@ -65,6 +73,7 @@ export function createWebHttpClient(config = {}) {
65
73
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
66
74
  },
67
75
  body: JSON.stringify(body),
76
+ ...withSignal(opts),
68
77
  });
69
78
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
70
79
  } catch {
@@ -80,6 +89,7 @@ export function createWebHttpClient(config = {}) {
80
89
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
81
90
  },
82
91
  body: JSON.stringify(body),
92
+ ...withSignal(opts),
83
93
  });
84
94
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
85
95
  } catch {
@@ -95,6 +105,7 @@ export function createWebHttpClient(config = {}) {
95
105
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
96
106
  },
97
107
  body: JSON.stringify(body),
108
+ ...withSignal(opts),
98
109
  });
99
110
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
100
111
  } catch {
@@ -110,6 +121,7 @@ export function createWebHttpClient(config = {}) {
110
121
  ...(opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {}),
111
122
  },
112
123
  body: JSON.stringify(body),
124
+ ...withSignal(opts),
113
125
  });
114
126
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
115
127
  } catch {
@@ -120,6 +132,7 @@ export function createWebHttpClient(config = {}) {
120
132
  try {
121
133
  const res = await fetch(`${BASE}${path}`, {
122
134
  headers: opts.bearerToken ? { authorization: `Bearer ${opts.bearerToken}` } : {},
135
+ ...withSignal(opts),
123
136
  });
124
137
  return maybeSignalReenroll(await toResponse(res), onReenrollRequired);
125
138
  } catch {
@@ -0,0 +1,60 @@
1
+ // httpClient.test.mjs — the web HttpClient forwards `opts.signal` to fetch on every
2
+ // method (Phase 8 #6), so the refresh timeout in @astram/frontend-core can abort a hung
3
+ // /auth/refresh, and an aborted request maps to the transport-failure shape.
4
+ //
5
+ // node --test src/platform/httpClient.test.mjs
6
+ //
7
+ // `fetch` is stubbed on globalThis, so this runs under plain Node.
8
+ import { test, describe, beforeEach, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { createWebHttpClient } from './httpClient.js';
11
+
12
+ let realFetch;
13
+ let seen;
14
+
15
+ beforeEach(() => {
16
+ realFetch = globalThis.fetch;
17
+ seen = [];
18
+ globalThis.fetch = async (url, init = {}) => {
19
+ seen.push({ url, init });
20
+ if (init.signal?.aborted) {
21
+ throw Object.assign(new Error('aborted'), { name: 'AbortError' });
22
+ }
23
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
24
+ };
25
+ });
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = realFetch;
29
+ });
30
+
31
+ describe('createWebHttpClient — abort signal forwarding', () => {
32
+ const methods = [
33
+ ['get', (c, o) => c.get('/x', o)],
34
+ ['post', (c, o) => c.post('/x', {}, o)],
35
+ ['patch', (c, o) => c.patch('/x', {}, o)],
36
+ ['put', (c, o) => c.put('/x', {}, o)],
37
+ ['delete', (c, o) => c.delete('/x', {}, o)],
38
+ ];
39
+
40
+ for (const [name, call] of methods) {
41
+ test(`${name} passes opts.signal to fetch`, async () => {
42
+ const ctl = new AbortController();
43
+ const r = await call(createWebHttpClient(), { signal: ctl.signal });
44
+ assert.equal(r.status, 200);
45
+ assert.equal(seen[0].init.signal, ctl.signal);
46
+ });
47
+
48
+ test(`${name} with an aborted signal resolves { ok: false, status: 0 } (never throws)`, async () => {
49
+ const ctl = new AbortController();
50
+ ctl.abort();
51
+ const r = await call(createWebHttpClient(), { signal: ctl.signal });
52
+ assert.deepEqual(r, { ok: false, status: 0, data: null });
53
+ });
54
+
55
+ test(`${name} without a signal sends no signal`, async () => {
56
+ await call(createWebHttpClient(), undefined);
57
+ assert.equal('signal' in seen[0].init, false);
58
+ });
59
+ }
60
+ });
@@ -11,18 +11,28 @@
11
11
  // entirely (this is exactly how astram-org's ORS got bypassed 165 times).
12
12
  //
13
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.
14
+ // (EngineModule seals it), and the package entry points (`@astram/engine`,
15
+ // `@astram/identity`, `@astram/guard`) export no raw pool or unit of work. Those live only
16
+ // on `@astram/*/internal` subpaths (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, …),
17
+ // which only the composition root (`src/app.module.ts`, `src/main.ts`) may import.
22
18
  //
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.
19
+ // THIS lint config is the BACKSTOP: it fails the build if any app file under `src/**`
20
+ // (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`) imports a raw DB symbol or an `/internal`
21
+ // subpath, or calls a raw DB method — also as `x['query']()` or a destructured `{ query }`.
22
+ // The composition root may wire the framework but may not re-export anything (no
23
+ // `export … from`, no `export { X as Y }`, no exported variables), and features may not
24
+ // import it. Tests (`*.spec.*`) are exempt from the DB rules but NOT from the `/internal`
25
+ // ban. Keep raw-DB wiring in the composition root; a `src/lib/db.ts` helper is linted like
26
+ // a feature. `scripts/` (dev runner, guide sync) is build tooling that never ships in
27
+ // `dist/` or runs in the request path, so it is not walled.
28
+ //
29
+ // What lint cannot see: a name built at runtime (`ns['MariaDb' + 'UnitOfWork']`). That is
30
+ // moot because the package entry points no longer carry those symbols (the lookup finds
31
+ // `undefined`). Deliberately reaching into framework internals some other way (a deep
32
+ // `node_modules` path via a computed `require`, a private field) is a code-review matter.
33
+ //
34
+ // The old names (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, `SHIELD_DB_POOL`, …) are
35
+ // still banned by NAME below, so an app pinned to an older framework is caught too.
26
36
  //
27
37
  // If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
28
38
  // around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
@@ -86,7 +96,7 @@ const FORBIDDEN_DB_SUBPATHS = {
86
96
  const FORBIDDEN_DB_NAMED_FROM = [
87
97
  {
88
98
  name: '@astram/guard',
89
- importNames: ['SHIELD_DB_POOL'],
99
+ importNames: ['SHIELD_DB_POOL', 'PersistenceModule'],
90
100
  message:
91
101
  'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
92
102
  },
@@ -110,6 +120,43 @@ const FORBIDDEN_FRAMEWORK_NAMES = FORBIDDEN_DB_NAMED_FROM.flatMap((e) => e.impor
110
120
  /** Driver specifiers for dynamic `import()` / `require()` (static imports use the lists above). */
111
121
  const DRIVER_SPECIFIER = '/^(mariadb|mysql2?|pg|typeorm|knex|@nestjs.typeorm)(\\W|$)/';
112
122
 
123
+ /** The framework's composition-only subpaths (`@astram/engine/internal`, …). */
124
+ const INTERNAL_SUBPATHS = {
125
+ group: ['@astram/*/internal', '@astram/*/internal/**'],
126
+ message:
127
+ 'Wall: @astram/*/internal (raw pools, units of work) is for src/app.module.ts and src/main.ts only — features use the RecordEngine (@astram/engine).',
128
+ };
129
+ const INTERNAL_SPECIFIER = '/^@astram\\u002F[^\\u002F]+\\u002Finternal(\\u002F|$)/';
130
+ /** `import('@astram/x/internal')` / `require('@astram/x/internal')` — not seen by no-restricted-imports. */
131
+ const INTERNAL_DYNAMIC = [
132
+ {
133
+ selector: `ImportExpression[source.value=${INTERNAL_SPECIFIER}]`,
134
+ message: INTERNAL_SUBPATHS.message,
135
+ },
136
+ {
137
+ selector: `CallExpression[callee.name='require'][arguments.0.value=${INTERNAL_SPECIFIER}]`,
138
+ message: INTERNAL_SUBPATHS.message,
139
+ },
140
+ ];
141
+
142
+ /** The composition root, where the framework (and its pools) are wired. Features never import it. */
143
+ const COMPOSITION_ROOT_IMPORTS = {
144
+ group: ['**/app.module', '**/app.module.*', '**/main', '**/main.*'],
145
+ message:
146
+ 'Wall: features never import the composition root (app.module/main) — it wires raw framework pools. Use the RecordEngine (@astram/engine).',
147
+ };
148
+
149
+ /** Raw driver methods: banned as calls, also reached by a computed key or destructuring. */
150
+ const RAW_METHODS = ['query', 'getConnection', 'createPool', 'createQueryRunner', 'getRepository'];
151
+ const RAW_METHOD_RE = `/^(${RAW_METHODS.join('|')})$/`;
152
+ const RAW_METHOD_MESSAGE =
153
+ 'Wall: no raw driver method (query/getConnection/createPool/createQueryRunner/getRepository) in app code, however it is reached — use the RecordEngine (@astram/engine).';
154
+
155
+ /** App source files: TypeScript and plain JavaScript in every module flavour. */
156
+ const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
157
+ const COMPOSITION_ROOT = ['src/app.module.ts', 'src/main.ts'];
158
+ const SPECS = ['**/*.spec.*', '**/*.test.*'];
159
+
113
160
  export default tseslint.config(
114
161
  js.configs.recommended,
115
162
  ...tseslint.configs.recommended,
@@ -118,8 +165,8 @@ export default tseslint.config(
118
165
  },
119
166
  // ── The wall: all app code except the composition root and tests ──────────
120
167
  {
121
- files: ['src/**/*.{ts,mts,cts}'],
122
- ignores: ['src/app.module.ts', 'src/main.ts', '**/*.spec.ts'],
168
+ files: SRC_FILES,
169
+ ignores: [...COMPOSITION_ROOT, ...SPECS],
123
170
  rules: {
124
171
  'no-restricted-imports': [
125
172
  'error',
@@ -127,6 +174,8 @@ export default tseslint.config(
127
174
  paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
128
175
  patterns: [
129
176
  FORBIDDEN_DB_SUBPATHS,
177
+ INTERNAL_SUBPATHS,
178
+ COMPOSITION_ROOT_IMPORTS,
130
179
  {
131
180
  group: ['**/adapters/mariadb-*', '@astram/*/dist/**', '@astram/*/src/**'],
132
181
  message:
@@ -160,6 +209,28 @@ export default tseslint.config(
160
209
  message:
161
210
  'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
162
211
  },
212
+ // The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
213
+ {
214
+ selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
215
+ message: RAW_METHOD_MESSAGE,
216
+ },
217
+ {
218
+ selector: `CallExpression > MemberExpression.callee[computed=true] > TemplateLiteral.property[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
219
+ message: RAW_METHOD_MESSAGE,
220
+ },
221
+ // …and DESTRUCTURED off anything: `const { query } = pool`, `{ ['query']: q } = pool`.
222
+ {
223
+ selector: `ObjectPattern > Property[computed=false][key.name=${RAW_METHOD_RE}]`,
224
+ message: RAW_METHOD_MESSAGE,
225
+ },
226
+ {
227
+ selector: `ObjectPattern > Property[computed=true][key.value=${RAW_METHOD_RE}]`,
228
+ message: RAW_METHOD_MESSAGE,
229
+ },
230
+ {
231
+ selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
232
+ message: RAW_METHOD_MESSAGE,
233
+ },
163
234
  {
164
235
  selector: `ImportExpression[source.value=${DRIVER_SPECIFIER}]`,
165
236
  message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
@@ -168,6 +239,7 @@ export default tseslint.config(
168
239
  selector: `CallExpression[callee.name='require'][arguments.0.value=${DRIVER_SPECIFIER}]`,
169
240
  message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
170
241
  },
242
+ ...INTERNAL_DYNAMIC,
171
243
  // The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
172
244
  // `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
173
245
  // (A named import is already reported by no-restricted-imports, so it is skipped here.)
@@ -191,4 +263,52 @@ export default tseslint.config(
191
263
  ],
192
264
  },
193
265
  },
266
+ // ── Tests: exempt from the DB wall, but not from the /internal ban ──────────
267
+ // (A spec that re-exported an internal pool factory would launder it to feature code.)
268
+ {
269
+ files: ['src/**/*.{spec,test}.{ts,mts,cts,js,mjs,cjs}', 'test/**/*.{ts,mts,cts,js,mjs,cjs}'],
270
+ rules: {
271
+ 'no-restricted-imports': ['error', { patterns: [INTERNAL_SUBPATHS] }],
272
+ 'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC],
273
+ },
274
+ },
275
+ // ── The composition root: may wire raw pools, may not hand them out ────────
276
+ // No re-exports (`export … from`, `export *`, `export { X as Y }`), no exported variables
277
+ // and no `export default <expression>`: only declared classes/functions/types. Features may
278
+ // not import these files either (see COMPOSITION_ROOT_IMPORTS above).
279
+ {
280
+ files: COMPOSITION_ROOT,
281
+ rules: {
282
+ 'no-restricted-syntax': [
283
+ 'error',
284
+ {
285
+ selector: 'ExportAllDeclaration',
286
+ message: 'Wall: the composition root re-exports nothing (no `export * from`).',
287
+ },
288
+ {
289
+ selector: 'ExportNamedDeclaration[source]',
290
+ message: 'Wall: the composition root re-exports nothing (no `export { … } from`).',
291
+ },
292
+ {
293
+ selector: 'ExportSpecifier',
294
+ message:
295
+ 'Wall: the composition root re-exports nothing (no `export { X }` / `export { X as Y }`).',
296
+ },
297
+ {
298
+ selector: 'ExportNamedDeclaration > VariableDeclaration',
299
+ message:
300
+ 'Wall: the composition root exports no variables (a wired pool must stay inside it).',
301
+ },
302
+ {
303
+ selector:
304
+ 'ExportDefaultDeclaration:not([declaration.type=/^(ClassDeclaration|FunctionDeclaration)$/])',
305
+ message: 'Wall: the composition root default-exports only a class or function.',
306
+ },
307
+ {
308
+ selector: 'TSExportAssignment',
309
+ message: 'Wall: the composition root re-exports nothing (no `export =`).',
310
+ },
311
+ ],
312
+ },
313
+ },
194
314
  );
@@ -14,17 +14,17 @@
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,mts,cts}\" \"test/**/*.ts\"",
17
+ "lint": "eslint \"src/**/*.{ts,mts,cts,js,mjs,cjs}\" \"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
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",
23
+ "@astram/engine": "^0.5.0",
24
+ "@astram/guard": "^0.9.0",
25
+ "@astram/guide": "^0.2.1",
26
+ "@astram/identity": "^0.6.0",
27
+ "@astram/migrate": "^0.3.1",
28
28
  "@astram/sdk": "^0.2.0",
29
29
  "@nestjs/common": "^11.2.7",
30
30
  "@nestjs/core": "^11.2.7",
@@ -52,8 +52,11 @@ import { DemoController } from './health/demo.controller';
52
52
  * nav lights up), AND pass `recordAuthorization` (so the decision guard actually
53
53
  * GRANTS — without it, decide() denies every classified route):
54
54
  * import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
55
- * createIdentityAuthzPool, buildIdentityRecordAuthorization } from '@astram/identity';
56
- * const authzPool = createIdentityAuthzPool();
55
+ * buildIdentityRecordAuthorization } from '@astram/identity';
56
+ * import { createIdentityAuthzPool } from '@astram/identity/internal';
57
+ * const authzPool = createIdentityAuthzPool(); // keep it in this file: never export it
58
+ * (`@astram/identity/internal` holds the raw-pool factory; the lint wall lets only this file
59
+ * and `main.ts` import it, and forbids re-exporting anything from them.)
57
60
  * imports: [
58
61
  * ShieldModule.forRoot({ auth: true,
59
62
  * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,