BetterIAM

Changelog

Every notable change to the Better IAM packages, newest first.

Unreleased

  • Organization sign-in addresses, custom hostnames, and regions. Guide docs/hosts-and-regions.md (site: Deployment → Sign-in addresses and regions).

    • hosts.patterns gives every organization with an alias its own address, like an AWS account sign-in URL: '{tenant}.signin.example.com', with the region if you like ('{tenant}.signin.{region}.example.com'), and '{tenant}.localhost:3000' in development. Requests on an organization's address are pinned to it: public sign-in calls act in it (tenantId may be left out), and another organization's tenantId, session, API key, or page is refused with HOST_MISMATCH (403). Organization origins are trusted like the deployment's own; cookies stay host-only. nodeHandler now keeps the request's Host when addresses are configured, and hosts.forwardedHost reads X-Forwarded-Host behind your own proxy.
    • Custom hostnames (hosts.customHostnames): new hostnames API group (add, list, verify, setPrimary, delete; actions iam:hostnames:*; collections tenantHostnames, hostnameOwners) verifies login.acme.com with a DNS TXT record (through domains.resolveTxt) and returns the CNAME to publish (hosts.cnameTarget). New codes HOSTNAME_TAKEN (409) and HOSTNAME_NOT_ALLOWED. Passkey ceremonies on a hostname outside the RP ID answer FEATURE_DISABLED.
    • Regions (regions: { current, regions, locate? }): Tenant.region (inherited from ancestors; organizations under the root default to the creating region), tenants.create({ region }), root-only tenants.setRegion (audit tenant:region). tenants.lookup, domains.discover, public sign-in calls, and organization addresses answer WRONG_REGION (421) with region and location (the sign-in URL in the home region) everywhere else; IamClientError gains region and location. regions.locate(alias) redirects aliases that live in another region's database.
    • tenants.lookup({ host }), and region / signInUrl in tenants.lookup and domains.discover results. iam.hosts (region, resolve, signInUrl, allowed for on-demand TLS checks). Every email and SMS message carries signInUrl, which renderDeliveryMessage passes to link builders; link builders now also receive the tenant ID for sign-in emails whose payload lacks it.
    • The Next.js and Express/Hono/Fastify/SvelteKit integrations keep the visitor's host on in-process calls, so server actions and loaders are pinned too.
  • Developer experience: the CLI is rebuilt on a declarative command registry, and everything it does is available from code. Guide docs/cli.md.

    • Every existing command keeps its flags, error codes, and results. New for all of them: --flag=value, better-iam help <command> / <command> --help with each flag's default and environment variable, "did you mean" suggestions, help --json (a machine-readable manifest, also cliManifest()), --format json|compact|table and --query PATH, CODE: message plus a Hint: line on failure, and exit status 2 for command-line mistakes (1 otherwise).
    • Configuration discovery: --config, then BETTER_IAM_CONFIG, then the nearest better-iam.config.{mjs,js,ts,mts,cjs} in the working directory or a parent, then BETTER_IAM_DATABASE_URL + BETTER_IAM_SECRET with no file (configFromEnv). Factories receive { command, env, cwd }. A configuration module may export cli = { defaults } (flag defaults per command or '*') and commands (project commands built with defineCommand, which get the same parsing, help, output, and completion). init --typescript writes better-iam.config.ts; the template is a side-effect-free defineConfig factory.
    • Token commands (config-*, analyze, report, mine-roles, check-invariants, whoami, and the new ones below) run in process or against a running server with --url / BETTER_IAM_URL, and read --tenant from BETTER_IAM_TENANT.
    • New commands: api GROUP.METHOD calls any HTTP API route (HTTPie-style key=value, key:=json, key:=@file.json, nested keys, --data @file|-; api --list shows every route and whether it needs a credential); login (password from BETTER_IAM_PASSWORD or a hidden prompt, authenticator or --email-code MFA, --with-token for API keys) saves sessions as profiles in an owner-only credentials file, with logout, profiles [use|remove], and token; can, explain, and who-can answer authorization questions; config-validate checks tenant configuration offline (--strict fails on names the file does not define); secret prints a new deployment secret; completion bash|zsh|fish|powershell.
    • Configuration as code: config-plan / config-apply / config-validate accept .mjs/.js/.ts modules whose default export is the configuration or a factory of { tenantId, env }, and config-export --output x.ts|x.mjs writes a typed module.
    • Code: @better-iam/server exports defineConfig, configOptions, and defineTenantConfig; @better-iam/cli exports runCli, createCli, defineCommand, main, runBinary, loadConfig, configFromEnv, findConfigFile, lintTenantConfig, loadTenantConfig, localTransport / remoteTransport, listRoutes, createProfileStore, formatResult, and selectPath. runCli's io gains optional err, prompt, stdin, cwd, and fetch. The umbrella better-iam package now installs the better-iam binary.
    • Changed: results that some commands printed on one line (purge, outbox, audit-prune, audit-export, config-export --output) are now indented like the others; use --format compact for one line.
  • Feature flags at platform and tenant level. Guide docs/feature-flags.md.

    • New features API group: create, update, delete, list, setTarget, listTargets, setOverride, and evaluate. Catalog actions iam:features:read, iam:features:manage, and iam:features:override act on iam/features/{key}. Audit actions feature:create, feature:update, feature:delete, feature:target, and feature:override. New error FEATURE_LOCKED.
    • Flags on the root tenant are platform flags. Any other tenant's flags reach its own subtree, and a key belongs to the tenant closest to the root, so tenants cannot shadow platform flags.
    • Resolution order: kill switch, then the closest target or tenant override (a tenant's override beats a target on the same tenant, and a locked target silences overrides beneath it), then a stable percentage rollout per branch, then the default. Targets can lapse (expiresAt), and internal flags stay hidden from tenants.
    • iam.features (evaluate, values, isEnabled) for server code. React hooks useFeatureFlags and useFeatureFlag. rolloutBucket is exported.
    • Decisions expose the flags that are on as the tenant.features context key, read only when a condition names it and stripped from resolveContext/plugin context. policies.test fills it in, and policy lint knows it.
    • Console: an Administration "Feature flags" page and an organization "Features" page.
    • Collections featureFlags and featureTargets are removed with their tenant on purge.
  • Temporary credentials (STS): AssumeRole parity, session tokens, signed session JWTs, and OIDC web-identity federation. Guide docs/temporary-credentials.md.

    • roles.assume takes sessionName, sourceIdentity, tags, format and audience, and accepts API keys and session tokens as sources. The source's scopes or session policy bound only the iam:roles:assume decision (re-checked on every use), not the role session, which acts with the role's permissions within the trust ceiling and its own policy. Durations are capped by the new sts.maxRoleSessionSeconds (default 3600, up to 43200) and a per-trust maxSessionSeconds; a duration outside the range is INVALID_INPUT. role:assumed is recorded in the target tenant.
    • Identity trusts gain maxSessionSeconds, passSourceAttributes, allowedTagKeys, sourceIdentityMode and description; new root-only trust.update. New analysis finding trust-passes-foreign-attributes.
    • sts.getSessionToken (iam:session-tokens:create): a biam_sts_ token for the caller's own identity from a user session or API key, bounded by an optional policy and the source's scopes, with an optional TOTP mfaCode step-up (auth:mfa:step-up) for the MFA-then-assume pattern; capped by sts.maxSessionTokenSeconds and sts.maxSessionTokensPerIdentity. New error CREDENTIAL_CHAINING_DISABLED.
    • sts.getCallerIdentity for every credential kind (CLI better-iam whoami), and roles.listSessions with allowlist projections that never carry hashes, policies or authority ids.
    • Revoke older sessions: roles.revokeSessions, trust.revokeSessions and oidcProviders.revokeSessions (iam:roles:revoke-sessions) move a sessionsRevokedBefore watermark and delete the matching rows; trust.revoke now deletes the trust's live sessions. identities.revokeSessions({ keepApiKeys: true }) ends everything but the API keys. oidcProviders.update also ends every session issued through the provider (watermark plus deletion, for good) when it changes jwks, jwksUri, algorithms, audiences, maxTokenLifetimeSeconds or clockToleranceSeconds, or disables the provider; a rename, a replayProtection change or enabling keeps them. Rotating static jwks therefore makes workloads exchange again.
    • Session JWTs (format: 'jwt', EdDSA/ES256) with the new sts.jwt option, GET {basePath}/.well-known/jwks.json, iam.sessionTokens (jwks(), verify()), and the runtime-neutral createSessionTokenVerifier (@better-iam/server/session-tokens, umbrella better-iam/session-tokens) with verifyRequest. IAM accepts a JWT bearer only with a matching stored session row.
    • Web identity (sts.webIdentity, off by default): tenant-managed oidcProviders, trust.create({ kind: 'web-identity' }) with claim conditions over token.<claim> keys (a token.sub pin is mandatory, else WEAK_TRUST_CONDITIONS), the public sts.assumeRoleWithWebIdentity exchange (uniform WEB_IDENTITY_REJECTED, per-trust rate limit, single-use replay protection, SSRF-guarded key fetch), and the trust.evaluateWebIdentity dry run.
    • Session-aware policy keys: principal.sessionId, tokenIssueTime, authTime, mfaTime, sessionTagKeys, sessionTags.<key>, sourceTenantId, sessionName, sourceIdentity, webIdentityProvider, webIdentitySubject, and request.sourceIp; audit events and webhook bodies carry sessionContext. New audit actions role:assumed, role:assumed-with-web-identity, session-token:issued, role:sessions-revoked, auth:mfa:step-up; new catalog actions iam:trust:update, iam:roles:revoke-sessions, iam:session-tokens:create, iam:oidc-providers:*. @better-iam/server now depends on jose.
    • Integrations: the client accepts JWTs and prefixed tokens, @better-iam/next principals add kind session-token with sessionName/sourceIdentity, the NestJS guard's CSRF check matches the bearer scheme case-insensitively, and the console labels the new kinds.
    • Migration: new tokens are prefixed and checksummed (biam_ses_, biam_key_, biam_rol_, biam_sts_, 58 characters; credentialTokenScanPattern for scanners). Legacy unprefixed tokens still work until they expire. Tooling that assumed 43-character tokens needs updating.
    • Migration: auth.requireRecent now refuses temporary credentials (role sessions and session tokens) with RECENT_AUTH_REQUIRED. Role sessions previously passed for five minutes after their source signed in; perform those operations from a signed-in session.
    • Migration: sign-in results no longer carry tokenHash: SessionResult.session is now SafeSession for every flow, in process and over HTTP.
    • Migration: new cross-tenant identity trusts default to passSourceAttributes: false, so source attributes no longer reach their role sessions unless enabled. Existing trusts keep passing them; turn it off with trust.update (the new finding lists them). trust.create and trust.list return the public projection (requiresExternalId, never externalIdHash), and the roles.assume response is a superset of the previous { token, session }.
    • Migration: credentials.revoke only accepts API keys (INVALID_CREDENTIAL for any other session id).
    • Migration: the new server-owned context keys (and every existing principal.* key, request.time, request.sourceIp and principal.sessionTags.*) now strip same-named values supplied by resolveContext or plugins, even when the server leaves the key absent. The new session names are reserved: identity attributes named sessionId, tokenIssueTime, authTime, mfaTime, sourceTenantId, sessionName, sourceIdentity, sessionTags, sessionTagKeys, webIdentityProvider or webIdentitySubject fail with INVALID_CONFIG.
  • React Router: new package @better-iam/react-router (umbrella better-iam/react-router) for framework mode, v7.9+ and v8. createIamRouter(iam, options) gives:

    • middleware for the root route: per-request helpers in the router context, with cookies from the in-process client put on the response
    • api for an api/iam/* resource route
    • guard(loader, spec): login and step-up redirects (.data-aware next), and a 403 data({ code, message }) for error boundaries or deniedRedirect
    • action(fn, spec): an Origin check first, then IAM refusals returned as data({ code, message }, { status })
    • helpers(args), requireSession, require, and sessionData

    Guide docs/react-router.md, runnable examples/react-router (react-router-serve, typegen + tsc, smoke test), tests tests/react-router.test.ts.

  • Express, Hono, and Fastify: new package @better-iam/middleware (umbrella better-iam/express, better-iam/hono, better-iam/fastify, and better-iam/middleware). createIamExpress / createIamHono / createIamFastify do four things:

    • serve the IAM API (Express and Fastify through iam.nodeHandler, so node-only protocol mounts listed in serve work too; Express also accepts bodies a parser already consumed and restores a stripped mount path)
    • attach per-request helpers (req.iam, c.get('iam'), request.iam): memoized getSession, requireSession, require, batched can, authorize, listAccessible, assertion, credential, signOut, and an in-process typed client whose cookies land on the response
    • provide requireSession({ stepUp }) / authorize(action, { resource, tenantId, stepUp }) route guards, which also refuse cookie-authenticated unsafe requests from untrusted origins (UNTRUSTED_ORIGIN / CSRF_REJECTED; trustedOrigins, csrf: false, exported checkRequestOrigin)
    • map refusals to the IAM JSON envelope, or to loginPath / stepUpPath redirects for page navigations (errorHandler / onError)

    The framework-neutral core (createRequestHelpers, enforceGuard, refusalResponse, checkStepUp, IamRequestError) now also backs @better-iam/svelte/kit. Guide docs/node-frameworks.md, tests tests/middleware.test.ts (real Express and Fastify servers, a Hono app).

  • SvelteKit and Svelte: new package @better-iam/svelte (umbrella better-iam/svelte and better-iam/svelte/kit). createIamKit(iam, options) gives SvelteKit apps:

    • a handle hook that serves the IAM API and enforces protect path rules (login redirect with ?next=, step-up page, authorize with deniedRedirect or a 403 carrying the IAM code)
    • per-request event.locals.iam: getSession, requireSession, require, batched can, authorize, listAccessible, assertion, credential, signOut, and an in-process typed client whose Set-Cookie answers land in event.cookies, so sign-in forms work without JavaScript
    • guard(load, spec) for server loads, action(fn, spec) for form actions (IAM refusals become fail(status, { code, message })), sessionData(event), safeRedirectPath, and checkStepUp

    The browser entry createIam({ client, initialSession }) gives Svelte 4/5 stores: session, authorize, can, and accessible. Inputs can be stores, and initial values come from server loads, so hydration needs no second request. It also exports setIamContext / getIamContext. Guide docs/sveltekit.md, runnable examples/sveltekit (adapter-node, smoke test, svelte-check), tests tests/svelte.test.ts and tests/svelte-kit.test.ts.

  • Storage queries run in SQL: find() filters become typed JSON conditions evaluated by the database, with pagination in SQL when the whole filter is expressible. Hot lookup fields (INDEXED_FIELDS: session token hashes, identity/group/role ids, email, OAuth artifact hashes, delivery queues) are indexed: partial expression indexes on SQLite/libSQL, one jsonb_path_ops GIN index on PostgreSQL, and id-ordered tenant and natural-key indexes. A session lookup among 20,000 sessions on SQLite fell from ~26 ms to ~0.25 ms. The SQLite adapter caches prepared statements. Schema steps are recorded by name in iam_migrations (new migration 0002_query_indexes). Results are ordered by id in code-point order on every adapter. Identifiers (collection, id, tenant, natural key) with an unpaired surrogate are refused (INVALID_RECORD) instead of silently aliasing the U+FFFD spelling; reads and deletes treat them as absent. Field values still accept any string; the PostgreSQL adapter stores U+0000 and unpaired surrogates in a reversible jsonb-safe encoding (encodeJsonbDocument / decodeJsonbDocument), and migration 0002 rewrites existing rows that need it. SQLite and libSQL compare numbers by their JSON text, so integers above 2^53 and extreme exponents match exactly. PostgreSQL migrations wait up to ten minutes for another instance's migration. New exports: RecordDriver.query, planQuery, MAX_QUERY_CONDITIONS, JSONB_ESCAPE_KEY, sqliteSelect, postgresSelect, applyMigrations, schemaMigrations, matchesFilter, compareIds, storableString, and the adapter conformance suite @better-iam/core/conformance (adapterConformanceCases, runAdapterConformance), now run against SQLite, libSQL, and PostgreSQL.

  • Storage operations:

    • Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI store-export --output FILE writes a consistent JSON Lines snapshot with a header and a counting trailer. store-import --input FILE loads it into an empty database in one transaction and rolls back on a truncated, corrupt, or refused snapshot. store-copy --target-config FILE copies directly. Copying a database onto itself fails with SAME_DATABASE. Core exports exportStore, importStore, and copyStore.
    • SQLite and libSQL: a second adapter instance of the same kind on the same file, used inside the first one's transaction, now fails with DATABASE_IN_USE instead of waiting forever for its own caller's lock. Paths are compared after resolving links, Windows short names, and file:// URLs. A SQLite and a libSQL adapter on one file still wait for the busy timeout and fail with STORAGE_BUSY.
    • Retention sweep: iam.sweepExpired({ limit, batchSize, deliveryRetentionMs, graceMs }) and CLI sweep [--limit N] [--retention-days N] delete, in short batches, records that nothing uses any more and that until now accumulated forever. API keys and other session kinds, invitations, access requests, usage records, and pending deliveries are kept. Migration 0004_expiry_indexes indexes expiresAt, deliveredAt, and failedAt across each collection; the sweep walks these indexes once per run and steps over records it keeps. Shared Signals deliveries now record failedAt, and dispatched audit hooks record deliveredAt (tests/retention.test.ts). Swept:
      • user and role sessions, trusted devices, and relationship tuples past expiry;
      • OAuth artifacts and login states (grants 31 days after expiry), and SAML request, relay, and replay records;
      • delivered or abandoned outbox messages and abandoned Shared Signals deliveries past the retention;
      • dispatched audit hook rows.
    • find() accepts an id cursor (after) for keyset pagination, served by the primary-key and tenant indexes; snapshots page with it, so exporting a large audit log does not slow down page by page.
    • Migration 0005_lookup_indexes indexes sourceSessionId and trustId (SQLite and libSQL). Lookup indexes are now listed per schema step (LOOKUP_INDEX_STEPS), so released steps never change; INDEXED_FIELDS is their union.
    • Continuous audit archiving: the auditArchive: { write(batch), batchSize } option and iam.archiveAudit() (CLI audit-archive [--tenant ID] [--limit N]) copy each tenant's audit chain to independent storage.
      • Batches are verified and in chain order, and a per-tenant cursor records what has been archived.
      • A per-tenant lease lets one run at a time archive a tenant (busy lists the others).
      • createJsonlAuditArchive({ directory }) is a crash-safe, write-once file sink (ARCHIVE_CONFLICT for a different batch under an existing name). CLI audit-verify-archive --directory DIR --tenant ID verifies an archive without the database.
      • Broken chains (AUDIT_CHAIN_BROKEN), sink failures, and gaps are reported rather than skipped.
      • Once a tenant has an archive cursor, pruneAudit deletes only events the archive holds, in every process, and reports heldForArchive; doctor reports audit-archive-behind (tests/audit-archive.test.ts).
      • better-iam init configurations read BETTER_IAM_PREVIOUS_SECRETS.
    • Deployment secret rotation: the new previousSecrets option keeps authenticator secrets, webhook secrets, pending deliveries, emailed links, and assertions sealed or signed under an old secret working. New values use secret. iam.rotateSecrets() (CLI rotate-secrets [--dry-run]) re-seals stored values with the current secret. iam.assertionKeys() lets downstream services accept both keys during the switch. verifyAssertion, IamAssertionModule.forRoot, and the Next.js verifyAssertionToken all accept a key list. The run reports complete and done, and doctor never calls a partial sample done (secret-rotation-unverified). The deployment guide describes a staged rollout that is safe with several instances. The outbound SCIM provisioner takes previousEncryptionKeys and offers rotateKeys(), for keys an application derives from the secret. doctor reports values still pending rotation, and values no configured secret opens (the secret was replaced outright). Auth exports openSecret, and decryptSecret accepts a list of secrets (tests/secret-rotation.test.ts).
    • Deployment self-check: iam.selfCheck() reports findings with a severity and a fix: schema behind, not bootstrapped, SQLite durability that can corrupt, in-memory or asynchronous-commit storage, a placeholder secret, a short metrics token, no email transport, and scheduled jobs that are not running (sweep backlog, lapsed records purge should have removed, stalled or abandoned outbox messages, stalled audit hooks). doctor prints them, and doctor --strict exits non-zero on any error or warning (tests/self-check.test.ts).
    • doctor adds storage from the new optional IamStore.describe(): schema version, applied migrations, record counts per collection, and adapter settings. A database that cannot be reached fails describe() instead of reporting as empty.
    • Ordered reads: optional IamStore.findOrdered and the findOrdered helper page by a numeric field in SQL (migration 0003_ordered_indexes). Audit list and export and pruneAudit use it, so their cost follows the page size instead of the log length. Optional IamStore.collections() lists collections.
    • SQLite file databases default to WAL with synchronous = FULL; journalMode: 'delete' and durability: 'normal' opt out.
    • Session validation writes lastSeenAt at most once a minute, or once per tenth of the idle timeout when that is shorter.
    • Authorization reads a subject's bindings with indexed lookups per group instead of scanning the tenant's bindings.
    • instrumentStore and summarizeStoreCalls report storage calls without values; pnpm bench:scale measures hot paths at a configurable deployment size.
    • Tests: tests/store-snapshot.test.ts, tests/store-describe.test.ts, and tests/retention.test.ts.
  • Security: an assumed-role session whose source account owned another tenant passed the target tenant's owner gates (identities.setOwner, owner offboarding) and satisfied principal.owner / principal.rootAdmin policy conditions there. Those gates now require an owner of the target tenant on an ordinary session, and role sessions report principal.owner and principal.rootAdmin as false (tests/owner-gates.test.ts).

  • auth.getSession and auth.listSessions no longer return a session's uniqueKey, which held its token hash; SafeSession omits it.

  • Role mining: roleMining.suggest (iam:analysis:read) finds direct bindings a group already covers, roles every member of a group holds directly, roles with identical grants, and role bundles many people hold together (closed itemsets, skipped when a package or inheriting role already matches); roleMining.apply (iam:analysis:update) carries out the binding suggestions in one transaction under the original grant authority; roleMining.outliers reports access few peers (same manager or identity attribute) hold and access most peers hold that a person lacks. CLI mine-roles, console Role mining page.

  • Access usage tracking: the accessUsage option records which actions each identity was allowed to use (authorization checks and provisioning operations, not root overrides or impersonation), buffered in memory and written in batches to the new accessUsage / accessUsageTracking collections (iam.flushAccessUsage() writes immediately). roleMining.usage lists it and roleMining.rightSize reports bindings whose holders used none or only some of a role's actions in a window, plus per role the actions nobody used. Console: a Least privilege card on the Role mining page (the console enables tracking).

  • Console redesign: grouped, collapsible sidebar sections with a "Jump to…" page filter; a Governance hub per organization (urgency-ranked "Needs attention" list and tiles for findings, invariants, separation of duties, unused access, role mining, certifications, approvals, and terms of use); a "Governance at a glance" card on the overview; and an Organization health page in the administration panel that ranks every organization by findings and broken invariants.

  • Review fixes for the governance features: the usage recorder's timer and early writes no longer inherit the async context of the transaction that recorded first (which broke periodic writes and could abort authorize), failed writes back off; listAccessible counts as usage and root overrides do not; enforced invariants also guard resource deletion, root grants, campaign closes, and agreement publishing, evaluate every subject, and refuse changes that make them unevaluable; role mining no longer treats package-owned memberships as cover or suggests group bindings for groups with inactive members; sign-in evidence (recommendations and the dormant-access finding) ignores "view as" sessions; accessPaths.find masks deny reasons; impact.preview is refused while impersonating.

  • Review recommendations: roleMining.reviewRecommendations suggests keep or revoke for each access-certification item from account status, recorded usage (when it covers the window), or the last sign-in, with the reason; the console's campaign page shows them.

  • Change impact preview: impact.preview (iam:policies:simulate) applies a candidate role update, policy document, or role deletion with the real validation and edit rights inside an always-rolled-back transaction and reports, per holder of the affected roles (including inheritors and group members) and per resource, the actions gained and lost. Console: a Change impact page.

  • Access invariants: invariants.create/update/delete/list/run (built-in actions iam:invariants:read|manage, collection accessInvariants) state who may, or must never, perform an action on a resource (one identity, a group, everyone with an attribute value, or everyone). run evaluates them; enforce mode re-checks them around every access-changing operation and refuses a change that newly breaks one with INVARIANT_VIOLATION, while pre-existing violations do not block unrelated work. impact.preview reports invariants a candidate change would break or fix. Console: an Access invariants page. CLI check-invariants --tenant ID [--fail-on-broken]. Scheduler job iam.checkInvariants() / CLI monitor-invariants records invariant:broken and invariant:restored audit events once per status change (lastCheck on each invariant).

  • Terms of use: agreements.create/update/delete/list/status (built-in actions iam:agreements:read|manage, collections agreements / agreementAcceptances) publish versioned agreements with optional periodic re-acceptance; members call agreements.listMine / accept without a permission (audited agreement:accept). Policies see the new context keys principal.agreements and principal.pendingAgreements (known to the linter and reserved from identity attributes), so a deny statement can require acceptance. Console: a Terms of use page and an acceptance banner for members.

  • Self-service access paths: accessPaths.find({ tenantId, action, resource }) tells a denied person what they could do themselves — step up to MFA, accept pending terms of use, activate one of their eligible bindings, or request a requestable package — each verified by simulating it in a rolled-back transaction; needs only an ordinary session.

  • @better-iam/react and @better-iam/vue: useAgreements (pending terms of use and accept) and useAccessPaths (self-service ways to be allowed).

  • Configuration as code covers invariants (subjects by group name, member email, attribute, or everyone) and agreements (a content change publishes a new version); both kinds are exported only when present, so existing documents are unchanged.

  • Birthright access packages: autoAssign rules (policy-condition include/exclude clauses over identity attributes, kind, owner flag, email, email domain and verification, manager, and direct group memberships) give a package to every matching identity and take it away when they stop matching, with an optional grace period. Rules run under their owner's grant authority and rights, re-checked on every run (suspended, never widened, when the owner leaves or loses rights). They reconcile after identity changes and rule saves, through packages.reconcile, and through the new scheduler job iam.reconcilePackages() / better-iam reconcile; brakes hold back unusually large unattended changes until confirmed. Also: packages.previewAutoAssign, listAssignments({ source }), automatic on assignments, packages.assign taking over automatic assignments, configuration documents carrying autoAssign (with group names), the console rule editor and preview, the new collection packageRuleIssues, and package:auto-* audit events.

  • @better-iam/next auth forms, step-up, service credentials, and background work:

    • Auth forms. iamNext.authActions(options) (and createAuthActions) returns drop-in server actions: signIn (password or emailed sign-in code, then authenticator, emailed, or recovery codes, first-time enrollment with one-time recovery codes, 'keep me signed in' and 'remember this device'), reauthenticate (step-up that ends the session it replaces), signOut (clears a stale cookie too, through the new clearSessionCookie()), requestPasswordReset, resetPassword, signUp, verifyEmail, and acceptInvitation. The organization is resolved from tenantId, an org slug, resolveTenant, or the email's verified domain. They return a serializable AuthFormState that never holds passwords, codes, tokens, or sessions. @better-iam/next/client adds matching unstyled, accessible forms that work without JavaScript (SignInForm, ReauthenticateForm, PasswordResetRequestForm, PasswordResetForm, SignUpForm, InvitationForm, each with a *View twin).
    • Step-up. stepUp: { mfa: true | 'fresh', maxAgeMs } on page, route, action, requireSession, pages.withSession, and pages.api redirects pages to stepUpPath?next=&reason= and answers handlers and actions with MFA_REQUIRED, RECENT_AUTH_REQUIRED, or IMPERSONATION_RESTRICTED (403). checkStepUp is exported, and page errors carry a BETTER_IAM_STEP_UP: digest for error.tsx.
    • Service credentials. apiRoute() accepts API keys and assumed roles through iam.authenticate and passes a whitelisted IamPrincipal.
    • Background work. dispatchAfterResponse schedules outbox and event dispatch with after() after every in-process call and every POST to the mounts. iamNext.background offers dispatch(), schedule(), and cron(), a route for schedulers: bearer CRON_SECRET, fails closed, and runs isolated tasks (purge, audit retention, digest, reminders, outbox, events).
    • Hardening. route(), apiRoute(), and pages.api() refuse cookie-authenticated cross-origin mutations (CSRF_REJECTED / UNTRUSTED_ORIGIN; new trustedOrigins option) and map only IamError / IamClientError to responses. safeRedirectPath no longer lets dot segments (/.//evil.example) normalize into a protocol-relative redirect. sessionForClient() and pages.withSession props drop the session's uniqueKey. Sessions refused for network or tenant-tree reasons read as signed out. The umbrella better-iam/next/client re-exports by name, because Next rejects export * in a 'use client' module.
    • Example. examples/nextjs uses all of this: the login, step-up, and password-reset pages, a development inbox, apiRoute, and a cron route.
  • SCIM directory sync: the enterprise extension's manager becomes Identity.managerId (mapManager, on by default; by SCIM ID, externalId, or userName, back-filled when the manager arrives later, loops and self-references skipped, administrator-set managers never cleared). SCIM can no longer reactivate or regroup an identity an administrator deleted. createScimService serves a JSON administration API for consoles at {adminBasePath}/connections/{list,create,rotate,revoke,groups,mappings} (default /scim/admin; CSRF header, Origin check, 64 KiB bodies) and gains listGroups. Console: a Directory sync page (connections with their SCIM endpoints, one-time tokens, rotation, revocation, and group-to-role mappings), inbound SCIM mounted at /api/iam/scim/v2, and the console's IAM route now forwards PUT, PATCH, and DELETE.

  • Policy linter: analysis.lintPolicy (and the exported lintPolicy) checks a candidate or stored document against the catalog and reports statements that are valid but probably wrong: unknown context keys such as request.ip, denies on optional keys that silently never fire, negated conditions over unresolvable variables, arrays compared with string operators, type mismatches, shadowed allows, duplicates, and wildcard administration. Its shadowing check is budgeted against adversarial documents. Console: a Lint card with a draft checker on each policy page.

  • Access analysis gains standing-privileged-access, unused-eligible-binding, orphaned-manager, manager-cycle, and policy-lint findings.

  • Manager-review certifications: reviewerMode: 'manager' routes each person's items to their manager, who decides them with certifications.review without holding a certification permission; certifications.listMine, certifications.remind (certification-reminder emails), and autoClose with the deployment job iam.closeOverdueCertifications / CLI close-certifications. renderDeliveryMessage renders certification-review and certification-reminder with a links.certification button. Console: "Assigned to you" review queue, reviewer column, "Remind reviewers", and manager-mode, due-date, and auto-close fields.

  • Separation of duties also covers access-package assignment and approved package requests.

  • Access-package and access-lifecycle hardening from a multi-agent review:

    • packages.extend now needs the rights assign needs, plus a grant authority, whenever it lengthens an assignment or removes its end. It moves the package's bindings to the extender's authority. Shortening still needs only iam:packages:assign.
    • Request and activation decisions are refused from impersonation sessions (IMPERSONATION_RESTRICTED).
    • packages.listApprovals lists only requests the caller holds iam:packages:approve on.
    • Requests that name approvers but would reach none are refused.
    • A request lapses no later than the end it asks for. listRequests reports lapsed requests as expired.
    • Tightening a package cancels pending requests that no longer fit it. A direct assign marks the pending request approved.
    • Package bindings are the assignment's own (their own uniqueness key), so they never replace or depend on hand-made grants.
    • Packages that share a group hand its membership to each other.
    • Hand edits (bindings.update, groups.updateMember, re-adding a lapsed member) take a package's record over.
    • Assignments whose bindings no longer grant are reported broken and can be assigned or requested again.
    • Offboarding revokes package assignments under package ownership.
    • groups.delete refuses an approver group still in use.
    • Offboarding hands reports to a successor without creating self-management or cycles. A manager must be active, and tombstones drop their managerId.
    • The access report ignores disabled expired identities, lapsed memberships, and activations that can no longer grant. The purge worker removes the activations of purged bindings and disabled identities.
    • Expiry-reminder dedupe is kept in a new expiryReminderMarks collection, so it survives audit pruning.
    • Configuration documents validate package and binding durations, treating null as no cap, and match package names case-insensitively, so a case-only difference is a rename.
    • bindings.update accepts a managerApproval-only change.
    • CLI remind --within-days is limited to 1–365.
    • The console member page keeps name and attributes, manager, and deactivation in separate forms.
  • Shared Signals Framework transmitter: createSharedSignalsTransmitter (better-iam/oauth) turns IAM activity into signed Security Event Tokens (RFC 8417) and pushes them (RFC 8935) to each tenant's receivers. It covers CAEP session-revoked (sign-outs and administrator or tenant-wide revocations) and credential-change (password, authenticator, passkey), and RISC identifier-changed (email), account-disabled (offboarding, expiry), and account-purged. Subjects are iss_sub or email, events carry initiating_entity and the audit event as txn. Streams are managed by tenant administrators (createStream/listStreams/getStream/updateStream/deleteStream, built-in actions iam:ssf:streams:* on the new internal resource type ssf; audited) with encrypted receiver credentials, event and subject filters, pause and resume, verifyStream verification events, delivery history, retries with backoff (dispatch), and /.well-known/ssf-configuration metadata. New collections ssfStreams and ssfDeliveries.

  • MCP-ready OAuth: dynamic client registration (RFC 7591) at the discovered registration_endpoint when createOAuthProvider({ registration }) is set. Registrations present a tenant-scoped token from createRegistrationToken (listRegistrationTokens and revokeRegistrationToken manage them; tokens are stored hashed with a use limit) or pass the host's registration.anonymous hook. New clients are bound to that tenant, public with PKCE unless allowConfidential, limited to the authorization code and refresh grants, HTTPS or loopback or reverse-domain redirect URIs, and the allowed scopes and resources. Nothing the provider would fetch can be registered. They are audited and listed with registeredVia. New collection oauthRegistrationTokens. RFC 9728 protected resource metadata (protectedResourceMetadata, protectedResourceMetadataUrl, createProtectedResourceHandler), and the verifier's challenge(error, realm, { resourceMetadata, scopes }) points clients at it. createResourceGuard combines metadata serving, token verification, and RFC 6750 challenges into one check(request) for MCP servers and other APIs. Plain OAuth 2.1 authorization codes (no openid) now receive a refresh token when the client is registered for the refresh grant, since MCP hosts send neither offline_access nor prompt=consent. OpenID requests are unchanged. tests/oauth-mcp-integration.test.ts runs the whole chain against a real server: challenge, discovery, registration, consent, the guarded call, and revocation on sign-out.

  • Console email verification: /cloud/verify-email completes a verify-email link on a click, the account page shows an unverified address with a "Resend verification email" button, and the deliveries page opens verification links in development.

  • Security notices link home: renderDeliveryMessage accepts the message's tenantId and a links.account({ tenantId }) builder; new-sign-in and sign-in-failures emails then carry a "Review your account" button (text and HTML). The console's deliveries page points it at the organization's account page.

  • Cookie options: http.cookieSameSite (lax by default, or strict) and http.persistentCookies (true by default). A request that issues a session may send X-Better-IAM-Persistent: 0 (or 1) to receive a browser-session cookie that disappears when the browser closes, or a lasting one, regardless of the default; the server session keeps its own lifetime either way. Preflight responses now also allow X-Request-Id and X-Better-IAM-Persistent. Console: a "Keep me signed in on this browser" checkbox on the login page, carried through the second-factor step.

  • Discoverable passkey sign-in: auth.beginPasskeyAuthentication({ tenantId }) without an email issues options that name no credential, so the authenticator offers its own passkeys for the relying party, and finishPasskeyAuthentication finds the account from the presented credential (user handle and ownership are still checked; disabled accounts are refused). Such anonymous starts are limited per address at ten times the ordinary allowance. Console: the login page offers passkeys in the email field through browser autofill as soon as it loads, and "Sign in with a passkey" no longer needs an email.

  • Client options: onUnauthenticated(error) runs once per request the server refused as UNAUTHENTICATED (a lapsed or revoked session; never a wrong password), so an application redirects to its login page in one place; retryRateLimited (true or { maxWaitMs }, off by default) retries a RATE_LIMITED call once after the server's retryAfterMs when that wait is short enough; requestId (true or a function) sends an X-Request-Id on every request, which the server echoes and records on its spans, and IamClientError.requestId carries it for support tickets.

  • Named passkeys: finishPasskeyRegistration accepts a name (at most 64 characters; the default names the device kind from the authenticator's transports), listPasskeys returns name, createdAt, lastUsedAt (stamped by passkey sign-ins and passkey MFA), deviceType, backedUp, aaguid, and transports newest first, and auth.renamePasskey({ id, name }) relabels one (audited as auth:passkey:rename). Console: the account page names each passkey with "synced" or "this device only", when it was added and last used, an inline rename, and a name suggestion (browser and system) when adding one.

  • Tenant policy bindSessionsToIp: a user session is accepted only from the client IP it was issued from; presented from another address it is refused with SESSION_NETWORK_MISMATCH (401) and the attempt is recorded in the person's trail as auth:session:mismatch with both addresses, so a stolen cookie is useless elsewhere and the person signs in again from the new network. Sessions and requests without a recorded address are not judged. Console: the toggle and status on organization settings.

  • Network blocks: security.blockNetwork({ tenantId, network, reason, durationMs?, platform? }) (iam:security:manage, recent authentication, audited as security:network-block) refuses every authentication flow and live session whose recorded client IP falls in an IPv4/IPv6 address or CIDR block with IP_BLOCKED, before rate limits or credentials are examined; root administrators set platform blocks on the root tenant for the whole installation, organizations block for themselves, a durationMs (one minute to a year) makes a block lapse, security.unblockNetwork lifts it, and security.listBlocks (iam:security:read) reports them with active. The caller's own address is refused. Console: "Block for a day" per source address and a platform block list on the admin Sign-in failures page, and a "Blocked networks" card on organization settings.

  • Brute-force defenses: authentication.rateLimits.ipAttempts (off by default) caps attempts per client IP and tenant across every authentication flow, on top of the per-account limits, so credential stuffing and password spraying from one address run out no matter how many accounts they name (needs a recorded IP; identities.unlock never clears it). authentication.failedSignInAlerts (off by default; needs sendEmail) queues one sign-in-failures email (attempts, time, ip, userAgent; rendered by renderDeliveryMessage) the moment a person's failed attempts since their last sign-in reach that number, once per streak. The console enables the alert at five attempts whenever it has a mail transport, and the admin panel gains a Sign-in failures page: failed attempts across organizations for the last hour, day, or week, grouped by source address and by targeted account (with the account's current streak and an unlock), plus the latest attempts. Console session, device, and activity lists now name clients ("Chrome 128 on Windows · 203.0.113.7") instead of showing raw user-agent strings.

  • Idle-timeout warning: auth.getSession returns limits (lifetimeMs, idleTimeoutMs, and idleExpiresAt, honouring the tenant policy), and both console panels warn two minutes before a session lapses for inactivity (or reaches its maximum length) with a countdown, "Stay signed in", and "Sign out now", returning to the login page once it has lapsed (which then explains the inactivity or the reached lifetime via ?reason=idle|expired); activity in the tab keeps the session alive without a prompt.

  • Sign-in record and failed attempts: a wrong password, authenticator or emailed code, or recovery code presented for a real, active account is recorded as an auth:signin:fail audit event (metadata.reason, ip, userAgent) in its own transaction and counted per person; auth:session:create events carry the method and client too. Every new session from a sign-in flow carries session.previousSignIn (previous sign-in time and client, failedAttempts since, latest failed attempt's time and client; absent on a first sign-in) and the count restarts. auth.listSecurityEvents returns event metadata. Console: a dismissable notice above every page when attempts failed since the previous sign-in, and "Previous sign-in" / "Failed attempts since" on the account page, whose security activity now names the reason and client of each event.

  • docs/enterprise.md walks one customer organization through verified domains, SAML or Entra ID single sign-on, sign-in policy, SCIM provisioning in and out, end-to-end offboarding, and audit. The console runs a periodic outbound provisioning sync so that expiries, which emit no event, still reach connected applications.

  • HTTP hygiene and account status: every JSON response carries X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer beside Cache-Control: no-store; a plain X-Request-Id is echoed on responses and recorded as requestId on http spans. auth.listSessions marks the calling session with current, and auth.mfaStatus reports the authenticator state, unused recovery codes, passkeys, remembered devices, and whether the session passed MFA (the console account page warns when recovery codes run low). The admin panel gains a Live sessions page: every unexpired session across organizations, filtered by organization, kind, name, email, or IP, with impersonators marked and a per-person sign-out.

  • Console sign-in links and codes: the login page offers "Email me a sign-in link or code" (passwordless email is enabled whenever the console server has a mail transport); a link is redeemed at /cloud/magic on a click, a six-digit code is typed on the login page itself (startPasswordless with kind: 'code', finishPasswordless with the code, resend and back), and the shared MfaChallenge step (authenticator, enrollment, recovery code, emailed code, passkey, remember-device) now serves password and link sign-ins alike; mfaChallengeState prepares it from any mfaRequired outcome. The deliveries page opens magic-link links in development. tenants.usage reports mfaEnrolled (people with an authenticator or a passkey) and the settings usage card shows MFA adoption.

  • Managers: Identity.managerId (identities.create/update({ managerId | null }): another active identity of the tenant, never oneself or a report, so reporting lines stay acyclic), identities.listReports, and offboarding hands a manager's reports to the successor (reportsReassigned) while deleting an identity clears them. managerApproval on an eligible binding or an access package lets the requester's manager decide on activation and package requests (alongside an approver group, if one is named) and emails them each request. The configuration document carries the flag; the console shows the manager and reports on the member page and offers the option on the assign-a-role and package forms.

  • Expiry reminders and assignment extension: iam.sendExpiryReminders({ tenantId?, withinMs? }) (CLI remind, a deployment operation like digest) emails each person whose account, direct role bindings, group memberships, or package assignments end within the window (seven days by default) one expiry-reminder message listing them (items as JSON with kind, name, and end), once per item and end date, recorded as identity:expiry-reminder; moving an end brings a fresh reminder. packages.extend({ packageId, identityId, expiresAt | null }) moves the end of an assignment and of every binding and membership it created together (audited as package:extend; console card on the Access packages page).

  • Self-service package requests: a requestable package (optionally with an approverGroupId) can be asked for with packages.request({ packageId, expiresAt?, justification? }) under iam:packages:request; the request waits for the tenant's approvalLifetimeMs, approver-group members are emailed (package-request), and packages.approveRequest / denyRequest (iam:packages:approve on the package, group membership when one is set, never one's own) decide it, assigning under the approver's authority and emailing the requester (package-decided). packages.cancelRequest, listRequests({ packageId?, identityId?, status? }), listApprovals, and listMine (requestable packages with the caller's status on each) complete the flow; lapsed requests are swept by the purge worker (expiredRequests), offboarding cancels pending ones, the configuration document carries requestable / approverGroup, and everything is audited as package:request, package:request-approved|denied|cancelled. packages.revoke now needs only iam:packages:assign on the package (the assignment owns its records, whichever authority issued them). Console: request/approve cards on the Elevate page, request settings and a requests table on the Access packages page.

  • Access packages: packages.create/update/delete/get/list bundle roles and groups (AccessPackage, actions iam:packages:create|read|update|delete|assign). packages.assign({ packageId, identityId, expiresAt?, justification? }) grants the whole bundle in one transaction as ordinary identity bindings and group memberships tagged with the assignment, skipping what the person already holds, honoring the package's maxDurationMs and requireJustification, and requiring iam:bindings:create on each role and iam:groups:update on each group besides iam:packages:assign; packages.revoke removes exactly what the assignment added; packages.listAssignments lists holders. Assignments end with their expiry (PurgeResult.expiredAssignments), go with offboarding and deletion, block deleting a packaged role or group or a held package (RESOURCE_IN_USE), travel with the configuration document (packages by name), and are audited as package:assign / package:revoke. Console: Access packages page.

  • Access digest: iam.sendAccessDigest({ tenantId?, withinMs?, unusedForMs?, minimumIntervalMs? }) (CLI digest, a deployment operation like purge) emails the owners of every active organization whose access report has findings an access-digest message (counts plus the full report as JSON), at most once per interval (20 hours by default) per organization, recorded as tenant:access-digest. groups.addMembers adds up to 100 members in one transaction with an optional shared expiry; the console group page adds several at once.

  • Temporary group memberships and future-dated bindings: groups.addMember({ expiresAt }) and groups.updateMember({ expiresAt | null }) make a membership end by itself (with every grant and activation it carried); listMembers and identities.listGroups report membershipExpiresAt, re-adding a lapsed member renews it, and the purge worker removes lapsed records (expiredMemberships). Bindings accept startsAt (bindings.create/update, null clears): the grant is listed with its start but applies only from then. The access report gains starting bindings and expiringMemberships. Console: "Member until" on the group page, "Starts on" / "Ends on" when assigning a role, and membership expiry on the member page.

  • Tenant access policy: tenants.setAccessPolicy({ tenantId, accessPolicy | null }) (iam:tenants:update, recent authentication, audited as tenant:access-policy) sets organization-wide floors for just-in-time activation: maxActivationMs caps every binding, requireJustification / requireMfa / requireApproval apply to every eligible binding, and approvalLifetimeMs sets how long requests wait. Bindings can only be stricter. Configuration sync carries accessPolicy ({} clears it). Console: an "Elevation defaults" card on the Configuration page.

  • Access report: reports.access({ tenantId, withinMs?, unusedForMs? }) (iam:identities:read; the binding and credential sections need iam:bindings:read and iam:credentials:read and are named in omitted otherwise) returns identities and temporary bindings ending within the window, live activations, the number of pending activation requests, and API keys nobody used or that end soon. CLI report --tenant ID [--within-days N] [--unused-days N] prints it as the BETTER_IAM_TOKEN holder. Console: a Reports page with clear-deadline, make-permanent, end-activation, revoke, and rotate actions, and an activation history card on the member page.

  • Approval-gated activation: eligible bindings accept requireApproval and approverGroupId; bindings.activate then records a pending request (lapsing after 24 hours) and emails the approver group (activation-request). bindings.approveActivation / bindings.denyActivation (iam:bindings:approve on the role; approver-group members or root when a group is set; never one's own request) make the role live for the requested or a shorter duration, or refuse it, and email the requester (activation-decided). bindings.listApprovals lists the requests a person may decide on, listActivations accepts status, listMine reports pendingActivation, and requesters cancel with deactivate. Audited as binding:activation-requested|approved|denied. Configuration sync carries requireApproval and approverGroup by name. Console: the Elevate page shows pending requests, lets approvers decide, and the member page sets the approval rules.

  • credentials.create({ scopes }) restricts an API key to an action allowlist (compiled to a session policy; CredentialSummary.scopes reports it), and bindings.list({ expiresBefore }) reports temporary bindings ending before a time. Console: a scopes field when issuing keys.

  • Tenant policy requireMfaForOwners requires a second factor from owners only (their non-MFA sessions stop at the next use; they enroll on the next sign-in). Console: the toggle in settings, a "Change email" card on the account page (auth.requestEmailChange), and /cloud/confirm-email for the confirmation link; the deliveries page opens email-change links in development.

  • Microsoft Entra ID sign-in: createOAuthLogin connections accept kind: 'microsoft' with microsoftTenant (organizations by default, common, consumers, or one tenant) and an optional sovereign-cloud issuer. Multi-tenant connections require allowedMicrosoftTenants (Entra tid values), and each ID token is validated against the concrete issuer of its own directory. Emails count as verified only with Entra's xms_edov claim (protection against "nOAuth" email spoofing). begin(connectionId, credential, { loginHint, domainHint, prompt }) and the login_hint / domain_hint / prompt query parameters of the start route forward validated sign-in hints (Microsoft domain_hint, Google hd, GitHub login).

  • Delivery templates and back-off: renderDeliveryMessage from @better-iam/auth/templates (also better-iam/auth/templates, a subpath without native dependencies; re-exported from the main auth entrypoint) renders every built-in outbox template (verify-email, password-reset, email-change, magic-link, code, mfa-code, new-sign-in, owner-invitation, member-invitation) into { subject, text, html } with your own link builders. RATE_LIMITED errors carry retryAfterMs; HTTP responses add Retry-After, and IamClientError.retryAfterMs exposes it. The console's deliveries page shows each message's rendered subject.

  • Role inheritance: roles.create/roles.update accept inherits (role IDs; at most 20, no cycles, no protected roles, empty list clears); a role grants its own policies plus, recursively, the grants of the roles it inherits, each bounded by the inheriting role's authority ceilings as well as the inherited role's. Deleting an inherited role is refused with RESOURCE_IN_USE. Configuration sync exports and applies inherits by name. Console: an Inherits card on the role page.

  • Access windows: bindings accept window: { from, to, timeZone, days? } (bindings.create/update, null clears); outside the recurring window the binding grants nothing. identities.listBindings reports inWindow; configuration sync carries windows on group bindings. Console: the window on the member page's role table and assignment form.

  • Offboarding: identities.offboard({ tenantId, identityId, reason, successorId? }) (recent authentication, iam:identities:update) disables an identity and, in one transaction, revokes its sessions and keys, removes its role bindings (under the caller's authority), group memberships, activations, relationships, and pending access requests, revokes the grant authorities it holds, removes ownership like setOwner, and reassigns the managed resources it owns to a successor or reports them. Audited as identity:offboard with the reason and counts. Console: an Offboard card on the member page.

  • CLI config-plan --fail-on-drift exits non-zero (CONFIG_DRIFT) after printing the plan when the tenant differs from the file, for CI checks.

  • Outbound SCIM provisioning: createScimProvisioner({ ...iam.protocolHost, encryptionKey }) from better-iam/scim keeps downstream SCIM 2.0 applications in step with a tenant's active members, or with members of chosen groups. It creates users (adopting existing ones by externalId or userName), pushes changes, and deactivates or deletes (deprovision) people who are disabled, deleted, expired, or leave scope. Identity attributes map to title and the enterprise extension (attributeMapping). Targets are managed through createTarget, listTargets, getTarget, updateTarget, and deleteTarget (built-in actions iam:scim:targets:create|read|update|delete|sync; audited), with encrypted write-only bearer tokens. syncTarget runs on demand, syncAll suits schedulers, and subscribe(iam.events) syncs after member changes. Each run reports created/updated/deactivated/deleted/unchanged/failed counts and failure details in lastRun. New collections provisioningTargets, provisioningLinks, and provisioningGroupLinks are removed with their tenant. pushGroups also maintains the scoped groups downstream (display name and provisioned members; adopted, updated, or deleted as scope changes). provisioner.handler serves the operations as a JSON API (basePath, IAM CSRF rule and envelope) for iam.useProtocol. Console: an App provisioning page connects applications, scopes them to groups, and syncs, pauses, or removes them. It shows per-run results and failures, and syncs follow member changes automatically. previewTarget (and a Preview button) reports what the next sync would change using read-only lookups only. Memberships past their expiresAt no longer count toward a target's scope or pushed groups, and access-package assignments (iam:packages:*, package:* events) trigger a sync.

  • Passkeys as the second factor: a sign-in that returns mfaRequired now reports passkeyAvailable when the person has a passkey registered; auth.beginPasskeyMfa({ tenantId, challenge }) and auth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice? }) complete MFA with a user-verified WebAuthn assertion bound to that login challenge (both challenges are consumed; remembered devices supported). Console: passkey registration and removal on the account page, "Sign in with a passkey" on the login page, and "Use a passkey" at the MFA step (all through better-iam/client/passkeys). tests/support/webauthn.ts provides a virtual P-256 authenticator for tests.

  • Tenant-managed SAML connections: with serviceProvider (one deployment-wide SP key pair and base URL) and the host's authorize, createSamlService adds createConnection, listConnections, getConnection, updateConnection, and deleteConnection (built-in actions iam:saml:connections:create|read|update|delete on the new internal resource type saml; audited). Organizations enter their IdP's metadata XML, or its sign-on URL, issuer, and certificates, and get fixed {basePath}/{id}/metadata|acs|login URLs. The declarative attributeMapping feeds identity attributes, summaries report certificate fingerprints and expiry for rollover, and enabled: false stops sign-ins. parseIdpMetadata, normalizeCertificate, and certificateInfo are exported, getMetadata(id) serves both configured and managed connections, and samlConnections is removed with its tenant. IdP-initiated SAML sign-in is available per connection (allowIdpInitiated, configured or managed): responses answering no request are fully validated and each assertion ID is accepted once (samlAssertions); idpInitiated(connectionId, samlResponse) is the direct call and validateSamlEnvelope accepts null as the expected request.

  • Nuxt and Vue. New package @better-iam/vue (also better-iam/vue) for Vue 3.3+. createIam({ client }) is the plugin, and useSession, useAuthorize, useCan, and useAccessible take refs or getters and re-run when the input or the signed-in identity changes. <IamCan> has default/fallback/loading slots. Server rendering awaits queries through onServerPrefetch and hands the results to the browser through an IamHydration store (createHydration), so hydration neither refetches nor mismatches. New package @better-iam/nuxt is a Nuxt 3.14+/4 module (betterIam config key). It mounts the IAM API at /api/iam/** in Nitro from the file named by instance (default ~~/server/iam), initializes the instance on first use, and loads the session during SSR through an in-process client bound to each request (event.context.betterIam). Page access comes from definePageMeta({ iam: true | false | { action, resource?, tenantId?, redirectTo? } }) with optional requireAuth: 302 to loginPath?next= when signed out, and a 403 error page on the server and on client navigation. The module auto-imports useIamSession/useIamClient/useIamAuthorize/useIamCan/useIamAccessible and <IamCan> (sessions typed from the registered instance) and the server utilities getIamSession, requireIamSession, requireIamAccess, iamCan, issueIamAssertion, iamCredential, and useIam. @better-iam/nuxt/h3 exports createIamH3 for any h3 v1/v2 or Nitro app (per-event session memo, h3-compatible IamH3Error with data.code, Web or Node request bodies). The framework-agnostic session store moved to @better-iam/client/session (better-iam/client/session); @better-iam/react re-exports it unchanged, and isUnauthenticated also recognizes in-process IamErrors with status 401/403. examples/nuxt is a runnable Nuxt 4 app with a production smoke test, and docs/nuxt.md is the guide.

  • Separation of duties: sod.create/list/update/delete/violations (built-in actions iam:sod:read|manage) declare roles nobody may hold together. Prevent rules refuse, with SOD_CONFLICT, any binding, group membership, bulk onboarding, access-request approval, configuration apply, or invitation acceptance that would create a new conflict, while pre-existing conflicts are reported instead of blocking; detect rules only report. The access analysis gains a high-severity separation-of-duties finding. Console: a Separation of duties page.

  • docs/authentication.md is a full guide to sign-in methods, MFA (authenticator, recovery codes, emailed codes, remembered devices), sessions and recent authentication, account recovery, tenant authentication policies, impersonation, HTTP cookie behaviour, and the deployment options and email templates. The console gains self-service password recovery: "Forgot your password?" on the login page, /cloud/reset to request the email and to choose a new password from its link, and the deliveries page opens password-reset links in development.

  • Configuration as code: the config API group (iam:config:read, iam:config:apply) exports a tenant's roles, policies, groups (with member emails), tenant-defined resource types, and group role bindings as one JSON document keyed by name (config.export), computes the creates, updates, and deletes a document implies (config.plan, with prune for items a listed kind omits), and applies it in a single transaction where every change is authorized like the direct call and one refusal rolls everything back (config.apply, audited as config:apply). CLI: config-export, config-plan, config-apply [--prune] as the BETTER_IAM_TOKEN holder. validateTenantConfig and the TenantConfig/ConfigPlan types are exported; the mutation helpers behind roles, policies, groups, resourceTypes, and bindings are shared with the API. Console: a Configuration page.

  • Just-in-time roles: bindings.create/update accept eligible, maxActivationMs (default one hour, up to seven days), requireJustification, and requireMfa; an eligible binding grants nothing until its subject (directly or through a group) calls bindings.activate (iam:bindings:activate on iam/{roleId}), for a bounded time, from an ordinary session, never while impersonating. bindings.deactivate ends one's own activation, bindings.revokeActivation ends someone else's (like deleting the binding), bindings.listActivations lists them, bindings.listMine shows members their own roles and what they may activate, and identities.listBindings reports activation. Activations end with the membership, the binding, or the role, and the purge worker sweeps expired ones (expiredActivations). Audited as binding:activate (with the justification) and binding:deactivate. Console: an Elevate page, eligibility on the member and role pages.

  • Identity expiry: Identity.expiresAt schedules deactivation for contractors and temporary service accounts (identities.create/createMany/update, serviceAccounts.create/update; null clears it). Past the deadline every credential of the identity is refused (UNAUTHENTICATED), identities.list({ expiresBefore }) reports upcoming expiries, and the purge worker disables such identities, revokes their sessions, and records identity:expire (expiredIdentities); re-enabling requires clearing or extending the deadline first. The last owner cannot be scheduled away. Console: the deadline on the member page and when creating service accounts.

  • API key hygiene: keys carry a name and description (credentials.create, credentials.update, which can also move the expiry within a year under recent authentication), credentials.get and credentials.list return lastUsedAt (recorded at most once a minute when the key authenticates a request) and credentials.list({ unusedForMs }) finds keys nobody has used, including never-used ones; rotation keeps the label and starts the usage history over. Console: labeled key issuance and an "unused 30d" flag.

  • @better-iam/adapter-sqlite: closing a store before its first query now releases the database file (Kysely opens the driver lazily, so the eagerly opened connection was left to garbage collection).

  • Emailed MFA codes: authentication.mfaEmailCodes and the tenant policy field mfaEmailCodes let people without an authenticator satisfy an MFA requirement with a one-time code (auth.requestMfaCode for a login challenge whose sign-in reported emailCodeAvailable, then verifyMfa); codes are hashed, single-use, bound to the challenge, and expire with it. Never offered to root administrators or to people with an authenticator. Console: "Email me a code instead" on the MFA step and the policy toggle in settings. The console's IAM route now answers GET, so /api/iam/health and /api/iam/metrics work behind Next.js.

  • CLI analyze --tenant ID [--dormant-days N] [--fail-on high|medium|low] prints access-analysis findings as the BETTER_IAM_TOKEN holder and exits non-zero at the chosen severity, for nightly jobs and deployment gates.

  • Access certification campaigns: certifications.create/list/get/decide/close/delete (built-in actions iam:certifications:read|review|manage) snapshot a tenant's role bindings for designated reviewers, forbid self-review, and on close remove revoked (optionally undecided) bindings under the closer's authority, recording each item's outcome. Console: a Certifications page with per-binding keep/revoke buttons.

  • Network allowlists: the tenant authentication policy gains allowedIpRanges (IPv4/IPv6 addresses or CIDR blocks); sessions, including impersonation, are refused with IP_NOT_ALLOWED when the recorded client IP is outside them, and existing sessions from outside stop working at their next use. @better-iam/core exports isIpRange and ipMatches. The console settings page edits the list, and the admin panel gains an Operations page (health, outbox backlog, active sessions, and the process's Prometheus counters and latency histograms; the console enables observability.metrics and exposes GET /api/iam/metrics when METRICS_TOKEN is set).

  • @better-iam/next App Router integration: iamNext.client() is the typed API bound to the current request, calling the IAM handler in process and writing the cookies it issues through cookies(), so server actions sign people in and out (and through MFA) without client JavaScript. page(render, spec), route(handler, spec), and action(fn, spec) wrap pages and layouts, route handlers (JSON error envelope with the server's status; cookie or bearer API key), and server actions (ActionResult for useActionState; Next control flow propagates) with authentication and optional authorize: { action, resource, tenantId }. The ambient getSession() is memoized per request with React cache (cache option), reads cookies a server action just set, and sessionForClient() returns JSON for IamProvider initialSession. interrupts: true uses Next's unauthorized() / forbidden(); requireTenantSession({ slug }) / tenant(slug) serve /[org]/... routes (unknown aliases call notFound(), other organizations go to /login?org=); handlers() adds GET for /health and /metrics. Middleware gains publicPaths globs, signedInRedirect (bare login visits only; guards add ?next= when they reject a stale cookie, so it cannot loop), and next (pass NextResponse.next) to forward x-better-iam-pathname, which becomes the default ?next=. The new edge-safe @better-iam/next/edge subpath (better-iam/next/edge) holds the middleware plus Web Crypto verifyAssertionToken / withAssertion (offline assertion checks for downstream services), verifyWebhook / createWebhookHandler (signed webhook receiver with secret rotation, freshness, size limits, and retry-friendly failures), and safeRedirectPath against open redirects. The server instance reports its HTTP endpoint (origin, basePath, secure). docs/nextjs.md is a full guide, and examples/nextjs is a runnable App Router application covering each helper.

  • @better-iam/next Pages Router and client components: iamNext.pages.withSession(gssp, spec) (getServerSideProps with login redirects, notFound or redirect on denial, and the session as JSON props), pages.api(handler, spec) (API routes with the JSON error envelope), pages.client(req, res) (in-process client appending cookies to the response), pages.getSession(req), and pages.handler() for pages/api/iam/[...path].ts (accepts bodies Next already parsed). interrupts: 'forbidden' interrupts denials only and keeps login redirects. The new 'use client' entry @better-iam/next/client (better-iam/next/client) adds IamNextProvider / useRouterSync() (calls router.refresh() when the signed-in identity changes in the browser) and useSignOut({ redirectTo }). iamNext.allowed(action, resource?) and the async server component <iamNext.Can action resource fallback> batch every check made during a render into one deduplicated authorizeMany per tenant (50 per call) and reuse answers for the rest of the request.

  • New package @better-iam/nestjs (also better-iam/nestjs) for NestJS 11 and 12 on Express or Fastify. IamModule.forRoot / forRootAsync provides IamService (principal, authorize, require, can, listAccessible, assertion, all per request) and IamGuard. Options install the guard globally (guard), serve the IAM HTTP API from the Nest app (mount, which also handles bodies Nest has already parsed), and register IamExceptionFilter so IamErrors keep their status and { error: { code, message } } body. Decorators: @Public, @Authorize(action, { resource, tenant }) (rules add up across class and method; values come from params, query, body, headers, GraphQL arguments or WebSocket message fields, or functions), @RequireMfa, @Credentials('api-key'), @CurrentPrincipal / @CurrentIdentity / @CurrentSession / @TenantId, and @OnIamEvent(pattern) for provider methods (with optional dispatchIntervalMs). The guard rejects cookie-authenticated unsafe requests from foreign origins (csrf) and supports HTTP, GraphQL and WebSocket contexts. Downstream services use IamAssertionModule.forRoot({ key, audience }) with IamAssertionGuard, @AssertionClaims() and @RequireClaims({ roles, groups, mfa, kinds }). That path loads only the new @better-iam/server/assertions subpath, so a verifying service doesn't need the server's native dependencies. @FilterAccessible(action, { type, id?, path? }) trims list responses to the resources the caller may act on through the listAccessible reverse query, so filtering doesn't write an audit denial per item. forRootAsync also takes useClass / useExisting (IamOptionsFactory). IamService.health() probes the IAM database in process, and IamService.credential(request) returns the caller's credential. The mount path defaults to the server's reported endpoint.basePath. examples/nestjs is a runnable Express app with a smoke test (pnpm --filter @better-iam/example-nestjs smoke). @better-iam/nestjs/testing (better-iam/nestjs/testing) exports createTestingIam, an in-memory stand-in for tests: principals chosen by bearer token, decisions made by a callback, registered resources for @FilterAccessible, a decisions log, and emit() for @OnIamEvent handlers.

  • OAuth authorization server: client management (listClients, getClient, updateClient, rotateClientSecret; new built-in action iam:oauth:clients:update). Narrowing a client's grant types, scopes, or resources, or requiring DPoP, revokes everything issued to it. Connected apps: listGrants, revokeGrant, revokeGrants (self-service, or iam:oauth:grants:read|revoke for other accounts; audited iam:oauth:RevokeGrant), and repeated consent in one provider session extends a single grant. Resource servers: the resourceServers option (RFC 8707 resource indicators) issues audience-restricted JWT access tokens (RFC 9068) to clients registered with resources. The new createAccessTokenVerifier verifies them offline, including DPoP proofs (RFC 9449) with replay detection. Clients can set requireDpop / requirePushedAuthorization, the provider takes requirePushedAuthorizationRequests / dpopNonceSecret, and interactionDetails adds clientName and resources. Confidential clients can authenticate with client_secret_post or private_key_jwt (tokenEndpointAuthMethod, with jwks / jwksUri; single-use assertions, key rotation through updateClient). OpenID back-channel logout: clients register backchannelLogoutUri, and logoutEndedSessions() notifies them and revokes consents whose IAM session ended (audited iam:oauth:SessionLogout). Outbound provider requests allow loopback targets only under allowInsecureLocalhost. Per-client accessTokenTtl / refreshTokenTtl can shorten token lifetimes below the provider and resource server defaults. Clients carry consent-screen branding (logoUri, clientUri, policyUri, tosUri) and a firstParty flag, reported by interactionDetails(...).client. Token exchange (RFC 8693): confidential clients registered for urn:ietf:params:oauth:grant-type:token-exchange trade an account's access token for a token to one of their resources, with the client as act and a lifetime capped by the subject token. authorizeTokenExchange adds policy, exchanges are audited as iam:oauth:TokenExchange, and the verifier exposes actor. Consent now grants requested resource scopes, so browser flows with resource receive resource-server tokens.

  • Access analysis: analysis.findings (iam:analysis:read) reports unrestricted administrator policies, service-wide action wildcards, administrators without a second factor, service accounts with full administration, dormant members who still hold access, trusts without MFA, and unattached policies, unused or empty roles, and member-less groups with bindings, ordered by severity with deterministic IDs; analysis.suppress / unsuppress (iam:analysis:update) record accepted risks. Console: a Security findings page.

  • Verified domains and home-realm discovery: a new domains API group (add, list, verify, delete; built-in actions iam:domains:create|read|update|delete) lets an organization prove control of an email domain with a DNS TXT record; each domain is verified by at most one tenant and consumer mailbox providers are refused. Public domains.discover({ email }) returns the owning tenant with its alias, accepted sign-in methods, and MFA requirement. The domains option injects the TXT resolver, record label, and blocked list. Console: a Domains page and "use your work email" on the organization picker.

  • Security activity and sign-in alerts: auth.listSecurityEvents returns the caller's own auth:* audit trail (impersonators named) for account pages; authentication.signInNotifications and the tenant policy field notifyNewSignIn queue a new-sign-in email when a session starts from a client (user agent + IP) that none of the person's live sessions or remembered devices has used. Console: "Recent security activity" on the account page and the alert toggle in settings.

  • Password policy: tenant authentication policies gain passwordHistory (refuse the last 1–24 passwords; PASSWORD_REUSED), passwordMaxAgeDays (a verified but expired password is refused with PASSWORD_EXPIRED until reset), passwordMinClasses (2–4 character classes), and passwordRejectPersonalInfo. authentication.passwordPolicy adds deployment-wide screening: a built-in common/sequential/low-variety screen (on by default), isBreached (BREACHED_PASSWORD; pwnedPasswords() is a k-anonymity Have I Been Pwned client), and a custom check. Identities record passwordChangedAt; previous hashes live in the new passwordHistory collection (newest 24, removed with the identity or tenant). @better-iam/auth exports pwnedPasswords, isCommonPassword, and characterClasses. Console settings edit the new rules.

  • Trusted devices ("remember this device"): verifyMfa and confirmMfa accept rememberDevice and return a deviceToken (with deviceExpiresAt) that lets the same browser skip MFA on later signIn / finishPasswordless calls; authentication.trustedDeviceLifetimeMs caps the deployment (30 days by default, 0 disables) and the tenant policy gains trustedDeviceDays. Root administrators are never remembered; password, email, and factor changes forget every device; sessions record trustedDeviceId; auth.listTrustedDevices, revokeTrustedDevice, and revokeTrustedDevices manage them (audited as auth:device:trust / auth:device:revoke). The HTTP handler keeps the token in a better-iam.device cookie and injects it into sign-in bodies. Console: a "remember this device" checkbox at MFA, a remembered-devices card on the account page, and the policy field in settings.

  • SCIM protocol coverage: RFC 7644 filters (and/or/not, grouping, value paths such as emails[type eq "work"], sub-attributes, schema-qualified enterprise attributes, gt/ge/lt/le) evaluated against rendered resources; sortBy/sortOrder; attributes/excludedAttributes projection; POST {Users|Groups}/.search; POST /Bulk (100 operations, per-operation transactions, bulkId forward references, failOnErrors); If-None-Match. PATCH gains sub-attribute and value-path targets (name.givenName, emails[type eq "work"].value, urn:…:enterprise:2.0:User:department), listed-member removal and Entra-style "True"/"False" booleans, and no longer drops title or the enterprise extension on unrelated changes. scim.listConnections reports usage (lastUsedAt, user/group counts) and scim.rotateToken replaces a connection's token without losing provisioned state. Discovery advertises bulk, sorting and the enterprise extension schema.

  • Operations endpoints: observability.metrics keeps Prometheus-style counters and histograms from spans (iam.metrics.render() / snapshot(), exported createMetrics), served at GET {basePath}/metrics when a bearerToken is configured; GET {basePath}/health reports database reachability. Caller-controlled names collapse and series are capped, so scrapes stay bounded; gauges: true adds outbox-backlog and live-session gauges read from storage on each scrape. The console audit log gains a search form (action glob, actor, resource, outcome, time range) with pagination and actor names.

  • Impersonation ("view as"): identities.impersonate opens a member session for support when the tenant's authentication policy sets allowImpersonation (new built-in action iam:identities:impersonate, recent authentication, a recorded reason, at most eight hours and never beyond the administrator's own session). Owners, root administrators, service accounts, and the caller are never eligible. Such sessions carry impersonatorId, cannot perform recent-authentication operations, re-authenticate, assume roles, grant OAuth consent, or impersonate further, end with the administrator's session (auth.endSession), and are excluded from maxSessions. Audit events and webhook bodies gain impersonatorId, policies gain principal.impersonated / principal.impersonatorId, assertions gain impersonatorId, AuthMethod gains impersonation, and the HTTP layer never sets a cookie for the impersonation token. Console: settings toggle, "View as" on the member page, a banner with a stop button, and attribution in the audit log and session lists.

  • Tenant authentication policy gains maxSessions (concurrent sessions per person; the oldest ends). identities.requestPasswordReset queues a reset email for a member on an administrator's behalf. docs/api-reference.md is generated from a live instance by pnpm docs:api; the HTTP route tables (routeGroups, publicApiMethods, publicAuthMethods, authenticatedAuthMethods) are exported from the server package.

  • tenantDefaults option: plan limits and an authentication policy applied to every tenant created with tenants.create, validated at construction. tenants.resendInvitation and identities.resendInvitation renew an invitation's token and lifetime and queue the email again; the console offers both.

  • Tenant authentication policy gains maxAttempts (tighter rate limits for the tenant's authentication flows) and minPasswordLength (enforced on creation, reset, and change). identities.update accepts email for administrator-driven address changes (recent authentication, unverified, sessions revoked, audited as identity:email-change).

  • Federated attributes: OAuth/OIDC sign-in connections and SAML connections accept mapAttributes; mapped values are validated against permissions.identityAttributes and stored on the identity at every sign-in (FederatedLogin.attributes).

  • Audit retention: iam.pruneAudit({ tenantId, retentionMs }) and CLI audit-prune delete a tenant's oldest events behind an audit:prune checkpoint that keeps the chain verifiable.

  • Access reviews accept platform resources (iam/...) as the reviewed resource.

  • OAuth/OIDC provider: the built-in iam scope adds roles, groups, and attributes claims (live when read, expired bindings excluded) to userinfo.

  • apps/console: member search and pagination on the members page.

  • Directory: identities.list accepts query (name or email, case-insensitive), limit, and offset, ordered by name; identities.listSessions returns a member's active sessions for administrators (iam:identities:read), without token material.

  • docs/recipes.md collects copy-ready examples for sharing, reviews, policy testing, tenant auth policies, audit archives, assertions, observability, incident response, exports, limits, webhooks, bulk onboarding, and libSQL.

  • Policy tooling: policies.restoreVersion rolls a policy back to an earlier version (re-validated, kept in history) and policies.test evaluates a candidate document against an action, resource, and supplied context without storing it; the console policy page offers both.

  • Account unlock: identities.unlock clears the sign-in, recovery, and MFA rate-limit counters of an identity (recent authentication, iam:identities:update, audited as identity:unlock). RateLimiter.reset is optional for custom limiters; the built-in limiters implement it.

  • SCIM: users carry title and the enterprise extension (department, division, manager, …); mapAttributes turns them into declared identity attributes, validated through protocolHost.validateIdentityAttributes.

  • Plan limits and usage: root sets tenants.setLimits (members, service accounts, groups, roles, policies, resources, webhooks; audited as tenant:limits); every creation path, including invitation acceptance, self-registration, federation, SCIM, and bulk creation, fails with LIMIT_EXCEEDED past a limit. tenants.usage reports counts, active sessions, and limits. The admin panel edits limits and both consoles show usage.

  • Webhook filters: subscriptions accept outcomes and resources (glob patterns over the resource ID) in addition to action patterns.

  • Bulk onboarding: identities.createMany creates up to 100 identities atomically with optional passwords, declared attributes, roles (authorized like invitations, bound under the caller's grant authority), and groups.

  • Webhook redelivery: outbox messages remember the audit event behind a delivery (reference; listDeliveries exposes eventId) and webhooks.redeliver queues that event again, rebuilt from the audit record and signed with the current secret. The console offers it per delivery.

  • @better-iam/next: iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? }) issues a stateless assertion for the current request's session.

  • apps/console: member attributes (department, title) editable on the member page; the admin audit page reports chain integrity.

  • Data-subject export and incident response: identities.export returns everything a tenant stores about one identity (no secrets; audit trail when the caller may read it), audited as identity:export; identities.revokeSessions ends one identity's sessions without disabling it; tenants.revokeSessions ends every session in a tenant (includeSelf optional). All three require recent authentication. The console offers them on the member page and in organization settings.

  • New package @better-iam/adapter-libsql (better-iam/adapter-libsql): libSQL persistence through @libsql/client for local files, encrypted files, embedded replicas, and remote Turso or sqld databases, with the same schema, transaction guarantees, and error mapping as the SQLite adapter. better-iam init --database libsql scaffolds it.

  • Session metadata: sessions issued through the HTTP handler record client.userAgent; http.clientInfo(request) derives ip, userAgent, and a device label behind a trusted proxy; iam.auth.withClient(info, fn) scopes details for direct calls. auth.revokeOtherSessions ends every other session of the caller (recent authentication required, audited as auth:session:revoke-others); the console lists devices and offers "Sign out other sessions".

  • CLI: audit-verify --tenant ID recomputes a tenant's audit chain from storage (non-zero exit when broken) and audit-export --tenant ID --output FILE writes it as JSON Lines; doctor reports chained tenants and events.

  • apps/console: workspace sharing through relationships (workspace page and member page), an access-reviews page (who can, effective actions), audit chain verification and JSONL export, the organization authentication policy in settings, relations on resource types, and the sign-in method on sessions.

  • Observability: observability.onSpan receives a timed span for every provisioning operation, authorize/authorizeMany/listAccessible query, authentication call, and HTTP request, with tenant, outcome (ok, denied, error), error code, status, and duration. Handler failures are ignored.

  • Stateless assertions (assertions API group): assertions.issue({ tenantId, audience, ttlSeconds?, claims? }) returns a short-lived HS256 JWT describing the caller for a service, authorized as iam:assertions:create on iam/{audience}. iam.assertionKey() derives the verification key from the deployment secret and verifyAssertion(token, { key, audience, issuer? }) checks tokens offline.

  • Tenant authentication policies: tenants.setAuthPolicy sets or clears requireMfa, allowedMethods, sessionLifetimeMs, and sessionIdleTimeoutMs per tenant (recent authentication and iam:tenants:update; audited as tenant:auth-policy). Policies only tighten the deployment's configuration; method restrictions are enforced before credentials are examined (METHOD_NOT_ALLOWED), sessions are re-validated on use, and MFA cannot be disabled while required. User sessions record their sign-in method, exposed to policies as principal.authMethod. iam.auth.mfaRequired, tenantRequiresMfa, and sessionLimits are public for trusted integrations.

  • Audit hash chain: every audit event carries sequence, previousHash, and hash (SHA-256 over canonical JSON) linked per tenant through auditChains; all writers (server, authentication, SCIM, OAuth provider) append through appendAuditEvent. audit.verify checks a tenant's chain or a window of it, audit.export pages events as JSON Lines, and verifyAuditChain/auditEventHash/canonicalJson (core and better-iam) verify archives offline. initialize() backfills unchained events once. Webhook bodies include sequence and hash.

  • Access reviews under iam:policies:simulate: policies.whoCan lists the identities that could perform an action on a resource (with kind, assumeMfa, and pagination), policies.effectiveActions lists the actions an identity holds on a resource across the catalog or a chosen list, and policies.simulate accepts assumeMfa.

  • Relationships (relationships API group): resource types declare relations (in permissions.resourceTypes, plugin resourceTypes, and resourceTypes.register/update); relationships.create/list/delete bind identities or groups to one resource under a declared relation with optional expiry, authorized by iam:relationships:create/read/delete on iam/{type}/{id}. Evaluation exposes the principal's live relations on the resource as resource.relations and on its registered parent as resource.parentRelations; iam/{type}/{id} administrative checks on registered managed resources now carry that resource's attributes, owner, parent, and relations. Tuples are removed with their identity, group, or resource, and a relation still in use cannot be dropped from its type.

  • Condition operators: StringNotEquals, StringEqualsIgnoreCase, StringNotEqualsIgnoreCase, StringNotLike, StringLikeIgnoreCase, NumericNotEquals, NumericLessThanEquals, NumericGreaterThanEquals, NotIpAddress, ArrayContains, and ArrayContainsAll. Negated operators and ArrayContainsAll require every listed value; missing or wrongly typed context never satisfies any operator.

  • Principal context: principal.kind, principal.owner, principal.rootAdmin, principal.sessionKind, principal.groups, and principal.roles are available to conditions and variables. permissions.identityAttributes declares typed attributes that identities.update and serviceAccounts.update set and policies read as principal.{name}; Identity.attributes and Identity.description are part of the public identity.

  • Plugin contract: plugins may contribute resourceTypes, hooks.beforeOperation/afterOperation (run inside the operation transaction; throwing aborts it), and resolveContext; plugin endpoints receive deliver to queue email/SMS through the host outbox.

  • Policy variables: ${principal.id} and any other trusted context key can appear in resource patterns (after the type segment) and in StringEquals/StringLike values. Substituted values match literally, unresolved variables never match, and malformed references are rejected at validation.

  • Reverse queries: iam.listAccessible / client.listAccessible return the registered resources of a managed type that the caller may perform an action on, evaluating grants once per query. resources.registerMany registers up to 100 resources atomically with per-item authorization.

  • Temporary bindings: bindings.create accepts expiresAt; expired bindings grant nothing and are hidden from effective views (bindings.list has includeExpired); bindings.update extends, shortens, or clears the expiry. purgeDeleted now also removes expired bindings and expires stale access requests, and reports expiredBindings/expiredRequests.

  • Access requests (accessRequests API group): members with iam:access-requests:create request roles with a justification and optional duration; reviewers with iam:access-requests:review approve (creating bindings under their own grant authority, subject to iam:bindings:create on each role) or deny; requesters cancel; pending requests expire after accessRequests.lifetimeMs (default 7 days).

  • Events and webhooks: every audit event is now an event. iam.events.subscribe(pattern, handler) and events.onEvent receive committed events from the dispatcher (iam.events.dispatch, alias of dispatchAuditHooks, now returns { dispatched }). The webhooks API group subscribes HTTPS endpoints per tenant (root may subscribe a subtree) to event patterns; deliveries are queued in the same transaction as the audit record, signed with a per-subscription secret (X-Better-IAM-Signature, verify with verifyWebhookSignature), retried with exponential backoff, and listed through webhooks.listDeliveries. events.deliverWebhook replaces the built-in HTTPS transport.

  • Outbox: dispatchOutbox returns { delivered, failed, abandoned }, retries with backoff (30 seconds doubling to one hour), abandons messages after authentication.maxDeliveryAttempts (default 25) with failedAt/lastError, and dispatches in creation order.

  • Configurable rate limits: authentication.rateLimits sets attempts (default 10), sensitiveAttempts (default 5), windowMs (default 15 minutes), and a pluggable limiter; createMemoryRateLimiter() is provided for single-process deployments and tests.

  • Administration fills: identities.delete (tombstones the identity, revokes credentials, factors, bindings, memberships, authorities, and links; identities.list gains kind, status, and includeDeleted filters), serviceAccounts.list/get/update/setStatus/delete with an optional description, credentials.list (iam:credentials:read), trust.list (iam:trust:read), root.listAdministrators, and audit.list filters (actorId, glob action, resourceId, outcome, from, to) with newest-first ordering. Identity.status gains deleted.

  • Internal restructure with no public API change: the server is split into focused modules (options, catalog, context, events, decisions, principals, operations, flows, lifecycle, federation, http, and one file per API group), the authentication service is assembled from feature classes over a shared base with outbox and rate-limit modules, SCIM separates its filter, discovery documents, provisioning, and handler, the OAuth provider adapter lives in its own module, tenantTreeActive in core replaces four private tenant-tree walkers, and the whole repository is formatted with Prettier.

  • New packages: @better-iam/react (IamProvider, useSession, useAuthorize, useAccessible, Can, and the framework-agnostic createSessionStore) and @better-iam/next (createIamNext with getSession, requireSession, require, can, handlers, plus createIamMiddleware), exposed as better-iam/react and better-iam/next.

  • apps/console: a Next.js administration panel (/admin, root administrators with MFA: organizations, root administrators, catalog, audit, deliveries) and multi-tenant cloud console (/cloud, alias sign-in, invitations, workspaces as managed resources, members, roles, groups, policies, resource types, resource registry, service accounts and API keys, account settings with sessions/MFA/linked accounts). Built on iam.api.* from server components, the typed client through /api/iam, and iam.require for enforced pages.

  • Fixed SafeIdentity/SafeSession (returned by getSession, listSessions, groups.listMembers) losing their known properties: Omit over the record index signature erased them; they are now mapped types.

  • Resource catalog: permissions.resourceTypes declares application-owned and IAM-managed resource types with actions, typed attributes, and parents; managed resources are registered through the new resources API and resolved without an application callback. Tenants in tenant-defined mode register their own managed types and {type}:{verb} actions through the resourceTypes and actions APIs. Policies, inline role documents, boundaries, ceilings, and session policies are validated against the catalog (INVALID_ACTION, INVALID_RESOURCE_TYPE).

  • Breaking: tenant-defined actions no longer use the tenant/{tenantId}/ prefix; actions.list returns { name, source, resourceType, description } objects. Added actions.unregister.

  • Roles: roles.create/update accept description, an inline document, or a permissions list; added roles.get, roles.listBindings, bindings.list, bindings.read action, identities.listBindings (effective roles through groups), identities.listGroups, identities.get, identities.update, groups.get, groups.update, groups.listMembers, policies.get, policies.listVersions, and policy/group descriptions. policies.update accepts name/description changes.

  • Organizations as accounts: optional globally unique tenant slug on tenants.create, bootstrap, and tenants.setSlug; public tenants.lookup resolves an active tenant for alias-based sign-in. Member invitations (identities.invite, listInvitations, revokeInvitation, public acceptInvitation) onboard people into an existing tenant with roles and groups applied under the inviter's authority. identities.create no longer requires a password. links.list returns linked accounts for account switchers.

  • iam.authorizeMany and client.authorizeMany evaluate up to 50 checks in one transaction for UI state. Invitation responses no longer include token hashes.

  • Application-level integration test (tests/application.test.ts) exercising the HTTP handler and typed client end to end, with compile-time inference checks; the example application demonstrates aliases, invitations, permission roles, resource types, and managed workspaces.

  • Tenant lifecycle administration: rename and move tenants with hierarchy, depth, cycle, and grant-authority validation; list and revoke owner invitations; delete pending tenants; retention purging through iam.purgeDeleted and the CLI purge command, with audit records preserved.

  • @better-iam/projects reference plugin: tenant-scoped project records through projects:read/projects:write plugin endpoints (create, list, get, update, archive, restore), including purge cleanup of purged tenants' records.

0.1.0

Initial independent authentication and IAM implementation: tenant hierarchy, isolated identities, policies and delegation, root authority, service credentials, role assumption, password/MFA/passkey flows, federation/provisioning packages, typed client, CLI, SQL adapters, tests, examples, and synchronized packaging.

Was this page helpful?

Last updated on

On this page