@astram/guide 0.2.8 → 0.2.10

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
@@ -27,7 +27,7 @@ const rootIds = rootIdsFromEnv(process.env); // SHIELD_ROOT_IDS; never fro
27
27
  imports: [
28
28
  ShieldModule.forRoot({
29
29
  auth: true,
30
- emailSender: myEmailSender, // or 'disabled' — REQUIRED outside dev/test
30
+ emailSender: 'env', // or your EmailSender, or 'disabled' — REQUIRED outside dev/test
31
31
  rootIds,
32
32
  identityActionKeys: identityAuthActionKeys,
33
33
  identityControlPlaneKeys,
@@ -40,7 +40,7 @@ imports: [
40
40
  - **`ShieldModule.forRoot(options?)`** (`@astram/guard`; `forRootAsync` for DI-computed options).
41
41
  Options: `auth?`, `recordAuthorization?` (or a pre-built `decisionAuthority?`; only for an app
42
42
  WITHOUT `IdentityFeatureModule`, never both),
43
- `emailSender?: EmailSender | 'disabled'`, `rootIds?`, `identityActionKeys?`,
43
+ `emailSender?: EmailSender | 'disabled' | 'env'`, `rootIds?`, `identityActionKeys?`,
44
44
  `identityControlPlaneKeys?`, `config?`, and optional overrides `credentialResolver?`,
45
45
  `profileResolver?`, `credentialWriter?`, `resetTokenStore?`, `recoveryCodeStore?`,
46
46
  `profileWriter?`, `sessionSweeper?`, `auditSink?`, `mfaDeviceStore?`, `totpVerifier?`,
@@ -100,15 +100,58 @@ imports: [
100
100
  - **Lockout and rate limits**: per-account login and forgot-password budgets (keyed on the
101
101
  resolved account, so spelling variants share one counter); per-IP limits for login,
102
102
  forgot-password and reset-password, active only when Express `trust proxy` is set explicitly
103
- (`TRUST_PROXY` in the scaffold). Rate limiters fail open on a Redis error by design; the request
104
- path and the TOTP replay guard fail closed.
103
+ (`TRUST_PROXY` in the scaffold). On a Redis error the login, MFA and recovery-code budgets fail
104
+ closed (503), as do the request path and the TOTP replay guard; the forgot-password and
105
+ reset-password per-IP counters fail open (logged).
105
106
  - **Disabled accounts** cannot log in or refresh; disabling a user revokes their sessions and
106
107
  drops their roles at once.
107
108
  - **Silent recovery**: after a Redis loss, the next refresh rebuilds the session from MariaDB
108
109
  (no forced re-login); a restricted (recovery-code) session rebuilds restricted.
109
- - **Email**: reset links go through your `EmailSender` (`send({ to, subject, html, text })`).
110
- Outside `NODE_ENV=development|test`, `auth: true` without `emailSender` fails boot;
111
- `'disabled'` sends nothing and `/auth/forgot-password` still answers 204.
110
+ - **Email** (G-010, ADR-0043): `emailSender: 'env'` picks the provider from configuration —
111
+ `EMAIL_PROVIDER` = `smtp` | `brevo` | `sendgrid` | `mailgun` | `disabled` (no default), plus
112
+ `EMAIL_FROM_ADDRESS`, `EMAIL_FROM_NAME` and the provider's variables (the MESSAGING block in the
113
+ template's `server/_env.example`; runbook §6). It needs the `@astram/messaging` package installed;
114
+ write no mail code. Outside `NODE_ENV=development|test` credentials must be `secret-ref:` values, so
115
+ a `SECRETS_PROVIDER` is required first; bad config fails boot naming the variable;
116
+ `MESSAGE_BOOT_PROBE=1` checks credentials at boot without sending. Your own `EmailSender`
117
+ (`send({ to, subject, html, text })`) still works. Either way every framework email (password
118
+ reset, invite, email-change confirmation, security notices) is bounded (`MESSAGE_SEND_TIMEOUT_MS`,
119
+ default 10 s, one retry on a temporary failure) and audited (`message.sent` / `message.failed`:
120
+ provider, purpose, masked recipient; never the link or body). Outside `NODE_ENV=development|test`,
121
+ `auth: true` without `emailSender` fails boot; `'disabled'` sends nothing and
122
+ `/auth/forgot-password` still answers 204. SMS and WhatsApp are ports only (`SmsProvider`,
123
+ `WhatsAppProvider` in `@astram/core`); no adapter yet, and `SMS_PROVIDER` / `WHATSAPP_PROVIDER`
124
+ accept only `disabled`.
125
+ - **Administrator invite** (G-010): `POST /identity/users/:uuid/invite` (permission
126
+ `identity:user:invite`; wildcard `identity:*` / `identity:user:*` holders have it after the upgrade)
127
+ emails a one-time set-password link to a user whose sign-in is still pending. It is tenant-walled and
128
+ dominance-checked like update; 409 for a user who already set a password or has no credential, 429
129
+ over 3 per user or `INVITE_RATE_PER_ADMIN` per admin per hour, 501 with mail disabled and no link
130
+ return. `IdentityFeatureModule.forRoot({ invite: { returnLinkToAdmin: true } })` also returns the
131
+ link once. User reads carry a top-level `signInStatus` (`pending` | `active` | `none`). Frontend:
132
+ `identity.inviteUser(token, uuid)` (`@astram/frontend-core`); the scaffolded Users page has the
133
+ Invite button, and the reset page shows "Set your password" for `purpose=invite` links.
134
+ - **Sign-in options** (ADR-0045; guard 0.20, identity 0.17, frontend-core 0.8). Each is OFF until
135
+ you turn it on; an upgrade without new options changes nothing on the wire except the logout fix
136
+ below. Write none of this yourself (Rule 6).
137
+
138
+ | Option | Default | What it does | Risk / note |
139
+ |--------|---------|--------------|-------------|
140
+ | `ShieldModule.forRoot({ signInWith: ['email', 'username'] })` | `['email']` | One field takes the email or the user's login name (`sonja`). `'email'` must stay in the list (boot fails otherwise). | Login names are global: an admin who hits "That sign-in name is already in use." learns the name exists somewhere (no tenant is named). |
141
+ | `captcha: 'env'` | off | Cloudflare Turnstile or Google reCAPTCHA (v3 score, v2 checkbox) on login, forgot-password and reset / set-password, from `CAPTCHA_*` env. Adaptive by default: a challenge after 3 failures for the account, the typed name or the IP. | Platform-wide only. Outside development/test `CAPTCHA_SECRET` must be a `secret-ref:` and `CAPTCHA_EXPECTED_HOSTNAMES` is required. `CAPTCHA_FAIL_MODE=open` lets sign-in through when the provider is down (audited, limits still apply); keep `closed` unless you accept that. The frontend build needs `VITE_CAPTCHA_PROVIDER` = the server's `CAPTCHA_PROVIDER` (CSP); a mismatch shows a "misconfigured" notice. |
142
+ | `logoutRequiresProof: true` | `false` this release; `true` from the next guard major | `POST /auth/logout` ends a session only with proof: its refresh token, or a bearer bound to it. | With `false`, an unproven logout (an old `{ sessionId }`-only frontend) still ends the session and is counted (`auth.logout.unproven`); anyone holding a leaked session id can end that session. The scaffolded app sets `true`. A custom frontend must send `{ sessionId, refreshToken }` + bearer first (`@astram/frontend-core` `logout(session)` does). |
143
+ | Mandatory 2FA (`/identity/security-policies`, identity wired) | no policies | Admins require 2FA for everyone in a company subtree, holders of a role, or holders of a permission key (`identity:*` allowed); grace 0–14 days, per user from when they become subject. Root can set a platform floor. | A required user without 2FA can only reach the enrolment pages (403 `mfa_enrollment_required`) until they enrol; during grace `/auth/me` carries `mfaEnrollmentDeadline`. Root accounts are bound only by floor policies. Takes effect on the user's next request. |
144
+
145
+ - **What counts as 2FA for a policy:** a confirmed **TOTP** authenticator only, in this release.
146
+ Recovery codes never count. Email codes, passkeys and SMS are not available as factors yet.
147
+ - Removing the last device while a policy applies answers 409 `mfa_required_by_policy`.
148
+ - `GET /auth/login-options` (public) returns `{ signInWith, captcha: { provider, siteKey, mode } | null }`
149
+ for the login page; a 401 with `captcha: 'required'` asks the page to show the widget.
150
+ - Sign-in events are audited by default when `IdentityFeatureModule` is loaded (`auth.login.*`,
151
+ `auth.logout`, `auth.mfa.*`, `auth.captcha.*`, `auth.handle.changed`; failed logins only for
152
+ real accounts, at most one row per lockout window). Your own `auditSink` still wins.
153
+ - Restricted sessions (recovery-code login or mandatory 2FA) get 403 on `POST /auth/scope-switch`,
154
+ and `GET /identity/auth-facts` answers `{ facts: [], restricted: 'reenroll' | 'mfa_enrollment' }`.
112
155
  - **Service-account API keys** (`Authorization: Bearer ask_…`) authenticate agents and machine
113
156
  clients through the same `decide()` path; scope, status and roles are read live; issuing a key
114
157
  needs an explicit expiry (`expiresAtMs`) or `neverExpires: true`.
@@ -148,6 +191,26 @@ imports: [
148
191
  active company; none when root creates it in the admin UI (a shared, root-only role). It cannot
149
192
  be changed by update. Deleting a company clears the owner of its roles. This round records
150
193
  ownership only: which roles an admin can see or administer is unchanged.
194
+ - **Security policies (mandatory 2FA, ADR-0045).** `GET|POST /identity/security-policies`,
195
+ `GET|PUT|DELETE /identity/security-policies/:uuid` (permissions
196
+ `identity:security_policy:{read,create,update,delete}`; no access bundle carries them). Any holder
197
+ can create or tighten a policy for a company in their subtree; lowering or deleting a role or
198
+ permission policy needs the grant ceiling over what it covers, a company-wide policy needs all of
199
+ `identity:*` at that company or above; the platform floor is root-only. Every write is a
200
+ `permission_change` audit row and takes effect on the affected users' next request.
201
+ - **Login names (G-014).** `loginName` on `POST /identity/users` and `PUT /identity/users/:uuid`
202
+ (`null` clears) (and optional `loginName` per
203
+ user in `bootstrap:org` files): `[a-z0-9._-]`, 3–64 characters, stored lower-case, never `@`.
204
+ Needs dominance over the user; audited `auth.handle.changed`; existing sessions stay. Users cannot
205
+ set or change their own (no `/account/*` route does).
206
+ - **Admin sign-in controls.** `POST /identity/users/:uuid/{reset-mfa,unlock,end-sessions}`
207
+ (permissions `identity:user:{reset_mfa,unlock,end_sessions}`), body `{ code }` = the admin's OWN
208
+ current TOTP code; an admin without 2FA gets 409 `mfa_required`; needs dominance; not on yourself.
209
+ Reset-MFA deletes the user's devices and recovery codes and ends their sessions; unlock clears the
210
+ login, MFA, recovery and CAPTCHA counters; end-sessions revokes every session. All audited. User
211
+ reads carry `mfaStatus` (`enrolled` | `required` | `grace` | `not_required`), never device data.
212
+ Roles holding `identity:*` or `identity:user:*` gain these actions (and `security_policy`) on
213
+ upgrade; exact-key roles need a grant.
151
214
 
152
215
  ## Organisation bootstrap (`npm run bootstrap:org`, `@astram/identity` `runOrgBootstrap`, ADR-0042)
153
216
  - **What it does.** Creates your organisation from one JSON file at deploy time: platform roots,
@@ -182,9 +245,10 @@ imports: [
182
245
  another tenant is a `conflict` (roles are still global by name); an unowned role needs
183
246
  `--claim-unowned-roles`.
184
247
  - **What it does NOT do.** No deletes, renames, moves or revokes (differences are reported only);
185
- it never activates an inactive membership or updates a changed email. No upload from the UI. No
186
- administrator invite route or emailed link yet (G-010); department and branch admin routes are
187
- separate too.
248
+ it never activates an inactive membership or updates a changed email. No upload from the UI.
249
+ Department and branch admin routes are separate. With `emailSender` configured, `--email-links`
250
+ emails each non-root set-password link instead of printing it; root links are never emailed and
251
+ follow the terminal-only print rule (it cannot be combined with `--links-to-stdout`).
188
252
  - **Existing apps** (scaffolded before this release) have no `bootstrap:org`. Upgrade
189
253
  `@astram/identity` and `@astram/guard` (`npm update @astram/identity @astram/guard`), copy
190
254
  `src/bootstrap-org.ts` and `src/bootstrap-org.args.ts` from a freshly scaffolded app (or from
@@ -464,6 +528,14 @@ upload(@Req() req: Record<string, unknown>) {
464
528
  - The scaffolded frontend (`create-astram-app --with-frontend` or `npx -p create-astram-app
465
529
  astram-add-frontend`) ships login, MFA, recovery, forgot/reset, account and admin pages, with a
466
530
  strict Content-Security-Policy.
531
+ - Sign-in options (frontend-core 0.8): `auth.loginOptions()`, `captchaToken` on `login` /
532
+ `requestReset` / `resetPassword` (a failure carries `captchaRequired`), `restrictionReasonOf(body)`,
533
+ `logout(session)` sends the proof, `createSecurityPolicyService(http)`, and
534
+ `identity.{resetUserMfa,unlockUser,endUserSessions}(token, uuid, code)`. The scaffolded frontend
535
+ has the login label from `signInWith`, the CAPTCHA widget (`VITE_CAPTCHA_PROVIDER`,
536
+ `create-astram-app --captcha <provider>`), routing to 2FA setup on `mfa_enrollment_required`, a
537
+ grace banner, the Security page (policies), and Users-page 2FA status, sign-in name and admin
538
+ actions.
467
539
 
468
540
  ## Available, but NOT wired to a data path (do not assume they protect data yet)
469
541
  - **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
@@ -481,9 +553,13 @@ upload(@Req() req: Record<string, unknown>) {
481
553
  - Per-principal column masking on reads (row-level scoping is provided; column-level is not).
482
554
  - External identity providers (OIDC, SAML, LDAP, Entra ID, Cognito, Okta, Google Workspace) and
483
555
  WebAuthn ceremonies.
484
- - Tenant- or role-mandated MFA (MFA is enforced for users who enrolled a device).
485
- - An administrator invite (admin-console route that emails a set-password link) and framework
486
- email/SMS delivery adapters (framework item G-010). Today first sign-in links come from
487
- `npm run bootstrap:org` only.
556
+ - Second factors other than TOTP (email code, passkey/WebAuthn, SMS), passwordless email-code
557
+ sign-in, a per-tenant allowed-factor list, a user's own session list, security notification
558
+ emails, and a configurable password policy (mandatory 2FA by tenant, role or permission IS
559
+ provided, above).
560
+ - SMS and WhatsApp delivery adapters, phone numbers on users, phone verification and SMS/WhatsApp
561
+ MFA (the ports exist in `@astram/core`; adapters come with a phone-number identity feature).
562
+ - A durable outbox or queue for guaranteed delivery (sends are best-effort with one retry; re-issue
563
+ an invite), bounce or webhook processing, and admin-editable message templates.
488
564
 
489
565
  If you need one of these, STOP and flag it in `.astram/guide/gaps.md` (Rule 4).
@@ -58,6 +58,16 @@ infrastructure, and they may not re-export it). The lint wall is a backstop, not
58
58
  sandbox: code that slips past it is still a Rule 2 violation. If you find yourself
59
59
  wanting a raw query, see Rule 4.
60
60
 
61
+ **Shared reference data** (code lists, catalogues, units: the same rows for every
62
+ tenant) is an object type declared `access: { plane, global: true }`, with no tenant
63
+ field and no `readScope`. It is read-only through the engine for everyone; create and
64
+ change its rows only in an app migration (idempotent, select-before-insert on the
65
+ business key). Never fake shared data with a match-all `readScope`, and never put
66
+ tenant data in a global type. A record policy on a global type applies only to callers
67
+ whose active tenant is the policy's tenant (plus platform policies): it does not cascade
68
+ down the company hierarchy, so a parent's deny does not hide the row from its child
69
+ companies' users. To hide a row across a group, author the policy in each tenant.
70
+
61
71
  ## 3. Classify every route (deny-by-default)
62
72
 
63
73
  Every HTTP route is DENIED by the global enforcement guard unless you classify it:
@@ -117,6 +127,24 @@ contributes configuration through the seam (`rootIds` from config, an `emailSend
117
127
  own object types as `extraObjects`) and its data; access policy is authored by
118
128
  administrators as role grants and record policies, not in code. See `CAPABILITIES.md`.
119
129
 
130
+ Sign-in options are configuration too, never app code (`CAPABILITIES.md`, "Sign-in options"):
131
+ - **Username sign-in:** `signInWith: ['email', 'username']`. Do not look users up by name
132
+ yourself, and do not let users set their own login name; admins set it (`loginName` on the users
133
+ API) or `bootstrap:org` does.
134
+ - **CAPTCHA:** `captcha: 'env'` plus the `CAPTCHA_*` variables and the same provider in
135
+ `VITE_CAPTCHA_PROVIDER`. Do not add your own CAPTCHA check, widget script origin or bot filter on
136
+ `/auth/*`; do not set `CAPTCHA_FAIL_MODE=open` without a recorded decision.
137
+ - **Mandatory 2FA:** admins create policies through `/identity/security-policies` (the Security
138
+ page). Do not gate routes on "has 2FA" in app code or keep your own list of who must enrol. In
139
+ this release only a confirmed **TOTP** authenticator counts as 2FA for a policy; recovery codes
140
+ never do. Handle 403 `mfa_enrollment_required` like `reenrollment_required`: send the user to the
141
+ 2FA setup page.
142
+ - **Logout:** call `logout(session)` from `@astram/frontend-core` (it sends the refresh token and
143
+ bearer as proof) and set `logoutRequiresProof: true` once your frontend does. Never end a
144
+ session by id alone.
145
+ - **Unlock, reset 2FA, end sessions:** use the users API actions (`reset-mfa`, `unlock`,
146
+ `end-sessions`); never clear limiter keys or delete MFA rows yourself.
147
+
120
148
  Never reimplement session handling, token verification, password hashing, or an
121
149
  allow/deny check in app code. Never take a tenant, company, role or user id for an access
122
150
  decision from the request body — the framework takes them from the session. If the auth
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astram/guide",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
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",
@@ -19,5 +19,10 @@
19
19
  "sideEffects": false,
20
20
  "publishConfig": {
21
21
  "access": "public"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/ASTRAM-2-0/astram-framework.git",
26
+ "directory": "packages/guide"
22
27
  }
23
28
  }