@astram/guide 0.1.0 → 0.2.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/CAPABILITIES.md CHANGED
@@ -1,58 +1,187 @@
1
1
  <!--
2
- @astram/guide — CAPABILITIES.md (framework-owned, version 0.1.0)
2
+ @astram/guide — CAPABILITIES.md (framework-owned, version 0.2.0)
3
3
 
4
4
  What the Astram framework provides THIS version. Refreshed on upgrade (ADR-0025).
5
5
  Use this to answer "does the framework already do X?" before building — and, if it
6
6
  does NOT, that's a Rule-4 gap (STOP and flag in .astram/guide/gaps.md).
7
+
8
+ Matches the framework release: @astram/core 1.4 · guard 0.8 · engine 0.4 · identity 0.5 ·
9
+ frontend-core 0.5 · migrate 0.3 · sdk 0.2 · create-astram-app 0.6.
7
10
  -->
8
11
 
9
- # Astram Framework — Capabilities (v0.1.0)
12
+ # Astram Framework — Capabilities (v0.2.0)
13
+
14
+ The building blocks the framework provides. Consume these; do not reimplement them. Everything
15
+ below is wired by the two `forRoot` calls in your `app.module.ts`; you write no security code.
16
+
17
+ ## Wiring (the ONE seam, plus the identity module)
18
+
19
+ ```ts
20
+ import { ShieldModule } from '@astram/guard';
21
+ import {
22
+ IdentityFeatureModule, identityAuthActionKeys, identityControlPlaneKeys,
23
+ createIdentityAuthzPool, buildIdentityRecordAuthorization,
24
+ } from '@astram/identity';
25
+
26
+ const rootIds = ['<platform-admin user uuid>']; // from config, never from a request
27
+ const authzPool = createIdentityAuthzPool();
10
28
 
11
- The building blocks the framework provides. Consume these; do not reimplement them.
29
+ imports: [
30
+ ShieldModule.forRoot({
31
+ auth: true,
32
+ emailSender: myEmailSender, // or 'disabled' — REQUIRED outside dev/test
33
+ rootIds,
34
+ identityActionKeys: identityAuthActionKeys,
35
+ identityControlPlaneKeys,
36
+ recordAuthorization: buildIdentityRecordAuthorization(authzPool, [/* YourType */]),
37
+ }),
38
+ IdentityFeatureModule.forRoot({ rootIds, extraObjects: [/* YourType */] }),
39
+ ],
40
+ ```
41
+
42
+ - **`ShieldModule.forRoot(options?)`** (`@astram/guard`; `forRootAsync` for DI-computed options).
43
+ Options: `auth?`, `recordAuthorization?` (or a pre-built `decisionAuthority?`, never both),
44
+ `emailSender?: EmailSender | 'disabled'`, `rootIds?`, `identityActionKeys?`,
45
+ `identityControlPlaneKeys?`, `config?`, and optional overrides `credentialResolver?`,
46
+ `profileResolver?`, `credentialWriter?`, `resetTokenStore?`, `recoveryCodeStore?`,
47
+ `profileWriter?`, `sessionSweeper?`, `auditSink?`, `mfaDeviceStore?`, `totpVerifier?`,
48
+ `sessionFactsProjector?`, `scopeSubtreeResolver?`, `membershipChecker?`. With `auth: true` every
49
+ override has a MariaDB/Redis default; leave them out unless you have a reason.
50
+ - **`IdentityFeatureModule.forRoot({ rootIds?, extraObjects? })`** (`@astram/identity`). Pass the
51
+ SAME `rootIds` as `ShieldModule`, and the SAME `extraObjects` as
52
+ `buildIdentityRecordAuthorization`. Without `rootIds` it logs a boot warning.
53
+ - `ShieldModule.forRoot()` with no options = enforcement only (no DB, no Redis, boots without
54
+ `.env`). Omitting `recordAuthorization` (and `decisionAuthority`) binds a stub that denies every
55
+ `@Classified` route.
12
56
 
