@protobase/server 0.19.14 → 0.19.16

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.
@@ -77,6 +77,11 @@ export type CreateAuthOptions = {
77
77
  encryptOAuthTokens?: boolean;
78
78
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
79
79
  onUserCreated?: UserCreatedHook;
80
+ /**
81
+ * The id of a new user, by any route. Default Better Auth's random 32-character text. `.owner()` fields hold it: a
82
+ * `text` column for the default, or an integer column when this takes the next value of the app's own users sequence.
83
+ */
84
+ generateUserId?: () => string | Promise<string>;
80
85
  /**
81
86
  * The identity provider the operator's staff sign in with to sign in as people of this app, for support. Defaults
82
87
  * to the one the platform passes in `PROTOBASE_OPERATOR_ISSUER`, `PROTOBASE_OPERATOR_CLIENT_ID` and
@@ -3744,7 +3749,15 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
3744
3749
  name?: string;
3745
3750
  slug?: string;
3746
3751
  logo?: string | null;
3747
- metadata?: Record<string, any>;
3752
+ metadata?
3753
+ /**
3754
+ * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
3755
+ * sign-in provider already resolved.
3756
+ */
3757
+ /**
3758
+ * The options `createAuth` gives Better Auth, with the roles, default role, mailer, operator provider and platform
3759
+ * sign-in provider already resolved.
3760
+ */ : Record<string, any>;
3748
3761
  [key: string]: any;
3749
3762
  };
3750
3763
  }>;
@@ -4354,6 +4367,8 @@ export declare const createAuth: (options: CreateAuthOptions) => import("better-
4354
4367
  roleDefinitions: RoleDefinitions;
4355
4368
  organizations: ResolvedOrganizations | undefined;
4356
4369
  defaultRole: string | undefined;
4370
+ /** New users get their ids from `generateUserId` instead of Better Auth's random text. */
4371
+ generatesUserIds: boolean;
4357
4372
  mail: boolean;
4358
4373
  passwordReset: boolean;
4359
4374
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
@@ -163,10 +163,14 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
163
163
  const given = String(user.role ?? "").split(",").map((name) => name.trim()).filter(Boolean);
164
164
  const unknown = given.find((name) => !roles.includes(name));
165
165
  if (given.length === 0 || unknown !== void 0) throw new APIError("BAD_REQUEST", { message: `Choose a role for the new user: ${roles.join(", ")}` });
166
- return { data: context?.path === "/admin/create-user" ? {
166
+ const withId = options.generateUserId ? {
167
167
  ...user,
168
+ id: await options.generateUserId()
169
+ } : user;
170
+ return { data: context?.path === "/admin/create-user" ? {
171
+ ...withId,
168
172
  emailVerified: true
169
- } : user };
173
+ } : withId };
170
174
  },
171
175
  ...options.onUserCreated && { after: options.onUserCreated }
172
176
  } },
