@alphabros/enterprise

中文文档
1.1.3发布于 2026/10/26MITTypeScriptES Module构建来源已验证近 30 天下载 6455

Alpha Bros studio enterprise layer for better-auth: SSO (SAML/OIDC), SCIM (Users+Groups), orgs, audit log, security policy, admin portal

原始使用文档
文档目录14 节
  1. Status
  2. Install
  3. Package layout
  4. Quick start — server
  5. Entitlements
  6. Quick start — client
  7. Portal — embedding the admin UI
  8. SvelteKit
  9. Astro
  10. Next.js (App Router)
  11. Theming
  12. CLI ( ab-enterprise )
  13. Documentation
  14. Development

@alphabros/enterprise

Alpha Bros studio enterprise layer for better-auth 1.7.10: SSO (SAML + OIDC), SCIM 2.0 provisioning (Users + Groups), organizations, a hash-chained audit log (tamper-evident against database writers when keyed with audit.chainKey and its head exported off-box — see docs/security.md), security policy enforcement, and a framework-agnostic admin portal — as one package with four subpath entry points and a CLI.

Status

v1.1. Server plugins, schema/CLI, client helpers, and all seven portal elements are implemented and tested (1026 tests). Upgrading from 1.0.x or 0.1.x: read docs/upgrading-to-1.1.md first — every change is on by default, a few change what existing users see, and a product that replaces one of the preset's plugins sets the preset's settings on its own call (step 2). Read docs/security.md before deploying to a real customer — it documents the patched SCIM migration, token storage, policy enforcement, audit chain limitations, and the retention/GDPR posture.

Install

pnpm add @alphabros/enterprise

This package ships one dependency of its own (zod) and declares everything else as a peer dependency. The better-auth family and better-call are caret ranges that start at the releases this package is tested against (the security-patched floor), so you can run any later 1.x release:

{
  "@better-auth/api-key": "^1.7.10",
  "@better-auth/core": "^1.7.10",
  "@better-auth/passkey": "^1.7.10",
  "@better-auth/scim": "^1.7.10",
  "@better-auth/sso": "^1.7.10",
  "better-auth": "^1.7.10",
  "better-call": "^1.4.2",
  "@libsql/client": "^0.15.15 || ^0.17.3 || ^0.18.0", // optional — only needed for ./schema and the CLI
  "drizzle-orm": "^0.45.2", // optional — only needed for ./schema
  "lit": "^3", // optional — only needed for ./portal
  "samlify": "^2.15.1", // peer since 1.1.0; 2.15.1+ is required for GHSA-w8r9-gp23-wcxp and others; already a dependency of @better-auth/sso
}

