create-astram-app 0.7.0 → 0.8.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
@@ -64,9 +64,14 @@ my-app/
64
64
  └── README.md how to run + how to add features the framework way
65
65
  ```
66
66
 
67
- `server/` scripts: `dev`, `build`, `start`, `test`, `lint`, `migration:run`,
67
+ `server/` scripts: `dev`, `build`, `start`, `test`, `lint`, `lint:wall`, `migration:run`,
68
68
  `migration:status`, `migration:revert`, `guide:sync`.
69
69
 
70
+ The wall (`eslint.config.mjs`, plus the `lint:wall` grep for `/internal` and
71
+ `node_modules/@astram` imports under `src/features`, run by `lint`) catches accidental use;
72
+ deliberate bypass (`.call`/`.apply`, a computed `pool[m]()`, an aliased `require`) is a
73
+ code-review matter.
74
+
70
75
  ## The frontend (`--with-frontend` / `astram-add-frontend`)
71
76
 
72
77
  A Vite + React + shadcn/ui app whose views you own and edit; the auth and API logic comes from
@@ -96,7 +101,6 @@ Redis). Its header comment shows the full wiring. In short:
96
101
 
97
102
  ```ts
98
103
  const rootIds: string[] = [/* platform-admin user uuids, read from your config */];
99
- const authzPool = createIdentityAuthzPool(); // from @astram/identity/internal; never export it
100
104
 
101
105
  imports: [
102
106
  ShieldModule.forRoot({
@@ -105,12 +109,15 @@ imports: [
105
109
  rootIds,
106
110
  identityActionKeys: identityAuthActionKeys,
107
111
  identityControlPlaneKeys,
108
- recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]),
109
112
  }),
110
113
  IdentityFeatureModule.forRoot({ rootIds, extraObjects: [/* YourType */] }),
111
114
  ],
