# @getstrata/core changelog

## 1.1.5

- Close dogfood gaps: webhook docs, starter extras, signing helper

## Unreleased

- `signWebhookBody(secret, body)` and `signedWebhookHeaders()` on `@getstrata/core/security/webhookSignature` sign outbound JSON using `webhookSignatureHeader()`.
- CSRF skips `POST /billing/webhooks/*` by path (Stripe-style inbound webhooks), same as SAML ACS and `/scim/`.

## 1.1.4

- Publish http/uploads, validateUploadFile, migration-adoption docs

## 1.1.3

- Eager with() arrays, OpenAPI mkdir, public-read/auth docs

## 1.1.2

- Product CLI helpers, file migrations, and job discovery.

## Unreleased

- `Job` tracks `static jobName` on construct so `queue.dispatch(new FooJob(), payload)` resolves a registry name.
- The core migration runner is dialect-aware (placeholders, upsert, returning, timestamp column) so sqlite and mysql product apps can use `framework_migrations`. Import it from `@getstrata/core/database/migrations`.

## 1.1.1

Fix generated app boot

## 1.1.0

Breaking security hardening. Claims below match the code.

### Migration

- JWT without `exp` is rejected.
- Non-expiring `signedUrl()` is now invalid on verify. `signedUrl()` without `expires` always fails verification.
- Existing API tokens hashed with unpeppered SHA256 will not match HMAC pepper hashes. Re-issue tokens after setting `TOKEN_HASH_PEPPER`.
- CSRF and session secret fallbacks are gone. `ADMIN_API_TOKEN` is no longer a session or CSRF secret.
- CORS no longer defaults to `*` outside production. Unset CORS is `APP_URL` locally and same-origin in production.
- CSRF cookie is HttpOnly. JavaScript cannot read it.
- Missing `emailVerifiedAt` now means unverified. This is a break for JWT and tokens that omitted the field.
- Failed Bearer no longer authenticates via the session cookie.
- `/ready` no longer returns checks unless `APP_DEBUG=true`.
- Staging `/metrics` requires a token or returns 404.
- Seed password is `StrataDemo!ChangeMe`.
- SAML `saml:email:name` stub is gone.
- MFA is when enrolled. Password login does not force enrollment.
- `signedUrl()` without `expires` always fails verification.
- SAML requires `SAML_IDP_ISSUER`. ACS compares assertion issuer to that value. Signed responses are required; production boot rejects `SAML_WANT_RESPONSE_SIGNED=false`.
- OIDC ID tokens must be RS256 with JWKS. HS256 ID tokens are rejected.
- Generated login tokens and JWTs mint `[]` abilities. SQL `api_tokens.abilities` default stays `[]`.
- `--tenancy=rls` FORCE RLS is on `notes` and `users`. Auth directory lookups and cookie session loads use `runWithMigrationBypassForIdentifier`, which sets `app.bypass_identifier`. The helper does not rewrite SQL.
- `GET /health`: `createHealthRoutes` without `pingOnHealth` is always 200 JSON and does not read notes. HiroApp `/health` is `schemaReady` (empty notes 200, unreadable not 200). Docker HEALTHCHECK fetches `/health`.

### Controls

