@protobase/server 0.22.0 → 0.22.1

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 (27) hide show
  1. package/dist/better-auth/auth-mail/invitation-email.js +23 -27
  2. package/dist/better-auth/create-auth.d.ts +460 -24
  3. package/dist/better-auth/create-auth.js +12 -1
  4. package/dist/better-auth/invitations/account-from-invitation.js +50 -0
  5. package/dist/better-auth/invitations/invitation-endpoints.js +102 -0
  6. package/dist/better-auth/invitations/invitation-link-hook.js +34 -0
  7. package/dist/better-auth/invitations/invitation-of-token.js +24 -0
  8. package/dist/better-auth/{organizations → invitations}/invitation-token.js +7 -2
  9. package/dist/better-auth/last-admin-guard.js +62 -0
  10. package/dist/better-auth/last-admin.js +18 -0
  11. package/dist/better-auth/organizations/accept-invitation.js +4 -8
  12. package/dist/better-auth/organizations/invitation-endpoints.js +17 -114
  13. package/dist/better-auth/organizations/options.js +2 -1
  14. package/dist/better-auth/organizations/organization-plugin.js +1 -1
  15. package/dist/better-auth/organizations/protobase-plugin.js +7 -9
  16. package/dist/better-auth/organizations/role-definitions.js +5 -4
  17. package/dist/better-auth/passkey-plugin.js +1 -1
  18. package/dist/better-auth/roles/role-labels.js +14 -0
  19. package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
  20. package/dist/better-auth/status-route.js +3 -8
  21. package/dist/better-auth/users/app-invitation-store.js +62 -0
  22. package/dist/better-auth/users/user-endpoints.js +79 -0
  23. package/dist/better-auth/users/users-plugin.js +22 -0
  24. package/dist/better-auth/users.js +1 -2
  25. package/dist/check-views.js +5 -3
  26. package/dist/index.js +1 -1
  27. package/package.json +4 -4