better-auth and its @better-auth/* plugin set must be at 1.7.10 or later within 1.x (1.7.7 fixed GHSA-965c-763c-88jm, and 1.7.10 includes that fix). The package reads records better-auth writes (the magic-link pre-flight reads the pending link's verification row); CI runs the full suite against the latest 1.7.x release, but a future minor that changes those records may need a new release of this package. Upgrade better-auth and every @better-auth/* package together. The former advisory exception is removed. See docs/security.md and the migration guide for the new credential and identity model. samlify is the SAML library @better-auth/sso already uses; the package asks it which certificates a SAML configuration will be verified against, so it must resolve from your app — add it explicitly if your package manager does not install peer dependencies. samlify 2.15 (a security release) is the minimum: it trusts only X.509 signing certificates declared in the IdP metadata and checks Audience, Destination and Recipient against the service provider, so an existing SAML connection whose IdP metadata declares no signing certificate, or whose service provider entity ID or ACS URL does not match what the IdP puts in its responses, now fails to sign in.

Requirements: Node >=22, pnpm 10 (or any package manager — pnpm is only this repo's own dev toolchain).

Package layout

Entry pointWhat it's for
@alphabros/enterprise/serverbetter-auth plugin preset (enterprisePreset) and its pieces
@alphabros/enterprise/schemaDrizzle table definitions + SQL migrations (Node-only, see below)
@alphabros/enterprise/clientbetter-auth client plugins + home-realm login helpers
@alphabros/enterprise/portalLit web components for a security-settings admin UI
ab-enterprise (bin)CLI: migrate, verify, audit-verify (Node-only)

Upgrading from 0.1 requires new SCIM tables, a separate credential HMAC secret and IdP reprovisioning. Read the migration guide first.

Quick start — server

import { betterAuth } from "better-auth";
import { enterprisePreset, type Feature } from "@alphabros/enterprise/server";

export const auth = betterAuth({
  database: /* drizzleAdapter(db, { provider: "sqlite", transaction: true }) */ myAdapter,
  plugins: enterprisePreset({
    product: "my-app",
    // Reads your own billing/plan state — see "Entitlements" below.
    resolveEntitlements: async (orgId) => resolveEntitlementsFromStripe(orgId),
    // Encrypts the per-provider IdP secrets stored on the `ssoProvider` row
    // (OIDC `clientSecret`, SAML private-key fields). >=32 chars, from your
    // deployment's secret manager — never a literal in source. See
    // docs/security.md for exactly which fields it covers.
    secretsKey: process.env.ENTERPRISE_SECRETS_KEY!,
    scimCredentialHashSecret: process.env.ENTERPRISE_SCIM_CREDENTIAL_HASH_SECRET!,
    // Optional: runs on every SSO-driven JIT signup (CRM sync, welcome email, ...).
    provisionUser: async (user) => trackNewEnterpriseUser(user),
    audit: { retentionDays: 365 },
    scim: { groupRoleMap: { "Acme-Admins": "admin" } },
  }),
});

For OIDC, configure the provider's exact origin through Better Auth's trustedOrigins option or BETTER_AUTH_TRUSTED_ORIGINS before using the portal — that is Better Auth's SSRF boundary for discovery/endpoint origins, not an issuer allow-list. To restrict which IdPs tenants may register, set sso.allowedIssuers — it is applied to the issuer and to the trust root the configuration actually points at: for OIDC every endpoint (upstream fetches the keys from the tenant-supplied JWKS), for SAML the entity id/entry point and the signing certificates, which must be pinned in sso.samlCertificateFingerprints (a SAML provider with no pins is refused — fail closed). Stored providers are re-checked at every sign-in. SSO sign-ins are bound to the signing provider's verified domain by default (sso.domainBinding) — that, not the allow-list, is the per-tenant control; domainBinding: "off" is only safe with the allow-list (and, for SAML, the pins) configured. See OIDC origins and the issuer allow-list and domain binding.

samlSpKeys is not in that list. It is typed on EnterpriseOptions and reserved, but enterprisePreset cannot consume it: @better-auth/[email protected]'s sso() has no plugin-level slot for a shared SP signing identity — only a per-provider samlConfig.spMetadata, set at /sso/register time. Read it back yourself and put it in your own registration body if you want one shared identity across orgs; see docs/sso.md. Wiring it properly is a P2 item, gated on upstream.

enterprisePreset returns the full plugin list: organization (teams enabled), sso, scim, twoFactor, passkey, apiKey, plus this package's own enterpriseGate (entitlement enforcement), auditLog, orgPolicy, scimMembershipSchema, and enterpriseApi (the portal-facing /enterprise/* wrapper endpoints). Every product already mounting the better-auth handler needs no per-framework server code beyond this.

Entitlements