- SAML ACS verifies HMAC RelayState (no SameSite cookie). Replay defaults to SQL `auth_saml_assertions.assertion_id` (unique insert). Tests may use an in-memory store. Both drop IDs after 1 hour (longer than typical assertion lifetime plus `acceptedClockSkewMs` 5000). ACS rejects a signed assertion whose issuer does not match `SAML_IDP_ISSUER`. Signed assertions and signed responses are required. AuthnContext is requested. JIT uses `currentTenantId()` and is skipped when `FEATURE_REGISTRATION=false`. `GET /auth/saml` does not set an unused OAuth state cookie. Enrolled MFA still challenges after SAML ACS.
- `completePasswordLogin` runs on HTML password POST, HTML MFA POST, API token, JWT, and Basic mint paths, and cookie JSON password login. Recovery-code consumption is persisted on those paths. `verifyCredentials` returns null when `mfa_enabled` is true. Basic `verifyCredentials` fallback still runs TOTP when the directory record is enrolled. MFA is when enrolled, not on every password login. JwtGuard does not run TOTP on each request; it requires a directory and rejects `iat` before `session_valid_after`. SAML ACS redirects enrolled users to `/login/mfa`. MFA enroll revokes sessions and API tokens.
- Password reset and email verify consume one-time tokens with `UPDATE ... consumed_at IS NULL`. Cookie sessions compare aliased `sessions.created_at` (`session_created_at`) to `session_valid_after`. Reset deletes `sessions` and `api_tokens` (missing tables only are swallowed). JwtGuard looks up the user and rejects tokens whose `iat` is before `session_valid_after`. Verify GET does not sign the visitor in.
- `--tenancy=rls` ENABLE+FORCE RLS is on `notes` and `users`. `sessions`, `api_tokens`, and `auth_one_time_tokens` get a user-join policy plus an `app.bypass_identifier` pin on the real key columns. Session create/destroy, token mint, password/email/MFA user writes, and one-time token insert/consume wrap `runWithMigrationBypassForIdentifier()`, which `SET LOCAL app.bypass_identifier` and does not rewrite SQL. `ForIdentifier(user id)` may see that user's sessions and tokens; that is the session-create pin, not a one-row-only policy. Consume passes `hashOneTimeToken(token)`. Notes honour unbounded `app.bypass_rls()` or tenant id only. SCIM isolation is tenant GUC plus `WHERE tenant_id`. `currentTenantId()` and generated SCIM `tenantId()` throw if ALS is missing. `createHealthRoutes` without `pingOnHealth` is always 200 JSON. HiroApp `/health` is `schemaReady` (empty notes 200, unreadable not 200). `runWithMigrationBypass()` always opens its own transaction and remains for migrate/seed/audit. Generated Compose still creates a `postgres` superuser for volume init, GRANT, migrate, and `migrate:fresh` DROP (tables are owned by that superuser). Runtime `DATABASE_URL` / HiroApp `APP_DATABASE_URL` use `strata_app` (`NOBYPASSRLS`), including `--no-docker` and host e2e via `with-host-env.sh`. `db/ensure-postgres-app-role.sql` is repeatable on an existing volume. Live `pg_roles` runs on every rls runtime pool after it is open. Username `postgres`/`root` is the URL denylist fast path. Named superuser (`deploy`) is the live inspect. The HiroApp e2e denylist test is the postgres URL fast path. The live inspect e2e is `assertRlsLiveDatabaseRole()` on the `strata_app` pool. `assertProductionSecrets()` stays production-only.
- SMTP rejects CR/LF. Envelope `MAIL FROM` / `RCPT TO` use the bare address. Display names stay on headers only. `MAIL_USERNAME` and `MAIL_PASSWORD` are checked before AUTH LOGIN writes. There is no SMTP e2e (`MAIL_DRIVER=log` in HiroApp e2e).
- SSRF blocks non-canonical IPv4 (including leading-zero / octal / hex forms), integer hosts, and mapped IPv6. DNS resolve defaults on. `safeFetch` then connects to a resolved public IP and sends the original Host plus TLS server name. `allowPrivate: true` skips DNS only outside production.
- API CSRF middleware runs on guest and session mutating requests. `POST /api/v1/auth/login` and `POST /api/auth/token` require double-submit CSRF. Session-mutating API (logout after cookie login) requires CSRF. `GET /api/v1/auth/csrf` Set-Cookies the HttpOnly CSRF cookie and returns that same token in JSON (it does not mint a second cookie). Nested API CSRF (`buildModuleRoutes` plus `wrap("api")`) reuses the first issued token. CSRF failures on the API group are JSON 403. Failed Bearer does not skip CSRF when `credentialSource` is null (`tests/unit/csrf.test.ts` `failed Bearer header does not skip CSRF when credentialSource is null`). Failed Bearer does not resolve a session or guest user (`tests/unit/authGuard.test.ts` `failed bearer does not fall back to a session or guest guard`). HiroApp e2e `cookie login requires CSRF and ignores garbage Bearer` is garbage Bearer still requiring CSRF on POST `/login`. Successful skip is `successful bearer or basic skips CSRF`. SCIM and SAML ACS skip CSRF by path. CORS allowlists `X-CSRF-Token` and, when reflecting a specific origin, sets `Access-Control-Allow-Credentials`. CSRF and session cookies stay `SameSite=Lax`. A foreign or missing `Origin` on a cookie mutating request is rejected even if the CSRF header matches. Cross-site SPAs stay on Bearer.
- OIDC `getAuthorizationUrl()` throws. Use `createAuthorization()` and pass the handshake to `exchangeCode()`. Authorization, token, and JWKS URLs come from discovery. Inbound OIDC ID tokens are verified RS256 via discovery JWKS (`iss` / `aud` / `azp` / `at_hash` / `exp` / `nbf` / `nonce`). App JWTs stay HS256 (`signJwt`). Multi-valued `aud` requires `azp` equal to the client id. `at_hash` is verified when present; omitted `access_token` plus `at_hash` throws. Missing or unverified email throws. JWKS is refetched once when the token `kid` is missing from the cache. GitHub OAuth uses `safeFetch`, always reads `/user/emails`, and throws when no verified address exists (no `{login}@users.noreply.github.com`; unverified `profile.email` is ignored).
- `protectMfaSecret` always encrypts and requires `KMS_ENCRYPTION_KEY`. Local and dogfood with `FEATURE_MFA=true` must set the key. When a KMS key is set, `revealMfaSecret` never returns plaintext. Production still refuses non-`enc:v1:`. Local without a key may still return plaintext. `verifyTotp` compares every window slot with `timingSafeCompareString`.
- Safer defaults: `FEATURE_PUBLIC_READS`, `FEATURE_SIEM_EXPORT`, and `APP_DEBUG` default off. `DEFAULT_TENANT.plan` is `free`. SQL token-ability default is `[]`. Generated token login and JWT mint insert `[]`.
- Token hashes are HMAC-peppered. Recovery codes are 16 bytes. bcrypt cost is 12.
- Identity **response** headers `x-authenticated-user-id`, `x-tenant-id`, and `x-tenant-region` are never set. CORS still allowlists `X-Authenticated-User-Id`, `X-Authenticated-User-Role`, and `X-Tenant-Id`.
- `GuestGuard`: production (`isProductionEnv`, including staging) is always null even when `AUTH_DEV_HEADERS=true`. Local `AUTH_DEV_HEADERS=true` still reads request headers.
- Postgres unique-violation `detail` is logged, not returned. Client JSON is a generic conflict message with no constraint name.
- Local disk paths go through `assertPathUnderRoot`.
- Client `x-trace-id` is ignored unless `APP_DEBUG=true` and the value is 32 hex characters.
- OpenAPI documents registered routes only. Generated `docs/API.md` lists the layer's live paths and does not include leftover `/webhooks` or `/billing` strings.
- Root Compose Redis requires `dev-redis-change-me`. Root Compose Postgres uses `dev-postgres-change-me`. Root Compose MySQL uses `dev-mysql-change-me`. Host helper `scripts/with-host-env.sh` uses those passwords and points HiroApp `APP_DATABASE_URL` at `strata_app` / `dev-strata-app-change-me`. Fixture `DATABASE_URL` stays fixture admin for `bun_testing_test`. Production Compose Redis requires `REDIS_PASSWORD` and requires `APP_ENV` to be set. Production Compose `app`/`worker` runtime `DATABASE_URL` and `APP_DATABASE_URL` are `strata_app` after the split. `MIGRATION_DATABASE_URL` stays `${POSTGRES_USER}` for migrate. `STRATA_APP_PASSWORD` is required the same way `REDIS_PASSWORD` is. Bind stays `127.0.0.1:3000`. `127.0.0.1:54329` / `6379` / `33061` stay published. Adminer is debug-profile only. Prod compose file test plus HiroApp live-role e2e. This CI does not compose-up `docker-compose.prod.yml`.