@@ -0,0 +1,102 @@
1
+ import { noGlobalRole } from "../organizations/role-definitions.js";
2
+ import { findOrganization } from "../organizations/store.js";
3
+ import { acceptInvitation } from "../organizations/accept-invitation.js";
4
+ import { deleteAppInvitation } from "../users/app-invitation-store.js";
5
+ import { createInvitedAccount, signInInvitedAccount } from "./account-from-invitation.js";
6
+ import { invitationOfToken } from "./invitation-of-token.js";
7
+ import * as z$1 from "zod";
8
+ import { APIError, createAuthEndpoint } from "better-auth/api";
9
+ //#region src/better-auth/invitations/invitation-endpoints.ts
10
+ const tokenQuery = z$1.object({ token: z$1.string().min(1) });
11
+ /**
12
+ * The endpoints an emailed invitation link opens, to the app or, with organizations, to one of them: what it is for,
13
+ * and creating an account from it for someone who has none. Open sign-up stays off; an invitation is an address someone
14
+ * vouched for, and the link in the mail proves the person reads it. Accepting an organization's invitation signed in is
15
+ * the organization plugin's.
16
+ */
17
+ const invitationEndpoints = ({ mail, organizations }) => ({
18
+ /**
19
+ * `GET /invitation?token=`: who invites, the address and roles, until when, and whether the address has an account;
20
+ * for an invitation to the app also the app's name, and for an organization's the organization and the role there.
21
+ */
22
+ getInvitation: createAuthEndpoint("/invitation", {
23
+ method: "GET",
24
+ query: tokenQuery
25
+ }, async (ctx) => {
26
+ const found = await invitationOfToken(ctx.context, ctx.query.token, { organizations: Boolean(organizations) });
27
+ const { invitation } = found;
28
+ const inviter = await ctx.context.internalAdapter.findUserById(invitation.inviterId);
29
+ const account = await ctx.context.internalAdapter.findUserByEmail(invitation.email.toLowerCase());
30
+ const common = {
31
+ inviter: { name: inviter?.name || inviter?.email || "" },
32
+ email: invitation.email,
33
+ expiresAt: new Date(invitation.expiresAt).toISOString(),
34
+ hasAccount: Boolean(account)
35
+ };
36
+ if (found.kind === "app") return ctx.json({
37
+ app: { name: ctx.context.appName },
38
+ ...common,
39
+ appRoles: found.invitation.roles
40
+ });
41
+ const organization = await findOrganization(ctx.context.adapter, found.invitation.organizationId);
42
+ return ctx.json({
43
+ organization: {
44
+ id: found.invitation.organizationId,
45
+ name: organization?.name ?? ""
46
+ },
47
+ ...common,
48
+ role: found.invitation.role,
49
+ appRoles: found.invitation.appRoles
50
+ });
51
+ }),
52
+ /**
53
+ * `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
54
+ * verified, and signs the person in. An invitation to the app gives the account its roles; one to an organization
55
+ * gives no global role, and accepts the membership, where the session then works.
56
+ */
57
+ signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
58
+ method: "POST",
59
+ body: z$1.object({
60
+ token: z$1.string().min(1),
61
+ name: z$1.string().trim().min(1).max(200),
62
+ password: z$1.string().optional()
63
+ })
64
+ }, async (ctx) => {
65
+ const found = await invitationOfToken(ctx.context, ctx.body.token, { organizations: Boolean(organizations) });
66
+ const { email } = found.invitation;
67
+ if (await ctx.context.internalAdapter.findUserByEmail(email.toLowerCase())) {
68
+ const message = found.kind === "app" ? "This address has an account already: sign in." : "This address has an account already: sign in to accept the invitation.";
69
+ throw new APIError("BAD_REQUEST", {
70
+ code: "ACCOUNT_EXISTS",
71
+ message
72
+ });
73
+ }
74
+ const account = {
75
+ email,
76
+ name: ctx.body.name,
77
+ ...ctx.body.password !== void 0 && { password: ctx.body.password },
78
+ mail
79
+ };
80
+ if (found.kind === "app") {
81
+ const user = await createInvitedAccount(ctx, {
82
+ ...account,
83
+ role: found.invitation.roles.join(",")
84
+ });
85
+ await deleteAppInvitation(ctx.context, found.invitation.id);
86
+ await signInInvitedAccount(ctx, user);
87
+ return ctx.json({ user: {
88
+ id: user.id,
89
+ email: user.email
90
+ } });
91
+ }
92
+ const user = await createInvitedAccount(ctx, {
93
+ ...account,
94
+ role: noGlobalRole
95
+ });
96
+ const member = await acceptInvitation(ctx.context, found.invitation, user, organizations.membershipLimit);
97
+ await signInInvitedAccount(ctx, user, found.invitation.organizationId);
98
+ return ctx.json({ member });
99
+ })
100
+ });
101
+ //#endregion
102
+ export { invitationEndpoints };
@@ -0,0 +1,34 @@
1
+ import { invitationLink, invitationToken } from "./invitation-token.js";
2
+ import { createAuthMiddleware } from "better-auth/api";
3
+ //#region src/better-auth/invitations/invitation-link-hook.ts
4
+ /**
5
+ * Without a mailer an invitation cannot be emailed, so whoever invites, at one of `paths`, gets its link in the answer,
6
+ * once, to pass on. The answer carries the invitation's `id` and `expiresAt`. Inviting again gives a new link, since it
7
+ * moves the expiry.
8
+ */
9
+ const invitationLinkHook = (paths, invitationUrl) => ({
10
+ matcher: (context) => paths.includes(context.path ?? ""),
11
+ handler: createAuthMiddleware(async (ctx) => {
12
+ const answer = await withInvitationLink(ctx.context.returned, ctx.context.secret, invitationUrl);
13
+ if (answer) ctx.context.returned = answer;
14
+ })
15
+ });
16
+ /** An invitation's answer with its `link` added; `undefined` for an error or an answer without an invitation. */
17
+ const withInvitationLink = async (returned, secret, invitationUrl) => {
18
+ const answer = returned;
19
+ if (!answer || answer instanceof Response || answer instanceof Error || !answer.id || !answer.expiresAt) return void 0;
20
+ const token = await invitationToken(secret, {
21
+ id: answer.id,
22
+ expiresAt: new Date(answer.expiresAt)
23
+ });
24
+ return {
25
+ ...answer,
26
+ link: invitationLink(invitationUrl, token)
27
+ };
28
+ };
29
+ /** The page an invitation link opens, with `?token=`, unless the app names another. */
30
+ const defaultInvitationUrl = (baseURL) => `${baseURL.replace(/\/$/, "")}/-/invitation`;
31
+ /** How long an invitation works unless the app says otherwise: 7 days, in seconds. */
32
+ const defaultInvitationSeconds = 604800;
33
+ //#endregion
34
+ export { defaultInvitationSeconds, defaultInvitationUrl, invitationLinkHook, withInvitationLink };
@@ -0,0 +1,24 @@
1
+ import { invalidInvitation, invitationIdOf, verifyInvitationToken } from "./invitation-token.js";
2
+ import { invitationOfToken as invitationOfToken$1 } from "../organizations/accept-invitation.js";
3
+ import { findAppInvitation } from "../users/app-invitation-store.js";
4
+ import { APIError } from "better-auth/api";
5
+ //#region src/better-auth/invitations/invitation-of-token.ts
6
+ /** The pending invitation an emailed link's token names, either kind, or a 400 `INVITATION_INVALID`. */
7
+ const invitationOfToken = async (context, token, { organizations }) => {
8
+ const id = invitationIdOf(token);
9
+ const app = id ? await findAppInvitation(context, id) : void 0;
10
+ if (app) {
11
+ if (!await verifyInvitationToken(context.secret, token, app)) throw new APIError("BAD_REQUEST", invalidInvitation);
12
+ return {
13
+ kind: "app",
14
+ invitation: app
15
+ };
16
+ }
17
+ if (!organizations) throw new APIError("BAD_REQUEST", invalidInvitation);
18
+ return {
19
+ kind: "organization",
20
+ invitation: await invitationOfToken$1(context, token)
21
+ };
22
+ };
23
+ //#endregion
24
+ export { invitationOfToken };
@@ -1,4 +1,4 @@
1
- //#region src/better-auth/organizations/invitation-token.ts
1
+ //#region src/better-auth/invitations/invitation-token.ts
2
2
  const encoder = new TextEncoder();
