@storyshelf/auth 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/auth-tables-pg.d.mts +1971 -0
  4. package/dist/auth-tables-pg.mjs +167 -0
  5. package/dist/auth-tables-pg.mjs.map +1 -0
  6. package/dist/auth-tables.d.mts +1092 -0
  7. package/dist/auth-tables.mjs +122 -0
  8. package/dist/auth-tables.mjs.map +1 -0
  9. package/dist/bootstrap.d.mts +15 -0
  10. package/dist/bootstrap.mjs +40 -0
  11. package/dist/bootstrap.mjs.map +1 -0
  12. package/dist/config.d.mts +27 -0
  13. package/dist/config.mjs +204 -0
  14. package/dist/config.mjs.map +1 -0
  15. package/dist/db-bridge.d.mts +20 -0
  16. package/dist/db-bridge.mjs +208 -0
  17. package/dist/db-bridge.mjs.map +1 -0
  18. package/dist/db-values.mjs +41 -0
  19. package/dist/db-values.mjs.map +1 -0
  20. package/dist/db-where.mjs +85 -0
  21. package/dist/db-where.mjs.map +1 -0
  22. package/dist/engine.d.mts +208 -0
  23. package/dist/engine.mjs +444 -0
  24. package/dist/engine.mjs.map +1 -0
  25. package/dist/groups.d.mts +54 -0
  26. package/dist/groups.mjs +129 -0
  27. package/dist/groups.mjs.map +1 -0
  28. package/dist/index.d.mts +18 -0
  29. package/dist/index.mjs +17 -0
  30. package/dist/invites.d.mts +41 -0
  31. package/dist/invites.mjs +225 -0
  32. package/dist/invites.mjs.map +1 -0
  33. package/dist/passkeys.d.mts +21 -0
  34. package/dist/passkeys.mjs +27 -0
  35. package/dist/passkeys.mjs.map +1 -0
  36. package/dist/presets/enterprise.d.mts +24 -0
  37. package/dist/presets/enterprise.mjs +44 -0
  38. package/dist/presets/enterprise.mjs.map +1 -0
  39. package/dist/presets/index.d.mts +5 -0
  40. package/dist/presets/social.d.mts +34 -0
  41. package/dist/presets/social.mjs +60 -0
  42. package/dist/presets/social.mjs.map +1 -0
  43. package/dist/presets/sso.d.mts +72 -0
  44. package/dist/presets/sso.mjs +136 -0
  45. package/dist/presets/sso.mjs.map +1 -0
  46. package/dist/sessions.d.mts +23 -0
  47. package/dist/sessions.mjs +51 -0
  48. package/dist/sessions.mjs.map +1 -0
  49. package/package.json +82 -0
