@rebasepro/server 0.25.0-canary.ge06257e → 0.25.1
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-CAKa_EFW.js → GCSStorageController-CRd9bFrc.js} +3 -3
- package/dist/{GCSStorageController-CAKa_EFW.js.map → GCSStorageController-CRd9bFrc.js.map} +1 -1
- package/dist/{S3StorageController-Kt-LJpf3.js → S3StorageController-D00EFa5P.js} +3 -3
- package/dist/{S3StorageController-Kt-LJpf3.js.map → S3StorageController-D00EFa5P.js.map} +1 -1
- package/dist/api/closed-database.d.ts +25 -0
- package/dist/api/errors.d.ts +6 -10
- package/dist/{auth-xk37vrHO.js → auth-Dkvo2tTJ.js} +43 -35
- package/dist/auth-Dkvo2tTJ.js.map +1 -0
- package/dist/backend/boot-announcements.d.ts +13 -0
- package/dist/backend/create-backend.d.ts +4 -0
- package/dist/{backup-CpaJWXSc.js → backup-B9m3cb3X.js} +7 -7
- package/dist/{backup-CpaJWXSc.js.map → backup-B9m3cb3X.js.map} +1 -1
- package/dist/boot/boot.d.ts +10 -0
- package/dist/{contract-routes-DiFwiLg2.js → contract-routes-CsFXW7j4.js} +2 -2
- package/dist/{contract-routes-DiFwiLg2.js.map → contract-routes-CsFXW7j4.js.map} +1 -1
- package/dist/{cron-loader-NHqRfKF0.js → cron-loader-Dj6lA4HQ.js} +2 -2
- package/dist/{cron-loader-NHqRfKF0.js.map → cron-loader-Dj6lA4HQ.js.map} +1 -1
- package/dist/{cron-routes-CPIhdasM.js → cron-routes-CwbLZguO.js} +5 -5
- package/dist/{cron-routes-CPIhdasM.js.map → cron-routes-CwbLZguO.js.map} +1 -1
- package/dist/{cron-scheduler-D1DT0nw5.js → cron-scheduler-B04ACoUE.js} +2 -2
- package/dist/{cron-scheduler-D1DT0nw5.js.map → cron-scheduler-B04ACoUE.js.map} +1 -1
- package/dist/{cron-store-DEpVstUU.js → cron-store-DkyfuLOH.js} +3 -3
- package/dist/{cron-store-DEpVstUU.js.map → cron-store-DkyfuLOH.js.map} +1 -1
- package/dist/{ddl-bootstrap-BNqi2NZe.js → ddl-bootstrap-Ck20GSIX.js} +2 -2
- package/dist/{ddl-bootstrap-BNqi2NZe.js.map → ddl-bootstrap-Ck20GSIX.js.map} +1 -1
- package/dist/{errors-CHSJYpoy.js → errors-OMWkKSH-.js} +76 -22
- package/dist/errors-OMWkKSH-.js.map +1 -0
- package/dist/{function-loader-DbCSoNJj.js → function-loader-KKDqTcIr.js} +2 -2
- package/dist/{function-loader-DbCSoNJj.js.map → function-loader-KKDqTcIr.js.map} +1 -1
- package/dist/{function-routes-C0emkHtX.js → function-routes-RxrCHNuM.js} +2 -2
- package/dist/{function-routes-C0emkHtX.js.map → function-routes-RxrCHNuM.js.map} +1 -1
- package/dist/functions/index.js +18 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-RZ1Ow4YY.js → history-recorder-B2rkuhuy.js} +2 -2
- package/dist/{history-recorder-RZ1Ow4YY.js.map → history-recorder-B2rkuhuy.js.map} +1 -1
- package/dist/index.es.js +632 -129
- package/dist/index.es.js.map +1 -1
- package/dist/{jobs-8G-_agld.js → jobs-h1WRq3cp.js} +3 -3
- package/dist/{jobs-8G-_agld.js.map → jobs-h1WRq3cp.js.map} +1 -1
- package/dist/{jwt-YhciR93H.js → jwt-D3oFauZI.js} +2 -2
- package/dist/{jwt-YhciR93H.js.map → jwt-D3oFauZI.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-BM2cDory.js → logs-routes-CBt7mMIL.js} +3 -3
- package/dist/{logs-routes-BM2cDory.js.map → logs-routes-CBt7mMIL.js.map} +1 -1
- package/dist/{openapi-generator-IKwDAQGO.js → openapi-generator-BjHr6upl.js} +2 -2
- package/dist/{openapi-generator-IKwDAQGO.js.map → openapi-generator-BjHr6upl.js.map} +1 -1
- package/dist/{proxy-BEKbsil6.js → proxy-CH8Jsovg.js} +2 -2
- package/dist/{proxy-BEKbsil6.js.map → proxy-CH8Jsovg.js.map} +1 -1
- package/dist/{query-parser-BnhugNwZ.js → query-parser-otFZqJsC.js} +3 -3
- package/dist/{query-parser-BnhugNwZ.js.map → query-parser-otFZqJsC.js.map} +1 -1
- package/dist/{request-timeout-LiGpCCP1.js → request-timeout-59crS-9u.js} +3 -3
- package/dist/{request-timeout-LiGpCCP1.js.map → request-timeout-59crS-9u.js.map} +1 -1
- package/dist/{schema-editor-routes-zjVKreVQ.js → schema-editor-routes-Bsut0bCC.js} +2 -2
- package/dist/{schema-editor-routes-zjVKreVQ.js.map → schema-editor-routes-Bsut0bCC.js.map} +1 -1
- package/dist/{soft-delete-params-uHxJoETb.js → soft-delete-params-BmlVwecC.js} +2 -2
- package/dist/{soft-delete-params-uHxJoETb.js.map → soft-delete-params-BmlVwecC.js.map} +1 -1
- package/dist/{sql-export-routes-D86u7N9i.js → sql-export-routes-DmB_uZ4l.js} +3 -3
- package/dist/{sql-export-routes-D86u7N9i.js.map → sql-export-routes-DmB_uZ4l.js.map} +1 -1
- package/dist/{upload-validation-pXcDy_EQ.js → upload-validation-Bgo626uY.js} +2 -2
- package/dist/{upload-validation-pXcDy_EQ.js.map → upload-validation-Bgo626uY.js.map} +1 -1
- 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/auth-xk37vrHO.js.map +0 -1
- package/dist/errors-CHSJYpoy.js.map +0 -1
- package/dist/logger-Bt32sHFw.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors-OMWkKSH-.js","names":[],"sources":["../src/api/closed-database.ts","../src/api/errors.ts"],"sourcesContent":["/**\n * A database closed to new connections (`ALLOW_CONNECTIONS false`), read off\n * the error a refused connection raises, and the operator's line that names it.\n *\n * Its own module rather than in `errors.ts`, which the package root re-exports\n * whole: these are the server's to use, not part of its API.\n *\n * @module\n */\nimport { logger } from \"../utils/logger\";\n\n/** How often the closed-database line is said: once a minute, as the other database lines in `errors.ts` are. */\nconst CLOSED_WARNING_INTERVAL_MS = 60_000;\n\n/** When the closed-database line was last said. */\nlet lastClosedWarningAt = 0;\n\n/** Forget when the closed-database line was last said. @internal For tests only. */\nexport function _resetClosedDatabaseWarningForTests(): void {\n lastClosedWarningAt = 0;\n}\n\n/**\n * The database that refused the connection because it is closed to new ones\n * (`ALLOW_CONNECTIONS false`), or null.\n *\n * Postgres says it as `database \"x\" is not currently accepting connections`,\n * SQLSTATE 55000. That code also covers statements the database judged\n * (`lastval is not yet defined in this session`), so the sentence is what is\n * matched — through Drizzle's `cause` and a dual-stack `AggregateError`. The\n * driver's CLI reads it the same way (`closedDatabaseName` in\n * `@rebasepro/server-postgres`'s `cli-errors.ts`).\n */\nexport function closedDatabaseName(error: unknown, depth = 0): string | null {\n if (typeof error !== \"object\" || error === null || depth > 8) return null;\n const message = Reflect.get(error, \"message\");\n if (typeof message === \"string\") {\n const named = /database \"(.+)\" is not currently accepting connections/.exec(message);\n if (named) return named[1];\n }\n const inner = Reflect.get(error, \"errors\");\n if (Array.isArray(inner)) {\n for (const item of inner) {\n const named = closedDatabaseName(item, depth + 1);\n if (named) return named;\n }\n }\n return closedDatabaseName(Reflect.get(error, \"cause\"), depth + 1);\n}\n\n/**\n * The operator's line for a closed database, at most once a minute, when\n * `error` is one: which database, that nothing is lost, and what opens it. `true`\n * when it was one — said now or within the minute — so the caller says nothing\n * more general about it.\n *\n * Nothing in a development database closes one but `rebase db branch create\n * --force`, which closes its source for the copy; one stopped part-way leaves\n * it closed, and every `rebase db branch` command opens it again first.\n */\nexport function warnIfDatabaseClosed(error: unknown): boolean {\n const database = closedDatabaseName(error);\n if (database === null) return false;\n const now = Date.now();\n if (now - lastClosedWarningAt < CLOSED_WARNING_INTERVAL_MS) return true;\n lastClosedWarningAt = now;\n logger.error(\n `❌ Database \"${database}\" is closed to new connections (ALLOW_CONNECTIONS false), so requests that need it ` +\n \"are answered 503 DATABASE_UNAVAILABLE. Nothing in it is lost: `rebase db branch create --force` closes its \" +\n \"source for the copy, and one stopped part-way leaves it closed. Open it again with `rebase db branch list` \" +\n `(every \\`rebase db branch\\` command opens it first), or run ALTER DATABASE \"${database.replace(/\"/g, '\"\"')}\" ` +\n \"ALLOW_CONNECTIONS true; connected to the server's postgres database. Not repeated for a minute.\"\n );\n return true;\n}\n","import type { Context, ErrorHandler } from \"hono\";\nimport type { ContentfulStatusCode } from \"hono/utils/http-status\";\nimport type { HonoEnv } from \"./types\";\nimport { logger } from \"../utils/logger\";\nimport { hostEnv } from \"../utils/host\";\nimport { closedDatabaseName, warnIfDatabaseClosed } from \"./closed-database\";\n\n/**\n * A stale caller's schema stamp, as the cause of the error it explains.\n *\n * `createSchemaDriftDetector` puts the two stamps on the context when a request\n * carries an `x-rebase-schema` older than this backend's. It lives next door;\n * this half is here because `errors.ts` is in the graph of\n * `@rebasepro/server/functions` and may not reach `@rebasepro/types` at runtime.\n */\nfunction schemaDriftCause(drift: { client: string; server: string } | undefined): {\n code: string;\n message: string;\n clientSchema: string;\n serverSchema: string;\n} | undefined {\n if (!drift) return undefined;\n return {\n code: \"SCHEMA_DRIFT\",\n message:\n `This client was generated against schema ${drift.client}; this backend serves `\n + `${drift.server}. If the field named above was renamed or removed, regenerate the `\n + \"SDK (`rebase generate-sdk`) and rebuild.\",\n clientSchema: drift.client,\n serverSchema: drift.server\n };\n}\n\n/** Tracks whether we've already shown the doctor hint (once per process). */\nlet _schemaDriftHinted = false;\n\n/**\n * The schema-drift remedy, in the words that work on *this* database.\n *\n * On the managed development database boot applies additive changes, so\n * restarting `rebase dev` is the first fix there, and `rebase db push` — which\n * plans against a throwaway database of its own — applies what boot leaves\n * alone. Elsewhere the push or the migrations are the fix, and a deployed\n * runtime applies the schema at boot, so the remedy has to know which database\n * is under this run.\n *\n * `REBASE_DEV_DATABASE_KIND` is set by the CLI from the database it resolved,\n * the same variable `rebase schema generate`'s closing line already branches\n * on. Absent — a deployed backend, a container, anything not started by\n * `rebase dev` — the answer is the general one.\n */\nexport function schemaDriftRemedy(): { short: string; lines: string[] } {\n if (hostEnv().REBASE_DEV_DATABASE_KIND === \"managed\") {\n return {\n short: \"Restart `rebase dev` — boot applies additive schema changes to the managed database — \" +\n \"or run `rebase db push` for a change boot leaves alone.\",\n lines: [\n \" Quick fixes (managed development database):\",\n \" restart `rebase dev` boot applies additive changes\",\n \" rebase db push applies what boot leaves alone\",\n \" (a drop or a retype; it asks first)\",\n \" rebase doctor full 3-way drift report\"\n ]\n };\n }\n\n return {\n short: \"Run `rebase db push` to sync your schema, or `rebase db migrate` to apply pending migrations.\",\n lines: [\n \" Quick fixes (local dev, against DATABASE_URL):\",\n \" rebase db push sync schema to database (dev)\",\n \" rebase db migrate apply pending migrations (prod)\",\n \" rebase doctor full 3-way drift report\",\n \"\",\n \" Managed cloud: the runtime applies schema + RLS at boot\",\n \" (REBASE_MIGRATE_ON_BOOT); redeploy rather than db push,\",\n \" which cannot reach the tenant database.\"\n ]\n };\n}\n\n/** Shape of Postgres / network errors with diagnostic codes */\ninterface PgLikeError {\n code?: string;\n address?: string;\n port?: number;\n message?: string;\n table?: string;\n column?: string;\n schema?: string;\n detail?: string;\n hint?: string;\n constraint?: string;\n}\n\n/** 5-character SQLSTATE, e.g. `42501`, `23505`. */\nconst SQLSTATE_RE = /^[0-9A-Z]{5}$/;\n\n/**\n * What SQLSTATE 25006 means here, in the only terms that help the author fix it.\n *\n * Every request-scoped read runs `withTransaction(..., { accessMode: \"read\n * only\" })`, so a write attempted anywhere under it — including from a\n * `context.data` call inside an `afterRead` callback — is refused by Postgres\n * rather than by us. The callback name is in the message because that is the\n * file the reader has to open, and nothing else on a read path can raise this.\n */\nconst READ_ONLY_TRANSACTION_MESSAGE =\n \"An `afterRead` callback tried to write. Request-scoped reads run in a READ ONLY \" +\n \"transaction, so neither the callback nor anything it calls (context.data included) \" +\n \"may write. Move the write outside the read: enqueue a background job, or use \" +\n \"`rebase.dataAsAdmin` from a job or a custom function.\";\n\n/**\n * Walk the cause chain for the underlying database error, identified by a\n * 5-char SQLSTATE `code`. Drizzle wraps the pg error in `.cause`, and route\n * code sometimes wraps drizzle again, so the real error may sit several\n * levels down.\n */\nfunction extractDbError(error: unknown, depth = 0): PgLikeError | null {\n if (!error || typeof error !== \"object\" || depth > 8) return null;\n const e = error as PgLikeError & { cause?: unknown };\n if (typeof e.code === \"string\" && SQLSTATE_RE.test(e.code)) return e;\n if (e.cause && typeof e.cause === \"object\") return extractDbError(e.cause, depth + 1);\n return null;\n}\n\n/**\n * Did the database refuse a value as unreadable for its column's type —\n * SQLSTATE class 22, anywhere down the cause chain?\n *\n * For a lookup by an id the caller supplied, that is an answer rather than a\n * failure: `abc` against a UUID key names no row, exactly as a well-formed id\n * nobody has does.\n */\nexport function isDataException(error: unknown): boolean {\n return extractDbError(error)?.code?.startsWith(\"22\") === true;\n}\n\n/**\n * The two ways a request fails before it ever reaches the database, read off\n * the cause chain by pg-pool's own wording (it sets no code on either).\n *\n * - `timeout exceeded when trying to connect` — every pooled connection was\n * busy for the whole of `connectionTimeoutMillis`. pg-pool raises it only\n * from its wait queue, so it means the pool is exhausted: slow queries\n * holding connections, or `DB_POOL_MAX` too small for the load.\n * - `Connection terminated due to connection timeout` — a fresh connection\n * took longer than that to open: the database is slow or far away.\n *\n * Either is load, not a fault. Answered as a 500 they read as a crash and gave\n * a client nothing to back off on; as a 503 with `Retry-After` the SDK's\n * existing retry path takes them (`offline-connectivity.ts`).\n */\nconst POOL_FAILURES: ReadonlyArray<{ message: string; code: \"DB_POOL_EXHAUSTED\" | \"DB_CONNECT_TIMEOUT\" }> = [\n { message: \"timeout exceeded when trying to connect\", code: \"DB_POOL_EXHAUSTED\" },\n { message: \"Connection terminated due to connection timeout\", code: \"DB_CONNECT_TIMEOUT\" }\n];\n\nfunction poolFailureOf(error: unknown, depth = 0): \"DB_POOL_EXHAUSTED\" | \"DB_CONNECT_TIMEOUT\" | undefined {\n if (!error || typeof error !== \"object\" || depth > 8) return undefined;\n const message = Reflect.get(error, \"message\");\n if (typeof message === \"string\") {\n const match = POOL_FAILURES.find(failure => message === failure.message);\n if (match) return match.code;\n }\n return poolFailureOf(Reflect.get(error, \"cause\"), depth + 1);\n}\n\n/** Seconds a client is asked to wait after a pool failure — one pool timeout's worth is too long to make a person wait. */\nconst POOL_RETRY_AFTER_SECONDS = 1;\n\n/**\n * SQLSTATEs that mean the database dropped or refused the connection, not\n * that it judged the statement: class 08 (connection exception), and the\n * three shutdown codes — `57P01` a server shutting down or an administrator\n * ending the session, `57P02` a crash restart, `57P03` a server still\n * starting.\n */\nconst UNREACHABLE_SQLSTATES = new Set([\"57P01\", \"57P02\", \"57P03\"]);\n\n/**\n * What node-postgres says when the socket under a client closed, in the words\n * it sets no code on.\n */\nconst CONNECTION_LOST_MESSAGES = new Set([\n \"Connection terminated unexpectedly\",\n \"Client has encountered a connection error and is not queryable\"\n]);\n\n/** The socket failures Node raises when a host cannot be reached or drops the connection. */\nconst SOCKET_FAILURE_CODES = new Set([\n \"ECONNREFUSED\", \"ECONNRESET\", \"ENOTFOUND\", \"EAI_AGAIN\", \"EHOSTUNREACH\", \"ENETUNREACH\", \"ETIMEDOUT\", \"EPIPE\"\n]);\n\n/**\n * A Node socket failure: one of {@link SOCKET_FAILURE_CODES} on an error the\n * runtime raised for a system call, or the `AggregateError` `net` raises when\n * every address a host resolved to failed that way.\n */\nfunction isSocketFailure(error: object, depth = 0): boolean {\n const code = Reflect.get(error, \"code\");\n if (typeof code === \"string\" && SOCKET_FAILURE_CODES.has(code) && typeof Reflect.get(error, \"syscall\") === \"string\") {\n return true;\n }\n const inner = Reflect.get(error, \"errors\");\n return depth < 2 && Array.isArray(inner) && inner.length > 0 &&\n inner.every(item => typeof item === \"object\" && item !== null && isSocketFailure(item, depth + 1));\n}\n\n/**\n * Did the request fail because the database could not be reached at all — not\n * because it refused the statement, and not because some other service is down?\n *\n * Read off the error the database driver raised, wherever it sits in the cause\n * chain:\n *\n * - a SQLSTATE that means the connection was refused or dropped (class `08`,\n * `57P01`–`57P03`). The database's own word, so it settles the question\n * either way: a SQLSTATE of any other class is the database answering;\n * - node-postgres's own sentence for a socket that closed under a query;\n * - a socket failure (`ECONNREFUSED`, `ENOTFOUND`, …), but only where it can\n * only be the database's: thrown as it is, which is how node-postgres hands\n * back a pool connection it could not open, or as the cause of a failed\n * Drizzle query (the wrapper that carries the statement). Under any other\n * wrapper it belongs to another client: `fetch failed` is a `fetch()` to\n * some other host, and an S3 call that cannot connect carries the AWS SDK's\n * `$metadata`. Answering those \"the database is unreachable\" would send an\n * operator to a database that is fine.\n */\nexport function isDatabaseUnreachable(error: unknown): boolean {\n if (typeof error !== \"object\" || error === null) return false;\n\n // Ahead of the SQLSTATE read below, which takes 55000 for the database\n // answering: closed to new connections, it cannot serve the request at all.\n if (closedDatabaseName(error) !== null) return true;\n\n const reported = databaseReport(error);\n if (reported !== undefined) return reported;\n\n const socketFailureOf = (candidate: unknown): boolean =>\n typeof candidate === \"object\" && candidate !== null &&\n isSocketFailure(candidate) && Reflect.get(candidate, \"$metadata\") === undefined;\n if (socketFailureOf(error)) return true;\n return typeof Reflect.get(error, \"query\") === \"string\" && socketFailureOf(Reflect.get(error, \"cause\"));\n}\n\n/**\n * What the database itself said about the connection, down the cause chain:\n * `true` for a lost or refused connection, `false` for a SQLSTATE that is the\n * database answering, `undefined` when nothing on the chain is the database's.\n */\nfunction databaseReport(error: object, depth = 0): boolean | undefined {\n const code = Reflect.get(error, \"code\");\n // `EPIPE` has a SQLSTATE's shape, and is a socket's.\n if (typeof code === \"string\" && SQLSTATE_RE.test(code) && !SOCKET_FAILURE_CODES.has(code)) {\n return code.startsWith(\"08\") || UNREACHABLE_SQLSTATES.has(code);\n }\n const message = Reflect.get(error, \"message\");\n if (typeof message === \"string\" && CONNECTION_LOST_MESSAGES.has(message)) return true;\n const cause = Reflect.get(error, \"cause\");\n return depth < 8 && typeof cause === \"object\" && cause !== null ? databaseReport(cause, depth + 1) : undefined;\n}\n\n/**\n * The operator's half of a connection that failed — to the database, or to a\n * service a request called — in a few words: the socket failure and the host\n * it names (`ECONNREFUSED at 127.0.0.1:5432`), or what the database or its\n * client said about the connection.\n */\nfunction describeConnectionFailure(error: unknown, depth = 0): string {\n if (typeof error !== \"object\" || error === null || depth > 8) return \"the connection failed\";\n const code = Reflect.get(error, \"code\");\n const message = Reflect.get(error, \"message\");\n if (typeof code === \"string\" && SOCKET_FAILURE_CODES.has(code)) {\n const address = Reflect.get(error, \"address\");\n const port = Reflect.get(error, \"port\");\n const hostname = Reflect.get(error, \"hostname\");\n if (address !== undefined) return `${code} at ${String(address)}${port !== undefined ? `:${String(port)}` : \"\"}`;\n if (typeof hostname === \"string\") return `${code} for ${hostname}`;\n }\n if (typeof message === \"string\" && (CONNECTION_LOST_MESSAGES.has(message) ||\n (typeof code === \"string\" && (code.startsWith(\"08\") || UNREACHABLE_SQLSTATES.has(code))))) {\n return typeof code === \"string\" ? `${message} (${code})` : message;\n }\n const inner = Reflect.get(error, \"errors\");\n if (Array.isArray(inner) && inner.length > 0) return describeConnectionFailure(inner[0], depth + 1);\n const cause = Reflect.get(error, \"cause\");\n if (typeof cause === \"object\" && cause !== null) return describeConnectionFailure(cause, depth + 1);\n return typeof code === \"string\" ? code : \"the connection failed\";\n}\n\n/**\n * Is there a Node socket failure under this error, anywhere down its cause\n * chain?\n *\n * Asked only once {@link isDatabaseUnreachable} has said no, so what it finds\n * belongs to another service: a `fetch()` to some other host (`fetch failed`,\n * its `cause` the socket's error), an S3 call that cannot connect (the AWS SDK\n * hands back the socket's own error with `$metadata` on it), or anything else\n * wrapping one. An error that only carries a socket code, with no system call\n * behind it, is not one.\n */\nfunction hasUpstreamSocketFailure(error: unknown, depth = 0): boolean {\n if (typeof error !== \"object\" || error === null || depth > 8) return false;\n return isSocketFailure(error) || hasUpstreamSocketFailure(Reflect.get(error, \"cause\"), depth + 1);\n}\n\n/** What a caller is told when a service the request needs cannot be reached. The host stays in the log. */\nconst UPSTREAM_UNAVAILABLE_MESSAGE =\n \"A service this request depends on could not be reached, so it was not served. Retry shortly.\";\n\n/** What a caller is told when the database cannot be reached. Retryable, and not about them. */\nexport const DATABASE_UNAVAILABLE_MESSAGE =\n \"The database could not be reached, so this request was not served. \" +\n \"Retry shortly; if it keeps happening, the server's database is down or unreachable from it.\";\n\n/** Seconds a client is asked to wait before retrying a request the database could not be reached for. */\nconst DATABASE_RETRY_AFTER_SECONDS = 5;\n\n/** The operator's line for an unreachable database, at most once a minute — see {@link warnPoolFailure}. */\nlet lastUnreachableWarningAt = 0;\n\nfunction warnDatabaseUnreachable(detail: string): void {\n const now = Date.now();\n if (now - lastUnreachableWarningAt < POOL_WARNING_INTERVAL_MS) return;\n lastUnreachableWarningAt = now;\n logger.error(\n `❌ The database is unreachable (${detail}), so requests that need it are answered 503 DATABASE_UNAVAILABLE. ` +\n \"Check that it is running and that this server can reach it; requests recover on their own once it is back. \" +\n \"Not repeated for a minute.\"\n );\n}\n\n/**\n * The operator's line, at most once a minute. A pool that is exhausted is\n * exhausted for every request at once, and a line per request buries the one\n * thing to do about it.\n */\nconst POOL_WARNING_INTERVAL_MS = 60_000;\nlet lastPoolWarningAt = 0;\n\nfunction warnPoolFailure(code: \"DB_POOL_EXHAUSTED\" | \"DB_CONNECT_TIMEOUT\"): void {\n const now = Date.now();\n if (now - lastPoolWarningAt < POOL_WARNING_INTERVAL_MS) return;\n lastPoolWarningAt = now;\n logger.warn(code === \"DB_POOL_EXHAUSTED\"\n ? \"⚠️ Database pool exhausted: every connection stayed busy for DB_POOL_CONNECT_TIMEOUT, so requests \" +\n \"are answered 503. Raise DB_POOL_MAX (default 20; DB_POOL_MAX__<KEY> for another data source), or \" +\n \"find the slow queries or long transactions holding connections. Not repeated for a minute.\"\n : \"⚠️ Opening a database connection took longer than DB_POOL_CONNECT_TIMEOUT, so requests are \" +\n \"answered 503. The database is slow to accept connections or far away. Not repeated for a minute.\");\n}\n\n/**\n * Extract the missing table or column name from a PG error.\n * PG 42P01 messages look like: 'relation \"my_table\" does not exist'\n * PG 42703 messages look like: 'column \"my_col\" does not exist' or 'column my_table.my_col does not exist'\n */\nfunction extractMissingIdentifier(pgMessage?: string): string | null {\n if (!pgMessage) return null;\n // Match quoted identifier: relation \"xxx\" / column \"xxx\"\n const quoted = pgMessage.match(/(?:relation|column|table)\\s+\"([^\"]+)\"/i);\n if (quoted) return quoted[1];\n // Match unquoted: column table.col does not exist\n const unquoted = pgMessage.match(/(?:relation|column|table)\\s+([\\w.]+)\\s+does not exist/i);\n if (unquoted) return unquoted[1];\n return null;\n}\n\n/**\n * The sentence a `INVALID_FILTER_VALUE` answer carries.\n *\n * Built from Postgres's own wording rather than passed through, because the\n * driver hands the whole failed statement over as `error.message` and this one\n * goes to the caller in production too. What is quoted back is the type name\n * and the literal the caller themselves sent — never a table, a column list or\n * a statement.\n *\n * The three shapes are the ones a filter actually produces: an unparseable\n * literal (22P02, `?id=eq.abc`), a value outside an enum's labels (22P02 with\n * different wording), and a number past the column type's range (22003). A\n * fourth SQLSTATE in class 22 lands on the general sentence, which still says\n * the useful thing: it is the value that is wrong, not the server.\n */\nfunction describeDataException(dbError?: PgLikeError): string {\n const message = dbError?.message ?? \"\";\n const column = dbError?.column ? ` for column \"${dbError.column}\"` : \"\";\n\n const syntax = message.match(/invalid input syntax for type ([\\w ]+): \"(.*)\"/);\n if (syntax) return `\"${syntax[2]}\" is not a valid ${syntax[1]}${column}.`;\n\n const enumValue = message.match(/invalid input value for enum ([\\w.\"]+): \"(.*)\"/);\n if (enumValue) return `\"${enumValue[2]}\" is not one of the values of ${enumValue[1]}${column}.`;\n\n const range = message.match(/value \"(.*)\" is out of range for type ([\\w ]+)/);\n if (range) return `\"${range[1]}\" is out of range for ${range[2]}${column}.`;\n\n return `A value in this request could not be read as the type of the column it was compared against${column}.`;\n}\n\n/**\n * Standardized API error class.\n * Throw this from any route handler — the errorHandler middleware\n * will format it into `{ error: { message, code, details? } }`.\n */\nexport class ApiError extends Error {\n public readonly statusCode: number;\n public readonly code: string;\n public readonly details?: unknown;\n /**\n * Whether this outcome is a routine part of normal operation rather than\n * something an operator should look at. Expected errors log at debug; every\n * other operational error logs at warn.\n *\n * The motivating case is `POST /auth/refresh` with no session: clients\n * refresh on page load before they know whether one exists, so every\n * anonymous page view is a 401 — correct, and not worth a warning line.\n *\n * The other class is a caller-caused 4xx that never reached the database: a\n * mistyped filter operator, sort direction or limit, a request for a\n * collection that does not exist. Nothing on this server is wrong, and the\n * response body has already told the caller what to fix — while one client\n * holding a stale name would otherwise write a warning per request, forever,\n * until the level means nothing. See `api/rest/query-parser.ts`.\n *\n * What stays at warn is anything that says something about the *server*:\n * a schema that has drifted from the code, a permission the database\n * refused, a dependency that failed. Those are 4xx too, and they are still\n * incidents.\n */\n public readonly expected: boolean;\n\n constructor(statusCode: number, code: string, message: string, details?: unknown, expected = false) {\n super(message);\n this.name = \"ApiError\";\n this.statusCode = statusCode;\n this.code = code;\n this.details = details;\n this.expected = expected;\n }\n\n // ── Factory methods ──────────────────────────────────────────────\n\n static badRequest(message: string, code = \"BAD_REQUEST\", details?: unknown): ApiError {\n return new ApiError(400, code, message, details);\n }\n\n static unauthorized(message: string, code = \"UNAUTHORIZED\"): ApiError {\n return new ApiError(401, code, message);\n }\n\n /**\n * A 401 that is a normal outcome, not an incident — logged at debug.\n * See {@link ApiError.expected}.\n */\n static unauthenticated(message: string, code = \"UNAUTHORIZED\"): ApiError {\n return new ApiError(401, code, message, undefined, true);\n }\n\n static forbidden(message: string, code = \"FORBIDDEN\"): ApiError {\n return new ApiError(403, code, message);\n }\n\n static notFound(message: string, code = \"NOT_FOUND\"): ApiError {\n return new ApiError(404, code, message);\n }\n\n /**\n * `details` because a 409 is usually a `23505`, and the one thing the\n * caller needs is *which field* collided. The column name goes in; the\n * value never does — see `pgFieldViolations`.\n */\n static conflict(message: string, code = \"CONFLICT\", details?: unknown): ApiError {\n return new ApiError(409, code, message, details);\n }\n\n static internal(message: string, code = \"INTERNAL_ERROR\"): ApiError {\n return new ApiError(500, code, message);\n }\n\n static serviceUnavailable(message: string, code = \"SERVICE_UNAVAILABLE\"): ApiError {\n return new ApiError(503, code, message);\n }\n\n /**\n * `503 DATABASE_UNAVAILABLE`: the database could not be reached. What\n * every door answers then — the data API, sign-in, the admin routes and\n * the realtime socket — so a client sees one retryable answer, sent with\n * `Retry-After`, rather than a permission failure or a crash.\n *\n * @see isDatabaseUnreachable\n */\n static databaseUnavailable(message = DATABASE_UNAVAILABLE_MESSAGE): ApiError {\n return new ApiError(503, \"DATABASE_UNAVAILABLE\", message);\n }\n}\n\n/**\n * Canonical error response shape:\n * `{ error: { message: string, code: string, details?: unknown } }`\n */\nexport interface ErrorResponse {\n error: {\n message: string;\n code: string;\n details?: unknown;\n /** Request correlation ID for tracing (echoes X-Request-ID). */\n requestId?: string;\n /**\n * Why this request was going to fail whatever it asked for.\n *\n * Only `SCHEMA_DRIFT` today: the caller's `x-rebase-schema` stamp is\n * older than this backend's, so a 400 naming an unknown field is very\n * likely a rename the client has not regenerated for. The error itself\n * is unchanged — this explains it, it does not cause it.\n */\n cause?: {\n code: string;\n message: string;\n clientSchema: string;\n serverSchema: string;\n };\n };\n}\n\n/**\n * General shape of errors that flow through the API error handler.\n * Extends Error with optional HTTP status, error code, and details.\n */\nexport interface RebaseApiError extends Error {\n statusCode?: number;\n code?: string;\n details?: unknown;\n}\n\n/**\n * The answer an error chose for itself, read the same way at every door.\n *\n * @see declaredErrorAnswer\n */\nexport interface DeclaredErrorAnswer {\n /** The HTTP status the error carries. A socket frame has no slot for it. */\n status: number;\n code: string;\n message: string;\n details?: unknown;\n /** See {@link ApiError.expected}: log it at debug, not warn. */\n expected: boolean;\n}\n\n/**\n * The status, code and message an error carries as its own answer — or\n * `undefined` for an error that carries none, which is a server fault and gets\n * masked.\n *\n * Two classes carry one. The server's `ApiError`, and `RebaseApiError` (or its\n * `RebaseClientError` subclass) from `@rebasepro/types` once it has a status.\n * The second is the browser-safe class: a `config/collections/*.ts` file is\n * bundled into the admin SPA and cannot import this package, so it is what a\n * collection callback throws, and what a callback refusal becomes —\n * `callbackRefusal` returns one, and `toCallbackError` wraps anything thrown\n * that does not already carry a status.\n *\n * One function because several doors turn an error into an answer: the REST\n * error handler, the two WebSocket servers, and the Postgres realtime\n * subscriptions. Each used to list the classes it recognised by hand. The\n * sockets listed only `ApiError`, so a `beforeDelete` veto that REST\n * answered as 400 `CALLBACK_REJECTED` with the author's message reached the\n * admin panel — which writes through the socket — as `INTERNAL_ERROR`, and in\n * production as \"An unexpected error occurred\".\n *\n * Matched by name as well as `instanceof`: a monorepo can resolve two copies of\n * a package, and `instanceof` is false across them. Name matching is also why\n * this file needs no runtime import of `@rebasepro/types`, which it may not\n * have — it is in the graph of `@rebasepro/server/functions`.\n */\nexport function declaredErrorAnswer(error: unknown): DeclaredErrorAnswer | undefined {\n if (error === null || typeof error !== \"object\") return undefined;\n const e = error as { name?: unknown; message?: unknown; code?: unknown; details?: unknown; statusCode?: unknown; status?: unknown };\n\n let status: number | undefined;\n if (error instanceof ApiError || e.name === \"ApiError\") {\n status = typeof e.statusCode === \"number\" ? e.statusCode : undefined;\n } else if (typeof e.name === \"string\" && /^Rebase(Api|Client)Error$/.test(e.name)) {\n // It spells its status `status`; `statusCode` wins when both are set.\n status = typeof e.statusCode === \"number\" ? e.statusCode\n : typeof e.status === \"number\" ? e.status\n : undefined;\n // Without a status it has not chosen an answer — `RebaseClientError`\n // is also raised for plain logic errors — so it is not one here.\n if (status === undefined) return undefined;\n } else {\n return undefined;\n }\n\n return {\n status: status || 500,\n code: typeof e.code === \"string\" && e.code ? e.code : \"INTERNAL_ERROR\",\n message: typeof e.message === \"string\" ? e.message : String(e.message ?? \"\"),\n ...(e.details !== undefined && { details: e.details }),\n expected: error instanceof ApiError && error.expected\n };\n}\n\n// `isRebaseApiError` was here. It read `return error instanceof Error`, so it\n// answered yes to every error while being named and used as though it\n// discriminated — the create and update handlers guarded a \"classify this as\n// BAD_REQUEST\" branch on it, and an unreachable database was therefore reported\n// to callers as a bad request. Deleted rather than repaired: the shape it\n// claimed to test is not decidable from an `Error`, and the layer that does\n// know — the driver, which holds the SQLSTATE — raises a real `ApiError`.\n\n/**\n * Leave the code and the message where the request log will find them.\n *\n * A failed request used to produce two lines, each holding half of it: this\n * handler had the code and the diagnosis, `requestLogger` had the user, the\n * collection, the status and the latency. Correlating them meant matching on\n * the request id — which only one of them printed reliably — and the pair cost\n * twice the volume for less than one line's worth of meaning.\n */\nfunction handOffToRequestLog(c: Context<HonoEnv>, code: string, message: string, expected = false): void {\n if (typeof c.set !== \"function\") return;\n c.set(\"errorSummary\", { code, message, expected });\n}\n\n/**\n * Is a request line coming for this request?\n *\n * `requestLogger` claims it before the handler runs, so by the time an error\n * reaches here the answer is already known. When nothing claimed it — a router\n * a project mounted onto its own Hono app, a test driving `app.fetch`\n * directly — this handler stays the only thing that would report the failure,\n * so it still writes its own line. Silence is the one outcome neither half may\n * produce.\n */\nfunction requestWillBeLogged(c: Context<HonoEnv>): boolean {\n return typeof c.get === \"function\" && c.get(\"requestLogged\") === true;\n}\n\n/**\n * Hono error-handling middleware (`app.onError`).\n * Converts any error into the canonical `{ error: { message, code } }` shape.\n */\nexport const errorHandler: ErrorHandler<HonoEnv> = (err, c) => {\n // Typecast custom error properties\n const error: RebaseApiError = err;\n const reqId = typeof c.get === \"function\" ? c.get(\"requestId\") : undefined;\n\n /* A stale SDK, named on the errors it explains.\n\n 400 and 404 only: those are what a renamed or removed field produces —\n an unknown filter field is a 400, a collection gone from under its slug\n is a 404 — and they are the two a caller can act on by regenerating.\n Attaching it to a 500 would be noise, since a server fault has nothing to\n do with how old the caller's schema is. */\n const driftFor = (status: number) =>\n (status === 400 || status === 404) && typeof c.get === \"function\"\n ? schemaDriftCause(c.get(\"schemaDrift\"))\n : undefined;\n\n // An error that chose its own answer — `ApiError`, or the browser-safe\n // `RebaseApiError` a collection callback throws. The same predicate the\n // WebSocket servers use; see `declaredErrorAnswer`.\n const answer = declaredErrorAnswer(error);\n if (answer) {\n // Operational errors — log at warn, unless the error declares itself a\n // routine outcome (see ApiError.expected), which would otherwise put a\n // warning in the log for every anonymous page view.\n handOffToRequestLog(c, answer.code, answer.message, answer.expected);\n if (answer.code === \"DATABASE_UNAVAILABLE\") c.header(\"Retry-After\", String(DATABASE_RETRY_AFTER_SECONDS));\n if (!requestWillBeLogged(c)) {\n const line = `[API] ${c.req.method} ${c.req.path} → ${answer.status} ${answer.code}: ${answer.message}` +\n (reqId ? ` [${reqId}]` : \"\");\n if (answer.expected) {\n logger.debug(line);\n } else {\n logger.warn(`⚠️ ${line}`);\n }\n }\n const apiErrorDrift = driftFor(answer.status);\n return c.json({\n error: {\n message: answer.message,\n code: answer.code,\n ...(answer.details !== undefined && { details: answer.details }),\n ...(reqId && { requestId: reqId }),\n ...(apiErrorDrift && { cause: apiErrorDrift })\n }\n } satisfies ErrorResponse, answer.status as ContentfulStatusCode);\n }\n\n // A body that is not JSON, read with `c.req.json()` — every auth and admin\n // route reads its body that way. It threw a bare `SyntaxError`, which\n // landed here as a 500 logged with a stack: a server fault for the\n // caller's typo, where the data API (which parses its own body) answered\n // 400. Mapped once, here, rather than at each of forty call sites.\n if (err instanceof SyntaxError && /JSON/i.test(err.message)) {\n handOffToRequestLog(c, \"INVALID_JSON\", \"Invalid JSON body\");\n return c.json({\n error: {\n message: \"Invalid JSON body\",\n code: \"INVALID_JSON\",\n ...(reqId && { requestId: reqId })\n }\n } satisfies ErrorResponse, 400);\n }\n\n let statusCode = error.statusCode || codeToStatus(error.code) || 500;\n let code = error.code || \"INTERNAL_ERROR\";\n\n // Handle DB connection and specific system errors for better logging\n let logMessage = error.message;\n\n // Resolve the actual cause — Node's net module wraps dual-stack failures\n // in an AggregateError whose inner errors carry the real address/port.\n let resolvedCause: PgLikeError | undefined;\n if (error.cause && typeof error.cause === \"object\" && error.cause !== null && \"code\" in error.cause) {\n const cause = error.cause as PgLikeError & { errors?: PgLikeError[] };\n if (cause.code === \"ECONNREFUSED\" && !cause.address && Array.isArray(cause.errors)) {\n // AggregateError — pick the first inner error that has address info\n resolvedCause = cause.errors.find(e => e.address) || cause;\n } else {\n resolvedCause = cause;\n }\n }\n\n // The real database error may sit several levels down the cause chain.\n // Losing it turns a precise failure (e.g. an RLS denial) into an opaque\n // \"Failed query: …\" 500 that is undiagnosable without direct DB access.\n const dbError = extractDbError(error);\n const poolFailure = dbError ? undefined : poolFailureOf(error);\n // Ahead of every SQLSTATE arm below: `57P01` is a SQLSTATE too, and it\n // says the connection is gone, not that the statement was wrong.\n const unreachable = !poolFailure && isDatabaseUnreachable(error);\n // A socket failure that is not the database's is another service's: a\n // `fetch()`, an S3 call. It answered `500` with the errno as the code\n // (`ECONNREFUSED`), or `INTERNAL_ERROR` — a server fault, for a service it\n // calls being down.\n const upstream = !poolFailure && !unreachable && !dbError && hasUpstreamSocketFailure(error);\n\n if (poolFailure) {\n code = poolFailure;\n statusCode = 503;\n logMessage = poolFailure === \"DB_POOL_EXHAUSTED\"\n ? \"Timed out waiting for a database connection: every pooled connection was busy (DB_POOL_MAX).\"\n : \"Timed out opening a database connection (DB_POOL_CONNECT_TIMEOUT).\";\n warnPoolFailure(poolFailure);\n c.header(\"Retry-After\", String(POOL_RETRY_AFTER_SECONDS));\n } else if (unreachable) {\n code = \"DATABASE_UNAVAILABLE\";\n statusCode = 503;\n const closedDatabase = closedDatabaseName(error);\n const reason = describeConnectionFailure(error);\n logMessage = closedDatabase !== null\n ? `Database \"${closedDatabase}\" is closed to new connections.`\n : `The database could not be reached: ${reason}.`;\n if (!warnIfDatabaseClosed(error)) warnDatabaseUnreachable(reason);\n c.header(\"Retry-After\", String(DATABASE_RETRY_AFTER_SECONDS));\n } else if (upstream) {\n code = \"SERVICE_UNAVAILABLE\";\n statusCode = 503;\n logMessage = `A service this request depends on could not be reached: ${describeConnectionFailure(error)}.`;\n } else if (resolvedCause && (resolvedCause.code === \"ENETUNREACH\" || resolvedCause.code === \"ECONNREFUSED\")) {\n // Some other service: a database that cannot be reached is the arm\n // above, so this one does not say \"database\".\n const cause = resolvedCause;\n logMessage = cause.code === \"ENETUNREACH\"\n ? `Network unreachable: cannot connect to ${cause.address}:${cause.port}.`\n : `Connection refused by ${cause.address}:${cause.port}.`;\n } else if (\"code\" in error && error.code === \"ENETUNREACH\") {\n const netErr = error as PgLikeError;\n logMessage = `Network unreachable. Cannot connect to service at ${netErr.address}:${netErr.port}.`;\n } else if (dbError && (dbError.code === \"42703\" || dbError.code === \"42P01\")) {\n code = \"SCHEMA_DRIFT\";\n const issue = dbError.code === \"42703\" ? \"column\" : \"table\";\n const identifier = dbError.table || dbError.column || extractMissingIdentifier(dbError.message) || \"unknown\";\n logMessage = `Schema drift: ${issue} \"${identifier}\" does not exist in the database. ${schemaDriftRemedy().short}`;\n } else if (dbError) {\n const parts = [`[PG ${dbError.code}] ${dbError.message}`];\n if (dbError.detail) parts.push(`Detail: ${dbError.detail}`);\n if (dbError.hint) parts.push(`Hint: ${dbError.hint}`);\n if (dbError.table) parts.push(`Table: ${dbError.table}`);\n if (dbError.column) parts.push(`Column: ${dbError.column}`);\n if (dbError.constraint) parts.push(`Constraint: ${dbError.constraint}`);\n if (dbError.code === \"42501\") {\n code = \"DB_PERMISSION_DENIED\";\n const ungranted = missingGrant(dbError.message);\n parts.push(ungranted\n ? `The role this request runs as was never granted ${ungranted.kind} \"${ungranted.name}\" — ` +\n \"a missing GRANT, not a row-level security policy.\"\n : \"The database rejected the statement for lack of privilege — usually a row-level \" +\n `security policy${dbError.table ? ` on \"${dbError.table}\"` : \"\"} denying this role, ` +\n \"or a stale FORCE ROW LEVEL SECURITY flag binding the owner connection.\"\n );\n }\n // 25006 read_only_sql_transaction. A request-scoped read opens its\n // transaction `READ ONLY`, so the only way to reach this is user code on\n // a read path attempting a write — which means an `afterRead` callback,\n // or something it called. Left in the generic branch it was a 500\n // \"Internal Server Error\", indistinguishable from the database being\n // down; it is the caller's own code, and it is not a server failure.\n if (dbError.code === \"25006\") {\n code = \"READ_ONLY_TRANSACTION\";\n statusCode = 409;\n parts.push(READ_ONLY_TRANSACTION_MESSAGE);\n }\n // SQLSTATE class 22 — data exception. The caller sent a value the\n // column's type cannot hold: `?id=eq.abc` on an integer key,\n // `?status=eq.nope` on an enum, a timestamp that is not one, a number\n // past the type's range. Every *other* bad query parameter already has\n // a precise 400 (`INVALID_LIMIT`, `UNKNOWN_FILTER_FIELD`,\n // `INVALID_LOGICAL_GROUP`); a bad *value* fell off the end of this\n // chain and answered 500 INTERNAL_ERROR — and in production `dbMessage`\n // is stripped, so the caller got a bare 500 naming nothing and had no\n // way to learn their own typo was the cause.\n if (dbError.code?.startsWith(\"22\")) {\n code = \"INVALID_FILTER_VALUE\";\n statusCode = 400;\n }\n logMessage = parts.join(\". \");\n }\n\n const isDbSchemaMismatch = code === \"SCHEMA_DRIFT\";\n\n // `logMessage`, not the sanitized client message: the request line is a\n // server log, and the whole point of this branch is the diagnosis it built.\n handOffToRequestLog(c, code, logMessage);\n\n if (isDbSchemaMismatch) {\n // Database schema mismatch is logged as a warning instead of a fatal error\n if (!requestWillBeLogged(c)) logger.warn(\n `⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n // In dev mode, show a one-time hint to run `rebase doctor`\n if (!_schemaDriftHinted && hostEnv().NODE_ENV !== \"production\") {\n _schemaDriftHinted = true;\n // Drawn rather than hand-aligned: the remedy inside it now varies\n // with the database, and a box whose rows were padded by hand\n // stayed straight only for the text it was written around.\n const WIDTH = 62;\n const row = (text: string) => `│${text.padEnd(WIDTH).slice(0, WIDTH)}│`;\n logger.warn([\n \"\",\n `┌${\"─\".repeat(WIDTH)}┐`,\n // One space short, deliberately: the emoji occupies two columns\n // in a terminal and one in `String.length`.\n `│${\" 💡 TIP: Run `rebase doctor` for full schema diagnostics\".padEnd(WIDTH - 1)}│`,\n row(\"\"),\n ...schemaDriftRemedy().lines.map(row),\n `└${\"─\".repeat(WIDTH)}┘`,\n \"\"\n ].join(\"\\n\"));\n }\n } else if (poolFailure || unreachable) {\n // Said once a minute by `warnPoolFailure` / `warnDatabaseUnreachable`;\n // the request line carries each occurrence.\n if (!requestWillBeLogged(c)) logger.warn(\n `⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n } else if (code === \"READ_ONLY_TRANSACTION\" || code === \"INVALID_FILTER_VALUE\") {\n // A 4xx: the application's own callback, refused — or a filter value\n // the caller's own request could not have worked with. Not a server\n // fault, so not an ❌ in the log either — and, like the drift arm above,\n // not a second line when the request log is already going to carry it.\n if (!requestWillBeLogged(c)) logger.warn(\n `⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n } else if (!requestWillBeLogged(c)) {\n // Unexpected errors — log at error level\n logger.error(\n `❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` +\n (reqId ? ` [${reqId}]` : \"\")\n );\n }\n\n // Suppress the huge stack trace for known DB errors: it is noisy, and the\n // extracted [PG …] line above carries the signal. The SQL and the bound\n // params it used to leak are no longer this branch's problem — `logger`\n // strips Drizzle's `Failed query: … / params: …` wrapper out of every\n // message and stack it emits, so the fallbacks below (a connection dropped\n // mid-statement carries no SQLSTATE, so `dbError` is null and the stack is\n // logged) are covered too.\n const suppressStack = isDbSchemaMismatch || dbError !== null || poolFailure !== undefined || unreachable ||\n (statusCode < 500 && code === \"BAD_REQUEST\");\n if (!suppressStack) {\n // The error goes in as a value, not as `String(error.stack)`. A string\n // is a leaf to the logger: `serialiseError` — the `.cause`/\n // `AggregateError` walker the boot path relies on — never runs on one,\n // so the request path used to print the outer wrapper's stack and drop\n // the sentence that says what actually failed (`connect ECONNRESET`,\n // sitting two `.cause` links down). Structured, it walks the chain and\n // redacts each link on the way.\n logger.error(\"unhandled request error\", { error });\n }\n\n // Sanitize the message for the client to prevent leaking sensitive details\n // like SQL queries or internal IP addresses.\n let clientMessage = \"An unexpected error occurred\";\n if (code === \"READ_ONLY_TRANSACTION\") {\n // Ahead of the generic 4xx arm below, which would echo the raw driver\n // message (\"Failed query: insert into …\") back to the caller.\n clientMessage = READ_ONLY_TRANSACTION_MESSAGE;\n } else if (code === \"INVALID_FILTER_VALUE\") {\n // Also ahead of the 4xx arm: `error.message` here is the driver's\n // \"Failed query: select … / params: …\", which is both unhelpful and the\n // one thing this envelope must never carry.\n clientMessage = describeDataException(dbError || (error as PgLikeError));\n } else if (statusCode < 500 && error.message) {\n // If it's a 4xx error (e.g. from validation), it's generally safe to send the message\n clientMessage = error.message;\n } else if (error instanceof ApiError || error.name === \"ApiError\") {\n // We already handled ApiError above, but just in case\n clientMessage = error.message;\n } else if (code === \"SCHEMA_DRIFT\") {\n const pgErr = dbError || (error as PgLikeError);\n const issue = pgErr.code === \"42703\" ? \"column\" : \"table\";\n const identifier = pgErr.table || pgErr.column || extractMissingIdentifier(pgErr.message || error.message) || \"unknown\";\n clientMessage = `Schema drift: ${issue} \"${identifier}\" does not exist. ${schemaDriftRemedy().short}`;\n } else if (poolFailure) {\n clientMessage = \"The database is busy: no connection was free in time. Retry shortly.\";\n } else if (unreachable) {\n clientMessage = DATABASE_UNAVAILABLE_MESSAGE;\n } else if (upstream) {\n clientMessage = UPSTREAM_UNAVAILABLE_MESSAGE;\n } else if (code === \"DB_PERMISSION_DENIED\") {\n const ungranted = missingGrant(dbError?.message);\n clientMessage = ungranted\n ? `Permission denied by the database: the role this request runs as has no privilege on ${ungranted.kind} ` +\n `\"${ungranted.name}\". That is a missing GRANT to that role, not a row-level security policy.`\n : `Permission denied by the database${dbError?.table ? ` on \"${dbError.table}\"` : \"\"} (row-level security). Check the RLS policies for this table.`;\n } else if (code === \"INTERNAL_ERROR\") {\n clientMessage = \"Internal Server Error\";\n }\n\n // Database diagnostics for the envelope: the SQLSTATE is always safe to\n // return; message/detail/hint can reference schema internals, so only\n // outside production.\n const dbDetails = dbError ? {\n dbCode: dbError.code,\n ...(hostEnv().NODE_ENV !== \"production\" && {\n dbMessage: dbError.message,\n ...(dbError.detail && { detail: dbError.detail }),\n ...(dbError.hint && { hint: dbError.hint })\n })\n } : undefined;\n\n const drift = driftFor(statusCode);\n\n return c.json({\n error: {\n message: clientMessage,\n code,\n ...(error.details !== undefined\n ? { details: error.details }\n : dbDetails !== undefined ? { details: dbDetails } : {}),\n ...(reqId && { requestId: reqId }),\n ...(drift && { cause: drift })\n }\n } satisfies ErrorResponse, statusCode as ContentfulStatusCode);\n};\n\n/**\n * The object a `42501` names when no policy was involved.\n *\n * One SQLSTATE covers two different failures. A policy refusing a row says\n * \"… violates row-level security policy …\"; a role that was never granted an\n * object says \"permission denied for schema rebase\" (or table, function, …).\n * Answering the second with \"check the RLS policies\" sent people looking at\n * policies that were fine, for a grant that was missing.\n */\nfunction missingGrant(message: string | undefined): { kind: string; name: string } | undefined {\n const match = message?.match(/^permission denied for (schema|table|relation|view|materialized view|sequence|function|procedure|routine|type|column|database|foreign table|large object|tablespace|language) (.+)$/i);\n return match ? { kind: match[1].toLowerCase(), name: match[2].replace(/^\"|\"$/g, \"\") } : undefined;\n}\n\n/**\n * Map known error codes to HTTP status codes.\n */\nfunction codeToStatus(code?: string): number | undefined {\n if (!code) return undefined;\n const map: Record<string, number> = {\n BAD_REQUEST: 400,\n INVALID_INPUT: 400,\n WEAK_PASSWORD: 400,\n UNAUTHORIZED: 401,\n INVALID_CREDENTIALS: 401,\n INVALID_TOKEN: 401,\n FORBIDDEN: 403,\n NOT_FOUND: 404,\n CONFLICT: 409,\n EMAIL_EXISTS: 409,\n ROLE_EXISTS: 409,\n READ_ONLY_TRANSACTION: 409,\n INVALID_FILTER_VALUE: 400,\n SCHEMA_DRIFT: 500,\n DB_PERMISSION_DENIED: 500,\n INTERNAL_ERROR: 500,\n NOT_CONFIGURED: 503,\n SERVICE_UNAVAILABLE: 503\n };\n return map[code];\n}\n\n\n"],"mappings":"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"}
|
|
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
6
|
+
import { a as logger } from "./logger-CW8pmTS6.js";
|
|
7
7
|
import { i as warnDeprecated, n as describeLoadFailure, r as nativeDynamicImport, t as SYNTAX_ERROR_ADVICE } from "./load-failure-L5hvfDoG.js";
|
|
8
8
|
import * as fs$1 from "fs";
|
|
9
9
|
import * as path$1 from "path";
|
|
@@ -173,4 +173,4 @@ function isHonoLike(obj) {
|
|
|
173
173
|
//#endregion
|
|
174
174
|
export { loadFunctionsWithDiagnostics as i, loadFunctions as n, loadFunctionsFromDirectory as r, function_loader_exports as t };
|
|
175
175
|
|
|
176
|
-
//# sourceMappingURL=function-loader-
|
|
176
|
+
//# sourceMappingURL=function-loader-KKDqTcIr.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-loader-DbCSoNJj.js","names":[],"sources":["../src/functions/function-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { Hono } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\nimport { describeLoadFailure, SYNTAX_ERROR_ADVICE } from \"../utils/load-failure.js\";\nimport { warnDeprecated } from \"@rebasepro/common\";\n\n/**\n * One custom function: a Hono app, served at `<functions mount>/<name>`.\n *\n * What `defineFunction(...)` returns, and what a function file default-exports:\n * a Hono app over {@link HonoEnv}, the environment a function runs in — build\n * one by hand as `new Hono<HonoEnv>()`. Any Hono-compatible app works at\n * runtime — the loader checks for `.fetch()` and `.routes` rather than an\n * `instanceof`, because a project and the server can hold two copies of hono.\n */\nexport type FunctionApp = Hono<HonoEnv>;\n\n/**\n * Functions as values: the URL segment each one answers at, to its app.\n *\n * `functions: { \"send-invoice\": sendInvoice }` serves `sendInvoice` at\n * `/api/functions/send-invoice` — the same thing a file named\n * `functions/send-invoice.ts` does. {@link loadFunctions} returns exactly this\n * shape, so `functions: await loadFunctions(dir)` and `functionsDir: dir` serve\n * the same routes.\n */\nexport type FunctionsRecord = Record<string, FunctionApp>;\n\n/** How a directory of modules is read. Shared by the function and cron loaders. */\nexport interface LoadDirectoryOptions {\n /**\n * How a module file is imported. Defaults to the runtime's own `import()`;\n * a caller with its own loader hooks (tsx, a test runner's sandbox) passes\n * `url => import(url)` so they apply.\n */\n importModule?: ModuleImporter;\n /**\n * Called once for every file the loader saw and did **not** load, as\n * `<file> (<reason>)`. The files are skipped either way — one malformed\n * file must not take the others with it — and logged; this is how a caller\n * reports them anywhere else.\n */\n onSkip?: (problem: string) => void;\n}\n\n/**\n * Load the custom functions in a directory, as values.\n *\n * Each file default-exports a Hono app — `defineFunction(...)` — or a factory\n * returning one. The filename without its extension is the record key, which\n * is the URL segment it is served at: `functions/send-invoice.ts` →\n * `{ \"send-invoice\": app }`. Only the top level is read.\n *\n * Never throws for a bad file: it is skipped, logged, and passed to\n * `options.onSkip`.\n *\n * @example\n * ```ts\n * const functions = await loadFunctions(path.resolve(\"functions\"));\n * const backend = await createRebaseBackend({ database, functions, provision: true });\n * ```\n */\nexport async function loadFunctions(directory: string, options: LoadDirectoryOptions = {}): Promise<FunctionsRecord> {\n const { functions, problems } = await readFunctionsDirectory(directory, options.importModule);\n if (options.onSkip) for (const problem of problems) options.onSkip(problem);\n return functions;\n}\n\n/** A loaded function, by name. The shape {@link createFunctionRoutes} mounts. */\nexport interface LoadedFunction {\n /** Endpoint name derived from filename (e.g., \"send-invoice\") */\n name: string;\n /** The Hono sub-app to mount */\n app: FunctionApp;\n}\n\n/** What a directory of function files produced: what mounted, and what did not. */\nexport interface LoadedFunctions {\n /** The functions that will be served. */\n functions: LoadedFunction[];\n /**\n * One entry per file the loader saw and did **not** mount, each already\n * phrased as `<name> (<reason>)`.\n */\n problems: string[];\n}\n\n/** A record of functions as the list {@link createFunctionRoutes} mounts, in record order. */\nexport function functionList(functions: FunctionsRecord): LoadedFunction[] {\n return Object.entries(functions).map(([name, app]) => ({ name, app }));\n}\n\n/**\n * Extensions the bundler compiles (or a developer might reasonably write) that\n * this loader cannot import. `rebase build` globs `functions/**\\/*.ts`, so the\n * build's idea of \"a function file\" is strictly wider than the runtime's — the\n * worst direction for a mismatch to go. Anything listed here is reported as a\n * problem instead of vanishing; non-code files (`.md`, `.json`, `.txt`) stay\n * silent, because a README next to your functions is not a mistake.\n */\nconst UNSUPPORTED_CODE_EXTENSIONS = [\".mts\", \".cts\", \".tsx\", \".jsx\", \".mjs\", \".cjs\"];\n\n/**\n * @deprecated Use `loadFunctions` instead, which returns the functions as a\n * record keyed by name. Will be removed in a future release.\n */\nexport async function loadFunctionsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunction[]> {\n warnDeprecated(\n \"server.loadFunctionsFromDirectory\",\n \"`loadFunctionsFromDirectory` is deprecated; use `loadFunctions(dir)`, which returns `{ [name]: app }`.\"\n );\n return functionList((await readFunctionsDirectory(directory, importModule)).functions);\n}\n\n/**\n * @deprecated Use `loadFunctions` instead, with `onSkip` to collect the files\n * it skipped. Will be removed in a future release.\n */\nexport async function loadFunctionsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunctions> {\n warnDeprecated(\n \"server.loadFunctionsWithDiagnostics\",\n \"`loadFunctionsWithDiagnostics` is deprecated; use `loadFunctions(dir, { onSkip })`, which returns `{ [name]: app }`.\"\n );\n const { functions, problems } = await readFunctionsDirectory(directory, importModule);\n return { functions: functionList(functions), problems };\n}\n\n/**\n * Read a directory of function files: what loaded, keyed by name, and one\n * `<file> (<reason>)` per file that did not.\n *\n * Never throws: a single malformed function must not crash server boot. The\n * backend mounts the router regardless and surfaces the count of skipped files\n * on the listing endpoint.\n */\nexport async function readFunctionsDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<{ functions: FunctionsRecord; problems: string[] }> {\n const functions: FunctionsRecord = {};\n // Aggregate problem files so a broken function surfaces as one loud\n // summary line, not just a warning buried per-file. We still don't throw:\n // a single malformed function must not crash server boot.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { functions, problems };\n }\n\n // `withFileTypes` so a directory entry is a *reported* skip rather than a\n // filter miss. `readdirSync(dir)` returned bare names, and a subdirectory\n // simply failed the `.ts`/`.js` test — so `functions/admin/users.ts` was\n // compiled by `rebase build`, shipped in the bundle, and then dropped at\n // boot without a single log line.\n const entries = fs.readdirSync(directory, { withFileTypes: true });\n for (const entry of entries) {\n const file = entry.name;\n\n if (entry.isDirectory()) {\n // Dot-directories are tooling (`.git`, `.turbo`), not intent.\n if (file.startsWith(\".\") || file === \"node_modules\") continue;\n logger.warn(\n `[functions] ${file}/: subdirectory ignored. Functions are loaded from the top level of ` +\n `${directory} only, so nothing under ${file}/ is served. Move the file up (or flatten the ` +\n \"name: `admin/users.ts` → `admin-users.ts`).\"\n );\n problems.push(`${file}/ (subdirectory — functions are not loaded recursively)`);\n continue;\n }\n\n const extension = path.extname(file);\n if (\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n UNSUPPORTED_CODE_EXTENSIONS.includes(extension)\n ) {\n logger.warn(\n `[functions] ${file}: ${extension} files are not loaded. Rename it to .ts (or .js) to serve it.`\n );\n problems.push(`${file} (unsupported extension ${extension})`);\n continue;\n }\n\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported = mod.default;\n\n if (!exported) {\n logger.warn(`[functions] ${file}: no default export. Skipping.`);\n problems.push(`${file} (no default export)`);\n continue;\n }\n\n // Accept a Hono instance — use duck-typing to handle different\n // Hono versions which may not share the same prototype.\n const app = isHonoLike(exported)\n ? exported as FunctionApp\n // Also accept a factory function that returns a Hono instance\n : typeof exported === \"function\" ? exported() : undefined;\n if (isHonoLike(app)) {\n const name = path.basename(file, path.extname(file));\n // `send.ts` and `send.js` side by side name one URL. A\n // record holds one app per name, so the second is reported\n // rather than silently replacing the first.\n if (Object.hasOwn(functions, name)) {\n logger.warn(`[functions] ${file}: another file is already served as '${name}'. Skipping.`);\n problems.push(`${file} (another file is already served as '${name}')`);\n continue;\n }\n functions[name] = app as FunctionApp;\n logger.debug(`⚡ Loaded function route: ${name}`);\n continue;\n }\n\n // Provide actionable diagnostics\n const exportType = typeof exported;\n const keys = exported && typeof exported === \"object\"\n ? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(\", \")\n : \"N/A\";\n logger.warn(\n `[functions] ${file}: default export is not a Hono app or factory. Skipping.\\n` +\n ` export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : \"\"}\\n` +\n ` prototype methods: ${keys}\\n` +\n \" Hint: ensure the function exports a Hono app created with the same hono version as the server.\\n\" +\n \" Author with `defineFunction(...)` from @rebasepro/server for a typed, checked contract.\\n\" +\n \" The loader checks for .fetch() and .routes — any Hono-compatible app will work.\"\n );\n problems.push(`${file} (not a Hono app or factory)`);\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n logger.error(`[functions] Failed to load ${file}: ${message}`);\n problems.push(describeLoadFailure(file, err));\n }\n }\n }\n\n if (problems.length > 0) {\n // Advice keyed to what actually went wrong. The old line said\n // \"author them with `defineFunction(...)`\" for every failure — which is\n // the fix for a wrong export shape and no help at all for the commonest\n // one: a file that threw while being imported, usually a module-scope\n // `process.env.X` read that came back undefined. Told to reach for\n // `defineFunction`, an author rewrites a file whose export was already\n // correct and gets the same failure.\n const advice: string[] = [];\n if (problems.some(p => p.includes(\"(syntax error\"))) {\n advice.push(SYNTAX_ERROR_ADVICE);\n }\n if (problems.some(p => p.includes(\"(threw:\"))) {\n advice.push(\n \" A file that threw ran at import time. Read configuration *inside* a handler \" +\n \"(`requireEnv(c, \\\"STRIPE_KEY\\\")`, or a lazily-built client) rather than at module \" +\n \"scope, where one undefined variable takes the whole file down before any route exists.\"\n );\n }\n if (problems.some(p => p.includes(\"(no default export)\"))) {\n advice.push(\n \" A file with no default export exports nothing the loader can mount. \" +\n \"`export default defineFunction((app) => { … })`.\"\n );\n }\n if (problems.some(p => p.includes(\"(not a Hono app or factory)\"))) {\n advice.push(\n \" A default export the loader did not recognise is usually two copies of hono. \" +\n \"Author with `defineFunction(...)` from @rebasepro/server/functions, which uses the \" +\n \"server's own copy.\"\n );\n }\n logger.warn(\n `[functions] ${problems.length} function file(s) were skipped and will NOT be served:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n advice.join(\"\\n\")\n );\n }\n\n return { functions, problems };\n}\n\n/**\n * Duck-type check for Hono apps.\n * We avoid `instanceof Hono` because different Hono versions\n * installed in the user's project vs. our dependencies will\n * not share the same prototype, causing false negatives.\n */\nfunction isHonoLike(obj: unknown): boolean {\n if (!obj || typeof obj !== \"object\") return false;\n // Hono instances always have .fetch() and .routes\n const record = obj as Record<string, unknown>;\n return (\n typeof record.fetch === \"function\" &&\n Array.isArray(record.routes)\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"function-loader-KKDqTcIr.js","names":[],"sources":["../src/functions/function-loader.ts"],"sourcesContent":["import * as fs from \"fs\";\nimport * as path from \"path\";\nimport { pathToFileURL } from \"url\";\nimport { Hono } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { logger } from \"../utils/logger.js\";\nimport { nativeDynamicImport, type ModuleImporter } from \"../utils/dynamic-import.js\";\nimport { describeLoadFailure, SYNTAX_ERROR_ADVICE } from \"../utils/load-failure.js\";\nimport { warnDeprecated } from \"@rebasepro/common\";\n\n/**\n * One custom function: a Hono app, served at `<functions mount>/<name>`.\n *\n * What `defineFunction(...)` returns, and what a function file default-exports:\n * a Hono app over {@link HonoEnv}, the environment a function runs in — build\n * one by hand as `new Hono<HonoEnv>()`. Any Hono-compatible app works at\n * runtime — the loader checks for `.fetch()` and `.routes` rather than an\n * `instanceof`, because a project and the server can hold two copies of hono.\n */\nexport type FunctionApp = Hono<HonoEnv>;\n\n/**\n * Functions as values: the URL segment each one answers at, to its app.\n *\n * `functions: { \"send-invoice\": sendInvoice }` serves `sendInvoice` at\n * `/api/functions/send-invoice` — the same thing a file named\n * `functions/send-invoice.ts` does. {@link loadFunctions} returns exactly this\n * shape, so `functions: await loadFunctions(dir)` and `functionsDir: dir` serve\n * the same routes.\n */\nexport type FunctionsRecord = Record<string, FunctionApp>;\n\n/** How a directory of modules is read. Shared by the function and cron loaders. */\nexport interface LoadDirectoryOptions {\n /**\n * How a module file is imported. Defaults to the runtime's own `import()`;\n * a caller with its own loader hooks (tsx, a test runner's sandbox) passes\n * `url => import(url)` so they apply.\n */\n importModule?: ModuleImporter;\n /**\n * Called once for every file the loader saw and did **not** load, as\n * `<file> (<reason>)`. The files are skipped either way — one malformed\n * file must not take the others with it — and logged; this is how a caller\n * reports them anywhere else.\n */\n onSkip?: (problem: string) => void;\n}\n\n/**\n * Load the custom functions in a directory, as values.\n *\n * Each file default-exports a Hono app — `defineFunction(...)` — or a factory\n * returning one. The filename without its extension is the record key, which\n * is the URL segment it is served at: `functions/send-invoice.ts` →\n * `{ \"send-invoice\": app }`. Only the top level is read.\n *\n * Never throws for a bad file: it is skipped, logged, and passed to\n * `options.onSkip`.\n *\n * @example\n * ```ts\n * const functions = await loadFunctions(path.resolve(\"functions\"));\n * const backend = await createRebaseBackend({ database, functions, provision: true });\n * ```\n */\nexport async function loadFunctions(directory: string, options: LoadDirectoryOptions = {}): Promise<FunctionsRecord> {\n const { functions, problems } = await readFunctionsDirectory(directory, options.importModule);\n if (options.onSkip) for (const problem of problems) options.onSkip(problem);\n return functions;\n}\n\n/** A loaded function, by name. The shape {@link createFunctionRoutes} mounts. */\nexport interface LoadedFunction {\n /** Endpoint name derived from filename (e.g., \"send-invoice\") */\n name: string;\n /** The Hono sub-app to mount */\n app: FunctionApp;\n}\n\n/** What a directory of function files produced: what mounted, and what did not. */\nexport interface LoadedFunctions {\n /** The functions that will be served. */\n functions: LoadedFunction[];\n /**\n * One entry per file the loader saw and did **not** mount, each already\n * phrased as `<name> (<reason>)`.\n */\n problems: string[];\n}\n\n/** A record of functions as the list {@link createFunctionRoutes} mounts, in record order. */\nexport function functionList(functions: FunctionsRecord): LoadedFunction[] {\n return Object.entries(functions).map(([name, app]) => ({ name, app }));\n}\n\n/**\n * Extensions the bundler compiles (or a developer might reasonably write) that\n * this loader cannot import. `rebase build` globs `functions/**\\/*.ts`, so the\n * build's idea of \"a function file\" is strictly wider than the runtime's — the\n * worst direction for a mismatch to go. Anything listed here is reported as a\n * problem instead of vanishing; non-code files (`.md`, `.json`, `.txt`) stay\n * silent, because a README next to your functions is not a mistake.\n */\nconst UNSUPPORTED_CODE_EXTENSIONS = [\".mts\", \".cts\", \".tsx\", \".jsx\", \".mjs\", \".cjs\"];\n\n/**\n * @deprecated Use `loadFunctions` instead, which returns the functions as a\n * record keyed by name. Will be removed in a future release.\n */\nexport async function loadFunctionsFromDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunction[]> {\n warnDeprecated(\n \"server.loadFunctionsFromDirectory\",\n \"`loadFunctionsFromDirectory` is deprecated; use `loadFunctions(dir)`, which returns `{ [name]: app }`.\"\n );\n return functionList((await readFunctionsDirectory(directory, importModule)).functions);\n}\n\n/**\n * @deprecated Use `loadFunctions` instead, with `onSkip` to collect the files\n * it skipped. Will be removed in a future release.\n */\nexport async function loadFunctionsWithDiagnostics(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<LoadedFunctions> {\n warnDeprecated(\n \"server.loadFunctionsWithDiagnostics\",\n \"`loadFunctionsWithDiagnostics` is deprecated; use `loadFunctions(dir, { onSkip })`, which returns `{ [name]: app }`.\"\n );\n const { functions, problems } = await readFunctionsDirectory(directory, importModule);\n return { functions: functionList(functions), problems };\n}\n\n/**\n * Read a directory of function files: what loaded, keyed by name, and one\n * `<file> (<reason>)` per file that did not.\n *\n * Never throws: a single malformed function must not crash server boot. The\n * backend mounts the router regardless and surfaces the count of skipped files\n * on the listing endpoint.\n */\nexport async function readFunctionsDirectory(\n directory: string,\n importModule: ModuleImporter = nativeDynamicImport\n): Promise<{ functions: FunctionsRecord; problems: string[] }> {\n const functions: FunctionsRecord = {};\n // Aggregate problem files so a broken function surfaces as one loud\n // summary line, not just a warning buried per-file. We still don't throw:\n // a single malformed function must not crash server boot.\n const problems: string[] = [];\n\n if (!fs.existsSync(directory)) {\n return { functions, problems };\n }\n\n // `withFileTypes` so a directory entry is a *reported* skip rather than a\n // filter miss. `readdirSync(dir)` returned bare names, and a subdirectory\n // simply failed the `.ts`/`.js` test — so `functions/admin/users.ts` was\n // compiled by `rebase build`, shipped in the bundle, and then dropped at\n // boot without a single log line.\n const entries = fs.readdirSync(directory, { withFileTypes: true });\n for (const entry of entries) {\n const file = entry.name;\n\n if (entry.isDirectory()) {\n // Dot-directories are tooling (`.git`, `.turbo`), not intent.\n if (file.startsWith(\".\") || file === \"node_modules\") continue;\n logger.warn(\n `[functions] ${file}/: subdirectory ignored. Functions are loaded from the top level of ` +\n `${directory} only, so nothing under ${file}/ is served. Move the file up (or flatten the ` +\n \"name: `admin/users.ts` → `admin-users.ts`).\"\n );\n problems.push(`${file}/ (subdirectory — functions are not loaded recursively)`);\n continue;\n }\n\n const extension = path.extname(file);\n if (\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n UNSUPPORTED_CODE_EXTENSIONS.includes(extension)\n ) {\n logger.warn(\n `[functions] ${file}: ${extension} files are not loaded. Rename it to .ts (or .js) to serve it.`\n );\n problems.push(`${file} (unsupported extension ${extension})`);\n continue;\n }\n\n if (\n (file.endsWith(\".ts\") || file.endsWith(\".js\")) &&\n // Dotfiles: notably macOS bsdtar AppleDouble sidecars (`._foo.ts`),\n // binary blobs that cannot be imported.\n !file.startsWith(\".\") &&\n !file.includes(\".test.\") &&\n !file.endsWith(\".d.ts\") &&\n file !== \"index.ts\" &&\n file !== \"index.js\"\n ) {\n const filePath = path.join(directory, file);\n try {\n const fileUrl = pathToFileURL(filePath).href;\n\n const mod = await importModule(fileUrl);\n\n const exported = mod.default;\n\n if (!exported) {\n logger.warn(`[functions] ${file}: no default export. Skipping.`);\n problems.push(`${file} (no default export)`);\n continue;\n }\n\n // Accept a Hono instance — use duck-typing to handle different\n // Hono versions which may not share the same prototype.\n const app = isHonoLike(exported)\n ? exported as FunctionApp\n // Also accept a factory function that returns a Hono instance\n : typeof exported === \"function\" ? exported() : undefined;\n if (isHonoLike(app)) {\n const name = path.basename(file, path.extname(file));\n // `send.ts` and `send.js` side by side name one URL. A\n // record holds one app per name, so the second is reported\n // rather than silently replacing the first.\n if (Object.hasOwn(functions, name)) {\n logger.warn(`[functions] ${file}: another file is already served as '${name}'. Skipping.`);\n problems.push(`${file} (another file is already served as '${name}')`);\n continue;\n }\n functions[name] = app as FunctionApp;\n logger.debug(`⚡ Loaded function route: ${name}`);\n continue;\n }\n\n // Provide actionable diagnostics\n const exportType = typeof exported;\n const keys = exported && typeof exported === \"object\"\n ? Object.getOwnPropertyNames(Object.getPrototypeOf(exported)).slice(0, 10).join(\", \")\n : \"N/A\";\n logger.warn(\n `[functions] ${file}: default export is not a Hono app or factory. Skipping.\\n` +\n ` export type: ${exportType}${exported?.constructor?.name ? ` (${exported.constructor.name})` : \"\"}\\n` +\n ` prototype methods: ${keys}\\n` +\n \" Hint: ensure the function exports a Hono app created with the same hono version as the server.\\n\" +\n \" Author with `defineFunction(...)` from @rebasepro/server for a typed, checked contract.\\n\" +\n \" The loader checks for .fetch() and .routes — any Hono-compatible app will work.\"\n );\n problems.push(`${file} (not a Hono app or factory)`);\n } catch (err: unknown) {\n const message =\n err instanceof Error ? err.message : String(err);\n logger.error(`[functions] Failed to load ${file}: ${message}`);\n problems.push(describeLoadFailure(file, err));\n }\n }\n }\n\n if (problems.length > 0) {\n // Advice keyed to what actually went wrong. The old line said\n // \"author them with `defineFunction(...)`\" for every failure — which is\n // the fix for a wrong export shape and no help at all for the commonest\n // one: a file that threw while being imported, usually a module-scope\n // `process.env.X` read that came back undefined. Told to reach for\n // `defineFunction`, an author rewrites a file whose export was already\n // correct and gets the same failure.\n const advice: string[] = [];\n if (problems.some(p => p.includes(\"(syntax error\"))) {\n advice.push(SYNTAX_ERROR_ADVICE);\n }\n if (problems.some(p => p.includes(\"(threw:\"))) {\n advice.push(\n \" A file that threw ran at import time. Read configuration *inside* a handler \" +\n \"(`requireEnv(c, \\\"STRIPE_KEY\\\")`, or a lazily-built client) rather than at module \" +\n \"scope, where one undefined variable takes the whole file down before any route exists.\"\n );\n }\n if (problems.some(p => p.includes(\"(no default export)\"))) {\n advice.push(\n \" A file with no default export exports nothing the loader can mount. \" +\n \"`export default defineFunction((app) => { … })`.\"\n );\n }\n if (problems.some(p => p.includes(\"(not a Hono app or factory)\"))) {\n advice.push(\n \" A default export the loader did not recognise is usually two copies of hono. \" +\n \"Author with `defineFunction(...)` from @rebasepro/server/functions, which uses the \" +\n \"server's own copy.\"\n );\n }\n logger.warn(\n `[functions] ${problems.length} function file(s) were skipped and will NOT be served:\\n` +\n problems.map((p) => ` - ${p}`).join(\"\\n\") + \"\\n\" +\n advice.join(\"\\n\")\n );\n }\n\n return { functions, problems };\n}\n\n/**\n * Duck-type check for Hono apps.\n * We avoid `instanceof Hono` because different Hono versions\n * installed in the user's project vs. our dependencies will\n * not share the same prototype, causing false negatives.\n */\nfunction isHonoLike(obj: unknown): boolean {\n if (!obj || typeof obj !== \"object\") return false;\n // Hono instances always have .fetch() and .routes\n const record = obj as Record<string, unknown>;\n return (\n typeof record.fetch === \"function\" &&\n Array.isArray(record.routes)\n );\n}\n"],"mappings":"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"}
|
|
@@ -3,7 +3,7 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import { t as ApiError } from "./errors-
|
|
6
|
+
import { t as ApiError } from "./errors-OMWkKSH-.js";
|
|
7
7
|
import { t as holdsAdminRole } from "./admin-roles-DI9RsgN3.js";
|
|
8
8
|
import { Hono } from "hono";
|
|
9
9
|
//#region src/functions/context.ts
|
|
@@ -258,4 +258,4 @@ function createFunctionRoutes(functions, problems = [], mountPath = "/functions"
|
|
|
258
258
|
//#endregion
|
|
259
259
|
export { getDriver as a, getUser as c, identityResolved as d, isAdmin as f, getApiKey as i, getUserId as l, requireDriver as m, function_routes_exports as n, getRequestId as o, isAuthenticated as p, requireRole as r, getRoles as s, createFunctionRoutes as t, hasRole as u };
|
|
260
260
|
|
|
261
|
-
//# sourceMappingURL=function-routes-
|
|
261
|
+
//# sourceMappingURL=function-routes-RxrCHNuM.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"function-routes-C0emkHtX.js","names":[],"sources":["../src/functions/context.ts","../src/functions/guards.ts","../src/functions/function-routes.ts"],"sourcesContent":["/**\n * Reading the request context from inside a custom function.\n *\n * The functions router resolves the caller's identity before any handler runs\n * and leaves the result on the Hono context. Getting it back out used to be the\n * user's problem, and the shape made that worse than it sounds: `HonoEnv`\n * types `user` as `AuthResult`, a union that includes `boolean`, `null` and an\n * index signature, because the same slot is filled by four different middlewares\n * — JWT, service key, API key, and a user-supplied validator that may return\n * `true`. Every example in the documentation therefore opened with\n *\n * const user = c.get(\"user\") as { uid: string; roles?: string[] } | undefined;\n *\n * and an assertion in a security-relevant position is exactly the kind of line\n * that gets copied once and then never re-examined. It is also wrong in one\n * case that occurs in practice: a custom validator returning `true` stores\n * `{ uid: \"default\", roles: [] }`, which the assertion above types as having a\n * `uid` — true here, but nothing checks it.\n *\n * These accessors do the narrowing once, in the framework, where it can be\n * tested. They are also **runtime-neutral by construction** — no crypto, no\n * token parsing, no I/O, nothing but property reads on an object another\n * middleware already populated. That is what lets them live in\n * `@rebasepro/server/functions` and run unchanged on a host that has no Node\n * built-ins.\n *\n * @module\n */\nimport type { Context } from \"hono\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport type { ApiKeyMasked } from \"../auth/api-keys/api-key-types\";\nimport { heldScopesGrant, holdsAdminRole } from \"../auth/admin-roles\";\n\n/**\n * The caller, as a custom function sees them.\n *\n * A narrowed view of whatever the auth middleware resolved: `uid` and `roles`\n * are guaranteed, and the index signature keeps any extra claims the token or\n * the adapter carried (`email`, `org_id`, anything a custom validator added)\n * reachable without a cast.\n */\nexport interface FunctionUser {\n /** Stable id of the caller. `\"service\"` for service-key and API-key callers. */\n uid: string;\n /** Roles as resolved for this request. Never `undefined` — an empty array instead. */\n roles: string[];\n /** Present when the identity carried one. Not every auth method does. */\n email?: string;\n /** Any further claim the token, adapter or validator supplied. */\n [claim: string]: unknown;\n}\n\n/** Anything with a Hono-style `.get`, so these work on any `Context` shape. */\ntype CtxLike = Context<HonoEnv> | Context;\n\nfunction read<K extends keyof HonoEnv[\"Variables\"]>(\n c: CtxLike,\n key: K\n): HonoEnv[\"Variables\"][K] | undefined {\n // `c.get` is typed against the app's own Env, which a handler mounted\n // through `app.route()` may have declared more loosely. The cast is\n // confined to this one function rather than repeated at every call site.\n return (c as Context<HonoEnv>).get(key);\n}\n\n/**\n * The authenticated caller, or `undefined` for an anonymous request.\n *\n * **`undefined` is not a permission decision.** The functions router mounts its\n * auth middleware with `requireAuth: false` on purpose — a webhook receiver has\n * no token to send — so an anonymous caller reaches the handler and reads\n * `undefined` here while the handler runs on regardless. Use {@link requireAuth}\n * (or a `!user` branch that returns 401) to make it a decision.\n *\n * A caller who presented a *bad* token never gets this far: both auth\n * middlewares reject an unverifiable token with 401 before the router is\n * reached, precisely so an expired session cannot be silently downgraded to an\n * anonymous one.\n */\nexport function getUser(c: CtxLike): FunctionUser | undefined {\n const raw = read(c, \"user\");\n if (!raw || typeof raw !== \"object\") return undefined;\n\n const record = raw as Record<string, unknown>;\n const uid = typeof record.uid === \"string\" ? record.uid : undefined;\n if (uid === undefined) return undefined;\n\n const roles = Array.isArray(record.roles)\n ? record.roles.filter((role): role is string => typeof role === \"string\")\n : [];\n\n return { ...record,\n uid,\n roles } as FunctionUser;\n}\n\n/** The caller's id, or `undefined` when nobody is signed in. */\nexport function getUserId(c: CtxLike): string | undefined {\n return getUser(c)?.uid;\n}\n\n/** The caller's roles. Empty for an anonymous request — never `undefined`. */\nexport function getRoles(c: CtxLike): string[] {\n return getUser(c)?.roles ?? [];\n}\n\n/**\n * Whether the caller holds **any** of the named roles.\n *\n * Any rather than all, because that is what a route guard means by a list of\n * roles; require several by calling this more than once.\n */\nexport function hasRole(c: CtxLike, ...roles: string[]): boolean {\n if (roles.length === 0) return false;\n const held = new Set(getRoles(c));\n return roles.some(role => held.has(role));\n}\n\n/** Whether the caller holds the `admin` role, which holds every scope. */\nexport function isAdmin(c: CtxLike): boolean {\n return holdsAdminRole(getRoles(c));\n}\n\n/**\n * Everything the caller may do, as `resource:action[:target]` scope strings —\n * the data plane, the app's own `auth.scopes`, and whatever their roles (or\n * their API key) hold. Empty for an anonymous request.\n *\n * Resolved by the framework before the handler runs; `undefined` only when no\n * Rebase auth middleware ran (see {@link identityResolved}).\n */\nexport function getScopes(c: CtxLike): string[] | undefined {\n return read(c, \"scopes\");\n}\n\n/**\n * Whether the caller holds `scope`, on `target` when one is given.\n *\n * For an app scope declared under `auth.scopes` — `project:deploy` — a\n * signed-in person always holds it, so this narrows only API keys and tokens.\n * Whether the *person* may deploy *this* project is still the handler's to\n * decide.\n */\nexport function hasScope(c: CtxLike, scope: string, target?: string): boolean {\n return heldScopesGrant(getScopes(c) ?? [], scope, target);\n}\n\n/** Whether the request carries an identity at all. */\nexport function isAuthenticated(c: CtxLike): boolean {\n return getUser(c) !== undefined;\n}\n\n/**\n * The request-scoped data driver: reads and writes run as **the caller**, with\n * your row-level security policies evaluated against their identity.\n *\n * This is the accessor to reach for when a function serves user-facing data.\n * `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs\n * as `{ uid: \"service\", roles: [\"admin\"] }` for every caller alike, which is\n * correct for trusted background work and wrong for a request.\n *\n * `undefined` only when no Rebase auth middleware ran (see\n * {@link identityResolved}); inside a function mounted by the framework it is\n * always present, anonymous requests included — they get an anon-scoped driver\n * so policies still have an identity to evaluate.\n */\nexport function getDriver(c: CtxLike): DataDriver | undefined {\n return read(c, \"driver\");\n}\n\n/**\n * {@link getDriver}, but throws instead of handing back `undefined`.\n *\n * For the common case where a handler cannot proceed without it and would\n * otherwise write `c.get(\"driver\")!` — an assertion that turns a wiring problem\n * into `Cannot read properties of undefined (reading 'fetchCollection')` twenty\n * lines away from the cause.\n */\nexport function requireDriver(c: CtxLike): DataDriver {\n const driver = getDriver(c);\n if (!driver) {\n throw new Error(\n \"No request-scoped driver on this context. A Rebase auth middleware \" +\n \"populates it before any custom function runs, so this means the handler \" +\n \"was mounted outside the functions router — e.g. added to your own Hono \" +\n \"app directly. Mount it from the functions directory, or use \" +\n \"`rebase.dataAsAdmin` if the work is genuinely service-scoped.\"\n );\n }\n return driver;\n}\n\n/**\n * The API key this request authenticated with, masked, or `undefined` when it\n * did not use one.\n *\n * Useful for attribution and for per-key behaviour. The permission check itself\n * has already happened — reaching a handler means the key was allowed to.\n */\nexport function getApiKey(c: CtxLike): ApiKeyMasked | undefined {\n return read(c, \"apiKey\");\n}\n\n/**\n * The correlation id for this request — generated, or taken from an inbound\n * `X-Request-ID`.\n *\n * Log it. It is the only thing that ties a line written inside a function to\n * the framework's own lines for the same request.\n */\nexport function getRequestId(c: CtxLike): string | undefined {\n return read(c, \"requestId\");\n}\n\n/**\n * The signal aborted when this request outlasts the functions timeout and is\n * answered `504 FUNCTION_TIMEOUT`. Pass it to `fetch` and anything else that\n * takes one, so the work stops with the request instead of running on after\n * the caller was told it failed.\n *\n * Never aborts when the timeout is off, or outside the functions router.\n */\nexport function getSignal(c: CtxLike): AbortSignal {\n return read(c, \"functionSignal\") ?? new AbortController().signal;\n}\n\n/**\n * Whether a Rebase auth middleware has run on this request.\n *\n * Both middlewares populate `driver` for *every* outcome, anonymous included,\n * and populate `user` whenever there is one. So \"neither is set\" does not mean\n * \"anonymous\" — it means nothing resolved the identity, and treating that as\n * anonymous is the dangerous reading. The guards use this to tell a genuinely\n * anonymous caller (401) from a misconfigured mount (500), because answering\n * 401 to the second sends whoever is debugging it to look at the token.\n */\nexport function identityResolved(c: CtxLike): boolean {\n return read(c, \"user\") !== undefined || read(c, \"driver\") !== undefined;\n}\n","/**\n * Route guards for custom functions.\n *\n * These decide access from the identity the platform already resolved. They do\n * **not** verify tokens, and that division is the point rather than a\n * limitation:\n *\n * - Verifying a token needs a signing key, constant-time comparison and a\n * revocation lookup. That is host work, it belongs to the process that holds\n * the secret, and it is the part of the stack that cannot be made\n * runtime-neutral without rewriting it against WebCrypto.\n * - Deciding whether *this* caller may call *this* route is application work.\n * It needs nothing but the resolved identity, so it costs nothing to make it\n * portable — and it is the half that lives in user code.\n *\n * Splitting there is what lets a function file compile and run unchanged on a\n * host with no Node built-ins, and it is why these live in\n * `@rebasepro/server/functions` while `verifyAccessToken` does not.\n *\n * **Inside the functions router these are equivalent to the guards exported\n * from the package root.** Both auth middlewares resolve the identity before\n * any handler runs: a valid credential populates `user`, an invalid one is\n * rejected with 401 by the middleware itself, and a missing one leaves `user`\n * unset. So the root `requireAuth`'s token-parsing branch is unreachable from a\n * function, and removing it changes no outcome. The one difference is a handler\n * mounted **outside** the framework's router, where no middleware ran: the root\n * guard would parse the `Authorization` header itself, and these refuse the\n * request with a 500 that names the wiring problem. Fail-closed, and legible.\n *\n * @module\n */\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { getUser, isAdmin, getRoles, identityResolved, hasScope } from \"./context\";\nimport { scopeGuardProblem } from \"../auth/admin-roles\";\n\n/**\n * The answer to \"a guard ran, but no middleware had resolved anything\".\n *\n * Deliberately a 500 and not a 401. A 401 tells the caller their credential is\n * the problem, and here the caller's credential was never looked at — sending\n * them to check their token is sending them to the one place the answer is not.\n */\nfunction unresolvedIdentity(): { error: { message: string; code: string } } {\n return {\n error: {\n message:\n \"This route's identity was never resolved: no Rebase auth middleware ran \" +\n \"before the guard. A function loaded from the functions directory always \" +\n \"has one. This usually means the Hono app was mounted onto your own \" +\n \"server directly, bypassing the functions router.\",\n code: \"AUTH_MIDDLEWARE_MISSING\"\n }\n };\n}\n\n/**\n * Reject anonymous callers with 401.\n *\n * Put it in the route's own middleware slot rather than `app.use(\"/*\", …)`:\n * `use()` covers only the routes declared *below* it, so a route appended later\n * — by you, months from now, at the bottom of the file — is silently\n * unprotected. The per-route form cannot drift that way.\n *\n * @example\n * ```ts\n * app.post(\"/\", requireAuth, async (c) => {\n * const user = getUser(c)!; // guaranteed by the guard\n * return c.json({ uid: user.uid });\n * });\n * ```\n */\nexport const requireAuth: MiddlewareHandler<HonoEnv> = async (c, next) => {\n if (getUser(c)) return next();\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n};\n\n/**\n * Reject callers without the `admin` role with 403.\n *\n * Must come **after** {@link requireAuth}: on its own it answers 401 for an\n * anonymous caller, which is right, but pairing them keeps the two failures\n * distinguishable — 401 \"who are you\", 403 \"not you\".\n *\n * Prefer {@link requireScope} for anything an app declares a scope for: a\n * scope can be granted to a narrower role and to a key, and `admin` cannot.\n */\nexport const requireAdmin: MiddlewareHandler<HonoEnv> = async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!isAdmin(c)) {\n return c.json({\n error: {\n message: \"Admin privileges required for this operation\",\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n};\n\n/**\n * Reject callers holding none of the named roles with 403.\n *\n * Any of them, not all — require several by chaining the guard twice. Naming no\n * role at all is a programming error and throws at module load rather than at\n * request time, because `requireRole()` with an empty list would otherwise read\n * as a guard while admitting everyone.\n *\n * @example\n * ```ts\n * app.post(\"/publish\", requireAuth, requireRole(\"editor\", \"admin\"), handler);\n * ```\n */\nexport function requireRole(...roles: string[]): MiddlewareHandler<HonoEnv> {\n if (roles.length === 0) {\n throw new Error(\n \"requireRole() needs at least one role. An empty list would admit every \" +\n \"signed-in caller while reading as a restriction.\"\n );\n }\n\n const allowed = new Set(roles);\n return async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!getRoles(c).some(role => allowed.has(role))) {\n return c.json({\n error: {\n message: `This operation requires one of these roles: ${roles.join(\", \")}`,\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n };\n}\n\n/**\n * Reject callers who do not hold `scope` with 403.\n *\n * `scope` is a built-in scope or one the app declares under `auth.scopes` on\n * the users collection. A name nobody could hold — not a scope name, a typo of\n * a built-in one (`data:raed`) — throws as the function loads; an app scope\n * the backend does not declare is answered `500 SCOPE_NOT_DECLARED`. `target` narrows it to one resource — a key holding\n * `project:deploy:p1` passes `requireScope(\"project:deploy\", c => c.req.param(\"project\"))`\n * for `p1` only — and may be read from the request.\n *\n * A signed-in person holds every app scope, so for a person this is no\n * authorization at all: it narrows API keys and tokens. Decide whether the\n * person may act in the handler, as you would without it.\n *\n * @example\n * ```ts\n * app.post(\"/deploy/:project\", requireAuth, requireScope(\"project:deploy\", c => c.req.param(\"project\")), handler);\n * ```\n */\nexport function requireScope(\n scope: string,\n target?: string | ((c: Context<HonoEnv>) => string | undefined)\n): MiddlewareHandler<HonoEnv> {\n // The root guard's rule, from the same function: refused as the route is\n // registered — which, in a function file, is as the backend loads it.\n const problem = scopeGuardProblem(scope, typeof target === \"string\" ? target : undefined);\n if (problem) throw new Error(problem);\n return async (c, next) => {\n if (!getUser(c)) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n // An app scope is declared per backend; the one serving the function\n // says which. One it does not declare nobody holds.\n const declared = c.get(\"services\")?.access.model.scopes;\n const undeclared = declared ? scopeGuardProblem(scope, undefined, Object.keys(declared)) : null;\n if (undeclared) return c.json({ error: { message: undeclared, code: \"SCOPE_NOT_DECLARED\" } }, 500);\n const resolvedTarget = typeof target === \"function\" ? target(c) : target;\n if (!hasScope(c, scope, resolvedTarget)) {\n const wanted = resolvedTarget !== undefined ? `${scope}:${resolvedTarget}` : scope;\n return c.json({\n error: {\n message: `This credential does not hold the \"${wanted}\" scope.`,\n code: \"SCOPE_MISSING\",\n details: { requiredScope: wanted }\n }\n }, 403);\n }\n return next();\n };\n}\n","import { Hono } from \"hono\";\nimport { HonoEnv } from \"../api/types\";\nimport { ApiError } from \"../api/errors\";\nimport { LoadedFunction } from \"./function-loader\";\nimport { requireAuth } from \"./guards\";\n\n/** The file a loader problem names, without its extension: `broken.ts (threw: …)` → `broken`. */\nfunction problemName(problem: string): string {\n return problem.split(\" \")[0].replace(/\\/$/, \"\").replace(/\\.[cm]?[jt]s$/, \"\");\n}\n\n/**\n * Mount all loaded function routes under a single Hono router.\n *\n * Each function is mounted at `/<function-name>`, preserving\n * whatever HTTP methods and middleware the Hono sub-app defines.\n *\n * @param functions What loaded. May be empty — the router still mounts, so\n * \"no functions are served\" answers 200 with an empty list instead of 404.\n * @param problems The files the loader saw and could not serve, as\n * `\"<file> (<reason>)\"`. The listing reports a count and a pointer to the log,\n * not the reasons: those carry import errors, and the listing is one guard\n * away from anyone. The unmatched-route handler uses the *names* to answer the\n * one question a 404 on a function nobody can find should answer — \"there is a\n * file for this and it did not load\" — which is the difference between a typo\n * and a broken deploy, and the loader was the only thing that knew.\n */\nexport function createFunctionRoutes(\n functions: LoadedFunction[],\n problems: string[] = [],\n /**\n * Where this router is mounted, so the listing can report a path a caller\n * can actually request. It used to hardcode `/functions/<name>`, which is\n * wrong under every `basePath` including the default `/api`. The\n * `mountPath` a functions group records for the request wins over it.\n */\n mountPath = \"/functions\"\n): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n const skipped = problems.length;\n\n // Listing endpoint: GET / → list available functions.\n //\n // Functions themselves stay anonymous-callable by default — a webhook\n // receiver has to be — but the index of them does not: it is an inventory\n // of every custom endpoint, for whoever asks. `requireAuth` admits any\n // resolved identity (a signed-in user, an API key, the service key), so\n // `rebase doctor` and `rebase cloud debug` keep their answer; the latter\n // already reads a 401 here as \"mounted\".\n router.get(\"/\", requireAuth, (c) => {\n const at = c.get(\"mountPath\") ?? mountPath;\n return c.json({\n functions: functions.map((fn) => ({\n name: fn.name,\n endpoint: `${at}/${fn.name}`\n })),\n ...(skipped > 0 && {\n skipped,\n note: `${skipped} function file(s) failed to load and are NOT served — see the server log for the reason.`\n })\n });\n });\n\n for (const fn of functions) {\n router.route(`/${fn.name}`, fn.app);\n }\n\n // A name that matches nothing, answered in the envelope.\n //\n // Registered last so every real route wins it. Without it, a typo'd\n // function name — the single most likely 404 a developer meets on this\n // surface — fell through to Hono's default `404 Not Found` as `text/plain`,\n // and through the SDK arrived as `RebaseApiError { code: undefined }`, so\n // the documented `e.code === \"FUNCTION_NOT_FOUND\"` branch never ran.\n //\n // What the message may say depends on who is asking. The mounted names are\n // an inventory of every custom endpoint, which is exactly what the listing\n // above requires an identity to see — so an anonymous caller is told their\n // name is unknown and nothing more, and a resolved caller gets the list\n // that turns the 404 into a fix.\n const mounted = new Set(functions.map(fn => fn.name));\n const failedToLoad = new Map(problems.map(p => [problemName(p), p.split(\" \")[0]]));\n\n router.all(\"/:name{.*}\", (c): never => {\n const requested = (c.req.param(\"name\") ?? \"\").split(\"/\").filter(Boolean);\n const name = requested[0] ?? \"\";\n const rest = requested.slice(1).join(\"/\");\n const identified = Boolean(c.get(\"user\"));\n\n const refuse = (message: string): never => {\n throw new ApiError(404, \"FUNCTION_NOT_FOUND\", message, { function: name }, true);\n };\n\n if (!name) {\n refuse(`No function in the request path. Expected ${c.get(\"mountPath\") ?? mountPath}/<function>.`);\n }\n if (mounted.has(name)) {\n refuse(\n `The function '${name}' is served, and has no ${c.req.method} route at '/${rest}'. ` +\n \"The path after the function name is routed by the function's own Hono app.\"\n );\n }\n // The file name is the same inventory, a file at a time — and it\n // names a source file besides — so it is told to an identified caller\n // only. An anonymous one gets exactly the answer a name that matches\n // nothing gets.\n const file = failedToLoad.get(name);\n if (file && identified) {\n refuse(\n `The function '${name}' is not served: '${file}' failed to load. ` +\n \"The server log records why, at boot.\"\n );\n }\n return refuse(\n `No function named '${name}' on this backend.` +\n (identified\n ? (mounted.size > 0\n ? ` This backend serves: ${[...mounted].sort().join(\", \")}.`\n : \" This backend serves no functions.\")\n : \"\") +\n (skipped > 0 && identified\n ? ` ${skipped} function file(s) failed to load and are not served — see the server log.`\n : \"\")\n );\n });\n\n return router;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"function-routes-RxrCHNuM.js","names":[],"sources":["../src/functions/context.ts","../src/functions/guards.ts","../src/functions/function-routes.ts"],"sourcesContent":["/**\n * Reading the request context from inside a custom function.\n *\n * The functions router resolves the caller's identity before any handler runs\n * and leaves the result on the Hono context. Getting it back out used to be the\n * user's problem, and the shape made that worse than it sounds: `HonoEnv`\n * types `user` as `AuthResult`, a union that includes `boolean`, `null` and an\n * index signature, because the same slot is filled by four different middlewares\n * — JWT, service key, API key, and a user-supplied validator that may return\n * `true`. Every example in the documentation therefore opened with\n *\n * const user = c.get(\"user\") as { uid: string; roles?: string[] } | undefined;\n *\n * and an assertion in a security-relevant position is exactly the kind of line\n * that gets copied once and then never re-examined. It is also wrong in one\n * case that occurs in practice: a custom validator returning `true` stores\n * `{ uid: \"default\", roles: [] }`, which the assertion above types as having a\n * `uid` — true here, but nothing checks it.\n *\n * These accessors do the narrowing once, in the framework, where it can be\n * tested. They are also **runtime-neutral by construction** — no crypto, no\n * token parsing, no I/O, nothing but property reads on an object another\n * middleware already populated. That is what lets them live in\n * `@rebasepro/server/functions` and run unchanged on a host that has no Node\n * built-ins.\n *\n * @module\n */\nimport type { Context } from \"hono\";\nimport type { DataDriver } from \"@rebasepro/types\";\nimport type { HonoEnv } from \"../api/types\";\nimport type { ApiKeyMasked } from \"../auth/api-keys/api-key-types\";\nimport { heldScopesGrant, holdsAdminRole } from \"../auth/admin-roles\";\n\n/**\n * The caller, as a custom function sees them.\n *\n * A narrowed view of whatever the auth middleware resolved: `uid` and `roles`\n * are guaranteed, and the index signature keeps any extra claims the token or\n * the adapter carried (`email`, `org_id`, anything a custom validator added)\n * reachable without a cast.\n */\nexport interface FunctionUser {\n /** Stable id of the caller. `\"service\"` for service-key and API-key callers. */\n uid: string;\n /** Roles as resolved for this request. Never `undefined` — an empty array instead. */\n roles: string[];\n /** Present when the identity carried one. Not every auth method does. */\n email?: string;\n /** Any further claim the token, adapter or validator supplied. */\n [claim: string]: unknown;\n}\n\n/** Anything with a Hono-style `.get`, so these work on any `Context` shape. */\ntype CtxLike = Context<HonoEnv> | Context;\n\nfunction read<K extends keyof HonoEnv[\"Variables\"]>(\n c: CtxLike,\n key: K\n): HonoEnv[\"Variables\"][K] | undefined {\n // `c.get` is typed against the app's own Env, which a handler mounted\n // through `app.route()` may have declared more loosely. The cast is\n // confined to this one function rather than repeated at every call site.\n return (c as Context<HonoEnv>).get(key);\n}\n\n/**\n * The authenticated caller, or `undefined` for an anonymous request.\n *\n * **`undefined` is not a permission decision.** The functions router mounts its\n * auth middleware with `requireAuth: false` on purpose — a webhook receiver has\n * no token to send — so an anonymous caller reaches the handler and reads\n * `undefined` here while the handler runs on regardless. Use {@link requireAuth}\n * (or a `!user` branch that returns 401) to make it a decision.\n *\n * A caller who presented a *bad* token never gets this far: both auth\n * middlewares reject an unverifiable token with 401 before the router is\n * reached, precisely so an expired session cannot be silently downgraded to an\n * anonymous one.\n */\nexport function getUser(c: CtxLike): FunctionUser | undefined {\n const raw = read(c, \"user\");\n if (!raw || typeof raw !== \"object\") return undefined;\n\n const record = raw as Record<string, unknown>;\n const uid = typeof record.uid === \"string\" ? record.uid : undefined;\n if (uid === undefined) return undefined;\n\n const roles = Array.isArray(record.roles)\n ? record.roles.filter((role): role is string => typeof role === \"string\")\n : [];\n\n return { ...record,\n uid,\n roles } as FunctionUser;\n}\n\n/** The caller's id, or `undefined` when nobody is signed in. */\nexport function getUserId(c: CtxLike): string | undefined {\n return getUser(c)?.uid;\n}\n\n/** The caller's roles. Empty for an anonymous request — never `undefined`. */\nexport function getRoles(c: CtxLike): string[] {\n return getUser(c)?.roles ?? [];\n}\n\n/**\n * Whether the caller holds **any** of the named roles.\n *\n * Any rather than all, because that is what a route guard means by a list of\n * roles; require several by calling this more than once.\n */\nexport function hasRole(c: CtxLike, ...roles: string[]): boolean {\n if (roles.length === 0) return false;\n const held = new Set(getRoles(c));\n return roles.some(role => held.has(role));\n}\n\n/** Whether the caller holds the `admin` role, which holds every scope. */\nexport function isAdmin(c: CtxLike): boolean {\n return holdsAdminRole(getRoles(c));\n}\n\n/**\n * Everything the caller may do, as `resource:action[:target]` scope strings —\n * the data plane, the app's own `auth.scopes`, and whatever their roles (or\n * their API key) hold. Empty for an anonymous request.\n *\n * Resolved by the framework before the handler runs; `undefined` only when no\n * Rebase auth middleware ran (see {@link identityResolved}).\n */\nexport function getScopes(c: CtxLike): string[] | undefined {\n return read(c, \"scopes\");\n}\n\n/**\n * Whether the caller holds `scope`, on `target` when one is given.\n *\n * For an app scope declared under `auth.scopes` — `project:deploy` — a\n * signed-in person always holds it, so this narrows only API keys and tokens.\n * Whether the *person* may deploy *this* project is still the handler's to\n * decide.\n */\nexport function hasScope(c: CtxLike, scope: string, target?: string): boolean {\n return heldScopesGrant(getScopes(c) ?? [], scope, target);\n}\n\n/** Whether the request carries an identity at all. */\nexport function isAuthenticated(c: CtxLike): boolean {\n return getUser(c) !== undefined;\n}\n\n/**\n * The request-scoped data driver: reads and writes run as **the caller**, with\n * your row-level security policies evaluated against their identity.\n *\n * This is the accessor to reach for when a function serves user-facing data.\n * `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs\n * as `{ uid: \"service\", roles: [\"admin\"] }` for every caller alike, which is\n * correct for trusted background work and wrong for a request.\n *\n * `undefined` only when no Rebase auth middleware ran (see\n * {@link identityResolved}); inside a function mounted by the framework it is\n * always present, anonymous requests included — they get an anon-scoped driver\n * so policies still have an identity to evaluate.\n */\nexport function getDriver(c: CtxLike): DataDriver | undefined {\n return read(c, \"driver\");\n}\n\n/**\n * {@link getDriver}, but throws instead of handing back `undefined`.\n *\n * For the common case where a handler cannot proceed without it and would\n * otherwise write `c.get(\"driver\")!` — an assertion that turns a wiring problem\n * into `Cannot read properties of undefined (reading 'fetchCollection')` twenty\n * lines away from the cause.\n */\nexport function requireDriver(c: CtxLike): DataDriver {\n const driver = getDriver(c);\n if (!driver) {\n throw new Error(\n \"No request-scoped driver on this context. A Rebase auth middleware \" +\n \"populates it before any custom function runs, so this means the handler \" +\n \"was mounted outside the functions router — e.g. added to your own Hono \" +\n \"app directly. Mount it from the functions directory, or use \" +\n \"`rebase.dataAsAdmin` if the work is genuinely service-scoped.\"\n );\n }\n return driver;\n}\n\n/**\n * The API key this request authenticated with, masked, or `undefined` when it\n * did not use one.\n *\n * Useful for attribution and for per-key behaviour. The permission check itself\n * has already happened — reaching a handler means the key was allowed to.\n */\nexport function getApiKey(c: CtxLike): ApiKeyMasked | undefined {\n return read(c, \"apiKey\");\n}\n\n/**\n * The correlation id for this request — generated, or taken from an inbound\n * `X-Request-ID`.\n *\n * Log it. It is the only thing that ties a line written inside a function to\n * the framework's own lines for the same request.\n */\nexport function getRequestId(c: CtxLike): string | undefined {\n return read(c, \"requestId\");\n}\n\n/**\n * The signal aborted when this request outlasts the functions timeout and is\n * answered `504 FUNCTION_TIMEOUT`. Pass it to `fetch` and anything else that\n * takes one, so the work stops with the request instead of running on after\n * the caller was told it failed.\n *\n * Never aborts when the timeout is off, or outside the functions router.\n */\nexport function getSignal(c: CtxLike): AbortSignal {\n return read(c, \"functionSignal\") ?? new AbortController().signal;\n}\n\n/**\n * Whether a Rebase auth middleware has run on this request.\n *\n * Both middlewares populate `driver` for *every* outcome, anonymous included,\n * and populate `user` whenever there is one. So \"neither is set\" does not mean\n * \"anonymous\" — it means nothing resolved the identity, and treating that as\n * anonymous is the dangerous reading. The guards use this to tell a genuinely\n * anonymous caller (401) from a misconfigured mount (500), because answering\n * 401 to the second sends whoever is debugging it to look at the token.\n */\nexport function identityResolved(c: CtxLike): boolean {\n return read(c, \"user\") !== undefined || read(c, \"driver\") !== undefined;\n}\n","/**\n * Route guards for custom functions.\n *\n * These decide access from the identity the platform already resolved. They do\n * **not** verify tokens, and that division is the point rather than a\n * limitation:\n *\n * - Verifying a token needs a signing key, constant-time comparison and a\n * revocation lookup. That is host work, it belongs to the process that holds\n * the secret, and it is the part of the stack that cannot be made\n * runtime-neutral without rewriting it against WebCrypto.\n * - Deciding whether *this* caller may call *this* route is application work.\n * It needs nothing but the resolved identity, so it costs nothing to make it\n * portable — and it is the half that lives in user code.\n *\n * Splitting there is what lets a function file compile and run unchanged on a\n * host with no Node built-ins, and it is why these live in\n * `@rebasepro/server/functions` while `verifyAccessToken` does not.\n *\n * **Inside the functions router these are equivalent to the guards exported\n * from the package root.** Both auth middlewares resolve the identity before\n * any handler runs: a valid credential populates `user`, an invalid one is\n * rejected with 401 by the middleware itself, and a missing one leaves `user`\n * unset. So the root `requireAuth`'s token-parsing branch is unreachable from a\n * function, and removing it changes no outcome. The one difference is a handler\n * mounted **outside** the framework's router, where no middleware ran: the root\n * guard would parse the `Authorization` header itself, and these refuse the\n * request with a 500 that names the wiring problem. Fail-closed, and legible.\n *\n * @module\n */\nimport type { Context, MiddlewareHandler } from \"hono\";\nimport type { HonoEnv } from \"../api/types\";\nimport { getUser, isAdmin, getRoles, identityResolved, hasScope } from \"./context\";\nimport { scopeGuardProblem } from \"../auth/admin-roles\";\n\n/**\n * The answer to \"a guard ran, but no middleware had resolved anything\".\n *\n * Deliberately a 500 and not a 401. A 401 tells the caller their credential is\n * the problem, and here the caller's credential was never looked at — sending\n * them to check their token is sending them to the one place the answer is not.\n */\nfunction unresolvedIdentity(): { error: { message: string; code: string } } {\n return {\n error: {\n message:\n \"This route's identity was never resolved: no Rebase auth middleware ran \" +\n \"before the guard. A function loaded from the functions directory always \" +\n \"has one. This usually means the Hono app was mounted onto your own \" +\n \"server directly, bypassing the functions router.\",\n code: \"AUTH_MIDDLEWARE_MISSING\"\n }\n };\n}\n\n/**\n * Reject anonymous callers with 401.\n *\n * Put it in the route's own middleware slot rather than `app.use(\"/*\", …)`:\n * `use()` covers only the routes declared *below* it, so a route appended later\n * — by you, months from now, at the bottom of the file — is silently\n * unprotected. The per-route form cannot drift that way.\n *\n * @example\n * ```ts\n * app.post(\"/\", requireAuth, async (c) => {\n * const user = getUser(c)!; // guaranteed by the guard\n * return c.json({ uid: user.uid });\n * });\n * ```\n */\nexport const requireAuth: MiddlewareHandler<HonoEnv> = async (c, next) => {\n if (getUser(c)) return next();\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n};\n\n/**\n * Reject callers without the `admin` role with 403.\n *\n * Must come **after** {@link requireAuth}: on its own it answers 401 for an\n * anonymous caller, which is right, but pairing them keeps the two failures\n * distinguishable — 401 \"who are you\", 403 \"not you\".\n *\n * Prefer {@link requireScope} for anything an app declares a scope for: a\n * scope can be granted to a narrower role and to a key, and `admin` cannot.\n */\nexport const requireAdmin: MiddlewareHandler<HonoEnv> = async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!isAdmin(c)) {\n return c.json({\n error: {\n message: \"Admin privileges required for this operation\",\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n};\n\n/**\n * Reject callers holding none of the named roles with 403.\n *\n * Any of them, not all — require several by chaining the guard twice. Naming no\n * role at all is a programming error and throws at module load rather than at\n * request time, because `requireRole()` with an empty list would otherwise read\n * as a guard while admitting everyone.\n *\n * @example\n * ```ts\n * app.post(\"/publish\", requireAuth, requireRole(\"editor\", \"admin\"), handler);\n * ```\n */\nexport function requireRole(...roles: string[]): MiddlewareHandler<HonoEnv> {\n if (roles.length === 0) {\n throw new Error(\n \"requireRole() needs at least one role. An empty list would admit every \" +\n \"signed-in caller while reading as a restriction.\"\n );\n }\n\n const allowed = new Set(roles);\n return async (c, next) => {\n const user = getUser(c);\n if (!user) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n\n if (!getRoles(c).some(role => allowed.has(role))) {\n return c.json({\n error: {\n message: `This operation requires one of these roles: ${roles.join(\", \")}`,\n code: \"FORBIDDEN\"\n }\n }, 403);\n }\n\n return next();\n };\n}\n\n/**\n * Reject callers who do not hold `scope` with 403.\n *\n * `scope` is a built-in scope or one the app declares under `auth.scopes` on\n * the users collection. A name nobody could hold — not a scope name, a typo of\n * a built-in one (`data:raed`) — throws as the function loads; an app scope\n * the backend does not declare is answered `500 SCOPE_NOT_DECLARED`. `target` narrows it to one resource — a key holding\n * `project:deploy:p1` passes `requireScope(\"project:deploy\", c => c.req.param(\"project\"))`\n * for `p1` only — and may be read from the request.\n *\n * A signed-in person holds every app scope, so for a person this is no\n * authorization at all: it narrows API keys and tokens. Decide whether the\n * person may act in the handler, as you would without it.\n *\n * @example\n * ```ts\n * app.post(\"/deploy/:project\", requireAuth, requireScope(\"project:deploy\", c => c.req.param(\"project\")), handler);\n * ```\n */\nexport function requireScope(\n scope: string,\n target?: string | ((c: Context<HonoEnv>) => string | undefined)\n): MiddlewareHandler<HonoEnv> {\n // The root guard's rule, from the same function: refused as the route is\n // registered — which, in a function file, is as the backend loads it.\n const problem = scopeGuardProblem(scope, typeof target === \"string\" ? target : undefined);\n if (problem) throw new Error(problem);\n return async (c, next) => {\n if (!getUser(c)) {\n if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);\n return c.json({\n error: {\n message: \"Authentication required\",\n code: \"UNAUTHORIZED\"\n }\n }, 401);\n }\n // An app scope is declared per backend; the one serving the function\n // says which. One it does not declare nobody holds.\n const declared = c.get(\"services\")?.access.model.scopes;\n const undeclared = declared ? scopeGuardProblem(scope, undefined, Object.keys(declared)) : null;\n if (undeclared) return c.json({ error: { message: undeclared, code: \"SCOPE_NOT_DECLARED\" } }, 500);\n const resolvedTarget = typeof target === \"function\" ? target(c) : target;\n if (!hasScope(c, scope, resolvedTarget)) {\n const wanted = resolvedTarget !== undefined ? `${scope}:${resolvedTarget}` : scope;\n return c.json({\n error: {\n message: `This credential does not hold the \"${wanted}\" scope.`,\n code: \"SCOPE_MISSING\",\n details: { requiredScope: wanted }\n }\n }, 403);\n }\n return next();\n };\n}\n","import { Hono } from \"hono\";\nimport { HonoEnv } from \"../api/types\";\nimport { ApiError } from \"../api/errors\";\nimport { LoadedFunction } from \"./function-loader\";\nimport { requireAuth } from \"./guards\";\n\n/** The file a loader problem names, without its extension: `broken.ts (threw: …)` → `broken`. */\nfunction problemName(problem: string): string {\n return problem.split(\" \")[0].replace(/\\/$/, \"\").replace(/\\.[cm]?[jt]s$/, \"\");\n}\n\n/**\n * Mount all loaded function routes under a single Hono router.\n *\n * Each function is mounted at `/<function-name>`, preserving\n * whatever HTTP methods and middleware the Hono sub-app defines.\n *\n * @param functions What loaded. May be empty — the router still mounts, so\n * \"no functions are served\" answers 200 with an empty list instead of 404.\n * @param problems The files the loader saw and could not serve, as\n * `\"<file> (<reason>)\"`. The listing reports a count and a pointer to the log,\n * not the reasons: those carry import errors, and the listing is one guard\n * away from anyone. The unmatched-route handler uses the *names* to answer the\n * one question a 404 on a function nobody can find should answer — \"there is a\n * file for this and it did not load\" — which is the difference between a typo\n * and a broken deploy, and the loader was the only thing that knew.\n */\nexport function createFunctionRoutes(\n functions: LoadedFunction[],\n problems: string[] = [],\n /**\n * Where this router is mounted, so the listing can report a path a caller\n * can actually request. It used to hardcode `/functions/<name>`, which is\n * wrong under every `basePath` including the default `/api`. The\n * `mountPath` a functions group records for the request wins over it.\n */\n mountPath = \"/functions\"\n): Hono<HonoEnv> {\n const router = new Hono<HonoEnv>();\n const skipped = problems.length;\n\n // Listing endpoint: GET / → list available functions.\n //\n // Functions themselves stay anonymous-callable by default — a webhook\n // receiver has to be — but the index of them does not: it is an inventory\n // of every custom endpoint, for whoever asks. `requireAuth` admits any\n // resolved identity (a signed-in user, an API key, the service key), so\n // `rebase doctor` and `rebase cloud debug` keep their answer; the latter\n // already reads a 401 here as \"mounted\".\n router.get(\"/\", requireAuth, (c) => {\n const at = c.get(\"mountPath\") ?? mountPath;\n return c.json({\n functions: functions.map((fn) => ({\n name: fn.name,\n endpoint: `${at}/${fn.name}`\n })),\n ...(skipped > 0 && {\n skipped,\n note: `${skipped} function file(s) failed to load and are NOT served — see the server log for the reason.`\n })\n });\n });\n\n for (const fn of functions) {\n router.route(`/${fn.name}`, fn.app);\n }\n\n // A name that matches nothing, answered in the envelope.\n //\n // Registered last so every real route wins it. Without it, a typo'd\n // function name — the single most likely 404 a developer meets on this\n // surface — fell through to Hono's default `404 Not Found` as `text/plain`,\n // and through the SDK arrived as `RebaseApiError { code: undefined }`, so\n // the documented `e.code === \"FUNCTION_NOT_FOUND\"` branch never ran.\n //\n // What the message may say depends on who is asking. The mounted names are\n // an inventory of every custom endpoint, which is exactly what the listing\n // above requires an identity to see — so an anonymous caller is told their\n // name is unknown and nothing more, and a resolved caller gets the list\n // that turns the 404 into a fix.\n const mounted = new Set(functions.map(fn => fn.name));\n const failedToLoad = new Map(problems.map(p => [problemName(p), p.split(\" \")[0]]));\n\n router.all(\"/:name{.*}\", (c): never => {\n const requested = (c.req.param(\"name\") ?? \"\").split(\"/\").filter(Boolean);\n const name = requested[0] ?? \"\";\n const rest = requested.slice(1).join(\"/\");\n const identified = Boolean(c.get(\"user\"));\n\n const refuse = (message: string): never => {\n throw new ApiError(404, \"FUNCTION_NOT_FOUND\", message, { function: name }, true);\n };\n\n if (!name) {\n refuse(`No function in the request path. Expected ${c.get(\"mountPath\") ?? mountPath}/<function>.`);\n }\n if (mounted.has(name)) {\n refuse(\n `The function '${name}' is served, and has no ${c.req.method} route at '/${rest}'. ` +\n \"The path after the function name is routed by the function's own Hono app.\"\n );\n }\n // The file name is the same inventory, a file at a time — and it\n // names a source file besides — so it is told to an identified caller\n // only. An anonymous one gets exactly the answer a name that matches\n // nothing gets.\n const file = failedToLoad.get(name);\n if (file && identified) {\n refuse(\n `The function '${name}' is not served: '${file}' failed to load. ` +\n \"The server log records why, at boot.\"\n );\n }\n return refuse(\n `No function named '${name}' on this backend.` +\n (identified\n ? (mounted.size > 0\n ? ` This backend serves: ${[...mounted].sort().join(\", \")}.`\n : \" This backend serves no functions.\")\n : \"\") +\n (skipped > 0 && identified\n ? ` ${skipped} function file(s) failed to load and are not served — see the server log.`\n : \"\")\n );\n });\n\n return router;\n}\n"],"mappings":"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"}
|
package/dist/functions/index.js
CHANGED
|
@@ -785,6 +785,17 @@ function getMinLevel() {
|
|
|
785
785
|
if (env in LOG_PRIORITY) return env;
|
|
786
786
|
return "info";
|
|
787
787
|
}
|
|
788
|
+
var LINE_HOOK_SLOT = Symbol.for("@rebasepro/server:log-line-hook");
|
|
789
|
+
/** Whether the installed hook takes this line. A hook that throws takes nothing. */
|
|
790
|
+
function lineTaken(level, write) {
|
|
791
|
+
const hook = globalThis[LINE_HOOK_SLOT];
|
|
792
|
+
if (!hook) return false;
|
|
793
|
+
try {
|
|
794
|
+
return hook(level, write) === true;
|
|
795
|
+
} catch {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
788
799
|
var FAILED_QUERY_MARKER = "Failed query:";
|
|
789
800
|
/**
|
|
790
801
|
* The marker says how to lift it.
|
|
@@ -991,12 +1002,18 @@ function createLogger(rawDefaultFields = {}) {
|
|
|
991
1002
|
...defaultFields,
|
|
992
1003
|
...formatData(data)
|
|
993
1004
|
};
|
|
1005
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1006
|
+
const write = () => writeEntry(level, safeMessage, merged, timestamp);
|
|
1007
|
+
if (lineTaken(level, write)) return;
|
|
1008
|
+
write();
|
|
1009
|
+
}
|
|
1010
|
+
function writeEntry(level, safeMessage, merged, timestamp) {
|
|
994
1011
|
fanOut(level, safeMessage, merged);
|
|
995
1012
|
if (isProduction()) {
|
|
996
1013
|
const entry = {
|
|
997
1014
|
severity: GCP_SEVERITY[level],
|
|
998
1015
|
message: safeMessage,
|
|
999
|
-
timestamp
|
|
1016
|
+
timestamp,
|
|
1000
1017
|
...merged
|
|
1001
1018
|
};
|
|
1002
1019
|
const line = JSON.stringify(entry);
|