@protobase/server 0.22.0 → 0.22.2

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 (28) hide show
  1. package/dist/better-auth/auth-mail/invitation-email.js +23 -27
  2. package/dist/better-auth/create-auth.d.ts +460 -24
  3. package/dist/better-auth/create-auth.js +12 -1
  4. package/dist/better-auth/invitations/account-from-invitation.js +50 -0
  5. package/dist/better-auth/invitations/invitation-endpoints.js +102 -0
  6. package/dist/better-auth/invitations/invitation-link-hook.js +34 -0
  7. package/dist/better-auth/invitations/invitation-of-token.js +24 -0
  8. package/dist/better-auth/{organizations → invitations}/invitation-token.js +7 -2
  9. package/dist/better-auth/last-admin-guard.js +62 -0
  10. package/dist/better-auth/last-admin.js +18 -0
  11. package/dist/better-auth/organizations/accept-invitation.js +4 -8
  12. package/dist/better-auth/organizations/invitation-endpoints.js +17 -114
  13. package/dist/better-auth/organizations/options.js +2 -1
  14. package/dist/better-auth/organizations/organization-plugin.js +1 -1
  15. package/dist/better-auth/organizations/protobase-plugin.js +7 -9
  16. package/dist/better-auth/organizations/role-definitions.js +5 -4
  17. package/dist/better-auth/passkey-plugin.js +1 -1
  18. package/dist/better-auth/roles/role-labels.js +14 -0
  19. package/dist/better-auth/sign-up/sign-up-plugin.js +1 -1
  20. package/dist/better-auth/status-route.js +3 -8
  21. package/dist/better-auth/users/app-invitation-store.js +62 -0
  22. package/dist/better-auth/users/user-endpoints.js +79 -0
  23. package/dist/better-auth/users/users-plugin.js +22 -0
  24. package/dist/better-auth/users.js +1 -2
  25. package/dist/check-views.js +5 -3
  26. package/dist/index.js +1 -1
  27. package/dist/view-related.js +11 -1
  28. package/package.json +4 -4