requireFeature(ctx, orgId, feature) is the single choke point for plan-gated behaviour. enterpriseGate already calls it in front of every path listed in GATED_PATHS (SSO/SCIM registration, team creation, API key creation, audit reads, policy writes, and the portal's /enterprise/sso/* and /enterprise/scim/tokens* wrappers) — you only need to implement resolveEntitlements. Feature is "sso" | "scim" | "audit_log" | "enforce_2fa" | "api_keys" | "teams".

A typical implementation maps a Stripe price/plan onto the feature set it unlocks:

import type { Feature } from "@alphabros/enterprise/server";

const PLAN_FEATURES: Record<string, Feature[]> = {
  free: [],
  team: ["teams", "api_keys"],
  business: ["teams", "api_keys", "audit_log"],
  enterprise: ["teams", "api_keys", "audit_log", "sso", "scim", "enforce_2fa"],
};

// price_... -> plan name, from your Stripe dashboard / pricing config.
const STRIPE_PRICE_TO_PLAN: Record<string, keyof typeof PLAN_FEATURES> = {
  price_1AbcTeam: "team",
  price_1AbcBusiness: "business",
  price_1AbcEnterprise: "enterprise",
};

export async function resolveEntitlementsFromStripe(orgId: string): Promise<Feature[]> {
  const org = await db.query.organization.findFirst({ where: eq(organization.id, orgId) });
  const plan = org?.stripePriceId ? (STRIPE_PRICE_TO_PLAN[org.stripePriceId] ?? "free") : "free";
  return PLAN_FEATURES[plan];
}

Entitlements are cached per request (one resolveEntitlements call per org per request, however many gated paths/requireFeature calls it serves), so it's safe to hit your own database or a cached plan lookup here without worrying about N+1 calls on a single request.

Quick start — client

import { createAuthClient } from "better-auth/client";
import {
  enterpriseClient,
  ssoClient,
  organizationClient,
  twoFactorClient,
  passkeyClient,
  apiKeyClient,
  discoverHomeRealm,
} from "@alphabros/enterprise/client";

export const authClient = createAuthClient({
  plugins: [
    enterpriseClient(),
    ssoClient(),
    organizationClient(),
    twoFactorClient(),
    passkeyClient(),
    apiKeyClient(),
  ],
});

// Email-first login: ask for the email, then decide SSO vs. local sign-in.
const result = await discoverHomeRealm(email);
if (result.method === "sso") {
  await authClient.signIn.sso({ providerId: result.providerId, callbackURL: "/dashboard" });
} else {
  // fall back to your existing magic-link / password / social sign-in
}

See docs/sso.md for the full flow, including what org_policy.sso_enforced does to non-SSO sign-in attempts. org_policy.require_2fa is enforced too: a session for a member of such an org stands only through a method that carries a second factor (SSO by default, policy.twoFactor.satisfiedBy) or a password sign-in that upstream's twoFactor() challenges, and a password user who has not enrolled reaches only the enrolment endpoints until they have — /get-session reports enterprise.enrollmentRequired for them. A session that predates the requirement for a user who cannot enrol (switched on later, or the user joined later) is refused, and /get-session — hence auth.api.getSession — answers null for it; with the opt-in API-key mock session, a key's owner is judged the same way (a no-password owner's key must postdate the requirement, an un-enrolled password holder's key yields no session). No schema change: upgrading needs only the data-only migration 0003. A product whose package tables came from its own migration emits just that one with ab-enterprise migrate --out <folder> --from 0003 — never the full emit, which would re-run 0001 and 0002 after its own. Any 1.0.x install runs --from 0003 too, even a folder whose 0001 this command wrote: 1.0.0 applied 0002 outside it (docs/migration-1-7.md step 4). Only a folder from 0.1.x that never applied 0002 runs --from 0002; a plain run on a folder holding this command's 0001 without its 0002 stops and asks which. Details in docs/security.md.

Portal — embedding the admin UI

@alphabros/enterprise/portal registers seven framework-agnostic Lit custom elements: <ab-members>, <ab-security-settings> (tabbed shell), <ab-sso-wizard>, <ab-scim-tokens>, <ab-security-policy>, <ab-api-keys>, <ab-audit-log>. They render a shadow DOM and talk to the /enterprise/* API directly — no server code needed beyond enterprisePreset above.

One scope caveat: API keys are per user, not per organization. <ab-api-keys> drives upstream /api-key/list and /api-key/create, which are scoped to the signed-in user, so an org's security screen shows and creates that user's keys wherever they were created. Keys are bound to the user at use time and bounded by expiry (since 1.1.0): every use re-checks the live user row (a banned or removed user's key is refused — USER_BANNED / USER_NOT_FOUND), keys expire after 90 days unless the caller picks another lifetime (apiKeys.defaultExpiresInS, capped by apiKeys.maxExpiresInDays, default 365), and every deprovisioning path — a SCIM deactivation, /admin/ban-user, /admin/remove-user — revokes all of that user's keys, not only the ones they used for this org, audited as api_key.revoked into every org the user belonged to. (A user who holds an owner membership anywhere is never banned by a SCIM feed, but their keys are revoked like everyone else's; they can sign in and mint new ones.) A request carrying an API key reaches an allow-list and nothing else: /get-session over HTTP, plus upstream's own server-side auth.api.verifyApiKey call. Every other better-auth endpoint — /enterprise/*, every credential-minting path, every key, organization, SSO-provider, account and credential write, every other read (/list-sessions, /organization/list, /api-key/list, /sso/get-provider, /list-accounts, …), every sign-in flow, /admin/*, /two-factor/*, and every endpoint of a plugin you add that this package has never heard of (jwt()'s /token, oneTimeToken()'s /one-time-token/generate, your own) — answers 403 API_KEY_NOT_ALLOWED_HERE. An allow-list, because a deny-list is fail-open for what it does not know; built from how integrators use a key (resolve its owner through auth.api.getSession({ headers }), nothing else) and widened only by a reviewed change to this package. The refusal is enforced in the key validator itself (so it holds however a key is carried) and on the x-api-key header alone (so it holds under the preset default and next to a valid cookie; /get-session with a stray header still answers the cookie's session). To let a key authenticate as its owner on your own routes, set apiKeys.enableSessionForAPIKeys: true on the preset rather than hand-composing apiKey() — and size apiKeys.rateLimit (upstream's default is 10 requests per key per 24 h) — see docs/security.md for the must-keep settings if you do. An expiry can never be removed from a key (/api-key/update { expiresIn: null } → 400 API_KEY_EXPIRY_REQUIRED) unless the operator set apiKeys.defaultExpiresInS: null. Per-org key scoping remains a follow-up.

Import the elements only from a browser-only context. Lit's browser build references HTMLElement, which doesn't exist on the server; importing the module in server-rendered code (SSR frontmatter, a Next Server Component) throws or bloats the server bundle. Import from a client-only place instead — verified against the fleet's own three Cloudflare-targeted stacks (spike report 2026-09-15-p0c-lit-portal-frameworks):

SvelteKit

<script lang="ts">
  import { onMount } from "svelte";
  import { browser } from "$app/environment";
  let orgId = "acme";
  onMount(async () => {
    if (browser) await import("@alphabros/enterprise/portal");
  });
</script>

<main style="--ab-color-primary: #0f766e">
  <ab-security-settings org-id={orgId}></ab-security-settings>
</main>

Astro

---
const orgId = "acme"; // do NOT import the portal module in frontmatter — see above
---
<main style="--ab-color-primary: #0f766e">
  <ab-security-settings org-id={orgId}></ab-security-settings>
</main>
<script>
  import "@alphabros/enterprise/portal"; // bundled by Vite, browser-only
</script>

Next.js (App Router)

"use client";
import { useEffect, useState } from "react";

export default function SecuritySettingsPage() {
  const [orgId] = useState("acme");
  useEffect(() => {
    import("@alphabros/enterprise/portal");
  }, []);
  return (
    <main style={{ "--ab-color-primary": "#0f766e" } as React.CSSProperties}>
      <ab-security-settings org-id={orgId} />
    </main>
  );
}

React needs a one-time JSX intrinsic-element declaration for each custom tag you use (put this once in a .d.ts, not per page — a stray @ts-expect-error on it elsewhere becomes a hard next build failure):

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "ab-security-settings": React.DetailedHTMLProps<
        React.HTMLAttributes<HTMLElement>,
        HTMLElement
      > & {
        "org-id"?: string;
      };
      // ...one entry per <ab-*> element you use
    }
  }
}

Net cost: lit 3 + the portal elements add about 6.5 kB gzip, code-split to whichever route renders them, in all three frameworks.

Theming

The portal ships no colours, fonts, or spacing of its own — every visual value is a var(--ab-*) CSS custom property with no fallback, so it inherits your product's design tokens (DESIGN.md) instead of imposing new ones:

TokenPurpose
--ab-font-familyBase font stack for every portal component.
--ab-font-sizeBase font size.
--ab-color-textPrimary text colour.
--ab-color-text-mutedSecondary/muted text — hints, empty states, loading copy.
--ab-color-bgComponent background.
--ab-color-surfaceRaised/secondary surface, e.g. an inactive tab button.
--ab-color-primaryPrimary action / selected-state colour.
--ab-color-on-primaryText/icon colour rendered on top of --ab-color-primary.
--ab-color-dangerErrors and destructive state.
--ab-color-successSuccess / positive state, e.g. an accepted invitation.
--ab-color-borderBorder colour used on its own, outside the --ab-border shorthand.
--ab-borderFull border shorthand (width, style, colour).
--ab-radiusCorner radius for buttons, panels, and controls.
--ab-space-1 … --ab-space-4Spacing scale, smallest to largest.
--ab-shadowElevation shadow for panels/popovers.

Define these once in a global stylesheet or :root block; the components consume them, they don't ship defaults. Full reference: src/portal/tokens.md.

CLI (ab-enterprise)

Node-only (reads/writes the local filesystem and talks to libSQL directly) — run it from a deploy step or locally, not from an edge/Workers runtime:

# Write this package's SQL migration into your product's drizzle migrations
# folder, numbered to follow whatever's already there. If the folder has a
# drizzle `meta/_journal.json`, the new file is registered in it too — that
# journal is the only thing `drizzle-kit migrate` reads to decide what to
# apply, so a file dropped in without an entry is silently skipped. Statements
# are separated by `--> statement-breakpoint`, which is what lets drizzle run
# them one at a time; it is an ordinary SQL comment everywhere else.
ab-enterprise migrate --out ./drizzle/migrations

# Upgrading from 1.0.x: always --from 0003 (the data-only 0003 is all 1.1.0
# adds), whatever wrote the folder's 0001 — a product's OWN migration (e.g. a
# hand-written NNNN_enterprise_pilot.sql) or this command — because 1.0.0
# applied 0002 outside the folder (docs/migration-1-7.md step 4).
ab-enterprise migrate --out ./drizzle/migrations --from 0003

# Upgrading from 0.1.x, where 0002 was never applied: 0002 and 0003.
ab-enterprise migrate --out ./drizzle/migrations --from 0002

# A plain run (no --from) is for a new install, or a folder whose 0002 this
# command wrote. It refuses, writing nothing, a folder holding an
# *enterprise*.sql file this command did not write, or this command's 0001
# without its 0002 — the folder alone cannot show what that one applied.

# No journal in the target folder? The command says so, and the supported
# application paths are `applyMigration()` from `@alphabros/enterprise/schema`
# or `turso db shell <db> < ./drizzle/migrations/NNNN_enterprise.sql`. Either
# way, `ab-enterprise verify` below is the backstop that proves it landed.

# Check a LIVE database has every table/column this package expects.
# Exits 1 and prints one line per gap if anything is missing.
ab-enterprise verify --url $TURSO_DATABASE_URL --token $TURSO_AUTH_TOKEN
# Prefer TURSO_DATABASE_URL / TURSO_AUTH_TOKEN env vars over --url/--token —
# CLI flags are visible in `ps` output and shell history.

# Recompute an org's audit_event hash chain and confirm nothing was tampered
# with (within the retention window — see docs/security.md). A keyed chain
# (audit.chainKey) is verified with the key from AB_AUDIT_CHAIN_KEY (env only);
# --expect-head checks the chain still holds a head exported earlier from
# GET /enterprise/audit/head (catches truncation and wholesale rewrites).
ab-enterprise audit-verify --org org_123 --url $TURSO_DATABASE_URL
AB_AUDIT_CHAIN_KEY=... ab-enterprise audit-verify --org org_123 --expect-head "$(cat head.json)"

Run ab-enterprise verify after every deploy (fleet migrations have historically not reached Turso even when the migration file itself was committed).

@alphabros/enterprise/schema is likewise Node-only — it reads the packaged SQL file from disk at runtime and does not run on Cloudflare Workers/workerd. ./server, ./client, and ./portal have no Node built-ins and run anywhere better-auth itself does. The workerd Vitest project (test/workerd) boots enterprisePreset on better-auth's memory adapter inside the real workerd runtime on every pnpm test run and covers social sign-in start, SAML SP metadata, SCIM ServiceProviderConfig and Groups, and an audit write.

Documentation

  • docs/sso.md — the SSO wizard flow, DNS TXT verification, SAML SP metadata URLs, and the node:dns-on-Workers caveat.
  • docs/scim.md — native SCIM Users/Groups, credential rotation, membership projection, decommissioning, and IdP setup notes.
  • docs/security.md — patched SCIM migration, secrets/token storage, policy enforcement, API keys, audit coverage and chain limits, retention/GDPR, and this package's trusted-publishing setup.
  • docs/upgrading-to-1.1.md — upgrading from 1.0.x: pre-upgrade checks, steps, behaviour changes, new options and error codes.
  • docs/releases/1.1.0.md — 1.1.0 release notes.

Development

mise exec node@24 -- pnpm install
mise exec node@24 -- pnpm typecheck
mise exec node@24 -- pnpm lint
mise exec node@24 -- pnpm format:check
mise exec node@24 -- pnpm test
mise exec node@24 -- pnpm build

Node >=24 is used for this repo's own toolchain and CI; the published package keeps engines.node >=22. mise exec node@24 -- <cmd> pins the working version regardless of your global default.

版本与发布记录

@alphabros/enterprise 共发布 7 个版本,首次发布于 2026/09/16;过去 12 个月发布了 7 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
1.1.3latest2026/10/2611 天前
1.1.22026/10/2511 天前
1.1.12026/10/1422 天前
1.1.02026/10/1423 天前
1.0.12026/09/241 个月前
1.0.02026/09/231 个月前

关于 @alphabros/enterprise 的常见问题

@alphabros/enterprise 是什么?

Alpha Bros studio enterprise layer for better-auth: SSO (SAML/OIDC), SCIM (Users+Groups), orgs, audit log, security policy, admin portal

如何安装 @alphabros/enterprise?

在项目目录运行 npm install @alphabros/enterprise,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:samlify(^2.15.1)、better-auth(^1.7.10)、better-call(^1.4.2)、@better-auth/sso(^1.7.10)、@better-auth/core(^1.7.10)、@better-auth/scim(^1.7.10)、@better-auth/api-key(^1.7.10)、@better-auth/passkey(^1.7.10)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。它还提供命令行工具 ab-enterprise,可以用 npx ab-enterprise 直接运行。

@alphabros/enterprise 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@alphabros/enterprise 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@alphabros/enterprise 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@alphabros/enterprise 可以商用吗?

@alphabros/enterprise 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@alphabros/enterprise 的下载量有多少?

根据 npm 官方下载统计,@alphabros/enterprise 在 2026/10/06 – 2026/11/04 这 30 天内被下载 6,455 次,日均约 215 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@alphabros/enterprise 还在维护吗?

最新版本 1.1.3 发布于 2026/10/26(11 天前);至今共发布 7 个版本,其中过去 12 个月发布了 7 个;首次发布于 2026/09/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。