## 1.0.9

Label HiroApp as internal e2e dogfood and seed notes via Model

## 1.0.8

- Container image starts and ships production dependencies only.

## 1.0.7

## 1.0.6

- Query `pluck` and `value` on `RepositoryQuery`, `ModelQuery`, and relation queries. `pluck(column)` returns `T[]`; `pluck(column, keyBy)` returns a `Map`. Models apply `$casts` and skip hydration/observers. Generated apps and lockstep packages move to `^1.0.6`.

## 1.0.5

- `@getstrata/core/database/mysqlConnection` re-exports the barrel so the barrel and the subpath share one lazy `mysql2` load. Importing both in one process no longer starts two independent loaders.
- npm publish uses GitHub Actions OIDC trusted publishing and provenance. Configure trusted publishers on all five packages before tagging `v1.0.5`. A failed or partial publish can be retried with Actions, Release, Run workflow; versions already on npm are skipped. The GitHub Release waits until npm succeeds.

## 1.0.4

- `isProductionEnv()` treats `APP_ENV` or `NODE_ENV` `production` as production (case-insensitive), treats `staging` as production, and default-denies unrecognized `APP_ENV` values such as `prod`.
- `envFlagEnabled()` is true only for the exact string `true`.
- JWT, session, CSRF, flash, signed-URL, OAuth-state, and token-pepper resolvers throw outside development when the secret is unset. They no longer derive a secret from the app name.
- `runWithMigrationBypass()` uses a transaction and `SET LOCAL` so `app.bypass_rls` cannot leak across pooled connections.
- `@getstrata/core/view` (and related singleton subpaths) re-export the main bundle so `configureWebErrorView` is shared.
- Shared subpath shims re-export the barrel. Subpath-only helpers such as `assertSafeOutboundUrl`, `webErrorResponse`, and `renderWebErrorHtml` are now on the public API so those imports work at runtime, not only in `.d.ts` files.

## 1.0.3

- `eta` and `mysql2` are optional peers, loaded with `import()` on first use. SQLite and Postgres apps no longer install `mysql2` through core. If a package is missing, the error names `bun add` for that package.
- Breaking: `createMysqlPool(url)` returns a `Promise` because it lazy-loads `mysql2`. It shipped synchronous in 1.0.1 and 1.0.2; `await` it. `createMysqlConnection(url)` stays synchronous and opens the pool on the first query.
- `createMysqlConnection()` shares one pool across concurrent first queries by caching the pending promise. Before this fix, three requests arriving together opened three pools and `close()` ended only the last one. A failed load is retried on the next query, and a query after `close()` opens a fresh pool.
- The MySQL helpers stay exported from `@getstrata/core` and `@getstrata/core/database`. Importing either barrel does not install `mysql2`; the barrel bundle carries the lazy `import("mysql2/promise")` and evaluates it only when a MySQL pool is opened.