13
57
  ## Enforcement (`@astram/guard`)
14
58
  - **Global deny-by-default guard** — every route is denied unless `@Public()` or
15
- `@Classified({ typeKey, action })`.
16
- - **Request authentication** — a `@Classified` route resolves the bearer access token
17
- → `Principal` → `decide()` automatically. `request.principal` is available to the
18
- handler (act as the logged-in user).
19
- - **The ONE seam** — `ShieldModule.forRoot({ auth?, credentialResolver?, decisionAuthority?, config? })`.
20
-
21
- ## Auth surface (`@astram/guard`, opt-in `auth: true`)
22
- - `POST /auth/login` `{ identityHint, password }` → `{ session, tokens }` (or an MFA challenge).
23
- - `POST /auth/mfa` · `POST /auth/refresh` · `POST /auth/logout`.
24
- - Argon2id password verification, sessions, refresh-token rotation + reuse detection,
25
- opaque access tokens bound to the principal (server-revocable; logout = immediate kill).
26
- - MFA/TOTP.
27
- - You provide: a `CredentialResolver` (reads your users) + a `DecisionAuthority` (your policy).
59
+ `@Classified({ typeKey, action })`. With `IdentityFeatureModule` wired, an unclassified route
60
+ aborts boot (`verifyGatable`).
61
+ - **One decision authority** — a `@Classified` route resolves the bearer token → `Principal` →
62
+ `decide()`; `request.principal` is the caller. Identity, tenant, roles and root status come from
63
+ the session, never from the request.
64
+ - **Redis-only request path** — session, cached facts and the per-tenant permission version are
65
+ read from Redis; no DB query on a request with fresh facts. Redis unavailable ⇒ deny (fail
66
+ closed), bounded by `REDIS_COMMAND_TIMEOUT_MS` (default 2000).
67
+ - **Live roles** — a role posting change, a grant change or a user disable bumps the permission
68
+ version; the next request reloads facts and roles. No re-login needed.
69
+ - **Engine errors → HTTP** — `AuthorizationDeniedError` → 403, `ValidationError` → 400,
70
+ `EngineUnavailableError` → 503 for your controllers too (global filter; id-free messages).
71
+
72
+ ## Authentication and sessions (`@astram/guard`, `auth: true`)
73
+ - `POST /auth/login` `{ identityHint, password }` → `{ session, tokens }`, or an MFA challenge.
74
+ `POST /auth/mfa` (TOTP or recovery code) · `POST /auth/refresh` · `POST /auth/logout` ·
75
+ `GET /auth/me` · `POST /auth/scope-switch` · `POST /auth/forgot-password` ·
76
+ `POST /auth/reset-password`.
77
+ - `/account/*`: change password, edit profile, MFA devices (enrol, confirm, rename, delete),
78
+ regenerate recovery codes. A recovery-code login is restricted until a new device is enrolled.
79
+ - Argon2id passwords; opaque access tokens bound in Redis (server-revocable, logout is immediate);
80
+ refresh-token rotation with reuse detection (a replay revokes the whole lineage and its live
81
+ access tokens). An access token lives at most the access TTL (15 min default).
82
+ - **MFA enforced**: any user with a confirmed TOTP device must pass `/auth/mfa` on every password
83
+ login. 5 wrong codes / 15 min per account; a used code cannot be replayed (90 s); the pending
84
+ challenge lives in Redis (works across instances, never holds the password).
85
+ - **Lockout and rate limits**: per-account login and forgot-password budgets (keyed on the
86
+ resolved account, so spelling variants share one counter); per-IP limits for login,
87
+ forgot-password and reset-password, active only when Express `trust proxy` is set explicitly
88
+ (`TRUST_PROXY` in the scaffold). Rate limiters fail open on a Redis error by design; the request
89
+ path and the TOTP replay guard fail closed.
90
+ - **Disabled accounts** cannot log in or refresh; disabling a user revokes their sessions and
91
+ drops their roles at once.
92
+ - **Silent recovery**: after a Redis loss, the next refresh rebuilds the session from MariaDB
93
+ (no forced re-login); a restricted (recovery-code) session rebuilds restricted.
94
+ - **Email**: reset links go through your `EmailSender` (`send({ to, subject, html, text })`).
95
+ Outside `NODE_ENV=development|test`, `auth: true` without `emailSender` fails boot;
96
+ `'disabled'` sends nothing and `/auth/forgot-password` still answers 204.
97
+ - **Service-account API keys** (`Authorization: Bearer ask_…`) authenticate agents and machine
98
+ clients through the same `decide()` path; scope, status and roles are read live; issuing a key
99
+ needs an explicit expiry (`expiresAtMs`) or `neverExpires: true`.
100
+ - Override contracts: the login lockout is keyed on the account the injected `credentialResolver`
101
+ returns for the hint as typed, so use that one resolver wherever login resolves accounts; a
102
+ custom `totpVerifier` must use a window of at most ±1 step and must not normalise the code, or
103
+ the 90 s replay claim no longer covers it.
104
+
105
+ ## Identity and tenancy (`@astram/identity`)
106
+ - Admin API under `/identity/*`: companies (create, update name/status, move via
107
+ `PUT /identity/companies/:uuid/parent`, delete), users, roles, memberships and role postings,
108
+ access (role ↔ permission grants and access bundles), permission catalog, record policies
109
+ (guided rule builder, 14 operators, no raw expressions), record shares (owner shares),
110
+ cross-tenant grants (offer, accept, revoke), service accounts (roles, scope, keys), audit read.
111
+ `GET /identity/auth-facts` feeds the frontend's permission-gated navigation.
112
+ - **Scoped-hierarchy tenancy** (ADR-0027): a tenant is a company node; an admin's authority covers
113
+ its subtree. Reads, updates, deletes AND creates of subtree-scoped types are walled in the query
114
+ (ADR-0030, ADR-0034). Non-root admins cannot create top-level tenants.
115
+ - **No escalation**: you can grant, revoke or assign (via postings) only roles and keys you hold
116
+ (ADR-0029); a role used outside your subtree is root-only; changing another user's email or
117
+ status needs dominance over that user. Grant changes are audited in the same transaction.
118
+ - **Cross-tenant access** only through an explicit, consented, scoped, revocable grant (ADR-0009).
119
+ - **Root** = the `rootIds` allowlist only. Root administers the control plane and cannot read
120
+ customer business data.
121
+ - Migrations for `shield_identity`, `shield_authz`, `shield_app` identity tables ship in
122
+ `@astram/identity` and `@astram/guard` and run through `@astram/migrate` automatically. Dev seeds
123
+ (099/100) run only with `NODE_ENV=development` or `ASTRAM_SEED_DEV=1`.
28
124
 
