chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# @trigger.dev/plugins
## 4.5.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.2`
## 4.5.1
### Patch Changes
- Extend the SSO plugin contract with WorkOS Directory Sync (SCIM) support. ([#4148](https://github.com/triggerdotdev/trigger.dev/pull/4148))
- Updated dependencies:
- `@trigger.dev/core@4.5.1`
## 4.5.0
### Patch Changes
- The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. ([#3499](https://github.com/triggerdotdev/trigger.dev/pull/3499))
- Updated dependencies:
- `@trigger.dev/core@4.5.0`
## 4.5.0-rc.7
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.7`
## 4.5.0-rc.6
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.6`
## 4.5.0-rc.5
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.5`
## 4.5.0-rc.4
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.4`
## 4.5.0-rc.3
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.3`
## 4.5.0-rc.2
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.2`
## 4.5.0-rc.1
### Patch Changes
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.1`
## 4.5.0-rc.0
### Patch Changes
- The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. ([#3499](https://github.com/triggerdotdev/trigger.dev/pull/3499))
- Updated dependencies:
- `@trigger.dev/core@4.5.0-rc.0`
## 0.0.0-prerelease-20260506134321
### Patch Changes
- b3a967765: The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces.
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@trigger.dev/plugins",
"version": "4.5.2",
"description": "Plugin contracts and interfaces for Trigger.dev",
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
"directory": "packages/plugins"
},
"type": "module",
"files": [
"dist"
],
"dependencies": {
"@trigger.dev/core": "workspace:*",
"neverthrow": "^8.2.0"
},
"scripts": {
"clean": "rimraf dist .turbo",
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.20.0",
"rimraf": "6.0.1",
"tsup": "^8.4.0",
"typescript": "^5.3.0"
},
"engines": {
"node": ">=18.20.0"
},
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": {
"@triggerdotdev/source": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
// Database connections for a plugin that owns its own client. The host
// resolves the URLs from its env so the plugin follows the same
// writer/replica topology the host uses. Shared by every plugin contract
// that carries a `database` section.
export type PluginDatabaseConfig = {
// Primary (writer) connection URL. Mutations and read-your-writes
// management reads run here.
writerUrl: string;
// Read-replica URL for the per-request auth reads. Omitted → those reads
// share the writer connection.
readerUrl?: string;
// Per-process pool sizes. Omitted → plugin defaults (writer 2, reader 5).
writerConnectionLimit?: number;
readerConnectionLimit?: number;
};
+60
View File
@@ -0,0 +1,60 @@
export type {
RoleBasedAccessControlPlugin,
RoleBaseAccessController,
RoleAssignmentResult,
RoleMutationResult,
Permission,
Role,
RbacAbility,
RbacSubject,
RbacResource,
RbacEnvironment,
RbacUser,
BearerAuthResult,
SessionAuthResult,
PatAuthResult,
UserActorAuthResult,
UserActorClaims,
RbacPluginConfig,
RbacDatabaseConfig,
SystemRole,
AuthenticatedEnvironment,
} from "./rbac.js";
export { buildJwtAbility } from "./rbac.js";
export {
isUserActorToken,
signUserActorToken,
verifyUserActorToken,
USER_ACTOR_TOKEN_PREFIX,
} from "./rbac.js";
export type { PluginDatabaseConfig } from "./databaseConfig.js";
export type {
SsoPlugin,
SsoPluginConfig,
SsoController,
OrgSsoStatus,
SsoRouteDecision,
SsoFlow,
SsoProfile,
SsoConnectionState,
SsoDomainState,
SsoDomainStatus,
SsoResolutionDecision,
SsoDecisionError,
SsoBeginError,
SsoCompleteError,
SsoMutationError,
SsoPortalError,
SsoValidateError,
SsoWebhookError,
SsoWebhookEvent,
DirectoryState,
DirectoryGroupMapping,
DirectorySyncStatus,
DirectorySyncEffect,
} from "./sso.js";
export { SSO_FLOWS } from "./sso.js";
+450
View File
@@ -0,0 +1,450 @@
/**
* Plugin-owned metadata for a built-in system role. The plugin returns
* these in canonical order (highest authority first) so the dashboard
* can render columns / build a level ladder without knowing role names.
*
* Roles the plugin chooses not to expose at all (e.g. seeded but hidden)
* are not returned by `systemRoles()` — there's no "advertised but
* absent" state.
*
* `available` indicates whether the role is assignable on the *org's
* plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
* Developer. Consumers may render unavailable rows with an upgrade
* badge, hide them, or otherwise gate UI on the flag.
*/
export type SystemRole = {
id: string;
name: string;
description: string;
available: boolean;
};
export type Permission = {
// `<action>:<subject>` — display name, derived from the ability rule.
name: string;
description: string;
// Display bucket for the Roles page (e.g. "Runs", "Tasks"). The page
// groups permissions by this string and lists groups in the order they
// first appear in `allPermissions()`, so the plugin owns both the
// bucket label and the section ordering. Omit for "no grouping".
group?: string;
// Inverted (deny) rules surface as ✗ in the Roles page.
inverted?: boolean;
// Rule conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
// the Roles page renders a tier badge alongside the permission row.
conditions?: Record<string, unknown>;
};
export type Role = {
id: string;
name: string;
description: string;
permissions: Permission[];
isSystem: boolean;
};
export type RbacSubject =
| { type: "user"; userId: string; organizationId: string; projectId?: string }
| { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string }
| { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string }
// Delegated user-actor token (`tr_uat_…`): a short-lived, stateless
// credential that authenticates as `userId`. `client` records what minted
// it (e.g. a dashboard agent) for attribution.
| {
type: "userActor";
userId: string;
client?: string;
organizationId: string;
projectId?: string;
};
export type RbacResource = {
type: string;
id?: string;
// Extra fields a route may pass for condition-based ability checks —
// e.g. `envType` for env-tier-scoped rules ("Member can read envvars
// unless envType === 'PRODUCTION'"). The plugin's ability matcher
// reads these off the resource object; routes that don't use
// conditional rules can keep passing `{ type, id? }`.
[key: string]: unknown;
};
// The plugin contract carries the same env shape that host webapps' auth
// flows use. Defined in @trigger.dev/core so it's importable from any
// internal package without going through the plugin contract itself.
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
import type { AuthenticatedEnvironment as RbacEnv } from "@trigger.dev/core/v3/auth/environment";
import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt";
/** @deprecated Renamed to `AuthenticatedEnvironment`. Kept as alias for transitional code. */
export type RbacEnvironment = RbacEnv;
export type RbacUser = {
id: string;
email: string;
name: string | null;
displayName: string | null;
avatarUrl: string | null;
admin: boolean;
confirmedBasicDetails: boolean;
isImpersonating: boolean;
};
/** Pre-built ability returned by authenticate* — all checks are sync, no DB call. */
export interface RbacAbility {
// Array form means "grant access if any resource in the array passes" —
// used by routes that touch multiple resources (e.g. a run also carries
// a batch id, tags, a task identifier) so a JWT scoped to any of them
// grants access.
can(action: string, resource: RbacResource | RbacResource[]): boolean;
canSuper(): boolean;
}
/**
* Builds an ability from JWT scope strings like "read:runs",
* "read:runs:run_abc", "read:all", "admin".
*
* This is the single source of truth for interpreting public-token scope
* strings. Both the host's built-in fallback and any auth plugin import it
* from here so a token minted by the host is decoded identically no matter
* which auth path serves the request — two copies of this grammar would
* drift, and the difference would silently change what a token grants.
*/
export function buildJwtAbility(scopes: string[]): RbacAbility {
const matches = (action: string, r: RbacResource): boolean =>
scopes.some((scope) => {
// Only the first two colons are delimiters — everything after the
// second colon is the resource id (which may itself contain colons,
// e.g. user-provided tags like "env:staging"). Naive
// `split(":")` + 3-tuple destructuring truncated such ids to the
// first segment and silently failed to match.
const parts = scope.split(":");
const scopeAction = parts[0];
const scopeType = parts[1];
const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined;
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
// it falls through to normal matching as action="admin" against
// resources of that type. Treating `admin:<anything>` as universal
// would silently broaden any such tokens beyond the narrow,
// route-listed grant they had before scope-based abilities.
if (scopeAction === "admin" && !scopeType) return true;
if (scopeAction !== action && scopeAction !== "*") return false;
if (scopeType === "all") return true;
if (scopeType !== r.type) return false;
if (!scopeId) return true;
return scopeId === r.id;
});
return {
can(action: string, resource: RbacResource | RbacResource[]): boolean {
// Array form means "any element passes → authorized", matching the
// legacy multi-key authorization semantic.
return Array.isArray(resource)
? resource.some((r) => matches(action, r))
: matches(action, resource);
},
canSuper(): boolean {
return false;
},
};
}
// ── Delegated user-actor token grammar ───────────────────────────────────
//
// A `tr_uat_…` token is the JWT body (signed HS256 with the platform secret)
// behind a routing prefix. Single source of truth for minting/verifying so
// the host, its fallback, and any auth plugin agree on the wire format — same
// reasoning as `buildJwtAbility`. Built on core's `generateJWT`/`validateJWT`
// so it shares the issuer/audience/alg the rest of the platform's JWTs use.
export const USER_ACTOR_TOKEN_PREFIX = "tr_uat_";
// Distinguishes a UAT from other platform-secret-signed JWTs.
const USER_ACTOR_KIND = "user_actor";
export type UserActorClaims = {
userId: string;
client?: string;
sessionId?: string;
// Optional scope cap (e.g. `["read:runs"]`) — ceilings the token below the
// user's role. Absent today; the auth path is already cap-ready.
cap?: string[];
};
export function isUserActorToken(token: string): boolean {
return token.startsWith(USER_ACTOR_TOKEN_PREFIX);
}
export async function signUserActorToken(
secret: string,
opts: {
userId: string;
client: string;
sessionId?: string;
cap?: string[];
expirationTime?: string | number | Date;
}
): Promise<string> {
const jwt = await generateJWT({
secretKey: secret,
payload: {
kind: USER_ACTOR_KIND,
sub: opts.userId,
act: { client: opts.client, ...(opts.sessionId ? { sessionId: opts.sessionId } : {}) },
...(opts.cap ? { cap: opts.cap } : {}),
},
expirationTime: opts.expirationTime ?? "1h",
});
return `${USER_ACTOR_TOKEN_PREFIX}${jwt}`;
}
// undefined for anything that isn't a valid, unexpired, correctly-signed UAT.
export async function verifyUserActorToken(
secret: string,
token: string
): Promise<UserActorClaims | undefined> {
if (!isUserActorToken(token)) return;
const result = await validateJWT(token.slice(USER_ACTOR_TOKEN_PREFIX.length), secret);
if (!result.ok) return;
const payload = result.payload;
if (payload.kind !== USER_ACTOR_KIND || typeof payload.sub !== "string") return;
const act = payload.act as { client?: string; sessionId?: string } | undefined;
return {
userId: payload.sub,
client: act?.client,
sessionId: act?.sessionId,
cap: Array.isArray(payload.cap) ? (payload.cap as string[]) : undefined,
};
}
export type BearerAuthResult =
| { ok: false; status: 401 | 403; error: string }
| {
ok: true;
environment: RbacEnv;
subject: RbacSubject;
ability: RbacAbility;
// `act` carries the acting user (`act.sub`) when the public JWT was
// minted from a PAT/UAT exchange that stamped a delegation claim. Hosts
// surface it for attribution (e.g. who resolved an error).
jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean; act?: { sub: string } };
};
export type SessionAuthResult =
| { ok: false; reason: "unauthenticated" | "unauthorized" }
| { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
// PAT auth deliberately omits `environment` — PATs are user identity
// tokens, not environment tokens. The ability is resolved per-request
// from the user's role in the target org (passed via `context`),
// intersected with the PAT's optional max-role cap.
export type PatAuthResult =
| { ok: false; status: 401 | 403; error: string }
| {
ok: true;
tokenId: string;
userId: string;
// The token's stored `lastAccessedAt`, returned alongside the
// identity so the host can throttle the per-request update in JS
// (skip the DB roundtrip when the value is fresh). Plugins must
// include this column in their auth lookup; the host owns the
// throttle window + the UPDATE itself. Null on a never-accessed
// token. The plugin contract requires this so the apiBuilder can
// collapse PAT auth + lastAccessedAt update from 2 queries to 1
// in the fresh-cache case — matching pre-RBAC main's query count.
lastAccessedAt: Date | null;
subject: RbacSubject;
ability: RbacAbility;
};
// Like PatAuthResult but stateless — a UAT has no stored row, so there's no
// `tokenId`/`lastAccessedAt`. The ability is `min(user's role in the target
// org, the token's optional scope cap)`, same cap-and-floor model as PATs.
export type UserActorAuthResult =
| { ok: false; status: 401 | 403; error: string }
| {
ok: true;
userId: string;
subject: RbacSubject;
ability: RbacAbility;
};
export interface RoleBaseAccessController {
// True when a real RBAC plugin is loaded; false when the built-in
// fallback is in use. Hosts gate behaviour that's only meaningful
// when the plugin is present (e.g. skipping role-attachment writes,
// hiding role-pickers in the UI, branching on whether ability checks
// are authoritative or permissive).
isUsingPlugin(): Promise<boolean>;
// API routes (Bearer token): one DB query → identity + pre-built ability
// options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise<BearerAuthResult>;
// Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability.
// The caller resolves `userId` from the session cookie and passes it in.
// (`null` means "no authenticated user"; the plugin returns `{ ok: false,
// reason: "unauthenticated" }`.) The plugin used to take a
// `helpers.getSessionUserId(request)` callback at create-time; pulling the
// userId resolution into the caller drops a static module-load coupling
// from the plugin's host module to the host's session-cookie code.
authenticateSession(
request: Request,
context: { userId: string | null; organizationId?: string; projectId?: string }
): Promise<SessionAuthResult>;
// PAT-authenticated routes (Authorization: Bearer tr_pat_…). The token
// identifies the user; the effective ability is `min(user's current
// role in the target org, the PAT's optional max-role cap)`. The user's
// actual org membership is the floor — if they've been demoted or
// removed, the PAT auto-narrows. The cap is set at PAT creation and
// ceilings the token even when the user is more privileged.
//
// No plugin installed → fallback returns a permissive ability so PAT
// routes that don't yet declare an `authorization` block keep working
// exactly as they did pre-RBAC.
authenticatePat(
request: Request,
context: { organizationId?: string; projectId?: string }
): Promise<PatAuthResult>;
// Delegated user-actor token routes (Authorization: Bearer tr_uat_…). The
// plugin verifies the token itself (stateless — no DB lookup of the token)
// and resolves the same cap-and-floor ability a PAT would for the same
// user: floor = the user's role in the target org (rejects non-members,
// like authenticatePat), cap = the token's optional scope cap.
//
// No plugin installed → fallback verifies the token and returns a
// permissive ability, mirroring the fallback's PAT behaviour.
authenticateUserActor(
request: Request,
context: { organizationId?: string; projectId?: string }
): Promise<UserActorAuthResult>;
// Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails.
// resource accepts the same single-or-array shape as RbacAbility.can — array form means
// "grant access if any element passes".
authenticateAuthorizeBearer(
request: Request,
check: { action: string; resource: RbacResource | RbacResource[] },
options?: { allowJWT?: boolean }
): Promise<BearerAuthResult>;
authenticateAuthorizeSession(
request: Request,
context: { userId: string | null; organizationId?: string; projectId?: string },
check: { action: string; resource: RbacResource | RbacResource[] }
): Promise<SessionAuthResult>;
// Plugin-owned catalogue of built-in system roles for the given org,
// in canonical order (highest authority first). Returns null when no
// plugin is installed — there are no seeded roles to refer to in that
// case (the default fallback's `allRoles` returns []).
//
// Hidden roles (e.g. Member in v1) are filtered out entirely. Each
// entry's `available` flag reflects whether the org's plan permits
// assigning that role; consumers can render unavailable entries with
// an upgrade badge or hide them.
systemRoles(organizationId: string): Promise<SystemRole[] | null>;
// Role introspection. The fallback returns []; a plugin may return
// its own role catalogue.
allPermissions(organizationId: string): Promise<Permission[]>;
allRoles(organizationId: string): Promise<Role[]>;
// Of the roles returned by `allRoles(organizationId)`, which IDs may
// be assigned right now? Used by the Teams page UI to disable
// role-dropdown options the org isn't allowed to assign. The default
// fallback returns every role id (permissive — it doesn't apply any
// gating). Server-side enforcement lives in setUserRole; this method
// is purely a UI affordance.
getAssignableRoleIds(organizationId: string): Promise<string[]>;
// Role management. Mutation methods return a discriminated Result
// rather than throwing — the dashboard surfaces `error` strings
// directly to the user (system role edits, gating, validation
// conflicts), so a thrown exception is only ever for unexpected
// failures (DB outage, bug). The default fallback returns
// `{ ok: false, error: "RBAC plugin not installed" }` for these.
createRole(params: {
organizationId: string;
name: string;
description: string;
permissions: string[];
}): Promise<RoleMutationResult>;
updateRole(params: {
roleId: string;
name?: string;
description?: string;
permissions?: string[];
}): Promise<RoleMutationResult>;
deleteRole(roleId: string): Promise<RoleAssignmentResult>;
// Role assignments. Same Result discipline as the role-management
// methods above. The default fallback returns
// `{ ok: false, error: "RBAC plugin not installed" }`.
getUserRole(params: {
userId: string;
organizationId: string;
projectId?: string;
}): Promise<Role | null>;
// Batch variant for callers that need per-user roles for many users
// in one round-trip (e.g. the Team page rendering N members).
// Org-scoped only — project-scoped reads still go through getUserRole.
// Returns a Map keyed by userId; users with no resolvable role map to
// null. The default fallback returns a Map of all userIds → null.
getUserRoles(userIds: string[], organizationId: string): Promise<Map<string, Role | null>>;
setUserRole(params: {
userId: string;
organizationId: string;
roleId: string;
projectId?: string;
}): Promise<RoleAssignmentResult>;
removeUserRole(params: {
userId: string;
organizationId: string;
projectId?: string;
}): Promise<RoleAssignmentResult>;
getTokenRole(tokenId: string): Promise<Role | null>;
setTokenRole(params: { tokenId: string; roleId: string }): Promise<RoleAssignmentResult>;
removeTokenRole(tokenId: string): Promise<RoleAssignmentResult>;
}
// Mutation result for role create/update — success carries the new
// `role`, failure carries a user-facing `error` string.
export type RoleMutationResult = { ok: true; role: Role } | { ok: false; error: string };
// Result for assignment / deletion mutations that don't return a value.
// `code` is an optional machine-readable reason so callers can branch on
// expected outcomes (e.g. `last_owner`, the guard that keeps an org from
// losing its final Owner) instead of matching the free-text `error`.
export type RoleAssignmentErrorCode = "last_owner";
export type RoleAssignmentResult =
| { ok: true }
| { ok: false; error: string; code?: RoleAssignmentErrorCode };
import type { PluginDatabaseConfig } from "./databaseConfig.js";
// Host-injected configuration the plugin can't read from the environment
// itself (the plugin runs in the host's process but owns no env contract).
export type RbacPluginConfig = {
// Platform secret the host signs user-actor tokens with; the plugin uses
// it to verify them in `authenticateUserActor`. Omitted → UAT auth 401s.
userActorSecret?: string;
// Database connections for a plugin that owns its own client. Omitted →
// the plugin falls back to its own defaults.
database?: PluginDatabaseConfig;
};
export type { PluginDatabaseConfig as RbacDatabaseConfig } from "./databaseConfig.js";
export interface RoleBasedAccessControlPlugin {
create(config?: RbacPluginConfig): RoleBaseAccessController | Promise<RoleBaseAccessController>;
}
+382
View File
@@ -0,0 +1,382 @@
import type { ResultAsync } from "neverthrow";
import type { PluginDatabaseConfig } from "./databaseConfig.js";
// === Domain types ===
export type SsoConnectionState = "active" | "inactive";
export type SsoDomainState = "pending" | "verified" | "failed";
export type SsoDomainStatus = {
domain: string;
verified: boolean;
state: SsoDomainState;
// Vendor-supplied reason code present when state === "failed".
// Plugin keeps it opaque; the host UI surfaces it to the admin so
// they know which knob to turn before retrying verification.
verificationFailedReason: string | null;
};
export type OrgSsoStatus = {
hasIdpOrg: boolean;
enforced: boolean;
jitProvisioningEnabled: boolean;
jitDefaultRoleId: string | null;
idpOrgId: string | null;
primaryConnectionId: string | null;
domains: ReadonlyArray<SsoDomainStatus>;
connections: ReadonlyArray<{
id: string;
name: string | null;
connectionType: string;
state: SsoConnectionState;
}>;
};
export type SsoRouteDecision = { kind: "no_sso" } | { kind: "sso_required"; idpOrgId: string };
export const SSO_FLOWS = [
"user_initiated",
"auto_discovery_magic",
"auto_discovery_oauth",
"auto_discovery_vercel",
"idp_initiated",
] as const;
export type SsoFlow = (typeof SSO_FLOWS)[number];
export type SsoProfile = {
// Lowercase-normalized at the plugin / host boundary.
email: string;
firstName: string | null;
lastName: string | null;
idpSubjectId: string;
idpOrgId: string;
idpConnectionId: string;
};
export type SsoResolutionDecision =
| { kind: "existing_user_by_idp"; userId: string }
| { kind: "linked_by_email"; userId: string }
| { kind: "create_new_user"; profile: SsoProfile };
// === Directory sync (SCIM) domain types ===
export type DirectoryState = "active" | "inactive";
// A directory group and the role it grants. `mappedRoleId === null` means the
// group has no role mapping; its members get no role from this group (and are
// not provisioned on its account alone).
export type DirectoryGroupMapping = {
groupId: string;
name: string;
mappedRoleId: string | null;
};
export type DirectorySyncStatus = {
hasDirectory: boolean;
// True when at least one directory is in the "active" state.
hasActiveDirectory: boolean;
// Per-org override: when true, directory users whose email domain is NOT a
// verified org domain are still provisioned. Default false.
allowExternalDomainSync: boolean;
// Raw per-org setting (for the settings toggle). When false AND a directory
// is active, manual membership adds/removes are blocked (see
// getMembershipPolicy for the effective value). Default true.
allowManualMembership: boolean;
// Role assigned to directory users who are active but belong to no mapped
// group. `null` (default) means ungrouped users are NOT provisioned; set a
// role to provision them at it (on verified domains, subject to the
// external-domain setting).
directoryDefaultRoleId: string | null;
userCount: number;
directories: ReadonlyArray<{
id: string;
name: string | null;
type: string;
state: DirectoryState;
}>;
groups: ReadonlyArray<DirectoryGroupMapping>;
};
// A host-actionable membership mutation derived from a directory-sync event.
// The plugin owns all `enterprise.*` writes; these effects describe the
// `public.*` (User / OrgMember / role / token) writes the host must perform —
// the plugin never touches those tables. The host worker applies them
// idempotently.
//
// - `provision`: ensure the User exists (create when `userId === null`),
// ensure the OrgMember exists, and set its role.
// - `deprovision`: remove the membership (guarded against last-Owner), force
// logout, and revoke tokens per host policy.
// - `set_role`: overwrite the member's role (directory-authoritative).
export type DirectorySyncEffect =
| {
kind: "provision";
userId: string | null;
email: string;
firstName: string | null;
lastName: string | null;
organizationId: string;
roleId: string | null;
}
| { kind: "deprovision"; userId: string; organizationId: string }
| { kind: "set_role"; userId: string; organizationId: string; roleId: string };
// === Errors ===
export type SsoDecisionError = "internal";
export type SsoBeginError = "no_org_for_domain" | "no_active_connection" | "feature_disabled";
export type SsoCompleteError =
| "state_replayed_or_expired"
| "state_invalid_signature"
| "code_exchange_failed"
| "org_mismatch"
| "email_mismatch"
| "connection_unknown";
export type SsoMutationError = "feature_disabled" | "rbac_role_invalid" | "internal";
// Vendor-neutral name for "the identity-provider organisation isn't available".
export type SsoPortalError = "idp_org_unavailable" | "internal";
// The only failure a session re-validation can report is "internal" —
// callers MUST treat it as fail-open (keep the session). An invalid
// session is NOT an error: it's a successful result of `{ valid: false }`.
export type SsoValidateError = "internal";
// Inbound webhook handling. `invalid_signature` → reject (4xx, no retry);
// `feature_disabled` → no plugin installed (host returns 404); `internal`
// → transient, the host returns 5xx so the provider retries.
export type SsoWebhookError = "invalid_signature" | "feature_disabled" | "internal";
// A verified, JSON-serializable inbound event. Vendor-neutral envelope —
// `event` is the provider's event-type string, `data` its opaque payload.
export type SsoWebhookEvent = { id: string; event: string; data: unknown };
// === Controller ===
export interface SsoController {
// True when a real SSO plugin is loaded. Hosts gate behaviour that's
// only meaningful when the plugin is present (e.g. rendering the
// settings tab, registering the SSO strategy actively).
isUsingPlugin(): Promise<boolean>;
// --- Provisioning + admin UI ---
getStatus(organizationId: string): ResultAsync<OrgSsoStatus, SsoDecisionError>;
// Returns an admin-portal link the customer's IT admin uses to
// configure their identity provider. First call also performs any lazy
// initialization the plugin needs (no separate enable() method).
generatePortalLink(params: {
organizationId: string;
userId: string;
intent: "sso" | "domain_verification" | "dsync";
returnUrl: string;
}): ResultAsync<{ url: string }, SsoPortalError>;
setEnforced(params: {
organizationId: string;
enforced: boolean;
}): ResultAsync<void, SsoMutationError>;
setJitProvisioningEnabled(params: {
organizationId: string;
enabled: boolean;
}): ResultAsync<void, SsoMutationError>;
setJitDefaultRole(params: {
organizationId: string;
roleId: string | null;
}): ResultAsync<void, SsoMutationError>;
// Atomic counterpart to the three setters above: the settings form
// presents enforced + JIT-enabled + JIT-default-role as a single Save,
// so they must commit all-or-nothing. Implementations write all three
// OrgSsoConfig columns in one atomic write, so an `internal` failure
// leaves none of the fields changed rather than a partially-applied
// config. Prefer this over the individual setters for the admin Save path.
updateConfig(params: {
organizationId: string;
enforced: boolean;
jitProvisioningEnabled: boolean;
jitDefaultRoleId: string | null;
}): ResultAsync<void, SsoMutationError>;
// --- Directory sync (SCIM) admin UI ---
// Full directory-sync state for the settings section: directories, the
// group→role mapping table, the external-domain toggle, and a member count.
getDirectorySyncStatus(
organizationId: string
): ResultAsync<DirectorySyncStatus, SsoDecisionError>;
// Map a directory group to an RBAC role (or clear the mapping with null).
// The role is validated against the org's assignable roles. Returns the
// membership effects the change implies for the group's current members
// (roles recomputed against the new mapping; deprovision when the group is
// cleared and it was a member's last mapped group) — the host applies them
// so a dashboard remap takes effect immediately, like a directory event.
setDirectoryGroupRole(params: {
organizationId: string;
groupId: string;
roleId: string | null;
}): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError>;
// Set the role for directory users with no mapped group (null = don't
// provision ungrouped users). Owner is reserved and rejected.
setDirectoryDefaultRole(params: {
organizationId: string;
roleId: string | null;
}): ResultAsync<void, SsoMutationError>;
// Toggle whether directory users outside the org's verified domains are
// provisioned. Default false (verified domains only).
setAllowExternalDomainSync(params: {
organizationId: string;
allowed: boolean;
}): ResultAsync<void, SsoMutationError>;
// --- Membership management policy + removal tombstones ---
// Effective policy for manual (dashboard) membership changes. Returns
// `manualMembershipAllowed: false` only when the org set allow-manual off
// AND a directory is active; otherwise true. Hosts use it to gate invite /
// accept-invite / remove / self-leave and to hide those buttons.
getMembershipPolicy(
organizationId: string
): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError>;
setAllowManualMembership(params: {
organizationId: string;
allowed: boolean;
}): ResultAsync<void, SsoMutationError>;
// Record that a user was removed from an org (manual removal or self-leave),
// so passive SSO-JIT won't silently re-add them on next login. Idempotent
// upsert keyed by (organizationId, userId). The host calls this after a
// successful team removal; DSync deprovisions record their own tombstone
// internally. No-op in the OSS fallback.
recordMembershipRemoval(params: {
organizationId: string;
userId: string;
reason: "manual_removal" | "self_leave";
}): ResultAsync<void, SsoMutationError>;
// Clear a removal tombstone — a deliberate re-admission (the host calls this
// when an invite is accepted). Idempotent; no-op when absent.
clearMembershipRemoval(params: {
organizationId: string;
userId: string;
}): ResultAsync<void, SsoMutationError>;
// --- Auth flow ---
// Called by every login entry point BEFORE the strategy proceeds.
// Composite gate (plan tier + feature flags + config + enforced) is
// implemented here. Fail-open: returns no_sso on internal error so a
// plugin outage doesn't lock users out.
decideRouteForEmail(email: string): ResultAsync<SsoRouteDecision, SsoDecisionError>;
// Returns the URL the user should be redirected to in order to
// authenticate with their identity provider. Internally mints a
// single-use signed state token; the implementation is opaque to
// OSS callers. Email is lowercase-normalized before lookup.
beginAuthorization(params: {
email: string;
redirectTo: string;
flow: SsoFlow;
}): ResultAsync<{ url: string }, SsoBeginError>;
// SP-initiated callback. Verifies and consumes the signed state token
// single-use, exchanges the code with the SSO provider, cross-checks
// the returned profile against the state claims. Returns profile +
// state-carried redirectTo + flow.
completeAuthorization(params: {
code: string;
state: string;
}): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError>;
// IdP-initiated callback (no state). Validates the returned connection
// identifier is one of ours. Default redirectTo is "/".
completeIdpInitiatedAuthorization(params: {
code: string;
}): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError>;
// Re-validate a live SSO session against the IdP. Called periodically
// (throttled by the host) for sessions that were established via SSO.
// The available signal is whether the user's identity-provider
// connection is still active, so `valid` reflects that. Returns an
// `internal` error on any infrastructure failure (e.g. the identity
// provider is unreachable) — the host MUST fail-open on the error and
// only invalidate the session on an explicit `{ valid: false }`.
validateSession(params: {
userId: string;
idpOrgId: string;
connectionId: string;
}): ResultAsync<{ valid: boolean }, SsoValidateError>;
// Look up an existing identity by IdP subject, or by lowercased email.
// Returns a decision the OSS callback handler uses to drive
// User/OrgMember writes. The plugin DOES NOT write to OSS public.*
// tables — those writes are the host's responsibility.
resolveSsoIdentity(params: {
profile: SsoProfile;
}): ResultAsync<SsoResolutionDecision, SsoMutationError>;
// After the host has created/found the User row, the plugin attaches
// the IdP identity row in its own storage.
attachSsoIdentity(params: {
userId: string;
profile: SsoProfile;
}): ResultAsync<void, SsoMutationError>;
// Returns whether JIT should provision a membership for the given
// (userId, idpOrgId), and the resolved roleId to assign (the org's
// JIT default role, or null when no RBAC plugin is installed).
// The host performs the actual OrgMember insert.
evaluateJit(params: {
userId: string;
idpOrgId: string;
}): ResultAsync<
{ shouldProvision: boolean; organizationId: string; roleId: string | null },
SsoMutationError
>;
// --- Inbound webhooks ---
// Verify the signature of a raw inbound webhook request and return the
// parsed, JSON-serializable event. The host forwards the raw body +
// headers from a thin proxy route; the plugin owns the vendor-specific
// signature scheme. The host enqueues the returned event for async
// processing (it never enqueues an unverified request).
verifyWebhook(params: {
rawBody: string;
headers: Record<string, string>;
}): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError>;
// Process a previously-verified webhook event (the host's background
// worker calls this). Performs the plugin's own `enterprise.*` state writes
// and returns any `public.*` membership effects the host must apply (empty
// for SSO/domain/connection events; populated for directory-sync events).
// Throws nothing — failures surface as `internal` so the worker retries;
// effect application on the host side must be idempotent.
processWebhookEvent(
event: SsoWebhookEvent
): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError>;
}
// Host-injected configuration the plugin can't read from the environment
// itself (the plugin runs in the host's process but owns no env contract).
export type SsoPluginConfig = {
// Database connections for a plugin that owns its own client. Omitted →
// the plugin falls back to its own defaults.
database?: PluginDatabaseConfig;
};
export interface SsoPlugin {
create(config?: SsoPluginConfig): SsoController | Promise<SsoController>;
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../.configs/tsconfig.base.json",
"compilerOptions": {
"sourceMap": true
},
"include": ["./src/**/*.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
treeshake: true,
});