@protobase/server 0.7.0 → 0.8.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 (114) hide show
  1. package/dist/assistant/conversations.d.ts +1 -1
  2. package/dist/assistant/tools.d.ts +1 -1
  3. package/dist/assistant/turn.d.ts +2 -2
  4. package/dist/audit/origin.js +12 -0
  5. package/dist/auth/jwt.d.ts +2 -0
  6. package/dist/auth/jwt.js +5 -3
  7. package/dist/batch/reorder.js +1 -1
  8. package/dist/batch/run-batch.js +2 -1
  9. package/dist/better-auth/authenticator.d.ts +7 -3
  10. package/dist/better-auth/authenticator.js +24 -3
  11. package/dist/better-auth/create-auth.d.ts +619 -81
  12. package/dist/better-auth/create-auth.js +64 -27
  13. package/dist/better-auth/organizations/accept-invitation.js +80 -0
  14. package/dist/better-auth/organizations/active-organization.js +12 -0
  15. package/dist/better-auth/organizations/admin-routes.js +83 -0
  16. package/dist/better-auth/organizations/app-roles.js +43 -0
  17. package/dist/better-auth/organizations/host.d.ts +53 -0
  18. package/dist/better-auth/organizations/host.js +251 -0
  19. package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
  20. package/dist/better-auth/organizations/invitation-mail.js +17 -0
  21. package/dist/better-auth/organizations/invitation-token.js +27 -0
  22. package/dist/better-auth/organizations/member-endpoints.js +182 -0
  23. package/dist/better-auth/organizations/options.d.ts +48 -0
  24. package/dist/better-auth/organizations/options.js +23 -0
  25. package/dist/better-auth/organizations/organization-plugin.js +128 -0
  26. package/dist/better-auth/organizations/protobase-plugin.js +21 -0
  27. package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
  28. package/dist/better-auth/organizations/role-definitions.js +56 -0
  29. package/dist/better-auth/organizations/session-organization.js +68 -0
  30. package/dist/better-auth/organizations/store.d.ts +19 -0
  31. package/dist/better-auth/organizations/store.js +73 -0
  32. package/dist/better-auth/staff-checks.js +5 -1
  33. package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
  34. package/dist/better-auth/status-route.js +14 -3
  35. package/dist/better-auth/users.d.ts +2 -1
  36. package/dist/better-auth/users.js +4 -1
  37. package/dist/changes/actor.js +12 -0
  38. package/dist/changes/capture-plan.js +62 -0
  39. package/dist/changes/capture-schema.d.ts +18 -0
  40. package/dist/changes/capture-schema.js +65 -0
  41. package/dist/changes/capture-sql.js +108 -0
  42. package/dist/changes/coalesce.d.ts +38 -0
  43. package/dist/changes/coalesce.js +69 -0
  44. package/dist/changes/events-options.d.ts +10 -0
  45. package/dist/changes/events-options.js +10 -0
  46. package/dist/changes/match-events.d.ts +9 -0
  47. package/dist/changes/match-events.js +55 -0
  48. package/dist/changes/resource-change.d.ts +27 -0
  49. package/dist/changes/resource-change.js +43 -0
  50. package/dist/create-admin.d.ts +11 -2
  51. package/dist/create-admin.js +33 -2
  52. package/dist/deps.d.ts +1 -0
  53. package/dist/files/attach.js +80 -0
  54. package/dist/files/cleanup-runner.d.ts +8 -0
  55. package/dist/files/cleanup-runner.js +58 -0
  56. package/dist/files/cleanup-schedule.d.ts +28 -0
  57. package/dist/files/cleanup-schedule.js +48 -0
  58. package/dist/files/detect.js +51 -0
  59. package/dist/files/download-url.js +43 -0
  60. package/dist/files/download.js +90 -0
  61. package/dist/files/duration.js +35 -0
  62. package/dist/files/file-cleanup.d.ts +18 -0
  63. package/dist/files/file-cleanup.js +17 -0
  64. package/dist/files/local-files.d.ts +17 -0
  65. package/dist/files/local-files.js +101 -0
  66. package/dist/files/media-types.js +99 -0
  67. package/dist/files/options.d.ts +15 -0
  68. package/dist/files/present.d.ts +15 -0
  69. package/dist/files/present.js +59 -0
  70. package/dist/files/processors.d.ts +15 -0
  71. package/dist/files/processors.js +39 -0
  72. package/dist/files/providers.js +70 -0
  73. package/dist/files/routes.js +26 -0
  74. package/dist/files/runtime.d.ts +3 -0
  75. package/dist/files/runtime.js +37 -0
  76. package/dist/files/signer.js +32 -0
  77. package/dist/files/store.d.ts +37 -0
  78. package/dist/files/ticket.js +87 -0
  79. package/dist/files/upload-body.js +85 -0
  80. package/dist/files/upload.js +124 -0
  81. package/dist/files/uri.js +47 -0
  82. package/dist/functions/define-function.d.ts +71 -0
  83. package/dist/functions/define-function.js +36 -0
  84. package/dist/functions/function-app.d.ts +17 -0
  85. package/dist/functions/function-app.js +19 -0
  86. package/dist/functions/read-json.d.ts +35 -0
  87. package/dist/functions/read-json.js +24 -0
  88. package/dist/functions/records.d.ts +43 -0
  89. package/dist/functions/records.js +31 -0
  90. package/dist/functions/require-env.d.ts +7 -0
  91. package/dist/functions/require-env.js +12 -0
  92. package/dist/functions/routes.js +60 -0
  93. package/dist/functions/verify-signature.d.ts +32 -0
  94. package/dist/functions/verify-signature.js +49 -0
  95. package/dist/index.d.ts +26 -3
  96. package/dist/index.js +17 -3
  97. package/dist/list-service.js +4 -2
  98. package/dist/openapi/resource-paths.js +78 -1
  99. package/dist/openapi/schemas.js +28 -3
  100. package/dist/project-config.d.ts +10 -1
  101. package/dist/project-config.js +41 -3
  102. package/dist/read-service.js +5 -1
  103. package/dist/request-access.d.ts +5 -0
  104. package/dist/request-access.js +9 -4
  105. package/dist/resource-source.d.ts +4 -1
  106. package/dist/routes/batch.js +1 -1
  107. package/dist/routes/collection.js +3 -1
  108. package/dist/routes/record.js +2 -5
  109. package/dist/transactions.js +10 -3
  110. package/dist/types.d.ts +25 -5
  111. package/dist/write-pipeline.js +4 -2
  112. package/dist/write-shared.js +6 -3
  113. package/dist/write-update.js +2 -1
  114. package/package.json +6 -4
