@rebasepro/server 0.13.0 → 0.13.1-canary.g06dbe5b
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/{admin_block-CcSGdH7M.js → admin_block-H86dCPsj.js} +62 -2
- package/dist/admin_block-H86dCPsj.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +74 -6
- package/dist/api/errors.d.ts +0 -6
- package/dist/api/openapi-generator.d.ts +14 -0
- package/dist/api/rest/api-generator.d.ts +1 -1
- package/dist/api/rest/idempotency.d.ts +62 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +52 -9
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/apple-oauth.d.ts +16 -11
- package/dist/auth/bitbucket-oauth.d.ts +2 -4
- package/dist/auth/builtin-auth-adapter.d.ts +2 -0
- package/dist/auth/discord-oauth.d.ts +5 -6
- package/dist/auth/facebook-oauth.d.ts +2 -4
- package/dist/auth/github-oauth.d.ts +2 -4
- package/dist/auth/gitlab-oauth.d.ts +6 -4
- package/dist/auth/google-oauth.d.ts +1 -0
- package/dist/auth/index.d.ts +8 -1
- package/dist/auth/interfaces.d.ts +68 -1
- package/dist/auth/jwt.d.ts +40 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- package/dist/auth/mfa-gate.d.ts +42 -0
- package/dist/auth/mfa-routes.d.ts +26 -1
- package/dist/auth/mfa.d.ts +16 -0
- package/dist/auth/microsoft-oauth.d.ts +19 -4
- package/dist/auth/oauth-code-flow.d.ts +66 -0
- package/dist/auth/oauth-signin-policy.d.ts +61 -0
- package/dist/auth/oidc-id-token.d.ts +61 -0
- package/dist/auth/rate-limiter.d.ts +37 -2
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +8 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-CuC9M2x6.js → auth-DkAHqvf5.js} +1604 -391
- package/dist/auth-DkAHqvf5.js.map +1 -0
- package/dist/{backup-CVggVhR2.js → backup-DLluVyuA.js} +2 -2
- package/dist/{backup-CVggVhR2.js.map → backup-DLluVyuA.js.map} +1 -1
- package/dist/boot/boot.d.ts +19 -0
- package/dist/boot/ddl-bootstrap.d.ts +70 -0
- package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-BB1U05sS.js} +3 -3
- package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-BB1U05sS.js.map} +1 -1
- package/dist/cron/cron-scheduler.d.ts +8 -3
- package/dist/cron/define-cron.d.ts +17 -3
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-3U5aGILy.js} +2 -2
- package/dist/{cron-loader-B1S2MCSl.js.map → cron-loader-3U5aGILy.js.map} +1 -1
- package/dist/{cron-routes-CrQ0tK-_.js → cron-routes-rZgwlOz4.js} +2 -2
- package/dist/{cron-routes-CrQ0tK-_.js.map → cron-routes-rZgwlOz4.js.map} +1 -1
- package/dist/{cron-scheduler-B3RFt0HS.js → cron-scheduler-FJAaCAXm.js} +30 -5
- package/dist/cron-scheduler-FJAaCAXm.js.map +1 -0
- package/dist/{cron-store-BywZsyfZ.js → cron-store-9NmUDfzL.js} +66 -47
- package/dist/cron-store-9NmUDfzL.js.map +1 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js +183 -0
- package/dist/ddl-bootstrap-BhXbTnBl.js.map +1 -0
- package/dist/email/html.d.ts +54 -0
- package/dist/email/index.d.ts +3 -0
- package/dist/email/link-base.d.ts +39 -0
- package/dist/email/smtp-email-service.d.ts +7 -1
- package/dist/email/templates.d.ts +9 -1
- package/dist/email/types.d.ts +11 -1
- package/dist/{errors-CgkCzoj7.js → errors-B1WZEdsK.js} +3 -11
- package/dist/errors-B1WZEdsK.js.map +1 -0
- package/dist/function-loader-D1SwtCa5.js +139 -0
- package/dist/function-loader-D1SwtCa5.js.map +1 -0
- package/dist/{function-routes-C0cLIy3N.js → function-routes-Btcez1T-.js} +18 -6
- package/dist/function-routes-Btcez1T-.js.map +1 -0
- package/dist/functions/define-function.d.ts +25 -6
- package/dist/functions/function-loader.d.ts +23 -0
- package/dist/functions/function-routes.d.ts +7 -1
- package/dist/functions/request-timeout.d.ts +34 -0
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.es.js +1960 -794
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +10 -1
- package/dist/init/process-safety.d.ts +25 -0
- package/dist/init.d.ts +27 -0
- package/dist/{jwt-DD6EtpGj.js → jwt-Cuq6MwNy.js} +70 -8
- package/dist/jwt-Cuq6MwNy.js.map +1 -0
- package/dist/logger-DfvF_8r-.js +190 -0
- package/dist/logger-DfvF_8r-.js.map +1 -0
- package/dist/openapi-generator-BQxxxDpb.js +865 -0
- package/dist/openapi-generator-BQxxxDpb.js.map +1 -0
- package/dist/request-timeout-RivJsME0.js +65 -0
- package/dist/request-timeout-RivJsME0.js.map +1 -0
- package/dist/schema-editor-routes-DZFKGXgq.js +437 -0
- package/dist/schema-editor-routes-DZFKGXgq.js.map +1 -0
- package/dist/serve-spa.d.ts +5 -4
- package/dist/services/outbound-url-guard.d.ts +53 -0
- package/dist/services/routed-realtime-service.d.ts +10 -2
- package/dist/services/webhook-service.d.ts +76 -1
- package/dist/singleton.d.ts +25 -8
- package/dist/{src-Cum9kox5.js → src-By48Ffg0.js} +322 -70
- package/dist/src-By48Ffg0.js.map +1 -0
- package/dist/{src-_qQ3RNCK.js → src-Ca6NhxKs.js} +79 -2
- package/dist/src-Ca6NhxKs.js.map +1 -0
- package/dist/storage/LocalStorageController.d.ts +12 -1
- package/dist/storage/image-transform.d.ts +57 -0
- package/dist/storage/keys.d.ts +98 -0
- package/dist/storage/routes.d.ts +11 -0
- package/dist/storage/tus-handler.d.ts +7 -1
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/logging.d.ts +0 -4
- package/dist/utils/sql.d.ts +11 -6
- package/package.json +7 -6
- package/dist/admin_block-CcSGdH7M.js.map +0 -1
- package/dist/auth-CuC9M2x6.js.map +0 -1
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/cron-scheduler-B3RFt0HS.js.map +0 -1
- package/dist/cron-store-BywZsyfZ.js.map +0 -1
- package/dist/errors-CgkCzoj7.js.map +0 -1
- package/dist/function-loader-B_1fYfUY.js +0 -86
- package/dist/function-loader-B_1fYfUY.js.map +0 -1
- package/dist/function-routes-C0cLIy3N.js.map +0 -1
- package/dist/jwt-DD6EtpGj.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-BEwyiaAr.js +0 -597
- package/dist/openapi-generator-BEwyiaAr.js.map +0 -1
- package/dist/schema-editor-routes-CbF20Mf1.js +0 -248
- package/dist/schema-editor-routes-CbF20Mf1.js.map +0 -1
- package/dist/src-Cum9kox5.js.map +0 -1
- package/dist/src-_qQ3RNCK.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-
|
|
7
|
-
import {
|
|
8
|
-
import { n as createContractRoutes } from "./contract-routes-
|
|
9
|
-
import { $ as
|
|
10
|
-
import { t as logger } from "./logger-
|
|
11
|
-
import { n as errorHandler,
|
|
12
|
-
import { i as generateDownloadToken, n as configureJwt } from "./jwt-
|
|
4
|
+
import { D as Vector, E as GeoPoint, O as RebaseApiError, T as EntityRelation, _ as resolvePrimaryKeys, a as serializeFilter, c as paginateFind, d as createDataSourceRegistry, f as resolveDataSource, g as buildCompositeId, h as resolveCollectionRelations, k as RebaseClientError, l as resolveFindWindow, m as findRelation, n as buildSdkData, o as serializeLogicalCondition, p as getEffectiveSecurityRules, s as collectAllPages, t as buildRoutedRebaseData, u as CollectionRegistry, v as toSnakeCase, w as EntityReference } from "./src-By48Ffg0.js";
|
|
5
|
+
import { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, p as toCanonicalOp, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-Ca6NhxKs.js";
|
|
6
|
+
import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-H86dCPsj.js";
|
|
7
|
+
import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-BhXbTnBl.js";
|
|
8
|
+
import { n as createContractRoutes } from "./contract-routes-BB1U05sS.js";
|
|
9
|
+
import { $ as fileTokenAuth, A as MemoryRateLimitStore, B as getEmailVerificationTemplate, C as ZodNumber, Ct as isPublicStoragePath, D as _coercedNumber, E as string, F as resolveAuthHooks, G as RawHtml, H as getPasswordResetTemplate, I as hashPassword, J as raw, K as escapeHtml, L as validatePasswordStrength, M as createEmailService, N as assertEmailLinkBases, O as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, P as resolveEmailLinkBase, Q as extractUserFromToken, R as verifyPassword, S as createBuiltinAuthAdapter, St as PUBLIC_STORAGE_PREFIX, T as object, U as getUserInvitationTemplate, V as getMagicLinkTemplate, W as getWelcomeEmailTemplate, X as createAuthMiddleware, Y as createAdapterAuthMiddleware, Z as createRequireAuth, _ as createLinkedinProvider, _t as scopeDataDriver, a as createSpotifyProvider, at as InvalidStorageBucketError, b as pkceTokenParams, bt as parseQueryOptions, c as createGitLabProvider, ct as canonicalStorageKey, d as createFacebookProvider, dt as createStorageApiKeyGuard, et as optionalAuth, f as createAppleProvider, ft as isApiKeyToken, g as createGitHubProvider, gt as SERVICE_IDENTITY, h as verifyOidcIdToken, ht as safeCompare, i as createApiKeyStore, it as requireAuth, j as SMTPEmailService, k as createDataRateLimiter, l as createDiscordProvider, lt as createApiKeyPreAuth, m as tryVerifyOidcIdToken, mt as extractBearerToken, n as createCustomAuthAdapter, nt as queryTokenAuth, o as createSlackProvider, ot as InvalidStorageKeyError, p as createMicrosoftProvider, pt as validateApiKey, q as html, r as createApiKeyRoutes, rt as requireAdmin, s as createBitbucketProvider, st as canonicalStorageBucket, tt as publicObjectAuth, u as createTwitterProvider, ut as createFunctionApiKeyGuard, v as createGoogleProvider, vt as httpMethodToOperation, w as _enum, x as providerVerifiedEmail, xt as resolveListLimitParam, y as oauthCodeFlowSchema, yt as isOperationAllowed, z as generateSecurePassword } from "./auth-DkAHqvf5.js";
|
|
10
|
+
import { t as logger } from "./logger-DfvF_8r-.js";
|
|
11
|
+
import { n as errorHandler, t as ApiError } from "./errors-B1WZEdsK.js";
|
|
12
|
+
import { i as generateDownloadToken, n as configureJwt } from "./jwt-Cuq6MwNy.js";
|
|
13
13
|
import { t as logMiddleware } from "./logs-routes-BYA72C_C.js";
|
|
14
14
|
import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-DSnOC4mF.js";
|
|
15
15
|
import { t as S3StorageController } from "./S3StorageController-B2EDXXMH.js";
|
|
16
16
|
import { t as GCSStorageController } from "./GCSStorageController-BEmDYFKc.js";
|
|
17
|
-
import { n as loadFunctionsFromDirectory } from "./function-loader-
|
|
18
|
-
import { t as createFunctionRoutes } from "./function-routes-
|
|
19
|
-
import { n as loadCronJobsFromDirectory } from "./cron-loader-
|
|
20
|
-
import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-
|
|
21
|
-
import { t as createCronRoutes } from "./cron-routes-
|
|
22
|
-
import { t as createCronStore } from "./cron-store-
|
|
23
|
-
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-
|
|
24
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
17
|
+
import { n as loadFunctionsFromDirectory } from "./function-loader-D1SwtCa5.js";
|
|
18
|
+
import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
|
|
19
|
+
import { n as loadCronJobsFromDirectory } from "./cron-loader-3U5aGILy.js";
|
|
20
|
+
import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-FJAaCAXm.js";
|
|
21
|
+
import { t as createCronRoutes } from "./cron-routes-rZgwlOz4.js";
|
|
22
|
+
import { t as createCronStore } from "./cron-store-9NmUDfzL.js";
|
|
23
|
+
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-DLluVyuA.js";
|
|
24
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
25
25
|
import * as fs$2 from "fs";
|
|
26
26
|
import fs, { existsSync } from "fs";
|
|
27
27
|
import * as path$2 from "path";
|
|
@@ -31,79 +31,24 @@ import { Hono } from "hono";
|
|
|
31
31
|
import { promisify } from "util";
|
|
32
32
|
import * as crypto$1 from "crypto";
|
|
33
33
|
import { createHmac, randomUUID as randomUUID$1 } from "crypto";
|
|
34
|
+
import path$1 from "node:path";
|
|
34
35
|
import { bodyLimit } from "hono/body-limit";
|
|
35
36
|
import { csrf } from "hono/csrf";
|
|
36
37
|
import { compress } from "hono/compress";
|
|
37
38
|
import fs$1 from "node:fs";
|
|
38
39
|
import fsp from "node:fs/promises";
|
|
40
|
+
import os from "node:os";
|
|
39
41
|
import { mkdir, open, unlink, writeFile } from "fs/promises";
|
|
40
42
|
import { sql } from "drizzle-orm";
|
|
43
|
+
import { lookup } from "node:dns/promises";
|
|
44
|
+
import { isIP } from "node:net";
|
|
41
45
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
42
46
|
import { createServer } from "http";
|
|
43
47
|
import { cors } from "hono/cors";
|
|
44
48
|
import { secureHeaders } from "hono/secure-headers";
|
|
45
49
|
import { getRequestListener } from "@hono/node-server";
|
|
46
|
-
import path$1 from "node:path";
|
|
47
|
-
import os from "node:os";
|
|
48
50
|
import { execFile } from "node:child_process";
|
|
49
51
|
import { promisify as promisify$1 } from "node:util";
|
|
50
|
-
//#region ../types/src/errors.ts
|
|
51
|
-
/**
|
|
52
|
-
* The single error type thrown across the entire Rebase client surface —
|
|
53
|
-
* HTTP data/control-plane calls, realtime/WebSocket operations, and
|
|
54
|
-
* client-side logic errors (e.g. an unknown collection accessor). A `catch`
|
|
55
|
-
* block only ever needs to check for this one class:
|
|
56
|
-
*
|
|
57
|
-
* ```ts
|
|
58
|
-
* import { RebaseApiError } from "@rebasepro/client"; // re-exported
|
|
59
|
-
*
|
|
60
|
-
* try {
|
|
61
|
-
* await client.data.products.update(id, { price: 9 });
|
|
62
|
-
* } catch (e) {
|
|
63
|
-
* if (e instanceof RebaseApiError) {
|
|
64
|
-
* if (e.status === 404) { ... } // HTTP failures carry a status
|
|
65
|
-
* console.error(e.code, e.details);
|
|
66
|
-
* }
|
|
67
|
-
* }
|
|
68
|
-
* ```
|
|
69
|
-
*
|
|
70
|
-
* `status` is present for HTTP failures and `undefined` otherwise, so its
|
|
71
|
-
* presence distinguishes transport-level errors from realtime/logic errors.
|
|
72
|
-
*
|
|
73
|
-
* @group Errors
|
|
74
|
-
*/
|
|
75
|
-
var RebaseApiError = class extends Error {
|
|
76
|
-
/** HTTP status code, or `undefined` for non-HTTP errors. */
|
|
77
|
-
status;
|
|
78
|
-
/** Stable machine-readable error code, when the server supplied one. */
|
|
79
|
-
code;
|
|
80
|
-
/** Structured error payload from the server, when present. */
|
|
81
|
-
details;
|
|
82
|
-
constructor(message, init = {}) {
|
|
83
|
-
super(message);
|
|
84
|
-
this.name = "RebaseApiError";
|
|
85
|
-
this.status = init.status;
|
|
86
|
-
this.code = init.code;
|
|
87
|
-
this.details = init.details;
|
|
88
|
-
if (init.cause !== void 0) this.cause = init.cause;
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
/**
|
|
92
|
-
* Client-side logic error — raised before any request is made (e.g. accessing
|
|
93
|
-
* an unknown collection accessor when a typed dictionary is configured).
|
|
94
|
-
*
|
|
95
|
-
* A subclass of {@link RebaseApiError} (with no `status`), so a single
|
|
96
|
-
* `catch (e) { if (e instanceof RebaseApiError) ... }` handles it too.
|
|
97
|
-
*
|
|
98
|
-
* @group Errors
|
|
99
|
-
*/
|
|
100
|
-
var RebaseClientError = class extends RebaseApiError {
|
|
101
|
-
constructor(message) {
|
|
102
|
-
super(message);
|
|
103
|
-
this.name = "RebaseClientError";
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
//#endregion
|
|
107
52
|
//#region ../types/src/types/storage_source.ts
|
|
108
53
|
/**
|
|
109
54
|
* Describes a named storage backend — a place files live.
|
|
@@ -250,23 +195,6 @@ function serializeOrderBy(orderBy) {
|
|
|
250
195
|
if (typeof orderBy === "string") return orderBy;
|
|
251
196
|
return `${orderBy[0]}:${orderBy[1]}`;
|
|
252
197
|
}
|
|
253
|
-
/**
|
|
254
|
-
* Deserialize a wire-format `"field:direction"` string into an {@link OrderByTuple}.
|
|
255
|
-
*
|
|
256
|
-
* Lenient parsing (matches existing server behaviour):
|
|
257
|
-
* - Bare field name (no colon): `"name"` → `["name", "asc"]`
|
|
258
|
-
* - Unknown direction: `"name:foo"` → `["name", "asc"]`
|
|
259
|
-
* - Empty / falsy input: → `undefined`
|
|
260
|
-
*
|
|
261
|
-
* @param raw - The wire-format string from an HTTP query parameter.
|
|
262
|
-
* @returns The canonical tuple, or `undefined` if the input is empty/falsy.
|
|
263
|
-
*/
|
|
264
|
-
function deserializeOrderBy(raw) {
|
|
265
|
-
if (!raw) return void 0;
|
|
266
|
-
const idx = raw.indexOf(":");
|
|
267
|
-
if (idx === -1) return [raw, "asc"];
|
|
268
|
-
return [raw.slice(0, idx), raw.slice(idx + 1) === "desc" ? "desc" : "asc"];
|
|
269
|
-
}
|
|
270
198
|
//#endregion
|
|
271
199
|
//#region src/collections/BackendCollectionRegistry.ts
|
|
272
200
|
/**
|
|
@@ -311,7 +239,6 @@ function unknownKeyPolicyFromEnv(env = process.env) {
|
|
|
311
239
|
].includes(raw)) return "off";
|
|
312
240
|
return "warn";
|
|
313
241
|
}
|
|
314
|
-
/** `BaseCollectionConfig`, plus every engine-specific field, plus the `admin` block. */
|
|
315
242
|
var COLLECTION_KEYS = /* @__PURE__ */ new Set([
|
|
316
243
|
"slug",
|
|
317
244
|
"name",
|
|
@@ -333,6 +260,7 @@ var COLLECTION_KEYS = /* @__PURE__ */ new Set([
|
|
|
333
260
|
"relations",
|
|
334
261
|
"securityRules",
|
|
335
262
|
"schema",
|
|
263
|
+
"search",
|
|
336
264
|
"path",
|
|
337
265
|
"subcollections",
|
|
338
266
|
"admin"
|
|
@@ -785,6 +713,20 @@ function isDataDriverDelegate(obj) {
|
|
|
785
713
|
//#endregion
|
|
786
714
|
//#region src/services/routed-realtime-service.ts
|
|
787
715
|
/**
|
|
716
|
+
* Channel/presence/broadcast messages are engine-agnostic pub/sub — which is
|
|
717
|
+
* why they go to a provider that implements them, not to whichever provider a
|
|
718
|
+
* bootstrapper happened to mark `isDefault`.
|
|
719
|
+
*/
|
|
720
|
+
var CHANNEL_MESSAGE_TYPES$1 = /* @__PURE__ */ new Set([
|
|
721
|
+
"join_channel",
|
|
722
|
+
"leave_channel",
|
|
723
|
+
"broadcast",
|
|
724
|
+
"presence_track",
|
|
725
|
+
"presence_untrack",
|
|
726
|
+
"presence_state",
|
|
727
|
+
"channel_history"
|
|
728
|
+
]);
|
|
729
|
+
/**
|
|
788
730
|
* Compose multiple per-engine {@link RealtimeProvider}s into one that routes
|
|
789
731
|
* each subscription to the provider owning the subscribed collection — the
|
|
790
732
|
* realtime counterpart of `buildRoutedRebaseData`.
|
|
@@ -794,8 +736,9 @@ function isDataDriverDelegate(obj) {
|
|
|
794
736
|
* - `subscribe_collection` / `subscribe_entity` → the provider for the
|
|
795
737
|
* collection's data source (by `payload.path`).
|
|
796
738
|
* - `unsubscribe` → forwarded to all providers (a no-op on non-owners).
|
|
797
|
-
* - channel / presence / broadcast → the
|
|
798
|
-
* pub/sub, not bound to an engine
|
|
739
|
+
* - channel / presence / broadcast → the first provider that declares
|
|
740
|
+
* `supportsChannels` (these are global pub/sub, not bound to an engine — but
|
|
741
|
+
* they are not implemented by every engine either).
|
|
799
742
|
* - `addClient` and lifecycle (`onServerReady`/`destroy`/`stopListening`) →
|
|
800
743
|
* all providers (each registers its own ws close handler for cleanup).
|
|
801
744
|
*/
|
|
@@ -809,6 +752,30 @@ function createRoutedRealtimeService(opts) {
|
|
|
809
752
|
const key = resolveKey(path);
|
|
810
753
|
return asWs(providers[key] ?? providers[defaultKey] ?? Object.values(providers)[0]);
|
|
811
754
|
};
|
|
755
|
+
/**
|
|
756
|
+
* The provider channel frames go to, resolved once and reported once.
|
|
757
|
+
*
|
|
758
|
+
* By capability, not by default-ness: the default provider is whichever
|
|
759
|
+
* bootstrapper set `isDefault`, and routing pub/sub there sends every
|
|
760
|
+
* broadcast, presence update and catch-up request into an engine that may
|
|
761
|
+
* not implement any of them. When the default *does* support channels it is
|
|
762
|
+
* still preferred, so single-engine behaviour is unchanged.
|
|
763
|
+
*/
|
|
764
|
+
let channelRouting;
|
|
765
|
+
const forChannels = () => {
|
|
766
|
+
if (!channelRouting) {
|
|
767
|
+
const entries = Object.entries(providers);
|
|
768
|
+
const supports = ([, p]) => asWs(p).supportsChannels === true;
|
|
769
|
+
const chosen = entries.find(([key]) => key === defaultKey && supports([key, providers[key]])) ?? entries.find(supports);
|
|
770
|
+
channelRouting = chosen ? {
|
|
771
|
+
provider: asWs(chosen[1]),
|
|
772
|
+
key: chosen[0]
|
|
773
|
+
} : {};
|
|
774
|
+
if (chosen) logger.info(`📡 [Realtime] Channels, presence and broadcast are served by the "${chosen[0]}" data source.`);
|
|
775
|
+
else logger.error(`❌ [Realtime] No configured realtime provider implements channels, presence or broadcast — every channel frame will be refused. Configured sources: [${entries.map(([k]) => k).join(", ")}]. Channels currently require a Postgres data source.`);
|
|
776
|
+
}
|
|
777
|
+
return channelRouting.provider;
|
|
778
|
+
};
|
|
812
779
|
return {
|
|
813
780
|
addClient(clientId, ws) {
|
|
814
781
|
for (const p of all()) p.addClient?.(clientId, ws);
|
|
@@ -823,6 +790,12 @@ function createRoutedRealtimeService(opts) {
|
|
|
823
790
|
await Promise.all(all().map((p) => p.handleClientMessage(clientId, message, authContext)));
|
|
824
791
|
return;
|
|
825
792
|
}
|
|
793
|
+
if (CHANNEL_MESSAGE_TYPES$1.has(type)) {
|
|
794
|
+
const provider = forChannels();
|
|
795
|
+
if (!provider) throw new Error(`Channels are not supported by any configured data source — "${type}" refused.`);
|
|
796
|
+
await provider.handleClientMessage(clientId, message, authContext);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
826
799
|
await fallback().handleClientMessage(clientId, message, authContext);
|
|
827
800
|
},
|
|
828
801
|
subscribeToCollection(subscriptionId, config, callback) {
|
|
@@ -849,173 +822,22 @@ function createRoutedRealtimeService(opts) {
|
|
|
849
822
|
};
|
|
850
823
|
}
|
|
851
824
|
//#endregion
|
|
852
|
-
//#region src/api/rest/query-parser.ts
|
|
853
|
-
function getLastValue(val) {
|
|
854
|
-
if (Array.isArray(val)) return val[val.length - 1];
|
|
855
|
-
return val;
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* Parse an `or(...)` / `and(...)` logical group from its wire form.
|
|
859
|
-
*
|
|
860
|
-
* The wire carries the inner conditions wrapped in parens (e.g.
|
|
861
|
-
* `(status.eq.active,age.gte.18)`); we re-attach the `or`/`and` prefix and
|
|
862
|
-
* delegate to the canonical filter dialect (`@rebasepro/common`). Values are
|
|
863
|
-
* preserved as strings — type coercion is the schema-aware driver's job, so
|
|
864
|
-
* this path stays byte-for-byte consistent with the SDK/admin path (which
|
|
865
|
-
* also parses via the shared dialect).
|
|
866
|
-
*/
|
|
867
|
-
function parseLogicalGroup(type, raw) {
|
|
868
|
-
let inner = String(raw).trim();
|
|
869
|
-
if (inner.startsWith("(") && inner.endsWith(")")) inner = inner.slice(1, -1);
|
|
870
|
-
inner = inner.trim();
|
|
871
|
-
if (!inner) return void 0;
|
|
872
|
-
const parsed = deserializeLogicalCondition(`${type}(${inner})`);
|
|
873
|
-
return "type" in parsed ? parsed : void 0;
|
|
874
|
-
}
|
|
875
|
-
/**
|
|
876
|
-
* Parse the `?where=` JSON filter object.
|
|
877
|
-
*
|
|
878
|
-
* This is the dialect the OpenAPI document publishes on every
|
|
879
|
-
* `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
|
|
880
|
-
* `[WhereFilterOp, value]` tuple. It is normalized through the same
|
|
881
|
-
* `deserializeFilter` as the `?field=op.value` params below, so a value that
|
|
882
|
-
* arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
|
|
883
|
-
* scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
|
|
884
|
-
* querystring dialect, JSON carries types — a number stays a number.
|
|
885
|
-
*
|
|
886
|
-
* A malformed value is a 400 rather than a silent drop: dropping the filter
|
|
887
|
-
* would run the read unfiltered and return everything RLS happens to allow.
|
|
888
|
-
*/
|
|
889
|
-
function parseWhereParam(raw) {
|
|
890
|
-
const str = String(raw).trim();
|
|
891
|
-
if (!str) return void 0;
|
|
892
|
-
let parsed;
|
|
893
|
-
try {
|
|
894
|
-
parsed = JSON.parse(str);
|
|
895
|
-
} catch {
|
|
896
|
-
throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
897
|
-
}
|
|
898
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
899
|
-
const filter = deserializeFilter(parsed);
|
|
900
|
-
return Object.keys(filter).length > 0 ? filter : void 0;
|
|
901
|
-
}
|
|
902
|
-
/**
|
|
903
|
-
* Parse query parameters into QueryOptions
|
|
904
|
-
*/
|
|
905
|
-
function parseQueryOptions(query, limits = {}) {
|
|
906
|
-
const options = {};
|
|
907
|
-
const rawLimit = getLastValue(query.limit);
|
|
908
|
-
const offsetVal = getLastValue(query.offset);
|
|
909
|
-
if (offsetVal) options.offset = parseInt(String(offsetVal));
|
|
910
|
-
const pageVal = getLastValue(query.page);
|
|
911
|
-
if (pageVal) {
|
|
912
|
-
const page = parseInt(String(pageVal));
|
|
913
|
-
const limit = resolveClientListLimit(rawLimit, {
|
|
914
|
-
defaultLimit: limits.defaultLimit,
|
|
915
|
-
maxLimit: limits.maxLimit
|
|
916
|
-
});
|
|
917
|
-
options.offset = (page - 1) * limit;
|
|
918
|
-
}
|
|
919
|
-
const orVal = getLastValue(query.or);
|
|
920
|
-
const andVal = getLastValue(query.and);
|
|
921
|
-
if (orVal) {
|
|
922
|
-
const logical = parseLogicalGroup("or", orVal);
|
|
923
|
-
if (logical) options.logical = logical;
|
|
924
|
-
} else if (andVal) {
|
|
925
|
-
const logical = parseLogicalGroup("and", andVal);
|
|
926
|
-
if (logical) options.logical = logical;
|
|
927
|
-
}
|
|
928
|
-
const reservedQueryKeys = [
|
|
929
|
-
"limit",
|
|
930
|
-
"offset",
|
|
931
|
-
"page",
|
|
932
|
-
"orderBy",
|
|
933
|
-
"include",
|
|
934
|
-
"fields",
|
|
935
|
-
"searchString",
|
|
936
|
-
"vector_search",
|
|
937
|
-
"vector",
|
|
938
|
-
"vector_distance",
|
|
939
|
-
"vector_threshold",
|
|
940
|
-
"or",
|
|
941
|
-
"and",
|
|
942
|
-
"where"
|
|
943
|
-
];
|
|
944
|
-
const filterDict = {};
|
|
945
|
-
for (const [key, rawValue] of Object.entries(query)) {
|
|
946
|
-
if (reservedQueryKeys.includes(key)) continue;
|
|
947
|
-
filterDict[key] = rawValue;
|
|
948
|
-
}
|
|
949
|
-
const whereVal = getLastValue(query.where);
|
|
950
|
-
const where = {
|
|
951
|
-
...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
|
|
952
|
-
...deserializeFilter(filterDict)
|
|
953
|
-
};
|
|
954
|
-
if (Object.keys(where).length > 0) options.where = where;
|
|
955
|
-
const orderByVal = getLastValue(query.orderBy);
|
|
956
|
-
if (orderByVal) try {
|
|
957
|
-
options.orderBy = typeof orderByVal === "string" ? JSON.parse(orderByVal) : orderByVal;
|
|
958
|
-
} catch {
|
|
959
|
-
if (typeof orderByVal === "string") {
|
|
960
|
-
const parsed = deserializeOrderBy(orderByVal);
|
|
961
|
-
if (parsed) options.orderBy = [{
|
|
962
|
-
field: parsed[0],
|
|
963
|
-
direction: parsed[1]
|
|
964
|
-
}];
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
const includeVal = getLastValue(query.include);
|
|
968
|
-
if (includeVal) {
|
|
969
|
-
const includeStr = String(includeVal).trim();
|
|
970
|
-
if (includeStr === "*") options.include = ["*"];
|
|
971
|
-
else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
972
|
-
}
|
|
973
|
-
const fieldsVal = getLastValue(query.fields);
|
|
974
|
-
if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
|
|
975
|
-
const vectorSearchVal = getLastValue(query.vector_search);
|
|
976
|
-
const vectorVal = getLastValue(query.vector);
|
|
977
|
-
if (vectorSearchVal && vectorVal) {
|
|
978
|
-
const vectorStr = String(vectorVal);
|
|
979
|
-
let decoded;
|
|
980
|
-
try {
|
|
981
|
-
decoded = JSON.parse(vectorStr);
|
|
982
|
-
} catch {
|
|
983
|
-
decoded = void 0;
|
|
984
|
-
}
|
|
985
|
-
if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw ApiError.badRequest("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
|
|
986
|
-
const queryVector = decoded;
|
|
987
|
-
const distanceParamVal = getLastValue(query.vector_distance);
|
|
988
|
-
const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
|
|
989
|
-
if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
|
|
990
|
-
const vectorSearch = {
|
|
991
|
-
property: String(vectorSearchVal),
|
|
992
|
-
vector: queryVector,
|
|
993
|
-
distance: distanceParam
|
|
994
|
-
};
|
|
995
|
-
const thresholdVal = getLastValue(query.vector_threshold);
|
|
996
|
-
if (thresholdVal) {
|
|
997
|
-
const threshold = parseFloat(String(thresholdVal));
|
|
998
|
-
if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
|
|
999
|
-
vectorSearch.threshold = threshold;
|
|
1000
|
-
}
|
|
1001
|
-
options.vectorSearch = vectorSearch;
|
|
1002
|
-
}
|
|
1003
|
-
options.limit = resolveClientListLimit(rawLimit, {
|
|
1004
|
-
vectorSearch: !!options.vectorSearch,
|
|
1005
|
-
defaultLimit: limits.defaultLimit,
|
|
1006
|
-
maxLimit: limits.maxLimit
|
|
1007
|
-
});
|
|
1008
|
-
return options;
|
|
1009
|
-
}
|
|
1010
|
-
//#endregion
|
|
1011
825
|
//#region src/api/rest/write-validation.ts
|
|
1012
826
|
/**
|
|
1013
827
|
* Reject a write naming a field the collection does not have.
|
|
1014
828
|
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
829
|
+
* An unknown key does *not* reach the INSERT and come back as `column "titel"
|
|
830
|
+
* does not exist`, which is what this comment used to claim. Drizzle builds
|
|
831
|
+
* INSERT and UPDATE from the table's own column list, so a key the table does
|
|
832
|
+
* not carry is left out of the statement: the write answers 201 having stored
|
|
833
|
+
* nothing under that name. A typo is a request problem, it belongs in a 400,
|
|
834
|
+
* and nothing below this was ever going to raise one.
|
|
835
|
+
*
|
|
836
|
+
* This is the config-level check and it is skipped on several paths (see the
|
|
837
|
+
* `strictWrites` bail below, and the auth-adapter contract at the call site).
|
|
838
|
+
* `assertWritableColumns` in the Postgres driver is the backstop every write
|
|
839
|
+
* passes through, including in-process `rebase.data` writes that never come
|
|
840
|
+
* near this layer.
|
|
1019
841
|
*
|
|
1020
842
|
* What counts as known:
|
|
1021
843
|
* - a declared property (for an introspected BaaS collection these *are* the
|
|
@@ -1043,6 +865,145 @@ function assertKnownWriteFields(values, collection, options) {
|
|
|
1043
865
|
throw ApiError.badRequest(`${where}'${collection.slug}' has no field${unknown.length > 1 ? "s" : ""} ${unknown.map((f) => `'${f}'`).join(", ")}. Known fields: ${[...known].sort().map((f) => `'${f}'`).join(", ")}.`, "VALIDATION_UNKNOWN_FIELDS");
|
|
1044
866
|
}
|
|
1045
867
|
/**
|
|
868
|
+
* The number a constraint should judge, or `undefined` if the value is not one.
|
|
869
|
+
*
|
|
870
|
+
* A JSON body may carry `"5"` for a number column and Postgres accepts it, so
|
|
871
|
+
* the string form is judged too — refusing to look at it would leave the exact
|
|
872
|
+
* bypass this function exists to close.
|
|
873
|
+
*/
|
|
874
|
+
function asNumber(value) {
|
|
875
|
+
if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
|
|
876
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
877
|
+
const parsed = Number(value);
|
|
878
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
/** The instant a constraint should judge, or `undefined` if the value is not one. */
|
|
882
|
+
function asDate(value) {
|
|
883
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value;
|
|
884
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
885
|
+
const parsed = new Date(value);
|
|
886
|
+
return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* `validation.matches` as a regex that can be tested repeatedly.
|
|
891
|
+
*
|
|
892
|
+
* A `RegExp` carrying `g` or `y` keeps `lastIndex` between calls, so the same
|
|
893
|
+
* pattern would accept and reject the same value on alternate rows of a bulk
|
|
894
|
+
* write. Rebuilt without those flags.
|
|
895
|
+
*/
|
|
896
|
+
function toPattern(matches) {
|
|
897
|
+
try {
|
|
898
|
+
if (typeof matches === "string") return new RegExp(matches);
|
|
899
|
+
return new RegExp(matches.source, matches.flags.replace(/[gy]/g, ""));
|
|
900
|
+
} catch {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function collectViolations(key, property, value, into) {
|
|
905
|
+
if (value === null || value === void 0) return;
|
|
906
|
+
switch (property.type) {
|
|
907
|
+
case "string": {
|
|
908
|
+
const rules = property.validation;
|
|
909
|
+
if (!rules || typeof value !== "string") return;
|
|
910
|
+
if (rules.length !== void 0 && value.length !== rules.length) into.push(`'${key}' must be exactly ${rules.length} character${rules.length === 1 ? "" : "s"} long (received ${value.length}).`);
|
|
911
|
+
if (rules.min !== void 0 && value.length < rules.min) into.push(`'${key}' must be at least ${rules.min} character${rules.min === 1 ? "" : "s"} long (received ${value.length}).`);
|
|
912
|
+
if (rules.max !== void 0 && value.length > rules.max) into.push(`'${key}' must be at most ${rules.max} character${rules.max === 1 ? "" : "s"} long (received ${value.length}).`);
|
|
913
|
+
if (rules.matches !== void 0) {
|
|
914
|
+
const pattern = toPattern(rules.matches);
|
|
915
|
+
if (pattern && !pattern.test(value)) into.push(rules.matchesMessage ?? `'${key}' does not match the required pattern ${pattern}.`);
|
|
916
|
+
}
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
case "number": {
|
|
920
|
+
const rules = property.validation;
|
|
921
|
+
if (!rules) return;
|
|
922
|
+
const num = asNumber(value);
|
|
923
|
+
if (num === void 0) return;
|
|
924
|
+
if (rules.integer && !Number.isInteger(num)) into.push(`'${key}' must be a whole number (received ${num}).`);
|
|
925
|
+
if (rules.positive && !(num > 0)) into.push(`'${key}' must be positive (received ${num}).`);
|
|
926
|
+
if (rules.negative && !(num < 0)) into.push(`'${key}' must be negative (received ${num}).`);
|
|
927
|
+
if (rules.min !== void 0 && num < rules.min) into.push(`'${key}' must be at least ${rules.min} (received ${num}).`);
|
|
928
|
+
if (rules.max !== void 0 && num > rules.max) into.push(`'${key}' must be at most ${rules.max} (received ${num}).`);
|
|
929
|
+
if (rules.moreThan !== void 0 && !(num > rules.moreThan)) into.push(`'${key}' must be greater than ${rules.moreThan} (received ${num}).`);
|
|
930
|
+
if (rules.lessThan !== void 0 && !(num < rules.lessThan)) into.push(`'${key}' must be less than ${rules.lessThan} (received ${num}).`);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
case "date": {
|
|
934
|
+
const rules = property.validation;
|
|
935
|
+
if (!rules) return;
|
|
936
|
+
const date = asDate(value);
|
|
937
|
+
if (date === void 0) return;
|
|
938
|
+
const min = rules.min !== void 0 ? asDate(rules.min) : void 0;
|
|
939
|
+
const max = rules.max !== void 0 ? asDate(rules.max) : void 0;
|
|
940
|
+
if (min && date.getTime() < min.getTime()) into.push(`'${key}' must not be before ${min.toISOString()}.`);
|
|
941
|
+
if (max && date.getTime() > max.getTime()) into.push(`'${key}' must not be after ${max.toISOString()}.`);
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
case "array": {
|
|
945
|
+
if (!Array.isArray(value)) return;
|
|
946
|
+
const rules = property.validation;
|
|
947
|
+
if (rules?.min !== void 0 && value.length < rules.min) into.push(`'${key}' must have at least ${rules.min} item${rules.min === 1 ? "" : "s"} (received ${value.length}).`);
|
|
948
|
+
if (rules?.max !== void 0 && value.length > rules.max) into.push(`'${key}' must have at most ${rules.max} item${rules.max === 1 ? "" : "s"} (received ${value.length}).`);
|
|
949
|
+
const of = property.of;
|
|
950
|
+
if (of && !Array.isArray(of)) value.forEach((item, index) => collectViolations(`${key}[${index}]`, of, item, into));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
case "map": {
|
|
954
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
|
|
955
|
+
const properties = property.properties;
|
|
956
|
+
if (!properties) return;
|
|
957
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
958
|
+
const subProperty = properties[subKey];
|
|
959
|
+
if (subProperty) collectViolations(`${key}.${subKey}`, subProperty, subValue, into);
|
|
960
|
+
}
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
default:
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Reject a write whose values break the constraints the collection declares.
|
|
968
|
+
*
|
|
969
|
+
* `validation.min`, `max`, `matches`, `positive`, `integer` and the date and
|
|
970
|
+
* array bounds were read by exactly three things: the DDL generators (for
|
|
971
|
+
* `integer` and for a `varchar` width), and the admin's client-side form. No
|
|
972
|
+
* layer on the write path looked at them, no `CHECK` constraint was emitted,
|
|
973
|
+
* and the generated OpenAPI published every one of them as `minimum`,
|
|
974
|
+
* `maximum`, `minLength`, `maxLength` and `pattern`. So the docs' own example —
|
|
975
|
+
* `price: { type: "number", validation: { min: 0 } }` — accepted `-5000` with a
|
|
976
|
+
* 201 while the spec said it would not, and any client or gateway generated
|
|
977
|
+
* from that spec believed the server was checking.
|
|
978
|
+
*
|
|
979
|
+
* Only `required` is left to the database, where it is a NOT NULL: a partial
|
|
980
|
+
* update legitimately omits keys, and this function sees one request's values,
|
|
981
|
+
* not the row they land on.
|
|
982
|
+
*
|
|
983
|
+
* A value of the wrong *type* is not this function's business — that reaches
|
|
984
|
+
* Postgres, which types every column and gives a 400 through the driver's
|
|
985
|
+
* SQLSTATE mapping. Constraints are checked against values of the right type,
|
|
986
|
+
* plus the numeric-string form a JSON body may carry for a number column.
|
|
987
|
+
*
|
|
988
|
+
* Every failing field is reported at once. A form that fixes one field per
|
|
989
|
+
* round trip is the reason validation errors get ignored.
|
|
990
|
+
*/
|
|
991
|
+
function assertWriteValuesValid(values, collection, options) {
|
|
992
|
+
const properties = collection.properties;
|
|
993
|
+
if (!properties || !values) return;
|
|
994
|
+
const violations = [];
|
|
995
|
+
for (const [key, value] of Object.entries(values)) {
|
|
996
|
+
const property = properties[key];
|
|
997
|
+
if (property) collectViolations(key, property, value, violations);
|
|
998
|
+
}
|
|
999
|
+
if (violations.length === 0) return;
|
|
1000
|
+
const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
|
|
1001
|
+
throw ApiError.badRequest(`${where}${violations.join(" ")}`, "VALIDATION_CONSTRAINT", {
|
|
1002
|
+
collection: collection.slug,
|
|
1003
|
+
violations
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1046
1007
|
* Narrow response rows to the fields the caller asked for.
|
|
1047
1008
|
*
|
|
1048
1009
|
* `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
|
|
@@ -1055,11 +1016,18 @@ function assertKnownWriteFields(values, collection, options) {
|
|
|
1055
1016
|
* This shapes the *response*, which is what the parameter says it does; it is
|
|
1056
1017
|
* not a column pushdown, so it saves bandwidth rather than database work.
|
|
1057
1018
|
*
|
|
1058
|
-
*
|
|
1059
|
-
* the admin table, realtime reconciliation, the offline
|
|
1060
|
-
* arrives without one is not a smaller row, it is an
|
|
1061
|
-
* `fields=title` and being unable to open the record
|
|
1062
|
-
* answer than one extra key.
|
|
1019
|
+
* The collection's key always survives. Rows are addressed by it everywhere
|
|
1020
|
+
* above this layer — the admin table, realtime reconciliation, the offline
|
|
1021
|
+
* cache — and a row that arrives without one is not a smaller row, it is an
|
|
1022
|
+
* unusable one. Asking for `fields=title` and being unable to open the record
|
|
1023
|
+
* you clicked is a worse answer than one extra key.
|
|
1024
|
+
*
|
|
1025
|
+
* That is a statement about the key, not about the name `id`: a collection
|
|
1026
|
+
* keyed on `slug`, or on `user_id + role_id`, was projected down to the fields
|
|
1027
|
+
* asked for and nothing else, which is the very outcome the paragraph above
|
|
1028
|
+
* describes. `assertKnownWriteFields` one function up already has a dedicated
|
|
1029
|
+
* error for collections with no `id` column, so this layer had no excuse for
|
|
1030
|
+
* assuming one.
|
|
1063
1031
|
*/
|
|
1064
1032
|
function projectResponseFields(rows, fields, collection, options) {
|
|
1065
1033
|
if (!fields || fields.length === 0) return rows;
|
|
@@ -1077,7 +1045,8 @@ function projectResponseFields(rows, fields, collection, options) {
|
|
|
1077
1045
|
collection: collection.slug
|
|
1078
1046
|
});
|
|
1079
1047
|
}
|
|
1080
|
-
const
|
|
1048
|
+
const primaryKeys = resolvePrimaryKeys(collection).map((key) => key.fieldName);
|
|
1049
|
+
const keep = /* @__PURE__ */ new Set([...fields, ...primaryKeys.length > 0 ? primaryKeys : ["id"]]);
|
|
1081
1050
|
return rows.map((row) => {
|
|
1082
1051
|
const projected = {};
|
|
1083
1052
|
for (const key of Object.keys(row)) if (keep.has(key)) projected[key] = row[key];
|
|
@@ -1102,6 +1071,9 @@ function projectResponseFields(rows, fields, collection, options) {
|
|
|
1102
1071
|
* generated on the client, so keying on the id alone would let anyone who
|
|
1103
1072
|
* learned (or guessed) another user's id replay their key and be handed that
|
|
1104
1073
|
* user's row back — a read of someone else's data through a write endpoint.
|
|
1074
|
+
* That is also why an unauthenticated caller gets no idempotency at all: every
|
|
1075
|
+
* anonymous request shares one principal, so honouring a key there would hand
|
|
1076
|
+
* one visitor's stored response to the next one who sent the same string.
|
|
1105
1077
|
*/
|
|
1106
1078
|
var TABLE = "\"rebase\".\"idempotency_keys\"";
|
|
1107
1079
|
/**
|
|
@@ -1112,15 +1084,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
|
|
|
1112
1084
|
*/
|
|
1113
1085
|
var TTL_HOURS = 24;
|
|
1114
1086
|
/**
|
|
1115
|
-
*
|
|
1087
|
+
* How long a claim that has not answered yet is presumed to still be running.
|
|
1088
|
+
*
|
|
1089
|
+
* A claim is taken before the write and given back after it, so every exit the
|
|
1090
|
+
* process does not survive — a rolled pod, an OOM kill, a node drain, a
|
|
1091
|
+
* `complete` that itself fails — leaves the row pending with nobody coming back
|
|
1092
|
+
* for it. Reusing the 24-hour replay window as the lease made that
|
|
1093
|
+
* indistinguishable from a request still in flight, so every retry of that key
|
|
1094
|
+
* was refused for a day: the mechanism turned a write that plain retrying would
|
|
1095
|
+
* have completed into permanent loss, which is worse than not having it at all.
|
|
1096
|
+
*
|
|
1097
|
+
* The number trades the two ways it can be wrong, and both are bounded. Too
|
|
1098
|
+
* short and a genuinely slow write has its key taken over by its own retry —
|
|
1099
|
+
* the duplicate this exists to prevent. Too long and a crashed claim refuses
|
|
1100
|
+
* its retries for that long. A minute outlasts any write the row cap allows
|
|
1101
|
+
* and stays well inside the window the offline queue keeps retrying for.
|
|
1102
|
+
*/
|
|
1103
|
+
var PENDING_LEASE_SECONDS = 60;
|
|
1104
|
+
/**
|
|
1105
|
+
* How long to wait before attempting the table creation again after it failed.
|
|
1116
1106
|
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1107
|
+
* `ensure`'s answer used to be memoised unconditionally, so one transient DDL
|
|
1108
|
+
* failure — a database still starting, a lock on the `rebase` schema, a sibling
|
|
1109
|
+
* pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
|
|
1110
|
+
* that pod for the life of the process after a single log line. A failure is
|
|
1111
|
+
* retried instead, and the cooldown is what stops every keyed request re-running
|
|
1112
|
+
* DDL against a database that is genuinely unhappy.
|
|
1113
|
+
*/
|
|
1114
|
+
var ENSURE_RETRY_COOLDOWN_MS = 3e4;
|
|
1115
|
+
/**
|
|
1116
|
+
* The principal a key belongs to, or `undefined` when there is nobody to scope
|
|
1117
|
+
* it to and the key must therefore be ignored.
|
|
1118
|
+
*
|
|
1119
|
+
* Anonymous callers are a supported configuration — `auth.requireAuth: false`
|
|
1120
|
+
* delegates access control to RLS — and they used to share one sentinel
|
|
1121
|
+
* principal, which is exactly the cross-principal replay the note above says
|
|
1122
|
+
* must not happen: two visitors of a public form who send the same key string
|
|
1123
|
+
* are handed each other's stored response. Refusing the key degrades them to no
|
|
1124
|
+
* idempotency, which is what they had before this table existed.
|
|
1121
1125
|
*/
|
|
1122
1126
|
function principal(uid) {
|
|
1123
|
-
return uid && uid.length > 0 ? uid :
|
|
1127
|
+
return uid && uid.length > 0 ? uid : void 0;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* A stable rendering of a JSON value: one body always produces one string,
|
|
1131
|
+
* whatever order its keys arrived in.
|
|
1132
|
+
*
|
|
1133
|
+
* `JSON.stringify` preserves insertion order, so two encodings of the same
|
|
1134
|
+
* object would fingerprint differently and a legitimate retry would be refused
|
|
1135
|
+
* as a reused key. Only JSON types reach here — the value came from `JSON.parse`.
|
|
1136
|
+
*/
|
|
1137
|
+
function stableStringify(value) {
|
|
1138
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
1139
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1140
|
+
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* What a key was claimed *for*, so presenting it on a different request is
|
|
1144
|
+
* caught instead of silently replaying the first one's answer.
|
|
1145
|
+
*
|
|
1146
|
+
* Nothing about the request used to enter the key, so `createMany(rows, { key })`
|
|
1147
|
+
* followed by `deleteMany(ids, { key })` — the reusable business id the docs
|
|
1148
|
+
* recommended — answered the delete with the create's `200` body and deleted
|
|
1149
|
+
* nothing. Method and path separate the routes; the body separates a re-run
|
|
1150
|
+
* carrying corrected rows from the retry of an unanswered one, which is the
|
|
1151
|
+
* difference between "here is your earlier answer" and silently discarding a
|
|
1152
|
+
* correction.
|
|
1153
|
+
*/
|
|
1154
|
+
function requestFingerprint(method, path, body) {
|
|
1155
|
+
return createHash("sha256").update(`${method.toUpperCase()} ${path}\n${stableStringify(body)}`).digest("hex");
|
|
1124
1156
|
}
|
|
1125
1157
|
/**
|
|
1126
1158
|
* Returns `undefined` when the driver cannot run SQL, which disables the whole
|
|
@@ -1137,9 +1169,19 @@ function createIdempotencyStore(driver) {
|
|
|
1137
1169
|
if (!isSQLAdmin(admin)) return void 0;
|
|
1138
1170
|
const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
|
|
1139
1171
|
let ready;
|
|
1172
|
+
let retryEnsureAt = 0;
|
|
1173
|
+
let warnedAnonymous = false;
|
|
1174
|
+
/** Said once per process: it is a deployment fact, not a per-request event. */
|
|
1175
|
+
const warnAnonymous = () => {
|
|
1176
|
+
if (warnedAnonymous) return;
|
|
1177
|
+
warnedAnonymous = true;
|
|
1178
|
+
logger.warn("Idempotency-Key ignored for an unauthenticated request — keys are scoped to a signed-in principal, and honouring one here would replay a stored response to whoever sends the same key next. A replayed write from an anonymous client may insert a duplicate row.");
|
|
1179
|
+
};
|
|
1140
1180
|
/** Created on first use: most deployments never send a key at all. */
|
|
1141
1181
|
const ensure = () => {
|
|
1142
|
-
|
|
1182
|
+
if (ready) return ready;
|
|
1183
|
+
if (Date.now() < retryEnsureAt) return Promise.resolve(false);
|
|
1184
|
+
ready = (async () => {
|
|
1143
1185
|
try {
|
|
1144
1186
|
await exec("CREATE SCHEMA IF NOT EXISTS rebase");
|
|
1145
1187
|
await exec(`
|
|
@@ -1151,36 +1193,71 @@ function createIdempotencyStore(driver) {
|
|
|
1151
1193
|
PRIMARY KEY (uid, key)
|
|
1152
1194
|
)
|
|
1153
1195
|
`);
|
|
1196
|
+
await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
|
|
1154
1197
|
await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
|
|
1198
|
+
await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
|
|
1155
1199
|
return true;
|
|
1156
1200
|
} catch (error) {
|
|
1201
|
+
retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
|
|
1157
1202
|
logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
|
|
1158
1203
|
return false;
|
|
1159
1204
|
}
|
|
1160
|
-
})()
|
|
1205
|
+
})().then((ok) => {
|
|
1206
|
+
if (!ok) ready = void 0;
|
|
1207
|
+
return ok;
|
|
1208
|
+
});
|
|
1161
1209
|
return ready;
|
|
1162
1210
|
};
|
|
1163
1211
|
return {
|
|
1164
|
-
async
|
|
1165
|
-
|
|
1212
|
+
async claim(key, uid, fingerprint) {
|
|
1213
|
+
const owner = principal(uid);
|
|
1214
|
+
if (key && !owner) warnAnonymous();
|
|
1215
|
+
if (!key || !owner || !await ensure()) return { status: "claimed" };
|
|
1166
1216
|
try {
|
|
1167
|
-
|
|
1168
|
-
|
|
1217
|
+
if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
|
|
1218
|
+
VALUES ($1, $2, $3, NULL, NOW())
|
|
1219
|
+
ON CONFLICT (uid, key) DO UPDATE
|
|
1220
|
+
SET response = NULL, created_at = NOW(), fingerprint = EXCLUDED.fingerprint
|
|
1221
|
+
WHERE (${TABLE}.response IS NULL
|
|
1222
|
+
AND ${TABLE}.created_at < NOW() - INTERVAL '${PENDING_LEASE_SECONDS} seconds')
|
|
1223
|
+
OR (${TABLE}.response IS NOT NULL
|
|
1224
|
+
AND ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours')
|
|
1225
|
+
RETURNING 1 AS claimed`, [
|
|
1226
|
+
key,
|
|
1227
|
+
owner,
|
|
1228
|
+
fingerprint
|
|
1229
|
+
])).length > 0) return { status: "claimed" };
|
|
1230
|
+
const row = (await exec(`SELECT response, response IS NULL AS pending, fingerprint FROM ${TABLE}
|
|
1231
|
+
WHERE uid = $1 AND key = $2`, [owner, key]))[0];
|
|
1232
|
+
if (!row) return { status: "claimed" };
|
|
1233
|
+
if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
|
|
1234
|
+
if (row.pending) return { status: "in-flight" };
|
|
1235
|
+
return {
|
|
1236
|
+
status: "replay",
|
|
1237
|
+
response: row.response
|
|
1238
|
+
};
|
|
1169
1239
|
} catch {
|
|
1170
|
-
return;
|
|
1240
|
+
return { status: "claimed" };
|
|
1171
1241
|
}
|
|
1172
1242
|
},
|
|
1173
|
-
async
|
|
1174
|
-
|
|
1243
|
+
async complete(key, uid, response) {
|
|
1244
|
+
const owner = principal(uid);
|
|
1245
|
+
if (!key || !owner || !await ensure()) return;
|
|
1175
1246
|
try {
|
|
1176
|
-
await exec(`
|
|
1177
|
-
|
|
1247
|
+
await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
|
|
1248
|
+
owner,
|
|
1178
1249
|
key,
|
|
1179
|
-
principal(uid),
|
|
1180
1250
|
JSON.stringify(response ?? null)
|
|
1181
1251
|
]);
|
|
1182
1252
|
if (Math.random() < .01) await exec(`DELETE FROM ${TABLE} WHERE created_at < NOW() - INTERVAL '${TTL_HOURS} hours'`);
|
|
1183
1253
|
} catch {}
|
|
1254
|
+
},
|
|
1255
|
+
async release(key, uid) {
|
|
1256
|
+
const owner = principal(uid);
|
|
1257
|
+
if (!key || !owner || !await ensure()) return;
|
|
1258
|
+
try {
|
|
1259
|
+
await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
|
|
1260
|
+
} catch {}
|
|
1184
1261
|
}
|
|
1185
1262
|
};
|
|
1186
1263
|
}
|
|
@@ -1204,6 +1281,18 @@ async function parseJsonBody(c) {
|
|
|
1204
1281
|
}
|
|
1205
1282
|
}
|
|
1206
1283
|
/**
|
|
1284
|
+
* A live key presented on a different request than the one it was claimed for.
|
|
1285
|
+
*
|
|
1286
|
+
* Refused rather than replayed, because the replay is the dangerous answer: a
|
|
1287
|
+
* delete sent under the key of an earlier create used to be handed the create's
|
|
1288
|
+
* `200` and its rows, so the caller saw a success for rows that are still
|
|
1289
|
+
* there. A `422` names the mistake instead — the key is well-formed, the
|
|
1290
|
+
* request is not repeatable under it.
|
|
1291
|
+
*/
|
|
1292
|
+
function idempotencyKeyReused(key) {
|
|
1293
|
+
return new ApiError(422, "IDEMPOTENCY_KEY_REUSED", `Idempotency-Key '${key}' was already used for a different request. A key names one write: use a new key for each distinct request, and re-send the identical request to replay its answer.`);
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1207
1296
|
* Lightweight REST API generator that leverages existing Rebase DataDriver.
|
|
1208
1297
|
* Supports `include` query parameter for eager-loading relations via Drizzle.
|
|
1209
1298
|
*/
|
|
@@ -1283,7 +1372,7 @@ var RestApiGenerator = class {
|
|
|
1283
1372
|
* Needed so a nested write can be checked against a schema at all. Without
|
|
1284
1373
|
* it these routes skipped `assertKnownWriteFields` entirely, which is why a
|
|
1285
1374
|
* typo `POST /posts` rejected with a 400 while the same typo on
|
|
1286
|
-
* `POST /authors/1/posts`
|
|
1375
|
+
* `POST /authors/1/posts` was dropped from the statement and answered 201.
|
|
1287
1376
|
*
|
|
1288
1377
|
* Returns `undefined` rather than throwing when the path cannot be walked:
|
|
1289
1378
|
* the driver raises the authoritative error a moment later, and duplicating
|
|
@@ -1333,6 +1422,7 @@ var RestApiGenerator = class {
|
|
|
1333
1422
|
const queryDict = c.req.queries();
|
|
1334
1423
|
const queryOptions = this.parseQuery(queryDict);
|
|
1335
1424
|
const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
|
|
1425
|
+
const searchExplain = (Array.isArray(queryDict.searchExplain) ? queryDict.searchExplain[queryDict.searchExplain.length - 1] : void 0) === "true";
|
|
1336
1426
|
const driver = this.getScopedDriver(c);
|
|
1337
1427
|
const fetchService = driver.restFetchService;
|
|
1338
1428
|
const entities = fetchService ? await fetchService.fetchCollectionForRest(collection.slug, {
|
|
@@ -1343,6 +1433,7 @@ var RestApiGenerator = class {
|
|
|
1343
1433
|
orderBy: queryOptions.orderBy?.[0]?.field,
|
|
1344
1434
|
order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
|
|
1345
1435
|
searchString,
|
|
1436
|
+
searchExplain,
|
|
1346
1437
|
vectorSearch: queryOptions.vectorSearch
|
|
1347
1438
|
}, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
|
|
1348
1439
|
const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
|
|
@@ -1367,121 +1458,241 @@ var RestApiGenerator = class {
|
|
|
1367
1458
|
if (!entity) throw ApiError.notFound("Entity not found");
|
|
1368
1459
|
return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
|
|
1369
1460
|
});
|
|
1461
|
+
/**
|
|
1462
|
+
* Run a bulk handler under an idempotency key.
|
|
1463
|
+
*
|
|
1464
|
+
* All three bulk routes need the identical claim-before-write dance —
|
|
1465
|
+
* claim, replay on a repeat, 409 while one is in flight, release on
|
|
1466
|
+
* failure, complete on success — and getting one of those steps wrong
|
|
1467
|
+
* is exactly the bug the key exists to prevent. Written once so the
|
|
1468
|
+
* three cannot drift into three different notions of "already done".
|
|
1469
|
+
*
|
|
1470
|
+
* `body` is what the key is claimed *for*: the same key on a different
|
|
1471
|
+
* request is a caller mistake, and replaying a create's answer to a
|
|
1472
|
+
* delete would report a deletion that never happened.
|
|
1473
|
+
*/
|
|
1474
|
+
const withIdempotency = async (c, body, run, respond) => {
|
|
1475
|
+
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1476
|
+
const uid = c.get("user")?.uid;
|
|
1477
|
+
const store = this.idempotency();
|
|
1478
|
+
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
|
|
1479
|
+
if (claimed?.status === "replay") return respond(claimed.response);
|
|
1480
|
+
if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
|
|
1481
|
+
if (claimed?.status === "in-flight") throw ApiError.conflict(`A request with Idempotency-Key '${idempotencyKey}' is already in progress. Retry once it has answered; its result will be replayed.`, "IDEMPOTENCY_KEY_IN_PROGRESS");
|
|
1482
|
+
let response;
|
|
1483
|
+
try {
|
|
1484
|
+
response = await run();
|
|
1485
|
+
} catch (error) {
|
|
1486
|
+
if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
|
|
1487
|
+
throw error;
|
|
1488
|
+
}
|
|
1489
|
+
if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
|
|
1490
|
+
return respond(response);
|
|
1491
|
+
};
|
|
1492
|
+
/**
|
|
1493
|
+
* Validate the envelope every bulk route shares: an array, non-empty,
|
|
1494
|
+
* of objects, within the row cap.
|
|
1495
|
+
*
|
|
1496
|
+
* The cap is the load-bearing one. A batch is a single transaction that
|
|
1497
|
+
* holds its locks for its whole duration, so an unbounded one is a
|
|
1498
|
+
* self-inflicted outage — and the message names both the limit and the
|
|
1499
|
+
* actual count so the caller can chunk to it rather than guess.
|
|
1500
|
+
*/
|
|
1501
|
+
const assertBulkShape = (items, field, requireObjects) => {
|
|
1502
|
+
if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
|
|
1503
|
+
if (requireObjects && items.some((it) => typeof it !== "object" || it === null || Array.isArray(it))) throw ApiError.badRequest(`Every entry in \`${field}\` must be an object.`, "INVALID_BULK_BODY");
|
|
1504
|
+
if (items.length > this.maxBulkRows) throw ApiError.badRequest(`Too many rows: ${items.length} exceeds the ${this.maxBulkRows}-row limit for a single bulk write. Send it in chunks of ${this.maxBulkRows} or fewer.`, "BULK_TOO_LARGE");
|
|
1505
|
+
};
|
|
1370
1506
|
this.router.post(`${basePath}/bulk`, async (c) => {
|
|
1371
1507
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
1372
1508
|
const driver = this.getScopedDriver(c);
|
|
1373
1509
|
const path = collection.slug;
|
|
1374
1510
|
const body = await parseJsonBody(c);
|
|
1375
|
-
|
|
1376
|
-
|
|
1511
|
+
assertBulkShape(body?.rows, "rows", true);
|
|
1512
|
+
const rows = body.rows;
|
|
1513
|
+
if (rows.length === 0) return c.json({
|
|
1377
1514
|
data: [],
|
|
1378
1515
|
meta: { written: 0 }
|
|
1379
1516
|
});
|
|
1380
|
-
if (body.rows.some((row) => typeof row !== "object" || row === null || Array.isArray(row))) throw ApiError.badRequest("Every entry in `rows` must be an object.", "INVALID_BULK_BODY");
|
|
1381
1517
|
if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
|
|
1382
|
-
const maxRows = this.maxBulkRows;
|
|
1383
|
-
if (body.rows.length > maxRows) throw ApiError.badRequest(`Too many rows: ${body.rows.length} exceeds the ${maxRows}-row limit for a single bulk write. Send it in chunks of ${maxRows} or fewer.`, "BULK_TOO_LARGE");
|
|
1384
1518
|
if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
rows: body.rows,
|
|
1389
|
-
collection: resolvedCollection,
|
|
1390
|
-
upsert: body.upsert === true
|
|
1519
|
+
rows.forEach((row, rowIndex) => {
|
|
1520
|
+
assertKnownWriteFields(row, resolvedCollection, { rowIndex });
|
|
1521
|
+
assertWriteValuesValid(row, resolvedCollection, { rowIndex });
|
|
1391
1522
|
});
|
|
1392
|
-
return c
|
|
1393
|
-
|
|
1394
|
-
|
|
1523
|
+
return withIdempotency(c, body, async () => {
|
|
1524
|
+
const written = await driver.saveMany({
|
|
1525
|
+
path,
|
|
1526
|
+
rows,
|
|
1527
|
+
collection: resolvedCollection,
|
|
1528
|
+
upsert: body.upsert === true
|
|
1529
|
+
});
|
|
1530
|
+
return {
|
|
1531
|
+
data: written.map((row) => this.formatResponse(row)),
|
|
1532
|
+
meta: { written: written.length }
|
|
1533
|
+
};
|
|
1534
|
+
}, (result) => c.json(result));
|
|
1535
|
+
});
|
|
1536
|
+
this.router.patch(`${basePath}/bulk`, async (c) => {
|
|
1537
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
1538
|
+
const driver = this.getScopedDriver(c);
|
|
1539
|
+
const path = collection.slug;
|
|
1540
|
+
const body = await parseJsonBody(c);
|
|
1541
|
+
assertBulkShape(body?.updates, "updates", true);
|
|
1542
|
+
const updates = body.updates;
|
|
1543
|
+
if (updates.length === 0) return c.json({
|
|
1544
|
+
data: [],
|
|
1545
|
+
meta: { written: 0 }
|
|
1546
|
+
});
|
|
1547
|
+
if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
|
|
1548
|
+
updates.forEach((entry, rowIndex) => {
|
|
1549
|
+
if (entry.id === void 0 || entry.id === null || entry.id === "") throw ApiError.badRequest(`Entry ${rowIndex} of \`updates\` is missing \`id\`. Each entry must be { id, data }.`, "INVALID_BULK_BODY");
|
|
1550
|
+
if (typeof entry.data !== "object" || entry.data === null || Array.isArray(entry.data)) throw ApiError.badRequest(`Entry ${rowIndex} of \`updates\` is missing \`data\`, or it is not an object.`, "INVALID_BULK_BODY");
|
|
1551
|
+
assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
|
|
1552
|
+
assertWriteValuesValid(entry.data, resolvedCollection, { rowIndex });
|
|
1395
1553
|
});
|
|
1554
|
+
return withIdempotency(c, body, async () => {
|
|
1555
|
+
const written = await driver.updateMany({
|
|
1556
|
+
path,
|
|
1557
|
+
updates: updates.map((entry) => ({
|
|
1558
|
+
id: entry.id,
|
|
1559
|
+
values: entry.data
|
|
1560
|
+
})),
|
|
1561
|
+
collection: resolvedCollection
|
|
1562
|
+
});
|
|
1563
|
+
return {
|
|
1564
|
+
data: written.map((row) => this.formatResponse(row)),
|
|
1565
|
+
meta: { written: written.length }
|
|
1566
|
+
};
|
|
1567
|
+
}, (result) => c.json(result));
|
|
1568
|
+
});
|
|
1569
|
+
this.router.post(`${basePath}/bulk/delete`, async (c) => {
|
|
1570
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
1571
|
+
const driver = this.getScopedDriver(c);
|
|
1572
|
+
const path = collection.slug;
|
|
1573
|
+
const body = await parseJsonBody(c);
|
|
1574
|
+
assertBulkShape(body?.ids, "ids", false);
|
|
1575
|
+
const ids = body.ids;
|
|
1576
|
+
if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
|
|
1577
|
+
if (ids.some((id) => typeof id !== "string" && typeof id !== "number")) throw ApiError.badRequest("Every entry in `ids` must be a string or a number.", "INVALID_BULK_BODY");
|
|
1578
|
+
if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
|
|
1579
|
+
return withIdempotency(c, body, async () => {
|
|
1580
|
+
await driver.deleteMany({
|
|
1581
|
+
path,
|
|
1582
|
+
ids,
|
|
1583
|
+
collection: resolvedCollection
|
|
1584
|
+
});
|
|
1585
|
+
return { meta: { deleted: ids.length } };
|
|
1586
|
+
}, (result) => c.json(result));
|
|
1396
1587
|
});
|
|
1397
1588
|
this.router.post(basePath, async (c) => {
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
const entity = await driver.save({
|
|
1414
|
-
path,
|
|
1415
|
-
values: prepared.values,
|
|
1416
|
-
collection: resolvedCollection,
|
|
1417
|
-
status: "new"
|
|
1418
|
-
});
|
|
1419
|
-
const result = prepared.hookHandledEmail ? {
|
|
1420
|
-
temporaryPassword: prepared.clearPassword,
|
|
1421
|
-
invitationSent: prepared.invitationSent
|
|
1422
|
-
} : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
|
|
1423
|
-
id: entity.id,
|
|
1424
|
-
values: entity
|
|
1425
|
-
}, prepared.clearPassword) : { invitationSent: false };
|
|
1426
|
-
const response = this.formatResponse(entity);
|
|
1427
|
-
return c.json({
|
|
1428
|
-
...response,
|
|
1429
|
-
invitationSent: result.invitationSent,
|
|
1430
|
-
...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
|
|
1431
|
-
..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
1432
|
-
}, 201);
|
|
1433
|
-
}
|
|
1434
|
-
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1435
|
-
const uid = c.get("user")?.uid;
|
|
1436
|
-
const store = this.idempotency();
|
|
1437
|
-
if (idempotencyKey && store) {
|
|
1438
|
-
const already = await store.recall(idempotencyKey, uid);
|
|
1439
|
-
if (already !== void 0) return c.json(already, 201);
|
|
1589
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
1590
|
+
const driver = this.getScopedDriver(c);
|
|
1591
|
+
const path = collection.slug;
|
|
1592
|
+
const body = await parseJsonBody(c);
|
|
1593
|
+
const isAuth = collection.auth;
|
|
1594
|
+
const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
|
|
1595
|
+
const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
|
|
1596
|
+
if (!isAuthCollection) {
|
|
1597
|
+
assertKnownWriteFields(body, resolvedCollection);
|
|
1598
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1599
|
+
} else {
|
|
1600
|
+
const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
|
|
1601
|
+
if (contract?.validate) {
|
|
1602
|
+
assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
|
|
1603
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1440
1604
|
}
|
|
1605
|
+
}
|
|
1606
|
+
if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
|
|
1607
|
+
const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
|
|
1441
1608
|
const entity = await driver.save({
|
|
1442
1609
|
path,
|
|
1443
|
-
values:
|
|
1610
|
+
values: prepared.values,
|
|
1444
1611
|
collection: resolvedCollection,
|
|
1445
1612
|
status: "new"
|
|
1446
1613
|
});
|
|
1614
|
+
const result = prepared.hookHandledEmail ? {
|
|
1615
|
+
temporaryPassword: prepared.clearPassword,
|
|
1616
|
+
invitationSent: prepared.invitationSent
|
|
1617
|
+
} : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
|
|
1618
|
+
id: entity.id,
|
|
1619
|
+
values: entity
|
|
1620
|
+
}, prepared.clearPassword) : { invitationSent: false };
|
|
1447
1621
|
const response = this.formatResponse(entity);
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
}
|
|
1454
|
-
throw error;
|
|
1622
|
+
return c.json({
|
|
1623
|
+
...response,
|
|
1624
|
+
invitationSent: result.invitationSent,
|
|
1625
|
+
...result.temporaryPassword ? { temporaryPassword: result.temporaryPassword } : {},
|
|
1626
|
+
..."emailDeliveryFailed" in result && result.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
1627
|
+
}, 201);
|
|
1455
1628
|
}
|
|
1456
|
-
|
|
1457
|
-
|
|
1629
|
+
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1630
|
+
const uid = c.get("user")?.uid;
|
|
1631
|
+
const store = this.idempotency();
|
|
1632
|
+
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
|
|
1633
|
+
if (claimed?.status === "replay") return c.json(claimed.response, 201);
|
|
1634
|
+
if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
|
|
1635
|
+
if (claimed?.status === "in-flight") throw ApiError.conflict(`A request with Idempotency-Key '${idempotencyKey}' is already in progress. Retry once it has answered; its result will be replayed.`, "IDEMPOTENCY_KEY_IN_PROGRESS");
|
|
1636
|
+
let response;
|
|
1458
1637
|
try {
|
|
1459
|
-
this.enforceApiKeyPermission(c, collection.slug);
|
|
1460
|
-
const id = c.req.param("id");
|
|
1461
|
-
const driver = this.getScopedDriver(c);
|
|
1462
|
-
if (!await driver.fetchOne({
|
|
1463
|
-
path: getCollectionDataPath(collection),
|
|
1464
|
-
id: String(id),
|
|
1465
|
-
collection: resolvedCollection
|
|
1466
|
-
})) throw ApiError.notFound("Entity not found");
|
|
1467
|
-
const body = await parseJsonBody(c);
|
|
1468
|
-
assertKnownWriteFields(body, resolvedCollection);
|
|
1469
1638
|
const entity = await driver.save({
|
|
1470
|
-
path
|
|
1471
|
-
id: String(id),
|
|
1639
|
+
path,
|
|
1472
1640
|
values: body,
|
|
1473
1641
|
collection: resolvedCollection,
|
|
1474
|
-
status: "
|
|
1642
|
+
status: "new"
|
|
1475
1643
|
});
|
|
1476
|
-
|
|
1477
|
-
return c.json(response);
|
|
1644
|
+
response = this.formatResponse(entity);
|
|
1478
1645
|
} catch (error) {
|
|
1479
|
-
if (
|
|
1480
|
-
if (!(error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError || error instanceof ReferenceError)) error.code = "BAD_REQUEST";
|
|
1481
|
-
}
|
|
1646
|
+
if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
|
|
1482
1647
|
throw error;
|
|
1483
1648
|
}
|
|
1649
|
+
if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
|
|
1650
|
+
return c.json(response, 201);
|
|
1484
1651
|
});
|
|
1652
|
+
const updateEntity = async (c) => {
|
|
1653
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
1654
|
+
const id = c.req.param("id");
|
|
1655
|
+
const driver = this.getScopedDriver(c);
|
|
1656
|
+
if (!await driver.fetchOne({
|
|
1657
|
+
path: getCollectionDataPath(collection),
|
|
1658
|
+
id: String(id),
|
|
1659
|
+
collection: resolvedCollection
|
|
1660
|
+
})) throw ApiError.notFound("Entity not found");
|
|
1661
|
+
const body = await parseJsonBody(c);
|
|
1662
|
+
assertKnownWriteFields(body, resolvedCollection);
|
|
1663
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1664
|
+
const entity = await driver.save({
|
|
1665
|
+
path: getCollectionDataPath(collection),
|
|
1666
|
+
id: String(id),
|
|
1667
|
+
values: body,
|
|
1668
|
+
collection: resolvedCollection,
|
|
1669
|
+
status: "existing"
|
|
1670
|
+
});
|
|
1671
|
+
const response = this.formatResponse(entity);
|
|
1672
|
+
return c.json(response);
|
|
1673
|
+
};
|
|
1674
|
+
/**
|
|
1675
|
+
* The verb that matches what this actually does.
|
|
1676
|
+
*
|
|
1677
|
+
* The handler merges: it writes the columns in the body and leaves the
|
|
1678
|
+
* rest alone, which is what the SDK's `update(id, data: Partial<M>)`
|
|
1679
|
+
* sends. PUT was the only route, and PUT means replace — so the
|
|
1680
|
+
* generated OpenAPI spec described a full replacement while the server
|
|
1681
|
+
* performed a merge, and it reused the *create* input schema for it,
|
|
1682
|
+
* `required` fields and all. A client generated from that spec was
|
|
1683
|
+
* given a contract the server does not implement, and a spec-validating
|
|
1684
|
+
* gateway in front of this API would reject partial updates the server
|
|
1685
|
+
* would have accepted.
|
|
1686
|
+
*
|
|
1687
|
+
* PUT stays mounted on the same handler rather than being changed or
|
|
1688
|
+
* removed: every published SDK sends it, and altering its semantics to
|
|
1689
|
+
* be a true replace would silently start nulling columns that callers
|
|
1690
|
+
* had simply omitted for years — a data-loss change disguised as a
|
|
1691
|
+
* standards fix. So PATCH is the honest name, PUT is the compatible one,
|
|
1692
|
+
* and both do the documented thing.
|
|
1693
|
+
*/
|
|
1694
|
+
this.router.patch(`${basePath}/:id`, updateEntity);
|
|
1695
|
+
this.router.put(`${basePath}/:id`, updateEntity);
|
|
1485
1696
|
this.router.delete(`${basePath}/:id`, async (c) => {
|
|
1486
1697
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
1487
1698
|
const id = c.req.param("id");
|
|
@@ -1552,6 +1763,7 @@ var RestApiGenerator = class {
|
|
|
1552
1763
|
const total = driver.count ? await driver.count({
|
|
1553
1764
|
path: parsed.collectionPath,
|
|
1554
1765
|
filter: queryOptions.where,
|
|
1766
|
+
logical: queryOptions.logical,
|
|
1555
1767
|
searchString
|
|
1556
1768
|
}) : 0;
|
|
1557
1769
|
return c.json({ count: total });
|
|
@@ -1563,7 +1775,9 @@ var RestApiGenerator = class {
|
|
|
1563
1775
|
id: parsed.id
|
|
1564
1776
|
});
|
|
1565
1777
|
if (!entity) throw ApiError.notFound("Entity not found");
|
|
1566
|
-
|
|
1778
|
+
const nestedCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1779
|
+
if (!nestedCollection) return c.json(entity);
|
|
1780
|
+
return c.json(projectResponseFields([entity], queryOptions.fields, nestedCollection, { include: queryOptions.include })[0]);
|
|
1567
1781
|
} else {
|
|
1568
1782
|
const queryDict = c.req.queries();
|
|
1569
1783
|
const queryOptions = this.parseQuery(queryDict);
|
|
@@ -1588,8 +1802,9 @@ var RestApiGenerator = class {
|
|
|
1588
1802
|
logical: queryOptions.logical,
|
|
1589
1803
|
searchString
|
|
1590
1804
|
}) : entities.length;
|
|
1805
|
+
const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1591
1806
|
return c.json({
|
|
1592
|
-
data: entities,
|
|
1807
|
+
data: listCollection ? projectResponseFields(entities, queryOptions.fields, listCollection, { include: queryOptions.include }) : entities,
|
|
1593
1808
|
meta: {
|
|
1594
1809
|
total,
|
|
1595
1810
|
limit: queryOptions.limit,
|
|
@@ -1609,7 +1824,10 @@ var RestApiGenerator = class {
|
|
|
1609
1824
|
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
1610
1825
|
const body = await parseJsonBody(c);
|
|
1611
1826
|
const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1612
|
-
if (targetCollection)
|
|
1827
|
+
if (targetCollection) {
|
|
1828
|
+
assertKnownWriteFields(body, targetCollection);
|
|
1829
|
+
assertWriteValuesValid(body, targetCollection);
|
|
1830
|
+
}
|
|
1613
1831
|
const entity = await driver.save({
|
|
1614
1832
|
path: parsed.collectionPath,
|
|
1615
1833
|
values: body,
|
|
@@ -1618,7 +1836,7 @@ var RestApiGenerator = class {
|
|
|
1618
1836
|
const response = this.formatResponse(entity);
|
|
1619
1837
|
return c.json(response, 201);
|
|
1620
1838
|
});
|
|
1621
|
-
|
|
1839
|
+
const updateNested = async (c, next) => {
|
|
1622
1840
|
const rest = c.req.param("rest");
|
|
1623
1841
|
if (!rest || rest === "undefined") return next();
|
|
1624
1842
|
const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
|
|
@@ -1628,7 +1846,10 @@ var RestApiGenerator = class {
|
|
|
1628
1846
|
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
1629
1847
|
const body = await parseJsonBody(c);
|
|
1630
1848
|
const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1631
|
-
if (targetCollection)
|
|
1849
|
+
if (targetCollection) {
|
|
1850
|
+
assertKnownWriteFields(body, targetCollection);
|
|
1851
|
+
assertWriteValuesValid(body, targetCollection);
|
|
1852
|
+
}
|
|
1632
1853
|
const entity = await driver.save({
|
|
1633
1854
|
path: parsed.collectionPath,
|
|
1634
1855
|
id: parsed.id,
|
|
@@ -1637,7 +1858,9 @@ var RestApiGenerator = class {
|
|
|
1637
1858
|
});
|
|
1638
1859
|
const response = this.formatResponse(entity);
|
|
1639
1860
|
return c.json(response);
|
|
1640
|
-
}
|
|
1861
|
+
};
|
|
1862
|
+
this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
|
|
1863
|
+
this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
|
|
1641
1864
|
this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
|
|
1642
1865
|
const rest = c.req.param("rest");
|
|
1643
1866
|
if (!rest || rest === "undefined") return next();
|
|
@@ -1917,7 +2140,7 @@ var LocalStorageController = class {
|
|
|
1917
2140
|
}
|
|
1918
2141
|
/**
|
|
1919
2142
|
* Get the full filesystem path for a storage path, with a traversal guard
|
|
1920
|
-
* that keeps the result inside the
|
|
2143
|
+
* that keeps the result inside the storage root.
|
|
1921
2144
|
*
|
|
1922
2145
|
* Defaults the bucket the way `putObject` does.
|
|
1923
2146
|
*
|
|
@@ -1928,10 +2151,22 @@ var LocalStorageController = class {
|
|
|
1928
2151
|
* design), and `listObjects` returned an empty page. So the obvious
|
|
1929
2152
|
* `putObject({ key })` → `getObject(key)` did not round-trip and nothing
|
|
1930
2153
|
* said why. One default, applied everywhere, removes the whole class.
|
|
2154
|
+
*
|
|
2155
|
+
* Two boundaries, checked separately, because there are two of them. The
|
|
2156
|
+
* key must stay inside its bucket — that check always worked. The bucket
|
|
2157
|
+
* must stay inside the storage root, and that one did not exist: the guard
|
|
2158
|
+
* derived `bucketPath` from the caller's `bucket` and then asked whether
|
|
2159
|
+
* the result was inside `bucketPath`, so `bucket = "../../etc"` made the
|
|
2160
|
+
* reference point `/etc` and `/etc/passwd` is comfortably inside it. A
|
|
2161
|
+
* guard whose reference point moves with its input is not a guard. The
|
|
2162
|
+
* route boundary rejects such a bucket outright
|
|
2163
|
+
* (`canonicalStorageBucket`); this is the layer underneath that does not
|
|
2164
|
+
* depend on it having run.
|
|
1931
2165
|
*/
|
|
1932
2166
|
getFullPath(storagePath, bucket) {
|
|
1933
|
-
const bucketPath = path$2.
|
|
1934
|
-
|
|
2167
|
+
const bucketPath = path$2.resolve(this.basePath, bucket || "default");
|
|
2168
|
+
if (!bucketPath.startsWith(this.basePath + path$2.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
|
|
2169
|
+
const resolved = path$2.resolve(bucketPath, storagePath);
|
|
1935
2170
|
if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
1936
2171
|
return resolved;
|
|
1937
2172
|
}
|
|
@@ -1947,7 +2182,7 @@ var LocalStorageController = class {
|
|
|
1947
2182
|
}
|
|
1948
2183
|
async putObject({ file, key, metadata, bucket }) {
|
|
1949
2184
|
this.validateFile(file);
|
|
1950
|
-
const usedBucket = bucket
|
|
2185
|
+
const usedBucket = bucket || "default";
|
|
1951
2186
|
const fullStoragePath = key;
|
|
1952
2187
|
const fullPath = this.getFullPath(fullStoragePath, usedBucket);
|
|
1953
2188
|
await this.ensureDir(path$2.dirname(fullPath));
|
|
@@ -2177,38 +2412,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
|
|
|
2177
2412
|
"outside"
|
|
2178
2413
|
]);
|
|
2179
2414
|
/**
|
|
2415
|
+
* A transform request naming parameters outside the declared bounds.
|
|
2416
|
+
*
|
|
2417
|
+
* Surfaced as a 400 by the route. Out-of-range values used to be *clamped*:
|
|
2418
|
+
* `width=99999` silently became 4096 and `format=tiff` silently became webp,
|
|
2419
|
+
* so the caller debugged a wrongly-sized `<img>` in the browser instead of
|
|
2420
|
+
* reading an error. A bound that rewrites its input is invisible, and this
|
|
2421
|
+
* endpoint's bounds are load-bearing — see {@link runTransform}.
|
|
2422
|
+
*/
|
|
2423
|
+
var InvalidTransformOptionsError = class extends Error {
|
|
2424
|
+
constructor(message) {
|
|
2425
|
+
super(message);
|
|
2426
|
+
this.name = "InvalidTransformOptionsError";
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
/**
|
|
2430
|
+
* Parse a bounded integer parameter, or throw.
|
|
2431
|
+
*
|
|
2432
|
+
* Strict digits only: `parseInt` accepted `300px` and `12abc`, which is a
|
|
2433
|
+
* second way for a caller to get a transform they did not describe.
|
|
2434
|
+
*/
|
|
2435
|
+
function boundedInt(name, raw, min, max) {
|
|
2436
|
+
if (!/^\d+$/.test(raw)) throw new InvalidTransformOptionsError(`Invalid '${name}': expected an integer between ${min} and ${max}.`);
|
|
2437
|
+
const value = parseInt(raw, 10);
|
|
2438
|
+
if (value < min || value > max) throw new InvalidTransformOptionsError(`Invalid '${name}': must be between ${min} and ${max}, got ${value}.`);
|
|
2439
|
+
return value;
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2180
2442
|
* Parse transform options from URL query parameters.
|
|
2181
2443
|
* Returns `null` when no transformation is requested.
|
|
2444
|
+
*
|
|
2445
|
+
* Throws {@link InvalidTransformOptionsError} for anything outside the bounds
|
|
2446
|
+
* declared here — these are the whole parameter surface of an endpoint that is
|
|
2447
|
+
* reachable anonymously for public objects, so what it accepts has to be a
|
|
2448
|
+
* closed, stated set rather than "whatever we could coerce into range".
|
|
2182
2449
|
*/
|
|
2183
2450
|
function parseTransformOptions(query) {
|
|
2184
2451
|
const opts = {};
|
|
2185
2452
|
let hasTransform = false;
|
|
2186
2453
|
if (query.width) {
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
opts.width = Math.min(w, MAX_DIMENSION);
|
|
2190
|
-
hasTransform = true;
|
|
2191
|
-
}
|
|
2454
|
+
opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
|
|
2455
|
+
hasTransform = true;
|
|
2192
2456
|
}
|
|
2193
2457
|
if (query.height) {
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
opts.height = Math.min(h, MAX_DIMENSION);
|
|
2197
|
-
hasTransform = true;
|
|
2198
|
-
}
|
|
2458
|
+
opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
|
|
2459
|
+
hasTransform = true;
|
|
2199
2460
|
}
|
|
2200
2461
|
if (query.quality) {
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
|
|
2204
|
-
hasTransform = true;
|
|
2205
|
-
}
|
|
2462
|
+
opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
|
|
2463
|
+
hasTransform = true;
|
|
2206
2464
|
}
|
|
2207
|
-
if (query.format
|
|
2465
|
+
if (query.format) {
|
|
2466
|
+
if (!VALID_FORMATS.has(query.format)) throw new InvalidTransformOptionsError(`Invalid 'format': expected one of ${[...VALID_FORMATS].join(", ")}.`);
|
|
2208
2467
|
opts.format = query.format;
|
|
2209
2468
|
hasTransform = true;
|
|
2210
2469
|
}
|
|
2211
|
-
if (query.fit
|
|
2470
|
+
if (query.fit) {
|
|
2471
|
+
if (!VALID_FITS.has(query.fit)) throw new InvalidTransformOptionsError(`Invalid 'fit': expected one of ${[...VALID_FITS].join(", ")}.`);
|
|
2212
2472
|
opts.fit = query.fit;
|
|
2213
2473
|
hasTransform = true;
|
|
2214
2474
|
}
|
|
@@ -2226,9 +2486,72 @@ function isTransformableImage(contentType) {
|
|
|
2226
2486
|
return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
|
|
2227
2487
|
}
|
|
2228
2488
|
/**
|
|
2489
|
+
* A transform refused because the server is already doing as many as it will.
|
|
2490
|
+
*
|
|
2491
|
+
* Surfaced as a 503 by the route.
|
|
2492
|
+
*/
|
|
2493
|
+
var TransformOverloadedError = class extends Error {
|
|
2494
|
+
constructor(message) {
|
|
2495
|
+
super(message);
|
|
2496
|
+
this.name = "TransformOverloadedError";
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
/**
|
|
2500
|
+
* A bounded work queue.
|
|
2501
|
+
*
|
|
2502
|
+
* A transform is seconds of libvips CPU and a full-resolution bitmap in RSS,
|
|
2503
|
+
* and `GET /file/*?width=…` is reachable anonymously for public objects — so
|
|
2504
|
+
* without a bound, a few hundred bytes per request buys unlimited pod CPU, and
|
|
2505
|
+
* enough of them at once buys the heap too. `maxConcurrent` caps the CPU and
|
|
2506
|
+
* memory in flight; `maxQueued` is what keeps an unbounded backlog from
|
|
2507
|
+
* becoming the same problem one level up, refusing fast (503) instead of
|
|
2508
|
+
* accepting work the process will not get to.
|
|
2509
|
+
*/
|
|
2510
|
+
var TransformQueue = class {
|
|
2511
|
+
maxConcurrent;
|
|
2512
|
+
maxQueued;
|
|
2513
|
+
active = 0;
|
|
2514
|
+
waiting = [];
|
|
2515
|
+
constructor(maxConcurrent, maxQueued) {
|
|
2516
|
+
this.maxConcurrent = maxConcurrent;
|
|
2517
|
+
this.maxQueued = maxQueued;
|
|
2518
|
+
}
|
|
2519
|
+
/** Number of tasks running plus waiting. Exposed for tests and metrics. */
|
|
2520
|
+
get depth() {
|
|
2521
|
+
return this.active + this.waiting.length;
|
|
2522
|
+
}
|
|
2523
|
+
async run(work) {
|
|
2524
|
+
if (this.active >= this.maxConcurrent) {
|
|
2525
|
+
if (this.waiting.length >= this.maxQueued) throw new TransformOverloadedError("Image transformation is at capacity. Retry shortly.");
|
|
2526
|
+
await new Promise((resolve) => this.waiting.push(resolve));
|
|
2527
|
+
}
|
|
2528
|
+
this.active++;
|
|
2529
|
+
try {
|
|
2530
|
+
return await work();
|
|
2531
|
+
} finally {
|
|
2532
|
+
this.active--;
|
|
2533
|
+
this.waiting.shift()?.();
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
/**
|
|
2538
|
+
* The queue every transform goes through.
|
|
2539
|
+
*
|
|
2540
|
+
* Small on purpose: transforms are CPU-bound, so more concurrency than cores
|
|
2541
|
+
* buys nothing but resident bitmaps.
|
|
2542
|
+
*/
|
|
2543
|
+
var transformQueue = new TransformQueue(Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length)), 64);
|
|
2544
|
+
/**
|
|
2229
2545
|
* Apply image transformations and return the result buffer + content type.
|
|
2546
|
+
*
|
|
2547
|
+
* Runs on {@link transformQueue}: the work is bounded, and callers past the
|
|
2548
|
+
* bound get {@link TransformOverloadedError} rather than a share of a
|
|
2549
|
+
* saturated CPU.
|
|
2230
2550
|
*/
|
|
2231
2551
|
async function transformImage(buffer, options) {
|
|
2552
|
+
return transformQueue.run(() => runTransform(buffer, options));
|
|
2553
|
+
}
|
|
2554
|
+
async function runTransform(buffer, options) {
|
|
2232
2555
|
let pipeline = (await getSharp())(buffer);
|
|
2233
2556
|
if (options.width || options.height) pipeline = pipeline.resize({
|
|
2234
2557
|
width: options.width,
|
|
@@ -2355,6 +2678,7 @@ var TusHandler = class {
|
|
|
2355
2678
|
this.cleanupTimer = setInterval(() => {
|
|
2356
2679
|
this.cleanupStale();
|
|
2357
2680
|
}, 6e4);
|
|
2681
|
+
this.cleanupTimer.unref?.();
|
|
2358
2682
|
}
|
|
2359
2683
|
/** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
|
|
2360
2684
|
async cleanupStale() {
|
|
@@ -2406,11 +2730,22 @@ var TusHandler = class {
|
|
|
2406
2730
|
if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
|
|
2407
2731
|
if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
|
|
2408
2732
|
const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
|
|
2409
|
-
if (this.authorizeUpload) {
|
|
2410
|
-
const key = metadata.key || metadata.filename || "";
|
|
2411
|
-
await this.authorizeUpload(c, key, metadata.bucket || "default");
|
|
2412
|
-
}
|
|
2413
2733
|
const id = randomUUID$1();
|
|
2734
|
+
const rawKey = metadata.key || metadata.filename || "";
|
|
2735
|
+
let key;
|
|
2736
|
+
try {
|
|
2737
|
+
key = canonicalStorageKey(rawKey) || id;
|
|
2738
|
+
} catch (err) {
|
|
2739
|
+
throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
|
|
2740
|
+
}
|
|
2741
|
+
let bucket;
|
|
2742
|
+
try {
|
|
2743
|
+
bucket = canonicalStorageBucket(metadata.bucket);
|
|
2744
|
+
} catch (err) {
|
|
2745
|
+
throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
|
|
2746
|
+
}
|
|
2747
|
+
const storageId = metadata.storageId || c.req.query("storageId") || void 0;
|
|
2748
|
+
if (this.authorizeUpload) await this.authorizeUpload(c, key, bucket || "default", storageId);
|
|
2414
2749
|
const filePath = join(this.tusDir, id);
|
|
2415
2750
|
await writeFile(filePath, Buffer.alloc(0));
|
|
2416
2751
|
const upload = {
|
|
@@ -2420,8 +2755,9 @@ var TusHandler = class {
|
|
|
2420
2755
|
metadata,
|
|
2421
2756
|
createdAt: Date.now(),
|
|
2422
2757
|
filePath,
|
|
2423
|
-
bucket
|
|
2424
|
-
|
|
2758
|
+
bucket,
|
|
2759
|
+
storageId,
|
|
2760
|
+
key,
|
|
2425
2761
|
completed: false
|
|
2426
2762
|
};
|
|
2427
2763
|
this.uploads.set(id, upload);
|
|
@@ -2496,7 +2832,7 @@ var TusHandler = class {
|
|
|
2496
2832
|
*/
|
|
2497
2833
|
async finalize(upload) {
|
|
2498
2834
|
upload.completed = true;
|
|
2499
|
-
const storageId = upload.
|
|
2835
|
+
const storageId = upload.storageId;
|
|
2500
2836
|
let targetController = this.storageController;
|
|
2501
2837
|
if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
|
|
2502
2838
|
if (!targetController) {
|
|
@@ -2506,7 +2842,7 @@ var TusHandler = class {
|
|
|
2506
2842
|
try {
|
|
2507
2843
|
const { readFile } = await import("fs/promises");
|
|
2508
2844
|
const data = await readFile(upload.filePath);
|
|
2509
|
-
const fileName = upload.key
|
|
2845
|
+
const fileName = upload.key;
|
|
2510
2846
|
const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
|
|
2511
2847
|
const file = new File([new Uint8Array(data)], fileName, { type: mimeType });
|
|
2512
2848
|
await targetController.putObject({
|
|
@@ -2537,6 +2873,82 @@ var TusHandler = class {
|
|
|
2537
2873
|
/** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
|
|
2538
2874
|
var transformCache = new TransformCache();
|
|
2539
2875
|
/**
|
|
2876
|
+
* Transforms currently being computed, keyed the same way the cache is.
|
|
2877
|
+
*
|
|
2878
|
+
* Without it, N concurrent requests for one uncached variant run N full
|
|
2879
|
+
* decode+encode pipelines — the cache only helps the requests that arrive
|
|
2880
|
+
* after the first one has finished. Joining the in-flight promise makes a
|
|
2881
|
+
* thundering herd cost one decode.
|
|
2882
|
+
*/
|
|
2883
|
+
var transformsInFlight = /* @__PURE__ */ new Map();
|
|
2884
|
+
/**
|
|
2885
|
+
* Compute a transform, or join the one already running for this key.
|
|
2886
|
+
*
|
|
2887
|
+
* `loadSource` is only called on a real miss, so the concurrent requests that
|
|
2888
|
+
* join an in-flight transform do not each read the source object either.
|
|
2889
|
+
*/
|
|
2890
|
+
async function transformOnce(cacheKey, options, loadSource) {
|
|
2891
|
+
const cached = transformCache.get(cacheKey);
|
|
2892
|
+
if (cached) return cached;
|
|
2893
|
+
const running = transformsInFlight.get(cacheKey);
|
|
2894
|
+
if (running) return running;
|
|
2895
|
+
const pending = (async () => {
|
|
2896
|
+
try {
|
|
2897
|
+
const result = await transformImage(await loadSource(), options);
|
|
2898
|
+
transformCache.set(cacheKey, result.data, result.contentType);
|
|
2899
|
+
return result;
|
|
2900
|
+
} catch (err) {
|
|
2901
|
+
if (err instanceof TransformOverloadedError) throw ApiError.serviceUnavailable(err.message, "TRANSFORM_OVERLOADED");
|
|
2902
|
+
throw err;
|
|
2903
|
+
}
|
|
2904
|
+
})();
|
|
2905
|
+
transformsInFlight.set(cacheKey, pending);
|
|
2906
|
+
try {
|
|
2907
|
+
return await pending;
|
|
2908
|
+
} finally {
|
|
2909
|
+
transformsInFlight.delete(cacheKey);
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
/**
|
|
2913
|
+
* Content types served inline. Everything else is handed back as
|
|
2914
|
+
* `application/octet-stream` with `Content-Disposition: attachment`.
|
|
2915
|
+
*
|
|
2916
|
+
* The stored content type is the *uploader's claim* — `putObject` writes
|
|
2917
|
+
* `file.type` from the multipart part, and TUS takes it from a request header;
|
|
2918
|
+
* nothing sniffs the bytes. Echoing that claim back as the response type turns
|
|
2919
|
+
* `/api/storage/file/*` into an HTML hosting endpoint on the API origin, and
|
|
2920
|
+
* where `cookieAuth` is enabled the refresh cookie is `Path=/` on exactly that
|
|
2921
|
+
* origin — so an uploaded page can fetch `/api/auth/refresh` same-origin and
|
|
2922
|
+
* read out a fresh access token, `HttpOnly` notwithstanding.
|
|
2923
|
+
*
|
|
2924
|
+
* `image/svg+xml` is deliberately absent: an SVG is a document that can carry
|
|
2925
|
+
* script. `text/html` and `application/xhtml+xml` are absent for the same
|
|
2926
|
+
* reason, and are what the attack actually uses.
|
|
2927
|
+
*/
|
|
2928
|
+
var INLINE_CONTENT_TYPE_PREFIXES = [
|
|
2929
|
+
"image/",
|
|
2930
|
+
"video/",
|
|
2931
|
+
"audio/"
|
|
2932
|
+
];
|
|
2933
|
+
var INLINE_CONTENT_TYPES = /* @__PURE__ */ new Set(["application/pdf", "text/plain"]);
|
|
2934
|
+
/**
|
|
2935
|
+
* Decide what to actually serve for a stored content type.
|
|
2936
|
+
*
|
|
2937
|
+
* Returns the type to send and whether to force a download. The check is an
|
|
2938
|
+
* allowlist rather than a blocklist of dangerous types: the set of types a
|
|
2939
|
+
* browser will execute grows, and the set we want to render inline does not.
|
|
2940
|
+
*/
|
|
2941
|
+
function resolveServedContentType(storedContentType) {
|
|
2942
|
+
const base = storedContentType.split(";")[0].trim().toLowerCase();
|
|
2943
|
+
return (base.includes("svg") ? false : INLINE_CONTENT_TYPES.has(base) || INLINE_CONTENT_TYPE_PREFIXES.some((p) => base.startsWith(p))) ? {
|
|
2944
|
+
contentType: storedContentType,
|
|
2945
|
+
attachment: false
|
|
2946
|
+
} : {
|
|
2947
|
+
contentType: "application/octet-stream",
|
|
2948
|
+
attachment: true
|
|
2949
|
+
};
|
|
2950
|
+
}
|
|
2951
|
+
/**
|
|
2540
2952
|
* Extract the wildcard portion of a route path from the full request path.
|
|
2541
2953
|
*
|
|
2542
2954
|
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
@@ -2556,16 +2968,48 @@ function extractWildcardPath(c) {
|
|
|
2556
2968
|
return fullPath.substring(idx + prefix.length + 1);
|
|
2557
2969
|
}
|
|
2558
2970
|
/**
|
|
2559
|
-
*
|
|
2560
|
-
*
|
|
2971
|
+
* Canonicalize a caller-supplied storage key, answering 400 when it names
|
|
2972
|
+
* something other than what it says.
|
|
2973
|
+
*
|
|
2974
|
+
* The single place a request's key becomes canonical. Every route runs its key
|
|
2975
|
+
* through this before anything else touches it, so the authorize hook, the
|
|
2976
|
+
* storage controller and the minted download token are all looking at the same
|
|
2977
|
+
* string — which is the only thing that makes the hook's answer meaningful.
|
|
2978
|
+
* See `keys.ts` for why an unacceptable key is refused rather than repaired.
|
|
2979
|
+
*/
|
|
2980
|
+
function canonicalKeyOrBadRequest(key) {
|
|
2981
|
+
try {
|
|
2982
|
+
return canonicalStorageKey(key);
|
|
2983
|
+
} catch (err) {
|
|
2984
|
+
throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
/**
|
|
2988
|
+
* Canonicalize a caller-supplied bucket name, answering 400 when it is not one.
|
|
2989
|
+
*
|
|
2990
|
+
* The bucket's counterpart to {@link canonicalKeyOrBadRequest}, and it exists
|
|
2991
|
+
* for the same reason: the value routes a write, so it has to be checked where
|
|
2992
|
+
* it enters rather than where it is used. Applied at every entry point a bucket
|
|
2993
|
+
* has — this route's multipart body, the `?bucket=` query, and the TUS
|
|
2994
|
+
* `Upload-Metadata` header.
|
|
2995
|
+
*/
|
|
2996
|
+
function canonicalBucketOrBadRequest(bucket) {
|
|
2997
|
+
try {
|
|
2998
|
+
return canonicalStorageBucket(bucket);
|
|
2999
|
+
} catch (err) {
|
|
3000
|
+
throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Parse image transform query parameters, answering 400 when they are out of
|
|
3005
|
+
* bounds rather than clamping them to something the caller did not ask for.
|
|
2561
3006
|
*/
|
|
2562
|
-
function
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
return sanitized;
|
|
3007
|
+
function transformOptionsOrBadRequest(query) {
|
|
3008
|
+
try {
|
|
3009
|
+
return parseTransformOptions(query);
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
throw new ApiError(400, "INVALID_TRANSFORM_OPTIONS", err instanceof InvalidTransformOptionsError ? err.message : "Invalid image transform options");
|
|
3012
|
+
}
|
|
2569
3013
|
}
|
|
2570
3014
|
/**
|
|
2571
3015
|
* Build adapter-aware auth middleware for storage routes.
|
|
@@ -2669,25 +3113,26 @@ function createStorageRoutes(config) {
|
|
|
2669
3113
|
/**
|
|
2670
3114
|
* Parse bucket and path from a combined file path.
|
|
2671
3115
|
*
|
|
2672
|
-
* The resolved path is
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
*
|
|
2676
|
-
*
|
|
2677
|
-
* (
|
|
2678
|
-
*
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
3116
|
+
* The resolved path is canonicalized here — by the same function the upload
|
|
3117
|
+
* route applies to incoming keys — so read, delete, metadata and folder
|
|
3118
|
+
* routes agree with the write side about what a key means before it reaches
|
|
3119
|
+
* the controller, the authorize hook, or the download token it mints.
|
|
3120
|
+
*
|
|
3121
|
+
* A key that cannot be canonicalized (a real `..` segment, a null byte) is
|
|
3122
|
+
* a 400, not a repaired key. The `LocalStorageController` traversal guard
|
|
3123
|
+
* (`getFullPath`) is the load-bearing defence for the *storage root*, and
|
|
3124
|
+
* `canonicalStorageBucket` for the bucket the caller names; this is what
|
|
3125
|
+
* defends the boundary the hook drew inside them.
|
|
2681
3126
|
*/
|
|
2682
3127
|
const parseBucketAndPath = (filePath) => {
|
|
2683
3128
|
const parts = filePath.split("/");
|
|
2684
3129
|
if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
|
|
2685
3130
|
bucket: "default",
|
|
2686
|
-
resolvedPath:
|
|
3131
|
+
resolvedPath: canonicalKeyOrBadRequest(parts.slice(1).join("/"))
|
|
2687
3132
|
};
|
|
2688
3133
|
return {
|
|
2689
3134
|
bucket: "default",
|
|
2690
|
-
resolvedPath:
|
|
3135
|
+
resolvedPath: canonicalKeyOrBadRequest(filePath)
|
|
2691
3136
|
};
|
|
2692
3137
|
};
|
|
2693
3138
|
/**
|
|
@@ -2700,9 +3145,9 @@ function createStorageRoutes(config) {
|
|
|
2700
3145
|
const uploadedFile = body["file"];
|
|
2701
3146
|
if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
|
|
2702
3147
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2703
|
-
const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
|
|
3148
|
+
const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
|
|
2704
3149
|
const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
|
|
2705
|
-
const finalKey =
|
|
3150
|
+
const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
|
|
2706
3151
|
const metadata = {};
|
|
2707
3152
|
for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
|
|
2708
3153
|
await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
|
|
@@ -2728,15 +3173,13 @@ function createStorageRoutes(config) {
|
|
|
2728
3173
|
const filePath = decodeURIComponent(rawPath);
|
|
2729
3174
|
const storageId = c.req.query("storageId");
|
|
2730
3175
|
const resolved = resolveController(storageId);
|
|
2731
|
-
{
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
}
|
|
2735
|
-
const transformOpts =
|
|
3176
|
+
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
3177
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
3178
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
3179
|
+
const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
|
|
3180
|
+
const transformOpts = transformOptionsOrBadRequest(c.req.query());
|
|
2736
3181
|
if (resolved.getType() === "local") {
|
|
2737
|
-
const
|
|
2738
|
-
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
2739
|
-
const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
|
|
3182
|
+
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
|
|
2740
3183
|
try {
|
|
2741
3184
|
await fsp.access(absolutePath);
|
|
2742
3185
|
} catch {
|
|
@@ -2748,37 +3191,30 @@ function createStorageRoutes(config) {
|
|
|
2748
3191
|
const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
|
|
2749
3192
|
contentType = JSON.parse(metadataRaw).contentType || contentType;
|
|
2750
3193
|
} catch {}
|
|
2751
|
-
const fileContent = await fsp.readFile(absolutePath);
|
|
2752
3194
|
if (transformOpts && isTransformableImage(contentType)) {
|
|
2753
|
-
const
|
|
2754
|
-
|
|
2755
|
-
if (!cached) {
|
|
2756
|
-
cached = await transformImage(Buffer.from(fileContent), transformOpts);
|
|
2757
|
-
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
2758
|
-
}
|
|
2759
|
-
c.header("Content-Type", cached.contentType);
|
|
3195
|
+
const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)));
|
|
3196
|
+
c.header("Content-Type", transformed.contentType);
|
|
2760
3197
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2761
|
-
return c.body(new Uint8Array(
|
|
3198
|
+
return c.body(new Uint8Array(transformed.data));
|
|
2762
3199
|
}
|
|
2763
|
-
|
|
3200
|
+
const fileContent = await fsp.readFile(absolutePath);
|
|
3201
|
+
const served = resolveServedContentType(contentType);
|
|
3202
|
+
c.header("Content-Type", served.contentType);
|
|
3203
|
+
if (served.attachment) c.header("Content-Disposition", "attachment");
|
|
2764
3204
|
return c.body(new Uint8Array(fileContent));
|
|
2765
3205
|
}
|
|
2766
|
-
const
|
|
2767
|
-
const fileObject = await resolved.getObject(parsedPath, parsedBucket);
|
|
3206
|
+
const fileObject = await resolved.getObject(resolvedPath, bucket);
|
|
2768
3207
|
if (!fileObject) throw ApiError.notFound("File not found");
|
|
2769
3208
|
const remoteContentType = fileObject.type || "application/octet-stream";
|
|
2770
3209
|
if (transformOpts && isTransformableImage(remoteContentType)) {
|
|
2771
|
-
const
|
|
2772
|
-
|
|
2773
|
-
if (!cached) {
|
|
2774
|
-
cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
|
|
2775
|
-
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
2776
|
-
}
|
|
2777
|
-
c.header("Content-Type", cached.contentType);
|
|
3210
|
+
const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()));
|
|
3211
|
+
c.header("Content-Type", transformed.contentType);
|
|
2778
3212
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2779
|
-
return c.body(new Uint8Array(
|
|
3213
|
+
return c.body(new Uint8Array(transformed.data));
|
|
2780
3214
|
}
|
|
2781
|
-
|
|
3215
|
+
const servedRemote = resolveServedContentType(remoteContentType);
|
|
3216
|
+
c.header("Content-Type", servedRemote.contentType);
|
|
3217
|
+
if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
|
|
2782
3218
|
c.header("Cache-Control", "public, max-age=3600, immutable");
|
|
2783
3219
|
const buf = await fileObject.arrayBuffer();
|
|
2784
3220
|
return c.body(new Uint8Array(buf));
|
|
@@ -2837,8 +3273,8 @@ function createStorageRoutes(config) {
|
|
|
2837
3273
|
* GET /list - List files in a path
|
|
2838
3274
|
*/
|
|
2839
3275
|
router.get("/list", writeAuthMiddleware, async (c) => {
|
|
2840
|
-
const storagePrefix =
|
|
2841
|
-
const bucket = c.req.query("bucket");
|
|
3276
|
+
const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
|
|
3277
|
+
const bucket = canonicalBucketOrBadRequest(c.req.query("bucket"));
|
|
2842
3278
|
const maxResults = c.req.query("maxResults");
|
|
2843
3279
|
const pageToken = c.req.query("pageToken");
|
|
2844
3280
|
const storageId = c.req.query("storageId");
|
|
@@ -2884,8 +3320,8 @@ function createStorageRoutes(config) {
|
|
|
2884
3320
|
}, 201);
|
|
2885
3321
|
});
|
|
2886
3322
|
const defaultCtrl = getDefaultController();
|
|
2887
|
-
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
|
|
2888
|
-
await checkAuthorized(c, "write",
|
|
3323
|
+
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket, storageId) => {
|
|
3324
|
+
await checkAuthorized(c, "write", key, bucket, storageId);
|
|
2889
3325
|
} : void 0);
|
|
2890
3326
|
tusHandler.startCleanup();
|
|
2891
3327
|
router.options("/tus", (_c) => tusHandler.options());
|
|
@@ -3079,11 +3515,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
3079
3515
|
}
|
|
3080
3516
|
//#endregion
|
|
3081
3517
|
//#region src/init/docs.ts
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3518
|
+
/**
|
|
3519
|
+
* Serve the OpenAPI document, and Swagger UI outside production.
|
|
3520
|
+
*
|
|
3521
|
+
* `serverCollections` must be the collections the REST routes were generated
|
|
3522
|
+
* from — the ones whose data source has a `"server"` transport — not every
|
|
3523
|
+
* active collection. A direct-transport collection is served by the client
|
|
3524
|
+
* against its own backend; documenting it here publishes a full set of CRUD
|
|
3525
|
+
* paths that 404, with a Try-It button next to each.
|
|
3526
|
+
*/
|
|
3527
|
+
async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections, requireAuth) {
|
|
3528
|
+
if (enableSwagger === false || serverCollections.length === 0) return;
|
|
3529
|
+
const { generateOpenApiSpec } = await import("./openapi-generator-BQxxxDpb.js");
|
|
3085
3530
|
app.get(`${basePath}/docs`, (c) => {
|
|
3086
|
-
const spec = generateOpenApiSpec(
|
|
3531
|
+
const spec = generateOpenApiSpec(serverCollections, {
|
|
3087
3532
|
basePath,
|
|
3088
3533
|
requireAuth
|
|
3089
3534
|
});
|
|
@@ -3258,6 +3703,54 @@ function createShutdown(config) {
|
|
|
3258
3703
|
};
|
|
3259
3704
|
}
|
|
3260
3705
|
//#endregion
|
|
3706
|
+
//#region src/init/process-safety.ts
|
|
3707
|
+
/**
|
|
3708
|
+
* Installed once per process. The singleton is process-global for the same
|
|
3709
|
+
* reason (see `singleton.ts`): more than one copy of this module can be loaded
|
|
3710
|
+
* — the image ships one at `/app/node_modules` and a project's bundle installs
|
|
3711
|
+
* another — and each copy would otherwise add its own listener.
|
|
3712
|
+
*/
|
|
3713
|
+
var HANDLER_SLOT = Symbol.for("@rebasepro/server:unhandled-rejection-handler");
|
|
3714
|
+
/**
|
|
3715
|
+
* Log unhandled promise rejections instead of letting Node terminate the
|
|
3716
|
+
* process.
|
|
3717
|
+
*
|
|
3718
|
+
* Node has terminated on an unhandled rejection since v15. Hono catches
|
|
3719
|
+
* anything thrown or rejected inside an awaited handler, so the ordinary
|
|
3720
|
+
* request path is covered — but a fire-and-forget `void syncToCrm(body)`, a
|
|
3721
|
+
* `setTimeout(async () => …)`, or an unawaited `.then()` in one custom function
|
|
3722
|
+
* rejects outside Hono's frame. The default outcome is that one project's
|
|
3723
|
+
* floating promise ends the process; on the managed runtime that process is
|
|
3724
|
+
* shared, so every other tenant on the pod goes with it, and the request log
|
|
3725
|
+
* points nowhere because the request itself succeeded.
|
|
3726
|
+
*
|
|
3727
|
+
* A logged rejection is the better trade here: the process keeps serving, and
|
|
3728
|
+
* the stack reaches the operator. `uncaughtException` is deliberately **not**
|
|
3729
|
+
* handled — a synchronous throw that escaped every frame leaves the process in
|
|
3730
|
+
* a state we cannot reason about, and swallowing it would be a worse bargain
|
|
3731
|
+
* than a restart.
|
|
3732
|
+
*
|
|
3733
|
+
* Set `REBASE_EXIT_ON_UNHANDLED_REJECTION=1` to restore Node's default.
|
|
3734
|
+
*
|
|
3735
|
+
* @returns An uninstall function (used by tests), or `undefined` when nothing
|
|
3736
|
+
* was installed.
|
|
3737
|
+
*/
|
|
3738
|
+
function installUnhandledRejectionHandler() {
|
|
3739
|
+
if (process.env.REBASE_EXIT_ON_UNHANDLED_REJECTION === "1") return void 0;
|
|
3740
|
+
const slot = globalThis;
|
|
3741
|
+
if (slot[HANDLER_SLOT]) return void 0;
|
|
3742
|
+
const handler = (reason) => {
|
|
3743
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
3744
|
+
logger.error("Unhandled promise rejection — the process kept running. This is almost always a fire-and-forget call in application code (a custom function, a cron job, a callback). Await it, or attach a .catch().", { error });
|
|
3745
|
+
};
|
|
3746
|
+
slot[HANDLER_SLOT] = handler;
|
|
3747
|
+
process.on("unhandledRejection", handler);
|
|
3748
|
+
return () => {
|
|
3749
|
+
process.removeListener("unhandledRejection", handler);
|
|
3750
|
+
delete slot[HANDLER_SLOT];
|
|
3751
|
+
};
|
|
3752
|
+
}
|
|
3753
|
+
//#endregion
|
|
3261
3754
|
//#region src/auth/collection-callback-warning.ts
|
|
3262
3755
|
/** The data callbacks the auth write path does not run. */
|
|
3263
3756
|
var DATA_CALLBACKS = [
|
|
@@ -3378,7 +3871,17 @@ function buildQueryString(params) {
|
|
|
3378
3871
|
const wire = serializeOrderBy(params.orderBy);
|
|
3379
3872
|
if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
|
|
3380
3873
|
}
|
|
3381
|
-
if (params.searchString)
|
|
3874
|
+
if (params.searchString) {
|
|
3875
|
+
parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
|
|
3876
|
+
if (params.searchExplain) parts.push("searchExplain=true");
|
|
3877
|
+
}
|
|
3878
|
+
if (params.vectorSearch) {
|
|
3879
|
+
const vs = params.vectorSearch;
|
|
3880
|
+
parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
|
|
3881
|
+
parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
|
|
3882
|
+
if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
|
|
3883
|
+
if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
|
|
3884
|
+
}
|
|
3382
3885
|
if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
|
|
3383
3886
|
if (params.logical) {
|
|
3384
3887
|
const root = params.logical;
|
|
@@ -3418,6 +3921,13 @@ function resolveBaseUrl(configured) {
|
|
|
3418
3921
|
function createTransport(config, environment) {
|
|
3419
3922
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
3420
3923
|
const apiPath = config.apiPath || "/api";
|
|
3924
|
+
for (const field of ["baseUrl", "storageUrlOrigin"]) {
|
|
3925
|
+
const value = config[field];
|
|
3926
|
+
if (!value || !apiPath) continue;
|
|
3927
|
+
const trimmed = value.replace(/\/+$/, "");
|
|
3928
|
+
if (!trimmed.endsWith(apiPath)) continue;
|
|
3929
|
+
console.warn(`[Rebase] ${field} ${JSON.stringify(value)} already ends with the API path ${JSON.stringify(apiPath)}, which is appended to it — requests will go to ${trimmed}${apiPath}/… and 404. Pass the origin only (${JSON.stringify(trimmed.slice(0, trimmed.length - apiPath.length) || "/")}), or set \`apiPath\` if the server really does mount the API one level deeper.`);
|
|
3930
|
+
}
|
|
3421
3931
|
let token = config.token;
|
|
3422
3932
|
let tokenGetter;
|
|
3423
3933
|
let onUnauthorizedHandler = config.onUnauthorized;
|
|
@@ -3601,6 +4111,11 @@ function createAuth(transport, options) {
|
|
|
3601
4111
|
const authFlowMode = opts.authFlowMode || "json";
|
|
3602
4112
|
const STORAGE_KEY = "rebase_auth";
|
|
3603
4113
|
const REFRESH_BUFFER_MS = 12e4;
|
|
4114
|
+
/**
|
|
4115
|
+
* The largest delay `setTimeout` can hold — 2^31 - 1 ms, about 24.8 days.
|
|
4116
|
+
* Anything larger is silently clamped to 1ms by Node and every browser.
|
|
4117
|
+
*/
|
|
4118
|
+
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
3604
4119
|
const MAX_REFRESH_RETRIES = 5;
|
|
3605
4120
|
const REFRESH_RETRY_BASE_MS = 1e3;
|
|
3606
4121
|
const REFRESH_RETRY_MAX_MS = 3e4;
|
|
@@ -3628,7 +4143,9 @@ function createAuth(transport, options) {
|
|
|
3628
4143
|
function emit(event, session) {
|
|
3629
4144
|
for (const fn of listeners) try {
|
|
3630
4145
|
fn(event, session);
|
|
3631
|
-
} catch (e) {
|
|
4146
|
+
} catch (e) {
|
|
4147
|
+
console.error("Error in auth state change listener:", e);
|
|
4148
|
+
}
|
|
3632
4149
|
}
|
|
3633
4150
|
function saveSession(session) {
|
|
3634
4151
|
if (!persistSession || authFlowMode === "cookie") return;
|
|
@@ -3732,6 +4249,10 @@ function createAuth(transport, options) {
|
|
|
3732
4249
|
attemptScheduledRefresh(0);
|
|
3733
4250
|
return;
|
|
3734
4251
|
}
|
|
4252
|
+
if (delay > MAX_TIMER_DELAY_MS) {
|
|
4253
|
+
refreshTimeout = setTimeout(() => scheduleRefresh(expiresAt), MAX_TIMER_DELAY_MS);
|
|
4254
|
+
return;
|
|
4255
|
+
}
|
|
3735
4256
|
refreshTimeout = setTimeout(() => {
|
|
3736
4257
|
attemptScheduledRefresh(0);
|
|
3737
4258
|
}, delay);
|
|
@@ -4468,9 +4989,55 @@ var SDKQueryBuilder = class {
|
|
|
4468
4989
|
}
|
|
4469
4990
|
/**
|
|
4470
4991
|
* Set a free-text search string if supported by the backend.
|
|
4992
|
+
*
|
|
4993
|
+
* By default this is a substring match across the collection's top-level
|
|
4994
|
+
* string properties. A Postgres collection that declares a `search` block
|
|
4995
|
+
* gets ranked full-text matching over the fields it named instead, and each
|
|
4996
|
+
* row comes back with a `_score` you can sort on:
|
|
4997
|
+
*
|
|
4998
|
+
* ```ts
|
|
4999
|
+
* client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
|
|
5000
|
+
* ```
|
|
5001
|
+
*
|
|
5002
|
+
* Pass `{ explain: true }` to have each row report which of the declared
|
|
5003
|
+
* fields matched, with a highlighted snippet, on `_matches`:
|
|
5004
|
+
*
|
|
5005
|
+
* ```ts
|
|
5006
|
+
* const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
|
|
5007
|
+
* data[0]._matches
|
|
5008
|
+
* // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
|
|
5009
|
+
* ```
|
|
4471
5010
|
*/
|
|
4472
|
-
search(searchString) {
|
|
5011
|
+
search(searchString, options) {
|
|
4473
5012
|
this.params.searchString = searchString;
|
|
5013
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
5014
|
+
return this;
|
|
5015
|
+
}
|
|
5016
|
+
/**
|
|
5017
|
+
* Order rows by nearest-neighbour distance to `vector`.
|
|
5018
|
+
*
|
|
5019
|
+
* The server has supported this from the REST layer since vectors landed;
|
|
5020
|
+
* this is the SDK reaching it. Results come back closest-first with a
|
|
5021
|
+
* `_distance` on each row, and any `where` / `orderBy` on the same query is
|
|
5022
|
+
* a filter applied before the ordering — distance decides the order.
|
|
5023
|
+
*
|
|
5024
|
+
* You supply the query vector. Rebase stores and searches embeddings; it
|
|
5025
|
+
* does not produce them, so this is where whatever model you already use
|
|
5026
|
+
* for the stored vectors gets called.
|
|
5027
|
+
*
|
|
5028
|
+
* @param property - Name of the `vector` property to compare against.
|
|
5029
|
+
* @param vector - The query embedding. Its length must match the property's
|
|
5030
|
+
* declared `dimensions`, or the server answers 400.
|
|
5031
|
+
* @example
|
|
5032
|
+
* client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
|
|
5033
|
+
*/
|
|
5034
|
+
vectorSearch(property, vector, options) {
|
|
5035
|
+
this.params.vectorSearch = {
|
|
5036
|
+
property,
|
|
5037
|
+
vector,
|
|
5038
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
5039
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
5040
|
+
};
|
|
4474
5041
|
return this;
|
|
4475
5042
|
}
|
|
4476
5043
|
/**
|
|
@@ -4506,6 +5073,11 @@ var SDKQueryBuilder = class {
|
|
|
4506
5073
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
4507
5074
|
}
|
|
4508
5075
|
};
|
|
5076
|
+
/**
|
|
5077
|
+
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
5078
|
+
* live only for the duration of the request — see `count()` for why.
|
|
5079
|
+
*/
|
|
5080
|
+
var inflightCounts = /* @__PURE__ */ new Map();
|
|
4509
5081
|
function createCollectionClient(transport, slug, ws) {
|
|
4510
5082
|
const basePath = `/data/${slug}`;
|
|
4511
5083
|
const client = {
|
|
@@ -4550,18 +5122,63 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4550
5122
|
body: JSON.stringify({
|
|
4551
5123
|
rows: data,
|
|
4552
5124
|
...options?.upsert ? { upsert: true } : {}
|
|
4553
|
-
})
|
|
5125
|
+
}),
|
|
5126
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
4554
5127
|
})).data || [];
|
|
4555
5128
|
},
|
|
5129
|
+
/**
|
|
5130
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
5131
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
5132
|
+
*
|
|
5133
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
5134
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
5135
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
5136
|
+
* a route that does not exist and get a **404**, which is
|
|
5137
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
5138
|
+
* a missing record.
|
|
5139
|
+
*
|
|
5140
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
5141
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
5142
|
+
* supported server is one that serves both.
|
|
5143
|
+
*/
|
|
4556
5144
|
async update(id, data) {
|
|
4557
5145
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
4558
5146
|
method: "PUT",
|
|
4559
5147
|
body: JSON.stringify(data)
|
|
4560
5148
|
});
|
|
4561
5149
|
},
|
|
5150
|
+
async updateMany(updates, options) {
|
|
5151
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
5152
|
+
if (updates.length === 0) return [];
|
|
5153
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
5154
|
+
method: "PATCH",
|
|
5155
|
+
body: JSON.stringify({ updates }),
|
|
5156
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
5157
|
+
})).data || [];
|
|
5158
|
+
},
|
|
4562
5159
|
async delete(id) {
|
|
4563
5160
|
await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
|
|
4564
5161
|
},
|
|
5162
|
+
/**
|
|
5163
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
5164
|
+
*
|
|
5165
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
5166
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
5167
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
5168
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
5169
|
+
* operation, so a generated client would send the request without its
|
|
5170
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
5171
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
5172
|
+
*/
|
|
5173
|
+
async deleteMany(ids, options) {
|
|
5174
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
5175
|
+
if (ids.length === 0) return;
|
|
5176
|
+
await transport.request(`${basePath}/bulk/delete`, {
|
|
5177
|
+
method: "POST",
|
|
5178
|
+
body: JSON.stringify({ ids }),
|
|
5179
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
5180
|
+
});
|
|
5181
|
+
},
|
|
4565
5182
|
async count(params) {
|
|
4566
5183
|
const qs = buildQueryString({
|
|
4567
5184
|
...params,
|
|
@@ -4569,12 +5186,28 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4569
5186
|
offset: void 0,
|
|
4570
5187
|
include: void 0
|
|
4571
5188
|
});
|
|
4572
|
-
|
|
5189
|
+
const key = basePath + "/count" + qs;
|
|
5190
|
+
const inflight = inflightCounts.get(key);
|
|
5191
|
+
if (inflight) return inflight;
|
|
5192
|
+
const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
|
|
5193
|
+
inflightCounts.set(key, request);
|
|
5194
|
+
try {
|
|
5195
|
+
return await request;
|
|
5196
|
+
} finally {
|
|
5197
|
+
inflightCounts.delete(key);
|
|
5198
|
+
}
|
|
4573
5199
|
},
|
|
4574
5200
|
observe(params, onResult, onError, options) {
|
|
4575
5201
|
let closed = false;
|
|
4576
|
-
|
|
5202
|
+
let liveDelivered = false;
|
|
5203
|
+
let signature;
|
|
5204
|
+
const deliver = (result, fromLive) => {
|
|
4577
5205
|
if (closed) return;
|
|
5206
|
+
if (fromLive) liveDelivered = true;
|
|
5207
|
+
else if (liveDelivered) return;
|
|
5208
|
+
const next = `${result.meta?.total ?? ""}|${JSON.stringify(result.data)}`;
|
|
5209
|
+
if (signature !== void 0 && next === signature) return;
|
|
5210
|
+
signature = next;
|
|
4578
5211
|
onResult({
|
|
4579
5212
|
...result,
|
|
4580
5213
|
fromCache: false,
|
|
@@ -4582,10 +5215,10 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4582
5215
|
partial: false
|
|
4583
5216
|
});
|
|
4584
5217
|
};
|
|
4585
|
-
client.find(params).then(
|
|
5218
|
+
client.find(params).then((result) => deliver(result, false)).catch((error) => {
|
|
4586
5219
|
if (!closed) onError?.(error);
|
|
4587
5220
|
});
|
|
4588
|
-
const live = options?.realtime !== false && client.listen ? client.listen(params,
|
|
5221
|
+
const live = options?.realtime !== false && client.listen ? client.listen(params, (result) => deliver(result, true), onError) : void 0;
|
|
4589
5222
|
return () => {
|
|
4590
5223
|
closed = true;
|
|
4591
5224
|
live?.();
|
|
@@ -4593,17 +5226,24 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4593
5226
|
},
|
|
4594
5227
|
observeById(id, onResult, onError, options) {
|
|
4595
5228
|
let closed = false;
|
|
4596
|
-
|
|
5229
|
+
let liveDelivered = false;
|
|
5230
|
+
let signature;
|
|
5231
|
+
const deliver = (row, fromLive) => {
|
|
4597
5232
|
if (closed) return;
|
|
5233
|
+
if (fromLive) liveDelivered = true;
|
|
5234
|
+
else if (liveDelivered) return;
|
|
5235
|
+
const next = row === void 0 ? "\0missing" : JSON.stringify(row);
|
|
5236
|
+
if (signature !== void 0 && next === signature) return;
|
|
5237
|
+
signature = next;
|
|
4598
5238
|
onResult(row, {
|
|
4599
5239
|
fromCache: false,
|
|
4600
5240
|
hasPendingWrites: false
|
|
4601
5241
|
});
|
|
4602
5242
|
};
|
|
4603
|
-
client.findById(id).then(
|
|
5243
|
+
client.findById(id).then((row) => deliver(row, false)).catch((error) => {
|
|
4604
5244
|
if (!closed) onError?.(error);
|
|
4605
5245
|
});
|
|
4606
|
-
const live = options?.realtime !== false && client.listenById ? client.listenById(id,
|
|
5246
|
+
const live = options?.realtime !== false && client.listenById ? client.listenById(id, (row) => deliver(row, true), onError) : void 0;
|
|
4607
5247
|
return () => {
|
|
4608
5248
|
closed = true;
|
|
4609
5249
|
live?.();
|
|
@@ -4623,8 +5263,11 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4623
5263
|
offset(count) {
|
|
4624
5264
|
return new SDKQueryBuilder(client).offset(count);
|
|
4625
5265
|
},
|
|
4626
|
-
search(searchString) {
|
|
4627
|
-
return new SDKQueryBuilder(client).search(searchString);
|
|
5266
|
+
search(searchString, options) {
|
|
5267
|
+
return new SDKQueryBuilder(client).search(searchString, options);
|
|
5268
|
+
},
|
|
5269
|
+
vectorSearch(property, vector, options) {
|
|
5270
|
+
return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
|
|
4628
5271
|
},
|
|
4629
5272
|
include(...relations) {
|
|
4630
5273
|
return new SDKQueryBuilder(client).include(...relations);
|
|
@@ -4634,51 +5277,44 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4634
5277
|
client.listen = (params, onUpdate, onError) => {
|
|
4635
5278
|
let active = true;
|
|
4636
5279
|
let lastUpdateId = 0;
|
|
5280
|
+
let lastKnownTotal;
|
|
5281
|
+
const window = resolveFindWindow(params);
|
|
4637
5282
|
const unsub = ws.listenCollection({
|
|
4638
5283
|
path: slug,
|
|
4639
5284
|
filter: params?.where,
|
|
5285
|
+
logical: params?.logical,
|
|
4640
5286
|
limit: params?.limit,
|
|
4641
|
-
|
|
5287
|
+
offset: window.driverOffset,
|
|
4642
5288
|
orderBy: params?.orderBy?.[0],
|
|
4643
5289
|
order: params?.orderBy?.[1],
|
|
4644
|
-
searchString: params?.searchString
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
const
|
|
4648
|
-
const
|
|
4649
|
-
const
|
|
4650
|
-
const
|
|
4651
|
-
const
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
data: rows,
|
|
4655
|
-
meta: {
|
|
4656
|
-
total,
|
|
4657
|
-
limit: requestedLimit,
|
|
4658
|
-
offset,
|
|
4659
|
-
hasMore: offset + rows.length < total
|
|
4660
|
-
}
|
|
4661
|
-
});
|
|
4662
|
-
}).catch(() => {
|
|
4663
|
-
if (active && currentUpdateId === lastUpdateId) onUpdate({
|
|
5290
|
+
searchString: params?.searchString,
|
|
5291
|
+
searchExplain: params?.searchExplain
|
|
5292
|
+
}, (incomingRows) => {
|
|
5293
|
+
const currentUpdateId = ++lastUpdateId;
|
|
5294
|
+
const requestedLimit = window.limit;
|
|
5295
|
+
const offset = window.offset;
|
|
5296
|
+
const rows = incomingRows;
|
|
5297
|
+
const emit = (total, hasMore) => {
|
|
5298
|
+
if (!active || currentUpdateId !== lastUpdateId) return;
|
|
5299
|
+
onUpdate({
|
|
4664
5300
|
data: rows,
|
|
4665
5301
|
meta: {
|
|
4666
|
-
total
|
|
5302
|
+
total,
|
|
4667
5303
|
limit: requestedLimit,
|
|
4668
5304
|
offset,
|
|
4669
|
-
hasMore
|
|
5305
|
+
hasMore
|
|
4670
5306
|
}
|
|
4671
5307
|
});
|
|
5308
|
+
};
|
|
5309
|
+
const emitWithoutCount = () => emit(offset + rows.length, rows.length >= requestedLimit);
|
|
5310
|
+
if (client.count) client.count(params).then((total) => {
|
|
5311
|
+
lastKnownTotal = total;
|
|
5312
|
+
emit(total, offset + rows.length < total);
|
|
5313
|
+
}).catch(() => {
|
|
5314
|
+
if (lastKnownTotal !== void 0) emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
|
|
5315
|
+
else emitWithoutCount();
|
|
4672
5316
|
});
|
|
4673
|
-
else
|
|
4674
|
-
data: rows,
|
|
4675
|
-
meta: {
|
|
4676
|
-
total: heuristicTotal,
|
|
4677
|
-
limit: requestedLimit,
|
|
4678
|
-
offset,
|
|
4679
|
-
hasMore: heuristicHasMore
|
|
4680
|
-
}
|
|
4681
|
-
});
|
|
5317
|
+
else emitWithoutCount();
|
|
4682
5318
|
}, onError);
|
|
4683
5319
|
return () => {
|
|
4684
5320
|
active = false;
|
|
@@ -5446,6 +6082,11 @@ var RebaseWebSocketClient = class {
|
|
|
5446
6082
|
callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
|
|
5447
6083
|
}
|
|
5448
6084
|
} else callback.onUpdate(message);
|
|
6085
|
+
return;
|
|
6086
|
+
}
|
|
6087
|
+
if (type === "ERROR" || type === "error" || message.error) {
|
|
6088
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
6089
|
+
console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
|
|
5449
6090
|
}
|
|
5450
6091
|
}
|
|
5451
6092
|
async ensureAuthenticated(retryCount = 3) {
|
|
@@ -6061,15 +6702,10 @@ var RebaseWebSocketClient = class {
|
|
|
6061
6702
|
}
|
|
6062
6703
|
}
|
|
6063
6704
|
createCollectionSubscriptionKey(props) {
|
|
6705
|
+
const { collection, ...query } = props;
|
|
6064
6706
|
const key = {
|
|
6065
|
-
|
|
6066
|
-
|
|
6067
|
-
limit: props.limit,
|
|
6068
|
-
startAfter: props.startAfter,
|
|
6069
|
-
orderBy: props.orderBy,
|
|
6070
|
-
order: props.order,
|
|
6071
|
-
searchString: props.searchString,
|
|
6072
|
-
collection: props.collection?.name
|
|
6707
|
+
...query,
|
|
6708
|
+
collection: collection?.name
|
|
6073
6709
|
};
|
|
6074
6710
|
return JSON.stringify(key, (_, value) => {
|
|
6075
6711
|
if (value && typeof value === "object" && !Array.isArray(value)) return Object.keys(value).sort().reduce((sorted, k) => {
|
|
@@ -6557,11 +7193,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
|
6557
7193
|
503,
|
|
6558
7194
|
504
|
|
6559
7195
|
]);
|
|
7196
|
+
/**
|
|
7197
|
+
* The server holds this key for a request it has not answered yet.
|
|
7198
|
+
*
|
|
7199
|
+
* It is a 409 like a duplicate row is a 409, and nothing but the code separates
|
|
7200
|
+
* them — one means "your write is already there", the other means "your write
|
|
7201
|
+
* may not have happened at all, ask again".
|
|
7202
|
+
*/
|
|
7203
|
+
var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
7204
|
+
/**
|
|
7205
|
+
* Is the server still answering an earlier attempt of this same write?
|
|
7206
|
+
*
|
|
7207
|
+
* The only correct response is to ask again — which is exactly what the
|
|
7208
|
+
* server's own message says, and exactly what this SDK used not to do.
|
|
7209
|
+
*/
|
|
7210
|
+
function isIdempotencyInProgressError(error) {
|
|
7211
|
+
return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
7212
|
+
}
|
|
6560
7213
|
/** Is this failure worth another attempt later? */
|
|
6561
7214
|
function isRetryableError(error) {
|
|
6562
7215
|
if (isNetworkError(error)) return true;
|
|
6563
|
-
if (error instanceof RebaseApiError) return
|
|
6564
|
-
return
|
|
7216
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
7217
|
+
if (isIdempotencyInProgressError(error)) return true;
|
|
7218
|
+
return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
|
|
6565
7219
|
}
|
|
6566
7220
|
/**
|
|
6567
7221
|
* Did this write fail because the row is already there?
|
|
@@ -6573,10 +7227,16 @@ function isRetryableError(error) {
|
|
|
6573
7227
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
6574
7228
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
6575
7229
|
* the server can only be rejecting it because the first attempt actually landed.
|
|
7230
|
+
*
|
|
7231
|
+
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
7232
|
+
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
7233
|
+
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
7234
|
+
* concluded there was nothing left to do and deleted the write from the queue.
|
|
6576
7235
|
*/
|
|
6577
7236
|
function isDuplicateKeyError(error) {
|
|
6578
7237
|
if (!(error instanceof RebaseApiError)) return false;
|
|
6579
|
-
|
|
7238
|
+
if (error.code === "23505") return true;
|
|
7239
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
6580
7240
|
}
|
|
6581
7241
|
var ConnectivityMonitor = class {
|
|
6582
7242
|
state = "online";
|
|
@@ -6982,14 +7642,23 @@ function looseEquals(a, b) {
|
|
|
6982
7642
|
*/
|
|
6983
7643
|
function likeToRegExp(pattern, caseInsensitive) {
|
|
6984
7644
|
let source = "^";
|
|
7645
|
+
let lastWasWildcard = false;
|
|
6985
7646
|
for (let i = 0; i < pattern.length; i++) {
|
|
6986
7647
|
const char = pattern[i];
|
|
6987
7648
|
if (char === "\\" && i + 1 < pattern.length) {
|
|
6988
7649
|
source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6989
7650
|
i++;
|
|
6990
|
-
|
|
6991
|
-
else if (char === "
|
|
6992
|
-
|
|
7651
|
+
lastWasWildcard = false;
|
|
7652
|
+
} else if (char === "%") {
|
|
7653
|
+
if (!lastWasWildcard) source += "[\\s\\S]*";
|
|
7654
|
+
lastWasWildcard = true;
|
|
7655
|
+
} else if (char === "_") {
|
|
7656
|
+
source += "[\\s\\S]";
|
|
7657
|
+
lastWasWildcard = false;
|
|
7658
|
+
} else {
|
|
7659
|
+
source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7660
|
+
lastWasWildcard = false;
|
|
7661
|
+
}
|
|
6993
7662
|
}
|
|
6994
7663
|
return new RegExp(source + "$", caseInsensitive ? "i" : "");
|
|
6995
7664
|
}
|
|
@@ -7121,14 +7790,41 @@ function sortRows(rows, orderBy) {
|
|
|
7121
7790
|
function tiebreak(a, b) {
|
|
7122
7791
|
return compareValues(a.id, b.id) ?? 0;
|
|
7123
7792
|
}
|
|
7124
|
-
/**
|
|
7793
|
+
/**
|
|
7794
|
+
* Resolve `page`/`offset`/`limit` the way the server does.
|
|
7795
|
+
*
|
|
7796
|
+
* It did not: this defaulted an absent limit to 20 while `/api/data` pages by
|
|
7797
|
+
* 50, so the same `observe()` answered with 20 rows from the local database and
|
|
7798
|
+
* 50 from the network — a list that changed length depending on which side
|
|
7799
|
+
* answered, with `page` striding differently on each. Delegated now, so the
|
|
7800
|
+
* sentence above is true by construction rather than by agreement.
|
|
7801
|
+
*/
|
|
7125
7802
|
function resolvePagination(params) {
|
|
7126
|
-
const limit = params
|
|
7803
|
+
const { limit, offset } = resolveFindWindow(params);
|
|
7127
7804
|
return {
|
|
7128
7805
|
limit,
|
|
7129
|
-
offset
|
|
7806
|
+
offset
|
|
7130
7807
|
};
|
|
7131
7808
|
}
|
|
7809
|
+
/** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
|
|
7810
|
+
var ORDERING_OPS = /* @__PURE__ */ new Set([
|
|
7811
|
+
"<",
|
|
7812
|
+
"<=",
|
|
7813
|
+
">",
|
|
7814
|
+
">="
|
|
7815
|
+
]);
|
|
7816
|
+
/** Does any condition in this `where` clause order its operands? */
|
|
7817
|
+
function whereOrders(where) {
|
|
7818
|
+
if (!where) return false;
|
|
7819
|
+
for (const condition of Object.values(where)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
|
|
7820
|
+
return false;
|
|
7821
|
+
}
|
|
7822
|
+
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
7823
|
+
function logicalOrders(condition, depth = 0) {
|
|
7824
|
+
if (!condition || depth > 32) return false;
|
|
7825
|
+
if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
|
|
7826
|
+
return ORDERING_OPS.has(condition.operator);
|
|
7827
|
+
}
|
|
7132
7828
|
/**
|
|
7133
7829
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
7134
7830
|
* assuming the cache holds every row of the collection?
|
|
@@ -7136,11 +7832,67 @@ function resolvePagination(params) {
|
|
|
7136
7832
|
* `include` pulls in rows from other collections that this evaluator never
|
|
7137
7833
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
7138
7834
|
* best effort rather than an equivalent one.
|
|
7835
|
+
*
|
|
7836
|
+
* **Ordering comparisons are refused, and that is the interesting one.**
|
|
7837
|
+
* `compareValues` falls back to an `Intl.Collator` for operands it cannot read
|
|
7838
|
+
* as numbers or instants. PostgreSQL orders text by the *database's* collation,
|
|
7839
|
+
* which is a property of the server this process has never been told: under the
|
|
7840
|
+
* C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
|
|
7841
|
+
* and the collator says true. So `["<", "Banana"]` selects a different set here
|
|
7842
|
+
* than it does there — silently, and in whichever direction the deployment
|
|
7843
|
+
* happens to have been created.
|
|
7844
|
+
*
|
|
7845
|
+
* The refusal covers *every* ordering comparison rather than only the ones with
|
|
7846
|
+
* a string operand, because the operand type does not settle it: a numeric
|
|
7847
|
+
* bound against a text column (`["<", 10]` on a `varchar`) also reaches the
|
|
7848
|
+
* collator, and nothing in `params` says what the column holds. Conservative on
|
|
7849
|
+
* purpose — the cost is that a query combining an ordering filter with
|
|
7850
|
+
* *unsynced local writes* stops placing those writes optimistically, which is a
|
|
7851
|
+
* degraded answer rather than a wrong one. Claiming exactness we do not have is
|
|
7852
|
+
* the other way round.
|
|
7853
|
+
*
|
|
7854
|
+
* This says nothing about ordering *results*; that is a separate claim with a
|
|
7855
|
+
* separate answer, because a sort changes which rows come first and not which
|
|
7856
|
+
* rows match. See {@link isLocallySortable}.
|
|
7139
7857
|
*/
|
|
7140
7858
|
function isExactlyEvaluable(params) {
|
|
7141
7859
|
if (!params) return true;
|
|
7142
7860
|
if (params.include && params.include.length > 0) return false;
|
|
7143
7861
|
if (params.searchString) return false;
|
|
7862
|
+
if (params.vectorSearch) return false;
|
|
7863
|
+
if (whereOrders(params.where)) return false;
|
|
7864
|
+
if (logicalOrders(params.logical)) return false;
|
|
7865
|
+
return true;
|
|
7866
|
+
}
|
|
7867
|
+
/**
|
|
7868
|
+
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
7869
|
+
*
|
|
7870
|
+
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
7871
|
+
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
7872
|
+
* collator only when it cannot read both operands as numbers, and `toComparable`
|
|
7873
|
+
* has already turned dates and relations into numbers and ids by then. If every
|
|
7874
|
+
* value on the sort column normalises to a number, the collator is unreachable
|
|
7875
|
+
* and the local order is the server's order.
|
|
7876
|
+
*
|
|
7877
|
+
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
7878
|
+
* the two cannot be made to agree — and so is a column this page happens to see
|
|
7879
|
+
* only as strings, which is the same thing from here.
|
|
7880
|
+
*
|
|
7881
|
+
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
7882
|
+
* ascending, first descending) that matches Postgres and never reaches the
|
|
7883
|
+
* comparator.
|
|
7884
|
+
*/
|
|
7885
|
+
function isLocallySortable(rows, orderBy) {
|
|
7886
|
+
if (!orderBy) return true;
|
|
7887
|
+
const [field] = orderBy;
|
|
7888
|
+
for (const row of rows) {
|
|
7889
|
+
const value = toComparable(row[field]);
|
|
7890
|
+
if (isNullish(value)) continue;
|
|
7891
|
+
if (typeof value === "number" || typeof value === "boolean") continue;
|
|
7892
|
+
if (typeof value === "bigint") continue;
|
|
7893
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
|
|
7894
|
+
return false;
|
|
7895
|
+
}
|
|
7144
7896
|
return true;
|
|
7145
7897
|
}
|
|
7146
7898
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
@@ -7170,6 +7922,17 @@ function generateOfflineId() {
|
|
|
7170
7922
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
7171
7923
|
}
|
|
7172
7924
|
var MISSING = "\0missing";
|
|
7925
|
+
/**
|
|
7926
|
+
* Replays to spend on a mutation whose idempotency key the server is still
|
|
7927
|
+
* holding, when the app has not asked for more.
|
|
7928
|
+
*
|
|
7929
|
+
* Retries double from a second and cap at the sync interval, so the default
|
|
7930
|
+
* budget of five covers about half a minute — less than the lease a server
|
|
7931
|
+
* gives a claim nobody came back for. This many outlast it with room for a slow
|
|
7932
|
+
* batch, and the count is what stops a server that never releases the key from
|
|
7933
|
+
* blocking the queue behind it indefinitely.
|
|
7934
|
+
*/
|
|
7935
|
+
var IN_PROGRESS_MIN_RETRIES = 12;
|
|
7173
7936
|
var OfflineManager = class {
|
|
7174
7937
|
store;
|
|
7175
7938
|
maxCachedQueries;
|
|
@@ -7450,6 +8213,72 @@ var OfflineManager = class {
|
|
|
7450
8213
|
this.notifyCollection(slug);
|
|
7451
8214
|
return rows;
|
|
7452
8215
|
},
|
|
8216
|
+
updateMany: async (updates, options) => {
|
|
8217
|
+
await this.ensureCollection(slug);
|
|
8218
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
8219
|
+
if (updates.length === 0) return [];
|
|
8220
|
+
const anyPending = updates.some((u) => this.hasPending(slug, u.id));
|
|
8221
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
8222
|
+
const rows = await inner.updateMany(updates, options);
|
|
8223
|
+
this.connectivity.markSuccess();
|
|
8224
|
+
await this.ingest(slug, rows);
|
|
8225
|
+
this.notifyCollection(slug);
|
|
8226
|
+
return rows;
|
|
8227
|
+
} catch (error) {
|
|
8228
|
+
if (!isNetworkError(error)) throw error;
|
|
8229
|
+
this.connectivity.markFailure();
|
|
8230
|
+
}
|
|
8231
|
+
const rollback = {};
|
|
8232
|
+
const optimistic = [];
|
|
8233
|
+
for (const { id, data } of updates) {
|
|
8234
|
+
const base = this.rawLocalRow(slug, id);
|
|
8235
|
+
rollback[String(id)] = base ?? null;
|
|
8236
|
+
optimistic.push({
|
|
8237
|
+
...base ?? {},
|
|
8238
|
+
...data,
|
|
8239
|
+
id
|
|
8240
|
+
});
|
|
8241
|
+
}
|
|
8242
|
+
await this.enqueue({
|
|
8243
|
+
collection: slug,
|
|
8244
|
+
type: "updateMany",
|
|
8245
|
+
updates: updates.map((u) => ({
|
|
8246
|
+
id: u.id,
|
|
8247
|
+
data: u.data
|
|
8248
|
+
})),
|
|
8249
|
+
rollback: { rows: rollback }
|
|
8250
|
+
});
|
|
8251
|
+
for (const row of optimistic) this.setLocalRow(slug, row.id, row);
|
|
8252
|
+
this.notifyCollection(slug);
|
|
8253
|
+
return optimistic;
|
|
8254
|
+
},
|
|
8255
|
+
deleteMany: async (ids, options) => {
|
|
8256
|
+
await this.ensureCollection(slug);
|
|
8257
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
8258
|
+
if (ids.length === 0) return;
|
|
8259
|
+
const anyPending = ids.some((id) => this.hasPending(slug, id));
|
|
8260
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
8261
|
+
await inner.deleteMany(ids, options);
|
|
8262
|
+
this.connectivity.markSuccess();
|
|
8263
|
+
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
8264
|
+
this.notifyCollection(slug);
|
|
8265
|
+
this.scheduleRefresh(slug);
|
|
8266
|
+
return;
|
|
8267
|
+
} catch (error) {
|
|
8268
|
+
if (!isNetworkError(error)) throw error;
|
|
8269
|
+
this.connectivity.markFailure();
|
|
8270
|
+
}
|
|
8271
|
+
const rollback = {};
|
|
8272
|
+
for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
|
|
8273
|
+
await this.enqueue({
|
|
8274
|
+
collection: slug,
|
|
8275
|
+
type: "deleteMany",
|
|
8276
|
+
ids,
|
|
8277
|
+
rollback: { rows: rollback }
|
|
8278
|
+
});
|
|
8279
|
+
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
8280
|
+
this.notifyCollection(slug);
|
|
8281
|
+
},
|
|
7453
8282
|
update: async (id, data) => {
|
|
7454
8283
|
await this.ensureCollection(slug);
|
|
7455
8284
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
@@ -7528,7 +8357,8 @@ var OfflineManager = class {
|
|
|
7528
8357
|
orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
|
|
7529
8358
|
limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
|
|
7530
8359
|
offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
|
|
7531
|
-
search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
|
|
8360
|
+
search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
|
|
8361
|
+
vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
|
|
7532
8362
|
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
|
|
7533
8363
|
};
|
|
7534
8364
|
if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
@@ -7748,9 +8578,8 @@ var OfflineManager = class {
|
|
|
7748
8578
|
if (!state) return {
|
|
7749
8579
|
data: [],
|
|
7750
8580
|
meta: {
|
|
8581
|
+
...resolvePagination(params),
|
|
7751
8582
|
total: 0,
|
|
7752
|
-
limit: params?.limit ?? 20,
|
|
7753
|
-
offset: params?.offset ?? 0,
|
|
7754
8583
|
hasMore: false
|
|
7755
8584
|
},
|
|
7756
8585
|
fromCache: true,
|
|
@@ -7786,16 +8615,15 @@ var OfflineManager = class {
|
|
|
7786
8615
|
}
|
|
7787
8616
|
let added = 0;
|
|
7788
8617
|
const offset = snapshot.offset ?? 0;
|
|
7789
|
-
if (exact && offset === 0) {
|
|
7790
|
-
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
added++;
|
|
7796
|
-
}
|
|
7797
|
-
if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
|
|
8618
|
+
if (exact && offset === 0) for (const [key, entry] of state.rows) {
|
|
8619
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
8620
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
8621
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
8622
|
+
rows.push(entry.row);
|
|
8623
|
+
added++;
|
|
7798
8624
|
}
|
|
8625
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
8626
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
7799
8627
|
return {
|
|
7800
8628
|
data: rows,
|
|
7801
8629
|
meta: {
|
|
@@ -7806,7 +8634,7 @@ var OfflineManager = class {
|
|
|
7806
8634
|
},
|
|
7807
8635
|
fromCache,
|
|
7808
8636
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
7809
|
-
partial: !exact
|
|
8637
|
+
partial: !exact || !orderIsLocal
|
|
7810
8638
|
};
|
|
7811
8639
|
}
|
|
7812
8640
|
localFind(slug, params) {
|
|
@@ -7942,17 +8770,17 @@ var OfflineManager = class {
|
|
|
7942
8770
|
return row;
|
|
7943
8771
|
}
|
|
7944
8772
|
recordSnapshot(slug, params, result) {
|
|
8773
|
+
const window = resolvePagination(params);
|
|
7945
8774
|
const meta = result.meta ?? {
|
|
7946
8775
|
total: result.data?.length ?? 0,
|
|
7947
|
-
|
|
7948
|
-
offset: 0,
|
|
8776
|
+
...window,
|
|
7949
8777
|
hasMore: false
|
|
7950
8778
|
};
|
|
7951
8779
|
const snapshot = {
|
|
7952
8780
|
ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
|
|
7953
8781
|
total: meta.total ?? result.data?.length ?? 0,
|
|
7954
|
-
limit: meta.limit ??
|
|
7955
|
-
offset: meta.offset ??
|
|
8782
|
+
limit: meta.limit ?? window.limit,
|
|
8783
|
+
offset: meta.offset ?? window.offset,
|
|
7956
8784
|
hasMore: meta.hasMore ?? false
|
|
7957
8785
|
};
|
|
7958
8786
|
const state = this.collectionState(slug);
|
|
@@ -8121,7 +8949,8 @@ var OfflineManager = class {
|
|
|
8121
8949
|
}
|
|
8122
8950
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
8123
8951
|
op.lastError = error?.message ?? String(error);
|
|
8124
|
-
|
|
8952
|
+
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
8953
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
8125
8954
|
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
8126
8955
|
this.connectivity.deferRetry();
|
|
8127
8956
|
this.patchStatus({ lastError: op.lastError });
|
|
@@ -8167,11 +8996,25 @@ var OfflineManager = class {
|
|
|
8167
8996
|
await this.adoptServerRow(op, op.id, row);
|
|
8168
8997
|
} else if (op.type === "createMany") {
|
|
8169
8998
|
const queued = op.data ?? [];
|
|
8170
|
-
const rows = await inner.createMany(queued,
|
|
8999
|
+
const rows = await inner.createMany(queued, {
|
|
9000
|
+
...op.upsert ? { upsert: true } : {},
|
|
9001
|
+
idempotencyKey: op.mutationId
|
|
9002
|
+
});
|
|
8171
9003
|
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
9004
|
+
} else if (op.type === "updateMany") {
|
|
9005
|
+
const queued = op.updates ?? [];
|
|
9006
|
+
const rows = await inner.updateMany(queued.map((u) => ({
|
|
9007
|
+
id: u.id,
|
|
9008
|
+
data: u.data
|
|
9009
|
+
})), { idempotencyKey: op.mutationId });
|
|
9010
|
+
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
8172
9011
|
} else if (op.type === "update") {
|
|
8173
9012
|
const row = await inner.update(op.id, op.data);
|
|
8174
9013
|
await this.ingestReplaced(op, op.id, row);
|
|
9014
|
+
} else if (op.type === "deleteMany") {
|
|
9015
|
+
const ids = op.ids ?? [];
|
|
9016
|
+
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
9017
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
8175
9018
|
} else if (op.type === "delete") {
|
|
8176
9019
|
await inner.delete(op.id);
|
|
8177
9020
|
this.removeLocalRow(op.collection, op.id, true);
|
|
@@ -8414,23 +9257,37 @@ var OfflineManager = class {
|
|
|
8414
9257
|
/**
|
|
8415
9258
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
8416
9259
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
9260
|
+
*
|
|
9261
|
+
* A backend mounted under a path is the reason `baseUrl` accepts one, so the
|
|
9262
|
+
* path is kept. It used to be kept for an absolute `baseUrl` and dropped for a
|
|
9263
|
+
* relative one — resolved through `.origin` — so one deployment dialled two
|
|
9264
|
+
* different sockets depending on whether its config said `"/backend"` or
|
|
9265
|
+
* `"https://app.example.com/backend"`.
|
|
9266
|
+
*
|
|
9267
|
+
* Returns `""` when there is nothing to resolve against: a relative `baseUrl`
|
|
9268
|
+
* outside a browser has no origin, and inventing one would dial somewhere
|
|
9269
|
+
* arbitrary. The caller warns rather than leaving that silent.
|
|
8417
9270
|
*/
|
|
8418
9271
|
function deriveWebSocketUrl(baseUrl) {
|
|
9272
|
+
const toWsProtocol = (url) => {
|
|
9273
|
+
const secure = /^(https|wss):/i.test(url);
|
|
9274
|
+
return url.replace(/^https?:\/\//i, secure ? "wss://" : "ws://").replace(/^wss?:\/\//i, secure ? "wss://" : "ws://").replace(/\/$/, "");
|
|
9275
|
+
};
|
|
8419
9276
|
if (typeof window !== "undefined") {
|
|
8420
|
-
let absoluteUrl
|
|
9277
|
+
let absoluteUrl;
|
|
8421
9278
|
if (!baseUrl) absoluteUrl = window.location.origin;
|
|
8422
9279
|
else if (/^https?:\/\//i.test(baseUrl) || /^wss?:\/\//i.test(baseUrl)) absoluteUrl = baseUrl;
|
|
8423
9280
|
else try {
|
|
8424
|
-
|
|
9281
|
+
const resolved = new URL(baseUrl, window.location.href);
|
|
9282
|
+
absoluteUrl = resolved.origin + resolved.pathname;
|
|
8425
9283
|
} catch {
|
|
8426
9284
|
absoluteUrl = window.location.origin;
|
|
8427
9285
|
}
|
|
8428
|
-
|
|
8429
|
-
return absoluteUrl.replace(/^https?:\/\//i, `${protocol}//`).replace(/^wss?:\/\//i, `${protocol}//`).replace(/\/$/, "");
|
|
9286
|
+
return toWsProtocol(absoluteUrl);
|
|
8430
9287
|
}
|
|
8431
9288
|
if (!baseUrl) return "";
|
|
8432
9289
|
if (!/^https?:\/\//i.test(baseUrl) && !/^wss?:\/\//i.test(baseUrl)) return "";
|
|
8433
|
-
return baseUrl
|
|
9290
|
+
return toWsProtocol(baseUrl);
|
|
8434
9291
|
}
|
|
8435
9292
|
function createRebaseClient(options) {
|
|
8436
9293
|
const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
|
|
@@ -8458,7 +9315,11 @@ function createRebaseClient(options) {
|
|
|
8458
9315
|
});
|
|
8459
9316
|
return storageSourcesPromise;
|
|
8460
9317
|
};
|
|
8461
|
-
const
|
|
9318
|
+
const realtimeEnabled = options.realtime !== false;
|
|
9319
|
+
const resolvedWsUrl = realtimeEnabled ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
9320
|
+
const realtimeUnreachable = realtimeEnabled && !resolvedWsUrl;
|
|
9321
|
+
const unreachableReason = `no WebSocket URL could be derived from baseUrl ${JSON.stringify(options.baseUrl ?? null)} — outside a browser there is no page origin to resolve a relative URL against. Pass an absolute \`baseUrl\`, set \`websocketUrl\` explicitly, or pass \`realtime: false\` to say this was intended.`;
|
|
9322
|
+
if (realtimeUnreachable) console.warn(`[Rebase] Realtime is enabled but ${unreachableReason} Live queries will fall back to a single fetch and channels will throw.`);
|
|
8462
9323
|
let ws;
|
|
8463
9324
|
/** One channel object per name — see `realtime.channel`. */
|
|
8464
9325
|
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
@@ -8578,7 +9439,7 @@ function createRebaseClient(options) {
|
|
|
8578
9439
|
* cut off the others.
|
|
8579
9440
|
*/
|
|
8580
9441
|
channel: (name, options) => {
|
|
8581
|
-
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
9442
|
+
if (!ws) throw new RebaseClientError(realtimeUnreachable ? `Realtime is enabled but ${unreachableReason}` : "Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
8582
9443
|
let existing = realtimeChannels.get(name);
|
|
8583
9444
|
if (!existing) {
|
|
8584
9445
|
existing = new RebaseRealtimeChannel(name, ws, options);
|
|
@@ -8621,39 +9482,88 @@ channel: (name, options) => {
|
|
|
8621
9482
|
}
|
|
8622
9483
|
//#endregion
|
|
8623
9484
|
//#region src/history/history-routes.ts
|
|
9485
|
+
/** Entries returned when the caller names no `?limit`. */
|
|
9486
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
9487
|
+
/** Most entries one history read will serve. Above it the request is refused. */
|
|
9488
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
8624
9489
|
function createHistoryRoutes(params) {
|
|
8625
|
-
const { historyService, registry
|
|
9490
|
+
const { historyService, registry } = params;
|
|
8626
9491
|
const router = new Hono();
|
|
8627
9492
|
router.onError(errorHandler);
|
|
8628
9493
|
/**
|
|
9494
|
+
* Both halves of the authorization the REST generator applies to every
|
|
9495
|
+
* other route on this router, for the row whose history is being asked for.
|
|
9496
|
+
*
|
|
9497
|
+
* `historyService` reads `rebase.entity_history` through the *privileged*
|
|
9498
|
+
* handle the bootstrapper built — not `c.get("driver")` — and it has to:
|
|
9499
|
+
* history is one table shadowing every audited collection, so it cannot
|
|
9500
|
+
* carry their row policies, and `ensure-history-table` revokes it from
|
|
9501
|
+
* `rebase_user` outright for exactly that reason. Nothing about the read is
|
|
9502
|
+
* therefore scoped to the caller. That makes this function the *entire*
|
|
9503
|
+
* access-control model for the route, the same way `storageAuthorize` is
|
|
9504
|
+
* for storage.
|
|
9505
|
+
*
|
|
9506
|
+
* It asks the two questions the data API already answers, in its order:
|
|
9507
|
+
*
|
|
9508
|
+
* - the API key's permission list, via the same predicate
|
|
9509
|
+
* `RestApiGenerator.enforceApiKeyPermission` uses, and keyed on the
|
|
9510
|
+
* request's own method so that reverting needs `write` and listing needs
|
|
9511
|
+
* `read` — a key scoped to no collection, or to a different one, or to
|
|
9512
|
+
* reading only, must not get past this;
|
|
9513
|
+
* - RLS, by fetching the row itself through the request-scoped driver.
|
|
9514
|
+
* A row the caller may not read comes back undefined, and its history is
|
|
9515
|
+
* then a 404 rather than a 403, because a 403 confirms the row exists.
|
|
9516
|
+
*
|
|
9517
|
+
* A missing scoped driver is an internal error, never a fallback to the
|
|
9518
|
+
* unscoped one — the same rule, and the same reason, as
|
|
9519
|
+
* {@link RestApiGenerator.getScopedDriver}.
|
|
9520
|
+
*/
|
|
9521
|
+
async function authorizeEntityRead(c, collection, id) {
|
|
9522
|
+
const apiKey = c.get("apiKey");
|
|
9523
|
+
const operation = httpMethodToOperation(c.req.method);
|
|
9524
|
+
if (apiKey && !isOperationAllowed(apiKey.permissions, collection.slug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collection.slug}"`, "API_KEY_FORBIDDEN");
|
|
9525
|
+
const scoped = c.get("driver");
|
|
9526
|
+
if (!scoped) throw ApiError.internal("Scoped driver not available");
|
|
9527
|
+
if (!await scoped.fetchOne({
|
|
9528
|
+
path: collection.slug,
|
|
9529
|
+
id,
|
|
9530
|
+
collection
|
|
9531
|
+
})) throw ApiError.notFound(`Entity '${id}' not found in collection '${collection.slug}'`);
|
|
9532
|
+
}
|
|
9533
|
+
/**
|
|
8629
9534
|
* GET /:slug/:id/history - List history entries for a entity
|
|
8630
9535
|
*
|
|
8631
9536
|
* Query params:
|
|
8632
|
-
* limit (default 20)
|
|
9537
|
+
* limit (default 20, maximum 100 — above that the request is refused)
|
|
8633
9538
|
* offset (default 0)
|
|
8634
9539
|
*/
|
|
8635
9540
|
router.get("/:slug/:id/history", async (c) => {
|
|
8636
9541
|
const slug = c.req.param("slug");
|
|
8637
9542
|
const id = c.req.param("id");
|
|
8638
|
-
const
|
|
9543
|
+
const limit = resolveListLimitParam(c.req.query("limit"), {
|
|
9544
|
+
defaultLimit: HISTORY_DEFAULT_LIMIT,
|
|
9545
|
+
maxLimit: HISTORY_MAX_LIMIT
|
|
9546
|
+
});
|
|
8639
9547
|
const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
|
|
8640
|
-
const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
|
|
8641
9548
|
const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
|
|
8642
9549
|
const collection = registry.getCollections().find((col) => col.slug === slug || false);
|
|
8643
9550
|
if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
|
|
8644
9551
|
if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
|
|
9552
|
+
await authorizeEntityRead(c, collection, id);
|
|
8645
9553
|
const tableName = collection.slug;
|
|
9554
|
+
const appliedLimit = limit;
|
|
9555
|
+
const appliedOffset = Math.max(offset, 0);
|
|
8646
9556
|
const result = await historyService.fetchHistory(tableName, id, {
|
|
8647
|
-
limit:
|
|
8648
|
-
offset:
|
|
9557
|
+
limit: appliedLimit,
|
|
9558
|
+
offset: appliedOffset
|
|
8649
9559
|
});
|
|
8650
9560
|
return c.json({
|
|
8651
9561
|
data: result.data,
|
|
8652
9562
|
meta: {
|
|
8653
9563
|
total: result.total,
|
|
8654
|
-
limit,
|
|
8655
|
-
offset,
|
|
8656
|
-
hasMore:
|
|
9564
|
+
limit: appliedLimit,
|
|
9565
|
+
offset: appliedOffset,
|
|
9566
|
+
hasMore: appliedOffset + result.data.length < result.total
|
|
8657
9567
|
}
|
|
8658
9568
|
});
|
|
8659
9569
|
});
|
|
@@ -8669,12 +9579,13 @@ function createHistoryRoutes(params) {
|
|
|
8669
9579
|
const collection = registry.getCollections().find((col) => col.slug === slug || false);
|
|
8670
9580
|
if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
|
|
8671
9581
|
if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
|
|
9582
|
+
await authorizeEntityRead(c, collection, id);
|
|
8672
9583
|
const historyEntry = await historyService.fetchHistoryEntry(historyId);
|
|
8673
9584
|
if (!historyEntry) throw ApiError.notFound(`History entry '${historyId}' not found`);
|
|
8674
9585
|
const tableName = collection.slug;
|
|
8675
9586
|
if (historyEntry.entity_id !== String(id) || historyEntry.table_name !== tableName) throw ApiError.badRequest("History entry does not belong to this entity");
|
|
8676
9587
|
if (!historyEntry.values) throw ApiError.badRequest("Cannot revert: history entry has no stored values");
|
|
8677
|
-
const authDriver = c.get("driver")
|
|
9588
|
+
const authDriver = c.get("driver");
|
|
8678
9589
|
const path = collection.slug;
|
|
8679
9590
|
const savedEntity = await authDriver.save({
|
|
8680
9591
|
path,
|
|
@@ -8691,126 +9602,6 @@ function createHistoryRoutes(params) {
|
|
|
8691
9602
|
return router;
|
|
8692
9603
|
}
|
|
8693
9604
|
//#endregion
|
|
8694
|
-
//#region src/email/smtp-email-service.ts
|
|
8695
|
-
var _nodemailer;
|
|
8696
|
-
async function loadNodemailer() {
|
|
8697
|
-
if (!_nodemailer) try {
|
|
8698
|
-
_nodemailer = await import("nodemailer");
|
|
8699
|
-
} catch {
|
|
8700
|
-
throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
|
|
8701
|
-
}
|
|
8702
|
-
return _nodemailer;
|
|
8703
|
-
}
|
|
8704
|
-
/**
|
|
8705
|
-
* Safely parse a hostname from a URL string
|
|
8706
|
-
*/
|
|
8707
|
-
function getHostname(urlStr) {
|
|
8708
|
-
try {
|
|
8709
|
-
return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
|
|
8710
|
-
} catch {
|
|
8711
|
-
return;
|
|
8712
|
-
}
|
|
8713
|
-
}
|
|
8714
|
-
/**
|
|
8715
|
-
* SMTP Email Service implementation using Nodemailer
|
|
8716
|
-
*/
|
|
8717
|
-
var SMTPEmailService = class {
|
|
8718
|
-
transporter = null;
|
|
8719
|
-
config;
|
|
8720
|
-
_initialized = false;
|
|
8721
|
-
constructor(config) {
|
|
8722
|
-
this.config = config;
|
|
8723
|
-
}
|
|
8724
|
-
/**
|
|
8725
|
-
* Lazily initialize the SMTP transporter on first use
|
|
8726
|
-
*/
|
|
8727
|
-
async ensureTransporter() {
|
|
8728
|
-
if (this._initialized) return;
|
|
8729
|
-
this._initialized = true;
|
|
8730
|
-
if (this.config.smtp) {
|
|
8731
|
-
const nodemailer = await loadNodemailer();
|
|
8732
|
-
let smtpName = this.config.smtp.name;
|
|
8733
|
-
if (!smtpName) {
|
|
8734
|
-
const urlsToTry = [
|
|
8735
|
-
process.env.FRONTEND_URL,
|
|
8736
|
-
this.config.resetPasswordUrl,
|
|
8737
|
-
this.config.verifyEmailUrl
|
|
8738
|
-
];
|
|
8739
|
-
for (const urlStr of urlsToTry) if (urlStr) {
|
|
8740
|
-
const hostname = getHostname(urlStr);
|
|
8741
|
-
if (hostname) {
|
|
8742
|
-
smtpName = hostname;
|
|
8743
|
-
break;
|
|
8744
|
-
}
|
|
8745
|
-
}
|
|
8746
|
-
}
|
|
8747
|
-
this.transporter = nodemailer.createTransport({
|
|
8748
|
-
name: smtpName,
|
|
8749
|
-
host: this.config.smtp.host,
|
|
8750
|
-
port: this.config.smtp.port,
|
|
8751
|
-
secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
|
|
8752
|
-
auth: this.config.smtp.auth ? {
|
|
8753
|
-
user: this.config.smtp.auth.user,
|
|
8754
|
-
pass: this.config.smtp.auth.pass
|
|
8755
|
-
} : void 0
|
|
8756
|
-
});
|
|
8757
|
-
}
|
|
8758
|
-
}
|
|
8759
|
-
/**
|
|
8760
|
-
* Check if the email service is properly configured
|
|
8761
|
-
*/
|
|
8762
|
-
isConfigured() {
|
|
8763
|
-
return !!(this.config.smtp || this.config.sendEmail);
|
|
8764
|
-
}
|
|
8765
|
-
/**
|
|
8766
|
-
* Send an email using SMTP or custom send function
|
|
8767
|
-
*/
|
|
8768
|
-
async send(options) {
|
|
8769
|
-
if (this.config.sendEmail) {
|
|
8770
|
-
await this.config.sendEmail(options);
|
|
8771
|
-
return;
|
|
8772
|
-
}
|
|
8773
|
-
await this.ensureTransporter();
|
|
8774
|
-
if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
|
|
8775
|
-
const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
|
|
8776
|
-
try {
|
|
8777
|
-
await this.transporter.sendMail({
|
|
8778
|
-
from: this.config.from,
|
|
8779
|
-
to,
|
|
8780
|
-
subject: options.subject,
|
|
8781
|
-
html: options.html,
|
|
8782
|
-
text: options.text,
|
|
8783
|
-
replyTo: options.replyTo
|
|
8784
|
-
});
|
|
8785
|
-
} catch (error) {
|
|
8786
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
8787
|
-
logger.error("Failed to send email", { detail: message });
|
|
8788
|
-
throw new Error(`Failed to send email: ${message}`);
|
|
8789
|
-
}
|
|
8790
|
-
}
|
|
8791
|
-
/**
|
|
8792
|
-
* Verify SMTP connection (useful for startup checks)
|
|
8793
|
-
*/
|
|
8794
|
-
async verifyConnection() {
|
|
8795
|
-
await this.ensureTransporter();
|
|
8796
|
-
if (!this.transporter) return !!this.config.sendEmail;
|
|
8797
|
-
try {
|
|
8798
|
-
await this.transporter.verify();
|
|
8799
|
-
return true;
|
|
8800
|
-
} catch (error) {
|
|
8801
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
8802
|
-
logger.error("SMTP connection verification failed", { detail: message });
|
|
8803
|
-
return false;
|
|
8804
|
-
}
|
|
8805
|
-
}
|
|
8806
|
-
};
|
|
8807
|
-
/**
|
|
8808
|
-
* Create an email service from configuration
|
|
8809
|
-
*/
|
|
8810
|
-
function createEmailService(config) {
|
|
8811
|
-
return new SMTPEmailService(config);
|
|
8812
|
-
}
|
|
8813
|
-
//#endregion
|
|
8814
9605
|
//#region src/singleton.ts
|
|
8815
9606
|
/**
|
|
8816
9607
|
* The backing instance lives on a process-global slot, NOT in a module-local
|
|
@@ -8871,14 +9662,30 @@ function _resetRebaseMock() {
|
|
|
8871
9662
|
* **Admin data plane** (`rebase.dataAsAdmin`):
|
|
8872
9663
|
* Backed by the native DataDriver — calls go directly to the database without
|
|
8873
9664
|
* JSON serialization, HTTP dispatch, or middleware overhead. The driver is
|
|
8874
|
-
* scoped as `{ uid: "service", roles: ["admin"] }
|
|
8875
|
-
*
|
|
8876
|
-
*
|
|
8877
|
-
*
|
|
8878
|
-
*
|
|
8879
|
-
*
|
|
8880
|
-
*
|
|
8881
|
-
*
|
|
9665
|
+
* scoped once as `{ uid: "service", roles: ["admin"] }` (`SERVICE_IDENTITY`),
|
|
9666
|
+
* which makes it **admin-scoped, not RLS-bypassing**: every read and write runs
|
|
9667
|
+
* in a transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
9668
|
+
* `app.uid = 'service'`, and policies are evaluated against that. No
|
|
9669
|
+
* `REBASE_SERVICE_KEY` is required.
|
|
9670
|
+
*
|
|
9671
|
+
* It clears the default policies through their `rolesOverlap(['admin'])` arm —
|
|
9672
|
+
* which is why the difference rarely shows. It shows when you write your own:
|
|
9673
|
+
*
|
|
9674
|
+
* - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
|
|
9675
|
+
* **false** for this accessor. A collection with `disableDefaultPolicies:
|
|
9676
|
+
* true` whose rule is `serverContext()` denies these writes (`42501`) and
|
|
9677
|
+
* returns zero rows — HTTP 200, empty — for these reads.
|
|
9678
|
+
* - Its reach equals an `admin`-roled application user's reach. It is not a
|
|
9679
|
+
* private door.
|
|
9680
|
+
*
|
|
9681
|
+
* `rebase.sql()` *is* an unconditional bypass — it runs on the owner connection
|
|
9682
|
+
* and never goes through `withAuth`. Of the two accessors on this object, the
|
|
9683
|
+
* quieter one is the more privileged.
|
|
9684
|
+
*
|
|
9685
|
+
* ⚠️ `rebase.dataAsAdmin` is for trusted background work (cron jobs,
|
|
9686
|
+
* migrations, service tasks) — **not** for serving user-facing data. Inside a
|
|
9687
|
+
* request handler, run user-scoped queries through the request-scoped driver
|
|
9688
|
+
* (`c.var.driver`), which carries the caller's identity.
|
|
8882
9689
|
*
|
|
8883
9690
|
* `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
|
|
8884
9691
|
* admin-scoped accessor has exactly one name and the privilege is visible at the
|
|
@@ -8894,7 +9701,8 @@ function _resetRebaseMock() {
|
|
|
8894
9701
|
* ```typescript
|
|
8895
9702
|
* import { rebase } from "@rebasepro/server";
|
|
8896
9703
|
*
|
|
8897
|
-
* // In a cron job, hook, or trusted service file (admin scope,
|
|
9704
|
+
* // In a cron job, hook, or trusted service file (admin scope, RLS evaluated
|
|
9705
|
+
* // as `{ uid: "service", roles: ["admin"] }`):
|
|
8898
9706
|
* await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
|
|
8899
9707
|
* const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
|
|
8900
9708
|
* ```
|
|
@@ -9009,6 +9817,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9009
9817
|
if (config.logging?.level) configureLogLevel(config.logging.level);
|
|
9010
9818
|
else configureLogLevel();
|
|
9011
9819
|
logger.info("Initializing Rebase Backend");
|
|
9820
|
+
installUnhandledRejectionHandler();
|
|
9012
9821
|
const basePath = config.basePath || "/api";
|
|
9013
9822
|
const isProduction = process.env.NODE_ENV === "production";
|
|
9014
9823
|
configureMiddlewares(config.app, basePath, isProduction, config);
|
|
@@ -9249,7 +10058,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9249
10058
|
]) {
|
|
9250
10059
|
const providerConfig = safeAuthConfig[key];
|
|
9251
10060
|
if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
|
|
9252
|
-
const createFn = (await import("./auth-
|
|
10061
|
+
const createFn = (await import("./auth-DkAHqvf5.js").then((n) => n.t))[factory];
|
|
9253
10062
|
oauthProviders.push(createFn(providerConfig));
|
|
9254
10063
|
}
|
|
9255
10064
|
}
|
|
@@ -9264,6 +10073,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9264
10073
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
9265
10074
|
defaultRole: safeAuthConfig.defaultRole,
|
|
9266
10075
|
oauthProviders,
|
|
10076
|
+
allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
|
|
9267
10077
|
serviceKey: serviceKey || internalServiceKey,
|
|
9268
10078
|
authHooks: safeAuthConfig.hooks,
|
|
9269
10079
|
collectionAuthConfig,
|
|
@@ -9343,7 +10153,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9343
10153
|
let schemaEditorOff = schemaEditorUnavailable();
|
|
9344
10154
|
let schemaEditorRoutes;
|
|
9345
10155
|
if (!schemaEditorOff && config.collectionsDir) try {
|
|
9346
|
-
schemaEditorRoutes = (await import("./schema-editor-routes-
|
|
10156
|
+
schemaEditorRoutes = (await import("./schema-editor-routes-DZFKGXgq.js")).createSchemaEditorRoutes(config.collectionsDir);
|
|
9347
10157
|
} catch (err) {
|
|
9348
10158
|
if (err?.code === "ERR_MODULE_NOT_FOUND") {
|
|
9349
10159
|
schemaEditorOff = {
|
|
@@ -9413,6 +10223,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9413
10223
|
config.app.route(`${basePath}/storage`, storageStub);
|
|
9414
10224
|
logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
|
|
9415
10225
|
}
|
|
10226
|
+
const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
|
|
9416
10227
|
if (activeCollections.length > 0) {
|
|
9417
10228
|
const dataRouter = new Hono();
|
|
9418
10229
|
dataRouter.onError(errorHandler);
|
|
@@ -9454,11 +10265,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
9454
10265
|
});
|
|
9455
10266
|
dataRouter.route("/", historyRoutes);
|
|
9456
10267
|
}
|
|
9457
|
-
const restGenerator = new RestApiGenerator(
|
|
10268
|
+
const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
|
|
9458
10269
|
dataRouter.route("/", restGenerator.generateRoutes());
|
|
9459
10270
|
config.app.route(`${basePath}/data`, dataRouter);
|
|
9460
10271
|
}
|
|
9461
|
-
await mountOpenApiDocs(config.app, basePath, config.enableSwagger,
|
|
10272
|
+
await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
|
|
9462
10273
|
const serverClient = createRebaseClient({
|
|
9463
10274
|
baseUrl: "http://localhost",
|
|
9464
10275
|
apiPath: basePath,
|
|
@@ -9468,10 +10279,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9468
10279
|
return await config.app.request(input, init);
|
|
9469
10280
|
}
|
|
9470
10281
|
});
|
|
9471
|
-
const serviceIdentity =
|
|
9472
|
-
uid: "service",
|
|
9473
|
-
roles: ["admin"]
|
|
9474
|
-
};
|
|
10282
|
+
const serviceIdentity = SERVICE_IDENTITY;
|
|
9475
10283
|
const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
|
|
9476
10284
|
storageAuthorizeData.current = defaultData;
|
|
9477
10285
|
const dataSourcesByKey = {};
|
|
@@ -9513,55 +10321,59 @@ async function _initializeRebaseBackend(config) {
|
|
|
9513
10321
|
Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
|
|
9514
10322
|
logger.info("SQL capability attached to singleton");
|
|
9515
10323
|
}
|
|
9516
|
-
|
|
10324
|
+
const serverSingleton = serverClient;
|
|
10325
|
+
_initRebase(serverSingleton);
|
|
9517
10326
|
logger.info("Rebase singleton initialized");
|
|
9518
10327
|
if (defaultDriverResult.internals) {
|
|
9519
10328
|
const driver = defaultDriverResult.internals.driver;
|
|
9520
10329
|
if (driver && "client" in driver) driver.client = serverClient;
|
|
9521
10330
|
}
|
|
9522
10331
|
if (config.functionsDir) {
|
|
9523
|
-
const {
|
|
9524
|
-
const { createFunctionRoutes } = await import("./function-routes-
|
|
9525
|
-
const
|
|
9526
|
-
|
|
9527
|
-
|
|
9528
|
-
|
|
9529
|
-
|
|
9530
|
-
|
|
9531
|
-
|
|
9532
|
-
|
|
9533
|
-
|
|
9534
|
-
|
|
9535
|
-
|
|
9536
|
-
|
|
9537
|
-
|
|
9538
|
-
|
|
9539
|
-
|
|
9540
|
-
|
|
9541
|
-
|
|
9542
|
-
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
9554
|
-
|
|
10332
|
+
const { loadFunctionsWithDiagnostics } = await import("./function-loader-D1SwtCa5.js").then((n) => n.t);
|
|
10333
|
+
const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
|
|
10334
|
+
const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-RivJsME0.js");
|
|
10335
|
+
const { functions: loadedFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
|
|
10336
|
+
const functionsRouter = new Hono();
|
|
10337
|
+
functionsRouter.onError(errorHandler);
|
|
10338
|
+
const functionsTimeoutMs = resolveFunctionsTimeoutMs(config.functionsTimeoutMs);
|
|
10339
|
+
if (functionsTimeoutMs > 0) functionsRouter.use("/*", createFunctionsRequestTimeout(functionsTimeoutMs));
|
|
10340
|
+
const functionsRequireAuth = false;
|
|
10341
|
+
if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
|
|
10342
|
+
adapter: authAdapter,
|
|
10343
|
+
driver: defaultDriver,
|
|
10344
|
+
requireAuth: functionsRequireAuth,
|
|
10345
|
+
apiKeyStore
|
|
10346
|
+
}));
|
|
10347
|
+
else functionsRouter.use("/*", createAuthMiddleware({
|
|
10348
|
+
driver: defaultDriver,
|
|
10349
|
+
requireAuth: functionsRequireAuth,
|
|
10350
|
+
serviceKey: internalServiceKey,
|
|
10351
|
+
apiKeyStore
|
|
10352
|
+
}));
|
|
10353
|
+
functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
|
|
10354
|
+
if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
|
|
10355
|
+
...rateLimitConfig,
|
|
10356
|
+
anonymous: rateLimitConfig.anonymousFunctions !== void 0 ? rateLimitConfig.anonymousFunctions : DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT
|
|
10357
|
+
}));
|
|
10358
|
+
const fnRoutes = createFunctionRoutes(loadedFunctions, problems.length);
|
|
10359
|
+
functionsRouter.route("/", fnRoutes);
|
|
10360
|
+
config.app.route(`${basePath}/functions`, functionsRouter);
|
|
10361
|
+
if (loadedFunctions.length > 0) logger.info("Mounted custom functions", {
|
|
10362
|
+
count: loadedFunctions.length,
|
|
10363
|
+
path: `${basePath}/functions`,
|
|
10364
|
+
timeoutMs: functionsTimeoutMs
|
|
10365
|
+
});
|
|
10366
|
+
else logger.warn(`Function routes mounted at ${basePath}/functions, but no functions loaded from ${config.functionsDir}. ` + (problems.length > 0 ? `${problems.length} file(s) were skipped — see the messages above.` : "The directory holds no .ts/.js function files."));
|
|
9555
10367
|
}
|
|
9556
10368
|
let cronScheduler;
|
|
9557
10369
|
if (config.cronsDir) {
|
|
9558
|
-
const { loadCronJobsFromDirectory } = await import("./cron-loader-
|
|
9559
|
-
const { CronScheduler } = await import("./cron-scheduler-
|
|
9560
|
-
const { createCronRoutes } = await import("./cron-routes-
|
|
9561
|
-
const { createCronStore } = await import("./cron-store-
|
|
10370
|
+
const { loadCronJobsFromDirectory } = await import("./cron-loader-3U5aGILy.js").then((n) => n.t);
|
|
10371
|
+
const { CronScheduler } = await import("./cron-scheduler-FJAaCAXm.js").then((n) => n.n);
|
|
10372
|
+
const { createCronRoutes } = await import("./cron-routes-rZgwlOz4.js").then((n) => n.n);
|
|
10373
|
+
const { createCronStore } = await import("./cron-store-9NmUDfzL.js").then((n) => n.n);
|
|
9562
10374
|
const loadedCronJobs = await loadCronJobsFromDirectory(config.cronsDir);
|
|
9563
10375
|
cronScheduler = new CronScheduler();
|
|
9564
|
-
cronScheduler.setClient(
|
|
10376
|
+
cronScheduler.setClient(serverSingleton);
|
|
9565
10377
|
if (loadedCronJobs.length > 0) {
|
|
9566
10378
|
cronScheduler.registerJobs(loadedCronJobs);
|
|
9567
10379
|
const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
|
|
@@ -9583,7 +10395,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9583
10395
|
} else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. Nothing is scheduled — check the messages above for files that failed to load.`);
|
|
9584
10396
|
}
|
|
9585
10397
|
{
|
|
9586
|
-
const { createBackupRoutes, parseBackupDestination } = await import("./backup-
|
|
10398
|
+
const { createBackupRoutes, parseBackupDestination } = await import("./backup-DLluVyuA.js").then((n) => n.t);
|
|
9587
10399
|
const backupRouter = new Hono();
|
|
9588
10400
|
applyAdminGate(backupRouter, "Backup");
|
|
9589
10401
|
backupRouter.route("/", createBackupRoutes({
|
|
@@ -9605,7 +10417,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9605
10417
|
logger.info("Logs routes mounted", { path: `${basePath}/logs` });
|
|
9606
10418
|
}
|
|
9607
10419
|
{
|
|
9608
|
-
const { createContractRoutes } = await import("./contract-routes-
|
|
10420
|
+
const { createContractRoutes } = await import("./contract-routes-BB1U05sS.js").then((n) => n.t);
|
|
9609
10421
|
const contractRouter = new Hono();
|
|
9610
10422
|
if (adminSurfacesGated) {
|
|
9611
10423
|
if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
|
|
@@ -9699,7 +10511,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
9699
10511
|
* export default defineFunction((app, { rebase }) => {
|
|
9700
10512
|
* app.use("/*", requireAuth);
|
|
9701
10513
|
* app.get("/home", async (c) => {
|
|
9702
|
-
*
|
|
10514
|
+
* // `rebase.sql` runs on the owner connection: no RLS, no policies,
|
|
10515
|
+
* // every row. It is the most privileged thing in this context —
|
|
10516
|
+
* // more so than `dataAsAdmin`, which is merely admin-scoped.
|
|
10517
|
+
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
9703
10518
|
* return c.json({ orders: Number(stats.n) });
|
|
9704
10519
|
* });
|
|
9705
10520
|
* });
|
|
@@ -9724,6 +10539,17 @@ function defineFunction(definition) {
|
|
|
9724
10539
|
*
|
|
9725
10540
|
* @see {@link defineFunction} for the equivalent custom-functions helper.
|
|
9726
10541
|
*
|
|
10542
|
+
* `ctx.rebase` is the `rebase` singleton itself — the same object a custom
|
|
10543
|
+
* function is handed — so `rebase.dataAsAdmin` here is **admin-scoped, not an
|
|
10544
|
+
* RLS bypass**: it is scoped once at boot as `SERVICE_IDENTITY`
|
|
10545
|
+
* (`{ uid: "service", roles: ["admin"] }`), and your policies are evaluated
|
|
10546
|
+
* against that identity, not skipped. See that constant's docblock. It clears
|
|
10547
|
+
* the default policies through their `rolesOverlap(['admin'])` arm, which is why
|
|
10548
|
+
* the distinction rarely shows; it shows against hand-written rules, where
|
|
10549
|
+
* `policy.serverContext()` (`auth.uid() IS NULL`) is **false** for it. Use
|
|
10550
|
+
* `rebase.sql()` when you need the genuine bypass — owner connection, no
|
|
10551
|
+
* policies.
|
|
10552
|
+
*
|
|
9727
10553
|
* @example
|
|
9728
10554
|
* ```ts
|
|
9729
10555
|
* import { defineCron } from "@rebasepro/server";
|
|
@@ -9731,12 +10557,15 @@ function defineFunction(definition) {
|
|
|
9731
10557
|
* export default defineCron({
|
|
9732
10558
|
* name: "Nightly cleanup",
|
|
9733
10559
|
* schedule: "0 3 * * *",
|
|
9734
|
-
* async handler({
|
|
9735
|
-
*
|
|
10560
|
+
* async handler({ rebase, log }) {
|
|
10561
|
+
* // `dataAsAdmin` runs as the service identity, with the `admin` role.
|
|
10562
|
+
* // A cron has no per-request user to narrow that, so scope the
|
|
10563
|
+
* // filters yourself — RLS will not do it for you here.
|
|
10564
|
+
* const { data: expired } = await rebase.dataAsAdmin.sessions.find({
|
|
9736
10565
|
* where: { expired: ["==", true] },
|
|
9737
10566
|
* });
|
|
9738
10567
|
* for (const session of expired) {
|
|
9739
|
-
* await
|
|
10568
|
+
* await rebase.dataAsAdmin.sessions.delete(session.id as string);
|
|
9740
10569
|
* }
|
|
9741
10570
|
* log(`Deleted ${expired.length} expired sessions`);
|
|
9742
10571
|
* },
|
|
@@ -9749,36 +10578,41 @@ function defineCron(definition) {
|
|
|
9749
10578
|
//#endregion
|
|
9750
10579
|
//#region src/utils/sql.ts
|
|
9751
10580
|
/**
|
|
9752
|
-
* Returns a SQL chunk calling `
|
|
9753
|
-
* This is a PostgreSQL RLS helper function created in the `
|
|
10581
|
+
* Returns a SQL chunk calling `rebase.uid()` — the current user's ID.
|
|
10582
|
+
* This is a PostgreSQL RLS helper function created in the `rebase` schema
|
|
9754
10583
|
* that reads `app.uid` set per-transaction by `withAuth()`.
|
|
9755
10584
|
*
|
|
10585
|
+
* The function moved from `auth.uid()` to `rebase.uid()` in 1.0 — `auth` is
|
|
10586
|
+
* Supabase's schema, and taking it meant Rebase could not be pointed at a
|
|
10587
|
+
* database that already had one. Use this helper rather than writing the call
|
|
10588
|
+
* by hand and the move costs you nothing.
|
|
10589
|
+
*
|
|
9756
10590
|
* @example
|
|
9757
10591
|
* sql`${table.uid} = ${authUid()}`
|
|
9758
10592
|
*/
|
|
9759
10593
|
var authUid = () => {
|
|
9760
|
-
return sql
|
|
10594
|
+
return sql.raw(RLS_UID_SQL);
|
|
9761
10595
|
};
|
|
9762
10596
|
/**
|
|
9763
|
-
* Returns a SQL chunk calling `
|
|
10597
|
+
* Returns a SQL chunk calling `rebase.roles()` — the current user's roles
|
|
9764
10598
|
* as a comma-separated string.
|
|
9765
10599
|
* Reads `app.user_roles` set per-transaction by `withAuth()`.
|
|
9766
10600
|
*
|
|
9767
10601
|
* @example
|
|
9768
|
-
* sql
|
|
10602
|
+
* sql`${authRoles()} ~ 'admin'`
|
|
9769
10603
|
*/
|
|
9770
10604
|
var authRoles = () => {
|
|
9771
|
-
return sql
|
|
10605
|
+
return sql.raw(RLS_ROLES_SQL);
|
|
9772
10606
|
};
|
|
9773
10607
|
/**
|
|
9774
|
-
* Returns a SQL chunk calling `
|
|
10608
|
+
* Returns a SQL chunk calling `rebase.jwt()` — the full JWT claims as JSONB.
|
|
9775
10609
|
* Reads `app.jwt` set per-transaction by `withAuth()`.
|
|
9776
10610
|
*
|
|
9777
10611
|
* @example
|
|
9778
|
-
* sql
|
|
10612
|
+
* sql`${authJwt()}->>'sub'`
|
|
9779
10613
|
*/
|
|
9780
10614
|
var authJwt = () => {
|
|
9781
|
-
return sql
|
|
10615
|
+
return sql.raw(RLS_JWT_SQL);
|
|
9782
10616
|
};
|
|
9783
10617
|
//#endregion
|
|
9784
10618
|
//#region src/env.ts
|
|
@@ -9915,7 +10749,158 @@ function loadEnv(options) {
|
|
|
9915
10749
|
return env;
|
|
9916
10750
|
}
|
|
9917
10751
|
//#endregion
|
|
10752
|
+
//#region src/services/outbound-url-guard.ts
|
|
10753
|
+
/**
|
|
10754
|
+
* Refusal to send a request to a destination the guard does not allow.
|
|
10755
|
+
*
|
|
10756
|
+
* Distinct from a network error on purpose: a blocked destination is
|
|
10757
|
+
* *terminal* — retrying it re-runs the same refusal — while an `ENOTFOUND`
|
|
10758
|
+
* or a refused connection is worth another attempt.
|
|
10759
|
+
*/
|
|
10760
|
+
var BlockedUrlError = class extends Error {
|
|
10761
|
+
constructor(message) {
|
|
10762
|
+
super(message);
|
|
10763
|
+
this.name = "BlockedUrlError";
|
|
10764
|
+
}
|
|
10765
|
+
};
|
|
10766
|
+
/** Names that mean "this machine" or "this network" before DNS is even asked. */
|
|
10767
|
+
var BLOCKED_HOST_SUFFIXES = [
|
|
10768
|
+
".localhost",
|
|
10769
|
+
".local",
|
|
10770
|
+
".internal",
|
|
10771
|
+
".home.arpa"
|
|
10772
|
+
];
|
|
10773
|
+
async function defaultLookup(hostname) {
|
|
10774
|
+
return (await lookup(hostname, {
|
|
10775
|
+
all: true,
|
|
10776
|
+
verbatim: true
|
|
10777
|
+
})).map((e) => e.address);
|
|
10778
|
+
}
|
|
10779
|
+
/**
|
|
10780
|
+
* Parse an IPv6 literal into its 16 bytes. Returns `null` for anything that is
|
|
10781
|
+
* not one — the caller has already established the string *is* an IPv6 address
|
|
10782
|
+
* via `isIP`, so `null` here means "shape we do not model", which is treated as
|
|
10783
|
+
* blocked rather than allowed.
|
|
10784
|
+
*/
|
|
10785
|
+
function parseIpv6(address) {
|
|
10786
|
+
let text = address;
|
|
10787
|
+
const zone = text.indexOf("%");
|
|
10788
|
+
if (zone !== -1) text = text.slice(0, zone);
|
|
10789
|
+
let tail = [];
|
|
10790
|
+
const lastColon = text.lastIndexOf(":");
|
|
10791
|
+
const maybeV4 = text.slice(lastColon + 1);
|
|
10792
|
+
if (maybeV4.includes(".")) {
|
|
10793
|
+
if (isIP(maybeV4) !== 4) return null;
|
|
10794
|
+
tail = maybeV4.split(".").map(Number);
|
|
10795
|
+
text = text.slice(0, lastColon);
|
|
10796
|
+
if (text.endsWith(":")) text += ":";
|
|
10797
|
+
}
|
|
10798
|
+
const halves = text.split("::");
|
|
10799
|
+
if (halves.length > 2) return null;
|
|
10800
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
10801
|
+
const rest = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
|
|
10802
|
+
const missing = 8 - tail.length / 2 - head.length - rest.length;
|
|
10803
|
+
if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
|
|
10804
|
+
const all = [
|
|
10805
|
+
...head,
|
|
10806
|
+
...new Array(halves.length === 2 ? missing : 0).fill("0"),
|
|
10807
|
+
...rest
|
|
10808
|
+
];
|
|
10809
|
+
const bytes = [];
|
|
10810
|
+
for (const group of all) {
|
|
10811
|
+
const value = Number.parseInt(group, 16);
|
|
10812
|
+
if (!Number.isFinite(value) || value < 0 || value > 65535 || !/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
|
|
10813
|
+
bytes.push(value >> 8 & 255, value & 255);
|
|
10814
|
+
}
|
|
10815
|
+
return [...bytes, ...tail];
|
|
10816
|
+
}
|
|
10817
|
+
/**
|
|
10818
|
+
* Why this IPv4 address must not be a webhook destination, or `null` if it is
|
|
10819
|
+
* an ordinary public address.
|
|
10820
|
+
*/
|
|
10821
|
+
function blockedIpv4Reason(bytes) {
|
|
10822
|
+
const [a, b] = bytes;
|
|
10823
|
+
if (a === 0) return "\"this network\" (0.0.0.0/8)";
|
|
10824
|
+
if (a === 10) return "private (10.0.0.0/8)";
|
|
10825
|
+
if (a === 127) return "loopback (127.0.0.0/8)";
|
|
10826
|
+
if (a === 169 && b === 254) return "link-local (169.254.0.0/16) — the cloud metadata range";
|
|
10827
|
+
if (a === 172 && b >= 16 && b <= 31) return "private (172.16.0.0/12)";
|
|
10828
|
+
if (a === 192 && b === 168) return "private (192.168.0.0/16)";
|
|
10829
|
+
if (a === 100 && b >= 64 && b <= 127) return "carrier-grade NAT (100.64.0.0/10)";
|
|
10830
|
+
if (a === 192 && b === 0 && bytes[2] === 0) return "IETF protocol assignments (192.0.0.0/24)";
|
|
10831
|
+
if (a === 198 && (b === 18 || b === 19)) return "benchmarking (198.18.0.0/15)";
|
|
10832
|
+
if (a >= 224) return "multicast, reserved or broadcast (224.0.0.0/4 and above)";
|
|
10833
|
+
return null;
|
|
10834
|
+
}
|
|
10835
|
+
/**
|
|
10836
|
+
* Why this address must not be a webhook destination, or `null` if it is an
|
|
10837
|
+
* ordinary public address. Exported for tests and for anyone else adding an
|
|
10838
|
+
* outbound call whose destination comes from data.
|
|
10839
|
+
*/
|
|
10840
|
+
function blockedAddressReason(address) {
|
|
10841
|
+
const version = isIP(address);
|
|
10842
|
+
if (version === 4) return blockedIpv4Reason(address.split(".").map(Number));
|
|
10843
|
+
if (version !== 6) return `not an IP address: ${address}`;
|
|
10844
|
+
const bytes = parseIpv6(address);
|
|
10845
|
+
if (!bytes || bytes.length !== 16) return `unparseable IPv6 address: ${address}`;
|
|
10846
|
+
const low32 = bytes.slice(12);
|
|
10847
|
+
const isMapped = bytes.slice(0, 10).every((x) => x === 0) && bytes[10] === 255 && bytes[11] === 255;
|
|
10848
|
+
const isCompat = bytes.slice(0, 12).every((x) => x === 0) && !(low32[0] === 0 && low32[1] === 0);
|
|
10849
|
+
const isNat64 = bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155;
|
|
10850
|
+
if (isMapped || isCompat || isNat64) return blockedIpv4Reason(low32);
|
|
10851
|
+
if (bytes[0] === 32 && bytes[1] === 2) return blockedIpv4Reason(bytes.slice(2, 6));
|
|
10852
|
+
if (bytes.every((x) => x === 0)) return "unspecified (::)";
|
|
10853
|
+
if (bytes.slice(0, 15).every((x) => x === 0) && bytes[15] === 1) return "loopback (::1)";
|
|
10854
|
+
if (bytes[0] === 254 && (bytes[1] & 192) === 128) return "link-local (fe80::/10)";
|
|
10855
|
+
if ((bytes[0] & 254) === 252) return "unique local (fc00::/7)";
|
|
10856
|
+
if (bytes[0] === 255) return "multicast (ff00::/8)";
|
|
10857
|
+
return null;
|
|
10858
|
+
}
|
|
10859
|
+
/**
|
|
10860
|
+
* Resolve and validate a destination before anything connects to it.
|
|
10861
|
+
*
|
|
10862
|
+
* Three checks, in order, because each is cheaper than the next: the scheme,
|
|
10863
|
+
* the hostname, then every address the hostname resolves to. *Every* address —
|
|
10864
|
+
* a name that answers with one public and one private record is a rebinding
|
|
10865
|
+
* attempt, and taking the first record would let it through.
|
|
10866
|
+
*
|
|
10867
|
+
* Known limit: this is validate-then-fetch. Between the lookup here and the
|
|
10868
|
+
* connection the runtime makes there is a second resolution this code does not
|
|
10869
|
+
* see, so a TTL-0 name that flips between answers can still land on a blocked
|
|
10870
|
+
* address. Closing that needs the socket pinned to the address checked here,
|
|
10871
|
+
* which Node's global `fetch` does not expose without an undici dispatcher.
|
|
10872
|
+
* The redirect policy in {@link WebhookDispatcher} closes the much wider
|
|
10873
|
+
* version of the same hole — a receiver that simply *points* at the internal
|
|
10874
|
+
* address.
|
|
10875
|
+
*
|
|
10876
|
+
* @throws BlockedUrlError when the destination is not allowed. DNS failures
|
|
10877
|
+
* are rethrown as they are, so callers can tell "never send this" from
|
|
10878
|
+
* "could not send this right now".
|
|
10879
|
+
*/
|
|
10880
|
+
async function assertAllowedOutboundUrl(rawUrl, options = {}) {
|
|
10881
|
+
let url;
|
|
10882
|
+
try {
|
|
10883
|
+
url = new URL(rawUrl);
|
|
10884
|
+
} catch {
|
|
10885
|
+
throw new BlockedUrlError(`Invalid webhook URL: ${JSON.stringify(rawUrl)}`);
|
|
10886
|
+
}
|
|
10887
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new BlockedUrlError(`Webhook URL must be http(s), got "${url.protocol}" in ${url.href}`);
|
|
10888
|
+
if (options.allowPrivateNetworks) return url;
|
|
10889
|
+
const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
|
|
10890
|
+
const lowered = hostname.toLowerCase();
|
|
10891
|
+
if (lowered === "localhost" || BLOCKED_HOST_SUFFIXES.some((suffix) => lowered.endsWith(suffix))) throw new BlockedUrlError(`Webhook URL host "${hostname}" is an internal name`);
|
|
10892
|
+
const addresses = isIP(hostname) ? [hostname] : await (options.lookup ?? defaultLookup)(hostname);
|
|
10893
|
+
if (addresses.length === 0) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolved to no addresses`);
|
|
10894
|
+
for (const address of addresses) {
|
|
10895
|
+
const reason = blockedAddressReason(address);
|
|
10896
|
+
if (reason) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolves to ${address}, which is ${reason}. Set allowPrivateNetworks to deliver to it anyway.`);
|
|
10897
|
+
}
|
|
10898
|
+
return url;
|
|
10899
|
+
}
|
|
10900
|
+
//#endregion
|
|
9918
10901
|
//#region src/services/webhook-service.ts
|
|
10902
|
+
/** Read at most this much of a response before cancelling the rest. */
|
|
10903
|
+
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
9919
10904
|
var WebhookDispatcher = class {
|
|
9920
10905
|
webhooks = [];
|
|
9921
10906
|
maxRetries = 3;
|
|
@@ -9924,35 +10909,107 @@ var WebhookDispatcher = class {
|
|
|
9924
10909
|
5e3,
|
|
9925
10910
|
15e3
|
|
9926
10911
|
];
|
|
10912
|
+
options;
|
|
10913
|
+
timeoutMs;
|
|
10914
|
+
queue = [];
|
|
10915
|
+
draining = null;
|
|
10916
|
+
constructor(options = {}) {
|
|
10917
|
+
this.options = options;
|
|
10918
|
+
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
10919
|
+
}
|
|
9927
10920
|
/** Register webhooks to watch */
|
|
9928
10921
|
setWebhooks(webhooks) {
|
|
9929
10922
|
this.webhooks = webhooks.filter((w) => w.enabled);
|
|
9930
10923
|
}
|
|
9931
|
-
/**
|
|
10924
|
+
/**
|
|
10925
|
+
* Called when a entity changes — checks if any webhook matches, and awaits
|
|
10926
|
+
* every delivery, retries included.
|
|
10927
|
+
*
|
|
10928
|
+
* **Do not await this inside a collection callback.** `afterSave` and
|
|
10929
|
+
* `afterDelete` run inside the write's Postgres transaction, so awaiting a
|
|
10930
|
+
* delivery here holds a pooled connection and the row's locks for as long
|
|
10931
|
+
* as the receiver takes to answer — up to ~36s across three attempts, per
|
|
10932
|
+
* matching webhook. Use {@link enqueueEntityChange} there; this method is
|
|
10933
|
+
* for a custom function or a job that wants the results.
|
|
10934
|
+
*/
|
|
9932
10935
|
async onEntityChange(table, event, id, entity, previousEntity) {
|
|
9933
|
-
const
|
|
9934
|
-
if (matchingWebhooks.length === 0) return [];
|
|
10936
|
+
const jobs = this.buildDeliveries(table, event, entity, previousEntity);
|
|
9935
10937
|
const results = [];
|
|
9936
|
-
for (const
|
|
9937
|
-
const
|
|
10938
|
+
for (const job of jobs) {
|
|
10939
|
+
const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
|
|
10940
|
+
this.options.onDelivery?.(result);
|
|
10941
|
+
results.push(result);
|
|
10942
|
+
}
|
|
10943
|
+
return results;
|
|
10944
|
+
}
|
|
10945
|
+
/**
|
|
10946
|
+
* Queue the same deliveries and return immediately.
|
|
10947
|
+
*
|
|
10948
|
+
* This is the form a collection callback wants. `afterSave` is awaited
|
|
10949
|
+
* *inside* the transaction that wrote the row, so anything it awaits is
|
|
10950
|
+
* transaction time; queueing hands the HTTP to a drain loop that runs
|
|
10951
|
+
* after the callback returns, and the transaction commits without waiting
|
|
10952
|
+
* for a third party. It also means a receiver that is down can no longer
|
|
10953
|
+
* fail the customer's write.
|
|
10954
|
+
*
|
|
10955
|
+
* The cost, stated plainly: the queue is in-process and in-memory. A crash
|
|
10956
|
+
* or a deploy between the enqueue and the delivery drops the event, and a
|
|
10957
|
+
* receiver may see the notification a few milliseconds before the row it
|
|
10958
|
+
* describes is committed. Use {@link flush} on shutdown, and
|
|
10959
|
+
* `onDelivery` to record failures.
|
|
10960
|
+
*/
|
|
10961
|
+
enqueueEntityChange(table, event, id, entity, previousEntity) {
|
|
10962
|
+
const jobs = this.buildDeliveries(table, event, entity, previousEntity);
|
|
10963
|
+
if (jobs.length === 0) return;
|
|
10964
|
+
this.queue.push(...jobs);
|
|
10965
|
+
this.startDraining();
|
|
10966
|
+
}
|
|
10967
|
+
/**
|
|
10968
|
+
* Wait for every queued delivery to finish. For graceful shutdown, and for
|
|
10969
|
+
* tests that need to observe what {@link enqueueEntityChange} sent.
|
|
10970
|
+
*/
|
|
10971
|
+
async flush() {
|
|
10972
|
+
while (this.draining) await this.draining;
|
|
10973
|
+
}
|
|
10974
|
+
startDraining() {
|
|
10975
|
+
if (this.draining) return;
|
|
10976
|
+
const run = this.drainQueue().finally(() => {
|
|
10977
|
+
if (this.draining === run) this.draining = null;
|
|
10978
|
+
});
|
|
10979
|
+
this.draining = run;
|
|
10980
|
+
}
|
|
10981
|
+
async drainQueue() {
|
|
10982
|
+
while (this.queue.length > 0) {
|
|
10983
|
+
const job = this.queue.shift();
|
|
10984
|
+
try {
|
|
10985
|
+
const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
|
|
10986
|
+
this.options.onDelivery?.(result);
|
|
10987
|
+
} catch {}
|
|
10988
|
+
}
|
|
10989
|
+
}
|
|
10990
|
+
/** The payload each matching webhook gets for this change. */
|
|
10991
|
+
buildDeliveries(table, event, entity, previousEntity) {
|
|
10992
|
+
return this.webhooks.filter((w) => w.table === table && w.events.includes(event)).map((webhook) => ({
|
|
10993
|
+
webhook,
|
|
10994
|
+
event,
|
|
10995
|
+
payload: {
|
|
9938
10996
|
type: event,
|
|
9939
10997
|
table,
|
|
9940
10998
|
record: entity,
|
|
9941
10999
|
old_record: event === "UPDATE" ? previousEntity : void 0,
|
|
9942
11000
|
schema: "public",
|
|
9943
11001
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
9944
|
-
}
|
|
9945
|
-
|
|
9946
|
-
results.push(result);
|
|
9947
|
-
}
|
|
9948
|
-
return results;
|
|
11002
|
+
}
|
|
11003
|
+
}));
|
|
9949
11004
|
}
|
|
9950
11005
|
async deliverWithRetry(webhook, event, payload) {
|
|
9951
11006
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
9952
|
-
const result = await this.deliver(webhook, event, payload, attempt);
|
|
9953
|
-
if (result.success) return result;
|
|
9954
|
-
if (attempt < this.maxRetries)
|
|
9955
|
-
|
|
11007
|
+
const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
|
|
11008
|
+
if (result.success || terminal) return result;
|
|
11009
|
+
if (attempt < this.maxRetries) {
|
|
11010
|
+
const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
|
|
11011
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
11012
|
+
} else return result;
|
|
9956
11013
|
}
|
|
9957
11014
|
return {
|
|
9958
11015
|
webhookId: webhook.id,
|
|
@@ -9975,41 +11032,106 @@ var WebhookDispatcher = class {
|
|
|
9975
11032
|
...webhook.headers || {}
|
|
9976
11033
|
};
|
|
9977
11034
|
if (webhook.secret) headers["X-Webhook-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`;
|
|
11035
|
+
const controller = new AbortController();
|
|
11036
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
9978
11037
|
try {
|
|
9979
|
-
const
|
|
9980
|
-
|
|
9981
|
-
|
|
11038
|
+
const url = await assertAllowedOutboundUrl(webhook.url, {
|
|
11039
|
+
allowPrivateNetworks: this.options.allowPrivateNetworks,
|
|
11040
|
+
lookup: this.options.lookup
|
|
11041
|
+
});
|
|
11042
|
+
const response = await fetch(url.href, {
|
|
9982
11043
|
method: "POST",
|
|
9983
11044
|
headers,
|
|
9984
11045
|
body,
|
|
9985
|
-
signal: controller.signal
|
|
11046
|
+
signal: controller.signal,
|
|
11047
|
+
redirect: "manual"
|
|
9986
11048
|
});
|
|
9987
|
-
|
|
9988
|
-
|
|
11049
|
+
if (response.status >= 300 && response.status < 400) {
|
|
11050
|
+
const location = typeof response.headers?.get === "function" ? response.headers.get("location") : null;
|
|
11051
|
+
return {
|
|
11052
|
+
terminal: true,
|
|
11053
|
+
result: {
|
|
11054
|
+
webhookId: webhook.id,
|
|
11055
|
+
event,
|
|
11056
|
+
payload,
|
|
11057
|
+
statusCode: response.status,
|
|
11058
|
+
responseBody: `Webhook receivers must not redirect: HTTP ${response.status}` + (location ? ` to ${location}` : "") + ". The redirect target is not re-validated, so it is not followed.",
|
|
11059
|
+
success: false,
|
|
11060
|
+
attemptNumber
|
|
11061
|
+
}
|
|
11062
|
+
};
|
|
11063
|
+
}
|
|
11064
|
+
const responseBody = await this.readCappedBody(response);
|
|
9989
11065
|
const success = response.status >= 200 && response.status < 300;
|
|
9990
11066
|
return {
|
|
9991
|
-
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
11067
|
+
terminal: false,
|
|
11068
|
+
result: {
|
|
11069
|
+
webhookId: webhook.id,
|
|
11070
|
+
event,
|
|
11071
|
+
payload,
|
|
11072
|
+
statusCode: response.status,
|
|
11073
|
+
responseBody: responseBody.slice(0, 1e3),
|
|
11074
|
+
success,
|
|
11075
|
+
attemptNumber
|
|
11076
|
+
}
|
|
9998
11077
|
};
|
|
9999
11078
|
} catch (error) {
|
|
10000
11079
|
const message = error instanceof Error ? error.message : String(error);
|
|
10001
11080
|
return {
|
|
10002
|
-
|
|
10003
|
-
|
|
10004
|
-
|
|
10005
|
-
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
11081
|
+
terminal: error instanceof BlockedUrlError,
|
|
11082
|
+
result: {
|
|
11083
|
+
webhookId: webhook.id,
|
|
11084
|
+
event,
|
|
11085
|
+
payload,
|
|
11086
|
+
statusCode: 0,
|
|
11087
|
+
responseBody: message.slice(0, 1e3),
|
|
11088
|
+
success: false,
|
|
11089
|
+
attemptNumber
|
|
11090
|
+
}
|
|
10009
11091
|
};
|
|
11092
|
+
} finally {
|
|
11093
|
+
clearTimeout(timeout);
|
|
11094
|
+
}
|
|
11095
|
+
}
|
|
11096
|
+
/**
|
|
11097
|
+
* Read at most {@link MAX_RESPONSE_BYTES} of the response and cancel the
|
|
11098
|
+
* rest, so a receiver cannot buffer a 10 GB body into the pod before the
|
|
11099
|
+
* caller truncates it to 1000 characters.
|
|
11100
|
+
*/
|
|
11101
|
+
async readCappedBody(response) {
|
|
11102
|
+
const stream = response.body;
|
|
11103
|
+
if (!stream || typeof stream.getReader !== "function") return await response.text().catch(() => "");
|
|
11104
|
+
const reader = stream.getReader();
|
|
11105
|
+
const chunks = [];
|
|
11106
|
+
let total = 0;
|
|
11107
|
+
try {
|
|
11108
|
+
for (;;) {
|
|
11109
|
+
const { done, value } = await reader.read();
|
|
11110
|
+
if (done) break;
|
|
11111
|
+
if (!value) continue;
|
|
11112
|
+
chunks.push(value);
|
|
11113
|
+
total += value.byteLength;
|
|
11114
|
+
if (total >= MAX_RESPONSE_BYTES) break;
|
|
11115
|
+
}
|
|
11116
|
+
} catch {
|
|
11117
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
11118
|
+
} finally {
|
|
11119
|
+
await reader.cancel().catch(() => void 0);
|
|
10010
11120
|
}
|
|
11121
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
10011
11122
|
}
|
|
10012
11123
|
};
|
|
11124
|
+
function concat(chunks, total) {
|
|
11125
|
+
const out = new Uint8Array(total);
|
|
11126
|
+
let offset = 0;
|
|
11127
|
+
for (const chunk of chunks) {
|
|
11128
|
+
const room = out.length - offset;
|
|
11129
|
+
if (room <= 0) break;
|
|
11130
|
+
out.set(chunk.length > room ? chunk.subarray(0, room) : chunk, offset);
|
|
11131
|
+
offset += chunk.length;
|
|
11132
|
+
}
|
|
11133
|
+
return out;
|
|
11134
|
+
}
|
|
10013
11135
|
//#endregion
|
|
10014
11136
|
//#region src/utils/dev-port.ts
|
|
10015
11137
|
var MAX_PORT_ATTEMPTS = 20;
|
|
@@ -10170,10 +11292,11 @@ function isUnderPath(requestPath, prefix) {
|
|
|
10170
11292
|
* Serve a Single Page Application from an Hono app.
|
|
10171
11293
|
*
|
|
10172
11294
|
* @internal Not part of the stable public API. Exported only because the
|
|
10173
|
-
*
|
|
10174
|
-
* and `app/backend/src/index.ts`
|
|
10175
|
-
*
|
|
10176
|
-
*
|
|
11295
|
+
* entrypoint `rebase eject` writes
|
|
11296
|
+
* (`packages/cli/templates/eject/backend/src/index.ts`) and `app/backend/src/index.ts`
|
|
11297
|
+
* call it to serve the built frontend in production — the scaffolded template
|
|
11298
|
+
* has no entrypoint of its own. Its request-handling behavior is an
|
|
11299
|
+
* implementation detail and may change without a major version bump.
|
|
10177
11300
|
*/
|
|
10178
11301
|
function serveSPA(app, config) {
|
|
10179
11302
|
const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
|
|
@@ -11785,7 +12908,8 @@ async function bootFromBundle(options = {}) {
|
|
|
11785
12908
|
});
|
|
11786
12909
|
await ensureCollectionPolicies(bundle, dataSources, env);
|
|
11787
12910
|
metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
|
|
11788
|
-
|
|
12911
|
+
const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
|
|
12912
|
+
for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
|
|
11789
12913
|
const result = await backend.healthCheck();
|
|
11790
12914
|
const unhealthy = (await Promise.all(dataSources.filter((source) => source.key !== DEFAULT_DATA_SOURCE_KEY).map(async (source) => ({
|
|
11791
12915
|
key: source.key,
|
|
@@ -11891,7 +13015,7 @@ async function bootStaticApp(bundle, devRoot, options) {
|
|
|
11891
13015
|
if (metrics) app.use("/*", metrics.middleware);
|
|
11892
13016
|
const server = createServer(getRequestListener(app.fetch));
|
|
11893
13017
|
app.get("/livez", (c) => c.json({ status: "ok" }));
|
|
11894
|
-
|
|
13018
|
+
for (const healthPath of [.../* @__PURE__ */ new Set(["/health", `${basePath.replace(/\/$/, "")}/health`])]) app.get(healthPath, (c) => c.json({
|
|
11895
13019
|
status: "ok",
|
|
11896
13020
|
latencyMs: 0
|
|
11897
13021
|
}));
|
|
@@ -12001,6 +13125,40 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
|
|
|
12001
13125
|
}
|
|
12002
13126
|
}
|
|
12003
13127
|
/**
|
|
13128
|
+
* The collections a data source's engine is the store for.
|
|
13129
|
+
*
|
|
13130
|
+
* A bundle's collections directory holds *every* collection the project
|
|
13131
|
+
* declares, whatever engine serves it — that is the point of `dataSource`
|
|
13132
|
+
* routing. What it is not is a list of tables to create: handing the whole
|
|
13133
|
+
* directory to the primary source's bootstrapper made a Firestore collection
|
|
13134
|
+
* declared alongside the Postgres ones arrive as an empty Postgres table, with
|
|
13135
|
+
* RLS policies, while the app went on reading its documents from Firestore.
|
|
13136
|
+
*
|
|
13137
|
+
* Excluding is deliberately conservative. A collection that names neither an
|
|
13138
|
+
* `engine` nor a `dataSource` belongs to whichever source is primary — the
|
|
13139
|
+
* "postgres" that `resolveDataSource` falls back to there is a default, not a
|
|
13140
|
+
* declaration, and must not exclude anything on its own. Only a collection that
|
|
13141
|
+
* explicitly routes to a *different* engine is dropped, so a project running two
|
|
13142
|
+
* sources on the same engine is unaffected.
|
|
13143
|
+
*/
|
|
13144
|
+
function collectionsStoredBy(collections, primary, dataSources) {
|
|
13145
|
+
const registry = createDataSourceRegistry(dataSources.map((source) => ({
|
|
13146
|
+
key: source.key,
|
|
13147
|
+
engine: source.engine
|
|
13148
|
+
})));
|
|
13149
|
+
return collections.filter((collection) => {
|
|
13150
|
+
const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
|
|
13151
|
+
if (!declared) return true;
|
|
13152
|
+
return declared === primary.engine;
|
|
13153
|
+
});
|
|
13154
|
+
}
|
|
13155
|
+
/** Say what was routed elsewhere, so a missing table is never a silent one. */
|
|
13156
|
+
function logForeignCollections(all, stored, primary) {
|
|
13157
|
+
if (stored.length === all.length) return;
|
|
13158
|
+
const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
|
|
13159
|
+
logger.info(`Skipping ${foreign.length} collection(s) served by another engine, not "${primary.engine}": ${foreign.join(", ")}. Their storage is not managed by this data source.`);
|
|
13160
|
+
}
|
|
13161
|
+
/**
|
|
12004
13162
|
* Bring the database's collection tables up to date before serving.
|
|
12005
13163
|
*
|
|
12006
13164
|
* Delegates to whichever driver bootstrapped the default data source; a driver
|
|
@@ -12049,11 +13207,17 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
|
|
|
12049
13207
|
skip(`the adapter from "${primary.driverPackage}" (engine "${primary.engine}") does not expose collection-table creation. ` + (skew.detail ? `${skew.detail} ` : "") + "The driver package may well implement it on a class the adapter does not forward, so check the adapter's shape before blaming its version. Collection tables will NOT be created, so every /api/data route will fail on a missing relation.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }), "warn");
|
|
12050
13208
|
return;
|
|
12051
13209
|
}
|
|
12052
|
-
const
|
|
12053
|
-
if (
|
|
13210
|
+
const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
|
|
13211
|
+
if (loaded.length === 0) {
|
|
12054
13212
|
skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
|
|
12055
13213
|
return;
|
|
12056
13214
|
}
|
|
13215
|
+
const collections = collectionsStoredBy(loaded, primary, dataSources);
|
|
13216
|
+
logForeignCollections(loaded, collections, primary);
|
|
13217
|
+
if (collections.length === 0) {
|
|
13218
|
+
skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
|
|
13219
|
+
return;
|
|
13220
|
+
}
|
|
12057
13221
|
const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.info(`schema: ${message}`));
|
|
12058
13222
|
logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
|
|
12059
13223
|
}
|
|
@@ -12103,12 +13267,14 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
|
|
|
12103
13267
|
logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections will deny reads until policies are applied.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }));
|
|
12104
13268
|
return;
|
|
12105
13269
|
}
|
|
12106
|
-
const
|
|
13270
|
+
const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
|
|
13271
|
+
if (loaded.length === 0) return;
|
|
13272
|
+
const collections = collectionsStoredBy(loaded, primary, dataSources);
|
|
12107
13273
|
if (collections.length === 0) return;
|
|
12108
13274
|
const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.info(`policies: ${message}`));
|
|
12109
13275
|
logger.info(applied > 0 ? `Applied ${applied} RLS policy statement(s) before serving.` : "RLS policies are up to date.");
|
|
12110
13276
|
}
|
|
12111
13277
|
//#endregion
|
|
12112
|
-
export { ApiError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertCollectionConfigs, assertDistinctSuffixes, authJwt, authRoles, authUid, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed,
|
|
13278
|
+
export { ApiError, BlockedUrlError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RawHtml, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, escapeHtml, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, html, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
|
|
12113
13279
|
|
|
12114
13280
|
//# sourceMappingURL=index.es.js.map
|