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 +46 -1
- package/package.json +1 -1
- 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 +134 -14
- package/template/server/package.json +6 -6
- package/template/server/src/app.module.ts +5 -2
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.
|
|
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",
|
|
@@ -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)
|
|
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 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:
|
|
122
|
-
ignores: [
|
|
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.
|
|
24
|
-
"@astram/guard": "^0.
|
|
25
|
-
"@astram/guide": "^0.2.
|
|
26
|
-
"@astram/identity": "^0.
|
|
27
|
-
"@astram/migrate": "^0.3.
|
|
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
|
-
*
|
|
56
|
-
*
|
|
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,
|