@headless-lms/adapter-auth 0.1.0 → 0.2.0

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.
@@ -1 +1 @@
1
- {"version":3,"file":"access.js","names":[],"sources":["../src/access.ts"],"sourcesContent":["// Better Auth org-plugin access control. Better Auth is the system of record for\n// roles; these four roles mirror the domain Role union (core/organizations/roles).\nimport { createAccessControl } from 'better-auth/plugins/access';\nimport { defaultStatements, ownerAc, adminAc } from 'better-auth/plugins/organization/access';\n\nexport const statement = {\n ...defaultStatements,\n course: ['create', 'read', 'update', 'delete'],\n progress: ['view'],\n} as const;\n\nconst ac = createAccessControl(statement);\n\nexport const roles = {\n owner: ac.newRole({\n ...ownerAc.statements,\n course: ['create', 'read', 'update', 'delete'],\n progress: ['view'],\n }),\n admin: ac.newRole({\n ...adminAc.statements,\n course: ['create', 'read', 'update', 'delete'],\n progress: ['view'],\n }),\n instructor: ac.newRole({\n course: ['read', 'update'],\n progress: ['view'],\n }),\n student: ac.newRole({\n course: ['read'],\n }),\n};\n\nexport { ac };\n"],"mappings":";;;AAKA,MAAa,YAAY;CACvB,GAAG;CACH,QAAQ;EAAC;EAAU;EAAQ;EAAU;CAAQ;CAC7C,UAAU,CAAC,MAAM;AACnB;AAEA,MAAM,KAAK,oBAAoB,SAAS;AAExC,MAAa,QAAQ;CACnB,OAAO,GAAG,QAAQ;EAChB,GAAG,QAAQ;EACX,QAAQ;GAAC;GAAU;GAAQ;GAAU;EAAQ;EAC7C,UAAU,CAAC,MAAM;CACnB,CAAC;CACD,OAAO,GAAG,QAAQ;EAChB,GAAG,QAAQ;EACX,QAAQ;GAAC;GAAU;GAAQ;GAAU;EAAQ;EAC7C,UAAU,CAAC,MAAM;CACnB,CAAC;CACD,YAAY,GAAG,QAAQ;EACrB,QAAQ,CAAC,QAAQ,QAAQ;EACzB,UAAU,CAAC,MAAM;CACnB,CAAC;CACD,SAAS,GAAG,QAAQ,EAClB,QAAQ,CAAC,MAAM,EACjB,CAAC;AACH"}
1
+ {"version":3,"file":"access.js","names":[],"sources":["../src/access.ts"],"sourcesContent":["// Better Auth org-plugin access control. Better Auth is the system of record for\n// roles; these four roles mirror the domain Role union (core/organizations/roles).\nimport { createAccessControl } from \"better-auth/plugins/access\";\nimport { defaultStatements, ownerAc, adminAc } from \"better-auth/plugins/organization/access\";\n\nexport const statement = {\n ...defaultStatements,\n course: [\"create\", \"read\", \"update\", \"delete\"],\n progress: [\"view\"],\n} as const;\n\nconst ac = createAccessControl(statement);\n\nexport const roles = {\n owner: ac.newRole({\n ...ownerAc.statements,\n course: [\"create\", \"read\", \"update\", \"delete\"],\n progress: [\"view\"],\n }),\n admin: ac.newRole({\n ...adminAc.statements,\n course: [\"create\", \"read\", \"update\", \"delete\"],\n progress: [\"view\"],\n }),\n instructor: ac.newRole({\n course: [\"read\", \"update\"],\n progress: [\"view\"],\n }),\n student: ac.newRole({\n course: [\"read\"],\n }),\n};\n\nexport { ac };\n"],"mappings":";;;AAKA,MAAa,YAAY;CACvB,GAAG;CACH,QAAQ;EAAC;EAAU;EAAQ;EAAU;CAAQ;CAC7C,UAAU,CAAC,MAAM;AACnB;AAEA,MAAM,KAAK,oBAAoB,SAAS;AAExC,MAAa,QAAQ;CACnB,OAAO,GAAG,QAAQ;EAChB,GAAG,QAAQ;EACX,QAAQ;GAAC;GAAU;GAAQ;GAAU;EAAQ;EAC7C,UAAU,CAAC,MAAM;CACnB,CAAC;CACD,OAAO,GAAG,QAAQ;EAChB,GAAG,QAAQ;EACX,QAAQ;GAAC;GAAU;GAAQ;GAAU;EAAQ;EAC7C,UAAU,CAAC,MAAM;CACnB,CAAC;CACD,YAAY,GAAG,QAAQ;EACrB,QAAQ,CAAC,QAAQ,QAAQ;EACzB,UAAU,CAAC,MAAM;CACnB,CAAC;CACD,SAAS,GAAG,QAAQ,EAClB,QAAQ,CAAC,MAAM,EACjB,CAAC;AACH"}
@@ -33,10 +33,30 @@ declare function createAuth(opts: CreateAuthOptions): import("better-auth").Auth
33
33
  emailAndPassword: {
34
34
  enabled: true;
35
35
  sendResetPassword: (data: {
36
- user: import("better-auth").User;
36
+ user: {
37
+ id: string;
38
+ createdAt: Date;
39
+ updatedAt: Date;
40
+ email: string;
41
+ emailVerified: boolean;
42
+ name: string;
43
+ image?: string | null | undefined;
44
+ };
37
45
  url: string;
38
46
  token: string;
39
- }, request?: Request) => Promise<void>;
47
+ }, request?: Request | undefined) => Promise<void>;
48
+ onPasswordReset: (data: {
49
+ user: {
50
+ id: string;
51
+ createdAt: Date;
52
+ updatedAt: Date;
53
+ email: string;
54
+ emailVerified: boolean;
55
+ name: string;
56
+ image?: string | null | undefined;
57
+ };
58
+ }, request?: Request | undefined) => Promise<void>;
59
+ revokeSessionsOnPasswordReset: true;
40
60
  };