29
125
  ## The record pipe (`@astram/engine`)
30
- - **`RecordEngine`** (token `RECORD_ENGINE`) — create/read/update/delete/list over
31
- registered tables, through one unbypassable pipe: permission gate (`decide()`) →
32
- server-trusted write-context (anti-forgery) → validate → write (real table) →
33
- junction sync → audit → event.
34
- - **Cross-tenant isolation** — a `tenantId`-trusted field auto-scopes reads AND writes
35
- to the actor's tenant.
36
- - **Physical wall** — the raw DB pool is sealed in the engine; feature scope never
37
- receives it. Off-road DB access fails the build (the app's `eslint.config.js`).
38
- - **Gap signal** — an unsupported field type / operation throws
39
- `CapabilityNotSupportedError` (a Rule-4 STOP).
40
- - Field types supported this version: `string`, `number`, `boolean`, `uuid`,
41
- `timestamp`, `json`. (Anything else → flag a gap.)
126
+ - **`RecordEngine`** (token `RECORD_ENGINE`) / `RecordsService` — create/get/update/delete/list
127
+ over registered object types through one pipe: permission gate (`decide()`) → server-trusted
128
+ write context → validate → write → junction sync → audit (mandatory) → event.
129
+ - **Record decision inputs**: `get`, `update` and `delete` read the stored row first and pass its
130
+ fields and tenant to `decide()`, so record rules such as "deny update where status eq locked"
131
+ hold on single-record paths. An unreachable id (missing, other tenant, outside your subtree) is
132
+ a 403, identical for missing and foreign.
133
+ - **List scoping in the query** (ADR-0028): `list()` returns exactly the rows `decide()` allows
134
+ (record rules, owner shares, cross-tenant grants, subtree), filtered in SQL; `get()` uses the
135
+ same scope. Membership/subtree-scoped lists ("companies I belong to") are provided.
136
+ - Stable paging: every `list()` orders by the primary key last.
137
+ - `transaction(fn)` runs several pipe calls in one unit of work (rolls back on any failed write);
138
+ `lockForUpdate(typeKey, id)` serialises a check-then-write.
139
+ - `@Field({ immutable: true })` create-only fields; `trusted` fields are stamped server-side.
140
+ `readScope` declares the subtree wall; `create: 'unscoped'` opts a type out of the create wall.
141
+ - **Physical wall** — the raw DB pool is sealed in the engine. The scaffold's
142
+ `server/eslint.config.mjs` fails lint on driver imports, raw pools, `.query()` and the
143
+ framework's internal DB exports anywhere in `src/**` except `app.module.ts` and `main.ts`. A lint
144
+ rule is a backstop, not a sandbox: do not go around it.
145
+ - **Gap signal** — an unsupported field type or operation throws `CapabilityNotSupportedError`
146
+ (a Rule-4 STOP). Field types this version: `string`, `number`, `boolean`, `uuid`, `timestamp`,
147
+ `json`.
42
148
 
43
149
  ## Migrations (`@astram/migrate`)
44
- - Deploy-time code-table migration runner: timestamped `up()`/`down()` files, a
45
- `schema_migrations` table, `migrate` / `migrate:revert` / `migrate:status`. Raw SQL
46
- through a DDL-capable deploy account (the running app never holds DDL).
150
+ - `migrate` / `migrate:revert` / `migrate:status` with the deploy-time DDL account (`DDL_DB_*`);
151
+ the running app never holds DDL. Framework migrations merge with yours (`APP_MIGRATION_DIR`).
152
+ - Numeric ordering, duplicate-id detection, a database lock so concurrent deploys never apply a
153
+ migration twice (`APP_MIGRATION_LOCK_TIMEOUT`, default 120 s), and drift detection (a changed
154
+ applied file is reported; `--strict` fails on it). Never edit a migration that already ran.
155
+
156
+ ## Frontend (`@astram/frontend-core`, `@astram/sdk`, scaffolded `frontend/`)
157
+ - `@astram/frontend-core`: auth, account, identity, audit, record-policy, share, cross-tenant and
158
+ service-account services over an injected `HttpClient`; `createRefreshingHttp` refreshes an
159
+ expired access token transparently (single-flight, cross-tab lock, one retry; an outage keeps
160
+ the session). Clients that call `/auth/refresh` concurrently without single-flight can log the
161
+ user out (reuse detection).
162
+ - `@astram/sdk`: platform-agnostic auth-fact projection (`check` → allowed/deny/unavailable,
163
+ version-staleness refetch, retry/backoff). The UI never re-decides.
164
+ - The scaffolded frontend (`create-astram-app --with-frontend` or `npx -p create-astram-app
165
+ astram-add-frontend`) ships login, MFA, recovery, forgot/reset, account and admin pages, with a
166
+ strict Content-Security-Policy.
47
167
 
48
- ## Frontend SDK (`@astram/sdk`)
49
- - Platform-agnostic fact projection for building UIs against the framework.
168
+ ## Available, but NOT wired to a data path (do not assume they protect data yet)
169
+ - **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
170
+ engine exists, but no record field is encrypted and no production `DekStore` ships.
171
+ - **Secrets provider** (`SecretsProvider`, `resolveSecretRefs`, `EncryptedFileSecretsProvider`):
172
+ the guard's runtime config does not resolve `secret-ref:` values at boot.
173
+ - **Risk engine** (`createRiskEngine`): advisory scoring with no request-path caller.
174
+ - **Hash-chained audit** (`createAuditRecorder`, `verifyChain`): the shipped audit writer is
175
+ append-only but does not store the chain.
50
176
 
51
- ## NOT provided yet (as of v0.1.0) — these are Rule-4 gaps if you need them
177
+ ## NOT provided yet — these are Rule-4 gaps if you need them
52
178
  - Runtime (no-code) object definition / admin-defined objects + runtime DDL.
53
179
  - Formula / lookup / rollup computed fields (the pipe's compute stage is a no-op).
54
180
  - Business-rule events (the pipe's emit-event stage is a no-op).
55
181
  - Extension-field sidecar (runtime custom fields on a registered table).
56
- - Membership-scoped list filtering (e.g. "companies I belong to").
182
+ - Per-principal column masking on reads (row-level scoping is provided; column-level is not).
183
+ - External identity providers (OIDC, SAML, LDAP, Entra ID, Cognito, Okta, Google Workspace) and
184
+ WebAuthn ceremonies.
185
+ - Tenant- or role-mandated MFA (MFA is enforced for users who enrolled a device).
57
186
 
58
187
  If you need one of these, STOP and flag it in `.astram/guide/gaps.md` (Rule 4).
@@ -1,5 +1,5 @@
1
1
  <!--
2
- @astram/guide — FRAMEWORK_RULES.md (framework-owned, version 0.1.0)
2
+ @astram/guide — FRAMEWORK_RULES.md (framework-owned, version 0.2.0)
3
3
 
4
4
  This file is the FRAMEWORK layer of the two-layer AI guide (ADR-0025). It is
5
5
  installed in node_modules and REPLACED WHOLESALE on `npm update` — never edit it
@@ -30,10 +30,12 @@ the current rules, not a stale memory of them. Your project's own rules in
30
30
  ## 1. NEVER edit or bypass the framework
31
31
 
32
32
  The framework packages (`@astram/core`, `@astram/guard`, `@astram/engine`,
33
- `@astram/migrate`, `@astram/sdk`, `@astram/guide`) live in `node_modules` and are
34
- **immutable**. Do not edit them, patch them, monkey-patch them, or reach around their
35
- public API. You register your app through the ONE seam — `ShieldModule.forRoot(...)` —
36
- and consume the exported building blocks. That is the entire contract.
33
+ `@astram/identity`, `@astram/migrate`, `@astram/frontend-core`, `@astram/sdk`,
34
+ `@astram/guide`) live in `node_modules` and are **immutable**. Do not edit them, patch
35
+ them, monkey-patch them, or reach around their public API. You register your app through
36
+ the ONE seam — `ShieldModule.forRoot(...)`, plus `IdentityFeatureModule.forRoot(...)` for
37
+ the identity surface — and consume the exported building blocks. That is the entire
38
+ contract.
37
39
 
38
40
  - **`@astram/core` is especially sacred.** It is the pure security engine. It has no
39
41
  reason to be imported directly by feature code and must never be modified.
@@ -48,9 +50,10 @@ the permission gate, validation, audit, and cross-tenant isolation on every writ
48
50
  by construction.
49
51
 
50
52
  This is enforced two ways: the raw DB pool is **sealed inside the engine module** (you
51
- cannot inject what you are not given), and the app's `eslint.config.js` **fails the
52
- build** on any off-road DB access under `src/features/**`. If you find yourself
53
- wanting a raw query, see Rule 4.
53
+ cannot inject what you are not given), and the app's `server/eslint.config.mjs` **fails
54
+ lint** on off-road DB access anywhere in `src/**` (only `app.module.ts` and `main.ts` may
55
+ wire infrastructure). The lint wall is a backstop, not a sandbox: code that slips past it
56
+ is still a Rule 2 violation. If you find yourself wanting a raw query, see Rule 4.
54
57
 
55
58
  ## 3. Classify every route (deny-by-default)
56
59
 
@@ -60,9 +63,10 @@ Every HTTP route is DENIED by the global enforcement guard unless you classify i
60
63
  request's bearer token → `Principal` → `decide()` and allows only on `allow`. No
61
64
  token → 401; denied → 403.
62
65
 
63
- An unclassified route is a 403, by design — a forgotten declaration locks the door
64
- instead of silently exposing it. Never make a route public to "make it work"; classify
65
- it correctly.
66
+ An unclassified route is a 403, by design, and once `IdentityFeatureModule` is wired the
67
+ app refuses to boot with one — a forgotten declaration locks the door instead of
68
+ silently exposing it. Never make a route public to "make it work"; classify it
69
+ correctly.
66
70
 
67
71
  ## 4. When the framework can't do it: STOP and FLAG the gap — do NOT work around it
68
72
 
@@ -87,17 +91,21 @@ signal, not something to catch-and-work-around.
87
91
  Resolve internal ids to human-readable labels before they reach a UI or a response a
88
92
  user sees. Never put a password, hash, token, or secret in a response body or a log.
89
93
 
90
- ## 6. Auth is the framework's; you provide only DATA + POLICY
91
-
92
- You do not write login/session/token code — the framework owns it (`ShieldModule
93
- .forRoot({ auth: true })` gives you `POST /auth/{login,mfa,refresh,logout}` for free).
94
- Your app contributes exactly two things through the seam:
95
- - a **`CredentialResolver`** — reads YOUR user/identity tables to answer "what's this
96
- user's password hash, tenant, roles?",
97
- - a **`DecisionAuthority`** — your allow/deny policy.
98
-
99
- Never reimplement session handling, token verification, or password hashing in app
100
- code. If the auth surface lacks something you need, that's Rule 4 (flag the gap).
94
+ ## 6. Auth and identity are the framework's; you provide only CONFIG + DATA
95
+
96
+ You do not write login/session/token/MFA code, user or role administration, or an
97
+ authorization policy engine — the framework owns all of it. `ShieldModule.forRoot({ auth:
98
+ true, recordAuthorization, … })` gives you `/auth/*` and `/account/*`, and
99
+ `IdentityFeatureModule.forRoot(...)` gives you `/identity/*` (companies, users, roles,
100
+ access, record policies, shares, cross-tenant grants, service accounts, audit). Your app
101
+ contributes configuration through the seam (`rootIds` from config, an `emailSender`, your
102
+ own object types as `extraObjects`) and its data; access policy is authored by
103
+ administrators as role grants and record policies, not in code. See `CAPABILITIES.md`.
104
+
105
+ Never reimplement session handling, token verification, password hashing, or an
106
+ allow/deny check in app code. Never take a tenant, company, role or user id for an access
107
+ decision from the request body — the framework takes them from the session. If the auth
108
+ or identity surface lacks something you need, that's Rule 4 (flag the gap).
101
109
 
102
110
  ## 7. Fail closed, always
103
111
 
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Astram
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guide",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "description": "Astram framework — the AI/dev GUIDE layer (ADR-0025). Ships the framework's hard rules + capabilities manifest as installed, upgrade-refreshed docs. A consumer app's CLAUDE.md references FRAMEWORK_RULES.md from this package; `npm update` replaces it wholesale so new rules arrive cleanly, while the app's own .astram/guide/ layer is never touched. This is the framework-owned FLOOR: an app rule may tighten it, never weaken it.",
6
6
  "type": "commonjs",
@@ -20,4 +20,4 @@
20
20
  "publishConfig": {
21
21
  "access": "public"
22
22
  }
23
- }
23
+ }