@protobase/server 0.8.2 → 0.10.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/active-role.js +8 -0
- package/dist/admin-options.d.ts +8 -0
- package/dist/api-cors.d.ts +5 -0
- package/dist/api-cors.js +53 -0
- package/dist/batch/run-batch.js +4 -2
- package/dist/better-auth/admin-only.js +5 -3
- package/dist/better-auth/authenticator.d.ts +1 -1
- package/dist/better-auth/authenticator.js +1 -1
- package/dist/better-auth/create-auth.d.ts +166 -160
- package/dist/better-auth/create-auth.js +40 -10
- package/dist/better-auth/operator-provider.d.ts +2 -1
- package/dist/better-auth/origins.d.ts +4 -0
- package/dist/better-auth/origins.js +43 -0
- package/dist/better-auth/passkey-domain.js +41 -0
- package/dist/better-auth/passkey-plugin.js +151 -0
- package/dist/better-auth/roles/active-role.js +26 -0
- package/dist/better-auth/roles/roles-plugin.js +73 -0
- package/dist/better-auth/roles/session-role.js +28 -0
- package/dist/better-auth/staff-sign-in-endpoints.js +9 -2
- package/dist/better-auth/status-route.js +5 -1
- package/dist/better-auth/tokens.d.ts +2 -1
- package/dist/better-auth/tokens.js +5 -2
- package/dist/changes/actor.js +7 -1
- package/dist/changes/capture-schema.js +1 -1
- package/dist/changes/capture-sql.js +13 -2
- package/dist/changes/coalesce.d.ts +4 -0
- package/dist/changes/coalesce.js +2 -1
- package/dist/changes/outbox.d.ts +14 -0
- package/dist/changes/outbox.js +51 -0
- package/dist/changes/resource-change.d.ts +2 -0
- package/dist/changes/resource-change.js +2 -1
- package/dist/check-shell.js +11 -2
- package/dist/create-admin.d.ts +13 -6
- package/dist/create-admin.js +42 -8
- package/dist/index.d.ts +13 -5
- package/dist/index.js +14 -8
- package/dist/jobs/current.d.ts +2 -0
- package/dist/jobs/current.js +3 -1
- package/dist/jobs/failed-jobs.js +10 -10
- package/dist/jobs/functions-sql.js +8 -5
- package/dist/jobs/runtime.js +9 -5
- package/dist/meta.js +9 -2
- package/dist/openapi/components.js +1 -1
- package/dist/openapi/document.js +7 -5
- package/dist/openapi/naming.js +2 -1
- package/dist/project-config.d.ts +4 -1
- package/dist/project-config.js +10 -2
- package/dist/resource-source.d.ts +9 -3
- package/dist/resource-source.js +8 -3
- package/dist/routes/docs.js +2 -2
- package/dist/types.d.ts +5 -1
- package/dist/webhooks/attempt.js +91 -0
- package/dist/webhooks/retry-schedule.js +40 -0
- package/dist/webhooks/route-events.js +61 -0
- package/dist/webhooks/standard-webhooks.d.ts +4 -0
- package/dist/webhooks/standard-webhooks.js +36 -0
- package/dist/webhooks/subscriber-view.js +76 -0
- package/dist/webhooks/subscription.d.ts +92 -0
- package/dist/webhooks/subscription.js +28 -0
- package/dist/webhooks/table/address-check.js +28 -0
- package/dist/webhooks/table/options.d.ts +23 -0
- package/dist/webhooks/table/options.js +14 -0
- package/dist/webhooks/table/owner-roles.js +29 -0
- package/dist/webhooks/table/plugin.d.ts +10 -0
- package/dist/webhooks/table/plugin.js +18 -0
- package/dist/webhooks/table/schema-sql.js +116 -0
- package/dist/webhooks/table/schema.d.ts +13 -0
- package/dist/webhooks/table/schema.js +44 -0
- package/dist/webhooks/table/table-subscriptions.js +77 -0
- package/dist/webhooks/table/validate-hook.js +32 -0
- package/dist/webhooks/table/webhook-table.js +23 -0
- package/dist/webhooks/test-event.js +48 -0
- package/dist/webhooks/webhooks.js +233 -0
- package/dist/write-pipeline.js +4 -2
- package/package.json +4 -4
package/dist/jobs/runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { jobsSchema } from "./schema.js";
|
|
2
1
|
import { clearCurrentJobs, setCurrentJobs } from "./current.js";
|
|
2
|
+
import { jobsSchema } from "./schema.js";
|
|
3
3
|
import "./schedule.js";
|
|
4
4
|
import { runJob, validatePayload } from "./run-job.js";
|
|
5
5
|
import { jobsDatabase } from "./database.js";
|
|
@@ -81,8 +81,10 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
|
|
|
81
81
|
const checked = await validatePayload(definition, payload);
|
|
82
82
|
if ("invalid" in checked) throw new Error(checked.invalid);
|
|
83
83
|
const data = checked.value ?? null;
|
|
84
|
+
const orderedId = definition.options.ordered ? (await sql`select uuidv7() as id`.execute(target)).rows[0].id : void 0;
|
|
84
85
|
const send = {
|
|
85
86
|
...sendOptions(definition.options, options),
|
|
87
|
+
...orderedId !== void 0 && { id: orderedId },
|
|
86
88
|
db: jobsDatabase(target)
|
|
87
89
|
};
|
|
88
90
|
const keyed = async (key) => {
|
|
@@ -98,10 +100,7 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
|
|
|
98
100
|
};
|
|
99
101
|
const id = options.key === void 0 ? await boss.send(name, data, send) : await keyed(options.key);
|
|
100
102
|
if (!id) throw new Error(`Job "${name}" was not enqueued`);
|
|
101
|
-
if (!target.isTransaction)
|
|
102
|
-
const worker = workers.get(name);
|
|
103
|
-
if (worker) boss.notifyWorker(worker.id);
|
|
104
|
-
}
|
|
103
|
+
if (!target.isTransaction) wake(name);
|
|
105
104
|
return id;
|
|
106
105
|
};
|
|
107
106
|
const nextDue = async () => (await sql`select ${sql.id(jobsSchema, "next_due")}() as due`.execute(db)).rows[0].due;
|
|
@@ -129,9 +128,14 @@ const startJobs = async ({ db, jobs, work = true, pollSeconds = 2, shutdownSecon
|
|
|
129
128
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
130
129
|
}
|
|
131
130
|
};
|
|
131
|
+
const wake = (name) => {
|
|
132
|
+
const worker = workers.get(name);
|
|
133
|
+
if (worker) boss.notifyWorker(worker.id);
|
|
134
|
+
};
|
|
132
135
|
const runtime = {
|
|
133
136
|
works: work,
|
|
134
137
|
enqueue,
|
|
138
|
+
wake,
|
|
135
139
|
update,
|
|
136
140
|
drain,
|
|
137
141
|
nextDue,
|
package/dist/meta.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
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 { activeRole } from "./active-role.js";
|
|
4
5
|
import { seesRuntime } from "./runtime/runtime-settings.js";
|
|
5
6
|
import { pruneView } from "./view-access.js";
|
|
6
7
|
import { prunePage } from "@protobase/layout";
|
|
7
|
-
import { pickView } from "@protobase/schema";
|
|
8
|
+
import { bottomBarFor, pickView } from "@protobase/schema";
|
|
8
9
|
//#region src/meta.ts
|
|
9
10
|
const modelsHash = (deps) => sha256Hex(stableJson(deps.registry.entries.map((entry) => entry.model))).then((hash) => hash.slice(0, 32));
|
|
10
11
|
/**
|
|
11
12
|
* `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
|
|
12
13
|
* absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
|
|
13
|
-
* user menu without the resources they cannot see.
|
|
14
|
+
* user menu and their bottom bar without the resources they cannot see.
|
|
14
15
|
* The ETag hashes all of it, so it differs per user and per roles.
|
|
15
16
|
*/
|
|
16
17
|
const createMeta = (deps) => {
|
|
@@ -32,6 +33,8 @@ const createMeta = (deps) => {
|
|
|
32
33
|
};
|
|
33
34
|
const pages = deps.pages.flatMap((page) => prunePage(page, session.user.roles, callerAccess) ?? []);
|
|
34
35
|
const shown = (item) => item.kind === "link" || (item.kind === "resource" ? visible.some((access) => access.entry.name === item.resource) : pages.some((page) => page.name === item.page));
|
|
36
|
+
const bar = bottomBarFor(deps.bottomBars, activeRole(session));
|
|
37
|
+
const barItems = bar?.items.filter(shown) ?? [];
|
|
35
38
|
const body = {
|
|
36
39
|
resources: visible.map((access) => access.readable),
|
|
37
40
|
views,
|
|
@@ -48,6 +51,10 @@ const createMeta = (deps) => {
|
|
|
48
51
|
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
|
|
49
52
|
}])),
|
|
50
53
|
...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } },
|
|
54
|
+
...bar && barItems.length > 0 && { bottomBar: {
|
|
55
|
+
label: bar.label,
|
|
56
|
+
items: barItems
|
|
57
|
+
} },
|
|
51
58
|
...deps.assistant && seesAssistant(session.user.roles) && { assistant: { url: deps.assistant } },
|
|
52
59
|
...deps.runtime && seesRuntime(session.user.roles) && { runtime: { url: deps.runtime } }
|
|
53
60
|
};
|
|
@@ -64,7 +64,7 @@ const parameters = {
|
|
|
64
64
|
orderBy: {
|
|
65
65
|
name: "order_by",
|
|
66
66
|
in: "query",
|
|
67
|
-
description: "Comma separated sortable fields, each with an optional `desc`, as in [AIP-132](https://google.aip.dev/132#ordering): `
|
|
67
|
+
description: "Comma separated sortable fields, each with an optional `desc`, as in [AIP-132](https://google.aip.dev/132#ordering): `created_at desc, number`. The primary key breaks ties.",
|
|
68
68
|
schema: { type: "string" }
|
|
69
69
|
},
|
|
70
70
|
pageSize: {
|
package/dist/openapi/document.js
CHANGED
|
@@ -2,20 +2,22 @@ import { batchSchemas, errorResponses, parameters, problemSchema, searchBody } f
|
|
|
2
2
|
import { resourcePaths } from "./resource-paths.js";
|
|
3
3
|
import { resourceSchemas } from "./schemas.js";
|
|
4
4
|
//#region src/openapi/document.ts
|
|
5
|
+
const resourcesServer = (basePath) => basePath.slice(basePath.lastIndexOf("/") + 1);
|
|
6
|
+
const systemServer = ".";
|
|
5
7
|
/** The OpenAPI 3.1 document for one caller, generated from the resource models they may see and their zod record schemas. */
|
|
6
|
-
const openApiDocument = (accesses, basePath
|
|
8
|
+
const openApiDocument = (accesses, basePath) => ({
|
|
7
9
|
openapi: "3.1.0",
|
|
8
10
|
info: {
|
|
9
11
|
title: "Protobase API",
|
|
10
12
|
version: "1.0.0",
|
|
11
13
|
description: "Admin API for an existing Postgres database. Follows Google's API design conventions ([AIP](https://google.aip.dev)) and reports errors as RFC 9457 `application/problem+json`."
|
|
12
14
|
},
|
|
13
|
-
servers: [{ url: basePath }],
|
|
15
|
+
servers: [{ url: resourcesServer(basePath) }],
|
|
14
16
|
security: [{ bearer: [] }],
|
|
15
17
|
tags: accesses.map(({ entry }) => ({ name: entry.name })),
|
|
16
18
|
paths: {
|
|
17
19
|
"/meta": {
|
|
18
|
-
servers: [{ url:
|
|
20
|
+
servers: [{ url: systemServer }],
|
|
19
21
|
get: {
|
|
20
22
|
operationId: "get_meta",
|
|
21
23
|
summary: "Resource and view models",
|
|
@@ -27,8 +29,8 @@ const openApiDocument = (accesses, basePath, systemPath) => ({
|
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
},
|
|
30
|
-
[`/${
|
|
31
|
-
servers: [{ url:
|
|
32
|
+
[`/${resourcesServer(basePath)}:batchWrite`]: {
|
|
33
|
+
servers: [{ url: systemServer }],
|
|
32
34
|
post: {
|
|
33
35
|
operationId: "batch_write",
|
|
34
36
|
summary: "Write many records in one transaction",
|
package/dist/openapi/naming.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
//#region src/openapi/naming.ts
|
|
2
|
-
|
|
2
|
+
/** A component name from a resource name: `stock_moves` and `stockMoves` both become `StockMoves`. */
|
|
3
|
+
const pascal = (name) => name.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
3
4
|
//#endregion
|
|
4
5
|
export { pascal };
|
package/dist/project-config.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { AdminAuth } from "./better-auth/create-auth.js";
|
|
2
2
|
import { EventsOptions } from "./changes/events-options.js";
|
|
3
|
-
import { FilesOptions } from "./files/options.js";
|
|
4
3
|
import { Authenticator } from "./types.js";
|
|
4
|
+
import { WebhooksOptions } from "./webhooks/table/options.js";
|
|
5
|
+
import { FilesOptions } from "./files/options.js";
|
|
5
6
|
import { FunctionSource } from "./functions/define-function.js";
|
|
6
7
|
import { AdminOptions } from "./admin-options.js";
|
|
7
8
|
import { Db } from "@protobase/query";
|
|
@@ -24,6 +25,8 @@ export type ProjectConfig = {
|
|
|
24
25
|
files?: FilesOptions;
|
|
25
26
|
/** Where the changes that resources' events are raised from are captured (see `EventsOptions`). */
|
|
26
27
|
events?: EventsOptions;
|
|
28
|
+
/** Webhook subscriptions made in the admin app; set by `extends: [webhooks()]` (see `WebhooksOptions`). */
|
|
29
|
+
webhooks?: WebhooksOptions;
|
|
27
30
|
/** The module of the app's jobs, e.g. `import * as jobs from './config/jobs'`: every `defineJob` it exports. */
|
|
28
31
|
jobs?: Record<string, unknown>;
|
|
29
32
|
options?: AdminOptions;
|
package/dist/project-config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jobDefinitions } from "./jobs/define-job.js";
|
|
2
|
+
import { isSubscription } from "./webhooks/subscription.js";
|
|
2
3
|
import { isPageDefinition } from "@protobase/layout";
|
|
3
4
|
//#region src/project-config.ts
|
|
4
5
|
/** Typed identity, for `export default defineConfig({ extends: [...], config, auth, ... })` in `protobase.config.ts`. */
|
|
@@ -41,6 +42,11 @@ const mergedModule = (named) => {
|
|
|
41
42
|
value,
|
|
42
43
|
label
|
|
43
44
|
})));
|
|
45
|
+
checkUnique("The subscription", exports.filter(({ value }) => isSubscription(value)).map(({ value, label }) => ({
|
|
46
|
+
name: value.name,
|
|
47
|
+
value,
|
|
48
|
+
label
|
|
49
|
+
})));
|
|
44
50
|
const menus = values.filter(({ value }) => typeof value?.toUserMenuModel === "function");
|
|
45
51
|
if (new Set(menus.map(({ value }) => value)).size > 1) throw new Error(`Only one config may export a user menu; ${menus.map(({ label }) => label).join(" and ")} each do`);
|
|
46
52
|
const module = {};
|
|
@@ -133,11 +139,12 @@ const mergedOptions = (named) => {
|
|
|
133
139
|
*/
|
|
134
140
|
const mergeConfig = (...configs) => {
|
|
135
141
|
const named = configs.flatMap((config, index) => flatten(config, config.name ?? `config ${index + 1}`));
|
|
136
|
-
const { db, authenticate, auth, events } = lastSet(named.map(({ config }) => ({
|
|
142
|
+
const { db, authenticate, auth, events, webhooks } = lastSet(named.map(({ config }) => ({
|
|
137
143
|
db: config.db,
|
|
138
144
|
authenticate: config.authenticate,
|
|
139
145
|
auth: config.auth,
|
|
140
|
-
events: config.events
|
|
146
|
+
events: config.events,
|
|
147
|
+
webhooks: config.webhooks
|
|
141
148
|
})));
|
|
142
149
|
const module = mergedModule(named);
|
|
143
150
|
const functions = mergedFunctions(named);
|
|
@@ -154,6 +161,7 @@ const mergeConfig = (...configs) => {
|
|
|
154
161
|
...functions && { functions },
|
|
155
162
|
...files && { files },
|
|
156
163
|
...events && { events },
|
|
164
|
+
...webhooks && { webhooks },
|
|
157
165
|
...jobs && { jobs },
|
|
158
166
|
...options && { options }
|
|
159
167
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { SubscriptionDefinition } from "./webhooks/subscription.js";
|
|
2
|
+
import "./index.js";
|
|
1
3
|
import { PageDefinition } from "@protobase/layout";
|
|
2
|
-
import { AccessSource, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
4
|
+
import { AccessSource, BottomBarSource, EventMatch, FileProcessor, UserMenuSource, ViewModel } from "@protobase/schema";
|
|
3
5
|
import { z } from "zod";
|
|
4
6
|
//#region src/resource-source.d.ts
|
|
5
7
|
type FieldSource = {
|
|
@@ -22,10 +24,12 @@ export type ResourceSource = AccessSource & {
|
|
|
22
24
|
message: string;
|
|
23
25
|
}> | undefined)[];
|
|
24
26
|
recordSchema(): z.ZodType;
|
|
27
|
+
/** The code predicates of its events, by event name. */
|
|
28
|
+
eventMatches?(): Record<string, EventMatch<any>>;
|
|
25
29
|
};
|
|
26
30
|
/**
|
|
27
|
-
* Splits a config module (`import * as config from './config'`) into its resources, views, pages and,
|
|
28
|
-
* one `userMenu(...)`, the user menu.
|
|
31
|
+
* Splits a config module (`import * as config from './config'`) into its resources, views, pages, subscriptions and,
|
|
32
|
+
* when it exports one `userMenu(...)`, the user menu, and its `bottomBar(...)` exports in order.
|
|
29
33
|
*/
|
|
30
34
|
export declare const configExports: (module: Record<string, unknown>) => {
|
|
31
35
|
userMenu?: UserMenuSource | undefined;
|
|
@@ -34,5 +38,7 @@ export declare const configExports: (module: Record<string, unknown>) => {
|
|
|
34
38
|
toModel(): ViewModel;
|
|
35
39
|
}>;
|
|
36
40
|
pages: PageDefinition[];
|
|
41
|
+
subscriptions: SubscriptionDefinition[];
|
|
42
|
+
bottomBars: BottomBarSource[];
|
|
37
43
|
};
|
|
38
44
|
//#endregion
|
package/dist/resource-source.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
import { isSubscription } from "./webhooks/subscription.js";
|
|
1
2
|
import { isPageDefinition } from "@protobase/layout";
|
|
2
3
|
//#region src/resource-source.ts
|
|
3
4
|
/**
|
|
4
|
-
* Splits a config module (`import * as config from './config'`) into its resources, views, pages and,
|
|
5
|
-
* one `userMenu(...)`, the user menu.
|
|
5
|
+
* Splits a config module (`import * as config from './config'`) into its resources, views, pages, subscriptions and,
|
|
6
|
+
* when it exports one `userMenu(...)`, the user menu, and its `bottomBar(...)` exports in order.
|
|
6
7
|
*/
|
|
7
8
|
const configExports = (module) => {
|
|
8
9
|
const values = Object.values(module);
|
|
9
10
|
const pages = values.filter(isPageDefinition);
|
|
10
|
-
const
|
|
11
|
+
const subscriptions = [...new Set(values.filter(isSubscription))];
|
|
12
|
+
const others = values.filter((value) => !isPageDefinition(value) && !isSubscription(value));
|
|
11
13
|
const buildable = others.filter((value) => typeof value?.toModel === "function");
|
|
12
14
|
const menus = others.filter((value) => typeof value?.toUserMenuModel === "function");
|
|
15
|
+
const bottomBars = [...new Set(others.filter((value) => typeof value?.toBottomBarModel === "function"))];
|
|
13
16
|
if (menus.length > 1) throw new Error(`The config module exports ${menus.length} user menus; export one userMenu(...)`);
|
|
14
17
|
return {
|
|
15
18
|
resources: buildable.filter((value) => typeof value.recordSchema === "function"),
|
|
16
19
|
views: buildable.filter((value) => typeof value.recordSchema !== "function"),
|
|
17
20
|
pages,
|
|
21
|
+
subscriptions,
|
|
22
|
+
bottomBars,
|
|
18
23
|
...menus[0] && { userMenu: menus[0] }
|
|
19
24
|
};
|
|
20
25
|
};
|
package/dist/routes/docs.js
CHANGED
|
@@ -8,10 +8,10 @@ const docsRoutes = (deps) => {
|
|
|
8
8
|
const app = new Hono();
|
|
9
9
|
app.get("/openapi.json", async (c) => {
|
|
10
10
|
const visible = (await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, c.get("session"))))).filter((access) => Object.values(access.resolved.operations).some(Boolean));
|
|
11
|
-
return c.json(openApiDocument(visible, deps.basePath
|
|
11
|
+
return c.json(openApiDocument(visible, deps.basePath));
|
|
12
12
|
});
|
|
13
13
|
app.get("/docs", Scalar({
|
|
14
|
-
url:
|
|
14
|
+
url: "openapi.json",
|
|
15
15
|
pageTitle: "Protobase API"
|
|
16
16
|
}));
|
|
17
17
|
return app;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { JobDefinition } from "./jobs/job-types.js";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { PageModel } from "@protobase/layout";
|
|
3
4
|
import { ResourceModel, ViewModel } from "@protobase/schema";
|
|
@@ -88,5 +89,8 @@ export type ViewSource = {
|
|
|
88
89
|
export type PageSource = {
|
|
89
90
|
toModel(): PageModel;
|
|
90
91
|
};
|
|
91
|
-
|
|
92
|
+
/** The admin API, and the jobs it needs a worker to run: the delivery of resource events. */
|
|
93
|
+
export type Admin = Hono<AdminEnv> & {
|
|
94
|
+
jobs: JobDefinition[];
|
|
95
|
+
};
|
|
92
96
|
//#endregion
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { invokeFunction } from "../functions/define-function.js";
|
|
2
|
+
import { retryAfterMs } from "./retry-schedule.js";
|
|
3
|
+
import { standardWebhookHeaders } from "./standard-webhooks.js";
|
|
4
|
+
import { subscriberSession } from "./subscriber-view.js";
|
|
5
|
+
//#region src/webhooks/attempt.ts
|
|
6
|
+
/** How long a receiver has to answer. */
|
|
7
|
+
const attemptTimeoutMs = 15e3;
|
|
8
|
+
const excerpt = async (response) => (await response.text()).slice(0, 200);
|
|
9
|
+
const resultOf = async (response) => {
|
|
10
|
+
if (response.status >= 200 && response.status < 300) return {
|
|
11
|
+
ok: true,
|
|
12
|
+
status: response.status
|
|
13
|
+
};
|
|
14
|
+
const body = await excerpt(response);
|
|
15
|
+
const wait = retryAfterMs(response.headers.get("retry-after"));
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
status: response.status,
|
|
19
|
+
error: `${response.status}${body ? `: ${body}` : ""}`,
|
|
20
|
+
...wait !== void 0 && { retryAfterMs: wait },
|
|
21
|
+
final: response.status === 410
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
const request = async (url, event, options, signal) => {
|
|
25
|
+
const body = JSON.stringify(options.body ? options.body(event) : event);
|
|
26
|
+
const signed = await standardWebhookHeaders(event.id, Math.floor(Date.now() / 1e3), body, options.secret);
|
|
27
|
+
return new Request(url, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
...options.headers,
|
|
31
|
+
...signed,
|
|
32
|
+
"content-type": "application/json"
|
|
33
|
+
},
|
|
34
|
+
body,
|
|
35
|
+
redirect: "manual",
|
|
36
|
+
signal
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
const callFunction = async (name, subscription, event, options, context, signal) => {
|
|
40
|
+
const fn = context.functions[name];
|
|
41
|
+
if (!fn) throw new Error(`The app has no function "${name}"`);
|
|
42
|
+
const session = subscriberSession(subscription) ?? { user: {
|
|
43
|
+
id: `subscription:${subscription.name}`,
|
|
44
|
+
roles: []
|
|
45
|
+
} };
|
|
46
|
+
const caller = {
|
|
47
|
+
session,
|
|
48
|
+
records: context.records(session)
|
|
49
|
+
};
|
|
50
|
+
const functionContext = {
|
|
51
|
+
name,
|
|
52
|
+
db: context.db,
|
|
53
|
+
caller: async () => caller
|
|
54
|
+
};
|
|
55
|
+
return invokeFunction(name, fn, await request("http://protobase.internal/", event, options, signal), fn.options.public ? functionContext : {
|
|
56
|
+
...functionContext,
|
|
57
|
+
...caller
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
const failure = (error) => ({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: error instanceof Error ? error.message : String(error),
|
|
63
|
+
final: false
|
|
64
|
+
});
|
|
65
|
+
/** One try of one event to one subscription's target, within `attemptTimeoutMs`. It never throws. */
|
|
66
|
+
const attemptDelivery = async (subscription, event, context) => {
|
|
67
|
+
const signal = AbortSignal.any([context.signal, AbortSignal.timeout(attemptTimeoutMs)]);
|
|
68
|
+
const { options } = subscription;
|
|
69
|
+
try {
|
|
70
|
+
if ("run" in options) {
|
|
71
|
+
await options.run(event, {
|
|
72
|
+
db: context.db,
|
|
73
|
+
attempt: context.attempt,
|
|
74
|
+
signal
|
|
75
|
+
});
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
if ("function" in options) return await resultOf(await callFunction(options.function, subscription, event, options, context, signal));
|
|
79
|
+
const blocked = await context.checkUrl?.(options.url);
|
|
80
|
+
if (blocked) return {
|
|
81
|
+
ok: false,
|
|
82
|
+
error: blocked,
|
|
83
|
+
final: true
|
|
84
|
+
};
|
|
85
|
+
return await resultOf(await fetch(await request(options.url, event, options, signal)));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return failure(error);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
export { attemptDelivery, attemptTimeoutMs };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/webhooks/retry-schedule.ts
|
|
2
|
+
const second = 1e3;
|
|
3
|
+
const minute = 60 * second;
|
|
4
|
+
const hour = 60 * minute;
|
|
5
|
+
/**
|
|
6
|
+
* The waits before each retry, as Standard Webhooks recommends: 5 s, 5 m, 30 m, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h,
|
|
7
|
+
* about three days in all, so a delivery has 10 tries.
|
|
8
|
+
*/
|
|
9
|
+
const retryDelays = [
|
|
10
|
+
5 * second,
|
|
11
|
+
5 * minute,
|
|
12
|
+
30 * minute,
|
|
13
|
+
2 * hour,
|
|
14
|
+
5 * hour,
|
|
15
|
+
10 * hour,
|
|
16
|
+
14 * hour,
|
|
17
|
+
20 * hour,
|
|
18
|
+
24 * hour
|
|
19
|
+
];
|
|
20
|
+
const maxTries = retryDelays.length + 1;
|
|
21
|
+
/**
|
|
22
|
+
* How long to wait before the try after `tried` failed ones: the schedule with up to 10% jitter, or longer when the
|
|
23
|
+
* receiver asked with Retry-After, up to a day. `undefined` once the tries are used up.
|
|
24
|
+
*/
|
|
25
|
+
const nextRetryDelay = (tried, retryAfterMs, random = Math.random) => {
|
|
26
|
+
const scheduled = retryDelays[tried - 1];
|
|
27
|
+
if (scheduled === void 0) return void 0;
|
|
28
|
+
const jittered = Math.round(scheduled * (1 + (random() * 2 - 1) * .1));
|
|
29
|
+
return Math.min(Math.max(jittered, retryAfterMs ?? 0), 24 * hour);
|
|
30
|
+
};
|
|
31
|
+
/** A Retry-After header in milliseconds from `now`: seconds, or an HTTP date. */
|
|
32
|
+
const retryAfterMs = (header, now = Date.now()) => {
|
|
33
|
+
if (header === null) return void 0;
|
|
34
|
+
const text = header.trim();
|
|
35
|
+
if (/^\d+$/.test(text)) return Number(text) * second;
|
|
36
|
+
const at = Date.parse(text);
|
|
37
|
+
return Number.isNaN(at) ? void 0 : Math.max(0, at - now);
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { maxTries, nextRetryDelay, retryAfterMs, retryDelays };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { eventPatternMatches } from "./subscription.js";
|
|
2
|
+
import { checkEventFilter, evaluateFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/webhooks/route-events.ts
|
|
4
|
+
const declaredEvents = (models) => models.flatMap((model) => (model.events ?? []).map((event) => ({
|
|
5
|
+
model,
|
|
6
|
+
name: event.name
|
|
7
|
+
})));
|
|
8
|
+
const coveredModels = (subscription, models) => {
|
|
9
|
+
const events = declaredEvents(models).filter(({ name }) => subscription.options.events.some((pattern) => eventPatternMatches(pattern, name)));
|
|
10
|
+
return [...new Set(events.map(({ model }) => model))];
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* What is wrong with the subscriptions, given the resources and the app's API functions: a pattern that names no
|
|
14
|
+
* declared event, a `when` that does not check against a resource it covers, a missing function or URL, roles left
|
|
15
|
+
* empty. Disabled subscriptions are checked too, except for their URL.
|
|
16
|
+
*/
|
|
17
|
+
const subscriptionProblems = (subscriptions, models, functions) => subscriptions.flatMap((subscription) => {
|
|
18
|
+
const { name, options } = subscription;
|
|
19
|
+
const declared = declaredEvents(models).map((event) => event.name);
|
|
20
|
+
return [
|
|
21
|
+
...options.events.filter((pattern) => !declared.some((event) => eventPatternMatches(pattern, event))).map((pattern) => `"${pattern}" matches no declared event`),
|
|
22
|
+
...options.when === void 0 ? [] : coveredModels(subscription, models).flatMap((model) => {
|
|
23
|
+
const { problem } = checkEventFilter(model, options.when);
|
|
24
|
+
return problem ? [`when ${problem} on ${model.name}`] : [];
|
|
25
|
+
}),
|
|
26
|
+
..."function" in options && !functions.includes(options.function) ? [`calls the function "${options.function}", which the app does not have`] : [],
|
|
27
|
+
..."url" in options && options.enabled !== false && !options.url ? ["has an empty url; is its environment variable set?"] : [],
|
|
28
|
+
...options.as && options.as.roles.length === 0 ? ["delivers as no roles"] : []
|
|
29
|
+
].map((problem) => `Subscription "${name}" ${problem}`);
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Which subscriptions take which of a change's events: enabled, naming the event, of the change's tenant when they set
|
|
33
|
+
* one, and passing their `when` on the record after the change (before it, for a delete). Filters are checked once,
|
|
34
|
+
* here; run `subscriptionProblems` first.
|
|
35
|
+
*/
|
|
36
|
+
const subscriptionRouter = (subscriptions, models) => {
|
|
37
|
+
const filters = /* @__PURE__ */ new Map();
|
|
38
|
+
for (const subscription of subscriptions) {
|
|
39
|
+
if (subscription.options.when === void 0) continue;
|
|
40
|
+
for (const model of coveredModels(subscription, models)) {
|
|
41
|
+
const { filter } = checkEventFilter(model, subscription.options.when);
|
|
42
|
+
if (filter) filters.set(`${subscription.name}\0${model.name}`, filter);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const passes = (subscription, change) => {
|
|
46
|
+
const { options } = subscription;
|
|
47
|
+
if (options.enabled === false) return false;
|
|
48
|
+
if (options.tenant !== void 0 && String(options.tenant) !== String(change.tenant)) return false;
|
|
49
|
+
const filter = filters.get(`${subscription.name}\0${change.resource}`);
|
|
50
|
+
return !filter || evaluateFilter(filter, change.after ?? change.before) === true;
|
|
51
|
+
};
|
|
52
|
+
return (change, events) => subscriptions.flatMap((subscription) => {
|
|
53
|
+
if (!passes(subscription, change)) return [];
|
|
54
|
+
return events.filter((event) => subscription.options.events.some((pattern) => eventPatternMatches(pattern, event))).map((event) => ({
|
|
55
|
+
subscription,
|
|
56
|
+
event
|
|
57
|
+
}));
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
export { subscriptionProblems, subscriptionRouter };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/webhooks/standard-webhooks.ts
|
|
2
|
+
const base64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
|
3
|
+
const secretKey = (secret) => {
|
|
4
|
+
const text = secret.replace(/^whsec_/, "");
|
|
5
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(text)) throw new Error("A webhook secret is `whsec_` and base64");
|
|
6
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
7
|
+
};
|
|
8
|
+
/** A new signing secret, `whsec_` and 32 random bytes in base64. */
|
|
9
|
+
const newWebhookSecret = () => `whsec_${base64(crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32)))}`;
|
|
10
|
+
const sign = async (secret, content) => {
|
|
11
|
+
const key = await crypto.subtle.importKey("raw", secretKey(secret), {
|
|
12
|
+
name: "HMAC",
|
|
13
|
+
hash: "SHA-256"
|
|
14
|
+
}, false, ["sign"]);
|
|
15
|
+
return `v1,${base64(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content))))}`;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* The Standard Webhooks headers of one request (https://www.standardwebhooks.com): its id, its time in seconds and,
|
|
19
|
+
* with a secret, the HMAC-SHA256 of `id.timestamp.body` as `v1,<base64>`; with several, one signature per secret,
|
|
20
|
+
* separated by spaces, as during a rotation.
|
|
21
|
+
*/
|
|
22
|
+
const standardWebhookHeaders = async (id, timestamp, body, secret) => {
|
|
23
|
+
const headers = {
|
|
24
|
+
"webhook-id": id,
|
|
25
|
+
"webhook-timestamp": String(timestamp)
|
|
26
|
+
};
|
|
27
|
+
const secrets = secret === void 0 ? [] : [secret].flat();
|
|
28
|
+
if (secrets.length === 0) return headers;
|
|
29
|
+
const signatures = await Promise.all(secrets.map((each) => sign(each, `${id}.${timestamp}.${body}`)));
|
|
30
|
+
return {
|
|
31
|
+
...headers,
|
|
32
|
+
"webhook-signature": signatures.join(" ")
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { newWebhookSecret, standardWebhookHeaders };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { requestAccess } from "../request-access.js";
|
|
2
|
+
import { checkRecord, evaluateFilter } from "@protobase/schema";
|
|
3
|
+
//#region src/webhooks/subscriber-view.ts
|
|
4
|
+
/**
|
|
5
|
+
* The session a subscription delivers as, or `undefined` for the system: every record, every field but sensitive ones.
|
|
6
|
+
* Its tenant is the change's, typed as the app's, when the subscription has one.
|
|
7
|
+
*/
|
|
8
|
+
const subscriberSession = ({ name, options }, change) => {
|
|
9
|
+
if (!options.as) return void 0;
|
|
10
|
+
const tenant = options.tenant === void 0 ? void 0 : change?.tenant ?? options.tenant;
|
|
11
|
+
return {
|
|
12
|
+
user: {
|
|
13
|
+
id: `subscription:${name}`,
|
|
14
|
+
roles: options.as.roles
|
|
15
|
+
},
|
|
16
|
+
...tenant !== void 0 && {
|
|
17
|
+
tenant,
|
|
18
|
+
organization: { id: tenant }
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const pick = (record, fields) => fields ? Object.fromEntries(Object.entries(record).filter(([name]) => fields.includes(name))) : record;
|
|
23
|
+
/** A change's event as a subscriber sees it: only `fields` of its records (all without), none at all when `thin`. */
|
|
24
|
+
const subscriptionEvent = (change, event, { fields, thin = false } = {}) => ({
|
|
25
|
+
id: `evt_${change.sequence}_${event.slice(event.indexOf(".") + 1)}`,
|
|
26
|
+
type: event,
|
|
27
|
+
timestamp: change.at,
|
|
28
|
+
data: {
|
|
29
|
+
resource: change.resource,
|
|
30
|
+
operation: change.operation,
|
|
31
|
+
key: change.key,
|
|
32
|
+
sequence: change.sequence,
|
|
33
|
+
...!thin && change.after && { after: pick(change.after, fields) },
|
|
34
|
+
...!thin && change.before && { before: pick(change.before, fields) },
|
|
35
|
+
changed: fields ? change.changed.filter((name) => fields.includes(name)) : change.changed,
|
|
36
|
+
...change.tenant !== void 0 && { tenant: change.tenant },
|
|
37
|
+
actor: change.actor,
|
|
38
|
+
source: change.source,
|
|
39
|
+
transaction: change.transaction
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
const mayRead = async (access, record) => {
|
|
43
|
+
const { resolved, session, entry } = access;
|
|
44
|
+
if (!resolved.operations.read) return false;
|
|
45
|
+
if (resolved.rowFilter.read && evaluateFilter(resolved.rowFilter.read, record) !== true) return false;
|
|
46
|
+
if (!resolved.recordChecks.includes("read")) return true;
|
|
47
|
+
const context = {
|
|
48
|
+
user: session.user,
|
|
49
|
+
...session.organization && { organization: session.organization }
|
|
50
|
+
};
|
|
51
|
+
return checkRecord(entry.source, context, "read", record, void 0, access.deps.accessOptions);
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Builds each subscriber's event, under the access rules of the session it delivers as, resolved once per subscription
|
|
55
|
+
* and resource: `undefined` when it may not read the record, after the change or before a delete.
|
|
56
|
+
*/
|
|
57
|
+
const subscriberEvents = (deps) => {
|
|
58
|
+
const accesses = /* @__PURE__ */ new Map();
|
|
59
|
+
return async (subscription, change, event) => {
|
|
60
|
+
const thin = subscription.options.payload === "thin";
|
|
61
|
+
const session = subscriberSession(subscription, change);
|
|
62
|
+
if (!session) return subscriptionEvent(change, event, { thin });
|
|
63
|
+
const entry = deps.registry.find(change.resource);
|
|
64
|
+
if (!entry) throw new Error(`Not implemented: a change of "${change.resource}", which is no resource of the app`);
|
|
65
|
+
const cacheKey = `${subscription.name}\0${change.resource}`;
|
|
66
|
+
if (!accesses.has(cacheKey)) accesses.set(cacheKey, requestAccess(deps, entry, session));
|
|
67
|
+
const access = await accesses.get(cacheKey);
|
|
68
|
+
if (!await mayRead(access, change.after ?? change.before)) return void 0;
|
|
69
|
+
return subscriptionEvent(change, event, {
|
|
70
|
+
fields: Object.keys(access.readable.fields),
|
|
71
|
+
thin
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
export { subscriberEvents, subscriberSession, subscriptionEvent };
|