@protobase/server 0.21.5 → 0.21.7
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/anonymous.js +7 -2
- package/dist/better-auth/status-route.js +1 -1
- package/dist/changes/capture-schema.d.ts +1 -1
- package/dist/changes/match-events.js +1 -1
- package/dist/changes/outbox.d.ts +1 -1
- package/dist/check-roles.js +1 -1
- package/dist/check-shell.js +1 -1
- package/dist/create-admin.d.ts +5 -2
- package/dist/create-admin.js +13 -4
- package/dist/deps.d.ts +2 -2
- package/dist/files/cleanup-runner.d.ts +1 -1
- package/dist/files/runtime.d.ts +2 -2
- package/dist/forms/challenge.d.ts +8 -0
- package/dist/forms/challenge.js +71 -0
- package/dist/forms/check-forms.js +59 -0
- package/dist/forms/rate-limit.js +38 -0
- package/dist/forms/routes.js +83 -0
- package/dist/forms/runtime.js +31 -0
- package/dist/history/read-history.js +3 -2
- package/dist/list-photos.js +1 -1
- package/dist/meta.js +2 -2
- package/dist/related-labels.js +1 -1
- package/dist/request-access.d.ts +2 -2
- package/dist/request-access.js +26 -2
- package/dist/scan-guard.d.ts +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/validate-body.js +6 -1
- package/dist/view-access.js +2 -1
- package/dist/webhooks/route-events.js +1 -1
- package/dist/webhooks/subscription.d.ts +1 -1
- package/dist/write-pipeline.js +6 -3
- package/package.json +5 -4
package/dist/anonymous.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MissingCredentials } from "./problem.js";
|
|
2
2
|
import { anonymousRole } from "@protobase/schema";
|
|
3
|
-
|
|
3
|
+
/** A caller without a token, acting as the `anonymous` role, with no tenant. */
|
|
4
4
|
const anonymousSession = () => ({
|
|
5
5
|
user: {
|
|
6
6
|
id: "",
|
|
@@ -23,5 +23,10 @@ const withAnonymous = (authenticate, roles) => {
|
|
|
23
23
|
}
|
|
24
24
|
};
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether visitors browse the admin app without signing in: the anonymous role reads a resource. One that only creates,
|
|
28
|
+
* through public forms, leaves the app behind sign-in.
|
|
29
|
+
*/
|
|
30
|
+
const visitorsBrowse = (roles) => (roles?.limits ?? []).some(({ role, capability }) => role === anonymousRole && capability.endsWith(".read"));
|
|
26
31
|
//#endregion
|
|
27
|
-
export { withAnonymous };
|
|
32
|
+
export { anonymousSession, visitorsBrowse, withAnonymous };
|
|
@@ -14,7 +14,7 @@ import { Hono } from "hono";
|
|
|
14
14
|
* operator provider's name, when staff can sign in as people; `passkeyDomains`, when people have passkeys for more than
|
|
15
15
|
* one of the app's hosts, the RP IDs a passkey sign-in can ask for, the passkey domain first; with organizations, who may create them and the roles
|
|
16
16
|
* with their labels, `membership` for those a member can hold, and the roles each one's holders may also give; and
|
|
17
|
-
* `anonymous` when the project
|
|
17
|
+
* `anonymous` when the project's anonymous role reads a resource, so the admin app opens for visitors before anyone signs in; and
|
|
18
18
|
* `plugins`, those the app loads before sign-in (see `PluginOption`).
|
|
19
19
|
*/
|
|
20
20
|
const statusRoute = (auth, { anonymous = false, plugins = [] } = {}) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventsOptions } from "./events-options.js";
|
|
2
|
-
import { ResourceModel } from "@protobase/schema";
|
|
3
2
|
import { Db } from "@protobase/query";
|
|
3
|
+
import { ResourceModel } from "@protobase/schema";
|
|
4
4
|
//#region src/changes/capture-schema.d.ts
|
|
5
5
|
/** The command that writes what the config needs as a migration file of the project. */
|
|
6
6
|
export declare const migrationCommand = "protobase migration";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventCodeError } from "./event-code-error.js";
|
|
2
|
-
import { evaluateFilter } from "@protobase/schema";
|
|
3
2
|
import { checkEventFilter } from "@protobase/schema/internal";
|
|
3
|
+
import { evaluateFilter } from "@protobase/schema";
|
|
4
4
|
//#region src/changes/match-events.ts
|
|
5
5
|
const filterOf = (model, source) => {
|
|
6
6
|
const { filter, problem } = checkEventFilter(model, source);
|
package/dist/changes/outbox.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Row } from "../types.js";
|
|
2
2
|
import { CapturedRow } from "./coalesce.js";
|
|
3
|
-
import { ResourceModel } from "@protobase/schema";
|
|
4
3
|
import { Db } from "@protobase/query";
|
|
4
|
+
import { ResourceModel } from "@protobase/schema";
|
|
5
5
|
//#region src/changes/outbox.d.ts
|
|
6
6
|
/**
|
|
7
7
|
* A captured row's columns from the JSON text the database stores: the columns of 64-bit integer and decimal fields as
|
package/dist/check-roles.js
CHANGED
|
@@ -12,7 +12,7 @@ const checkRoleLimits = (registry, limits) => {
|
|
|
12
12
|
if (!model) throw new Error(`Role "${role}" limits "${capability}" but "${name}" is not a resource`);
|
|
13
13
|
const unknown = fields?.find((field) => !Object.hasOwn(model.fields, field));
|
|
14
14
|
if (unknown) throw new Error(`Role "${role}" lists the field "${unknown}" for "${capability}" but ${name} has no such field`);
|
|
15
|
-
if (role === anonymousRole && model.tenant) throw new Error(`The anonymous role
|
|
15
|
+
if (role === anonymousRole && model.tenant) throw new Error(`The anonymous role grants "${capability}" on ${name}, which is tenant-scoped, but an anonymous caller works in no organization`);
|
|
16
16
|
if (!filter) continue;
|
|
17
17
|
const trusted = {
|
|
18
18
|
...model,
|
package/dist/check-shell.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { bottomBarErrors } from "@protobase/schema";
|
|
2
1
|
import { navRecentErrors } from "@protobase/schema/internal";
|
|
2
|
+
import { bottomBarErrors } from "@protobase/schema";
|
|
3
3
|
//#region src/check-shell.ts
|
|
4
4
|
/** Fails at startup when a sidebar group or a user menu entry does not fit the resources, instead of on the first page load. */
|
|
5
5
|
const checkShell = (registry, views, userMenu, pages = []) => {
|
package/dist/create-admin.d.ts
CHANGED
|
@@ -6,11 +6,12 @@ import { Admin, Authenticator, PageSource, ViewSource } from "./types.js";
|
|
|
6
6
|
import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
7
7
|
import { SubscriptionDefinition } from "./webhooks/subscription.js";
|
|
8
8
|
import { FilesOptions } from "./files/options.js";
|
|
9
|
+
import { Difficulty } from "./forms/challenge.js";
|
|
9
10
|
import { ResourceSource } from "./resource-source.js";
|
|
10
11
|
import { FunctionSource } from "./functions/define-function.js";
|
|
11
12
|
import { AdminOptions } from "./admin-options.js";
|
|
12
|
-
import { BottomBarSource, UserMenuSource } from "@protobase/schema";
|
|
13
13
|
import { Db } from "@protobase/query";
|
|
14
|
+
import { BottomBarSource, UserMenuSource } from "@protobase/schema";
|
|
14
15
|
//#region src/create-admin.d.ts
|
|
15
16
|
export type CreateAdminInput = {
|
|
16
17
|
resources: ResourceSource[];
|
|
@@ -52,7 +53,9 @@ export type CreateAdminInput = {
|
|
|
52
53
|
*/
|
|
53
54
|
fieldTypes?: Record<string, string[]>;
|
|
54
55
|
options?: AdminOptions;
|
|
56
|
+
/** @internal The proof-of-work range of public forms' bot checks, which tests make tiny. */
|
|
57
|
+
formsDifficulty?: Difficulty;
|
|
55
58
|
};
|
|
56
59
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
57
|
-
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, bundlePlugins, fieldTypes, options }: CreateAdminInput) => Admin;
|
|
60
|
+
export declare const createAdmin: ({ resources: appResources, views: appViews, pages, userMenu, bottomBars, db, authenticate, auth, functions, files: filesOptions, events, history: historyOptions, jobs, subscriptions, webhooks: webhooksOptions, bundlePlugins, fieldTypes, options, formsDifficulty }: CreateAdminInput) => Admin;
|
|
58
61
|
//#endregion
|
package/dist/create-admin.js
CHANGED
|
@@ -20,7 +20,7 @@ import { toApiFunction } from "./functions/define-function.js";
|
|
|
20
20
|
import { createWebhooks } from "./webhooks/webhooks.js";
|
|
21
21
|
import { webhookTable } from "./webhooks/table/webhook-table.js";
|
|
22
22
|
import { subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
|
|
23
|
-
import { withAnonymous } from "./anonymous.js";
|
|
23
|
+
import { visitorsBrowse, withAnonymous } from "./anonymous.js";
|
|
24
24
|
import { checkBottomBars, checkShell } from "./check-shell.js";
|
|
25
25
|
import { checkOwnerFields } from "./check-owners.js";
|
|
26
26
|
import { checkRoleLimits } from "./check-roles.js";
|
|
@@ -28,6 +28,8 @@ import { checkViews } from "./check-views.js";
|
|
|
28
28
|
import { errorReportingSettings } from "./error-reporting/error-reporting-settings.js";
|
|
29
29
|
import { createFilesRuntime } from "./files/runtime.js";
|
|
30
30
|
import { fileRoutes } from "./files/routes.js";
|
|
31
|
+
import { formRoutes } from "./forms/routes.js";
|
|
32
|
+
import { createFormsRuntime } from "./forms/runtime.js";
|
|
31
33
|
import { functionRoutes } from "./functions/routes.js";
|
|
32
34
|
import { mapSettings } from "./maps/map-settings.js";
|
|
33
35
|
import { pluginOptions, pluginsBeforeSignIn } from "./plugins/plugin-settings.js";
|
|
@@ -44,14 +46,13 @@ import { createScanGuard } from "./scan-guard.js";
|
|
|
44
46
|
import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
|
|
45
47
|
import { Hono } from "hono";
|
|
46
48
|
import { checkPages } from "@protobase/layout";
|
|
47
|
-
import { anonymousRole } from "@protobase/schema";
|
|
48
49
|
//#region src/create-admin.ts
|
|
49
50
|
const declaredRoles = (auth, roles) => {
|
|
50
51
|
const names = [...auth?.roles ?? [], ...Object.keys(roles?.definitions ?? {})];
|
|
51
52
|
return names.length > 0 ? [...new Set(names)] : void 0;
|
|
52
53
|
};
|
|
53
54
|
/** The admin REST API as a Hono app. It uses only Web APIs, so it runs on Node, Workers and anywhere else Hono does. */
|
|
54
|
-
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, files: filesOptions, events, history: historyOptions, jobs = [], subscriptions = [], webhooks: webhooksOptions, bundlePlugins = [], fieldTypes = {}, options = {} }) => {
|
|
55
|
+
const createAdmin = ({ resources: appResources, views: appViews = [], pages = [], userMenu, bottomBars = [], db, authenticate, auth, functions = {}, files: filesOptions, events, history: historyOptions, jobs = [], subscriptions = [], webhooks: webhooksOptions, bundlePlugins = [], fieldTypes = {}, options = {}, formsDifficulty }) => {
|
|
55
56
|
const basePath = options.basePath ?? "/api/v1";
|
|
56
57
|
const appModels = appResources.map((resource) => resource.toModel());
|
|
57
58
|
const withJobs = jobs.length > 0 || appModels.some(isCaptured);
|
|
@@ -83,6 +84,13 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
83
84
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
84
85
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
|
85
86
|
});
|
|
87
|
+
const forms = createFormsRuntime({
|
|
88
|
+
registry,
|
|
89
|
+
views: viewModels,
|
|
90
|
+
limits: options.roles?.limits ?? [],
|
|
91
|
+
env: globalThis.process?.env ?? {},
|
|
92
|
+
...formsDifficulty && { difficulty: formsDifficulty }
|
|
93
|
+
});
|
|
86
94
|
const systemPath = basePath.slice(0, basePath.lastIndexOf("/"));
|
|
87
95
|
const assistant = assistantSettings(options.assistant, globalThis.process?.env ?? {});
|
|
88
96
|
const runtime = runtimeUrl(options.runtime, globalThis.process?.env ?? {});
|
|
@@ -173,7 +181,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
173
181
|
}
|
|
174
182
|
if (auth) {
|
|
175
183
|
app.route(authBasePath, statusRoute(auth, {
|
|
176
|
-
anonymous:
|
|
184
|
+
anonymous: visitorsBrowse(options.roles),
|
|
177
185
|
plugins: pluginsBeforeSignIn(plugins)
|
|
178
186
|
}));
|
|
179
187
|
if (auth.organizations) app.route(authBasePath, organizationRoutes({
|
|
@@ -209,6 +217,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
|
|
|
209
217
|
}));
|
|
210
218
|
app.route("/", batchRoute(deps, authenticate, meta));
|
|
211
219
|
app.route("/", fileRoutes(deps, authenticate));
|
|
220
|
+
if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
|
|
212
221
|
app.route(basePath, api);
|
|
213
222
|
app.route(`${deps.systemPath}/meta`, metaApp);
|
|
214
223
|
app.route(deps.systemPath || "/", system);
|
package/dist/deps.d.ts
CHANGED
|
@@ -4,5 +4,5 @@ import "./plugins/plugin-settings.js";
|
|
|
4
4
|
import "./registry.js";
|
|
5
5
|
import "./scan-guard.js";
|
|
6
6
|
import { PageModel } from "@protobase/layout";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { Db } from "@protobase/query";
|
|
8
|
+
import { ResolveOptions, ViewModel } from "@protobase/schema";
|
package/dist/files/runtime.d.ts
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { CappedMap, createChallenge, randomInt, verifySolution } from "altcha-lib";
|
|
2
|
+
import { deriveKey } from "altcha-lib/algorithms/web/sha";
|
|
3
|
+
//#region src/forms/challenge.ts
|
|
4
|
+
/**
|
|
5
|
+
* How many SHA-256 rounds a visitor's browser runs to send a form: the challenge is made for one count in this range,
|
|
6
|
+
* and the solver counts up to it. About a second in Chromium on a current laptop: 0.4 s at the low end and 1.1 s at
|
|
7
|
+
* the high end, measured on an Apple M1 Pro.
|
|
8
|
+
*/
|
|
9
|
+
const formDifficulty = {
|
|
10
|
+
min: 5e4,
|
|
11
|
+
max: 1e5
|
|
12
|
+
};
|
|
13
|
+
const expiresInSeconds = 300;
|
|
14
|
+
const spentCapacity = 1e4;
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const toHex = (bytes) => [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
const purposeKey = async (secret, info) => {
|
|
18
|
+
const material = await crypto.subtle.importKey("raw", encoder.encode(secret), "HKDF", false, ["deriveBits"]);
|
|
19
|
+
return toHex(await crypto.subtle.deriveBits({
|
|
20
|
+
name: "HKDF",
|
|
21
|
+
hash: "SHA-256",
|
|
22
|
+
salt: /* @__PURE__ */ new Uint8Array(0),
|
|
23
|
+
info: encoder.encode(info)
|
|
24
|
+
}, material, 256));
|
|
25
|
+
};
|
|
26
|
+
const isProof = (value) => {
|
|
27
|
+
if (typeof value !== "object" || value === null) return false;
|
|
28
|
+
const { challenge, solution } = value;
|
|
29
|
+
return typeof challenge === "object" && challenge !== null && typeof challenge.parameters === "object" && typeof solution === "object" && solution !== null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The bot check of public forms: a signed proof-of-work challenge (ALTCHA) per submission, made for one form and valid
|
|
33
|
+
* for five minutes, which verifies once. The secret signs the challenges, so they verify on every instance that shares it.
|
|
34
|
+
*/
|
|
35
|
+
const createFormChallenges = (secret, { difficulty = formDifficulty, now = Date.now } = {}) => {
|
|
36
|
+
const keys = Promise.all([purposeKey(secret, "protobase forms challenge"), purposeKey(secret, "protobase forms key")]);
|
|
37
|
+
const spent = new CappedMap({ maxSize: spentCapacity });
|
|
38
|
+
const issue = async (form) => {
|
|
39
|
+
const [hmacSignatureSecret, hmacKeySignatureSecret] = await keys;
|
|
40
|
+
return createChallenge({
|
|
41
|
+
algorithm: "SHA-256",
|
|
42
|
+
cost: 1,
|
|
43
|
+
deriveKey,
|
|
44
|
+
counter: randomInt(difficulty.max, difficulty.min),
|
|
45
|
+
data: { form },
|
|
46
|
+
expiresAt: Math.floor(now() / 1e3) + expiresInSeconds,
|
|
47
|
+
hmacSignatureSecret,
|
|
48
|
+
hmacKeySignatureSecret
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const verify = async (form, proof) => {
|
|
52
|
+
if (!isProof(proof) || proof.challenge.parameters.data?.form !== form) return "invalid";
|
|
53
|
+
const [hmacSignatureSecret, hmacKeySignatureSecret] = await keys;
|
|
54
|
+
const result = await verifySolution({
|
|
55
|
+
challenge: proof.challenge,
|
|
56
|
+
solution: proof.solution,
|
|
57
|
+
deriveKey,
|
|
58
|
+
hmacSignatureSecret,
|
|
59
|
+
hmacKeySignatureSecret
|
|
60
|
+
});
|
|
61
|
+
if (result.expired) return "expired";
|
|
62
|
+
if (!result.verified) return "invalid";
|
|
63
|
+
return spent.setIfAbsent(proof.challenge.signature, true) ? "ok" : "replayed";
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
issue,
|
|
67
|
+
verify
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
export { createFormChallenges, formDifficulty };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { requiredOnCreate } from "../validate-body.js";
|
|
2
|
+
//#region src/forms/check-forms.ts
|
|
3
|
+
const plainTypes = [
|
|
4
|
+
"text",
|
|
5
|
+
"integer",
|
|
6
|
+
"bigint",
|
|
7
|
+
"decimal",
|
|
8
|
+
"boolean",
|
|
9
|
+
"date",
|
|
10
|
+
"enum",
|
|
11
|
+
"currency",
|
|
12
|
+
"country"
|
|
13
|
+
];
|
|
14
|
+
const fieldErrors = (registry, { form, view }, granted) => {
|
|
15
|
+
const entry = registry.find(view.resource);
|
|
16
|
+
if (!entry) return [];
|
|
17
|
+
const { model } = entry;
|
|
18
|
+
if (!granted) return [`the anonymous role must grant creating its fields: { '${view.resource}.create': [${form.fields.map((name) => `'${name}'`).join(", ")}] }`];
|
|
19
|
+
const fields = form.fields.flatMap((name) => {
|
|
20
|
+
const field = model.fields[name];
|
|
21
|
+
if (!field) return [`field "${name}" does not exist on ${view.resource}`];
|
|
22
|
+
if (field.readOnly) return [`field "${name}" is read-only, so nobody fills it in`];
|
|
23
|
+
if (field.sensitive) return [`field "${name}" is sensitive, and a public form does not take sensitive fields`];
|
|
24
|
+
if (field.type === "relation") return [`field "${name}" is a relation, whose records a visitor cannot read to pick one`];
|
|
25
|
+
if (field.type === "file") return [`field "${name}" is a file field, and a visitor cannot upload`];
|
|
26
|
+
if (!plainTypes.includes(field.type)) return [`field "${name}" is ${field.type}, which a form has no input for`];
|
|
27
|
+
if (name === model.tenant) return [`field "${name}" is the tenant, which the server sets`];
|
|
28
|
+
if (!granted.includes(name)) return [`the anonymous role does not create "${name}": add it to { '${view.resource}.create': [...] }`];
|
|
29
|
+
return [];
|
|
30
|
+
});
|
|
31
|
+
const sources = entry.source.state.fields ?? {};
|
|
32
|
+
const missing = Object.values(model.fields).filter((field) => !field.readOnly && field.name !== model.tenant && !form.fields.includes(field.name)).filter((field) => sources[field.name] && requiredOnCreate(field, sources[field.name].schema)).map((field) => `it leaves out "${field.name}", which every create must give, so no submission could be saved`);
|
|
33
|
+
return [...fields, ...missing];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* What stops startup about the public forms: a name two forms share, a resource that does not exist, and fields a
|
|
37
|
+
* visitor cannot fill in, the anonymous role does not create, or that leave out a field the create needs. Identical forms
|
|
38
|
+
* on several views of one resource, such as one view built on another, count as one. Returns the forms by name.
|
|
39
|
+
*/
|
|
40
|
+
const checkForms = (registry, declared, anonymousCreates) => {
|
|
41
|
+
const forms = /* @__PURE__ */ new Map();
|
|
42
|
+
const problems = [];
|
|
43
|
+
for (const item of declared) {
|
|
44
|
+
const { form, view } = item;
|
|
45
|
+
const where = `View "${view.resource}": form "${form.name}"`;
|
|
46
|
+
const taken = forms.get(form.name);
|
|
47
|
+
if (taken) {
|
|
48
|
+
if (!(taken.view.resource === view.resource && JSON.stringify(taken.form) === JSON.stringify(form))) problems.push(`${where}: the name is taken by a form of ${taken.view.resource}; form names are unique across the app`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
forms.set(form.name, item);
|
|
52
|
+
if (!registry.find(view.resource)) problems.push(`${where}: there is no resource "${view.resource}"`);
|
|
53
|
+
problems.push(...fieldErrors(registry, item, anonymousCreates(view.resource)).map((problem) => `${where}: ${problem}`));
|
|
54
|
+
}
|
|
55
|
+
if (problems.length > 0) throw new Error(`The public forms cannot be served:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
56
|
+
return forms;
|
|
57
|
+
};
|
|
58
|
+
//#endregion
|
|
59
|
+
export { checkForms };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getIP } from "better-auth/api";
|
|
2
|
+
//#region src/forms/rate-limit.ts
|
|
3
|
+
const windowMs = 6e4;
|
|
4
|
+
/**
|
|
5
|
+
* The client a request counts against, as Better Auth's sign-in limit reads it: an `X-Forwarded-For` of one valid
|
|
6
|
+
* address (IPv6 by its /64), or `unknown`, one bucket for every request without one (several addresses included, since
|
|
7
|
+
* the leftmost is whatever the client sent). In development and tests a request without one is `127.0.0.1`.
|
|
8
|
+
*/
|
|
9
|
+
const clientAddress = (headers) => getIP(headers, {}) ?? "unknown";
|
|
10
|
+
/**
|
|
11
|
+
* At most `perMinute` takes per key in a fixed window of a minute, kept in this process. Keys whose window has passed are
|
|
12
|
+
* dropped as new ones come, so memory follows the clients of the last minute.
|
|
13
|
+
*/
|
|
14
|
+
const createRateLimit = ({ now = Date.now } = {}) => {
|
|
15
|
+
const windows = /* @__PURE__ */ new Map();
|
|
16
|
+
const prune = (at) => {
|
|
17
|
+
for (const [key, window] of windows) if (at - window.start >= windowMs) windows.delete(key);
|
|
18
|
+
};
|
|
19
|
+
/** Counts a take for `key`: `0` when allowed, else the seconds until its window lets it through again. */
|
|
20
|
+
const take = (key, perMinute) => {
|
|
21
|
+
const at = now();
|
|
22
|
+
const current = windows.get(key);
|
|
23
|
+
if (!current || at - current.start >= windowMs) {
|
|
24
|
+
prune(at);
|
|
25
|
+
windows.set(key, {
|
|
26
|
+
start: at,
|
|
27
|
+
count: 1
|
|
28
|
+
});
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
if (current.count >= perMinute) return Math.ceil((current.start + windowMs - at) / 1e3);
|
|
32
|
+
current.count += 1;
|
|
33
|
+
return 0;
|
|
34
|
+
};
|
|
35
|
+
return { take };
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { clientAddress, createRateLimit };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { HttpProblem, badRequest, notFound } from "../problem.js";
|
|
2
|
+
import { runWrite } from "../write-pipeline.js";
|
|
3
|
+
import { anonymousSession } from "../anonymous.js";
|
|
4
|
+
import { pruneView } from "../view-access.js";
|
|
5
|
+
import { clientAddress } from "./rate-limit.js";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import { restrictModel } from "@protobase/schema";
|
|
8
|
+
//#region src/forms/routes.ts
|
|
9
|
+
const refusedProof = {
|
|
10
|
+
invalid: "The check that you are not a bot failed; send the form again",
|
|
11
|
+
expired: "The check that you are not a bot expired; send the form again",
|
|
12
|
+
replayed: "This check was already used; send the form again"
|
|
13
|
+
};
|
|
14
|
+
const formPage = (deps, { form, view }) => {
|
|
15
|
+
const entry = deps.registry.find(view.resource);
|
|
16
|
+
const model = restrictModel(entry.model, {
|
|
17
|
+
readableFields: form.fields,
|
|
18
|
+
writableFields: {
|
|
19
|
+
create: form.fields,
|
|
20
|
+
update: []
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const pruned = pruneView(view, model, { [model.name]: model });
|
|
24
|
+
const shown = {
|
|
25
|
+
resource: view.resource,
|
|
26
|
+
...view.names && { names: view.names },
|
|
27
|
+
fields: pruned.fields,
|
|
28
|
+
filters: [],
|
|
29
|
+
layout: [],
|
|
30
|
+
actions: []
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
name: form.name,
|
|
34
|
+
...form.title !== void 0 && { title: form.title },
|
|
35
|
+
fields: form.fields,
|
|
36
|
+
thanks: form.thanks,
|
|
37
|
+
resource: model,
|
|
38
|
+
view: shown
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Beside the API, without sign-in: `GET <systemPath>/forms/<name>` describes a public form, `GET .../<name>:challenge`
|
|
43
|
+
* hands out a bot check, and `POST .../<name>` with `{ values, proof }` creates the record as the anonymous role, limited
|
|
44
|
+
* to the form's fields, after the rate limit and the check. It answers `204`: the visitor never reads the record back.
|
|
45
|
+
*/
|
|
46
|
+
const formRoutes = (deps, runtime) => {
|
|
47
|
+
const app = new Hono();
|
|
48
|
+
const find = (target) => {
|
|
49
|
+
const colon = target.indexOf(":");
|
|
50
|
+
const declared = runtime.forms.get(colon < 0 ? target : target.slice(0, colon));
|
|
51
|
+
if (!declared) throw notFound(`There is no form "${colon < 0 ? target : target.slice(0, colon)}"`);
|
|
52
|
+
return {
|
|
53
|
+
declared,
|
|
54
|
+
action: colon < 0 ? "" : target.slice(colon + 1)
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
app.get("/:target", async (c) => {
|
|
58
|
+
const { declared, action } = find(c.req.param("target"));
|
|
59
|
+
if (action === "challenge") return c.json(await runtime.challenges.issue(declared.form.name), 200, { "Cache-Control": "no-store" });
|
|
60
|
+
if (action !== "") throw notFound(`There is no method ":${action}"`);
|
|
61
|
+
return c.json(formPage(deps, declared));
|
|
62
|
+
});
|
|
63
|
+
app.post("/:target", async (c) => {
|
|
64
|
+
const { declared, action } = find(c.req.param("target"));
|
|
65
|
+
if (action !== "") throw notFound(`There is no method ":${action}"`);
|
|
66
|
+
const { form, view } = declared;
|
|
67
|
+
const wait = runtime.limit.take(`${form.name}|${clientAddress(c.req.raw.headers)}`, form.perMinute);
|
|
68
|
+
if (wait > 0) throw new HttpProblem(429, "too-many-submissions", "Too Many Requests", "This form was sent too often; wait a minute and send it again", {}, { "Retry-After": String(wait) });
|
|
69
|
+
const body = await c.req.json();
|
|
70
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) throw badRequest("invalid-submission", "Send the form as { values, proof }");
|
|
71
|
+
const { values, proof } = body;
|
|
72
|
+
const verdict = await runtime.challenges.verify(form.name, proof);
|
|
73
|
+
if (verdict !== "ok") throw badRequest("bot-check-failed", refusedProof[verdict]);
|
|
74
|
+
await runWrite(deps, deps.registry.find(view.resource), anonymousSession(), {
|
|
75
|
+
operation: "create",
|
|
76
|
+
body: values
|
|
77
|
+
}, form);
|
|
78
|
+
return c.body(null, 204);
|
|
79
|
+
});
|
|
80
|
+
return app;
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
export { formRoutes };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createRateLimit } from "./rate-limit.js";
|
|
2
|
+
import { createFormChallenges } from "./challenge.js";
|
|
3
|
+
import { checkForms } from "./check-forms.js";
|
|
4
|
+
import { anonymousRole } from "@protobase/schema";
|
|
5
|
+
//#region src/forms/runtime.ts
|
|
6
|
+
/** The fields the anonymous role may create on `resource`, or `undefined` when it may not create there. */
|
|
7
|
+
const anonymousCreates = (limits) => (resource) => {
|
|
8
|
+
const held = limits.filter((limit) => limit.role === anonymousRole && limit.capability === `${resource}.create`);
|
|
9
|
+
return held.length === 0 ? void 0 : held.flatMap((limit) => limit.fields ?? []);
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The public forms of the views, checked, with the challenges that guard them and their rate limit; `undefined` without
|
|
13
|
+
* any. Fails at startup on a form that cannot be served and when nothing signs the challenges.
|
|
14
|
+
*/
|
|
15
|
+
const createFormsRuntime = ({ registry, views, limits, env, difficulty }) => {
|
|
16
|
+
const declared = [...views].sort((a, b) => Number(a.roles !== void 0) - Number(b.roles !== void 0)).flatMap((view) => (view.forms ?? []).map((form) => ({
|
|
17
|
+
form,
|
|
18
|
+
view
|
|
19
|
+
})));
|
|
20
|
+
if (declared.length === 0) return void 0;
|
|
21
|
+
const forms = checkForms(registry, declared, anonymousCreates(limits));
|
|
22
|
+
const secret = env.PROTOBASE_FORMS_SECRET ?? env.BETTER_AUTH_SECRET;
|
|
23
|
+
if (!secret) throw new Error("Public forms need a secret to sign their bot checks: set PROTOBASE_FORMS_SECRET or BETTER_AUTH_SECRET");
|
|
24
|
+
return {
|
|
25
|
+
forms,
|
|
26
|
+
challenges: createFormChallenges(secret, { ...difficulty && { difficulty } }),
|
|
27
|
+
limit: createRateLimit()
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { createFormsRuntime };
|
|
@@ -14,6 +14,7 @@ const params = z.object({
|
|
|
14
14
|
page_token: z.string().regex(/^\d+$/, "Use the next_page_token of the previous page").optional()
|
|
15
15
|
});
|
|
16
16
|
const defaultPageSize = 20;
|
|
17
|
+
const visitorName = "Visitor";
|
|
17
18
|
const picked = (row, names) => row ? Object.fromEntries(Object.entries(row).filter(([name]) => names.has(name))) : void 0;
|
|
18
19
|
const shown = (row, readable, names) => {
|
|
19
20
|
const values = new Set(Object.values(readable.fields).filter((field) => !field.sensitive && !field.file).map((field) => field.name));
|
|
@@ -21,7 +22,7 @@ const shown = (row, readable, names) => {
|
|
|
21
22
|
if (row.operation === "update" && changed.length === 0) return [];
|
|
22
23
|
const before = picked(row.before, values);
|
|
23
24
|
const after = picked(row.after, values);
|
|
24
|
-
const name = row.actor_id === null ? void 0 : names[row.actor_id];
|
|
25
|
+
const name = row.actor_id === null ? void 0 : row.actor_id === "" ? visitorName : names[row.actor_id];
|
|
25
26
|
return [{
|
|
26
27
|
id: row.id,
|
|
27
28
|
at: row.at,
|
|
@@ -61,7 +62,7 @@ const readHistory = async (deps, entry, session, key, query) => {
|
|
|
61
62
|
});
|
|
62
63
|
if (!rows) throw notFound(`No ${entry.name} with this key`);
|
|
63
64
|
const page = rows.slice(0, size);
|
|
64
|
-
const ids = [...new Set(page.flatMap((row) => row.actor_id === null ? [] : [row.actor_id]))];
|
|
65
|
+
const ids = [...new Set(page.flatMap((row) => row.actor_id === null || row.actor_id === "" ? [] : [row.actor_id]))];
|
|
65
66
|
const names = ids.length > 0 && actorNames ? await actorNames(ids) : {};
|
|
66
67
|
return {
|
|
67
68
|
items: page.flatMap((row) => shown(row, access.readable, names)),
|
package/dist/list-photos.js
CHANGED
|
@@ -5,9 +5,9 @@ import { checkedSort } from "./filter-input.js";
|
|
|
5
5
|
import { readScope, requestAccess } from "./request-access.js";
|
|
6
6
|
import { readTransaction } from "./transactions.js";
|
|
7
7
|
import { cardPhotosErrors } from "./view-card-photos.js";
|
|
8
|
-
import { checkFilter, createWhere } from "@protobase/schema";
|
|
9
8
|
import { cardPhotoLimit } from "@protobase/schema/internal";
|
|
10
9
|
import { relatedRows } from "@protobase/query";
|
|
10
|
+
import { checkFilter, createWhere } from "@protobase/schema";
|
|
11
11
|
//#region src/list-photos.ts
|
|
12
12
|
const where = createWhere();
|
|
13
13
|
const invalid = (parameter, message) => badRequest("invalid-parameter", `Invalid ${parameter}: ${message}`, { errors: [{
|
package/dist/meta.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { seesAssistant } from "./assistant/assistant-settings.js";
|
|
2
2
|
import { sha256Hex, stableJson } from "./hash.js";
|
|
3
3
|
import { requestAccess } from "./request-access.js";
|
|
4
|
+
import { pruneView } from "./view-access.js";
|
|
4
5
|
import { activeRole } from "./active-role.js";
|
|
5
6
|
import { pluginsFor } from "./plugins/plugin-settings.js";
|
|
6
7
|
import { seesRuntime } from "./runtime/runtime-settings.js";
|
|
7
8
|
import { sourcesFor } from "./sources/source-settings.js";
|
|
8
|
-
import { pruneView } from "./view-access.js";
|
|
9
9
|
import { prunePage } from "@protobase/layout";
|
|
10
|
-
import { pickView } from "@protobase/schema";
|
|
11
10
|
import { bottomBarFor } from "@protobase/schema/internal";
|
|
11
|
+
import { pickView } from "@protobase/schema";
|
|
12
12
|
//#region src/meta.ts
|
|
13
13
|
const signInView = (view) => {
|
|
14
14
|
if (!view?.signIn) return [];
|
package/dist/related-labels.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { badRequest } from "./problem.js";
|
|
2
2
|
import { exposeRow } from "./rows.js";
|
|
3
3
|
import { readScope, requestAccess } from "./request-access.js";
|
|
4
|
-
import { checkFilter, createWhere } from "@protobase/schema";
|
|
5
4
|
import { listQuery } from "@protobase/query";
|
|
5
|
+
import { checkFilter, createWhere } from "@protobase/schema";
|
|
6
6
|
//#region src/related-labels.ts
|
|
7
7
|
const where = createWhere();
|
|
8
8
|
const invalid = (entry, message) => badRequest("invalid-parameter", `Invalid labels entry "${entry}": ${message}`, { errors: [{
|
package/dist/request-access.d.ts
CHANGED
package/dist/request-access.js
CHANGED
|
@@ -9,14 +9,38 @@ const withoutSensitive = (model) => ({
|
|
|
9
9
|
...model,
|
|
10
10
|
fields: Object.fromEntries(Object.entries(model.fields).filter(([, field]) => !field.sensitive))
|
|
11
11
|
});
|
|
12
|
-
const
|
|
13
|
-
|
|
12
|
+
const withoutAnonymousCreate = (resolved, session) => session.anonymous ? {
|
|
13
|
+
...resolved,
|
|
14
|
+
operations: {
|
|
15
|
+
...resolved.operations,
|
|
16
|
+
create: false
|
|
17
|
+
},
|
|
18
|
+
writableFields: {
|
|
19
|
+
...resolved.writableFields,
|
|
20
|
+
create: []
|
|
21
|
+
}
|
|
22
|
+
} : resolved;
|
|
23
|
+
const throughForm = (resolved, form) => {
|
|
24
|
+
const create = resolved.writableFields.create.filter((name) => form.fields.includes(name));
|
|
25
|
+
return {
|
|
26
|
+
...resolved,
|
|
27
|
+
readableFields: [.../* @__PURE__ */ new Set([...resolved.readableFields, ...create])],
|
|
28
|
+
writableFields: {
|
|
29
|
+
create,
|
|
30
|
+
update: []
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** `form` is the public form a visitor sends, the only way a caller without a token creates. */
|
|
35
|
+
const requestAccess = async (deps, entry, session, form) => {
|
|
36
|
+
const all = await resolveAccess(entry.source, {
|
|
14
37
|
user: {
|
|
15
38
|
id: session.user.id,
|
|
16
39
|
roles: session.user.roles
|
|
17
40
|
},
|
|
18
41
|
...organizationOf(session)
|
|
19
42
|
}, deps.accessOptions);
|
|
43
|
+
const resolved = form ? throughForm(all, form) : withoutAnonymousCreate(all, session);
|
|
20
44
|
checkOwnerId(entry, session.user, resolved);
|
|
21
45
|
const readable = restrictModel(entry.model, resolved);
|
|
22
46
|
return {
|
package/dist/scan-guard.d.ts
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { JobDefinition } from "./jobs/job-types.js";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { PageModel } from "@protobase/layout";
|
|
4
|
-
import { ResourceModel, ViewModel } from "@protobase/schema";
|
|
5
4
|
import { Db } from "@protobase/query";
|
|
5
|
+
import { ResourceModel, ViewModel } from "@protobase/schema";
|
|
6
6
|
//#region src/types.d.ts
|
|
7
7
|
export type TenantValue = string | number;
|
|
8
8
|
export type User = {
|
package/dist/validate-body.js
CHANGED
|
@@ -20,6 +20,11 @@ const isObject = (value) => typeof value === "object" && value !== null && !Arra
|
|
|
20
20
|
const reserved = ["etag", "permissions"];
|
|
21
21
|
const isEmptyText = (field, value) => field.type === "text" && !field.nullable && value === "";
|
|
22
22
|
/**
|
|
23
|
+
* Whether a create must give the field, as `validateBody` decides when it is missing: the database supplies no value,
|
|
24
|
+
* it may not be null, and its schema has no default of its own (`.default()`).
|
|
25
|
+
*/
|
|
26
|
+
const requiredOnCreate = (field, schema) => !(field.default && "db" in field.default) && !field.nullable && !schema.safeParse(void 0).success;
|
|
27
|
+
/**
|
|
23
28
|
* Checks a create or update body for this caller. A field the caller cannot read is an unknown field, one they can read
|
|
24
29
|
* but not write is read-only (the tenant column and the API's own `etag` and `permissions` keys are always read-only);
|
|
25
30
|
* every provided value goes through its field's zod schema, then `.validate()` rules run on the result. An empty text
|
|
@@ -92,4 +97,4 @@ const validateBody = (access, mode, body, current = {}) => {
|
|
|
92
97
|
return values;
|
|
93
98
|
};
|
|
94
99
|
//#endregion
|
|
95
|
-
export { validateBody };
|
|
100
|
+
export { requiredOnCreate, validateBody };
|
package/dist/view-access.js
CHANGED
|
@@ -235,7 +235,8 @@ const viewShape = (resource, model, models, prompted) => shapes.object({
|
|
|
235
235
|
searchesOf: shapes.map((searches) => searchesOfFor(searches, resource, models)),
|
|
236
236
|
import: shapes.data,
|
|
237
237
|
export: shapes.data,
|
|
238
|
-
create: shapes.data
|
|
238
|
+
create: shapes.data,
|
|
239
|
+
forms: shapes.map(() => void 0)
|
|
239
240
|
});
|
|
240
241
|
/**
|
|
241
242
|
* The view with every reference to a field the caller cannot read removed: list columns, sort, search and displays,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EventCodeError } from "../changes/event-code-error.js";
|
|
2
2
|
import { eventPatternMatches } from "./subscription.js";
|
|
3
|
-
import { evaluateFilter } from "@protobase/schema";
|
|
4
3
|
import { checkEventFilter } from "@protobase/schema/internal";
|
|
4
|
+
import { evaluateFilter } from "@protobase/schema";
|
|
5
5
|
//#region src/webhooks/route-events.ts
|
|
6
6
|
const declaredEvents = (models) => models.flatMap((model) => (model.events ?? []).map((event) => ({
|
|
7
7
|
model,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Row, TenantValue } from "../types.js";
|
|
2
|
-
import { EventActor, EventOperation } from "@protobase/schema";
|
|
3
2
|
import { Db } from "@protobase/query";
|
|
3
|
+
import { EventActor, EventOperation } from "@protobase/schema";
|
|
4
4
|
//#region src/webhooks/subscription.d.ts
|
|
5
5
|
/** What happened, as a receiver gets it in the `data` of an event. Records use field names, as the API shows them. */
|
|
6
6
|
export type EventData = {
|
package/dist/write-pipeline.js
CHANGED
|
@@ -91,9 +91,12 @@ const writeIn = async (trx, deps, access, request) => {
|
|
|
91
91
|
if (request.operation === "update") return update(trx, deps, access, request);
|
|
92
92
|
return removeOrRestore(trx, deps, access, request);
|
|
93
93
|
};
|
|
94
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
/**
|
|
95
|
+
* The path every single write takes: resolve access once, then `writeIn` in its own transaction with a statement timeout.
|
|
96
|
+
* The caller has already authenticated `session`; `form` is the public form a visitor creates through.
|
|
97
|
+
*/
|
|
98
|
+
const runWrite = async (deps, entry, session, request, form) => {
|
|
99
|
+
const access = await requestAccess(deps, entry, session, form);
|
|
97
100
|
requireOperation(access, request.operation === "undelete" ? "update" : request.operation);
|
|
98
101
|
const result = await writeTransaction(deps.db, deps.statementTimeoutMs, (trx) => writeIn(trx, deps, access, request), actorSettings(deps, session));
|
|
99
102
|
wakeDispatch(deps);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.7",
|
|
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,13 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@better-auth/passkey": "^1.7.7",
|
|
33
|
-
"@protobase/layout": "0.21.
|
|
34
|
-
"@protobase/query": "0.21.
|
|
35
|
-
"@protobase/schema": "0.21.
|
|
33
|
+
"@protobase/layout": "0.21.7",
|
|
34
|
+
"@protobase/query": "0.21.7",
|
|
35
|
+
"@protobase/schema": "0.21.7",
|
|
36
36
|
"@react-email/render": "^2.1.0",
|
|
37
37
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
38
38
|
"@sentry/core": "^11.7.0",
|
|
39
39
|
"@simplewebauthn/server": "^13.3.1",
|
|
40
|
+
"altcha-lib": "^2.6.0",
|
|
40
41
|
"better-auth": "^1.7.7",
|
|
41
42
|
"cron-parser": "^5.10.1",
|
|
42
43
|
"file-type": "^22.1.1",
|