@@ -0,0 +1,251 @@
1
+ import { membershipRoles } from "./role-definitions.js";
2
+ import { everyRow, findMember, membersOf, organizationRoleOf, setAppRoles } from "./store.js";
3
+ //#region src/better-auth/organizations/host.ts
4
+ const contextOf = async (auth) => await auth.$context;
5
+ const settingsOf = (auth) => {
6
+ if (!auth.organizations) throw new Error("Organizations are off: pass `organizations` to createAuth");
7
+ return auth.organizations;
8
+ };
9
+ const organizationRoles = [
10
+ "owner",
11
+ "admin",
12
+ "member"
13
+ ];
14
+ const checkOrganizationRole = (role) => {
15
+ if (!organizationRoles.includes(role)) throw new Error(`Unknown organization role "${role}"; use ${organizationRoles.join(", ")}`);
16
+ return role;
17
+ };
18
+ const checkAppRoles = (auth, appRoles) => {
19
+ const holdable = membershipRoles(settingsOf(auth).definitions);
20
+ const unknown = appRoles.find((name) => !holdable.includes(name));
21
+ if (unknown !== void 0) throw new Error(`A member cannot hold "${unknown}"; use ${holdable.join(", ")}`);
22
+ return [...new Set(appRoles)];
23
+ };
24
+ const userByEmail = async (auth, email) => {
25
+ const user = await (await contextOf(auth)).internalAdapter.findUserByEmail(email.toLowerCase());
26
+ if (!user) throw new Error(`No user with email ${email}`);
27
+ return user.user;
28
+ };
29
+ /** An organization by its id or slug. */
30
+ const findOrganizationByKey = async (auth, key) => {
31
+ const { adapter } = await contextOf(auth);
32
+ const found = await adapter.findOne({
33
+ model: "organization",
34
+ where: [{
35
+ field: "id",
36
+ value: key
37
+ }]
38
+ }) ?? await adapter.findOne({
39
+ model: "organization",
40
+ where: [{
41
+ field: "slug",
42
+ value: key
43
+ }]
44
+ });
45
+ if (!found) throw new Error(`No organization "${key}"`);
46
+ return found;
47
+ };
48
+ const slugPattern = /^[a-z0-9][a-z0-9-]{0,62}$/;
49
+ /**
50
+ * Creates an organization with `owner` (an email) as its owner, holding the creator's app roles. `id` keeps an existing
51
+ * tenant value, for an app that had one organization before; otherwise the configured `generateId` makes one.
52
+ */
53
+ const createOrganization = async (auth, input) => {
54
+ const settings = settingsOf(auth);
55
+ if (!input.name.trim()) throw new Error("The organization needs a name");
56
+ if (!slugPattern.test(input.slug)) throw new Error(`Invalid slug "${input.slug}": lowercase letters, digits and "-", at most 63`);
57
+ const owner = await userByEmail(auth, input.owner);
58
+ const { adapter } = await contextOf(auth);
59
+ if (await adapter.findOne({
60
+ model: "organization",
61
+ where: [{
62
+ field: "slug",
63
+ value: input.slug
64
+ }]
65
+ })) throw new Error(`An organization with slug "${input.slug}" exists already`);
66
+ const now = /* @__PURE__ */ new Date();
67
+ const id = input.id ?? await settings.generateId();
68
+ const { organization, member } = await adapter.transaction(async (trx) => {
69
+ const organization = await trx.create({
70
+ model: "organization",
71
+ data: {
72
+ id,
73
+ name: input.name.trim(),
74
+ slug: input.slug,
75
+ createdAt: now
76
+ },
77
+ forceAllowId: true
78
+ });
79
+ return {
80
+ organization,
81
+ member: await trx.create({
82
+ model: "member",
83
+ data: {
84
+ organizationId: organization.id,
85
+ userId: owner.id,
86
+ role: "owner",
87
+ appRoles: settings.creatorAppRoles,
88
+ createdAt: now
89
+ }
90
+ })
91
+ };
92
+ });
93
+ const stored = {
94
+ ...member,
95
+ appRoles: [...settings.creatorAppRoles]
96
+ };
97
+ await settings.options.onCreated?.({
98
+ organization,
99
+ member: stored
100
+ });
101
+ return {
102
+ organization,
103
+ member: stored
104
+ };
105
+ };
106
+ /** Adds someone with an account as a member: organization role `member` unless given, and the app roles given. */
107
+ const addMember = async (auth, input) => {
108
+ const organization = await findOrganizationByKey(auth, input.organization);
109
+ const user = await userByEmail(auth, input.email);
110
+ const { adapter } = await contextOf(auth);
111
+ if (await findMember(adapter, {
112
+ organizationId: organization.id,
113
+ userId: user.id
114
+ })) throw new Error(`${input.email} is a member of ${organization.slug} already`);
115
+ const role = checkOrganizationRole(input.role ?? "member");
116
+ const appRoles = checkAppRoles(auth, input.appRoles ?? []);
117
+ return adapter.create({
118
+ model: "member",
119
+ data: {
120
+ organizationId: organization.id,
121
+ userId: user.id,
122
+ role,
123
+ appRoles,
124
+ createdAt: /* @__PURE__ */ new Date()
125
+ }
126
+ });
127
+ };
128
+ const memberOrThrow = async (auth, organizationKey, email) => {
129
+ const organization = await findOrganizationByKey(auth, organizationKey);
130
+ const user = await userByEmail(auth, email);
131
+ const member = await findMember((await contextOf(auth)).adapter, {
132
+ organizationId: organization.id,
133
+ userId: user.id
134
+ });
135
+ if (!member) throw new Error(`${email} is not a member of ${organization.slug}`);
136
+ return {
137
+ organization,
138
+ member
139
+ };
140
+ };
141
+ const refuseLastOwner = async (auth, member, action) => {
142
+ if (organizationRoleOf(member) !== "owner") return;
143
+ if ((await membersOf((await contextOf(auth)).adapter, member.organizationId)).filter((other) => organizationRoleOf(other) === "owner").length <= 1) throw new Error(`Cannot ${action} the last owner`);
144
+ };
145
+ /** Changes a member's organization role, app roles, or both. */
146
+ const setMemberRoles = async (auth, input) => {
147
+ const { member } = await memberOrThrow(auth, input.organization, input.email);
148
+ const { adapter } = await contextOf(auth);
149
+ if (input.role !== void 0) {
150
+ const role = checkOrganizationRole(input.role);
151
+ if (role !== "owner") await refuseLastOwner(auth, member, "demote");
152
+ await adapter.update({
153
+ model: "member",
154
+ where: [{
155
+ field: "id",
156
+ value: member.id
157
+ }],
158
+ update: { role }
159
+ });
160
+ }
161
+ if (input.appRoles !== void 0) await setAppRoles(adapter, member.id, checkAppRoles(auth, input.appRoles));
162
+ return findMember(adapter, {
163
+ organizationId: member.organizationId,
164
+ userId: member.userId
165
+ });
166
+ };
167
+ /** Removes a member; refuses the last owner. */
168
+ const removeMember = async (auth, input) => {
169
+ const { member } = await memberOrThrow(auth, input.organization, input.email);
170
+ await refuseLastOwner(auth, member, "remove");
171
+ await (await contextOf(auth)).adapter.delete({
172
+ model: "member",
173
+ where: [{
174
+ field: "id",
175
+ value: member.id
176
+ }]
177
+ });
178
+ };
179
+ /** Every organization, oldest first, with its members' emails and roles. */
180
+ const listOrganizations = async (auth) => {
181
+ settingsOf(auth);
182
+ const { adapter, internalAdapter } = await contextOf(auth);
183
+ const organizations = await adapter.findMany({
184
+ model: "organization",
185
+ sortBy: {
186
+ field: "createdAt",
187
+ direction: "asc"
188
+ },
189
+ limit: everyRow
190
+ });
191
+ return Promise.all(organizations.map(async (organization) => ({
192
+ id: organization.id,
193
+ name: organization.name,
194
+ slug: organization.slug,
195
+ members: await Promise.all((await membersOf(adapter, organization.id)).map(async (member) => ({
196
+ email: (await internalAdapter.findUserById(member.userId))?.email ?? member.userId,
197
+ role: organizationRoleOf(member),
198
+ appRoles: member.appRoles
199
+ })))
200
+ })));
201
+ };
202
+ /**
203
+ * Makes `userId` the owner, with the creator's app roles, of every organization without members: for the first user of
204
+ * an app whose organizations were made before anyone could own them, such as by its seed. Returns how many.
205
+ */
206
+ const ownOrganizationsWithoutMembers = async (auth, userId) => {
207
+ const settings = settingsOf(auth);
208
+ const { adapter } = await contextOf(auth);
209
+ const organizations = await adapter.findMany({
210
+ model: "organization",
211
+ limit: everyRow
212
+ });
213
+ let owned = 0;
214
+ for (const organization of organizations) {
215
+ if ((await membersOf(adapter, organization.id)).length > 0) continue;
216
+ await adapter.create({
217
+ model: "member",
218
+ data: {
219
+ organizationId: organization.id,
220
+ userId,
221
+ role: "owner",
222
+ appRoles: settings.creatorAppRoles,
223
+ createdAt: /* @__PURE__ */ new Date()
224
+ }
225
+ });
226
+ owned++;
227
+ }
228
+ return owned;
229
+ };
230
+ /** Gives an app role to every owner of every organization, for a role the project adds once organizations exist. */
231
+ const addAppRoleToOwners = async (auth, appRole) => {
232
+ checkAppRoles(auth, [appRole]);
233
+ const { adapter } = await contextOf(auth);
234
+ const owners = (await adapter.findMany({
235
+ model: "member",
236
+ limit: everyRow
237
+ })).filter((member) => organizationRoleOf(member) === "owner");
238
+ let changed = 0;
239
+ for (const owner of owners) {
240
+ const current = (await findMember(adapter, {
241
+ organizationId: owner.organizationId,
242
+ userId: owner.userId
243
+ })).appRoles;
244
+ if (current.includes(appRole)) continue;
245
+ await setAppRoles(adapter, owner.id, [...current, appRole]);
246
+ changed++;
247
+ }
248
+ return changed;
249
+ };
250
+ //#endregion
251
+ export { addAppRoleToOwners, addMember, createOrganization, findOrganizationByKey, listOrganizations, ownOrganizationsWithoutMembers, removeMember, setMemberRoles };
@@ -0,0 +1,133 @@
1
+ import { noGlobalRole } from "./role-definitions.js";
2
+ import { findOrganization } from "./store.js";
3
+ import { invitationLink, invitationToken } from "./invitation-token.js";
4
+ import { effectiveSignInPolicy } from "../sign-in-policy.js";
5
+ import { readSignInPolicy } from "../policy-store.js";
6
+ import { acceptInvitation, invitationOfToken } from "./accept-invitation.js";
7
+ import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
8
+ import { setSessionCookie } from "better-auth/cookies";
9
+ import * as z$1 from "zod";
10
+ //#region src/better-auth/organizations/invitation-endpoints.ts
11
+ const minPasswordLength = 12;
12
+ const maxPasswordLength = 128;
13
+ const tokenBody = z$1.object({ token: z$1.string().min(1) });
14
+ const startIn = (context, sessionToken, organizationId) => context.internalAdapter.updateSession(sessionToken, { activeOrganizationId: organizationId });
15
+ /**
16
+ * The endpoints an emailed invitation link uses: what it is for, accepting it signed in, and creating an account from it
17
+ * for someone who has none. Open sign-up stays off; an invitation is an address someone vouched for, and the link in
18
+ * the mail proves the person reads it.
19
+ */
20
+ const invitationEndpoints = ({ resolved, mail }) => ({
21
+ /** `GET /invitation?token=`: the organization, who invites, the address and roles, until when, and whether the address has an account. */
22
+ getInvitation: createAuthEndpoint("/invitation", {
23
+ method: "GET",
24
+ query: tokenBody
25
+ }, async (ctx) => {
26
+ const invitation = await invitationOfToken(ctx.context, ctx.query.token);
27
+ const organization = await findOrganization(ctx.context.adapter, invitation.organizationId);
28
+ const inviter = await ctx.context.internalAdapter.findUserById(invitation.inviterId);
29
+ const account = await ctx.context.internalAdapter.findUserByEmail(invitation.email.toLowerCase());
30
+ return ctx.json({
31
+ organization: {
32
+ id: invitation.organizationId,
33
+ name: organization?.name ?? ""
34
+ },
35
+ inviter: { name: inviter?.name || inviter?.email || "" },
36
+ email: invitation.email,
37
+ role: invitation.role,
38
+ appRoles: invitation.appRoles,
39
+ expiresAt: new Date(invitation.expiresAt).toISOString(),
40
+ hasAccount: Boolean(account)
41
+ });
42
+ }),
43
+ /** `POST /invitation/accept` with `{ token }`, signed in as the invited address: becomes a member, and works there. */
44
+ acceptInvitation: createAuthEndpoint("/invitation/accept", {
45
+ method: "POST",
46
+ body: tokenBody,
47
+ use: [sessionMiddleware]
48
+ }, async (ctx) => {
49
+ const invitation = await invitationOfToken(ctx.context, ctx.body.token);
50
+ const { user, session } = ctx.context.session;
51
+ const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
52
+ await startIn(ctx.context, session.token, invitation.organizationId);
53
+ return ctx.json({ member });
54
+ }),
55
+ /**
56
+ * `POST /invitation/sign-up` with `{ token, name, password? }`, for an address without an account: creates it, as
57
+ * verified and with no global role, accepts the invitation and signs the person in there. A password is needed unless
58
+ * the sign-in policy offers emailed codes; the policy's required methods are then set up as after any sign-in.
59
+ */
60
+ signUpFromInvitation: createAuthEndpoint("/invitation/sign-up", {
61
+ method: "POST",
62
+ body: z$1.object({
63
+ token: z$1.string().min(1),
64
+ name: z$1.string().trim().min(1).max(200),
65
+ password: z$1.string().optional()
66
+ })
67
+ }, async (ctx) => {
68
+ const invitation = await invitationOfToken(ctx.context, ctx.body.token);
69
+ const { internalAdapter } = ctx.context;
70
+ if (await internalAdapter.findUserByEmail(invitation.email.toLowerCase())) throw new APIError("BAD_REQUEST", {
71
+ code: "ACCOUNT_EXISTS",
72
+ message: "This address has an account already: sign in to accept the invitation."
73
+ });
74
+ const policy = effectiveSignInPolicy((await readSignInPolicy(ctx.context.adapter)).policy, { mail });
75
+ const { password } = ctx.body;
76
+ if (password !== void 0 && policy.password === "forbidden") throw new APIError("BAD_REQUEST", {
77
+ code: "SIGN_IN_METHOD_FORBIDDEN",
78
+ message: "This app does not sign in with passwords; leave the password out."
79
+ });
80
+ if (password === void 0 && policy.emailCode !== "allowed") throw new APIError("BAD_REQUEST", {
81
+ code: "PASSWORD_REQUIRED",
82
+ message: "Choose a password to sign in with."
83
+ });
84
+ if (password !== void 0 && (password.length < minPasswordLength || password.length > maxPasswordLength)) throw new APIError("BAD_REQUEST", {
85
+ code: "PASSWORD_LENGTH",
86
+ message: `Choose a password of ${minPasswordLength} to ${maxPasswordLength} characters.`
87
+ });
88
+ const user = await internalAdapter.createUser({
89
+ email: invitation.email.toLowerCase(),
90
+ name: ctx.body.name,
91
+ emailVerified: true,
92
+ role: noGlobalRole
93
+ }, { method: "invitation" });
94
+ if (password !== void 0) await internalAdapter.linkAccount({
95
+ userId: user.id,
96
+ providerId: "credential",
97
+ accountId: user.id,
98
+ password: await ctx.context.password.hash(password)
99
+ });
100
+ const member = await acceptInvitation(ctx.context, invitation, user, resolved.membershipLimit);
101
+ const session = await internalAdapter.createSession(user.id, false);
102
+ await startIn(ctx.context, session.token, invitation.organizationId);
103
+ await setSessionCookie(ctx, {
104
+ session: {
105
+ ...session,
106
+ activeOrganizationId: invitation.organizationId
107
+ },
108
+ user
109
+ });
110
+ return ctx.json({ member });
111
+ })
112
+ });
113
+ /**
114
+ * Without a mailer the invitation cannot be emailed, so whoever invites gets its link in the answer, once, to pass on.
115
+ * Resending gives a new link, since it moves the expiry.
116
+ */
117
+ const invitationLinkHook = (resolved) => ({
118
+ matcher: (context) => context.path === "/organization/invite-member",
119
+ handler: createAuthMiddleware(async (ctx) => {
120
+ const returned = ctx.context.returned;
121
+ if (!returned || returned instanceof Response || returned instanceof Error || !returned.id || !returned.expiresAt) return;
122
+ const token = await invitationToken(ctx.context.secret, {
123
+ id: returned.id,
124
+ expiresAt: new Date(returned.expiresAt)
125
+ });
126
+ ctx.context.returned = {
127
+ ...returned,
128
+ link: invitationLink(resolved.invitationUrl, token)
129
+ };
130
+ })
131
+ });
132
+ //#endregion
133
+ export { invitationEndpoints, invitationLinkHook };
@@ -0,0 +1,17 @@
1
+ //#region src/better-auth/organizations/invitation-mail.ts
2
+ /** The invitation email: who invites whom to which organization, the link, and until when it works. */
3
+ const invitationEmail = ({ to, inviter, organization, url, expiresAt }) => ({
4
+ to,
5
+ subject: `Join ${organization}`,
6
+ text: [
7
+ `${inviter} invites you to join ${organization}. To accept, open this link:`,
8
+ "",
9
+ url,
10
+ "",
11
+ `The link works until ${new Date(expiresAt).toUTCString()}.`,
12
+ "If you do not want to join, ignore this email: nothing happens without you.",
13
+ ""
14
+ ].join("\n")
15
+ });
16
+ //#endregion
17
+ export { invitationEmail };
@@ -0,0 +1,27 @@
1
+ //#region src/better-auth/organizations/invitation-token.ts
2
+ const encoder = new TextEncoder();
3
+ const base64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
4
+ const signature = async (secret, id, expiresAt) => {
5
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
6
+ name: "HMAC",
7
+ hash: "SHA-256"
8
+ }, false, ["sign"]);
9
+ return base64url(await crypto.subtle.sign("HMAC", key, encoder.encode(`invitation:${id}:${new Date(expiresAt).getTime()}`)));
10
+ };
11
+ const invitationToken = async (secret, invitation) => `${invitation.id}.${await signature(secret, invitation.id, invitation.expiresAt)}`;
12
+ /** The invitation id a token names; the signature is checked against the stored expiry with `verifyInvitationToken`. */
13
+ const invitationIdOf = (token) => {
14
+ const dot = token.lastIndexOf(".");
15
+ return dot > 0 ? token.slice(0, dot) : void 0;
16
+ };
17
+ const verifyInvitationToken = async (secret, token, invitation) => {
18
+ const expected = await invitationToken(secret, invitation);
19
+ if (expected.length !== token.length) return false;
20
+ let difference = 0;
21
+ for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ token.charCodeAt(index);
22
+ return difference === 0;
23
+ };
24
+ /** The link in the email: the app's invitation page with the token. */
25
+ const invitationLink = (pageUrl, token) => `${pageUrl}?token=${encodeURIComponent(token)}`;
26
+ //#endregion
27
+ export { invitationIdOf, invitationLink, invitationToken, verifyInvitationToken };
@@ -0,0 +1,182 @@
1
+ import "./role-definitions.js";
2
+ import { checkAppRoleChange, effectiveRoles, globalRoles } from "./app-roles.js";
3
+ import { findMember, findMemberById, findOrganization, organizationRoleOf, setAppRoles } from "./store.js";
4
+ import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
5
+ import * as z$1 from "zod";
6
+ //#region src/better-auth/organizations/member-endpoints.ts
7
+ const memberNotFound = {
8
+ code: "MEMBER_NOT_FOUND",
9
+ message: "There is no such member."
10
+ };
11
+ const searchLimit = 20;
12
+ /**
13
+ * Protobase's member endpoints beside Better Auth's: changing a member's app roles, handing ownership over in one step,
14
+ * and, for people with a global role, finding any organization to work in.
15
+ */
16
+ const memberEndpoints = ({ resolved }) => ({
17
+ /**
18
+ * `POST /organization/set-app-roles` with `{ memberId, appRoles }`, by the organization's owners and admins, or a
19
+ * superuser: only roles the caller may give change (`checkAppRoleChange`).
20
+ */
21
+ setAppRoles: createAuthEndpoint("/organization/set-app-roles", {
22
+ method: "POST",
23
+ use: [sessionMiddleware],
24
+ body: z$1.object({
25
+ memberId: z$1.string().min(1),
26
+ appRoles: z$1.array(z$1.string())
27
+ })
28
+ }, async (ctx) => {
29
+ const { adapter } = ctx.context;
30
+ const { user } = ctx.context.session;
31
+ const target = await findMemberById(adapter, ctx.body.memberId);
32
+ if (!target) throw new APIError("BAD_REQUEST", memberNotFound);
33
+ const actor = await findMember(adapter, {
34
+ organizationId: target.organizationId,
35
+ userId: user.id
36
+ });
37
+ const global = globalRoles(user.role);
38
+ if (!(global.includes("admin") || actor !== void 0 && organizationRoleOf(actor) !== "member")) throw new APIError("FORBIDDEN", {
39
+ code: "NOT_AN_ORGANIZATION_ADMIN",
40
+ message: "Only the organization's owners and admins can change members' roles."
41
+ });
42
+ const after = [...new Set(ctx.body.appRoles)];
43
+ const refusal = checkAppRoleChange({
44
+ held: effectiveRoles(global, actor?.appRoles),
45
+ before: target.appRoles,
46
+ after,
47
+ definitions: resolved.definitions
48
+ });
49
+ if (refusal) throw new APIError(refusal.status, {
50
+ code: refusal.code,
51
+ message: refusal.message
52
+ });
53
+ await setAppRoles(adapter, target.id, after);
54
+ return ctx.json({ member: {
55
+ ...target,
56
+ appRoles: after
57
+ } });
58
+ }),
59
+ /**
60
+ * `POST /organization/transfer-ownership` with `{ memberId, keepAs? }`, by an owner: in one transaction the member
61
+ * becomes owner and the caller `keepAs` (`admin` unless `member`). App roles stay as they are.
62
+ */
63
+ transferOwnership: createAuthEndpoint("/organization/transfer-ownership", {
64
+ method: "POST",
65
+ use: [sessionMiddleware],
66
+ body: z$1.object({
67
+ memberId: z$1.string().min(1),
68
+ keepAs: z$1.enum(["admin", "member"]).optional()
69
+ })
70
+ }, async (ctx) => {
71
+ const { adapter } = ctx.context;
72
+ const target = await findMemberById(adapter, ctx.body.memberId);
73
+ if (!target) throw new APIError("BAD_REQUEST", memberNotFound);
74
+ const actor = await findMember(adapter, {
75
+ organizationId: target.organizationId,
76
+ userId: ctx.context.session.user.id
77
+ });
78
+ if (!actor || organizationRoleOf(actor) !== "owner") throw new APIError("FORBIDDEN", {
79
+ code: "NOT_THE_OWNER",
80
+ message: "Only an owner can hand the organization over."
81
+ });
82
+ if (actor.id === target.id) throw new APIError("BAD_REQUEST", {
83
+ code: "ALREADY_THE_OWNER",
84
+ message: "Choose another member to hand the organization over to."
85
+ });
86
+ await adapter.transaction(async (trx) => {
87
+ await trx.update({
88
+ model: "member",
89
+ where: [{
90
+ field: "id",
91
+ value: target.id
92
+ }],
93
+ update: { role: "owner" }
94
+ });
95
+ await trx.update({
96
+ model: "member",
97
+ where: [{
98
+ field: "id",
99
+ value: actor.id
100
+ }],
101
+ update: { role: ctx.body.keepAs ?? "admin" }
102
+ });
103
+ });
104
+ return ctx.json({ owner: target.id });
105
+ }),
106
+ /**
107
+ * `GET /organization/current`: the organization the session works in, with the caller's organization role and app
108
+ * roles there (none for someone with a global role who is not a member) and their global roles; `null` for none.
109
+ */
110
+ currentOrganization: createAuthEndpoint("/organization/current", {
111
+ method: "GET",
112
+ use: [sessionMiddleware]
113
+ }, async (ctx) => {
114
+ const { user, session } = ctx.context.session;
115
+ const global = globalRoles(user.role);
116
+ const organizationId = session.activeOrganizationId;
117
+ const organization = organizationId ? await findOrganization(ctx.context.adapter, organizationId) : null;
118
+ if (!organization) return ctx.json({
119
+ organization: null,
120
+ globalRoles: global
121
+ });
122
+ const member = await findMember(ctx.context.adapter, {
123
+ organizationId: organization.id,
124
+ userId: user.id
125
+ });
126
+ if (!member && global.length === 0) return ctx.json({
127
+ organization: null,
128
+ globalRoles: global
129
+ });
130
+ const { id, name, slug, logo } = organization;
131
+ return ctx.json({
132
+ organization: {
133
+ id,
134
+ name,
135
+ slug,
136
+ logo: logo ?? null
137
+ },
138
+ ...member && {
139
+ role: organizationRoleOf(member),
140
+ appRoles: member.appRoles,
141
+ memberId: member.id
142
+ },
143
+ globalRoles: global
144
+ });
145
+ }),
146
+ /**
147
+ * `GET /organization/search?query=`, for someone with a global role, who may work in any organization: the first
148
+ * organizations whose name or slug contains the text, by name.
149
+ */
150
+ searchOrganizations: createAuthEndpoint("/organization/search", {
151
+ method: "GET",
152
+ use: [sessionMiddleware],
153
+ query: z$1.object({ query: z$1.string().default("") })
154
+ }, async (ctx) => {
155
+ if (globalRoles(ctx.context.session.user.role).length === 0) throw new APIError("FORBIDDEN", {
156
+ code: "GLOBAL_ROLE_REQUIRED",
157
+ message: "Only people with a global role can open organizations they are not a member of."
158
+ });
159
+ const text = ctx.query.query.trim();
160
+ const find = (field) => ctx.context.adapter.findMany({
161
+ model: "organization",
162
+ ...text && { where: [{
163
+ field,
164
+ value: text,
165
+ operator: "contains"
166
+ }] },
167
+ sortBy: {
168
+ field: "name",
169
+ direction: "asc"
170
+ },
171
+ limit: searchLimit
172
+ });
173
+ const organizations = [...new Map([...await find("name"), ...text ? await find("slug") : []].map((organization) => [organization.id, organization])).values()].sort((a, b) => a.name.localeCompare(b.name)).slice(0, searchLimit);
174
+ return ctx.json({ organizations: organizations.map(({ id, name, slug }) => ({
175
+ id,
176
+ name,
177
+ slug
178
+ })) });
179
+ })
180
+ });
181
+ //#endregion
182
+ export { memberEndpoints };
@@ -0,0 +1,48 @@
1
+ import { RoleDefinitions } from "./role-definitions.js";
2
+ import { StoredMember, StoredOrganization } from "./store.js";
3
+ import { OrganizationOptions } from "better-auth/plugins/organization";
4
+ //#region src/better-auth/organizations/options.d.ts
5
+ type OrganizationHooks = NonNullable<OrganizationOptions['organizationHooks']>;
6
+ export type OrganizationsOptions = {
7
+ /** The app roles an organization's creator holds there: `'all'` (default), every role a member can hold, or a list. */
8
+ creatorAppRoles?: 'all' | string[];
9
+ /** Who may create organizations: `'admins'` (default), superusers only, or `'everyone'` signed in. */
10
+ create?: 'admins' | 'everyone';
11
+ /** How long an invitation works, in days. Default 7. */
12
+ invitationDays?: number;
13
+ /** The most members an organization can have. Default 1000. */
14
+ membershipLimit?: number;
15
+ /**
16
+ * The id of a new organization. Default `crypto.randomUUID()`. `.tenant` columns hold it: `text` or `uuid` for the
17
+ * default, or an integer column when this takes the next value of the app's own organizations sequence.
18
+ */
19
+ generateId?: () => string | Promise<string>;
20
+ /**
21
+ * What deleting an organization does with its records: `'refuse'` (default) deletes only an organization without
22
+ * rows in any `.tenant` resource; `'cascade'` leaves it to the project's foreign keys.
23
+ */
24
+ deleteRecords?: 'refuse' | 'cascade';
25
+ /** The page an invitation link opens, with `?token=`. Default `{baseURL}/-/invitation`. */
26
+ invitationUrl?: string;
27
+ /** Runs after an organization is created, with its owner's membership, for example to add the app's own row for it. */
28
+ onCreated?: (input: {
29
+ organization: StoredOrganization;
30
+ member: StoredMember;
31
+ }) => Promise<void>;
32
+ /** Runs before an organization is deleted; throwing refuses the deletion. */
33
+ beforeDelete?: (organization: StoredOrganization) => Promise<void>;
34
+ /** Better Auth's organization hooks, run after Protobase's own. */
35
+ hooks?: OrganizationHooks;
36
+ };
37
+ export type ResolvedOrganizations = {
38
+ definitions: RoleDefinitions;
39
+ creatorAppRoles: string[];
40
+ create: 'admins' | 'everyone';
41
+ invitationExpiresIn: number;
42
+ membershipLimit: number;
43
+ generateId: () => string | Promise<string>;
44
+ deleteRecords: 'refuse' | 'cascade';
45
+ invitationUrl: string;
46
+ options: OrganizationsOptions;
47
+ };
48
+ //#endregion
@@ -0,0 +1,23 @@
1
+ import { membershipRoles } from "./role-definitions.js";
2
+ //#region src/better-auth/organizations/options.ts
3
+ const resolveOrganizations = (options, definitions, baseURL) => {
4
+ const holdable = membershipRoles(definitions);
5
+ const creatorAppRoles = options.creatorAppRoles === void 0 || options.creatorAppRoles === "all" ? holdable : options.creatorAppRoles;
6
+ const unknown = creatorAppRoles.find((name) => !holdable.includes(name));
7
+ if (unknown !== void 0) throw new Error(`creatorAppRoles names "${unknown}", which a member cannot hold: ${holdable.join(", ")}`);
8
+ const days = options.invitationDays ?? 7;
9
+ if (!(days > 0)) throw new Error("invitationDays must be more than 0");
10
+ return {
11
+ definitions,
12
+ creatorAppRoles,
13
+ create: options.create ?? "admins",
14
+ invitationExpiresIn: Math.round(days * 24 * 60 * 60),
15
+ membershipLimit: options.membershipLimit ?? 1e3,
16
+ generateId: options.generateId ?? (() => crypto.randomUUID()),
17
+ deleteRecords: options.deleteRecords ?? "refuse",
18
+ invitationUrl: options.invitationUrl ?? `${baseURL.replace(/\/$/, "")}/-/invitation`,
19
+ options
20
+ };
21
+ };
22
+ //#endregion
23
+ export { resolveOrganizations };