3
3
  const base64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4
4
  const signature = async (secret, id, expiresAt) => {
@@ -23,5 +23,10 @@ const verifyInvitationToken = async (secret, token, invitation) => {
23
23
  };
24
24
  /** The link in the email: the app's invitation page with the token. */
25
25
  const invitationLink = (pageUrl, token) => `${pageUrl}?token=${encodeURIComponent(token)}`;
26
+ /** Why a link's token finds no pending invitation, of either kind: the body of its 400. */
27
+ const invalidInvitation = {
28
+ code: "INVITATION_INVALID",
29
+ message: "This invitation link is not valid any more: it was used, revoked, replaced by a newer one, or it expired."
30
+ };
26
31
  //#endregion
27
- export { invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
32
+ export { invalidInvitation, invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
@@ -0,0 +1,62 @@
1
+ import { parseRoles } from "./parse-roles.js";
2
+ import { wouldLeaveNoAdmin } from "./last-admin.js";
3
+ import { APIError, createAuthMiddleware } from "better-auth/api";
4
+ //#region src/better-auth/last-admin-guard.ts
5
+ const rolesOf = (role) => typeof role === "string" || Array.isArray(role) ? parseRoles(role) : void 0;
6
+ /** The change an admin endpoint makes to its user, from its path and body; `undefined` when it touches no role or block. */
7
+ const adminChange = (path, body) => {
8
+ if (typeof body.userId !== "string") return void 0;
9
+ const userId = body.userId;
10
+ if (path === "/admin/set-role") return {
11
+ userId,
12
+ roles: rolesOf(body.role) ?? []
13
+ };
14
+ if (path === "/admin/ban-user") return {
15
+ userId,
16
+ banned: true
17
+ };
18
+ if (path === "/admin/remove-user") return {
19
+ userId,
20
+ removed: true
21
+ };
22
+ if (path !== "/admin/update-user") return void 0;
23
+ const roles = rolesOf(body.data?.role);
24
+ const banned = typeof body.data?.banned === "boolean" ? body.data.banned : void 0;
25
+ if (roles === void 0 && banned === void 0) return void 0;
26
+ return {
27
+ userId,
28
+ ...roles && { roles },
29
+ ...banned !== void 0 && { banned }
30
+ };
31
+ };
32
+ const guardedPaths = [
33
+ "/admin/set-role",
34
+ "/admin/ban-user",
35
+ "/admin/remove-user",
36
+ "/admin/update-user"
37
+ ];
38
+ /** Refuses, with `400 LAST_ADMIN`, an admin endpoint that would demote, block or remove the last active admin. */
39
+ const lastAdminGuardPlugin = () => ({
40
+ id: "protobase-last-admin",
41
+ hooks: { before: [{
42
+ matcher: (ctx) => guardedPaths.includes(ctx.path ?? ""),
43
+ handler: createAuthMiddleware(async (ctx) => {
44
+ const change = adminChange(ctx.path, ctx.body ?? {});
45
+ if (!change) return;
46
+ const users = await ctx.context.adapter.findMany({
47
+ model: "user",
48
+ select: [
49
+ "id",
50
+ "role",
51
+ "banned"
52
+ ]
53
+ });
54
+ if (wouldLeaveNoAdmin(users, change)) throw new APIError("BAD_REQUEST", {
55
+ code: "LAST_ADMIN",
56
+ message: "This is the last admin: make someone else an admin first."
57
+ });
58
+ })
59
+ }] }
60
+ });
61
+ //#endregion
62
+ export { adminChange, lastAdminGuardPlugin };
@@ -0,0 +1,18 @@
1
+ import { parseRoles } from "./parse-roles.js";
2
+ //#region src/better-auth/last-admin.ts
3
+ /** Holds the admin role and is not blocked: the last of these can never be demoted, blocked or removed. */
4
+ const isActiveAdmin = (user) => parseRoles(user.role).includes("admin") && !user.banned;
5
+ /** Whether `change` would leave `users` without an active admin, while they have one now. */
6
+ const wouldLeaveNoAdmin = (users, change) => {
7
+ const target = users.find((user) => user.id === change.userId);
8
+ if (!target || !isActiveAdmin(target)) return false;
9
+ if (change.removed) return users.filter(isActiveAdmin).length <= 1;
10
+ const after = {
11
+ ...target,
12
+ ...change.roles && { role: change.roles },
13
+ ...change.banned !== void 0 && { banned: change.banned }
14
+ };
15
+ return !isActiveAdmin(after) && users.filter(isActiveAdmin).length <= 1;
16
+ };
17
+ //#endregion
18
+ export { isActiveAdmin, wouldLeaveNoAdmin };
@@ -1,12 +1,8 @@
1
+ import { invalidInvitation, invitationIdOf, verifyInvitationToken } from "../invitations/invitation-token.js";
1
2
  import { parseAppRoles } from "./app-roles.js";
2
3
  import { findMember, membersOf } from "./store.js";
3
- import { invitationIdOf, verifyInvitationToken } from "./invitation-token.js";
4
4
  import { APIError } from "better-auth/api";
5
5
  //#region src/better-auth/organizations/accept-invitation.ts
6
- const invalid = {
7
- code: "INVITATION_INVALID",
8
- message: "This invitation link is not valid any more: it was used, revoked, replaced by a newer one, or it expired."
9
- };
10
6
  /** The pending invitation an emailed link's token names, or a 400 `INVITATION_INVALID`. */
11
7
  const invitationOfToken = async (context, token) => {
12
8
  const id = invitationIdOf(token);
@@ -17,8 +13,8 @@ const invitationOfToken = async (context, token) => {
17
13
  value: id
18
14
  }]
19
15
  });
20
- if (!row || !await verifyInvitationToken(context.secret, token, row)) throw new APIError("BAD_REQUEST", invalid);
21
- if (row.status !== "pending" || new Date(row.expiresAt).getTime() < Date.now()) throw new APIError("BAD_REQUEST", invalid);
16
+ if (!row || !await verifyInvitationToken(context.secret, token, row)) throw new APIError("BAD_REQUEST", invalidInvitation);
17
+ if (row.status !== "pending" || new Date(row.expiresAt).getTime() < Date.now()) throw new APIError("BAD_REQUEST", invalidInvitation);
22
18
  return {
23
19
  ...row,
24
20
  appRoles: parseAppRoles(row.appRoles)
@@ -60,7 +56,7 @@ const acceptInvitation = async (context, invitation, user, membershipLimit) => {
60
56
  value: "pending"
61
57
  }],
62
58
  update: { status: "accepted" }
63
- })) throw new APIError("BAD_REQUEST", invalid);
59
+ })) throw new APIError("BAD_REQUEST", invalidInvitation);
64
60
  return {
65
61
  ...await trx.create({
66
62
  model: "member",
@@ -1,120 +1,23 @@
1
- import { noGlobalRole } from "./role-definitions.js";
2
- import { findOrganization } from "./store.js";
3
- import { invitationLink, invitationToken } from "./invitation-token.js";
4
- import { checkNewAccountPassword } from "../new-account-password.js";
5
- import { effectiveSignInPolicy } from "../sign-in-policy.js";
6
- import { readSignInPolicy } from "../policy-store.js";
7
1
  import { acceptInvitation, invitationOfToken } from "./accept-invitation.js";
8
2
  import * as z$1 from "zod";
9
- import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
10
- import { setSessionCookie } from "better-auth/cookies";
3
+ import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
11
4
  //#region src/better-auth/organizations/invitation-endpoints.ts
12
- const tokenBody = z$1.object({ token: z$1.string().min(1) });
13
- const startIn = (context, sessionToken, organizationId) => context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId });
14
5
  /**
15
- * The endpoints an emailed invitation link uses: what it is for, accepting it signed in, and creating an account from it
16
- * for someone who has none. Open sign-up stays off; an invitation is an address someone vouched for, and the link in
17
- * the mail proves the person reads it.
6
+ * Accepting an organization's emailed invitation signed in. What a link is for, and making an account from it, are the
7
+ * shared invitation endpoints (`invitations/invitation-endpoints.ts`), which serve invitations to the app as well.
18
8
  */
19
- const invitationEndpoints = ({ resolved, mail }) => ({
20
- /** `GET /invitation?token=`: the organization, who invites, the address and roles, until when, and whether the address has an account. */
21
- getInvitation: createAuthEndpoint("/invitation", {
22
- method: "GET",
23
- query: tokenBody
24
- }, async (ctx) => {
25
- const invitation = await invitationOfToken(ctx.context, ctx.query.token);
26
- const organization = await findOrganization(ctx.context.adapter, invitation.organizationId);
27
- const inviter = await ctx.context.internalAdapter.findUserById(invitation.inviterId);
28
- const account = await ctx.context.internalAdapter.findUserByEmail(invitation.email.toLowerCase());
29
- return ctx.json({
30
- organization: {
31
- id: invitation.organizationId,
32
- name: organization?.name ?? ""
33
- },
34
- inviter: { name: inviter?.name || inviter?.email || "" },
35
- email: invitation.email,
36
- role: invitation.role,
37
- appRoles: invitation.appRoles,
38
- expiresAt: new Date(invitation.expiresAt).toISOString(),
39
- hasAccount: Boolean(account)
40
- });
41
- }),
42
- /** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
43
- acceptInvitation: createAuthEndpoint("/invitation/accept", {
44
- method: "POST",
45
- body: tokenBody,
46
- use: [sessionMiddleware]
47
- }, async (ctx) => {
48
- const invitation = await invitationOfToken(ctx.context, ctx.body.token);
49
- const { user, session } = ctx.context.session;
50
- const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
51
- await startIn(ctx.context, session.token, invitation.organizationId);
52
- return ctx.json({ member });
53
- }),
54
- /**
55
- * `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
56
- * verified and with no global role, accepts the invitation and signs the person in there. A password is needed unless
57
- * the sign-in policy offers emailed codes; the policy's required methods are then set up as after any sign-in.
58
- */
59
- signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
60
- method: "POST",
61
- body: z$1.object({
62
- token: z$1.string().min(1),
63
- name: z$1.string().trim().min(1).max(200),
64
- password: z$1.string().optional()
65
- })
66
- }, async (ctx) => {
67
- const invitation = await invitationOfToken(ctx.context, ctx.body.token);
68
- const { internalAdapter } = ctx.context;
69
- if (await internalAdapter.findUserByEmail(invitation.email.toLowerCase())) throw new APIError("BAD_REQUEST", {
70
- code: "ACCOUNT_EXISTS",
71
- message: "This address has an account already: sign in to accept the invitation."
72
- });
73
- const { password } = ctx.body;
74
- checkNewAccountPassword(effectiveSignInPolicy((await readSignInPolicy(ctx.context.adapter)).policy, { mail }), password);
75
- const user = await internalAdapter.createUser({
76
- email: invitation.email.toLowerCase(),
77
- name: ctx.body.name,
78
- emailVerified: true,
79
- role: noGlobalRole
80
- }, { method: "invitation" });
81
- if (password !== void 0) await internalAdapter.linkAccount({
82
- userId: user.id,
83
- providerId: "credential",
84
- accountId: user.id,
85
- password: await ctx.context.password.hash(password)
86
- });
87
- const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
88
- const session = await internalAdapter.createSession(user.id, false);
89
- await startIn(ctx.context, session.token, invitation.organizationId);
90
- await setSessionCookie(ctx, {
91
- session: {
92
- ...session,
93
- activeOrganizationId: invitation.organizationId
94
- },
95
- user
96
- });
97
- return ctx.json({ member });
98
- })
99
- });
100
- /**
101
- * Without a mailer the invitation cannot be emailed, so whoever invites gets its link in the answer, once, to pass on.
102
- * Resending gives a new link, since it moves the expiry.
103
- */
104
- const invitationLinkHook = (resolved) => ({
105
- matcher: (context) => context.path === "/organization/invite-member",
106
- handler: createAuthMiddleware(async (ctx) => {
107
- const returned = ctx.context.returned;
108
- if (!returned || returned instanceof Response || returned instanceof Error || !returned.id || !returned.expiresAt) return;
109
- const token = await invitationToken(ctx.context.secret, {
110
- id: returned.id,
111
- expiresAt: new Date(returned.expiresAt)
112
- });
113
- ctx.context.returned = {
114
- ...returned,
115
- link: invitationLink(resolved.invitationUrl, token)
116
- };
117
- })
118
- });
9
+ const invitationEndpoints = ({ resolved }) => ({
10
+ /** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
11
+ acceptInvitation: createAuthEndpoint("/invitation/accept", {
12
+ method: "POST",
13
+ body: z$1.object({ token: z$1.string().min(1) }),
14
+ use: [sessionMiddleware]
15
+ }, async (ctx) => {
16
+ const invitation = await invitationOfToken(ctx.context, ctx.body.token);
17
+ const { user, session } = ctx.context.session;
18
+ const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
19
+ await ctx.context.internalAdapter.updateSession(session.token, { activeOrganizationId: invitation.organizationId });
20
+ return ctx.json({ member });
21
+ }) });
119
22
  //#endregion
120
- export { invitationEndpoints, invitationLinkHook };
23
+ export { invitationEndpoints };
@@ -1,3 +1,4 @@
1
+ import { defaultInvitationUrl } from "../invitations/invitation-link-hook.js";
1
2
  import { membershipRoles } from "./role-definitions.js";
2
3
  //#region src/better-auth/organizations/options.ts
3
4
  const resolveOrganizations = (options, definitions, baseURL) => {
@@ -15,7 +16,7 @@ const resolveOrganizations = (options, definitions, baseURL) => {
15
16
  membershipLimit: options.membershipLimit ?? 1e3,
16
17
  generateId: options.generateId ?? (() => crypto.randomUUID()),
17
18
  deleteRecords: options.deleteRecords ?? "refuse",
18
- invitationUrl: options.invitationUrl ?? `${baseURL.replace(/\/$/, "")}/-/invitation`,
19
+ invitationUrl: options.invitationUrl ?? defaultInvitationUrl(baseURL),
19
20
  options
20
21
  };
21
22
  };
@@ -1,8 +1,8 @@
1
+ import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
1
2
  import "./role-definitions.js";
2
3
  import { checkAppRoleChange, effectiveRoles, globalRoles, parseAppRoles } from "./app-roles.js";
3
4
  import { findMember } from "./store.js";
4
5
  import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
5
- import { invitationLink, invitationToken } from "./invitation-token.js";
6
6
  import { APIError } from "better-auth/api";
7
7
  import { organization } from "better-auth/plugins/organization";
8
8
  //#region src/better-auth/organizations/organization-plugin.ts
@@ -1,21 +1,19 @@
1
- import { invitationEndpoints, invitationLinkHook } from "./invitation-endpoints.js";
1
+ import { invitationLinkHook } from "../invitations/invitation-link-hook.js";
2
+ import { invitationEndpoints } from "./invitation-endpoints.js";
2
3
  import { memberEndpoints } from "./member-endpoints.js";
3
4
  //#region src/better-auth/organizations/protobase-plugin.ts
4
5
  /**
5
- * Protobase's endpoints beside Better Auth's organization plugin: invitation links that also make accounts, app roles,
6
- * handing ownership over, and finding organizations for people with a global role. Without a mailer, whoever invites
7
- * gets the link in the answer.
6
+ * Protobase's endpoints beside Better Auth's organization plugin: accepting invitations signed in, app roles, handing
7
+ * ownership over, and finding organizations for people with a global role. Without a mailer, whoever invites gets the
8
+ * link in the answer.
8
9
  */
9
10
  const protobaseOrganizationPlugin = ({ resolved, mail }) => ({
10
11
  id: "protobase-organizations",
11
12
  endpoints: {
12
- ...invitationEndpoints({
13
- resolved,
14
- mail
15
- }),
13
+ ...invitationEndpoints({ resolved }),
16
14
  ...memberEndpoints({ resolved })
17
15
  },
18
- hooks: { after: mail ? [] : [invitationLinkHook(resolved)] }
16
+ hooks: { after: mail ? [] : [invitationLinkHook(["/organization/invite-member"], resolved.invitationUrl)] }
19
17
  });
20
18
  //#endregion
21
19
  export { protobaseOrganizationPlugin };
@@ -5,10 +5,10 @@ const superuserRole = "admin";
5
5
  /** The admin plugin's role for an account without a global role; in organization mode it grants nothing. */
6
6
  const noGlobalRole = "user";
7
7
  const organizationRoleNames = ["owner", "member"];
8
- const builtInLabels = {
8
+ const builtInLabels = (organizations) => ({
9
9
  [superuserRole]: "Superuser",
10
- [noGlobalRole]: "No global role"
11
- };
10
+ [noGlobalRole]: organizations ? "No global role" : "User"
11
+ });
12
12
  const entriesOf = (input) => Array.isArray(input) ? input.map((name) => [name, void 0]) : Object.entries(input);
13
13
  /**
14
14
  * The roles with `admin` first (added when missing), their labels and grants. With `organizations`, every role needs a
@@ -26,10 +26,11 @@ const roleDefinitions = (input = ["admin", "user"], { organizations = false } =
26
26
  ...organizations && !given.has("user") ? [noGlobalRole] : []
27
27
  ];
28
28
  const labels = {};
29
+ const builtIn = builtInLabels(organizations);
29
30
  const grants = {};
30
31
  for (const name of names) {
31
32
  const definition = given.get(name);
32
- const label = typeof definition === "string" ? definition : definition?.label ?? builtInLabels[name];
33
+ const label = typeof definition === "string" ? definition : definition?.label ?? builtIn[name];
33
34
  if (label !== void 0) labels[name] = label;
34
35
  grants[name] = typeof definition === "object" ? definition.grants ?? [] : [];
35
36
  const unknown = grants[name].find((granted) => !names.includes(granted));
@@ -1,9 +1,9 @@
1
1
  import { pluginNames } from "./snake-case-names.js";
2
2
  import * as z$1 from "zod";
3
3
  import { APIError, createAuthEndpoint, getSessionFromCtx } from "better-auth/api";
4
- import { setSessionCookie } from "better-auth/cookies";
5
4
  import { PASSKEY_ERROR_CODES, passkey } from "@better-auth/passkey";
6
5
  import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
6
+ import { setSessionCookie } from "better-auth/cookies";
7
7
  import { generateRandomString } from "better-auth/crypto";
8
8
  //#region src/better-auth/passkey-plugin.ts
9
9
  const challengeCookie = "better-auth-passkey";
@@ -0,0 +1,14 @@
1
+ import { membershipRoles } from "../organizations/role-definitions.js";
2
+ //#region src/better-auth/roles/role-labels.ts
3
+ /** Every role with its label, `membership` for those a member can hold, and the roles each one's holders may also give. */
4
+ const roleLabels = (definitions) => {
5
+ const membership = membershipRoles(definitions);
6
+ return definitions.names.map((name) => ({
7
+ name,
8
+ label: definitions.labels[name] ?? name,
9
+ membership: membership.includes(name),
10
+ grants: definitions.grants[name] ?? []
11
+ }));
12
+ };
13
+ //#endregion
14
+ export { roleLabels };
@@ -1,7 +1,7 @@
1
1
  import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
2
- import { checkNewAccountPassword } from "../new-account-password.js";
3
2
  import { effectiveSignInPolicy } from "../sign-in-policy.js";
4
3
  import { readSignInPolicy } from "../policy-store.js";
4
+ import { checkNewAccountPassword } from "../new-account-password.js";
5
5
  import { consumeSignUpCode, issueSignUpCode, signUpCodeLifetime } from "./sign-up-code.js";
6
6
  import { consumeSignUpTicket, issueSignUpTicket } from "./sign-up-ticket.js";
7
7
  import * as z$1 from "zod";
@@ -1,7 +1,7 @@
1
- import { membershipRoles } from "./organizations/role-definitions.js";
1
+ import { signInPasskeyDomains } from "./passkey-domain.js";
2
2
  import { effectiveSignInPolicy } from "./sign-in-policy.js";
3
3
  import { readSignInPolicy } from "./policy-store.js";
4
- import { signInPasskeyDomains } from "./passkey-domain.js";
4
+ import { roleLabels } from "./roles/role-labels.js";
5
5
  import { hasUsers } from "./users.js";
6
6
  import { Hono } from "hono";
7
7
  //#region src/better-auth/status-route.ts
@@ -46,12 +46,7 @@ const statusRoute = (auth, { anonymous = false, plugins = [] } = {}) => {
46
46
  ...plugins.length > 0 && { plugins },
47
47
  ...auth.organizations && { organizations: {
48
48
  create: auth.organizations.create,
49
- roles: auth.roleDefinitions.names.map((name) => ({
50
- name,
51
- label: auth.roleDefinitions.labels[name] ?? name,
52
- membership: membershipRoles(auth.roleDefinitions).includes(name),
53
- grants: auth.roleDefinitions.grants[name] ?? []
54
- }))
49
+ roles: roleLabels(auth.roleDefinitions)
55
50
  } }
56
51
  });
57
52
  });
