@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,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; and `staffSignIn`, the
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 };