112
115
  ```
113
116
 
117
+ `IdentityFeatureModule` builds the record authorization from its `extraObjects` and `ShieldModule`
118
+ decides with it, so your own object types are listed once. Do not pass `recordAuthorization` or
119
+ `decisionAuthority` to `ShieldModule` next to it: boot fails.
120
+
114
121
  ## Production requirements
115
122
 
116
123
  With `auth: true`, check every item before the first production deploy.
@@ -134,12 +141,32 @@ With `auth: true`, check every item before the first production deploy.
134
141
  check your production ACL).
135
142
  - **A custom `credentialResolver`** (`ShieldModule.forRoot({ credentialResolver })`) replaces the
136
143
  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.
144
+ - **MFA enforcement:** return `requiredFactors: [{ factor: 'totp', secret }]` for every user
145
+ with a confirmed MFA device, where `secret` is the device's TOTP shared secret (several
146
+ devices: one entry, secrets joined with `\n`, as the built-in resolver does). A plain
147
+ `['totp']` does not type-check and leaves the user stuck at `mfa_required`. Return `[]` (or
148
+ omit the field) and that user signs in with the password alone.
139
149
  - **Disabled accounts:** return `null` for an account that must not sign in (the built-in
140
150
  resolver does this for `shield_app.users.u_status` other than `active`).
141
151
  - **Lockout keying:** the per-account lockout is keyed on the `id` you return, so return the
142
152
  same stable id for every spelling of the user's login hint.
153
+ ```ts
154
+ import type { CredentialResolver, ResolvedCredential } from '@astram/guard';
155
+
156
+ export class DirectoryCredentialResolver implements CredentialResolver {
157
+ async resolve(identityHint: string): Promise<ResolvedCredential | null> {
158
+ const user = await directory.findByLogin(identityHint.trim().toLowerCase());
159
+ if (!user || user.status !== 'active') return null; // unknown or disabled: same answer
160
+ const secrets = await directory.confirmedTotpSecrets(user.id);
161
+ return {
162
+ passwordHash: user.passwordHash,
163
+ claims: { id: user.id, tenantId: user.companyId, roleIds: user.roleIds }, // stable id
164
+ requiredFactors: secrets.length ? [{ factor: 'totp', secret: secrets.join('\n') }] : [],
165
+ };
166
+ }
167
+ }
168
+ ```
169
+
143
170
  The authenticator's other protections (decoy verify, no account enumeration, no secret leaks,
144
171
  TOTP limiter and replay guard) still apply.
145
172
  - **`rootIds`.** Pass the same platform-administrator user uuids to `ShieldModule.forRoot` and
@@ -166,6 +193,81 @@ With `auth: true`, check every item before the first production deploy.
166
193
  in-flight `/auth/refresh` per session). Concurrent refreshes of the same token can trip reuse
167
194
  detection, which revokes the session (the user must sign in again).
168
195
 
196
+ ## Upgrading from 0.7.x
197
+
198
+ create-astram-app 0.8 pairs with `@astram/identity` 0.7 and `@astram/guard` 0.10 (breaking wiring
199
+ change). `IdentityFeatureModule` now builds the record authorization itself, from its own
200
+ `extraObjects`, and `ShieldModule` decides with it. Before, the app built it separately with
201
+ `buildIdentityRecordAuthorization(authzPool, extraObjects)`, and an app could pass different object
202
+ types to the two calls, or skip the second call and get the deny-everything authority.
203
+
204
+ **1. Bump the ranges** in `server/package.json` to `@astram/identity` ^0.7.0, `@astram/guard`
205
+ ^0.10.0, `@astram/engine` ^0.5.1, `@astram/migrate` ^0.3.2 and `@astram/guide` ^0.2.2, and
206
+ `frontend/package.json` to `@astram/frontend-core` ^0.5.2, then `npm install` in both. Every package now requires `mariadb` ^3.5.4 (driver SQL-injection advisories). No new
207
+ migrations.
208
+
209
+ **2. Change `server/src/app.module.ts`:**
210
+
211
+ - Delete `recordAuthorization: buildIdentityRecordAuthorization(...)` from `ShieldModule.forRoot`
212
+ (and from a `forRootAsync` factory). `buildIdentityRecordAuthorization` no longer exists.
213
+ - Pass your own object types only to `IdentityFeatureModule.forRoot({ extraObjects })`.
214
+ - Delete `createIdentityAuthzPool()` and its import from `@astram/identity/internal` unless another
215
+ wiring line still uses the pool (for example `new DbScopeSubtreeResolver(authzPool)`).
216
+
217
+ ```ts
218
+ // before
219
+ const authzPool = createIdentityAuthzPool();
220
+ ShieldModule.forRoot({ auth: true, /* … */, recordAuthorization: buildIdentityRecordAuthorization(authzPool, [Note]) }),
221
+ IdentityFeatureModule.forRoot({ rootIds, extraObjects: [Note] }),
222
+ // after
223
+ ShieldModule.forRoot({ auth: true, /* … */ }),
224
+ IdentityFeatureModule.forRoot({ rootIds, extraObjects: [Note] }),
225
+ ```
226
+
227
+ **Boot checks:** `ShieldModule` fails the boot if it receives `recordAuthorization` or
228
+ `decisionAuthority` while `IdentityFeatureModule` is imported (two sources), and
229
+ `IdentityFeatureModule` fails the boot if the request path would use the deny-everything authority
230
+ (for example with an older `@astram/guard`).
231
+
232
+ **Behaviour changes:** your `extraObjects` types are now also known to cross-tenant grants (offerable
233
+ types, the offer/accept check) and to the guided rule-builder's policy check. The identity module
234
+ opens one pool fewer (the request path uses the identity module's authz pool).
235
+
236
+ **3. Copy the lint-wall files** from a fresh scaffold: `server/eslint.config.mjs`, the new
237
+ `server/scripts/check-wall.mjs`, and the `lint` and `lint:wall` scripts in `server/package.json`.
238
+ The wall now also rejects any `node_modules/` or `@astram/<pkg>/dist|src` deep import, banned names
239
+ imported under an alias, computed `import()`/`require()` specifiers, and the driver methods
240
+ `execute`, `batch`, `queryStream` and `beginTransaction` (so `CommandBus.execute()` from
241
+ `@nestjs/cqrs` is flagged too). `lint:wall` fails on any `/internal` or `node_modules/@astram`
242
+ import under `src/features`; run it in CI. The wall catches accidental use; deliberate bypass
243
+ (`.call`, `Reflect.apply`, computed method names, an aliased `require`) is a code-review matter.
244
+
245
+ **More behaviour changes:**
246
+
247
+ - Change-password answers a wrong current password with 400 `invalid_current_password` (was 401)
248
+ and a locked account with 429 (was 401). The frontend shows clear messages for 429 and 503.
249
+ - Login, `/auth/mfa`, change-password and the code-gated MFA account routes count each attempt
250
+ before verifying it (parallel guesses can no longer slip past the limit) and answer 503 when the
251
+ limiter's Redis call fails (before: login let the attempt through). A refused attempt counts too.
252
+ An app that overrides a limiter token with its own object must implement `consume` and `refund`.
253
+ - A role delete the engine refuses no longer strips the role's grants. Revoking a grant needs
254
+ authority over every service account holding the role (403 otherwise).
255
+ - Renaming another user needs authority over that user. A new posting's department and branch must
256
+ be linked to the membership's company (400 otherwise). A non-root admin cannot change the status
257
+ of their own root company (403).
258
+
259
+ **4. Check for agent keys that never expire.** Keys issued before an expiry became mandatory
260
+ (identity < 0.5) have none. `@astram/identity` 0.7 ships a read-only report; run it in `server/`
261
+ with the app's `APP_DB_*` variables set:
262
+
263
+ ```sh
264
+ npx astram-sa-keys-report # lists agent label, company, key version, issue date
265
+ npx astram-sa-keys-report --fail-on-found # exit 1 if any are listed (CI)
266
+ ```
267
+
268
+ Rotate each listed agent's key with an expiry in the service-accounts page. The report never prints
269
+ secrets, hashes or ids.
270
+
169
271
  ## Upgrading from 0.6.x
170
272
 
171
273
  create-astram-app 0.7 (2026-10-09) pairs with a framework release that moves raw pools off the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.7.0",
3
+ "version": "0.8.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",
@@ -70,9 +70,10 @@ boot-pool assertion (ADR-0018), and the Core decision-authority token. With no o
70
70
  needs no database or Redis. To turn on login, sessions, MFA and the identity admin
71
71
  (`/auth/*`, `/account/*`, `/identity/*`), follow the commented wiring in the header of
72
72
  `server/src/app.module.ts`: `ShieldModule.forRoot({ auth: true, emailSender, rootIds,
73
- identityActionKeys, identityControlPlaneKeys, recordAuthorization })` plus
74
- `IdentityFeatureModule.forRoot({ rootIds, extraObjects })`. The framework provides the
75
- credential store, MFA, session handling and the decision authority; you write none of them.
73
+ identityActionKeys, identityControlPlaneKeys })` plus
74
+ `IdentityFeatureModule.forRoot({ rootIds, extraObjects })`, which builds the record
75
+ authorization `ShieldModule` decides with (pass no `recordAuthorization`). The framework provides
76
+ the credential store, MFA, session handling and the decision authority; you write none of them.
76
77
 
77
78
  ### Before you deploy with auth on
78
79
 
@@ -12,7 +12,7 @@
12
12
  "test": "node --test src/**/*.test.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.5.1",