@@ -0,0 +1,129 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { MemberModel, ProjectGroupMappingModel, ProjectModel, UserModel } from "@storyshelf/core/models";
5
+ //#region src/groups.ts
6
+ /** Default claim name when recipes leave `groups.claim` unset. */
7
+ const DEFAULT_GROUP_CLAIM = "groups";
8
+ /** Rank for top-match resolution (highest role wins). */
9
+ const ROLE_RANK = {
10
+ viewer: 0,
11
+ developer: 1,
12
+ approver: 2,
13
+ admin: 3
14
+ };
15
+ /** Collect group names from one claim value. */
16
+ function claimGroups(value, into) {
17
+ if (typeof value === "string" && value) into.push(value);
18
+ else if (Array.isArray(value)) {
19
+ for (const entry of value) if (typeof entry === "string" && entry) into.push(entry);
20
+ }
21
+ }
22
+ /**
23
+ * Read group names from provider userInfo. Arrays pass through; a lone
24
+ * string counts as exactly one group (never whitespace-split — real names
25
+ * contain spaces). Non-string entries are ignored. First claim hit wins
26
+ * across names; results de-duplicated.
27
+ */
28
+ function extractGroups(userInfo, claim) {
29
+ const names = claim === void 0 ? [DEFAULT_GROUP_CLAIM] : Array.isArray(claim) ? claim : [claim];
30
+ for (const name of names) {
31
+ const found = [];
32
+ claimGroups(userInfo[name], found);
33
+ if (found.length > 0) return [...new Set(found)];
34
+ }
35
+ return [];
36
+ }
37
+ /**
38
+ * Site role from admin-group intersection (full reconcile). Without
39
+ * configured admins the current role passes through untouched.
40
+ */
41
+ function resolveSiteRole(current, groups, admins = []) {
42
+ if (admins.length === 0) return current;
43
+ return groups.some((group) => admins.includes(group)) ? "admin" : "member";
44
+ }
45
+ /**
46
+ * Build the plugin `provisionUser` hook bound to shelf providers.
47
+ * Unknown provider ids are no-ops (defensive; the plugin resolves first).
48
+ */
49
+ function provisionHook(db, providers) {
50
+ return async (data) => {
51
+ await syncSsoGroups(db, providers, {
52
+ userId: data.user.id,
53
+ email: data.user.email,
54
+ name: data.user.name,
55
+ userInfo: data.userInfo,
56
+ providerId: data.provider.providerId
57
+ });
58
+ };
59
+ }
60
+ /** Highest-ranked mapping matched by the groups, or null. */
61
+ function topMatch(rows, groups) {
62
+ let top = null;
63
+ for (const row of rows) {
64
+ if (!groups.includes(row.groupName)) continue;
65
+ if (!top || ROLE_RANK[row.role] > ROLE_RANK[top.role]) top = {
66
+ groupName: row.groupName,
67
+ role: row.role
68
+ };
69
+ }
70
+ return top;
71
+ }
72
+ /** Reconcile one project's memberships against the groups. */
73
+ async function syncProjectGroups(members, mappings, projectId, userId, groups) {
74
+ const rows = await mappings.list(projectId);
75
+ if (rows.length === 0) return;
76
+ if ((await members.get(projectId, userId))?.source === "manual") return;
77
+ await applyProjectTop(members, projectId, userId, topMatch(rows, groups));
78
+ }
79
+ /** Apply the top match (or revoke stale) for one project. */
80
+ async function applyProjectTop(members, projectId, userId, top) {
81
+ if (!top) {
82
+ await revokeStale(members, projectId, userId);
83
+ return;
84
+ }
85
+ const current = await members.get(projectId, userId);
86
+ if (!current || current.role !== top.role || current.source !== `sso:${top.groupName}`) await members.set(projectId, userId, top.role, `sso:${top.groupName}`);
87
+ }
88
+ /** Remove a stale synced grant (manual grants already excluded by the caller). */
89
+ async function revokeStale(members, projectId, userId) {
90
+ if (await members.get(projectId, userId)) await members.remove(projectId, userId);
91
+ }
92
+ /** Apply the reconciled site role (upsert first-timers, update on change). */
93
+ async function syncSiteRole(db, input, role) {
94
+ const users = new UserModel(db);
95
+ const stored = await users.get(input.userId);
96
+ if (!stored) await users.upsert({
97
+ id: input.userId,
98
+ email: input.email,
99
+ name: input.name,
100
+ avatarUrl: null,
101
+ role
102
+ });
103
+ else if (stored.role !== role) await db.update(db.tables.users, stored.id, { role });
104
+ }
105
+ /** Reconcile project memberships across every project. */
106
+ async function syncProjectRoles(db, userId, groups) {
107
+ const members = new MemberModel(db);
108
+ const mappings = new ProjectGroupMappingModel(db);
109
+ const projects = await new ProjectModel(db).list();
110
+ await Promise.all(projects.map(async (project) => {
111
+ await syncProjectGroups(members, mappings, project.id, userId, groups);
112
+ }));
113
+ }
114
+ /**
115
+ * Sync IdP groups into shelf state: site role (full reconcile) plus project
116
+ * memberships. No-op for providers without `groups` config. Creates the
117
+ * shelf row on first SSO login when the mirror hook has not run yet.
118
+ */
119
+ async function syncSsoGroups(db, providers, input) {
120
+ const sync = providers.find((entry) => entry.id === input.providerId)?.groups;
121
+ if (!sync) return;
122
+ const groups = extractGroups(input.userInfo, sync.claim);
123
+ await syncSiteRole(db, input, resolveSiteRole((await new UserModel(db).get(input.userId))?.role ?? "member", groups, sync.admins));
124
+ await syncProjectRoles(db, input.userId, groups);
125
+ }
126
+ //#endregion
127
+ export { DEFAULT_GROUP_CLAIM, extractGroups, provisionHook, resolveSiteRole, syncSsoGroups };
128
+
129
+ //# sourceMappingURL=groups.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"groups.mjs","names":[],"sources":["../src/groups.ts"],"sourcesContent":["/**\n * IdP group sync for SSO providers (site role + project memberships).\n *\n * The SSO plugin calls the hook with raw provider `userInfo` on every\n * sign-in (`provisionUserOnEveryLogin`); this module turns group claims into\n * shelf state. Semantics mirror the app-side login sync: exact group names\n * (no wildcards), highest-ranked project role wins recorded as `sso:<group>`,\n * stale non-manual grants revoked, manual grants never touched. Social and\n * generic-OAuth logins have no per-login hook and never sync groups.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport {\n MemberModel,\n ProjectGroupMappingModel,\n ProjectModel,\n UserModel,\n} from \"@storyshelf/core/models\";\nimport type { ProjectRole, SiteRole } from \"@storyshelf/core/types\";\nimport type { ShelfSSOProvider } from \"./engine.ts\";\n\n/** Default claim name when recipes leave `groups.claim` unset. */\nexport const DEFAULT_GROUP_CLAIM = \"groups\";\n\n/** Rank for top-match resolution (highest role wins). */\nconst ROLE_RANK: Record<ProjectRole, number> = { viewer: 0, developer: 1, approver: 2, admin: 3 };\n\n/** Collect group names from one claim value. */\nfunction claimGroups(value: unknown, into: string[]): void {\n if (typeof value === \"string\" && value) {\n into.push(value);\n } else if (Array.isArray(value)) {\n for (const entry of value) {\n if (typeof entry === \"string\" && entry) {\n into.push(entry);\n }\n }\n }\n}\n\n/**\n * Read group names from provider userInfo. Arrays pass through; a lone\n * string counts as exactly one group (never whitespace-split — real names\n * contain spaces). Non-string entries are ignored. First claim hit wins\n * across names; results de-duplicated.\n */\nexport function extractGroups(\n userInfo: Record<string, unknown>,\n claim?: string | string[],\n): string[] {\n const names =\n claim === undefined ? [DEFAULT_GROUP_CLAIM] : Array.isArray(claim) ? claim : [claim];\n for (const name of names) {\n const found: string[] = [];\n claimGroups(userInfo[name], found);\n if (found.length > 0) {\n return [...new Set(found)];\n }\n }\n return [];\n}\n\n/**\n * Site role from admin-group intersection (full reconcile). Without\n * configured admins the current role passes through untouched.\n */\nexport function resolveSiteRole(\n current: SiteRole,\n groups: string[],\n admins: string[] = [],\n): SiteRole {\n if (admins.length === 0) {\n return current;\n }\n return groups.some((group) => admins.includes(group)) ? \"admin\" : \"member\";\n}\n\n/** Input mapped from the SSO plugin's provisionUser payload. */\nexport interface GroupProvisionData {\n user: { id: string; email: string; name: string };\n userInfo: Record<string, unknown>;\n provider: { providerId: string };\n}\n\n/** Normalized input for the sync (plugin payload flattened). */\nexport interface SsoGroupSyncInput {\n userId: string;\n email: string;\n name: string;\n userInfo: Record<string, unknown>;\n providerId: string;\n}\n\n/**\n * Build the plugin `provisionUser` hook bound to shelf providers.\n * Unknown provider ids are no-ops (defensive; the plugin resolves first).\n */\nexport function provisionHook(\n db: DatabaseAdapter,\n providers: ShelfSSOProvider[],\n): (data: GroupProvisionData) => Promise<void> {\n return async (data) => {\n await syncSsoGroups(db, providers, {\n userId: data.user.id,\n email: data.user.email,\n name: data.user.name,\n userInfo: data.userInfo,\n providerId: data.provider.providerId,\n });\n };\n}\n\n/** Highest-ranked mapping matched by the groups, or null. */\nfunction topMatch(\n rows: { groupName: string; role: ProjectRole }[],\n groups: string[],\n): { groupName: string; role: ProjectRole } | null {\n let top: { groupName: string; role: ProjectRole } | null = null;\n for (const row of rows) {\n if (!groups.includes(row.groupName)) {\n continue;\n }\n if (!top || ROLE_RANK[row.role] > ROLE_RANK[top.role]) {\n top = { groupName: row.groupName, role: row.role };\n }\n }\n return top;\n}\n\n/** Reconcile one project's memberships against the groups. */\nasync function syncProjectGroups(\n members: MemberModel,\n mappings: ProjectGroupMappingModel,\n projectId: string,\n userId: string,\n groups: string[],\n): Promise<void> {\n const rows = await mappings.list(projectId);\n if (rows.length === 0) {\n return;\n }\n const existing = await members.get(projectId, userId);\n // Manual grants win over IdP mappings, always.\n if (existing?.source === \"manual\") {\n return;\n }\n await applyProjectTop(members, projectId, userId, topMatch(rows, groups));\n}\n\n/** Apply the top match (or revoke stale) for one project. */\nasync function applyProjectTop(\n members: MemberModel,\n projectId: string,\n userId: string,\n top: { groupName: string; role: ProjectRole } | null,\n): Promise<void> {\n if (!top) {\n await revokeStale(members, projectId, userId);\n return;\n }\n const current = await members.get(projectId, userId);\n if (!current || current.role !== top.role || current.source !== `sso:${top.groupName}`) {\n await members.set(projectId, userId, top.role, `sso:${top.groupName}`);\n }\n}\n\n/** Remove a stale synced grant (manual grants already excluded by the caller). */\nasync function revokeStale(members: MemberModel, projectId: string, userId: string): Promise<void> {\n const existing = await members.get(projectId, userId);\n if (existing) {\n await members.remove(projectId, userId);\n }\n}\n\n/** Apply the reconciled site role (upsert first-timers, update on change). */\nasync function syncSiteRole(\n db: DatabaseAdapter,\n input: SsoGroupSyncInput,\n role: SiteRole,\n): Promise<void> {\n const users = new UserModel(db);\n const stored = await users.get(input.userId);\n if (!stored) {\n await users.upsert({\n id: input.userId,\n email: input.email,\n name: input.name,\n avatarUrl: null,\n role,\n });\n } else if (stored.role !== role) {\n await db.update(db.tables.users, stored.id, { role });\n }\n}\n\n/** Reconcile project memberships across every project. */\nasync function syncProjectRoles(\n db: DatabaseAdapter,\n userId: string,\n groups: string[],\n): Promise<void> {\n const members = new MemberModel(db);\n const mappings = new ProjectGroupMappingModel(db);\n const projects = await new ProjectModel(db).list();\n await Promise.all(\n projects.map(async (project) => {\n await syncProjectGroups(members, mappings, project.id, userId, groups);\n }),\n );\n}\n\n/**\n * Sync IdP groups into shelf state: site role (full reconcile) plus project\n * memberships. No-op for providers without `groups` config. Creates the\n * shelf row on first SSO login when the mirror hook has not run yet.\n */\nexport async function syncSsoGroups(\n db: DatabaseAdapter,\n providers: ShelfSSOProvider[],\n input: SsoGroupSyncInput,\n): Promise<void> {\n const provider = providers.find((entry) => entry.id === input.providerId);\n const sync = provider?.groups;\n if (!sync) {\n return;\n }\n const groups = extractGroups(input.userInfo, sync.claim);\n const stored = await new UserModel(db).get(input.userId);\n const role = resolveSiteRole(stored?.role ?? \"member\", groups, sync.admins);\n await syncSiteRole(db, input, role);\n await syncProjectRoles(db, input.userId, groups);\n}\n"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { baseAuthDateFields, baseAuthTables } from "./auth-tables.mjs";
4
+ import { authAccountPg, authPasskeyPg, authSessionPg, authSsoProviderPg, authUserPg, authVerificationPg, baseAuthDateFieldsPg, baseAuthTablesPg } from "./auth-tables-pg.mjs";
5
+ import { PasswordAdminInput, ensurePasswordAdmin } from "./bootstrap.mjs";
6
+ import { ENGINE_PROVIDER_ID, EngineSessionUser, ShelfAuth, ShelfAuthInstance, ShelfAuthOptions, ShelfOAuthProvider, ShelfPasskeyOptions, ShelfSSOGroupSync, ShelfSSOOidc, ShelfSSOOptions, ShelfSSOProvider, ShelfSSOSaml, ShelfSocialProvider, createShelfAuth } from "./engine.mjs";
7
+ import { RawShelfAuthOptions, ResolveSecrets, authOptionsSchema, parseEnvRef, resolveAuthOptions } from "./config.mjs";
8
+ import { AuthBridgeSchema, createShelfDbBridge } from "./db-bridge.mjs";
9
+ import { DEFAULT_GROUP_CLAIM, GroupProvisionData, SsoGroupSyncInput, extractGroups, provisionHook, resolveSiteRole, syncSsoGroups } from "./groups.mjs";
10
+ import { AcceptInviteInput, InviteTokenInput, IssueInviteInput, MIN_PASSWORD_LENGTH, acceptInvite, issueInvite, verifyInvite } from "./invites.mjs";
11
+ import { EnterpriseClientOptions, auth0Preset, keycloakPreset, oktaPreset } from "./presets/enterprise.mjs";
12
+ import { SocialClientOptions, cognitoPreset, entraPreset, githubPreset, gitlabPreset, googlePreset } from "./presets/social.mjs";
13
+ import "./presets/index.mjs";
14
+ import { SAMLGroupOptions, SAMLIdPOptions, SSOClientOptions, samlPreset, ssoAuth0Preset, ssoCallbackUrls, ssoEntraPreset, ssoGooglePreset, ssoKeycloakPreset, ssoOktaPreset } from "./presets/sso.mjs";
15
+ import { hasPasswordCredential, listUserPasskeys } from "./passkeys.mjs";
16
+ import { listUserSessions, unsignedToken } from "./sessions.mjs";
17
+ import { Auth, EngineLoginMethod, EngineLoginMethodKind, EnginePasskeyInfo, EngineSessionInfo } from "@storyshelf/core/auth";
18
+ export { type AcceptInviteInput, type Auth, type AuthBridgeSchema, DEFAULT_GROUP_CLAIM, ENGINE_PROVIDER_ID, type EngineLoginMethod, type EngineLoginMethodKind, type EnginePasskeyInfo, type EngineSessionInfo, type EngineSessionUser, type EnterpriseClientOptions, type GroupProvisionData, type InviteTokenInput, type IssueInviteInput, MIN_PASSWORD_LENGTH, type PasswordAdminInput, type RawShelfAuthOptions, type ResolveSecrets, type SAMLGroupOptions, type SAMLIdPOptions, type SSOClientOptions, type ShelfAuth, type ShelfAuthInstance, type ShelfAuthOptions, type ShelfOAuthProvider, type ShelfPasskeyOptions, type ShelfSSOGroupSync, type ShelfSSOOidc, type ShelfSSOOptions, type ShelfSSOProvider, type ShelfSSOSaml, type ShelfSocialProvider, type SocialClientOptions, type SsoGroupSyncInput, acceptInvite, auth0Preset, authAccountPg, authOptionsSchema, authPasskeyPg, authSessionPg, authSsoProviderPg, authUserPg, authVerificationPg, baseAuthDateFields, baseAuthDateFieldsPg, baseAuthTables, baseAuthTablesPg, cognitoPreset, createShelfAuth, createShelfDbBridge, ensurePasswordAdmin, entraPreset, extractGroups, githubPreset, gitlabPreset, googlePreset, hasPasswordCredential, issueInvite, keycloakPreset, listUserPasskeys, listUserSessions, oktaPreset, parseEnvRef, provisionHook, resolveAuthOptions, resolveSiteRole, samlPreset, ssoAuth0Preset, ssoCallbackUrls, ssoEntraPreset, ssoGooglePreset, ssoKeycloakPreset, ssoOktaPreset, syncSsoGroups, unsignedToken, verifyInvite };
package/dist/index.mjs ADDED
@@ -0,0 +1,17 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { baseAuthDateFields, baseAuthTables } from "./auth-tables.mjs";
5
+ import { authAccountPg, authPasskeyPg, authSessionPg, authSsoProviderPg, authUserPg, authVerificationPg, baseAuthDateFieldsPg, baseAuthTablesPg } from "./auth-tables-pg.mjs";
6
+ import { MIN_PASSWORD_LENGTH, acceptInvite, issueInvite, verifyInvite } from "./invites.mjs";
7
+ import { ensurePasswordAdmin } from "./bootstrap.mjs";
8
+ import { authOptionsSchema, parseEnvRef, resolveAuthOptions } from "./config.mjs";
9
+ import { createShelfDbBridge } from "./db-bridge.mjs";
10
+ import { DEFAULT_GROUP_CLAIM, extractGroups, provisionHook, resolveSiteRole, syncSsoGroups } from "./groups.mjs";
11
+ import { hasPasswordCredential, listUserPasskeys } from "./passkeys.mjs";
12
+ import { listUserSessions, unsignedToken } from "./sessions.mjs";
13
+ import { ENGINE_PROVIDER_ID, createShelfAuth } from "./engine.mjs";
14
+ import { auth0Preset, keycloakPreset, oktaPreset } from "./presets/enterprise.mjs";
15
+ import { cognitoPreset, entraPreset, githubPreset, gitlabPreset, googlePreset } from "./presets/social.mjs";
16
+ import { samlPreset, ssoAuth0Preset, ssoCallbackUrls, ssoEntraPreset, ssoGooglePreset, ssoKeycloakPreset, ssoOktaPreset } from "./presets/sso.mjs";
17
+ export { DEFAULT_GROUP_CLAIM, ENGINE_PROVIDER_ID, MIN_PASSWORD_LENGTH, acceptInvite, auth0Preset, authAccountPg, authOptionsSchema, authPasskeyPg, authSessionPg, authSsoProviderPg, authUserPg, authVerificationPg, baseAuthDateFields, baseAuthDateFieldsPg, baseAuthTables, baseAuthTablesPg, cognitoPreset, createShelfAuth, createShelfDbBridge, ensurePasswordAdmin, entraPreset, extractGroups, githubPreset, gitlabPreset, googlePreset, hasPasswordCredential, issueInvite, keycloakPreset, listUserPasskeys, listUserSessions, oktaPreset, parseEnvRef, provisionHook, resolveAuthOptions, resolveSiteRole, samlPreset, ssoAuth0Preset, ssoCallbackUrls, ssoEntraPreset, ssoGooglePreset, ssoKeycloakPreset, ssoOktaPreset, syncSsoGroups, unsignedToken, verifyInvite };
@@ -0,0 +1,41 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { Table } from "drizzle-orm";
4
+ import { AuthUser } from "@storyshelf/core/types";
5
+ import { DatabaseAdapter } from "@storyshelf/core/adapter/database";
6
+ //#region src/invites.d.ts
7
+ /** Minimum password length for local accounts (matches legacy policy). */
8
+ declare const MIN_PASSWORD_LENGTH = 12;
9
+ /** Input for {@link issueInvite}. */
10
+ interface IssueInviteInput {
11
+ email: string;
12
+ name: string;
13
+ role: AuthUser["role"];
14
+ /** Invite lifetime override. Defaults to 7 days. */
15
+ inviteExpiryMs?: number;
16
+ }
17
+ /** Input for {@link acceptInvite} and {@link verifyInvite}. */
18
+ interface InviteTokenInput {
19
+ inviteId: string;
20
+ token: string;
21
+ }
22
+ /** Input for {@link acceptInvite}. */
23
+ interface AcceptInviteInput extends InviteTokenInput {
24
+ password: string;
25
+ }
26
+ /** Stage a shelf user and mint a single-use invite token (returned once). */
27
+ declare function issueInvite(db: DatabaseAdapter, input: IssueInviteInput): Promise<{
28
+ inviteId: string;
29
+ token: string;
30
+ expiresAt: string;
31
+ }>;
32
+ /** Check an invite token without consuming it (drives the accept page). */
33
+ declare function verifyInvite(db: DatabaseAdapter, input: InviteTokenInput): Promise<{
34
+ email: string;
35
+ name: string;
36
+ }>;
37
+ /** Consume an invite: set the engine credential and return the shelf user. */
38
+ declare function acceptInvite(db: DatabaseAdapter, input: AcceptInviteInput): Promise<AuthUser>;
39
+ //#endregion
40
+ export { AcceptInviteInput, InviteTokenInput, IssueInviteInput, MIN_PASSWORD_LENGTH, acceptInvite, issueInvite, verifyInvite };
41
+ //# sourceMappingURL=invites.d.mts.map
@@ -0,0 +1,225 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { baseAuthTables } from "./auth-tables.mjs";
5
+ import { randomToken, sha256, timingSafeEqualString, ulid } from "@storyshelf/core/utils";
6
+ import { eq, getTableColumns, sql } from "drizzle-orm";
7
+ import { hashPassword } from "better-auth/crypto";
8
+ //#region src/invites.ts
9
+ const DEFAULT_INVITE_MS = 6048e5;
10
+ const MAX_INVITE_MS = 31536e6;
11
+ /** Minimum password length for local accounts (matches legacy policy). */
12
+ const MIN_PASSWORD_LENGTH = 12;
13
+ /**
14
+ * Run `fn` inside a transaction. Invites are consume-once state: without
15
+ * driver transactions concurrent accepts race, so transact is required
16
+ * (every production driver offers it) rather than silently sequential.
17
+ */
18
+ async function withInviteTx(db, fn) {
19
+ if (!db.transact) throw new Error("Shelf invites require a database with transaction support");
20
+ return await db.transact(async (tx) => await fn({
21
+ ...tx,
22
+ tables: db.tables
23
+ }));
24
+ }
25
+ function normalizeEmail(email) {
26
+ return email.trim().toLowerCase();
27
+ }
28
+ function isEmail(value) {
29
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value);
30
+ }
31
+ function inviteError() {
32
+ return /* @__PURE__ */ new Error("Invalid or expired invite");
33
+ }
34
+ /** Resolve a shelf/engine table column, failing fast on schema drift. */
35
+ function tableColumn(table, name) {
36
+ const column = getTableColumns(table)[name];
37
+ if (!column) throw new Error(`Shelf table is missing column "${name}"`);
38
+ return column;
39
+ }
40
+ function assertPasswordPolicy(password) {
41
+ if (password.length < 12) throw new Error("Password must be at least 12 characters");
42
+ }
43
+ async function listShelfUsersByEmail(db, email) {
44
+ return await db.list(db.tables.users, {
45
+ where: eq(tableColumn(db.tables.users, "email"), normalizeEmail(email)),
46
+ limit: 1
47
+ });
48
+ }
49
+ async function findEngineUserByEmail(db, email) {
50
+ const column = tableColumn(baseAuthTables.user, "email");
51
+ return (await db.list(baseAuthTables.user, {
52
+ where: eq(sql`lower(${column})`, email.toLowerCase()),
53
+ limit: 1
54
+ }))[0] ?? null;
55
+ }
56
+ async function findCredentialAccount(db, userId) {
57
+ return (await db.list(baseAuthTables.account, { where: eq(tableColumn(baseAuthTables.account, "userId"), userId) })).find((row) => row["providerId"] === "credential") ?? null;
58
+ }
59
+ /** Load the invite row, or throw the generic invite error. */
60
+ async function loadInvite(db, input) {
61
+ const invite = await db.get(db.tables.userInviteTokens, input.inviteId);
62
+ if (!invite || invite["usedAt"]) throw inviteError();
63
+ const expires = new Date(String(invite["expiresAt"])).getTime();
64
+ if (Number.isNaN(expires) || expires <= Date.now()) throw inviteError();
65
+ if (!timingSafeEqualString(sha256(input.token), String(invite["tokenHash"]))) throw inviteError();
66
+ return invite;
67
+ }
68
+ /** Load the invite plus its shelf user, or throw the generic invite error. */
69
+ async function assertInviteValid(db, input) {
70
+ const invite = await loadInvite(db, input);
71
+ const shelfUser = await db.get(db.tables.users, String(invite["userId"]));
72
+ if (!shelfUser || shelfUser["disabled"]) throw inviteError();
73
+ return {
74
+ invite,
75
+ shelfUser
76
+ };
77
+ }
78
+ /** Create the credential account row, or refresh the existing password. */
79
+ async function upsertCredentialAccount(db, userId, password, now) {
80
+ const hash = await hashPassword(password);
81
+ const account = await findCredentialAccount(db, userId);
82
+ if (account) {
83
+ await db.update(baseAuthTables.account, String(account["id"]), {
84
+ password: hash,
85
+ updatedAt: now
86
+ });
87
+ return;
88
+ }
89
+ await db.insert(baseAuthTables.account, {
90
+ id: ulid(),
91
+ userId,
92
+ accountId: userId,
93
+ providerId: "credential",
94
+ password: hash,
95
+ createdAt: now,
96
+ updatedAt: now
97
+ });
98
+ }
99
+ /** Create the engine identity rows (shared id), or refresh the credential. */
100
+ async function ensureEngineIdentity(db, shelfUser, password) {
101
+ const userId = String(shelfUser["id"]);
102
+ const email = String(shelfUser["email"]);
103
+ const now = (/* @__PURE__ */ new Date()).toISOString();
104
+ const engineUser = await findEngineUserByEmail(db, email);
105
+ if (engineUser && engineUser["emailVerified"] !== true) throw new Error("Email is registered with an unverified SSO identity; sign in with SSO instead");
106
+ if (!engineUser) await db.insert(baseAuthTables.user, {
107
+ id: userId,
108
+ name: String(shelfUser["name"]),
109
+ email,
110
+ emailVerified: true,
111
+ image: null,
112
+ createdAt: now,
113
+ updatedAt: now
114
+ });
115
+ await upsertCredentialAccount(db, engineUser ? String(engineUser["id"]) : userId, password, now);
116
+ }
117
+ function toAuthUser(row) {
118
+ return {
119
+ id: String(row["id"]),
120
+ email: String(row["email"]),
121
+ name: String(row["name"]),
122
+ avatarUrl: row["avatarUrl"] ?? void 0,
123
+ role: row["role"]
124
+ };
125
+ }
126
+ function inviteLifetimeMs(expiryMs) {
127
+ const lifetime = expiryMs ?? DEFAULT_INVITE_MS;
128
+ if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > MAX_INVITE_MS) throw new Error("Invite lifetime must be between 1ms and 365 days");
129
+ return lifetime;
130
+ }
131
+ /** Refresh name/role on the existing row (re-invite is admin intent). */
132
+ async function refreshShelfUser(db, id, input) {
133
+ return await db.update(db.tables.users, id, {
134
+ name: input.name.trim(),
135
+ role: input.role
136
+ });
137
+ }
138
+ async function stageShelfUser(db, input) {
139
+ const email = normalizeEmail(input.email);
140
+ if (!isEmail(email)) throw new Error("Invalid email");
141
+ if (input.name.trim() === "") throw new Error("Name is required");
142
+ const existing = await listShelfUsersByEmail(db, email);
143
+ if (existing[0]) return await refreshShelfUser(db, String(existing[0]["id"]), input);
144
+ const now = (/* @__PURE__ */ new Date()).toISOString();
145
+ return await db.insert(db.tables.users, {
146
+ id: ulid(),
147
+ email,
148
+ name: input.name.trim(),
149
+ avatarUrl: null,
150
+ role: input.role,
151
+ lastLoginAt: null,
152
+ createdAt: now,
153
+ passwordHash: null,
154
+ displayNameOverride: null,
155
+ authProvider: "local",
156
+ disabled: false
157
+ });
158
+ }
159
+ async function removeUnusedInvite(db, row) {
160
+ await db.remove(db.tables.userInviteTokens, String(row["id"]));
161
+ }
162
+ async function supersedeUnusedInvites(db, userId) {
163
+ const existing = await db.list(db.tables.userInviteTokens, { where: eq(tableColumn(db.tables.userInviteTokens, "userId"), userId) });
164
+ await Promise.all(existing.filter((row) => !row["usedAt"]).map(async (row) => {
165
+ await removeUnusedInvite(db, row);
166
+ }));
167
+ }
168
+ /** Stage a shelf user and mint a single-use invite token (returned once). */
169
+ async function issueInvite(db, input) {
170
+ return await withInviteTx(db, async (store) => {
171
+ const shelfUser = await stageShelfUser(store, input);
172
+ const userId = String(shelfUser["id"]);
173
+ await supersedeUnusedInvites(store, userId);
174
+ const now = (/* @__PURE__ */ new Date()).toISOString();
175
+ const expiresAt = new Date(Date.now() + inviteLifetimeMs(input.inviteExpiryMs)).toISOString();
176
+ const inviteId = ulid();
177
+ const token = randomToken("inv_").value;
178
+ await store.insert(store.tables.userInviteTokens, {
179
+ id: inviteId,
180
+ userId,
181
+ tokenHash: sha256(token),
182
+ expiresAt,
183
+ usedAt: null,
184
+ createdAt: now
185
+ });
186
+ return {
187
+ inviteId,
188
+ token,
189
+ expiresAt
190
+ };
191
+ });
192
+ }
193
+ /** Check an invite token without consuming it (drives the accept page). */
194
+ async function verifyInvite(db, input) {
195
+ const { shelfUser } = await assertInviteValid(db, input);
196
+ return {
197
+ email: String(shelfUser["email"]),
198
+ name: String(shelfUser["name"])
199
+ };
200
+ }
201
+ /** Whether an invite row is already spent (busy-downgrade probe). */
202
+ async function inviteSpent(db, inviteId) {
203
+ return !!(await db.get(db.tables.userInviteTokens, inviteId))?.["usedAt"];
204
+ }
205
+ /** Consume an invite: set the engine credential and return the shelf user. */
206
+ async function acceptInvite(db, input) {
207
+ assertPasswordPolicy(input.password);
208
+ try {
209
+ return await withInviteTx(db, async (store) => {
210
+ const { invite, shelfUser } = await assertInviteValid(store, input);
211
+ await ensureEngineIdentity(store, shelfUser, input.password);
212
+ const now = (/* @__PURE__ */ new Date()).toISOString();
213
+ await store.update(store.tables.users, String(shelfUser["id"]), { lastLoginAt: now });
214
+ await store.update(store.tables.userInviteTokens, String(invite["id"]), { usedAt: now });
215
+ return toAuthUser(shelfUser);
216
+ });
217
+ } catch (error) {
218
+ if (await inviteSpent(db, input.inviteId)) throw inviteError();
219
+ throw error;
220
+ }
221
+ }
222
+ //#endregion
223
+ export { MIN_PASSWORD_LENGTH, acceptInvite, ensureEngineIdentity, issueInvite, tableColumn, verifyInvite };
224
+
225
+ //# sourceMappingURL=invites.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invites.mjs","names":[],"sources":["../src/invites.ts"],"sourcesContent":["import type { DatabaseAdapter, TxStore } from \"@storyshelf/core/adapter/database\";\n/**\n * Invite-only local accounts on the engine: our tables own the invite,\n * Better Auth owns the credential.\n *\n * `issueInvite` stages a shelf `users` row plus a single-use token;\n * `acceptInvite` verifies the token, then writes the engine `user` row and\n * `credential` account row directly through the DatabaseAdapter. Direct\n * writes (instead of the admin `createUser` endpoint) because invite accept\n * is a pre-authentication flow and admin endpoints require a session by\n * design. Passwords use Better Auth's own hasher so engine sign-in verifies\n * them without format translation.\n */\nimport type { AuthUser } from \"@storyshelf/core/types\";\nimport { randomToken, sha256, timingSafeEqualString, ulid } from \"@storyshelf/core/utils\";\nimport { hashPassword } from \"better-auth/crypto\";\nimport { eq, getTableColumns, sql } from \"drizzle-orm\";\nimport type { SQL, Table } from \"drizzle-orm\";\nimport { baseAuthTables } from \"./auth-tables.ts\";\n\nconst DEFAULT_INVITE_MS = 7 * 24 * 60 * 60 * 1000;\nconst MAX_INVITE_MS = 365 * 24 * 60 * 60 * 1000;\n\n/** Minimum password length for local accounts (matches legacy policy). */\nexport const MIN_PASSWORD_LENGTH = 12;\n\n/** Input for {@link issueInvite}. */\nexport interface IssueInviteInput {\n email: string;\n name: string;\n role: AuthUser[\"role\"];\n /** Invite lifetime override. Defaults to 7 days. */\n inviteExpiryMs?: number;\n}\n\n/** Input for {@link acceptInvite} and {@link verifyInvite}. */\nexport interface InviteTokenInput {\n inviteId: string;\n token: string;\n}\n\n/** Input for {@link acceptInvite}. */\nexport interface AcceptInviteInput extends InviteTokenInput {\n password: string;\n}\n\ntype ShelfRow = Record<string, unknown>;\n\n/**\n * CRUD surface invites run on: the top-level adapter or a transaction store\n * (TxStore carries no table handles, so callers attach them). Both\n * DatabaseAdapter and TxStore satisfy this structurally.\n */\nexport type InviteStore = Pick<TxStore, \"insert\" | \"update\" | \"get\" | \"remove\" | \"list\"> & {\n readonly tables: DatabaseAdapter[\"tables\"];\n};\n\n/**\n * Run `fn` inside a transaction. Invites are consume-once state: without\n * driver transactions concurrent accepts race, so transact is required\n * (every production driver offers it) rather than silently sequential.\n */\nasync function withInviteTx<R>(\n db: DatabaseAdapter,\n fn: (store: InviteStore) => Promise<R>,\n): Promise<R> {\n if (!db.transact) {\n throw new Error(\"Shelf invites require a database with transaction support\");\n }\n return await db.transact(async (tx) => await fn({ ...tx, tables: db.tables }));\n}\n\nfunction normalizeEmail(email: string): string {\n return email.trim().toLowerCase();\n}\n\nfunction isEmail(value: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/u.test(value);\n}\n\nfunction inviteError(): Error {\n return new Error(\"Invalid or expired invite\");\n}\n\n/** Resolve a shelf/engine table column, failing fast on schema drift. */\nexport function tableColumn(table: Table, name: string): SQL {\n const column = getTableColumns(table)[name] as SQL | undefined;\n if (!column) {\n throw new Error(`Shelf table is missing column \"${name}\"`);\n }\n return column;\n}\n\nfunction assertPasswordPolicy(password: string): void {\n if (password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\"Password must be at least 12 characters\");\n }\n}\n\nasync function listShelfUsersByEmail(db: InviteStore, email: string): Promise<ShelfRow[]> {\n return await db.list(db.tables.users, {\n where: eq(tableColumn(db.tables.users, \"email\"), normalizeEmail(email)),\n limit: 1,\n });\n}\n\nasync function findEngineUserByEmail(db: InviteStore, email: string): Promise<ShelfRow | null> {\n // Case-insensitive: engine rows may carry provider-given casing while the\n // shelf normalizes (and UNIQUE(email) is case-sensitive, so an exact-only\n // lookup would 500 on a case-variant insert).\n const column = tableColumn(baseAuthTables.user, \"email\");\n const rows = (await db.list(baseAuthTables.user, {\n where: eq(sql`lower(${column})`, email.toLowerCase()),\n limit: 1,\n })) as ShelfRow[];\n return rows[0] ?? null;\n}\n\nasync function findCredentialAccount(db: InviteStore, userId: string): Promise<ShelfRow | null> {\n const rows = (await db.list(baseAuthTables.account, {\n where: eq(tableColumn(baseAuthTables.account, \"userId\"), userId),\n })) as ShelfRow[];\n return rows.find((row) => row[\"providerId\"] === \"credential\") ?? null;\n}\n\n/** Load the invite row, or throw the generic invite error. */\nasync function loadInvite(db: InviteStore, input: InviteTokenInput): Promise<ShelfRow> {\n const invite: ShelfRow | null = await db.get(db.tables.userInviteTokens, input.inviteId);\n if (!invite || invite[\"usedAt\"]) {\n throw inviteError();\n }\n // Fail closed: a corrupt expiresAt parses to NaN, and NaN comparisons\n // are false — without this check the invite would never expire.\n const expires = new Date(String(invite[\"expiresAt\"])).getTime();\n if (Number.isNaN(expires) || expires <= Date.now()) {\n throw inviteError();\n }\n if (!timingSafeEqualString(sha256(input.token), String(invite[\"tokenHash\"]))) {\n throw inviteError();\n }\n return invite;\n}\n\n/** Load the invite plus its shelf user, or throw the generic invite error. */\nasync function assertInviteValid(\n db: InviteStore,\n input: InviteTokenInput,\n): Promise<{ invite: ShelfRow; shelfUser: ShelfRow }> {\n const invite = await loadInvite(db, input);\n const shelfUser: ShelfRow | null = await db.get(db.tables.users, String(invite[\"userId\"]));\n if (!shelfUser || shelfUser[\"disabled\"]) {\n throw inviteError();\n }\n return { invite, shelfUser };\n}\n\n/** Create the credential account row, or refresh the existing password. */\nasync function upsertCredentialAccount(\n db: InviteStore,\n userId: string,\n password: string,\n now: string,\n): Promise<void> {\n const hash = await hashPassword(password);\n const account = await findCredentialAccount(db, userId);\n if (account) {\n await db.update(baseAuthTables.account, String(account[\"id\"]), {\n password: hash,\n updatedAt: now,\n });\n return;\n }\n await db.insert(baseAuthTables.account, {\n id: ulid(),\n userId,\n accountId: userId,\n providerId: \"credential\",\n password: hash,\n createdAt: now,\n updatedAt: now,\n });\n}\n\n/** Create the engine identity rows (shared id), or refresh the credential. */\nexport async function ensureEngineIdentity(\n db: InviteStore,\n shelfUser: ShelfRow,\n password: string,\n): Promise<void> {\n const userId = String(shelfUser[\"id\"]);\n const email = String(shelfUser[\"email\"]);\n const now = new Date().toISOString();\n // Verified-email linking only: an existing engine row takes the credential\n // when its email is verified (same owner). An unverified row (e.g. an OIDC\n // identity with an untrusted email claim) must stay separate — but engine\n // emails are UNIQUE, so no second row can mint: reject and point at SSO.\n const engineUser = await findEngineUserByEmail(db, email);\n if (engineUser && engineUser[\"emailVerified\"] !== true) {\n throw new Error(\n \"Email is registered with an unverified SSO identity; sign in with SSO instead\",\n );\n }\n if (!engineUser) {\n await db.insert(baseAuthTables.user, {\n id: userId,\n name: String(shelfUser[\"name\"]),\n email,\n emailVerified: true,\n image: null,\n createdAt: now,\n updatedAt: now,\n });\n }\n const targetId = engineUser ? String(engineUser[\"id\"]) : userId;\n await upsertCredentialAccount(db, targetId, password, now);\n}\n\nfunction toAuthUser(row: ShelfRow): AuthUser {\n return {\n id: String(row[\"id\"]),\n email: String(row[\"email\"]),\n name: String(row[\"name\"]),\n avatarUrl: (row[\"avatarUrl\"] as string | null) ?? undefined,\n role: row[\"role\"] as AuthUser[\"role\"],\n };\n}\n\nfunction inviteLifetimeMs(expiryMs: number | undefined): number {\n const lifetime = expiryMs ?? DEFAULT_INVITE_MS;\n if (!Number.isFinite(lifetime) || lifetime <= 0 || lifetime > MAX_INVITE_MS) {\n throw new Error(\"Invite lifetime must be between 1ms and 365 days\");\n }\n return lifetime;\n}\n\n/** Refresh name/role on the existing row (re-invite is admin intent). */\nasync function refreshShelfUser(\n db: InviteStore,\n id: string,\n input: IssueInviteInput,\n): Promise<ShelfRow> {\n const updated: ShelfRow = await db.update(db.tables.users, id, {\n name: input.name.trim(),\n role: input.role,\n });\n return updated;\n}\n\nasync function stageShelfUser(db: InviteStore, input: IssueInviteInput): Promise<ShelfRow> {\n const email = normalizeEmail(input.email);\n if (!isEmail(email)) {\n throw new Error(\"Invalid email\");\n }\n if (input.name.trim() === \"\") {\n throw new Error(\"Name is required\");\n }\n const existing = await listShelfUsersByEmail(db, email);\n // Re-invite refreshes name and role: issuing is operator-only, so the\n // latest invite is the current admin intent (and the password flow).\n if (existing[0]) {\n return await refreshShelfUser(db, String(existing[0][\"id\"]), input);\n }\n const now = new Date().toISOString();\n return await db.insert(db.tables.users, {\n id: ulid(),\n email,\n name: input.name.trim(),\n avatarUrl: null,\n role: input.role,\n lastLoginAt: null,\n createdAt: now,\n passwordHash: null,\n displayNameOverride: null,\n authProvider: \"local\",\n disabled: false,\n });\n}\n\nasync function removeUnusedInvite(db: InviteStore, row: ShelfRow): Promise<void> {\n await db.remove(db.tables.userInviteTokens, String(row[\"id\"]));\n}\n\nasync function supersedeUnusedInvites(db: InviteStore, userId: string): Promise<void> {\n const existing = (await db.list(db.tables.userInviteTokens, {\n where: eq(tableColumn(db.tables.userInviteTokens, \"userId\"), userId),\n })) as ShelfRow[];\n await Promise.all(\n existing\n .filter((row) => !row[\"usedAt\"])\n .map(async (row) => {\n await removeUnusedInvite(db, row);\n }),\n );\n}\n\n/** Stage a shelf user and mint a single-use invite token (returned once). */\nexport async function issueInvite(\n db: DatabaseAdapter,\n input: IssueInviteInput,\n): Promise<{ inviteId: string; token: string; expiresAt: string }> {\n return await withInviteTx(db, async (store) => {\n const shelfUser = await stageShelfUser(store, input);\n const userId = String(shelfUser[\"id\"]);\n await supersedeUnusedInvites(store, userId);\n const now = new Date().toISOString();\n const expiresAt = new Date(Date.now() + inviteLifetimeMs(input.inviteExpiryMs)).toISOString();\n const inviteId = ulid();\n const token = randomToken(\"inv_\").value;\n await store.insert(store.tables.userInviteTokens, {\n id: inviteId,\n userId,\n tokenHash: sha256(token),\n expiresAt,\n usedAt: null,\n createdAt: now,\n });\n return { inviteId, token, expiresAt };\n });\n}\n\n/** Check an invite token without consuming it (drives the accept page). */\nexport async function verifyInvite(\n db: DatabaseAdapter,\n input: InviteTokenInput,\n): Promise<{ email: string; name: string }> {\n const { shelfUser } = await assertInviteValid(db, input);\n return { email: String(shelfUser[\"email\"]), name: String(shelfUser[\"name\"]) };\n}\n\n/** Whether an invite row is already spent (busy-downgrade probe). */\nasync function inviteSpent(db: InviteStore, inviteId: string): Promise<boolean> {\n const invite: ShelfRow | null = await db.get(db.tables.userInviteTokens, inviteId);\n return !!invite?.[\"usedAt\"];\n}\n\n/** Consume an invite: set the engine credential and return the shelf user. */\nexport async function acceptInvite(\n db: DatabaseAdapter,\n input: AcceptInviteInput,\n): Promise<AuthUser> {\n assertPasswordPolicy(input.password);\n try {\n return await withInviteTx(db, async (store) => {\n const { invite, shelfUser } = await assertInviteValid(store, input);\n await ensureEngineIdentity(store, shelfUser, input.password);\n const now = new Date().toISOString();\n await store.update(store.tables.users, String(shelfUser[\"id\"]), { lastLoginAt: now });\n await store.update(store.tables.userInviteTokens, String(invite[\"id\"]), { usedAt: now });\n return toAuthUser(shelfUser);\n });\n } catch (error) {\n // A transaction loser (e.g. sqlite's busy connection) surfaces a driver\n // error instead of the invite error; re-probe so callers see the stable\n // \"Invalid or expired invite\" whenever the winner consumed the token.\n if (await inviteSpent(db, input.inviteId)) {\n throw inviteError();\n }\n throw error;\n }\n}\n"],"mappings":";;;;;;;;AAoBA,MAAM,oBAAoB;AAC1B,MAAM,gBAAgB;;AAGtB,MAAa,sBAAsB;;;;;;AAsCnC,eAAe,aACb,IACA,IACY;CACZ,IAAI,CAAC,GAAG,UACN,MAAM,IAAI,MAAM,2DAA2D;CAE7E,OAAO,MAAM,GAAG,SAAS,OAAO,OAAO,MAAM,GAAG;EAAE,GAAG;EAAI,QAAQ,GAAG;CAAO,CAAC,CAAC;AAC/E;AAEA,SAAS,eAAe,OAAuB;CAC7C,OAAO,MAAM,KAAK,CAAC,CAAC,YAAY;AAClC;AAEA,SAAS,QAAQ,OAAwB;CACvC,OAAO,8BAA8B,KAAK,KAAK;AACjD;AAEA,SAAS,cAAqB;CAC5B,uBAAO,IAAI,MAAM,2BAA2B;AAC9C;;AAGA,SAAgB,YAAY,OAAc,MAAmB;CAC3D,MAAM,SAAS,gBAAgB,KAAK,CAAC,CAAC;CACtC,IAAI,CAAC,QACH,MAAM,IAAI,MAAM,kCAAkC,KAAK,EAAE;CAE3D,OAAO;AACT;AAEA,SAAS,qBAAqB,UAAwB;CACpD,IAAI,SAAS,SAAA,IACX,MAAM,IAAI,MAAM,yCAAyC;AAE7D;AAEA,eAAe,sBAAsB,IAAiB,OAAoC;CACxF,OAAO,MAAM,GAAG,KAAK,GAAG,OAAO,OAAO;EACpC,OAAO,GAAG,YAAY,GAAG,OAAO,OAAO,OAAO,GAAG,eAAe,KAAK,CAAC;EACtE,OAAO;CACT,CAAC;AACH;AAEA,eAAe,sBAAsB,IAAiB,OAAyC;CAI7F,MAAM,SAAS,YAAY,eAAe,MAAM,OAAO;CAKvD,QAAO,MAJa,GAAG,KAAK,eAAe,MAAM;EAC/C,OAAO,GAAG,GAAG,SAAS,OAAO,IAAI,MAAM,YAAY,CAAC;EACpD,OAAO;CACT,CAAC,EAAA,CACW,MAAM;AACpB;AAEA,eAAe,sBAAsB,IAAiB,QAA0C;CAI9F,QAAO,MAHa,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC,EAAA,CACW,MAAM,QAAQ,IAAI,kBAAkB,YAAY,KAAK;AACnE;;AAGA,eAAe,WAAW,IAAiB,OAA4C;CACrF,MAAM,SAA0B,MAAM,GAAG,IAAI,GAAG,OAAO,kBAAkB,MAAM,QAAQ;CACvF,IAAI,CAAC,UAAU,OAAO,WACpB,MAAM,YAAY;CAIpB,MAAM,UAAU,IAAI,KAAK,OAAO,OAAO,YAAY,CAAC,CAAC,CAAC,QAAQ;CAC9D,IAAI,OAAO,MAAM,OAAO,KAAK,WAAW,KAAK,IAAI,GAC/C,MAAM,YAAY;CAEpB,IAAI,CAAC,sBAAsB,OAAO,MAAM,KAAK,GAAG,OAAO,OAAO,YAAY,CAAC,GACzE,MAAM,YAAY;CAEpB,OAAO;AACT;;AAGA,eAAe,kBACb,IACA,OACoD;CACpD,MAAM,SAAS,MAAM,WAAW,IAAI,KAAK;CACzC,MAAM,YAA6B,MAAM,GAAG,IAAI,GAAG,OAAO,OAAO,OAAO,OAAO,SAAS,CAAC;CACzF,IAAI,CAAC,aAAa,UAAU,aAC1B,MAAM,YAAY;CAEpB,OAAO;EAAE;EAAQ;CAAU;AAC7B;;AAGA,eAAe,wBACb,IACA,QACA,UACA,KACe;CACf,MAAM,OAAO,MAAM,aAAa,QAAQ;CACxC,MAAM,UAAU,MAAM,sBAAsB,IAAI,MAAM;CACtD,IAAI,SAAS;EACX,MAAM,GAAG,OAAO,eAAe,SAAS,OAAO,QAAQ,KAAK,GAAG;GAC7D,UAAU;GACV,WAAW;EACb,CAAC;EACD;CACF;CACA,MAAM,GAAG,OAAO,eAAe,SAAS;EACtC,IAAI,KAAK;EACT;EACA,WAAW;EACX,YAAY;EACZ,UAAU;EACV,WAAW;EACX,WAAW;CACb,CAAC;AACH;;AAGA,eAAsB,qBACpB,IACA,WACA,UACe;CACf,MAAM,SAAS,OAAO,UAAU,KAAK;CACrC,MAAM,QAAQ,OAAO,UAAU,QAAQ;CACvC,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;CAKnC,MAAM,aAAa,MAAM,sBAAsB,IAAI,KAAK;CACxD,IAAI,cAAc,WAAW,qBAAqB,MAChD,MAAM,IAAI,MACR,+EACF;CAEF,IAAI,CAAC,YACH,MAAM,GAAG,OAAO,eAAe,MAAM;EACnC,IAAI;EACJ,MAAM,OAAO,UAAU,OAAO;EAC9B;EACA,eAAe;EACf,OAAO;EACP,WAAW;EACX,WAAW;CACb,CAAC;CAGH,MAAM,wBAAwB,IADb,aAAa,OAAO,WAAW,KAAK,IAAI,QACb,UAAU,GAAG;AAC3D;AAEA,SAAS,WAAW,KAAyB;CAC3C,OAAO;EACL,IAAI,OAAO,IAAI,KAAK;EACpB,OAAO,OAAO,IAAI,QAAQ;EAC1B,MAAM,OAAO,IAAI,OAAO;EACxB,WAAY,IAAI,gBAAkC,KAAA;EAClD,MAAM,IAAI;CACZ;AACF;AAEA,SAAS,iBAAiB,UAAsC;CAC9D,MAAM,WAAW,YAAY;CAC7B,IAAI,CAAC,OAAO,SAAS,QAAQ,KAAK,YAAY,KAAK,WAAW,eAC5D,MAAM,IAAI,MAAM,kDAAkD;CAEpE,OAAO;AACT;;AAGA,eAAe,iBACb,IACA,IACA,OACmB;CAKnB,OAAO,MAJyB,GAAG,OAAO,GAAG,OAAO,OAAO,IAAI;EAC7D,MAAM,MAAM,KAAK,KAAK;EACtB,MAAM,MAAM;CACd,CAAC;AAEH;AAEA,eAAe,eAAe,IAAiB,OAA4C;CACzF,MAAM,QAAQ,eAAe,MAAM,KAAK;CACxC,IAAI,CAAC,QAAQ,KAAK,GAChB,MAAM,IAAI,MAAM,eAAe;CAEjC,IAAI,MAAM,KAAK,KAAK,MAAM,IACxB,MAAM,IAAI,MAAM,kBAAkB;CAEpC,MAAM,WAAW,MAAM,sBAAsB,IAAI,KAAK;CAGtD,IAAI,SAAS,IACX,OAAO,MAAM,iBAAiB,IAAI,OAAO,SAAS,EAAE,CAAC,KAAK,GAAG,KAAK;CAEpE,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;CACnC,OAAO,MAAM,GAAG,OAAO,GAAG,OAAO,OAAO;EACtC,IAAI,KAAK;EACT;EACA,MAAM,MAAM,KAAK,KAAK;EACtB,WAAW;EACX,MAAM,MAAM;EACZ,aAAa;EACb,WAAW;EACX,cAAc;EACd,qBAAqB;EACrB,cAAc;EACd,UAAU;CACZ,CAAC;AACH;AAEA,eAAe,mBAAmB,IAAiB,KAA8B;CAC/E,MAAM,GAAG,OAAO,GAAG,OAAO,kBAAkB,OAAO,IAAI,KAAK,CAAC;AAC/D;AAEA,eAAe,uBAAuB,IAAiB,QAA+B;CACpF,MAAM,WAAY,MAAM,GAAG,KAAK,GAAG,OAAO,kBAAkB,EAC1D,OAAO,GAAG,YAAY,GAAG,OAAO,kBAAkB,QAAQ,GAAG,MAAM,EACrE,CAAC;CACD,MAAM,QAAQ,IACZ,SACG,QAAQ,QAAQ,CAAC,IAAI,SAAS,CAAC,CAC/B,IAAI,OAAO,QAAQ;EAClB,MAAM,mBAAmB,IAAI,GAAG;CAClC,CAAC,CACL;AACF;;AAGA,eAAsB,YACpB,IACA,OACiE;CACjE,OAAO,MAAM,aAAa,IAAI,OAAO,UAAU;EAC7C,MAAM,YAAY,MAAM,eAAe,OAAO,KAAK;EACnD,MAAM,SAAS,OAAO,UAAU,KAAK;EACrC,MAAM,uBAAuB,OAAO,MAAM;EAC1C,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;EACnC,MAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,iBAAiB,MAAM,cAAc,CAAC,CAAC,CAAC,YAAY;EAC5F,MAAM,WAAW,KAAK;EACtB,MAAM,QAAQ,YAAY,MAAM,CAAC,CAAC;EAClC,MAAM,MAAM,OAAO,MAAM,OAAO,kBAAkB;GAChD,IAAI;GACJ;GACA,WAAW,OAAO,KAAK;GACvB;GACA,QAAQ;GACR,WAAW;EACb,CAAC;EACD,OAAO;GAAE;GAAU;GAAO;EAAU;CACtC,CAAC;AACH;;AAGA,eAAsB,aACpB,IACA,OAC0C;CAC1C,MAAM,EAAE,cAAc,MAAM,kBAAkB,IAAI,KAAK;CACvD,OAAO;EAAE,OAAO,OAAO,UAAU,QAAQ;EAAG,MAAM,OAAO,UAAU,OAAO;CAAE;AAC9E;;AAGA,eAAe,YAAY,IAAiB,UAAoC;CAE9E,OAAO,CAAC,EAAC,MAD6B,GAAG,IAAI,GAAG,OAAO,kBAAkB,QAAQ,EAAA,GAC/D;AACpB;;AAGA,eAAsB,aACpB,IACA,OACmB;CACnB,qBAAqB,MAAM,QAAQ;CACnC,IAAI;EACF,OAAO,MAAM,aAAa,IAAI,OAAO,UAAU;GAC7C,MAAM,EAAE,QAAQ,cAAc,MAAM,kBAAkB,OAAO,KAAK;GAClE,MAAM,qBAAqB,OAAO,WAAW,MAAM,QAAQ;GAC3D,MAAM,uBAAM,IAAI,KAAK,EAAA,CAAE,YAAY;GACnC,MAAM,MAAM,OAAO,MAAM,OAAO,OAAO,OAAO,UAAU,KAAK,GAAG,EAAE,aAAa,IAAI,CAAC;GACpF,MAAM,MAAM,OAAO,MAAM,OAAO,kBAAkB,OAAO,OAAO,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;GACvF,OAAO,WAAW,SAAS;EAC7B,CAAC;CACH,SAAS,OAAO;EAId,IAAI,MAAM,YAAY,IAAI,MAAM,QAAQ,GACtC,MAAM,YAAY;EAEpB,MAAM;CACR;AACF"}
@@ -0,0 +1,21 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { DatabaseAdapter } from "@storyshelf/core/adapter/database";
4
+ //#region src/passkeys.d.ts
5
+ /** One registered passkey for the profile page. */
6
+ interface EnginePasskeyInfo {
7
+ id: string;
8
+ name: string | null;
9
+ deviceType: string;
10
+ backedUp: boolean;
11
+ transports: string | null;
12
+ aaguid: string | null;
13
+ createdAt: string;
14
+ }
15
+ /** Registered passkeys for a user, oldest first. */
16
+ declare function listUserPasskeys(db: DatabaseAdapter, userId: string): Promise<EnginePasskeyInfo[]>;
17
+ /** Whether the user can change a local password (has a credential account). */
18
+ declare function hasPasswordCredential(db: DatabaseAdapter, userId: string): Promise<boolean>;
19
+ //#endregion
20
+ export { EnginePasskeyInfo, hasPasswordCredential, listUserPasskeys };
21
+ //# sourceMappingURL=passkeys.d.mts.map
@@ -0,0 +1,27 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { baseAuthTables } from "./auth-tables.mjs";
5
+ import { tableColumn } from "./invites.mjs";
6
+ import { eq } from "drizzle-orm";
7
+ //#region src/passkeys.ts
8
+ /** Registered passkeys for a user, oldest first. */
9
+ async function listUserPasskeys(db, userId) {
10
+ return (await db.list(baseAuthTables.passkey, { where: eq(tableColumn(baseAuthTables.passkey, "userId"), userId) })).map((row) => ({
11
+ id: String(row["id"]),
12
+ name: row["name"] ?? null,
13
+ deviceType: typeof row["deviceType"] === "string" ? row["deviceType"] : "unknown",
14
+ backedUp: row["backedUp"] === true,
15
+ transports: row["transports"] ?? null,
16
+ aaguid: row["aaguid"] ?? null,
17
+ createdAt: String(row["createdAt"])
18
+ })).toSorted((a, b) => a.createdAt < b.createdAt ? -1 : 1);
19
+ }
20
+ /** Whether the user can change a local password (has a credential account). */
21
+ async function hasPasswordCredential(db, userId) {
22
+ return (await db.list(baseAuthTables.account, { where: eq(tableColumn(baseAuthTables.account, "userId"), userId) })).some((row) => row["providerId"] === "credential");
23
+ }
24
+ //#endregion
25
+ export { hasPasswordCredential, listUserPasskeys };
26
+
27
+ //# sourceMappingURL=passkeys.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passkeys.mjs","names":[],"sources":["../src/passkeys.ts"],"sourcesContent":["/**\n * Shelf-side passkey inventory plus the local-credential flag.\n *\n * The profile page needs both to decide which security sections to render:\n * the key list (with the \"register a 2nd key\" nudge) and the password form\n * (only when a `credential` account exists — social-only users have none).\n * Registration and deletion go through the passkey plugin endpoints;\n * deletion is ownership-scoped server-side.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport { eq } from \"drizzle-orm\";\nimport { baseAuthTables } from \"./auth-tables.ts\";\nimport { tableColumn } from \"./invites.ts\";\n\n/** One registered passkey for the profile page. */\nexport interface EnginePasskeyInfo {\n id: string;\n name: string | null;\n deviceType: string;\n backedUp: boolean;\n transports: string | null;\n aaguid: string | null;\n createdAt: string;\n}\n\n/** Registered passkeys for a user, oldest first. */\nexport async function listUserPasskeys(\n db: DatabaseAdapter,\n userId: string,\n): Promise<EnginePasskeyInfo[]> {\n const rows = (await db.list(baseAuthTables.passkey, {\n where: eq(tableColumn(baseAuthTables.passkey, \"userId\"), userId),\n })) as Record<string, unknown>[];\n return rows\n .map((row) => ({\n id: String(row[\"id\"]),\n name: (row[\"name\"] as string | null) ?? null,\n deviceType: typeof row[\"deviceType\"] === \"string\" ? row[\"deviceType\"] : \"unknown\",\n backedUp: row[\"backedUp\"] === true,\n transports: (row[\"transports\"] as string | null) ?? null,\n aaguid: (row[\"aaguid\"] as string | null) ?? null,\n createdAt: String(row[\"createdAt\"]),\n }))\n .toSorted((a, b) => (a.createdAt < b.createdAt ? -1 : 1));\n}\n\n/** Whether the user can change a local password (has a credential account). */\nexport async function hasPasswordCredential(db: DatabaseAdapter, userId: string): Promise<boolean> {\n const rows = (await db.list(baseAuthTables.account, {\n where: eq(tableColumn(baseAuthTables.account, \"userId\"), userId),\n })) as Record<string, unknown>[];\n return rows.some((row) => row[\"providerId\"] === \"credential\");\n}\n"],"mappings":";;;;;;;;AA0BA,eAAsB,iBACpB,IACA,QAC8B;CAI9B,QAAO,MAHa,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC,EAAA,CAEE,KAAK,SAAS;EACb,IAAI,OAAO,IAAI,KAAK;EACpB,MAAO,IAAI,WAA6B;EACxC,YAAY,OAAO,IAAI,kBAAkB,WAAW,IAAI,gBAAgB;EACxE,UAAU,IAAI,gBAAgB;EAC9B,YAAa,IAAI,iBAAmC;EACpD,QAAS,IAAI,aAA+B;EAC5C,WAAW,OAAO,IAAI,YAAY;CACpC,EAAE,CAAC,CACF,UAAU,GAAG,MAAO,EAAE,YAAY,EAAE,YAAY,KAAK,CAAE;AAC5D;;AAGA,eAAsB,sBAAsB,IAAqB,QAAkC;CAIjG,QAAO,MAHa,GAAG,KAAK,eAAe,SAAS,EAClD,OAAO,GAAG,YAAY,eAAe,SAAS,QAAQ,GAAG,MAAM,EACjE,CAAC,EAAA,CACW,MAAM,QAAQ,IAAI,kBAAkB,YAAY;AAC9D"}
@@ -0,0 +1,24 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { ShelfOAuthProvider } from "../engine.mjs";
4
+ //#region src/presets/enterprise.d.ts
5
+ /** Client credentials shared by the enterprise presets. */
6
+ interface EnterpriseClientOptions {
7
+ clientId: string;
8
+ clientSecret?: string;
9
+ label?: string;
10
+ }
11
+ /** Keycloak realm preset (discovery over the realm URL). */
12
+ declare function keycloakPreset(realmUrl: string, options: EnterpriseClientOptions): ShelfOAuthProvider;
13
+ /** Okta preset (custom authorization server, explicit endpoints). */
14
+ declare function oktaPreset(options: EnterpriseClientOptions & {
15
+ domain: string;
16
+ authorizationServerId: string;
17
+ }): ShelfOAuthProvider;
18
+ /** Auth0 preset (explicit endpoints; groups need a tenant Action claim). */
19
+ declare function auth0Preset(options: EnterpriseClientOptions & {
20
+ domain: string;
21
+ }): ShelfOAuthProvider;
22
+ //#endregion
23
+ export { EnterpriseClientOptions, auth0Preset, keycloakPreset, oktaPreset };
24
+ //# sourceMappingURL=enterprise.d.mts.map
@@ -0,0 +1,44 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ //#region src/presets/enterprise.ts
5
+ /** Keycloak realm preset (discovery over the realm URL). */
6
+ function keycloakPreset(realmUrl, options) {
7
+ return {
8
+ id: "keycloak",
9
+ label: options.label ?? "Keycloak",
10
+ clientId: options.clientId,
11
+ clientSecret: options.clientSecret,
12
+ discoveryUrl: `${realmUrl.replace(/\/+$/u, "")}/.well-known/openid-configuration`
13
+ };
14
+ }
15
+ /** Okta preset (custom authorization server, explicit endpoints). */
16
+ function oktaPreset(options) {
17
+ const issuer = `https://${options.domain}/oauth2/${options.authorizationServerId}`;
18
+ return {
19
+ id: "okta",
20
+ label: options.label ?? "Okta",
21
+ clientId: options.clientId,
22
+ clientSecret: options.clientSecret,
23
+ authorizationUrl: `${issuer}/v1/authorize`,
24
+ tokenUrl: `${issuer}/v1/token`,
25
+ userInfoUrl: `${issuer}/v1/userinfo`
26
+ };
27
+ }
28
+ /** Auth0 preset (explicit endpoints; groups need a tenant Action claim). */
29
+ function auth0Preset(options) {
30
+ const issuer = `https://${options.domain}/`;
31
+ return {
32
+ id: "auth0",
33
+ label: options.label ?? "Auth0",
34
+ clientId: options.clientId,
35
+ clientSecret: options.clientSecret,
36
+ authorizationUrl: `${issuer}authorize`,
37
+ tokenUrl: `${issuer}oauth/token`,
38
+ userInfoUrl: `${issuer}userinfo`
39
+ };
40
+ }
41
+ //#endregion
42
+ export { auth0Preset, keycloakPreset, oktaPreset };
43
+
44
+ //# sourceMappingURL=enterprise.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enterprise.mjs","names":[],"sources":["../../src/presets/enterprise.ts"],"sourcesContent":["/**\n * Enterprise OIDC recipes: generic-OAuth configs for providers without a\n * Better Auth native integration.\n *\n * Each preset returns a {@link ShelfOAuthProvider} for\n * `createShelfAuth({ oauth })`. Endpoint construction mirrors the retired\n * `auth-oauth` presets so existing issuer URLs keep working; Keycloak\n * prefers discovery, Okta/Auth0 pin explicit endpoints.\n */\nimport type { ShelfOAuthProvider } from \"../engine.ts\";\n\n/** Client credentials shared by the enterprise presets. */\nexport interface EnterpriseClientOptions {\n clientId: string;\n clientSecret?: string;\n label?: string;\n}\n\n/** Keycloak realm preset (discovery over the realm URL). */\nexport function keycloakPreset(\n realmUrl: string,\n options: EnterpriseClientOptions,\n): ShelfOAuthProvider {\n return {\n id: \"keycloak\",\n label: options.label ?? \"Keycloak\",\n clientId: options.clientId,\n clientSecret: options.clientSecret,\n discoveryUrl: `${realmUrl.replace(/\\/+$/u, \"\")}/.well-known/openid-configuration`,\n };\n}\n\n/** Okta preset (custom authorization server, explicit endpoints). */\nexport function oktaPreset(\n options: EnterpriseClientOptions & { domain: string; authorizationServerId: string },\n): ShelfOAuthProvider {\n const issuer = `https://${options.domain}/oauth2/${options.authorizationServerId}`;\n return {\n id: \"okta\",\n label: options.label ?? \"Okta\",\n clientId: options.clientId,\n clientSecret: options.clientSecret,\n authorizationUrl: `${issuer}/v1/authorize`,\n tokenUrl: `${issuer}/v1/token`,\n userInfoUrl: `${issuer}/v1/userinfo`,\n };\n}\n\n/** Auth0 preset (explicit endpoints; groups need a tenant Action claim). */\nexport function auth0Preset(\n options: EnterpriseClientOptions & { domain: string },\n): ShelfOAuthProvider {\n const issuer = `https://${options.domain}/`;\n return {\n id: \"auth0\",\n label: options.label ?? \"Auth0\",\n clientId: options.clientId,\n clientSecret: options.clientSecret,\n authorizationUrl: `${issuer}authorize`,\n tokenUrl: `${issuer}oauth/token`,\n userInfoUrl: `${issuer}userinfo`,\n };\n}\n"],"mappings":";;;;;AAmBA,SAAgB,eACd,UACA,SACoB;CACpB,OAAO;EACL,IAAI;EACJ,OAAO,QAAQ,SAAS;EACxB,UAAU,QAAQ;EAClB,cAAc,QAAQ;EACtB,cAAc,GAAG,SAAS,QAAQ,SAAS,EAAE,EAAE;CACjD;AACF;;AAGA,SAAgB,WACd,SACoB;CACpB,MAAM,SAAS,WAAW,QAAQ,OAAO,UAAU,QAAQ;CAC3D,OAAO;EACL,IAAI;EACJ,OAAO,QAAQ,SAAS;EACxB,UAAU,QAAQ;EAClB,cAAc,QAAQ;EACtB,kBAAkB,GAAG,OAAO;EAC5B,UAAU,GAAG,OAAO;EACpB,aAAa,GAAG,OAAO;CACzB;AACF;;AAGA,SAAgB,YACd,SACoB;CACpB,MAAM,SAAS,WAAW,QAAQ,OAAO;CACzC,OAAO;EACL,IAAI;EACJ,OAAO,QAAQ,SAAS;EACxB,UAAU,QAAQ;EAClB,cAAc,QAAQ;EACtB,kBAAkB,GAAG,OAAO;EAC5B,UAAU,GAAG,OAAO;EACpB,aAAa,GAAG,OAAO;CACzB;AACF"}
@@ -0,0 +1,5 @@
1
+ import __tsdown_shims_path from 'node:path';
2
+ import __tsdown_shims_url from 'node:url';
3
+ import { EnterpriseClientOptions, auth0Preset, keycloakPreset, oktaPreset } from "./enterprise.mjs";
4
+ import { SocialClientOptions, cognitoPreset, entraPreset, githubPreset, gitlabPreset, googlePreset } from "./social.mjs";
5
+ export { type EnterpriseClientOptions, type SocialClientOptions, auth0Preset, cognitoPreset, entraPreset, githubPreset, gitlabPreset, googlePreset, keycloakPreset, oktaPreset };