@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,128 @@
|
|
|
1
|
+
import "./role-definitions.js";
|
|
2
|
+
import { checkAppRoleChange, effectiveRoles, globalRoles, parseAppRoles } from "./app-roles.js";
|
|
3
|
+
import { findMember } from "./store.js";
|
|
4
|
+
import { invitationEmail } from "./invitation-mail.js";
|
|
5
|
+
import { invitationLink, invitationToken } from "./invitation-token.js";
|
|
6
|
+
import { APIError } from "better-auth/api";
|
|
7
|
+
import { organization } from "better-auth/plugins/organization";
|
|
8
|
+
//#region src/better-auth/organizations/organization-plugin.ts
|
|
9
|
+
/** The snake_case names of the organization plugin's tables and columns, with the app roles of members and invitations. */
|
|
10
|
+
const organizationNames = {
|
|
11
|
+
organization: { fields: { createdAt: "created_at" } },
|
|
12
|
+
member: {
|
|
13
|
+
fields: {
|
|
14
|
+
organizationId: "organization_id",
|
|
15
|
+
userId: "user_id",
|
|
16
|
+
createdAt: "created_at"
|
|
17
|
+
},
|
|
18
|
+
additionalFields: { appRoles: {
|
|
19
|
+
type: "string[]",
|
|
20
|
+
required: false,
|
|
21
|
+
defaultValue: [],
|
|
22
|
+
input: false,
|
|
23
|
+
fieldName: "app_roles"
|
|
24
|
+
} }
|
|
25
|
+
},
|
|
26
|
+
invitation: {
|
|
27
|
+
fields: {
|
|
28
|
+
organizationId: "organization_id",
|
|
29
|
+
inviterId: "inviter_id",
|
|
30
|
+
expiresAt: "expires_at",
|
|
31
|
+
createdAt: "created_at"
|
|
32
|
+
},
|
|
33
|
+
additionalFields: { appRoles: {
|
|
34
|
+
type: "string[]",
|
|
35
|
+
required: false,
|
|
36
|
+
defaultValue: [],
|
|
37
|
+
input: true,
|
|
38
|
+
fieldName: "app_roles"
|
|
39
|
+
} }
|
|
40
|
+
},
|
|
41
|
+
session: { fields: { activeOrganizationId: "active_organization_id" } }
|
|
42
|
+
};
|
|
43
|
+
/** Runs Protobase's hook, then the project's, with the same data; data either returns is merged, the project's last. */
|
|
44
|
+
const mergeHooks = (own, project = {}) => {
|
|
45
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(own), ...Object.keys(project)]);
|
|
46
|
+
return Object.fromEntries([...keys].map((key) => {
|
|
47
|
+
const first = own[key];
|
|
48
|
+
const second = project[key];
|
|
49
|
+
if (!first || !second) return [key, first ?? second];
|
|
50
|
+
const both = async (...args) => {
|
|
51
|
+
const data = {
|
|
52
|
+
...(await first(...args))?.data,
|
|
53
|
+
...(await second(...args))?.data
|
|
54
|
+
};
|
|
55
|
+
return Object.keys(data).length > 0 ? { data } : void 0;
|
|
56
|
+
};
|
|
57
|
+
return [key, both];
|
|
58
|
+
}));
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Better Auth's organization plugin as Protobase uses it: organization roles `owner`, `admin` and `member` with Better
|
|
62
|
+
* Auth's own access control, app roles on members and invitations, ids from `generateId`, creators holding
|
|
63
|
+
* `creatorAppRoles`, invitations checked against what the inviter may give and mailed with a signed link, and the
|
|
64
|
+
* project's hooks after Protobase's.
|
|
65
|
+
*/
|
|
66
|
+
const organizationPlugin = ({ resolved, secret, mailer, context }) => {
|
|
67
|
+
const { definitions } = resolved;
|
|
68
|
+
const own = {
|
|
69
|
+
beforeCreateOrganization: async () => ({ data: { id: await resolved.generateId() } }),
|
|
70
|
+
beforeAddMember: async ({ member }) => member.role === "owner" && member.appRoles === void 0 ? { data: { appRoles: resolved.creatorAppRoles } } : void 0,
|
|
71
|
+
afterCreateOrganization: async ({ organization, member }) => {
|
|
72
|
+
await resolved.options.onCreated?.({
|
|
73
|
+
organization,
|
|
74
|
+
member: {
|
|
75
|
+
...member,
|
|
76
|
+
appRoles: parseAppRoles(member.appRoles)
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
beforeCreateInvitation: async ({ invitation, inviter }) => {
|
|
81
|
+
const membership = await findMember((await context()).adapter, {
|
|
82
|
+
organizationId: invitation.organizationId,
|
|
83
|
+
userId: inviter.id
|
|
84
|
+
});
|
|
85
|
+
const held = effectiveRoles(globalRoles(inviter.role), membership?.appRoles);
|
|
86
|
+
const refusal = checkAppRoleChange({
|
|
87
|
+
held,
|
|
88
|
+
before: [],
|
|
89
|
+
after: parseAppRoles(invitation.appRoles),
|
|
90
|
+
definitions
|
|
91
|
+
});
|
|
92
|
+
if (refusal) throw new APIError(refusal.status, {
|
|
93
|
+
code: refusal.code,
|
|
94
|
+
message: refusal.message
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
afterRemoveMember: async ({ member, user }) => {
|
|
98
|
+
if (globalRoles(user.role).length > 0) return;
|
|
99
|
+
const { internalAdapter } = await context();
|
|
100
|
+
for (const session of await internalAdapter.listSessions(member.userId)) if (session.activeOrganizationId === member.organizationId) await internalAdapter.updateSession(session.token, { activeOrganizationId: null });
|
|
101
|
+
},
|
|
102
|
+
beforeDeleteOrganization: async ({ organization }) => {
|
|
103
|
+
await resolved.options.beforeDelete?.(organization);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return organization({
|
|
107
|
+
creatorRole: "owner",
|
|
108
|
+
allowUserToCreateOrganization: (user) => resolved.create === "everyone" || globalRoles(user.role).includes("admin"),
|
|
109
|
+
invitationExpiresIn: resolved.invitationExpiresIn,
|
|
110
|
+
membershipLimit: resolved.membershipLimit,
|
|
111
|
+
requireEmailVerificationOnInvitation: true,
|
|
112
|
+
cancelPendingInvitationsOnReInvite: true,
|
|
113
|
+
schema: organizationNames,
|
|
114
|
+
...mailer && { sendInvitationEmail: async ({ invitation, organization, inviter }) => {
|
|
115
|
+
const token = await invitationToken(secret, invitation);
|
|
116
|
+
await mailer.send(invitationEmail({
|
|
117
|
+
to: invitation.email,
|
|
118
|
+
inviter: inviter.user.name || inviter.user.email,
|
|
119
|
+
organization: organization.name,
|
|
120
|
+
url: invitationLink(resolved.invitationUrl, token),
|
|
121
|
+
expiresAt: invitation.expiresAt
|
|
122
|
+
}));
|
|
123
|
+
} },
|
|
124
|
+
organizationHooks: mergeHooks(own, resolved.options.hooks)
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
128
|
+
export { organizationNames, organizationPlugin };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { invitationEndpoints, invitationLinkHook } from "./invitation-endpoints.js";
|
|
2
|
+
import { memberEndpoints } from "./member-endpoints.js";
|
|
3
|
+
//#region src/better-auth/organizations/protobase-plugin.ts
|
|
4
|
+
/**
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
const protobaseOrganizationPlugin = ({ resolved, mail }) => ({
|
|
10
|
+
id: "protobase-organizations",
|
|
11
|
+
endpoints: {
|
|
12
|
+
...invitationEndpoints({
|
|
13
|
+
resolved,
|
|
14
|
+
mail
|
|
15
|
+
}),
|
|
16
|
+
...memberEndpoints({ resolved })
|
|
17
|
+
},
|
|
18
|
+
hooks: { after: mail ? [] : [invitationLinkHook(resolved)] }
|
|
19
|
+
});
|
|
20
|
+
//#endregion
|
|
21
|
+
export { protobaseOrganizationPlugin };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/better-auth/organizations/role-definitions.d.ts
|
|
2
|
+
/** A role: its label, or its label and the other roles its holders may also give (see `grantableRoles`). */
|
|
3
|
+
export type RoleDefinition = string | {
|
|
4
|
+
label: string;
|
|
5
|
+
grants?: string[];
|
|
6
|
+
};
|
|
7
|
+
/** The roles of an app: names only, or each with its definition. */
|
|
8
|
+
export type RolesInput = string[] | Record<string, RoleDefinition>;
|
|
9
|
+
export type RoleDefinitions = {
|
|
10
|
+
/** `admin` first, then the others in the order given. */
|
|
11
|
+
names: string[];
|
|
12
|
+
labels: Record<string, string>;
|
|
13
|
+
grants: Record<string, string[]>;
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//#region src/better-auth/organizations/role-definitions.ts
|
|
2
|
+
const roleName = /^[a-z][a-z0-9_-]*$/;
|
|
3
|
+
/** The superuser: holds every role in every organization. */
|
|
4
|
+
const superuserRole = "admin";
|
|
5
|
+
/** The admin plugin's role for an account without a global role; in organization mode it grants nothing. */
|
|
6
|
+
const noGlobalRole = "user";
|
|
7
|
+
const organizationRoleNames = ["owner", "member"];
|
|
8
|
+
const builtInLabels = {
|
|
9
|
+
[superuserRole]: "Superuser",
|
|
10
|
+
[noGlobalRole]: "No global role"
|
|
11
|
+
};
|
|
12
|
+
const entriesOf = (input) => Array.isArray(input) ? input.map((name) => [name, void 0]) : Object.entries(input);
|
|
13
|
+
/**
|
|
14
|
+
* The roles with `admin` first (added when missing), their labels and grants. With `organizations`, every role needs a
|
|
15
|
+
* label (`admin` and `user` have one built in), `user` is always there, as the role of an account without a global
|
|
16
|
+
* role, and `owner` and `member` are refused, since they name organization roles.
|
|
17
|
+
*/
|
|
18
|
+
const roleDefinitions = (input = ["admin", "user"], { organizations = false } = {}) => {
|
|
19
|
+
const entries = entriesOf(input);
|
|
20
|
+
const bad = entries.find(([name]) => !roleName.test(name));
|
|
21
|
+
if (bad) throw new Error(`Invalid role "${bad[0]}": use lowercase letters, digits, "_" or "-"`);
|
|
22
|
+
const given = new Map(entries);
|
|
23
|
+
const names = [
|
|
24
|
+
superuserRole,
|
|
25
|
+
...new Set(entries.map(([name]) => name).filter((name) => name !== superuserRole)),
|
|
26
|
+
...organizations && !given.has("user") ? [noGlobalRole] : []
|
|
27
|
+
];
|
|
28
|
+
const labels = {};
|
|
29
|
+
const grants = {};
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const definition = given.get(name);
|
|
32
|
+
const label = typeof definition === "string" ? definition : definition?.label ?? builtInLabels[name];
|
|
33
|
+
if (label !== void 0) labels[name] = label;
|
|
34
|
+
grants[name] = typeof definition === "object" ? definition.grants ?? [] : [];
|
|
35
|
+
const unknown = grants[name].find((granted) => !names.includes(granted));
|
|
36
|
+
if (unknown !== void 0) throw new Error(`Role "${name}" grants "${unknown}", which is not one of the roles: ${names.join(", ")}`);
|
|
37
|
+
}
|
|
38
|
+
if (!organizations) return {
|
|
39
|
+
names,
|
|
40
|
+
labels,
|
|
41
|
+
grants
|
|
42
|
+
};
|
|
43
|
+
const reserved = names.find((name) => organizationRoleNames.includes(name));
|
|
44
|
+
if (reserved) throw new Error(`Role "${reserved}" names an organization role; choose another name`);
|
|
45
|
+
const unlabelled = names.find((name) => labels[name] === void 0);
|
|
46
|
+
if (unlabelled) throw new Error(`Role "${unlabelled}" needs a label: with organizations, roles are given as { name: 'Label' }`);
|
|
47
|
+
return {
|
|
48
|
+
names,
|
|
49
|
+
labels,
|
|
50
|
+
grants
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/** The roles a member of an organization can hold there: every role but the superuser's and the placeholder. */
|
|
54
|
+
const membershipRoles = ({ names }) => names.filter((name) => name !== "admin" && name !== "user");
|
|
55
|
+
//#endregion
|
|
56
|
+
export { membershipRoles, noGlobalRole, roleDefinitions, superuserRole };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { globalRoles } from "./app-roles.js";
|
|
2
|
+
import { initialOrganization } from "./active-organization.js";
|
|
3
|
+
import { findMember, findOrganization, membershipsOf, organizationRoleOf, summaries } from "./store.js";
|
|
4
|
+
//#region src/better-auth/organizations/session-organization.ts
|
|
5
|
+
/** The user field that keeps the organization someone last worked in, for their next sign-in. */
|
|
6
|
+
const lastOrganizationField = { lastOrganizationId: {
|
|
7
|
+
type: "string",
|
|
8
|
+
required: false,
|
|
9
|
+
input: false,
|
|
10
|
+
fieldName: "last_organization_id"
|
|
11
|
+
} };
|
|
12
|
+
/** Whether someone may work in an organization: a member, or holding a global role, which enters every one. */
|
|
13
|
+
const mayEnter = async (adapter, user, organizationId) => {
|
|
14
|
+
if (await findMember(adapter, {
|
|
15
|
+
organizationId,
|
|
16
|
+
userId: user.id
|
|
17
|
+
})) return true;
|
|
18
|
+
return globalRoles(user.role).length > 0 && await findOrganization(adapter, organizationId) !== null;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* The token's organization claims for a session: `org`, the active organization, while the person may still work in
|
|
22
|
+
* it; for a member also `org_role`, their organization role, and `app_roles`, their app roles there. Read when the
|
|
23
|
+
* token is issued, so a removed member or a changed role shows within the token's lifetime.
|
|
24
|
+
*/
|
|
25
|
+
const organizationClaims = async (adapter, user, activeOrganizationId) => {
|
|
26
|
+
if (!activeOrganizationId) return {};
|
|
27
|
+
const member = await findMember(adapter, {
|
|
28
|
+
organizationId: activeOrganizationId,
|
|
29
|
+
userId: user.id
|
|
30
|
+
});
|
|
31
|
+
if (member) return {
|
|
32
|
+
org: activeOrganizationId,
|
|
33
|
+
org_role: organizationRoleOf(member),
|
|
34
|
+
app_roles: member.appRoles
|
|
35
|
+
};
|
|
36
|
+
if (await mayEnter(adapter, user, activeOrganizationId)) return { org: activeOrganizationId };
|
|
37
|
+
return {};
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Better Auth's session hooks for organizations: a new session starts in the organization the person last worked in
|
|
41
|
+
* (`initialOrganization`), and a session's change of organization is kept on the user as the next one's start.
|
|
42
|
+
*/
|
|
43
|
+
const sessionOrganizationHooks = (context) => ({
|
|
44
|
+
create: { before: async (session) => {
|
|
45
|
+
const { adapter, internalAdapter } = await context();
|
|
46
|
+
const user = await internalAdapter.findUserById(session.userId);
|
|
47
|
+
if (!user) return;
|
|
48
|
+
const last = user.lastOrganizationId;
|
|
49
|
+
const activeOrganizationId = initialOrganization({
|
|
50
|
+
memberships: summaries(await membershipsOf(adapter, user.id)),
|
|
51
|
+
last,
|
|
52
|
+
entersAny: globalRoles(user.role).length > 0,
|
|
53
|
+
lastExists: Boolean(last) && await findOrganization(adapter, last) !== null
|
|
54
|
+
});
|
|
55
|
+
return activeOrganizationId ? { data: {
|
|
56
|
+
...session,
|
|
57
|
+
activeOrganizationId
|
|
58
|
+
} } : void 0;
|
|
59
|
+
} },
|
|
60
|
+
update: { after: async (session) => {
|
|
61
|
+
if (!session.activeOrganizationId) return;
|
|
62
|
+
const { internalAdapter } = await context();
|
|
63
|
+
const user = await internalAdapter.findUserById(session.userId);
|
|
64
|
+
if (user && user.lastOrganizationId !== session.activeOrganizationId) await internalAdapter.updateUser(session.userId, { lastOrganizationId: session.activeOrganizationId });
|
|
65
|
+
} }
|
|
66
|
+
});
|
|
67
|
+
//#endregion
|
|
68
|
+
export { lastOrganizationField, mayEnter, organizationClaims, sessionOrganizationHooks };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createAuthMiddleware } from "better-auth/api";
|
|
2
|
+
//#region src/better-auth/organizations/store.d.ts
|
|
3
|
+
export type OrganizationRole = 'owner' | 'admin' | 'member';
|
|
4
|
+
export type StoredMember = {
|
|
5
|
+
id: string;
|
|
6
|
+
organizationId: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
role: string;
|
|
9
|
+
appRoles: string[];
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
};
|
|
12
|
+
export type StoredOrganization = {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
slug: string;
|
|
16
|
+
logo?: string | null;
|
|
17
|
+
createdAt: Date;
|
|
18
|
+
};
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseAppRoles } from "./app-roles.js";
|
|
2
|
+
//#region src/better-auth/organizations/store.ts
|
|
3
|
+
/** Better Auth's `findMany` returns 100 rows unless told otherwise; these lists are read whole. */
|
|
4
|
+
const everyRow = 1e5;
|
|
5
|
+
const memberOf = (row) => ({
|
|
6
|
+
...row,
|
|
7
|
+
appRoles: parseAppRoles(row.appRoles)
|
|
8
|
+
});
|
|
9
|
+
/** Better Auth stores several organization roles comma separated; Protobase gives each member one. */
|
|
10
|
+
const organizationRoleOf = (member) => {
|
|
11
|
+
const roles = member.role.split(",").map((name) => name.trim());
|
|
12
|
+
return roles.includes("owner") ? "owner" : roles.includes("admin") ? "admin" : "member";
|
|
13
|
+
};
|
|
14
|
+
const findMember = async (adapter, where) => {
|
|
15
|
+
const row = await adapter.findOne({
|
|
16
|
+
model: "member",
|
|
17
|
+
where: [{
|
|
18
|
+
field: "organizationId",
|
|
19
|
+
value: where.organizationId
|
|
20
|
+
}, {
|
|
21
|
+
field: "userId",
|
|
22
|
+
value: where.userId
|
|
23
|
+
}]
|
|
24
|
+
});
|
|
25
|
+
return row ? memberOf(row) : void 0;
|
|
26
|
+
};
|
|
27
|
+
const findMemberById = async (adapter, id) => {
|
|
28
|
+
const row = await adapter.findOne({
|
|
29
|
+
model: "member",
|
|
30
|
+
where: [{
|
|
31
|
+
field: "id",
|
|
32
|
+
value: id
|
|
33
|
+
}]
|
|
34
|
+
});
|
|
35
|
+
return row ? memberOf(row) : void 0;
|
|
36
|
+
};
|
|
37
|
+
const membershipsOf = async (adapter, userId) => (await adapter.findMany({
|
|
38
|
+
model: "member",
|
|
39
|
+
where: [{
|
|
40
|
+
field: "userId",
|
|
41
|
+
value: userId
|
|
42
|
+
}],
|
|
43
|
+
limit: everyRow
|
|
44
|
+
})).map(memberOf);
|
|
45
|
+
const membersOf = async (adapter, organizationId) => (await adapter.findMany({
|
|
46
|
+
model: "member",
|
|
47
|
+
where: [{
|
|
48
|
+
field: "organizationId",
|
|
49
|
+
value: organizationId
|
|
50
|
+
}],
|
|
51
|
+
limit: everyRow
|
|
52
|
+
})).map(memberOf);
|
|
53
|
+
const findOrganization = (adapter, id) => adapter.findOne({
|
|
54
|
+
model: "organization",
|
|
55
|
+
where: [{
|
|
56
|
+
field: "id",
|
|
57
|
+
value: id
|
|
58
|
+
}]
|
|
59
|
+
});
|
|
60
|
+
const summaries = (members) => members.map(({ organizationId, createdAt }) => ({
|
|
61
|
+
organizationId,
|
|
62
|
+
createdAt
|
|
63
|
+
}));
|
|
64
|
+
const setAppRoles = (adapter, memberId, appRoles) => adapter.update({
|
|
65
|
+
model: "member",
|
|
66
|
+
where: [{
|
|
67
|
+
field: "id",
|
|
68
|
+
value: memberId
|
|
69
|
+
}],
|
|
70
|
+
update: { appRoles: [...appRoles] }
|
|
71
|
+
});
|
|
72
|
+
//#endregion
|
|
73
|
+
export { everyRow, findMember, findMemberById, findOrganization, membersOf, membershipsOf, organizationRoleOf, setAppRoles, summaries };
|
|
@@ -51,7 +51,11 @@ const accountChanges = [
|
|
|
51
51
|
"/passkey/generate-register-options",
|
|
52
52
|
"/passkey/verify-registration",
|
|
53
53
|
"/passkey/update-passkey",
|
|
54
|
-
"/passkey/delete-passkey"
|
|
54
|
+
"/passkey/delete-passkey",
|
|
55
|
+
"/invitation/accept",
|
|
56
|
+
"/organization/leave",
|
|
57
|
+
"/organization/transfer-ownership",
|
|
58
|
+
"/organization/delete"
|
|
55
59
|
];
|
|
56
60
|
const staffCannotChange = {
|
|
57
61
|
code: "STAFF_CANNOT_CHANGE_SIGN_IN",
|
|
@@ -6,8 +6,8 @@ import { policyRefusal, staffSignInPath } from "./sign-in-enforcement.js";
|
|
|
6
6
|
import { staffSignInEmail } from "./sign-in-mail.js";
|
|
7
7
|
import { logStaffSignIn } from "./staff-log.js";
|
|
8
8
|
import { APIError, createAuthEndpoint } from "better-auth/api";
|
|
9
|
-
import * as z$1 from "zod";
|
|
10
9
|
import { expireCookie, setSessionCookie } from "better-auth/cookies";
|
|
10
|
+
import * as z$1 from "zod";
|
|
11
11
|
//#region src/better-auth/staff-sign-in-endpoints.ts
|
|
12
12
|
const startLifetimeMs = 6e5;
|
|
13
13
|
const stateCookie = "staff_state";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { membershipRoles } from "./organizations/role-definitions.js";
|
|
1
2
|
import { effectiveSignInPolicy } from "./sign-in-policy.js";
|
|
2
3
|
import { readSignInPolicy } from "./policy-store.js";
|
|
3
4
|
import { hasUsers } from "./users.js";
|
|
@@ -8,8 +9,9 @@ import { Hono } from "hono";
|
|
|
8
9
|
* `signInMethods`, the ways to sign in the sign-in policy leaves on (`password`, `emailCode`, `passkey`), so it offers
|
|
9
10
|
* only those; `passwordReset` when reset mail can be sent and passwords are on, so it offers "Forgot password?" only
|
|
10
11
|
* then; the ids of the `socialProviders` people can sign in with, so it offers "Continue with GitHub" when `github` is
|
|
11
|
-
* one; `platformSignIn`, the id and name of the platform's provider when it is one of them;
|
|
12
|
-
* operator provider's name, when staff can sign in as people
|
|
12
|
+
* one; `platformSignIn`, the id and name of the platform's provider when it is one of them; `staffSignIn`, the
|
|
13
|
+
* operator provider's name, when staff can sign in as people; and with organizations, who may create them and the roles
|
|
14
|
+
* with their labels, `membership` for those a member can hold, and the roles each one's holders may also give.
|
|
13
15
|
*/
|
|
14
16
|
const statusRoute = (auth) => {
|
|
15
17
|
const app = new Hono();
|
|
@@ -31,7 +33,16 @@ const statusRoute = (auth) => {
|
|
|
31
33
|
passwordReset: auth.passwordReset && policy.password === "allowed",
|
|
32
34
|
socialProviders,
|
|
33
35
|
...platformSignIn && { platformSignIn },
|
|
34
|
-
...staffSignIn && { staffSignIn }
|
|
36
|
+
...staffSignIn && { staffSignIn },
|
|
37
|
+
...auth.organizations && { organizations: {
|
|
38
|
+
create: auth.organizations.create,
|
|
39
|
+
roles: auth.roleDefinitions.names.map((name) => ({
|
|
40
|
+
name,
|
|
41
|
+
label: auth.roleDefinitions.labels[name] ?? name,
|
|
42
|
+
membership: membershipRoles(auth.roleDefinitions).includes(name),
|
|
43
|
+
grants: auth.roleDefinitions.grants[name] ?? []
|
|
44
|
+
}))
|
|
45
|
+
} }
|
|
35
46
|
});
|
|
36
47
|
});
|
|
37
48
|
return app;
|
|
@@ -28,7 +28,8 @@ export declare const hasUsers: (auth: AdminAuth) => Promise<boolean>;
|
|
|
28
28
|
* Creates a user directly in the admin store. Host side only (no HTTP route creates accounts while the store is empty):
|
|
29
29
|
* the first user is always an `admin`, later ones get `role` (default `user`). The address counts as verified, so the
|
|
30
30
|
* user can also sign in with an emailed code. `accounts` link the user to sign-in providers up front, so a provider
|
|
31
|
-
* signs them in by its own id whatever address it reports; one that signs in another user already is refused.
|
|
31
|
+
* signs them in by its own id whatever address it reports; one that signs in another user already is refused. With
|
|
32
|
+
* organizations, the first user also owns every organization without members, such as those an app's seed made.
|
|
32
33
|
*/
|
|
33
34
|
export declare const createUser: (auth: AdminAuth, input: NewUser) => Promise<{
|
|
34
35
|
id: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseRoles } from "./parse-roles.js";
|
|
2
|
+
import { ownOrganizationsWithoutMembers } from "./organizations/host.js";
|
|
2
3
|
//#region src/better-auth/users.ts
|
|
3
4
|
/** The roles this auth instance allows, `admin` first. */
|
|
4
5
|
const roleChoices = (auth) => [...auth.roles];
|
|
@@ -21,7 +22,8 @@ const checkAccounts = async (auth, accounts) => {
|
|
|
21
22
|
* Creates a user directly in the admin store. Host side only (no HTTP route creates accounts while the store is empty):
|
|
22
23
|
* the first user is always an `admin`, later ones get `role` (default `user`). The address counts as verified, so the
|
|
23
24
|
* user can also sign in with an emailed code. `accounts` link the user to sign-in providers up front, so a provider
|
|
24
|
-
* signs them in by its own id whatever address it reports; one that signs in another user already is refused.
|
|
25
|
+
* signs them in by its own id whatever address it reports; one that signs in another user already is refused. With
|
|
26
|
+
* organizations, the first user also owns every organization without members, such as those an app's seed made.
|
|
25
27
|
*/
|
|
26
28
|
const createUser = async (auth, input) => {
|
|
27
29
|
const first = !await hasUsers(auth);
|
|
@@ -41,6 +43,7 @@ const createUser = async (auth, input) => {
|
|
|
41
43
|
userId: user.id,
|
|
42
44
|
...account
|
|
43
45
|
});
|
|
46
|
+
if (first && auth.organizations) await ownOrganizationsWithoutMembers(auth, user.id);
|
|
44
47
|
return {
|
|
45
48
|
id: user.id,
|
|
46
49
|
email: user.email,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/changes/actor.ts
|
|
2
|
+
/** The setting a write transaction sets so the change capture records who wrote, when a resource has events. */
|
|
3
|
+
const actorSettings = (deps, session) => {
|
|
4
|
+
if (!deps.actorSetting) return {};
|
|
5
|
+
const actor = {
|
|
6
|
+
id: session.user.id,
|
|
7
|
+
roles: session.user.roles
|
|
8
|
+
};
|
|
9
|
+
return { [deps.actorSetting]: JSON.stringify(actor) };
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { actorSettings };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { captureBody, captureFunctionSql, createTriggerSql, dropTriggerSql, outboxSql, quoteIdent, resourceTriggers, tableSql } from "./capture-sql.js";
|
|
2
|
+
//#region src/changes/capture-plan.ts
|
|
3
|
+
const sameTrigger = (found, wanted) => found.type === wanted.type && found.args.length === wanted.args.length && found.args.every((arg, index) => arg === wanted.args[index]);
|
|
4
|
+
const tableOf = (state, model) => {
|
|
5
|
+
const table = state.tables[model.name];
|
|
6
|
+
if (!table) throw new Error(`The table ${tableSql(model)} of resource "${model.name}" does not exist`);
|
|
7
|
+
return table;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Compares the database with the resources that declare events. Fails on what it cannot change safely: a missing
|
|
11
|
+
* table, two resources with events on one table, or a trigger of the same name that calls another function.
|
|
12
|
+
*/
|
|
13
|
+
const capturePlan = (schema, models, state) => {
|
|
14
|
+
const withEvents = models.filter((model) => (model.events ?? []).length > 0);
|
|
15
|
+
const capture = state.capture;
|
|
16
|
+
const current = capture !== void 0 && capture.definer && capture.source === captureBody(schema);
|
|
17
|
+
const wanted = withEvents.flatMap((model) => resourceTriggers(model).map((trigger) => ({
|
|
18
|
+
model,
|
|
19
|
+
trigger,
|
|
20
|
+
table: tableOf(state, model)
|
|
21
|
+
})));
|
|
22
|
+
const tables = /* @__PURE__ */ new Map();
|
|
23
|
+
for (const { model, table } of wanted) {
|
|
24
|
+
const other = tables.get(table);
|
|
25
|
+
if (other && other !== model.name) throw new Error(`Not implemented: resources "${other}" and "${model.name}" both have events on table ${tableSql(model)}; declare them on one`);
|
|
26
|
+
tables.set(table, model.name);
|
|
27
|
+
}
|
|
28
|
+
const create = wanted.filter(({ model, trigger, table }) => {
|
|
29
|
+
const found = state.triggers.find((candidate) => candidate.table === table && candidate.name === trigger.name);
|
|
30
|
+
if (found && found.function !== capture?.oid) throw new Error(`Table ${tableSql(model)} has a trigger "${trigger.name}" of its own; rename it, since events need that name`);
|
|
31
|
+
return !found || !sameTrigger(found, trigger);
|
|
32
|
+
});
|
|
33
|
+
const drop = state.triggers.filter((found) => found.function === capture?.oid).filter((found) => !wanted.some(({ trigger, table }) => table === found.table && trigger.name === found.name));
|
|
34
|
+
return {
|
|
35
|
+
setup: withEvents.length > 0 && (!state.outbox || !current),
|
|
36
|
+
create: create.map(({ model, trigger }) => ({
|
|
37
|
+
model,
|
|
38
|
+
trigger
|
|
39
|
+
})),
|
|
40
|
+
drop: drop.map(({ name, tableSql }) => ({
|
|
41
|
+
name,
|
|
42
|
+
tableSql
|
|
43
|
+
}))
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
/** The plan as SQL statements, each made to apply over what exists already; `undefined` when there is nothing to do. */
|
|
47
|
+
const planSql = (schema, plan) => {
|
|
48
|
+
const statements = [
|
|
49
|
+
...plan.setup ? [
|
|
50
|
+
`create schema if not exists ${quoteIdent(schema)}`,
|
|
51
|
+
outboxSql(schema),
|
|
52
|
+
captureFunctionSql(schema)
|
|
53
|
+
] : [],
|
|
54
|
+
...plan.drop.map(({ name, tableSql }) => dropTriggerSql(name, tableSql)),
|
|
55
|
+
...plan.create.flatMap(({ model, trigger }) => [dropTriggerSql(trigger.name, tableSql(model)), createTriggerSql(schema, trigger, tableSql(model))])
|
|
56
|
+
];
|
|
57
|
+
return statements.length === 0 ? void 0 : `${statements.join(";\n\n")};`;
|
|
58
|
+
};
|
|
59
|
+
/** The resources whose changes would not be captured as the config declares: what a server refuses to start with. */
|
|
60
|
+
const uncaptured = (plan, models) => plan.setup ? models.filter((model) => (model.events ?? []).length > 0).map((model) => model.name) : [...new Set(plan.create.map(({ model }) => model.name))];
|
|
61
|
+
//#endregion
|
|
62
|
+
export { capturePlan, planSql, uncaptured };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { EventsOptions } from "./events-options.js";
|
|
2
|
+
import { ResourceModel } from "@protobase/schema";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/changes/capture-schema.d.ts
|
|
5
|
+
/** The command that writes what the config needs as a migration file of the project. */
|
|
6
|
+
export declare const migrationCommand = "protobase migration";
|
|
7
|
+
/**
|
|
8
|
+
* The SQL that brings the database to the capture the resources' events need: the schema with the outbox and
|
|
9
|
+
* `capture()`, and a trigger pair per resource with events, recreated when its fields changed; triggers of resources
|
|
10
|
+
* without events any more are dropped. `undefined` when the database is current. Tables are never dropped.
|
|
11
|
+
*/
|
|
12
|
+
export declare const changesMigration: (db: Db, models: readonly ResourceModel[], options?: EventsOptions) => Promise<string | undefined>;
|
|
13
|
+
/**
|
|
14
|
+
* Refuses to go on while a resource's events would not be captured as declared: a missing or out-of-date schema, or
|
|
15
|
+
* trigger. The server never changes the schema itself: the project's migrations do, before the new version serves.
|
|
16
|
+
*/
|
|
17
|
+
export declare const checkChanges: (db: Db, models: readonly ResourceModel[], options?: EventsOptions) => Promise<void>;
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { changesSchema } from "./events-options.js";
|
|
2
|
+
import { quoteIdent, tableSql } from "./capture-sql.js";
|
|
3
|
+
import { capturePlan, planSql, uncaptured } from "./capture-plan.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/changes/capture-schema.ts
|
|
6
|
+
/** The command that writes what the config needs as a migration file of the project. */
|
|
7
|
+
const migrationCommand = "protobase migration";
|
|
8
|
+
const triggerArgs = (hex) => {
|
|
9
|
+
const bytes = new Uint8Array((hex.match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
|
|
10
|
+
return new TextDecoder().decode(bytes).split("\0").slice(0, -1);
|
|
11
|
+
};
|
|
12
|
+
const rowsOf = async (db, query) => (await query.execute(db)).rows;
|
|
13
|
+
const readCaptureState = async (db, schema, models) => {
|
|
14
|
+
const [outbox] = await rowsOf(db, sql`select to_regclass(${`${quoteIdent(schema)}."outbox"`}) is not null as found`);
|
|
15
|
+
const [capture] = await rowsOf(db, sql`select p.oid::text as oid, p.prosrc as source, p.prosecdef as definer from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = ${schema} and p.proname = 'capture' and p.pronargs = 0`);
|
|
16
|
+
const tables = {};
|
|
17
|
+
for (const model of models) {
|
|
18
|
+
const [found] = await rowsOf(db, sql`select to_regclass(${tableSql(model)})::oid::text as oid`);
|
|
19
|
+
tables[model.name] = found?.oid ?? null;
|
|
20
|
+
}
|
|
21
|
+
const oids = Object.values(tables).filter((oid) => oid !== null);
|
|
22
|
+
const triggers = await rowsOf(db, sql`select t.tgrelid::text as "table", format('%I.%I', ns.nspname, c.relname) as "tableSql", t.tgname as name,
|
|
23
|
+
t.tgfoid::text as "function", t.tgtype::int as type, encode(t.tgargs, 'hex') as args
|
|
24
|
+
from pg_trigger t join pg_class c on c.oid = t.tgrelid join pg_namespace ns on ns.oid = c.relnamespace
|
|
25
|
+
where not t.tgisinternal and (t.tgfoid::text = ${capture?.oid ?? ""} or t.tgrelid::text = any(${oids}::text[]))`);
|
|
26
|
+
return {
|
|
27
|
+
outbox: outbox?.found ?? false,
|
|
28
|
+
...capture && { capture },
|
|
29
|
+
triggers: triggers.map((trigger) => ({
|
|
30
|
+
...trigger,
|
|
31
|
+
args: triggerArgs(trigger.args)
|
|
32
|
+
})),
|
|
33
|
+
tables
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const plan = async (db, models, options) => {
|
|
37
|
+
const schema = changesSchema(options);
|
|
38
|
+
const withEvents = models.filter((model) => (model.events ?? []).length > 0);
|
|
39
|
+
return {
|
|
40
|
+
schema,
|
|
41
|
+
plan: capturePlan(schema, models, await readCaptureState(db, schema, withEvents))
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* The SQL that brings the database to the capture the resources' events need: the schema with the outbox and
|
|
46
|
+
* `capture()`, and a trigger pair per resource with events, recreated when its fields changed; triggers of resources
|
|
47
|
+
* without events any more are dropped. `undefined` when the database is current. Tables are never dropped.
|
|
48
|
+
*/
|
|
49
|
+
const changesMigration = async (db, models, options) => {
|
|
50
|
+
const { schema, plan: found } = await plan(db, models, options);
|
|
51
|
+
return planSql(schema, found);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Refuses to go on while a resource's events would not be captured as declared: a missing or out-of-date schema, or
|
|
55
|
+
* trigger. The server never changes the schema itself: the project's migrations do, before the new version serves.
|
|
56
|
+
*/
|
|
57
|
+
const checkChanges = async (db, models, options) => {
|
|
58
|
+
if (!models.some((model) => model.events)) return;
|
|
59
|
+
const { plan: found } = await plan(db, models, options);
|
|
60
|
+
const missing = uncaptured(found, models);
|
|
61
|
+
if (missing.length === 0) return;
|
|
62
|
+
throw new Error(`The changes of ${missing.join(", ")} are not captured as their events need: their triggers or the changes schema are missing or out of date. Write the change as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { changesMigration, checkChanges, migrationCommand };
|