@rebasepro/server 0.13.1-canary.gf57a27e → 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-CLeUMG8G.js → admin_block-H86dCPsj.js} +60 -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-BANhRhN9.js → auth-CYoPVf-E.js} +1814 -354
- 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/{contract-routes-FSt0Nyfk.js → contract-routes-Bet-eCNJ.js} +3 -3
- package/dist/{contract-routes-FSt0Nyfk.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 +5 -0
- package/dist/cron/define-cron.d.ts +14 -2
- 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-CsQtYLMq.js → cron-scheduler-DIpYBmZP.js} +13 -7
- package/dist/cron-scheduler-DIpYBmZP.js.map +1 -0
- package/dist/{cron-store-ZbjfuvMb.js → cron-store-Dvr4Y1sZ.js} +5 -5
- package/dist/{cron-store-ZbjfuvMb.js.map → cron-store-Dvr4Y1sZ.js.map} +1 -1
- package/dist/{ddl-bootstrap-Bp3mr6Td.js → ddl-bootstrap-BhXbTnBl.js} +2 -2
- package/dist/{ddl-bootstrap-Bp3mr6Td.js.map → ddl-bootstrap-BhXbTnBl.js.map} +1 -1
- 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 +24 -5
- 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/index.d.ts +3 -2
- package/dist/index.es.js +1437 -694
- 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-VB0MUH8h.js → openapi-generator-DPKtUC9X.js} +87 -31
- 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-DVb5a0bp.js → src-C7rkDGxA.js} +536 -61
- package/dist/src-C7rkDGxA.js.map +1 -0
- package/dist/{src-Ca6NhxKs.js → src-Cz9nMgUR.js} +16 -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 +34 -1
- 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/package.json +6 -5
- package/dist/admin_block-CLeUMG8G.js.map +0 -1
- package/dist/auth-BANhRhN9.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-CsQtYLMq.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-VB0MUH8h.js.map +0 -1
- package/dist/schema-editor-routes-BqjzvvWU.js +0 -248
- package/dist/schema-editor-routes-BqjzvvWU.js.map +0 -1
- package/dist/src-Ca6NhxKs.js.map +0 -1
- package/dist/src-DVb5a0bp.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 { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath,
|
|
6
|
-
import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-
|
|
7
|
-
import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-
|
|
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";
|
|
@@ -37,73 +37,18 @@ import { csrf } from "hono/csrf";
|
|
|
37
37
|
import { compress } from "hono/compress";
|
|
38
38
|
import fs$1 from "node:fs";
|
|
39
39
|
import fsp from "node:fs/promises";
|
|
40
|
+
import os from "node:os";
|
|
40
41
|
import { mkdir, open, unlink, writeFile } from "fs/promises";
|
|
41
42
|
import { sql } from "drizzle-orm";
|
|
43
|
+
import { lookup } from "node:dns/promises";
|
|
44
|
+
import { isIP } from "node:net";
|
|
42
45
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
43
46
|
import { createServer } from "http";
|
|
44
47
|
import { cors } from "hono/cors";
|
|
45
48
|
import { secureHeaders } from "hono/secure-headers";
|
|
46
49
|
import { getRequestListener } from "@hono/node-server";
|
|
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. See {@link RebaseErrorCode}. */
|
|
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
|
/**
|
|
@@ -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,236 +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
|
-
function invalidOrderBy(detail) {
|
|
908
|
-
throw ApiError.badRequest(`Invalid \`orderBy\` parameter: ${detail}. Expected \`field\`, \`field:desc\`, or a JSON array like [{"field":"created_at","direction":"desc"}]`, "INVALID_ORDER_BY");
|
|
909
|
-
}
|
|
910
|
-
/** `asc`/`desc`, in any case. Anything else is a request to sort in a way that does not exist. */
|
|
911
|
-
function toDirection(raw, context) {
|
|
912
|
-
if (raw === void 0 || raw === null) return "asc";
|
|
913
|
-
if (typeof raw !== "string") invalidOrderBy(`${context} has a non-string \`direction\``);
|
|
914
|
-
const lowered = raw.toLowerCase();
|
|
915
|
-
if (lowered !== "asc" && lowered !== "desc") invalidOrderBy(`${context} has direction '${raw}'`);
|
|
916
|
-
return lowered;
|
|
917
|
-
}
|
|
918
|
-
/** One entry: the canonical `{field, direction}`, or the `field:direction` shorthand as a string. */
|
|
919
|
-
function toOrderByEntry(raw, index) {
|
|
920
|
-
const context = `entry ${index}`;
|
|
921
|
-
if (typeof raw === "string") {
|
|
922
|
-
const tuple = deserializeOrderBy(raw);
|
|
923
|
-
if (!tuple || !tuple[0]) invalidOrderBy(`${context} is an empty field name`);
|
|
924
|
-
return {
|
|
925
|
-
field: tuple[0],
|
|
926
|
-
direction: toDirection(tuple[1], context)
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) invalidOrderBy(`${context} is not a field name or a {field, direction} object`);
|
|
930
|
-
const entry = raw;
|
|
931
|
-
if (typeof entry.field !== "string" || entry.field.trim() === "") invalidOrderBy(`${context} has no \`field\``);
|
|
932
|
-
return {
|
|
933
|
-
field: entry.field,
|
|
934
|
-
direction: toDirection(entry.direction, context)
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Parse the `orderBy` query parameter.
|
|
939
|
-
*
|
|
940
|
-
* The field *name* has been validated against the schema for a while — an
|
|
941
|
-
* `?orderBy=titel` is a 400 rather than 200 with unsorted rows, on the grounds
|
|
942
|
-
* that silently dropping the sort leaves the caller believing in an order that
|
|
943
|
-
* is not there. The parameter's *shape* was never checked the same way, and it
|
|
944
|
-
* failed in exactly the same silent manner one layer earlier: whatever
|
|
945
|
-
* `JSON.parse` returned was assigned to an option declared as an array of
|
|
946
|
-
* `{field, direction}`, and the REST layer reads only `orderBy[0].field`. So
|
|
947
|
-
* `?orderBy={"field":"name"}` — an object rather than an array, and the most
|
|
948
|
-
* natural thing for a client to try — read `undefined`, dropped the ORDER BY,
|
|
949
|
-
* and answered 200. So did a number, a boolean, `null`, and `["name"]`.
|
|
950
|
-
*
|
|
951
|
-
* This refuses those, the way `parseWhereParam` above already refuses a
|
|
952
|
-
* malformed filter and for the same reason. What it keeps working is every
|
|
953
|
-
* shape that worked before: the `field` and `field:desc` shorthands, and the
|
|
954
|
-
* canonical JSON array.
|
|
955
|
-
*/
|
|
956
|
-
function parseOrderByParam(raw) {
|
|
957
|
-
if (Array.isArray(raw)) return raw.length === 0 ? void 0 : raw.map(toOrderByEntry);
|
|
958
|
-
const str = String(raw).trim();
|
|
959
|
-
if (!str) return void 0;
|
|
960
|
-
let parsed;
|
|
961
|
-
try {
|
|
962
|
-
parsed = JSON.parse(str);
|
|
963
|
-
} catch {
|
|
964
|
-
return [toOrderByEntry(str, 0)];
|
|
965
|
-
}
|
|
966
|
-
if (Array.isArray(parsed)) {
|
|
967
|
-
if (parsed.length === 0) return void 0;
|
|
968
|
-
return parsed.map(toOrderByEntry);
|
|
969
|
-
}
|
|
970
|
-
if (typeof parsed === "string") return [toOrderByEntry(parsed, 0)];
|
|
971
|
-
if (typeof parsed === "object" && parsed !== null) invalidOrderBy("a single object was given where a JSON array was expected");
|
|
972
|
-
invalidOrderBy(`${typeof parsed} is not a field name or a list of them`);
|
|
973
|
-
}
|
|
974
|
-
/**
|
|
975
|
-
* Parse query parameters into QueryOptions
|
|
976
|
-
*/
|
|
977
|
-
function parseQueryOptions(query, limits = {}) {
|
|
978
|
-
const options = {};
|
|
979
|
-
const rawLimit = getLastValue(query.limit);
|
|
980
|
-
const offsetVal = getLastValue(query.offset);
|
|
981
|
-
if (offsetVal) options.offset = parseInt(String(offsetVal));
|
|
982
|
-
const pageVal = getLastValue(query.page);
|
|
983
|
-
if (pageVal) {
|
|
984
|
-
const page = parseInt(String(pageVal));
|
|
985
|
-
const limit = resolveClientListLimit(rawLimit, {
|
|
986
|
-
defaultLimit: limits.defaultLimit,
|
|
987
|
-
maxLimit: limits.maxLimit
|
|
988
|
-
});
|
|
989
|
-
options.offset = (page - 1) * limit;
|
|
990
|
-
}
|
|
991
|
-
const orVal = getLastValue(query.or);
|
|
992
|
-
const andVal = getLastValue(query.and);
|
|
993
|
-
if (orVal) {
|
|
994
|
-
const logical = parseLogicalGroup("or", orVal);
|
|
995
|
-
if (logical) options.logical = logical;
|
|
996
|
-
} else if (andVal) {
|
|
997
|
-
const logical = parseLogicalGroup("and", andVal);
|
|
998
|
-
if (logical) options.logical = logical;
|
|
999
|
-
}
|
|
1000
|
-
const reservedQueryKeys = [
|
|
1001
|
-
"limit",
|
|
1002
|
-
"offset",
|
|
1003
|
-
"page",
|
|
1004
|
-
"orderBy",
|
|
1005
|
-
"include",
|
|
1006
|
-
"fields",
|
|
1007
|
-
"searchString",
|
|
1008
|
-
"searchExplain",
|
|
1009
|
-
"vector_search",
|
|
1010
|
-
"vector",
|
|
1011
|
-
"vector_distance",
|
|
1012
|
-
"vector_threshold",
|
|
1013
|
-
"or",
|
|
1014
|
-
"and",
|
|
1015
|
-
"where"
|
|
1016
|
-
];
|
|
1017
|
-
const filterDict = {};
|
|
1018
|
-
for (const [key, rawValue] of Object.entries(query)) {
|
|
1019
|
-
if (reservedQueryKeys.includes(key)) continue;
|
|
1020
|
-
filterDict[key] = rawValue;
|
|
1021
|
-
}
|
|
1022
|
-
const whereVal = getLastValue(query.where);
|
|
1023
|
-
const where = {
|
|
1024
|
-
...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
|
|
1025
|
-
...deserializeFilter(filterDict)
|
|
1026
|
-
};
|
|
1027
|
-
if (Object.keys(where).length > 0) options.where = where;
|
|
1028
|
-
const orderByVal = getLastValue(query.orderBy);
|
|
1029
|
-
if (orderByVal) options.orderBy = parseOrderByParam(orderByVal);
|
|
1030
|
-
const includeVal = getLastValue(query.include);
|
|
1031
|
-
if (includeVal) {
|
|
1032
|
-
const includeStr = String(includeVal).trim();
|
|
1033
|
-
if (includeStr === "*") options.include = ["*"];
|
|
1034
|
-
else options.include = includeStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1035
|
-
}
|
|
1036
|
-
const fieldsVal = getLastValue(query.fields);
|
|
1037
|
-
if (fieldsVal) options.fields = String(fieldsVal).trim().split(",").map((s) => s.trim()).filter(Boolean);
|
|
1038
|
-
const vectorSearchVal = getLastValue(query.vector_search);
|
|
1039
|
-
const vectorVal = getLastValue(query.vector);
|
|
1040
|
-
if (vectorSearchVal && vectorVal) {
|
|
1041
|
-
const vectorStr = String(vectorVal);
|
|
1042
|
-
let decoded;
|
|
1043
|
-
try {
|
|
1044
|
-
decoded = JSON.parse(vectorStr);
|
|
1045
|
-
} catch {
|
|
1046
|
-
decoded = void 0;
|
|
1047
|
-
}
|
|
1048
|
-
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");
|
|
1049
|
-
const queryVector = decoded;
|
|
1050
|
-
const distanceParamVal = getLastValue(query.vector_distance);
|
|
1051
|
-
const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
|
|
1052
|
-
if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
|
|
1053
|
-
const vectorSearch = {
|
|
1054
|
-
property: String(vectorSearchVal),
|
|
1055
|
-
vector: queryVector,
|
|
1056
|
-
distance: distanceParam
|
|
1057
|
-
};
|
|
1058
|
-
const thresholdVal = getLastValue(query.vector_threshold);
|
|
1059
|
-
if (thresholdVal) {
|
|
1060
|
-
const threshold = parseFloat(String(thresholdVal));
|
|
1061
|
-
if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
|
|
1062
|
-
vectorSearch.threshold = threshold;
|
|
1063
|
-
}
|
|
1064
|
-
options.vectorSearch = vectorSearch;
|
|
1065
|
-
}
|
|
1066
|
-
options.limit = resolveClientListLimit(rawLimit, {
|
|
1067
|
-
vectorSearch: !!options.vectorSearch,
|
|
1068
|
-
defaultLimit: limits.defaultLimit,
|
|
1069
|
-
maxLimit: limits.maxLimit
|
|
1070
|
-
});
|
|
1071
|
-
return options;
|
|
1072
|
-
}
|
|
1073
|
-
//#endregion
|
|
1074
855
|
//#region src/api/rest/write-validation.ts
|
|
1075
856
|
/**
|
|
1076
857
|
* Reject a write naming a field the collection does not have.
|
|
1077
858
|
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
1080
|
-
*
|
|
1081
|
-
*
|
|
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.
|
|
1082
871
|
*
|
|
1083
872
|
* What counts as known:
|
|
1084
873
|
* - a declared property (for an introspected BaaS collection these *are* the
|
|
@@ -1093,7 +882,7 @@ function assertKnownWriteFields(values, collection, options) {
|
|
|
1093
882
|
if (collection.strictWrites === false) return;
|
|
1094
883
|
if (!collection.properties || Object.keys(collection.properties).length === 0) return;
|
|
1095
884
|
const known = new Set(Object.keys(collection.properties));
|
|
1096
|
-
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));
|
|
1097
886
|
for (const field of options?.extraKnownFields ?? []) known.add(field);
|
|
1098
887
|
const unknown = Object.keys(values).filter((key) => !known.has(key));
|
|
1099
888
|
if (unknown.length === 0) return;
|
|
@@ -1106,6 +895,145 @@ function assertKnownWriteFields(values, collection, options) {
|
|
|
1106
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");
|
|
1107
896
|
}
|
|
1108
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
|
+
/**
|
|
1109
1037
|
* Narrow response rows to the fields the caller asked for.
|
|
1110
1038
|
*
|
|
1111
1039
|
* `?fields=id,title` is documented in the generated OpenAPI — "Comma-separated
|
|
@@ -1136,7 +1064,7 @@ function projectResponseFields(rows, fields, collection, options) {
|
|
|
1136
1064
|
const declared = new Set(Object.keys(collection.properties ?? {}));
|
|
1137
1065
|
for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
1138
1066
|
declared.add(key);
|
|
1139
|
-
if (relation.kind === "belongsTo") declared.add(relation.localKey);
|
|
1067
|
+
if (relation.kind === "belongsTo") declared.add(fieldKeyForColumn(collection, relation.localKey));
|
|
1140
1068
|
}
|
|
1141
1069
|
for (const included of options?.include ?? []) declared.add(included);
|
|
1142
1070
|
declared.add("id");
|
|
@@ -1173,6 +1101,9 @@ function projectResponseFields(rows, fields, collection, options) {
|
|
|
1173
1101
|
* generated on the client, so keying on the id alone would let anyone who
|
|
1174
1102
|
* learned (or guessed) another user's id replay their key and be handed that
|
|
1175
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.
|
|
1176
1107
|
*/
|
|
1177
1108
|
var TABLE = "\"rebase\".\"idempotency_keys\"";
|
|
1178
1109
|
/**
|
|
@@ -1183,15 +1114,75 @@ var TABLE = "\"rebase\".\"idempotency_keys\"";
|
|
|
1183
1114
|
*/
|
|
1184
1115
|
var TTL_HOURS = 24;
|
|
1185
1116
|
/**
|
|
1186
|
-
*
|
|
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.
|
|
1136
|
+
*
|
|
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.
|
|
1187
1148
|
*
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
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.
|
|
1192
1155
|
*/
|
|
1193
1156
|
function principal(uid) {
|
|
1194
|
-
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");
|
|
1195
1186
|
}
|
|
1196
1187
|
/**
|
|
1197
1188
|
* Returns `undefined` when the driver cannot run SQL, which disables the whole
|
|
@@ -1208,9 +1199,19 @@ function createIdempotencyStore(driver) {
|
|
|
1208
1199
|
if (!isSQLAdmin(admin)) return void 0;
|
|
1209
1200
|
const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
|
|
1210
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
|
+
};
|
|
1211
1210
|
/** Created on first use: most deployments never send a key at all. */
|
|
1212
1211
|
const ensure = () => {
|
|
1213
|
-
|
|
1212
|
+
if (ready) return ready;
|
|
1213
|
+
if (Date.now() < retryEnsureAt) return Promise.resolve(false);
|
|
1214
|
+
ready = (async () => {
|
|
1214
1215
|
try {
|
|
1215
1216
|
await exec("CREATE SCHEMA IF NOT EXISTS rebase");
|
|
1216
1217
|
await exec(`
|
|
@@ -1222,29 +1223,44 @@ function createIdempotencyStore(driver) {
|
|
|
1222
1223
|
PRIMARY KEY (uid, key)
|
|
1223
1224
|
)
|
|
1224
1225
|
`);
|
|
1226
|
+
await exec(`ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS fingerprint TEXT`);
|
|
1225
1227
|
await exec(`CREATE INDEX IF NOT EXISTS idx_idempotency_created ON ${TABLE}(created_at)`);
|
|
1226
1228
|
await exec(revokeInternalTableSql("rebase", "idempotency_keys"));
|
|
1227
1229
|
return true;
|
|
1228
1230
|
} catch (error) {
|
|
1231
|
+
retryEnsureAt = Date.now() + ENSURE_RETRY_COOLDOWN_MS;
|
|
1229
1232
|
logger.warn("Idempotency keys unavailable — a replayed offline write may insert a duplicate row.", { detail: error instanceof Error ? error.message : String(error) });
|
|
1230
1233
|
return false;
|
|
1231
1234
|
}
|
|
1232
|
-
})()
|
|
1235
|
+
})().then((ok) => {
|
|
1236
|
+
if (!ok) ready = void 0;
|
|
1237
|
+
return ok;
|
|
1238
|
+
});
|
|
1233
1239
|
return ready;
|
|
1234
1240
|
};
|
|
1235
1241
|
return {
|
|
1236
|
-
async claim(key, uid) {
|
|
1237
|
-
|
|
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" };
|
|
1238
1246
|
try {
|
|
1239
|
-
if ((await exec(`INSERT INTO ${TABLE} (key, uid, response, created_at)
|
|
1240
|
-
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())
|
|
1241
1249
|
ON CONFLICT (uid, key) DO UPDATE
|
|
1242
|
-
SET response = NULL, created_at = NOW()
|
|
1243
|
-
WHERE ${TABLE}.
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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];
|
|
1247
1262
|
if (!row) return { status: "claimed" };
|
|
1263
|
+
if (row.fingerprint != null && row.fingerprint !== fingerprint) return { status: "mismatch" };
|
|
1248
1264
|
if (row.pending) return { status: "in-flight" };
|
|
1249
1265
|
return {
|
|
1250
1266
|
status: "replay",
|
|
@@ -1255,10 +1271,11 @@ function createIdempotencyStore(driver) {
|
|
|
1255
1271
|
}
|
|
1256
1272
|
},
|
|
1257
1273
|
async complete(key, uid, response) {
|
|
1258
|
-
|
|
1274
|
+
const owner = principal(uid);
|
|
1275
|
+
if (!key || !owner || !await ensure()) return;
|
|
1259
1276
|
try {
|
|
1260
1277
|
await exec(`UPDATE ${TABLE} SET response = $3::jsonb WHERE uid = $1 AND key = $2`, [
|
|
1261
|
-
|
|
1278
|
+
owner,
|
|
1262
1279
|
key,
|
|
1263
1280
|
JSON.stringify(response ?? null)
|
|
1264
1281
|
]);
|
|
@@ -1266,9 +1283,10 @@ function createIdempotencyStore(driver) {
|
|
|
1266
1283
|
} catch {}
|
|
1267
1284
|
},
|
|
1268
1285
|
async release(key, uid) {
|
|
1269
|
-
|
|
1286
|
+
const owner = principal(uid);
|
|
1287
|
+
if (!key || !owner || !await ensure()) return;
|
|
1270
1288
|
try {
|
|
1271
|
-
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]);
|
|
1272
1290
|
} catch {}
|
|
1273
1291
|
}
|
|
1274
1292
|
};
|
|
@@ -1293,6 +1311,18 @@ async function parseJsonBody(c) {
|
|
|
1293
1311
|
}
|
|
1294
1312
|
}
|
|
1295
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
|
+
/**
|
|
1296
1326
|
* Lightweight REST API generator that leverages existing Rebase DataDriver.
|
|
1297
1327
|
* Supports `include` query parameter for eager-loading relations via Drizzle.
|
|
1298
1328
|
*/
|
|
@@ -1341,17 +1371,52 @@ var RestApiGenerator = class {
|
|
|
1341
1371
|
this.createCollectionRoutes(collection);
|
|
1342
1372
|
});
|
|
1343
1373
|
this.createSubcollectionRoutes();
|
|
1374
|
+
this.createUnmatchedRoute();
|
|
1344
1375
|
return this.router;
|
|
1345
1376
|
}
|
|
1346
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
|
+
/**
|
|
1347
1412
|
* Check API key permissions for a collection operation.
|
|
1348
1413
|
* Throws 403 if the key doesn't have the required permission.
|
|
1349
1414
|
* No-ops if the request is not authenticated via an API key.
|
|
1350
1415
|
*/
|
|
1351
|
-
enforceApiKeyPermission(c, collectionSlug) {
|
|
1416
|
+
enforceApiKeyPermission(c, collectionSlug, operationOverride) {
|
|
1352
1417
|
const apiKey = c.get("apiKey");
|
|
1353
1418
|
if (!apiKey) return;
|
|
1354
|
-
const operation = httpMethodToOperation(c.req.method);
|
|
1419
|
+
const operation = operationOverride ?? httpMethodToOperation(c.req.method);
|
|
1355
1420
|
if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
|
|
1356
1421
|
}
|
|
1357
1422
|
/**
|
|
@@ -1372,7 +1437,7 @@ var RestApiGenerator = class {
|
|
|
1372
1437
|
* Needed so a nested write can be checked against a schema at all. Without
|
|
1373
1438
|
* it these routes skipped `assertKnownWriteFields` entirely, which is why a
|
|
1374
1439
|
* typo `POST /posts` rejected with a 400 while the same typo on
|
|
1375
|
-
* `POST /authors/1/posts`
|
|
1440
|
+
* `POST /authors/1/posts` was dropped from the statement and answered 201.
|
|
1376
1441
|
*
|
|
1377
1442
|
* Returns `undefined` rather than throwing when the path cannot be walked:
|
|
1378
1443
|
* the driver raises the authoritative error a moment later, and duplicating
|
|
@@ -1466,13 +1531,18 @@ var RestApiGenerator = class {
|
|
|
1466
1531
|
* failure, complete on success — and getting one of those steps wrong
|
|
1467
1532
|
* is exactly the bug the key exists to prevent. Written once so the
|
|
1468
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.
|
|
1469
1538
|
*/
|
|
1470
|
-
const withIdempotency = async (c, run, respond) => {
|
|
1539
|
+
const withIdempotency = async (c, body, run, respond) => {
|
|
1471
1540
|
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1472
1541
|
const uid = c.get("user")?.uid;
|
|
1473
1542
|
const store = this.idempotency();
|
|
1474
|
-
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid) : void 0;
|
|
1543
|
+
const claimed = idempotencyKey && store ? await store.claim(idempotencyKey, uid, requestFingerprint(c.req.method, c.req.path, body)) : void 0;
|
|
1475
1544
|
if (claimed?.status === "replay") return respond(claimed.response);
|
|
1545
|
+
if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
|
|
1476
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");
|
|
1477
1547
|
let response;
|
|
1478
1548
|
try {
|
|
@@ -1511,8 +1581,11 @@ var RestApiGenerator = class {
|
|
|
1511
1581
|
});
|
|
1512
1582
|
if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
|
|
1513
1583
|
if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
|
|
1514
|
-
rows.forEach((row, rowIndex) =>
|
|
1515
|
-
|
|
1584
|
+
rows.forEach((row, rowIndex) => {
|
|
1585
|
+
assertKnownWriteFields(row, resolvedCollection, { rowIndex });
|
|
1586
|
+
assertWriteValuesValid(row, resolvedCollection, { rowIndex });
|
|
1587
|
+
});
|
|
1588
|
+
return withIdempotency(c, body, async () => {
|
|
1516
1589
|
const written = await driver.saveMany({
|
|
1517
1590
|
path,
|
|
1518
1591
|
rows,
|
|
@@ -1541,8 +1614,9 @@ var RestApiGenerator = class {
|
|
|
1541
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");
|
|
1542
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");
|
|
1543
1616
|
assertKnownWriteFields(entry.data, resolvedCollection, { rowIndex });
|
|
1617
|
+
assertWriteValuesValid(entry.data, resolvedCollection, { rowIndex });
|
|
1544
1618
|
});
|
|
1545
|
-
return withIdempotency(c, async () => {
|
|
1619
|
+
return withIdempotency(c, body, async () => {
|
|
1546
1620
|
const written = await driver.updateMany({
|
|
1547
1621
|
path,
|
|
1548
1622
|
updates: updates.map((entry) => ({
|
|
@@ -1558,7 +1632,7 @@ var RestApiGenerator = class {
|
|
|
1558
1632
|
}, (result) => c.json(result));
|
|
1559
1633
|
});
|
|
1560
1634
|
this.router.post(`${basePath}/bulk/delete`, async (c) => {
|
|
1561
|
-
this.enforceApiKeyPermission(c, collection.slug);
|
|
1635
|
+
this.enforceApiKeyPermission(c, collection.slug, "delete");
|
|
1562
1636
|
const driver = this.getScopedDriver(c);
|
|
1563
1637
|
const path = collection.slug;
|
|
1564
1638
|
const body = await parseJsonBody(c);
|
|
@@ -1567,7 +1641,7 @@ var RestApiGenerator = class {
|
|
|
1567
1641
|
if (ids.length === 0) return c.json({ meta: { deleted: 0 } });
|
|
1568
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");
|
|
1569
1643
|
if (!driver.deleteMany) throw ApiError.badRequest("This collection's data source does not support bulk deletes.", "BULK_UNSUPPORTED");
|
|
1570
|
-
return withIdempotency(c, async () => {
|
|
1644
|
+
return withIdempotency(c, body, async () => {
|
|
1571
1645
|
await driver.deleteMany({
|
|
1572
1646
|
path,
|
|
1573
1647
|
ids,
|
|
@@ -1584,10 +1658,15 @@ var RestApiGenerator = class {
|
|
|
1584
1658
|
const isAuth = collection.auth;
|
|
1585
1659
|
const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
|
|
1586
1660
|
const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
|
|
1587
|
-
if (!isAuthCollection)
|
|
1588
|
-
|
|
1661
|
+
if (!isAuthCollection) {
|
|
1662
|
+
assertKnownWriteFields(body, resolvedCollection);
|
|
1663
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1664
|
+
} else {
|
|
1589
1665
|
const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
|
|
1590
|
-
if (contract?.validate)
|
|
1666
|
+
if (contract?.validate) {
|
|
1667
|
+
assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
|
|
1668
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1669
|
+
}
|
|
1591
1670
|
}
|
|
1592
1671
|
if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
|
|
1593
1672
|
const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
|
|
@@ -1615,8 +1694,9 @@ var RestApiGenerator = class {
|
|
|
1615
1694
|
const idempotencyKey = c.req.header(IDEMPOTENCY_HEADER);
|
|
1616
1695
|
const uid = c.get("user")?.uid;
|
|
1617
1696
|
const store = this.idempotency();
|
|
1618
|
-
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;
|
|
1619
1698
|
if (claimed?.status === "replay") return c.json(claimed.response, 201);
|
|
1699
|
+
if (claimed?.status === "mismatch") throw idempotencyKeyReused(idempotencyKey);
|
|
1620
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");
|
|
1621
1701
|
let response;
|
|
1622
1702
|
try {
|
|
@@ -1645,6 +1725,7 @@ var RestApiGenerator = class {
|
|
|
1645
1725
|
})) throw ApiError.notFound("Entity not found");
|
|
1646
1726
|
const body = await parseJsonBody(c);
|
|
1647
1727
|
assertKnownWriteFields(body, resolvedCollection);
|
|
1728
|
+
assertWriteValuesValid(body, resolvedCollection);
|
|
1648
1729
|
const entity = await driver.save({
|
|
1649
1730
|
path: getCollectionDataPath(collection),
|
|
1650
1731
|
id: String(id),
|
|
@@ -1808,7 +1889,10 @@ var RestApiGenerator = class {
|
|
|
1808
1889
|
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
1809
1890
|
const body = await parseJsonBody(c);
|
|
1810
1891
|
const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1811
|
-
if (targetCollection)
|
|
1892
|
+
if (targetCollection) {
|
|
1893
|
+
assertKnownWriteFields(body, targetCollection);
|
|
1894
|
+
assertWriteValuesValid(body, targetCollection);
|
|
1895
|
+
}
|
|
1812
1896
|
const entity = await driver.save({
|
|
1813
1897
|
path: parsed.collectionPath,
|
|
1814
1898
|
values: body,
|
|
@@ -1827,7 +1911,10 @@ var RestApiGenerator = class {
|
|
|
1827
1911
|
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
1828
1912
|
const body = await parseJsonBody(c);
|
|
1829
1913
|
const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
|
|
1830
|
-
if (targetCollection)
|
|
1914
|
+
if (targetCollection) {
|
|
1915
|
+
assertKnownWriteFields(body, targetCollection);
|
|
1916
|
+
assertWriteValuesValid(body, targetCollection);
|
|
1917
|
+
}
|
|
1831
1918
|
const entity = await driver.save({
|
|
1832
1919
|
path: parsed.collectionPath,
|
|
1833
1920
|
id: parsed.id,
|
|
@@ -2047,7 +2134,7 @@ function configureMiddlewares(app, basePath, isProduction, config) {
|
|
|
2047
2134
|
app.use(`${basePath}/*`, requestId());
|
|
2048
2135
|
if (config.compression !== false) {
|
|
2049
2136
|
app.use(`${basePath}/*`, responseCompression());
|
|
2050
|
-
logger.
|
|
2137
|
+
logger.debug("Response compression enabled");
|
|
2051
2138
|
}
|
|
2052
2139
|
const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
|
|
2053
2140
|
if (maxBodySize > 0) {
|
|
@@ -2060,11 +2147,11 @@ function configureMiddlewares(app, basePath, isProduction, config) {
|
|
|
2060
2147
|
} }, 413);
|
|
2061
2148
|
}
|
|
2062
2149
|
}));
|
|
2063
|
-
logger.
|
|
2150
|
+
logger.debug("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
|
|
2064
2151
|
}
|
|
2065
2152
|
if (config.csrf?.origin) {
|
|
2066
2153
|
app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
|
|
2067
|
-
logger.
|
|
2154
|
+
logger.debug("CSRF protection enabled");
|
|
2068
2155
|
}
|
|
2069
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.");
|
|
2070
2157
|
app.use(`${basePath}/*`, requestLogger());
|
|
@@ -2118,7 +2205,7 @@ var LocalStorageController = class {
|
|
|
2118
2205
|
}
|
|
2119
2206
|
/**
|
|
2120
2207
|
* Get the full filesystem path for a storage path, with a traversal guard
|
|
2121
|
-
* that keeps the result inside the
|
|
2208
|
+
* that keeps the result inside the storage root.
|
|
2122
2209
|
*
|
|
2123
2210
|
* Defaults the bucket the way `putObject` does.
|
|
2124
2211
|
*
|
|
@@ -2129,10 +2216,22 @@ var LocalStorageController = class {
|
|
|
2129
2216
|
* design), and `listObjects` returned an empty page. So the obvious
|
|
2130
2217
|
* `putObject({ key })` → `getObject(key)` did not round-trip and nothing
|
|
2131
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.
|
|
2132
2230
|
*/
|
|
2133
2231
|
getFullPath(storagePath, bucket) {
|
|
2134
|
-
const bucketPath = path$2.
|
|
2135
|
-
|
|
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);
|
|
2136
2235
|
if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
2137
2236
|
return resolved;
|
|
2138
2237
|
}
|
|
@@ -2148,7 +2247,7 @@ var LocalStorageController = class {
|
|
|
2148
2247
|
}
|
|
2149
2248
|
async putObject({ file, key, metadata, bucket }) {
|
|
2150
2249
|
this.validateFile(file);
|
|
2151
|
-
const usedBucket = bucket
|
|
2250
|
+
const usedBucket = bucket || "default";
|
|
2152
2251
|
const fullStoragePath = key;
|
|
2153
2252
|
const fullPath = this.getFullPath(fullStoragePath, usedBucket);
|
|
2154
2253
|
await this.ensureDir(path$2.dirname(fullPath));
|
|
@@ -2378,38 +2477,63 @@ var VALID_FITS = /* @__PURE__ */ new Set([
|
|
|
2378
2477
|
"outside"
|
|
2379
2478
|
]);
|
|
2380
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
|
+
/**
|
|
2381
2507
|
* Parse transform options from URL query parameters.
|
|
2382
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".
|
|
2383
2514
|
*/
|
|
2384
2515
|
function parseTransformOptions(query) {
|
|
2385
2516
|
const opts = {};
|
|
2386
2517
|
let hasTransform = false;
|
|
2387
2518
|
if (query.width) {
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
opts.width = Math.min(w, MAX_DIMENSION);
|
|
2391
|
-
hasTransform = true;
|
|
2392
|
-
}
|
|
2519
|
+
opts.width = boundedInt("width", query.width, 1, MAX_DIMENSION);
|
|
2520
|
+
hasTransform = true;
|
|
2393
2521
|
}
|
|
2394
2522
|
if (query.height) {
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
opts.height = Math.min(h, MAX_DIMENSION);
|
|
2398
|
-
hasTransform = true;
|
|
2399
|
-
}
|
|
2523
|
+
opts.height = boundedInt("height", query.height, 1, MAX_DIMENSION);
|
|
2524
|
+
hasTransform = true;
|
|
2400
2525
|
}
|
|
2401
2526
|
if (query.quality) {
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
opts.quality = Math.min(Math.max(q, MIN_QUALITY), MAX_QUALITY);
|
|
2405
|
-
hasTransform = true;
|
|
2406
|
-
}
|
|
2527
|
+
opts.quality = boundedInt("quality", query.quality, MIN_QUALITY, MAX_QUALITY);
|
|
2528
|
+
hasTransform = true;
|
|
2407
2529
|
}
|
|
2408
|
-
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(", ")}.`);
|
|
2409
2532
|
opts.format = query.format;
|
|
2410
2533
|
hasTransform = true;
|
|
2411
2534
|
}
|
|
2412
|
-
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(", ")}.`);
|
|
2413
2537
|
opts.fit = query.fit;
|
|
2414
2538
|
hasTransform = true;
|
|
2415
2539
|
}
|
|
@@ -2427,9 +2551,72 @@ function isTransformableImage(contentType) {
|
|
|
2427
2551
|
return contentType.startsWith("image/") && !contentType.includes("svg") && !contentType.includes("gif");
|
|
2428
2552
|
}
|
|
2429
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
|
+
/**
|
|
2430
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.
|
|
2431
2615
|
*/
|
|
2432
2616
|
async function transformImage(buffer, options) {
|
|
2617
|
+
return transformQueue.run(() => runTransform(buffer, options));
|
|
2618
|
+
}
|
|
2619
|
+
async function runTransform(buffer, options) {
|
|
2433
2620
|
let pipeline = (await getSharp())(buffer);
|
|
2434
2621
|
if (options.width || options.height) pipeline = pipeline.resize({
|
|
2435
2622
|
width: options.width,
|
|
@@ -2556,6 +2743,7 @@ var TusHandler = class {
|
|
|
2556
2743
|
this.cleanupTimer = setInterval(() => {
|
|
2557
2744
|
this.cleanupStale();
|
|
2558
2745
|
}, 6e4);
|
|
2746
|
+
this.cleanupTimer.unref?.();
|
|
2559
2747
|
}
|
|
2560
2748
|
/** Remove uploads that have been idle for longer than UPLOAD_EXPIRY_MS. */
|
|
2561
2749
|
async cleanupStale() {
|
|
@@ -2615,7 +2803,14 @@ var TusHandler = class {
|
|
|
2615
2803
|
} catch (err) {
|
|
2616
2804
|
throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
|
|
2617
2805
|
}
|
|
2618
|
-
|
|
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);
|
|
2619
2814
|
const filePath = join(this.tusDir, id);
|
|
2620
2815
|
await writeFile(filePath, Buffer.alloc(0));
|
|
2621
2816
|
const upload = {
|
|
@@ -2625,7 +2820,8 @@ var TusHandler = class {
|
|
|
2625
2820
|
metadata,
|
|
2626
2821
|
createdAt: Date.now(),
|
|
2627
2822
|
filePath,
|
|
2628
|
-
bucket
|
|
2823
|
+
bucket,
|
|
2824
|
+
storageId,
|
|
2629
2825
|
key,
|
|
2630
2826
|
completed: false
|
|
2631
2827
|
};
|
|
@@ -2697,16 +2893,32 @@ var TusHandler = class {
|
|
|
2697
2893
|
});
|
|
2698
2894
|
}
|
|
2699
2895
|
/**
|
|
2700
|
-
* 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.
|
|
2701
2911
|
*/
|
|
2702
2912
|
async finalize(upload) {
|
|
2703
|
-
|
|
2704
|
-
const storageId = upload.metadata.storageId;
|
|
2913
|
+
const storageId = upload.storageId;
|
|
2705
2914
|
let targetController = this.storageController;
|
|
2706
2915
|
if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
|
|
2707
2916
|
if (!targetController) {
|
|
2708
|
-
logger.
|
|
2709
|
-
|
|
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.");
|
|
2710
2922
|
}
|
|
2711
2923
|
try {
|
|
2712
2924
|
const { readFile } = await import("fs/promises");
|
|
@@ -2719,6 +2931,7 @@ var TusHandler = class {
|
|
|
2719
2931
|
key: fileName,
|
|
2720
2932
|
bucket: upload.bucket
|
|
2721
2933
|
});
|
|
2934
|
+
upload.completed = true;
|
|
2722
2935
|
try {
|
|
2723
2936
|
await unlink(upload.filePath);
|
|
2724
2937
|
} catch {}
|
|
@@ -2726,6 +2939,8 @@ var TusHandler = class {
|
|
|
2726
2939
|
logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
|
|
2727
2940
|
} catch (err) {
|
|
2728
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)));
|
|
2729
2944
|
}
|
|
2730
2945
|
}
|
|
2731
2946
|
};
|
|
@@ -2742,6 +2957,82 @@ var TusHandler = class {
|
|
|
2742
2957
|
/** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
|
|
2743
2958
|
var transformCache = new TransformCache();
|
|
2744
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
|
+
/**
|
|
2745
3036
|
* Extract the wildcard portion of a route path from the full request path.
|
|
2746
3037
|
*
|
|
2747
3038
|
* Hono's `c.req.param('*')` does not work reliably in sub-routers mounted
|
|
@@ -2778,6 +3069,33 @@ function canonicalKeyOrBadRequest(key) {
|
|
|
2778
3069
|
}
|
|
2779
3070
|
}
|
|
2780
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
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
/**
|
|
2781
3099
|
* Build adapter-aware auth middleware for storage routes.
|
|
2782
3100
|
*
|
|
2783
3101
|
* When an `AuthAdapter` is provided, token verification is delegated to the
|
|
@@ -2886,8 +3204,9 @@ function createStorageRoutes(config) {
|
|
|
2886
3204
|
*
|
|
2887
3205
|
* A key that cannot be canonicalized (a real `..` segment, a null byte) is
|
|
2888
3206
|
* a 400, not a repaired key. The `LocalStorageController` traversal guard
|
|
2889
|
-
* (`getFullPath`) is
|
|
2890
|
-
*
|
|
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.
|
|
2891
3210
|
*/
|
|
2892
3211
|
const parseBucketAndPath = (filePath) => {
|
|
2893
3212
|
const parts = filePath.split("/");
|
|
@@ -2910,7 +3229,7 @@ function createStorageRoutes(config) {
|
|
|
2910
3229
|
const uploadedFile = body["file"];
|
|
2911
3230
|
if (!uploadedFile || typeof uploadedFile === "string") throw ApiError.badRequest("No file provided");
|
|
2912
3231
|
const key = typeof body["key"] === "string" ? body["key"] : "";
|
|
2913
|
-
const bucket = typeof body["bucket"] === "string" ? body["bucket"] : void 0;
|
|
3232
|
+
const bucket = canonicalBucketOrBadRequest(typeof body["bucket"] === "string" ? body["bucket"] : void 0);
|
|
2914
3233
|
const storageId = typeof body["storageId"] === "string" ? body["storageId"] : c.req.query("storageId");
|
|
2915
3234
|
const finalKey = canonicalKeyOrBadRequest(key || uploadedFile.name || "unnamed");
|
|
2916
3235
|
const metadata = {};
|
|
@@ -2938,15 +3257,13 @@ function createStorageRoutes(config) {
|
|
|
2938
3257
|
const filePath = decodeURIComponent(rawPath);
|
|
2939
3258
|
const storageId = c.req.query("storageId");
|
|
2940
3259
|
const resolved = resolveController(storageId);
|
|
2941
|
-
{
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
}
|
|
2945
|
-
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());
|
|
2946
3265
|
if (resolved.getType() === "local") {
|
|
2947
|
-
const
|
|
2948
|
-
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
2949
|
-
const absolutePath = localController.getAbsolutePath(resolvedPath, bucket);
|
|
3266
|
+
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
|
|
2950
3267
|
try {
|
|
2951
3268
|
await fsp.access(absolutePath);
|
|
2952
3269
|
} catch {
|
|
@@ -2958,37 +3275,30 @@ function createStorageRoutes(config) {
|
|
|
2958
3275
|
const metadataRaw = await fsp.readFile(metadataPath, "utf-8");
|
|
2959
3276
|
contentType = JSON.parse(metadataRaw).contentType || contentType;
|
|
2960
3277
|
} catch {}
|
|
2961
|
-
const fileContent = await fsp.readFile(absolutePath);
|
|
2962
3278
|
if (transformOpts && isTransformableImage(contentType)) {
|
|
2963
|
-
const
|
|
2964
|
-
|
|
2965
|
-
if (!cached) {
|
|
2966
|
-
cached = await transformImage(Buffer.from(fileContent), transformOpts);
|
|
2967
|
-
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
2968
|
-
}
|
|
2969
|
-
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);
|
|
2970
3281
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2971
|
-
return c.body(new Uint8Array(
|
|
3282
|
+
return c.body(new Uint8Array(transformed.data));
|
|
2972
3283
|
}
|
|
2973
|
-
|
|
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");
|
|
2974
3288
|
return c.body(new Uint8Array(fileContent));
|
|
2975
3289
|
}
|
|
2976
|
-
const
|
|
2977
|
-
const fileObject = await resolved.getObject(parsedPath, parsedBucket);
|
|
3290
|
+
const fileObject = await resolved.getObject(resolvedPath, bucket);
|
|
2978
3291
|
if (!fileObject) throw ApiError.notFound("File not found");
|
|
2979
3292
|
const remoteContentType = fileObject.type || "application/octet-stream";
|
|
2980
3293
|
if (transformOpts && isTransformableImage(remoteContentType)) {
|
|
2981
|
-
const
|
|
2982
|
-
|
|
2983
|
-
if (!cached) {
|
|
2984
|
-
cached = await transformImage(Buffer.from(await fileObject.arrayBuffer()), transformOpts);
|
|
2985
|
-
transformCache.set(cacheKey, cached.data, cached.contentType);
|
|
2986
|
-
}
|
|
2987
|
-
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);
|
|
2988
3296
|
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
2989
|
-
return c.body(new Uint8Array(
|
|
3297
|
+
return c.body(new Uint8Array(transformed.data));
|
|
2990
3298
|
}
|
|
2991
|
-
|
|
3299
|
+
const servedRemote = resolveServedContentType(remoteContentType);
|
|
3300
|
+
c.header("Content-Type", servedRemote.contentType);
|
|
3301
|
+
if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
|
|
2992
3302
|
c.header("Cache-Control", "public, max-age=3600, immutable");
|
|
2993
3303
|
const buf = await fileObject.arrayBuffer();
|
|
2994
3304
|
return c.body(new Uint8Array(buf));
|
|
@@ -3048,7 +3358,7 @@ function createStorageRoutes(config) {
|
|
|
3048
3358
|
*/
|
|
3049
3359
|
router.get("/list", writeAuthMiddleware, async (c) => {
|
|
3050
3360
|
const storagePrefix = canonicalKeyOrBadRequest(c.req.query("prefix") || c.req.query("path") || "");
|
|
3051
|
-
const bucket = c.req.query("bucket");
|
|
3361
|
+
const bucket = canonicalBucketOrBadRequest(c.req.query("bucket"));
|
|
3052
3362
|
const maxResults = c.req.query("maxResults");
|
|
3053
3363
|
const pageToken = c.req.query("pageToken");
|
|
3054
3364
|
const storageId = c.req.query("storageId");
|
|
@@ -3094,8 +3404,8 @@ function createStorageRoutes(config) {
|
|
|
3094
3404
|
}, 201);
|
|
3095
3405
|
});
|
|
3096
3406
|
const defaultCtrl = getDefaultController();
|
|
3097
|
-
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
|
|
3098
|
-
await checkAuthorized(c, "write", key, bucket,
|
|
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);
|
|
3099
3409
|
} : void 0);
|
|
3100
3410
|
tusHandler.startCleanup();
|
|
3101
3411
|
router.options("/tus", (_c) => tusHandler.options());
|
|
@@ -3232,7 +3542,7 @@ async function createStorageController(config) {
|
|
|
3232
3542
|
//#region src/init/storage.ts
|
|
3233
3543
|
async function initializeStorage(storageConfig, isProduction) {
|
|
3234
3544
|
if (!storageConfig) return {};
|
|
3235
|
-
logger.
|
|
3545
|
+
logger.debug("Configuring storage");
|
|
3236
3546
|
const controllers = {};
|
|
3237
3547
|
const toController = async (entry, label) => {
|
|
3238
3548
|
if (typeof entry.putObject === "function") return entry;
|
|
@@ -3289,11 +3599,20 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
3289
3599
|
}
|
|
3290
3600
|
//#endregion
|
|
3291
3601
|
//#region src/init/docs.ts
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
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");
|
|
3295
3614
|
app.get(`${basePath}/docs`, (c) => {
|
|
3296
|
-
const spec = generateOpenApiSpec(
|
|
3615
|
+
const spec = generateOpenApiSpec(serverCollections, {
|
|
3297
3616
|
basePath,
|
|
3298
3617
|
requireAuth
|
|
3299
3618
|
});
|
|
@@ -3468,6 +3787,54 @@ function createShutdown(config) {
|
|
|
3468
3787
|
};
|
|
3469
3788
|
}
|
|
3470
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
|
|
3471
3838
|
//#region src/auth/collection-callback-warning.ts
|
|
3472
3839
|
/** The data callbacks the auth write path does not run. */
|
|
3473
3840
|
var DATA_CALLBACKS = [
|
|
@@ -3674,6 +4041,23 @@ function createTransport(config, environment) {
|
|
|
3674
4041
|
...init?.headers || {}
|
|
3675
4042
|
};
|
|
3676
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
|
+
}
|
|
3677
4061
|
async function request(path, init) {
|
|
3678
4062
|
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
3679
4063
|
let activeToken = token;
|
|
@@ -3691,9 +4075,27 @@ function createTransport(config, environment) {
|
|
|
3691
4075
|
if (res.status === 204) return void 0;
|
|
3692
4076
|
const text = await res.text().catch(() => "");
|
|
3693
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;
|
|
3694
4094
|
if (text) try {
|
|
3695
4095
|
body = JSON.parse(text, rebaseReviver);
|
|
3696
|
-
} catch (e) {
|
|
4096
|
+
} catch (e) {
|
|
4097
|
+
unreadableBody = true;
|
|
4098
|
+
}
|
|
3697
4099
|
const getErrorField = (obj, field) => {
|
|
3698
4100
|
const err = obj?.error;
|
|
3699
4101
|
if (err && typeof err === "object" && err !== null) return err[field];
|
|
@@ -3713,9 +4115,12 @@ function createTransport(config, environment) {
|
|
|
3713
4115
|
if (retryRes.status === 204) return void 0;
|
|
3714
4116
|
const retryText = await retryRes.text().catch(() => "");
|
|
3715
4117
|
let retryBody = {};
|
|
4118
|
+
let retryUnreadable = false;
|
|
3716
4119
|
if (retryText) try {
|
|
3717
4120
|
retryBody = JSON.parse(retryText, rebaseReviver);
|
|
3718
|
-
} catch (e) {
|
|
4121
|
+
} catch (e) {
|
|
4122
|
+
retryUnreadable = true;
|
|
4123
|
+
}
|
|
3719
4124
|
if (!retryRes.ok) {
|
|
3720
4125
|
let fallbackMessage = retryRes.statusText;
|
|
3721
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.`;
|
|
@@ -3725,6 +4130,7 @@ function createTransport(config, environment) {
|
|
|
3725
4130
|
details: getErrorField(retryBody, "details")
|
|
3726
4131
|
});
|
|
3727
4132
|
}
|
|
4133
|
+
if (retryUnreadable) throw unreadableResponse(retryRes.status, retryText);
|
|
3728
4134
|
return retryBody;
|
|
3729
4135
|
}
|
|
3730
4136
|
}
|
|
@@ -3737,6 +4143,7 @@ function createTransport(config, environment) {
|
|
|
3737
4143
|
details: getErrorField(body, "details")
|
|
3738
4144
|
});
|
|
3739
4145
|
}
|
|
4146
|
+
if (unreadableBody) throw unreadableResponse(res.status, text);
|
|
3740
4147
|
return body;
|
|
3741
4148
|
}
|
|
3742
4149
|
return {
|
|
@@ -5287,9 +5694,11 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
|
5287
5694
|
*
|
|
5288
5695
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
5289
5696
|
* manages this internally (exposed as `client.ws`, typed by the minimal
|
|
5290
|
-
* `RebaseWebSocket` contract in `@rebasepro/types`). It
|
|
5291
|
-
* package root
|
|
5292
|
-
*
|
|
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.
|
|
5293
5702
|
*/
|
|
5294
5703
|
var RebaseWebSocketClient = class {
|
|
5295
5704
|
websocketUrl;
|
|
@@ -5799,6 +6208,11 @@ var RebaseWebSocketClient = class {
|
|
|
5799
6208
|
callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
|
|
5800
6209
|
}
|
|
5801
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}`);
|
|
5802
6216
|
}
|
|
5803
6217
|
}
|
|
5804
6218
|
async ensureAuthenticated(retryCount = 3) {
|
|
@@ -6905,11 +7319,29 @@ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
|
6905
7319
|
503,
|
|
6906
7320
|
504
|
|
6907
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
|
+
}
|
|
6908
7339
|
/** Is this failure worth another attempt later? */
|
|
6909
7340
|
function isRetryableError(error) {
|
|
6910
7341
|
if (isNetworkError(error)) return true;
|
|
6911
|
-
if (error instanceof RebaseApiError) return
|
|
6912
|
-
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);
|
|
6913
7345
|
}
|
|
6914
7346
|
/**
|
|
6915
7347
|
* Did this write fail because the row is already there?
|
|
@@ -6921,10 +7353,16 @@ function isRetryableError(error) {
|
|
|
6921
7353
|
* The queue uses this to recognise its own earlier attempt. A create whose
|
|
6922
7354
|
* response was lost is replayed, and for a row carrying an id the SDK generated
|
|
6923
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.
|
|
6924
7361
|
*/
|
|
6925
7362
|
function isDuplicateKeyError(error) {
|
|
6926
7363
|
if (!(error instanceof RebaseApiError)) return false;
|
|
6927
|
-
|
|
7364
|
+
if (error.code === "23505") return true;
|
|
7365
|
+
return error.status === 409 && !isIdempotencyInProgressError(error);
|
|
6928
7366
|
}
|
|
6929
7367
|
var ConnectivityMonitor = class {
|
|
6930
7368
|
state = "online";
|
|
@@ -7494,6 +7932,25 @@ function resolvePagination(params) {
|
|
|
7494
7932
|
offset
|
|
7495
7933
|
};
|
|
7496
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
|
+
}
|
|
7497
7954
|
/**
|
|
7498
7955
|
* Can a locally evaluated answer to `params` be trusted to match the server's,
|
|
7499
7956
|
* assuming the cache holds every row of the collection?
|
|
@@ -7501,12 +7958,67 @@ function resolvePagination(params) {
|
|
|
7501
7958
|
* `include` pulls in rows from other collections that this evaluator never
|
|
7502
7959
|
* sees, and `searchString` is only approximated — both make the local answer a
|
|
7503
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}.
|
|
7504
7983
|
*/
|
|
7505
7984
|
function isExactlyEvaluable(params) {
|
|
7506
7985
|
if (!params) return true;
|
|
7507
7986
|
if (params.include && params.include.length > 0) return false;
|
|
7508
7987
|
if (params.searchString) return false;
|
|
7509
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
|
+
}
|
|
7510
8022
|
return true;
|
|
7511
8023
|
}
|
|
7512
8024
|
/** Run a full query — filter, sort, paginate — over a set of rows. */
|
|
@@ -7536,6 +8048,17 @@ function generateOfflineId() {
|
|
|
7536
8048
|
return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
7537
8049
|
}
|
|
7538
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;
|
|
7539
8062
|
var OfflineManager = class {
|
|
7540
8063
|
store;
|
|
7541
8064
|
maxCachedQueries;
|
|
@@ -8225,7 +8748,8 @@ var OfflineManager = class {
|
|
|
8225
8748
|
rows.push(entry.row);
|
|
8226
8749
|
added++;
|
|
8227
8750
|
}
|
|
8228
|
-
|
|
8751
|
+
const orderIsLocal = isLocallySortable(rows, params?.orderBy);
|
|
8752
|
+
if (params?.orderBy && orderIsLocal) sortRows(rows, params.orderBy);
|
|
8229
8753
|
return {
|
|
8230
8754
|
data: rows,
|
|
8231
8755
|
meta: {
|
|
@@ -8236,7 +8760,7 @@ var OfflineManager = class {
|
|
|
8236
8760
|
},
|
|
8237
8761
|
fromCache,
|
|
8238
8762
|
hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
|
|
8239
|
-
partial: !exact
|
|
8763
|
+
partial: !exact || !orderIsLocal
|
|
8240
8764
|
};
|
|
8241
8765
|
}
|
|
8242
8766
|
localFind(slug, params) {
|
|
@@ -8551,7 +9075,8 @@ var OfflineManager = class {
|
|
|
8551
9075
|
}
|
|
8552
9076
|
op.attempts = (op.attempts ?? 0) + 1;
|
|
8553
9077
|
op.lastError = error?.message ?? String(error);
|
|
8554
|
-
|
|
9078
|
+
const limit = isIdempotencyInProgressError(error) ? Math.max(this.maxRetries, IN_PROGRESS_MIN_RETRIES) : this.maxRetries;
|
|
9079
|
+
if (isRetryableError(error) && op.attempts < limit) {
|
|
8555
9080
|
await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
|
|
8556
9081
|
this.connectivity.deferRetry();
|
|
8557
9082
|
this.patchStatus({ lastError: op.lastError });
|
|
@@ -9083,6 +9608,10 @@ channel: (name, options) => {
|
|
|
9083
9608
|
}
|
|
9084
9609
|
//#endregion
|
|
9085
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;
|
|
9086
9615
|
function createHistoryRoutes(params) {
|
|
9087
9616
|
const { historyService, registry } = params;
|
|
9088
9617
|
const router = new Hono();
|
|
@@ -9131,22 +9660,24 @@ function createHistoryRoutes(params) {
|
|
|
9131
9660
|
* GET /:slug/:id/history - List history entries for a entity
|
|
9132
9661
|
*
|
|
9133
9662
|
* Query params:
|
|
9134
|
-
* limit (default 20)
|
|
9663
|
+
* limit (default 20, maximum 100 — above that the request is refused)
|
|
9135
9664
|
* offset (default 0)
|
|
9136
9665
|
*/
|
|
9137
9666
|
router.get("/:slug/:id/history", async (c) => {
|
|
9138
9667
|
const slug = c.req.param("slug");
|
|
9139
9668
|
const id = c.req.param("id");
|
|
9140
|
-
const
|
|
9669
|
+
const limit = resolveListLimitParam(c.req.query("limit"), {
|
|
9670
|
+
defaultLimit: HISTORY_DEFAULT_LIMIT,
|
|
9671
|
+
maxLimit: HISTORY_MAX_LIMIT
|
|
9672
|
+
});
|
|
9141
9673
|
const parsedOffset = parseInt(c.req.query("offset") ?? "0", 10);
|
|
9142
|
-
const limit = Number.isNaN(parsedLimit) ? 20 : parsedLimit;
|
|
9143
9674
|
const offset = Number.isNaN(parsedOffset) ? 0 : parsedOffset;
|
|
9144
9675
|
const collection = registry.getCollections().find((col) => col.slug === slug || false);
|
|
9145
9676
|
if (!collection) throw ApiError.notFound(`Collection '${slug}' not found`);
|
|
9146
9677
|
if (!collection.history) throw ApiError.badRequest(`History is not enabled for collection '${slug}'`);
|
|
9147
9678
|
await authorizeEntityRead(c, collection, id);
|
|
9148
9679
|
const tableName = collection.slug;
|
|
9149
|
-
const appliedLimit =
|
|
9680
|
+
const appliedLimit = limit;
|
|
9150
9681
|
const appliedOffset = Math.max(offset, 0);
|
|
9151
9682
|
const result = await historyService.fetchHistory(tableName, id, {
|
|
9152
9683
|
limit: appliedLimit,
|
|
@@ -9197,126 +9728,6 @@ function createHistoryRoutes(params) {
|
|
|
9197
9728
|
return router;
|
|
9198
9729
|
}
|
|
9199
9730
|
//#endregion
|
|
9200
|
-
//#region src/email/smtp-email-service.ts
|
|
9201
|
-
var _nodemailer;
|
|
9202
|
-
async function loadNodemailer() {
|
|
9203
|
-
if (!_nodemailer) try {
|
|
9204
|
-
_nodemailer = await import("nodemailer");
|
|
9205
|
-
} catch {
|
|
9206
|
-
throw new Error("nodemailer is required for SMTP email. Install it: pnpm add nodemailer");
|
|
9207
|
-
}
|
|
9208
|
-
return _nodemailer;
|
|
9209
|
-
}
|
|
9210
|
-
/**
|
|
9211
|
-
* Safely parse a hostname from a URL string
|
|
9212
|
-
*/
|
|
9213
|
-
function getHostname(urlStr) {
|
|
9214
|
-
try {
|
|
9215
|
-
return new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`).hostname;
|
|
9216
|
-
} catch {
|
|
9217
|
-
return;
|
|
9218
|
-
}
|
|
9219
|
-
}
|
|
9220
|
-
/**
|
|
9221
|
-
* SMTP Email Service implementation using Nodemailer
|
|
9222
|
-
*/
|
|
9223
|
-
var SMTPEmailService = class {
|
|
9224
|
-
transporter = null;
|
|
9225
|
-
config;
|
|
9226
|
-
_initialized = false;
|
|
9227
|
-
constructor(config) {
|
|
9228
|
-
this.config = config;
|
|
9229
|
-
}
|
|
9230
|
-
/**
|
|
9231
|
-
* Lazily initialize the SMTP transporter on first use
|
|
9232
|
-
*/
|
|
9233
|
-
async ensureTransporter() {
|
|
9234
|
-
if (this._initialized) return;
|
|
9235
|
-
this._initialized = true;
|
|
9236
|
-
if (this.config.smtp) {
|
|
9237
|
-
const nodemailer = await loadNodemailer();
|
|
9238
|
-
let smtpName = this.config.smtp.name;
|
|
9239
|
-
if (!smtpName) {
|
|
9240
|
-
const urlsToTry = [
|
|
9241
|
-
process.env.FRONTEND_URL,
|
|
9242
|
-
this.config.resetPasswordUrl,
|
|
9243
|
-
this.config.verifyEmailUrl
|
|
9244
|
-
];
|
|
9245
|
-
for (const urlStr of urlsToTry) if (urlStr) {
|
|
9246
|
-
const hostname = getHostname(urlStr);
|
|
9247
|
-
if (hostname) {
|
|
9248
|
-
smtpName = hostname;
|
|
9249
|
-
break;
|
|
9250
|
-
}
|
|
9251
|
-
}
|
|
9252
|
-
}
|
|
9253
|
-
this.transporter = nodemailer.createTransport({
|
|
9254
|
-
name: smtpName,
|
|
9255
|
-
host: this.config.smtp.host,
|
|
9256
|
-
port: this.config.smtp.port,
|
|
9257
|
-
secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
|
|
9258
|
-
auth: this.config.smtp.auth ? {
|
|
9259
|
-
user: this.config.smtp.auth.user,
|
|
9260
|
-
pass: this.config.smtp.auth.pass
|
|
9261
|
-
} : void 0
|
|
9262
|
-
});
|
|
9263
|
-
}
|
|
9264
|
-
}
|
|
9265
|
-
/**
|
|
9266
|
-
* Check if the email service is properly configured
|
|
9267
|
-
*/
|
|
9268
|
-
isConfigured() {
|
|
9269
|
-
return !!(this.config.smtp || this.config.sendEmail);
|
|
9270
|
-
}
|
|
9271
|
-
/**
|
|
9272
|
-
* Send an email using SMTP or custom send function
|
|
9273
|
-
*/
|
|
9274
|
-
async send(options) {
|
|
9275
|
-
if (this.config.sendEmail) {
|
|
9276
|
-
await this.config.sendEmail(options);
|
|
9277
|
-
return;
|
|
9278
|
-
}
|
|
9279
|
-
await this.ensureTransporter();
|
|
9280
|
-
if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
|
|
9281
|
-
const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
|
|
9282
|
-
try {
|
|
9283
|
-
await this.transporter.sendMail({
|
|
9284
|
-
from: this.config.from,
|
|
9285
|
-
to,
|
|
9286
|
-
subject: options.subject,
|
|
9287
|
-
html: options.html,
|
|
9288
|
-
text: options.text,
|
|
9289
|
-
replyTo: options.replyTo
|
|
9290
|
-
});
|
|
9291
|
-
} catch (error) {
|
|
9292
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
9293
|
-
logger.error("Failed to send email", { detail: message });
|
|
9294
|
-
throw new Error(`Failed to send email: ${message}`);
|
|
9295
|
-
}
|
|
9296
|
-
}
|
|
9297
|
-
/**
|
|
9298
|
-
* Verify SMTP connection (useful for startup checks)
|
|
9299
|
-
*/
|
|
9300
|
-
async verifyConnection() {
|
|
9301
|
-
await this.ensureTransporter();
|
|
9302
|
-
if (!this.transporter) return !!this.config.sendEmail;
|
|
9303
|
-
try {
|
|
9304
|
-
await this.transporter.verify();
|
|
9305
|
-
return true;
|
|
9306
|
-
} catch (error) {
|
|
9307
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
9308
|
-
logger.error("SMTP connection verification failed", { detail: message });
|
|
9309
|
-
return false;
|
|
9310
|
-
}
|
|
9311
|
-
}
|
|
9312
|
-
};
|
|
9313
|
-
/**
|
|
9314
|
-
* Create an email service from configuration
|
|
9315
|
-
*/
|
|
9316
|
-
function createEmailService(config) {
|
|
9317
|
-
return new SMTPEmailService(config);
|
|
9318
|
-
}
|
|
9319
|
-
//#endregion
|
|
9320
9731
|
//#region src/singleton.ts
|
|
9321
9732
|
/**
|
|
9322
9733
|
* The backing instance lives on a process-global slot, NOT in a module-local
|
|
@@ -9377,14 +9788,30 @@ function _resetRebaseMock() {
|
|
|
9377
9788
|
* **Admin data plane** (`rebase.dataAsAdmin`):
|
|
9378
9789
|
* Backed by the native DataDriver — calls go directly to the database without
|
|
9379
9790
|
* JSON serialization, HTTP dispatch, or middleware overhead. The driver is
|
|
9380
|
-
* scoped as `{ uid: "service", roles: ["admin"] }
|
|
9381
|
-
*
|
|
9382
|
-
*
|
|
9383
|
-
*
|
|
9384
|
-
*
|
|
9385
|
-
*
|
|
9386
|
-
*
|
|
9387
|
-
*
|
|
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.
|
|
9388
9815
|
*
|
|
9389
9816
|
* `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
|
|
9390
9817
|
* admin-scoped accessor has exactly one name and the privilege is visible at the
|
|
@@ -9400,7 +9827,8 @@ function _resetRebaseMock() {
|
|
|
9400
9827
|
* ```typescript
|
|
9401
9828
|
* import { rebase } from "@rebasepro/server";
|
|
9402
9829
|
*
|
|
9403
|
-
* // 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"] }`):
|
|
9404
9832
|
* await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
|
|
9405
9833
|
* const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
|
|
9406
9834
|
* ```
|
|
@@ -9514,7 +9942,8 @@ async function initializeRebaseBackend(config) {
|
|
|
9514
9942
|
async function _initializeRebaseBackend(config) {
|
|
9515
9943
|
if (config.logging?.level) configureLogLevel(config.logging.level);
|
|
9516
9944
|
else configureLogLevel();
|
|
9517
|
-
logger.
|
|
9945
|
+
logger.debug("Initializing Rebase Backend");
|
|
9946
|
+
installUnhandledRejectionHandler();
|
|
9518
9947
|
const basePath = config.basePath || "/api";
|
|
9519
9948
|
const isProduction = process.env.NODE_ENV === "production";
|
|
9520
9949
|
configureMiddlewares(config.app, basePath, isProduction, config);
|
|
@@ -9532,13 +9961,14 @@ async function _initializeRebaseBackend(config) {
|
|
|
9532
9961
|
});
|
|
9533
9962
|
}
|
|
9534
9963
|
const introspectCollections = activeCollections.length === 0;
|
|
9535
|
-
logger.info(
|
|
9964
|
+
if (introspectCollections) logger.info("No collections declared — deriving them from the database schema");
|
|
9965
|
+
else logger.debug("Serving declared collections");
|
|
9536
9966
|
const realtimeServices = {};
|
|
9537
9967
|
const delegates = {};
|
|
9538
9968
|
let bootstrappers = config.bootstrappers || [];
|
|
9539
9969
|
if (config.database) {
|
|
9540
9970
|
const dbAdapter = config.database;
|
|
9541
|
-
logger.
|
|
9971
|
+
logger.debug("Using DatabaseAdapter", { type: dbAdapter.type });
|
|
9542
9972
|
bootstrappers = [wrapDatabaseAdapter(dbAdapter)];
|
|
9543
9973
|
}
|
|
9544
9974
|
if (bootstrappers.length === 0) throw new Error("No bootstrappers or database adapter provided. Cannot initialize database drivers.");
|
|
@@ -9546,7 +9976,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9546
9976
|
let defaultDriverResult = void 0;
|
|
9547
9977
|
for (const bootstrapper of bootstrappers) {
|
|
9548
9978
|
const b = bootstrapper;
|
|
9549
|
-
logger.
|
|
9979
|
+
logger.debug("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
|
|
9550
9980
|
if (b.isDefault) defaultDriverId = b.id || bootstrapper.type;
|
|
9551
9981
|
const driverResult = await bootstrapper.initializeDriver({
|
|
9552
9982
|
collections: activeCollections,
|
|
@@ -9604,7 +10034,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9604
10034
|
authAdapter = config.auth;
|
|
9605
10035
|
serviceKey = authAdapter.serviceKey;
|
|
9606
10036
|
if (authAdapter.initialize) await authAdapter.initialize();
|
|
9607
|
-
logger.
|
|
10037
|
+
logger.debug("Using AuthAdapter", { id: authAdapter.id });
|
|
9608
10038
|
authConfigResult = { userService: authAdapter.userManagement ?? {} };
|
|
9609
10039
|
} else {
|
|
9610
10040
|
const safeAuthConfig = config.auth;
|
|
@@ -9615,7 +10045,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9615
10045
|
});
|
|
9616
10046
|
if (foundAuthCollection) {
|
|
9617
10047
|
safeAuthConfig.collection = foundAuthCollection;
|
|
9618
|
-
logger.
|
|
10048
|
+
logger.debug("Auto-discovered auth collection from collection definitions", { slug: foundAuthCollection.slug });
|
|
9619
10049
|
}
|
|
9620
10050
|
}
|
|
9621
10051
|
if (safeAuthConfig.collection) {
|
|
@@ -9632,17 +10062,17 @@ async function _initializeRebaseBackend(config) {
|
|
|
9632
10062
|
if (safeAuthConfig.serviceKey) {
|
|
9633
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'))\"");
|
|
9634
10064
|
serviceKey = safeAuthConfig.serviceKey;
|
|
9635
|
-
logger.
|
|
10065
|
+
logger.debug("Service key configured for script/server-to-server authentication");
|
|
9636
10066
|
}
|
|
9637
10067
|
if (defaultBootstrapper.initializeAuth) {
|
|
9638
|
-
logger.
|
|
10068
|
+
logger.debug("Bootstrapping authentication via driver protocol");
|
|
9639
10069
|
authConfigResult = await defaultBootstrapper.initializeAuth(config.auth, defaultDriverResult);
|
|
9640
10070
|
logger.info("Authentication initialized");
|
|
9641
10071
|
} else logger.warn("Auth requested but default bootstrapper does not support initializeAuth");
|
|
9642
10072
|
}
|
|
9643
10073
|
let historyConfigResult = void 0;
|
|
9644
10074
|
if (config.history) if (defaultBootstrapper.initializeHistory) {
|
|
9645
|
-
logger.
|
|
10075
|
+
logger.debug("Bootstrapping entity history via driver protocol");
|
|
9646
10076
|
historyConfigResult = await defaultBootstrapper.initializeHistory(config.history, defaultDriverResult);
|
|
9647
10077
|
if (historyConfigResult?.historyService && defaultDriverResult.internals) {
|
|
9648
10078
|
const driver = defaultDriverResult.internals.driver;
|
|
@@ -9658,7 +10088,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9658
10088
|
if (apiKeyStoreResult) {
|
|
9659
10089
|
apiKeyStore = apiKeyStoreResult;
|
|
9660
10090
|
await apiKeyStore.ensureTable();
|
|
9661
|
-
logger.
|
|
10091
|
+
logger.debug("Service API Keys initialized");
|
|
9662
10092
|
}
|
|
9663
10093
|
const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
|
|
9664
10094
|
store: apiKeyStore,
|
|
@@ -9671,7 +10101,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9671
10101
|
serviceKey: internalServiceKey
|
|
9672
10102
|
});
|
|
9673
10103
|
config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
|
|
9674
|
-
logger.
|
|
10104
|
+
logger.debug("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
|
|
9675
10105
|
}
|
|
9676
10106
|
const rateLimitConfig = config.rateLimit?.enabled !== false ? {
|
|
9677
10107
|
...config.rateLimit,
|
|
@@ -9755,7 +10185,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9755
10185
|
]) {
|
|
9756
10186
|
const providerConfig = safeAuthConfig[key];
|
|
9757
10187
|
if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
|
|
9758
|
-
const createFn = (await import("./auth-
|
|
10188
|
+
const createFn = (await import("./auth-CYoPVf-E.js").then((n) => n.t))[factory];
|
|
9759
10189
|
oauthProviders.push(createFn(providerConfig));
|
|
9760
10190
|
}
|
|
9761
10191
|
}
|
|
@@ -9767,9 +10197,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
9767
10197
|
emailConfig: safeAuthConfig.email,
|
|
9768
10198
|
allowRegistration: safeAuthConfig.allowRegistration ?? false,
|
|
9769
10199
|
disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
|
|
10200
|
+
allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
|
|
9770
10201
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
9771
10202
|
defaultRole: safeAuthConfig.defaultRole,
|
|
9772
10203
|
oauthProviders,
|
|
10204
|
+
allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
|
|
9773
10205
|
serviceKey: serviceKey || internalServiceKey,
|
|
9774
10206
|
authHooks: safeAuthConfig.hooks,
|
|
9775
10207
|
collectionAuthConfig,
|
|
@@ -9784,14 +10216,14 @@ async function _initializeRebaseBackend(config) {
|
|
|
9784
10216
|
const authRoutes = authAdapter.createAuthRoutes();
|
|
9785
10217
|
if (authRoutes) {
|
|
9786
10218
|
config.app.route(`${basePath}/auth`, authRoutes);
|
|
9787
|
-
logger.
|
|
10219
|
+
logger.debug("Auth routes mounted via adapter", { adapter: authAdapter.id });
|
|
9788
10220
|
}
|
|
9789
10221
|
}
|
|
9790
10222
|
if (authAdapter && authAdapter.createAdminRoutes) {
|
|
9791
10223
|
const adminRoutes = authAdapter.createAdminRoutes();
|
|
9792
10224
|
if (adminRoutes) {
|
|
9793
10225
|
config.app.route(`${basePath}/admin`, adminRoutes);
|
|
9794
|
-
logger.
|
|
10226
|
+
logger.debug("Admin routes mounted via adapter", { adapter: authAdapter.id });
|
|
9795
10227
|
}
|
|
9796
10228
|
}
|
|
9797
10229
|
}
|
|
@@ -9849,7 +10281,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9849
10281
|
let schemaEditorOff = schemaEditorUnavailable();
|
|
9850
10282
|
let schemaEditorRoutes;
|
|
9851
10283
|
if (!schemaEditorOff && config.collectionsDir) try {
|
|
9852
|
-
schemaEditorRoutes = (await import("./schema-editor-routes-
|
|
10284
|
+
schemaEditorRoutes = (await import("./schema-editor-routes-CRcS3ArS.js")).createSchemaEditorRoutes(config.collectionsDir);
|
|
9853
10285
|
} catch (err) {
|
|
9854
10286
|
if (err?.code === "ERR_MODULE_NOT_FOUND") {
|
|
9855
10287
|
schemaEditorOff = {
|
|
@@ -9873,7 +10305,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9873
10305
|
message: schemaEditorOff.message
|
|
9874
10306
|
} }, 501));
|
|
9875
10307
|
config.app.route(`${basePath}/schema-editor`, schemaEditorRouter);
|
|
9876
|
-
if (schemaEditorRoutes) logger.
|
|
10308
|
+
if (schemaEditorRoutes) logger.debug("Schema Editor mounted", { path: `${basePath}/schema-editor` });
|
|
9877
10309
|
else logger.debug("Schema Editor unavailable", {
|
|
9878
10310
|
path: `${basePath}/schema-editor`,
|
|
9879
10311
|
code: schemaEditorOff.code
|
|
@@ -9899,6 +10331,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9899
10331
|
});
|
|
9900
10332
|
const storageRouter = new Hono();
|
|
9901
10333
|
if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
|
|
10334
|
+
if (rateLimitConfig) storageRouter.use("/*", createDataRateLimiter(rateLimitConfig));
|
|
9902
10335
|
storageRouter.use("/upload", bodyLimit({
|
|
9903
10336
|
maxSize: storageMaxSize,
|
|
9904
10337
|
onError: (c) => {
|
|
@@ -9919,6 +10352,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
9919
10352
|
config.app.route(`${basePath}/storage`, storageStub);
|
|
9920
10353
|
logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
|
|
9921
10354
|
}
|
|
10355
|
+
const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
|
|
9922
10356
|
if (activeCollections.length > 0) {
|
|
9923
10357
|
const dataRouter = new Hono();
|
|
9924
10358
|
dataRouter.onError(errorHandler);
|
|
@@ -9926,7 +10360,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
9926
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.");
|
|
9927
10361
|
else {
|
|
9928
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);
|
|
9929
|
-
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
|
+
}
|
|
9930
10367
|
}
|
|
9931
10368
|
const dataPathMarker = `${basePath}/data/`;
|
|
9932
10369
|
const resolveRequestDriver = (reqPath) => {
|
|
@@ -9960,24 +10397,21 @@ async function _initializeRebaseBackend(config) {
|
|
|
9960
10397
|
});
|
|
9961
10398
|
dataRouter.route("/", historyRoutes);
|
|
9962
10399
|
}
|
|
9963
|
-
const restGenerator = new RestApiGenerator(
|
|
10400
|
+
const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
|
|
9964
10401
|
dataRouter.route("/", restGenerator.generateRoutes());
|
|
9965
10402
|
config.app.route(`${basePath}/data`, dataRouter);
|
|
9966
10403
|
}
|
|
9967
|
-
await mountOpenApiDocs(config.app, basePath, config.enableSwagger,
|
|
10404
|
+
await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
|
|
9968
10405
|
const serverClient = createRebaseClient({
|
|
9969
10406
|
baseUrl: "http://localhost",
|
|
9970
10407
|
apiPath: basePath,
|
|
9971
|
-
|
|
10408
|
+
realtime: false,
|
|
9972
10409
|
token: internalServiceKey,
|
|
9973
10410
|
fetch: async (input, init) => {
|
|
9974
10411
|
return await config.app.request(input, init);
|
|
9975
10412
|
}
|
|
9976
10413
|
});
|
|
9977
|
-
const serviceIdentity =
|
|
9978
|
-
uid: "service",
|
|
9979
|
-
roles: ["admin"]
|
|
9980
|
-
};
|
|
10414
|
+
const serviceIdentity = SERVICE_IDENTITY;
|
|
9981
10415
|
const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
|
|
9982
10416
|
storageAuthorizeData.current = defaultData;
|
|
9983
10417
|
const dataSourcesByKey = {};
|
|
@@ -9996,20 +10430,20 @@ async function _initializeRebaseBackend(config) {
|
|
|
9996
10430
|
data: serverData,
|
|
9997
10431
|
dataAsAdmin: serverData
|
|
9998
10432
|
});
|
|
9999
|
-
logger.
|
|
10433
|
+
logger.debug("Native data plane attached to singleton (bypasses HTTP loop)");
|
|
10000
10434
|
if (storageController) {
|
|
10001
10435
|
Object.assign(serverClient, { storage: storageController });
|
|
10002
|
-
logger.
|
|
10436
|
+
logger.debug("Native storage attached to singleton (bypasses HTTP loop)");
|
|
10003
10437
|
}
|
|
10004
10438
|
let emailService;
|
|
10005
10439
|
if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
|
|
10006
10440
|
else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
|
|
10007
10441
|
if (emailService) {
|
|
10008
10442
|
Object.assign(serverClient, { email: emailService });
|
|
10009
|
-
logger.
|
|
10443
|
+
logger.debug("Email service attached to singleton", { configured: emailService.isConfigured() });
|
|
10010
10444
|
if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") emailService.verifyConnection().then((success) => {
|
|
10011
10445
|
if (!success) logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
|
|
10012
|
-
else logger.
|
|
10446
|
+
else logger.debug("SMTP connection verified successfully.");
|
|
10013
10447
|
}).catch((err) => {
|
|
10014
10448
|
logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
|
|
10015
10449
|
});
|
|
@@ -10017,56 +10451,59 @@ async function _initializeRebaseBackend(config) {
|
|
|
10017
10451
|
const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
|
|
10018
10452
|
if (isSQLAdmin(driverAdmin)) {
|
|
10019
10453
|
Object.assign(serverClient, { sql: (query, options) => driverAdmin.executeSql(query, options) });
|
|
10020
|
-
logger.
|
|
10454
|
+
logger.debug("SQL capability attached to singleton");
|
|
10021
10455
|
}
|
|
10022
10456
|
const serverSingleton = serverClient;
|
|
10023
10457
|
_initRebase(serverSingleton);
|
|
10024
|
-
logger.
|
|
10458
|
+
logger.debug("Rebase singleton initialized");
|
|
10025
10459
|
if (defaultDriverResult.internals) {
|
|
10026
10460
|
const driver = defaultDriverResult.internals.driver;
|
|
10027
10461
|
if (driver && "client" in driver) driver.client = serverClient;
|
|
10028
10462
|
}
|
|
10029
10463
|
if (config.functionsDir) {
|
|
10030
|
-
const {
|
|
10031
|
-
const { createFunctionRoutes } = await import("./function-routes-
|
|
10032
|
-
const
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
|
|
10041
|
-
|
|
10042
|
-
|
|
10043
|
-
|
|
10044
|
-
|
|
10045
|
-
|
|
10046
|
-
|
|
10047
|
-
|
|
10048
|
-
|
|
10049
|
-
|
|
10050
|
-
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
|
|
10054
|
-
|
|
10055
|
-
|
|
10056
|
-
|
|
10057
|
-
|
|
10058
|
-
|
|
10059
|
-
|
|
10060
|
-
|
|
10061
|
-
|
|
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."));
|
|
10062
10499
|
}
|
|
10063
10500
|
let cronScheduler;
|
|
10064
10501
|
if (config.cronsDir) {
|
|
10065
|
-
const {
|
|
10066
|
-
const { CronScheduler } = await import("./cron-scheduler-
|
|
10067
|
-
const { createCronRoutes } = await import("./cron-routes-
|
|
10068
|
-
const { createCronStore } = await import("./cron-store-
|
|
10069
|
-
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);
|
|
10070
10507
|
cronScheduler = new CronScheduler();
|
|
10071
10508
|
cronScheduler.setClient(serverSingleton);
|
|
10072
10509
|
if (loadedCronJobs.length > 0) {
|
|
@@ -10079,7 +10516,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10079
10516
|
}
|
|
10080
10517
|
const cronRouter = new Hono();
|
|
10081
10518
|
applyAdminGate(cronRouter, "Cron");
|
|
10082
|
-
cronRouter.route("/", createCronRoutes(cronScheduler));
|
|
10519
|
+
cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
|
|
10083
10520
|
config.app.route(`${basePath}/cron`, cronRouter);
|
|
10084
10521
|
if (loadedCronJobs.length > 0) {
|
|
10085
10522
|
cronScheduler.start();
|
|
@@ -10087,10 +10524,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
10087
10524
|
count: loadedCronJobs.length,
|
|
10088
10525
|
path: `${basePath}/cron`
|
|
10089
10526
|
});
|
|
10090
|
-
} 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."));
|
|
10091
10528
|
}
|
|
10092
10529
|
{
|
|
10093
|
-
const { createBackupRoutes, parseBackupDestination } = await import("./backup-
|
|
10530
|
+
const { createBackupRoutes, parseBackupDestination } = await import("./backup-C6ljYVTp.js").then((n) => n.t);
|
|
10094
10531
|
const backupRouter = new Hono();
|
|
10095
10532
|
applyAdminGate(backupRouter, "Backup");
|
|
10096
10533
|
backupRouter.route("/", createBackupRoutes({
|
|
@@ -10101,7 +10538,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10101
10538
|
storage: storageController
|
|
10102
10539
|
}));
|
|
10103
10540
|
config.app.route(`${basePath}/admin/backups`, backupRouter);
|
|
10104
|
-
logger.
|
|
10541
|
+
logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
|
|
10105
10542
|
}
|
|
10106
10543
|
{
|
|
10107
10544
|
const { default: logsRoutes } = await import("./logs-routes-BYA72C_C.js").then((n) => n.n);
|
|
@@ -10109,10 +10546,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
10109
10546
|
applyAdminGate(logsRouter, "Logs");
|
|
10110
10547
|
logsRouter.route("/", logsRoutes);
|
|
10111
10548
|
config.app.route(`${basePath}/logs`, logsRouter);
|
|
10112
|
-
logger.
|
|
10549
|
+
logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
|
|
10113
10550
|
}
|
|
10114
10551
|
{
|
|
10115
|
-
const { createContractRoutes } = await import("./contract-routes-
|
|
10552
|
+
const { createContractRoutes } = await import("./contract-routes-Bet-eCNJ.js").then((n) => n.t);
|
|
10116
10553
|
const contractRouter = new Hono();
|
|
10117
10554
|
if (adminSurfacesGated) {
|
|
10118
10555
|
if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
|
|
@@ -10130,7 +10567,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10130
10567
|
runtimeVersion: config.runtimeVersion
|
|
10131
10568
|
}));
|
|
10132
10569
|
config.app.route(`${basePath}/meta`, contractRouter);
|
|
10133
|
-
logger.
|
|
10570
|
+
logger.debug("Contract routes mounted", { path: `${basePath}/meta` });
|
|
10134
10571
|
}
|
|
10135
10572
|
const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
|
|
10136
10573
|
providers: realtimeServices,
|
|
@@ -10138,7 +10575,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10138
10575
|
resolveKey: keyForCollectionPath
|
|
10139
10576
|
}) : defaultRealtimeService;
|
|
10140
10577
|
if (defaultBootstrapper.initializeWebsockets && effectiveRealtimeService) await defaultBootstrapper.initializeWebsockets(config.server, effectiveRealtimeService, defaultDriver, config.auth, authAdapter);
|
|
10141
|
-
logger.
|
|
10578
|
+
logger.debug("Rebase Backend Initialized");
|
|
10142
10579
|
const authSchemaCheck = authConfigResult?.schemaHealthCheck;
|
|
10143
10580
|
const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
|
|
10144
10581
|
const shutdown = createShutdown({
|
|
@@ -10206,6 +10643,9 @@ async function _initializeRebaseBackend(config) {
|
|
|
10206
10643
|
* export default defineFunction((app, { rebase }) => {
|
|
10207
10644
|
* app.use("/*", requireAuth);
|
|
10208
10645
|
* app.get("/home", async (c) => {
|
|
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.
|
|
10209
10649
|
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
10210
10650
|
* return c.json({ orders: Number(stats.n) });
|
|
10211
10651
|
* });
|
|
@@ -10231,6 +10671,17 @@ function defineFunction(definition) {
|
|
|
10231
10671
|
*
|
|
10232
10672
|
* @see {@link defineFunction} for the equivalent custom-functions helper.
|
|
10233
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
|
+
*
|
|
10234
10685
|
* @example
|
|
10235
10686
|
* ```ts
|
|
10236
10687
|
* import { defineCron } from "@rebasepro/server";
|
|
@@ -10239,8 +10690,9 @@ function defineFunction(definition) {
|
|
|
10239
10690
|
* name: "Nightly cleanup",
|
|
10240
10691
|
* schedule: "0 3 * * *",
|
|
10241
10692
|
* async handler({ rebase, log }) {
|
|
10242
|
-
* // `dataAsAdmin`
|
|
10243
|
-
* //
|
|
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.
|
|
10244
10696
|
* const { data: expired } = await rebase.dataAsAdmin.sessions.find({
|
|
10245
10697
|
* where: { expired: ["==", true] },
|
|
10246
10698
|
* });
|
|
@@ -10366,6 +10818,7 @@ var rebaseEnvSchema = object({
|
|
|
10366
10818
|
REBASE_SERVICE_KEY: string().optional(),
|
|
10367
10819
|
ALLOW_REGISTRATION: boolString,
|
|
10368
10820
|
DISABLE_SELF_REGISTRATION: optionalBoolString,
|
|
10821
|
+
ALLOW_ANONYMOUS: optionalBoolString,
|
|
10369
10822
|
ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
|
|
10370
10823
|
CORS_ORIGINS: string().optional(),
|
|
10371
10824
|
FRONTEND_URL: string().optional(),
|
|
@@ -10429,7 +10882,158 @@ function loadEnv(options) {
|
|
|
10429
10882
|
return env;
|
|
10430
10883
|
}
|
|
10431
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
|
|
10432
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;
|
|
10433
11037
|
var WebhookDispatcher = class {
|
|
10434
11038
|
webhooks = [];
|
|
10435
11039
|
maxRetries = 3;
|
|
@@ -10438,33 +11042,103 @@ var WebhookDispatcher = class {
|
|
|
10438
11042
|
5e3,
|
|
10439
11043
|
15e3
|
|
10440
11044
|
];
|
|
11045
|
+
options;
|
|
11046
|
+
timeoutMs;
|
|
11047
|
+
queue = [];
|
|
11048
|
+
draining = null;
|
|
11049
|
+
constructor(options = {}) {
|
|
11050
|
+
this.options = options;
|
|
11051
|
+
this.timeoutMs = options.timeoutMs ?? 1e4;
|
|
11052
|
+
}
|
|
10441
11053
|
/** Register webhooks to watch */
|
|
10442
11054
|
setWebhooks(webhooks) {
|
|
10443
11055
|
this.webhooks = webhooks.filter((w) => w.enabled);
|
|
10444
11056
|
}
|
|
10445
|
-
/**
|
|
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
|
+
*/
|
|
10446
11068
|
async onEntityChange(table, event, id, entity, previousEntity) {
|
|
10447
|
-
const
|
|
10448
|
-
if (matchingWebhooks.length === 0) return [];
|
|
11069
|
+
const jobs = this.buildDeliveries(table, event, entity, previousEntity);
|
|
10449
11070
|
const results = [];
|
|
10450
|
-
for (const
|
|
10451
|
-
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: {
|
|
10452
11129
|
type: event,
|
|
10453
11130
|
table,
|
|
10454
11131
|
record: entity,
|
|
10455
11132
|
old_record: event === "UPDATE" ? previousEntity : void 0,
|
|
10456
11133
|
schema: "public",
|
|
10457
11134
|
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
10458
|
-
}
|
|
10459
|
-
|
|
10460
|
-
results.push(result);
|
|
10461
|
-
}
|
|
10462
|
-
return results;
|
|
11135
|
+
}
|
|
11136
|
+
}));
|
|
10463
11137
|
}
|
|
10464
11138
|
async deliverWithRetry(webhook, event, payload) {
|
|
10465
11139
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
10466
|
-
const result = await this.deliver(webhook, event, payload, attempt);
|
|
10467
|
-
if (result.success) return result;
|
|
11140
|
+
const { result, terminal } = await this.deliver(webhook, event, payload, attempt);
|
|
11141
|
+
if (result.success || terminal) return result;
|
|
10468
11142
|
if (attempt < this.maxRetries) {
|
|
10469
11143
|
const backoff = this.retryDelays[attempt - 1] ?? this.retryDelays[this.retryDelays.length - 1];
|
|
10470
11144
|
await new Promise((r) => setTimeout(r, backoff));
|
|
@@ -10491,41 +11165,106 @@ var WebhookDispatcher = class {
|
|
|
10491
11165
|
...webhook.headers || {}
|
|
10492
11166
|
};
|
|
10493
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);
|
|
10494
11170
|
try {
|
|
10495
|
-
const
|
|
10496
|
-
|
|
10497
|
-
|
|
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, {
|
|
10498
11176
|
method: "POST",
|
|
10499
11177
|
headers,
|
|
10500
11178
|
body,
|
|
10501
|
-
signal: controller.signal
|
|
11179
|
+
signal: controller.signal,
|
|
11180
|
+
redirect: "manual"
|
|
10502
11181
|
});
|
|
10503
|
-
|
|
10504
|
-
|
|
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);
|
|
10505
11198
|
const success = response.status >= 200 && response.status < 300;
|
|
10506
11199
|
return {
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
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
|
+
}
|
|
10514
11210
|
};
|
|
10515
11211
|
} catch (error) {
|
|
10516
11212
|
const message = error instanceof Error ? error.message : String(error);
|
|
10517
11213
|
return {
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
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
|
+
}
|
|
10525
11224
|
};
|
|
11225
|
+
} finally {
|
|
11226
|
+
clearTimeout(timeout);
|
|
10526
11227
|
}
|
|
10527
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);
|
|
11253
|
+
}
|
|
11254
|
+
return new TextDecoder().decode(concat(chunks, total));
|
|
11255
|
+
}
|
|
10528
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
|
+
}
|
|
10529
11268
|
//#endregion
|
|
10530
11269
|
//#region src/utils/dev-port.ts
|
|
10531
11270
|
var MAX_PORT_ATTEMPTS = 20;
|
|
@@ -10686,10 +11425,11 @@ function isUnderPath(requestPath, prefix) {
|
|
|
10686
11425
|
* Serve a Single Page Application from an Hono app.
|
|
10687
11426
|
*
|
|
10688
11427
|
* @internal Not part of the stable public API. Exported only because the
|
|
10689
|
-
*
|
|
10690
|
-
* and `app/backend/src/index.ts`
|
|
10691
|
-
*
|
|
10692
|
-
*
|
|
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.
|
|
10693
11433
|
*/
|
|
10694
11434
|
function serveSPA(app, config) {
|
|
10695
11435
|
const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
|
|
@@ -11833,6 +12573,7 @@ function resolveAuthOptions(env, usersCollection) {
|
|
|
11833
12573
|
requireAuth: env.AUTH_REQUIRE,
|
|
11834
12574
|
allowRegistration: env.ALLOW_REGISTRATION,
|
|
11835
12575
|
disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
|
|
12576
|
+
allowAnonymous: env.ALLOW_ANONYMOUS,
|
|
11836
12577
|
allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
|
|
11837
12578
|
email: resolveEmailOptions(env),
|
|
11838
12579
|
cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
|
|
@@ -12240,7 +12981,7 @@ async function bootFromBundle(options = {}) {
|
|
|
12240
12981
|
const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
|
|
12241
12982
|
const bundle = options.bundle ?? loadBundle(bundleDir);
|
|
12242
12983
|
const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
|
|
12243
|
-
logger.
|
|
12984
|
+
logger.debug("Loaded bundle", {
|
|
12244
12985
|
app: bundle.manifest.app,
|
|
12245
12986
|
kind: bundle.manifest.kind,
|
|
12246
12987
|
schemaVersion: bundle.manifest.schemaVersion,
|
|
@@ -12611,8 +13352,9 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
|
|
|
12611
13352
|
skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
|
|
12612
13353
|
return;
|
|
12613
13354
|
}
|
|
12614
|
-
const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.
|
|
12615
|
-
|
|
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.");
|
|
12616
13358
|
}
|
|
12617
13359
|
/**
|
|
12618
13360
|
* The `InitializedDriver` to hand a bootstrapper before any driver exists.
|
|
@@ -12657,17 +13399,18 @@ async function ensureCollectionPolicies(bundle, dataSources, env) {
|
|
|
12657
13399
|
if (!primary) return;
|
|
12658
13400
|
if (!primary.bootstrapper.ensureCollectionPolicies) {
|
|
12659
13401
|
const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
|
|
12660
|
-
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 }));
|
|
12661
13403
|
return;
|
|
12662
13404
|
}
|
|
12663
13405
|
const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
|
|
12664
13406
|
if (loaded.length === 0) return;
|
|
12665
13407
|
const collections = collectionsStoredBy(loaded, primary, dataSources);
|
|
12666
13408
|
if (collections.length === 0) return;
|
|
12667
|
-
const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.
|
|
12668
|
-
|
|
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.");
|
|
12669
13412
|
}
|
|
12670
13413
|
//#endregion
|
|
12671
|
-
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 };
|
|
12672
13415
|
|
|
12673
13416
|
//# sourceMappingURL=index.es.js.map
|