## 1.0.2

- Lockstep with `create-strata` 1.0.2. No runtime changes.

## 1.0.1

- Publish the `contracts/authUserDirectory` subpath. Generated cookie and token apps import `AuthUserDirectory` from it, and without the export their `tsc --noEmit` failed with TS2307.
- `SqlDialect` gains `timestampValue()`, plus a `sqlTimestamp()` helper. MySQL `DATETIME` rejects the ISO-8601 `T` separator and trailing `Z`, which broke cookie session inserts on MySQL.
- `DatabaseConnection.close?()` now returns `void | Promise<void>`, matching the synchronous `close()` on the SQLite connection it is supposed to describe.
- `engines.bun` is declared.
- MySQL pools are UTC end to end: `createMysqlConnection()` passes `timezone: "Z"` to mysql2 and runs `SET time_zone = '+00:00'` on every new connection. Before, `DATETIME` values written as UTC by `timestampValue()` were read back shifted by the host offset, and `expires_at > NOW()` depended on the server time zone. `createMysqlPool(url)` exposes the configured raw pool.
- SQLite `nowExpression()` is `strftime('%Y-%m-%dT%H:%M:%fZ', 'now')` instead of `CURRENT_TIMESTAMP`, matching the ISO-8601 text that `timestampValue()` writes. With `CURRENT_TIMESTAMP` the `T` separator sorted after the space, so an expired session compared as valid until the next UTC day.
- Unknown exceptions map to `InternalServerError` (500, generic message) instead of a 400 that echoed the raw error text. SQLite (`SQLITE_CONSTRAINT_*`) and MySQL (`ER_DUP_ENTRY`, `ER_NO_REFERENCED_ROW_2`, `ER_ROW_IS_REFERENCED_2`, `ER_BAD_NULL_ERROR`, `ER_CHECK_CONSTRAINT_VIOLATED`) constraint errors map to 409/422/400 like Postgres SQLSTATEs. 5xx responses are logged with the original message and stack.
- Client IP: `readClientIp()` falls back to the socket address the web server records in the request context, and with `TRUST_FORWARDED_FOR=true` takes the rightmost public `X-Forwarded-For` hop instead of the first (client-controlled) one. Throttles no longer collapse every client into an `unknown` bucket.
- CORS: with `CORS_ALLOWED_ORIGINS` unset, production sends no `Access-Control-Allow-Origin` (same-origin only) instead of reflecting any origin; outside production the default stays `*`. Listed origins are reflected; unlisted ones get no header.
- `TENANCY_DRIVER` must be `none`, `column`, or `rls`. Unknown values throw instead of enabling rls.
- SQLite connections enable WAL, `busy_timeout = 5000`, and `synchronous = NORMAL` for file databases.
- New subpath `runtime/appEnv` with `isProductionEnv()` (`APP_ENV` or `NODE_ENV`), used for the cookie `Secure` flag and for hiding 500 messages in HTML.

## 1.0.0

- First stable release of the public `@getstrata/core` API.

## 0.7.5

- `TENANCY_DRIVER=column`: tenant ALS without Postgres `SET LOCAL` / `set_config`. `isRlsTenancy()` is the RLS-only check.
- `AuthUserRecord` may include `name` and optional MFA columns used by generated starters.

## 0.7.4

- Default database pool and query handles live on `globalThis`, so the published `@getstrata/core` bundle and `src/core` share one pool in the same process.

## 0.7.3

- `JsonResource.whenLoaded` returns `null` when a relation is loaded but empty. It does not call the transform, so `new PositionResource(value).toArray()` cannot crash on a missing belongsTo.

## 0.7.2

- `withJsonErrorHandling` always maps thrown errors to JSON. `requestPrefersJson` treats `/api/` as JSON even when `Accept` is HTML, so hybrid HTML cannot remap JSON API errors.

## 0.7.1

- `readSpaPrefix` / `normalizeSpaPrefix` own `SPA_PREFIX` (default `/app`). Apps set the env value. They do not copy a second static-file server.
- The log mail driver records `htmlBytes` instead of dumping the HTML document onto stdout.

## 0.7.0

- `FRONTEND_MODE=hybrid` turns on HTML at `/` and a SPA under `SPA_PREFIX` together. Views are on for `server-htmx` and `hybrid`. The SPA is on for `spa-react` and `hybrid`.
- `parseFrontendMode`, `FRONTEND_MODES`, and `FRONTEND_MODE_PATTERN` are the allowed-value list. Apps should reuse that pattern in env schemas.
- Named database connections: `registerNamedConnection`, `runOnNamedConnection`, SQLite (`bun:sqlite`), and MySQL (`mysql2`). `runWithSqlDialect` keeps the dialect across `await` via AsyncLocalStorage.
- New subpaths: `database/namedConnections`, `database/sqliteConnection`, `database/mysqlConnection`, `database/connectionContext`.
- OpenAPI treats unauthenticated login routes as public.