41
61
  plugins: [{
42
62
  id: "magic-link";
@@ -9,7 +9,10 @@ import { eq } from "drizzle-orm";
9
9
  import { OrganizationRuleError, parseRole } from "@headless-lms/core/organizations";
10
10
  //#region src/better-auth.ts
11
11
  function createAuth(opts) {
12
- const { sendResetPassword, sendMagicLink, beforeUserCreate, beforeCreateSession } = opts.hooks;
12
+ const { sendResetPassword, onPasswordReset, sendMagicLink, beforeUserCreate, beforeCreateSession } = opts.hooks;
13
+ const detached = (name, hook) => async (...args) => {
14
+ hook(...args).catch((err) => opts.logger.error(`auth hook ${name} failed`, { err }));
15
+ };
13
16
  return betterAuth({
14
17
  baseURL: opts.baseUrl,
15
18
  secret: opts.secret,
@@ -36,7 +39,9 @@ function createAuth(opts) {
36
39
  }),
37
40
  emailAndPassword: {
38
41
  enabled: true,
39
- sendResetPassword
42
+ sendResetPassword: detached("sendResetPassword", sendResetPassword),
43
+ onPasswordReset: detached("onPasswordReset", onPasswordReset),
44
+ revokeSessionsOnPasswordReset: true
40
45
  },
41
46
  plugins: [
42
47
  magicLink({
@@ -174,11 +179,16 @@ var BetterAuth = class {
174
179
  }
175
180
  }
176
181
  async grantMembership(orgExternalId, userExternalId, role) {
177
- await this.auth.api.addMember({ body: {
178
- userId: userExternalId,
179
- organizationId: orgExternalId,
180
- role: parseRole(role)
181
- } });
182
+ try {
183
+ await this.auth.api.addMember({ body: {
184
+ userId: userExternalId,
185
+ organizationId: orgExternalId,
186
+ role: parseRole(role)
187
+ } });
188
+ } catch (err) {
189
+ if (err instanceof APIError && err.body?.code === "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION") return;
190
+ throw err;
191
+ }
182
192
  }
183
193
  async updateRole(ctx, userExternalId, role) {
184
194
  await this.auth.api.updateMemberRole({
@@ -1 +1 @@
1
- {"version":3,"file":"better-auth.js","names":[],"sources":["../src/better-auth.ts"],"sourcesContent":["import type {\n ActiveSession,\n Logger, NewOrganizationInput,\n SessionVerifier,\n} from '@headless-lms/core/types';\nimport type { IncomingHttpHeaders } from 'node:http';\nimport { fromNodeHeaders } from 'better-auth/node';\nimport { APIError, betterAuth } from 'better-auth';\nimport type { CreateAuthOptions } from './types.js';\nimport { drizzleAdapter } from 'better-auth/adapters/drizzle';\nimport * as authSchema from '@headless-lms/adapter-db/schema/better-auth';\nimport { prefixId } from '@headless-lms/core/shared/id';\nimport { customSession, magicLink, organization } from 'better-auth/plugins';\nimport { eq } from 'drizzle-orm';\nimport { ac, roles } from './access.js';\nimport { type AuthHeaders, type AuthOrganization, type MemberWriteContext, type OrgAdmin, OrganizationRuleError, parseRole, type Role, type UpdateOrganizationInput } from '@headless-lms/core/organizations';\nimport type { SessionAdmin } from '@headless-lms/core/identity';\n\nexport function createAuth(opts: CreateAuthOptions) {\n const { sendResetPassword, sendMagicLink, beforeUserCreate, beforeCreateSession } = opts.hooks;\n\n const auth = betterAuth({\n baseURL: opts.baseUrl,\n secret: opts.secret,\n trustedOrigins: opts.trustedOrigins,\n session: {\n // Signed short-lived cookie cache: avoids a Postgres session lookup on\n // every request. The BFF verifies the session per request (each API call\n // + every SSR getSession), so without this each one is a DB round-trip.\n // The cache holds for maxAge; sign-out / expiry still invalidate it.\n cookieCache: {\n enabled: true,\n maxAge: 5 * 60,\n },\n },\n advanced: {\n database: {\n // Prefixed, KSUID-bodied ids for every better-auth table (usr_, org_, …).\n generateId: () => prefixId('id'),\n },\n // Cross-subdomain shared session cookie for admin/api/web on one parent\n // domain (e.g. `.example.com` in prod). Left unset in local dev so the\n // host-only `localhost` cookie is used, which is already shared across\n // ports (cookies are not port-scoped).\n crossSubDomainCookies: {\n enabled: true,\n domain: opts.cookieDomain || undefined,\n },\n // Same-site cookie for the shared-parent-domain plan. Only switch to\n // `sameSite: \"none\"` + `secure` if admin and api are genuinely cross-site\n // (different registrable domains).\n defaultCookieAttributes: {\n sameSite: 'lax',\n secure: opts.secureCookies ?? false,\n httpOnly: true,\n },\n },\n database: drizzleAdapter(opts.db, {\n provider: 'pg',\n schema: authSchema,\n }),\n emailAndPassword: {\n enabled: true,\n sendResetPassword,\n },\n plugins: [\n magicLink({\n disableSignUp: true,\n sendMagicLink,\n }),\n organization({\n ac,\n roles,\n creatorRole: 'owner',\n organizationHooks: opts.hooks,\n }),\n // Enrich get-session with the caller's memberships (role + org) so BFFs\n // resolve auth in one call instead of fanning out to get-active-member /\n // get-full-organization / organization/list. Custom fields bypass the\n // cookie cache, so this costs one indexed query per get-session.\n customSession(async ({ user, session }) => {\n const memberships = await opts.db\n .select({\n role: authSchema.member.role,\n id: authSchema.organization.id,\n name: authSchema.organization.name,\n slug: authSchema.organization.slug,\n })\n .from(authSchema.member)\n .innerJoin(authSchema.organization, eq(authSchema.member.organizationId, authSchema.organization.id))\n .where(eq(authSchema.member.userId, user.id));\n const activeOrgId =\n (session as { activeOrganizationId?: string | null }).activeOrganizationId ?? null;\n const active = memberships.find((m) => m.id === activeOrgId) ?? null;\n return {\n user,\n session,\n activeMemberRole: active?.role ?? null,\n activeOrganization: active ? { id: active.id, name: active.name, slug: active.slug } : null,\n organizations: memberships.map(({ id, name, slug }) => ({ id, name, slug })),\n };\n }),\n ],\n databaseHooks: {\n user: {\n create: {\n before: beforeUserCreate,\n },\n },\n session: {\n create: {\n before: beforeCreateSession,\n },\n },\n },\n });\n return auth;\n}\n\nexport type Auth = ReturnType<typeof createAuth>;\n\nexport class BetterAuth implements SessionVerifier, OrgAdmin, SessionAdmin {\n protected auth: Auth;\n protected logger: Logger;\n\n constructor(opts: CreateAuthOptions) {\n this.auth = createAuth(opts);\n this.logger = opts.logger;\n }\n\n async handler(request: Request) :Promise<Response>{\n return this.auth.handler(request);\n }\n\n async verify(headers: IncomingHttpHeaders): Promise<ActiveSession | null> {\n const res = await this.auth.api.getSession({ headers: fromNodeHeaders(headers) });\n if (!res) {\n return null;\n }\n return {\n user: {\n id: res.user.id,\n createdAt: res.user.createdAt,\n email: res.user.email,\n firstName: res.user.name,\n externalId: res.user.id,\n lastName: res.user.name,\n updatedAt: res.user.updatedAt,\n },\n session: {\n // customSession erases the org plugin's session extension from the\n // inferred type; the field is still present at runtime.\n activeOrganizationId:\n (res.session as { activeOrganizationId?: string | null }).activeOrganizationId ?? null,\n },\n };\n }\n\n\n /** Stamps the org onto the caller's session row. The 5-minute cookie cache\n * still holds the pre-stamp session, so callers refresh it client-side with\n * `getSession({ disableCookieCache: true })`. */\n async stampActiveOrganization(headers: AuthHeaders, orgId: string): Promise<boolean> {\n const session = await this.auth.api.getSession({ headers: fromNodeHeaders(headers) });\n const token = (session?.session as { token?: string } | undefined)?.token;\n if (!token) {\n return false;\n }\n const ctx = await this.auth.$context;\n await ctx.internalAdapter.updateSession(token, { activeOrganizationId: orgId });\n return true;\n }\n\n async createOrganization(\n headers: AuthHeaders,\n input: NewOrganizationInput,\n ): Promise<AuthOrganization> {\n const org = await this.auth.api.createOrganization({\n body: { name: input.name, slug: input.slug },\n headers: fromNodeHeaders(headers),\n });\n if (!org) {\n throw new Error(`Couldn't create organization`);\n }\n return { id: org.id, name: org.name, slug: org.slug, createdAt: org.createdAt };\n }\n async setActiveOrganization(headers: AuthHeaders, externalId: string): Promise<void> {\n await this.auth.api.setActiveOrganization({\n body: { organizationId: externalId },\n headers: fromNodeHeaders(headers),\n });\n }\n async updateOrganization(\n headers: AuthHeaders,\n externalId: string,\n input: UpdateOrganizationInput,\n ): Promise<AuthOrganization> {\n try {\n const org = await this.auth.api.updateOrganization({\n body: { organizationId: externalId, data: { name: input.name, slug: input.slug } },\n headers: fromNodeHeaders(headers),\n });\n if (!org) {\n throw new Error(`Couldn't update organization`);\n }\n return { id: org.id, name: org.name, slug: org.slug, createdAt: org.createdAt };\n } catch (err) {\n // Surface Better Auth validation/permission failures (e.g. slug taken) as\n // a domain rule error the route maps to 409 rather than a raw 500.\n if (err instanceof APIError) {\n throw new OrganizationRuleError(err.message || 'Could not update organization');\n }\n throw err;\n }\n }\n async deleteOrganization(headers: AuthHeaders, externalId: string): Promise<void> {\n try {\n await this.auth.api.deleteOrganization({\n body: { organizationId: externalId },\n headers: fromNodeHeaders(headers),\n });\n } catch (err) {\n if (err instanceof APIError) {\n throw new OrganizationRuleError(err.message || 'Could not delete organization');\n }\n throw err;\n }\n }\n async grantMembership(\n orgExternalId: string,\n userExternalId: string,\n role: string,\n ): Promise<void> {\n // Server-side (no session): the accepted invitation is the authorisation.\n await this.auth.api.addMember({\n body: { userId: userExternalId, organizationId: orgExternalId, role: parseRole(role) },\n });\n }\n async updateRole(ctx: MemberWriteContext, userExternalId: string, role: Role): Promise<void> {\n await this.auth.api.updateMemberRole({\n body: {\n memberId: await this.resolveMemberId(ctx, userExternalId),\n role,\n organizationId: ctx.authOrgId,\n },\n headers: this.headersOf(ctx),\n });\n }\n async removeMember(ctx: MemberWriteContext, userExternalId: string): Promise<void> {\n await this.auth.api.removeMember({\n body: {\n memberIdOrEmail: await this.resolveMemberId(ctx, userExternalId),\n organizationId: ctx.authOrgId,\n },\n headers: this.headersOf(ctx),\n });\n }\n\n protected headersOf(ctx: MemberWriteContext): Headers {\n return fromNodeHeaders(ctx.headers);\n }\n\n // Better Auth's member endpoints key on its own member id, which the domain\n // deliberately does not store — `(org, user)` already identifies the record,\n // so it is looked up here instead of mirrored into org_users.\n protected async resolveMemberId(\n ctx: MemberWriteContext,\n userExternalId: string,\n ): Promise<string> {\n const res = await this.auth.api.listMembers({\n query: {\n organizationId: ctx.authOrgId,\n filterField: 'userId',\n filterValue: userExternalId,\n limit: 1,\n },\n headers: this.headersOf(ctx),\n });\n const memberId = res?.members?.[0]?.id;\n if (!memberId) {\n throw new OrganizationRuleError('That person is no longer a member of this organization');\n }\n return memberId;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"better-auth.js","names":[],"sources":["../src/better-auth.ts"],"sourcesContent":["import type {\n ActiveSession,\n Logger,\n NewOrganizationInput,\n SessionVerifier,\n} from \"@headless-lms/core/types\";\nimport type { IncomingHttpHeaders } from \"node:http\";\nimport { fromNodeHeaders } from \"better-auth/node\";\nimport { APIError, betterAuth } from \"better-auth\";\nimport type { CreateAuthOptions } from \"./types.js\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\nimport * as authSchema from \"@headless-lms/adapter-db/schema/better-auth\";\nimport { prefixId } from \"@headless-lms/core/shared/id\";\nimport { customSession, magicLink, organization } from \"better-auth/plugins\";\nimport { eq } from \"drizzle-orm\";\nimport { ac, roles } from \"./access.js\";\nimport {\n type AuthHeaders,\n type AuthOrganization,\n type MemberWriteContext,\n type OrgAdmin,\n OrganizationRuleError,\n parseRole,\n type Role,\n type UpdateOrganizationInput,\n} from \"@headless-lms/core/organizations\";\nimport type { SessionAdmin } from \"@headless-lms/core/identity\";\n\nexport function createAuth(opts: CreateAuthOptions) {\n const {\n sendResetPassword,\n onPasswordReset,\n sendMagicLink,\n beforeUserCreate,\n beforeCreateSession,\n } = opts.hooks;\n const detached =\n <A extends unknown[]>(name: string, hook: (...args: A) => Promise<void>) =>\n async (...args: A): Promise<void> => {\n void hook(...args).catch((err: unknown) =>\n opts.logger.error(`auth hook ${name} failed`, { err }),\n );\n };\n\n const auth = betterAuth({\n baseURL: opts.baseUrl,\n secret: opts.secret,\n trustedOrigins: opts.trustedOrigins,\n session: {\n // Signed short-lived cookie cache: avoids a Postgres session lookup on\n // every request. The BFF verifies the session per request (each API call\n // + every SSR getSession), so without this each one is a DB round-trip.\n // The cache holds for maxAge; sign-out / expiry still invalidate it.\n cookieCache: {\n enabled: true,\n maxAge: 5 * 60,\n },\n },\n advanced: {\n database: {\n // Prefixed, KSUID-bodied ids for every better-auth table (usr_, org_, …).\n generateId: () => prefixId(\"id\"),\n },\n // Cross-subdomain shared session cookie for admin/api/web on one parent\n // domain (e.g. `.example.com` in prod). Left unset in local dev so the\n // host-only `localhost` cookie is used, which is already shared across\n // ports (cookies are not port-scoped).\n crossSubDomainCookies: {\n enabled: true,\n domain: opts.cookieDomain || undefined,\n },\n // Same-site cookie for the shared-parent-domain plan. Only switch to\n // `sameSite: \"none\"` + `secure` if admin and api are genuinely cross-site\n // (different registrable domains).\n defaultCookieAttributes: {\n sameSite: \"lax\",\n secure: opts.secureCookies ?? false,\n httpOnly: true,\n },\n },\n database: drizzleAdapter(opts.db, {\n provider: \"pg\",\n schema: authSchema,\n }),\n emailAndPassword: {\n enabled: true,\n // Detached so send time can't reveal which emails have accounts, and a failed notice can't fail the reset.\n sendResetPassword: detached(\"sendResetPassword\", sendResetPassword),\n onPasswordReset: detached(\"onPasswordReset\", onPasswordReset),\n revokeSessionsOnPasswordReset: true,\n },\n plugins: [\n magicLink({\n disableSignUp: true,\n sendMagicLink,\n }),\n organization({\n ac,\n roles,\n creatorRole: \"owner\",\n organizationHooks: opts.hooks,\n }),\n // Enrich get-session with the caller's memberships (role + org) so BFFs\n // resolve auth in one call instead of fanning out to get-active-member /\n // get-full-organization / organization/list. Custom fields bypass the\n // cookie cache, so this costs one indexed query per get-session.\n customSession(async ({ user, session }) => {\n const memberships = await opts.db\n .select({\n role: authSchema.member.role,\n id: authSchema.organization.id,\n name: authSchema.organization.name,\n slug: authSchema.organization.slug,\n })\n .from(authSchema.member)\n .innerJoin(\n authSchema.organization,\n eq(authSchema.member.organizationId, authSchema.organization.id),\n )\n .where(eq(authSchema.member.userId, user.id));\n const activeOrgId =\n (session as { activeOrganizationId?: string | null }).activeOrganizationId ?? null;\n const active = memberships.find((m) => m.id === activeOrgId) ?? null;\n return {\n user,\n session,\n activeMemberRole: active?.role ?? null,\n activeOrganization: active\n ? { id: active.id, name: active.name, slug: active.slug }\n : null,\n organizations: memberships.map(({ id, name, slug }) => ({ id, name, slug })),\n };\n }),\n ],\n databaseHooks: {\n user: {\n create: {\n before: beforeUserCreate,\n },\n },\n session: {\n create: {\n before: beforeCreateSession,\n },\n },\n },\n });\n return auth;\n}\n\nexport type Auth = ReturnType<typeof createAuth>;\n\nexport class BetterAuth implements SessionVerifier, OrgAdmin, SessionAdmin {\n protected auth: Auth;\n protected logger: Logger;\n\n constructor(opts: CreateAuthOptions) {\n this.auth = createAuth(opts);\n this.logger = opts.logger;\n }\n\n async handler(request: Request): Promise<Response> {\n return this.auth.handler(request);\n }\n\n async verify(headers: IncomingHttpHeaders): Promise<ActiveSession | null> {\n const res = await this.auth.api.getSession({ headers: fromNodeHeaders(headers) });\n if (!res) {\n return null;\n }\n return {\n user: {\n id: res.user.id,\n createdAt: res.user.createdAt,\n email: res.user.email,\n firstName: res.user.name,\n externalId: res.user.id,\n lastName: res.user.name,\n updatedAt: res.user.updatedAt,\n },\n session: {\n // customSession erases the org plugin's session extension from the\n // inferred type; the field is still present at runtime.\n activeOrganizationId:\n (res.session as { activeOrganizationId?: string | null }).activeOrganizationId ?? null,\n },\n };\n }\n\n /** Stamps the org onto the caller's session row. The 5-minute cookie cache\n * still holds the pre-stamp session, so callers refresh it client-side with\n * `getSession({ disableCookieCache: true })`. */\n async stampActiveOrganization(headers: AuthHeaders, orgId: string): Promise<boolean> {\n const session = await this.auth.api.getSession({ headers: fromNodeHeaders(headers) });\n const token = (session?.session as { token?: string } | undefined)?.token;\n if (!token) {\n return false;\n }\n const ctx = await this.auth.$context;\n await ctx.internalAdapter.updateSession(token, { activeOrganizationId: orgId });\n return true;\n }\n\n async createOrganization(\n headers: AuthHeaders,\n input: NewOrganizationInput,\n ): Promise<AuthOrganization> {\n const org = await this.auth.api.createOrganization({\n body: { name: input.name, slug: input.slug },\n headers: fromNodeHeaders(headers),\n });\n if (!org) {\n throw new Error(`Couldn't create organization`);\n }\n return { id: org.id, name: org.name, slug: org.slug, createdAt: org.createdAt };\n }\n async setActiveOrganization(headers: AuthHeaders, externalId: string): Promise<void> {\n await this.auth.api.setActiveOrganization({\n body: { organizationId: externalId },\n headers: fromNodeHeaders(headers),\n });\n }\n async updateOrganization(\n headers: AuthHeaders,\n externalId: string,\n input: UpdateOrganizationInput,\n ): Promise<AuthOrganization> {\n try {\n const org = await this.auth.api.updateOrganization({\n body: { organizationId: externalId, data: { name: input.name, slug: input.slug } },\n headers: fromNodeHeaders(headers),\n });\n if (!org) {\n throw new Error(`Couldn't update organization`);\n }\n return { id: org.id, name: org.name, slug: org.slug, createdAt: org.createdAt };\n } catch (err) {\n // Surface Better Auth validation/permission failures (e.g. slug taken) as\n // a domain rule error the route maps to 409 rather than a raw 500.\n if (err instanceof APIError) {\n throw new OrganizationRuleError(err.message || \"Could not update organization\");\n }\n throw err;\n }\n }\n async deleteOrganization(headers: AuthHeaders, externalId: string): Promise<void> {\n try {\n await this.auth.api.deleteOrganization({\n body: { organizationId: externalId },\n headers: fromNodeHeaders(headers),\n });\n } catch (err) {\n if (err instanceof APIError) {\n throw new OrganizationRuleError(err.message || \"Could not delete organization\");\n }\n throw err;\n }\n }\n async grantMembership(\n orgExternalId: string,\n userExternalId: string,\n role: string,\n ): Promise<void> {\n // Server-side (no session): the accepted invitation is the authorisation.\n try {\n await this.auth.api.addMember({\n body: { userId: userExternalId, organizationId: orgExternalId, role: parseRole(role) },\n });\n } catch (err) {\n if (\n err instanceof APIError &&\n err.body?.code === \"USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION\"\n ) {\n return;\n }\n throw err;\n }\n }\n async updateRole(ctx: MemberWriteContext, userExternalId: string, role: Role): Promise<void> {\n await this.auth.api.updateMemberRole({\n body: {\n memberId: await this.resolveMemberId(ctx, userExternalId),\n role,\n organizationId: ctx.authOrgId,\n },\n headers: this.headersOf(ctx),\n });\n }\n async removeMember(ctx: MemberWriteContext, userExternalId: string): Promise<void> {\n await this.auth.api.removeMember({\n body: {\n memberIdOrEmail: await this.resolveMemberId(ctx, userExternalId),\n organizationId: ctx.authOrgId,\n },\n headers: this.headersOf(ctx),\n });\n }\n\n protected headersOf(ctx: MemberWriteContext): Headers {\n return fromNodeHeaders(ctx.headers);\n }\n\n // Better Auth's member endpoints key on its own member id, which the domain\n // deliberately does not store — `(org, user)` already identifies the record,\n // so it is looked up here instead of mirrored into org_users.\n protected async resolveMemberId(\n ctx: MemberWriteContext,\n userExternalId: string,\n ): Promise<string> {\n const res = await this.auth.api.listMembers({\n query: {\n organizationId: ctx.authOrgId,\n filterField: \"userId\",\n filterValue: userExternalId,\n limit: 1,\n },\n headers: this.headersOf(ctx),\n });\n const memberId = res?.members?.[0]?.id;\n if (!memberId) {\n throw new OrganizationRuleError(\"That person is no longer a member of this organization\");\n }\n return memberId;\n }\n}\n"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -3,18 +3,23 @@ import { OrganizationOptions } from "better-auth/plugins";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { Logger } from "@headless-lms/core/types";
5
5
  //#region src/types.d.ts
6
- type Hooks = NonNullable<OrganizationOptions['organizationHooks']> & {
6
+ type Hooks = NonNullable<OrganizationOptions["organizationHooks"]> & {
7
7
  beforeUserCreate: (user: User & Record<string, unknown>, context: GenericEndpointContext | null) => Promise<boolean | void | {
8
8
  data: User;
9
9
  }>;
10
10
  beforeCreateSession?: (session: Session & Record<string, unknown>, context: GenericEndpointContext | null) => Promise<boolean | void | {
11
11
  data: Session & Record<string, unknown>;
12
12
  }>;
13
+ /** Runs detached from the request; the adapter logs a failure. */
13
14
  sendResetPassword: (data: {
14
15
  user: User;
15
16
  url: string;
16
17
  token: string;
17
18
  }, request?: Request) => Promise<void>;
19
+ /** Runs detached from the request, after the new password is stored; the adapter logs a failure. */
20
+ onPasswordReset: (data: {
21
+ user: User;
22
+ }, request?: Request) => Promise<void>;
18
23
  sendMagicLink: (data: {
19
24
  email: string;
20
25
  url: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headless-lms/adapter-auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -16,8 +16,8 @@
16
16
  "dependencies": {
17
17
  "better-auth": "^1.6.25",
18
18
  "drizzle-orm": "^0.45.2",
19
- "@headless-lms/adapter-db": "0.1.0",
20
- "@headless-lms/core": "0.1.0"
19
+ "@headless-lms/adapter-db": "0.2.0",
20
+ "@headless-lms/core": "0.2.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^22.10.2",