@@ -0,0 +1,62 @@
1
+ //#region src/better-auth/users/app-invitation-store.ts
2
+ const prefix = "app-invitation:";
3
+ const invitationOf = (row) => {
4
+ const { email, roles, inviterId } = JSON.parse(row.value);
5
+ return {
6
+ id: row.identifier.slice(15),
7
+ email,
8
+ roles,
9
+ inviterId,
10
+ expiresAt: new Date(row.expiresAt)
11
+ };
12
+ };
13
+ const pending = (invitation) => invitation.expiresAt.getTime() > Date.now();
14
+ const allAppInvitations = async (context) => {
15
+ return (await context.adapter.findMany({
16
+ model: "verification",
17
+ where: [{
18
+ field: "identifier",
19
+ operator: "starts_with",
20
+ value: prefix
21
+ }],
22
+ sortBy: {
23
+ field: "createdAt",
24
+ direction: "asc"
25
+ },
26
+ limit: 1e5
27
+ })).map(invitationOf);
28
+ };
29
+ /** The pending app invitations, oldest first; expired ones are left out. */
30
+ const listAppInvitations = async (context) => (await allAppInvitations(context)).filter(pending);
31
+ /** The pending app invitation with `id`, or `undefined` when there is none or it expired. */
32
+ const findAppInvitation = async (context, id) => {
33
+ const row = await context.internalAdapter.findVerificationValue(`${prefix}${id}`);
34
+ const invitation = row ? invitationOf(row) : void 0;
35
+ return invitation && pending(invitation) ? invitation : void 0;
36
+ };
37
+ const deleteAppInvitation = (context, id) => context.internalAdapter.deleteVerificationByIdentifier(`${prefix}${id}`);
38
+ /** Invites `email` with `roles` for `lifetimeSeconds`; an earlier invitation of the same address is replaced, so its link stops working. */
39
+ const createAppInvitation = async (context, input, lifetimeSeconds) => {
40
+ const email = input.email.toLowerCase();
41
+ const earlier = (await allAppInvitations(context)).filter((invitation) => invitation.email === email);
42
+ for (const invitation of earlier) await deleteAppInvitation(context, invitation.id);
43
+ const invitation = {
44
+ id: crypto.randomUUID(),
45
+ email,
46
+ roles: input.roles,
47
+ inviterId: input.inviterId,
48
+ expiresAt: new Date(Date.now() + lifetimeSeconds * 1e3)
49
+ };
50
+ await context.internalAdapter.createVerificationValue({
51
+ identifier: `${prefix}${invitation.id}`,
52
+ value: JSON.stringify({
53
+ email,
54
+ roles: invitation.roles,
55
+ inviterId: invitation.inviterId
56
+ }),
57
+ expiresAt: invitation.expiresAt
58
+ });
59
+ return invitation;
60
+ };
61
+ //#endregion
62
+ export { createAppInvitation, deleteAppInvitation, findAppInvitation, listAppInvitations };