## 0.6.0

- **Breaking:** cookie session signatures use HMAC-SHA256. Existing HMAC cookies signed with the previous digest will not verify. Rotate `SESSION_SECRET` or sign users in again.
- **Breaking:** default `MEMBER_ABILITIES` are profile and token scopes (`profile:read`, `auth:tokens:*`), not org/project/task. Apps replace the catalog with `configureAbilityCatalog`. Generated HiroApp maps admin / member.
- Named auth guards: opaque Bearer tokens, JWT HS256, HTTP Basic, and cookie sessions. `AuthManager` picks a guard from the `Authorization` scheme. CSRF is skipped for Bearer and Basic.
- OpenAPI treats HiroApp login and JWT mint (`POST /api/auth/token`) as unauthenticated. Partner ping and audit export require credentials.
- SQL dialect helpers (`pgsql`, `mysql`, `sqlite`) for placeholders, quoting, `ILIKE`/`LIKE`, `RETURNING`, and `NULLS LAST`. Full-text `tsMatch` stays Postgres-only and throws on other engines.
- HiroApp is the in-repo generated example. The leftover `src/db` schema is a test fixture, not a second app.
- New subpaths: `auth/jwt`, `auth/jwtGuard`, `auth/basicAuthGuard`, `auth/tokenAbilityChecker`, `database/dialect`, `http/statelessAuth`.

## 0.5.101

- Tenant middleware falls back with explicit branches when a member, admin, or guest tenant lookup misses, so the request still scopes to the default tenant.

## 0.5.100

HiroApp dogfood of 0.5.99: eager `with()` / nested `load("a.b")` missed related rows when a Postgres int4 PK arrived as a JS `number` and an int8 FK as a `bigint`. Map matching used `===`.

- Relation indexers and eager attach compare keys with `relationMatchKey` (`1`, `1n`, `"1"` match).
- Sequential `load("position")` / `application.position()` already used SQL `get()` and were unaffected.
- `registerModelRepository(User, users)` already names `User` and `$morphClass`. Do not also call `registerModelClass("User", User)`. `registerModelClass` is only for an alias that is neither `constructor.name` nor `$morphClass`.

Still non-conforming (honest): Bun cannot infer `morphTo()` method names. `hashed` cast is a no-op. `Factory.has()` without a bound model still needs an explicit FK.

## 0.5.99

HiroApp dogfood of 0.5.98 found lookalike APIs. This release matches the existing call shape and SQL, not just export names.

- Relation queries are thenable: `await user.applications()` delegates to `get()`.
- `Model.with()` / `where()` / `whereHas()` return a `ModelQuery` that hydrates models and chains into `where` / `find` / `findOrFail` / `first` / `get`. `first()` / `find()` use `LIMIT` / PK lookup.
- `belongsTo.where()` threads constraints into `whereHas` EXISTS (HiroApp application search).
- `{ ilike }` uses the value as-is. Pass `%term%` yourself; the operator no longer wraps extra `%`.
- Morph type defaults to `$morphClass` / class name, not `table.name`. Override with `$morphClass = "App\\Models\\User"` or the last `morphMany` argument. `morphTo()` no longer silently defaults to `imageable_*`.
- `primaryKey()` defaults to the table PK (`id`).
- Nested `load("a.b")` / `with("a.b")` skip already-loaded heads and batch the next level.
- `count()` is `COUNT(*)` (relations and `RepositoryQuery`).
- `belongsToMany` eager-loads in two queries; `toggle()` and `withPivotValues()` exist. `hasMany.save($model)` sets the FK and saves.
- Factory `for(parent)` infers `user_id` from a Model parent. Bound `model` uses `Model.create()` so observers fire.
- `JsonResource.collection().toResponse()` wraps once: `{ data: [...] }`.
- Observers: `saving` / `saved` / `retrieved`. Integer casts: `integer` / `int`.
- `hasMany("Application")` / `() => Application` / `registerModelClass()` for ESM cycles.

Still non-conforming (honest): Bun cannot infer `morphTo()` method names (`debug_backtrace` equivalent is empty). `hashed` cast is a no-op (bcrypt is async). `Factory.has()` without a model class still needs an explicit FK.

## 0.5.98

- Model relations: `this.hasMany(Related)` returns a relation query (`get`/`where`/`create`/`attach`). `load()` / `loaded()` and `Model.with()` load related rows. `whereHas`/`has`/`doesntHave`, morph* methods, and nested `with("a.b")` are supported.
- Model `$hidden`/`$visible`/`$appends`, `toArray()`/`toJSON()`, `makeHidden`/`makeVisible`/`append`, and `observe()`.
- `Model.where()`, `firstOrNew()`, `firstOrCreate()`, and `updateOrCreate()`.
- Factory `count`/`state`/`sequence`/`for`/`has`/`recycle` plus `afterMaking`/`afterCreating`.
- `JsonResource` (`wrap`, `whenLoaded`, `additional`, `collection`).
- Convenience aliases: container `make`/`instance`, EventBus `on`/`emit`, query `whereNull`/`whereIn`/`whereExists`.
- Parity audit reports design score separately. Queue dashboards, admin UIs, and CLI stay Bun-native.