@@ -266,6 +270,8 @@ const createAuth = (options) => {
266
270
  roleDefinitions: definitions,
267
271
  organizations,
268
272
  defaultRole,
273
+ /** New users get their ids from `generateUserId` instead of Better Auth's random text. */
274
+ generatesUserIds: Boolean(options.generateUserId),
269
275
  mail: Boolean(mailer),
270
276
  passwordReset: Boolean(mailer),
271
277
  /** Open sign-up is on and offered: the app asks for it, and can mail the codes. */
@@ -0,0 +1,15 @@
1
+ //#region src/check-owners.ts
2
+ /**
3
+ * Stops startup on an `.owner()` field that cannot hold the ids of the auth instance's users: Better Auth's own ids
4
+ * are random text, so the field must be text unless `generateUserId` makes the ids. Without an auth instance the
5
+ * ids come from the app's own authenticator, which this cannot see.
6
+ */
7
+ const checkOwnerFields = (registry, auth) => {
8
+ if (!auth || auth.generatesUserIds) return;
9
+ for (const entry of registry.entries) {
10
+ if (entry.ownerType === void 0 || entry.ownerType === "text") continue;
11
+ throw new Error(`${entry.name}.${entry.model.owner} is the owner of its records, so it holds the signed-in user's id, but it holds ${entry.ownerType} values and Better Auth's user ids are random text: make it a text column, or give createAuth a generateUserId that makes ${entry.ownerType} ids`);
12
+ }
13
+ };
14
+ //#endregion
15
+ export { checkOwnerFields };
@@ -4,7 +4,8 @@ import { cardFavoriteErrors, favoriteOwnerErrors } from "./view-card-favorite.js
4
4
  import { saveSearchErrors, searchesOfErrors } from "./view-saved-search.js";
5
5
  import { mapDisplayErrors, mapErrors } from "./view-map.js";
6
6
  import { relatedColumnErrors } from "./view-related-column.js";
7
- import { addressErrors, addressPlaceholders, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
7
+ import { editableErrors } from "./view-editable.js";
8
+ import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, linkActionError } from "@protobase/schema/internal";
8
9
  //#region src/check-views.ts
9
10
  const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
10
11
  const sensitiveErrors = (view, model) => {
@@ -57,6 +58,14 @@ const linkErrors = (view, model, models, pages) => view.actions.flatMap(({ name,
57
58
  source: model
58
59
  }).map((error) => `action "${name}": ${error}`) : [];
59
60
  });
61
+ const inputErrors = (view, models) => view.actions.flatMap(({ name, run, inputs }) => {
62
+ if (!inputs) return [];
63
+ return [
64
+ ...run ? [`inputs need a handler, and a${run.kind === "update" ? "n" : ""} ${run.kind} action has none`] : [],
65
+ ...actionInputErrors(inputs),
66
+ ...inputs.flatMap((input) => input.resource !== void 0 && !models[input.resource] ? [`input "${input.name}" picks from resource "${input.resource}", which the app does not have`] : [])
67
+ ].map((error) => `action "${name}": ${error}`);
68
+ });
60
69
  const createErrors = (view, pages) => view.create && !pages.has(view.create.page) ? [`create: unknown page "${view.create.page}"`] : [];
61
70
  const unknownFieldErrors = (view, model) => {
62
71
  const unknown = (names, what) => names.filter((name) => !Object.hasOwn(model.fields, name)).map((name) => `${what} names field "${name}", which ${model.name} does not have`);
@@ -85,6 +94,7 @@ const relatedColumnsErrors = (view, model, models) => (view.list?.relatedColumns
85
94
  ...view.list.columns.includes(column.name) ? [] : ["it is not among the list's columns"],
86
95
  ...relatedColumnErrors(column, view.resource, models)
87
96
  ].map((error) => `related column "${column.name}": ${error}`));
97
+ const listEditErrors = (view, model) => (view.list?.editable ?? []).flatMap((name) => [...view.list.columns.includes(name) ? [] : ["it is not among the list's columns"], ...editableErrors(name, model, view.fields[name])].map((error) => `editable field "${name}": ${error}`));
88
98
  const galleryErrors = (item, resource, model, models) => {
89
99
  const image = item.image === void 0 ? void 0 : model.fields[item.image];
90
100
  return [...image && image.type !== "file" ? [`"${item.image}" is not a file field`] : [], ...item.photos ? cardPhotosErrors(item.photos, resource, models).map((error) => `photos: ${error}`) : []];
@@ -98,10 +108,11 @@ const listOfErrors = (model, name, models) => {
98
108
  * Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
99
109
  * field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
100
110
  * `fieldTypes` (each with the storage types it shows) that does not show it,
111
+ * edits a list column in its cell that is not a field with an editor of one line the API may write,
101
112
  * has a related section or related list column, a cards display's or gallery's photos or a cards display's favorite button, a `listOf`, a "Save search" button or a
102
113
  * `searchesOf` that do not fit the resources, cards
103
114
  * displays of one list that name different photos, a map that cannot show the
104
- * record's position or a map display that cannot find the records in its view, a link to an address the app does not have, or a create page that is not one of `pages`.
115
+ * record's position or a map display that cannot find the records in its view, a board whose columns are no filterable enum, a link to an address the app does not have, action inputs that do not fit, or a create page that is not one of `pages`.
105
116
  */
106
117
  const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
107
118
  const models = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
@@ -119,12 +130,14 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
119
130
  const listOf = view.listOf === void 0 ? [] : listOfErrors(model, view.listOf, models).map((error) => `listOf: ${error}`);
120
131
  const searches = [...view.saveSearch ? saveSearchErrors(view.saveSearch, view.resource, models, views).map((error) => `saveSearch: ${error}`) : [], ...view.searchesOf ? searchesOfErrors(view.searchesOf, view.resource, models).map((error) => `searchesOf: ${error}`) : []];
121
132
  const maps = [...view.layout.flatMap((item) => item.kind === "map" ? mapErrors(item, model).map((error) => `map "${item.title}": ${error}`) : []), ...(view.list?.displays ?? []).flatMap((display) => display.kind === "map" ? mapDisplayErrors(display, model).map((error) => `display "${display.name}": ${error}`) : [])];
133
+ const boards = (view.list?.displays ?? []).flatMap((display) => display.kind === "board" ? boardErrors(display, model).map((error) => `display "${display.name}": ${error}`) : []);
122
134
  return [
123
135
  ...sensitiveErrors(view, model),
124
136
  ...unknownFieldErrors(view, model),
125
137
  ...valueLabelErrors(view, model),
126
138
  ...valueColorErrors(view, model),
127
139
  ...textHintErrors(view, model),
140
+ ...listEditErrors(view, model),
128
141
  ...widgetErrors(view, model, fieldTypes),
129
142
  ...related,
130
143
  ...photos,
@@ -132,7 +145,9 @@ const checkViews = (registry, views, pages = [], fieldTypes = {}) => {
132
145
  ...listOf,
133
146
  ...searches,
134
147
  ...maps,
148
+ ...boards,
135
149
  ...linkErrors(view, model, models, pageNames),
150
+ ...inputErrors(view, models),
136
151
  ...createErrors(view, pageNames)
137
152
  ].map((problem) => `View "${view.resource}": ${problem}`);
138
153
  });
@@ -16,6 +16,7 @@ import { webhookTable } from "./webhooks/table/webhook-table.js";
16
16
  import { subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
17
17
  import { withAnonymous } from "./anonymous.js";
18
18
  import { checkBottomBars, checkShell } from "./check-shell.js";
19
+ import { checkOwnerFields } from "./check-owners.js";
19
20
  import { checkRoleLimits } from "./check-roles.js";
20
21
  import { checkViews } from "./check-views.js";
21
22
  import { errorReportingSettings } from "./error-reporting/error-reporting-settings.js";
@@ -59,6 +60,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
59
60
  checkBottomBars(registry, bottomBarModels, pageModels.map((entry) => entry.name), declaredRoles(auth, options.roles));
60
61
  checkViews(registry, viewModels, pageModels.map((entry) => entry.name), fieldTypes);
61
62
  checkRoleLimits(registry, options.roles?.limits ?? []);
63
+ checkOwnerFields(registry, auth);
62
64
  checkPages(pageModels, {
63
65
  models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
64
66
  actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
@@ -0,0 +1,21 @@
1
+ import { HttpProblem } from "./problem.js";
2
+ import { fitsKeyType } from "@protobase/schema";
3
+ //#region src/owner-id.ts
4
+ const mentions = (filter, name) => {
5
+ if (filter.kind === "and" || filter.kind === "or") return filter.args.some((arg) => mentions(arg, name));
6
+ if (filter.kind === "not") return mentions(filter.arg, name);
7
+ return "field" in filter && filter.field.name === name;
8
+ };
9
+ /**
10
+ * Refuses a request whose row filters compare the `.owner()` field with a user id it cannot hold, such as Better
11
+ * Auth's random text in an integer column, naming the field, instead of failing as a value of the wrong format.
12
+ */
13
+ const checkOwnerId = (entry, user, resolved) => {
14
+ const { ownerType, model } = entry;
15
+ if (ownerType === void 0 || model.owner === void 0 || fitsKeyType(String(user.id), ownerType)) return;
16
+ const owner = model.owner;
17
+ if (!Object.values(resolved.rowFilter).some((filter) => filter && mentions(filter, owner))) return;
18
+ throw new HttpProblem(500, "owner-id-mismatch", "Internal Server Error", `${entry.name}.${owner} holds ${ownerType} user ids, and the signed-in user's id "${user.id}" is not one, so their own records cannot be found`);
19
+ };
20
+ //#endregion
21
+ export { checkOwnerId };
package/dist/registry.js CHANGED
@@ -13,6 +13,14 @@ const etagSource = (model) => {
13
13
  kind: "timestamp"
14
14
  };
15
15
  };
16
+ const isKeyType = (type) => type === "text" || type === "integer" || type === "bigint" || type === "uuid";
17
+ const ownerType = (model, resolve) => {
18
+ const field = model.owner === void 0 ? void 0 : model.fields[model.owner];
19
+ if (!field) return void 0;
20
+ if (isKeyType(field.type)) return field.type;
21
+ const target = field.type === "relation" && field.relation ? resolve(field.relation.resource) : void 0;
22
+ return target && keyTypes(target, resolve)[0];
23
+ };
16
24
  const buildRegistry = (resources) => {
17
25
  const built = resources.map((source) => ({
18
26
  source,
@@ -25,11 +33,13 @@ const buildRegistry = (resources) => {
25
33
  const types = keyTypes(model, (name) => models.get(name));
26
34
  if (types.length !== model.primaryKey.length) throw new Error(`Not implemented: resource "${model.name}" has a primary key field that points at a composite-key resource; the server maps one key part per key field`);
27
35
  const etag = etagSource(model);
36
+ const owner = ownerType(model, (name) => models.get(name));
28
37
  return {
29
38
  name: model.name,
30
39
  source,
31
40
  model,
32
41
  keyTypes: types,
42
+ ...owner && { ownerType: owner },
33
43
  ...etag && { etag }
34
44
  };
35
45
  });
@@ -1,5 +1,6 @@
1
1
  import { forbidden, notFound, unauthorized } from "./problem.js";
2
2
  import { checkedFilter } from "./filter-input.js";
3
+ import { checkOwnerId } from "./owner-id.js";
3
4
  import { tenantScope } from "./tenant.js";
4
5
  import { checkFilter, resolveAccess, restrictModel } from "@protobase/schema";
5
6
  //#region src/request-access.ts
@@ -16,6 +17,7 @@ const requestAccess = async (deps, entry, session) => {
16
17
  },
17
18
  ...organizationOf(session)
18
19
  }, deps.accessOptions);
20
+ checkOwnerId(entry, session.user, resolved);
19
21
  const readable = restrictModel(entry.model, resolved);
20
22
  return {
21
23
  entry,
@@ -4,10 +4,13 @@ import { writableFields } from "./request-access.js";
4
4
  const fail = (errors) => badRequest("invalid-record", `The request body is invalid: ${errors[0].field}: ${errors[0].message}`, { errors });
5
5
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
6
6
  const reserved = ["etag", "permissions"];
7
+ const isEmptyText = (field, value) => field.type === "text" && !field.nullable && value === "";
7
8
  /**
8
9
  * Checks a create or update body for this caller. A field the caller cannot read is an unknown field, one they can read
9
10
  * but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always read-only);
10
- * every provided value goes through its field's zod schema, then `.validate()` rules run on the result.
11
+ * every provided value goes through its field's zod schema, then `.validate()` rules run on the result. An empty text
12
+ * for a text field that may not be null counts as not given: a create fills in its default or reports it required,
13
+ * an update reports it required. Only the values written are checked, so a stored '' still reads.
11
14
  * Returns only the values to write: on create the defaults are filled in, on update only the provided fields.
12
15
  * `current` is the stored record an update merges into for `.validate()` rules.
13
16
  */
@@ -36,7 +39,16 @@ const validateBody = (access, mode, body, current = {}) => {
36
39
  for (const [name, source] of Object.entries(entry.source.state.fields ?? {})) {
37
40
  const field = full.fields[name];
38
41
  if (!source || !field || !writable.has(name) || name === full.tenant) continue;
39
- const provided = Object.hasOwn(body, name);
42
+ const empty = Object.hasOwn(body, name) && isEmptyText(field, body[name]);
43
+ if (empty && mode === "update") {
44
+ errors.push({
45
+ field: name,
46
+ code: "required",
47
+ message: `"${name}" is required`
48
+ });
49
+ continue;
50
+ }
51
+ const provided = Object.hasOwn(body, name) && !empty;
40
52
  if (!provided && mode === "update") continue;
41
53
  if (!provided && field.default && "db" in field.default) continue;
42
54
  const parsed = source.schema.safeParse(provided ? body[name] : void 0);
@@ -27,15 +27,26 @@ const pruneGallery = (item, resource, model, models) => {
27
27
  ...item.when !== void 0 && { when: item.when }
28
28
  };
29
29
  };
30
- const tidyList = (model) => ({ default: opens, displays, columns, relatedColumns, ...list }) => {
31
- const shown = displays && displays.length > 0 ? displays : void 0;
32
- const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
30
+ const withKeptDots = (display, related = []) => {
31
+ if (display.kind !== "cards" || display.dots === void 0) return display;
32
+ const { dots, ...card } = display;
33
+ const kept = dots.filter((name) => related.some((column) => column.name === name));
34
+ return kept.length > 0 ? {
35
+ ...card,
36
+ dots: kept
37
+ } : card;
38
+ };
39
+ const tidyList = (model) => ({ default: opens, displays, columns, relatedColumns, editable, ...list }) => {
33
40
  const related = relatedColumns && relatedColumns.length > 0 ? relatedColumns : void 0;
41
+ const shown = displays && displays.length > 0 ? displays.map((display) => withKeptDots(display, related)) : void 0;
42
+ const kept = opens === tableDisplay || shown?.some((display) => display.name === opens);
34
43
  const visible = columns.filter((name) => Object.hasOwn(model.fields, name) || related?.some((column) => column.name === name));
44
+ const writable = editable?.filter((name) => visible.includes(name) && !model.fields[name].readOnly);
35
45
  return {
36
46
  ...list,
37
47
  columns: visible,
38
48
  ...related && { relatedColumns: related },
49
+ ...writable && writable.length > 0 && { editable: writable },
39
50
  ...shown && { displays: shown },
40
51
  ...opens !== void 0 && kept && { default: opens }
41
52
  };
@@ -45,6 +56,7 @@ const listShape = (resource, model, models, prompted) => shapes.object({
45
56
  relatedColumns: shapes.list(shapes.map((column) => relatedColumnErrors(column, resource, models).length === 0 ? column : void 0)),
46
57
  sort: shapes.list(shapes.tuple(shapes.field, shapes.data)),
47
58
  search: shapes.list(shapes.field),
59
+ editable: shapes.list(shapes.field),
48
60
  displays: shapes.list(shapes.oneOf({
49
61
  cards: shapes.object({
50
62
  kind: shapes.data,
@@ -59,6 +71,7 @@ const listShape = (resource, model, models, prompted) => shapes.object({
59
71
  meta: shapes.list(shapes.field),
60
72
  footer: shapes.list(shapes.field),
61
73
  flags: shapes.list(shapes.field),
74
+ dots: shapes.data,
62
75
  card: shapes.data,
63
76
  photos: shapes.map((photos) => cardPhotosErrors(photos, resource, models).length === 0 ? photos : void 0),
64
77
  favorite: shapes.map((favorite) => prompted.has(favorite.resource) || cardFavoriteErrors(favorite, resource, models).length === 0 ? favorite : void 0)
@@ -77,6 +90,17 @@ const listShape = (resource, model, models, prompted) => shapes.object({
77
90
  defaultRadius: shapes.data,
78
91
  area: shapes.field
79
92
  }, { requires: ["latitude", "longitude"] }),
93
+ board: shapes.object({
94
+ kind: shapes.data,
95
+ name: shapes.data,
96
+ label: shapes.data,
97
+ icon: shapes.data,
98
+ by: shapes.field,
99
+ title: shapes.field,
100
+ subtitle: shapes.field,
101
+ badge: shapes.field,
102
+ meta: shapes.list(shapes.field)
103
+ }, { requires: ["by"] }),
80
104
  custom: shapes.object({
81
105
  kind: shapes.data,
82
106
  name: shapes.data,
@@ -156,6 +180,7 @@ const linkShape = (model, models) => shapes.map((run) => {
156
180
  source: model
157
181
  }).length === 0 : hrefPlaceholders(run.href).every((name) => Object.hasOwn(model.fields, name))) ? run : void 0;
158
182
  });
183
+ const inputsShape = (models) => shapes.map((inputs) => inputs.every((input) => input.resource === void 0 || Object.hasOwn(models, input.resource)) ? inputs : void 0);
159
184
  const actionShape = (model, models) => shapes.object({
160
185
  name: shapes.data,
161
186
  label: shapes.data,
@@ -169,8 +194,9 @@ const actionShape = (model, models) => shapes.object({
169
194
  }, { requires: ["values"] }),
170
195
  delete: shapes.data,
171
196
  link: linkShape(model, models)
172
- })
173
- }, { requires: ["run"] });
197
+ }),
198
+ inputs: inputsShape(models)
199
+ }, { requires: ["run", "inputs"] });
174
200
  const viewShape = (resource, model, models, prompted) => shapes.object({
175
201
  resource: shapes.data,
176
202
  roles: shapes.data,
@@ -0,0 +1,23 @@
1
+ //#region src/view-editable.ts
2
+ const cellTypes = /* @__PURE__ */ new Set([
3
+ "text",
4
+ "integer",
5
+ "bigint",
6
+ "decimal",
7
+ "date",
8
+ "enum",
9
+ "boolean",
10
+ "currency",
11
+ "country"
12
+ ]);
13
+ /** What is wrong with editing the field `name` of the list in its cell; empty when it can be. */
14
+ const editableErrors = (name, model, hints) => {
15
+ const field = Object.hasOwn(model.fields, name) ? model.fields[name] : void 0;
16
+ if (!field) return [`"${name}" is not a field of ${model.name}`];
17
+ if (field.readOnly) return [`"${name}" is read-only`];
18
+ if (!cellTypes.has(field.type)) return [`"${name}" is a ${field.type} field, which is not edited in a cell`];
19
+ if (hints?.multiline || hints?.markdown) return [`"${name}" runs over several lines, which a cell does not hold`];
20
+ return [];
21
+ };
22
+ //#endregion
23
+ export { editableErrors };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.19.14",
3
+ "version": "0.19.16",
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": {
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@better-auth/passkey": "^1.7.7",
33
- "@protobase/layout": "0.19.14",
34
- "@protobase/query": "0.19.14",
35
- "@protobase/schema": "0.19.14",
33
+ "@protobase/layout": "0.19.16",
34
+ "@protobase/query": "0.19.16",
35
+ "@protobase/schema": "0.19.16",
36
36
  "@react-email/render": "^2.1.0",
37
37
  "@scalar/hono-api-reference": "^0.12.10",
38
38
  "@sentry/core": "^11.7.0",