create-astram-app 0.6.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 +150 -3
- package/package.json +1 -1
- package/template/README.md +4 -3
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/auth/AuthContext.jsx +4 -2
- package/template/frontend/src/platform/httpClient.js +13 -0
- package/template/frontend/src/platform/httpClient.test.mjs +60 -0
- package/template/server/eslint.config.mjs +228 -25
- package/template/server/package.json +7 -6
- package/template/server/scripts/check-wall.mjs +66 -0
- package/template/server/src/app.module.ts +17 -20
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
|
|
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.
|
|
@@ -132,6 +139,36 @@ With `auth: true`, check every item before the first production deploy.
|
|
|
132
139
|
requests fail closed (401) and refresh answers 503, within `REDIS_COMMAND_TIMEOUT_MS` (default
|
|
133
140
|
2000). The app's Redis user must be allowed `EVAL` (the shipped `infra/redis` ACL allows it;
|
|
134
141
|
check your production ACL).
|
|
142
|
+
- **A custom `credentialResolver`** (`ShieldModule.forRoot({ credentialResolver })`) replaces the
|
|
143
|
+
framework's resolver, and with it three login checks that you then own:
|
|
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.
|
|
149
|
+
- **Disabled accounts:** return `null` for an account that must not sign in (the built-in
|
|
150
|
+
resolver does this for `shield_app.users.u_status` other than `active`).
|
|
151
|
+
- **Lockout keying:** the per-account lockout is keyed on the `id` you return, so return the
|
|
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
|
+
|
|
170
|
+
The authenticator's other protections (decoy verify, no account enumeration, no secret leaks,
|
|
171
|
+
TOTP limiter and replay guard) still apply.
|
|
135
172
|
- **`rootIds`.** Pass the same platform-administrator user uuids to `ShieldModule.forRoot` and
|
|
136
173
|
`IdentityFeatureModule.forRoot`, from config. Without them nobody logs in as root and the
|
|
137
174
|
identity module logs a boot warning. The generated `app.module.ts` does not pass them yet; add
|
|
@@ -156,6 +193,116 @@ With `auth: true`, check every item before the first production deploy.
|
|
|
156
193
|
in-flight `/auth/refresh` per session). Concurrent refreshes of the same token can trip reuse
|
|
157
194
|
detection, which revokes the session (the user must sign in again).
|
|
158
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
|
+
|
|
271
|
+
## Upgrading from 0.6.x
|
|
272
|
+
|
|
273
|
+
create-astram-app 0.7 (2026-10-09) pairs with a framework release that moves raw pools off the
|
|
274
|
+
package entry points (breaking: `@astram/engine` 0.5, `@astram/identity` 0.6, `@astram/guard` 0.9)
|
|
275
|
+
and ships the follow-up security fixes (see each package's CHANGELOG).
|
|
276
|
+
|
|
277
|
+
**1. Bump the ranges** in `server/package.json` to `@astram/engine` ^0.5.0,
|
|
278
|
+
`@astram/identity` ^0.6.0, `@astram/guard` ^0.9.0, `@astram/migrate` ^0.3.1, `@astram/guide` ^0.2.1,
|
|
279
|
+
and `frontend/package.json` to `@astram/frontend-core` ^0.5.1, then `npm install` in both. No new
|
|
280
|
+
migrations.
|
|
281
|
+
|
|
282
|
+
**2. Change these files** (or copy them from a fresh scaffold and merge by hand):
|
|
283
|
+
|
|
284
|
+
| File | Change |
|
|
285
|
+
|---|---|
|
|
286
|
+
| `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. |
|
|
287
|
+
| `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 |
|
|
288
|
+
|
|
289
|
+
**Breaking changes:** `MariaDbUnitOfWork` moved from `@astram/engine` to `@astram/engine/internal`;
|
|
290
|
+
`createIdentityAuthzPool` from `@astram/identity` to `@astram/identity/internal`;
|
|
291
|
+
`PersistenceModule` and `SHIELD_DB_POOL` from `@astram/guard` to `@astram/guard/internal`. Only the
|
|
292
|
+
composition root (`app.module.ts`, `main.ts`) and tests that compose the framework should import
|
|
293
|
+
them. The wider lint wall may flag existing code that destructures `query` (for example
|
|
294
|
+
`const { query } = req`); read request query parameters with `@Query()` instead.
|
|
295
|
+
|
|
296
|
+
**Behaviour changes:** deleting a role or a company now needs authority over everyone who holds
|
|
297
|
+
it (403 otherwise). Deleting a role removes its grants and takes it off service accounts.
|
|
298
|
+
Deleting a company disables its service accounts, revokes their keys and ends cross-tenant grants
|
|
299
|
+
naming it. An API key whose company no longer exists is rejected. Removing an MFA device,
|
|
300
|
+
regenerating recovery codes and changing the password are rate-limited per account; change-password
|
|
301
|
+
shares the login lockout. Admin role assignment treats permission keys the server does not know as
|
|
302
|
+
not held, and `IdentityFeatureModule.forRoot` and `buildIdentityRecordAuthorization` must receive
|
|
303
|
+
the same `extraObjects` (boot fails otherwise). The frontend refresh call times out after 15 s
|
|
304
|
+
(treated as an outage, the user stays signed in).
|
|
305
|
+
|
|
159
306
|
## Upgrading from 0.5.x
|
|
160
307
|
|
|
161
308
|
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.
|
|
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",
|
package/template/README.md
CHANGED
|
@@ -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
|
|
74
|
-
`IdentityFeatureModule.forRoot({ rootIds, extraObjects })
|
|
75
|
-
|
|
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
|
|
|
@@ -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,35 @@
|
|
|
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)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// (`src/app.module.ts`, `src/main.ts`)
|
|
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
|
-
//
|
|
24
|
-
// (
|
|
25
|
-
//
|
|
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 (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
|
|
25
|
+
// `export … from`, no `export { X as Y }`, no exported variables), and features may not
|
|
26
|
+
// import it. Tests (`*.spec.*`) are exempt from the DB rules but NOT from the `/internal`
|
|
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.
|
|
32
|
+
//
|
|
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.
|
|
40
|
+
//
|
|
41
|
+
// The old names (`createIdentityAuthzPool`, `MariaDbUnitOfWork`, `SHIELD_DB_POOL`, …) are
|
|
42
|
+
// still banned by NAME below, so an app pinned to an older framework is caught too.
|
|
26
43
|
//
|
|
27
44
|
// If you hit this rule because the pipe can't do what you need: STOP. Do NOT work
|
|
28
45
|
// around it. Record the gap in `.astram/guide/gaps.md` and raise it with the
|
|
@@ -86,7 +103,7 @@ const FORBIDDEN_DB_SUBPATHS = {
|
|
|
86
103
|
const FORBIDDEN_DB_NAMED_FROM = [
|
|
87
104
|
{
|
|
88
105
|
name: '@astram/guard',
|
|
89
|
-
importNames: ['SHIELD_DB_POOL'],
|
|
106
|
+
importNames: ['SHIELD_DB_POOL', 'PersistenceModule'],
|
|
90
107
|
message:
|
|
91
108
|
'Wall: the DB pool is sealed inside the engine — features use the RecordEngine (@astram/engine).',
|
|
92
109
|
},
|
|
@@ -110,6 +127,108 @@ const FORBIDDEN_FRAMEWORK_NAMES = FORBIDDEN_DB_NAMED_FROM.flatMap((e) => e.impor
|
|
|
110
127
|
/** Driver specifiers for dynamic `import()` / `require()` (static imports use the lists above). */
|
|
111
128
|
const DRIVER_SPECIFIER = '/^(mariadb|mysql2?|pg|typeorm|knex|@nestjs.typeorm)(\\W|$)/';
|
|
112
129
|
|
|
130
|
+
/** The framework's composition-only subpaths (`@astram/engine/internal`, …). */
|
|
131
|
+
const INTERNAL_SUBPATHS = {
|
|
132
|
+
group: ['@astram/*/internal', '@astram/*/internal/**'],
|
|
133
|
+
message:
|
|
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).',
|
|
135
|
+
};
|
|
136
|
+
const INTERNAL_SPECIFIER = '/^@astram\\u002F[^\\u002F]+\\u002Finternal(\\u002F|$)/';
|
|
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 = [
|
|
191
|
+
{
|
|
192
|
+
selector:
|
|
193
|
+
"ImportExpression:not([source.type='Literal']):not([source.type='TemplateLiteral'][source.expressions.length=0])",
|
|
194
|
+
message: NON_STATIC_SPECIFIER_MESSAGE,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
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,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/** The composition root, where the framework (and its pools) are wired. Features never import it. */
|
|
204
|
+
const COMPOSITION_ROOT_IMPORTS = {
|
|
205
|
+
group: ['**/app.module', '**/app.module.*', '**/main', '**/main.*'],
|
|
206
|
+
message:
|
|
207
|
+
'Wall: features never import the composition root (app.module/main) — it wires raw framework pools. Use the RecordEngine (@astram/engine).',
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Raw driver methods: banned as calls, also reached by a computed key or destructuring. */
|
|
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
|
+
];
|
|
223
|
+
const RAW_METHOD_RE = `/^(${RAW_METHODS.join('|')})$/`;
|
|
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).`;
|
|
226
|
+
|
|
227
|
+
/** App source files: TypeScript and plain JavaScript in every module flavour. */
|
|
228
|
+
const SRC_FILES = ['src/**/*.{ts,mts,cts,js,mjs,cjs}'];
|
|
229
|
+
const COMPOSITION_ROOT = ['src/app.module.ts', 'src/main.ts'];
|
|
230
|
+
const SPECS = ['**/*.spec.*', '**/*.test.*'];
|
|
231
|
+
|
|
113
232
|
export default tseslint.config(
|
|
114
233
|
js.configs.recommended,
|
|
115
234
|
...tseslint.configs.recommended,
|
|
@@ -118,8 +237,8 @@ export default tseslint.config(
|
|
|
118
237
|
},
|
|
119
238
|
// ── The wall: all app code except the composition root and tests ──────────
|
|
120
239
|
{
|
|
121
|
-
files:
|
|
122
|
-
ignores: [
|
|
240
|
+
files: SRC_FILES,
|
|
241
|
+
ignores: [...COMPOSITION_ROOT, ...SPECS],
|
|
123
242
|
rules: {
|
|
124
243
|
'no-restricted-imports': [
|
|
125
244
|
'error',
|
|
@@ -127,10 +246,13 @@ export default tseslint.config(
|
|
|
127
246
|
paths: [...FORBIDDEN_DB_IMPORTS, ...FORBIDDEN_DB_NAMED_FROM],
|
|
128
247
|
patterns: [
|
|
129
248
|
FORBIDDEN_DB_SUBPATHS,
|
|
249
|
+
INTERNAL_SUBPATHS,
|
|
250
|
+
COMPOSITION_ROOT_IMPORTS,
|
|
130
251
|
{
|
|
131
|
-
|
|
252
|
+
// (Deep `@astram/*/dist|src` and `node_modules/` paths: DEEP_PATHS, every import form.)
|
|
253
|
+
group: ['**/adapters/mariadb-*'],
|
|
132
254
|
message:
|
|
133
|
-
'Wall: framework internals (pool/uow/audit adapters
|
|
255
|
+
'Wall: framework internals (pool/uow/audit adapters) are off-limits — features use the RecordEngine facade only.',
|
|
134
256
|
},
|
|
135
257
|
],
|
|
136
258
|
},
|
|
@@ -161,20 +283,53 @@ export default tseslint.config(
|
|
|
161
283
|
'Wall: no raw .getConnection() in app code — use the RecordEngine (@astram/engine).',
|
|
162
284
|
},
|
|
163
285
|
{
|
|
164
|
-
selector: `
|
|
165
|
-
message:
|
|
286
|
+
selector: `CallExpression > MemberExpression.callee[computed=false][property.name=${MORE_RAW_METHOD_RE}]`,
|
|
287
|
+
message: RAW_METHOD_MESSAGE,
|
|
288
|
+
},
|
|
289
|
+
// The same methods by COMPUTED key: `pool['query']()`, pool[`getConnection`]().
|
|
290
|
+
{
|
|
291
|
+
selector: `CallExpression > MemberExpression.callee[computed=true][property.value=${RAW_METHOD_RE}]`,
|
|
292
|
+
message: RAW_METHOD_MESSAGE,
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
selector: `CallExpression > MemberExpression.callee[computed=true] > TemplateLiteral.property[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
|
|
296
|
+
message: RAW_METHOD_MESSAGE,
|
|
297
|
+
},
|
|
298
|
+
// …and DESTRUCTURED off anything: `const { query } = pool`, `{ ['query']: q } = pool`.
|
|
299
|
+
{
|
|
300
|
+
selector: `ObjectPattern > Property[computed=false][key.name=${RAW_METHOD_RE}]`,
|
|
301
|
+
message: RAW_METHOD_MESSAGE,
|
|
166
302
|
},
|
|
167
303
|
{
|
|
168
|
-
selector: `
|
|
169
|
-
message:
|
|
304
|
+
selector: `ObjectPattern > Property[computed=true][key.value=${RAW_METHOD_RE}]`,
|
|
305
|
+
message: RAW_METHOD_MESSAGE,
|
|
170
306
|
},
|
|
307
|
+
{
|
|
308
|
+
selector: `ObjectPattern > Property[computed=true] > TemplateLiteral.key[expressions.length=0] > TemplateElement[value.cooked=${RAW_METHOD_RE}]`,
|
|
309
|
+
message: RAW_METHOD_MESSAGE,
|
|
310
|
+
},
|
|
311
|
+
...specifierBan(
|
|
312
|
+
DRIVER_SPECIFIER,
|
|
313
|
+
'Wall: features never load a DB driver — use the RecordEngine (@astram/engine).',
|
|
314
|
+
),
|
|
315
|
+
...INTERNAL_DYNAMIC,
|
|
316
|
+
...DEEP_PATHS,
|
|
317
|
+
...NON_STATIC_SPECIFIERS,
|
|
171
318
|
// The banned framework names however they are reached: `ns.SHIELD_DB_POOL`,
|
|
172
319
|
// `require('@astram/guard').SHIELD_DB_POOL`, `(await import('@astram/engine')).TableRegistry`.
|
|
173
|
-
// (A named import is already reported by no-restricted-imports,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
]),
|
|
178
333
|
// …and reached by STRING: `ns['SHIELD_DB_POOL']`, ns[`TableRegistry`],
|
|
179
334
|
// `const { ['MariaDbUnitOfWork']: x } = ns`. (Import sources are other strings, so the
|
|
180
335
|
// module specifiers themselves never match.)
|
|
@@ -191,4 +346,52 @@ export default tseslint.config(
|
|
|
191
346
|
],
|
|
192
347
|
},
|
|
193
348
|
},
|
|
349
|
+
// ── Tests: exempt from the DB wall, but not from the /internal and deep-path bans ──
|
|
350
|
+
// (A spec that re-exported an internal pool factory would launder it to feature code.)
|
|
351
|
+
{
|
|
352
|
+
files: ['src/**/*.{spec,test}.{ts,mts,cts,js,mjs,cjs}', 'test/**/*.{ts,mts,cts,js,mjs,cjs}'],
|
|
353
|
+
rules: {
|
|
354
|
+
'no-restricted-imports': ['error', { patterns: [INTERNAL_SUBPATHS] }],
|
|
355
|
+
'no-restricted-syntax': ['error', ...INTERNAL_DYNAMIC, ...DEEP_PATHS],
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
// ── The composition root: may wire raw pools, may not hand them out ────────
|
|
359
|
+
// No re-exports (`export … from`, `export *`, `export { X as Y }`), no exported variables
|
|
360
|
+
// and no `export default <expression>`: only declared classes/functions/types. Features may
|
|
361
|
+
// not import these files either (see COMPOSITION_ROOT_IMPORTS above).
|
|
362
|
+
{
|
|
363
|
+
files: COMPOSITION_ROOT,
|
|
364
|
+
rules: {
|
|
365
|
+
'no-restricted-syntax': [
|
|
366
|
+
'error',
|
|
367
|
+
{
|
|
368
|
+
selector: 'ExportAllDeclaration',
|
|
369
|
+
message: 'Wall: the composition root re-exports nothing (no `export * from`).',
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
selector: 'ExportNamedDeclaration[source]',
|
|
373
|
+
message: 'Wall: the composition root re-exports nothing (no `export { … } from`).',
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
selector: 'ExportSpecifier',
|
|
377
|
+
message:
|
|
378
|
+
'Wall: the composition root re-exports nothing (no `export { X }` / `export { X as Y }`).',
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
selector: 'ExportNamedDeclaration > VariableDeclaration',
|
|
382
|
+
message:
|
|
383
|
+
'Wall: the composition root exports no variables (a wired pool must stay inside it).',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
selector:
|
|
387
|
+
'ExportDefaultDeclaration:not([declaration.type=/^(ClassDeclaration|FunctionDeclaration)$/])',
|
|
388
|
+
message: 'Wall: the composition root default-exports only a class or function.',
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
selector: 'TSExportAssignment',
|
|
392
|
+
message: 'Wall: the composition root re-exports nothing (no `export =`).',
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
},
|
|
194
397
|
);
|
|
@@ -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}\" \"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.
|
|
24
|
-
"@astram/guard": "^0.
|
|
25
|
-
"@astram/guide": "^0.2.
|
|
26
|
-
"@astram/identity": "^0.
|
|
27
|
-
"@astram/migrate": "^0.3.
|
|
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,38 +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),
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* createIdentityAuthzPool, buildIdentityRecordAuthorization } from '@astram/identity';
|
|
56
|
-
* const authzPool = createIdentityAuthzPool();
|
|
52
|
+
* nav lights up), and import the identity module:
|
|
53
|
+
* import { identityAuthActionKeys, identityControlPlaneKeys,
|
|
54
|
+
* IdentityFeatureModule } from '@astram/identity';
|
|
57
55
|
* imports: [
|
|
58
56
|
* ShieldModule.forRoot({ auth: true,
|
|
59
|
-
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys,
|
|
60
|
-
* recordAuthorization: buildIdentityRecordAuthorization(authzPool) }),
|
|
57
|
+
* identityActionKeys: identityAuthActionKeys, identityControlPlaneKeys }),
|
|
61
58
|
* IdentityFeatureModule.forRoot(), // mounts /identity/{companies,users,roles,…}
|
|
62
59
|
* ],
|
|
63
60
|
* `IdentityFeatureModule.forRoot()` self-wires the engine from your `APP_DB_*` env
|
|
64
|
-
* (no separate engine setup)
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
|
67
66
|
*
|
|
68
|
-
* ⛔ IF YOU REGISTER YOUR OWN OWNED OBJECT TYPES (e.g. a `Note`): pass them
|
|
69
|
-
* `
|
|
70
|
-
* into gate-5 grants
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* `.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`.
|
|
76
74
|
*/
|
|
77
75
|
@Module({
|
|
78
76
|
// Enforcement-only by default (boots with no config). For real login + the identity
|
|
79
77
|
// admin console (incl. the guided rule-builder + owner shares), use (see the OPTIONAL
|
|
80
78
|
// blocks above for the full form + the extraObjects note for your OWN owned types):
|
|
81
79
|
// imports: [
|
|
82
|
-
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys,
|
|
83
|
-
// recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]) }),
|
|
80
|
+
// ShieldModule.forRoot({ auth: true, identityActionKeys, identityControlPlaneKeys }),
|
|
84
81
|
// IdentityFeatureModule.forRoot({ /* extraObjects: [YourType] */ }),
|
|
85
82
|
// ],
|
|
86
83
|
imports: [ShieldModule.forRoot()],
|