15
+ "@astram/frontend-core": "^0.5.2",
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",
@@ -17,19 +17,26 @@
17
17
  // which only the composition root (`src/app.module.ts`, `src/main.ts`) may import.
18
18
  //
19
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
20
+ // (`.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`) imports a raw DB symbol (also under an
21
+ // alias), an `/internal` subpath or a deep path (`node_modules/…`, `@astram/*/dist/…`), or
22
+ // calls a raw DB method (query, execute, batch, queryStream, beginTransaction, …) — also as
23
+ // `x['query']()` or a destructured `{ query }`. `import()`/`require()` must take a static
24
+ // specifier. The composition root may wire the framework but may not re-export anything (no
23
25
  // `export … from`, no `export { X as Y }`, no exported variables), and features may not
24
26
  // 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.
27
+ // and deep-path bans. Keep raw-DB wiring in the composition root; a `src/lib/db.ts` helper
28
+ // is linted like a feature. `scripts/` (dev runner, guide sync) is build tooling that never
29
+ // ships in `dist/` or runs in the request path, so it is not walled. `npm run lint:wall`
30
+ // (`scripts/check-wall.mjs`, part of `npm run lint`) also greps `src/features/**` for any
31
+ // `/internal` or `node_modules/@astram` specifier, independent of this config.
28
32
  //
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
+ // SCOPE: the wall catches ACCIDENTAL use. Deliberate bypass is a CODE-REVIEW matter, not a
34
+ // lint matter — no static rule can stop code that sets out to evade it. Known, by design
35
+ // not linted: indirect calls (`fn.call(pool, …)`, `.apply`, `.bind`, `Reflect.apply`),
36
+ // method names computed at runtime (`pool[m]()`, `ns['MariaDb' + 'UnitOfWork']` — the
37
+ // latter is moot, the package entry points no longer carry those symbols), an aliased
38
+ // `require` (`const r = require; r(…)`), `module.require`, `createRequire`, private fields.
39
+ // Reviewers: treat any of these in feature code as a wall violation.
33
40
  //
