BetterIAM

Changelog

Release notes for the Better IAM packages and documentation site, every notable change in each version, newest first.

Changelog

0.3.1 (2026-10-07)

Security fixes for security clearances (only deployments that set the clearances option are affected; nothing else changes). An adversarial review of 0.3.0 found ways for labels to stop applying: moving a tenant to a parent without a scheme switched enforcement off for its labels (now refused, and labels without a scheme in force refuse everyone), deleting a managed resource and registering it again under another parent dropped the label it inherited (now kept), and labels on AI models in an organization did not reach its projects. SSH host labels now reach logins, verifiable-credential offers check the holder's clearance, scheme edits can no longer re-rank a level, and label types are validated. Upgrade if you enabled clearances.

  • Security clearances: labels no longer slip away from what they protect (only with the clearances option).
    • tenants.reparent refuses (RESOURCE_IN_USE) a move that would change the classification scheme in force for a subtree holding labels or live (interim, active, suspended) clearances; before, moving a project below an organization without a scheme dropped every label in it, so root and members could read up. A label left in a tenant no scheme applies to now refuses everyone, root included, instead of being ignored.
    • resources.delete keeps what a registration inherited from its managed parents as its own label (audited as classification:label with reason: 'resource-deleted'), so deleting it and registering it again under another parent no longer declassifies it; deleting is refused while that inherited label cannot be read under the scheme in force.
    • An administrator's verifiable credential offer (iam:vc:issue) needs the holder to dominate the credential type's label when it is offered (audited ACCESS_DENIED), when the wallet redeems it, and in every verifiableCredentials.sweep(), which now revokes any credential (access-changed) whose holder no longer does, not only self-service ones.
    • A label on ssh-host/{host} applies to every login of the host (ssh-login/{host}/{login}, those added later included), whatever its inheritToChildren, so certificates, myAccess and the SSH sweep follow it; plans for ssh-login answer UNSUPPORTED_FILTER while a host is labeled.
    • Labels on model/{name} in any tenant above the deciding one apply too, so a model labeled where it is defined is refused in the projects that inherit it (inference calls, listMine, plans).
    • An application action on iam/{type}/{id} (plugin endpoints included) also applies what resolveResource answers for {type}/{id} itself, its classification and the parent it reports, and refuses when that resolver fails, so the alias never reads lower.
    • Label types must be resource type names (no /, INVALID_INPUT otherwise), so {type}/{id} keys can no longer collide and block labeling a resource whose id holds a slash.
    • updateScheme never re-ranks a level, in use or not (labels your resolver asserts and a defaultLabel may use any), and a removed level comes back only at its former rank (RESOURCE_IN_USE); the scheme remembers removed levels (at most 500).
    • requireLabels: ['*'] no longer covers the built-in model-tool type, which refused every inference:use-tool decision.
    • Policy lint treats the principal.clearance* keys as server keys only when the deployment enables clearances, as before the option existed.
    • Decisions read the scheme once: resolve takes the scheme the prepared decision already loaded (preparedLabeling), and the root check is not repeated.
    • Docs: declassify of a label written under another scheme still needs a clearance that dominates it as the scheme in force reads it; only a label naming what the scheme does not define may be repaired by anyone with the permission.

0.3.0 (2026-10-07)

