@rebasepro/server 0.25.0 → 0.26.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/{GCSStorageController-C7ARrDAp.js → GCSStorageController-40FyFeSx.js} +8 -4
- package/dist/GCSStorageController-40FyFeSx.js.map +1 -0
- package/dist/{S3StorageController-qevJ0pVE.js → S3StorageController-Bl5shp94.js} +8 -4
- package/dist/S3StorageController-Bl5shp94.js.map +1 -0
- package/dist/access-BFAUvsSs.js +1897 -0
- package/dist/access-BFAUvsSs.js.map +1 -0
- package/dist/api/branch-routes.d.ts +25 -0
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/api/live-schema-routes.d.ts +10 -0
- package/dist/api/rest/api-generator.d.ts +9 -1
- package/dist/api/rest/idempotency.d.ts +15 -1
- package/dist/api/root-error-handler.d.ts +6 -5
- package/dist/api/types.d.ts +12 -0
- package/dist/{ast-schema-editor-mCeSC03x.js → ast-schema-editor-madTa9Hh.js} +2 -2
- package/dist/{ast-schema-editor-mCeSC03x.js.map → ast-schema-editor-madTa9Hh.js.map} +1 -1
- package/dist/auth/access.d.ts +10 -0
- package/dist/auth/adapter-middleware.d.ts +2 -1
- package/dist/auth/adapter-service-key.d.ts +24 -0
- package/dist/auth/api-keys/api-key-middleware.d.ts +21 -1
- package/dist/auth/api-keys/api-key-store.d.ts +8 -1
- package/dist/auth/api-keys/key-grant.d.ts +18 -1
- package/dist/auth/expired-token-sweep.d.ts +5 -0
- package/dist/auth/impersonation.d.ts +3 -1
- package/dist/auth/jwt.d.ts +8 -2
- package/dist/auth/oauth-signin-policy.d.ts +21 -35
- package/dist/auth/rls-scope.d.ts +17 -7
- package/dist/auth/rls-simulator-routes.d.ts +51 -0
- package/dist/auth/simulation-read-only.d.ts +28 -0
- package/dist/auth/simulation.d.ts +141 -0
- package/dist/auth/sql-rate-limit-store.d.ts +15 -1
- package/dist/{auth-CteGWxsL.js → auth-izI7AEzC.js} +391 -1893
- package/dist/auth-izI7AEzC.js.map +1 -0
- package/dist/auth_adapter-B7RUMk6e.js +89 -0
- package/dist/auth_adapter-B7RUMk6e.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +10 -0
- package/dist/backend/options.d.ts +9 -4
- package/dist/{backend-DTAOsLQc.js → backend-CaG_Jyso.js} +9 -2
- package/dist/backend-CaG_Jyso.js.map +1 -0
- package/dist/{backup-Dq0lyJ_D.js → backup-BVbHoKo8.js} +8 -8
- package/dist/{backup-Dq0lyJ_D.js.map → backup-BVbHoKo8.js.map} +1 -1
- package/dist/boot/boot.d.ts +2 -15
- package/dist/boot/ddl-bootstrap.d.ts +34 -0
- package/dist/boot/env.d.ts +6 -0
- package/dist/boot/fetch-bundle.d.ts +5 -1
- package/dist/boot/provision-only.d.ts +23 -0
- package/dist/boot/provision.d.ts +53 -2
- package/dist/boot/role.d.ts +2 -0
- package/dist/boot/schema-change-recorder.d.ts +45 -0
- package/dist/boot/security-headers.d.ts +22 -0
- package/dist/branch-routes-B2vvEx53.js +84 -0
- package/dist/branch-routes-B2vvEx53.js.map +1 -0
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CU2A3JnF.js} +3 -3
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CU2A3JnF.js.map} +1 -1
- package/dist/cron/cron-store.d.ts +8 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-mMhqbVsl.js} +3 -3
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-mMhqbVsl.js.map} +1 -1
- package/dist/{cron-routes-Dhf82HzT.js → cron-routes-Cs33j_Kr.js} +5 -5
- package/dist/{cron-routes-Dhf82HzT.js.map → cron-routes-Cs33j_Kr.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-Be7CNghL.js} +3 -3
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-Be7CNghL.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DLn11M8H.js} +11 -10
- package/dist/cron-store-DLn11M8H.js.map +1 -0
- package/dist/{strings.es-BeZWAodn.js → data_driver-D0mg1Y_x.js} +2 -90
- package/dist/data_driver-D0mg1Y_x.js.map +1 -0
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-CjRrn12l.js} +92 -3
- package/dist/ddl-bootstrap-CjRrn12l.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/email/dev-sink.d.ts +6 -0
- package/dist/email/index.d.ts +1 -1
- package/dist/{errors-_ytoGi8A.js → errors-OMWkKSH-.js} +66 -5
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{soft-delete-params-BAgpmBvp.js → field-access-DYsvjI_P.js} +27 -57
- package/dist/field-access-DYsvjI_P.js.map +1 -0
- package/dist/{filter-dialect-O1HK6Nvr.js → filter-dialect-TrKFWLqT.js} +3 -3
- package/dist/{filter-dialect-O1HK6Nvr.js.map → filter-dialect-TrKFWLqT.js.map} +1 -1
- package/dist/{function-loader-DbCSoNJj.js → function-loader-B8YzXEsQ.js} +22 -15
- package/dist/function-loader-B8YzXEsQ.js.map +1 -0
- package/dist/{function-routes-DNV4RUF0.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-DNV4RUF0.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/hono-like.d.ts +12 -0
- package/dist/functions/index.js +37 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +3 -3
- package/dist/history/history-routes.d.ts +6 -0
- package/dist/history/retention-sweep.d.ts +5 -0
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-Bsau7h4X.js} +7 -7
- package/dist/history-recorder-Bsau7h4X.js.map +1 -0
- package/dist/{history-store-DPofGVDp.js → history-store-BQz5YCkW.js} +26 -2
- package/dist/history-store-BQz5YCkW.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.es.js +3003 -681
- package/dist/index.es.js.map +1 -1
- package/dist/init/process-safety.d.ts +4 -3
- package/dist/init/shutdown.d.ts +8 -0
- package/dist/init.d.ts +6 -1
- package/dist/jobs/job-store.d.ts +8 -1
- package/dist/{jobs-8G-_agld.js → jobs-Co6Ni5HP.js} +7 -7
- package/dist/jobs-Co6Ni5HP.js.map +1 -0
- package/dist/{jwt-YhciR93H.js → jwt-rDS0884_.js} +5 -4
- package/dist/{jwt-YhciR93H.js.map → jwt-rDS0884_.js.map} +1 -1
- package/dist/{keys-COzQ2aE8.js → keys-BXcdRNhJ.js} +2 -2
- package/dist/{keys-COzQ2aE8.js.map → keys-BXcdRNhJ.js.map} +1 -1
- package/dist/{logger-Bt32sHFw.js → logger-CW8pmTS6.js} +35 -3
- package/dist/logger-CW8pmTS6.js.map +1 -0
- package/dist/{logs-routes-CelTiS7C.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-CelTiS7C.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/mcp/consent-page.d.ts +38 -1
- package/dist/mcp/mcp-routes.d.ts +9 -0
- package/dist/mcp/mcp-tools.d.ts +7 -1
- package/dist/mcp/oauth-store.d.ts +12 -5
- package/dist/metrics/history-recorder.d.ts +4 -1
- package/dist/metrics/history-store.d.ts +8 -0
- package/dist/{offline-BrqCRgih-BtDB1ecy.js → offline-AvuEcg7u-DFHL98OT.js} +127 -40
- package/dist/offline-AvuEcg7u-DFHL98OT.js.map +1 -0
- package/dist/{offline-store-BhYXViha-BW5j1We_.js → offline-store-BhYXViha-YTmjBpo2.js} +5 -5
- package/dist/{offline-store-BhYXViha-BW5j1We_.js.map → offline-store-BhYXViha-YTmjBpo2.js.map} +1 -1
- package/dist/{openapi-generator-BamY6-ga.js → openapi-generator-CyxEtYEX.js} +61 -22
- package/dist/openapi-generator-CyxEtYEX.js.map +1 -0
- package/dist/{proxy-BEKbsil6.js → proxy-BYir8tWK.js} +5 -7
- package/dist/proxy-BYir8tWK.js.map +1 -0
- package/dist/{query-parser-CGohjyWI.js → query-parser-CDXWf2hH.js} +7 -6
- package/dist/{query-parser-CGohjyWI.js.map → query-parser-CDXWf2hH.js.map} +1 -1
- package/dist/{request-timeout-CRny5KD0.js → request-timeout-DVjo-c_8.js} +6 -6
- package/dist/request-timeout-DVjo-c_8.js.map +1 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js +190 -0
- package/dist/rls-simulator-routes-bEY7-5-u.js.map +1 -0
- package/dist/schema-edit/deployed-schema-guard.d.ts +47 -0
- package/dist/{schema-editor-routes-CnNGCayU.js → schema-editor-routes-Dc3DU_jm.js} +4 -4
- package/dist/{schema-editor-routes-CnNGCayU.js.map → schema-editor-routes-Dc3DU_jm.js.map} +1 -1
- package/dist/soft-delete-params-BRONCOA2.js +59 -0
- package/dist/soft-delete-params-BRONCOA2.js.map +1 -0
- package/dist/{sql-export-routes-BgTJ7K3S.js → sql-export-routes-QycX8W8S.js} +40 -6
- package/dist/sql-export-routes-QycX8W8S.js.map +1 -0
- package/dist/{src-D5Y3N-S4.js → src-Bh9LRPb9.js} +21 -2
- package/dist/{src-D5Y3N-S4.js.map → src-Bh9LRPb9.js.map} +1 -1
- package/dist/storage/GCSStorageController.d.ts +2 -0
- package/dist/storage/LocalStorageController.d.ts +8 -3
- package/dist/storage/S3StorageController.d.ts +2 -0
- package/dist/storage/maintenance-keys.d.ts +17 -0
- package/dist/storage/maintenance-routes.d.ts +28 -5
- package/dist/storage/maintenance.d.ts +136 -4
- package/dist/storage/orphan-scans.d.ts +120 -0
- package/dist/storage/types.d.ts +11 -0
- package/dist/strings.es-B7QLnHh2.js +96 -0
- package/dist/strings.es-B7QLnHh2.js.map +1 -0
- package/dist/{upload-validation-I9cCUBIW.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/upload-validation-Bgo626uY.js.map +1 -0
- package/dist/utils/dev-dependency-command.d.ts +15 -0
- package/dist/utils/dev-port.d.ts +8 -0
- package/dist/utils/logger.d.ts +24 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-C7ARrDAp.js.map +0 -1
- package/dist/S3StorageController-qevJ0pVE.js.map +0 -1
- package/dist/auth-CteGWxsL.js.map +0 -1
- package/dist/auth_adapter-CIItCTc8.js +0 -21
- package/dist/auth_adapter-CIItCTc8.js.map +0 -1
- package/dist/backend-DTAOsLQc.js.map +0 -1
- package/dist/cron-store-DEpVstUU.js.map +0 -1
- package/dist/ddl-bootstrap-BNqi2NZe.js.map +0 -1
- package/dist/errors-_ytoGi8A.js.map +0 -1
- package/dist/function-loader-DbCSoNJj.js.map +0 -1
- package/dist/history-recorder-RZ1Ow4YY.js.map +0 -1
- package/dist/history-store-DPofGVDp.js.map +0 -1
- package/dist/jobs-8G-_agld.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
- package/dist/offline-BrqCRgih-BtDB1ecy.js.map +0 -1
- package/dist/openapi-generator-BamY6-ga.js.map +0 -1
- package/dist/proxy-BEKbsil6.js.map +0 -1
- package/dist/request-timeout-CRny5KD0.js.map +0 -1
- package/dist/soft-delete-params-BAgpmBvp.js.map +0 -1
- package/dist/sql-export-routes-BgTJ7K3S.js.map +0 -1
- package/dist/strings.es-BeZWAodn.js.map +0 -1
- package/dist/upload-validation-I9cCUBIW.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"request-timeout-DVjo-c_8.js","names":[],"sources":["../src/functions/request-timeout.ts"],"sourcesContent":["import type { MiddlewareHandler } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { logger } from \"../utils/logger\";\nimport { ApiError, errorHandler } from \"../api/errors\";\n\n/** Default ceiling for a custom function request, in milliseconds. */\nexport const DEFAULT_FUNCTIONS_TIMEOUT_MS = 30_000;\n\n/**\n * Resolve the functions request timeout from config, then env, then the default.\n *\n * `0` (or any non-positive number) disables the ceiling — for a deployment\n * whose proxy already imposes one, or a function that legitimately streams for\n * minutes.\n */\nexport function resolveFunctionsTimeoutMs(configured?: number): number {\n if (typeof configured === \"number\" && Number.isFinite(configured)) {\n return Math.max(0, Math.floor(configured));\n }\n // Blank means unset, not zero. `Number(\"\")` and `Number(\" \")` are both 0,\n // and 0 here means \"no ceiling\" — so a compose file with\n // `REBASE_FUNCTIONS_TIMEOUT_MS=${SOMETHING}` and `SOMETHING` undefined, or\n // a `.env` line with the name and no value, silently switched off the one\n // bound on how long code the framework did not write may hold a socket.\n // Declaring a variable without setting it is the ordinary way to write\n // both of those files, and the failure is invisible: nothing logs, and the\n // deployment behaves exactly as it did before the ceiling existed.\n const raw = process.env.REBASE_FUNCTIONS_TIMEOUT_MS?.trim();\n if (raw) {\n const fromEnv = Number(raw);\n if (Number.isFinite(fromEnv) && fromEnv >= 0) {\n return Math.floor(fromEnv);\n }\n }\n return DEFAULT_FUNCTIONS_TIMEOUT_MS;\n}\n\n/**\n * A per-request ceiling for the custom functions router.\n *\n * Custom functions are the one router that runs code the framework did not\n * write, and nothing else in the stack bounds how long that code takes: the\n * Node server is constructed without `requestTimeout`/`headersTimeout`, so a\n * handler awaiting a promise that never settles — a `fetch` to an unreachable\n * third party with no `AbortSignal`, a query on a wedged connection — holds its\n * socket and its request object until the client gives up. The process also\n * serves the project's data API, auth and realtime, so that is not only the\n * slow caller's problem.\n *\n * The handler is **not** killed — JavaScript cannot stop a function from the\n * outside. What is bounded is the client-visible request: after `ms` the caller\n * gets a 504 and the socket is released, and the handler's eventual result is\n * dropped. The handler is *told*, though: `getSignal(c)` is aborted at the same\n * moment, so a `fetch` or a driver call it was passed to stops too. It is a\n * ceiling, not a kill switch, and the exact number matters far less than its\n * existence.\n *\n * \"The handler keeps running\" is a **Node** guarantee, not a property of the\n * contract. It follows from the process outliving the request, which is not\n * true on an isolate-based host: there, work still in flight when the response\n * resolves is terminated rather than orphaned. So a handler must not depend on\n * finishing after its 504 — anything that has to complete belongs in\n * `waitUntil()`, which is the one construct both hosts honour. See\n * `./wait-until.ts`.\n *\n * Mounted in front of the auth middleware rather than behind it, so a wedged\n * driver — the failure that also hangs `withAuth()` — is covered too.\n */\nexport function createFunctionsRequestTimeout(ms: number): MiddlewareHandler<HonoEnv> {\n return async (c, next) => {\n // Set either way, so `getSignal(c)` hands every handler a signal it can\n // pass on without checking whether a ceiling is configured.\n const controller = new AbortController();\n c.set(\"functionSignal\", controller.signal);\n if (ms <= 0) return next();\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<\"timeout\">((resolve) => {\n timer = setTimeout(() => {\n // Settled first, so the race is decided before a handler that\n // listens on the signal can return in reaction to it; then the\n // abort, so that handler — or a `fetch` it was handed to —\n // stops with the request.\n resolve(\"timeout\");\n controller.abort(new Error(`Function timed out after ${ms}ms`));\n }, ms);\n });\n\n try {\n const outcome = await Promise.race([next().then(() => \"done\" as const), timedOut]);\n if (outcome === \"timeout\") {\n logger.warn(\n `[functions] ${c.req.method} ${c.req.path} exceeded the ${ms}ms request timeout — ` +\n \"answering 504. Its signal was aborted; work not handed `getSignal(c)` keeps running. \" +\n \"Pass it to outbound calls, or raise `functionsTimeoutMs` / REBASE_FUNCTIONS_TIMEOUT_MS.\"\n );\n // Through `errorHandler`, not `c.json`: that is what puts the\n // `requestId` in the body and hands the outcome to the request\n // log, so the Studio Logs entry for a timeout carries\n // `errorCode` like every other failure. Hand-built, it was the\n // one 504 nobody could join to a log line.\n return errorHandler(\n new ApiError(504, \"FUNCTION_TIMEOUT\", \"Function timed out\"),\n c\n ) as Response;\n }\n } finally {\n if (timer) clearTimeout(timer);\n }\n return undefined;\n };\n}\n"],"mappings":";;;;;;;;;;;;;;AAMA,IAAa,+BAA+B;;;;;;;;AAS5C,SAAgB,0BAA0B,YAA6B;CACnE,IAAI,OAAO,eAAe,YAAY,OAAO,SAAS,UAAU,GAC5D,OAAO,KAAK,IAAI,GAAG,KAAK,MAAM,UAAU,CAAC;CAU7C,MAAM,MAAM,QAAQ,IAAI,6BAA6B,KAAK;CAC1D,IAAI,KAAK;EACL,MAAM,UAAU,OAAO,GAAG;EAC1B,IAAI,OAAO,SAAS,OAAO,KAAK,WAAW,GACvC,OAAO,KAAK,MAAM,OAAO;CAEjC;CACA,OAAO;AACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,SAAgB,8BAA8B,IAAwC;CAClF,OAAO,OAAO,GAAG,SAAS;EAGtB,MAAM,aAAa,IAAI,gBAAgB;EACvC,EAAE,IAAI,kBAAkB,WAAW,MAAM;EACzC,IAAI,MAAM,GAAG,OAAO,KAAK;EAEzB,IAAI;EACJ,MAAM,WAAW,IAAI,SAAoB,YAAY;GACjD,QAAQ,iBAAiB;IAKrB,QAAQ,SAAS;IACjB,WAAW,sBAAM,IAAI,MAAM,4BAA4B,GAAG,GAAG,CAAC;GAClE,GAAG,EAAE;EACT,CAAC;EAED,IAAI;GAEA,IAAI,MADkB,QAAQ,KAAK,CAAC,KAAK,CAAC,CAAC,WAAW,MAAe,GAAG,QAAQ,CAAC,MACjE,WAAW;IACvB,OAAO,KACH,eAAe,EAAE,IAAI,OAAO,GAAG,EAAE,IAAI,KAAK,gBAAgB,GAAG,sMAGjE;IAMA,OAAO,aACH,IAAI,SAAS,KAAK,oBAAoB,oBAAoB,GAC1D,CACJ;GACJ;EACJ,UAAU;GACN,IAAI,OAAO,aAAa,KAAK;EACjC;CAEJ;AACJ"}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
+
import __rebaseProcess from "process";
|
|
3
|
+
globalThis.process ??= __rebaseProcess;
|
|
4
|
+
__rebaseCreateRequire(import.meta.url);
|
|
5
|
+
import { h as scopeGrants, y as summarizeRoles } from "./src-Bh9LRPb9.js";
|
|
6
|
+
import { B as maskedFieldsFor, R as callerOf, Z as scopeDataDriver, r as accessOf, z as decideSimulation } from "./access-BFAUvsSs.js";
|
|
7
|
+
import { i as errorHandler, t as ApiError } from "./errors-OMWkKSH-.js";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
//#region src/auth/rls-simulator-routes.ts
|
|
10
|
+
/**
|
|
11
|
+
* The RLS simulator's admin surface, under `/api/admin/rls-simulator`.
|
|
12
|
+
*
|
|
13
|
+
* The data API answers one collection at a time for a simulated identity
|
|
14
|
+
* (`auth/simulation.ts`). This answers the questions the admin panel's
|
|
15
|
+
* "Simulate access" bar asks about all of them at once:
|
|
16
|
+
*
|
|
17
|
+
* - `GET /options` — what can be simulated: the roles the project names (the
|
|
18
|
+
* access model's, the ones its row and field rules mention, and `anon`) and
|
|
19
|
+
* the API keys that are live.
|
|
20
|
+
* - `POST /preview` — for `{ role?, userId?, apiKeyId? }`, each collection's
|
|
21
|
+
* rows as that identity sees them against the caller's own view, whether a
|
|
22
|
+
* key's scopes shut it out, and which fields its roles cannot read.
|
|
23
|
+
*
|
|
24
|
+
* The same decision as the data API: only an administrator's own session, a
|
|
25
|
+
* 403 for anyone else.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
/** How the caller authenticated, read off what the admin gate left on the context. */
|
|
30
|
+
function credentialOf(c) {
|
|
31
|
+
const caller = callerOf(c);
|
|
32
|
+
if (!caller) return "none";
|
|
33
|
+
if (c.get("apiKey") || c.get("scopes") !== void 0) return "api-key";
|
|
34
|
+
if (caller.uid === "service") return "service-key";
|
|
35
|
+
return "session";
|
|
36
|
+
}
|
|
37
|
+
function readSimulation(body) {
|
|
38
|
+
if (typeof body !== "object" || body === null) throw ApiError.badRequest("Send { role?, userId?, apiKeyId? } naming what to simulate.", "SIMULATION_INVALID");
|
|
39
|
+
const field = (name) => {
|
|
40
|
+
if (!(name in body)) return void 0;
|
|
41
|
+
const value = Reflect.get(body, name);
|
|
42
|
+
if (value === void 0 || value === null) return void 0;
|
|
43
|
+
if (typeof value !== "string") throw ApiError.badRequest(`${name} must be a string.`, "SIMULATION_INVALID");
|
|
44
|
+
return value;
|
|
45
|
+
};
|
|
46
|
+
const role = field("role");
|
|
47
|
+
const userId = field("userId");
|
|
48
|
+
const apiKeyId = field("apiKeyId");
|
|
49
|
+
if (role === void 0 && userId === void 0 && apiKeyId === void 0) throw ApiError.badRequest("Name a role, a user id or an API key id to simulate.", "SIMULATION_INVALID");
|
|
50
|
+
return {
|
|
51
|
+
...role !== void 0 ? { role } : {},
|
|
52
|
+
...userId !== void 0 ? { userId } : {},
|
|
53
|
+
...apiKeyId !== void 0 ? { apiKeyId } : {}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** Every role the project's rules name, beyond the access model's own. */
|
|
57
|
+
function rolesNamedByRules(collections) {
|
|
58
|
+
const named = /* @__PURE__ */ new Set();
|
|
59
|
+
for (const collection of collections) {
|
|
60
|
+
for (const rule of collection.securityRules ?? []) for (const role of rule.roles ?? []) named.add(role);
|
|
61
|
+
for (const property of Object.values(collection.properties ?? {})) {
|
|
62
|
+
const access = property.access;
|
|
63
|
+
for (const role of [...access?.read ?? [], ...access?.write ?? []]) named.add(role);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...named];
|
|
67
|
+
}
|
|
68
|
+
async function countRows(driver, collection) {
|
|
69
|
+
if (driver.count) return driver.count({
|
|
70
|
+
path: collection.slug,
|
|
71
|
+
collection
|
|
72
|
+
});
|
|
73
|
+
return (await driver.fetchCollection({
|
|
74
|
+
path: collection.slug,
|
|
75
|
+
collection
|
|
76
|
+
})).length;
|
|
77
|
+
}
|
|
78
|
+
function createRlsSimulatorRoutes(config) {
|
|
79
|
+
const router = new Hono();
|
|
80
|
+
router.onError(errorHandler);
|
|
81
|
+
/** The caller, refused unless an administrator's own session. */
|
|
82
|
+
const decide = async (c, simulation) => {
|
|
83
|
+
const decision = await decideSimulation({
|
|
84
|
+
simulation,
|
|
85
|
+
credential: credentialOf(c),
|
|
86
|
+
caller: callerOf(c),
|
|
87
|
+
resolveUser: config.resolveUser,
|
|
88
|
+
apiKeyStore: config.apiKeyStore,
|
|
89
|
+
access: accessOf(c),
|
|
90
|
+
audit: {
|
|
91
|
+
door: "rls-simulator",
|
|
92
|
+
method: c.req.method,
|
|
93
|
+
path: c.req.path,
|
|
94
|
+
requestId: c.get("requestId")
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if ("refused" in decision) throw decision.refused;
|
|
98
|
+
return decision;
|
|
99
|
+
};
|
|
100
|
+
router.get("/options", async (c) => {
|
|
101
|
+
await decide(c, { role: "anon" });
|
|
102
|
+
const declared = summarizeRoles(accessOf(c).model).map((role) => ({
|
|
103
|
+
id: role.id,
|
|
104
|
+
name: role.name
|
|
105
|
+
}));
|
|
106
|
+
const seen = new Set(declared.map((role) => role.id));
|
|
107
|
+
const roles = [
|
|
108
|
+
{
|
|
109
|
+
id: "anon",
|
|
110
|
+
name: "anon"
|
|
111
|
+
},
|
|
112
|
+
...declared,
|
|
113
|
+
...rolesNamedByRules(config.collections()).filter((role) => !seen.has(role) && role !== "anon").map((role) => ({
|
|
114
|
+
id: role,
|
|
115
|
+
name: role
|
|
116
|
+
}))
|
|
117
|
+
];
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const apiKeys = config.apiKeyStore ? (await config.apiKeyStore.listApiKeys({ kind: "service" })).filter((key) => !key.revoked_at && !(key.expires_at && new Date(key.expires_at).getTime() < now)).map((key) => ({
|
|
120
|
+
id: key.id,
|
|
121
|
+
name: key.name,
|
|
122
|
+
keyPrefix: key.key_prefix,
|
|
123
|
+
roles: key.roles,
|
|
124
|
+
scopes: key.scopes
|
|
125
|
+
})) : [];
|
|
126
|
+
return c.json({
|
|
127
|
+
roles,
|
|
128
|
+
apiKeys
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
router.post("/preview", async (c) => {
|
|
132
|
+
const simulation = readSimulation(await c.req.json().catch(() => void 0));
|
|
133
|
+
const { granted: identity, simulator } = await decide(c, simulation);
|
|
134
|
+
const callerIdentity = {
|
|
135
|
+
uid: simulator,
|
|
136
|
+
roles: callerOf(c)?.roles ?? []
|
|
137
|
+
};
|
|
138
|
+
const collections = [];
|
|
139
|
+
for (const collection of config.collections()) {
|
|
140
|
+
const view = {
|
|
141
|
+
slug: collection.slug,
|
|
142
|
+
name: collection.name ?? collection.slug,
|
|
143
|
+
maskedColumns: maskedFieldsFor(collection, identity.roles)
|
|
144
|
+
};
|
|
145
|
+
if (identity.scopes && !scopeGrants(identity.scopes, "data:read", collection.slug)) {
|
|
146
|
+
view.hiddenBy = "scope";
|
|
147
|
+
collections.push(view);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const driver = config.driverFor(collection.slug);
|
|
152
|
+
const [visible, total] = await Promise.all([scopeDataDriver(driver, {
|
|
153
|
+
uid: identity.uid,
|
|
154
|
+
roles: identity.roles,
|
|
155
|
+
isAnonymous: identity.isAnonymous,
|
|
156
|
+
...identity.claims ? { claims: identity.claims } : {}
|
|
157
|
+
}).then((scoped) => countRows(scoped, collection)), scopeDataDriver(driver, callerIdentity).then((scoped) => countRows(scoped, collection))]);
|
|
158
|
+
view.visibleRows = visible;
|
|
159
|
+
view.totalRows = total;
|
|
160
|
+
if (total > 0 && visible === 0) view.hiddenBy = "rls";
|
|
161
|
+
} catch (error) {
|
|
162
|
+
view.hiddenBy = "error";
|
|
163
|
+
view.error = error instanceof Error ? error.message : String(error);
|
|
164
|
+
}
|
|
165
|
+
collections.push(view);
|
|
166
|
+
}
|
|
167
|
+
return c.json({
|
|
168
|
+
simulation: {
|
|
169
|
+
role: identity.role,
|
|
170
|
+
sub: identity.uid,
|
|
171
|
+
roles: identity.roles,
|
|
172
|
+
...identity.apiKey ? { apiKey: {
|
|
173
|
+
id: identity.apiKey.id,
|
|
174
|
+
name: identity.apiKey.name,
|
|
175
|
+
keyPrefix: identity.apiKey.key_prefix
|
|
176
|
+
} } : {}
|
|
177
|
+
},
|
|
178
|
+
collections,
|
|
179
|
+
summary: {
|
|
180
|
+
hiddenCollections: collections.filter((view) => view.hiddenBy).length,
|
|
181
|
+
maskedColumns: collections.reduce((sum, view) => sum + view.maskedColumns.length, 0)
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
return router;
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
export { createRlsSimulatorRoutes };
|
|
189
|
+
|
|
190
|
+
//# sourceMappingURL=rls-simulator-routes-bEY7-5-u.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rls-simulator-routes-bEY7-5-u.js","names":[],"sources":["../src/auth/rls-simulator-routes.ts"],"sourcesContent":["/**\n * The RLS simulator's admin surface, under `/api/admin/rls-simulator`.\n *\n * The data API answers one collection at a time for a simulated identity\n * (`auth/simulation.ts`). This answers the questions the admin panel's\n * \"Simulate access\" bar asks about all of them at once:\n *\n * - `GET /options` — what can be simulated: the roles the project names (the\n * access model's, the ones its row and field rules mention, and `anon`) and\n * the API keys that are live.\n * - `POST /preview` — for `{ role?, userId?, apiKeyId? }`, each collection's\n * rows as that identity sees them against the caller's own view, whether a\n * key's scopes shut it out, and which fields its roles cannot read.\n *\n * The same decision as the data API: only an administrator's own session, a\n * 403 for anyone else.\n *\n * @module\n */\n\nimport { Hono, type Context } from \"hono\";\nimport {\n scopeGrants,\n summarizeRoles,\n type AccessSimulation,\n type CollectionConfig,\n type DataDriver,\n type Property\n} from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport { ApiError, errorHandler } from \"../api/errors\";\nimport { scopeDataDriver } from \"./rls-scope\";\nimport { accessOf } from \"./access\";\nimport type { ApiKeyStore } from \"./api-keys/api-key-store\";\nimport type { ImpersonationCredential, ImpersonationUserResolver } from \"./impersonation\";\nimport { callerOf, decideSimulation, maskedFieldsFor } from \"./simulation\";\n\nexport interface RlsSimulatorRouteConfig {\n /** The collections this backend serves, as they are now. */\n collections(): readonly CollectionConfig[];\n /** The unscoped driver a collection's rows live in. */\n driverFor(slug: string): DataDriver;\n resolveUser?: ImpersonationUserResolver;\n apiKeyStore?: ApiKeyStore;\n}\n\n/** One collection, as the simulated identity reaches it. */\nexport interface SimulatedCollectionView {\n slug: string;\n name: string;\n /**\n * Why the identity sees nothing here: its key's scopes, row policies\n * hiding every row, or a read that fails for it (`error` says why).\n */\n hiddenBy?: \"scope\" | \"rls\" | \"error\";\n /** Rows the simulated identity reads. Absent when not counted. */\n visibleRows?: number;\n /** Rows the administrator asking reads. */\n totalRows?: number;\n /** Fields the identity's roles cannot read. */\n maskedColumns: string[];\n /** Why the rows could not be counted. */\n error?: string;\n}\n\n/** How the caller authenticated, read off what the admin gate left on the context. */\nfunction credentialOf(c: Context<HonoEnv>): ImpersonationCredential {\n const caller = callerOf(c);\n if (!caller) return \"none\";\n if (c.get(\"apiKey\") || c.get(\"scopes\") !== undefined) return \"api-key\";\n if (caller.uid === \"service\") return \"service-key\";\n return \"session\";\n}\n\nfunction readSimulation(body: unknown): AccessSimulation {\n if (typeof body !== \"object\" || body === null) {\n throw ApiError.badRequest(\"Send { role?, userId?, apiKeyId? } naming what to simulate.\", \"SIMULATION_INVALID\");\n }\n const field = (name: string): string | undefined => {\n if (!(name in body)) return undefined;\n const value: unknown = Reflect.get(body, name);\n if (value === undefined || value === null) return undefined;\n if (typeof value !== \"string\") throw ApiError.badRequest(`${name} must be a string.`, \"SIMULATION_INVALID\");\n return value;\n };\n const role = field(\"role\");\n const userId = field(\"userId\");\n const apiKeyId = field(\"apiKeyId\");\n if (role === undefined && userId === undefined && apiKeyId === undefined) {\n throw ApiError.badRequest(\"Name a role, a user id or an API key id to simulate.\", \"SIMULATION_INVALID\");\n }\n return {\n ...(role !== undefined ? { role } : {}),\n ...(userId !== undefined ? { userId } : {}),\n ...(apiKeyId !== undefined ? { apiKeyId } : {})\n };\n}\n\n/** Every role the project's rules name, beyond the access model's own. */\nfunction rolesNamedByRules(collections: readonly CollectionConfig[]): string[] {\n const named = new Set<string>();\n for (const collection of collections) {\n for (const rule of collection.securityRules ?? []) {\n for (const role of rule.roles ?? []) named.add(role);\n }\n for (const property of Object.values(collection.properties ?? {})) {\n const access = (property as Property).access;\n for (const role of [...(access?.read ?? []), ...(access?.write ?? [])]) named.add(role);\n }\n }\n return [...named];\n}\n\nasync function countRows(driver: DataDriver, collection: CollectionConfig): Promise<number> {\n if (driver.count) return driver.count({ path: collection.slug, collection });\n return (await driver.fetchCollection({ path: collection.slug, collection })).length;\n}\n\nexport function createRlsSimulatorRoutes(config: RlsSimulatorRouteConfig): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n /** The caller, refused unless an administrator's own session. */\n const decide = async (c: Context<HonoEnv>, simulation: AccessSimulation) => {\n const decision = await decideSimulation({\n simulation,\n credential: credentialOf(c),\n caller: callerOf(c),\n resolveUser: config.resolveUser,\n apiKeyStore: config.apiKeyStore,\n access: accessOf(c),\n audit: { door: \"rls-simulator\", method: c.req.method, path: c.req.path, requestId: c.get(\"requestId\") }\n });\n if (\"refused\" in decision) throw decision.refused;\n return decision;\n };\n\n router.get(\"/options\", async (c) => {\n // The same gate as a preview, with a role that is always there.\n await decide(c, { role: \"anon\" });\n const declared = summarizeRoles(accessOf(c).model).map(role => ({ id: role.id, name: role.name }));\n const seen = new Set(declared.map(role => role.id));\n const roles = [\n { id: \"anon\", name: \"anon\" },\n ...declared,\n ...rolesNamedByRules(config.collections())\n .filter(role => !seen.has(role) && role !== \"anon\")\n .map(role => ({ id: role, name: role }))\n ];\n const now = Date.now();\n const apiKeys = config.apiKeyStore\n ? (await config.apiKeyStore.listApiKeys({ kind: \"service\" }))\n .filter(key => !key.revoked_at && !(key.expires_at && new Date(key.expires_at).getTime() < now))\n .map(key => ({ id: key.id, name: key.name, keyPrefix: key.key_prefix, roles: key.roles, scopes: key.scopes }))\n : [];\n return c.json({ roles, apiKeys });\n });\n\n router.post(\"/preview\", async (c) => {\n const simulation = readSimulation(await c.req.json().catch(() => undefined));\n const { granted: identity, simulator } = await decide(c, simulation);\n const callerIdentity = { uid: simulator, roles: callerOf(c)?.roles ?? [] };\n\n const collections: SimulatedCollectionView[] = [];\n for (const collection of config.collections()) {\n const view: SimulatedCollectionView = {\n slug: collection.slug,\n name: collection.name ?? collection.slug,\n maskedColumns: maskedFieldsFor(collection, identity.roles)\n };\n if (identity.scopes && !scopeGrants(identity.scopes, \"data:read\", collection.slug)) {\n view.hiddenBy = \"scope\";\n collections.push(view);\n continue;\n }\n try {\n const driver = config.driverFor(collection.slug);\n const [visible, total] = await Promise.all([\n scopeDataDriver(driver, {\n uid: identity.uid,\n roles: identity.roles,\n isAnonymous: identity.isAnonymous,\n ...(identity.claims ? { claims: identity.claims } : {})\n }).then(scoped => countRows(scoped, collection)),\n scopeDataDriver(driver, callerIdentity).then(scoped => countRows(scoped, collection))\n ]);\n view.visibleRows = visible;\n view.totalRows = total;\n if (total > 0 && visible === 0) view.hiddenBy = \"rls\";\n } catch (error: unknown) {\n // A read that fails for this identity — a policy that casts\n // its uid, a grant it lacks — shows it nothing either.\n view.hiddenBy = \"error\";\n view.error = error instanceof Error ? error.message : String(error);\n }\n collections.push(view);\n }\n\n return c.json({\n simulation: {\n role: identity.role,\n sub: identity.uid,\n roles: identity.roles,\n ...(identity.apiKey\n ? { apiKey: { id: identity.apiKey.id, name: identity.apiKey.name, keyPrefix: identity.apiKey.key_prefix } }\n : {})\n },\n collections,\n summary: {\n hiddenCollections: collections.filter(view => view.hiddenBy).length,\n maskedColumns: collections.reduce((sum, view) => sum + view.maskedColumns.length, 0)\n }\n });\n });\n\n return router;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the repository at the schema this deployment runs?
|
|
3
|
+
*
|
|
4
|
+
* A deployment committing to a remote repository (a built bundle) plans every
|
|
5
|
+
* change against the collections it *serves*, and renders the commit's
|
|
6
|
+
* generated schema files from those. The collection source it edits is fetched
|
|
7
|
+
* fresh from the repository. The two agree only while the repository holds no
|
|
8
|
+
* schema change this deployment is not running yet. When it does — a change
|
|
9
|
+
* committed here and not deployed, or one a developer pushed — the next commit
|
|
10
|
+
* would rewrite the generated files from the served collections and leave that
|
|
11
|
+
* change out of them.
|
|
12
|
+
*
|
|
13
|
+
* This used to be a flag in process memory, set when this process committed.
|
|
14
|
+
* A restart, a second replica, or a developer's push got past it. The check
|
|
15
|
+
* here keeps no state: render the generated files from the collections this
|
|
16
|
+
* deployment serves and compare them with the repository's copies. Equal means
|
|
17
|
+
* the repository's schema is the one running here; different means something
|
|
18
|
+
* is committed that is not running yet, and the change is refused.
|
|
19
|
+
*
|
|
20
|
+
* Why this and not a commit SHA. Comparing the branch head with the commit the
|
|
21
|
+
* bundle was built from would be exact, but no bundle records that commit
|
|
22
|
+
* today, and the head read at boot is no substitute: after a restart that
|
|
23
|
+
* follows an undeployed commit, that head *is* the undeployed commit. The
|
|
24
|
+
* generated files are what a change here rewrites, so they are what has to
|
|
25
|
+
* agree.
|
|
26
|
+
*
|
|
27
|
+
* The comparison tolerates line endings and trailing whitespace only. A file
|
|
28
|
+
* written by another Rebase version, or reformatted, also differs, and the
|
|
29
|
+
* refusal says how to bring it back in step.
|
|
30
|
+
*/
|
|
31
|
+
import type { SchemaChangeFile } from "@rebasepro/types";
|
|
32
|
+
import type { SchemaEditRepository } from "./apply-schema-change.js";
|
|
33
|
+
export declare class RepositoryAheadOfDeploymentError extends Error {
|
|
34
|
+
readonly paths: string[];
|
|
35
|
+
constructor(paths: string[], branch: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Refuse when the repository's generated files differ from `served`, the files
|
|
39
|
+
* the collections this deployment serves generate.
|
|
40
|
+
*
|
|
41
|
+
* A file the repository does not have is not a difference: nothing has
|
|
42
|
+
* committed one, so there is no change in it for a commit to leave out. A
|
|
43
|
+
* repository that cannot read a file cannot be checked, and is refused: the
|
|
44
|
+
* remote write path reads every file it edits, so that repository could not
|
|
45
|
+
* make the change either.
|
|
46
|
+
*/
|
|
47
|
+
export declare function refuseWhenRepositoryIsAhead(repository: Pick<SchemaEditRepository, "readFile" | "currentBranch">, served: SchemaChangeFile[]): Promise<void>;
|
|
@@ -2,10 +2,10 @@ import { createRequire as __rebaseCreateRequire } from "module";
|
|
|
2
2
|
import __rebaseProcess from "process";
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
import "./src-
|
|
5
|
+
import "./src-Bh9LRPb9.js";
|
|
6
6
|
import { i as isCollectionPatch, n as collectionPatchProblems } from "./collection_patch-BRu-BvDv.js";
|
|
7
|
-
import { i as errorHandler, t as ApiError } from "./errors-
|
|
8
|
-
import { AstSchemaEditor } from "./ast-schema-editor-
|
|
7
|
+
import { i as errorHandler, t as ApiError } from "./errors-OMWkKSH-.js";
|
|
8
|
+
import { AstSchemaEditor } from "./ast-schema-editor-madTa9Hh.js";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
//#region src/schema-edit/source-editor.ts
|
|
@@ -133,4 +133,4 @@ function createSchemaEditorRoutes(source) {
|
|
|
133
133
|
//#endregion
|
|
134
134
|
export { createSchemaEditorRoutes, createSchemaSourceEditor };
|
|
135
135
|
|
|
136
|
-
//# sourceMappingURL=schema-editor-routes-
|
|
136
|
+
//# sourceMappingURL=schema-editor-routes-Dc3DU_jm.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-editor-routes-
|
|
1
|
+
{"version":3,"file":"schema-editor-routes-Dc3DU_jm.js","names":[],"sources":["../src/schema-edit/source-editor.ts","../src/api/schema-editor-routes.ts"],"sourcesContent":["import type { AstSchemaEditor } from \"../api/ast-schema-editor\";\n\n/**\n * The process's editor of the collection source files, one edit at a time.\n *\n * Two routers rewrite those files: the source-only editor\n * (`/api/schema-editor/*`) and live schema editing's `/apply`. Both go through\n * this one {@link AstSchemaEditor}, so the ts-morph project over the\n * collections directory is parsed once per process rather than once per\n * apply — 180–250 ms of blocked event loop at 200 collection files, on every\n * save. The editor re-reads the disk at the start of each edit\n * (`syncWithDisk`): a file changed since — by the IDE, by a commit being put\n * back — is refreshed, a new one is added, a deleted one is dropped.\n *\n * One project cannot take two edits at once. An edit holds unsaved changes in\n * memory across `await project.save()`, and a second edit arriving meanwhile\n * would refresh those files from disk under it, or save them with its own.\n * So every edit, from either router, waits in one queue for the edits before\n * it to settle — a refused one included — and anything an edit does with the\n * files it wrote (reading them back for a commit) belongs inside the same\n * {@link run}, before the next edit can touch them.\n */\nexport class SchemaSourceEditor {\n private settled: Promise<unknown> = Promise.resolve();\n\n constructor(private readonly editor: AstSchemaEditor) {\n }\n\n /** Run `edit` once every edit queued before it has finished. */\n run<T>(edit: (editor: AstSchemaEditor) => Promise<T>): Promise<T> {\n const result = this.settled.then(() => edit(this.editor));\n this.settled = result.then(() => undefined, () => undefined);\n return result;\n }\n}\n","import { Hono } from \"hono\";\nimport { z } from \"zod\";\nimport { collectionPatchProblems, isCollectionPatch } from \"@rebasepro/types\";\nimport { AstSchemaEditor } from \"./ast-schema-editor\";\nimport { SchemaSourceEditor } from \"../schema-edit/source-editor\";\nimport { ApiError, errorHandler } from \"./errors\";\nimport { HonoEnv } from \"./types\";\n\n/**\n * Rewriting collection source from the admin panel.\n *\n * Every refusal in `AstSchemaEditor` is written for the person who will read it\n * — \"Collection X has no file at …\", \"Relation Y has no target collection. Pick\n * one before saving.\" — and every one of them was a plain `Error`, which\n * `errorHandler` treats as an unexpected failure: 500, and a body that says\n * \"Internal Server Error\". The messages never left the server. They are 400s\n * now, so the panel can show what it was told.\n */\nfunction refusalsAsBadRequest<T>(run: () => Promise<T>): Promise<T> {\n return run().catch((error: unknown) => {\n if (error instanceof ApiError) throw error;\n throw ApiError.badRequest(\n error instanceof Error ? error.message : String(error),\n \"SCHEMA_EDIT_REFUSED\"\n );\n });\n}\n\n/** The identifier half of every payload here, checked once. */\nconst collectionIdSchema = z.string().min(1, \"`collectionId` is required\");\nconst propertyKeySchema = z.string().min(1, \"`propertyKey` is required\");\n\nconst propertySaveSchema = z.object({\n collectionId: collectionIdSchema,\n propertyKey: propertyKeySchema,\n propertyConfig: z.record(z.string(), z.unknown())\n});\nconst propertyDeleteSchema = z.object({\n collectionId: collectionIdSchema,\n propertyKey: propertyKeySchema\n});\nconst collectionSaveSchema = z.object({\n collectionId: collectionIdSchema,\n collectionData: z.record(z.string(), z.unknown()).optional(),\n /** What changed about an existing collection — see `collection_patch.ts`. */\n patch: z.array(z.unknown()).optional(),\n partial: z.boolean().optional()\n}).refine(\n save => (save.collectionData === undefined) !== (save.patch === undefined),\n { message: \"send `patch` (what changed) or `collectionData` (a new collection), not both\" }\n);\nconst collectionDeleteSchema = z.object({\n collectionId: collectionIdSchema\n});\n\n/** Parse a body against a schema, or 400 naming the field. */\nasync function body<S extends z.ZodType>(c: { req: { json: () => Promise<unknown> } }, schema: S): Promise<z.infer<S>> {\n const raw = await c.req.json().catch(() => undefined);\n const parsed = schema.safeParse(raw);\n if (!parsed.success) {\n throw ApiError.badRequest(\n parsed.error.issues.map(i => `${i.path.join(\".\") || \"body\"}: ${i.message}`).join(\"; \"),\n \"INVALID_INPUT\"\n );\n }\n return parsed.data;\n}\n\n/**\n * The editor for `collectionsDir`: one per process, shared by these routes\n * and live schema editing's `/apply`. See {@link SchemaSourceEditor}.\n *\n * Here rather than beside the class because building it loads `ts-morph`, an\n * optional peer dependency this module is imported to probe for.\n */\nexport function createSchemaSourceEditor(collectionsDir: string): SchemaSourceEditor {\n return new SchemaSourceEditor(new AstSchemaEditor(collectionsDir));\n}\n\nexport function createSchemaEditorRoutes(source: SchemaSourceEditor): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n router.onError(errorHandler);\n\n router.post(\"/property/save\", async (c) => {\n const { collectionId, propertyKey, propertyConfig } = await body(c, propertySaveSchema);\n await refusalsAsBadRequest(() => source.run(editor => editor.saveProperty(collectionId, propertyKey, propertyConfig)));\n return c.json({ success: true });\n });\n\n router.post(\"/property/delete\", async (c) => {\n const { collectionId, propertyKey } = await body(c, propertyDeleteSchema);\n await refusalsAsBadRequest(() => source.run(editor => editor.deleteProperty(collectionId, propertyKey)));\n return c.json({ success: true });\n });\n\n /**\n * `partial: true` means \"this payload is what changed\", not \"this is the\n * collection\". Without it a one-key patch — which is what adding a column\n * posts — is read as a whole-collection save and deletes everything it does\n * not mention, `securityRules` included. Absent, it defaults to a full save,\n * so an older panel keeps the behaviour it was written against.\n */\n router.post(\"/collection/save\", async (c) => {\n const { collectionId, collectionData, patch, partial } = await body(c, collectionSaveSchema);\n if (patch !== undefined && !isCollectionPatch(patch)) {\n throw ApiError.badRequest(collectionPatchProblems(patch).join(\" \"), \"INVALID_INPUT\");\n }\n await refusalsAsBadRequest(() => source.run(editor => patch\n ? editor.applyPatch(collectionId, patch)\n : editor.saveCollection(collectionId, collectionData ?? {}, { partial: partial === true })));\n return c.json({ success: true });\n });\n\n router.post(\"/collection/delete\", async (c) => {\n const { collectionId } = await body(c, collectionDeleteSchema);\n await refusalsAsBadRequest(() => source.run(editor => editor.deleteCollection(collectionId)));\n return c.json({ success: true });\n });\n\n return router;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
+
import __rebaseProcess from "process";
|
|
3
|
+
globalThis.process ??= __rebaseProcess;
|
|
4
|
+
__rebaseCreateRequire(import.meta.url);
|
|
5
|
+
import { t as ApiError } from "./errors-OMWkKSH-.js";
|
|
6
|
+
//#region src/api/rest/soft-delete-params.ts
|
|
7
|
+
/**
|
|
8
|
+
* The two query parameters soft delete adds to the REST surface.
|
|
9
|
+
*
|
|
10
|
+
* Kept in their own module so the call sites in `query-parser.ts` and the
|
|
11
|
+
* delete routes are a single line each: the parsing rules belong to soft
|
|
12
|
+
* delete, not to the parser, and a rule spread across the two files that read
|
|
13
|
+
* it is a rule that drifts.
|
|
14
|
+
*/
|
|
15
|
+
/** `?deleted=` — what to do about rows a soft delete has stamped. */
|
|
16
|
+
var DELETED_QUERY_PARAM = "deleted";
|
|
17
|
+
/** `?hard=` — ask for a real `DELETE` on a soft-delete collection. */
|
|
18
|
+
var HARD_DELETE_QUERY_PARAM = "hard";
|
|
19
|
+
/**
|
|
20
|
+
* `?deleted=include|only` → the driver's `withDeleted`.
|
|
21
|
+
*
|
|
22
|
+
* Spelled `deleted` on the wire and `withDeleted` in the driver, deliberately:
|
|
23
|
+
* the URL reads as a question about the rows (`?deleted=only` — "only the
|
|
24
|
+
* deleted ones"), and the driver option reads as an instruction about the query.
|
|
25
|
+
*
|
|
26
|
+
* A value neither word is a 400 rather than a silent fallback to the default.
|
|
27
|
+
* `?deleted=true` quietly hiding every deleted row is the worst of both: it
|
|
28
|
+
* looks like it worked and answers the opposite question. Absent is the
|
|
29
|
+
* default, which is "hide them".
|
|
30
|
+
*/
|
|
31
|
+
function parseWithDeleted(raw) {
|
|
32
|
+
if (raw === void 0 || raw === null || raw === "") return void 0;
|
|
33
|
+
const value = String(raw).trim().toLowerCase();
|
|
34
|
+
if (value === "include") return true;
|
|
35
|
+
if (value === "only") return "only";
|
|
36
|
+
throw ApiError.badRequest(`Invalid \`?${DELETED_QUERY_PARAM}=${String(raw)}\`. It takes 'include' (live rows and deleted ones) or 'only' (deleted rows alone). Omit it to see only the live rows.`, "INVALID_DELETED_PARAM");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `?hard=true` → a real `DELETE` on a collection that soft-deletes.
|
|
40
|
+
*
|
|
41
|
+
* Needs no permission beyond the delete it replaces: it is the same verb, and a
|
|
42
|
+
* second access-control surface for one operation is a second thing to get
|
|
43
|
+
* wrong. What it changes is whether the row can be restored.
|
|
44
|
+
*
|
|
45
|
+
* Only the exact words `true` and `1` mean yes. Anything else is a 400, not a
|
|
46
|
+
* "no" — a typo that silently soft-deletes when the caller asked to purge is a
|
|
47
|
+
* caller who believes the data is gone.
|
|
48
|
+
*/
|
|
49
|
+
function parseHardDelete(raw) {
|
|
50
|
+
if (raw === void 0 || raw === null || raw === "") return false;
|
|
51
|
+
const value = String(raw).trim().toLowerCase();
|
|
52
|
+
if (value === "true" || value === "1") return true;
|
|
53
|
+
if (value === "false" || value === "0") return false;
|
|
54
|
+
throw ApiError.badRequest(`Invalid \`?${HARD_DELETE_QUERY_PARAM}=${String(raw)}\`. It takes 'true' or 'false'.`, "INVALID_HARD_PARAM");
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
export { parseWithDeleted as i, HARD_DELETE_QUERY_PARAM as n, parseHardDelete as r, DELETED_QUERY_PARAM as t };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=soft-delete-params-BRONCOA2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"soft-delete-params-BRONCOA2.js","names":[],"sources":["../src/api/rest/soft-delete-params.ts"],"sourcesContent":["import { ApiError } from \"../errors\";\n\n/**\n * The two query parameters soft delete adds to the REST surface.\n *\n * Kept in their own module so the call sites in `query-parser.ts` and the\n * delete routes are a single line each: the parsing rules belong to soft\n * delete, not to the parser, and a rule spread across the two files that read\n * it is a rule that drifts.\n */\n\n/** `?deleted=` — what to do about rows a soft delete has stamped. */\nexport const DELETED_QUERY_PARAM = \"deleted\";\n/** `?hard=` — ask for a real `DELETE` on a soft-delete collection. */\nexport const HARD_DELETE_QUERY_PARAM = \"hard\";\n\n/**\n * `?deleted=include|only` → the driver's `withDeleted`.\n *\n * Spelled `deleted` on the wire and `withDeleted` in the driver, deliberately:\n * the URL reads as a question about the rows (`?deleted=only` — \"only the\n * deleted ones\"), and the driver option reads as an instruction about the query.\n *\n * A value neither word is a 400 rather than a silent fallback to the default.\n * `?deleted=true` quietly hiding every deleted row is the worst of both: it\n * looks like it worked and answers the opposite question. Absent is the\n * default, which is \"hide them\".\n */\nexport function parseWithDeleted(raw: unknown): boolean | \"only\" | undefined {\n if (raw === undefined || raw === null || raw === \"\") return undefined;\n const value = String(raw).trim().toLowerCase();\n if (value === \"include\") return true;\n if (value === \"only\") return \"only\";\n throw ApiError.badRequest(\n `Invalid \\`?${DELETED_QUERY_PARAM}=${String(raw)}\\`. It takes 'include' (live rows and deleted ones) ` +\n \"or 'only' (deleted rows alone). Omit it to see only the live rows.\",\n \"INVALID_DELETED_PARAM\"\n );\n}\n\n/**\n * `?hard=true` → a real `DELETE` on a collection that soft-deletes.\n *\n * Needs no permission beyond the delete it replaces: it is the same verb, and a\n * second access-control surface for one operation is a second thing to get\n * wrong. What it changes is whether the row can be restored.\n *\n * Only the exact words `true` and `1` mean yes. Anything else is a 400, not a\n * \"no\" — a typo that silently soft-deletes when the caller asked to purge is a\n * caller who believes the data is gone.\n */\nexport function parseHardDelete(raw: unknown): boolean {\n if (raw === undefined || raw === null || raw === \"\") return false;\n const value = String(raw).trim().toLowerCase();\n if (value === \"true\" || value === \"1\") return true;\n if (value === \"false\" || value === \"0\") return false;\n throw ApiError.badRequest(\n `Invalid \\`?${HARD_DELETE_QUERY_PARAM}=${String(raw)}\\`. It takes 'true' or 'false'.`,\n \"INVALID_HARD_PARAM\"\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;AAYA,IAAa,sBAAsB;;AAEnC,IAAa,0BAA0B;;;;;;;;;;;;;AAcvC,SAAgB,iBAAiB,KAA4C;CACzE,IAAI,QAAQ,KAAA,KAAa,QAAQ,QAAQ,QAAQ,IAAI,OAAO,KAAA;CAC5D,MAAM,QAAQ,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY;CAC7C,IAAI,UAAU,WAAW,OAAO;CAChC,IAAI,UAAU,QAAQ,OAAO;CAC7B,MAAM,SAAS,WACX,cAAc,oBAAoB,GAAG,OAAO,GAAG,EAAE,yHAEjD,uBACJ;AACJ;;;;;;;;;;;;AAaA,SAAgB,gBAAgB,KAAuB;CACnD,IAAI,QAAQ,KAAA,KAAa,QAAQ,QAAQ,QAAQ,IAAI,OAAO;CAC5D,MAAM,QAAQ,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,YAAY;CAC7C,IAAI,UAAU,UAAU,UAAU,KAAK,OAAO;CAC9C,IAAI,UAAU,WAAW,UAAU,KAAK,OAAO;CAC/C,MAAM,SAAS,WACX,cAAc,wBAAwB,GAAG,OAAO,GAAG,EAAE,kCACrD,oBACJ;AACJ"}
|
|
@@ -2,21 +2,55 @@ import { createRequire as __rebaseCreateRequire } from "module";
|
|
|
2
2
|
import __rebaseProcess from "process";
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
|
-
import "./src-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { t as ApiError } from "./errors-
|
|
5
|
+
import "./src-Bh9LRPb9.js";
|
|
6
|
+
import { n as isSQLAdmin } from "./backend-CaG_Jyso.js";
|
|
7
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
8
|
+
import { t as ApiError } from "./errors-OMWkKSH-.js";
|
|
9
9
|
import { Hono } from "hono";
|
|
10
10
|
//#region ../utils/dist/csv.es.js
|
|
11
11
|
/**
|
|
12
|
+
* Characters that make a spreadsheet read a cell as a formula rather than text.
|
|
13
|
+
*
|
|
14
|
+
* Quoting is CSV *escaping* and is not enough: Excel, LibreOffice Calc and
|
|
15
|
+
* Sheets strip the CSV quoting at parse time and then evaluate a cell whose
|
|
16
|
+
* first character is one of these — `=HYPERLINK(…)` exfiltrates neighbouring
|
|
17
|
+
* cells on click, `=cmd|'…'!A0` is worse.
|
|
18
|
+
*/
|
|
19
|
+
var FORMULA_TRIGGERS = [
|
|
20
|
+
"=",
|
|
21
|
+
"+",
|
|
22
|
+
"-",
|
|
23
|
+
"@",
|
|
24
|
+
" ",
|
|
25
|
+
"\r"
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Neutralise a cell that a spreadsheet would otherwise evaluate, by prefixing a
|
|
29
|
+
* single quote — the standard OWASP mitigation. The apostrophe is consumed by
|
|
30
|
+
* the spreadsheet as "treat the rest as text", so the value reads back intact.
|
|
31
|
+
*/
|
|
32
|
+
function escapeCsvFormula(value) {
|
|
33
|
+
const first = value.charAt(0);
|
|
34
|
+
if (!FORMULA_TRIGGERS.includes(first)) return value;
|
|
35
|
+
if ((first === "-" || first === "+") && value.trim() !== "" && Number.isFinite(Number(value))) return value;
|
|
36
|
+
return "'" + value;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
12
39
|
* One CSV field (RFC 4180): quoted when it holds a comma, a quote or a line
|
|
13
40
|
* break, with each quote doubled. SQL NULL — `null` or `undefined` — is an
|
|
14
41
|
* empty field, as `COPY … CSV` writes it; an object (a plan, a parsed `jsonb`)
|
|
15
42
|
* is its JSON.
|
|
43
|
+
*
|
|
44
|
+
* Text a spreadsheet would run as a formula (`=…`, `+…`, `-…`, `@…`, a leading
|
|
45
|
+
* tab or carriage return) gets a leading `'` ({@link escapeCsvFormula}), as
|
|
46
|
+
* the CMS export has always done; a signed number is left a number. Every CSV
|
|
47
|
+
* the product writes goes through here, so a file opened in Excel or Sheets
|
|
48
|
+
* never evaluates a cell — at the cost of not being byte-identical to
|
|
49
|
+
* `COPY … CSV` for such cells.
|
|
16
50
|
*/
|
|
17
51
|
function csvField(value) {
|
|
18
52
|
if (value === null || value === void 0) return "";
|
|
19
|
-
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
53
|
+
const text = escapeCsvFormula(typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
20
54
|
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
21
55
|
}
|
|
22
56
|
/** One CSV record: each value through {@link csvField}, separated by commas, with no line break. */
|
|
@@ -237,4 +271,4 @@ function createSqlExportRoutes(options) {
|
|
|
237
271
|
//#endregion
|
|
238
272
|
export { createSqlExportRoutes };
|
|
239
273
|
|
|
240
|
-
//# sourceMappingURL=sql-export-routes-
|
|
274
|
+
//# sourceMappingURL=sql-export-routes-QycX8W8S.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql-export-routes-QycX8W8S.js","names":[],"sources":["../../utils/dist/csv.es.js","../../common/src/util/sql-redaction.ts","../src/api/sql-export-routes.ts"],"sourcesContent":["//#region src/csv.ts\n/**\n* Characters that make a spreadsheet read a cell as a formula rather than text.\n*\n* Quoting is CSV *escaping* and is not enough: Excel, LibreOffice Calc and\n* Sheets strip the CSV quoting at parse time and then evaluate a cell whose\n* first character is one of these — `=HYPERLINK(…)` exfiltrates neighbouring\n* cells on click, `=cmd|'…'!A0` is worse.\n*/\nvar FORMULA_TRIGGERS = [\n\t\"=\",\n\t\"+\",\n\t\"-\",\n\t\"@\",\n\t\"\t\",\n\t\"\\r\"\n];\n/**\n* Neutralise a cell that a spreadsheet would otherwise evaluate, by prefixing a\n* single quote — the standard OWASP mitigation. The apostrophe is consumed by\n* the spreadsheet as \"treat the rest as text\", so the value reads back intact.\n*/\nfunction escapeCsvFormula(value) {\n\tconst first = value.charAt(0);\n\tif (!FORMULA_TRIGGERS.includes(first)) return value;\n\tif ((first === \"-\" || first === \"+\") && value.trim() !== \"\" && Number.isFinite(Number(value))) return value;\n\treturn \"'\" + value;\n}\n/**\n* The inverse of {@link escapeCsvFormula}, for a file read back in: a cell that\n* is an apostrophe followed by a formula trigger loses the apostrophe, as it\n* does when a spreadsheet reads it. Any other apostrophe is text.\n*/\nfunction unescapeCsvFormula(value) {\n\treturn value.charAt(0) === \"'\" && FORMULA_TRIGGERS.includes(value.charAt(1)) ? value.slice(1) : value;\n}\n/**\n* One CSV field (RFC 4180): quoted when it holds a comma, a quote or a line\n* break, with each quote doubled. SQL NULL — `null` or `undefined` — is an\n* empty field, as `COPY … CSV` writes it; an object (a plan, a parsed `jsonb`)\n* is its JSON.\n*\n* Text a spreadsheet would run as a formula (`=…`, `+…`, `-…`, `@…`, a leading\n* tab or carriage return) gets a leading `'` ({@link escapeCsvFormula}), as\n* the CMS export has always done; a signed number is left a number. Every CSV\n* the product writes goes through here, so a file opened in Excel or Sheets\n* never evaluates a cell — at the cost of not being byte-identical to\n* `COPY … CSV` for such cells.\n*/\nfunction csvField(value) {\n\tif (value === null || value === void 0) return \"\";\n\tconst text = escapeCsvFormula(typeof value === \"object\" ? JSON.stringify(value) : String(value));\n\treturn /[\",\\r\\n]/.test(text) ? `\"${text.replace(/\"/g, \"\\\"\\\"\")}\"` : text;\n}\n/** One CSV record: each value through {@link csvField}, separated by commas, with no line break. */\nfunction csvRecord(values) {\n\treturn values.map(csvField).join(\",\");\n}\n//#endregion\nexport { csvField, csvRecord, escapeCsvFormula, unescapeCsvFormula };\n\n//# sourceMappingURL=csv.es.js.map","/**\n * SQL a person ran, as the audit log may keep it: every string literal masked.\n *\n * The SQL console's audit line wrote the statement verbatim, so `ALTER ROLE app\n * PASSWORD 'hunter2'`, `CREATE USER MAPPING … OPTIONS (password 'hunter2')` and\n * `dblink_connect('host=db password=hunter2')` put the password in the\n * production logs, where the logger's redaction — by key name — never looks.\n *\n * A literal is a value the statement carries inline, which is exactly what the\n * line already declines to write when it is bound as a parameter: the\n * statement's shape is the audit signal, the values are whatever the operator\n * was touching. So every literal goes, not only the ones beside a word that\n * looks like `password` — a secret has no reliable neighbour. Identifiers,\n * keywords, numbers and comments stay.\n *\n * Read with Postgres's own lexical rules, not a regular expression over the\n * text: a quote inside a comment, a doubled quote, an `E'…\\'…'` escape or a\n * `$tag$` body must not shift where a literal is thought to end, or the mask\n * covers the statement and leaves the secret.\n */\nexport function redactSqlLiterals(sql: string): string {\n let out = \"\";\n let i = 0;\n while (i < sql.length) {\n const char = sql[i];\n const next = sql[i + 1];\n\n // -- line comment\n if (char === \"-\" && next === \"-\") {\n const end = sql.indexOf(\"\\n\", i);\n const stop = end === -1 ? sql.length : end;\n out += sql.slice(i, stop);\n i = stop;\n continue;\n }\n\n // /* block comment */ — nested, as Postgres reads them\n if (char === \"/\" && next === \"*\") {\n let depth = 1;\n let j = i + 2;\n while (j < sql.length && depth > 0) {\n if (sql[j] === \"/\" && sql[j + 1] === \"*\") { depth++; j += 2; } else if (sql[j] === \"*\" && sql[j + 1] === \"/\") { depth--; j += 2; } else j++;\n }\n out += sql.slice(i, j);\n i = j;\n continue;\n }\n\n // \"quoted identifier\"\n if (char === \"\\\"\") {\n const end = closingQuote(sql, i + 1, \"\\\"\", false);\n out += sql.slice(i, end);\n i = end;\n continue;\n }\n\n // 'string', E'string' with backslash escapes, and the B'', X'', N'',\n // U&'' prefixes — the prefix is kept, the content masked.\n if (char === \"'\") {\n const prefix = /(?:^|[^A-Za-z0-9_$])([EeBbXxNn]|[Uu]&)$/.exec(out)?.[1] ?? \"\";\n out += \"'***'\";\n i = closingQuote(sql, i + 1, \"'\", prefix.toUpperCase() === \"E\");\n continue;\n }\n\n // $tag$ … $tag$ — a dollar-quoted string. A function or DO body is SQL\n // in its own right, and its literals are masked as these are; a\n // password written as `PASSWORD $$hunter2$$` is masked whole.\n if (char === \"$\") {\n const tag = /^\\$([A-Za-z_][A-Za-z0-9_]*)?\\$/.exec(sql.slice(i));\n const precededByIdentifier = /[A-Za-z0-9_]$/.test(out);\n if (tag && !precededByIdentifier) {\n const delimiter = tag[0];\n const bodyStart = i + delimiter.length;\n const bodyEnd = sql.indexOf(delimiter, bodyStart);\n const stop = bodyEnd === -1 ? sql.length : bodyEnd;\n const body = sql.slice(bodyStart, stop);\n const isPassword = /\\bpassword\\s*$/i.test(out);\n out += delimiter + (isPassword ? \"***\" : redactSqlLiterals(body)) + (bodyEnd === -1 ? \"\" : delimiter);\n i = bodyEnd === -1 ? sql.length : bodyEnd + delimiter.length;\n continue;\n }\n }\n\n out += char;\n i++;\n }\n return out;\n}\n\n/**\n * The index just past the quote that closes a literal or identifier opened\n * before `from`. A doubled quote is one quote inside it; with `backslashEscapes`\n * (an `E''` string) so is a backslash-escaped one. Unterminated runs to the end.\n */\nfunction closingQuote(sql: string, from: number, quote: string, backslashEscapes: boolean): number {\n let j = from;\n while (j < sql.length) {\n if (backslashEscapes && sql[j] === \"\\\\\") { j += 2; continue; }\n if (sql[j] === quote) {\n if (sql[j + 1] === quote) { j += 2; continue; }\n return j + 1;\n }\n j++;\n }\n return sql.length;\n}\n","import { Hono } from \"hono\";\nimport { isSQLAdmin, type DatabaseAdmin, type SqlCursor } from \"@rebasepro/types\";\nimport { redactSqlLiterals } from \"@rebasepro/common\";\nimport { csvRecord } from \"@rebasepro/utils\";\nimport { ApiError } from \"./errors\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\n\n/**\n * The SQL console's \"Download full result as CSV\".\n *\n * The console shows at most the first 10,000 rows of a result (see\n * `SQL_CONSOLE_RESULT_LIMITS`), and its own export writes the rows on screen.\n * This route is the way to the rest: it runs ONE query through\n * `SQLAdmin.openSqlCursor` — a server-side cursor in a read-only transaction,\n * as the role the console picked — and streams it as RFC 4180 CSV, one batch\n * at a time. A batch is fetched only when the response has taken the last\n * one, so the server holds one batch however long the result is.\n *\n * Mounted under `/api/admin/sql` behind the admin gate with `database:write` —\n * the scope the socket's `EXECUTE_SQL` asks of the same console, so whoever\n * can run the query can download it and nobody else.\n */\n\n/** RFC 4180's record separator. */\nconst CRLF = \"\\r\\n\";\n\nexport interface SqlExportRoutesOptions {\n /** The default data source's admin capabilities, read per request. */\n getAdmin: () => DatabaseAdmin | undefined;\n}\n\ninterface ExportRequest {\n sql: string;\n database?: string;\n role?: string;\n}\n\nfunction readExportRequest(body: unknown): ExportRequest {\n if (typeof body !== \"object\" || body === null || !(\"sql\" in body) || typeof body.sql !== \"string\" || body.sql.trim() === \"\") {\n throw ApiError.badRequest(\"Send the query to download as `sql`, a non-empty string.\", \"INVALID_INPUT\");\n }\n const optional = (key: \"database\" | \"role\"): string | undefined => {\n const value: unknown = key in body ? Reflect.get(body, key) : undefined;\n if (value === undefined || value === null || value === \"\") return undefined;\n if (typeof value !== \"string\") throw ApiError.badRequest(`\\`${key}\\` must be a string.`, \"INVALID_INPUT\");\n return value;\n };\n return { sql: body.sql, database: optional(\"database\"), role: optional(\"role\") };\n}\n\n/**\n * The cursor's rows as CSV, a header record first. Read one batch per pull:\n * the stream asks for the next only when the response has taken this one.\n *\n * A failure after the first byte cannot change the status any more, so the\n * stream is errored instead — the download fails rather than ending early\n * with a file that looks whole.\n */\nfunction csvStream(cursor: SqlCursor, onFailure: (error: unknown) => void): ReadableStream<Uint8Array> {\n const encoder = new TextEncoder();\n let headerSent = false;\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n if (!headerSent) {\n headerSent = true;\n controller.enqueue(encoder.encode(csvRecord(cursor.columns) + CRLF));\n return;\n }\n const rows = await cursor.read();\n if (!rows) {\n await cursor.close();\n controller.close();\n return;\n }\n controller.enqueue(encoder.encode(rows.map(row => csvRecord(row) + CRLF).join(\"\")));\n } catch (error: unknown) {\n onFailure(error);\n await cursor.close();\n controller.error(error);\n }\n },\n async cancel() {\n // The reader went away: end the read and give the session back.\n await cursor.close();\n }\n }, { highWaterMark: 1 });\n}\n\nexport function createSqlExportRoutes(options: SqlExportRoutesOptions): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n\n router.post(\"/csv\", async (c) => {\n const request = readExportRequest(await c.req.json());\n const admin = options.getAdmin();\n if (!isSQLAdmin(admin) || typeof admin.openSqlCursor !== \"function\") {\n throw new ApiError(501, \"SQL_EXPORT_UNSUPPORTED\",\n \"This data source cannot stream a query's result. Add a LIMIT and export the rows on screen.\");\n }\n\n const caller = c.get(\"user\");\n const user = typeof caller === \"object\" && caller !== null ? caller : undefined;\n const audit = {\n // Literals masked, as on the socket: a statement's shape is the\n // audit signal, its values are whatever the operator was reading.\n sql: redactSqlLiterals(request.sql).substring(0, 500),\n database: request.database,\n role: request.role,\n uid: user?.uid ?? \"unknown\",\n roles: user?.roles ?? [],\n ...(c.get(\"apiKey\") ? { apiKey: c.get(\"apiKey\")?.id } : {}),\n requestId: c.get(\"requestId\")\n };\n\n // Opened before the response starts: a query the database refuses — a\n // write, a second statement, a syntax error — is a 400 with its\n // reason, not an empty file.\n const cursor = await admin.openSqlCursor(request.sql, { database: request.database, role: request.role });\n logger.info(\"[SQL Audit] SQL console CSV download\", audit);\n\n const body = csvStream(cursor, (error) => logger.warn(\"[SQL export] The download failed part-way and was cut off\", {\n requestId: audit.requestId,\n error: error instanceof Error ? error.message : String(error)\n }));\n return new Response(body, {\n status: 200,\n headers: {\n \"Content-Type\": \"text/csv; charset=utf-8\",\n \"Content-Disposition\": \"attachment; filename=\\\"query_results.csv\\\"\",\n \"Cache-Control\": \"no-store\"\n }\n });\n });\n\n return router;\n}\n"],"mappings":"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"}
|
|
@@ -1267,6 +1267,25 @@ function hasAdminRole(roles) {
|
|
|
1267
1267
|
return !!roles?.includes(ADMIN_ROLE);
|
|
1268
1268
|
}
|
|
1269
1269
|
/**
|
|
1270
|
+
* Why `name` cannot be written as a role, or `undefined` when it can.
|
|
1271
|
+
*
|
|
1272
|
+
* No naming pattern is imposed — an app may call its roles anything. What is
|
|
1273
|
+
* refused is a name that would be read back as *other* roles: a role travels as
|
|
1274
|
+
* one element of a Postgres `text[]` and as one entry of the comma-joined
|
|
1275
|
+
* `app.user_roles` setting that RLS policies split with
|
|
1276
|
+
* `string_to_array(…, ',')`. Written as `"support,admin"`, a role comes back as
|
|
1277
|
+
* `admin`; braces, quotes and backslashes are array-literal syntax, so they are
|
|
1278
|
+
* refused too, as are empty names and control characters.
|
|
1279
|
+
*
|
|
1280
|
+
* @group Auth
|
|
1281
|
+
*/
|
|
1282
|
+
function roleNameProblem(name) {
|
|
1283
|
+
if (typeof name !== "string") return "is not a string";
|
|
1284
|
+
if (name.trim() === "") return "is empty";
|
|
1285
|
+
if (/[,{}"\\]/.test(name)) return "contains , { } \" or \\";
|
|
1286
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) return "contains a control character";
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1270
1289
|
* Does a set of held scopes grant `scope`, optionally on one `target`?
|
|
1271
1290
|
*
|
|
1272
1291
|
* The unqualified grant covers every target. A qualified grant covers its own
|
|
@@ -1416,6 +1435,6 @@ function summarizeScopes(model = EMPTY_ACCESS_MODEL) {
|
|
|
1416
1435
|
return [...builtIn, ...app];
|
|
1417
1436
|
}
|
|
1418
1437
|
//#endregion
|
|
1419
|
-
export {
|
|
1438
|
+
export { isRelationAggregateSort as $, storageEnvSuffix as A, RLS_UID_SQL as B, declaredQueueConsumers as C, setQueueRuntime as D, resourceToStorageSource as E, resourceKeyOf as F, isRelationalCollectionConfig as G, getCollectionDataPath as H, resourceKind as I, ALL_WHERE_FILTER_OPS as J, DEFAULT_DATA_SOURCE_KEY as K, resourceKinds as L, buildResourceGraph as M, resolveResourceRefs as N, setTopicRuntime as O, resourceEnvSuffix as P, REST_TO_CANONICAL as Q, RLS_JWT_SQL as R, declareCron as S, resourceToDataSource as T, getDeclaredSubcollections as U, rewriteLegacyRlsFunctions as V, isPostgresCollectionConfig as W, LIST_OPS as X, CANONICAL_TO_REST as Y, NULL_OPS as Z, scopesBeyond as _, KEY_MANAGEMENT_SCOPES as a, summarizeScopes as b, intersectScopes as c, isDataPlaneScope as d, parseRelationAggregateSort as et, knownScopes as f, scopeGrantsAny as g, scopeGrants as h, EMPTY_ACCESS_MODEL as i, DEFAULT_RESOURCE_KEY as j, DEFAULT_STORAGE_SOURCE_KEY as k, isAdminScope as l, roleNameProblem as m, ADMIN_SCOPES as n, toCanonicalOp as nt, SCOPE_TARGETS as o, parseScope as p, getDataSourceCapabilities as q, BUILT_IN_SCOPES as r, hasAdminRole as s, ADMIN_ROLE as t, sortKeyToString as tt, isBuiltInScope as u, scopesForRoles as v, declaredSubscriptions as w, validateScopes as x, summarizeRoles as y, RLS_ROLES_SQL as z };
|
|
1420
1439
|
|
|
1421
|
-
//# sourceMappingURL=src-
|
|
1440
|
+
//# sourceMappingURL=src-Bh9LRPb9.js.map
|