34
41
  // The old names (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, `SHIELD_DB_POOL`, …) are
35
42
  // still banned by NAME below, so an app pinned to an older framework is caught too.
@@ -127,15 +134,69 @@ const INTERNAL_SUBPATHS = {
127
134
  '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
135
  };
129
136
  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 = [
137
+
138
+ /**
139
+ * Selectors for a module specifier matching `re` in the forms no-restricted-imports does not
140
+ * see: `import(…)` and `require(…)`, each as a string or a template literal with no
141
+ * `${…}` (which is just a string). With `statics`, also `import`/`export … from` and TS
142
+ * `import x = require(…)` (for specifiers checked here instead of by no-restricted-imports).
143
+ */
144
+ function specifierBan(re, message, { statics = false } = {}) {
145
+ const sources = statics
146
+ ? 'ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, ImportExpression'
147
+ : 'ImportExpression';
148
+ return [
149
+ { selector: `:matches(${sources}) > Literal.source[value=${re}]`, message },
150
+ {
151
+ selector: `ImportExpression > TemplateLiteral.source[expressions.length=0] > TemplateElement[value.cooked=${re}]`,
152
+ message,
153
+ },
154
+ { selector: `CallExpression[callee.name='require'] > Literal.arguments[value=${re}]`, message },
155
+ {
156
+ selector: `CallExpression[callee.name='require'] > TemplateLiteral.arguments[expressions.length=0] > TemplateElement[value.cooked=${re}]`,
157
+ message,
158
+ },
159
+ ...(statics
160
+ ? [{ selector: `TSExternalModuleReference > Literal.expression[value=${re}]`, message }]
161
+ : []),
162
+ ];
163
+ }
164
+
165
+ /** `import('@astram/x/internal')` / `require(\`@astram/x/internal\`)` — not seen by no-restricted-imports. */
166
+ const INTERNAL_DYNAMIC = specifierBan(INTERNAL_SPECIFIER, INTERNAL_SUBPATHS.message);
167
+
168
+ /**
169
+ * Deep paths into installed packages, in every import form: any `node_modules/` segment
170
+ * (`../../../node_modules/@astram/identity/dist/internal.js`, `node_modules/mariadb/…`) and
171
+ * `@astram/<pkg>/dist|src/…`. They skip the package `exports` map, so the `/internal` and
172
+ * driver bans above would never see them.
173
+ */
174
+ const DEEP_SPECIFIER =
175
+ '/(^|\\u002F)node_modules(\\u002F|$)|^@astram\\u002F[^\\u002F]+\\u002F(dist|src)(\\u002F|$)/';
176
+ const DEEP_PATHS = specifierBan(
177
+ DEEP_SPECIFIER,
178
+ 'Wall: no deep paths into installed packages (node_modules/…, @astram/*/dist|src/…) — import the package entry point; features use the RecordEngine (@astram/engine).',
179
+ { statics: true },
180
+ );
181
+
182
+ /**
183
+ * `import()` / `require()` in walled code take a STATIC specifier (a string, or a template
184
+ * literal without `${…}`): a specifier computed at runtime (`import(\`@astram/${p}/internal\`)`,
185
+ * `require(spec)`) cannot be checked by any rule above, so it is rejected outright. App code
186
+ * has no need for computed module names; a plugin loader belongs in the composition root.
187
+ */
188
+ const NON_STATIC_SPECIFIER_MESSAGE =
189
+ 'Wall: import()/require() in app code take a static specifier (a string literal) so the wall can check it — load computed modules from the composition root.';
190
+ const NON_STATIC_SPECIFIERS = [
132
191
  {
133
- selector: `ImportExpression[source.value=${INTERNAL_SPECIFIER}]`,
134
- message: INTERNAL_SUBPATHS.message,
192
+ selector:
193
+ "ImportExpression:not([source.type='Literal']):not([source.type='TemplateLiteral'][source.expressions.length=0])",
194
+ message: NON_STATIC_SPECIFIER_MESSAGE,
135
195
  },
136
196
  {
137
- selector: `CallExpression[callee.name='require'][arguments.0.value=${INTERNAL_SPECIFIER}]`,
138
- message: INTERNAL_SUBPATHS.message,
197
+ selector:
198
+ "CallExpression[callee.name='require']:not([arguments.0.type='Literal']):not([arguments.0.type='TemplateLiteral'][arguments.0.expressions.length=0])",
199
+ message: NON_STATIC_SPECIFIER_MESSAGE,
139
200
  },
140
201
  ];
141
202
 
@@ -147,10 +208,21 @@ const COMPOSITION_ROOT_IMPORTS = {
147
208
  };
148
209
 
149
210
  /** Raw driver methods: banned as calls, also reached by a computed key or destructuring. */
150
- const RAW_METHODS = ['query', 'getConnection', 'createPool', 'createQueryRunner', 'getRepository'];
211
+ /** (Calls to the first five also have their own messages below; the rest use RAW_METHOD_MESSAGE.) */
212
+ const RAW_METHODS = [
213
+ 'query',
214
+ 'getConnection',
215
+ 'createPool',
216
+ 'createQueryRunner',
217
+ 'getRepository',
218
+ 'execute',
219
+ 'batch',
220
+ 'queryStream',
221
+ 'beginTransaction',
222
+ ];
151
223
  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).';
