@astram/guide 0.2.9 → 0.2.11

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
@@ -100,8 +100,9 @@ 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
@@ -130,6 +131,27 @@ imports: [
130
131
  link once. User reads carry a top-level `signInStatus` (`pending` | `active` | `none`). Frontend:
131
132
  `identity.inviteUser(token, uuid)` (`@astram/frontend-core`); the scaffolded Users page has the
132
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' }`.
133
155
  - **Service-account API keys** (`Authorization: Bearer ask_…`) authenticate agents and machine
134
156
  clients through the same `decide()` path; scope, status and roles are read live; issuing a key
135
157
  needs an explicit expiry (`expiresAtMs`) or `neverExpires: true`.
@@ -165,10 +187,43 @@ imports: [
165
187
  service is built without `IdentityFeatureModule.forRoot`) when no credential was made.
166
188
  Once your `EmailSender` works, `POST /auth/forgot-password` reaches the user. A user whose email
167
189
  is set later stays locked until the next `bootstrap:org` run gives them a credential.
168
- - **Role owner.** Every role records an owner company when it is created: a non-root admin's
169
- active company; none when root creates it in the admin UI (a shared, root-only role). It cannot
170
- be changed by update. Deleting a company clears the owner of its roles. This round records
171
- ownership only: which roles an admin can see or administer is unchanged.
190
+ - **Roles belong to one company** (G-013, ADR-0047). A role is assigned only in its own company
191
+ (postings and service accounts; 400 otherwise, root included), read and managed through the owner
192
+ wall (a parent company's admins manage their subsidiaries' roles; out of reach ⇒ 404), and its name
193
+ is unique within its company only (409 names the existing role). `GET|POST
194
+ /identity/companies/:companyUuid/roles`; `POST /identity/roles` creates the role in the caller's
195
+ active company (400 "Choose a company for the role." without one). Role routes need
196
+ `identity:company:read` (the `access-admin` bundle grants it). An ownerless role (seeded without
197
+ `r_owner_company_uuid`) is root-only and assignable nowhere.
198
+ - **Departments and branches belong to one company** (G-012). `POST
199
+ /identity/companies/:companyUuid/departments` and `/branches` (`{ name }`), `PUT` (rename) and
200
+ `DELETE` on `/identity/departments/:uuid` and `/identity/branches/:uuid`; the list routes are walled
201
+ and return `{ rows: [{ uuid, name, usedBy? }], total, page, limit }` (`?page&limit&search`). Names
202
+ are unique per company ignoring case and accents (409); delete is refused (409) while any posting
203
+ uses the row; at most 1000 of each per company. Access bundle `org-structure`.
204
+ - **The active membership is always `active`** (G-011): `PUT /identity/memberships/:uuid` refuses
205
+ activating a non-active membership or moving the active one off `active` (409), and a body that
206
+ sets `isActive: true` with another status (400).
207
+ - **Security policies (mandatory 2FA, ADR-0045).** `GET|POST /identity/security-policies`,
208
+ `GET|PUT|DELETE /identity/security-policies/:uuid` (permissions
209
+ `identity:security_policy:{read,create,update,delete}`; no access bundle carries them). Any holder
210
+ can create or tighten a policy for a company in their subtree; lowering or deleting a role or
211
+ permission policy needs the grant ceiling over what it covers, a company-wide policy needs all of
212
+ `identity:*` at that company or above; the platform floor is root-only. Every write is a
213
+ `permission_change` audit row and takes effect on the affected users' next request.
214
+ - **Login names (G-014).** `loginName` on `POST /identity/users` and `PUT /identity/users/:uuid`
215
+ (`null` clears) (and optional `loginName` per
216
+ user in `bootstrap:org` files): `[a-z0-9._-]`, 3–64 characters, stored lower-case, never `@`.
217
+ Needs dominance over the user; audited `auth.handle.changed`; existing sessions stay. Users cannot
218
+ set or change their own (no `/account/*` route does).
219
+ - **Admin sign-in controls.** `POST /identity/users/:uuid/{reset-mfa,unlock,end-sessions}`
220
+ (permissions `identity:user:{reset_mfa,unlock,end_sessions}`), body `{ code }` = the admin's OWN
221
+ current TOTP code; an admin without 2FA gets 409 `mfa_required`; needs dominance; not on yourself.
222
+ Reset-MFA deletes the user's devices and recovery codes and ends their sessions; unlock clears the
223
+ login, MFA, recovery and CAPTCHA counters; end-sessions revokes every session. All audited. User
224
+ reads carry `mfaStatus` (`enrolled` | `required` | `grace` | `not_required`), never device data.
225
+ Roles holding `identity:*` or `identity:user:*` gain these actions (and `security_policy`) on
226
+ upgrade; exact-key roles need a grant.
172
227
 
173
228
  ## Organisation bootstrap (`npm run bootstrap:org`, `@astram/identity` `runOrgBootstrap`, ADR-0042)
174
229
  - **What it does.** Creates your organisation from one JSON file at deploy time: platform roots,
@@ -191,7 +246,8 @@ imports: [
191
246
  `ASTRAM_BOOTSTRAP_OPERATOR`. Root passwords: `ASTRAM_BOOTSTRAP_ROOT_PASSWORD_<USERNAME>` (a
192
247
  `secret-ref:` outside development/test, so a `SECRETS_PROVIDER` is needed) or `--interactive`.
193
248
  Other flags: `--reissue-links`, `--link-ttl-hours <1..168>` (default 72), `--links-to-stdout`,
194
- `--claim-unowned-roles`. Runs hold a database lock.
249
+ `--claim-unowned-roles` (a no-op notice since roles belong to one company). Runs hold a database
250
+ lock.
195
251
  - **First sign-in.** Non-root users get a locked credential and a one-time set-password link,
196
252
  printed once, to a terminal only (`--links-to-stdout` sends them to a pipe, where logs can keep
197
253
  them). The operator delivers each link; the user redeems it at the reset page. Outside
@@ -199,9 +255,11 @@ imports: [
199
255
  - **Roots.** The run prints each root's uuid and a `SHIELD_ROOT_IDS=…` line: set it and restart
200
256
  (`rootIdsFromEnv`, above).
201
257
  - **Re-runs and new tenants.** Additive and idempotent: a second `--apply` writes nothing. A file
202
- covers only the tenants it declares, so onboard a new tenant with a new file. A role name owned by
203
- another tenant is a `conflict` (roles are still global by name); an unowned role needs
204
- `--claim-unowned-roles`.
258
+ covers only the tenants it declares, so onboard a new tenant with a new file. Roles, departments
259
+ and branches are created in the company that declares them, and the same name may exist in
260
+ another company. A posting must use a role declared on its own company (a role declared only on a
261
+ parent is a file error). After renaming a department or branch in the app, rename it in the file
262
+ too, or a re-run creates the old name again.
205
263
  - **What it does NOT do.** No deletes, renames, moves or revokes (differences are reported only);
206
264
  it never activates an inactive membership or updates a changed email. No upload from the UI.
207
265
  Department and branch admin routes are separate. With `emailSender` configured, `--email-links`
@@ -279,6 +337,28 @@ imports: [
279
337
  (a Rule-4 STOP). Field types this version: `string`, `number`, `boolean`, `uuid`, `timestamp`,
280
338
  `json`.
281
339
 
340
+ ## Shared reference data: global types (ADR-0046; engine 0.11, guard 0.20)
341
+ - **Declare** `@ObjectType({ ..., access: { plane, global: true } })` for rows every tenant reads the
342
+ same (code lists, catalogues, units). No tenant field, no `readScope`. Registration refuses
343
+ `global` with `tenantScoped`, `owned`, `system`, a `readScope`, a `trusted: 'tenantId'` field,
344
+ `crudActions: true` or any action other than `read`, naming the type.
345
+ - **Reads** (list, get, `aggregate()`, search) carry no tenant clause, but the type's `read` grant
346
+ and deny policies still apply: a caller without `read` sees nothing. Root follows the plane:
347
+ `plane: 'data'` keeps root denied, `plane: 'control'` lets root read.
348
+ - **Writes** through the engine (create, update, delete, `createMany`) are refused for everyone,
349
+ root included, before any SQL. Seed and change rows only in an app migration, idempotent and
350
+ select-before-insert on the business key.
351
+ - **Policies do not cascade.** A record policy on a global type applies only to callers whose
352
+ active tenant is the policy's tenant, plus platform policies; a parent's deny does not reach its
353
+ child companies' users. To hide a row across a group, author the policy in each tenant or as a
354
+ platform policy.
355
+ - **No sharing, no cross-tenant reach**: a global type is never `owned`, so never a share target.
356
+ - **Tenant-less data types are now refused writes.** A `plane: 'data'` type with no tenant field,
357
+ no `readScope` and not global refuses non-root create, update, delete and `createMany`
358
+ (`AuthorizationDeniedError('tenantless_data_write_denied')`). Boot logs the global types once and
359
+ warns once per such type: make it global, give it a tenant field, or add a `readScope`.
360
+ - Never fake shared data with a match-all `readScope`, and never put tenant data in a global type.
361
+
282
362
  ## Custom object actions (ADR-0038; engine 0.7, guard 0.14, identity 0.11)
283
363
  - **Declare domain verbs on the type**: `@ObjectType({ ..., actions: ['activate', 'review'] })`.
284
364
  The type's vocabulary is `read, create, update, delete` plus the declared actions, and each one
@@ -486,6 +566,14 @@ upload(@Req() req: Record<string, unknown>) {
486
566
  - The scaffolded frontend (`create-astram-app --with-frontend` or `npx -p create-astram-app
487
567
  astram-add-frontend`) ships login, MFA, recovery, forgot/reset, account and admin pages, with a
488
568
  strict Content-Security-Policy.
569
+ - Sign-in options (frontend-core 0.8): `auth.loginOptions()`, `captchaToken` on `login` /
570
+ `requestReset` / `resetPassword` (a failure carries `captchaRequired`), `restrictionReasonOf(body)`,
571
+ `logout(session)` sends the proof, `createSecurityPolicyService(http)`, and
572
+ `identity.{resetUserMfa,unlockUser,endUserSessions}(token, uuid, code)`. The scaffolded frontend
573
+ has the login label from `signInWith`, the CAPTCHA widget (`VITE_CAPTCHA_PROVIDER`,
574
+ `create-astram-app --captcha <provider>`), routing to 2FA setup on `mfa_enrollment_required`, a
575
+ grace banner, the Security page (policies), and Users-page 2FA status, sign-in name and admin
576
+ actions.
489
577
 
490
578
  ## Available, but NOT wired to a data path (do not assume they protect data yet)
491
579
  - **Envelope encryption / per-tenant DEKs** (`createEncryptionService`, `LocalKekCrypto`): the
@@ -503,7 +591,10 @@ upload(@Req() req: Record<string, unknown>) {
503
591
  - Per-principal column masking on reads (row-level scoping is provided; column-level is not).
504
592
  - External identity providers (OIDC, SAML, LDAP, Entra ID, Cognito, Okta, Google Workspace) and
505
593
  WebAuthn ceremonies.
506
- - Tenant- or role-mandated MFA (MFA is enforced for users who enrolled a device).
594
+ - Second factors other than TOTP (email code, passkey/WebAuthn, SMS), passwordless email-code
595
+ sign-in, a per-tenant allowed-factor list, a user's own session list, security notification
596
+ emails, and a configurable password policy (mandatory 2FA by tenant, role or permission IS
597
+ provided, above).
507
598
  - SMS and WhatsApp delivery adapters, phone numbers on users, phone verification and SMS/WhatsApp
508
599
  MFA (the ports exist in `@astram/core`; adapters come with a phone-number identity feature).
509
600
  - A durable outbox or queue for guaranteed delivery (sends are best-effort with one retry; re-issue
@@ -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.9",
3
+ "version": "0.2.11",
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
  }