@protobase/server 0.54.6 → 0.55.0
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/aggregate-service.js +3 -2
- package/dist/check-parents.js +6 -1
- package/dist/check-views.js +8 -4
- package/dist/create-admin.js +2 -1
- package/dist/fetch-readable.js +4 -3
- package/dist/package.js +1 -1
- package/dist/parent-reads.js +30 -0
- package/dist/read-scope.js +1 -1
- package/dist/records.js +8 -3
- package/dist/request-access.js +21 -3
- package/dist/scan-guard.js +14 -5
- package/dist/webhooks/subscriber-view.js +10 -0
- package/package.json +4 -4
|
@@ -8,7 +8,7 @@ import { parseParams } from "./params.js";
|
|
|
8
8
|
import { rangeEnd, rangeStart } from "./range-ends.js";
|
|
9
9
|
import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
10
10
|
import { z } from "zod";
|
|
11
|
-
import { facetCounts, histogram, series, tenantCondition, withoutField } from "@protobase/query";
|
|
11
|
+
import { facetCounts, histogram, parentCondition, series, tenantCondition, withoutField } from "@protobase/query";
|
|
12
12
|
import { sql } from "kysely";
|
|
13
13
|
//#region src/aggregate-service.ts
|
|
14
14
|
const field = z.string().min(1);
|
|
@@ -113,7 +113,8 @@ const earliest = async (deps, access, fieldName, scope) => {
|
|
|
113
113
|
const conditions = [
|
|
114
114
|
...scopeConditions(access.full, scope.tenantValue),
|
|
115
115
|
...across,
|
|
116
|
-
...filterCondition(access.full, scope.rowFilter)
|
|
116
|
+
...filterCondition(access.full, scope.rowFilter),
|
|
117
|
+
...(scope.parents ?? []).map(parentCondition)
|
|
117
118
|
];
|
|
118
119
|
return (await readTransaction(deps.db, deps.statementTimeoutMs, (trx) => sql`select min(${columnId(target)})::text as lowest from ${tableId(access.full)} as t${whereClause(conditions)}`.execute(trx))).rows[0]?.lowest ?? void 0;
|
|
119
120
|
};
|
package/dist/check-parents.js
CHANGED
|
@@ -12,5 +12,10 @@ const checkParents = (registry) => {
|
|
|
12
12
|
}));
|
|
13
13
|
if (problems.length > 0) throw new Error(`The parents cannot find the records they point at:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
14
14
|
};
|
|
15
|
+
/**
|
|
16
|
+
* The resources whose lists would read across tenants (`tenantAcross`) but keep to the parents the caller reads, which
|
|
17
|
+
* are resolved in the caller's own tenant only: they list that tenant alone.
|
|
18
|
+
*/
|
|
19
|
+
const parentWarnings = (registry) => registry.entries.filter(({ model }) => model.tenantAcross && model.parents?.some((parent) => parent.access === "read")).map(({ name }) => `"${name}" keeps to the parents its readers read (.parent(..., 'read')), so its lists read the caller's own tenant only: tenantAcross is ignored`);
|
|
15
20
|
//#endregion
|
|
16
|
-
export { checkParents };
|
|
21
|
+
export { checkParents, parentWarnings };
|
package/dist/check-views.js
CHANGED
|
@@ -9,7 +9,7 @@ import { relatedColumnErrors } from "./view-related-column.js";
|
|
|
9
9
|
import { editableErrors } from "./view-editable.js";
|
|
10
10
|
import { actionWhenErrors, actionWhenFields } from "./view-action-when.js";
|
|
11
11
|
import { conditionProblems } from "@protobase/layout";
|
|
12
|
-
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, chartFields, datesErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
|
|
12
|
+
import { actionInputErrors, addressErrors, addressPlaceholders, boardErrors, builtInFieldTypes, chartFields, datesErrors, displayFields, fieldRefsIn, hrefField, hrefPlaceholders, layoutItems, linkActionError, promptError, promptFields, updateSetErrors, viewOfList } from "@protobase/schema/internal";
|
|
13
13
|
import { isRecordCondition } from "@protobase/schema";
|
|
14
14
|
//#region src/check-views.ts
|
|
15
15
|
const linkFields = (run) => run?.kind !== "link" ? [] : "to" in run ? addressPlaceholders(run.to) : hrefPlaceholders(run.href);
|
|
@@ -106,8 +106,12 @@ const unknownFieldErrors = (view, model) => {
|
|
|
106
106
|
...layoutItems(view.layout).flatMap((item) => item.kind === "gallery" && item.image !== void 0 ? unknown([item.image], `gallery "${item.title}"`) : [])
|
|
107
107
|
];
|
|
108
108
|
};
|
|
109
|
-
const widgetErrors = (view, model,
|
|
110
|
-
const
|
|
109
|
+
const widgetErrors = (view, model, registered) => Object.entries(view.fields).flatMap(([name, hints]) => {
|
|
110
|
+
const fieldTypes = {
|
|
111
|
+
...builtInFieldTypes,
|
|
112
|
+
...registered
|
|
113
|
+
};
|
|
114
|
+
const of = hints.widget && Object.hasOwn(fieldTypes, hints.widget.name) ? fieldTypes[hints.widget.name] : void 0;
|
|
111
115
|
const field = model.fields[name];
|
|
112
116
|
if (!of || !field || of.includes(field.type)) return [];
|
|
113
117
|
return [`field "${name}" is ${field.type}, and its field type "${hints.widget.name}" shows only ${of.join(", ")}`];
|
|
@@ -144,7 +148,7 @@ const listOfErrors = (model, name, models) => {
|
|
|
144
148
|
/**
|
|
145
149
|
* Fails at startup when a view uses a sensitive field where it would show empty, a display or custom section names a
|
|
146
150
|
* field the resource does not have, labels or colours values its enum does not have, shows a field with a field type of
|
|
147
|
-
* `fieldTypes` (each with the storage types it shows) that does not show it,
|
|
151
|
+
* `fieldTypes` (each with the storage types it shows) or a built-in one that does not show it,
|
|
148
152
|
* edits a list column in its cell that is not a field with an editor of one line the API may write,
|
|
149
153
|
* has a section, related section or sidebar whose `when` condition names what the app does not have, 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
|
|
150
154
|
* `searchesOf` that do not fit the resources, cards
|
package/dist/create-admin.js
CHANGED
|
@@ -66,7 +66,7 @@ import { liveSessions } from "./better-auth/live-sessions.js";
|
|
|
66
66
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
67
67
|
import { checkOwnerFields } from "./check-owners.js";
|
|
68
68
|
import { checkRoleLimits } from "./check-roles.js";
|
|
69
|
-
import { checkParents } from "./check-parents.js";
|
|
69
|
+
import { checkParents, parentWarnings } from "./check-parents.js";
|
|
70
70
|
import { checkPositionAfter } from "./check-position-after.js";
|
|
71
71
|
import { checkViews } from "./check-views.js";
|
|
72
72
|
import { liveRoutes } from "./live/live-route.js";
|
|
@@ -147,6 +147,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
147
147
|
if (options.mcp) checkMcpWrites(registry, declaredRoles(auth, options.roles), options.mcp.writes);
|
|
148
148
|
checkPositionAfter(registry);
|
|
149
149
|
checkParents(registry);
|
|
150
|
+
for (const warning of parentWarnings(registry)) console.warn(warning);
|
|
150
151
|
const resourceModels = Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model]));
|
|
151
152
|
checkPages([...pageModels, accountModel], {
|
|
152
153
|
models: resourceModels,
|
package/dist/fetch-readable.js
CHANGED
|
@@ -3,9 +3,9 @@ import { recordDecision, targetFilter } from "./request-access.js";
|
|
|
3
3
|
import { fetchRecord } from "./records.js";
|
|
4
4
|
//#region src/fetch-readable.ts
|
|
5
5
|
/**
|
|
6
|
-
* The row with this key if the caller may read it: inside their tenant
|
|
7
|
-
* rule. Such a rule may look at fields the caller cannot see, so then the whole row is read;
|
|
8
|
-
* `lock` locks the row the first read finds.
|
|
6
|
+
* The row with this key if the caller may read it: inside their tenant, row filter and the parents they read, and
|
|
7
|
+
* passing a record-level read rule. Such a rule may look at fields the caller cannot see, so then the whole row is read;
|
|
8
|
+
* the caller cuts it down. `lock` locks the row the first read finds.
|
|
9
9
|
*/
|
|
10
10
|
const fetchReadable = async (trx, access, key, deleted, { fields, lock, ...shape }) => {
|
|
11
11
|
const { entry } = access;
|
|
@@ -15,6 +15,7 @@ const fetchReadable = async (trx, access, key, deleted, { fields, lock, ...shape
|
|
|
15
15
|
const current = await fetchRecord(trx, entry, key, tenant, {
|
|
16
16
|
deleted,
|
|
17
17
|
...filter && { filter },
|
|
18
|
+
parents: access.parents,
|
|
18
19
|
...lock && { lock },
|
|
19
20
|
...shape,
|
|
20
21
|
...!recordRule && fields && { fields }
|
package/dist/package.js
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/parent-reads.ts
|
|
2
|
+
/**
|
|
3
|
+
* The parents the caller must read for a record of `entry` to be seen (`.parent(fk, 'read')`), each with the caller's
|
|
4
|
+
* scope on the parent's resource: tenant, read row filter, soft delete and its own parents. A caller who cannot read the
|
|
5
|
+
* parent resource, or whose read rule there needs the record, which no query can express, reads no parent. `chain` holds
|
|
6
|
+
* the resources whose parents led here: one already on it is not followed further, so a resource that is its own parent,
|
|
7
|
+
* such as a tree, is checked one level up.
|
|
8
|
+
*/
|
|
9
|
+
const parentReads = async (entry, session, chain, accessOf, find) => {
|
|
10
|
+
if (chain.includes(entry.name)) return [];
|
|
11
|
+
const reads = (entry.model.parents ?? []).filter((parent) => parent.access === "read");
|
|
12
|
+
return Promise.all(reads.map(async ({ field }) => {
|
|
13
|
+
const relation = entry.model.fields[field];
|
|
14
|
+
const parentEntry = find(relation.relation.resource);
|
|
15
|
+
if (!parentEntry) throw new Error(`Parent "${field}" of "${entry.name}" points at "${relation.relation.resource}", which the app does not have`);
|
|
16
|
+
if (parentEntry.model.tenant && session.tenant === void 0) return { field: relation };
|
|
17
|
+
const access = await accessOf(parentEntry, [...chain, entry.name]);
|
|
18
|
+
if (!access.resolved.operations.read || access.resolved.recordChecks.includes("read")) return { field: relation };
|
|
19
|
+
return {
|
|
20
|
+
field: relation,
|
|
21
|
+
parent: {
|
|
22
|
+
model: access.full,
|
|
23
|
+
key: access.full.fields[access.full.primaryKey[0]],
|
|
24
|
+
scope: access.scope
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}));
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { parentReads };
|
package/dist/read-scope.js
CHANGED
|
@@ -15,7 +15,7 @@ const listsAsMuch = (own, other) => other.resolved.operations.list && Object.key
|
|
|
15
15
|
const otherTenants = async (access) => {
|
|
16
16
|
const { deps, entry, session } = access;
|
|
17
17
|
const own = session.tenant;
|
|
18
|
-
if (!access.model.tenantAcross || own === void 0 || !session.memberships) return [];
|
|
18
|
+
if (!access.model.tenantAcross || own === void 0 || !session.memberships || access.parents.length > 0) return [];
|
|
19
19
|
const memberships = new Map((await session.memberships()).filter((membership) => !sameTenant(membership.tenant, own)).map((membership) => [String(membership.tenant), membership]));
|
|
20
20
|
return (await Promise.all([...memberships.values()].map(async (membership) => {
|
|
21
21
|
const other = await requestAccess(deps, entry, memberSession(session, membership));
|
package/dist/records.js
CHANGED
|
@@ -2,6 +2,7 @@ import { exposeRow, pickFields } from "./rows.js";
|
|
|
2
2
|
import { columnId, filterCondition, keyConditions, returningList, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
3
3
|
import { etagOf } from "./etag.js";
|
|
4
4
|
import { storedValue } from "./encryption/stored-value.js";
|
|
5
|
+
import { parentCondition } from "@protobase/query";
|
|
5
6
|
import { sql } from "kysely";
|
|
6
7
|
//#region src/records.ts
|
|
7
8
|
const stored = async (entry, raw, shape = {}) => {
|
|
@@ -14,13 +15,17 @@ const stored = async (entry, raw, shape = {}) => {
|
|
|
14
15
|
};
|
|
15
16
|
const rowsOf = async (db, query) => (await query.execute(db)).rows;
|
|
16
17
|
const lockClause = (lock) => lock === "no-key-update" ? sql` for no key update` : lock ? sql` for update` : sql``;
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* The row of the caller's tenant with this key (live unless `deleted` says otherwise), optionally narrowed by a filter
|
|
20
|
+
* and the parents the caller reads.
|
|
21
|
+
*/
|
|
18
22
|
const fetchRecord = async (db, entry, key, tenant, options = {}) => {
|
|
19
|
-
const { filter, deleted, lock = false, ...shape } = options;
|
|
23
|
+
const { filter, parents = [], deleted, lock = false, ...shape } = options;
|
|
20
24
|
const conditions = [
|
|
21
25
|
...scopeConditions(entry.model, tenant, deleted),
|
|
22
26
|
...keyConditions(entry.model, key),
|
|
23
|
-
...filterCondition(entry.model, filter)
|
|
27
|
+
...filterCondition(entry.model, filter),
|
|
28
|
+
...parents.map(parentCondition)
|
|
24
29
|
];
|
|
25
30
|
const query = sql`select ${returningList(entry, shape.fields)} from ${tableId(entry.model)} as t${whereClause(conditions)} limit 1${lockClause(lock)}`;
|
|
26
31
|
return stored(entry, (await rowsOf(db, query))[0], shape);
|
package/dist/request-access.js
CHANGED
|
@@ -2,6 +2,7 @@ import { maskEncrypted } from "./encryption/mask-stored.js";
|
|
|
2
2
|
import { forbidden, notFound, unauthorized } from "./problem.js";
|
|
3
3
|
import { checkedFilter } from "./filter-input.js";
|
|
4
4
|
import { checkOwnerId } from "./owner-id.js";
|
|
5
|
+
import { parentReads } from "./parent-reads.js";
|
|
5
6
|
import { tenantScope } from "./tenant.js";
|
|
6
7
|
import { checkFilter, resolveAccess, restrictModel, where } from "@protobase/schema";
|
|
7
8
|
//#region src/request-access.ts
|
|
@@ -64,8 +65,11 @@ const ownOnly = (resolved, entry, session) => {
|
|
|
64
65
|
}
|
|
65
66
|
};
|
|
66
67
|
};
|
|
67
|
-
/**
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* `form` is the public form a visitor sends, the only way a caller without a token creates. `chain` holds the resources
|
|
70
|
+
* whose parents led to this one (see `parentReads`).
|
|
71
|
+
*/
|
|
72
|
+
const requestAccess = async (deps, entry, session, form, chain = []) => {
|
|
69
73
|
const all = await resolveAccess(entry.source, {
|
|
70
74
|
user: {
|
|
71
75
|
id: session.user.id,
|
|
@@ -77,6 +81,15 @@ const requestAccess = async (deps, entry, session, form) => {
|
|
|
77
81
|
const resolved = deps.settingsResources.has(entry.name) ? ownOnly(allowed, entry, session) : allowed;
|
|
78
82
|
checkOwnerId(entry, session.user, resolved);
|
|
79
83
|
const readable = restrictModel(entry.model, resolved);
|
|
84
|
+
const parentAccess = async (parent, along) => {
|
|
85
|
+
const access = await requestAccess(deps, parent, session, void 0, along);
|
|
86
|
+
return {
|
|
87
|
+
resolved: access.resolved,
|
|
88
|
+
full: access.full,
|
|
89
|
+
scope: ownScope(access)
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const parents = await parentReads(entry, session, chain, parentAccess, deps.registry.find);
|
|
80
93
|
return {
|
|
81
94
|
entry,
|
|
82
95
|
session,
|
|
@@ -84,6 +97,7 @@ const requestAccess = async (deps, entry, session, form) => {
|
|
|
84
97
|
full: entry.model,
|
|
85
98
|
readable,
|
|
86
99
|
model: withoutSensitive(readable),
|
|
100
|
+
parents,
|
|
87
101
|
deps
|
|
88
102
|
};
|
|
89
103
|
};
|
|
@@ -125,13 +139,17 @@ const andFilters = (filters) => {
|
|
|
125
139
|
};
|
|
126
140
|
/** The row filter that narrows which rows an operation may target: the read filter, plus the operation's own. */
|
|
127
141
|
const targetFilter = (access, operation) => operation === "read" ? access.resolved.rowFilter.read : andFilters([access.resolved.rowFilter.read, access.resolved.rowFilter[operation]]);
|
|
128
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The query-layer scope of the caller's own tenant: it, the access row filter and the parents the caller reads against
|
|
144
|
+
* the full model, soft delete from the full model.
|
|
145
|
+
*/
|
|
129
146
|
const ownScope = (access, fullModel = access.full) => {
|
|
130
147
|
const tenant = tenantScope(access.entry, access.session);
|
|
131
148
|
const rowFilter = access.resolved.rowFilter.read;
|
|
132
149
|
return {
|
|
133
150
|
...tenant && { tenantValue: tenant.tenantValue },
|
|
134
151
|
...rowFilter && { rowFilter },
|
|
152
|
+
...access.parents.length > 0 && { parents: access.parents },
|
|
135
153
|
fullModel
|
|
136
154
|
};
|
|
137
155
|
};
|
package/dist/scan-guard.js
CHANGED
|
@@ -5,6 +5,7 @@ const reading = (column) => {
|
|
|
5
5
|
switch (column.use) {
|
|
6
6
|
case "filter": return `Filtering on "${column.field}"`;
|
|
7
7
|
case "access": return `The filter your role is limited by, on "${column.field}",`;
|
|
8
|
+
case "parent": return `Keeping to the records whose "${column.field}" you may read`;
|
|
8
9
|
case "sort": return `Sorting by "${column.field}"`;
|
|
9
10
|
case "facet": return `Counting the values of "${column.field}"`;
|
|
10
11
|
}
|
|
@@ -20,17 +21,25 @@ const describe = (column) => {
|
|
|
20
21
|
const createScanGuard = (db, options = {}) => {
|
|
21
22
|
const { mode = "reject" } = options;
|
|
22
23
|
const tables = /* @__PURE__ */ new Map();
|
|
23
|
-
const
|
|
24
|
-
const known = tables.get(
|
|
24
|
+
const indexesOfModel = async (model) => {
|
|
25
|
+
const known = tables.get(model.name);
|
|
25
26
|
if (known) return known;
|
|
26
|
-
const read = await tableIndexes(db,
|
|
27
|
-
tables.set(
|
|
27
|
+
const read = await tableIndexes(db, model);
|
|
28
|
+
tables.set(model.name, read);
|
|
28
29
|
return read;
|
|
29
30
|
};
|
|
31
|
+
const indexesOf = (entry) => indexesOfModel(entry.model);
|
|
32
|
+
const parentColumns = async (scope) => {
|
|
33
|
+
const parents = (scope.parents ?? []).flatMap((read) => read.parent ? [read.parent] : []);
|
|
34
|
+
return (await Promise.all(parents.map(async ({ model, scope: own }) => [...uncoveredColumns(model, await indexesOfModel(model), {}, own).map((column) => ({
|
|
35
|
+
...column,
|
|
36
|
+
field: `${model.name}.${column.field}`
|
|
37
|
+
})), ...await parentColumns(own)]))).flat();
|
|
38
|
+
};
|
|
30
39
|
/** Throws a 400 problem for a column no index serves in `reject` mode; in `warn` mode returns the warning text. */
|
|
31
40
|
const check = async (entry, query, context) => {
|
|
32
41
|
if (mode === "off") return void 0;
|
|
33
|
-
const columns = uncoveredColumns(context.model, await indexesOf(entry), query, context.scope);
|
|
42
|
+
const columns = [...uncoveredColumns(context.model, await indexesOf(entry), query, context.scope), ...await parentColumns(context.scope)];
|
|
34
43
|
const first = columns[0];
|
|
35
44
|
if (!first) return void 0;
|
|
36
45
|
const detail = describe(first);
|
|
@@ -2,6 +2,7 @@ import { requestAccess } from "../request-access.js";
|
|
|
2
2
|
import { EventCodeError } from "../changes/event-code-error.js";
|
|
3
3
|
import { currentRecord } from "../changes/resource-change.js";
|
|
4
4
|
import { checkRecord, evaluateFilter } from "@protobase/schema";
|
|
5
|
+
import { readsParent } from "@protobase/query";
|
|
5
6
|
//#region src/webhooks/subscriber-view.ts
|
|
6
7
|
/**
|
|
7
8
|
* The session a subscription delivers as, or `undefined` for the system: every record, every field but sensitive ones.
|
|
@@ -86,10 +87,19 @@ const withUnknownFields = (model, record) => {
|
|
|
86
87
|
check
|
|
87
88
|
};
|
|
88
89
|
};
|
|
90
|
+
const readsParents = async (access, record) => {
|
|
91
|
+
for (const read of access.parents) {
|
|
92
|
+
const value = record[read.field.name];
|
|
93
|
+
if (value === null || value === void 0) continue;
|
|
94
|
+
if (!await readsParent(access.deps.db, read, value)) return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
89
98
|
const decide = async (access, record) => {
|
|
90
99
|
const { resolved, session, entry } = access;
|
|
91
100
|
if (!resolved.operations.read) return false;
|
|
92
101
|
if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
|
|
102
|
+
if (!await readsParents(access, record)) return false;
|
|
93
103
|
if (!resolved.recordChecks.includes("read")) return true;
|
|
94
104
|
const context = {
|
|
95
105
|
user: session.user,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
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": {
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"@better-auth/passkey": "^1.7.12",
|
|
41
41
|
"@better-auth/sso": "^1.7.12",
|
|
42
42
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
43
|
-
"@protobase/layout": "0.
|
|
44
|
-
"@protobase/query": "0.
|
|
45
|
-
"@protobase/schema": "0.
|
|
43
|
+
"@protobase/layout": "0.55.0",
|
|
44
|
+
"@protobase/query": "0.55.0",
|
|
45
|
+
"@protobase/schema": "0.55.0",
|
|
46
46
|
"@react-email/render": "^2.1.0",
|
|
47
47
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
48
48
|
"@sentry/core": "^11.7.0",
|