224
+ const MORE_RAW_METHOD_RE = `/^(${RAW_METHODS.slice(5).join('|')})$/`;
225
+ const RAW_METHOD_MESSAGE = `Wall: no raw driver method (${RAW_METHODS.join('/')}) in app code, however it is reached — use the RecordEngine (@astram/engine).`;
154
226
 
155
227
  /** App source files: TypeScript and plain JavaScript in every module flavour. */
156
228
  const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
@@ -177,9 +249,10 @@ export default tseslint.config(
177
249
  INTERNAL_SUBPATHS,
178
250
  COMPOSITION_ROOT_IMPORTS,
179
251
  {
180
- group: ['**/adapters/mariadb-*', '@astram/*/dist/**', '@astram/*/src/**'],
252
+ // (Deep `@astram/*/dist|src` and `node_modules/` paths: DEEP_PATHS, every import form.)
253
+ group: ['**/adapters/mariadb-*'],
181
254
  message:
182
- 'Wall: framework internals (pool/uow/audit adapters, deep dist/src paths) are off-limits — features use the RecordEngine facade only.',
255
+ 'Wall: framework internals (pool/uow/audit adapters) are off-limits — features use the RecordEngine facade only.',
183
256
  },
184
257
  ],
185
258
  },
@@ -209,6 +282,10 @@ export default tseslint.config(
209
282
  message:
210
283
  'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
211
284
  },