## 0.5.97

- `mapDatabaseError()` recognizes HTTP errors by `status` + `message`, not only `instanceof HttpError`. Subpath builds duplicate the class, so a thrown `ForbiddenError` was remapped to `400 Bad Request`.

## 0.5.92

- Re-export `resolveMembershipLookup` and `runWithMembershipContext` from the public barrel. Shared subpath shims re-export that barrel, so published `@getstrata/bootstrap` can import `@getstrata/core/auth/membershipContext` without a missing-export boot failure.

## 0.5.91

- `Schedule.command()` accepts any expression `Bun.cron.parse` understands. `dueTasks()` uses the next fire time in the current minute instead of a `*/N` whitelist.
- `Factory.create()` persists `make()` through subclass `persist()` and strips a placeholder `id` of `0`. No states, sequences, or relationships.

## 0.5.90

- **Breaking identity defaults:** `appKeyPrefix()` is `strata` and `appDisplayName()` is `Strata` when `APP_KEY_PREFIX` / `APP_NAME` are unset (were `strata` / `HiroApp`). HiroApp pins those env vars.
- `Schedule.command()` rejects cron strings other than `* * * * *` and `*/N * * * *` instead of silently never running them.
- OpenAPI marks `GET /users/me/*` as bearer-authenticated.
- OpenAPI `/users/me/current-organization` summaries no longer say team invitations.

## 0.5.89

- **Breaking:** removed `@getstrata/core/jobs/dispatchWebhookJob`. Import `DispatchWebhookJob` from the app webhook module (`src/modules/webhook/dispatchWebhookJob.ts`).

## 0.5.88

- Exported cookie name constants (`SESSION_COOKIE`, `CSRF_COOKIE`, `FLASH_COOKIE`, `INTENDED_URL_COOKIE`, `PASSWORD_CONFIRM_COOKIE`) are `appCookieName(...)` so they match the default prefix instead of a hardcoded `strata_` string.
- `@getstrata/core/jobs/dispatchWebhookJob` is a deprecated compatibility re-export of the HiroApp webhook job.

## 0.5.87

- `AuthUserDirectory.hasActiveBrowserSession?(userId, issuedAt)` is optional. `SessionGuard` calls it after `session_valid_after` and rejects the HMAC cookie when it returns false so HiroApp can revoke a single browser session by deleting the `sessions` row.

## 0.5.86

- `createSessionCookieDetails()` returns the HMAC session header plus `issuedAt` / `ttlSeconds` so apps can persist team invitations browser-session rows without changing the cookie format.

## 0.5.85

- `applicationRegistry` prefers the latest `Symbol.for("@getstrata/applicationContext")` on `globalThis` so a stale module-local context cannot hide the bootstrapped app after `build:framework`.

## 0.5.84

- `eventBus` is a `Symbol.for("@getstrata/eventBus")` process singleton so model writes from a built `@getstrata/core/database/baseRepository` bundle reach app listeners that imported a different copy of `@getstrata/core/events` (GitHub Actions `build:framework` before tests).

## 0.5.83

- `MEMBER_ABILITIES` includes `auth:tokens:delete` so team invitations-style personal access token revoke works for members (`DELETE /auth/tokens/:id`). HTML `/account/tokens/:id/revoke` was already authenticated-only.
- `buildOtpauthUrl()` defaults the issuer through `appDisplayName()` (`APP_NAME`).

## 0.5.82

- `MEMBER_ABILITIES` includes `organizations:create` so team invitations-style extra teams work for members (HTML `POST /organizations` and JSON `POST /organizations`). `OrganizationPolicy.create` already allowed any authenticated user.

## 0.5.81

- `@getstrata/core/auth/intendedUrlCookie` (`createIntendedUrlCookie`, `readIntendedUrl`, `clearIntendedUrlCookie`, `createIntendedUrlCookieFromRequest`). HTML auth intended URL after HTML email verification: register with `redirect=` and verified HTML redirects stash `${APP_KEY_PREFIX}_intended` (`INTENDED_URL_COOKIE_NAME`, default `strata_intended`; TTL `INTENDED_URL_TTL_SECONDS`, default 86400s). `GET /verify-email` honors and clears it.

## 0.5.80

- `appCookieName()` / `appDevSecret()` on `@getstrata/core/runtime/appKeyPrefix`. HMAC session, CSRF, flash, password-confirm, signed-URL, OAuth-state, and token-pepper fallbacks follow `APP_KEY_PREFIX` (HiroApp defaults unchanged).