@@ -0,0 +1,79 @@
1
+ import { invitationLink, invitationToken } from "../invitations/invitation-token.js";
2
+ import { sendAuthEmail } from "../auth-mail/send-auth-email.js";
3
+ import { adminOnly } from "../admin-only.js";
4
+ import { createAppInvitation, deleteAppInvitation, listAppInvitations } from "./app-invitation-store.js";
5
+ import { roleLabels } from "../roles/role-labels.js";
6
+ import * as z$1 from "zod";
7
+ import { APIError, createAuthEndpoint } from "better-auth/api";
8
+ //#region src/better-auth/users/user-endpoints.ts
9
+ const admins = adminOnly("Only an admin manages users.");
10
+ const answerOf = ({ id, email, roles, expiresAt }) => ({
11
+ id,
12
+ email,
13
+ roles,
14
+ expiresAt: expiresAt.toISOString()
15
+ });
16
+ /**
17
+ * What an admin manages people with besides Better Auth's admin endpoints, which list, create, set roles and block:
18
+ * invitations to the app by email, which work with or without organizations, and the roles with their labels.
19
+ */
20
+ const userEndpoints = ({ definitions, mail, invitationUrl, invitationSeconds }) => ({
21
+ /**
22
+ * `POST /users/invite` with `{ email, roles }`: invites an address without an account to make one, with `roles`, from
23
+ * the link it is mailed. Inviting it again replaces the earlier invitation, so its link stops working.
24
+ */
25
+ inviteUser: createAuthEndpoint("/users/invite", {
26
+ method: "POST",
27
+ use: [admins],
28
+ body: z$1.object({
29
+ email: z$1.email(),
30
+ roles: z$1.array(z$1.string()).min(1)
31
+ })
32
+ }, async (ctx) => {
33
+ const unknown = ctx.body.roles.find((role) => !definitions.names.includes(role));
34
+ if (unknown !== void 0) throw new APIError("BAD_REQUEST", {
35
+ code: "UNKNOWN_ROLE",
36
+ message: `Unknown role "${unknown}"; use ${definitions.names.join(", ")}`
37
+ });
38
+ if (await ctx.context.internalAdapter.findUserByEmail(ctx.body.email.toLowerCase())) throw new APIError("BAD_REQUEST", {
39
+ code: "ACCOUNT_EXISTS",
40
+ message: "This address has an account already."
41
+ });
42
+ const inviter = ctx.context.session.user;
43
+ const invitation = await createAppInvitation(ctx.context, {
44
+ email: ctx.body.email,
45
+ roles: [...new Set(ctx.body.roles)],
46
+ inviterId: inviter.id
47
+ }, invitationSeconds);
48
+ if (mail) {
49
+ const url = invitationLink(invitationUrl, await invitationToken(ctx.context.secret, invitation));
50
+ await sendAuthEmail(mail, "invitationEmail", invitation.email, {
51
+ inviter: inviter.name || inviter.email,
52
+ url,
53
+ expiresAt: invitation.expiresAt
54
+ }, url);
55
+ }
56
+ return ctx.json(answerOf(invitation));
57
+ }),
58
+ /** `GET /users/invitations`: the pending invitations to the app, oldest first. */
59
+ listUserInvitations: createAuthEndpoint("/users/invitations", {
60
+ method: "GET",
61
+ use: [admins]
62
+ }, async (ctx) => ctx.json({ invitations: (await listAppInvitations(ctx.context)).map(answerOf) })),
63
+ /** `POST /users/invitations/cancel` with `{ id }`: revokes an invitation to the app, so its link stops working. */
64
+ cancelUserInvitation: createAuthEndpoint("/users/invitations/cancel", {
65
+ method: "POST",
66
+ use: [admins],
67
+ body: z$1.object({ id: z$1.string().min(1) })
68
+ }, async (ctx) => {
69
+ await deleteAppInvitation(ctx.context, ctx.body.id);
70
+ return ctx.json({ id: ctx.body.id });
71
+ }),
72
+ /** `GET /users/roles`: every role with its label, to choose from when inviting, creating or changing a user. */
73
+ userRoles: createAuthEndpoint("/users/roles", {
74
+ method: "GET",
75
+ use: [admins]
76
+ }, async (ctx) => ctx.json(roleLabels(definitions)))
77
+ });
78
+ //#endregion
79
+ export { userEndpoints };
@@ -0,0 +1,22 @@
1
+ import { invitationLinkHook } from "../invitations/invitation-link-hook.js";
2
+ import { invitationEndpoints } from "../invitations/invitation-endpoints.js";
3
+ import { userEndpoints } from "./user-endpoints.js";
4
+ //#region src/better-auth/users/users-plugin.ts
5
+ /**
6
+ * Managing people without the command line: invitations to the app and the roles to give, for admins, and the endpoints
7
+ * every invitation link opens, to the app or, with `organizations`, to one of them. Without a mailer, whoever invites
8
+ * gets the link in the answer.
9
+ */
10
+ const usersPlugin = (options) => ({
11
+ id: "protobase-users",
12
+ endpoints: {
13
+ ...userEndpoints(options),
14
+ ...invitationEndpoints({
15
+ mail: Boolean(options.mail),
16
+ ...options.organizations && { organizations: options.organizations }
17
+ })
18
+ },
19
+ hooks: { after: options.mail ? [] : [invitationLinkHook(["/users/invite"], options.invitationUrl)] }
20
+ });
21
+ //#endregion
22
+ export { usersPlugin };
@@ -1,4 +1,5 @@
1
1
  import { parseRoles } from "./parse-roles.js";
2
+ import { isActiveAdmin } from "./last-admin.js";
2
3
  import { ownOrganizationsWithoutMembers } from "./organizations/host.js";
3
4
  //#region src/better-auth/users.ts
4
5
  /** The roles this auth instance allows, `admin` first. */
@@ -61,8 +62,6 @@ const allUsers = async (auth) => {
61
62
  });
62
63
  };
63
64
  const rolesOf = (user) => parseRoles(user.role);
64
- const isAdmin = (user) => rolesOf(user).includes("admin");
65
- const isActiveAdmin = (user) => isAdmin(user) && !user.banned;
66
65
  const findUser = async (auth, email) => {
67
66
  const user = (await allUsers(auth)).find((candidate) => candidate.email.toLowerCase() === email.toLowerCase());
68
67
  if (!user) throw new Error(`No user with email ${email}`);
@@ -7,7 +7,7 @@ import { mapDisplayErrors, mapErrors } from "./view-map.js";
7
7
  import { relatedColumnErrors } from "./view-related-column.js";
8
8
  import { editableErrors } from "./view-editable.js";
9
9
  import { conditionProblems } from "@protobase/layout";
10
- import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, calendarErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields } from "@protobase/schema/internal";
10
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, calendarErrors, chartFields, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors } from "@protobase/schema/internal";
11
11
  import { isRecordCondition } from "@protobase/schema";
12
12
  //#region src/check-views.ts
13
13
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
@@ -85,9 +85,11 @@ const aiActionErrors = (view, model) => view.actions.flatMap(({ name, run }) =>
85
85
  ].map((error) => `action "${name}": ${error}`);
86
86
  });