285
+ {
286
+ selector: `CallExpression > MemberExpression.callee[computed=false][property.name=${MORE_RAW_METHOD_RE}]`,
287
+ message: RAW_METHOD_MESSAGE,
288
+ },
212
289
  // The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
213
290
  {
214
291
  selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
@@ -231,22 +308,28 @@ export default tseslint.config(
231
308
  selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
232
309
  message: RAW_METHOD_MESSAGE,
233
310
  },
234
- {
235
- selector: `ImportExpression[source.value=${DRIVER_SPECIFIER}]`,
236
- message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
237
- },
238
- {
239
- selector: `CallExpression[callee.name='require'][arguments.0.value=${DRIVER_SPECIFIER}]`,
240
- message: 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
241
- },
311
+ ...specifierBan(
312
+ DRIVER_SPECIFIER,
313
+ 'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
314
+ ),
242
315
  ...INTERNAL_DYNAMIC,
316
+ ...DEEP_PATHS,
317
+ ...NON_STATIC_SPECIFIERS,
243
318
  // The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
244
319
  // `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
245
- // (A named import is already reported by no-restricted-imports, so it is skipped here.)
246
- ...FORBIDDEN_FRAMEWORK_NAMES.map((name) => ({
247
- selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
248
- message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
249
- })),
320
+ // (A named import from the listed package is already reported by no-restricted-imports,
321
+ // so plain import specifiers are skipped here; an ALIASED one is reported below, from
322
+ // any module, because the alias hides the name from every later use.)
323
+ ...FORBIDDEN_FRAMEWORK_NAMES.flatMap((name) => [
324
+ {
325
+ selector: `Identifier[name='${name}']:not(ImportSpecifier > Identifier)`,
326
+ message: `Wall: ${name} is framework-internal DB access — use the RecordEngine (@astram/engine).`,
327
+ },
328
+ {
329
+ selector: `ImportSpecifier[imported.name='${name}'][local.name!='${name}']`,
330
+ message: `Wall: ${name} is framework-internal DB access (imported under an alias) — use the RecordEngine (@astram/engine).`,
331
+ },
332
+ ]),
250
333
  // …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
251
334
  // `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
252
335
  // module specifiers themselves never match.)
@@ -263,13 +346,13 @@ export default tseslint.config(
263
346
  ],
264
347
  },
265
348
  },
266
- // ── Tests: exempt from the DB wall, but not from the /internal ban ──────────
349
+ // ── Tests: exempt from the DB wall, but not from the /internal and deep-path bans ──
267
350
  // (A spec that re-exported an internal pool factory would launder it to feature code.)
268
351
  {
269
352
  files: ['src/**/*.{spec,test}.{ts,mts,cts,js,mjs,cjs}', 'test/**/*.{ts,mts,cts,js,mjs,cjs}'],
270
353
  rules: {
271
354
  'no-restricted-imports': ['error', { patterns: [INTERNAL_SUBPATHS] }],
272
- 'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC],
355
+ 'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC, ...DEEP_PATHS],
273
356
  },
274
357
  },
275
358
  // ── The composition root: may wire raw pools, may not hand them out ────────