## 0.5.79

- `readSession()` returns `{ userId, issuedAt }` from the HMAC session cookie. `isSessionInvalidated(issuedAt, session_valid_after)` lets `SessionGuard` reject cookies issued before `AuthUserRecord.session_valid_after` (team invitations logout-other-devices / password change).

## 0.5.78

- OpenAPI treats `POST /auth/two-factor-challenge` as a public operation (no bearer), including when routes are registered under `API_PREFIX`.

## 0.5.77

- `@getstrata/core/security/recoveryCodes` (`generateRecoveryCodes`, `hashRecoveryCode`, `recoveryCodeMatches`). HTML auth-style one-time MFA backup codes (`abcd-efgh`).

## 0.5.76

- `createSessionCookie(userId, { remember })` issues a longer HMAC session (default 30 days, `SESSION_REMEMBER_TTL_SECONDS`). Remember cookies embed the TTL in the signed payload so they stay valid after the default 7-day session lifetime. `SESSION_TTL_SECONDS` overrides the short session.

## 0.5.75

- Password confirm: `@getstrata/core/auth/passwordConfirmCookie` (`createPasswordConfirmCookie`, `hasFreshPasswordConfirmation`, `clearPasswordConfirmCookie`) and `createRequirePasswordConfirmMiddleware()` (HTML 302 `/confirm-password`, JSON 423). Cookie name `PASSWORD_CONFIRM_COOKIE_NAME` (default `strata_password_confirmed`), TTL `PASSWORD_CONFIRM_TIMEOUT` (default 10800s).

## 0.5.74

- `AuthUser.emailVerifiedAt` and `@getstrata/core/auth/emailVerification` (`isEmailVerificationRequired`, `hasVerifiedEmail`). `null` means unverified; missing is treated as verified (GuestGuard / legacy).
- `createRequireVerifiedMiddleware()` requires a verified email (HTML 302 `/email/verify`, JSON 403).

## 0.5.73

- OpenAPI treats `POST /auth/email/verification-notification` as a public operation (no bearer).

## 0.5.72

- OpenAPI treats `POST /auth/forgot-password` and `POST /auth/reset-password` as public operations (no bearer).

## 0.5.71

- OpenAPI treats `POST /auth/login` and `POST /auth/register` as public (no bearer), including when routes are registered under `API_PREFIX`.
- Route summaries look up `PUBLIC_ROUTE_DESCRIPTIONS` after stripping `API_PREFIX`.

## 0.5.70

- `OAuthProvider.getAuthorizationUrl` / `exchangeCode` accept an optional `redirectUri` so HTMX login can use `/oauth/:provider/callback` while the API keeps `OAUTH_REDIRECT_URI`.

## 0.5.69

- `MembershipLookup.updateMemberRole(organizationId, userId, role)` is required. The uninitialized lookup throws until `configureMembershipLookup()` is called. `MembershipService.updateMemberRole` delegates to the adapter.

## 0.5.68

- Published core no longer imports HiroApp `src/config`. Frontend mode, queue retries, CORS, and upload limits read env (`FRONTEND_MODE`, `QUEUE_*`, `CORS_ALLOWED_ORIGINS`, `MAX_UPLOAD_BYTES`).
- `@getstrata/core/runtime/frontendMode` exports `readFrontendMode` / `isViewsEnabled` / `isSpaEnabled`.

## 0.5.67

- OpenAPI title, server URLs, and generated SDK class name come from `APP_NAME` / `APP_URL` / `API_PREFIX` / `APP_SDK_CLASS` instead of importing HiroApp `src/config/app`.
- `appEnv()`, `appUrl()`, `apiPrefix()`, and `sdkClientClassName()` live on `@getstrata/core/runtime/appKeyPrefix`. SIEM export, HSTS, and `safeFetch` DNS resolve use `appEnv()` instead of HiroApp `appConfig`.

## 0.5.66

- `createValidateSignatureMiddleware()` on `@getstrata/core/http/signedUrl` rejects invalid or expired signed links with `ForbiddenError`.

## 0.5.65

- Identity helpers on `@getstrata/core/runtime/appKeyPrefix` (`smtpEhloHost`, `siemEventType`, `appUserAgent`, `otelServiceName`, `appDisplayName`, `webhookSignatureHeader`) so sibling apps are not stuck with HiroApp SMTP/SIEM/OTEL/OAuth names.
- SIEM `event_type` and CEF vendor follow `SIEM_EVENT_TYPE` / `APP_NAME` (defaults stay `strata.audit` / `HiroApp`).

## 0.5.64

- `APP_KEY_PREFIX` (default `strata`) namespaces Redis cache, queue, and throttle keys so sibling apps do not share HiroApp's keyspace.
- `DispatchWebhookJob` implementation lives in the HiroApp webhook module. `@getstrata/core/jobs/dispatchWebhookJob` remains a compatibility re-export.

## 0.5.63

