create-astram-app 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,61 @@
1
1
  # create-astram-app
2
2
 
3
- **STUB (ADR-0023 §3)** — the Astram app scaffolder. `private`, not implemented in
4
- this layer (Rule of Three, ADR-0023 §7 — don't over-build the scaffolder before
5
- it's needed).
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
- ## Intent
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
- will emit a consumer app **skeleton** (a scaffolded app skeleton that depends on
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
- The CLI currently prints a stub notice and exits non-zero (nothing is scaffolded).
21
- The canonical shape of the emitted skeleton is [`examples/example-backend/`](../examples/example-backend/).
18
+ A running, framework-**enforced** NestJS app (the minimal, clean version of
19
+ [`examples/example-backend/`](../examples/example-backend/)):
22
20
 
23
- ## Commands
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 stub)
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,196 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * `create-astram-app` — the Shield scaffolder (ADR-0023 §3).
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
- * INTENT (what the finished tool will do):
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
- * npx create-astram-app my-app
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
- * emits the consumer's editable app SKELETON (a scaffolded app skeleton that
17
- * depends on the published framework package) — scaffolded from this package's
18
- * `template/` reference — whose `package.json` depends
19
- * on the published `@astram/core` + `@astram/guard`. The scaffolded `src/` is
20
- * where the consumer builds features; its `AppModule` declares Shield through the
21
- * ONE registration seam, `ShieldModule.forRoot(...)`. Framework upgrades flow
22
- * through the dependency (`npm update`), never by re-cloning the skeleton.
23
- *
24
- * The canonical shape of the emitted skeleton is `packages/nestjs-app/` in this
25
- * monorepo (the consumer-skeleton reference). See `template/README.md`.
21
+ * Reference skeleton: `examples/example-backend/` in the astram-framework repo.
22
+ */
23
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, 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
- const STUB_MESSAGE = [
28
- 'create-astram-app is a STUB (ADR-0023 §3) — not implemented in this layer.',
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
- 'When built, it will scaffold a consumer app skeleton (a scaffolded app',
31
- 'skeleton that depends on the published framework package) depending on',
32
- '@astram/core + @astram/guard, whose AppModule declares',
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
- 'Reference skeleton: packages/nestjs-app/ · template: this package’s template/.',
68
+ 'Example:',
69
+ ' npx create-astram-app my-app',
36
70
  ].join('\n');
37
- /** Entry point. Prints the stub notice and exits non-zero (nothing scaffolded). */
38
- export function main() {
39
- console.error(STUB_MESSAGE);
40
- return 1;
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;
171
+ }
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 exports NODE_ENV=test still gets a
175
+ // working CLI). CRITICAL: `npx`/npm run the package through a `bin` SYMLINK, so
176
+ // `process.argv[1]` is the symlink path, not this real dist file — we must
177
+ // resolve it (realpath) before comparing, or `import.meta.url` (the real file)
178
+ // never matches and the CLI silently no-ops. When imported by a test, argv[1]
179
+ // is the test runner, which resolves to a different real file → correctly skips.
180
+ function isInvokedAsCli() {
181
+ const entry = process.argv[1];
182
+ if (entry === undefined)
183
+ return false;
184
+ const thisFile = fileURLToPath(import.meta.url);
185
+ try {
186
+ return realpathSync(entry) === realpathSync(thisFile);
187
+ }
188
+ catch {
189
+ // If either path can't be realpath'd, fall back to a direct compare.
190
+ return pathToFileURL(entry).href === import.meta.url;
191
+ }
41
192
  }
42
- // Only run when invoked as a CLI (not when imported by a test).
43
- if (process.env.NODE_ENV !== 'test') {
193
+ if (isInvokedAsCli()) {
44
194
  process.exitCode = main();
45
195
  }
46
196
  //# 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;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,MAAM,YAAY,GAAG;IACnB,4EAA4E;IAC5E,EAAE;IACF,wEAAwE;IACxE,wEAAwE;IACxE,wDAAwD;IACxD,uCAAuC;IACvC,EAAE;IACF,gFAAgF;CACjF,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAEb,mFAAmF;AACnF,MAAM,UAAU,IAAI;IAClB,OAAO,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC;IAC5B,OAAO,CAAC,CAAC;AACX,CAAC;AAED,gEAAgE;AAChE,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,MAAM,EAAE,CAAC;IACpC,OAAO,CAAC,QAAQ,GAAG,IAAI,EAAE,CAAC;AAC5B,CAAC"}
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.0",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
- "description": "Scaffolder for a new Astram app — emits a consumer project skeleton (Auth boilerplate + empty src/features/) that installs the sealed @astram/core + @astram/guard framework.",
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
+ }
@@ -1,18 +1,89 @@
1
- # create-astram-app template (reference)
2
-
3
- This directory will hold the **app-skeleton template** the scaffolder copies to
4
- emit a new consumer app (ADR-0023 §3). It is a placeholder in this layer.
5
-
6
- Until the scaffolder is built, the **canonical reference for the emitted skeleton
7
- is [`examples/example-backend/`](../../examples/example-backend/)** in this monorepo — the
8
- consumer app skeleton (a scaffolded app skeleton that depends on the published
9
- framework package). A generated app follows it:
10
-
11
- - `package.json` depends on `@astram/core` + `@astram/guard` (published), plus the
12
- host NestJS runtime (`@nestjs/common`, `@nestjs/core`, `@nestjs/platform-express`,
13
- `reflect-metadata`, `rxjs`).
14
- - `src/app.module.ts` declares Astram via `ShieldModule.forRoot(...)` — the one
15
- registration seam — and adds the consumer's feature controllers.
16
- - `src/main.ts` bootstraps Nest and listens (thin).
17
- - Feature code lives in the consumer's editable `src/`; the framework is never
18
- edited (upgrades flow through `npm update`).
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,5 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.log
5
+ .DS_Store
@@ -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,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "sourceMap": false
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["test/**/*", "vitest.config.ts", "**/*.spec.ts", "**/*.test.ts"]
9
+ }
@@ -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
+ });