Third release: 24 packages. Three new access-governance domains: B2B guest collaboration (outside people invited with a sponsor, an end date and cross-tenant admission rules), license management (products, seat pools, assignments, seniority and waiting lists, and principal.licenses / identity.licenses for policies and birthright packages), and security clearances with mandatory access control (classification schemes, adjudicated clearances and compartment read-ins, IAM-held resource labels, and a "no read up" check on every decision path, off unless the new clearances option is set). Threat detection gains list filters and a classified-access rule, and installing better-iam no longer installs every framework. Breaking: guest, guestSponsorId, homeTenantId and licenses are reserved principal names; with clearances enabled, more names are reserved and root administrators no longer read up on labeled resources.

  • Installing better-iam no longer installs every framework: @better-iam/next, @better-iam/react, @better-iam/vue, @better-iam/svelte, @better-iam/nestjs and @better-iam/react-router mark their framework peers optional in peerDependenciesMeta (the umbrella already did), so npm stops auto-installing Next.js, React, Vue, Svelte, NestJS and React Router for apps that use none of them. Apps using a binding keep their framework installed as before.
  • Security clearances on the decision path (only with the new clearances option; without it nothing changes): the first step of every evaluation is the mandatory check, keyed on the evaluated action (never iam:*), so no grant, relationship, ownership or delegation confirmation opens a labeled resource above the session's clearance, and the refusal (CLEARANCE_REQUIRED, masked as ACCESS_DENIED wherever reasons are masked) uses nothing up. resolve attaches every non-platform resource's effective label as the server-owned ResolvedResource.classification (null when unlabeled); resolveResource may return a classification of its own, which can only raise the label. Root keeps its override except on labeled application resources (clearances.appliesToRoot, default true): its prepared decision then becomes an evaluator, recognized everywhere through the new isRootOverride helper (usage, audit, cross-tenant SSH certificates and their sweep, credential requests), with decideOn as the one way to apply a prepared decision. Policies see principal.clearanceLevel, principal.clearanceRank, principal.clearanceStatus, principal.clearanceCompartments and principal.clearanceCitizenship, and resource.classification, resource.classificationRank, resource.compartments, resource.noforn and resource.releasableTo (written after the resource's attributes; policy lint knows them, the resource keys through the new PolicyLintContext.clearances; policies.test defaults them to an uncleared person and an unlabeled resource). listAccessible reads each type's labels in one batch, data filter plans (root's too) AND a clearance filter in (core PlanInput.mandatory) and refuse conditions on the derived resource keys (PlanInput.derivedResourceKeys, UNSUPPORTED_FILTER), SSH logins and hosts and credential types carry their labels like resolved resources, accessPaths.find offers no path for a clearance refusal, and a clearance refusal is audited with { mandatory: 'clearance' }. Enforced access invariants are re-checked around iam:clearances:adjudicate, iam:classifications:manage and iam:classifications:declassify. AgentDecisionScope gains agentIds (the acting agent and every agent of a hand-off chain), and isServerOwnedKey(key, clearances) treats the clearance keys as the server's only when the option is set.
    • Breaking only with the option: clearanceLevel, clearanceRank, clearanceStatus, clearanceCompartments and clearanceCitizenship become reserved principal names and classification, classificationRank, compartments, noforn and releasableTo reserved resource attribute names (INVALID_CONFIG at startup, INVALID_INPUT for tenant-registered types), and values of those keys that resolveContext or a plugin supplies are removed before the server sets its own.
    • Breaking only with the option: root administrators no longer read up. With clearances.appliesToRoot left at its default (true), root is refused (CLEARANCE_REQUIRED, ACCESS_DENIED to callers) on every labeled application resource above its own clearance, in authorize, listAccessible and data filter plans alike; a clearance is adjudicated in its holder's own tenant, so root holds one only under a scheme the root tenant defines, and under an organization's scheme it reads none of that organization's labeled resources. Every iam:* action keeps the override, cross-tenant SSH stays ROOT_SSH_RESTRICTED, and appliesToRoot: false restores the old override. In a tenant whose scheme requires labels, a plugin endpoint with its own (non-iam:) action on an iam/{type}/{id} record of a required type is refused until that record is labeled (or the scheme sets a defaultLabel), as authorize is.
    • Fail closed at the edges: a stored label written under another scheme (a tenant moved below an ancestor that defines its own) refuses every party, and only declassify replaces it; data filter plans for application (unmanaged) types answer UNSUPPORTED_FILTER while any label in the tenant passes down to children, since the parents a resolver reports are invisible to a filter; an application action on iam/{type}/{id} gets the answer the named resource gets, a missing required label included; and revoke and debrief, which share iam:clearances:adjudicate with grants, are never blocked by an enforced "expect allow" access invariant ("expect deny" ones still apply).
    • Enforcement review fixes: policies.effectiveActions, policies.whoCan, access invariants and impact.preview on iam/{type}/{id} with an application action, and plugin endpoints with their own action on a record they name, now apply the named resource's label as authorize does (they resolved it without one and reported, or allowed, access the label refuses; only a platform resource resolved for an iam: action goes unlabeled), and the alias also joins what was resolved for it (a resolver's own classification, the parent its attributes report), which it ignored; and a resource inherits the labels of both its registration's parent and the parent its attributes report, so registering a tenant type named like an application type (tenant-defined mode) and a record of a resource no longer drops what the application's reported parent passes down, and such a type is planned as an application type for the application's actions (UNSUPPORTED_FILTER while labels pass down, or when the scheme requires labels on it).
  • Security clearances, the domain and API (new clearances option, { appliesToRoot?: boolean }, validated at construction; without it the new clearances API group answers FEATURE_DISABLED (403) for every method and nothing is read): a tenant defines one classification scheme (defineScheme from the us, uk, nato or corporate template or a definition, updateScheme, getScheme, templates) with ranked levels, compartments, owner countries, the NOFORN and REL TO caveats, the resource types that must carry a label (requireLabels, * for every application type) and an optional defaultLabel, a guestCeiling, interimAllowed and the adjudication mode. The scheme in force is the one defined closest to the root, so defining one below or above another is CONFLICT; new levels go above every kept level and a level or compartment that a live clearance or a label uses cannot be removed or re-ranked (RESOURCE_IN_USE). Officers grant, update, readIn (optionally backed by an NDA agreement: the compartment counts only while the person's acceptance of its current version is current), debrief, suspend (iam:clearances:suspend, no recent sign-in, for incidents), reinstate and revoke one clearance per identity: level, adjudicated citizenship (the only source of NOFORN and REL TO decisions, never identity attributes), interim status, investigation, reinvestigation date and end; get, list, explain (officers only: which dimension a person fails on a resource) and mine (no permission). Nobody adjudicates their own clearance, owners and root included (an audited ACCESS_DENIED); guests never adjudicate, are held to the guest ceiling and are never read into compartments; under within-own adjudication an officer grants only levels and compartments their own clearance holds, except that an owner or root may bootstrap one nobody in the scheme's subtree holds yet (audited with bootstrap: true). Status, expiry, the guest ceiling and NDA acceptance are evaluated at decision time. Labels are IAM-held and outlive their resources: label only raises (iam:classifications:label), declassify lowers or removes (iam:classifications:declassify, recent sign-in, and only someone whose clearance dominates the current label), getLabel and listLabels; a label marked inheritToChildren applies to every managed descendant (16 levels at most, fail closed beyond). Offboarding terminates the leaver's clearance and debriefs every compartment (clearancesTerminated, reported when non-zero), deletion keeps it as terminated history, and the instance job iam.clearances.sendReminders() (daily) emails the scheme tenant's owners and the scheme's notify.emails about reinvestigations due and clearances ending within 60 days (template clearance-reminder); suspensions, reinstatements and revocations email the person (template clearance-status, unless notifyPerson: false). Both templates name the level only, never compartments; link builder links.clearances. Permissions iam:clearances:read, iam:clearances:adjudicate, iam:clearances:suspend, iam:classifications:manage, iam:classifications:label and iam:classifications:declassify on iam/clearances[/{identityId}], iam/classifications/scheme and iam/classifications/labels[/{type}/{id}]; audited as clearance:grant, clearance:update, clearance:read-in, clearance:debrief, clearance:suspend, clearance:reinstate, clearance:revoke, clearance:terminate, clearance:reminder, classification:scheme-define, classification:scheme-update, classification:label and classification:declassify, with level and compartment ids only. New tenant-scoped collections classificationSchemes, clearances and resourceLabels, purged with their tenant; exports attachLabel, clearanceScope, labelsForType, effectiveScheme, labelKey, clearanceCollections, clearanceLimits and the module's types.
    • Review fixes: under within-own adjudication, reinstate also requires the officer to hold every compartment the suspended clearance is read into (reinstating gives them back), so an officer outside a compartment can no longer restore someone's access to it; and a clearance revoked for cause stays revoked through offboarding (no clearancesTerminated) and identity deletion, instead of being rewritten as terminated and dropping out of list({ status: 'revoked' }).
    • Console (the console's configuration enables clearances): Security › Clearances shows your own clearance and the register (level, status and what decisions count, read-in count, reinvestigation and end dates; filtered by status, level or ending soon, with tiles for clearances in force, suspended, not counted, ending and reinvestigations due) and grants clearances; each person's clearance page shows the record, read-ins with their NDA and whether they count, and the history from the audit trail, and grants, changes (only the fields edited are sent), reads in and debriefs, suspends (optionally linked to a threat incident, without telling the person), reinstates and revokes, asking for the password again where a recent sign-in is required. Security › Classification defines the scheme from a template or a definition, edits its settings, default label, levels and compartments, lists labels with raise and declassify forms, labels a resource, looks up a resource's own and inherited label, and gives officers the explain tool. Member pages gain a Clearance card. Every form is offered only to those authorizeMany allows, and compartment names reach only holders of iam:clearances:read (others see compartment ids). The console runs iam.clearances.sendReminders daily.
    • Docs: new guide docs/security-clearances.md (the model and the Bell-LaPadula simple security property, what is and is not enforced, turning it on and what breaks, schemes and templates, clearances and adjudication with the bootstrap, labels and inheritance, parties per session kind, root, policy keys, listings, data filters and reviews, explain, incidents, reminders, emails and limits), linked from the README; docs/events.md gains Security clearance events (every clearance:* and classification:* action and the { mandatory: 'clearance' } refusal marker), docs/policies.md the clearance and classification context keys and the check in the evaluation order, docs/data-filtering.md the clearance filter, and docs/security.md the root note. The docs site gains the clearances API reference (descriptions/api/clearances.md, listed under Access model) and the iam.clearances.sendReminders job, CLEARANCE_REQUIRED with the error codes and in the decision reason tables, the clearance step in the decision order, and clearance notes on authorize, listAccessible, planResources, the filters group, whoCan, FEATURE_DISABLED, RESOURCE_IN_USE and UNSUPPORTED_FILTER. docs/api-reference.md is regenerated (adds the clearances and licenses groups).
    • Threat detection: a new rule classified-access-attempts (high, MITRE ATT&CK T1213, on by default: 3 within an hour, tunable from 1 to 1000 within one minute to seven days) counts clearance refusals (deny events marked { mandatory: 'clearance' }) per responsible actor, the administrator behind a "view as" session included, and reports the actions and how many resources, never a label (threats.rules lists 22 rules). A "view as" refusal caused by the administrator's own clearance is now CLEARANCE_REQUIRED rather than IMPERSONATOR_DENIED, so it is audited with the marker and counted against the administrator. A new response action suspend-clearance (threats.respond and playbooks) suspends the identity's clearance until an officer's clearances.reinstate (threats.release never touches it). By hand it also needs iam:clearances:suspend on iam/clearances/{identityId} (checked before anything about the clearance is read; never while impersonating or as a guest); playbooks skip owners and root administrators (protected), people without a clearance (no-clearance) and suspended ones (already-applied); and writing a playbook with it, adding it to one, changing such a playbook's trigger or switching one on needs iam:clearances:suspend on iam/clearances. Without the option it is FEATURE_DISABLED (a stored playbook's is skipped as feature-disabled). Audited as clearance:suspend and threat:suspend-clearance; the person is not emailed. The console offers it on incidents and in playbooks to people who may suspend clearances.
  • Threat detection API: threats.listIncidents takes status as one status or a list (['open', 'investigating'] lists every active incident in one call, ordered and paged together); threats.timeline takes until (epoch milliseconds, after since; since then defaults to seven days before it) to bound the window; detections and incidents returned by the threats group give an identity subject of the tenant its kind (user, service, or agent, looked up when read and never stored; new types ThreatSubjectView, ThreatDetectionView, ThreatIncidentView); and threats.reportSuspicious and threats.configure name people in their detections as the detection rules do, Carol (carol@acme.test) rather than Carol. The console's threats overview reads active incidents with one call (and offers an "open or investigating" filter on the incidents page), links agent subjects to agent pages, and bounds an incident's identity timeline to the incident's window.
  • B2B guest collaboration: a new guests API group invites people from outside the organization (or from another tenant) as guests. A guest is an ordinary person of the host tenant with a server-owned Identity.guest marker (GuestProfile: sponsor, since, home tenant and domain), so bindings, groups, packages, separation of duties, invariants, certifications and offboarding apply unchanged. invite emails a personal invitation (template guest-invitation, link builder links.guestInvitation) naming a sponsor (the caller by default: an active member who is not a guest), optional roles, groups and access packages authorized like identities.invite, how long access lasts (accessDays, default 90, at most 365) and how long the invitation stays valid (1 to 30 days, default 14); one pending invitation per address. The public redeem (rate limited, sets the session cookie) re-checks everything, creates the guest with a password and a verified email, grants the roles, groups and packages under the inviter's authority until the guest's access ends, applies separation-of-duties rules and enforced invariants, runs birthright rules, and signs the guest in. listInvitations, revokeInvitation, resendInvitation (sends only an invitation that can still be redeemed: the settings, the sponsor and the inviter's rights are checked again), list (by sponsor, status, or access ending soon, with the last sign-in), get, attest (the sponsor renews access without any permission, others need iam:guests:manage; guests renew nobody's access; what the invitation granted is renewed with it), setSponsor, remove (disables the guest and removes everything they hold), convertToMember, mine (the guests a person sponsors), getSettings and configure (unknown settings are refused). Cross-tenant access settings per tenant: inbound (allowGuests, allowed and blocked domains, partner organizations) and outbound (allowGuestInvitations, partners) rules, where an address on a domain another tenant verified is that tenant's member and needs both sides to allow it; the default access and review periods; and a guestBoundary ceiling on every guest's decisions. Policies see principal.guest (always), principal.guestSponsorId and principal.homeTenantId; birthright package rules gain identity.guest, and rules (also onboarding and workflow scopes) that never test it no longer match guests. The instance jobs iam.guests.sendReviewReminders() (template guest-review; each review date and each access end is reminded once) and iam.guests.sweep() (lapsed invitations, ended guests, missing sponsors, template guest-sponsor-missing to owners) run hourly. Offboarding hands a leaver's guests to the successor (guestsReassigned, guestsUnsponsored, reported when non-zero), deletion flags them, a guest's email address cannot be changed (by administrators or through the guest's own auth.requestEmailChange), and redeemed, revoked and expired invitations are swept 90 days after they lapsed. Permissions iam:guests:read, iam:guests:invite, iam:guests:manage and iam:guests:settings on iam/guests, iam/guests/{identityId}, iam/guests/invitations[/{id}] and iam/guests/settings; audited as guest:invite, guest:invitation-revoke, guest:invitation-resend, guest:redeem, guest:attest, guest:sponsor-change, guest:sponsor-missing, guest:remove, guest:convert, guest:settings, guest:expire and guest:review-reminder. New tenant-scoped collections guestInvitations, guestAccounts and crossTenantAccess, purged with their tenant; new types GuestProfile, GuestView, GuestPage, GuestInvitationView, GuestInviteInput, GuestSettings, GuestSettingsInput, GuestSettingsView, GuestRemoval, GuestReminderResult, GuestSweepResult and IamGuests. New error: GUEST_NOT_ALLOWED. Guide: docs/guests.md.
    • Breaking: guest, guestSponsorId and homeTenantId are reserved principal names, so a deployment that declares an identity attribute with one of those names fails at startup with INVALID_CONFIG. Values of principal.guest, principal.guestSponsorId and principal.homeTenantId that resolveContext or a plugin supplies are now removed before the server sets its own.
    • Console: Directory › Guests lists the guests with their sponsor, access end, next review and last sign-in (filtered by sponsor, status or access ending soon, with tiles for guests needing a sponsor, reviews due and access ending), shows each person the guests they sponsor with one-click renewal, and invites guests with roles, groups and packages; a guest's page renews access, changes the sponsor, makes the guest a member or removes them, and shows what the invitation granted. Guest invitations can be filtered, sent again or revoked, and Directory › Cross-tenant access edits the inbound and outbound rules, access and review periods and the guest boundary. Member pages show a Guest badge and a Guest card with the sponsor. Invitees accept at /cloud/join?kind=guest. The console runs iam.guests.sweep and iam.guests.sendReviewReminders hourly.
    • Review fixes: an invited address's domain is compared, stored and emailed in canonical form (lowercase, punycode, no trailing dot), so gil@globex.test. or an internationalized spelling no longer slips past verified domains, blocked domains, partner entries and the home organization's outbound settings, at invitation or redemption (a domain that is not a DNS name is INVALID_INPUT). Naming someone else as the sponsor needs iam:guests:manage, and an assumed role cannot invite (redemption could never re-check its rights). Guests never make anyone a member or choose a sponsor, themselves included. Renewing a guest who owns the organization, or clearing their end with convertToMember, needs an owner or root caller, as identities.update does. remove releases the guest's license seats at once. Redemption counts the client's budget before the organization-wide one. An overdue review is reminded once also for a guest without an end. Delegation tokens for a guest's delegations stay within the guest boundary, when issued and on live verification. The workflow disable step revokes the guest invitations a person sent or sponsors, as identities.setStatus does. Inviting a former guest's address explains that their disabled account must be deleted first.
  • License management: a new licenses API group tracks who holds a seat of what you sell or buy. Products (createProduct, updateProduct, retireProduct, listProducts, getProduct) have a permanent key, a name, a description and up to 50 feature keys; the root tenant defines platform products that every tenant sees (root administrators only), and any other tenant defines products for itself and the tenants below it, never repeating a key an enclosing tenant defines (a later enclosing key marks the lower product shadowed). Retiring a product releases its seats in every tenant and keeps its pools and assignments as read-only history. Pools (addPool, updatePool, removePool, listPools) give a tenant seats of a product, optionally from startsAt until endsAt and referencing the billing subscription that paid for them; capacity is granted by the product's owner (root for platform products, a tenant's managers for its own products, to itself or a tenant below it) and never by the tenant that consumes it, and ended pools are kept 400 days as purchase history. Products are assigned to people of any kind and to groups (assign, assignMany for up to 100 people, unassign, listAssignments; a team through its backing group). Seats are materialized per person (listSeats): the claimants (direct assignees and live members of assigned groups, active and unexpired identities, guests included) are ranked by seniority, the first capacity hold an active seat and the rest wait in order with their position, and a capacity cut moves the newest active seats back to the waiting list instead of failing. Seats move in the same transaction as assignments, pool changes, retirement, group membership changes from every writer (groups, teams and team sync, SCIM, packages), group deletion and identity deletion, and right after disabling or offboarding a person. usage reports capacity, active, waiting, available and reclaimable seats per product; mine gives people their own licenses, waiting places and feature keys without a permission (not audited); getSettings and configure turn on inactive-seat reclaim (reclaimAfterDays, 7 to 365) and waiting-list email (notifyWaiting, templates license-waiting and license-activated). What a license unlocks uses existing governance: the policy context key principal.licenses (the keys of the products the principal holds an active seat for in the decision's tenant, read only when a document names it; assumed roles hold none; simulations, whoCan and access invariants see the real seats; policies.test defaults it to []; policy lint knows it), the birthright package rule key identity.licenses (product keys, checked when a package is saved and by both config.plan and config.apply; seats held only through memberships a package created do not count; autoAssign.warnings flags rules that test it; a rule whose product disappears is suspended), and iam.licenses.features(identityId, tenantId) / iam.licenses.products(identityId, tenantId) for application code. Scheduler jobs iam.licenses.reconcile({ tenantId? }) (hourly: pool terms, lapsed memberships and accounts, repairs; birthright packages follow for the people whose seats changed) and iam.licenses.reclaim({ tenantId? }) (daily: removes the direct assignments of people with no sign-in or credential use for reclaimAfterDays). Assignment and pool changes run the tenant's enforced access invariants, and capacity granted from an enclosing tenant runs the receiving tenant's as well (INVARIANT_VIOLATION, nothing saved). Permissions iam:licenses:read, iam:licenses:manage and iam:licenses:assign on iam/licenses/products[/{id}], iam/licenses/pools[/{id}], iam/licenses/assignments, iam/licenses/seats and iam/licenses/settings (creating and retiring products need a recent sign-in); audited as license:product-create, license:product-update, license:product-retire, license:pool-add, license:pool-update, license:pool-remove, license:assign, license:unassign, license:seat-activate, license:seat-waiting, license:seat-release, license:reclaim and license:settings. Collections licenseProducts, licensePools, licenseAssignments, licenseSeats and licenseSettings, removed with their tenant; iam.sweepExpired() removes pools 400 days after they ended. Exports licenseCapacity, licenseCollections, licenseContextKey, licenseKey, liveLicensePool, maxLicenseProductsPerTenant, maxLicenseFeatureKeys, maxLicensePoolsPerProduct, maxLicensePoolQuantity, maxLicenseAssignmentsPerProduct and the module's types. Guide: docs/licenses.md.
    • Breaking: licenses is a reserved principal name, so a deployment that declares an identity attribute called licenses fails at startup with INVALID_CONFIG, and principal.licenses values supplied by resolveContext or a plugin are removed before the server sets its own.
    • Console: Organization › Licenses shows seats in use per product, every product the organization can use, the waiting list, and the reclaim and waiting-list email settings, and lets managers define products; each product's page shows its pools (seats granted to the organization or its projects, changed and removed there for the organization's own products), the people and groups given it (one, several at once, or a group), the active seats with who has been inactive, and the waiting list in order. Member pages gain a Licenses card that gives or takes back a product. The console runs iam.licenses.reconcile hourly and iam.licenses.reclaim daily.
    • Review fixes: when an enclosing tenant (the platform included) defines a key a tenant below already uses, the enclosing product wins: the lower product is shadowed and its seats no longer grant the key or its feature keys (principal.licenses, identity.licenses, iam.licenses.products/features, licenses.mine), so no tenant can claim a key such as enterprise first and mint seats that count as the platform's. A tenant moved out from under a product's definer (tenants.reparent) stops holding it at once, the next iam.licenses.reconcile() releases its seats (assignments stay), and the definer can still removePool the capacity it granted there. Changing the members of a group a product is assigned to (or of a group that team sync copies into a licensed team) through groups.addMember, addMembers and updateMember, packages, workflows or configuration sync needs iam:licenses:assign as well, and the identity.licenses rule warning now names team maintainers and directory sync as the other ways in. Seats now follow the backing groups team sync fills (the synced teams' and those above them), accepted member invitations into licensed groups, SCIM deactivation, reactivation and DELETE of a person (new optional SCIM host callback identityStatusChanged), and SCIM DELETE of a group, which also removes the group's license assignments (callback groupDeleted), all in the same transaction. groups.addMembers, offboarding and configuration sync reconcile each product once per batch instead of once per membership. The reclaim job leaves the assignments of retired products alone.
    • Feature keys and seat claims: iam.licenses.features(identityId, tenantId, { trustedTenantId? }) counts only products defined by the trusted tenant or a tenant above it, the root tenant (platform products) by default, so a tenant can no longer mint a paid feature by defining a product of its own that lists its key. licenses.mine marks each license platform and definedBy; its top-level featureKeys now holds platform products' keys only, and allFeatureKeys (new) every product's. Every other writer that puts people into a licensed group now needs iam:licenses:assign too (ACCESS_DENIED, audited as a denial): identities.createMany and identities.invite with groupIds (re-checked at acceptance, and by identities.resendInvitation, which now refuses with INVITATION_INVALID to send an invitation acceptance would refuse), guest invitations (re-checked at redemption), guests.convertToMember with clearExpiry for a licensed group the guest would stay in for good, access packages (assign, approve, extend, saving a rule; an automatic rule whose owner lacks it has its additions suspended as an issue), onboarding completion groups, team administrators (iam:teams:update) on a licensed team or one below it, and configuration sync. Team maintainers, directory sync (SCIM) and renewing a guest (guests.attest, by the sponsor or an administrator) stay exempt by design; a renewal now lengthens only the memberships the invitation created at redemption (GuestGrants.packageMembershipIds), so a group a package gained since, or a membership the guest holds by hand or through another package, keeps its end.

0.2.0 (2026-09-24)

Second release, and the first under Apache-2.0: 24 packages, with the new @better-iam/ldap. New security services (key management, secrets vault, tokenization, private CA, SSH certificates, verifiable credentials, an LDAP gateway, identity threat detection, device posture, a Shared Signals receiver), governance additions (privacy and consent, lifecycle workflows, a compliance center, an application catalog), data filtering and API usage plans, a redesigned documentation site, and fixes from three security reviews.

  • Relicensed under the Apache License 2.0 (was MIT): the root and every package's LICENSE file, license in every manifest (validate-packages now requires Apache-2.0), a root NOTICE, and the READMEs and docs. Version 0.1.0 on npm remains MIT; the next published version carries Apache-2.0. Package code is otherwise unchanged since 0.1.0.
  • Redesigned the documentation site in black and white on BoardUI. The BoardUI components and design tokens are source files in apps/docs, and a monochrome layer (styles/monochrome.css) makes controls black in light mode and white in dark mode. Fumadocs' palette maps onto the same tokens, so the docs and the landing pages match. There are no radial gradients: states are told apart by weight (solid, outlined, dashed, hatched), code blocks and Mermaid diagrams are grayscale, and social cards are monochrome.
  • Icons come from react-icons: Remix Icon (the set BoardUI is drawn with) through the lib/icons.ts registry for frontmatter, meta.json and MDX, and Simple Icons for framework logos in the sidebar, the framework cards and the landing page. lucide-react was removed.
  • New landing page layout: every band sits between the same two page-length rails with crosshairs where they meet section lines, and marketing pages scroll smoothly with Lenis (in-page links glide to their section below the sticky bars).
    • A new hero: an editorial headline and a walkthrough panel whose three steps (Authenticate, Authorize, Audit) play real code beside a live result. The audit scene verifies its chain in the browser with verifyAuditChain.
    • The pipeline chapter is now a compact trace: a timeline sized to each stage's share of the request auto-advances, and stage panels slide sideways beside the code. It is no longer pinned to page scrolling.
    • Every chapter is more interactive, with hover states and scroll-in animations: the decision demo uses BoardUI switches and lights its checks in order, clicking a tenant suspends it down the tree, the elevation timeline can be scrubbed, the audit chain breaks visibly when edited and can be restored, and federation lanes light up on hover.
    • Diagrams that play by themselves hold while the pointer is over them.
  • BoardUI-styled search trigger, theme toggle, callouts, cards and page components in the docs, and a restyled policy playground. BoardUI's MIT notice is in apps/docs/THIRD-PARTY-NOTICES.md.
  • The site header's call to action is now a GitHub button, and repository links (GitHub, "Edit this page", source files, issues) default to github.com/Better-IAM/better-iam.
  • Credits Sean Filimon, the creator of Better IAM, in page metadata, the site footer, the hero, the docs sidebar, at the end of every docs page, on social cards, in llms.txt, in the FAQ, and on the Introduction page.
  • Search and social metadata for the documentation site. Favicons (SVG that follows the light or dark color scheme, a 16/32/48 px ICO, and an Apple touch icon), a web app manifest with regular and maskable icons, and the same browser icons in the console. Every page has a canonical URL, keywords, and complete Open Graph and X card tags. Social cards are redesigned 1200×630 images in the site's typefaces, with new cards for the home page and the playground. Docs pages add article tags, Slack preview labels, and schema.org TechArticle and BreadcrumbList data, and the home page describes the site and the software in JSON-LD. Docs frontmatter gains an optional metaTitle for pages whose sidebar title is too terse on its own (31 pages use it), API reference groups are titled "{group} API", and 9 over-long descriptions were shortened to fit search snippets. Optional build variables: DOCS_X_HANDLE and search console verification tokens. The Markdown copy of each page sends a canonical Link header, the 404 page is titled "Page not found", and the console is marked noindex.
  • Every published package has its own npm description and more search keywords. Most packages previously shared one generic description.
  • Search engine indexing for better-iam.com. www. and Railway-generated hosts redirect permanently to the canonical origin, and /docs now redirects to /docs/introduction permanently instead of temporarily. llms.txt and llms-full.txt are marked noindex. Bing and the other IndexNow engines get changed pages after every successful Railway deploy (apps/docs/scripts/indexnow.mjs, .github/workflows/indexnow.yml).
  • Key management (KMS): a new keys API group gives each tenant keys whose material never leaves the server. AES-256-GCM keys encrypt and decrypt up to 4 KiB, bound to an encryption context, and issue data keys for envelope encryption; reEncrypt moves ciphertexts between keys. ECDSA (P-256, P-384), Ed25519 and RSA keys sign and verify, RSA keys can also encrypt with OAEP, and HMAC keys compute and check MACs. signJwt and verifyJwt work with signing and MAC keys, and publicKey / jwks publish public keys for offline verification. Keys have aliases, tags, versions with on-demand and automatic rotation, disable/enable, and deletion after a 7 to 30 day waiting period. Policies authorize iam:kms:* actions on iam/kms/{keyId} with conditions on key tags, spec, aliases, encryption context and algorithm, and key grants delegate named operations to one identity, re-checked against the grantor's own access on every use. Alias names are authorized on iam/kms/alias/{name}, and signJwt exposes the token's sub, iss and aud to policies. Every call is audited without plaintexts, and plugins never see plaintexts or tokens. Material is sealed under the deployment secret and re-sealed by iam.rotateSecrets(). The scheduler job iam.kms.maintain() rotates keys, destroys deleted ones and removes lapsed grants. New errors: KEY_STATE_INVALID, INVALID_CIPHERTEXT, KEY_MATERIAL_UNAVAILABLE. Guide: docs/key-management.md.
  • Private certificate authority: a new pki API group runs X.509 certificate authorities per tenant, for mutual TLS, internal HTTPS and workload identity. Roots and intermediates (path lengths, name constraints, CRL distribution points) sign with KMS keys pinned to one version. issueCertificate takes a PKCS#10 request (createCertificateRequest builds one without OpenSSL) and decides iam:pki:issue separately for every name on the certificate (resource.name, resource.nameType, validity, usage, key type), so policies can limit teams to their own DNS names. requestCertificate gives a service account, agent or person a SPIFFE X.509-SVID for its own identity. Revocation (a subordinate CA's included), cached signed CRLs served publicly through iam.pki.crlResponse, trust bundles, and iam.pki.verify for servers that terminate mutual TLS (it checks the stored chain, revocation and authority state, and returns the workload identity). New errors: INVALID_CSR, NAME_NOT_PERMITTED, AUTHORITY_UNAVAILABLE, CERTIFICATE_INVALID. Guide: docs/private-ca.md.
  • Data protection by tokenization: a new protection API group replaces sensitive values with tokens. Profiles per data type (card, ssn, email, phone, generic) choose format-preserving tokens (a card token keeps its length and last four digits but never passes the Luhn check) or random tok_ tokens, deterministic tokens for joins, a display mask and a retention. Values are validated, normalized, and encrypted with one data key per call wrapped under a tenant KMS key. tokenize, detokenize (with a required purpose, visible to policies as resource.purpose), mask and deleteTokens (by token, or by value through a keyed fingerprint, for erasure requests) are separate permissions on iam/protection/{profile}. The audit trail records counts and purposes, never values or tokens. iam.protection.sweep() enforces retention. Guide: docs/data-protection.md.
  • Key management, private CA and data protection hardening (security reviews): keys a certificate authority or a data protection profile uses are managed by it (managedBy, KEY_MANAGED for direct KMS use); changing a key's tags or aliases can never give the caller a KMS action they are refused now; delegated agents use up one confirmation per KMS, CA or data protection call; refusals are audited with the profile and purpose. The CA decides canonical names (wildcards as nameType: wildcard), requires the common name among the alternative names, reserves SPIFFE identity paths for requestCertificate, caps SVIDs at a day and at the credential and identity lifetimes, keeps trust domains unique, enforces path lengths over the whole chain, revokes descendant authorities with their parent, bundles roots only, and iam.pki.verify requires tenantId and a live identity. Data protection masks never show more than half a value (except first six and last four digits of 15+ digit cards), mask styles are per data type and policies see the style used, letters and digits of every script are masked and replaced, text is NFC-normalized and control, bidi and unpaired-surrogate characters are refused, format-preserving ssn/phone profiles are deterministic, SSN tokens avoid ITIN ranges, tokens are unique per profile (hashed key), the lookup key is wrapped under the profile's KMS key, customer keys need the caller's own KMS permission on every call, tokenize no longer returns created, shortening retention needs iam:protection:delete, sweeps run in batches and are audited, profiles report tokens, and deleting a profile retires its key. Migration 0006_protection_indexes indexes value fingerprints (SQLite and libSQL).
  • Secrets vault: a new vault API group keeps each tenant's secrets under path-like names, authorized as iam:vault:read|reveal|write|rotate|lease|manage on iam/vault/secrets/{name}, with the secret's tags as policy attributes (resource.tag.{key}). Secrets are text or json, versioned with stage labels (current, previous, pending, custom), kept to maxVersions, and can be disabled or destroyed per version. Rotation stages a generated value (CSPRNG generators with charsets, exclusions and character classes), lets a deployment-configured rotator apply it to the system it unlocks, and promotes it; failures keep the pending value for an idempotent retry and are recorded redacted. Check-out policies hand shared privileged credentials out one holder at a time, for a limited time, with a reason, rotating them when they come back. Dynamic secrets mint a credential per caller through an engine (vault.engines) with renewable, revocable leases. Values are sealed with the deployment secret, bound to tenant, secret and version, or under a customer-managed KMS key (kmsKey), so disabling the key shreds them. Deletion has a 7 to 30 day recovery window. accessLog shows who used a secret. Server code reads secrets and resolves vault://name#field references through iam.vault, whose jobs rotateDue, expireLeases and purgeDeleted run the schedule. Values never leave through "view as" sessions. New errors: SECRET_PENDING_DELETION, CHECKOUT_REQUIRED, SECRET_CHECKED_OUT, VERSION_DISABLED, VERSION_DESTROYED, ROTATION_FAILED, ENGINE_FAILED. Guide: docs/secrets-vault.md.
  • Data filtering (query planning): iam.planResources and the filters API group (plan, and planFor for an administrator's preview with iam:policies:simulate) answer "which resources of this type may the caller perform this action on?" as a filter over the resource's id and attributes, by partially evaluating the same grants, denies, boundaries, relationships, sessions and delegations a decision uses (always, never, or conditional). The planner, planResources in @better-iam/core, mirrors the engine exactly (a randomized differential test compares it with evaluatePolicy and with SQLite), and the filter compiles with filterToSql (PostgreSQL and SQLite, parameterized and NULL-safe), filterToPrisma, filterToMongo, filterMatches and describeFilter. The Express, Hono, Fastify, SvelteKit and React Router integrations gain plan({ action, type }) on their per-request helpers, and the console a Data filters preview page. Prepared decisions expose their inputs read-only (DecisionInputs). New error: UNSUPPORTED_FILTER. Guide: docs/data-filtering.md.
  • SSH certificate authority (ssh option): a new ssh API group makes Better IAM the certificate authority for your servers. People, service accounts and agents get short-lived OpenSSH user certificates (issueCertificate, CLI ssh-cert) naming one principal {login}@{host} per host and login their policies allow: ssh:login on ssh-login/{host}/{login} with the host's labels as attributes, and ssh:port-forward/ssh:agent-forward/ssh:x11-forward on ssh-host/{host}. Certificates live only as long as the session or API key that requested them and the time-limited grants (just-in-time activations, temporary bindings and memberships, access windows) that allowed them; agents acting for people get ten minutes. Tenant settings can require MFA, FIDO security keys (with verify-required) or source-address binding. Hosts enroll once with a join token (enrollHost, CLI ssh-host-enroll), get a host certificate, the trusted user authority, per-login principals files, a key revocation list and an sshd_config drop-in, and check in with a renewal token (syncHost, CLI ssh-host-sync) that can never move them to another key. The host authority only vouches for names the organization owns (single labels, private addresses, its verified domains, within hostPatterns), each unique to one host and within the administrator's own iam/ssh/hosts/{name} scope; host keys can never be authority keys or another host's key; members' clients trust it for the enrolled names only, and the public trust route lists no host names. Revocation is by two KRLs, user for sshd RevokedKeys and host for clients' RevokedHostKeys; iam.ssh.sweep() (CLI ssh-sweep) re-validates each certificate's own session and revokes those whose holder became inactive, whose session ended, or whose hosts, logins or forwarding policies no longer allow. Suspended organizations still publish revocations (all user certificates). Renewal tokens rotate with every renewed host certificate, public host routes and issuance are rate limited, and refused host calls are audited. Authorities rotate in two phases (pending, active, previous, retired), are sealed under the deployment secret and re-sealed by iam.rotateSecrets(). whoCanLogin reviews host access, and the platform root override never opens an organization's hosts. New errors: SSH_NOT_CONFIGURED, SECURITY_KEY_REQUIRED, SOURCE_ADDRESS_UNKNOWN, TOO_MANY_HOSTS, ROOT_SSH_RESTRICTED, HOST_OUTSIDE_PATTERNS, HOST_NAME_TAKEN, HOST_KEY_IN_USE, HOST_KEY_CHANGED. Guide: docs/ssh-access.md.
  • LDAP directory gateway: the new @better-iam/ldap package (also better-iam/ldap) is a read-only LDAPv3 server (createLdapServer({ iam, tls })) so applications that only speak LDAP (VPNs, NAS devices, CI servers, wikis) look up people and groups and check passwords against Better IAM. Each organization publishes its directory under its own base DN with the new ldap API group (getSettings, updateSettings, directory; permissions iam:ldap:read, iam:ldap:manage): people under ou=people (uid as email, local part or ID), groups as groupOfNames with member/memberOf, optionally service accounts, and only the identity attributes it selects. People bind with their password as a Better IAM sign-in (rate limits, lockouts, IP rules and alerts apply; people who use MFA append their current code), exactly one attempt per bind; service accounts bind with an API key. Binds without TLS are refused except from loopback, names with empty passwords are refused, and searches are authorized as the bound account: the whole directory with iam:ldap:read (audited as ldap:directory:read), otherwise only its own entry. Search (all RFC 4515 filters, paged results), compare and WhoAmI are supported; writes are refused. New error: LDAP_BASE_TAKEN. Guide: docs/ldap.md.
  • Verifiable credentials (verifiableCredentials option): every organization issues SD-JWT VCs (IETF SD-JWT-based Verifiable Credentials, RFC 9901 selective disclosure) that people keep in digital wallets. Credential types (iam:vc:manage) map account data to claims: email, name, organization, teams, department, declared identity attributes, static values, each selectively disclosable unless marked otherwise. People get credentials for themselves where policies allow vc:request on credential-type/{name} (and MFA when a type requires it), through OpenID4VCI (createOffer returns an openid-credential-offer:// link with a pre-authorized code and optional PIN, served by issuer endpoints mounted at {basePath}/vc/{tenantId} with /.well-known metadata, token, nonce and credential endpoints) or directly with request; administrators offer them to others (iam:vc:issue). Credentials are bound to a holder key proven with an openid4vci-proof+jwt over a single-use nonce. Revocation and suspension use an IETF Token Status List with random, never-reused indexes, served as application/statuslist+jwt; holders can revoke their own, and iam.verifiableCredentials.sweep() revokes those of people who left or may no longer request them. Self-service credentials end with the time-limited grant that allows vc:request (expiring bindings and memberships, just-in-time activations, access windows) or the role session they came from, and delegated agent sessions cannot obtain them. verify (public) and iam.verifiableCredentials.verify check signature, disclosures, key binding (audience and nonce, both required, freshness, sd_hash) and status. Proof nonces are signed rather than stored, public issuer endpoints only read, wrong wallet PINs and refused redemptions are audited as denied vc:offer:redeem, and a type's vct may not claim another organization's issuer. Issuer ES256 keys are sealed under the deployment secret and rotate with rotateKey/retireKey. issueSdJwt, presentSdJwt, verifySdJwt, signStatusList, readStatusList and verifyHolderProof are exported for wallets and verifiers. New errors: TYPE_DISABLED, IDENTITY_INACTIVE, CLAIM_UNAVAILABLE, INVALID_PROOF, INVALID_NONCE, ACCESS_EXPIRING. Guide: docs/verifiable-credentials.md.
  • Privacy and consent management: a new privacy API group records each tenant's processing purposes with their legal basis (consent opt-in or opt-out, legitimate interests, contract, legal obligation, vital interests, public task), data categories and retention, and each person's decision on them, made by the person (mine, decide), recorded by the application for its own customers (record, importDecisions, or iam.privacy.record without a credential) and checked with check, filterSubjects, audience or iam.privacy.check. Purposes have versions that ask for consent again and optional consent lifetimes. Every decision appends to an append-only history and returns an HMAC-signed consent receipt that verifyReceipt checks, across secret rotations. Policies see principal.consents, the purposes that may be processed for the person now. Data-subject requests (access, portability, erasure, rectification, restriction, objection, opt-out) are filed by the person, by staff with a verification record, or through an optional public form confirmed by email (submitPublic, confirmPublic); requests known only by an email address are linked to their subject (linkRequest) or declined as no-data. They run on GDPR, UK GDPR, CCPA, LGPD and PIPEDA deadlines with one extension where the law allows, and fulfilment builds exports (downloadable for a limited time, then swept), erases accounts and privacy records (an erased application subject stays suppressed as ERASED), restricts processing, or withdraws consents. Legal holds refuse erasure and every deletion of the person's account. iam.privacy.sendDeadlineReminders() reminds handlers of due and overdue requests and lapses unconfirmed ones. Authorized as iam:privacy:read|manage|record|check|handle; audited as privacy:* events; emails privacy-request-verify, privacy-request-received, privacy-request-update and privacy-request-due with the links.privacy builder. Console pages: Organization › Privacy and Home › Your privacy. New errors: LEGAL_HOLD, CONFIRMATION_INVALID. Guide: docs/privacy.md.
  • Security fixes from a review of authentication, sessions, temporary credentials and the framework integrations:
    • SvelteKit protect rules now match the path SvelteKit actually routes (percent-decoded, and the matched route id for reroute aliases); before, /%61dmin skipped a /admin rule. Remote functions still need their own guards.
    • A password alone can no longer enroll an authenticator over an existing factor: when a passkey is registered, the MFA challenge answers enrollmentRequired: false (new authenticatorEnrolled field) and beginMfa/confirmMfa from the login challenge are refused with MFA_REQUIRED.
    • NestJS: @Public() no longer turns @Authorize, @RequireMfa, @Credentials or @RequireClaims into no-ops for anonymous or invalid credentials (401 instead); CSRF checks also cover GraphQL over HTTP; the assertion guard accepts a lowercase bearer scheme.
    • roles.assume refuses delegated agent sessions (CREDENTIAL_CHAINING_DISABLED), so an agent cannot mint a role JWT that outlives its delegation.
    • An identity past its expiresAt is refused at sign-in and on every session use in the authentication service itself, not only in authorization decisions.
    • startPasswordless replaces earlier codes and links for the destination; the first emailed sign-in to a self-registered account whose address was never verified ends its sessions and removes the password chosen at sign-up (new Identity.unprovenPassword, cleared by verifying the address or resetting the password; the owner sets a password through reset), so an account registered in someone else's name cannot be kept by its registrant; verify-email and password-reset links only work for the address they were sent to; emailed MFA codes are stored with a keyed digest; new authenticator secrets are 160 bits.
    • reauthenticate refuses an expired password (PASSWORD_EXPIRED) and honours emailPassword: false.
    • Impersonation sessions also end when the administrator's session idles out (the administrator's tenant idle timeout), when the tenant turns allowImpersonation off, or when the member becomes an owner or root administrator.
    • Lowering sessionLifetimeMs or trustedDeviceDays now also shortens sessions and remembered devices issued under the longer value. Network blocks also judge the address presenting a user session, and bindSessionsToIp also applies when a user session is the source of a role session.
    • Passkey sign-in with an email answers an unknown address, or one without passkeys, with the same shape of options as a real account, so it no longer reveals which accounts exist.
    • Assertions: scoped credentials (API keys with scopes, role sessions with a session policy) are refused like scoped session tokens, and only the canonical spelling of a signature verifies (server and @better-iam/next edge verifier).
    • Web-identity trusts: a StringLike pin on token.sub may use a wildcard only after two complete segments (repo:acme/* is accepted; repo:*, which admits every repository on the platform, and repo:acme*, which also admits acme-evil, are refused with WEAK_TRUST_CONDITIONS). Existing trusts are not re-validated: review them with trust.list. With a recorded client address, sts.assumeRoleWithWebIdentity counts maxExchangesPerWindow per trust and address (ceiling: ten budgets per trust), so anyone posting junk tokens to a public trust id can no longer block the real workload.
    • Delegation tokens never carry a scope that a deny among the person's grants could touch, including denies whose author's authority was revoked (these still apply to decisions).
    • Sealed values require a 128-bit GCM tag and a 96-bit IV; delegation-token and agent-card verifiers fail closed on a NaN or out-of-range clock tolerance; web-identity replay records are kept for the largest possible clock tolerance.
    • Origin: null is refused by the middleware and Next.js CSRF checks (it equals the .origin of trusted entries such as capacitor://localhost); auth/confirmMfa is pinned to the organization address like other sign-in calls; the Fastify and h3 fallback paths cap request bodies at 2 MiB; the Next edge middleware treats only /api/iam and /api/iam/* as the IAM API.
    • Sign-in notices summarize the User-Agent ("Chrome on Windows", new describeUserAgent) instead of quoting attacker-chosen text, email subjects are kept on one line, and display names have control characters replaced at creation.
  • Security fixes from a review of authorization and the management API (each covered by tests/security-*.test.ts):
    • "View as" sessions: every check an operation makes on the side (may this role be granted, may this group be changed) now needs the impersonating administrator's rights too, not only the first one. No grant authority can be used while impersonating (IMPERSONATION_RESTRICTED), and view-as sessions can neither decide access requests nor change or remove bindings the member issued. Before, support staff could "view as" a broader administrator to create accounts with that administrator's roles or approve their own requests.
    • Policy matching: a pattern's * now also matches a literal * in the value, so a Deny on file/shared/* covers file/shared/*x. ? is one character for astral characters on every path. Evaluation has a per-call work budget and refuses with reason evaluation-limit past it; *-only globs match by native substring search. One crafted policies.test call could block the server for seconds.
    • Denies outlive their author: when the authority behind a binding, role or attached policy is revoked (or its administrator offboarded), what it granted lapses and what it denied still applies. An editor can no longer remove or change a deny statement that another authority relies on (it attached the policy, bound the role, or its role inherits it): PROTECTED_RESOURCE, unless root.
    • Separation-of-duties rules follow role inheritance, and roles.update is checked like other granting operations.
    • Tenant-defined mode: a type only a tenant registered answers from its registry for its own {type}:{verb} actions; the application's actions on an undeclared type always go to resolveResource, so an organization cannot register document and pass checks on another organization's documents.
    • A principal of another tenant is refused before any resource or action is looked up, and the refusal is recorded in its own tenant (metadata targetTenantId) instead of the target's audit chain and webhooks. authorize returns a denial instead of NOT_FOUND/RESOURCE_MISMATCH for such callers.
    • audit.verify (and verifyAuditChain with a head) checks the whole stored chain: new failures missing-prefix (oldest events deleted without an audit:prune checkpoint) and unchained (an event inserted without chain fields); deleting every event fails the head check. Startup backfill chains only tenants without a chain yet.
    • Teams: moving a team, or opening an admins-only team to its maintainers, needs grant authority over what the teams hold; the maintainer path no longer overrides an explicit deny. Onboarding completion groups, group-to-team sync, team leave (when a team carries a deny) and maintainer standing through admins-only teams are re-checked against grant authority.
    • Access packages: extend and membership hand-over only lengthen what the package still bundles, automatic grants run the enforced access invariants, and autoAssign.warnings flags rules on facts other administrators control.
    • Identities and tenants: non-owners cannot disable, expire or delete owners; administrators cannot edit their own attributes, manager or expiry, and setting attributes or a manager at creation needs iam:identities:update; invitations are re-checked against the inviter at redemption; child tenants inherit the stricter of their parent's plan limits; subtree webhooks and the root tenant's sign-in policy, sessions and network blocks are root's; scoped API keys only issue keys within their scopes; demoting an owner revokes the owner grant authority.
    • Billing: plan prices apply only to usage inside the subscription and trials once per account and plan; seat recording uses internal keys callers cannot claim; sub-accounts inherit contract terms; usage cannot be attributed to platform identities or teams; payment terms and built-in meter keys are the platform's; CSV exports neutralize formulas.
    • Also: plugin endpoints can name the record they act on (PluginEndpoint.resource), so per-record policies apply; resource IDs cannot contain * or ?; resources.list and relationships.list leave out records a policy keeps from the caller; approvers can shorten but not lengthen an activation; role mining is linear on crafted inputs; certification auto-close acts only for a creator still in good standing; analysis suppressions need a current finding.
  • Identity lifecycle workflows (joiner, mover, leaver): a new workflows API group runs steps for each person a trigger fires for. Triggers are joiner (people who join after the workflow is enabled, or everyone with includeExisting), mover (watched attributes or managerId changed, against a baseline taken when the workflow starts), leaver (an active person was disabled), date (days before or after createdAt, expiresAt or an ISO date attribute such as startDate; a moved date fires again) and manual. A scope in the access-package rule language picks the people. Steps add to and remove from groups (remove-from-all-groups leaves team and package memberships alone), assign and revoke packages (rule-made assignments stay with their rule), send emails with placeholders (workflow-message) to the person, their manager, or addresses of members and verified domains only, sign people out, disable, enable, set attributes and expiry, delete (last step only), emit workflow:event for webhooks, and wait up to five times. A run keeps the steps it started with and the rights of the owner who approved them (the workflow's owner when it started, or whoever retried it), re-checked at every step with the owner's MFA state (OWNER_INACTIVE when the owner is gone); update with activeRuns: 'cancel' stops runs in progress. Steps never strip or edit owners or root administrators (PROTECTED_RESOURCE), and nobody changes their own account through a workflow they own, start or retry. A daily brake (maxRunsPerDay, 25 when a step takes access away, else 200) starts runs up to the day's allowance, holds the rest, and records workflow:brake. preview shows who is in scope, who would start and upcoming dates; run, retryRun, cancelRun and evaluate handle runs by hand, and the readers show names only to callers with iam:identities:read. iam.workflows.runDue() (every few minutes) and iam.workflows.subscribe() (with iam.dispatchAuditHooks(); iam.workflows.idle() waits for its background evaluations) run them; finished runs are swept after 180 days. Authorized as iam:workflows:read|manage|run; audited as workflow:run:start, workflow:step, workflow:run:complete, workflow:run:fail, workflow:brake and workflow:event. Console: Access › Workflows. New error: OWNER_INACTIVE. Guide: docs/workflows.md.
  • Identity threat detection and response (ITDR): a new threats API group watches each tenant's audit trail for identity attacks. Twenty-one detection rules, each mapped to a MITRE ATT&CK technique: password-spray, brute-force, brute-force-success, mfa-bombardment, session-hijack, new-network (privileged people, or everyone), dormant-reactivated, account-takeover-persistence (sign-in methods changed after a risky sign-in), privilege-escalation, root-admin-granted, mass-deletion, directory-mass-change, impersonation-burst, denial-burst (AI agents included), recon-burst, guardrail-weakened, token-replay, audit-tampering, invariant-broken, user-reported and upstream-signal (an upstream provider's Shared Signals event about a matched person: compromise, a disabled account, or medium/high risk). The scheduler job iam.detectThreats({ tenantId?, maxEvents? }) (CLI detect-threats --tenant --max-events, every minute) reads each active tenant's audit chain from a sequence cursor, at most 2000 events per run by default, verifies the hash chain as it goes (a break raises audit-tampering; prune checkpoints are followed), and never slows a sign-in. Detections are deduplicated per triggering event or burst, grouped into one open incident per subject (identity, network, tenant or SCIM connection), and raise a per-identity risk score (10/25/50/80 points by severity, decaying with the tenant's half-life, levels none, low, medium, high, administrator overrides). Methods: settings (rules, getSettings, configure: per-rule enabled, severity, threshold and windowMs within each rule's bounds, trusted networks, dormancy, risk half-life, notification recipients, the containment brake), detections (listDetections, getDetection, dismissDetection), incidents (listIncidents, getIncident, updateIncident, addNote, resolveIncident, summary), responses (respond with revoke-sessions, forget-devices, contain, block-network and notify; release), risk (listRisk, getRisk, setRisk, timeline), playbooks (listPlaybooks, createPlaybook, updatePlaybook, deletePlaybook), detect (run now), and self-service reportSuspicious ("this wasn't me": no permission, ends the person's other sessions and forgets their devices). Authorized as iam:threats:read|manage|respond on iam/threats/.... Automatic responses never contain owners or root administrators, contain at most maxAutomaticContainments (3) identities per tenant and run, and never block trusted networks; nobody clears risk raised about themselves. Policy context keys principal.riskLevel and principal.riskScore, read only when a document names them (simulated principals see none and 0). Audited as threat:detection, threat:incident-open, threat:risk-change, threat:settings, threat:detection-dismiss, threat:incident-update, threat:note, threat:incident-resolve, threat:risk-override, threat:revoke-sessions, threat:forget-devices, threat:contain, threat:release, threat:block-network, threat:notify, threat:response-braked, threat:playbook-create|update|delete and threat:user-report; Shared Signals sends threat:revoke-sessions as CAEP session-revoked and threat:contain as RISC account-disabled. Email template threat-alert with the links.threats builder. Collections threatDetections, threatIncidents, threatNotes, identityRisk, threatBaselines, threatCursors, threatSettings, threatPlaybooks, threatResponses. Exports threatRules, riskLevelFor, effectiveRisk, resolveThreatSettings and the module's types. Guide: docs/threat-detection.md.
    • Breaking: riskLevel and riskScore are reserved identity attribute names (declaring either fails with INVALID_CONFIG), and principal.riskLevel / principal.riskScore supplied by resolveContext or a plugin are removed before the server sets its own values.
  • Device posture: a new devices API group lets policies ask which device a request comes from and whether an MDM or EDR finds it compliant. A browser or agent enrolls a public key once (enroll, self-service for people: EC P-256 or Ed25519, identified by its RFC 7638 thumbprint) and then sends a short-lived proof in the x-better-iam-device header: a compact JWS (typ: device-proof+jwt, kid the thumbprint, iat within 300 seconds, sid the presenting session) whose algorithm is pinned by the enrolled key, whose header may carry no key material, and which verifies only with the session it names. Decisions verify it only when a document names request.device, never write, and treat a missing or invalid proof as no device. Policies see request.deviceAssurance (none, registered, managed, compliant), request.deviceManaged, request.deviceCompliant and, when a proof verified, request.deviceId and request.devicePlatform; policies.test defaults the first three to none and false. People list, check and retire their own devices (mine, check, retireMine). Administrators use list, get, update (rename, reassign, mark lost), retire, delete and removeKey; one-time enrollment codes (createEnrollment, listEnrollments, revokeEnrollment; biam_denr_…, stored hashed) that bind a browser or agent key to a managed record; compliance requirements (getSettings, configure: encryption, screen lock, firewall, EDR health, jailbreak, minimum OS version per platform, check-in age); and MDM/EDR integrations (listIntegrations, createIntegration, updateIntegration, deleteIntegration; Intune, Jamf, Kandji, Workspace ONE, Google endpoint management, CrowdStrike, SentinelOne or custom). Integrations post up to 500 devices per call with report: matched by vendor id, with owner (email or id), posture, OS version, and the thumbprint of a key the vendor's agent enrolled. Authorized as iam:devices:read|manage|report on iam/devices… (report on iam/devices/integrations/{id}, meant for a service account's API key); audited as device:enroll, device:retire, device:update, device:delete, device:key-remove, device:enrollment-create, device:enrollment-revoke, device:settings, device:integration-create, device:integration-update, device:integration-delete, device:report and device:compliance-change. The HTTP handler allows the header cross-origin, the Next.js and Express/Hono/Fastify/SvelteKit in-process clients forward it, and AuthenticatedPrincipal.deviceProof carries it (unverified) to decisions. Deleting a person retires their self-enrolled devices and unassigns their managed ones; the retention sweep deletes expired enrollment codes. New client entry point @better-iam/client/device (also better-iam/client/device): createDeviceProver (creates and keeps the key pair, returns the public JWK and thumbprint, caches proofs, and headers(sessionId) plugs into ClientOptions.headers), indexedDbKeyStore (a non-extractable key in the browser) and memoryKeyStore; WebCrypto only, for browsers and Node 22 or later. Registered devices are separate from remembered devices and never satisfy MFA. Guide: docs/device-posture.md.
    • Behaviour change: request.deviceAssurance, request.deviceManaged, request.deviceCompliant, request.deviceId and request.devicePlatform are now server-owned context keys, so values that resolveContext or a plugin supplies under those names are removed before the server assigns its own. Applications that passed their own device signals under these names should rename them.
  • Compliance center: a new compliance API group runs 14 automated checks over each tenant's identity and access state (mfa-enforced, mfa-coverage, password-length, session-lifetime, inactive-accounts, leaver-access, stale-api-keys, api-key-lifetime, access-reviews, privileged-access, owner-redundancy, separation-of-duties, audit-integrity, privacy-deadlines), with parameters, mapped to requirements of SOC 2, ISO/IEC 27001:2022, NIST SP 800-53 Rev. 5 and GDPR as evidence for them (not full coverage). Tenants adopt a framework (adoptFramework, one shared control per check) or add controls of their own (createControl, free-form mappings), evaluate them (evaluate, or the daily scheduler job iam.compliance.evaluateAll()), accept findings with exceptions that expire within a year, see each requirement's status (status), and export evidence packs (exportEvidence) that carry the latest results, active exceptions, recent runs and the audit chain head. Packs are signed with an Ed25519 key derived from the deployment secret: evidenceKeys publishes the public keys, verifyEvidencePack checks a pack offline and verifyEvidence on the server (earlier HMAC-signed packs still verify, across secret rotations). Exceptions take a second person's approval (approveException; revoked ones are kept as history), the audit-integrity check verifies the chain incrementally from a checkpoint, checks read outside transactions, results report when they are stale, and CLI compliance-evaluate runs the evaluation from a scheduler. Each run's results digest is recorded in the audit chain. Results and runs are kept 400 days. Authorized as iam:compliance:read|evaluate|manage; audited as compliance:evaluate, compliance:control:fail, compliance:control:recover, compliance:framework:adopt, compliance:control:create|update|delete, compliance:exception (proposed), compliance:exception:approve, compliance:exception:revoke and compliance:evidence-export. Exports complianceChecks and complianceFrameworks. Console: Governance › Compliance. Guide: docs/compliance.md.
  • Shared Signals receiver: Better IAM now receives OpenID Shared Signals (SSF 1.0 with CAEP and RISC) from upstream identity providers. A new signals API group registers each organization's transmitters as signal sources (createSource, listSources, getSource, updateSource, deleteSource, rotatePushToken; at most 20 per organization, one per issuer). A source has an issuer with up to 5 aliases, accepted audiences, static public keys, a key URL or SSF discovery (/.well-known/ssf-configuration, then the legacy RISC document), an algorithm allowlist (never HMAC or none), push or poll delivery, subject mapping, and an action per event type. Events arrive by RFC 8935 push at {signals.pushPath}/{sourceId} (default /api/iam/signals/push/{sourceId}: application/secevent+jwt, an optional bearer token shown once and stored hashed, 64 KiB bodies, rate limited per source, 202 or { err, description } with 400, 401, 404, 413, 415, 429 and 503 while keys cannot be fetched) or by RFC 8936 poll through the new scheduler job iam.signals.poll() (acknowledgements only after the event is stored, sent with the next request; setErrs for refused events; up to five requests per source and run; the poll token sealed with the deployment secret and re-sealed by iam.rotateSecrets()). iam.signals.receive(sourceId, set) hands in SETs from custom transports, and signals.poll polls one source on demand. Each SET is checked for size (16 KiB), compact JWS form, no key headers (jwk, jku, x5u, x5c) or crit, the allowed algorithm, typ secevent+jwt (per-source requireTyp: false for legacy Google RISC), signature, issuer, audience, iat within seven days, one event, no nonce and a consistent subject, then deduplicated per organization on issuer and jti. Subjects (RFC 9493 formats, SSF complex, legacy RISC subject_type) map to people of the source's organization only: iss_sub through the federation links of chosen sign-in connections, iss_sub and opaque through the externalId of users provisioned by chosen SCIM connections, and email or acct: subjects only at domains the organization verified. Service accounts, agents and deleted identities never match. Events are recorded for 90 days with a status (applied, recorded, unmatched, ignored, failed; listEvents, getEvent, and reprocess after the mapping is fixed). The only direct action is revoke-sessions (every session except API keys) for the event types a source chooses, never against root administrators from other organizations. Everything is audited under names the transmitter does not publish, so nothing echoes back upstream: signal:received (actor signal:{sourceId}, with sourceId, eventType, jti, status, identityId, reasonAdmin, currentLevel and credentialType for threat detection), signal:revoke-sessions, signal:source-create|update|delete|rotate and signal:reprocess. Authorized as iam:signals:read|manage on iam/signals/sources and iam/signals/events. Deployment option signals (pushPath, allowPrivateNetworks, allowInsecureLocalhost); key, discovery and poll requests go through the SSRF-guarded transport. Collections signalSources and signalEvents. @better-iam/core exports the pure pieces: signalEventUris, signalEventType, parseSubjectIdentifier, legacyRiscSubject, readSecurityEvent, userSubjects and SignalFormatError. New error: SIGNAL_REJECTED. Guide: docs/shared-signals-receiver.md.
  • Application catalog and My apps: a new applications API group lists the tools an organization gives its people, each an OpenID Connect client of this deployment (oauthClientId, kind oidc) or a plain link (link), with a category, logo, owners, and everyone or assigned visibility (at most 500 per tenant). Apps are assigned to people and groups, optionally until a date (assign, unassign, listAssignments); people without an app can request it through a requestable access package (requestPackageId). People see their apps with mine and open them with launch, which records and audits the launch and returns the app's URL (refused in "view as" sessions with IMPERSONATION_RESTRICTED). A deployment's sign-in pages can refuse people who lack an OIDC app: iam.applications.allowed(...), check over the API, and the OAuth provider's new optional clientAllowed(identityId, tenantId, clientId) hook, which the protocol host wires to the catalog. usage shows launches per app and the direct assignments nobody uses, and removeUnused removes them. Deleting or clearing an app that governs an OAuth client needs releaseClient: true. Deleting a person or group releases their assignments. Authorized as iam:applications:read|manage|assign; audited as app:* events. React useMyApps. Console: Home › My apps (a launcher grouped by category) and Access › Applications. Guide: docs/applications.md.
  • API usage plans and quotas: a new quotas API group limits how much of your API (or anything else you count) a caller may use. A plan counts one meter with a token-bucket throttle (ratePerSecond, burst) and/or at most one limit per minute, hour, day, week and month (days, weeks and months in the plan's timeZone), per subject or pooled for the tenant (scope: 'tenant'). Plans are assigned to API keys, identities (people, service accounts, agents) and groups; the most specific applies (the presented key, the acting agent, the identity, its highest-priority group, then the tenant's default plan), and a meter without a plan is unlimited. iam.quotas.enforce throws QUOTA_EXCEEDED (429, with retryAfterMs), consume (in process or over HTTP) returns the decision instead, status reads what is left without counting, and consumeFor counts work your code attributes itself; a refused call counts nothing. Counters live in the database, so every instance sees the same totals, and iam.sweepExpired() removes old ones. alertThresholds record quota:threshold once per window, and the first refusal of a period window records quota:exceeded, for webhooks (throttle refusals are not audited). Methods createPlan, updatePlan, deletePlan, getPlan, listPlans, assign, unassign, listAssignments, usage, reset; authorized as iam:quotas:read|manage on iam/quotas…. New error: QUOTA_EXCEEDED. Guide: docs/quotas.md.
  • Security fixes from a review of the protocols, integrations, storage, CLI and console (each covered by tests/security-*.test.ts):
    • Outbound requests to addresses tenant administrators choose go through one SSRF guard (createGuardedFetch, exported from @better-iam/auth): webhooks, outbound SCIM targets, Shared Signals streams and organization-chosen inference base URLs refuse loopback, private, link-local, CGNAT, reserved and metadata addresses unless the deployment sets events.allowPrivateNetworks, the SCIM provisioner's or SSF transmitter's allowPrivateNetworks, or inference.allowPrivateNetworks. Changing a SCIM target's or SSF stream's URL requires its token or authorization again. A guarded response body cancelled by its reader no longer crashes the process.
    • SAML: managed connections get a random providerKey that keys their account links (deleting a connection unlinks them), trust-widening edits are re-checked and audited, IdP-initiated responses are judged fresh by IssueInstant, and only encrypted assertions that are direct children of the response count.
    • OAuth and OpenID Connect: identity_id appears only on client-credentials tokens, the registration gate normalizes its path, iam claims come from live grants, token exchange refuses revoked or disconnected subjects and DPoP-only clients, DPoP proofs have a replay cache, prompt=login and max_age are honored at the end of an interaction, and resource servers can pin a tenantId.
    • SCIM: outbound provisioning adopts existing remote accounts only by verified email and escapes filter values; inbound provisioning honors plan limits, only changes the status of accounts it provisioned, revokes fully on deprovisioning, and audits every change.
    • Shared Signals transmitter subjects use verified emails only and carry a tenant_id claim. Federation never provisions just-in-time into the root tenant, and OAuth sign-in connections accept allowedEmailDomains.
    • Delegation confirmations are single-use. Inference models on a parent tenant's provider and platform-set budgets need root, and a provider key must be supplied again when its base URL changes. Configuration sync needs a recent sign-in to change the tenant access policy (audited as tenant:access-policy), hides SCIM groups named like administrator groups, and keeps team approver groups and binding dates; pruning agents through AI configuration sync needs a recent sign-in. Plugin endpoints of @better-iam/projects name the record they act on.
    • CLI: help and shell completion never import a discovered configuration file, discovery stops at the repository root or home directory and skips files others could plant, a cli.defaults['*'] entry no longer fills destructive retention flags such as audit-prune's (only a default for that very command does), exports and profiles are written with mode 0600, and terminal control sequences in server data are neutralized in text output.
    • A2A and MCP gates refuse non-string skill and tool names. The console sends security headers and refuses the in-memory mail inbox in production.

0.1.0 (2026-09-23)

First public release: all 23 packages published to npm as better-iam and @better-iam/*.

  • Licensed under MIT (was proprietary / UNLICENSED). Every package now carries repository, homepage, bugs, keywords, and publishConfig.access: public; validate-packages enforces them. CI publishes all packages to npm with provenance after a green push to main that carries a version not yet on npm, then tags it and creates a GitHub release (needs the NPM_TOKEN secret).

  • Billing and spend tracking for people, teams, departments, projects and organizations. Guide docs/billing.md.

    • New billing API group (meters: createMeter, updateMeter, deleteMeter, listMeters; rate card: setPrice, listPrices, quote; usage: record, recordMany, listUsage; spend: spend, trend, mySpend, teamSpend, departmentSpend, accounts; budgets: createBudget, updateBudget, deleteBudget, listBudgets, check; credits: grantCredit, revokeCredit, listCredits; profiles: getProfile, setProfile, deleteProfile; statements: listStatements, getStatement, previewStatement, closePeriod, markPaid, voidStatement; actions iam:billing:read|manage|record; collections billingMeters, billingPrices, billingUsage, billingRollups, billingBudgets, billingBudgetAlerts, billingCredits, billingProfiles, billingStatements).
    • Platform meters (root) and organization chargeback meters; per-unit, graduated, volume and package prices with free allowances and negotiated prices per organization; sum and unique (active users) meters; reported-cost meters. Each meter's monthly total is priced once per billing account and shared out by quantity, so person, team, department and project spend add up to the account's charges. Usage is attributed at record time to the identity, their teams and department (agents to their sponsor's), with tags and idempotency keys.
    • Spend budgets per month, quarter or year for a tenant, team, department or person with threshold and forecast alerts (spend-alert email, audit billing:budget-alert) and optional enforcement (SPEND_LIMIT_REACHED, 402). Team maintainers and department heads read their own spend without a permission.
    • Billing profiles (a project becomes its own billing account only from its parent), credits, and monthly statements with lines, credit, a breakdown by project/team/department/person and a content hash (billing-statement email, TemplateLinks.billing); late usage for an invoiced month fails with BILLING_PERIOD_CLOSED. New codes BILLING_PERIOD_CLOSED, METER_ARCHIVED, SPEND_LIMIT_REACHED.
    • Contract terms per billing account (billing.setTerms, root-only; billing.getTerms; collection billingTerms, audit billing:terms): a discount off the subtotal, a minimum monthly commitment with a true-up line, and tax; statements carry discount, commitment and tax (in the content hash when present, so earlier statements still verify).
    • Policy context keys principal.spendExceeded (an enforced budget covering the principal is spent) and principal.budgetsExceeded (names of spent covering budgets), read only when a document names them; reserved as identity attribute names.
    • Insights: spend spikes (billing.anomalies; daily job iam.billing.detectAnomalies with audit billing:anomaly and the spend-anomaly email), showback (spend({ shareUnattributed: true }) spreads unattributed spend over people, teams or departments by their share), and CSV exports (billing.exportSpend, billing.exportStatement; console downloads).
    • iam.billing (record, recordMany, check, spend, and the jobs checkBudgets, recordSeats, closePeriod, detectAnomalies), option billing (currency, time zone, team attribution, payment terms, statement prefix, usage retention), ctx.billing.recordPriced (inference calls land on the built-in inference meter). CLI billing-close, billing-alerts, billing-seats, billing-anomalies, spend. Console: organization Billing page (spend by person/team/department/project/meter/day, trend, budgets, statements, meters, profile, chargeback), statement page, and the admin Billing page (accounts, close a month, credit, prices, mark paid/void).
    • Invoicing in the style of Stripe and Orb. Invoice lifecycle draft → finalized → paid / uncollectible / void (billing.finalizeInvoice, markUncollectible; closePeriod({ draft }) and option billing.autoFinalize); invoice items (createInvoiceItem, listInvoiceItems, deleteInvoiceItem; negative amounts are credits, a negative balance becomes account credit); payments (recordPayment, partial and over-payments; trusted iam.billing.recordPayment with an idempotency key for payment-processor webhooks); credit notes (createCreditNote, listCreditNotes, numbered {invoice}-CN-01); payment reminders (iam.billing.sendPaymentReminders, option billing.paymentReminderDays, payment-reminder email, CLI billing-reminders); a printable invoice (renderInvoice, option billing.issuer). Plans with fees, seats and meter prices, billed in advance or arrears (createPlan, updatePlan, listPlans); subscriptions with trials, prorated first invoices, seat changes, plan changes and cancellation (subscribe, updateSubscription, changePlan, cancelSubscription, resumeSubscription, listSubscriptions; self-serve plans for account billing managers); coupons (createCoupon, deactivateCoupon, listCoupons, redeemCoupon, listDiscounts, removeDiscount). Invoice lines show the tiers a quantity used, free units, service periods and prorations; prices take minimumAmount / maximumAmount. Invoice amounts are whole cents. Summaries gain amountPaidMicros, amountDueMicros, billingReason; accounts outstandingMicros; closePeriod results drafted. Collections billingInvoiceItems, billingCreditNotes, billingPlans, billingSubscriptions, billingCoupons, billingDiscounts. Console: invoice page (tiers, payments, credit notes, print), subscription/plans/promotion-code/next-invoice cards on the organization Billing page, and plans, subscriptions, coupons, invoice items, payments, credit notes and finalize/write-off on the admin page.
  • Teams inside organizations and departments. Guide docs/teams-and-departments.md.

    • New teams API group (create, update, delete, list, get, listMembers, addMember, addMembers, updateMember, removeMember, candidates, listForIdentity, listMine, requestToJoin, cancelRequest, listRequests, approveRequest, denyRequest, leave, reconcile; actions iam:teams:create|read|update|delete; collections teams, teamMembers, teamJoinRequests). Teams nest (parentId, ten levels), have maintainers who manage membership without an administrator permission (memberManagement: 'admins' turns that off), take join requests (joinPolicy: 'request', emails team-join-request / team-join-decided, TemplateLinks.team), and grant access through a team-managed backing group (team.groupId, Group.teamId) that holds the members of the team and of every team below it. The groups API, access packages, invitations, and onboarding refuse backing groups (new code TEAM_MANAGED, 409); configuration sync ignores them.
    • New departments API group (create, update, delete, list, tree, get, listMembers, assign, unassign, ofIdentity, mine (a person's own department and, for heads, the people they lead, no permission), importFromAttribute, syncManagers; actions iam:departments:read|manage; collections departments, departmentMembers): a department tree with heads, codes and cost centers, one department per person, import from a string identity attribute, and department heads as managers for manager-routed approvals.
    • Policy context keys principal.teams, principal.departments, principal.departmentId (read only when a policy names them; reserved as identity attribute names). Offboarding reports teamsLeft and departmentsReassigned when non-zero. Helpers teamsOf, primaryTeamOf, teamMaintainers, isTeamMaintainer, departmentOf, departmentPath, departmentHeads. Access analysis reports team-maintainers-grant-admin (high), team-without-maintainer, and department-without-head (finding subjects gain team and department). Configuration as code gains the teams (by slug: parent, department, join settings, maintainers, members, roles) and departments (by name: code, parent, head, cost center, members) kinds (TenantConfigTeam, TenantConfigDepartment; change kinds team, department).
    • Birthright access by org structure: access package rules may test identity.teams (a person's teams and those above them) and identity.departments (their department and those above it); team and department changes made through the API reconcile the people they touch at once; a team or department a rule names cannot be deleted (RESOURCE_IN_USE); configuration documents name them by slug and name (teams and departments are applied before packages, deleted after); console team and department pages list their birthright packages. A team membership copied by team sync counts only through a group membership no package created. departments.suggestBirthright / teams.suggestBirthright (iam:analysis:read) propose such packages from the roles and groups most of a unit already holds by hand, each ready for packages.create (console: "Suggested birthright access" with a Create package button). Team membership reviews: teams.startReview / decideReview / completeReview / cancelReview / getReview / listReviews (maintainers decide keep or remove, nobody decides on themselves, removals apply on completion with onUndecided for the rest), the scheduler job iam.closeOverdueTeamReviews(), email team-review-requested, audit team:review:*, listMine.reviews, collections teamReviews / teamReviewItems, and a Membership review card on the console team page.
    • Console: Teams and Departments pages (org chart, join requests, maintainer view), teams and department on member pages, team-backed groups marked on the Groups page. React and Vue: useTeams (@better-iam/react, @better-iam/vue). The retention sweep (sweepExpired) deletes ended team memberships (teamMembers). Team sync: Team.syncGroupIds keeps a team's members in step with up to ten ordinary groups (SCIM directory groups included, through the protocol host's group sync); synced memberships carry source: 'sync', mirror the source membership's end, and can only be changed through the source group; teams.reconcile also returns synced; the config teams kind gains syncGroups.
  • AI agents as accounts, delegation, and MCP authorization. Guide docs/agents.md.

    • Identity.kind gains 'agent' with an Identity.agent profile (AgentProfile: sponsorId, model, provider, purpose, url, protocols, delegable, maxDelegatedSessionSeconds, boundary, suspended). New agents API group (create, get, list, listMine, update, suspend, resume, delete, standing; actions iam:agents:create|read|update|delete; plan limit TenantLimits.agents). An agent's keys (credentials.create now accepts agents) work only while its sponsor is an active person of the tenant; sponsors can suspend their agents without a permission; offboarding hands agents to the successor (agentsReassigned, or agentsUnsponsored without one). Audit agent:create|suspend|resume|sponsor-change. New code INVALID_SPONSOR.
    • Delegation: new delegations API group (grant, request, approve, deny, revoke, get, list, listMine, assume; actions iam:delegations:read|revoke; collection delegations). A person lets an agent act for them within a scope; the agent opens biam_dlg_… sessions of the new kind 'delegated' (Session.agentId, Session.delegationId) that act as the person, bounded by the delegation scope, the agent's boundary and an optional scope-down policy, re-validated on every use. Agents can request consent (delegation-request email template, TemplateLinks.delegation). Offboarding or deleting a person revokes their delegations. New codes DELEGATION_EXISTS, DELEGATION_PENDING, DELEGATION_INACTIVE, DELEGATION_NOT_ALLOWED.
    • Policy context keys principal.delegated (always present), principal.delegationId, principal.agentId, principal.agentSponsorId, principal.agentModel, principal.agentProvider; principal.kind is agent for an agent's own key. AuditSessionContext and sts.getCallerIdentity carry agentId and delegationId. Delegated sessions are temporary (temporarySessionKinds), never recent, owner or root, cannot mint session tokens or assertions, and are swept by sweepExpired. credentialTokenKinds gains dlg; update secret-scanning rules to credentialTokenScanPattern.
    • Human-in-the-loop confirmations: a delegation's confirm action patterns hold those actions back until the person approves each call (delegations.requestConfirmation by the delegated session, decideConfirmation by the person, listConfirmations, getConfirmation; collection delegationConfirmations; email template delegation-confirmation; audit delegation:confirmation-request|confirm|reject). An approval opens exactly that action on that resource for validSeconds.
    • Accountability: agents.activity (everything an agent did, for its sponsor or iam:agents:read), delegations.activity (everything done under a delegation, for the people involved), agents.catalog (the delegable agents people may pick). Access analysis reports agent-without-sponsor, agent-admin, unbounded-agent, broad-delegation, and unused-delegation. @better-iam/next IamPrincipal accepts delegated sessions (session.agentId, session.delegationId) and agent identities. Metrics gain better_iam_sessions_live{kind="delegated"}.
    • Console: new "AI" section with Agents (sponsor kill switch, keys, delegation switch, sponsor hand-over), Delegations (approve requests and confirmations, grant with a confirm list, organization-wide list), and Models & budgets pages.
    • New package @better-iam/mcp (better-iam/mcp): createMcpGate puts tool-level authorization in front of any Streamable HTTP MCP server (Better IAM credentials decided by policy, OAuth tokens by scope, RFC 9728 metadata and challenges, refused tools/call, filtered tools/list in JSON and SSE); createMcpAuthorizer for SDK tool handlers. For delegated callers whose delegation holds a tool's action back, the gate files the confirmation request and returns its id in _meta['better-iam/confirmationId']. tenantId is required: decisions are made in the organization that runs the server, never in the caller's own tenant, and OAuth tokens for another tenant are refused. Bodies are read with a byte limit and forwarded as parsed.
    • Inference model fallbacks (up to five): when a provider is unreachable or answers 429/5xx/529, the gateway tries the next fallback the caller may use in the same wire format, metering the failed attempt and naming the serving model in x-better-iam-model with x-better-iam-fallback-from. Also in the inferenceModels config kind.
    • Delegation tokens: delegations.issueToken gives an agent acting for a person (delegated session) a short-lived JWT for one service outside Better IAM (typ biam-delegation+jwt, signed with the a2a card keys; sub the person, nested RFC 8693 act claims for the agent chain, scope from the delegation). Audiences must match AgentProfile.tokenAudiences of every agent in the chain (new profile field, in agents.catalog and the agents config kind). Scopes must be allowed outright by every limit on the session, and may not be actions the person confirms call by call or ones a deny among the person's own grants could touch. Audience patterns match by URL structure (* only as a leading host label or in the path). Lifetime is 30 s to 1 h (5 min by default); audited as delegation:token-issue; issued tokens are recorded (collection delegationTokens, swept at expiry) so live verification re-checks the chain, the acting key, the audience and the scopes. @better-iam/a2a gains verifyDelegationToken (offline, trusted issuers, optional replay check; DelegationTokenError) and createDelegationTokenCache. iam.a2a.verifyDelegationToken(token, { audience, live }) verifies in the deployment, with an optional live check of the chain (DELEGATION_TOKEN_INVALID). Core exports readDelegationTokenClaims, delegationActor, delegationTokenType, delegationTokenLimits, delegationTokenScopePattern.
    • Provider-run tool governance: InferenceModel.providerTools (allow by default, deny, or policy) controls tools the provider runs itself (web search/fetch, code execution/interpreter, file search, image generation, computer use, remote MCP servers). With policy, each tool is decided as inference:use-tool on the new resource type model-tool (model-tool/web_search, model-tool/mcp:{host}; attributes kind, host). The gateway reads tools from Anthropic tools/mcp_servers, OpenAI Responses tools and Chat Completions web_search_options, and refuses with 403 TOOL_NOT_ALLOWED before the provider is called (a Responses prompt counts as the tool prompt; non-canonical MCP URLs read as mcp:invalid; more than 256 tools or 64 MCP servers are refused); InvocationCheck reason TOOL_NOT_ALLOWED with tool. inference.check and iam.inference.authorize take tools. Exports providerToolsOf, inferenceToolAction, inferenceToolResourceType, ProviderToolsMode; providerTools is in the inferenceModels config kind.
    • Inference gateway endpoints: OpenAI Responses (POST /v1/responses, max_output_tokens capped, usage from response.completed when streamed), Embeddings (POST /v1/embeddings) and Anthropic token counting (POST /v1/messages/count_tokens, checked but not metered). Responses conversations stay with their caller: previous_response_id must name a response the same person (through the same agent) created through the gateway, else 404 RESPONSE_NOT_FOUND; conversation, background and references to objects stored at the provider (item_reference, file_id, Anthropic file sources, existing containers) answer 400 UNSUPPORTED_PARAMETER; a continued response must be at the provider that answered it. Streams that end without the provider's final counts are metered from estimates (request size, generated characters) rather than as free. New collection inferenceResponses (swept with usage records), GatewayRuntime.ownsResponse, exported UsageFormat.
    • Access analysis reports open-handoff (delegations handed on to any agent) and agent-denials (agents refused 20+ times in a day).
    • agents.suspendAll (organization-wide emergency stop, optionally by sponsor, provider or model; audit agent:suspend-all) and agents.directory (the current attested A2A cards of the tenant's agents in good standing, by skill or protocol; collection agentCards, filled by signCard, swept when expired).
    • Configuration as code for AI: new kinds agents (by name, sponsor by email, profile), inferenceModels (the tenant's own models, provider by name) and inferenceBudgets (subject tenant, { group }, { identity } or { agent }) in config.export|plan|apply (new server/src/ai-sync.ts; change kinds agent, inferenceModel, inferenceBudget). Keys, provider keys and delegations stay runtime state.
    • Delegation spending caps: spend: { period, maxCostUsd?, maxTokens?, maxRequests? } on grant, request, approve (null removes) and handoff caps the AI model calls made under a delegation and the hand-offs below it (checked and metered like an inference budget; BUDGET_EXCEEDED, audit on delegation:{id}); summaries report spend with what the current window used. Console Delegations page shows "AI spend: $x of $y today".
    • Hand-offs between agents: a person may let their agent hand parts of a delegation on to other agents (handoff: { agents?, depth? } on grant, request, approve; new method delegations.handoff for the delegated session; audit delegation:handoff). A hand-off is a delegation with parentId and chain that the receiving agent assumes with its own key. It is bounded by every delegation above it, every agent's ceiling in the chain, and the handing session's scope-down policy, key scopes and key issuer, and it is checked link by link on every use. Revoking a delegation revokes the hand-offs below it (handoffsRevoked), the person's confirm list travels down, and delegations.activity includes the hand-offs' events. A hand-off is bound to the handing agent's API key (ends with it, never outlasts it), the handing agents' budgets count its calls, and an agent's requested handoff takes effect only when the person restates it on approve. New policy context key principal.delegationChain. inference.record meters a ticket as the caller the check saw, even after their session ended.
    • Agent-to-agent (A2A): option a2a (card-signing keys, jwksUrl, cardLifetimeSeconds, issuer) and agents.signCard, which attests an agent's A2A card. IAM confines the card's endpoints to the agent's registered origin, sets provider.organization to the tenant, adds the extension urn:better-iam:a2a:attestation:v1, and signs the RFC 8785 canonical card as a detached JWS (audit agent:card-sign). iam.a2a.jwks() and jwksResponse() publish the keys. @better-iam/core exports canonicalizeJson (RFC 8785).
    • New package @better-iam/a2a (better-iam/a2a): verifyAgentCard and discoverAgent check that a remote agent is attested by a trusted deployment; createCardAttestor keeps an agent's served card signed; createA2aGate and createA2aAuthorizer authorize A2A JSON-RPC servers in a required tenantId. They decide messages by skill or an agent-wide rule, keep tasks and conversations (and referenceTaskIds) private to the caller who started them, refuse ambiguous skills and non-POST requests, filter the extended card's skills, and ask a person to confirm when their delegation requires it (A2A_ACCESS_DENIED -32050, A2A_CONFIRMATION_REQUESTED -32051). trustedIssuers binds verification keys to attestation issuers. withHandoff / handoffOf (handoffMetadataKey) carry a delegation hand-off in an A2A message's metadata to the called agent.
    • React: useDelegations, useConfirmations, useAgentCatalog and useModels (@better-iam/react). The key that opens a delegated session now also caps that session (its scopes or session policy, and its issuer's authority).
  • Inference access control for AI models (inference option). Guide docs/inference.md.

    • Resource type model and action inference:invoke: model access is an ordinary policy decision with model attributes (resource.tier, resource.provider, prices, ...). New inference API group (createProvider, updateProvider, deleteProvider, listProviders, createModel, updateModel, deleteModel, listModels, listMine, setBudget, deleteBudget, listBudgets, usage, myUsage, check, record; actions iam:inference:manage|read|record; collections inferenceProviders, inferenceModels, inferenceBudgets, inferenceCounters, inferenceUsage, inferenceTickets).
    • Provider keys (anthropic, openai, openai-compatible) are sealed with the deployment secret and re-sealed by rotateSecrets; custom base URLs need root unless inference.allowCustomBaseUrls. Models and providers are inherited by sub-tenants.
    • Budgets cap tokens, cost and/or request count (maxRequests) per minute (a rate limit), hour, day or month for a tenant, a group or an identity (an agent's budget covers its delegated sessions too), shared or per identity, optionally over some models; BUDGET_EXCEEDED, audit inference:budget-exceeded and inference:budget-alert. Every call is metered (inferenceUsage, cost in micro-dollars) and reported by identity, agent, model or day.
    • iam.inference: gateway() (Anthropic POST /v1/messages, OpenAI POST /v1/chat/completions, GET /v1/models; streaming passthrough with usage capture; provider keys never leave the server; output held to the model's maxOutputTokens), authorize, record, models. External gateways use inference.check tickets and inference.record. Exports createInferenceGateway, usageFrom, usageCost, budgetWindow. Metered calls also feed the billing ledger as the inference meter when billing is configured. The console mounts the gateway at /api/ai.
  • Customizable onboarding at platform, organization, and project level. Guide docs/onboarding.md.

    • New onboarding API group (createFlow, updateFlow, deleteFlow, listFlows, getFlow, effective, setSettings, mine, submitStep, setup, submitSetupStep, progress, memberProgress, verifyStep, resetProgress; actions iam:onboarding:read|manage; collections onboardingFlows, onboardingSettings, onboardingProgress).
    • Member flows onboard people (forms, acknowledgements, self-attested or administrator-verified tasks, terms of use, email verification, MFA, passkeys); tenant flows are setup checklists for the organizations or projects below, with live checks (verified domain, members, owners, MFA policy, terms of use, sign-in alias, SSO, directory sync, member onboarding).
    • Flows defined at the platform reach every tenant below; organizations and projects add their own, switch off unlocked inherited flows, and override the welcome screen. Flows can target people with the access-package rule language, ask only newcomers (or everyone with includeExisting), fill empty identity attributes of the defining tenant's own people from answers, and add people to groups on completion.
    • Policy context keys principal.onboarding and principal.pendingOnboarding (read only when a condition names them). Audit events onboarding:step, onboarding:complete, onboarding:verify, onboarding:reject, onboarding:reset, onboarding:groups. Deleting a person removes their answers.
    • Console: Get started checklist and banner, organization/project Onboarding page with a flow builder and templates, Setup checklist page and overview card, and a platform Onboarding page in the administration panel.
  • 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.
    • Saved sessions stay with the deployment that issued them: a profile's token is never sent to another --url / BETTER_IAM_URL or used with another --config (the command fails with MISSING_ENV and says why), and login refuses (PROFILE_IN_USE) to replace a profile saved for another deployment unless --profile names it. --url must be https:// except for localhost, and --url with --config is refused.
  • 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.

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

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page