- Flash cookies honor `FLASH_COOKIE_NAME` (default `strata_flash`) so sibling apps do not inherit HiroApp's cookie name.

## 0.5.62

URL signing, Bun-native markdown mail, and session-auth cleanup.

- `temporarySignedUrl` / `signedUrl` / `hasValidSignature` / `assertValidSignature` on `@getstrata/core/http/signedUrl`. HMAC uses `SIGNED_URL_SECRET` or `SESSION_SECRET`. Paths must be same-origin (`/` only; reject `//` and `://`).
- `markdownToHtml()` uses `Bun.markdown.html()` plus `sanitizeMailHtml` (allowlist). Scripts, `javascript:` links, and unknown tags are stripped.
- `ExportAuditLogsJob` (`@getstrata/core/jobs/exportAuditLogsJob`) wraps SIEM export so the scheduler can dispatch a real job.
- `DispatchWebhookJob` reads `APP_ENV` and `WEBHOOK_SIGNATURE_HEADER` instead of HiroApp `appConfig`.
- `createRequireWebAuthMiddleware` runs the handler inside `runWithAuthUser` so `currentAuthUser()` works on HTMX session routes.
- `generateTotpSecret()` / `buildOtpauthUrl()` on `@getstrata/core/security/totp`.
- `createRequireAbilityMiddleware` rethrows `ForbiddenError` for HTML views so HTMX routes render a styled 403 instead of JSON.

## 0.5.61

HTMX HTML kernel gaps that sibling apps could not work around without weakening CSP or duplicating the framework.

- `createSecurityHeadersMiddleware({ csp | htmlCsp | directives })` and `configureContentSecurityPolicy()` extend the HTMX baseline. Defaults now allow YouTube/Vimeo embeds, `https:` media, `data:`/`https:` images, the HTMX 2.0.4 indicator style hash, and a per-request nonce on `script-src`/`style-src`. API JSON stays `default-src 'none'`. Do not add `'unsafe-inline'` to `script-src`.
- `configureWebErrorView` / `notFoundHtmlResponse()` render styled HTML 404/403/500 (app `errors/*.eta` or kernel chrome with `/assets/app.css`). Production 5xx messages do not leak stacks.
- `EtaViewEngine` passes `Request` into `resolveLayoutData`. `configureWebLayoutData({ loadUser })` receives that request as the second argument. Layout data includes `cspNonce`.
- `securedBindRouteModelByKey` treats a null model as `NotFoundError`. GET ETags are skipped for HTML and composite objects unless `etag: true`.
- Login redirects keep `pathname + search` after a same-origin safe-path check (`/forum?page=2` → `/login?redirect=%2Fforum%3Fpage%3D2`).

## 0.5.60

- `@getstrata/core/database/boundConnection` publishes `getBoundDatabaseConnection` and `resetBoundDatabaseConnection` on the JS entry (not only the types file). Tests can reset the bound pool after `closeDatabase()` without a postinstall patch.
- `bindBunSql` / `createBunSqlPool` on `@getstrata/core/database/bunSql` create a Bun `SQL` pool and register it as the bound connection plus default pool.
- `xmlResponse` accepts `{ contentType }`. `rssResponse` sends `application/rss+xml`.
- Memory throttle buckets are per middleware instance. `resetMemoryThrottleForTests()` clears every instance map so nested HTTP tests do not leak.

## 0.5.59

Sibling HTMX apps can consume published packages without HiroApp-only glue.

- Emit `@getstrata/core/facades` types at `dist/core/facades/index.d.ts` (CI checks every `exports.*.types` path after build).
- Session and CSRF cookie names are configurable (`SESSION_COOKIE_NAME`, `CSRF_COOKIE_NAME`). Defaults remain `strata_session` and `strata_csrf` for HiroApp.
- Split `CORE_ABILITY_CHECKER_TOKEN` and `CORE_AUTH_USER_DIRECTORY_TOKEN` from `CORE_TOKEN_SERVICE_TOKEN`. HttpKernel prefers the ability-checker token; HMAC `SessionGuard` / layout data prefer the user directory. A compatibility shim uses `CORE_TOKEN_SERVICE_TOKEN` only when the bound value matches the requested type.
- `configureWebLayoutData` lets apps choose `currentUser` vs `authUser`, a custom user loader, and extra template fields. HiroApp still gets `{ authUser, csrfToken, flash }`.
- `redirectResponse`, `notFoundHtmlResponse`, `textResponse`, and `xmlResponse` join `htmlResponse` / `isHtmxRequest` on `@getstrata/core/view`.
- `LOGIN_RATE_LIMIT_WINDOW_MS` is a deprecated alias for `LOGIN_RATE_LIMIT_WINDOW_SECONDS`.

HMAC `SessionGuard` is unchanged for HiroApp API/token apps. Sibling HTMX apps should use `@getstrata/bootstrap` `CookieSessionStore` + `createCookieSessionAuthManager`.