87
87
  const inputErrors = (view, models) => view.actions.flatMap(({ name, run, inputs }) => {
88
- if (!inputs) return [];
88
+ const update = run?.kind === "update" ? updateSetErrors(run.values, inputs) : [];
89
+ if (!inputs) return update.map((error) => `action "${name}": ${error}`);
89
90
  return [
90
- ...run ? [`inputs need a handler, and a${run.kind === "update" || run.kind === "ai" ? "n" : ""} ${run.kind} action has none`] : [],
91
+ ...update,
92
+ ...run && run.kind !== "update" ? [`inputs need a handler, and a${run.kind === "ai" ? "n" : ""} ${run.kind} action has none`] : [],
91
93
  ...actionInputErrors(inputs),
92
94
  ...inputs.flatMap((input) => input.resource !== void 0 && !models[input.resource] ? [`input "${input.name}" picks from resource "${input.resource}", which the app does not have`] : [])
93
95
  ].map((error) => `action "${name}": ${error}`);
package/dist/index.js CHANGED
@@ -19,8 +19,8 @@ import { consoleAuditQueue } from "./audit/console-queue.js";
19
19
  import { webauthnPath } from "./better-auth/origins.js";
20
20
  import { readOperatorSettings } from "./better-auth/operator-provider.js";
21
21
  import { parseRoles } from "./better-auth/parse-roles.js";
22
- import { defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
23
22
  import { readPlatformSignIn } from "./better-auth/platform-sign-in.js";
23
+ import { defaultSignInPolicy, signInPolicyMethods } from "./better-auth/sign-in-policy.js";
24
24
  import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
25
25
  import { addAppRoleToOwners, addMember, createOrganization, listOrganizations, removeMember, setMemberRoles } from "./better-auth/organizations/host.js";
26
26
  import { createUser, deleteUser, hasUsers, listUsers, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
@@ -46,6 +46,16 @@ const openErrors = (item, models) => {
46
46
  if (!far) return [`open: unknown resource "${field.relation.resource}"`];
47
47
  return far.primaryKey.length === 1 ? [] : [`open: ${far.name} has a composite key, so its records cannot be opened from "${item.open}"`];
48
48
  };
49
+ /** What is wrong with matching `field` of the listed records against the `from` field of the owner's record. */
50
+ const fromErrors = (item, from, owner, target) => {
51
+ if (item.through) return ["from and through cannot be combined"];
52
+ const source = Object.hasOwn(owner.fields, from) ? owner.fields[from] : void 0;
53
+ if (!source) return [`from: unknown field "${from}" on ${owner.name}`];
54
+ if (source.sensitive) return [`from: "${from}" on ${owner.name} is sensitive`];
55
+ const match = Object.hasOwn(target.fields, item.field) ? target.fields[item.field] : void 0;
56
+ if (!match) return [];
57
+ return source.type === match.type && source.relation?.resource === match.relation?.resource ? [] : [`from: "${from}" on ${owner.name} and "${item.field}" on ${target.name} do not hold the same kind of value`];
58
+ };
49
59
  /**
50
60
  * What is wrong with a related section on the record page of `resource`, against these models; empty when it can be
51
61
  * shown. Against the full models it checks the config; against a caller's models, whether that caller can use it.
@@ -56,7 +66,7 @@ const relatedErrors = (item, resource, models) => {
56
66
  if (!owner || !target) return [`unknown resource "${owner ? item.resource : resource}"`];
57
67
  if (owner.primaryKey.length !== 1) return [`${resource} has a composite key, so a related section cannot match it`];
58
68
  return [
59
- ...item.through ? [...throughErrors(item.through, resource, models), ...matchErrors(target, item.field)] : matchErrors(target, item.field, resource),
69
+ ...item.from !== void 0 ? [...matchErrors(target, item.field), ...fromErrors(item, item.from, owner, target)] : item.through ? [...throughErrors(item.through, resource, models), ...matchErrors(target, item.field)] : matchErrors(target, item.field, resource),
60
70
  ...filterErrors(target, item.filter).map((error) => `filter: ${error}`),
61
71
  ...sortErrors(target, item.sort).map((error) => `sort: ${error}`),
62
72
  ...(item.columns ?? []).flatMap((column) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,9 +31,9 @@
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
33
  "@better-auth/sso": "^1.7.7",
34
- "@protobase/layout": "0.22.0",
35
- "@protobase/query": "0.22.0",
36
- "@protobase/schema": "0.22.0",
34
+ "@protobase/layout": "0.22.2",
35
+ "@protobase/query": "0.22.2",
36
+ "@protobase/schema": "0.22.2",
37
37
  "@react-email/render": "^2.1.0",
38
38
  "@scalar/hono-api-reference": "^0.12.10",
39
39
  "@sentry/core": "^11.7.0",