create-astram-app
v0.14.5
Published
Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.
Readme
create-astram-app
The Astram app scaffolder (ADR-0023 §3). It emits a new consumer app: a NestJS API that depends on the published, immutable framework packages and enforces deny-by-default from the first boot, plus (optionally) a complete identity frontend. You write features; the framework owns identity, authentication, sessions, authorization and audit.
Use it
npx create-astram-app my-app # server/ only
npx create-astram-app my-app --with-frontend # server/ + frontend/ (identity UI)--with-frontend may come before or after the name. The app name must be a single directory
name (letters, numbers, ., _, -; no leading dot).
cd my-app/server
npm install
npm run dev # API on http://localhost:3000; GET /health → 200, GET /demo/public → 200
npm test # proves deny-by-default (an unclassified route is denied)
cd ../frontend # only with --with-frontend
npm install
npm run dev # identity UI on http://localhost:5173 (proxies /api → :3000)Add the frontend to an existing app
cd my-app # the app root (the folder that holds server/)
npx -p create-astram-app astram-add-frontendastram-add-frontend is a second bin in the create-astram-app package, so npx needs
-p create-astram-app unless the package is already installed (then plain
npx astram-add-frontend works). It copies the frontend template into ./frontend/, substitutes
the app name from ./package.json (or the folder name), and never overwrites an existing
file. Re-running it later only adds files the newer template has and your copy lacks.
What it emits
my-app/
├── server/ the API (NestJS) — always emitted
│ ├── package.json @astram/core, guard, engine, identity, migrate, guide, sdk as
│ │ published semver ranges (never workspace:*), NestJS runtime
│ ├── src/main.ts bootstrap; reads TRUST_PROXY → Express `trust proxy`
│ ├── src/app.module.ts ShieldModule.forRoot() — the ONE seam (auth/identity wiring
│ │ documented in its header comment)
│ ├── src/health/ @Public health probes + /demo/public (the unclassified demo
│ │ route is commented out → 404; see demo.controller.ts)
│ ├── src/features/ EMPTY — your editable space
│ ├── test/ enforcement e2e: 200 health; a test-only unclassified route is
│ │ denied 403 and fails the verifyGatable boot check
│ ├── eslint.config.mjs the feature wall: no raw DB access anywhere in src/** except
│ │ app.module.ts / main.ts
│ ├── scripts/ dev runner (sets NODE_ENV=development), guide sync
│ └── .env.example config references only (boots with no .env)
├── frontend/ only with --with-frontend (see below)
├── .astram/guide/ DEVELOPER-OWNED guide layer (ADR-0025) — never touched on upgrade
├── CLAUDE.md, .gitignore
└── README.md how to run + how to add features the framework wayserver/ scripts: dev, build, start, test, lint, lint:wall, migration:run,
migration:status, migration:revert, guide:sync.
The wall (eslint.config.mjs, plus the lint:wall grep for /internal and
node_modules/@astram imports under src/features, run by lint) catches accidental use;
deliberate bypass (.call/.apply, a computed pool[m](), an aliased require) is a
code-review matter.
The wall also flags uuid-named route and body inputs (id, uuid, …Id(s), …Uuid(s)) that are
read without UuidPipe (@Param('uuid', UuidPipe)) or uuidField / optionalUuidField /
uuidArrayField from @astram/guard. The database matches uuids ignoring case, trailing spaces and
character width, so a raw one would slip past exact checks in your code. The rule only matches
those names: a uuid input under another name (@Param('target')), a destructured body, a body
bound to another variable name, a value read off req, or a param given any other pipe is not
flagged, so a clean lint run does not prove every uuid input
is canonicalised. Put UuidPipe on every uuid route param, whatever its name, and a uuid helper
on every uuid body or query field.
The frontend (--with-frontend / astram-add-frontend)
A Vite + React + shadcn/ui app whose views you own and edit; the auth and API logic comes from
@astram/frontend-core and @astram/sdk and updates with npm update. See
frontend/FRONTEND.md in the generated app for the update and re-skin model.
- Sign-in flows: login, MFA challenge (TOTP or a recovery code), forced MFA re-enrolment after a recovery-code login, forgot password, reset password.
- Account: profile, change password, two-factor devices and recovery codes. Changing the email asks for the current password (and an authenticator or recovery code when MFA is on); the server mails a link to the new address and the email changes only when it is opened.
- Email confirmation: the public
/confirm-emailpage opens that link (/confirm-email#t=…). The token is in the URL fragment; the page removes it from the address bar and history at once, setsReferrer-Policy: no-referrer(a<meta>tag while mounted, and a header fromnpm run dev/npm run preview), and posts it once. In production, also sendReferrer-Policy: no-referrerfor/confirm-emailfrom whatever serves the built frontend. - Administration (shown only to users whose roles grant it): companies, users (memberships and role postings), roles, access (role grants and access bundles), record policies (guided rule builder), service accounts (roles, scope, API keys with an explicit expiry), cross-tenant grants (offer, inbox, revoke), audit log; owner shares from record screens.
- Sessions: a 401 refreshes the access token once and retries (single-flight, shared across tabs with Web Locks); a refresh outage keeps you signed in; a rejected refresh signs you out and remembers the page; sign-out in one tab reaches the others.
- Content-Security-Policy:
index.htmlships a strict CSP (script-src 'self', no inline script or eval). A dev-only Vite plugin relaxes it for hot reload. If your API is not served same-origin under/api, add its origin toconnect-src.
The admin pages need the server's identity wiring (below); without it they 404.
Turning on auth and the identity admin
server/src/app.module.ts starts enforcement-only (ShieldModule.forRoot(): no database, no
Redis). Its header comment shows the full wiring. In short:
const rootIds: string[] = [/* platform-admin user uuids, read from your config */];
imports: [
ShieldModule.forRoot({
auth: true,
emailSender: myEmailSender, // or 'disabled'
rootIds,
identityActionKeys: identityAuthActionKeys,
identityControlPlaneKeys,
}),
IdentityFeatureModule.forRoot({ rootIds, extraObjects: [/* YourType */] }),
],IdentityFeatureModule builds the record authorization from its extraObjects and ShieldModule
decides with it, so your own object types are listed once. Do not pass recordAuthorization or
decisionAuthority to ShieldModule next to it: boot fails.
Production requirements
With auth: true, check every item before the first production deploy.
emailSender. OutsideNODE_ENV=development|test,auth: truefails boot unless you pass anEmailSender(send({ to, subject, html, text }), e.g. SMTP or SendGrid) oremailSender: 'disabled'(no reset emails;POST /auth/forgot-passwordstill answers 204). The dev logger, which prints reset links, is never used in production.RESET_LINK_BASE_URLandEMAIL_CHANGE_LINK_BASE_URL. The public URLs of your reset-password and confirm-email pages (e.g.https://app.example.com/reset-passwordandhttps://app.example.com/confirm-email);ShieldModule.forRoot({ emailChangeLinkBaseUrl })wins over the env var. OutsideNODE_ENV=development|test, with anemailSender, boot fails when either is unset, not a URL, or points atlocalhost,127.0.0.1,::1or0.0.0.0(emailSender: 'disabled'mails no links, so it skips this check).Explicit
NODE_ENV. SetNODE_ENV=production.npm run devsetsdevelopmentfor you;npm startsets nothing. An unsetNODE_ENVcounts as production for the email rule, and the framework's dev seed migrations run only underdevelopment(orASTRAM_SEED_DEV=1).TRUST_PROXY. The real number of reverse-proxy hops in front of the app (0= none,1= behind the Astram Traefik). Unset turns the per-IP limits for login, forgot-password and reset-password off, with a boot warning. Too high lets clients spoof their IP.REFRESH_HMAC_KEY. A long random secret, supplied through your secrets mechanism, never committed. Boot fails without it.Redis. Required with auth: sessions, cached permission facts, the permission version, MFA challenges and rate limits live there, and the request path reads only Redis. If Redis is down, requests fail closed (401) and refresh answers 503, within
REDIS_COMMAND_TIMEOUT_MS(default 2000). The app's Redis user must be allowedEVAL(the shippedinfra/redisACL allows it; check your production ACL).A custom
credentialResolver(ShieldModule.forRoot({ credentialResolver })) replaces the framework's resolver, and with it three login checks that you then own:- MFA enforcement: return
requiredFactors: [{ factor: 'totp', secret }]for every user with a confirmed MFA device, wheresecretis the device's TOTP shared secret (several devices: one entry, secrets joined with\n, as the built-in resolver does). A plain['totp']does not type-check and leaves the user stuck atmfa_required. Return[](or omit the field) and that user signs in with the password alone. - Disabled accounts: return
nullfor an account that must not sign in (the built-in resolver does this forshield_app.users.u_statusother thanactive). - Lockout keying: the per-account lockout is keyed on the
idyou return, so return the same stable id for every spelling of the user's login hint.
import type { CredentialResolver, ResolvedCredential } from '@astram/guard'; export class DirectoryCredentialResolver implements CredentialResolver { async resolve(identityHint: string): Promise<ResolvedCredential | null> { const user = await directory.findByLogin(identityHint.trim().toLowerCase()); if (!user || user.status !== 'active') return null; // unknown or disabled: same answer const secrets = await directory.confirmedTotpSecrets(user.id); return { passwordHash: user.passwordHash, claims: { id: user.id, tenantId: user.companyId, roleIds: user.roleIds }, // stable id requiredFactors: secrets.length ? [{ factor: 'totp', secret: secrets.join('\n') }] : [], }; } }The authenticator's other protections (decoy verify, no account enumeration, no secret leaks, TOTP limiter and replay guard) still apply.
- MFA enforcement: return
rootIds. Pass the same platform-administrator user uuids toShieldModule.forRootandIdentityFeatureModule.forRoot, from config. Without them nobody logs in as root and the identity module logs a boot warning. The generatedapp.module.tsdoes not pass them yet; add them when you turn auth on.Migrations with the DDL account. Run
npm run migration:runwithDDL_DB_USER/DDL_DB_PASSWORD(a DDL-capable deploy account; host, port and database come fromAPP_DB_*). The running app'sAPP_DB_*account must not hold DDL. The framework's guard and identity migrations run first, then yours (APP_MIGRATION_DIR). Upgrading to guard 0.8 needs identity migration1710000000019-AddSessionMustReenroll: apply migrations before starting the new version. Concurrent deploys are safe (migrations hold a database lock;APP_MIGRATION_LOCK_TIMEOUT, default 120 s).--strict.migration:statusandmigration:runreport migrations whose file changed after they ran (drift). Add--strictin CI against a fresh database to fail on drift. Do not edit an applied migration. Out-of-order pending migrations are only a warning, because framework upgrades ship migrations older than your own.Existing Astram infra database volumes. Init SQL runs only on an empty volume. On a volume created before 2026-10-08, apply
infra/mariadb/upgrade/2026-10-08-shield-core-audit-append-only.sqlonce as root (it makes the runtimeshield_coreaccount append-only onshield_audit). Seeinfra/README.mdin the astram-framework repo.API clients other than the generated frontend must refresh tokens one at a time (one in-flight
/auth/refreshper session). Concurrent refreshes of the same token can trip reuse detection, which revokes the session (the user must sign in again).
Upgrading from 0.9.x
The next @astram/identity minor (KI #87) makes shield_app.users.u_email unique: identity
migration 1710000000021-AddUsersEmailUniqueIndex adds UNIQUE INDEX uq_users_email. Bump
@astram/identity and @astram/guard (patch) in server/package.json, npm install, and run the
migrations with the DDL account (astram-migrate migrate) before starting the new version.
- What counts as the same address is the column collation,
utf8mb4_unicode_ci: case, trailing spaces, full-width letters, accents (é=e) andß=ssall match. Leading spaces and a trailing tab do not; the framework trims and lower-cases on write. - Empty addresses: the migration first sets empty or whitespace-only
u_emailto NULL (NULL may repeat).migrate:revertdrops the index but does not restore the empty strings. - Out-of-order warning: an app that already ran the dev seeds (
...099/...100) seesWARNING out of order: pending migration 1710000000021. That is expected; it is not a failure, also under--strict. - If it fails with duplicates: the migration stops and lists each duplicate address MASKED
(
a***@example.com: 2 users), with no user ids. Nothing is changed and no index is created. Find the users (the admin user list searches by email), give each a distinct address or clear it (PUT /identity/users/:uuid, or your own data fix as a migration), then run the migrations again. The framework never picks which user keeps the address. - After the upgrade: a write of an address another user already holds fails in the database
(ER_DUP_ENTRY 1062) even when two requests race. The framework's own paths answer it: the
email-change confirm
409 email_unavailable,PUT /identity/users/:uuidandPOST /identity/users409 ("That username or email is already in use." — a taken username was a 500 before). Because the collation folds accents,ßand width,[email protected]andjosé@example.comare now the same address: the second one is refused with 409. If your own code writesshield_app.usersdirectly, handle errno 1062 as a conflict, and never log or return the driver's message: it contains the full address.
Run migration 1710000000021 in a low-traffic window on a large users table: it scans the table to
normalise blank emails and holds those row locks until the index build commits. If it fails because
a duplicate was written during the build (rather than at the pre-check), blank emails have already
been set to NULL; resolve the reported addresses and re-run.
Upgrading from 0.8.x
create-astram-app 0.9.0 adds one lint rule (KI #82) and the verified email change (KI #14).
Bump the ranges in server/package.json to @astram/identity ^0.8.0, @astram/guard ^0.11.0,
@astram/engine ^0.5.2 and @astram/guide ^0.2.3, and frontend/package.json to
@astram/frontend-core ^0.6.0, then npm install in both and run the migrations (identity
1710000000020) with the DDL account before starting the new version.
Lint rule (KI #82): copy server/eslint.config.mjs from a fresh scaffold, run npm run lint,
and put every flagged uuid input through UuidPipe (route params) or uuidField /
optionalUuidField / uuidArrayField (body and query fields).
Verified email change (KI #14) — BREAKING in @astram/guard 0.11, @astram/identity 0.8:
| Change | What you do |
|---|---|
| BREAKING: PATCH /account/profile with an email needs currentPassword (and code — TOTP or a recovery code — when the user has MFA) and answers 202 { pendingEmail: true, profile } instead of 200. The address changes only after the user opens the link sent to the NEW address. A name-only edit still answers 200. | Send currentPassword (+ code) with an email change and treat 202 as "check your new inbox". Errors: 400 invalid_current_password / mfa_required / invalid_request, 422 invalid_email / no_changes, 429 too_many_requests, 503 authentication_unavailable. |
| BREAKING: adding an MFA device when one is already confirmed needs code: POST /account/mfa/devices/begin { name?, code } takes a TOTP from an existing device or a recovery code; 400 mfa_required without it, 400 invalid_request when wrong, 429 / 503. A recovery-login re-enroll session still adds a device without a code. With MFA on, confirm accepts only a device begun in the last 15 minutes (404 otherwise). | Ask for a current code on the add-device form when the user already has a device and send it with accountService.beginMfaDevice(token, name, code) (reason: 'mfa_required' → ask for it). Scaffolded apps: copy frontend/src/pages/account/TwoFactorPage.jsx and frontend/src/auth/deviceEnrollment.js (+ its *.test.mjs) from a fresh scaffold. |
| New route POST /auth/confirm-email { token } (public): 200 { ok: true }, 400 invalid_token, 409 email_unavailable, 429, 503. On success every other session of the user is signed out and the old address gets a notice. | Add a confirm page that reads the token from the URL fragment (#t=…), POSTs it, and sets Referrer-Policy: no-referrer. |
| New option ShieldModule.forRoot({ emailChangeLinkBaseUrl }), default http://localhost:5173/confirm-email. The mailed link is ${emailChangeLinkBaseUrl}#t=<token>. | Set it to your confirm page URL in every non-local environment. |
| Identity migration 1710000000020-CreateEmailChangeToken (shield_identity.email_change_token, FK to shield_app.users). | Run the migrations (DDL account) before starting the new version. |
| A password reset or change cancels a pending email change. An admin email change (PUT /identity/users/:uuid) refuses an address another user or login already has (409), rejects a malformed one (400), and moves the login key when it equalled the old email. | Nothing, unless your admin UI sends an empty email to clear it (now 400). |
Frontend for the email change: PATCH /account/profile with an email now needs
currentPassword (plus code when MFA is enrolled) and answers 202; the email changes only when
the user opens the link mailed to the new address. An app scaffolded earlier still saves names,
but its profile page cannot change the email. Update @astram/frontend-core to 0.6 and copy from
a fresh scaffold: frontend/src/pages/account/ProfilePage.jsx, frontend/src/pages/ConfirmEmail.jsx,
frontend/src/auth/profileEdit.js, frontend/src/auth/confirmEmailLink.js (and their
*.test.mjs), the /confirm-email route in frontend/src/App.jsx, and the
confirmEmailNoReferrer plugin in frontend/vite.config.js. If you build your own UI, use
accountService.updateProfile(token, { email, currentPassword, code }) (202 →
{ ok: true, pendingEmail: true }, reason: 'mfa_required' → ask for a code) and
authService.confirmEmail(token).
Upgrading from 0.7.x
create-astram-app 0.8 pairs with @astram/identity 0.7 and @astram/guard 0.10 (breaking wiring
change). IdentityFeatureModule now builds the record authorization itself, from its own
extraObjects, and ShieldModule decides with it. Before, the app built it separately with
buildIdentityRecordAuthorization(authzPool, extraObjects), and an app could pass different object
types to the two calls, or skip the second call and get the deny-everything authority.
1. Bump the ranges in server/package.json to @astram/identity ^0.7.0, @astram/guard
^0.10.0, @astram/engine ^0.5.1, @astram/migrate ^0.3.2 and @astram/guide ^0.2.2, and
frontend/package.json to @astram/frontend-core ^0.5.2, then npm install in both. Every package now requires mariadb ^3.5.4 (driver SQL-injection advisories). No new
migrations.
2. Change server/src/app.module.ts:
- Delete
recordAuthorization: buildIdentityRecordAuthorization(...)fromShieldModule.forRoot(and from aforRootAsyncfactory).buildIdentityRecordAuthorizationno longer exists. - Pass your own object types only to
IdentityFeatureModule.forRoot({ extraObjects }). - Delete
createIdentityAuthzPool()and its import from@astram/identity/internalunless another wiring line still uses the pool (for examplenew DbScopeSubtreeResolver(authzPool)).
// before
const authzPool = createIdentityAuthzPool();
ShieldModule.forRoot({ auth: true, /* … */, recordAuthorization: buildIdentityRecordAuthorization(authzPool, [Note]) }),
IdentityFeatureModule.forRoot({ rootIds, extraObjects: [Note] }),
// after
ShieldModule.forRoot({ auth: true, /* … */ }),
IdentityFeatureModule.forRoot({ rootIds, extraObjects: [Note] }),Boot checks: ShieldModule fails the boot if it receives recordAuthorization or
decisionAuthority while IdentityFeatureModule is imported (two sources), and
IdentityFeatureModule fails the boot if the request path would use the deny-everything authority
(for example with an older @astram/guard).
Behaviour changes: your extraObjects types are now also known to cross-tenant grants (offerable
types, the offer/accept check) and to the guided rule-builder's policy check. The identity module
opens one pool fewer (the request path uses the identity module's authz pool).
3. Copy the lint-wall files from a fresh scaffold: server/eslint.config.mjs, the new
server/scripts/check-wall.mjs, and the lint and lint:wall scripts in server/package.json.
The wall now also rejects any node_modules/ or @astram/<pkg>/dist|src deep import, banned names
imported under an alias, computed import()/require() specifiers, and the driver methods
execute, batch, queryStream and beginTransaction (so CommandBus.execute() from
@nestjs/cqrs is flagged too). lint:wall fails on any /internal or node_modules/@astram
import under src/features; run it in CI. The wall catches accidental use; deliberate bypass
(.call, Reflect.apply, computed method names, an aliased require) is a code-review matter.
More behaviour changes:
- Change-password answers a wrong current password with 400
invalid_current_password(was 401) and a locked account with 429 (was 401). The frontend shows clear messages for 429 and 503. - Login,
/auth/mfa, change-password and the code-gated MFA account routes count each attempt before verifying it (parallel guesses can no longer slip past the limit) and answer 503 when the limiter's Redis call fails (before: login let the attempt through). A refused attempt counts too. An app that overrides a limiter token with its own object must implementconsumeandrefund. - A role delete the engine refuses no longer strips the role's grants. Revoking a grant needs authority over every service account holding the role (403 otherwise).
- Renaming another user needs authority over that user. A new posting's department and branch must be linked to the membership's company (400 otherwise). A non-root admin cannot change the status of their own root company (403).
4. Check for agent keys that never expire. Keys issued before an expiry became mandatory
(identity < 0.5) have none. @astram/identity 0.7 ships a read-only report; run it in server/
with the app's APP_DB_* variables set:
npx astram-sa-keys-report # lists agent label, company, key version, issue date
npx astram-sa-keys-report --fail-on-found # exit 1 if any are listed (CI)Rotate each listed agent's key with an expiry in the service-accounts page. The report never prints secrets, hashes or ids.
Upgrading from 0.6.x
create-astram-app 0.7 (2026-10-09) pairs with a framework release that moves raw pools off the
package entry points (breaking: @astram/engine 0.5, @astram/identity 0.6, @astram/guard 0.9)
and ships the follow-up security fixes (see each package's CHANGELOG).
1. Bump the ranges in server/package.json to @astram/engine ^0.5.0,
@astram/identity ^0.6.0, @astram/guard ^0.9.0, @astram/migrate ^0.3.1, @astram/guide ^0.2.1,
and frontend/package.json to @astram/frontend-core ^0.5.1, then npm install in both. No new
migrations.
2. Change these files (or copy them from a fresh scaffold and merge by hand):
| File | Change |
|---|---|
| server/src/app.module.ts | import { createIdentityAuthzPool } from '@astram/identity/internal' (it is no longer exported from @astram/identity). Keep the pool inside this file: do not export it. |
| server/eslint.config.mjs, lint script in server/package.json | the wall also lints .js/.mjs/.cjs under src/, rejects @astram/*/internal outside app.module.ts/main.ts (specs included), x['query']() and destructured { query }, re-exports from app.module.ts/main.ts, and features importing either file |
Breaking changes: MariaDbUnitOfWork moved from @astram/engine to @astram/engine/internal;
createIdentityAuthzPool from @astram/identity to @astram/identity/internal;
PersistenceModule and SHIELD_DB_POOL from @astram/guard to @astram/guard/internal. Only the
composition root (app.module.ts, main.ts) and tests that compose the framework should import
them. The wider lint wall may flag existing code that destructures query (for example
const { query } = req); read request query parameters with @Query() instead.
Behaviour changes: deleting a role or a company now needs authority over everyone who holds
it (403 otherwise). Deleting a role removes its grants and takes it off service accounts.
Deleting a company disables its service accounts, revokes their keys and ends cross-tenant grants
naming it. An API key whose company no longer exists is rejected. Removing an MFA device,
regenerating recovery codes and changing the password are rate-limited per account; change-password
shares the login lockout. Admin role assignment treats permission keys the server does not know as
not held, and IdentityFeatureModule.forRoot and buildIdentityRecordAuthorization must receive
the same extraObjects (boot fails otherwise). The frontend refresh call times out after 15 s
(treated as an outage, the user stays signed in).
Upgrading from 0.5.x
The generated app owns its files, and the scaffolder never overwrites them, so an upgrade has two parts.
1. Bump the framework ranges in server/package.json (@astram/core ^1.4.0,
@astram/guard ^0.8.0, @astram/engine ^0.4.0, @astram/identity ^0.5.0,
@astram/migrate ^0.3.0, @astram/guide ^0.2.0; @nestjs/* ^11.2.7) and
frontend/package.json (@astram/frontend-core ^0.5.0), then npm install in both. Run the
migrations before starting the new server.
2. Copy these files from a fresh scaffold (npx create-astram-app@latest tmp-app
--with-frontend), and merge by hand where you changed them:
| File | Why |
|---|---|
| server/src/main.ts | reads TRUST_PROXY |
| server/scripts/dev.mjs | npm run dev sets NODE_ENV=development |
| server/eslint.config.mjs (delete the old eslint.config.js), lint script in server/package.json | lint no longer crashes; wider feature wall over src/**/*.{ts,mts,cts} |
| server/scripts/sync-astram-guide.mjs | guide sync only in an app root, ASTRAM_GUIDE_SYNC=0, never fails install |
| server/.env.example | TRUST_PROXY, NODE_ENV, REDIS_COMMAND_TIMEOUT_MS, LOGIN_IP_* |
| frontend/src/auth/AuthContext.jsx, sessionFlow.js, routeGate.js | token refresh; admin nav after a fresh login; gated deep links |
| frontend/src/App.jsx | RequireAuth waits for auth facts |
| frontend/src/platform/sessionStore.js (plus doc-only httpClient.js, shieldTransport.js) | cross-tab session sync |
| frontend/index.html, frontend/vite.config.js | Content-Security-Policy and its dev relaxation |
| frontend/src/pages/service-accounts/ServiceAccountsPage.jsx, accessModel.js | asks for an API-key expiry (the server now requires one) |
Breaking and behaviour changes (details in each package's CHANGELOG):
- guard 0.8 (breaking):
auth: trueoutside development/test fails boot withoutemailSender(or'disabled'). - Login: users with a confirmed MFA device must complete
/auth/mfaon every password login; 5 wrong TOTP codes lock MFA for 15 minutes; a TOTP code works once. Lockout counters are per account now (they reset once on upgrade). Per-IP limits needTRUST_PROXY. Disabled accounts cannot sign in, and disabling a user ends their sessions. - Sessions: a refresh-minted access token lives at most the access TTL; the previous token stops working 30 s after a refresh; a replayed refresh token kills the session's live access tokens; roles follow posting changes on the next request without re-login; after a Redis loss the next refresh rebuilds the session instead of forcing a login. A Redis outage now fails fast (about 2 s) instead of hanging; rate limiters stop limiting during the outage.
- Identity API: errors map to 403/404/409/400/503 instead of 500.
PUT /identity/companies/:uuidchanges onlyname/status; moves usePUT /identity/companies/:uuid/parent. User updates accept onlyusername/email/status, and changing another user's email or status needs dominance over that user. Role grants and postings are limited to roles you hold and that are used only inside your subtree. Non-root admins cannot create top-level companies or attach a user from outside their subtree. Grant changes are audited. Issuing a service-account key needsexpiresAtMsorneverExpires: true. API keys use the account's current scope and roles. - Engine: non-root creates of subtree-scoped types are walled (403 outside your subtree);
update/delete/get of an unreachable id is a 403 instead of a silent no-op; record rules with
field conditions now apply to single-record reads and writes; unsorted lists come back in
primary-key order. Custom
RecordEngineimplementations must addtransactionandlockForUpdate. - Migrate: migrations are ordered numerically (mixed-width prefixes may reorder; check
migration:status), runs take a database lock, and the tracking table gains achecksumcolumn (added automatically). - Core: registering the same
(typeKey, actionKey)twice across modules now fails at boot.
Guarantees
- Immutable-core consumption — the emitted
package.jsonfiles use published semver ranges, neverworkspace:*or a source symlink. The framework upgrades vianpm update; yoursrc/is never touched. - Never clobbers — scaffolding into an existing directory (or re-running
astram-add-frontend) writes only files that do not exist yet. Yourdocs/,.astram/guide/, README and edited files are left as they are and reported as skipped. - Runs immediately —
npm install && npm run devboots a framework-enforced API with no.env. Real login and the identity admin need the database, Redis and the wiring above.
Commands (for developing this package)
pnpm --filter create-astram-app build # tsc → dist/index.js, dist/add-frontend.js
pnpm --filter create-astram-app test # vitest — CLI, never-clobber, emitted shape, lint wall
pnpm --filter create-astram-app lint
pnpm template:verify # (repo root) scaffold + build/lint/test against packed tarballs