@rebasepro/server 0.13.1-canary.gef9608c → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{admin_block-EmIHae6X.js → admin_block-H86dCPsj.js} +61 -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 +12 -0
- package/dist/api/rest/api-generator.d.ts +26 -1
- package/dist/api/rest/idempotency.d.ts +25 -4
- package/dist/api/rest/query-parser.d.ts +15 -1
- package/dist/api/rest/write-validation.d.ts +40 -4
- package/dist/auth/adapter-middleware.d.ts +3 -1
- package/dist/auth/admin-roles.d.ts +24 -0
- 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 +11 -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 +7 -0
- package/dist/auth/interfaces.d.ts +81 -1
- package/dist/auth/jwt.d.ts +40 -0
- package/dist/auth/linkedin-oauth.d.ts +2 -4
- package/dist/auth/mfa-crypto.d.ts +54 -3
- 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/middleware.d.ts +26 -0
- 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 +62 -5
- package/dist/auth/registration-policy.d.ts +31 -0
- package/dist/auth/rls-scope.d.ts +25 -0
- package/dist/auth/routes.d.ts +15 -0
- package/dist/auth/slack-oauth.d.ts +2 -4
- package/dist/auth/spotify-oauth.d.ts +2 -4
- package/dist/auth/token-revocation.d.ts +38 -0
- package/dist/auth/twitter-oauth.d.ts +2 -5
- package/dist/{auth-DU4ShERo.js → auth-CYoPVf-E.js} +1985 -395
- package/dist/auth-CYoPVf-E.js.map +1 -0
- package/dist/{backup-BJiY_mM-.js → backup-C6ljYVTp.js} +2 -2
- package/dist/{backup-BJiY_mM-.js.map → backup-C6ljYVTp.js.map} +1 -1
- package/dist/boot/ddl-bootstrap.d.ts +70 -0
- package/dist/{contract-routes-Dj8i5AiM.js → contract-routes-Bet-eCNJ.js} +3 -3
- package/dist/{contract-routes-Dj8i5AiM.js.map → contract-routes-Bet-eCNJ.js.map} +1 -1
- package/dist/cron/cron-loader.d.ts +26 -1
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +13 -3
- package/dist/cron/define-cron.d.ts +17 -3
- package/dist/cron/index.d.ts +2 -2
- package/dist/{cron-loader-B1S2MCSl.js → cron-loader-YhhQeVBM.js} +43 -8
- package/dist/cron-loader-YhhQeVBM.js.map +1 -0
- package/dist/{cron-routes-D5a9v1HY.js → cron-routes-maM_RlUu.js} +10 -4
- package/dist/cron-routes-maM_RlUu.js.map +1 -0
- package/dist/{cron-scheduler-BhFZWR0T.js → cron-scheduler-DIpYBmZP.js} +20 -8
- package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
- package/dist/{cron-store-Cu4sOseb.js → cron-store-Dvr4Y1sZ.js} +60 -51
- package/dist/cron-store-Dvr4Y1sZ.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/env.d.ts +5 -0
- package/dist/{errors-BpudWAVU.js → errors-EBYiaJ2E.js} +14 -2
- package/dist/errors-EBYiaJ2E.js.map +1 -0
- package/dist/function-loader-DDS1v7YX.js +139 -0
- package/dist/function-loader-DDS1v7YX.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 +3 -2
- package/dist/index.es.js +1893 -714
- 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 +38 -0
- package/dist/{jwt-CzeviDcB.js → jwt-_IFqfTOg.js} +57 -9
- package/dist/jwt-_IFqfTOg.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-D5xsfVJY.js → openapi-generator-DPKtUC9X.js} +326 -55
- package/dist/openapi-generator-DPKtUC9X.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-CRcS3ArS.js +437 -0
- package/dist/schema-editor-routes-CRcS3ArS.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-CKOZBpeJ.js → src-C7rkDGxA.js} +619 -70
- package/dist/src-C7rkDGxA.js.map +1 -0
- package/dist/{src-_qQ3RNCK.js → src-Cz9nMgUR.js} +93 -3
- package/dist/src-Cz9nMgUR.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 +22 -2
- package/dist/utils/logger.d.ts +18 -0
- package/dist/utils/sql.d.ts +11 -6
- package/package.json +6 -5
- package/dist/admin_block-EmIHae6X.js.map +0 -1
- package/dist/auth-DU4ShERo.js.map +0 -1
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/cron-loader-B1S2MCSl.js.map +0 -1
- package/dist/cron-routes-D5a9v1HY.js.map +0 -1
- package/dist/cron-scheduler-BhFZWR0T.js.map +0 -1
- package/dist/cron-store-Cu4sOseb.js.map +0 -1
- package/dist/errors-BpudWAVU.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-CzeviDcB.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/openapi-generator-D5xsfVJY.js.map +0 -1
- package/dist/schema-editor-routes-C1DxDnDR.js +0 -248
- package/dist/schema-editor-routes-C1DxDnDR.js.map +0 -1
- package/dist/src-CKOZBpeJ.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, t as ApiError } from "./errors-
|
|
12
|
-
import { i as generateDownloadToken, n as configureJwt } from "./jwt-
|
|
4
|
+
import { A as RebaseApiError, D as EntityRelation, E as EntityReference, O as GeoPoint, _ as resolveCollectionRelations, b as toSnakeCase, c as collectAllPages, d as CollectionRegistry, f as createDataSourceRegistry, g as findRelation, h as fieldKeyForColumn, j as RebaseClientError, k as Vector, l as paginateFind, m as getEffectiveSecurityRules, n as buildSdkData, o as serializeFilter, p as resolveDataSource, s as serializeLogicalCondition, t as buildRoutedRebaseData, u as resolveFindWindow, v as buildCompositeId, y as resolvePrimaryKeys } from "./src-C7rkDGxA.js";
|
|
5
|
+
import { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath, m as toCanonicalOp, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-Cz9nMgUR.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-Bet-eCNJ.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-CYoPVf-E.js";
|
|
10
|
+
import { t as logger } from "./logger-DfvF_8r-.js";
|
|
11
|
+
import { n as errorHandler, t as ApiError } from "./errors-EBYiaJ2E.js";
|
|
12
|
+
import { i as generateDownloadToken, n as configureJwt } from "./jwt-_IFqfTOg.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-DDS1v7YX.js";
|
|
18
|
+
import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
|
|
19
|
+
import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-YhhQeVBM.js";
|
|
20
|
+
import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-DIpYBmZP.js";
|
|
21
|
+
import { t as createCronRoutes } from "./cron-routes-maM_RlUu.js";
|
|
22
|
+
import { t as createCronStore } from "./cron-store-Dvr4Y1sZ.js";
|
|
23
|
+
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-C6ljYVTp.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"
|
|
@@ -544,6 +472,35 @@ function checkRelation(relation, path, collect) {
|
|
|
544
472
|
for (const field of RELATION_LINK_FIELDS) if (relation[field] !== void 0 && !allowed.includes(field)) collect.error(`${path}.${field}`, `\`${field}\` is not valid on a "${kind}" relation. A "${kind}" takes ${allowed.length ? allowed.map((a) => `\`${a}\``).join(" and ") : "no link field"}.`);
|
|
545
473
|
}
|
|
546
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* `validation.matches`, checked by compiling it.
|
|
477
|
+
*
|
|
478
|
+
* This file is about key *identity* and this is a value — but it is a value
|
|
479
|
+
* with the property the file exists for: no runtime signal. `toPattern` in
|
|
480
|
+
* `write-validation.ts` builds the `RegExp` per request and answers `undefined`
|
|
481
|
+
* when the pattern will not compile, and the caller reads
|
|
482
|
+
* `if (pattern && !pattern.test(value))` — so a pattern with an unclosed
|
|
483
|
+
* bracket does not reject the write, it removes the rule. Every value passes,
|
|
484
|
+
* for the lifetime of the deployment, and the only trace is that the author
|
|
485
|
+
* believes a validation is running.
|
|
486
|
+
*
|
|
487
|
+
* Fatal for the reason a renamed key is fatal: the author wrote a rule to keep
|
|
488
|
+
* something out of their database, and a minute of downtime beats finding out
|
|
489
|
+
* from the data.
|
|
490
|
+
*
|
|
491
|
+
* A `RegExp` literal cannot be wrong here — the engine already compiled it — so
|
|
492
|
+
* only strings are checked.
|
|
493
|
+
*/
|
|
494
|
+
function checkValidationPattern(validation, path, collect) {
|
|
495
|
+
if (!isPlainObject$1(validation)) return;
|
|
496
|
+
const matches = validation.matches;
|
|
497
|
+
if (typeof matches !== "string") return;
|
|
498
|
+
try {
|
|
499
|
+
new RegExp(matches);
|
|
500
|
+
} catch (e) {
|
|
501
|
+
collect.error(`${path}.matches`, `\`matches: ${JSON.stringify(matches)}\` is not a valid regular expression (${e instanceof Error ? e.message : String(e)}). It would compile to nothing at write time, which does not reject values — it silently drops the rule, and every value passes.`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
547
504
|
function checkProperty(property, path, collect) {
|
|
548
505
|
if (typeof property === "function") return;
|
|
549
506
|
if (!isPlainObject$1(property)) {
|
|
@@ -571,6 +528,7 @@ function checkProperty(property, path, collect) {
|
|
|
571
528
|
collect.unknown(`${path}.${key}`, key, `property (\`${String(type)}\`)`);
|
|
572
529
|
}
|
|
573
530
|
if (type === "relation" && property.relation !== void 0) checkRelation(property.relation, `${path}.relation`, collect);
|
|
531
|
+
checkValidationPattern(property.validation, `${path}.validation`, collect);
|
|
574
532
|
if (type === "array") {
|
|
575
533
|
const of = property.of;
|
|
576
534
|
if (Array.isArray(of)) of.forEach((entry, index) => checkProperty(entry, `${path}.of[${index}]`, collect));
|
|
@@ -785,6 +743,20 @@ function isDataDriverDelegate(obj) {
|
|
|
785
743
|
//#endregion
|
|
786
744
|
//#region src/services/routed-realtime-service.ts
|
|
787
745
|
/**
|
|
746
|
+
* Channel/presence/broadcast messages are engine-agnostic pub/sub — which is
|
|
747
|
+
* why they go to a provider that implements them, not to whichever provider a
|
|
748
|
+
* bootstrapper happened to mark `isDefault`.
|
|
749
|
+
*/
|
|
750
|
+
var CHANNEL_MESSAGE_TYPES$1 = /* @__PURE__ */ new Set([
|
|
751
|
+
"join_channel",
|
|
752
|
+
"leave_channel",
|
|
753
|
+
"broadcast",
|
|
754
|
+
"presence_track",
|
|
755
|
+
"presence_untrack",
|
|
756
|
+
"presence_state",
|
|
757
|
+
"channel_history"
|
|
758
|
+
]);
|
|
759
|
+
/**
|
|
788
760
|
* Compose multiple per-engine {@link RealtimeProvider}s into one that routes
|
|
789
761
|
* each subscription to the provider owning the subscribed collection — the
|
|
790
762
|
* realtime counterpart of `buildRoutedRebaseData`.
|
|
@@ -794,8 +766,9 @@ function isDataDriverDelegate(obj) {
|
|
|
794
766
|
* - `subscribe_collection` / `subscribe_entity` → the provider for the
|
|
795
767
|
* collection's data source (by `payload.path`).
|
|
796
768
|
* - `unsubscribe` → forwarded to all providers (a no-op on non-owners).
|
|
797
|
-
* - channel / presence / broadcast → the
|
|
798
|
-
* pub/sub, not bound to an engine
|
|
769
|
+
* - channel / presence / broadcast → the first provider that declares
|
|
770
|
+
* `supportsChannels` (these are global pub/sub, not bound to an engine — but
|
|
771
|
+
* they are not implemented by every engine either).
|
|
799
772
|
* - `addClient` and lifecycle (`onServerReady`/`destroy`/`stopListening`) →
|
|
800
773
|
* all providers (each registers its own ws close handler for cleanup).
|
|
801
774
|
*/
|
|
@@ -809,6 +782,30 @@ function createRoutedRealtimeService(opts) {
|
|
|
809
782
|
const key = resolveKey(path);
|
|
810
783
|
return asWs(providers[key] ?? providers[defaultKey] ?? Object.values(providers)[0]);
|
|
811
784
|
};
|
|
785
|
+
/**
|
|
786
|
+
* The provider channel frames go to, resolved once and reported once.
|
|
787
|
+
*
|
|
788
|
+
* By capability, not by default-ness: the default provider is whichever
|
|
789
|
+
* bootstrapper set `isDefault`, and routing pub/sub there sends every
|
|
790
|
+
* broadcast, presence update and catch-up request into an engine that may
|
|
791
|
+
* not implement any of them. When the default *does* support channels it is
|
|
792
|
+
* still preferred, so single-engine behaviour is unchanged.
|
|
793
|
+
*/
|
|
794
|
+
let channelRouting;
|
|
795
|
+
const forChannels = () => {
|
|
796
|
+
if (!channelRouting) {
|
|
797
|
+
const entries = Object.entries(providers);
|
|
798
|
+
const supports = ([, p]) => asWs(p).supportsChannels === true;
|
|
799
|
+
const chosen = entries.find(([key]) => key === defaultKey && supports([key, providers[key]])) ?? entries.find(supports);
|
|
800
|
+
channelRouting = chosen ? {
|
|
801
|
+
provider: asWs(chosen[1]),
|
|
802
|
+
key: chosen[0]
|
|
803
|
+
} : {};
|
|
804
|
+
if (chosen) logger.info(`📡 [Realtime] Channels, presence and broadcast are served by the "${chosen[0]}" data source.`);
|
|
805
|
+
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.`);
|
|
806
|
+
}
|
|
807
|
+
return channelRouting.provider;
|
|
808
|
+
};
|
|
812
809
|
return {
|
|
813
810
|
addClient(clientId, ws) {
|
|
814
811
|
for (const p of all()) p.addClient?.(clientId, ws);
|
|
@@ -823,6 +820,12 @@ function createRoutedRealtimeService(opts) {
|
|
|
823
820
|
await Promise.all(all().map((p) => p.handleClientMessage(clientId, message, authContext)));
|
|
824
821
|
return;
|
|
825
822
|
}
|
|
823
|
+
if (CHANNEL_MESSAGE_TYPES$1.has(type)) {
|
|
824
|
+
const provider = forChannels();
|
|
825
|
+
if (!provider) throw new Error(`Channels are not supported by any configured data source — "${type}" refused.`);
|
|
826
|
+
await provider.handleClientMessage(clientId, message, authContext);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
826
829
|
await fallback().handleClientMessage(clientId, message, authContext);
|
|
827
830
|
},
|
|
828
831
|
subscribeToCollection(subscriptionId, config, callback) {
|
|
@@ -849,178 +852,22 @@ function createRoutedRealtimeService(opts) {
|
|
|
849
852
|
};
|
|
850
853
|
}
|
|
851
854
|
//#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
|
-
let parsed;
|
|
873
|
-
try {
|
|
874
|
-
parsed = deserializeLogicalCondition(`${type}(${inner})`);
|
|
875
|
-
} catch (e) {
|
|
876
|
-
throw ApiError.badRequest(`Invalid \`${type}\` parameter: ${e instanceof Error ? e.message : String(e)}`, "INVALID_LOGICAL_GROUP");
|
|
877
|
-
}
|
|
878
|
-
return "type" in parsed ? parsed : void 0;
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Parse the `?where=` JSON filter object.
|
|
882
|
-
*
|
|
883
|
-
* This is the dialect the OpenAPI document publishes on every
|
|
884
|
-
* `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
|
|
885
|
-
* `[WhereFilterOp, value]` tuple. It is normalized through the same
|
|
886
|
-
* `deserializeFilter` as the `?field=op.value` params below, so a value that
|
|
887
|
-
* arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
|
|
888
|
-
* scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
|
|
889
|
-
* querystring dialect, JSON carries types — a number stays a number.
|
|
890
|
-
*
|
|
891
|
-
* A malformed value is a 400 rather than a silent drop: dropping the filter
|
|
892
|
-
* would run the read unfiltered and return everything RLS happens to allow.
|
|
893
|
-
*/
|
|
894
|
-
function parseWhereParam(raw) {
|
|
895
|
-
const str = String(raw).trim();
|
|
896
|
-
if (!str) return void 0;
|
|
897
|
-
let parsed;
|
|
898
|
-
try {
|
|
899
|
-
parsed = JSON.parse(str);
|
|
900
|
-
} catch {
|
|
901
|
-
throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
|
|
902
|
-
}
|
|
903
|
-
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");
|
|
904
|
-
const filter = deserializeFilter(parsed);
|
|
905
|
-
return Object.keys(filter).length > 0 ? filter : void 0;
|
|
906
|
-
}
|
|
907
|
-
/**
|
|
908
|
-
* Parse query parameters into QueryOptions
|
|
909
|
-
*/
|
|
910
|
-
function parseQueryOptions(query, limits = {}) {
|
|
911
|
-
const options = {};
|
|
912
|
-
const rawLimit = getLastValue(query.limit);
|
|
913
|
-
const offsetVal = getLastValue(query.offset);
|
|
914
|
-
if (offsetVal) options.offset = parseInt(String(offsetVal));
|
|
915
|
-
const pageVal = getLastValue(query.page);
|
|
916
|
-
if (pageVal) {
|
|
917
|
-
const page = parseInt(String(pageVal));
|
|
918
|
-
const limit = resolveClientListLimit(rawLimit, {
|
|
919
|
-
defaultLimit: limits.defaultLimit,
|
|
920
|
-
maxLimit: limits.maxLimit
|
|
921
|
-
});
|
|
922
|
-
options.offset = (page - 1) * limit;
|
|
923
|
-
}
|
|
924
|
-
const orVal = getLastValue(query.or);
|
|
925
|
-
const andVal = getLastValue(query.and);
|
|
926
|
-
if (orVal) {
|
|
927
|
-
const logical = parseLogicalGroup("or", orVal);
|
|
928
|
-
if (logical) options.logical = logical;
|
|
929
|
-
} else if (andVal) {
|
|
930
|
-
const logical = parseLogicalGroup("and", andVal);
|
|
931
|
-
if (logical) options.logical = logical;
|
|
932
|
-
}
|
|
933
|
-
const reservedQueryKeys = [
|
|
934
|
-
"limit",
|
|
935
|
-
"offset",
|
|
936
|
-
"page",
|
|
937
|
-
"orderBy",
|
|
938
|
-
"include",
|
|
939
|
-
"fields",
|
|
940
|
-
"searchString",
|
|
941
|
-
"vector_search",
|
|
942
|
-
"vector",
|
|
943
|
-
"vector_distance",
|
|
944
|
-
"vector_threshold",
|
|
945
|
-
"or",
|
|
946
|
-
"and",
|
|
947
|
-
"where"
|
|
948
|
-
];
|
|
949
|
-
const filterDict = {};
|
|
950
|
-
for (const [key, rawValue] of Object.entries(query)) {
|
|
951
|
-
if (reservedQueryKeys.includes(key)) continue;
|
|
952
|
-
filterDict[key] = rawValue;
|
|
953
|
-
}
|
|
954
|
-
const whereVal = getLastValue(query.where);
|
|
955
|
-
const where = {
|
|
956
|
-
...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
|
|
957
|
-
...deserializeFilter(filterDict)
|
|
958
|
-
};
|
|
959
|
-
if (Object.keys(where).length > 0) options.where = where;
|
|
960
|
-
const orderByVal = getLastValue(query.orderBy);
|
|
961
|
-
if (orderByVal) try {
|
|
962
|
-
options.orderBy = typeof orderByVal === "string" ? JSON.parse(orderByVal) : orderByVal;
|
|
963
|
-
} catch {
|
|
964
|
-
if (typeof orderByVal === "string") {
|
|
965
|
-
const parsed = deserializeOrderBy(orderByVal);
|
|
966
|
-
if (parsed) options.orderBy = [{
|
|
967
|
-
field: parsed[0],
|
|
968
|
-
direction: parsed[1]
|
|
969
|
-
}];
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
const includeVal = getLastValue(query.include);
|
|
973
|
-
if (includeVal) {
|
|
974
|
-
const includeStr = String(includeVal).trim();
|
|
975
|
-
if (includeStr === "*") options.include = ["*"];
|
|
976
|
-
else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
977
|
-
}
|
|
978
|
-
const fieldsVal = getLastValue(query.fields);
|
|
979
|
-
if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
|
|
980
|
-
const vectorSearchVal = getLastValue(query.vector_search);
|
|
981
|
-
const vectorVal = getLastValue(query.vector);
|
|
982
|
-
if (vectorSearchVal && vectorVal) {
|
|
983
|
-
const vectorStr = String(vectorVal);
|
|
984
|
-
let decoded;
|
|
985
|
-
try {
|
|
986
|
-
decoded = JSON.parse(vectorStr);
|
|
987
|
-
} catch {
|
|
988
|
-
decoded = void 0;
|
|
989
|
-
}
|
|
990
|
-
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");
|
|
991
|
-
const queryVector = decoded;
|
|
992
|
-
const distanceParamVal = getLastValue(query.vector_distance);
|
|
993
|
-
const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
|
|
994
|
-
if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
|
|
995
|
-
const vectorSearch = {
|
|
996
|
-
property: String(vectorSearchVal),
|
|
997
|
-
vector: queryVector,
|
|
998
|
-
distance: distanceParam
|
|
999
|
-
};
|
|
1000
|
-
const thresholdVal = getLastValue(query.vector_threshold);
|
|
1001
|
-
if (thresholdVal) {
|
|
1002
|
-
const threshold = parseFloat(String(thresholdVal));
|
|
1003
|
-
if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
|
|
1004
|
-
vectorSearch.threshold = threshold;
|
|
1005
|
-
}
|
|
1006
|
-
options.vectorSearch = vectorSearch;
|
|
1007
|
-
}
|
|
1008
|
-
options.limit = resolveClientListLimit(rawLimit, {
|
|
1009
|
-
vectorSearch: !!options.vectorSearch,
|
|
1010
|
-
defaultLimit: limits.defaultLimit,
|
|
1011
|
-
maxLimit: limits.maxLimit
|
|
1012
|
-
});
|
|
1013
|
-
return options;
|
|
1014
|
-
}
|
|
1015
|
-
//#endregion
|
|
1016
855
|
//#region src/api/rest/write-validation.ts
|
|
1017
856
|
/**
|
|
1018
857
|
* Reject a write naming a field the collection does not have.
|
|
1019
858
|
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
859
|
+
* An unknown key does *not* reach the INSERT and come back as `column "titel"
|
|
860
|
+
* does not exist`, which is what this comment used to claim. Drizzle builds
|
|
861
|
+
* INSERT and UPDATE from the table's own column list, so a key the table does
|
|
862
|
+
* not carry is left out of the statement: the write answers 201 having stored
|
|
863
|
+
* nothing under that name. A typo is a request problem, it belongs in a 400,
|
|
864
|
+
* and nothing below this was ever going to raise one.
|
|
865
|
+
*
|
|
866
|
+
* This is the config-level check and it is skipped on several paths (see the
|
|
867
|
+
* `strictWrites` bail below, and the auth-adapter contract at the call site).
|
|
868
|
+
* `assertWritableColumns` in the Postgres driver is the backstop every write
|
|
869
|
+
* passes through, including in-process `rebase.data` writes that never come
|
|
870
|
+
* near this layer.
|
|
1024
871
|
*
|
|
1025
872
|
* What counts as known:
|
|
1026
873
|
* - a declared property (for an introspected BaaS collection these *are* the
|
|
@@ -1035,7 +882,7 @@ function assertKnownWriteFields(values, collection, options) {
|
|
|
1035
882
|
if (collection.strictWrites === false) return;
|
|
1036
883
|
if (!collection.properties || Object.keys(collection.properties).length === 0) return;
|
|
1037
884
|
const known = new Set(Object.keys(collection.properties));
|
|
1038
|
-
for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.kind === "belongsTo") known.add(relation.localKey);
|
|
885
|
+
for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.kind === "belongsTo") known.add(fieldKeyForColumn(collection, relation.localKey));
|
|
1039
886
|
for (const field of options?.extraKnownFields ?? []) known.add(field);
|
|
1040
887
|
const unknown = Object.keys(values).filter((key) => !known.has(key));
|
|
1041
888
|
if (unknown.length === 0) return;
|
|
@@ -1048,6 +895,145 @@ function assertKnownWriteFields(values, collection, options) {
|
|
|
1048
895
|
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");
|
|
1049
896
|
}
|
|
1050
897
|
/**
|
|
898
|
+
* The number a constraint should judge, or `undefined` if the value is not one.
|
|
899
|
+
*
|
|
900
|
+
* A JSON body may carry `"5"` for a number column and Postgres accepts it, so
|
|
901
|
+
* the string form is judged too — refusing to look at it would leave the exact
|
|
902
|
+
* bypass this function exists to close.
|
|
903
|
+
*/
|
|
904
|
+
function asNumber(value) {
|
|
905
|
+
if (typeof value === "number") return Number.isNaN(value) ? void 0 : value;
|
|
906
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
907
|
+
const parsed = Number(value);
|
|
908
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/** The instant a constraint should judge, or `undefined` if the value is not one. */
|
|
912
|
+
function asDate(value) {
|
|
913
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? void 0 : value;
|
|
914
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
915
|
+
const parsed = new Date(value);
|
|
916
|
+
return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* `validation.matches` as a regex that can be tested repeatedly.
|
|
921
|
+
*
|
|
922
|
+
* A `RegExp` carrying `g` or `y` keeps `lastIndex` between calls, so the same
|
|
923
|
+
* pattern would accept and reject the same value on alternate rows of a bulk
|
|
924
|
+
* write. Rebuilt without those flags.
|
|
925
|
+
*/
|
|
926
|
+
function toPattern(matches) {
|
|
927
|
+
try {
|
|
928
|
+
if (typeof matches === "string") return new RegExp(matches);
|
|
929
|
+
return new RegExp(matches.source, matches.flags.replace(/[gy]/g, ""));
|
|
930
|
+
} catch {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
function collectViolations(key, property, value, into) {
|
|
935
|
+
if (value === null || value === void 0) return;
|
|
936
|
+
switch (property.type) {
|
|
937
|
+
case "string": {
|
|
938
|
+
const rules = property.validation;
|
|
939
|
+
if (!rules || typeof value !== "string") return;
|
|
940
|
+
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}).`);
|
|
941
|
+
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}).`);
|
|
942
|
+
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}).`);
|
|
943
|
+
if (rules.matches !== void 0) {
|
|
944
|
+
const pattern = toPattern(rules.matches);
|
|
945
|
+
if (pattern && !pattern.test(value)) into.push(rules.matchesMessage ?? `'${key}' does not match the required pattern ${pattern}.`);
|
|
946
|
+
}
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
case "number": {
|
|
950
|
+
const rules = property.validation;
|
|
951
|
+
if (!rules) return;
|
|
952
|
+
const num = asNumber(value);
|
|
953
|
+
if (num === void 0) return;
|
|
954
|
+
if (rules.integer && !Number.isInteger(num)) into.push(`'${key}' must be a whole number (received ${num}).`);
|
|
955
|
+
if (rules.positive && !(num > 0)) into.push(`'${key}' must be positive (received ${num}).`);
|
|
956
|
+
if (rules.negative && !(num < 0)) into.push(`'${key}' must be negative (received ${num}).`);
|
|
957
|
+
if (rules.min !== void 0 && num < rules.min) into.push(`'${key}' must be at least ${rules.min} (received ${num}).`);
|
|
958
|
+
if (rules.max !== void 0 && num > rules.max) into.push(`'${key}' must be at most ${rules.max} (received ${num}).`);
|
|
959
|
+
if (rules.moreThan !== void 0 && !(num > rules.moreThan)) into.push(`'${key}' must be greater than ${rules.moreThan} (received ${num}).`);
|
|
960
|
+
if (rules.lessThan !== void 0 && !(num < rules.lessThan)) into.push(`'${key}' must be less than ${rules.lessThan} (received ${num}).`);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
case "date": {
|
|
964
|
+
const rules = property.validation;
|
|
965
|
+
if (!rules) return;
|
|
966
|
+
const date = asDate(value);
|
|
967
|
+
if (date === void 0) return;
|
|
968
|
+
const min = rules.min !== void 0 ? asDate(rules.min) : void 0;
|
|
969
|
+
const max = rules.max !== void 0 ? asDate(rules.max) : void 0;
|
|
970
|
+
if (min && date.getTime() < min.getTime()) into.push(`'${key}' must not be before ${min.toISOString()}.`);
|
|
971
|
+
if (max && date.getTime() > max.getTime()) into.push(`'${key}' must not be after ${max.toISOString()}.`);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
case "array": {
|
|
975
|
+
if (!Array.isArray(value)) return;
|
|
976
|
+
const rules = property.validation;
|
|
977
|
+
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}).`);
|
|
978
|
+
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}).`);
|
|
979
|
+
const of = property.of;
|
|
980
|
+
if (of && !Array.isArray(of)) value.forEach((item, index) => collectViolations(`${key}[${index}]`, of, item, into));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
case "map": {
|
|
984
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
|
|
985
|
+
const properties = property.properties;
|
|
986
|
+
if (!properties) return;
|
|
987
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
988
|
+
const subProperty = properties[subKey];
|
|
989
|
+
if (subProperty) collectViolations(`${key}.${subKey}`, subProperty, subValue, into);
|
|
990
|
+
}
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
default:
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Reject a write whose values break the constraints the collection declares.
|
|
998
|
+
*
|
|
999
|
+
* `validation.min`, `max`, `matches`, `positive`, `integer` and the date and
|
|
1000
|
+
* array bounds were read by exactly three things: the DDL generators (for
|
|
1001
|
+
* `integer` and for a `varchar` width), and the admin's client-side form. No
|
|
1002
|
+
* layer on the write path looked at them, no `CHECK` constraint was emitted,
|
|
1003
|
+
* and the generated OpenAPI published every one of them as `minimum`,
|
|
1004
|
+
* `maximum`, `minLength`, `maxLength` and `pattern`. So the docs' own example —
|
|
1005
|
+
* `price: { type: "number", validation: { min: 0 } }` — accepted `-5000` with a
|
|
1006
|
+
* 201 while the spec said it would not, and any client or gateway generated
|
|
1007
|
+
* from that spec believed the server was checking.
|
|
1008
|
+
*
|
|
1009
|
+
* Only `required` is left to the database, where it is a NOT NULL: a partial
|
|
1010
|
+
* update legitimately omits keys, and this function sees one request's values,
|
|
1011
|
+
* not the row they land on.
|
|
1012
|
+
*
|
|
1013
|
+
* A value of the wrong *type* is not this function's business — that reaches
|
|
1014
|
+
* Postgres, which types every column and gives a 400 through the driver's
|
|
1015
|
+
* SQLSTATE mapping. Constraints are checked against values of the right type,
|
|
1016
|
+
* plus the numeric-string form a JSON body may carry for a number column.
|
|
1017
|
+
*
|
|
1018
|
+
* Every failing field is reported at once. A form that fixes one field per
|
|
1019
|
+
* round trip is the reason validation errors get ignored.
|
|
1020
|
+
*/
|
|
1021
|
+
function assertWriteValuesValid(values, collection, options) {
|
|
1022
|
+
const properties = collection.properties;
|
|
1023
|
+
if (!properties || !values) return;
|
|
1024
|
+
const violations = [];
|
|
1025
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1026
|
+
const property = properties[key];
|
|
1027
|
+
if (property) collectViolations(key, property, value, violations);
|
|
1028
|
+
}
|
|
1029
|
+
if (violations.length === 0) return;
|
|
1030
|
+
const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
|
|
1031
|
+
throw ApiError.badRequest(`${where}${violations.join(" ")}`, "VALIDATION_CONSTRAINT", {
|
|
1032
|
+
collection: collection.slug,
|
|
1033
|
+
violations
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1051
1037
|
* Narrow response rows to the fields the caller asked for.
|
|
1052
1038
|
*
|
|
1053
1039
|
* `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
|
|
@@ -1078,7 +1064,7 @@ function projectResponseFields(rows, fields, collection, options) {
|
|
|
1078
1064
|
const declared = new Set(Object.keys(collection.properties ?? {}));
|
|
1079
1065
|
for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
1080
1066
|
declared.add(key);
|
|
1081
|
-
if (relation.kind === "belongsTo") declared.add(relation.localKey);
|
|
1067
|
+
if (relation.kind === "belongsTo") declared.add(fieldKeyForColumn(collection, relation.localKey));
|
|
1082
1068
|
}
|
|
1083
1069
|
for (const included of options?.include ?? []) declared.add(included);
|
|
1084
1070
|
declared.add("id");
|
|
@@ -1115,6 +1101,9 @@ function projectResponseFields(rows, fields, collection, options) {
|
|
|
1115
1101
|
* generated on the client, so keying on the id alone would let anyone who
|
|
1116
1102
|
* learned (or guessed) another user's id replay their key and be handed that
|
|
1117
1103
|
* user's row back — a read of someone else's data through a write endpoint.
|
|
1104
|
+
* That is also why an unauthenticated caller gets no idempotency at all: every
|
|
1105
|
+
* anonymous request shares one principal, so honouring a key there would hand
|
|
1106
|
+
* one visitor's stored response to the next one who sent the same string.
|
|
1118
1107
|
*/
|
|
1119
1108
|
var TABLE = "\"rebase\".\"idempotency_keys\"";
|
|
1120
1109
|
/**
|
|
@@ -1125,15 +1114,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
|
|
|
1125
1114
|
*/
|
|
1126
1115
|
var TTL_HOURS = 24;
|
|
1127
1116
|
/**
|
|
1128
|
-
*
|
|
1117
|
+
* How long a claim that has not answered yet is presumed to still be running.
|
|
1118
|
+
*
|
|
1119
|
+
* A claim is taken before the write and given back after it, so every exit the
|
|
1120
|
+
* process does not survive — a rolled pod, an OOM kill, a node drain, a
|
|
1121
|
+
* `complete` that itself fails — leaves the row pending with nobody coming back
|
|
1122
|
+
* for it. Reusing the 24-hour replay window as the lease made that
|
|
1123
|
+
* indistinguishable from a request still in flight, so every retry of that key
|
|
1124
|
+
* was refused for a day: the mechanism turned a write that plain retrying would
|
|
1125
|
+
* have completed into permanent loss, which is worse than not having it at all.
|
|
1126
|
+
*
|
|
1127
|
+
* The number trades the two ways it can be wrong, and both are bounded. Too
|
|
1128
|
+
* short and a genuinely slow write has its key taken over by its own retry —
|
|
1129
|
+
* the duplicate this exists to prevent. Too long and a crashed claim refuses
|
|
1130
|
+
* its retries for that long. A minute outlasts any write the row cap allows
|
|
1131
|
+
* and stays well inside the window the offline queue keeps retrying for.
|
|
1132
|
+
*/
|
|
1133
|
+
var PENDING_LEASE_SECONDS = 60;
|
|
1134
|
+
/**
|
|
1135
|
+
* How long to wait before attempting the table creation again after it failed.
|
|
1129
1136
|
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1137
|
+
* `ensure`'s answer used to be memoised unconditionally, so one transient DDL
|
|
1138
|
+
* failure — a database still starting, a lock on the `rebase` schema, a sibling
|
|
1139
|
+
* pod running the same `CREATE TABLE IF NOT EXISTS` — disabled idempotency on
|
|
1140
|
+
* that pod for the life of the process after a single log line. A failure is
|
|
1141
|
+
* retried instead, and the cooldown is what stops every keyed request re-running
|
|
1142
|
+
* DDL against a database that is genuinely unhappy.
|
|
1143
|
+
*/
|
|
1144
|
+
var ENSURE_RETRY_COOLDOWN_MS = 3e4;
|
|
1145
|
+
/**
|
|
1146
|
+
* The principal a key belongs to, or `undefined` when there is nobody to scope
|
|
1147
|
+
* it to and the key must therefore be ignored.
|
|
1148
|
+
*
|
|
1149
|
+
* Anonymous callers are a supported configuration — `auth.requireAuth: false`
|
|
1150
|
+
* delegates access control to RLS — and they used to share one sentinel
|
|
1151
|
+
* principal, which is exactly the cross-principal replay the note above says
|
|
1152
|
+
* must not happen: two visitors of a public form who send the same key string
|
|
1153
|
+
* are handed each other's stored response. Refusing the key degrades them to no
|
|
1154
|
+
* idempotency, which is what they had before this table existed.
|
|
1134
1155
|
*/
|
|
1135
1156
|
function principal(uid) {
|
|
1136
|
-
return uid && uid.length > 0 ? uid :
|
|
1157
|
+
return uid && uid.length > 0 ? uid : void 0;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* A stable rendering of a JSON value: one body always produces one string,
|
|
1161
|
+
* whatever order its keys arrived in.
|
|
1162
|
+
*
|
|
1163
|
+
* `JSON.stringify` preserves insertion order, so two encodings of the same
|
|
1164
|
+
* object would fingerprint differently and a legitimate retry would be refused
|
|
1165
|
+
* as a reused key. Only JSON types reach here — the value came from `JSON.parse`.
|
|
1166
|
+
*/
|
|
1167
|
+
function stableStringify(value) {
|
|
1168
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
1169
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1170
|
+
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(",")}}`;
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* What a key was claimed *for*, so presenting it on a different request is
|
|
1174
|
+
* caught instead of silently replaying the first one's answer.
|
|
1175
|
+
*
|
|
1176
|
+
* Nothing about the request used to enter the key, so `createMany(rows, { key })`
|
|
1177
|
+
* followed by `deleteMany(ids, { key })` — the reusable business id the docs
|
|
1178
|
+
* recommended — answered the delete with the create's `200` body and deleted
|
|
1179
|
+
* nothing. Method and path separate the routes; the body separates a re-run
|
|
1180
|
+
* carrying corrected rows from the retry of an unanswered one, which is the
|
|
1181
|
+
* difference between "here is your earlier answer" and silently discarding a
|
|
1182
|
+
* correction.
|
|
1183
|
+
*/
|
|
1184
|
+
function requestFingerprint(method, path, body) {
|
|
1185
|
+
return createHash("sha256").update(`${method.toUpperCase()} ${path}\n${stableStringify(body)}`).digest("hex");
|
|
1137
1186
|
}
|
|
1138
1187
|
/**
|
|
1139
1188
|
* Returns `undefined` when the driver cannot run SQL, which disables the whole
|
|
@@ -1150,9 +1199,19 @@ function createIdempotencyStore(driver) {
|
|
|
1150
1199
|
if (!isSQLAdmin(admin)) return void 0;
|
|
1151
1200
|
const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
|
|
1152
1201
|
let ready;
|
|
1202
|
+
let retryEnsureAt = 0;
|
|
1203
|
+
let warnedAnonymous = false;
|
|
1204
|
+
/** Said once per process: it is a deployment fact, not a per-request event. */
|
|
1205
|
+
const warnAnonymous = () => {
|
|
1206
|
+
if (warnedAnonymous) return;
|
|
1207
|
+
warnedAnonymous = true;
|
|
1208
|
+
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.");
|
|
1209
|
+
};
|
|
1153
1210
|
/** Created on first use: most deployments never send a key at all. */
|
|
1154
1211
|
const ensure = () => {
|
|
1155
|
-
|
|
1212
|
+
if (ready) return ready;
|
|
1213
|
+
if (Date.now() < retryEnsureAt) return Promise.resolve(false);
|
|
1214
|
+
ready = (async () => {
|
|
1156
1215
|
try {
|
|
1157
1216
|
await exec("CREATE SCHEMA IF NOT EXISTS rebase");
|
|
1158
1217
|
await exec(`
|
|
@@ -1164,28 +1223,44 @@ function createIdempotencyStore(driver) {
|
|
|
1164
1223
|
PRIMARY KEY (uid, key)
|
|
1165
1224
|
)
|
|
1166
1225
|
`);
|
|
1226
|
+
await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
|
|
1167
1227
|
await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
|
|
1228
|
+
await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
|
|
1168
1229
|
return true;
|
|
1169
1230
|
} catch (error) {
|
|
1231
|
+
retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
|
|
1170
1232
|
logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
|
|
1171
1233
|
return false;
|
|
1172
1234
|
}
|
|
1173
|
-
})()
|
|
1235
|
+
})().then((ok) => {
|
|
1236
|
+
if (!ok) ready = void 0;
|
|
1237
|
+
return ok;
|
|
1238
|
+
});
|
|
1174
1239
|
return ready;
|
|
1175
1240
|
};
|
|
1176
1241
|
return {
|
|
1177
|
-
async claim(key, uid) {
|
|
1178
|
-
|
|
1242
|
+
async claim(key, uid, fingerprint) {
|
|
1243
|
+
const owner = principal(uid);
|
|
1244
|
+
if (key && !owner) warnAnonymous();
|
|
1245
|
+
if (!key || !owner || !await ensure()) return { status: "claimed" };
|
|
1179
1246
|
try {
|
|
1180
|
-
if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
|
|
1181
|
-
VALUES ($1, $2, NULL, NOW())
|
|
1247
|
+
if ((await exec(`INSERT INTO ${TABLE} (key, uid, fingerprint, response, created_at)
|
|
1248
|
+
VALUES ($1, $2, $3, NULL, NOW())
|
|
1182
1249
|
ON CONFLICT (uid, key) DO UPDATE
|
|
1183
|
-
SET response = NULL, created_at = NOW()
|
|
1184
|
-
WHERE ${TABLE}.
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1250
|
+
SET response = NULL, created_at = NOW(), fingerprint = EXCLUDED.fingerprint
|
|
1251
|
+
WHERE (${TABLE}.response IS NULL
|
|
1252
|
+
AND ${TABLE}.created_at < NOW() - INTERVAL '${PENDING_LEASE_SECONDS} seconds')
|
|
1253
|
+
OR (${TABLE}.response IS NOT NULL
|
|
1254
|
+
AND ${TABLE}.created_at < NOW() - INTERVAL '${TTL_HOURS} hours')
|
|
1255
|
+
RETURNING 1 AS claimed`, [
|
|
1256
|
+
key,
|
|
1257
|
+
owner,
|
|
1258
|
+
fingerprint
|
|
1259
|
+
])).length > 0) return { status: "claimed" };
|
|
1260
|
+
const row = (await exec(`SELECT response, response IS NULL AS pending, fingerprint FROM ${TABLE}
|
|
1261
|
+
WHERE uid = $1 AND key = $2`, [owner, key]))[0];
|
|
1188
1262
|
if (!row) return { status: "claimed" };
|
|
1263
|
+
if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
|
|
1189
1264
|
if (row.pending) return { status: "in-flight" };
|
|
1190
1265
|
return {
|
|
1191
1266
|
status: "replay",
|
|
@@ -1196,10 +1271,11 @@ function createIdempotencyStore(driver) {
|
|
|
1196
1271
|
}
|
|
1197
1272
|
},
|
|
1198
1273
|
async complete(key, uid, response) {
|
|
1199
|
-
|
|
1274
|
+
const owner = principal(uid);
|
|
1275
|
+
if (!key || !owner || !await ensure()) return;
|
|
1200
1276
|
try {
|
|
1201
1277
|
await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
|
|
1202
|
-
|
|
1278
|
+
owner,
|
|
1203
1279
|
key,
|
|
1204
1280
|
JSON.stringify(response ?? null)
|
|
1205
1281
|
]);
|
|
@@ -1207,9 +1283,10 @@ function createIdempotencyStore(driver) {
|
|
|
1207
1283
|
} catch {}
|
|
1208
1284
|
},
|
|
1209
1285
|
async release(key, uid) {
|
|
1210
|
-
|
|
1286
|
+
const owner = principal(uid);
|
|
1287
|
+
if (!key || !owner || !await ensure()) return;
|
|
1211
1288
|
try {
|
|
1212
|
-
await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [
|
|
1289
|
+
await exec(`DELETE FROM ${TABLE} WHERE uid = $1 AND key = $2 AND response IS NULL`, [owner, key]);
|
|
1213
1290
|
} catch {}
|
|
1214
1291
|
}
|
|
1215
1292
|
};
|
|
@@ -1234,6 +1311,18 @@ async function parseJsonBody(c) {
|
|
|
1234
1311
|
}
|
|
1235
1312
|
}
|
|
1236
1313
|
/**
|
|
1314
|
+
* A live key presented on a different request than the one it was claimed for.
|
|
1315
|
+
*
|
|
1316
|
+
* Refused rather than replayed, because the replay is the dangerous answer: a
|
|
1317
|
+
* delete sent under the key of an earlier create used to be handed the create's
|
|
1318
|
+
* `200` and its rows, so the caller saw a success for rows that are still
|
|
1319
|
+
* there. A `422` names the mistake instead — the key is well-formed, the
|
|
1320
|
+
* request is not repeatable under it.
|
|
1321
|
+
*/
|
|
1322
|
+
function idempotencyKeyReused(key) {
|
|
1323
|
+
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.`);
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1237
1326
|
* Lightweight REST API generator that leverages existing Rebase DataDriver.
|
|
1238
1327
|
* Supports `include` query parameter for eager-loading relations via Drizzle.
|
|
1239
1328
|
*/
|
|
@@ -1282,17 +1371,52 @@ var RestApiGenerator = class {
|
|
|
1282
1371
|
this.createCollectionRoutes(collection);
|
|
1283
1372
|
});
|
|
1284
1373
|
this.createSubcollectionRoutes();
|
|
1374
|
+
this.createUnmatchedRoute();
|
|
1285
1375
|
return this.router;
|
|
1286
1376
|
}
|
|
1287
1377
|
/**
|
|
1378
|
+
* Answer anything left under `/api/data` in the canonical error envelope.
|
|
1379
|
+
*
|
|
1380
|
+
* Without this, `GET /api/data/nonexistent` fell through to Hono's default
|
|
1381
|
+
* handler and came back as the plain text `404 Not Found` — the one error
|
|
1382
|
+
* on the data API that is not `{"error":{"message","code","requestId"}}`.
|
|
1383
|
+
* A client that does `res.json()` on the error path got a parse failure
|
|
1384
|
+
* where every other 4xx hands it a code, so a mistyped collection name
|
|
1385
|
+
* surfaced as "invalid JSON" rather than "no such collection".
|
|
1386
|
+
*
|
|
1387
|
+
* Registered after every real route, so a request reaches it only when the
|
|
1388
|
+
* slug is unknown *or* the method/sub-path is: those are different
|
|
1389
|
+
* mistakes, and the message says which. The collection list is deliberately
|
|
1390
|
+
* not enumerated — an unauthenticated request to this same URL is answered
|
|
1391
|
+
* 401 before routing, on purpose, and echoing the full set of slugs back
|
|
1392
|
+
* would undo that for any signed-in caller.
|
|
1393
|
+
*
|
|
1394
|
+
* Every answer here is `expected`, so it logs at debug (see
|
|
1395
|
+
* {@link ApiError.expected}). This path used to log *nothing at all* —
|
|
1396
|
+
* Hono's default 404 never reaches `errorHandler` — so routing it through
|
|
1397
|
+
* the handler at warn would have traded a missing error envelope for a
|
|
1398
|
+
* `⚠️` line on every request from any frontend holding a stale slug. The
|
|
1399
|
+
* envelope is the fix; the log volume is not part of it.
|
|
1400
|
+
*/
|
|
1401
|
+
createUnmatchedRoute() {
|
|
1402
|
+
const known = new Set(this.collections.map((collection) => collection.slug));
|
|
1403
|
+
const notFound = (message) => new ApiError(404, "NOT_FOUND", message, void 0, true);
|
|
1404
|
+
this.router.all("/:slug{.*}", (c) => {
|
|
1405
|
+
const slug = (c.req.param("slug") ?? "").split("/")[0];
|
|
1406
|
+
if (!slug) throw notFound("No collection in the request path. Expected /api/data/<collection>.");
|
|
1407
|
+
if (known.has(slug)) throw notFound(`No ${c.req.method} route on collection '${slug}' at this path.`);
|
|
1408
|
+
throw notFound(`Unknown collection '${slug}'. It is not defined in this backend, or its route is not exposed.`);
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1288
1412
|
* Check API key permissions for a collection operation.
|
|
1289
1413
|
* Throws 403 if the key doesn't have the required permission.
|
|
1290
1414
|
* No-ops if the request is not authenticated via an API key.
|
|
1291
1415
|
*/
|
|
1292
|
-
enforceApiKeyPermission(c, collectionSlug) {
|
|
1416
|
+
enforceApiKeyPermission(c, collectionSlug, operationOverride) {
|
|
1293
1417
|
const apiKey = c.get("apiKey");
|
|
1294
1418
|
if (!apiKey) return;
|
|
1295
|
-
const operation = httpMethodToOperation(c.req.method);
|
|
1419
|
+
const operation = operationOverride ?? httpMethodToOperation(c.req.method);
|
|
1296
1420
|
if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
|
|
1297
1421
|
}
|
|
1298
1422
|
/**
|
|
@@ -1313,7 +1437,7 @@ var RestApiGenerator = class {
|
|
|
1313
1437
|
* Needed so a nested write can be checked against a schema at all. Without
|
|
1314
1438
|
* it these routes skipped `assertKnownWriteFields` entirely, which is why a
|
|
1315
1439
|
* typo `POST /posts` rejected with a 400 while the same typo on
|
|
1316
|
-
* `POST /authors/1/posts`
|
|
1440
|
+
* `POST /authors/1/posts` was dropped from the statement and answered 201.
|
|
1317
1441
|
*
|
|
1318
1442
|
* Returns `undefined` rather than throwing when the path cannot be walked:
|
|
1319
1443
|
* the driver raises the authoritative error a moment later, and duplicating
|
|
@@ -1363,6 +1487,7 @@ var RestApiGenerator = class {
|
|
|
1363
1487
|
const queryDict = c.req.queries();
|
|
1364
1488
|
const queryOptions = this.parseQuery(queryDict);
|
|
1365
1489
|
const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
|
|
1490
|
+
const searchExplain = (Array.isArray(queryDict.searchExplain) ? queryDict.searchExplain[queryDict.searchExplain.length - 1] : void 0) === "true";
|
|
1366
1491
|
const driver = this.getScopedDriver(c);
|
|
1367
1492
|
const fetchService = driver.restFetchService;
|
|
1368
1493
|
const entities = fetchService ? await fetchService.fetchCollectionForRest(collection.slug, {
|
|
@@ -1373,6 +1498,7 @@ var RestApiGenerator = class {
|
|
|
1373
1498
|
orderBy: queryOptions.orderBy?.[0]?.field,
|
|
1374
1499
|
order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
|
|
1375
1500
|
searchString,
|
|
1501
|
+
searchExplain,
|
|
1376
1502
|
vectorSearch: queryOptions.vectorSearch
|
|
1377
1503
|
}, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
|
|
1378
1504
|
const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
|
|
@@ -1397,32 +1523,132 @@ var RestApiGenerator = class {
|
|
|
1397
1523
|
if (!entity) throw ApiError.notFound("Entity not found");
|
|
1398
1524
|
return c.json(projectResponseFields([entity], queryOptions.fields, resolvedCollection, { include: queryOptions.include })[0]);
|
|
1399
1525
|
});
|
|
1526
|
+
/**
|
|
1527
|
+
* Run a bulk handler under an idempotency key.
|
|
1528
|
+
*
|
|
1529
|
+
* All three bulk routes need the identical claim-before-write dance —
|
|
1530
|
+
* claim, replay on a repeat, 409 while one is in flight, release on
|
|
1531
|
+
* failure, complete on success — and getting one of those steps wrong
|
|
1532
|
+
* is exactly the bug the key exists to prevent. Written once so the
|
|
1533
|
+
* three cannot drift into three different notions of "already done".
|
|
1534
|
+
*
|
|
1535
|
+
* `body` is what the key is claimed *for*: the same key on a different
|
|
1536
|
+
* request is a caller mistake, and replaying a create's answer to a
|
|
1537
|
+
* delete would report a deletion that never happened.
|
|
1538
|
+
*/
|
|
1539
|
+
const withIdempotency = async (c, body, run, respond) => {
|
|
1540
|
+
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1541
|
+
const uid = c.get("user")?.uid;
|
|
1542
|
+
const store = this.idempotency();
|
|
1543
|
+
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
|
|
1544
|
+
if (claimed?.status === "replay") return respond(claimed.response);
|
|
1545
|
+
if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
|
|
1546
|
+
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");
|
|
1547
|
+
let response;
|
|
1548
|
+
try {
|
|
1549
|
+
response = await run();
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
if (claimed?.status === "claimed" && idempotencyKey && store) await store.release(idempotencyKey, uid);
|
|
1552
|
+
throw error;
|
|
1553
|
+
}
|
|
1554
|
+
if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
|
|
1555
|
+
return respond(response);
|
|
1556
|
+
};
|
|
1557
|
+
/**
|
|
1558
|
+
* Validate the envelope every bulk route shares: an array, non-empty,
|
|
1559
|
+
* of objects, within the row cap.
|
|
1560
|
+
*
|
|
1561
|
+
* The cap is the load-bearing one. A batch is a single transaction that
|
|
1562
|
+
* holds its locks for its whole duration, so an unbounded one is a
|
|
1563
|
+
* self-inflicted outage — and the message names both the limit and the
|
|
1564
|
+
* actual count so the caller can chunk to it rather than guess.
|
|
1565
|
+
*/
|
|
1566
|
+
const assertBulkShape = (items, field, requireObjects) => {
|
|
1567
|
+
if (!Array.isArray(items)) throw ApiError.badRequest(`Expected a JSON body of { ${field}: [...] }.`, "INVALID_BULK_BODY");
|
|
1568
|
+
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");
|
|
1569
|
+
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");
|
|
1570
|
+
};
|
|
1400
1571
|
this.router.post(`${basePath}/bulk`, async (c) => {
|
|
1401
1572
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
1402
1573
|
const driver = this.getScopedDriver(c);
|
|
1403
1574
|
const path = collection.slug;
|
|
1404
1575
|
const body = await parseJsonBody(c);
|
|
1405
|
-
|
|
1406
|
-
|
|
1576
|
+
assertBulkShape(body?.rows, "rows", true);
|
|
1577
|
+
const rows = body.rows;
|
|
1578
|
+
if (rows.length === 0) return c.json({
|
|
1407
1579
|
data: [],
|
|
1408
1580
|
meta: { written: 0 }
|
|
1409
1581
|
});
|
|
1410
|
-
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");
|
|
1411
1582
|
if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
|
|
1412
|
-
const maxRows = this.maxBulkRows;
|
|
1413
|
-
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");
|
|
1414
1583
|
if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
rows: body.rows,
|
|
1419
|
-
collection: resolvedCollection,
|
|
1420
|
-
upsert: body.upsert === true
|
|
1584
|
+
rows.forEach((row, rowIndex) => {
|
|
1585
|
+
assertKnownWriteFields(row, resolvedCollection, { rowIndex });
|
|
1586
|
+
assertWriteValuesValid(row, resolvedCollection, { rowIndex });
|
|
1421
1587
|
});
|
|
1422
|
-
return c
|
|
1423
|
-
|
|
1424
|
-
|
|
1588
|
+
return withIdempotency(c, body, async () => {
|
|
1589
|
+
const written = await driver.saveMany({
|
|
1590
|
+
path,
|
|
1591
|
+
rows,
|
|
1592
|
+
collection: resolvedCollection,
|
|
1593
|
+
upsert: body.upsert === true
|
|
1594
|
+
});
|
|
1595
|
+
return {
|
|
1596
|
+
data: written.map((row) => this.formatResponse(row)),
|
|
1597
|
+
meta: { written: written.length }
|
|
1598
|
+
};
|
|
1599
|
+
}, (result) => c.json(result));
|
|
1600
|
+
});
|
|
1601
|
+
this.router.patch(`${basePath}/bulk`, async (c) => {
|
|
1602
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
1603
|
+
const driver = this.getScopedDriver(c);
|
|
1604
|
+
const path = collection.slug;
|
|
1605
|
+
const body = await parseJsonBody(c);
|
|
1606
|
+
assertBulkShape(body?.updates, "updates", true);
|
|
1607
|
+
const updates = body.updates;
|
|
1608
|
+
if (updates.length === 0) return c.json({
|
|
1609
|
+
data: [],
|
|
1610
|
+
meta: { written: 0 }
|
|
1611
|
+
});
|
|
1612
|
+
if (!driver.updateMany) throw ApiError.badRequest("This collection's data source does not support bulk updates.", "BULK_UNSUPPORTED");
|
|
1613
|
+
updates.forEach((entry, rowIndex) => {
|
|
1614
|
+
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");
|
|
1615
|
+
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");
|
|
1616
|
+
assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
|
|
1617
|
+
assertWriteValuesValid(entry.data, resolvedCollection, { rowIndex });
|
|
1425
1618
|
});
|
|
1619
|
+
return withIdempotency(c, body, async () => {
|
|
1620
|
+
const written = await driver.updateMany({
|
|
1621
|
+
path,
|
|
1622
|
+
updates: updates.map((entry) => ({
|
|
1623
|
+
id: entry.id,
|
|
1624
|
+
values: entry.data
|
|
1625
|
+
})),
|
|
1626
|
+
collection: resolvedCollection
|
|
1627
|
+
});
|
|
1628
|
+
return {
|
|
1629
|
+
data: written.map((row) => this.formatResponse(row)),
|
|
1630
|
+
meta: { written: written.length }
|
|
1631
|
+
};
|
|
1632
|
+
}, (result) => c.json(result));
|
|
1633
|
+
});
|
|
1634
|
+
this.router.post(`${basePath}/bulk/delete`, async (c) => {
|
|
1635
|
+
this.enforceApiKeyPermission(c, collection.slug, "delete");
|
|
1636
|
+
const driver = this.getScopedDriver(c);
|
|
1637
|
+
const path = collection.slug;
|
|
1638
|
+
const body = await parseJsonBody(c);
|
|
1639
|
+
assertBulkShape(body?.ids, "ids", false);
|
|
1640
|
+
const ids = body.ids;
|
|
1641
|
+
if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
|
|
1642
|
+
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");
|
|
1643
|
+
if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
|
|
1644
|
+
return withIdempotency(c, body, async () => {
|
|
1645
|
+
await driver.deleteMany({
|
|
1646
|
+
path,
|
|
1647
|
+
ids,
|
|
1648
|
+
collection: resolvedCollection
|
|
1649
|
+
});
|
|
1650
|
+
return { meta: { deleted: ids.length } };
|
|
1651
|
+
}, (result) => c.json(result));
|
|
1426
1652
|
});
|
|
1427
1653
|
this.router.post(basePath, async (c) => {
|
|
1428
1654
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
@@ -1432,10 +1658,15 @@ var RestApiGenerator = class {
|
|
|
1432
1658
|
const isAuth = collection.auth;
|
|
1433
1659
|
const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
|
|
1434
1660
|
const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
|
|
1435
|
-
if (!isAuthCollection)
|
|
1436
|
-
|
|
1661
|
+
if (!isAuthCollection) {
|
|
1662
|
+
assertKnownWriteFields(body, resolvedCollection);
|
|
1663
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1664
|
+
} else {
|
|
1437
1665
|
const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
|
|
1438
|
-
if (contract?.validate)
|
|
1666
|
+
if (contract?.validate) {
|
|
1667
|
+
assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
|
|
1668
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1669
|
+
}
|
|
1439
1670
|
}
|
|
1440
1671
|
if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
|
|
1441
1672
|
const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
|
|
@@ -1463,8 +1694,9 @@ var RestApiGenerator = class {
|
|
|
1463
1694
|
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1464
1695
|
const uid = c.get("user")?.uid;
|
|
1465
1696
|
const store = this.idempotency();
|
|
1466
|
-
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
|
|
1697
|
+
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
|
|
1467
1698
|
if (claimed?.status === "replay") return c.json(claimed.response, 201);
|
|
1699
|
+
if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
|
|
1468
1700
|
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");
|
|
1469
1701
|
let response;
|
|
1470
1702
|
try {
|
|
@@ -1482,7 +1714,7 @@ var RestApiGenerator = class {
|
|
|
1482
1714
|
if (claimed?.status === "claimed" && idempotencyKey && store) await store.complete(idempotencyKey, uid, response);
|
|
1483
1715
|
return c.json(response, 201);
|
|
1484
1716
|
});
|
|
1485
|
-
|
|
1717
|
+
const updateEntity = async (c) => {
|
|
1486
1718
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
1487
1719
|
const id = c.req.param("id");
|
|
1488
1720
|
const driver = this.getScopedDriver(c);
|
|
@@ -1493,6 +1725,7 @@ var RestApiGenerator = class {
|
|
|
1493
1725
|
})) throw ApiError.notFound("Entity not found");
|
|
1494
1726
|
const body = await parseJsonBody(c);
|
|
1495
1727
|
assertKnownWriteFields(body, resolvedCollection);
|
|
1728
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1496
1729
|
const entity = await driver.save({
|
|
1497
1730
|
path: getCollectionDataPath(collection),
|
|
1498
1731
|
id: String(id),
|
|
@@ -1502,7 +1735,29 @@ var RestApiGenerator = class {
|
|
|
1502
1735
|
});
|
|
1503
1736
|
const response = this.formatResponse(entity);
|
|
1504
1737
|
return c.json(response);
|
|
1505
|
-
}
|
|
1738
|
+
};
|
|
1739
|
+
/**
|
|
1740
|
+
* The verb that matches what this actually does.
|
|
1741
|
+
*
|
|
1742
|
+
* The handler merges: it writes the columns in the body and leaves the
|
|
1743
|
+
* rest alone, which is what the SDK's `update(id, data: Partial<M>)`
|
|
1744
|
+
* sends. PUT was the only route, and PUT means replace — so the
|
|
1745
|
+
* generated OpenAPI spec described a full replacement while the server
|
|
1746
|
+
* performed a merge, and it reused the *create* input schema for it,
|
|
1747
|
+
* `required` fields and all. A client generated from that spec was
|
|
1748
|
+
* given a contract the server does not implement, and a spec-validating
|
|
1749
|
+
* gateway in front of this API would reject partial updates the server
|
|
1750
|
+
* would have accepted.
|
|
1751
|
+
*
|
|
1752
|
+
* PUT stays mounted on the same handler rather than being changed or
|
|
1753
|
+
* removed: every published SDK sends it, and altering its semantics to
|
|
1754
|
+
* be a true replace would silently start nulling columns that callers
|
|
1755
|
+
* had simply omitted for years — a data-loss change disguised as a
|
|
1756
|
+
* standards fix. So PATCH is the honest name, PUT is the compatible one,
|
|
1757
|
+
* and both do the documented thing.
|
|
1758
|
+
*/
|
|
1759
|
+
this.router.patch(`${basePath}/:id`, updateEntity);
|
|
1760
|
+
this.router.put(`${basePath}/:id`, updateEntity);
|
|
1506
1761
|
this.router.delete(`${basePath}/:id`, async (c) => {
|
|
1507
1762
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
1508
1763
|
const id = c.req.param("id");
|
|
@@ -1634,7 +1889,10 @@ var RestApiGenerator = class {
|
|
|
1634
1889
|
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
1635
1890
|
const body = await parseJsonBody(c);
|
|
1636
1891
|
const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1637
|
-
if (targetCollection)
|
|
1892
|
+
if (targetCollection) {
|
|
1893
|
+
assertKnownWriteFields(body, targetCollection);
|
|
1894
|
+
assertWriteValuesValid(body, targetCollection);
|
|
1895
|
+
}
|
|
1638
1896
|
const entity = await driver.save({
|
|
1639
1897
|
path: parsed.collectionPath,
|
|
1640
1898
|
values: body,
|
|
@@ -1643,7 +1901,7 @@ var RestApiGenerator = class {
|
|
|
1643
1901
|
const response = this.formatResponse(entity);
|
|
1644
1902
|
return c.json(response, 201);
|
|
1645
1903
|
});
|
|
1646
|
-
|
|
1904
|
+
const updateNested = async (c, next) => {
|
|
1647
1905
|
const rest = c.req.param("rest");
|
|
1648
1906
|
if (!rest || rest === "undefined") return next();
|
|
1649
1907
|
const rawPath = `${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`;
|
|
@@ -1653,7 +1911,10 @@ var RestApiGenerator = class {
|
|
|
1653
1911
|
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
1654
1912
|
const body = await parseJsonBody(c);
|
|
1655
1913
|
const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1656
|
-
if (targetCollection)
|
|
1914
|
+
if (targetCollection) {
|
|
1915
|
+
assertKnownWriteFields(body, targetCollection);
|
|
1916
|
+
assertWriteValuesValid(body, targetCollection);
|
|
1917
|
+
}
|
|
1657
1918
|
const entity = await driver.save({
|
|
1658
1919
|
path: parsed.collectionPath,
|
|
1659
1920
|
id: parsed.id,
|
|
@@ -1662,7 +1923,9 @@ var RestApiGenerator = class {
|
|
|
1662
1923
|
});
|
|
1663
1924
|
const response = this.formatResponse(entity);
|
|
1664
1925
|
return c.json(response);
|
|
1665
|
-
}
|
|
1926
|
+
};
|
|
1927
|
+
this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
|
|
1928
|
+
this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
|
|
1666
1929
|
this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
|
|
1667
1930
|
const rest = c.req.param("rest");
|
|
1668
1931
|
if (!rest || rest === "undefined") return next();
|
|
@@ -1871,7 +2134,7 @@ function configureMiddlewares(app, basePath, isProduction, config) {
|
|
|
1871
2134
|
app.use(`${basePath}/*`, requestId());
|
|
1872
2135
|
if (config.compression !== false) {
|
|
1873
2136
|
app.use(`${basePath}/*`, responseCompression());
|
|
1874
|
-
logger.
|
|
2137
|
+
logger.debug("Response compression enabled");
|
|
1875
2138
|
}
|
|
1876
2139
|
const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
|
|
1877
2140
|
if (maxBodySize > 0) {
|
|
@@ -1884,11 +2147,11 @@ function configureMiddlewares(app, basePath, isProduction, config) {
|
|
|
1884
2147
|
} }, 413);
|
|
1885
2148
|
}
|
|
1886
2149
|
}));
|
|
1887
|
-
logger.
|
|
2150
|
+
logger.debug("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
|
|
1888
2151
|
}
|
|
1889
2152
|
if (config.csrf?.origin) {
|
|
1890
2153
|
app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
|
|
1891
|
-
logger.
|
|
2154
|
+
logger.debug("CSRF protection enabled");
|
|
1892
2155
|
}
|
|
1893
2156
|
if (!config.corsHandled && !process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn((isProduction ? "[PRODUCTION] " : "") + "No CORS configuration detected (CORS_ORIGINS / FRONTEND_URL not set). If your app does not install its own CORS middleware, the API may accept requests from any origin. Set CORS_ORIGINS to restrict access.");
|
|
1894
2157
|
app.use(`${basePath}/*`, requestLogger());
|
|
@@ -1942,7 +2205,7 @@ var LocalStorageController = class {
|
|
|
1942
2205
|
}
|
|
1943
2206
|
/**
|
|
1944
2207
|
* Get the full filesystem path for a storage path, with a traversal guard
|
|
1945
|
-
* that keeps the result inside the
|
|
2208
|
+
* that keeps the result inside the storage root.
|
|
1946
2209
|
*
|
|
1947
2210
|
* Defaults the bucket the way `putObject` does.
|
|
1948
2211
|
*
|
|
@@ -1953,10 +2216,22 @@ var LocalStorageController = class {
|
|
|
1953
2216
|
* design), and `listObjects` returned an empty page. So the obvious
|
|
1954
2217
|
* `putObject({ key })` → `getObject(key)` did not round-trip and nothing
|
|
1955
2218
|
* said why. One default, applied everywhere, removes the whole class.
|
|
2219
|
+
*
|
|
2220
|
+
* Two boundaries, checked separately, because there are two of them. The
|
|
2221
|
+
* key must stay inside its bucket — that check always worked. The bucket
|
|
2222
|
+
* must stay inside the storage root, and that one did not exist: the guard
|
|
2223
|
+
* derived `bucketPath` from the caller's `bucket` and then asked whether
|
|
2224
|
+
* the result was inside `bucketPath`, so `bucket = "../../etc"` made the
|
|
2225
|
+
* reference point `/etc` and `/etc/passwd` is comfortably inside it. A
|
|
2226
|
+
* guard whose reference point moves with its input is not a guard. The
|
|
2227
|
+
* route boundary rejects such a bucket outright
|
|
2228
|
+
* (`canonicalStorageBucket`); this is the layer underneath that does not
|
|
2229
|
+
* depend on it having run.
|
|
1956
2230
|
*/
|
|
1957
2231
|
getFullPath(storagePath, bucket) {
|
|
1958
|
-
const bucketPath = path$2.
|
|
1959
|
-
|
|
2232
|
+
const bucketPath = path$2.resolve(this.basePath, bucket || "default");
|
|
2233
|
+
if (!bucketPath.startsWith(this.basePath + path$2.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
|
|
2234
|
+
const resolved = path$2.resolve(bucketPath, storagePath);
|
|
1960
2235
|
if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
1961
2236
|
return resolved;
|
|
1962
2237
|
}
|
|
@@ -1972,7 +2247,7 @@ var LocalStorageController = class {
|
|
|
1972
2247
|
}
|
|
1973
2248
|
async putObject({ file, key, metadata, bucket }) {
|
|
1974
2249
|
this.validateFile(file);
|
|
1975
|
-
const usedBucket = bucket
|
|
2250
|
+
const usedBucket = bucket || "default";
|
|
1976
2251
|
const fullStoragePath = key;
|
|
1977
2252
|
const fullPath = this.getFullPath(fullStoragePath, usedBucket);
|
|
1978
2253
|
await this.ensureDir(path$2.dirname(fullPath));
|
|
@@ -2202,38 +2477,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
|
|
|
2202
2477
|
"outside"
|
|
2203
2478
|
]);
|
|
2204
2479
|
/**
|
|
2480
|
+
* A transform request naming parameters outside the declared bounds.
|
|
2481
|
+
*
|
|
2482
|
+
* Surfaced as a 400 by the route. Out-of-range values used to be *clamped*:
|
|
2483
|
+
* `width=99999` silently became 4096 and `format=tiff` silently became webp,
|
|
2484
|
+
* so the caller debugged a wrongly-sized `<img>` in the browser instead of
|
|
2485
|
+
* reading an error. A bound that rewrites its input is invisible, and this
|
|
2486
|
+
* endpoint's bounds are load-bearing — see {@link runTransform}.
|
|
2487
|
+
*/
|
|
2488
|
+
var InvalidTransformOptionsError = class extends Error {
|
|
2489
|
+
constructor(message) {
|
|
2490
|
+
super(message);
|
|
2491
|
+
this.name = "InvalidTransformOptionsError";
|
|
2492
|
+
}
|
|
2493
|
+
};
|
|
2494
|
+
/**
|
|
2495
|
+
* Parse a bounded integer parameter, or throw.
|
|
2496
|
+
*
|
|
2497
|
+
* Strict digits only: `parseInt` accepted `300px` and `12abc`, which is a
|
|
2498
|
+
* second way for a caller to get a transform they did not describe.
|
|
2499
|
+
*/
|
|
2500
|
+
function boundedInt(name, raw, min, max) {
|
|
2501
|
+
if (!/^\d+$/.test(raw)) throw new InvalidTransformOptionsError(`Invalid '${name}': expected an integer between ${min} and ${max}.`);
|
|
2502
|
+
const value = parseInt(raw, 10);
|
|
2503
|
+
if (value < min || value > max) throw new InvalidTransformOptionsError(`Invalid '${name}': must be between ${min} and ${max}, got ${value}.`);
|
|
2504
|
+
return value;
|
|
2505
|
+
}
|
|
2506
|
+
/**
|
|
2205
2507
|
* Parse transform options from URL query parameters.
|
|
2206
2508
|
* Returns `null` when no transformation is requested.
|
|
2509
|
+
*
|
|
2510
|
+
* Throws {@link InvalidTransformOptionsError} for anything outside the bounds
|
|
2511
|
+
* declared here — these are the whole parameter surface of an endpoint that is
|
|
2512
|
+
* reachable anonymously for public objects, so what it accepts has to be a
|
|
2513
|
+
* closed, stated set rather than "whatever we could coerce into range".
|
|
2207
2514
|
*/
|
|
2208
2515
|
function parseTransformOptions(query) {
|
|
2209
2516
|
const opts = {};
|
|
2210
2517
|
let hasTransform = false;
|
|
2211
2518
|
if (query.width) {
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
opts.width = Math.min(w, MAX_DIMENSION);
|
|
2215
|
-
hasTransform = true;
|
|
2216
|
-
}
|
|
2519
|
+
opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
|
|
2520
|
+
hasTransform = true;
|
|
2217
2521
|
}
|
|
2218
2522
|
if (query.height) {
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
opts.height = Math.min(h, MAX_DIMENSION);
|
|
2222
|
-
hasTransform = true;
|
|
2223
|
-
}
|
|
2523
|
+
opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
|
|
2524
|
+
hasTransform = true;
|
|
2224
2525
|
}
|
|
2225
2526
|
if (query.quality) {
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
|
|
2229
|
-
hasTransform = true;
|
|
2230
|
-
}
|
|
2527
|
+
opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
|
|
2528
|
+
hasTransform = true;
|
|
2231
2529
|
}
|
|
2232
|
-
if (query.format
|
|
2530
|
+
if (query.format) {
|
|
2531
|
+
if (!VALID_FORMATS.has(query.format)) throw new InvalidTransformOptionsError(`Invalid 'format': expected one of ${[...VALID_FORMATS].join(", ")}.`);
|
|
2233
2532
|
opts.format = query.format;
|
|
2234
2533
|
hasTransform = true;
|
|
2235
2534
|
}
|
|
2236
|
-
if (query.fit
|
|
2535
|
+
if (query.fit) {
|
|
2536
|
+
if (!VALID_FITS.has(query.fit)) throw new InvalidTransformOptionsError(`Invalid 'fit': expected one of ${[...VALID_FITS].join(", ")}.`);
|
|
2237
2537
|
opts.fit = query.fit;
|
|
2238
2538
|
hasTransform = true;
|
|
2239
2539
|
}
|
|
@@ -2251,9 +2551,72 @@ function isTransformableImage(contentType) {
|
|
|
2251
2551
|
return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
|
|
2252
2552
|
}
|
|
2253
2553
|
/**
|
|
2554
|
+
* A transform refused because the server is already doing as many as it will.
|
|
2555
|
+
*
|
|
2556
|
+
* Surfaced as a 503 by the route.
|
|
2557
|
+
*/
|
|
2558
|
+
var TransformOverloadedError = class extends Error {
|
|
2559
|
+
constructor(message) {
|
|
2560
|
+
super(message);
|
|
2561
|
+
this.name = "TransformOverloadedError";
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
/**
|
|
2565
|
+
* A bounded work queue.
|
|
2566
|
+
*
|
|
2567
|
+
* A transform is seconds of libvips CPU and a full-resolution bitmap in RSS,
|
|
2568
|
+
* and `GET /file/*?width=…` is reachable anonymously for public objects — so
|
|
2569
|
+
* without a bound, a few hundred bytes per request buys unlimited pod CPU, and
|
|
2570
|
+
* enough of them at once buys the heap too. `maxConcurrent` caps the CPU and
|
|
2571
|
+
* memory in flight; `maxQueued` is what keeps an unbounded backlog from
|
|
2572
|
+
* becoming the same problem one level up, refusing fast (503) instead of
|
|
2573
|
+
* accepting work the process will not get to.
|
|
2574
|
+
*/
|
|
2575
|
+
var TransformQueue = class {
|
|
2576
|
+
maxConcurrent;
|
|
2577
|
+
maxQueued;
|
|
2578
|
+
active = 0;
|
|
2579
|
+
waiting = [];
|
|
2580
|
+
constructor(maxConcurrent, maxQueued) {
|
|
2581
|
+
this.maxConcurrent = maxConcurrent;
|
|
2582
|
+
this.maxQueued = maxQueued;
|
|
2583
|
+
}
|
|
2584
|
+
/** Number of tasks running plus waiting. Exposed for tests and metrics. */
|
|
2585
|
+
get depth() {
|
|
2586
|
+
return this.active + this.waiting.length;
|
|
2587
|
+
}
|
|
2588
|
+
async run(work) {
|
|
2589
|
+
if (this.active >= this.maxConcurrent) {
|
|
2590
|
+
if (this.waiting.length >= this.maxQueued) throw new TransformOverloadedError("Image transformation is at capacity. Retry shortly.");
|
|
2591
|
+
await new Promise((resolve) => this.waiting.push(resolve));
|
|
2592
|
+
}
|
|
2593
|
+
this.active++;
|
|
2594
|
+
try {
|
|
2595
|
+
return await work();
|
|
2596
|
+
} finally {
|
|
2597
|
+
this.active--;
|
|
2598
|
+
this.waiting.shift()?.();
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
/**
|
|
2603
|
+
* The queue every transform goes through.
|
|
2604
|
+
*
|
|
2605
|
+
* Small on purpose: transforms are CPU-bound, so more concurrency than cores
|
|
2606
|
+
* buys nothing but resident bitmaps.
|
|
2607
|
+
*/
|
|
2608
|
+
var transformQueue = new TransformQueue(Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length)), 64);
|
|
2609
|
+
/**
|
|
2254
2610
|
* Apply image transformations and return the result buffer + content type.
|
|
2611
|
+
*
|
|
2612
|
+
* Runs on {@link transformQueue}: the work is bounded, and callers past the
|
|
2613
|
+
* bound get {@link TransformOverloadedError} rather than a share of a
|
|
2614
|
+
* saturated CPU.
|
|
2255
2615
|
*/
|
|
2256
2616
|
async function transformImage(buffer, options) {
|
|
2617
|
+
return transformQueue.run(() => runTransform(buffer, options));
|
|
2618
|
+
}
|
|
2619
|
+
async function runTransform(buffer, options) {
|
|
2257
2620
|
let pipeline = (await getSharp())(buffer);
|
|
2258
2621
|
if (options.width || options.height) pipeline = pipeline.resize({
|
|
2259
2622
|
width: options.width,
|
|
@@ -2380,6 +2743,7 @@ var TusHandler = class {
|
|
|
2380
2743
|
this.cleanupTimer = setInterval(() => {
|
|
2381
2744
|
this.cleanupStale();
|
|
2382
2745
|
}, 6e4);
|
|
2746
|
+
this.cleanupTimer.unref?.();
|
|
2383
2747
|
}
|
|
2384
2748
|
/** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
|
|
2385
2749
|
async cleanupStale() {
|
|
@@ -2431,11 +2795,22 @@ var TusHandler = class {
|
|
|
2431
2795
|
if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
|
|
2432
2796
|
if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
|
|
2433
2797
|
const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
|
|
2434
|
-
if (this.authorizeUpload) {
|
|
2435
|
-
const key = metadata.key || metadata.filename || "";
|
|
2436
|
-
await this.authorizeUpload(c, key, metadata.bucket || "default");
|
|
2437
|
-
}
|
|
2438
2798
|
const id = randomUUID$1();
|
|
2799
|
+
const rawKey = metadata.key || metadata.filename || "";
|
|
2800
|
+
let key;
|
|
2801
|
+
try {
|
|
2802
|
+
key = canonicalStorageKey(rawKey) || id;
|
|
2803
|
+
} catch (err) {
|
|
2804
|
+
throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
|
|
2805
|
+
}
|
|
2806
|
+
let bucket;
|
|
2807
|
+
try {
|
|
2808
|
+
bucket = canonicalStorageBucket(metadata.bucket);
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
|
|
2811
|
+
}
|
|
2812
|
+
const storageId = metadata.storageId || c.req.query("storageId") || void 0;
|
|
2813
|
+
if (this.authorizeUpload) await this.authorizeUpload(c, key, bucket || "default", storageId);
|
|
2439
2814
|
const filePath = join(this.tusDir, id);
|
|
2440
2815
|
await writeFile(filePath, Buffer.alloc(0));
|
|
2441
2816
|
const upload = {
|
|
@@ -2445,8 +2820,9 @@ var TusHandler = class {
|
|
|
2445
2820
|
metadata,
|
|
2446
2821
|
createdAt: Date.now(),
|
|
2447
2822
|
filePath,
|
|
2448
|
-
bucket
|
|
2449
|
-
|
|
2823
|
+
bucket,
|
|
2824
|
+
storageId,
|
|
2825
|
+
key,
|
|
2450
2826
|
completed: false
|
|
2451
2827
|
};
|
|
2452
2828
|
this.uploads.set(id, upload);
|
|
@@ -2517,21 +2893,37 @@ var TusHandler = class {
|
|
|
2517
2893
|
});
|
|
2518
2894
|
}
|
|
2519
2895
|
/**
|
|
2520
|
-
* Move a
|
|
2896
|
+
* Move a fully-received upload into the storage controller.
|
|
2897
|
+
*
|
|
2898
|
+
* Everything here used to be best-effort: no controller warned and
|
|
2899
|
+
* returned, a failing `putObject` was caught and logged, and either way the
|
|
2900
|
+
* `PATCH` that carried the last chunk answered `204` with
|
|
2901
|
+
* `Upload-Offset: <size>` — which in TUS is the server saying "I have your
|
|
2902
|
+
* file". The object did not exist. For the one operation a user can spend
|
|
2903
|
+
* minutes on, the failure mode was silent data loss with a success code.
|
|
2904
|
+
*
|
|
2905
|
+
* `completed` is now set only once the bytes are stored, and that matters
|
|
2906
|
+
* twice over: a "completed" upload is refused a retry (`patch` answers
|
|
2907
|
+
* "Upload already completed") and is skipped by the stale sweeper, so
|
|
2908
|
+
* marking it up front both locked the client out of retrying and leaked the
|
|
2909
|
+
* temp file forever. Left false, a client that retries sends an empty chunk
|
|
2910
|
+
* at `offset === size`, which lands straight back here.
|
|
2521
2911
|
*/
|
|
2522
2912
|
async finalize(upload) {
|
|
2523
|
-
|
|
2524
|
-
const storageId = upload.metadata.storageId;
|
|
2913
|
+
const storageId = upload.storageId;
|
|
2525
2914
|
let targetController = this.storageController;
|
|
2526
2915
|
if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
|
|
2527
2916
|
if (!targetController) {
|
|
2528
|
-
logger.
|
|
2529
|
-
|
|
2917
|
+
logger.error("[TUS] Upload received but no StorageController is configured — nothing can be written", {
|
|
2918
|
+
uploadId: upload.id,
|
|
2919
|
+
storageId
|
|
2920
|
+
});
|
|
2921
|
+
throw new ApiError(503, "STORAGE_NOT_CONFIGURED", storageId ? `Storage source "${storageId}" is not configured on this server, so the upload cannot be stored.` : "No storage is configured on this server, so the upload cannot be stored.");
|
|
2530
2922
|
}
|
|
2531
2923
|
try {
|
|
2532
2924
|
const { readFile } = await import("fs/promises");
|
|
2533
2925
|
const data = await readFile(upload.filePath);
|
|
2534
|
-
const fileName = upload.key
|
|
2926
|
+
const fileName = upload.key;
|
|
2535
2927
|
const mimeType = upload.metadata.contentType || upload.metadata.filetype || "application/octet-stream";
|
|
2536
2928
|
const file = new File([new Uint8Array(data)], fileName, { type: mimeType });
|
|
2537
2929
|
await targetController.putObject({
|
|
@@ -2539,6 +2931,7 @@ var TusHandler = class {
|
|
|
2539
2931
|
key: fileName,
|
|
2540
2932
|
bucket: upload.bucket
|
|
2541
2933
|
});
|
|
2934
|
+
upload.completed = true;
|
|
2542
2935
|
try {
|
|
2543
2936
|
await unlink(upload.filePath);
|
|
2544
2937
|
} catch {}
|
|
@@ -2546,6 +2939,8 @@ var TusHandler = class {
|
|
|
2546
2939
|
logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
|
|
2547
2940
|
} catch (err) {
|
|
2548
2941
|
logger.error(`[TUS] Failed to finalize upload ${upload.id}`, { error: err });
|
|
2942
|
+
if (err instanceof ApiError) throw err;
|
|
2943
|
+
throw new ApiError(502, "STORAGE_WRITE_FAILED", `Upload ${upload.id} was received but could not be stored: ` + (err instanceof Error ? err.message : String(err)));
|
|
2549
2944
|
}
|
|
2550
2945
|
}
|
|
2551
2946
|
};
|
|
@@ -2562,6 +2957,82 @@ var TusHandler = class {
|
|
|
2562
2957
|
/** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
|
|
2563
2958
|
var transformCache = new TransformCache();
|
|
2564
2959
|
/**
|
|
2960
|
+
* Transforms currently being computed, keyed the same way the cache is.
|
|
2961
|
+
*
|
|
2962
|
+
* Without it, N concurrent requests for one uncached variant run N full
|
|
2963
|
+
* decode+encode pipelines — the cache only helps the requests that arrive
|
|
2964
|
+
* after the first one has finished. Joining the in-flight promise makes a
|
|
2965
|
+
* thundering herd cost one decode.
|
|
2966
|
+
*/
|
|
2967
|
+
var transformsInFlight = /* @__PURE__ */ new Map();
|
|
2968
|
+
/**
|
|
2969
|
+
* Compute a transform, or join the one already running for this key.
|
|
2970
|
+
*
|
|
2971
|
+
* `loadSource` is only called on a real miss, so the concurrent requests that
|
|
2972
|
+
* join an in-flight transform do not each read the source object either.
|
|
2973
|
+
*/
|
|
2974
|
+
async function transformOnce(cacheKey, options, loadSource) {
|
|
2975
|
+
const cached = transformCache.get(cacheKey);
|
|
2976
|
+
if (cached) return cached;
|
|
2977
|
+
const running = transformsInFlight.get(cacheKey);
|
|
2978
|
+
if (running) return running;
|
|
2979
|
+
const pending = (async () => {
|
|
2980
|
+
try {
|
|
2981
|
+
const result = await transformImage(await loadSource(), options);
|
|
2982
|
+
transformCache.set(cacheKey, result.data, result.contentType);
|
|
2983
|
+
return result;
|
|
2984
|
+
} catch (err) {
|
|
2985
|
+
if (err instanceof TransformOverloadedError) throw ApiError.serviceUnavailable(err.message, "TRANSFORM_OVERLOADED");
|
|
2986
|
+
throw err;
|
|
2987
|
+
}
|
|
2988
|
+
})();
|
|
2989
|
+
transformsInFlight.set(cacheKey, pending);
|
|
2990
|
+
try {
|
|
2991
|
+
return await pending;
|
|
2992
|
+
} finally {
|
|
2993
|
+
transformsInFlight.delete(cacheKey);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Content types served inline. Everything else is handed back as
|
|
2998
|
+
* `application/octet-stream` with `Content-Disposition: attachment`.
|
|
2999
|
+
*
|
|
3000
|
+
* The stored content type is the *uploader's claim* — `putObject` writes
|
|
3001
|
+
* `file.type` from the multipart part, and TUS takes it from a request header;
|
|
3002
|
+
* nothing sniffs the bytes. Echoing that claim back as the response type turns
|
|
3003
|
+
* `/api/storage/file/*` into an HTML hosting endpoint on the API origin, and
|
|
3004
|
+
* where `cookieAuth` is enabled the refresh cookie is `Path=/` on exactly that
|
|
3005
|
+
* origin — so an uploaded page can fetch `/api/auth/refresh` same-origin and
|
|
3006
|
+
* read out a fresh access token, `HttpOnly` notwithstanding.
|
|
3007
|
+
*
|
|
3008
|
+
* `image/svg+xml` is deliberately absent: an SVG is a document that can carry
|
|
3009
|
+
* script. `text/html` and `application/xhtml+xml` are absent for the same
|
|
3010
|
+
* reason, and are what the attack actually uses.
|
|
3011
|
+
*/
|
|
3012
|
+
var INLINE_CONTENT_TYPE_PREFIXES = [
|
|
3013
|
+
"image/",
|
|
3014
|
+
"video/",
|
|
3015
|
+
"audio/"
|
|
3016
|
+
];
|
|
3017
|
+
var INLINE_CONTENT_TYPES = /* @__PURE__ */ new Set(["application/pdf", "text/plain"]);
|
|
3018
|
+
/**
|
|
3019
|
+
* Decide what to actually serve for a stored content type.
|
|
3020
|
+
*
|
|
3021
|
+
* Returns the type to send and whether to force a download. The check is an
|
|
3022
|
+
* allowlist rather than a blocklist of dangerous types: the set of types a
|
|
3023
|
+
* browser will execute grows, and the set we want to render inline does not.
|
|
3024
|
+
*/
|
|
3025
|
+
function resolveServedContentType(storedContentType) {
|
|
3026
|
+
const base = storedContentType.split(";")[0].trim().toLowerCase();
|
|
3027
|
+
return (base.includes("svg") ? false : INLINE_CONTENT_TYPES.has(base) || INLINE_CONTENT_TYPE_PREFIXES.some((p) => base.startsWith(p))) ? {
|
|
3028
|
+
contentType: storedContentType,
|
|
3029
|
+
attachment: false
|
|
3030
|
+
} : {
|
|
3031
|
+
contentType: "application/octet-stream",
|
|
3032
|
+
attachment: true
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
/**
|
|
2565
3036
|
* Extract the wildcard portion of a route path from the full request path.
|
|
2566
3037
|
*
|
|
2567
3038
|
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
@@ -2581,16 +3052,48 @@ function extractWildcardPath(c) {
|
|
|
2581
3052
|
return fullPath.substring(idx + prefix.length + 1);
|
|
2582
3053
|
}
|
|
2583
3054
|
/**
|
|
2584
|
-
*
|
|
2585
|
-
*
|
|
3055
|
+
* Canonicalize a caller-supplied storage key, answering 400 when it names
|
|
3056
|
+
* something other than what it says.
|
|
3057
|
+
*
|
|
3058
|
+
* The single place a request's key becomes canonical. Every route runs its key
|
|
3059
|
+
* through this before anything else touches it, so the authorize hook, the
|
|
3060
|
+
* storage controller and the minted download token are all looking at the same
|
|
3061
|
+
* string — which is the only thing that makes the hook's answer meaningful.
|
|
3062
|
+
* See `keys.ts` for why an unacceptable key is refused rather than repaired.
|
|
2586
3063
|
*/
|
|
2587
|
-
function
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
3064
|
+
function canonicalKeyOrBadRequest(key) {
|
|
3065
|
+
try {
|
|
3066
|
+
return canonicalStorageKey(key);
|
|
3067
|
+
} catch (err) {
|
|
3068
|
+
throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* Canonicalize a caller-supplied bucket name, answering 400 when it is not one.
|
|
3073
|
+
*
|
|
3074
|
+
* The bucket's counterpart to {@link canonicalKeyOrBadRequest}, and it exists
|
|
3075
|
+
* for the same reason: the value routes a write, so it has to be checked where
|
|
3076
|
+
* it enters rather than where it is used. Applied at every entry point a bucket
|
|
3077
|
+
* has — this route's multipart body, the `?bucket=` query, and the TUS
|
|
3078
|
+
* `Upload-Metadata` header.
|
|
3079
|
+
*/
|
|
3080
|
+
function canonicalBucketOrBadRequest(bucket) {
|
|
3081
|
+
try {
|
|
3082
|
+
return canonicalStorageBucket(bucket);
|
|
3083
|
+
} catch (err) {
|
|
3084
|
+
throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
/**
|
|
3088
|
+
* Parse image transform query parameters, answering 400 when they are out of
|
|
3089
|
+
* bounds rather than clamping them to something the caller did not ask for.
|
|
3090
|
+
*/
|
|
3091
|
+
function transformOptionsOrBadRequest(query) {
|
|
3092
|
+
try {
|
|
3093
|
+
return parseTransformOptions(query);
|
|
3094
|
+
} catch (err) {
|
|
3095
|
+
throw new ApiError(400, "INVALID_TRANSFORM_OPTIONS", err instanceof InvalidTransformOptionsError ? err.message : "Invalid image transform options");
|
|
3096
|
+
}
|
|
2594
3097
|
}
|
|
2595
3098
|
/**
|
|
2596
3099
|
* Build adapter-aware auth middleware for storage routes.
|
|
@@ -2694,25 +3197,26 @@ function createStorageRoutes(config) {
|
|
|
2694
3197
|
/**
|
|
2695
3198
|
* Parse bucket and path from a combined file path.
|
|
2696
3199
|
*
|
|
2697
|
-
* The resolved path is
|
|
2698
|
-
*
|
|
2699
|
-
*
|
|
2700
|
-
*
|
|
2701
|
-
*
|
|
2702
|
-
* (
|
|
2703
|
-
*
|
|
2704
|
-
*
|
|
2705
|
-
*
|
|
3200
|
+
* The resolved path is canonicalized here — by the same function the upload
|
|
3201
|
+
* route applies to incoming keys — so read, delete, metadata and folder
|
|
3202
|
+
* routes agree with the write side about what a key means before it reaches
|
|
3203
|
+
* the controller, the authorize hook, or the download token it mints.
|
|
3204
|
+
*
|
|
3205
|
+
* A key that cannot be canonicalized (a real `..` segment, a null byte) is
|
|
3206
|
+
* a 400, not a repaired key. The `LocalStorageController` traversal guard
|
|
3207
|
+
* (`getFullPath`) is the load-bearing defence for the *storage root*, and
|
|
3208
|
+
* `canonicalStorageBucket` for the bucket the caller names; this is what
|
|
3209
|
+
* defends the boundary the hook drew inside them.
|
|
2706
3210
|
*/
|
|
2707
3211
|
const parseBucketAndPath = (filePath) => {
|
|
2708
3212
|
const parts = filePath.split("/");
|
|
2709
3213
|
if (parts.length > 1 && parts[0].toLowerCase() === "default") return {
|
|
2710
3214
|
bucket: "default",
|
|
2711
|
-
resolvedPath:
|
|
3215
|
+
resolvedPath: canonicalKeyOrBadRequest(parts.slice(1).join("/"))
|
|
2712
3216
|
};
|
|
2713
3217
|
return {
|
|
2714
3218
|
bucket: "default",
|
|
2715
|
-
resolvedPath:
|
|
3219
|
+
resolvedPath: canonicalKeyOrBadRequest(filePath)
|
|
2716
3220
|
};
|
|
2717
3221
|
};
|
|
2718
3222
|
/**
|
|
@@ -2725,9 +3229,9 @@ function createStorageRoutes(config) {
|
|
|
2725
3229
|
const uploadedFile = body["file"];
|
|
2726
3230
|
if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
|
|
2727
3231
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2728
|
-
const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
|
|
3232
|
+
const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
|
|
2729
3233
|
const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
|
|
2730
|
-
const finalKey =
|
|
3234
|
+
const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
|
|
2731
3235
|
const metadata = {};
|
|
2732
3236
|
for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
|
|
2733
3237
|
await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
|
|
@@ -2753,15 +3257,13 @@ function createStorageRoutes(config) {
|
|
|
2753
3257
|
const filePath = decodeURIComponent(rawPath);
|
|
2754
3258
|
const storageId = c.req.query("storageId");
|
|
2755
3259
|
const resolved = resolveController(storageId);
|
|
2756
|
-
{
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
}
|
|
2760
|
-
const transformOpts =
|
|
3260
|
+
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
3261
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
3262
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
3263
|
+
const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
|
|
3264
|
+
const transformOpts = transformOptionsOrBadRequest(c.req.query());
|
|
2761
3265
|
if (resolved.getType() === "local") {
|
|
2762
|
-
const
|
|
2763
|
-
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
2764
|
-
const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
|
|
3266
|
+
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
|
|
2765
3267
|
try {
|
|
2766
3268
|
await fsp.access(absolutePath);
|
|
2767
3269
|
} catch {
|
|
@@ -2773,37 +3275,30 @@ function createStorageRoutes(config) {
|
|
|
2773
3275
|
const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
|
|
2774
3276
|
contentType = JSON.parse(metadataRaw).contentType || contentType;
|
|
2775
3277
|
} catch {}
|
|
2776
|
-
const fileContent = await fsp.readFile(absolutePath);
|
|
2777
3278
|
if (transformOpts && isTransformableImage(contentType)) {
|
|
2778
|
-
const
|
|
2779
|
-
|
|
2780
|
-
if (!cached) {
|
|
2781
|
-
cached = await transformImage(Buffer.from(fileContent), transformOpts);
|
|
2782
|
-
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
2783
|
-
}
|
|
2784
|
-
c.header("Content-Type", cached.contentType);
|
|
3279
|
+
const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)));
|
|
3280
|
+
c.header("Content-Type", transformed.contentType);
|
|
2785
3281
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2786
|
-
return c.body(new Uint8Array(
|
|
3282
|
+
return c.body(new Uint8Array(transformed.data));
|
|
2787
3283
|
}
|
|
2788
|
-
|
|
3284
|
+
const fileContent = await fsp.readFile(absolutePath);
|
|
3285
|
+
const served = resolveServedContentType(contentType);
|
|
3286
|
+
c.header("Content-Type", served.contentType);
|
|
3287
|
+
if (served.attachment) c.header("Content-Disposition", "attachment");
|
|
2789
3288
|
return c.body(new Uint8Array(fileContent));
|
|
2790
3289
|
}
|
|
2791
|
-
const
|
|
2792
|
-
const fileObject = await resolved.getObject(parsedPath, parsedBucket);
|
|
3290
|
+
const fileObject = await resolved.getObject(resolvedPath, bucket);
|
|
2793
3291
|
if (!fileObject) throw ApiError.notFound("File not found");
|
|
2794
3292
|
const remoteContentType = fileObject.type || "application/octet-stream";
|
|
2795
3293
|
if (transformOpts && isTransformableImage(remoteContentType)) {
|
|
2796
|
-
const
|
|
2797
|
-
|
|
2798
|
-
if (!cached) {
|
|
2799
|
-
cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
|
|
2800
|
-
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
2801
|
-
}
|
|
2802
|
-
c.header("Content-Type", cached.contentType);
|
|
3294
|
+
const transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()));
|
|
3295
|
+
c.header("Content-Type", transformed.contentType);
|
|
2803
3296
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2804
|
-
return c.body(new Uint8Array(
|
|
3297
|
+
return c.body(new Uint8Array(transformed.data));
|
|
2805
3298
|
}
|
|
2806
|
-
|
|
3299
|
+
const servedRemote = resolveServedContentType(remoteContentType);
|
|
3300
|
+
c.header("Content-Type", servedRemote.contentType);
|
|
3301
|
+
if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
|
|
2807
3302
|
c.header("Cache-Control", "public, max-age=3600, immutable");
|
|
2808
3303
|
const buf = await fileObject.arrayBuffer();
|
|
2809
3304
|
return c.body(new Uint8Array(buf));
|
|
@@ -2862,8 +3357,8 @@ function createStorageRoutes(config) {
|
|
|
2862
3357
|
* GET /list - List files in a path
|
|
2863
3358
|
*/
|
|
2864
3359
|
router.get("/list", writeAuthMiddleware, async (c) => {
|
|
2865
|
-
const storagePrefix =
|
|
2866
|
-
const bucket = c.req.query("bucket");
|
|
3360
|
+
const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
|
|
3361
|
+
const bucket = canonicalBucketOrBadRequest(c.req.query("bucket"));
|
|
2867
3362
|
const maxResults = c.req.query("maxResults");
|
|
2868
3363
|
const pageToken = c.req.query("pageToken");
|
|
2869
3364
|
const storageId = c.req.query("storageId");
|
|
@@ -2909,8 +3404,8 @@ function createStorageRoutes(config) {
|
|
|
2909
3404
|
}, 201);
|
|
2910
3405
|
});
|
|
2911
3406
|
const defaultCtrl = getDefaultController();
|
|
2912
|
-
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
|
|
2913
|
-
await checkAuthorized(c, "write",
|
|
3407
|
+
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket, storageId) => {
|
|
3408
|
+
await checkAuthorized(c, "write", key, bucket, storageId);
|
|
2914
3409
|
} : void 0);
|
|
2915
3410
|
tusHandler.startCleanup();
|
|
2916
3411
|
router.options("/tus", (_c) => tusHandler.options());
|
|
@@ -3047,7 +3542,7 @@ async function createStorageController(config) {
|
|
|
3047
3542
|
//#region src/init/storage.ts
|
|
3048
3543
|
async function initializeStorage(storageConfig, isProduction) {
|
|
3049
3544
|
if (!storageConfig) return {};
|
|
3050
|
-
logger.
|
|
3545
|
+
logger.debug("Configuring storage");
|
|
3051
3546
|
const controllers = {};
|
|
3052
3547
|
const toController = async (entry, label) => {
|
|
3053
3548
|
if (typeof entry.putObject === "function") return entry;
|
|
@@ -3104,11 +3599,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
3104
3599
|
}
|
|
3105
3600
|
//#endregion
|
|
3106
3601
|
//#region src/init/docs.ts
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3602
|
+
/**
|
|
3603
|
+
* Serve the OpenAPI document, and Swagger UI outside production.
|
|
3604
|
+
*
|
|
3605
|
+
* `serverCollections` must be the collections the REST routes were generated
|
|
3606
|
+
* from — the ones whose data source has a `"server"` transport — not every
|
|
3607
|
+
* active collection. A direct-transport collection is served by the client
|
|
3608
|
+
* against its own backend; documenting it here publishes a full set of CRUD
|
|
3609
|
+
* paths that 404, with a Try-It button next to each.
|
|
3610
|
+
*/
|
|
3611
|
+
async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections, requireAuth) {
|
|
3612
|
+
if (enableSwagger === false || serverCollections.length === 0) return;
|
|
3613
|
+
const { generateOpenApiSpec } = await import("./openapi-generator-DPKtUC9X.js");
|
|
3110
3614
|
app.get(`${basePath}/docs`, (c) => {
|
|
3111
|
-
const spec = generateOpenApiSpec(
|
|
3615
|
+
const spec = generateOpenApiSpec(serverCollections, {
|
|
3112
3616
|
basePath,
|
|
3113
3617
|
requireAuth
|
|
3114
3618
|
});
|
|
@@ -3283,6 +3787,54 @@ function createShutdown(config) {
|
|
|
3283
3787
|
};
|
|
3284
3788
|
}
|
|
3285
3789
|
//#endregion
|
|
3790
|
+
//#region src/init/process-safety.ts
|
|
3791
|
+
/**
|
|
3792
|
+
* Installed once per process. The singleton is process-global for the same
|
|
3793
|
+
* reason (see `singleton.ts`): more than one copy of this module can be loaded
|
|
3794
|
+
* — the image ships one at `/app/node_modules` and a project's bundle installs
|
|
3795
|
+
* another — and each copy would otherwise add its own listener.
|
|
3796
|
+
*/
|
|
3797
|
+
var HANDLER_SLOT = Symbol.for("@rebasepro/server:unhandled-rejection-handler");
|
|
3798
|
+
/**
|
|
3799
|
+
* Log unhandled promise rejections instead of letting Node terminate the
|
|
3800
|
+
* process.
|
|
3801
|
+
*
|
|
3802
|
+
* Node has terminated on an unhandled rejection since v15. Hono catches
|
|
3803
|
+
* anything thrown or rejected inside an awaited handler, so the ordinary
|
|
3804
|
+
* request path is covered — but a fire-and-forget `void syncToCrm(body)`, a
|
|
3805
|
+
* `setTimeout(async () => …)`, or an unawaited `.then()` in one custom function
|
|
3806
|
+
* rejects outside Hono's frame. The default outcome is that one project's
|
|
3807
|
+
* floating promise ends the process; on the managed runtime that process is
|
|
3808
|
+
* shared, so every other tenant on the pod goes with it, and the request log
|
|
3809
|
+
* points nowhere because the request itself succeeded.
|
|
3810
|
+
*
|
|
3811
|
+
* A logged rejection is the better trade here: the process keeps serving, and
|
|
3812
|
+
* the stack reaches the operator. `uncaughtException` is deliberately **not**
|
|
3813
|
+
* handled — a synchronous throw that escaped every frame leaves the process in
|
|
3814
|
+
* a state we cannot reason about, and swallowing it would be a worse bargain
|
|
3815
|
+
* than a restart.
|
|
3816
|
+
*
|
|
3817
|
+
* Set `REBASE_EXIT_ON_UNHANDLED_REJECTION=1` to restore Node's default.
|
|
3818
|
+
*
|
|
3819
|
+
* @returns An uninstall function (used by tests), or `undefined` when nothing
|
|
3820
|
+
* was installed.
|
|
3821
|
+
*/
|
|
3822
|
+
function installUnhandledRejectionHandler() {
|
|
3823
|
+
if (process.env.REBASE_EXIT_ON_UNHANDLED_REJECTION === "1") return void 0;
|
|
3824
|
+
const slot = globalThis;
|
|
3825
|
+
if (slot[HANDLER_SLOT]) return void 0;
|
|
3826
|
+
const handler = (reason) => {
|
|
3827
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
3828
|
+
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 });
|
|
3829
|
+
};
|
|
3830
|
+
slot[HANDLER_SLOT] = handler;
|
|
3831
|
+
process.on("unhandledRejection", handler);
|
|
3832
|
+
return () => {
|
|
3833
|
+
process.removeListener("unhandledRejection", handler);
|
|
3834
|
+
delete slot[HANDLER_SLOT];
|
|
3835
|
+
};
|
|
3836
|
+
}
|
|
3837
|
+
//#endregion
|
|
3286
3838
|
//#region src/auth/collection-callback-warning.ts
|
|
3287
3839
|
/** The data callbacks the auth write path does not run. */
|
|
3288
3840
|
var DATA_CALLBACKS = [
|
|
@@ -3403,7 +3955,17 @@ function buildQueryString(params) {
|
|
|
3403
3955
|
const wire = serializeOrderBy(params.orderBy);
|
|
3404
3956
|
if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
|
|
3405
3957
|
}
|
|
3406
|
-
if (params.searchString)
|
|
3958
|
+
if (params.searchString) {
|
|
3959
|
+
parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
|
|
3960
|
+
if (params.searchExplain) parts.push("searchExplain=true");
|
|
3961
|
+
}
|
|
3962
|
+
if (params.vectorSearch) {
|
|
3963
|
+
const vs = params.vectorSearch;
|
|
3964
|
+
parts.push(`vector_search=${encodeURIComponent(vs.property)}`);
|
|
3965
|
+
parts.push(`vector=${encodeURIComponent(JSON.stringify(vs.vector))}`);
|
|
3966
|
+
if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
|
|
3967
|
+
if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
|
|
3968
|
+
}
|
|
3407
3969
|
if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
|
|
3408
3970
|
if (params.logical) {
|
|
3409
3971
|
const root = params.logical;
|
|
@@ -3479,6 +4041,23 @@ function createTransport(config, environment) {
|
|
|
3479
4041
|
...init?.headers || {}
|
|
3480
4042
|
};
|
|
3481
4043
|
}
|
|
4044
|
+
/**
|
|
4045
|
+
* The refusal for a success status carrying a body this client cannot read.
|
|
4046
|
+
*
|
|
4047
|
+
* The first 120 characters go in the message because they identify the
|
|
4048
|
+
* sender at a glance: `<!doctype html>` says "you are talking to a web
|
|
4049
|
+
* server, not to this API" faster than any wording here could.
|
|
4050
|
+
*
|
|
4051
|
+
* One function for both the first attempt and the post-refresh retry — the
|
|
4052
|
+
* retry is a second copy of this whole response-reading path, and copies
|
|
4053
|
+
* are how one of them ends up fixed and the other not.
|
|
4054
|
+
*/
|
|
4055
|
+
function unreadableResponse(status, text) {
|
|
4056
|
+
return new RebaseApiError(`The server answered ${status} with a body that is not JSON, so there is nothing to return. This usually means the request reached something other than the Rebase API — a single-page-app fallback serving index.html, or a proxy error page — so check the API URL configuration (e.g. VITE_API_URL). The body began: ${JSON.stringify(text.slice(0, 120))}`, {
|
|
4057
|
+
status,
|
|
4058
|
+
code: "INVALID_JSON_RESPONSE"
|
|
4059
|
+
});
|
|
4060
|
+
}
|
|
3482
4061
|
async function request(path, init) {
|
|
3483
4062
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
3484
4063
|
let activeToken = token;
|
|
@@ -3496,9 +4075,27 @@ function createTransport(config, environment) {
|
|
|
3496
4075
|
if (res.status === 204) return void 0;
|
|
3497
4076
|
const text = await res.text().catch(() => "");
|
|
3498
4077
|
let body = {};
|
|
4078
|
+
/**
|
|
4079
|
+
* Whether the body was there and could not be read as JSON.
|
|
4080
|
+
*
|
|
4081
|
+
* On an error status this does not matter — the status is the answer
|
|
4082
|
+
* and the message falls back to `statusText`. On a *success* status it
|
|
4083
|
+
* is the whole answer, and `{}` was being returned as though the server
|
|
4084
|
+
* had sent it: `find()` answered `{}` instead of an array, `getOne()`
|
|
4085
|
+
* an empty object, with nothing thrown.
|
|
4086
|
+
*
|
|
4087
|
+
* The case that produces it is not exotic. Point `VITE_API_URL` at the
|
|
4088
|
+
* frontend's own host and `/api/data/posts` lands on the SPA fallback,
|
|
4089
|
+
* which answers `200` with `index.html` — so the misconfiguration the
|
|
4090
|
+
* 404 branch below spends four lines explaining reaches the caller, in
|
|
4091
|
+
* its most common form, as an empty success.
|
|
4092
|
+
*/
|
|
4093
|
+
let unreadableBody = false;
|
|
3499
4094
|
if (text) try {
|
|
3500
4095
|
body = JSON.parse(text, rebaseReviver);
|
|
3501
|
-
} catch (e) {
|
|
4096
|
+
} catch (e) {
|
|
4097
|
+
unreadableBody = true;
|
|
4098
|
+
}
|
|
3502
4099
|
const getErrorField = (obj, field) => {
|
|
3503
4100
|
const err = obj?.error;
|
|
3504
4101
|
if (err && typeof err === "object" && err !== null) return err[field];
|
|
@@ -3518,9 +4115,12 @@ function createTransport(config, environment) {
|
|
|
3518
4115
|
if (retryRes.status === 204) return void 0;
|
|
3519
4116
|
const retryText = await retryRes.text().catch(() => "");
|
|
3520
4117
|
let retryBody = {};
|
|
4118
|
+
let retryUnreadable = false;
|
|
3521
4119
|
if (retryText) try {
|
|
3522
4120
|
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
3523
|
-
} catch (e) {
|
|
4121
|
+
} catch (e) {
|
|
4122
|
+
retryUnreadable = true;
|
|
4123
|
+
}
|
|
3524
4124
|
if (!retryRes.ok) {
|
|
3525
4125
|
let fallbackMessage = retryRes.statusText;
|
|
3526
4126
|
if (retryRes.status === 404 && !fallbackMessage) fallbackMessage = `Endpoint not found (${init?.method || "GET"} ${path}). This usually means the collection is not registered on the backend, or the frontend API URL configuration (e.g. VITE_API_URL) is missing or pointing to the wrong host.`;
|
|
@@ -3530,6 +4130,7 @@ function createTransport(config, environment) {
|
|
|
3530
4130
|
details: getErrorField(retryBody, "details")
|
|
3531
4131
|
});
|
|
3532
4132
|
}
|
|
4133
|
+
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
3533
4134
|
return retryBody;
|
|
3534
4135
|
}
|
|
3535
4136
|
}
|
|
@@ -3542,6 +4143,7 @@ function createTransport(config, environment) {
|
|
|
3542
4143
|
details: getErrorField(body, "details")
|
|
3543
4144
|
});
|
|
3544
4145
|
}
|
|
4146
|
+
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
3545
4147
|
return body;
|
|
3546
4148
|
}
|
|
3547
4149
|
return {
|
|
@@ -4505,15 +5107,61 @@ var SDKQueryBuilder = class {
|
|
|
4505
5107
|
/**
|
|
4506
5108
|
* Skip the first N results.
|
|
4507
5109
|
*/
|
|
4508
|
-
offset(count) {
|
|
4509
|
-
this.params.offset = count;
|
|
5110
|
+
offset(count) {
|
|
5111
|
+
this.params.offset = count;
|
|
5112
|
+
return this;
|
|
5113
|
+
}
|
|
5114
|
+
/**
|
|
5115
|
+
* Set a free-text search string if supported by the backend.
|
|
5116
|
+
*
|
|
5117
|
+
* By default this is a substring match across the collection's top-level
|
|
5118
|
+
* string properties. A Postgres collection that declares a `search` block
|
|
5119
|
+
* gets ranked full-text matching over the fields it named instead, and each
|
|
5120
|
+
* row comes back with a `_score` you can sort on:
|
|
5121
|
+
*
|
|
5122
|
+
* ```ts
|
|
5123
|
+
* client.data.talents.search("auditor iso 14001").orderBy("_score", "desc").find()
|
|
5124
|
+
* ```
|
|
5125
|
+
*
|
|
5126
|
+
* Pass `{ explain: true }` to have each row report which of the declared
|
|
5127
|
+
* fields matched, with a highlighted snippet, on `_matches`:
|
|
5128
|
+
*
|
|
5129
|
+
* ```ts
|
|
5130
|
+
* const { data } = await client.data.talents.search("iso 14001", { explain: true }).find();
|
|
5131
|
+
* data[0]._matches
|
|
5132
|
+
* // [{ field: "questionnaire.certifications", snippet: "<mark>ISO</mark> <mark>14001</mark> Lead Auditor" }]
|
|
5133
|
+
* ```
|
|
5134
|
+
*/
|
|
5135
|
+
search(searchString, options) {
|
|
5136
|
+
this.params.searchString = searchString;
|
|
5137
|
+
if (options?.explain !== void 0) this.params.searchExplain = options.explain;
|
|
4510
5138
|
return this;
|
|
4511
5139
|
}
|
|
4512
5140
|
/**
|
|
4513
|
-
*
|
|
5141
|
+
* Order rows by nearest-neighbour distance to `vector`.
|
|
5142
|
+
*
|
|
5143
|
+
* The server has supported this from the REST layer since vectors landed;
|
|
5144
|
+
* this is the SDK reaching it. Results come back closest-first with a
|
|
5145
|
+
* `_distance` on each row, and any `where` / `orderBy` on the same query is
|
|
5146
|
+
* a filter applied before the ordering — distance decides the order.
|
|
5147
|
+
*
|
|
5148
|
+
* You supply the query vector. Rebase stores and searches embeddings; it
|
|
5149
|
+
* does not produce them, so this is where whatever model you already use
|
|
5150
|
+
* for the stored vectors gets called.
|
|
5151
|
+
*
|
|
5152
|
+
* @param property - Name of the `vector` property to compare against.
|
|
5153
|
+
* @param vector - The query embedding. Its length must match the property's
|
|
5154
|
+
* declared `dimensions`, or the server answers 400.
|
|
5155
|
+
* @example
|
|
5156
|
+
* client.data.docs.vectorSearch("embedding", queryVector, { threshold: 0.35 }).limit(10).find()
|
|
4514
5157
|
*/
|
|
4515
|
-
|
|
4516
|
-
this.params.
|
|
5158
|
+
vectorSearch(property, vector, options) {
|
|
5159
|
+
this.params.vectorSearch = {
|
|
5160
|
+
property,
|
|
5161
|
+
vector,
|
|
5162
|
+
...options?.distance !== void 0 && { distance: options.distance },
|
|
5163
|
+
...options?.threshold !== void 0 && { threshold: options.threshold }
|
|
5164
|
+
};
|
|
4517
5165
|
return this;
|
|
4518
5166
|
}
|
|
4519
5167
|
/**
|
|
@@ -4549,6 +5197,11 @@ var SDKQueryBuilder = class {
|
|
|
4549
5197
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
4550
5198
|
}
|
|
4551
5199
|
};
|
|
5200
|
+
/**
|
|
5201
|
+
* Counts currently in flight, keyed by the exact request they issue. Entries
|
|
5202
|
+
* live only for the duration of the request — see `count()` for why.
|
|
5203
|
+
*/
|
|
5204
|
+
var inflightCounts = /* @__PURE__ */ new Map();
|
|
4552
5205
|
function createCollectionClient(transport, slug, ws) {
|
|
4553
5206
|
const basePath = `/data/${slug}`;
|
|
4554
5207
|
const client = {
|
|
@@ -4593,18 +5246,63 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4593
5246
|
body: JSON.stringify({
|
|
4594
5247
|
rows: data,
|
|
4595
5248
|
...options?.upsert ? { upsert: true } : {}
|
|
4596
|
-
})
|
|
5249
|
+
}),
|
|
5250
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
4597
5251
|
})).data || [];
|
|
4598
5252
|
},
|
|
5253
|
+
/**
|
|
5254
|
+
* Still `PUT`, deliberately, even though the server now serves `PATCH`
|
|
5255
|
+
* on the same handler and `PATCH` is the honest verb for a merge.
|
|
5256
|
+
*
|
|
5257
|
+
* The two are interchangeable server-side, so switching buys nothing at
|
|
5258
|
+
* runtime — and it costs compatibility in the direction that fails
|
|
5259
|
+
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
5260
|
+
* a route that does not exist and get a **404**, which is
|
|
5261
|
+
* indistinguishable from "that row is gone". Every write would look like
|
|
5262
|
+
* a missing record.
|
|
5263
|
+
*
|
|
5264
|
+
* `PATCH` is what the OpenAPI spec advertises, so anyone generating a
|
|
5265
|
+
* client gets the correct verb; this stays on `PUT` until the oldest
|
|
5266
|
+
* supported server is one that serves both.
|
|
5267
|
+
*/
|
|
4599
5268
|
async update(id, data) {
|
|
4600
5269
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
4601
5270
|
method: "PUT",
|
|
4602
5271
|
body: JSON.stringify(data)
|
|
4603
5272
|
});
|
|
4604
5273
|
},
|
|
5274
|
+
async updateMany(updates, options) {
|
|
5275
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
5276
|
+
if (updates.length === 0) return [];
|
|
5277
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
5278
|
+
method: "PATCH",
|
|
5279
|
+
body: JSON.stringify({ updates }),
|
|
5280
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
5281
|
+
})).data || [];
|
|
5282
|
+
},
|
|
4605
5283
|
async delete(id) {
|
|
4606
5284
|
await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
|
|
4607
5285
|
},
|
|
5286
|
+
/**
|
|
5287
|
+
* `POST .../bulk/delete`, not `DELETE .../bulk`.
|
|
5288
|
+
*
|
|
5289
|
+
* The honest verb would take the ids in a DELETE body, and that is the
|
|
5290
|
+
* one request shape the HTTP ecosystem handles unreliably: bodies on
|
|
5291
|
+
* DELETE are permitted but widely dropped by proxies and CDNs, and
|
|
5292
|
+
* several OpenAPI generators ignore `requestBody` on a DELETE
|
|
5293
|
+
* operation, so a generated client would send the request without its
|
|
5294
|
+
* ids. A backend deployed behind arbitrary ingress cannot take that
|
|
5295
|
+
* bet. Same reason `:batchDelete` exists in Google's API guidelines.
|
|
5296
|
+
*/
|
|
5297
|
+
async deleteMany(ids, options) {
|
|
5298
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
5299
|
+
if (ids.length === 0) return;
|
|
5300
|
+
await transport.request(`${basePath}/bulk/delete`, {
|
|
5301
|
+
method: "POST",
|
|
5302
|
+
body: JSON.stringify({ ids }),
|
|
5303
|
+
...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
|
|
5304
|
+
});
|
|
5305
|
+
},
|
|
4608
5306
|
async count(params) {
|
|
4609
5307
|
const qs = buildQueryString({
|
|
4610
5308
|
...params,
|
|
@@ -4612,7 +5310,16 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4612
5310
|
offset: void 0,
|
|
4613
5311
|
include: void 0
|
|
4614
5312
|
});
|
|
4615
|
-
|
|
5313
|
+
const key = basePath + "/count" + qs;
|
|
5314
|
+
const inflight = inflightCounts.get(key);
|
|
5315
|
+
if (inflight) return inflight;
|
|
5316
|
+
const request = transport.request(key, { method: "GET" }).then((raw) => raw.count ?? 0);
|
|
5317
|
+
inflightCounts.set(key, request);
|
|
5318
|
+
try {
|
|
5319
|
+
return await request;
|
|
5320
|
+
} finally {
|
|
5321
|
+
inflightCounts.delete(key);
|
|
5322
|
+
}
|
|
4616
5323
|
},
|
|
4617
5324
|
observe(params, onResult, onError, options) {
|
|
4618
5325
|
let closed = false;
|
|
@@ -4680,8 +5387,11 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4680
5387
|
offset(count) {
|
|
4681
5388
|
return new SDKQueryBuilder(client).offset(count);
|
|
4682
5389
|
},
|
|
4683
|
-
search(searchString) {
|
|
4684
|
-
return new SDKQueryBuilder(client).search(searchString);
|
|
5390
|
+
search(searchString, options) {
|
|
5391
|
+
return new SDKQueryBuilder(client).search(searchString, options);
|
|
5392
|
+
},
|
|
5393
|
+
vectorSearch(property, vector, options) {
|
|
5394
|
+
return new SDKQueryBuilder(client).vectorSearch(property, vector, options);
|
|
4685
5395
|
},
|
|
4686
5396
|
include(...relations) {
|
|
4687
5397
|
return new SDKQueryBuilder(client).include(...relations);
|
|
@@ -4701,7 +5411,8 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
4701
5411
|
offset: window.driverOffset,
|
|
4702
5412
|
orderBy: params?.orderBy?.[0],
|
|
4703
5413
|
order: params?.orderBy?.[1],
|
|
4704
|
-
searchString: params?.searchString
|
|
5414
|
+
searchString: params?.searchString,
|
|
5415
|
+
searchExplain: params?.searchExplain
|
|
4705
5416
|
}, (incomingRows) => {
|
|
4706
5417
|
const currentUpdateId = ++lastUpdateId;
|
|
4707
5418
|
const requestedLimit = window.limit;
|
|
@@ -4983,9 +5694,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
4983
5694
|
*
|
|
4984
5695
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
4985
5696
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
4986
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
4987
|
-
* package root
|
|
4988
|
-
*
|
|
5697
|
+
* `RebaseWebSocket` contract in `@rebasepro/types`). It stays exported from the
|
|
5698
|
+
* package root for the same reason it always was — a data-source driver may
|
|
5699
|
+
* instantiate it directly — but nothing in this repo does since
|
|
5700
|
+
* `@rebasepro/client-postgres` was removed; its surface may change without a
|
|
5701
|
+
* major bump.
|
|
4989
5702
|
*/
|
|
4990
5703
|
var RebaseWebSocketClient = class {
|
|
4991
5704
|
websocketUrl;
|
|
@@ -5495,6 +6208,11 @@ var RebaseWebSocketClient = class {
|
|
|
5495
6208
|
callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
|
|
5496
6209
|
}
|
|
5497
6210
|
} else callback.onUpdate(message);
|
|
6211
|
+
return;
|
|
6212
|
+
}
|
|
6213
|
+
if (type === "ERROR" || type === "error" || message.error) {
|
|
6214
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
6215
|
+
console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
|
|
5498
6216
|
}
|
|
5499
6217
|
}
|
|
5500
6218
|
async ensureAuthenticated(retryCount = 3) {
|
|
@@ -6601,11 +7319,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
|
6601
7319
|
503,
|
|
6602
7320
|
504
|
|
6603
7321
|
]);
|
|
7322
|
+
/**
|
|
7323
|
+
* The server holds this key for a request it has not answered yet.
|
|
7324
|
+
*
|
|
7325
|
+
* It is a 409 like a duplicate row is a 409, and nothing but the code separates
|
|
7326
|
+
* them — one means "your write is already there", the other means "your write
|
|
7327
|
+
* may not have happened at all, ask again".
|
|
7328
|
+
*/
|
|
7329
|
+
var IDEMPOTENCY_IN_PROGRESS = "IDEMPOTENCY_KEY_IN_PROGRESS";
|
|
7330
|
+
/**
|
|
7331
|
+
* Is the server still answering an earlier attempt of this same write?
|
|
7332
|
+
*
|
|
7333
|
+
* The only correct response is to ask again — which is exactly what the
|
|
7334
|
+
* server's own message says, and exactly what this SDK used not to do.
|
|
7335
|
+
*/
|
|
7336
|
+
function isIdempotencyInProgressError(error) {
|
|
7337
|
+
return error instanceof RebaseApiError && error.status === 409 && error.code === IDEMPOTENCY_IN_PROGRESS;
|
|
7338
|
+
}
|
|
6604
7339
|
/** Is this failure worth another attempt later? */
|
|
6605
7340
|
function isRetryableError(error) {
|
|
6606
7341
|
if (isNetworkError(error)) return true;
|
|
6607
|
-
if (error instanceof RebaseApiError) return
|
|
6608
|
-
return
|
|
7342
|
+
if (!(error instanceof RebaseApiError)) return false;
|
|
7343
|
+
if (isIdempotencyInProgressError(error)) return true;
|
|
7344
|
+
return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
|
|
6609
7345
|
}
|
|
6610
7346
|
/**
|
|
6611
7347
|
* Did this write fail because the row is already there?
|
|
@@ -6617,10 +7353,16 @@ function isRetryableError(error) {
|
|
|
6617
7353
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
6618
7354
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
6619
7355
|
* the server can only be rejecting it because the first attempt actually landed.
|
|
7356
|
+
*
|
|
7357
|
+
* Which is why the status alone cannot decide it: `IDEMPOTENCY_KEY_IN_PROGRESS`
|
|
7358
|
+
* is a 409 that means the opposite — the row may not exist at all. Read as a
|
|
7359
|
+
* duplicate, the queue looked for a row that was never written, found nothing,
|
|
7360
|
+
* concluded there was nothing left to do and deleted the write from the queue.
|
|
6620
7361
|
*/
|
|
6621
7362
|
function isDuplicateKeyError(error) {
|
|
6622
7363
|
if (!(error instanceof RebaseApiError)) return false;
|
|
6623
|
-
|
|
7364
|
+
if (error.code === "23505") return true;
|
|
7365
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
6624
7366
|
}
|
|
6625
7367
|
var ConnectivityMonitor = class {
|
|
6626
7368
|
state = "online";
|
|
@@ -7190,6 +7932,25 @@ function resolvePagination(params) {
|
|
|
7190
7932
|
offset
|
|
7191
7933
|
};
|
|
7192
7934
|
}
|
|
7935
|
+
/** `<`, `<=`, `>`, `>=` — the operators whose answer depends on a collation. */
|
|
7936
|
+
var ORDERING_OPS = /* @__PURE__ */ new Set([
|
|
7937
|
+
"<",
|
|
7938
|
+
"<=",
|
|
7939
|
+
">",
|
|
7940
|
+
">="
|
|
7941
|
+
]);
|
|
7942
|
+
/** Does any condition in this `where` clause order its operands? */
|
|
7943
|
+
function whereOrders(where) {
|
|
7944
|
+
if (!where) return false;
|
|
7945
|
+
for (const condition of Object.values(where)) if ((isTuple(condition) ? [condition] : condition.filter(isTuple)).some(([op]) => ORDERING_OPS.has(op))) return true;
|
|
7946
|
+
return false;
|
|
7947
|
+
}
|
|
7948
|
+
/** The same question, through an `and(...)`/`or(...)` tree. */
|
|
7949
|
+
function logicalOrders(condition, depth = 0) {
|
|
7950
|
+
if (!condition || depth > 32) return false;
|
|
7951
|
+
if ("type" in condition) return (condition.conditions ?? []).some((c) => logicalOrders(c, depth + 1));
|
|
7952
|
+
return ORDERING_OPS.has(condition.operator);
|
|
7953
|
+
}
|
|
7193
7954
|
/**
|
|
7194
7955
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
7195
7956
|
* assuming the cache holds every row of the collection?
|
|
@@ -7197,11 +7958,67 @@ function resolvePagination(params) {
|
|
|
7197
7958
|
* `include` pulls in rows from other collections that this evaluator never
|
|
7198
7959
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
7199
7960
|
* best effort rather than an equivalent one.
|
|
7961
|
+
*
|
|
7962
|
+
* **Ordering comparisons are refused, and that is the interesting one.**
|
|
7963
|
+
* `compareValues` falls back to an `Intl.Collator` for operands it cannot read
|
|
7964
|
+
* as numbers or instants. PostgreSQL orders text by the *database's* collation,
|
|
7965
|
+
* which is a property of the server this process has never been told: under the
|
|
7966
|
+
* C collation `'apple' < 'Banana'` is false, under `en_US.UTF-8` it is true,
|
|
7967
|
+
* and the collator says true. So `["<", "Banana"]` selects a different set here
|
|
7968
|
+
* than it does there — silently, and in whichever direction the deployment
|
|
7969
|
+
* happens to have been created.
|
|
7970
|
+
*
|
|
7971
|
+
* The refusal covers *every* ordering comparison rather than only the ones with
|
|
7972
|
+
* a string operand, because the operand type does not settle it: a numeric
|
|
7973
|
+
* bound against a text column (`["<", 10]` on a `varchar`) also reaches the
|
|
7974
|
+
* collator, and nothing in `params` says what the column holds. Conservative on
|
|
7975
|
+
* purpose — the cost is that a query combining an ordering filter with
|
|
7976
|
+
* *unsynced local writes* stops placing those writes optimistically, which is a
|
|
7977
|
+
* degraded answer rather than a wrong one. Claiming exactness we do not have is
|
|
7978
|
+
* the other way round.
|
|
7979
|
+
*
|
|
7980
|
+
* This says nothing about ordering *results*; that is a separate claim with a
|
|
7981
|
+
* separate answer, because a sort changes which rows come first and not which
|
|
7982
|
+
* rows match. See {@link isLocallySortable}.
|
|
7200
7983
|
*/
|
|
7201
7984
|
function isExactlyEvaluable(params) {
|
|
7202
7985
|
if (!params) return true;
|
|
7203
7986
|
if (params.include && params.include.length > 0) return false;
|
|
7204
7987
|
if (params.searchString) return false;
|
|
7988
|
+
if (params.vectorSearch) return false;
|
|
7989
|
+
if (whereOrders(params.where)) return false;
|
|
7990
|
+
if (logicalOrders(params.logical)) return false;
|
|
7991
|
+
return true;
|
|
7992
|
+
}
|
|
7993
|
+
/**
|
|
7994
|
+
* Would sorting `rows` locally reproduce the order the server would have sent?
|
|
7995
|
+
*
|
|
7996
|
+
* Asked of the rows rather than of the query, because unlike a filter this one
|
|
7997
|
+
* *is* decidable from the data in hand: {@link compareValues} reaches the
|
|
7998
|
+
* collator only when it cannot read both operands as numbers, and `toComparable`
|
|
7999
|
+
* has already turned dates and relations into numbers and ids by then. If every
|
|
8000
|
+
* value on the sort column normalises to a number, the collator is unreachable
|
|
8001
|
+
* and the local order is the server's order.
|
|
8002
|
+
*
|
|
8003
|
+
* A text column is therefore refused — see {@link isExactlyEvaluable} for why
|
|
8004
|
+
* the two cannot be made to agree — and so is a column this page happens to see
|
|
8005
|
+
* only as strings, which is the same thing from here.
|
|
8006
|
+
*
|
|
8007
|
+
* Nulls are fine either way: they are ordered by an explicit rule (last
|
|
8008
|
+
* ascending, first descending) that matches Postgres and never reaches the
|
|
8009
|
+
* comparator.
|
|
8010
|
+
*/
|
|
8011
|
+
function isLocallySortable(rows, orderBy) {
|
|
8012
|
+
if (!orderBy) return true;
|
|
8013
|
+
const [field] = orderBy;
|
|
8014
|
+
for (const row of rows) {
|
|
8015
|
+
const value = toComparable(row[field]);
|
|
8016
|
+
if (isNullish(value)) continue;
|
|
8017
|
+
if (typeof value === "number" || typeof value === "boolean") continue;
|
|
8018
|
+
if (typeof value === "bigint") continue;
|
|
8019
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
|
|
8020
|
+
return false;
|
|
8021
|
+
}
|
|
7205
8022
|
return true;
|
|
7206
8023
|
}
|
|
7207
8024
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
@@ -7231,6 +8048,17 @@ function generateOfflineId() {
|
|
|
7231
8048
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
7232
8049
|
}
|
|
7233
8050
|
var MISSING = "\0missing";
|
|
8051
|
+
/**
|
|
8052
|
+
* Replays to spend on a mutation whose idempotency key the server is still
|
|
8053
|
+
* holding, when the app has not asked for more.
|
|
8054
|
+
*
|
|
8055
|
+
* Retries double from a second and cap at the sync interval, so the default
|
|
8056
|
+
* budget of five covers about half a minute — less than the lease a server
|
|
8057
|
+
* gives a claim nobody came back for. This many outlast it with room for a slow
|
|
8058
|
+
* batch, and the count is what stops a server that never releases the key from
|
|
8059
|
+
* blocking the queue behind it indefinitely.
|
|
8060
|
+
*/
|
|
8061
|
+
var IN_PROGRESS_MIN_RETRIES = 12;
|
|
7234
8062
|
var OfflineManager = class {
|
|
7235
8063
|
store;
|
|
7236
8064
|
maxCachedQueries;
|
|
@@ -7511,6 +8339,72 @@ var OfflineManager = class {
|
|
|
7511
8339
|
this.notifyCollection(slug);
|
|
7512
8340
|
return rows;
|
|
7513
8341
|
},
|
|
8342
|
+
updateMany: async (updates, options) => {
|
|
8343
|
+
await this.ensureCollection(slug);
|
|
8344
|
+
if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
|
|
8345
|
+
if (updates.length === 0) return [];
|
|
8346
|
+
const anyPending = updates.some((u) => this.hasPending(slug, u.id));
|
|
8347
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
8348
|
+
const rows = await inner.updateMany(updates, options);
|
|
8349
|
+
this.connectivity.markSuccess();
|
|
8350
|
+
await this.ingest(slug, rows);
|
|
8351
|
+
this.notifyCollection(slug);
|
|
8352
|
+
return rows;
|
|
8353
|
+
} catch (error) {
|
|
8354
|
+
if (!isNetworkError(error)) throw error;
|
|
8355
|
+
this.connectivity.markFailure();
|
|
8356
|
+
}
|
|
8357
|
+
const rollback = {};
|
|
8358
|
+
const optimistic = [];
|
|
8359
|
+
for (const { id, data } of updates) {
|
|
8360
|
+
const base = this.rawLocalRow(slug, id);
|
|
8361
|
+
rollback[String(id)] = base ?? null;
|
|
8362
|
+
optimistic.push({
|
|
8363
|
+
...base ?? {},
|
|
8364
|
+
...data,
|
|
8365
|
+
id
|
|
8366
|
+
});
|
|
8367
|
+
}
|
|
8368
|
+
await this.enqueue({
|
|
8369
|
+
collection: slug,
|
|
8370
|
+
type: "updateMany",
|
|
8371
|
+
updates: updates.map((u) => ({
|
|
8372
|
+
id: u.id,
|
|
8373
|
+
data: u.data
|
|
8374
|
+
})),
|
|
8375
|
+
rollback: { rows: rollback }
|
|
8376
|
+
});
|
|
8377
|
+
for (const row of optimistic) this.setLocalRow(slug, row.id, row);
|
|
8378
|
+
this.notifyCollection(slug);
|
|
8379
|
+
return optimistic;
|
|
8380
|
+
},
|
|
8381
|
+
deleteMany: async (ids, options) => {
|
|
8382
|
+
await this.ensureCollection(slug);
|
|
8383
|
+
if (!Array.isArray(ids)) throw new TypeError("deleteMany expects an array of ids.");
|
|
8384
|
+
if (ids.length === 0) return;
|
|
8385
|
+
const anyPending = ids.some((id) => this.hasPending(slug, id));
|
|
8386
|
+
if (this.connectivity.shouldAttempt() && !anyPending) try {
|
|
8387
|
+
await inner.deleteMany(ids, options);
|
|
8388
|
+
this.connectivity.markSuccess();
|
|
8389
|
+
for (const id of ids) this.removeLocalRow(slug, id, true);
|
|
8390
|
+
this.notifyCollection(slug);
|
|
8391
|
+
this.scheduleRefresh(slug);
|
|
8392
|
+
return;
|
|
8393
|
+
} catch (error) {
|
|
8394
|
+
if (!isNetworkError(error)) throw error;
|
|
8395
|
+
this.connectivity.markFailure();
|
|
8396
|
+
}
|
|
8397
|
+
const rollback = {};
|
|
8398
|
+
for (const id of ids) rollback[String(id)] = this.rawLocalRow(slug, id) ?? null;
|
|
8399
|
+
await this.enqueue({
|
|
8400
|
+
collection: slug,
|
|
8401
|
+
type: "deleteMany",
|
|
8402
|
+
ids,
|
|
8403
|
+
rollback: { rows: rollback }
|
|
8404
|
+
});
|
|
8405
|
+
for (const id of ids) this.removeLocalRow(slug, id, false);
|
|
8406
|
+
this.notifyCollection(slug);
|
|
8407
|
+
},
|
|
7514
8408
|
update: async (id, data) => {
|
|
7515
8409
|
await this.ensureCollection(slug);
|
|
7516
8410
|
if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
|
|
@@ -7589,7 +8483,8 @@ var OfflineManager = class {
|
|
|
7589
8483
|
orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
|
|
7590
8484
|
limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
|
|
7591
8485
|
offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
|
|
7592
|
-
search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
|
|
8486
|
+
search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
|
|
8487
|
+
vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
|
|
7593
8488
|
include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
|
|
7594
8489
|
};
|
|
7595
8490
|
if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
|
|
@@ -7846,16 +8741,15 @@ var OfflineManager = class {
|
|
|
7846
8741
|
}
|
|
7847
8742
|
let added = 0;
|
|
7848
8743
|
const offset = snapshot.offset ?? 0;
|
|
7849
|
-
if (exact && offset === 0) {
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
7854
|
-
|
|
7855
|
-
added++;
|
|
7856
|
-
}
|
|
7857
|
-
if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
|
|
8744
|
+
if (exact && offset === 0) for (const [key, entry] of state.rows) {
|
|
8745
|
+
if (seen.has(key) || !this.hasPending(slug, key)) continue;
|
|
8746
|
+
if (!this.isLocallyCreated(slug, key)) continue;
|
|
8747
|
+
if (!matchesParams(entry.row, params)) continue;
|
|
8748
|
+
rows.push(entry.row);
|
|
8749
|
+
added++;
|
|
7858
8750
|
}
|
|
8751
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
8752
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
7859
8753
|
return {
|
|
7860
8754
|
data: rows,
|
|
7861
8755
|
meta: {
|
|
@@ -7866,7 +8760,7 @@ var OfflineManager = class {
|
|
|
7866
8760
|
},
|
|
7867
8761
|
fromCache,
|
|
7868
8762
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
7869
|
-
partial: !exact
|
|
8763
|
+
partial: !exact || !orderIsLocal
|
|
7870
8764
|
};
|
|
7871
8765
|
}
|
|
7872
8766
|
localFind(slug, params) {
|
|
@@ -8181,7 +9075,8 @@ var OfflineManager = class {
|
|
|
8181
9075
|
}
|
|
8182
9076
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
8183
9077
|
op.lastError = error?.message ?? String(error);
|
|
8184
|
-
|
|
9078
|
+
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
9079
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
8185
9080
|
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
8186
9081
|
this.connectivity.deferRetry();
|
|
8187
9082
|
this.patchStatus({ lastError: op.lastError });
|
|
@@ -8227,11 +9122,25 @@ var OfflineManager = class {
|
|
|
8227
9122
|
await this.adoptServerRow(op, op.id, row);
|
|
8228
9123
|
} else if (op.type === "createMany") {
|
|
8229
9124
|
const queued = op.data ?? [];
|
|
8230
|
-
const rows = await inner.createMany(queued,
|
|
9125
|
+
const rows = await inner.createMany(queued, {
|
|
9126
|
+
...op.upsert ? { upsert: true } : {},
|
|
9127
|
+
idempotencyKey: op.mutationId
|
|
9128
|
+
});
|
|
8231
9129
|
for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
|
|
9130
|
+
} else if (op.type === "updateMany") {
|
|
9131
|
+
const queued = op.updates ?? [];
|
|
9132
|
+
const rows = await inner.updateMany(queued.map((u) => ({
|
|
9133
|
+
id: u.id,
|
|
9134
|
+
data: u.data
|
|
9135
|
+
})), { idempotencyKey: op.mutationId });
|
|
9136
|
+
for (let i = 0; i < rows.length; i++) await this.ingestReplaced(op, queued[i].id, rows[i]);
|
|
8232
9137
|
} else if (op.type === "update") {
|
|
8233
9138
|
const row = await inner.update(op.id, op.data);
|
|
8234
9139
|
await this.ingestReplaced(op, op.id, row);
|
|
9140
|
+
} else if (op.type === "deleteMany") {
|
|
9141
|
+
const ids = op.ids ?? [];
|
|
9142
|
+
await inner.deleteMany(ids, { idempotencyKey: op.mutationId });
|
|
9143
|
+
for (const id of ids) this.removeLocalRow(op.collection, id, true);
|
|
8235
9144
|
} else if (op.type === "delete") {
|
|
8236
9145
|
await inner.delete(op.id);
|
|
8237
9146
|
this.removeLocalRow(op.collection, op.id, true);
|
|
@@ -8699,39 +9608,88 @@ channel: (name, options) => {
|
|
|
8699
9608
|
}
|
|
8700
9609
|
//#endregion
|
|
8701
9610
|
//#region src/history/history-routes.ts
|
|
9611
|
+
/** Entries returned when the caller names no `?limit`. */
|
|
9612
|
+
var HISTORY_DEFAULT_LIMIT = 20;
|
|
9613
|
+
/** Most entries one history read will serve. Above it the request is refused. */
|
|
9614
|
+
var HISTORY_MAX_LIMIT = 100;
|
|
8702
9615
|
function createHistoryRoutes(params) {
|
|
8703
|
-
const { historyService, registry
|
|
9616
|
+
const { historyService, registry } = params;
|
|
8704
9617
|
const router = new Hono();
|
|
8705
9618
|
router.onError(errorHandler);
|
|
8706
9619
|
/**
|
|
9620
|
+
* Both halves of the authorization the REST generator applies to every
|
|
9621
|
+
* other route on this router, for the row whose history is being asked for.
|
|
9622
|
+
*
|
|
9623
|
+
* `historyService` reads `rebase.entity_history` through the *privileged*
|
|
9624
|
+
* handle the bootstrapper built — not `c.get("driver")` — and it has to:
|
|
9625
|
+
* history is one table shadowing every audited collection, so it cannot
|
|
9626
|
+
* carry their row policies, and `ensure-history-table` revokes it from
|
|
9627
|
+
* `rebase_user` outright for exactly that reason. Nothing about the read is
|
|
9628
|
+
* therefore scoped to the caller. That makes this function the *entire*
|
|
9629
|
+
* access-control model for the route, the same way `storageAuthorize` is
|
|
9630
|
+
* for storage.
|
|
9631
|
+
*
|
|
9632
|
+
* It asks the two questions the data API already answers, in its order:
|
|
9633
|
+
*
|
|
9634
|
+
* - the API key's permission list, via the same predicate
|
|
9635
|
+
* `RestApiGenerator.enforceApiKeyPermission` uses, and keyed on the
|
|
9636
|
+
* request's own method so that reverting needs `write` and listing needs
|
|
9637
|
+
* `read` — a key scoped to no collection, or to a different one, or to
|
|
9638
|
+
* reading only, must not get past this;
|
|
9639
|
+
* - RLS, by fetching the row itself through the request-scoped driver.
|
|
9640
|
+
* A row the caller may not read comes back undefined, and its history is
|
|
9641
|
+
* then a 404 rather than a 403, because a 403 confirms the row exists.
|
|
9642
|
+
*
|
|
9643
|
+
* A missing scoped driver is an internal error, never a fallback to the
|
|
9644
|
+
* unscoped one — the same rule, and the same reason, as
|
|
9645
|
+
* {@link RestApiGenerator.getScopedDriver}.
|
|
9646
|
+
*/
|
|
9647
|
+
async function authorizeEntityRead(c, collection, id) {
|
|
9648
|
+
const apiKey = c.get("apiKey");
|
|
9649
|
+
const operation = httpMethodToOperation(c.req.method);
|
|
9650
|
+
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");
|
|
9651
|
+
const scoped = c.get("driver");
|
|
9652
|
+
if (!scoped) throw ApiError.internal("Scoped driver not available");
|
|
9653
|
+
if (!await scoped.fetchOne({
|
|
9654
|
+
path: collection.slug,
|
|
9655
|
+
id,
|
|
9656
|
+
collection
|
|
9657
|
+
})) throw ApiError.notFound(`Entity '${id}' not found in collection '${collection.slug}'`);
|
|
9658
|
+
}
|
|
9659
|
+
/**
|
|
8707
9660
|
* GET /:slug/:id/history - List history entries for a entity
|
|
8708
9661
|
*
|
|
8709
9662
|
* Query params:
|
|
8710
|
-
* limit (default 20)
|
|
9663
|
+
* limit (default 20, maximum 100 — above that the request is refused)
|
|
8711
9664
|
* offset (default 0)
|
|
8712
9665
|
*/
|
|
8713
9666
|
router.get("/:slug/:id/history", async (c) => {
|
|
8714
9667
|
const slug = c.req.param("slug");
|
|
8715
9668
|
const id = c.req.param("id");
|
|
8716
|
-
const
|
|
9669
|
+
const limit = resolveListLimitParam(c.req.query("limit"), {
|
|
9670
|
+
defaultLimit: HISTORY_DEFAULT_LIMIT,
|
|
9671
|
+
maxLimit: HISTORY_MAX_LIMIT
|
|
9672
|
+
});
|
|
8717
9673
|
const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
|
|
8718
|
-
const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
|
|
8719
9674
|
const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
|
|
8720
9675
|
const collection = registry.getCollections().find((col) => col.slug === slug || false);
|
|
8721
9676
|
if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
|
|
8722
9677
|
if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
|
|
9678
|
+
await authorizeEntityRead(c, collection, id);
|
|
8723
9679
|
const tableName = collection.slug;
|
|
9680
|
+
const appliedLimit = limit;
|
|
9681
|
+
const appliedOffset = Math.max(offset, 0);
|
|
8724
9682
|
const result = await historyService.fetchHistory(tableName, id, {
|
|
8725
|
-
limit:
|
|
8726
|
-
offset:
|
|
9683
|
+
limit: appliedLimit,
|
|
9684
|
+
offset: appliedOffset
|
|
8727
9685
|
});
|
|
8728
9686
|
return c.json({
|
|
8729
9687
|
data: result.data,
|
|
8730
9688
|
meta: {
|
|
8731
9689
|
total: result.total,
|
|
8732
|
-
limit,
|
|
8733
|
-
offset,
|
|
8734
|
-
hasMore:
|
|
9690
|
+
limit: appliedLimit,
|
|
9691
|
+
offset: appliedOffset,
|
|
9692
|
+
hasMore: appliedOffset + result.data.length < result.total
|
|
8735
9693
|
}
|
|
8736
9694
|
});
|
|
8737
9695
|
});
|
|
@@ -8747,12 +9705,13 @@ function createHistoryRoutes(params) {
|
|
|
8747
9705
|
const collection = registry.getCollections().find((col) => col.slug === slug || false);
|
|
8748
9706
|
if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
|
|
8749
9707
|
if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
|
|
9708
|
+
await authorizeEntityRead(c, collection, id);
|
|
8750
9709
|
const historyEntry = await historyService.fetchHistoryEntry(historyId);
|
|
8751
9710
|
if (!historyEntry) throw ApiError.notFound(`History entry '${historyId}' not found`);
|
|
8752
9711
|
const tableName = collection.slug;
|
|
8753
9712
|
if (historyEntry.entity_id !== String(id) || historyEntry.table_name !== tableName) throw ApiError.badRequest("History entry does not belong to this entity");
|
|
8754
9713
|
if (!historyEntry.values) throw ApiError.badRequest("Cannot revert: history entry has no stored values");
|
|
8755
|
-
const authDriver = c.get("driver")
|
|
9714
|
+
const authDriver = c.get("driver");
|
|
8756
9715
|
const path = collection.slug;
|
|
8757
9716
|
const savedEntity = await authDriver.save({
|
|
8758
9717
|
path,
|
|
@@ -8769,126 +9728,6 @@ function createHistoryRoutes(params) {
|
|
|
8769
9728
|
return router;
|
|
8770
9729
|
}
|
|
8771
9730
|
//#endregion
|
|
8772
|
-
//#region src/email/smtp-email-service.ts
|
|
8773
|
-
var _nodemailer;
|
|
8774
|
-
async function loadNodemailer() {
|
|
8775
|
-
if (!_nodemailer) try {
|
|
8776
|
-
_nodemailer = await import("nodemailer");
|
|
8777
|
-
} catch {
|
|
8778
|
-
throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
|
|
8779
|
-
}
|
|
8780
|
-
return _nodemailer;
|
|
8781
|
-
}
|
|
8782
|
-
/**
|
|
8783
|
-
* Safely parse a hostname from a URL string
|
|
8784
|
-
*/
|
|
8785
|
-
function getHostname(urlStr) {
|
|
8786
|
-
try {
|
|
8787
|
-
return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
|
|
8788
|
-
} catch {
|
|
8789
|
-
return;
|
|
8790
|
-
}
|
|
8791
|
-
}
|
|
8792
|
-
/**
|
|
8793
|
-
* SMTP Email Service implementation using Nodemailer
|
|
8794
|
-
*/
|
|
8795
|
-
var SMTPEmailService = class {
|
|
8796
|
-
transporter = null;
|
|
8797
|
-
config;
|
|
8798
|
-
_initialized = false;
|
|
8799
|
-
constructor(config) {
|
|
8800
|
-
this.config = config;
|
|
8801
|
-
}
|
|
8802
|
-
/**
|
|
8803
|
-
* Lazily initialize the SMTP transporter on first use
|
|
8804
|
-
*/
|
|
8805
|
-
async ensureTransporter() {
|
|
8806
|
-
if (this._initialized) return;
|
|
8807
|
-
this._initialized = true;
|
|
8808
|
-
if (this.config.smtp) {
|
|
8809
|
-
const nodemailer = await loadNodemailer();
|
|
8810
|
-
let smtpName = this.config.smtp.name;
|
|
8811
|
-
if (!smtpName) {
|
|
8812
|
-
const urlsToTry = [
|
|
8813
|
-
process.env.FRONTEND_URL,
|
|
8814
|
-
this.config.resetPasswordUrl,
|
|
8815
|
-
this.config.verifyEmailUrl
|
|
8816
|
-
];
|
|
8817
|
-
for (const urlStr of urlsToTry) if (urlStr) {
|
|
8818
|
-
const hostname = getHostname(urlStr);
|
|
8819
|
-
if (hostname) {
|
|
8820
|
-
smtpName = hostname;
|
|
8821
|
-
break;
|
|
8822
|
-
}
|
|
8823
|
-
}
|
|
8824
|
-
}
|
|
8825
|
-
this.transporter = nodemailer.createTransport({
|
|
8826
|
-
name: smtpName,
|
|
8827
|
-
host: this.config.smtp.host,
|
|
8828
|
-
port: this.config.smtp.port,
|
|
8829
|
-
secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
|
|
8830
|
-
auth: this.config.smtp.auth ? {
|
|
8831
|
-
user: this.config.smtp.auth.user,
|
|
8832
|
-
pass: this.config.smtp.auth.pass
|
|
8833
|
-
} : void 0
|
|
8834
|
-
});
|
|
8835
|
-
}
|
|
8836
|
-
}
|
|
8837
|
-
/**
|
|
8838
|
-
* Check if the email service is properly configured
|
|
8839
|
-
*/
|
|
8840
|
-
isConfigured() {
|
|
8841
|
-
return !!(this.config.smtp || this.config.sendEmail);
|
|
8842
|
-
}
|
|
8843
|
-
/**
|
|
8844
|
-
* Send an email using SMTP or custom send function
|
|
8845
|
-
*/
|
|
8846
|
-
async send(options) {
|
|
8847
|
-
if (this.config.sendEmail) {
|
|
8848
|
-
await this.config.sendEmail(options);
|
|
8849
|
-
return;
|
|
8850
|
-
}
|
|
8851
|
-
await this.ensureTransporter();
|
|
8852
|
-
if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
|
|
8853
|
-
const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
|
|
8854
|
-
try {
|
|
8855
|
-
await this.transporter.sendMail({
|
|
8856
|
-
from: this.config.from,
|
|
8857
|
-
to,
|
|
8858
|
-
subject: options.subject,
|
|
8859
|
-
html: options.html,
|
|
8860
|
-
text: options.text,
|
|
8861
|
-
replyTo: options.replyTo
|
|
8862
|
-
});
|
|
8863
|
-
} catch (error) {
|
|
8864
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
8865
|
-
logger.error("Failed to send email", { detail: message });
|
|
8866
|
-
throw new Error(`Failed to send email: ${message}`);
|
|
8867
|
-
}
|
|
8868
|
-
}
|
|
8869
|
-
/**
|
|
8870
|
-
* Verify SMTP connection (useful for startup checks)
|
|
8871
|
-
*/
|
|
8872
|
-
async verifyConnection() {
|
|
8873
|
-
await this.ensureTransporter();
|
|
8874
|
-
if (!this.transporter) return !!this.config.sendEmail;
|
|
8875
|
-
try {
|
|
8876
|
-
await this.transporter.verify();
|
|
8877
|
-
return true;
|
|
8878
|
-
} catch (error) {
|
|
8879
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
8880
|
-
logger.error("SMTP connection verification failed", { detail: message });
|
|
8881
|
-
return false;
|
|
8882
|
-
}
|
|
8883
|
-
}
|
|
8884
|
-
};
|
|
8885
|
-
/**
|
|
8886
|
-
* Create an email service from configuration
|
|
8887
|
-
*/
|
|
8888
|
-
function createEmailService(config) {
|
|
8889
|
-
return new SMTPEmailService(config);
|
|
8890
|
-
}
|
|
8891
|
-
//#endregion
|
|
8892
9731
|
//#region src/singleton.ts
|
|
8893
9732
|
/**
|
|
8894
9733
|
* The backing instance lives on a process-global slot, NOT in a module-local
|
|
@@ -8949,14 +9788,30 @@ function _resetRebaseMock() {
|
|
|
8949
9788
|
* **Admin data plane** (`rebase.dataAsAdmin`):
|
|
8950
9789
|
* Backed by the native DataDriver — calls go directly to the database without
|
|
8951
9790
|
* JSON serialization, HTTP dispatch, or middleware overhead. The driver is
|
|
8952
|
-
* scoped as `{ uid: "service", roles: ["admin"] }
|
|
8953
|
-
*
|
|
8954
|
-
*
|
|
8955
|
-
*
|
|
8956
|
-
*
|
|
8957
|
-
*
|
|
8958
|
-
*
|
|
8959
|
-
*
|
|
9791
|
+
* scoped once as `{ uid: "service", roles: ["admin"] }` (`SERVICE_IDENTITY`),
|
|
9792
|
+
* which makes it **admin-scoped, not RLS-bypassing**: every read and write runs
|
|
9793
|
+
* in a transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
9794
|
+
* `app.uid = 'service'`, and policies are evaluated against that. No
|
|
9795
|
+
* `REBASE_SERVICE_KEY` is required.
|
|
9796
|
+
*
|
|
9797
|
+
* It clears the default policies through their `rolesOverlap(['admin'])` arm —
|
|
9798
|
+
* which is why the difference rarely shows. It shows when you write your own:
|
|
9799
|
+
*
|
|
9800
|
+
* - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is therefore
|
|
9801
|
+
* **false** for this accessor. A collection with `disableDefaultPolicies:
|
|
9802
|
+
* true` whose rule is `serverContext()` denies these writes (`42501`) and
|
|
9803
|
+
* returns zero rows — HTTP 200, empty — for these reads.
|
|
9804
|
+
* - Its reach equals an `admin`-roled application user's reach. It is not a
|
|
9805
|
+
* private door.
|
|
9806
|
+
*
|
|
9807
|
+
* `rebase.sql()` *is* an unconditional bypass — it runs on the owner connection
|
|
9808
|
+
* and never goes through `withAuth`. Of the two accessors on this object, the
|
|
9809
|
+
* quieter one is the more privileged.
|
|
9810
|
+
*
|
|
9811
|
+
* ⚠️ `rebase.dataAsAdmin` is for trusted background work (cron jobs,
|
|
9812
|
+
* migrations, service tasks) — **not** for serving user-facing data. Inside a
|
|
9813
|
+
* request handler, run user-scoped queries through the request-scoped driver
|
|
9814
|
+
* (`c.var.driver`), which carries the caller's identity.
|
|
8960
9815
|
*
|
|
8961
9816
|
* `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
|
|
8962
9817
|
* admin-scoped accessor has exactly one name and the privilege is visible at the
|
|
@@ -8972,7 +9827,8 @@ function _resetRebaseMock() {
|
|
|
8972
9827
|
* ```typescript
|
|
8973
9828
|
* import { rebase } from "@rebasepro/server";
|
|
8974
9829
|
*
|
|
8975
|
-
* // In a cron job, hook, or trusted service file (admin scope,
|
|
9830
|
+
* // In a cron job, hook, or trusted service file (admin scope, RLS evaluated
|
|
9831
|
+
* // as `{ uid: "service", roles: ["admin"] }`):
|
|
8976
9832
|
* await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
|
|
8977
9833
|
* const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
|
|
8978
9834
|
* ```
|
|
@@ -9086,7 +9942,8 @@ async function initializeRebaseBackend(config) {
|
|
|
9086
9942
|
async function _initializeRebaseBackend(config) {
|
|
9087
9943
|
if (config.logging?.level) configureLogLevel(config.logging.level);
|
|
9088
9944
|
else configureLogLevel();
|
|
9089
|
-
logger.
|
|
9945
|
+
logger.debug("Initializing Rebase Backend");
|
|
9946
|
+
installUnhandledRejectionHandler();
|
|
9090
9947
|
const basePath = config.basePath || "/api";
|
|
9091
9948
|
const isProduction = process.env.NODE_ENV === "production";
|
|
9092
9949
|
configureMiddlewares(config.app, basePath, isProduction, config);
|
|
@@ -9104,13 +9961,14 @@ async function _initializeRebaseBackend(config) {
|
|
|
9104
9961
|
});
|
|
9105
9962
|
}
|
|
9106
9963
|
const introspectCollections = activeCollections.length === 0;
|
|
9107
|
-
logger.info(
|
|
9964
|
+
if (introspectCollections) logger.info("No collections declared — deriving them from the database schema");
|
|
9965
|
+
else logger.debug("Serving declared collections");
|
|
9108
9966
|
const realtimeServices = {};
|
|
9109
9967
|
const delegates = {};
|
|
9110
9968
|
let bootstrappers = config.bootstrappers || [];
|
|
9111
9969
|
if (config.database) {
|
|
9112
9970
|
const dbAdapter = config.database;
|
|
9113
|
-
logger.
|
|
9971
|
+
logger.debug("Using DatabaseAdapter", { type: dbAdapter.type });
|
|
9114
9972
|
bootstrappers = [wrapDatabaseAdapter(dbAdapter)];
|
|
9115
9973
|
}
|
|
9116
9974
|
if (bootstrappers.length === 0) throw new Error("No bootstrappers or database adapter provided. Cannot initialize database drivers.");
|
|
@@ -9118,7 +9976,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9118
9976
|
let defaultDriverResult = void 0;
|
|
9119
9977
|
for (const bootstrapper of bootstrappers) {
|
|
9120
9978
|
const b = bootstrapper;
|
|
9121
|
-
logger.
|
|
9979
|
+
logger.debug("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
|
|
9122
9980
|
if (b.isDefault) defaultDriverId = b.id || bootstrapper.type;
|
|
9123
9981
|
const driverResult = await bootstrapper.initializeDriver({
|
|
9124
9982
|
collections: activeCollections,
|
|
@@ -9176,7 +10034,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9176
10034
|
authAdapter = config.auth;
|
|
9177
10035
|
serviceKey = authAdapter.serviceKey;
|
|
9178
10036
|
if (authAdapter.initialize) await authAdapter.initialize();
|
|
9179
|
-
logger.
|
|
10037
|
+
logger.debug("Using AuthAdapter", { id: authAdapter.id });
|
|
9180
10038
|
authConfigResult = { userService: authAdapter.userManagement ?? {} };
|
|
9181
10039
|
} else {
|
|
9182
10040
|
const safeAuthConfig = config.auth;
|
|
@@ -9187,7 +10045,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9187
10045
|
});
|
|
9188
10046
|
if (foundAuthCollection) {
|
|
9189
10047
|
safeAuthConfig.collection = foundAuthCollection;
|
|
9190
|
-
logger.
|
|
10048
|
+
logger.debug("Auto-discovered auth collection from collection definitions", { slug: foundAuthCollection.slug });
|
|
9191
10049
|
}
|
|
9192
10050
|
}
|
|
9193
10051
|
if (safeAuthConfig.collection) {
|
|
@@ -9204,17 +10062,17 @@ async function _initializeRebaseBackend(config) {
|
|
|
9204
10062
|
if (safeAuthConfig.serviceKey) {
|
|
9205
10063
|
if (safeAuthConfig.serviceKey.length < 32) throw new Error("REBASE_SERVICE_KEY is too short. Must be at least 32 characters. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
|
|
9206
10064
|
serviceKey = safeAuthConfig.serviceKey;
|
|
9207
|
-
logger.
|
|
10065
|
+
logger.debug("Service key configured for script/server-to-server authentication");
|
|
9208
10066
|
}
|
|
9209
10067
|
if (defaultBootstrapper.initializeAuth) {
|
|
9210
|
-
logger.
|
|
10068
|
+
logger.debug("Bootstrapping authentication via driver protocol");
|
|
9211
10069
|
authConfigResult = await defaultBootstrapper.initializeAuth(config.auth, defaultDriverResult);
|
|
9212
10070
|
logger.info("Authentication initialized");
|
|
9213
10071
|
} else logger.warn("Auth requested but default bootstrapper does not support initializeAuth");
|
|
9214
10072
|
}
|
|
9215
10073
|
let historyConfigResult = void 0;
|
|
9216
10074
|
if (config.history) if (defaultBootstrapper.initializeHistory) {
|
|
9217
|
-
logger.
|
|
10075
|
+
logger.debug("Bootstrapping entity history via driver protocol");
|
|
9218
10076
|
historyConfigResult = await defaultBootstrapper.initializeHistory(config.history, defaultDriverResult);
|
|
9219
10077
|
if (historyConfigResult?.historyService && defaultDriverResult.internals) {
|
|
9220
10078
|
const driver = defaultDriverResult.internals.driver;
|
|
@@ -9230,7 +10088,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9230
10088
|
if (apiKeyStoreResult) {
|
|
9231
10089
|
apiKeyStore = apiKeyStoreResult;
|
|
9232
10090
|
await apiKeyStore.ensureTable();
|
|
9233
|
-
logger.
|
|
10091
|
+
logger.debug("Service API Keys initialized");
|
|
9234
10092
|
}
|
|
9235
10093
|
const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
|
|
9236
10094
|
store: apiKeyStore,
|
|
@@ -9243,7 +10101,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9243
10101
|
serviceKey: internalServiceKey
|
|
9244
10102
|
});
|
|
9245
10103
|
config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
|
|
9246
|
-
logger.
|
|
10104
|
+
logger.debug("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
|
|
9247
10105
|
}
|
|
9248
10106
|
const rateLimitConfig = config.rateLimit?.enabled !== false ? {
|
|
9249
10107
|
...config.rateLimit,
|
|
@@ -9327,7 +10185,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9327
10185
|
]) {
|
|
9328
10186
|
const providerConfig = safeAuthConfig[key];
|
|
9329
10187
|
if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
|
|
9330
|
-
const createFn = (await import("./auth-
|
|
10188
|
+
const createFn = (await import("./auth-CYoPVf-E.js").then((n) => n.t))[factory];
|
|
9331
10189
|
oauthProviders.push(createFn(providerConfig));
|
|
9332
10190
|
}
|
|
9333
10191
|
}
|
|
@@ -9339,9 +10197,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
9339
10197
|
emailConfig: safeAuthConfig.email,
|
|
9340
10198
|
allowRegistration: safeAuthConfig.allowRegistration ?? false,
|
|
9341
10199
|
disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
|
|
10200
|
+
allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
|
|
9342
10201
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
9343
10202
|
defaultRole: safeAuthConfig.defaultRole,
|
|
9344
10203
|
oauthProviders,
|
|
10204
|
+
allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
|
|
9345
10205
|
serviceKey: serviceKey || internalServiceKey,
|
|
9346
10206
|
authHooks: safeAuthConfig.hooks,
|
|
9347
10207
|
collectionAuthConfig,
|
|
@@ -9356,14 +10216,14 @@ async function _initializeRebaseBackend(config) {
|
|
|
9356
10216
|
const authRoutes = authAdapter.createAuthRoutes();
|
|
9357
10217
|
if (authRoutes) {
|
|
9358
10218
|
config.app.route(`${basePath}/auth`, authRoutes);
|
|
9359
|
-
logger.
|
|
10219
|
+
logger.debug("Auth routes mounted via adapter", { adapter: authAdapter.id });
|
|
9360
10220
|
}
|
|
9361
10221
|
}
|
|
9362
10222
|
if (authAdapter && authAdapter.createAdminRoutes) {
|
|
9363
10223
|
const adminRoutes = authAdapter.createAdminRoutes();
|
|
9364
10224
|
if (adminRoutes) {
|
|
9365
10225
|
config.app.route(`${basePath}/admin`, adminRoutes);
|
|
9366
|
-
logger.
|
|
10226
|
+
logger.debug("Admin routes mounted via adapter", { adapter: authAdapter.id });
|
|
9367
10227
|
}
|
|
9368
10228
|
}
|
|
9369
10229
|
}
|
|
@@ -9421,7 +10281,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9421
10281
|
let schemaEditorOff = schemaEditorUnavailable();
|
|
9422
10282
|
let schemaEditorRoutes;
|
|
9423
10283
|
if (!schemaEditorOff && config.collectionsDir) try {
|
|
9424
|
-
schemaEditorRoutes = (await import("./schema-editor-routes-
|
|
10284
|
+
schemaEditorRoutes = (await import("./schema-editor-routes-CRcS3ArS.js")).createSchemaEditorRoutes(config.collectionsDir);
|
|
9425
10285
|
} catch (err) {
|
|
9426
10286
|
if (err?.code === "ERR_MODULE_NOT_FOUND") {
|
|
9427
10287
|
schemaEditorOff = {
|
|
@@ -9445,7 +10305,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9445
10305
|
message: schemaEditorOff.message
|
|
9446
10306
|
} }, 501));
|
|
9447
10307
|
config.app.route(`${basePath}/schema-editor`, schemaEditorRouter);
|
|
9448
|
-
if (schemaEditorRoutes) logger.
|
|
10308
|
+
if (schemaEditorRoutes) logger.debug("Schema Editor mounted", { path: `${basePath}/schema-editor` });
|
|
9449
10309
|
else logger.debug("Schema Editor unavailable", {
|
|
9450
10310
|
path: `${basePath}/schema-editor`,
|
|
9451
10311
|
code: schemaEditorOff.code
|
|
@@ -9471,6 +10331,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9471
10331
|
});
|
|
9472
10332
|
const storageRouter = new Hono();
|
|
9473
10333
|
if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
|
|
10334
|
+
if (rateLimitConfig) storageRouter.use("/*", createDataRateLimiter(rateLimitConfig));
|
|
9474
10335
|
storageRouter.use("/upload", bodyLimit({
|
|
9475
10336
|
maxSize: storageMaxSize,
|
|
9476
10337
|
onError: (c) => {
|
|
@@ -9491,6 +10352,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9491
10352
|
config.app.route(`${basePath}/storage`, storageStub);
|
|
9492
10353
|
logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
|
|
9493
10354
|
}
|
|
10355
|
+
const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
|
|
9494
10356
|
if (activeCollections.length > 0) {
|
|
9495
10357
|
const dataRouter = new Hono();
|
|
9496
10358
|
dataRouter.onError(errorHandler);
|
|
@@ -9498,7 +10360,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
9498
10360
|
if (!dataRequireAuth) logger.warn("Data routes running WITHOUT authentication enforcement. Access control is fully delegated to Postgres RLS policies. If no RLS policies exist, data is publicly accessible. Set auth.requireAuth to true (or remove it) to require authentication.");
|
|
9499
10361
|
else {
|
|
9500
10362
|
const publicSelect = activeCollections.filter((c) => getEffectiveSecurityRules(c).some((rule) => "access" in rule && rule.access === "public" && (rule.operation === "select" || rule.operation === "all" || Array.isArray(rule.operations) && rule.operations.includes("select")))).map((c) => c.slug);
|
|
9501
|
-
if (publicSelect.length > 0)
|
|
10363
|
+
if (publicSelect.length > 0) {
|
|
10364
|
+
logger.info(`${publicSelect.length} collection(s) grant unfiltered reads (${publicSelect.join(", ")}) — reads still require a token. Set AUTH_REQUIRE=false to let RLS alone decide: https://rebase.pro/docs/collections/security-rules`);
|
|
10365
|
+
logger.debug("`access: \"public\"` widens which ROWS a caller sees, not who may call, so an unauthenticated read of a public collection answers 401 rather than returning rows. Setting AUTH_REQUIRE=false (or `auth.requireAuth: false`) removes the API-level gate and delegates the decision entirely to the Postgres RLS policies — the usual choice for a public website reading its own backend.");
|
|
10366
|
+
}
|
|
9502
10367
|
}
|
|
9503
10368
|
const dataPathMarker = `${basePath}/data/`;
|
|
9504
10369
|
const resolveRequestDriver = (reqPath) => {
|
|
@@ -9532,24 +10397,21 @@ async function _initializeRebaseBackend(config) {
|
|
|
9532
10397
|
});
|
|
9533
10398
|
dataRouter.route("/", historyRoutes);
|
|
9534
10399
|
}
|
|
9535
|
-
const restGenerator = new RestApiGenerator(
|
|
10400
|
+
const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
|
|
9536
10401
|
dataRouter.route("/", restGenerator.generateRoutes());
|
|
9537
10402
|
config.app.route(`${basePath}/data`, dataRouter);
|
|
9538
10403
|
}
|
|
9539
|
-
await mountOpenApiDocs(config.app, basePath, config.enableSwagger,
|
|
10404
|
+
await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
|
|
9540
10405
|
const serverClient = createRebaseClient({
|
|
9541
10406
|
baseUrl: "http://localhost",
|
|
9542
10407
|
apiPath: basePath,
|
|
9543
|
-
|
|
10408
|
+
realtime: false,
|
|
9544
10409
|
token: internalServiceKey,
|
|
9545
10410
|
fetch: async (input, init) => {
|
|
9546
10411
|
return await config.app.request(input, init);
|
|
9547
10412
|
}
|
|
9548
10413
|
});
|
|
9549
|
-
const serviceIdentity =
|
|
9550
|
-
uid: "service",
|
|
9551
|
-
roles: ["admin"]
|
|
9552
|
-
};
|
|
10414
|
+
const serviceIdentity = SERVICE_IDENTITY;
|
|
9553
10415
|
const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
|
|
9554
10416
|
storageAuthorizeData.current = defaultData;
|
|
9555
10417
|
const dataSourcesByKey = {};
|
|
@@ -9568,20 +10430,20 @@ async function _initializeRebaseBackend(config) {
|
|
|
9568
10430
|
data: serverData,
|
|
9569
10431
|
dataAsAdmin: serverData
|
|
9570
10432
|
});
|
|
9571
|
-
logger.
|
|
10433
|
+
logger.debug("Native data plane attached to singleton (bypasses HTTP loop)");
|
|
9572
10434
|
if (storageController) {
|
|
9573
10435
|
Object.assign(serverClient, { storage: storageController });
|
|
9574
|
-
logger.
|
|
10436
|
+
logger.debug("Native storage attached to singleton (bypasses HTTP loop)");
|
|
9575
10437
|
}
|
|
9576
10438
|
let emailService;
|
|
9577
10439
|
if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
|
|
9578
10440
|
else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
|
|
9579
10441
|
if (emailService) {
|
|
9580
10442
|
Object.assign(serverClient, { email: emailService });
|
|
9581
|
-
logger.
|
|
10443
|
+
logger.debug("Email service attached to singleton", { configured: emailService.isConfigured() });
|
|
9582
10444
|
if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") emailService.verifyConnection().then((success) => {
|
|
9583
10445
|
if (!success) logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
|
|
9584
|
-
else logger.
|
|
10446
|
+
else logger.debug("SMTP connection verified successfully.");
|
|
9585
10447
|
}).catch((err) => {
|
|
9586
10448
|
logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
|
|
9587
10449
|
});
|
|
@@ -9589,57 +10451,61 @@ async function _initializeRebaseBackend(config) {
|
|
|
9589
10451
|
const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
|
|
9590
10452
|
if (isSQLAdmin(driverAdmin)) {
|
|
9591
10453
|
Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
|
|
9592
|
-
logger.
|
|
10454
|
+
logger.debug("SQL capability attached to singleton");
|
|
9593
10455
|
}
|
|
9594
|
-
|
|
9595
|
-
|
|
10456
|
+
const serverSingleton = serverClient;
|
|
10457
|
+
_initRebase(serverSingleton);
|
|
10458
|
+
logger.debug("Rebase singleton initialized");
|
|
9596
10459
|
if (defaultDriverResult.internals) {
|
|
9597
10460
|
const driver = defaultDriverResult.internals.driver;
|
|
9598
10461
|
if (driver && "client" in driver) driver.client = serverClient;
|
|
9599
10462
|
}
|
|
9600
10463
|
if (config.functionsDir) {
|
|
9601
|
-
const {
|
|
9602
|
-
const { createFunctionRoutes } = await import("./function-routes-
|
|
9603
|
-
const
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9610
|
-
|
|
9611
|
-
|
|
9612
|
-
|
|
9613
|
-
|
|
9614
|
-
|
|
9615
|
-
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
|
|
9619
|
-
|
|
9620
|
-
|
|
9621
|
-
|
|
9622
|
-
|
|
9623
|
-
|
|
9624
|
-
|
|
9625
|
-
|
|
9626
|
-
|
|
9627
|
-
|
|
9628
|
-
|
|
9629
|
-
|
|
9630
|
-
|
|
9631
|
-
|
|
9632
|
-
|
|
10464
|
+
const { loadFunctionsWithDiagnostics } = await import("./function-loader-DDS1v7YX.js").then((n) => n.t);
|
|
10465
|
+
const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
|
|
10466
|
+
const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-RivJsME0.js");
|
|
10467
|
+
const { functions: loadedFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
|
|
10468
|
+
const functionsRouter = new Hono();
|
|
10469
|
+
functionsRouter.onError(errorHandler);
|
|
10470
|
+
const functionsTimeoutMs = resolveFunctionsTimeoutMs(config.functionsTimeoutMs);
|
|
10471
|
+
if (functionsTimeoutMs > 0) functionsRouter.use("/*", createFunctionsRequestTimeout(functionsTimeoutMs));
|
|
10472
|
+
const functionsRequireAuth = false;
|
|
10473
|
+
if (authAdapter) functionsRouter.use("/*", createAdapterAuthMiddleware({
|
|
10474
|
+
adapter: authAdapter,
|
|
10475
|
+
driver: defaultDriver,
|
|
10476
|
+
requireAuth: functionsRequireAuth,
|
|
10477
|
+
apiKeyStore
|
|
10478
|
+
}));
|
|
10479
|
+
else functionsRouter.use("/*", createAuthMiddleware({
|
|
10480
|
+
driver: defaultDriver,
|
|
10481
|
+
requireAuth: functionsRequireAuth,
|
|
10482
|
+
serviceKey: internalServiceKey,
|
|
10483
|
+
apiKeyStore
|
|
10484
|
+
}));
|
|
10485
|
+
functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
|
|
10486
|
+
if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
|
|
10487
|
+
...rateLimitConfig,
|
|
10488
|
+
anonymous: rateLimitConfig.anonymousFunctions !== void 0 ? rateLimitConfig.anonymousFunctions : DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT
|
|
10489
|
+
}));
|
|
10490
|
+
const fnRoutes = createFunctionRoutes(loadedFunctions, problems.length);
|
|
10491
|
+
functionsRouter.route("/", fnRoutes);
|
|
10492
|
+
config.app.route(`${basePath}/functions`, functionsRouter);
|
|
10493
|
+
if (loadedFunctions.length > 0) logger.info("Mounted custom functions", {
|
|
10494
|
+
count: loadedFunctions.length,
|
|
10495
|
+
path: `${basePath}/functions`,
|
|
10496
|
+
timeoutMs: functionsTimeoutMs
|
|
10497
|
+
});
|
|
10498
|
+
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."));
|
|
9633
10499
|
}
|
|
9634
10500
|
let cronScheduler;
|
|
9635
10501
|
if (config.cronsDir) {
|
|
9636
|
-
const {
|
|
9637
|
-
const { CronScheduler } = await import("./cron-scheduler-
|
|
9638
|
-
const { createCronRoutes } = await import("./cron-routes-
|
|
9639
|
-
const { createCronStore } = await import("./cron-store-
|
|
9640
|
-
const loadedCronJobs = await
|
|
10502
|
+
const { loadCronJobsWithDiagnostics } = await import("./cron-loader-YhhQeVBM.js").then((n) => n.t);
|
|
10503
|
+
const { CronScheduler } = await import("./cron-scheduler-DIpYBmZP.js").then((n) => n.n);
|
|
10504
|
+
const { createCronRoutes } = await import("./cron-routes-maM_RlUu.js").then((n) => n.n);
|
|
10505
|
+
const { createCronStore } = await import("./cron-store-Dvr4Y1sZ.js").then((n) => n.n);
|
|
10506
|
+
const { jobs: loadedCronJobs, problems: cronProblems } = await loadCronJobsWithDiagnostics(config.cronsDir);
|
|
9641
10507
|
cronScheduler = new CronScheduler();
|
|
9642
|
-
cronScheduler.setClient(
|
|
10508
|
+
cronScheduler.setClient(serverSingleton);
|
|
9643
10509
|
if (loadedCronJobs.length > 0) {
|
|
9644
10510
|
cronScheduler.registerJobs(loadedCronJobs);
|
|
9645
10511
|
const store = defaultBootstrapper.getAdmin?.(defaultDriverResult) && config.cronPersistence !== false ? createCronStore(defaultDriver) : void 0;
|
|
@@ -9650,7 +10516,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9650
10516
|
}
|
|
9651
10517
|
const cronRouter = new Hono();
|
|
9652
10518
|
applyAdminGate(cronRouter, "Cron");
|
|
9653
|
-
cronRouter.route("/", createCronRoutes(cronScheduler));
|
|
10519
|
+
cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
|
|
9654
10520
|
config.app.route(`${basePath}/cron`, cronRouter);
|
|
9655
10521
|
if (loadedCronJobs.length > 0) {
|
|
9656
10522
|
cronScheduler.start();
|
|
@@ -9658,10 +10524,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
9658
10524
|
count: loadedCronJobs.length,
|
|
9659
10525
|
path: `${basePath}/cron`
|
|
9660
10526
|
});
|
|
9661
|
-
} else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. Nothing is scheduled —
|
|
10527
|
+
} else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. ` + (cronProblems.length > 0 ? `Nothing is scheduled — ${cronProblems.length} file(s) were skipped, see the messages above.` : "The directory holds no .ts/.js cron files, so nothing is scheduled."));
|
|
9662
10528
|
}
|
|
9663
10529
|
{
|
|
9664
|
-
const { createBackupRoutes, parseBackupDestination } = await import("./backup-
|
|
10530
|
+
const { createBackupRoutes, parseBackupDestination } = await import("./backup-C6ljYVTp.js").then((n) => n.t);
|
|
9665
10531
|
const backupRouter = new Hono();
|
|
9666
10532
|
applyAdminGate(backupRouter, "Backup");
|
|
9667
10533
|
backupRouter.route("/", createBackupRoutes({
|
|
@@ -9672,7 +10538,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9672
10538
|
storage: storageController
|
|
9673
10539
|
}));
|
|
9674
10540
|
config.app.route(`${basePath}/admin/backups`, backupRouter);
|
|
9675
|
-
logger.
|
|
10541
|
+
logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
|
|
9676
10542
|
}
|
|
9677
10543
|
{
|
|
9678
10544
|
const { default: logsRoutes } = await import("./logs-routes-BYA72C_C.js").then((n) => n.n);
|
|
@@ -9680,10 +10546,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
9680
10546
|
applyAdminGate(logsRouter, "Logs");
|
|
9681
10547
|
logsRouter.route("/", logsRoutes);
|
|
9682
10548
|
config.app.route(`${basePath}/logs`, logsRouter);
|
|
9683
|
-
logger.
|
|
10549
|
+
logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
|
|
9684
10550
|
}
|
|
9685
10551
|
{
|
|
9686
|
-
const { createContractRoutes } = await import("./contract-routes-
|
|
10552
|
+
const { createContractRoutes } = await import("./contract-routes-Bet-eCNJ.js").then((n) => n.t);
|
|
9687
10553
|
const contractRouter = new Hono();
|
|
9688
10554
|
if (adminSurfacesGated) {
|
|
9689
10555
|
if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
|
|
@@ -9701,7 +10567,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9701
10567
|
runtimeVersion: config.runtimeVersion
|
|
9702
10568
|
}));
|
|
9703
10569
|
config.app.route(`${basePath}/meta`, contractRouter);
|
|
9704
|
-
logger.
|
|
10570
|
+
logger.debug("Contract routes mounted", { path: `${basePath}/meta` });
|
|
9705
10571
|
}
|
|
9706
10572
|
const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
|
|
9707
10573
|
providers: realtimeServices,
|
|
@@ -9709,7 +10575,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9709
10575
|
resolveKey: keyForCollectionPath
|
|
9710
10576
|
}) : defaultRealtimeService;
|
|
9711
10577
|
if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
|
|
9712
|
-
logger.
|
|
10578
|
+
logger.debug("Rebase Backend Initialized");
|
|
9713
10579
|
const authSchemaCheck = authConfigResult?.schemaHealthCheck;
|
|
9714
10580
|
const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
|
|
9715
10581
|
const shutdown = createShutdown({
|
|
@@ -9777,7 +10643,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
9777
10643
|
* export default defineFunction((app, { rebase }) => {
|
|
9778
10644
|
* app.use("/*", requireAuth);
|
|
9779
10645
|
* app.get("/home", async (c) => {
|
|
9780
|
-
*
|
|
10646
|
+
* // `rebase.sql` runs on the owner connection: no RLS, no policies,
|
|
10647
|
+
* // every row. It is the most privileged thing in this context —
|
|
10648
|
+
* // more so than `dataAsAdmin`, which is merely admin-scoped.
|
|
10649
|
+
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
9781
10650
|
* return c.json({ orders: Number(stats.n) });
|
|
9782
10651
|
* });
|
|
9783
10652
|
* });
|
|
@@ -9802,6 +10671,17 @@ function defineFunction(definition) {
|
|
|
9802
10671
|
*
|
|
9803
10672
|
* @see {@link defineFunction} for the equivalent custom-functions helper.
|
|
9804
10673
|
*
|
|
10674
|
+
* `ctx.rebase` is the `rebase` singleton itself — the same object a custom
|
|
10675
|
+
* function is handed — so `rebase.dataAsAdmin` here is **admin-scoped, not an
|
|
10676
|
+
* RLS bypass**: it is scoped once at boot as `SERVICE_IDENTITY`
|
|
10677
|
+
* (`{ uid: "service", roles: ["admin"] }`), and your policies are evaluated
|
|
10678
|
+
* against that identity, not skipped. See that constant's docblock. It clears
|
|
10679
|
+
* the default policies through their `rolesOverlap(['admin'])` arm, which is why
|
|
10680
|
+
* the distinction rarely shows; it shows against hand-written rules, where
|
|
10681
|
+
* `policy.serverContext()` (`auth.uid() IS NULL`) is **false** for it. Use
|
|
10682
|
+
* `rebase.sql()` when you need the genuine bypass — owner connection, no
|
|
10683
|
+
* policies.
|
|
10684
|
+
*
|
|
9805
10685
|
* @example
|
|
9806
10686
|
* ```ts
|
|
9807
10687
|
* import { defineCron } from "@rebasepro/server";
|
|
@@ -9809,12 +10689,15 @@ function defineFunction(definition) {
|
|
|
9809
10689
|
* export default defineCron({
|
|
9810
10690
|
* name: "Nightly cleanup",
|
|
9811
10691
|
* schedule: "0 3 * * *",
|
|
9812
|
-
* async handler({
|
|
9813
|
-
*
|
|
10692
|
+
* async handler({ rebase, log }) {
|
|
10693
|
+
* // `dataAsAdmin` runs as the service identity, with the `admin` role.
|
|
10694
|
+
* // A cron has no per-request user to narrow that, so scope the
|
|
10695
|
+
* // filters yourself — RLS will not do it for you here.
|
|
10696
|
+
* const { data: expired } = await rebase.dataAsAdmin.sessions.find({
|
|
9814
10697
|
* where: { expired: ["==", true] },
|
|
9815
10698
|
* });
|
|
9816
10699
|
* for (const session of expired) {
|
|
9817
|
-
* await
|
|
10700
|
+
* await rebase.dataAsAdmin.sessions.delete(session.id as string);
|
|
9818
10701
|
* }
|
|
9819
10702
|
* log(`Deleted ${expired.length} expired sessions`);
|
|
9820
10703
|
* },
|
|
@@ -9827,36 +10710,41 @@ function defineCron(definition) {
|
|
|
9827
10710
|
//#endregion
|
|
9828
10711
|
//#region src/utils/sql.ts
|
|
9829
10712
|
/**
|
|
9830
|
-
* Returns a SQL chunk calling `
|
|
9831
|
-
* This is a PostgreSQL RLS helper function created in the `
|
|
10713
|
+
* Returns a SQL chunk calling `rebase.uid()` — the current user's ID.
|
|
10714
|
+
* This is a PostgreSQL RLS helper function created in the `rebase` schema
|
|
9832
10715
|
* that reads `app.uid` set per-transaction by `withAuth()`.
|
|
9833
10716
|
*
|
|
10717
|
+
* The function moved from `auth.uid()` to `rebase.uid()` in 1.0 — `auth` is
|
|
10718
|
+
* Supabase's schema, and taking it meant Rebase could not be pointed at a
|
|
10719
|
+
* database that already had one. Use this helper rather than writing the call
|
|
10720
|
+
* by hand and the move costs you nothing.
|
|
10721
|
+
*
|
|
9834
10722
|
* @example
|
|
9835
10723
|
* sql`${table.uid} = ${authUid()}`
|
|
9836
10724
|
*/
|
|
9837
10725
|
var authUid = () => {
|
|
9838
|
-
return sql
|
|
10726
|
+
return sql.raw(RLS_UID_SQL);
|
|
9839
10727
|
};
|
|
9840
10728
|
/**
|
|
9841
|
-
* Returns a SQL chunk calling `
|
|
10729
|
+
* Returns a SQL chunk calling `rebase.roles()` — the current user's roles
|
|
9842
10730
|
* as a comma-separated string.
|
|
9843
10731
|
* Reads `app.user_roles` set per-transaction by `withAuth()`.
|
|
9844
10732
|
*
|
|
9845
10733
|
* @example
|
|
9846
|
-
* sql
|
|
10734
|
+
* sql`${authRoles()} ~ 'admin'`
|
|
9847
10735
|
*/
|
|
9848
10736
|
var authRoles = () => {
|
|
9849
|
-
return sql
|
|
10737
|
+
return sql.raw(RLS_ROLES_SQL);
|
|
9850
10738
|
};
|
|
9851
10739
|
/**
|
|
9852
|
-
* Returns a SQL chunk calling `
|
|
10740
|
+
* Returns a SQL chunk calling `rebase.jwt()` — the full JWT claims as JSONB.
|
|
9853
10741
|
* Reads `app.jwt` set per-transaction by `withAuth()`.
|
|
9854
10742
|
*
|
|
9855
10743
|
* @example
|
|
9856
|
-
* sql
|
|
10744
|
+
* sql`${authJwt()}->>'sub'`
|
|
9857
10745
|
*/
|
|
9858
10746
|
var authJwt = () => {
|
|
9859
|
-
return sql
|
|
10747
|
+
return sql.raw(RLS_JWT_SQL);
|
|
9860
10748
|
};
|
|
9861
10749
|
//#endregion
|
|
9862
10750
|
//#region src/env.ts
|
|
@@ -9930,6 +10818,7 @@ var rebaseEnvSchema = object({
|
|
|
9930
10818
|
REBASE_SERVICE_KEY: string().optional(),
|
|
9931
10819
|
ALLOW_REGISTRATION: boolString,
|
|
9932
10820
|
DISABLE_SELF_REGISTRATION: optionalBoolString,
|
|
10821
|
+
ALLOW_ANONYMOUS: optionalBoolString,
|
|
9933
10822
|
ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
|
|
9934
10823
|
CORS_ORIGINS: string().optional(),
|
|
9935
10824
|
FRONTEND_URL: string().optional(),
|
|
@@ -9993,7 +10882,158 @@ function loadEnv(options) {
|
|
|
9993
10882
|
return env;
|
|
9994
10883
|
}
|
|
9995
10884
|
//#endregion
|
|
10885
|
+
//#region src/services/outbound-url-guard.ts
|
|
10886
|
+
/**
|
|
10887
|
+
* Refusal to send a request to a destination the guard does not allow.
|
|
10888
|
+
*
|
|
10889
|
+
* Distinct from a network error on purpose: a blocked destination is
|
|
10890
|
+
* *terminal* — retrying it re-runs the same refusal — while an `ENOTFOUND`
|
|
10891
|
+
* or a refused connection is worth another attempt.
|
|
10892
|
+
*/
|
|
10893
|
+
var BlockedUrlError = class extends Error {
|
|
10894
|
+
constructor(message) {
|
|
10895
|
+
super(message);
|
|
10896
|
+
this.name = "BlockedUrlError";
|
|
10897
|
+
}
|
|
10898
|
+
};
|
|
10899
|
+
/** Names that mean "this machine" or "this network" before DNS is even asked. */
|
|
10900
|
+
var BLOCKED_HOST_SUFFIXES = [
|
|
10901
|
+
".localhost",
|
|
10902
|
+
".local",
|
|
10903
|
+
".internal",
|
|
10904
|
+
".home.arpa"
|
|
10905
|
+
];
|
|
10906
|
+
async function defaultLookup(hostname) {
|
|
10907
|
+
return (await lookup(hostname, {
|
|
10908
|
+
all: true,
|
|
10909
|
+
verbatim: true
|
|
10910
|
+
})).map((e) => e.address);
|
|
10911
|
+
}
|
|
10912
|
+
/**
|
|
10913
|
+
* Parse an IPv6 literal into its 16 bytes. Returns `null` for anything that is
|
|
10914
|
+
* not one — the caller has already established the string *is* an IPv6 address
|
|
10915
|
+
* via `isIP`, so `null` here means "shape we do not model", which is treated as
|
|
10916
|
+
* blocked rather than allowed.
|
|
10917
|
+
*/
|
|
10918
|
+
function parseIpv6(address) {
|
|
10919
|
+
let text = address;
|
|
10920
|
+
const zone = text.indexOf("%");
|
|
10921
|
+
if (zone !== -1) text = text.slice(0, zone);
|
|
10922
|
+
let tail = [];
|
|
10923
|
+
const lastColon = text.lastIndexOf(":");
|
|
10924
|
+
const maybeV4 = text.slice(lastColon + 1);
|
|
10925
|
+
if (maybeV4.includes(".")) {
|
|
10926
|
+
if (isIP(maybeV4) !== 4) return null;
|
|
10927
|
+
tail = maybeV4.split(".").map(Number);
|
|
10928
|
+
text = text.slice(0, lastColon);
|
|
10929
|
+
if (text.endsWith(":")) text += ":";
|
|
10930
|
+
}
|
|
10931
|
+
const halves = text.split("::");
|
|
10932
|
+
if (halves.length > 2) return null;
|
|
10933
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
10934
|
+
const rest = halves.length === 2 ? halves[1] ? halves[1].split(":") : [] : [];
|
|
10935
|
+
const missing = 8 - tail.length / 2 - head.length - rest.length;
|
|
10936
|
+
if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
|
|
10937
|
+
const all = [
|
|
10938
|
+
...head,
|
|
10939
|
+
...new Array(halves.length === 2 ? missing : 0).fill("0"),
|
|
10940
|
+
...rest
|
|
10941
|
+
];
|
|
10942
|
+
const bytes = [];
|
|
10943
|
+
for (const group of all) {
|
|
10944
|
+
const value = Number.parseInt(group, 16);
|
|
10945
|
+
if (!Number.isFinite(value) || value < 0 || value > 65535 || !/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
|
|
10946
|
+
bytes.push(value >> 8 & 255, value & 255);
|
|
10947
|
+
}
|
|
10948
|
+
return [...bytes, ...tail];
|
|
10949
|
+
}
|
|
10950
|
+
/**
|
|
10951
|
+
* Why this IPv4 address must not be a webhook destination, or `null` if it is
|
|
10952
|
+
* an ordinary public address.
|
|
10953
|
+
*/
|
|
10954
|
+
function blockedIpv4Reason(bytes) {
|
|
10955
|
+
const [a, b] = bytes;
|
|
10956
|
+
if (a === 0) return "\"this network\" (0.0.0.0/8)";
|
|
10957
|
+
if (a === 10) return "private (10.0.0.0/8)";
|
|
10958
|
+
if (a === 127) return "loopback (127.0.0.0/8)";
|
|
10959
|
+
if (a === 169 && b === 254) return "link-local (169.254.0.0/16) — the cloud metadata range";
|
|
10960
|
+
if (a === 172 && b >= 16 && b <= 31) return "private (172.16.0.0/12)";
|
|
10961
|
+
if (a === 192 && b === 168) return "private (192.168.0.0/16)";
|
|
10962
|
+
if (a === 100 && b >= 64 && b <= 127) return "carrier-grade NAT (100.64.0.0/10)";
|
|
10963
|
+
if (a === 192 && b === 0 && bytes[2] === 0) return "IETF protocol assignments (192.0.0.0/24)";
|
|
10964
|
+
if (a === 198 && (b === 18 || b === 19)) return "benchmarking (198.18.0.0/15)";
|
|
10965
|
+
if (a >= 224) return "multicast, reserved or broadcast (224.0.0.0/4 and above)";
|
|
10966
|
+
return null;
|
|
10967
|
+
}
|
|
10968
|
+
/**
|
|
10969
|
+
* Why this address must not be a webhook destination, or `null` if it is an
|
|
10970
|
+
* ordinary public address. Exported for tests and for anyone else adding an
|
|
10971
|
+
* outbound call whose destination comes from data.
|
|
10972
|
+
*/
|
|
10973
|
+
function blockedAddressReason(address) {
|
|
10974
|
+
const version = isIP(address);
|
|
10975
|
+
if (version === 4) return blockedIpv4Reason(address.split(".").map(Number));
|
|
10976
|
+
if (version !== 6) return `not an IP address: ${address}`;
|
|
10977
|
+
const bytes = parseIpv6(address);
|
|
10978
|
+
if (!bytes || bytes.length !== 16) return `unparseable IPv6 address: ${address}`;
|
|
10979
|
+
const low32 = bytes.slice(12);
|
|
10980
|
+
const isMapped = bytes.slice(0, 10).every((x) => x === 0) && bytes[10] === 255 && bytes[11] === 255;
|
|
10981
|
+
const isCompat = bytes.slice(0, 12).every((x) => x === 0) && !(low32[0] === 0 && low32[1] === 0);
|
|
10982
|
+
const isNat64 = bytes[0] === 0 && bytes[1] === 100 && bytes[2] === 255 && bytes[3] === 155;
|
|
10983
|
+
if (isMapped || isCompat || isNat64) return blockedIpv4Reason(low32);
|
|
10984
|
+
if (bytes[0] === 32 && bytes[1] === 2) return blockedIpv4Reason(bytes.slice(2, 6));
|
|
10985
|
+
if (bytes.every((x) => x === 0)) return "unspecified (::)";
|
|
10986
|
+
if (bytes.slice(0, 15).every((x) => x === 0) && bytes[15] === 1) return "loopback (::1)";
|
|
10987
|
+
if (bytes[0] === 254 && (bytes[1] & 192) === 128) return "link-local (fe80::/10)";
|
|
10988
|
+
if ((bytes[0] & 254) === 252) return "unique local (fc00::/7)";
|
|
10989
|
+
if (bytes[0] === 255) return "multicast (ff00::/8)";
|
|
10990
|
+
return null;
|
|
10991
|
+
}
|
|
10992
|
+
/**
|
|
10993
|
+
* Resolve and validate a destination before anything connects to it.
|
|
10994
|
+
*
|
|
10995
|
+
* Three checks, in order, because each is cheaper than the next: the scheme,
|
|
10996
|
+
* the hostname, then every address the hostname resolves to. *Every* address —
|
|
10997
|
+
* a name that answers with one public and one private record is a rebinding
|
|
10998
|
+
* attempt, and taking the first record would let it through.
|
|
10999
|
+
*
|
|
11000
|
+
* Known limit: this is validate-then-fetch. Between the lookup here and the
|
|
11001
|
+
* connection the runtime makes there is a second resolution this code does not
|
|
11002
|
+
* see, so a TTL-0 name that flips between answers can still land on a blocked
|
|
11003
|
+
* address. Closing that needs the socket pinned to the address checked here,
|
|
11004
|
+
* which Node's global `fetch` does not expose without an undici dispatcher.
|
|
11005
|
+
* The redirect policy in {@link WebhookDispatcher} closes the much wider
|
|
11006
|
+
* version of the same hole — a receiver that simply *points* at the internal
|
|
11007
|
+
* address.
|
|
11008
|
+
*
|
|
11009
|
+
* @throws BlockedUrlError when the destination is not allowed. DNS failures
|
|
11010
|
+
* are rethrown as they are, so callers can tell "never send this" from
|
|
11011
|
+
* "could not send this right now".
|
|
11012
|
+
*/
|
|
11013
|
+
async function assertAllowedOutboundUrl(rawUrl, options = {}) {
|
|
11014
|
+
let url;
|
|
11015
|
+
try {
|
|
11016
|
+
url = new URL(rawUrl);
|
|
11017
|
+
} catch {
|
|
11018
|
+
throw new BlockedUrlError(`Invalid webhook URL: ${JSON.stringify(rawUrl)}`);
|
|
11019
|
+
}
|
|
11020
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new BlockedUrlError(`Webhook URL must be http(s), got "${url.protocol}" in ${url.href}`);
|
|
11021
|
+
if (options.allowPrivateNetworks) return url;
|
|
11022
|
+
const hostname = url.hostname.startsWith("[") ? url.hostname.slice(1, -1) : url.hostname;
|
|
11023
|
+
const lowered = hostname.toLowerCase();
|
|
11024
|
+
if (lowered === "localhost" || BLOCKED_HOST_SUFFIXES.some((suffix) => lowered.endsWith(suffix))) throw new BlockedUrlError(`Webhook URL host "${hostname}" is an internal name`);
|
|
11025
|
+
const addresses = isIP(hostname) ? [hostname] : await (options.lookup ?? defaultLookup)(hostname);
|
|
11026
|
+
if (addresses.length === 0) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolved to no addresses`);
|
|
11027
|
+
for (const address of addresses) {
|
|
11028
|
+
const reason = blockedAddressReason(address);
|
|
11029
|
+
if (reason) throw new BlockedUrlError(`Webhook URL host "${hostname}" resolves to ${address}, which is ${reason}. Set allowPrivateNetworks to deliver to it anyway.`);
|
|
11030
|
+
}
|
|
11031
|
+
return url;
|
|
11032
|
+
}
|
|
11033
|
+
//#endregion
|
|
9996
11034
|
//#region src/services/webhook-service.ts
|
|
11035
|
+
/** Read at most this much of a response before cancelling the rest. */
|
|
11036
|
+
var MAX_RESPONSE_BYTES = 64 * 1024;
|
|
9997
11037
|
var WebhookDispatcher = class {
|
|
9998
11038
|
webhooks = [];
|
|
9999
11039
|
maxRetries = 3;
|
|
@@ -10002,33 +11042,103 @@ var WebhookDispatcher = class {
|
|
|
10002
11042
|
5e3,
|
|
10003
11043
|
15e3
|
|
10004
11044
|
];
|
|
11045
|
+
options;
|
|
11046
|
+
timeoutMs;
|
|
11047
|
+
queue = [];
|
|
11048
|
+
draining = null;
|
|
11049
|
+
constructor(options = {}) {
|
|
11050
|
+
this.options = options;
|
|
11051
|
+
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
11052
|
+
}
|
|
10005
11053
|
/** Register webhooks to watch */
|
|
10006
11054
|
setWebhooks(webhooks) {
|
|
10007
11055
|
this.webhooks = webhooks.filter((w) => w.enabled);
|
|
10008
11056
|
}
|
|
10009
|
-
/**
|
|
11057
|
+
/**
|
|
11058
|
+
* Called when a entity changes — checks if any webhook matches, and awaits
|
|
11059
|
+
* every delivery, retries included.
|
|
11060
|
+
*
|
|
11061
|
+
* **Do not await this inside a collection callback.** `afterSave` and
|
|
11062
|
+
* `afterDelete` run inside the write's Postgres transaction, so awaiting a
|
|
11063
|
+
* delivery here holds a pooled connection and the row's locks for as long
|
|
11064
|
+
* as the receiver takes to answer — up to ~36s across three attempts, per
|
|
11065
|
+
* matching webhook. Use {@link enqueueEntityChange} there; this method is
|
|
11066
|
+
* for a custom function or a job that wants the results.
|
|
11067
|
+
*/
|
|
10010
11068
|
async onEntityChange(table, event, id, entity, previousEntity) {
|
|
10011
|
-
const
|
|
10012
|
-
if (matchingWebhooks.length === 0) return [];
|
|
11069
|
+
const jobs = this.buildDeliveries(table, event, entity, previousEntity);
|
|
10013
11070
|
const results = [];
|
|
10014
|
-
for (const
|
|
10015
|
-
const
|
|
11071
|
+
for (const job of jobs) {
|
|
11072
|
+
const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
|
|
11073
|
+
this.options.onDelivery?.(result);
|
|
11074
|
+
results.push(result);
|
|
11075
|
+
}
|
|
11076
|
+
return results;
|
|
11077
|
+
}
|
|
11078
|
+
/**
|
|
11079
|
+
* Queue the same deliveries and return immediately.
|
|
11080
|
+
*
|
|
11081
|
+
* This is the form a collection callback wants. `afterSave` is awaited
|
|
11082
|
+
* *inside* the transaction that wrote the row, so anything it awaits is
|
|
11083
|
+
* transaction time; queueing hands the HTTP to a drain loop that runs
|
|
11084
|
+
* after the callback returns, and the transaction commits without waiting
|
|
11085
|
+
* for a third party. It also means a receiver that is down can no longer
|
|
11086
|
+
* fail the customer's write.
|
|
11087
|
+
*
|
|
11088
|
+
* The cost, stated plainly: the queue is in-process and in-memory. A crash
|
|
11089
|
+
* or a deploy between the enqueue and the delivery drops the event, and a
|
|
11090
|
+
* receiver may see the notification a few milliseconds before the row it
|
|
11091
|
+
* describes is committed. Use {@link flush} on shutdown, and
|
|
11092
|
+
* `onDelivery` to record failures.
|
|
11093
|
+
*/
|
|
11094
|
+
enqueueEntityChange(table, event, id, entity, previousEntity) {
|
|
11095
|
+
const jobs = this.buildDeliveries(table, event, entity, previousEntity);
|
|
11096
|
+
if (jobs.length === 0) return;
|
|
11097
|
+
this.queue.push(...jobs);
|
|
11098
|
+
this.startDraining();
|
|
11099
|
+
}
|
|
11100
|
+
/**
|
|
11101
|
+
* Wait for every queued delivery to finish. For graceful shutdown, and for
|
|
11102
|
+
* tests that need to observe what {@link enqueueEntityChange} sent.
|
|
11103
|
+
*/
|
|
11104
|
+
async flush() {
|
|
11105
|
+
while (this.draining) await this.draining;
|
|
11106
|
+
}
|
|
11107
|
+
startDraining() {
|
|
11108
|
+
if (this.draining) return;
|
|
11109
|
+
const run = this.drainQueue().finally(() => {
|
|
11110
|
+
if (this.draining === run) this.draining = null;
|
|
11111
|
+
});
|
|
11112
|
+
this.draining = run;
|
|
11113
|
+
}
|
|
11114
|
+
async drainQueue() {
|
|
11115
|
+
while (this.queue.length > 0) {
|
|
11116
|
+
const job = this.queue.shift();
|
|
11117
|
+
try {
|
|
11118
|
+
const result = await this.deliverWithRetry(job.webhook, job.event, job.payload);
|
|
11119
|
+
this.options.onDelivery?.(result);
|
|
11120
|
+
} catch {}
|
|
11121
|
+
}
|
|
11122
|
+
}
|
|
11123
|
+
/** The payload each matching webhook gets for this change. */
|
|
11124
|
+
buildDeliveries(table, event, entity, previousEntity) {
|
|
11125
|
+
return this.webhooks.filter((w) => w.table === table && w.events.includes(event)).map((webhook) => ({
|
|
11126
|
+
webhook,
|
|
11127
|
+
event,
|
|
11128
|
+
payload: {
|
|
10016
11129
|
type: event,
|
|
10017
11130
|
table,
|
|
10018
11131
|
record: entity,
|
|
10019
11132
|
old_record: event === "UPDATE" ? previousEntity : void 0,
|
|
10020
11133
|
schema: "public",
|
|
10021
11134
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
10022
|
-
}
|
|
10023
|
-
|
|
10024
|
-
results.push(result);
|
|
10025
|
-
}
|
|
10026
|
-
return results;
|
|
11135
|
+
}
|
|
11136
|
+
}));
|
|
10027
11137
|
}
|
|
10028
11138
|
async deliverWithRetry(webhook, event, payload) {
|
|
10029
11139
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
10030
|
-
const result = await this.deliver(webhook, event, payload, attempt);
|
|
10031
|
-
if (result.success) return result;
|
|
11140
|
+
const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
|
|
11141
|
+
if (result.success || terminal) return result;
|
|
10032
11142
|
if (attempt < this.maxRetries) {
|
|
10033
11143
|
const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
|
|
10034
11144
|
await new Promise((r) => setTimeout(r, backoff));
|
|
@@ -10055,41 +11165,106 @@ var WebhookDispatcher = class {
|
|
|
10055
11165
|
...webhook.headers || {}
|
|
10056
11166
|
};
|
|
10057
11167
|
if (webhook.secret) headers["X-Webhook-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`;
|
|
11168
|
+
const controller = new AbortController();
|
|
11169
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
10058
11170
|
try {
|
|
10059
|
-
const
|
|
10060
|
-
|
|
10061
|
-
|
|
11171
|
+
const url = await assertAllowedOutboundUrl(webhook.url, {
|
|
11172
|
+
allowPrivateNetworks: this.options.allowPrivateNetworks,
|
|
11173
|
+
lookup: this.options.lookup
|
|
11174
|
+
});
|
|
11175
|
+
const response = await fetch(url.href, {
|
|
10062
11176
|
method: "POST",
|
|
10063
11177
|
headers,
|
|
10064
11178
|
body,
|
|
10065
|
-
signal: controller.signal
|
|
11179
|
+
signal: controller.signal,
|
|
11180
|
+
redirect: "manual"
|
|
10066
11181
|
});
|
|
10067
|
-
|
|
10068
|
-
|
|
11182
|
+
if (response.status >= 300 && response.status < 400) {
|
|
11183
|
+
const location = typeof response.headers?.get === "function" ? response.headers.get("location") : null;
|
|
11184
|
+
return {
|
|
11185
|
+
terminal: true,
|
|
11186
|
+
result: {
|
|
11187
|
+
webhookId: webhook.id,
|
|
11188
|
+
event,
|
|
11189
|
+
payload,
|
|
11190
|
+
statusCode: response.status,
|
|
11191
|
+
responseBody: `Webhook receivers must not redirect: HTTP ${response.status}` + (location ? ` to ${location}` : "") + ". The redirect target is not re-validated, so it is not followed.",
|
|
11192
|
+
success: false,
|
|
11193
|
+
attemptNumber
|
|
11194
|
+
}
|
|
11195
|
+
};
|
|
11196
|
+
}
|
|
11197
|
+
const responseBody = await this.readCappedBody(response);
|
|
10069
11198
|
const success = response.status >= 200 && response.status < 300;
|
|
10070
11199
|
return {
|
|
10071
|
-
|
|
10072
|
-
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
|
|
10076
|
-
|
|
10077
|
-
|
|
11200
|
+
terminal: false,
|
|
11201
|
+
result: {
|
|
11202
|
+
webhookId: webhook.id,
|
|
11203
|
+
event,
|
|
11204
|
+
payload,
|
|
11205
|
+
statusCode: response.status,
|
|
11206
|
+
responseBody: responseBody.slice(0, 1e3),
|
|
11207
|
+
success,
|
|
11208
|
+
attemptNumber
|
|
11209
|
+
}
|
|
10078
11210
|
};
|
|
10079
11211
|
} catch (error) {
|
|
10080
11212
|
const message = error instanceof Error ? error.message : String(error);
|
|
10081
11213
|
return {
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
10087
|
-
|
|
10088
|
-
|
|
11214
|
+
terminal: error instanceof BlockedUrlError,
|
|
11215
|
+
result: {
|
|
11216
|
+
webhookId: webhook.id,
|
|
11217
|
+
event,
|
|
11218
|
+
payload,
|
|
11219
|
+
statusCode: 0,
|
|
11220
|
+
responseBody: message.slice(0, 1e3),
|
|
11221
|
+
success: false,
|
|
11222
|
+
attemptNumber
|
|
11223
|
+
}
|
|
10089
11224
|
};
|
|
11225
|
+
} finally {
|
|
11226
|
+
clearTimeout(timeout);
|
|
11227
|
+
}
|
|
11228
|
+
}
|
|
11229
|
+
/**
|
|
11230
|
+
* Read at most {@link MAX_RESPONSE_BYTES} of the response and cancel the
|
|
11231
|
+
* rest, so a receiver cannot buffer a 10 GB body into the pod before the
|
|
11232
|
+
* caller truncates it to 1000 characters.
|
|
11233
|
+
*/
|
|
11234
|
+
async readCappedBody(response) {
|
|
11235
|
+
const stream = response.body;
|
|
11236
|
+
if (!stream || typeof stream.getReader !== "function") return await response.text().catch(() => "");
|
|
11237
|
+
const reader = stream.getReader();
|
|
11238
|
+
const chunks = [];
|
|
11239
|
+
let total = 0;
|
|
11240
|
+
try {
|
|
11241
|
+
for (;;) {
|
|
11242
|
+
const { done, value } = await reader.read();
|
|
11243
|
+
if (done) break;
|
|
11244
|
+
if (!value) continue;
|
|
11245
|
+
chunks.push(value);
|
|
11246
|
+
total += value.byteLength;
|
|
11247
|
+
if (total >= MAX_RESPONSE_BYTES) break;
|
|
11248
|
+
}
|
|
11249
|
+
} catch {
|
|
11250
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
11251
|
+
} finally {
|
|
11252
|
+
await reader.cancel().catch(() => void 0);
|
|
10090
11253
|
}
|
|
11254
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
10091
11255
|
}
|
|
10092
11256
|
};
|
|
11257
|
+
function concat(chunks, total) {
|
|
11258
|
+
const out = new Uint8Array(total);
|
|
11259
|
+
let offset = 0;
|
|
11260
|
+
for (const chunk of chunks) {
|
|
11261
|
+
const room = out.length - offset;
|
|
11262
|
+
if (room <= 0) break;
|
|
11263
|
+
out.set(chunk.length > room ? chunk.subarray(0, room) : chunk, offset);
|
|
11264
|
+
offset += chunk.length;
|
|
11265
|
+
}
|
|
11266
|
+
return out;
|
|
11267
|
+
}
|
|
10093
11268
|
//#endregion
|
|
10094
11269
|
//#region src/utils/dev-port.ts
|
|
10095
11270
|
var MAX_PORT_ATTEMPTS = 20;
|
|
@@ -10250,10 +11425,11 @@ function isUnderPath(requestPath, prefix) {
|
|
|
10250
11425
|
* Serve a Single Page Application from an Hono app.
|
|
10251
11426
|
*
|
|
10252
11427
|
* @internal Not part of the stable public API. Exported only because the
|
|
10253
|
-
*
|
|
10254
|
-
* and `app/backend/src/index.ts`
|
|
10255
|
-
*
|
|
10256
|
-
*
|
|
11428
|
+
* entrypoint `rebase eject` writes
|
|
11429
|
+
* (`packages/cli/templates/eject/backend/src/index.ts`) and `app/backend/src/index.ts`
|
|
11430
|
+
* call it to serve the built frontend in production — the scaffolded template
|
|
11431
|
+
* has no entrypoint of its own. Its request-handling behavior is an
|
|
11432
|
+
* implementation detail and may change without a major version bump.
|
|
10257
11433
|
*/
|
|
10258
11434
|
function serveSPA(app, config) {
|
|
10259
11435
|
const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
|
|
@@ -11397,6 +12573,7 @@ function resolveAuthOptions(env, usersCollection) {
|
|
|
11397
12573
|
requireAuth: env.AUTH_REQUIRE,
|
|
11398
12574
|
allowRegistration: env.ALLOW_REGISTRATION,
|
|
11399
12575
|
disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
|
|
12576
|
+
allowAnonymous: env.ALLOW_ANONYMOUS,
|
|
11400
12577
|
allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
|
|
11401
12578
|
email: resolveEmailOptions(env),
|
|
11402
12579
|
cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
|
|
@@ -11804,7 +12981,7 @@ async function bootFromBundle(options = {}) {
|
|
|
11804
12981
|
const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
|
|
11805
12982
|
const bundle = options.bundle ?? loadBundle(bundleDir);
|
|
11806
12983
|
const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
|
|
11807
|
-
logger.
|
|
12984
|
+
logger.debug("Loaded bundle", {
|
|
11808
12985
|
app: bundle.manifest.app,
|
|
11809
12986
|
kind: bundle.manifest.kind,
|
|
11810
12987
|
schemaVersion: bundle.manifest.schemaVersion,
|
|
@@ -12175,8 +13352,9 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
|
|
|
12175
13352
|
skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
|
|
12176
13353
|
return;
|
|
12177
13354
|
}
|
|
12178
|
-
const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.
|
|
12179
|
-
|
|
13355
|
+
const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.debug(`schema: ${message}`));
|
|
13356
|
+
if (applied > 0) logger.info(`Applied ${applied} additive schema change(s) before boot.`);
|
|
13357
|
+
else logger.debug("Collection schema is up to date.");
|
|
12180
13358
|
}
|
|
12181
13359
|
/**
|
|
12182
13360
|
* The `InitializedDriver` to hand a bootstrapper before any driver exists.
|
|
@@ -12221,17 +13399,18 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
|
|
|
12221
13399
|
if (!primary) return;
|
|
12222
13400
|
if (!primary.bootstrapper.ensureCollectionPolicies) {
|
|
12223
13401
|
const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
|
|
12224
|
-
logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections
|
|
13402
|
+
logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections are not row-secured until policies are applied — depending on the driver's grants that means reads are denied or that they are unfiltered. Do not serve traffic until this is resolved.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }));
|
|
12225
13403
|
return;
|
|
12226
13404
|
}
|
|
12227
13405
|
const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
|
|
12228
13406
|
if (loaded.length === 0) return;
|
|
12229
13407
|
const collections = collectionsStoredBy(loaded, primary, dataSources);
|
|
12230
13408
|
if (collections.length === 0) return;
|
|
12231
|
-
const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.
|
|
12232
|
-
|
|
13409
|
+
const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.debug(`policies: ${message}`));
|
|
13410
|
+
if (applied > 0) logger.info(`Applied ${applied} RLS policy statement(s) before serving.`);
|
|
13411
|
+
else logger.debug("RLS policies are up to date.");
|
|
12233
13412
|
}
|
|
12234
13413
|
//#endregion
|
|
12235
|
-
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, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, queryTokenAuth, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyPassword };
|
|
13414
|
+
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, loadCronJobsWithDiagnostics, 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 };
|
|
12236
13415
|
|
|
12237
13416
|
//# sourceMappingURL=index.es.js.map
|