@@ -14,17 +14,18 @@
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,js,mjs,cjs}\" \"test/**/*.ts\"",
17
+ "lint": "node scripts/check-wall.mjs && eslint \"src/**/*.{ts,mts,cts,js,mjs,cjs}\" \"test/**/*.ts\"",
18
+ "lint:wall": "node scripts/check-wall.mjs",
18
19
  "guide:sync": "node scripts/sync-astram-guide.mjs",
19
20
  "postinstall": "node scripts/sync-astram-guide.mjs"
20
21
  },
21
22
  "dependencies": {
22
23
  "@astram/core": "^1.4.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",
24
+ "@astram/engine": "^0.5.1",
25
+ "@astram/guard": "^0.10.0",
26
+ "@astram/guide": "^0.2.2",
27
+ "@astram/identity": "^0.7.0",
28
+ "@astram/migrate": "^0.3.2",
28
29
  "@astram/sdk": "^0.2.0",
29
30
  "@nestjs/common": "^11.2.7",
30
31
  "@nestjs/core": "^11.2.7",
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Feature-wall grep — `npm run lint:wall` (also the first step of `npm run lint`).
3
+ *
4
+ * Fails when any file under `src/features/**` names a framework-internal module:
5
+ * an `@astram/<pkg>/internal` subpath, or any path through `node_modules/@astram`
6
+ * (e.g. `../../../node_modules/@astram/identity/dist/internal.js`). Those carry the raw
7
+ * pools and units of work that only the composition root (`src/app.module.ts`,
8
+ * `src/main.ts`) may wire; features use the RecordEngine (@astram/engine).
9
+ *
10
+ * This is a plain text scan, independent of the ESLint config, so a weakened or
11
+ * mis-scoped eslint.config.mjs still cannot let such an import through. It reads every
12
+ * quoted string after `from`, `import`, `require` or `import(` (comments included —
13
+ * a false positive there is cheap to reword). No dependencies; runs on macOS and Linux.
14
+ *
15
+ * Usage: node scripts/check-wall.mjs [appRoot ...] (default: the current directory).
16
+ * An app root without `src/features` is skipped. Exit 1 on any hit.
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
20
+ import { join, relative, resolve } from 'node:path';
21
+
22
+ const EXTENSIONS = /\.(ts|mts|cts|tsx|js|mjs|cjs|jsx)$/;
23
+ /** A module specifier: the quoted string after `from` / `import` / `require(` / `import(`. */
24
+ const SPECIFIER = /\b(?:from|import|require)\s*\(?\s*(['"`])([^'"`\n]+)\1/g;
25
+ /** What a feature may never name. */
26
+ const FORBIDDEN = /^@astram\/[^/]+\/internal(\/|\.|$)|node_modules\/@astram(\/|$)/;
27
+
28
+ function walk(dir, out) {
29
+ for (const name of readdirSync(dir)) {
30
+ if (name === 'node_modules') continue;
31
+ const full = join(dir, name);
32
+ if (statSync(full).isDirectory()) walk(full, out);
33
+ else if (EXTENSIONS.test(name)) out.push(full);
34
+ }
35
+ return out;
36
+ }
37
+
38
+ const roots = process.argv.length > 2 ? process.argv.slice(2) : ['.'];
39
+ const hits = [];
40
+ for (const root of roots) {
41
+ const features = resolve(root, 'src', 'features');
42
+ if (!existsSync(features)) {
43
+ console.log(`lint:wall: ${relative(process.cwd(), features) || features} absent, skipped`);
44
+ continue;
45
+ }
46
+ for (const file of walk(features, [])) {
47
+ const text = readFileSync(file, 'utf8');
48
+ for (const m of text.matchAll(SPECIFIER)) {
49
+ const spec = m[2].replace(/\\/g, '/');
50
+ if (FORBIDDEN.test(spec)) {
51
+ const line = text.slice(0, m.index).split('\n').length;
52
+ hits.push(`${relative(process.cwd(), file)}:${line} ${spec}`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ if (hits.length > 0) {
59
+ console.error(
60
+ 'lint:wall: features may not import @astram/*/internal or node_modules/@astram paths — ' +
61
+ 'those are for src/app.module.ts and src/main.ts only; use the RecordEngine (@astram/engine).',
62
+ );
63
+ for (const h of hits) console.error(` ${h}`);
64
+ process.exit(1);
65
+ }
66
+ console.log('lint:wall: no /internal or node_modules/@astram imports under src/features');
@@ -49,41 +49,35 @@ import { DemoController } from './health/demo.controller';
49
49
  * ONE module. `@astram/guard` never imports `@astram/identity` (the dependency graph is
50
50
  * identity → guard), so the consumer wires it (ADR-0026):
51
51
  * 1. Turn auth on (above), pass the identity permission keys (so the frontend admin
52
- * nav lights up), AND pass `recordAuthorization` (so the decision guard actually
53
- * GRANTS — without it, decide() denies every classified route):
54
- * import { identityAuthActionKeys, identityControlPlaneKeys, IdentityFeatureModule,
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.)
52
+ * nav lights up), and import the identity module:
53
+ * import { identityAuthActionKeys, identityControlPlaneKeys,
54
+ * IdentityFeatureModule } from '@astram/identity';
60
55
  * imports: [
61
56
  * ShieldModule.forRoot({ auth: true,
62
- * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
63
- * recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
57
+ * identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys }),
64
58
  * IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
65
59
  * ],
66
60
  * `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
67
- * (no separate engine setup). Every route is `@Classified` — enforced by the same
68
- * deny-by-default guard; `recordAuthorization` makes that guard render REAL decisions
69
- * from your roles' grants. Omit the module and login still works; the admin pages 404.
61
+ * (no separate engine setup) and builds the record authorization `ShieldModule` decides
62
+ * with, so the guard renders REAL decisions from your roles' grants. Do NOT also pass
63
+ * `recordAuthorization` or `decisionAuthority` to `ShieldModule`: boot fails (one source).
64
+ * Every route is `@Classified` — enforced by the same deny-by-default guard. Omit the
65
+ * module and login still works; the admin pages 404.
70
66
  *
71
- * ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them BOTH to
72
- * `buildIdentityRecordAuthorization(authzPool, [Note])` (so their action keys project
73
- * into gate-5 grants) AND to `IdentityFeatureModule.forRoot({ extraObjects: [Note] })`
74
- * (so the GUIDED RULE-BUILDER schema + the OWNER-SHARE resolver know them — else a
75
- * record of your type is NOT policy-authorable and NOT shareable: "type '…' is not
76
- * registered — cannot share"). The frontend ships the matching pages/widget
77
- * (RecordPoliciesPage, ShareDialog) already wired to `useAuth().recordPolicy` /
78
- * `.recordShare`.
67
+ * ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them to
68
+ * `IdentityFeatureModule.forRoot({ extraObjects: [Note] })` (the ONE place: their action
69
+ * keys then project into gate-5 grants, and the GUIDED RULE-BUILDER schema, the
70
+ * OWNER-SHARE resolver and cross-tenant grants know them — else a record of your type is
71
+ * denied, NOT policy-authorable and NOT shareable: "type '…' is not registered — cannot
72
+ * share"). The frontend ships the matching pages/widget (RecordPoliciesPage, ShareDialog)
73
+ * already wired to `useAuth().recordPolicy` / `.recordShare`.
79
74
  */
80
75
  @Module({
81
76
  // Enforcement-only by default (boots with no config). For real login + the identity
82
77
  // admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
83
78
  // blocks above for the full form + the extraObjects note for your OWN owned types):
84
79
  // imports: [
85
- // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys,
86
- // recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]) }),
80
+ // ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
87
81
  // IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
88
82
  // ],
89
83
  imports: [ShieldModule.forRoot()],