npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@aiquants/auth-directory-drizzle

v0.8.1

Published

Drizzle (mssql) adapter for @aiquants/auth-directory-core: the identity-side tables (external group links, upstream member ledger, tenant membership groups, login allowlist), the synchronization writer that runs inside the authz lockout guard, and an admi

Readme

@aiquants/auth-directory-drizzle

Drizzle (SQL Server) adapter for @aiquants/auth-directory-core. It owns the identity-side tables and the four surfaces that operate on them.

Why these tables are not in @aiquants/authz-drizzle

Nothing in the authorization query path reads them — only authentication and user administration do. Beyond the separation of concerns there is a practical reason: @aiquants/authz-drizzle is a shared, published package, and adding a column or a table to it forces a DDL migration on every deployment that uses it. Drizzle puts every declared column in its INSERT column list, so a column the physical table does not have fails with Invalid column name (SQL Server 207) on the very next write. Deployments that never touch an external directory should not be dragged into that migration.

Tables

| Table | tenant_id | Notes | | --- | --- | --- | | TDGroupExternalLink | yes (denormalized) | One link per group. The column duplicates TMGroup.tenant_id so UNIQUE (tenant_id, provider, external_id) is expressible, and is also used as a predicate; writes must prove it matches the parent, and reads re-verify it against the parent | | TDGroupMemberEmail | no — inherited | Every member observed upstream, including people with no local user row. resolved_user_id is nullable and carries no foreign key: rows that cannot be resolved are the reason this table exists | | TDTenantMembershipGroup | yes (it is the key) | Groups a tenant accepts as proof of membership. At least one row must be is_break_glass and locally managed | | TDAllowlistGroup | yes (owner, not a filter) | Groups whose members may sign in | | TDDirectorySyncRequest | yes | The "sync now" queue. group_id is NULL for a whole-tenant run | | TMAllowlist | yes (owner, not a filter) | Moved here from @aiquants/authz-drizzle, now tenant-scoped |

No table declares a drizzle .default() on tenant_id, and the new tables carry no physical DEFAULT either. A default makes tenantId optional in the insert type, so a write that forgot the tenant type-checks and lands in whichever tenant the DBA happened to name.

"Owner" is not a filter

TMAllowlist.tenant_id and TDAllowlistGroup.tenant_id answer who may edit this row, not who may sign in. Authentication happens before a tenant is resolved, so the login decision is the union over every tenant this deployment serves. isEmailAllowedByGroup deliberately emits no tenant predicate — a regression test asserts its absence, because adding one silently breaks every first sign-in.

Surfaces

| Export | Role | | --- | --- | | defineAuthDirectorySchema | Table factory (schema name and referenced tables injected) | | createDirectoryLinkStore | Links, tenant membership proof, group allowlist, sync queue, health | | createDirectorySyncRepository | Storage operations for one sync run; removals go through the authz lockout guard | | assertMembershipMode | The single reader of membership_mode — no reader may default it | | promoteLedgerForUser | First-sign-in promotion of ledger rows to real memberships | | withExternalGroupGuards / assertGroupMayBeLinked | Keep administrative roles off externally-sourced groups, in both directions |

Removals go through the lockout guard, and one refusal does not stop the group

Group membership reaches roles through TDGroupRole, so one upstream departure can take a tenant's last administrator with it. Every projection removal is written inside createAdminLockoutGuard(...).guardedWrite(...) — the same serializable transaction the authorization admin UI uses. Removals are issued one at a time because AdminRemoval takes a single descriptor; adding a batch form to the authorization core would breach the package boundary, and the count is already bounded by the reconciler's circuit breaker.

Every transaction goes through one seam. All nine of them are opened by transactionRunner in db.ts, which runs the work again when — and only when — the server rolled it back (see @aiquants/mssql — only the deadlock victim 1205 is run again, and the closure is wrapped in capture so the ORM cannot destroy that evidence). Calling db.transaction directly at each site means touching nine places to change the policy, and one of them eventually gets left behind without saying so. Configure with transactionRetry on any factory's options; the closure must keep its side effects inside the database, because a retry runs it from the start.

A refusal does not abort the run. Stopping at the first refused removal leaves the additions committed, the earlier removals committed and the ledger untouched — and every later run recomputes the same plan and dies at the same index, so one legitimately-guarded row blocks the whole group's convergence forever. Refusals are collected into ApplyReport.refusals, the remaining removals proceed, and the caller records that the group has not converged. The refused person keeps both their membership and their ledger row: revoking sign-in while leaving the role would produce a state that makes sense from neither side.

Ledger deletions are chunked. SQL Server binds one parameter per value in an IN list against a 2,100-parameter statement limit; a group large enough to exceed it would fail with error 8003 after the memberships were already removed, and every subsequent run would fail identically — the state does not self-heal.

Deactivation is not a one-way door

A group missing upstream is deactivated only after vanishedThreshold consecutive absences, so a propagation window or a scope change does not disable a live group. Deactivated groups keep being polled, and a successful read reactivates them; setGroupActive gives an operator the same exit.

Deactivation revokes: is_active is part of the login gate and of the tenant-membership evidence, not only of the sync query. The reverse — enforced for syncing, ignored for access — leaves a frozen ledger granting sign-in to departed members while the only process that could clean it is switched off.

Promotion never reaches the directory

promoteLedgerForUser matches an identity against the ledger and inserts the missing membership rows. That is entirely local work, so the web tier can grant group permissions during a first sign-in without holding the directory credential — which stays with the sync job alone. Do not import a DirectoryProvider here; the placement is the point.

Testing the shape of the SQL

This adapter accepts drizzle through structural types, so tsc checks neither the order of the builder calls nor the presence of a tenant predicate: putting .innerJoin() before .from(), dropping .output(), or forgetting WHERE tenant_id = ? all type-check. link-store.spec.ts and sync-repository.spec.ts therefore record the actual call sequence against a fake builder and walk each condition for the columns it references.

sync-repository.ts is the only module that deletes rows, so it is tested directly rather than through the orchestration's fake repository — an orchestration test proves nothing about whether the real writer goes through the lockout guard. Fifteen mutations are each caught by at least one test, including: replacing guardedWrite with a bare db.delete, excluding deactivated groups from the sync query, pinning membership_mode to a constant, removing the IN-list chunking, dropping a tenant predicate, skipping reactivation, omitting the audit actor, opening the third escalation direction, ignoring appKey when deciding what is administrative, dropping is_active from the login gate, collapsing the identity precedence back to an OR, treating a removal-only run as "unchanged", and skipping the resolver completeness check.

Install

pnpm add @aiquants/auth-directory-drizzle @aiquants/auth-directory-core @aiquants/auth-core @aiquants/authz-core @aiquants/authz-drizzle drizzle-orm zod

All five @aiquants/* peers are value imports and every one is listed in this package's tsup external, so omitting any of them yields ERR_MODULE_NOT_FOUND at runtime rather than a type error. zod is required transitively by @aiquants/authz-core.