@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.
- package/dist/assistant/conversations.d.ts +1 -1
- package/dist/assistant/tools.d.ts +1 -1
- package/dist/assistant/turn.d.ts +2 -2
- package/dist/audit/origin.js +12 -0
- package/dist/auth/jwt.d.ts +2 -0
- package/dist/auth/jwt.js +5 -3
- package/dist/batch/reorder.js +1 -1
- package/dist/batch/run-batch.js +2 -1
- package/dist/better-auth/authenticator.d.ts +7 -3
- package/dist/better-auth/authenticator.js +24 -3
- package/dist/better-auth/create-auth.d.ts +619 -81
- package/dist/better-auth/create-auth.js +64 -27
- package/dist/better-auth/organizations/accept-invitation.js +80 -0
- package/dist/better-auth/organizations/active-organization.js +12 -0
- package/dist/better-auth/organizations/admin-routes.js +83 -0
- package/dist/better-auth/organizations/app-roles.js +43 -0
- package/dist/better-auth/organizations/host.d.ts +53 -0
- package/dist/better-auth/organizations/host.js +251 -0
- package/dist/better-auth/organizations/invitation-endpoints.js +133 -0
- package/dist/better-auth/organizations/invitation-mail.js +17 -0
- package/dist/better-auth/organizations/invitation-token.js +27 -0
- package/dist/better-auth/organizations/member-endpoints.js +182 -0
- package/dist/better-auth/organizations/options.d.ts +48 -0
- package/dist/better-auth/organizations/options.js +23 -0
- package/dist/better-auth/organizations/organization-plugin.js +128 -0
- package/dist/better-auth/organizations/protobase-plugin.js +21 -0
- package/dist/better-auth/organizations/role-definitions.d.ts +15 -0
- package/dist/better-auth/organizations/role-definitions.js +56 -0
- package/dist/better-auth/organizations/session-organization.js +68 -0
- package/dist/better-auth/organizations/store.d.ts +19 -0
- package/dist/better-auth/organizations/store.js +73 -0
- package/dist/better-auth/staff-checks.js +5 -1
- package/dist/better-auth/staff-sign-in-endpoints.js +1 -1
- package/dist/better-auth/status-route.js +14 -3
- package/dist/better-auth/users.d.ts +2 -1
- package/dist/better-auth/users.js +4 -1
- package/dist/changes/actor.js +12 -0
- package/dist/changes/capture-plan.js +62 -0
- package/dist/changes/capture-schema.d.ts +18 -0
- package/dist/changes/capture-schema.js +65 -0
- package/dist/changes/capture-sql.js +108 -0
- package/dist/changes/coalesce.d.ts +38 -0
- package/dist/changes/coalesce.js +69 -0
- package/dist/changes/events-options.d.ts +10 -0
- package/dist/changes/events-options.js +10 -0
- package/dist/changes/match-events.d.ts +9 -0
- package/dist/changes/match-events.js +55 -0
- package/dist/changes/resource-change.d.ts +27 -0
- package/dist/changes/resource-change.js +43 -0
- package/dist/create-admin.d.ts +11 -2
- package/dist/create-admin.js +33 -2
- package/dist/deps.d.ts +1 -0
- package/dist/files/attach.js +80 -0
- package/dist/files/cleanup-runner.d.ts +8 -0
- package/dist/files/cleanup-runner.js +58 -0
- package/dist/files/cleanup-schedule.d.ts +28 -0
- package/dist/files/cleanup-schedule.js +48 -0
- package/dist/files/detect.js +51 -0
- package/dist/files/download-url.js +43 -0
- package/dist/files/download.js +90 -0
- package/dist/files/duration.js +35 -0
- package/dist/files/file-cleanup.d.ts +18 -0
- package/dist/files/file-cleanup.js +17 -0
- package/dist/files/local-files.d.ts +17 -0
- package/dist/files/local-files.js +101 -0
- package/dist/files/media-types.js +99 -0
- package/dist/files/options.d.ts +15 -0
- package/dist/files/present.d.ts +15 -0
- package/dist/files/present.js +59 -0
- package/dist/files/processors.d.ts +15 -0
- package/dist/files/processors.js +39 -0
- package/dist/files/providers.js +70 -0
- package/dist/files/routes.js +26 -0
- package/dist/files/runtime.d.ts +3 -0
- package/dist/files/runtime.js +37 -0
- package/dist/files/signer.js +32 -0
- package/dist/files/store.d.ts +37 -0
- package/dist/files/ticket.js +87 -0
- package/dist/files/upload-body.js +85 -0
- package/dist/files/upload.js +124 -0
- package/dist/files/uri.js +47 -0
- package/dist/functions/define-function.d.ts +71 -0
- package/dist/functions/define-function.js +36 -0
- package/dist/functions/function-app.d.ts +17 -0
- package/dist/functions/function-app.js +19 -0
- package/dist/functions/read-json.d.ts +35 -0
- package/dist/functions/read-json.js +24 -0
- package/dist/functions/records.d.ts +43 -0
- package/dist/functions/records.js +31 -0
- package/dist/functions/require-env.d.ts +7 -0
- package/dist/functions/require-env.js +12 -0
- package/dist/functions/routes.js +60 -0
- package/dist/functions/verify-signature.d.ts +32 -0
- package/dist/functions/verify-signature.js +49 -0
- package/dist/index.d.ts +26 -3
- package/dist/index.js +17 -3
- package/dist/list-service.js +4 -2
- package/dist/openapi/resource-paths.js +78 -1
- package/dist/openapi/schemas.js +28 -3
- package/dist/project-config.d.ts +10 -1
- package/dist/project-config.js +41 -3
- package/dist/read-service.js +5 -1
- package/dist/request-access.d.ts +5 -0
- package/dist/request-access.js +9 -4
- package/dist/resource-source.d.ts +4 -1
- package/dist/routes/batch.js +1 -1
- package/dist/routes/collection.js +3 -1
- package/dist/routes/record.js +2 -5
- package/dist/transactions.js +10 -3
- package/dist/types.d.ts +25 -5
- package/dist/write-pipeline.js +4 -2
- package/dist/write-shared.js +6 -3
- package/dist/write-update.js +2 -1
- 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 };
|