create-astram-app 0.1.0 → 0.1.1
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 +47 -14
- package/dist/index.js +167 -32
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/template/README.md +89 -18
- package/template/_astram/guide/README.md +32 -0
- package/template/_env.example +33 -0
- package/template/_gitignore +5 -0
- package/template/package.json +37 -0
- package/template/src/app.module.ts +45 -0
- package/template/src/features/_gitkeep +9 -0
- package/template/src/health/demo.controller.ts +28 -0
- package/template/src/health/health.controller.ts +43 -0
- package/template/src/main.ts +25 -0
- package/template/test/enforcement.e2e.spec.ts +56 -0
- package/template/tsconfig.build.json +9 -0
- package/template/tsconfig.json +39 -0
- package/template/vitest.config.ts +32 -0
package/README.md
CHANGED
|
@@ -1,28 +1,61 @@
|
|
|
1
1
|
# create-astram-app
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The Astram app scaffolder (ADR-0023 §3). Emits a **new consumer app** that depends
|
|
4
|
+
on the published, immutable framework (`@astram/core`, `@astram/guard`,
|
|
5
|
+
`@astram/sdk`) and enforces **deny-by-default** from the first boot.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Use it
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npx create-astram-app my-app
|
|
11
|
+
cd my-app
|
|
12
|
+
npm install
|
|
13
|
+
npm run dev # boots the app; GET /health → 200, unclassified route → 403
|
|
11
14
|
```
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
the published framework package) — generated from this package's
|
|
15
|
-
`template/` — whose `package.json` depends on the published `@astram/core` +
|
|
16
|
-
`@astram/guard`. The consumer builds features in the scaffolded `src/`, declaring
|
|
17
|
-
Astram through the ONE seam `ShieldModule.forRoot(...)`. Framework upgrades flow
|
|
18
|
-
through the dependency (`npm update`), never by re-cloning the skeleton.
|
|
16
|
+
## What it emits
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
A running, framework-**enforced** NestJS app (the minimal, clean version of
|
|
19
|
+
[`examples/example-backend/`](../examples/example-backend/)):
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
```
|
|
22
|
+
my-app/
|
|
23
|
+
├── package.json depends on @astram/core ^0.1.0, @astram/guard ^0.1.1,
|
|
24
|
+
│ @astram/sdk ^0.1.0 (published ranges — NEVER workspace:*),
|
|
25
|
+
│ plus the NestJS runtime. "private": true.
|
|
26
|
+
├── tsconfig.json + tsconfig.build.json
|
|
27
|
+
├── vitest.config.ts
|
|
28
|
+
├── src/
|
|
29
|
+
│ ├── main.ts bootstraps Nest; loadAppConfig from @astram/guard
|
|
30
|
+
│ ├── app.module.ts imports ShieldModule.forRoot() — the ONE seam
|
|
31
|
+
│ ├── health/ @Public health probes + a demo denied route (enforcement proof)
|
|
32
|
+
│ └── features/ EMPTY — the developer's editable space
|
|
33
|
+
├── test/ enforcement e2e (200 health + 403 unclassified)
|
|
34
|
+
├── .astram/guide/ DEVELOPER-OWNED guide layer (ADR-0025) — upgrade-safe, never touched
|
|
35
|
+
├── .env.example config references only (safe defaults; boots with no .env)
|
|
36
|
+
├── .gitignore
|
|
37
|
+
└── README.md how to run + how to add features the framework way
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Guarantees
|
|
41
|
+
|
|
42
|
+
- **Immutable-core consumption** — the emitted `package.json` uses published semver
|
|
43
|
+
ranges, never `workspace:*` and never a source symlink. The framework upgrades via
|
|
44
|
+
`npm update`; the app's `src/` is never touched.
|
|
45
|
+
- **Never clobbers** — scaffolding into an existing directory only writes files that
|
|
46
|
+
do NOT already exist. A developer-authored `docs/`, `.astram/guide/`, `README`, or
|
|
47
|
+
any other file is left exactly as-is (and reported as skipped). astram3's program
|
|
48
|
+
docs survive scaffolding.
|
|
49
|
+
- **Runs immediately** — `npm install && npm run dev` boots a framework-enforced app
|
|
50
|
+
with no further wiring. (A turnkey login/session boilerplate arrives in a later
|
|
51
|
+
framework release; today the app gives correct-by-default enforcement.)
|
|
52
|
+
- **Honest** — the CLI never prints "not implemented"; it scaffolds a real app and
|
|
53
|
+
tells you how to run it and add a feature.
|
|
54
|
+
|
|
55
|
+
## Commands (for developing this package)
|
|
24
56
|
|
|
25
57
|
```bash
|
|
26
|
-
pnpm --filter create-astram-app build # tsc → dist/index.js (the CLI
|
|
58
|
+
pnpm --filter create-astram-app build # tsc → dist/index.js (the CLI)
|
|
59
|
+
pnpm --filter create-astram-app test # vitest — CLI + never-clobber + emitted-shape
|
|
27
60
|
pnpm --filter create-astram-app lint
|
|
28
61
|
```
|
package/dist/index.js
CHANGED
|
@@ -1,46 +1,181 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* `create-astram-app` — the
|
|
3
|
+
* `create-astram-app` — the Astram app scaffolder (ADR-0023 §3).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* │ STUB — NOT IMPLEMENTED IN THIS LAYER. │
|
|
7
|
-
* │ This is a deliberate placeholder (Rule of Three, ADR-0023 §7): the │
|
|
8
|
-
* │ scaffolder is not built before it is needed. A later layer replaces the │
|
|
9
|
-
* │ body below with the real copy-from-template + install flow. │
|
|
10
|
-
* └─────────────────────────────────────────────────────────────────────────┘
|
|
5
|
+
* npx create-astram-app <name>
|
|
11
6
|
*
|
|
12
|
-
*
|
|
7
|
+
* Emits a NEW consumer app directory that depends on the published, immutable
|
|
8
|
+
* framework packages (`@astram/core`, `@astram/guard`, `@astram/sdk`) via real
|
|
9
|
+
* semver ranges — never `workspace:*`, never a source symlink. The developer
|
|
10
|
+
* builds features in the emitted `src/features/`; the framework lives read-only
|
|
11
|
+
* in `node_modules` and upgrades through `npm update`.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
13
|
+
* What the emitted app IS today (honest scope — Phase 0a):
|
|
14
|
+
* A running, framework-ENFORCED NestJS app. `ShieldModule.forRoot()` is the ONE
|
|
15
|
+
* registration seam: it wires the global deny-by-default guard (ADR-0002), so
|
|
16
|
+
* every route is denied unless explicitly classified (`@Public()`/`@Classified()`).
|
|
17
|
+
* Health probes + a demo route prove enforcement from the consumer's side.
|
|
18
|
+
* A turnkey login/session boilerplate arrives in a later framework release — this
|
|
19
|
+
* scaffold does NOT emit a fake login route.
|
|
15
20
|
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
* Reference skeleton: `examples/example-backend/` in the astram-framework repo.
|
|
22
|
+
*/
|
|
23
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
/** Token substituted with the app name at copy time (in package.json name, README title, …). */
|
|
27
|
+
const APP_NAME_TOKEN = '__APP_NAME__';
|
|
28
|
+
/** Directory (inside the published package) that holds the emitted app template. */
|
|
29
|
+
function templateDir() {
|
|
30
|
+
// dist/index.js → ../template (bundled via the package's `files` field).
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
return resolve(here, '..', 'template');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate a requested app name: non-empty, safe as a single directory segment,
|
|
36
|
+
* no path traversal / separators / leading dot. Returns an error string or null.
|
|
26
37
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
export function validateAppName(name) {
|
|
39
|
+
if (!name || name.trim().length === 0) {
|
|
40
|
+
return 'an app name is required';
|
|
41
|
+
}
|
|
42
|
+
const trimmed = name.trim();
|
|
43
|
+
if (trimmed !== name) {
|
|
44
|
+
return `app name must not have leading/trailing whitespace: "${name}"`;
|
|
45
|
+
}
|
|
46
|
+
if (trimmed === '.' || trimmed === '..') {
|
|
47
|
+
return `app name must not be "." or ".."`;
|
|
48
|
+
}
|
|
49
|
+
if (/[/\\]/.test(trimmed)) {
|
|
50
|
+
return `app name must be a single directory name (no "/" or "\\"): "${name}"`;
|
|
51
|
+
}
|
|
52
|
+
if (trimmed.startsWith('.')) {
|
|
53
|
+
return `app name must not start with a dot: "${name}"`;
|
|
54
|
+
}
|
|
55
|
+
// Conservative, npm-friendly directory segment.
|
|
56
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
|
|
57
|
+
return `app name may only contain letters, numbers, ".", "_", and "-": "${name}"`;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const USAGE = [
|
|
62
|
+
'Usage: create-astram-app <name>',
|
|
29
63
|
'',
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'Shield via ShieldModule.forRoot(...).',
|
|
64
|
+
'Scaffolds a new Astram consumer app that depends on the published framework',
|
|
65
|
+
'(@astram/core, @astram/guard, @astram/sdk) and enforces deny-by-default from',
|
|
66
|
+
'the first boot.',
|
|
34
67
|
'',
|
|
35
|
-
'
|
|
68
|
+
'Example:',
|
|
69
|
+
' npx create-astram-app my-app',
|
|
36
70
|
].join('\n');
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Recursively copy `srcDir` → `destDir`, substituting {@link APP_NAME_TOKEN} in
|
|
73
|
+
* text file CONTENTS, and NEVER overwriting a file that already exists at the
|
|
74
|
+
* destination (spec §3.1 — never clobber developer-authored files). Directories
|
|
75
|
+
* are created as needed; only missing files are written.
|
|
76
|
+
*
|
|
77
|
+
* `relBase` accumulates the path relative to the template root for reporting.
|
|
78
|
+
*/
|
|
79
|
+
function copyTemplate(srcDir, destDir, appName, report, relBase = '') {
|
|
80
|
+
mkdirSync(destDir, { recursive: true });
|
|
81
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
82
|
+
const srcPath = join(srcDir, entry.name);
|
|
83
|
+
// Template files that must land under a leading-dot name are stored WITHOUT
|
|
84
|
+
// the dot so npm/pnpm ship them (npm strips a top-level `.gitignore`, and a
|
|
85
|
+
// dotfile inside `files` can be filtered). `_gitignore` → `.gitignore`, etc.
|
|
86
|
+
const destName = entry.name.startsWith('_') ? `.${entry.name.slice(1)}` : entry.name;
|
|
87
|
+
const destPath = join(destDir, destName);
|
|
88
|
+
const rel = relBase ? `${relBase}/${destName}` : destName;
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
copyTemplate(srcPath, destPath, appName, report, rel);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (existsSync(destPath)) {
|
|
94
|
+
// Never-clobber: leave the developer's file exactly as-is.
|
|
95
|
+
report.skipped.push(rel);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const raw = readFileSync(srcPath);
|
|
99
|
+
if (isTextFile(entry.name)) {
|
|
100
|
+
const substituted = raw.toString('utf8').split(APP_NAME_TOKEN).join(appName);
|
|
101
|
+
writeFileSync(destPath, substituted, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
cpSync(srcPath, destPath);
|
|
105
|
+
}
|
|
106
|
+
report.written.push(rel);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Text extensions that get token substitution + utf8 handling. */
|
|
110
|
+
function isTextFile(fileName) {
|
|
111
|
+
return /\.(json|ts|js|mjs|cjs|md|txt|example|yml|yaml|gitignore)$/i.test(fileName) || fileName.startsWith('_');
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Scaffold `appName` into `<cwd>/<appName>`. Returns the copy report + resolved
|
|
115
|
+
* target dir. Exported for tests (which pass an explicit `cwd`).
|
|
116
|
+
*/
|
|
117
|
+
export function scaffold(appName, cwd = process.cwd()) {
|
|
118
|
+
const targetDir = resolve(cwd, appName);
|
|
119
|
+
const report = { written: [], skipped: [] };
|
|
120
|
+
if (existsSync(targetDir)) {
|
|
121
|
+
const stat = statSync(targetDir);
|
|
122
|
+
if (!stat.isDirectory()) {
|
|
123
|
+
throw new Error(`target "${appName}" exists and is not a directory`);
|
|
124
|
+
}
|
|
125
|
+
// Existing dir is allowed — copyTemplate skips any file that already exists,
|
|
126
|
+
// so developer-authored docs/, .astram/guide/, README, etc. survive untouched.
|
|
127
|
+
}
|
|
128
|
+
copyTemplate(templateDir(), targetDir, appName, report);
|
|
129
|
+
return { targetDir, report };
|
|
130
|
+
}
|
|
131
|
+
/** Entry point. Returns a process exit code (0 ok, non-zero on error). */
|
|
132
|
+
export function main(argv = process.argv.slice(2)) {
|
|
133
|
+
const name = argv[0];
|
|
134
|
+
const validationError = validateAppName(name);
|
|
135
|
+
if (validationError) {
|
|
136
|
+
console.error(`create-astram-app: ${validationError}\n`);
|
|
137
|
+
console.error(USAGE);
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
const appName = name.trim();
|
|
141
|
+
let report;
|
|
142
|
+
try {
|
|
143
|
+
({ report } = scaffold(appName));
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error(`create-astram-app: ${err.message}`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
// Honest, helpful output — no "not implemented" text.
|
|
150
|
+
console.log(`\nScaffolded a new Astram app in ./${appName}`);
|
|
151
|
+
console.log(` ${report.written.length} file(s) written.`);
|
|
152
|
+
if (report.skipped.length > 0) {
|
|
153
|
+
console.log(` ${report.skipped.length} existing file(s) left untouched (never-clobber): ${report.skipped.join(', ')}`);
|
|
154
|
+
}
|
|
155
|
+
console.log('\nWhat you got:');
|
|
156
|
+
console.log(' • A running, framework-ENFORCED NestJS app — every route is denied by');
|
|
157
|
+
console.log(' default (ADR-0002) unless you classify it with @Public()/@Classified().');
|
|
158
|
+
console.log(' • The framework installed as immutable dependencies (@astram/core,');
|
|
159
|
+
console.log(' @astram/guard, @astram/sdk) — you never edit it; upgrade via `npm update`.');
|
|
160
|
+
console.log(' • An empty src/features/ — your editable space. Add a feature by writing a');
|
|
161
|
+
console.log(' Nest module/controller there and registering it in src/app.module.ts.');
|
|
162
|
+
console.log(' • .astram/guide/ — your own, upgrade-safe project-rules space (ADR-0025).');
|
|
163
|
+
console.log('\n (A turnkey login/session boilerplate ships in a later framework release;');
|
|
164
|
+
console.log(' today the app gives you correct-by-default ENFORCEMENT out of the box.)');
|
|
165
|
+
console.log('\nNext steps:');
|
|
166
|
+
console.log(` cd ${appName}`);
|
|
167
|
+
console.log(' npm install');
|
|
168
|
+
console.log(' npm run dev # boots the app; GET /health → 200, unclassified → 403');
|
|
169
|
+
console.log('');
|
|
170
|
+
return 0;
|
|
41
171
|
}
|
|
42
|
-
//
|
|
43
|
-
|
|
172
|
+
// Run ONLY when this module is the process entry point (i.e. executed as the
|
|
173
|
+
// CLI), NOT when imported by a test. Keyed on the actual entry script rather
|
|
174
|
+
// than NODE_ENV, so a consumer whose shell happens to export NODE_ENV=test
|
|
175
|
+
// still gets a working `npx create-astram-app` (avoids a silent no-op).
|
|
176
|
+
const invokedAsCli = process.argv[1] !== undefined &&
|
|
177
|
+
import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
178
|
+
if (invokedAsCli) {
|
|
44
179
|
process.exitCode = main();
|
|
45
180
|
}
|
|
46
181
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Scaffolder for a new Astram app — emits a consumer project
|
|
5
|
+
"description": "Scaffolder for a new Astram app — emits a running, framework-enforced consumer project (empty src/features/ + deny-by-default enforcement) that installs the immutable @astram/core, @astram/guard, and @astram/sdk framework.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"create-astram-app": "dist/index.js"
|
|
@@ -14,14 +14,14 @@
|
|
|
14
14
|
"dist",
|
|
15
15
|
"template"
|
|
16
16
|
],
|
|
17
|
-
"scripts": {
|
|
18
|
-
"build": "tsc -p tsconfig.json",
|
|
19
|
-
"lint": "eslint \"src/**/*.ts\"",
|
|
20
|
-
"test": "vitest run --passWithNoTests"
|
|
21
|
-
},
|
|
22
17
|
"devDependencies": {
|
|
23
18
|
"@types/node": "^22.0.0",
|
|
24
19
|
"typescript": "^5.7.0",
|
|
25
20
|
"vitest": "^2.1.8"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"lint": "eslint \"src/**/*.ts\"",
|
|
25
|
+
"test": "vitest run --passWithNoTests"
|
|
26
26
|
}
|
|
27
|
-
}
|
|
27
|
+
}
|
package/template/README.md
CHANGED
|
@@ -1,18 +1,89 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
1
|
+
# __APP_NAME__
|
|
2
|
+
|
|
3
|
+
An **Astram consumer app**, scaffolded with `create-astram-app`. It depends on the
|
|
4
|
+
published, immutable framework packages ([`@astram/core`](https://www.npmjs.com/package/@astram/core),
|
|
5
|
+
[`@astram/guard`](https://www.npmjs.com/package/@astram/guard),
|
|
6
|
+
[`@astram/sdk`](https://www.npmjs.com/package/@astram/sdk)) and enforces
|
|
7
|
+
**deny-by-default** from the very first boot. You build features; the framework
|
|
8
|
+
lives read-only in `node_modules` and upgrades via `npm update` — you never edit it.
|
|
9
|
+
|
|
10
|
+
## Run it
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install
|
|
14
|
+
npm run dev # boots the app (tsx watch) on http://localhost:3000
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
curl -i localhost:3000/health # 200 — @Public health probe
|
|
21
|
+
curl -i localhost:3000/demo/public # 200 — explicit @Public() opt-out
|
|
22
|
+
curl -i localhost:3000/demo/unclassified # 403 — DENIED (no classification)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
That last 403 is the whole point: **every route is denied unless you explicitly
|
|
26
|
+
classify it.** Forgetting to declare a route locks the door instead of silently
|
|
27
|
+
exposing it.
|
|
28
|
+
|
|
29
|
+
Other scripts: `npm run build` (→ `dist/`), `npm start` (run the build),
|
|
30
|
+
`npm test` (the enforcement e2e), `npm run lint`.
|
|
31
|
+
|
|
32
|
+
## How it wires the framework
|
|
33
|
+
|
|
34
|
+
`src/app.module.ts` imports the ONE registration seam — no local security wiring:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { ShieldModule } from '@astram/guard';
|
|
38
|
+
|
|
39
|
+
@Module({
|
|
40
|
+
imports: [ShieldModule.forRoot()], // framework owns enforcement
|
|
41
|
+
controllers: [HealthController, DemoController],
|
|
42
|
+
})
|
|
43
|
+
export class AppModule {}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`ShieldModule.forRoot()` binds the global deny-by-default guard (ADR-0002), the
|
|
47
|
+
boot-pool assertion (ADR-0018), and the Core decision-authority token. You never
|
|
48
|
+
re-implement enforcement — you register it once and add your controllers.
|
|
49
|
+
|
|
50
|
+
## Add a feature (the framework way)
|
|
51
|
+
|
|
52
|
+
1. Create `src/features/<your-feature>/` with a Nest module + controller(s).
|
|
53
|
+
2. Register it in `src/app.module.ts` (add the module to `imports`, or its
|
|
54
|
+
controller to `controllers`).
|
|
55
|
+
3. **Classify every route:** `@Public()` for open routes, `@Classified(...)` for
|
|
56
|
+
guarded ones. Anything unclassified is denied (403) by design.
|
|
57
|
+
4. Add a test (see `test/enforcement.e2e.spec.ts` for the pattern).
|
|
58
|
+
|
|
59
|
+
`src/features/` starts empty — it's your space.
|
|
60
|
+
|
|
61
|
+
## What's here today (and what's next)
|
|
62
|
+
|
|
63
|
+
Today you get correct-by-default **enforcement** out of the box: the framework's
|
|
64
|
+
guard protects every route from the first boot. A turnkey **login / session**
|
|
65
|
+
boilerplate (wired through the same `ShieldModule.forRoot()` seam) arrives in a
|
|
66
|
+
later framework release — when it lands, you'll pick it up via `npm update` without
|
|
67
|
+
touching your `src/`.
|
|
68
|
+
|
|
69
|
+
## Layout
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
src/
|
|
73
|
+
main.ts HTTP bootstrap (thin) — loadAppConfig from @astram/guard
|
|
74
|
+
app.module.ts declares enforcement via ShieldModule.forRoot()
|
|
75
|
+
health/
|
|
76
|
+
health.controller.ts /health /ready /live (all @Public)
|
|
77
|
+
demo.controller.ts /demo/public (@Public) + /demo/unclassified (denied)
|
|
78
|
+
features/ EMPTY — your editable space
|
|
79
|
+
test/
|
|
80
|
+
enforcement.e2e.spec.ts boots the app; proves 200 health + 403 unclassified
|
|
81
|
+
.astram/guide/ YOUR project guide (upgrade-safe; never touched)
|
|
82
|
+
docs/ your own docs (the scaffolder never clobbers this)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## The `.astram/guide/` directory
|
|
86
|
+
|
|
87
|
+
This is your **project-owned** AI/dev guide layer (ADR-0025) — framework upgrades
|
|
88
|
+
never touch it. Put your project's own rules and context there. See
|
|
89
|
+
`.astram/guide/README.md`.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# `.astram/guide/` — your project's AI/dev guide (yours to own)
|
|
2
|
+
|
|
3
|
+
This directory is **yours**. The framework scaffolder created it once and will
|
|
4
|
+
**never touch it again** — `npm update` / framework upgrades leave everything here
|
|
5
|
+
exactly as you wrote it. It is the *developer-owned* half of Astram's two-layer
|
|
6
|
+
guide model (ADR-0025):
|
|
7
|
+
|
|
8
|
+
| Layer | Where it lives | Who owns it | On upgrade |
|
|
9
|
+
| ---------------------- | --------------------------------------- | ----------- | ----------------- |
|
|
10
|
+
| **Framework guide** | the installed `@astram/guide` package\* | framework | replaced on update |
|
|
11
|
+
| **Project guide (you)**| **this `.astram/guide/` directory** | **you** | **never touched** |
|
|
12
|
+
|
|
13
|
+
\* The framework-owned guide is delivered as an installed package (resolved in
|
|
14
|
+
`node_modules`), not copied into your repo — so upgrades can refresh it without
|
|
15
|
+
overwriting your work. It is a later framework release; today your app is simply
|
|
16
|
+
structured to consume it when it lands.
|
|
17
|
+
|
|
18
|
+
## What to put here
|
|
19
|
+
|
|
20
|
+
Your **project-specific** rules and context for AI assistants and teammates:
|
|
21
|
+
|
|
22
|
+
- Conventions and patterns specific to *this* app.
|
|
23
|
+
- Domain glossary, module map, "how we do X here" notes.
|
|
24
|
+
- Decisions and constraints an assistant should honor when generating code.
|
|
25
|
+
|
|
26
|
+
## What NOT to put here
|
|
27
|
+
|
|
28
|
+
- **Framework rules.** Those come from the framework guide package — don't copy
|
|
29
|
+
them in, or upgrades and your local edits will diverge. Keep this directory to
|
|
30
|
+
*your* project's additions only.
|
|
31
|
+
|
|
32
|
+
Add markdown files alongside this README as your project grows.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# __APP_NAME__ — environment configuration (references only, no secrets).
|
|
2
|
+
#
|
|
3
|
+
# Every value below has a safe default, so the app boots with NO .env file at all.
|
|
4
|
+
# Copy this file to `.env` and override only what you need. `.env` is gitignored.
|
|
5
|
+
#
|
|
6
|
+
# These are read by `loadAppConfig()` from the installed @astram/guard framework.
|
|
7
|
+
|
|
8
|
+
# HTTP port the app listens on (default: 3000).
|
|
9
|
+
PORT=3000
|
|
10
|
+
|
|
11
|
+
# Runtime environment: development | production | test (default: development).
|
|
12
|
+
NODE_ENV=development
|
|
13
|
+
|
|
14
|
+
# --- ADR-0018 connection-pool budget (all optional; framework defaults apply) ---
|
|
15
|
+
# Uncomment + tune only if your deployment needs to.
|
|
16
|
+
# SHIELD_POOL_WORKERS=1
|
|
17
|
+
# SHIELD_DB_MAX_CONNECTIONS=50
|
|
18
|
+
# SHIELD_POOL_HEADROOM=5
|
|
19
|
+
# SHIELD_POOL_MAX_CORE=10
|
|
20
|
+
# SHIELD_POOL_MAX_APP_CRUD=10
|
|
21
|
+
# SHIELD_POOL_MAX_SCHEMA_SVC=5
|
|
22
|
+
|
|
23
|
+
# --- OPTIONAL: real authentication (only if you set ShieldModule.forRoot({ auth: true })) ---
|
|
24
|
+
# Auth needs a live MariaDB + Redis and a refresh-token HMAC key. These are
|
|
25
|
+
# REQUIRED once auth is enabled (the app fails closed without the HMAC key) — but
|
|
26
|
+
# irrelevant (unread) while auth is off. Point them at your MariaDB/Redis.
|
|
27
|
+
# SHIELD_DB_HOST=127.0.0.1
|
|
28
|
+
# SHIELD_DB_PORT=3306
|
|
29
|
+
# SHIELD_DB_USER=shield_core
|
|
30
|
+
# SHIELD_DB_PASSWORD=change-me
|
|
31
|
+
# SHIELD_DB_DATABASE=shield_identity
|
|
32
|
+
# SHIELD_REDIS_URL=redis://127.0.0.1:6379
|
|
33
|
+
# SHIELD_REFRESH_HMAC_KEY=change-me-to-a-long-random-secret # REQUIRED when auth is on
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "An Astram consumer app. Depends on the published, immutable framework (@astram/core, @astram/guard, @astram/sdk) and declares enforcement via ShieldModule.forRoot(). Features go in src/features/.",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "dist/main.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
10
|
+
"build": "tsc -p tsconfig.build.json",
|
|
11
|
+
"start": "node dist/main.js",
|
|
12
|
+
"dev": "tsx watch src/main.ts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\""
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@astram/core": "^0.1.0",
|
|
18
|
+
"@astram/guard": "^0.1.1",
|
|
19
|
+
"@astram/sdk": "^0.1.0",
|
|
20
|
+
"@nestjs/common": "^11.0.0",
|
|
21
|
+
"@nestjs/core": "^11.0.0",
|
|
22
|
+
"@nestjs/platform-express": "^11.0.0",
|
|
23
|
+
"reflect-metadata": "^0.2.2",
|
|
24
|
+
"rxjs": "^7.8.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@nestjs/testing": "^11.0.0",
|
|
28
|
+
"@types/express": "^5.0.0",
|
|
29
|
+
"@types/node": "^22.0.0",
|
|
30
|
+
"@types/supertest": "^6.0.2",
|
|
31
|
+
"supertest": "^7.0.0",
|
|
32
|
+
"tsx": "^4.19.2",
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"unplugin-swc": "^1.5.1",
|
|
35
|
+
"vitest": "^2.1.8"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ShieldModule } from '@astram/guard';
|
|
3
|
+
import { HealthController } from './health/health.controller';
|
|
4
|
+
import { DemoController } from './health/demo.controller';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Root module — YOUR editable space.
|
|
8
|
+
*
|
|
9
|
+
* `ShieldModule.forRoot()` is the ONE registration seam into the framework. That
|
|
10
|
+
* single import wires the global deny-by-default enforcement guard (ADR-0002) via
|
|
11
|
+
* `APP_GUARD`, the boot-pool assertion (ADR-0018), and the Core decision-authority
|
|
12
|
+
* token — all owned by the framework, all read-only in `node_modules`. You never
|
|
13
|
+
* re-wire enforcement yourself; you register it once and add your controllers.
|
|
14
|
+
*
|
|
15
|
+
* TO ADD A FEATURE:
|
|
16
|
+
* 1. Create a module + controller under `src/features/<your-feature>/`.
|
|
17
|
+
* 2. Import that module here (add it to `imports`) or list its controller in
|
|
18
|
+
* `controllers`.
|
|
19
|
+
* 3. Classify every route: `@Public()` for open routes, `@Classified(...)` for
|
|
20
|
+
* guarded ones. Anything left unclassified is DENIED (403) by design.
|
|
21
|
+
*
|
|
22
|
+
* `HealthController` + `DemoController` below are starter routes that prove the
|
|
23
|
+
* framework enforces from your side (health/@Public → 200, unclassified → 403).
|
|
24
|
+
* Keep the health probes; the demo controller is illustrative — delete it once
|
|
25
|
+
* you've seen enforcement work.
|
|
26
|
+
*
|
|
27
|
+
* ── OPTIONAL: real authentication (login/MFA/refresh/logout) ──────────────────
|
|
28
|
+
* The framework ships a complete Auth surface you can turn on WITHOUT writing any
|
|
29
|
+
* auth code. It is OFF by default so this app boots with NO `.env` at all (pure
|
|
30
|
+
* enforcement). To enable it:
|
|
31
|
+
* 1. Change `ShieldModule.forRoot()` below to `ShieldModule.forRoot({ auth: true })`.
|
|
32
|
+
* 2. Provide the DB/Redis/HMAC config in `.env` (see the AUTH block in
|
|
33
|
+
* `.env.example`) — auth needs a real MariaDB + Redis and a refresh HMAC key.
|
|
34
|
+
* 3. Create a user in the `shield_identity.identity_credential` table.
|
|
35
|
+
* You then get framework-owned `POST /auth/{login,mfa,refresh,logout}` for free —
|
|
36
|
+
* no controllers to write. (Leaving `auth` off keeps the zero-config enforcement
|
|
37
|
+
* boot; turning it on requires the config above, by design — fail-closed.)
|
|
38
|
+
*/
|
|
39
|
+
@Module({
|
|
40
|
+
// Enforcement-only by default (boots with no config). For real login, use:
|
|
41
|
+
// imports: [ShieldModule.forRoot({ auth: true })],
|
|
42
|
+
imports: [ShieldModule.forRoot()],
|
|
43
|
+
controllers: [HealthController, DemoController],
|
|
44
|
+
})
|
|
45
|
+
export class AppModule {}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Your editable feature space.
|
|
2
|
+
#
|
|
3
|
+
# Add each feature under its own folder here, e.g. src/features/orders/, with a
|
|
4
|
+
# Nest module + controller(s). Register the feature in src/app.module.ts and
|
|
5
|
+
# classify every route (@Public / @Classified) — unclassified routes are denied
|
|
6
|
+
# by the framework's global guard (deny-by-default, ADR-0002).
|
|
7
|
+
#
|
|
8
|
+
# This file only keeps the empty directory in version control; delete it once you
|
|
9
|
+
# add your first feature.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { Public } from '@astram/guard';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Demonstration routes proving the deny-by-default enforcement seam (ADR-0002).
|
|
6
|
+
*
|
|
7
|
+
* - `GET /demo/public` → `@Public()` → REACHABLE (200).
|
|
8
|
+
* - `GET /demo/unclassified` → no metadata → DENIED by the global guard (403).
|
|
9
|
+
*
|
|
10
|
+
* The second handler carries NO classification decorator on purpose: it shows
|
|
11
|
+
* that forgetting to declare a route locks the door (deny-by-default) rather than
|
|
12
|
+
* silently exposing it. This controller is illustrative — delete it once you've
|
|
13
|
+
* seen enforcement work; a real feature would classify every route.
|
|
14
|
+
*/
|
|
15
|
+
@Controller('demo')
|
|
16
|
+
export class DemoController {
|
|
17
|
+
@Public()
|
|
18
|
+
@Get('public')
|
|
19
|
+
publicRoute(): { message: string } {
|
|
20
|
+
return { message: 'public route reachable — explicit @Public() opt-out' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// NOTE: intentionally NO @Public() / @Classified() — must be DENIED (403).
|
|
24
|
+
@Get('unclassified')
|
|
25
|
+
unclassifiedRoute(): { message: string } {
|
|
26
|
+
return { message: 'this body must never be reached — the guard denies first' };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { Public } from '@astram/guard';
|
|
3
|
+
|
|
4
|
+
/** Simple ok payload shape for the probe endpoints. */
|
|
5
|
+
export interface ProbePayload {
|
|
6
|
+
readonly status: 'ok';
|
|
7
|
+
readonly check: 'health' | 'ready' | 'live';
|
|
8
|
+
/** UTC epoch milliseconds (set explicitly in UTC). */
|
|
9
|
+
readonly timestampMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ok(check: ProbePayload['check']): ProbePayload {
|
|
13
|
+
return { status: 'ok', check, timestampMs: Date.now() };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Liveness / readiness / health probes.
|
|
18
|
+
*
|
|
19
|
+
* All three are explicitly `@Public()` — they must be reachable without a
|
|
20
|
+
* classification. This app returns a static ok payload; wire real readiness
|
|
21
|
+
* (DB / cache / pool) into `/ready` as your app grows.
|
|
22
|
+
*/
|
|
23
|
+
@Controller()
|
|
24
|
+
export class HealthController {
|
|
25
|
+
@Public()
|
|
26
|
+
@Get('health')
|
|
27
|
+
health(): ProbePayload {
|
|
28
|
+
return ok('health');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Public()
|
|
32
|
+
@Get('ready')
|
|
33
|
+
ready(): ProbePayload {
|
|
34
|
+
// LATER: reflect real readiness (pool / deps) here.
|
|
35
|
+
return ok('ready');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Public()
|
|
39
|
+
@Get('live')
|
|
40
|
+
live(): ProbePayload {
|
|
41
|
+
return ok('live');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { NestFactory } from '@nestjs/core';
|
|
3
|
+
import { loadAppConfig } from '@astram/guard';
|
|
4
|
+
import { AppModule } from './app.module';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HTTP bootstrap for this Astram consumer app.
|
|
8
|
+
*
|
|
9
|
+
* Thin on purpose: it builds the Nest app (whose `AppModule` declares enforcement
|
|
10
|
+
* via `ShieldModule.forRoot()`, wiring the framework's global deny-by-default
|
|
11
|
+
* guard) and starts the HTTP listener. No security logic lives here —
|
|
12
|
+
* `loadAppConfig` comes from the installed `@astram/guard` framework package and
|
|
13
|
+
* reads PORT / ENVIRONMENT from the environment (see `.env.example`).
|
|
14
|
+
*/
|
|
15
|
+
export async function bootstrap(): Promise<void> {
|
|
16
|
+
const config = loadAppConfig();
|
|
17
|
+
const app = await NestFactory.create(AppModule, { bufferLogs: false });
|
|
18
|
+
await app.listen(config.port);
|
|
19
|
+
console.log(`[__APP_NAME__] listening on :${config.port} (${config.environment})`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Only auto-start when run directly (not when imported by tests).
|
|
23
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
24
|
+
void bootstrap();
|
|
25
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { INestApplication } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
5
|
+
import { AppModule } from '../src/app.module';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* End-to-end proof of the framework's global, deny-by-default enforcement seam
|
|
9
|
+
* (ADR-0002) and the public health probes. Boots the real Nest app (with the
|
|
10
|
+
* global APP_GUARD wired by ShieldModule.forRoot()) and drives it over HTTP.
|
|
11
|
+
*
|
|
12
|
+
* This is your starter test — extend it as you add features. Every new route you
|
|
13
|
+
* write should have a test asserting it's reachable only when you meant it to be.
|
|
14
|
+
*/
|
|
15
|
+
describe('__APP_NAME__ — framework enforcement + health (e2e)', () => {
|
|
16
|
+
let app: INestApplication;
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
const moduleRef = await Test.createTestingModule({
|
|
20
|
+
imports: [AppModule],
|
|
21
|
+
}).compile();
|
|
22
|
+
app = moduleRef.createNestApplication();
|
|
23
|
+
await app.init();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(async () => {
|
|
27
|
+
await app.close();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('boots the application', () => {
|
|
31
|
+
expect(app).toBeDefined();
|
|
32
|
+
expect(app.getHttpServer()).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('GET /health returns 200 with an ok payload (@Public)', async () => {
|
|
36
|
+
const res = await request(app.getHttpServer()).get('/health');
|
|
37
|
+
expect(res.status).toBe(200);
|
|
38
|
+
expect(res.body).toMatchObject({ status: 'ok', check: 'health' });
|
|
39
|
+
expect(typeof res.body.timestampMs).toBe('number');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('GET /demo/public is REACHABLE — explicit @Public() opt-out', async () => {
|
|
43
|
+
const res = await request(app.getHttpServer()).get('/demo/public');
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
expect(res.body).toMatchObject({
|
|
46
|
+
message: expect.stringContaining('public route reachable'),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('GET /demo/unclassified is DENIED (403) — deny-by-default (ADR-0002)', async () => {
|
|
51
|
+
const res = await request(app.getHttpServer()).get('/demo/unclassified');
|
|
52
|
+
expect(res.status).toBe(403);
|
|
53
|
+
// The guard denies BEFORE the handler body runs.
|
|
54
|
+
expect(res.body.message).toContain('no access classification');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
|
|
7
|
+
// NestJS is a Node/CommonJS runtime that relies on emitted decorator metadata
|
|
8
|
+
// (reflect-metadata DI). This is the standard NestJS TS setup.
|
|
9
|
+
"module": "commonjs",
|
|
10
|
+
"moduleResolution": "node",
|
|
11
|
+
"emitDecoratorMetadata": true,
|
|
12
|
+
"experimentalDecorators": true,
|
|
13
|
+
|
|
14
|
+
"outDir": "dist",
|
|
15
|
+
"rootDir": "src",
|
|
16
|
+
"declaration": false,
|
|
17
|
+
"declarationMap": false,
|
|
18
|
+
"sourceMap": true,
|
|
19
|
+
|
|
20
|
+
"esModuleInterop": true,
|
|
21
|
+
"forceConsistentCasingInFileNames": true,
|
|
22
|
+
"resolveJsonModule": true,
|
|
23
|
+
"skipLibCheck": true,
|
|
24
|
+
|
|
25
|
+
"strict": true,
|
|
26
|
+
"noImplicitOverride": true,
|
|
27
|
+
"noUncheckedIndexedAccess": true,
|
|
28
|
+
"noFallthroughCasesInSwitch": true,
|
|
29
|
+
"exactOptionalPropertyTypes": true,
|
|
30
|
+
|
|
31
|
+
// NestJS DI injects constructor params that trivial wiring may not reference,
|
|
32
|
+
// and uses framework decorators — relax these two so DI wiring type-checks.
|
|
33
|
+
"noUnusedLocals": false,
|
|
34
|
+
"noUnusedParameters": false,
|
|
35
|
+
|
|
36
|
+
"types": ["node", "vitest/globals"]
|
|
37
|
+
},
|
|
38
|
+
"include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"]
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import swc from 'unplugin-swc';
|
|
2
|
+
import { defineConfig } from 'vitest/config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vitest config for the NestJS app.
|
|
6
|
+
*
|
|
7
|
+
* NestJS DI depends on emitted decorator metadata (`emitDecoratorMetadata`).
|
|
8
|
+
* Vitest's default esbuild transform strips decorators WITHOUT emitting that
|
|
9
|
+
* metadata, which breaks constructor injection. unplugin-swc runs SWC with
|
|
10
|
+
* `legacyDecorator` + `decoratorMetadata` enabled so the reflect-metadata model
|
|
11
|
+
* works exactly as it does at build time (tsc).
|
|
12
|
+
*/
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
plugins: [
|
|
15
|
+
swc.vite({
|
|
16
|
+
module: { type: 'es6' },
|
|
17
|
+
jsc: {
|
|
18
|
+
target: 'es2022',
|
|
19
|
+
transform: {
|
|
20
|
+
legacyDecorator: true,
|
|
21
|
+
decoratorMetadata: true,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
],
|
|
26
|
+
test: {
|
|
27
|
+
globals: true,
|
|
28
|
+
environment: 'node',
|
|
29
|
+
include: ['test/**/*.{test,spec}.ts', 'src/**/*.{test,spec}.ts'],
|
|
30
|
+
passWithNoTests: false,
|
|
31
|
+
},
|
|
32
|
+
});
|