@rebasepro/server 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/{admin_block-0Xu0r6eZ.js → admin_block-DxKLmdiv.js} +26 -8
  2. package/dist/{admin_block-0Xu0r6eZ.js.map → admin_block-DxKLmdiv.js.map} +1 -1
  3. package/dist/api/ast-schema-editor.d.ts +15 -0
  4. package/dist/api/rest/api-generator.d.ts +28 -0
  5. package/dist/{ast-schema-editor-C6mDz0XN.js → ast-schema-editor-CslO8Oje.js} +24 -4
  6. package/dist/ast-schema-editor-CslO8Oje.js.map +1 -0
  7. package/dist/auth/jwt.d.ts +67 -0
  8. package/dist/auth/rls-scope.d.ts +14 -0
  9. package/dist/{auth-DJsLXsCR.js → auth-gzoRjnuh.js} +24 -12
  10. package/dist/auth-gzoRjnuh.js.map +1 -0
  11. package/dist/boot/env.d.ts +8 -2
  12. package/dist/boot/fetch-bundle.d.ts +19 -0
  13. package/dist/boot/role.d.ts +2 -0
  14. package/dist/contract-routes-eLxV0le1.js.map +1 -1
  15. package/dist/{cron-routes-Bfwni8Zg.js → cron-routes-BamgO0Xr.js} +2 -2
  16. package/dist/{cron-routes-Bfwni8Zg.js.map → cron-routes-BamgO0Xr.js.map} +1 -1
  17. package/dist/{cron-store-Bsiw4Q6u.js → cron-store-yXOyIEmn.js} +2 -3
  18. package/dist/{cron-store-Bsiw4Q6u.js.map → cron-store-yXOyIEmn.js.map} +1 -1
  19. package/dist/db/ambient-transaction.d.ts +48 -0
  20. package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
  21. package/dist/deploy/pod-contract.d.ts +1 -1
  22. package/dist/errors-DMImyqyR.js.map +1 -1
  23. package/dist/functions/index.js.map +1 -1
  24. package/dist/{history-recorder-r5_IzSHK.js → history-recorder-hBGuXcZc.js} +2 -2
  25. package/dist/{history-recorder-r5_IzSHK.js.map → history-recorder-hBGuXcZc.js.map} +1 -1
  26. package/dist/{history-store-D4RVK-uZ.js → history-store-C9EtGtoL.js} +5 -6
  27. package/dist/history-store-C9EtGtoL.js.map +1 -0
  28. package/dist/index.d.ts +2 -1
  29. package/dist/index.es.js +2266 -93
  30. package/dist/index.es.js.map +1 -1
  31. package/dist/init/callback-client.d.ts +16 -0
  32. package/dist/init/surfaces.d.ts +37 -7
  33. package/dist/jobs/types.d.ts +5 -0
  34. package/dist/{jobs-CW5lm_Ix.js → jobs-DZ8DLLcU.js} +82 -13
  35. package/dist/jobs-DZ8DLLcU.js.map +1 -0
  36. package/dist/{jwt-DATvkKB_.js → jwt-C4OW-DNq.js} +136 -4
  37. package/dist/jwt-C4OW-DNq.js.map +1 -0
  38. package/dist/logger-DO2PZc4i.js.map +1 -1
  39. package/dist/mcp/consent-page.d.ts +75 -0
  40. package/dist/mcp/mcp-routes.d.ts +52 -0
  41. package/dist/mcp/mcp-tools.d.ts +51 -0
  42. package/dist/mcp/oauth-metadata.d.ts +123 -0
  43. package/dist/mcp/oauth-routes.d.ts +52 -0
  44. package/dist/mcp/oauth-store.d.ts +157 -0
  45. package/dist/metrics/index.d.ts +16 -1
  46. package/dist/{openapi-generator-DGyLbISS.js → openapi-generator-CAFKF36E.js} +2 -8
  47. package/dist/openapi-generator-CAFKF36E.js.map +1 -0
  48. package/dist/{query-parser-BQiPZrM-.js → query-parser-uqzkZ82i.js} +2 -2
  49. package/dist/{query-parser-BQiPZrM-.js.map → query-parser-uqzkZ82i.js.map} +1 -1
  50. package/dist/{schema-editor-routes-C3TLZqAC.js → schema-editor-routes-BKOmdf4M.js} +2 -2
  51. package/dist/{schema-editor-routes-C3TLZqAC.js.map → schema-editor-routes-BKOmdf4M.js.map} +1 -1
  52. package/dist/services/webhook-service.d.ts +11 -8
  53. package/dist/sql-rows-C6GEc2oE.js +45 -0
  54. package/dist/sql-rows-C6GEc2oE.js.map +1 -0
  55. package/dist/src-Br6ARbs6.js.map +1 -1
  56. package/dist/{src-DqZ9YiGA.js → src-DW1cQ_28.js} +261 -17
  57. package/dist/src-DW1cQ_28.js.map +1 -0
  58. package/dist/utils/portable-crypto.d.ts +8 -0
  59. package/package.json +5 -5
  60. package/dist/ast-schema-editor-C6mDz0XN.js.map +0 -1
  61. package/dist/auth-DJsLXsCR.js.map +0 -1
  62. package/dist/history-store-D4RVK-uZ.js.map +0 -1
  63. package/dist/internal-tables-DYVcFFSv.js +0 -117
  64. package/dist/internal-tables-DYVcFFSv.js.map +0 -1
  65. package/dist/jobs-CW5lm_Ix.js.map +0 -1
  66. package/dist/jwt-DATvkKB_.js.map +0 -1
  67. package/dist/openapi-generator-DGyLbISS.js.map +0 -1
  68. package/dist/src-DqZ9YiGA.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -2,17 +2,16 @@ import { createRequire as __rebaseCreateRequire } from "module";
2
2
  import __rebaseProcess from "process";
3
3
  globalThis.process ??= __rebaseProcess;
4
4
  __rebaseCreateRequire(import.meta.url);
5
- import { $ as isFieldOperation, A as createDataSourceRegistry, B as resolveCollectionRelations, D as restrictedFieldNames, E as effectiveAccess, F as securityRuleToConditions, G as suggestNearMiss, H as buildCompositeId, I as fieldKeyForColumn, K as toSnakeCase, L as findRelation, M as resolveDataSource, N as getEffectiveSecurityRules, O as defaultUsersCollection, Q as hasFieldOperation, R as getTableName, S as cursorToStartAfter, T as canWriteField, U as resolvePrimaryKeys, V as enumToObjectEntries, W as hydrateRegExp, X as BATCH_REF_KEY, Z as FIELD_OPERATORS, at as Vector, c as collectAllPages, ct as isUnsupported, g as topLevelIncludeNames, h as serializeInclude, it as GeoPoint, j as isRelationalCollection, k as CollectionRegistry, l as paginateFind, lt as unsupportedMethod, n as buildSdkData, nt as EntityReference, o as serializeFilter, ot as RebaseApiError, p as mergeIncludeSpecs, rt as EntityRelation, s as serializeLogicalCondition, st as RebaseClientError, t as buildRoutedRebaseData, u as resolveFindWindow, v as normalizeOrderBy, y as serializeOrderBy } from "./src-DqZ9YiGA.js";
5
+ import { A as createDataSourceRegistry, B as findRelation, D as restrictedFieldNames, E as effectiveAccess, F as getJunctionConfigForRelation, G as buildCompositeId, I as getEffectiveSecurityRules, J as suggestNearMiss, K as resolvePrimaryKeys, M as resolveDataSource, O as defaultUsersCollection, P as revokeInternalTableSql, R as securityRuleToConditions, S as cursorToStartAfter, T as canWriteField, U as resolveCollectionRelations, V as getTableName, W as enumToObjectEntries, X as unref, Y as toSnakeCase, c as collectAllPages, dt as GeoPoint, et as BATCH_REF_KEY, ft as Vector, g as topLevelIncludeNames, gt as unsupportedMethod, h as serializeInclude, ht as isUnsupported, j as isRelationalCollection, k as CollectionRegistry, l as paginateFind, lt as EntityReference, mt as RebaseClientError, n as buildSdkData, nt as hasFieldOperation, o as serializeFilter, ot as JUNCTION_PIVOT_KEY, p as mergeIncludeSpecs, pt as RebaseApiError, q as hydrateRegExp, rt as isFieldOperation, s as serializeLogicalCondition, st as isManyToMany, t as buildRoutedRebaseData, tt as FIELD_OPERATORS, u as resolveFindWindow, ut as EntityRelation, v as normalizeOrderBy, y as serializeOrderBy, z as fieldKeyForColumn } from "./src-DW1cQ_28.js";
6
6
  import { E as DEFAULT_DATA_SOURCE_KEY, F as sortKeyToString, I as toCanonicalOp, P as parseRelationAggregateSort, S as getCollectionDataPath, _ as resourceKinds, a as resourceToStorageSource, b as RLS_UID_SQL, c as DEFAULT_STORAGE_SOURCE_KEY, d as DEFAULT_RESOURCE_KEY, f as buildResourceGraph, g as resourceKind, h as resourceKeyOf, i as resourceToDataSource, l as findStorageSuffixCollision, m as resourceEnvSuffix, n as declaredQueueConsumers, o as setQueueRuntime, p as resolveResourceRefs, r as declaredSubscriptions, s as setTopicRuntime, u as storageEnvSuffix, v as RLS_JWT_SQL, w as isPostgresCollectionConfig, y as RLS_ROLES_SQL } from "./src-Br6ARbs6.js";
7
- import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-0Xu0r6eZ.js";
7
+ import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-DxKLmdiv.js";
8
8
  import { a as isDuplicateObjectRace, i as isConcurrentDdlRace, n as createDdlBootstrapper, o as isSQLAdmin, s as isSchemaEditingAdmin, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-CfNvxMuK.js";
9
9
  import { i as SCHEMA_VERSION_HEADER, n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-eLxV0le1.js";
10
- import { $ as escapeHtml, A as MemoryRateLimitStore, B as resolveAuthHooks, C as providerVerifiedEmail, Ct as PUBLIC_STORAGE_PREFIX, D as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, E as isBootstrapWindowOpen, F as registerDevEmailSink, G as getEmailOtpTemplate, H as validatePasswordStrength, I as SMTPEmailService, J as getPasswordResetTemplate, K as getEmailVerificationTemplate, L as createEmailService, M as clearActiveDevEmailSink, N as createDevEmailSink, O as createDataRateLimiter, P as extractLinks, Q as RawHtml, R as assertEmailLinkBases, S as pkceTokenParams, St as isOperationAllowed, T as createBuiltinAuthAdapter, U as verifyPassword, V as hashPassword, W as generateSecurePassword, X as getWelcomeEmailTemplate, Y as getUserInvitationTemplate, Z as resolveEmailBranding, _ as verifyOidcIdToken, _t as extractBearerToken, a as resolveRateLimitStoreKind, at as extractUserFromToken, b as createGoogleProvider, bt as scopeDataDriver, c as createSlackProvider, ct as publicObjectAuth, d as createDiscordProvider, dt as requireAuth, et as html, f as createTwitterProvider, ft as createApiKeyPreAuth, g as tryVerifyOidcIdToken, gt as validateApiKey, h as createMicrosoftProvider, ht as isApiKeyToken, i as createApiKeyStore, it as createRequireAuth, j as activeDevEmailSink, k as defaultAuthLimiter, l as createBitbucketProvider, lt as queryTokenAuth, m as createAppleProvider, mt as createStorageApiKeyGuard, n as createCustomAuthAdapter, nt as createAdapterAuthMiddleware, o as createSqlRateLimitStore, ot as fileTokenAuth, p as createFacebookProvider, pt as createFunctionApiKeyGuard, q as getMagicLinkTemplate, r as createApiKeyRoutes, rt as createAuthMiddleware, s as createSpotifyProvider, st as optionalAuth, tt as raw, u as createGitLabProvider, ut as requireAdmin, v as createGitHubProvider, vt as safeCompare, w as createJwksRoutes, wt as isPublicStoragePath, x as oauthCodeFlowSchema, xt as httpMethodToOperation, y as createLinkedinProvider, yt as SERVICE_IDENTITY, z as resolveEmailLinkBase } from "./auth-DJsLXsCR.js";
11
- import { t as revokeInternalTableSql } from "./internal-tables-DYVcFFSv.js";
10
+ import { $ as RawHtml, A as defaultAuthLimiter, B as resolveEmailLinkBase, C as providerVerifiedEmail, Ct as httpMethodToOperation, D as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, E as isBootstrapWindowOpen, Et as isPublicStoragePath, F as extractLinks, G as generateSecurePassword, H as hashPassword, I as registerDevEmailSink, J as getMagicLinkTemplate, K as getEmailOtpTemplate, L as SMTPEmailService, M as activeDevEmailSink, N as clearActiveDevEmailSink, O as createDataRateLimiter, P as createDevEmailSink, Q as resolveEmailBranding, R as createEmailService, S as pkceTokenParams, St as supportsRlsScoping, T as createBuiltinAuthAdapter, Tt as PUBLIC_STORAGE_PREFIX, U as validatePasswordStrength, V as resolveAuthHooks, W as verifyPassword, X as getUserInvitationTemplate, Y as getPasswordResetTemplate, Z as getWelcomeEmailTemplate, _ as verifyOidcIdToken, _t as validateApiKey, a as resolveRateLimitStoreKind, at as createRequireAuth, b as createGoogleProvider, bt as SERVICE_IDENTITY, c as createSlackProvider, ct as optionalAuth, d as createDiscordProvider, dt as requireAdmin, et as escapeHtml, f as createTwitterProvider, ft as requireAuth, g as tryVerifyOidcIdToken, gt as isApiKeyToken, h as createMicrosoftProvider, ht as createStorageApiKeyGuard, i as createApiKeyStore, it as createAuthMiddleware, j as MemoryRateLimitStore, k as createRateLimiter, l as createBitbucketProvider, lt as publicObjectAuth, m as createAppleProvider, mt as createFunctionApiKeyGuard, n as createCustomAuthAdapter, nt as raw, o as createSqlRateLimitStore, ot as extractUserFromToken, p as createFacebookProvider, pt as createApiKeyPreAuth, q as getEmailVerificationTemplate, r as createApiKeyRoutes, rt as createAdapterAuthMiddleware, s as createSpotifyProvider, st as fileTokenAuth, tt as html, u as createGitLabProvider, ut as queryTokenAuth, v as createGitHubProvider, vt as extractBearerToken, w as createJwksRoutes, wt as isOperationAllowed, x as oauthCodeFlowSchema, xt as scopeDataDriver, y as createLinkedinProvider, yt as safeCompare, z as assertEmailLinkBases } from "./auth-gzoRjnuh.js";
12
11
  import { i as rawQueryLoggingEnabled, n as describeCauseChain, o as setLogLevel, r as logger, s as hostEnv } from "./logger-DO2PZc4i.js";
13
12
  import { n as errorHandler, r as schemaDriftRemedy, t as ApiError } from "./errors-DMImyqyR.js";
14
- import { a as resolveListLimitParam, c as HARD_DELETE_QUERY_PARAM, i as parseQueryOptions, l as parseHardDelete, n as parseAggregateSelect, o as assertReadableFields, r as parseGroupBy, s as requestViewer, t as orderByEntriesToTuples } from "./query-parser-BQiPZrM-.js";
15
- import { S as sha256Hex, c as getJwks, d as hasAsymmetricSigningKey, i as generateDownloadToken, n as configureJwt, p as isJwtConfigured, v as normalizePemFromEnv } from "./jwt-DATvkKB_.js";
13
+ import { a as resolveListLimitParam, c as HARD_DELETE_QUERY_PARAM, i as parseQueryOptions, l as parseHardDelete, n as parseAggregateSelect, o as assertReadableFields, r as parseGroupBy, s as requestViewer, t as orderByEntriesToTuples } from "./query-parser-uqzkZ82i.js";
14
+ import { D as sha256Bytes, O as sha256Hex, S as normalizePemFromEnv, T as randomHex, _ as verifyAccessToken, a as generateMcpAccessToken, f as hasAsymmetricSigningKey, g as signPurposeToken, i as generateDownloadToken, l as getJwks, m as isJwtConfigured, n as configureJwt, w as constantTimeEqual, x as verifyPurposeToken, y as verifyMcpAccessToken } from "./jwt-C4OW-DNq.js";
16
15
  import { a as canonicalStorageKey, i as canonicalStorageId, n as InvalidStorageKeyError, r as canonicalStorageBucket, t as InvalidStorageBucketError } from "./keys-Qfc4XieN.js";
17
16
  import { t as logMiddleware } from "./logs-routes-DnJINsMu.js";
18
17
  import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-BfKcm9do.js";
@@ -25,11 +24,11 @@ import "./request-timeout-BR-OBwES.js";
25
24
  import { t as FunctionSelectionError } from "./selection-CRpqKUbt.js";
26
25
  import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-CQjvjpEw.js";
27
26
  import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-D47tdB9T.js";
28
- import { t as createCronRoutes } from "./cron-routes-Bfwni8Zg.js";
29
- import { t as createCronStore } from "./cron-store-Bsiw4Q6u.js";
27
+ import { t as createCronRoutes } from "./cron-routes-BamgO0Xr.js";
28
+ import { t as createCronStore } from "./cron-store-yXOyIEmn.js";
30
29
  import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-DGu0v9Ku.js";
31
- import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-CW5lm_Ix.js";
32
- import { t as METRIC_SERIES } from "./history-store-D4RVK-uZ.js";
30
+ import { a as currentAmbientTransaction, i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-DZ8DLLcU.js";
31
+ import { t as METRIC_SERIES } from "./history-store-C9EtGtoL.js";
33
32
  import { createHash, createSign, randomBytes } from "node:crypto";
34
33
  import * as fs$3 from "fs";
35
34
  import fs, { existsSync } from "fs";
@@ -756,6 +755,48 @@ var TENANT_FIELD_TYPES = /* @__PURE__ */ new Set([
756
755
  * claims hook cannot assert them. A tenancy rule reading `uid` as a tenant
757
756
  * would compile and would mean something nobody intended.
758
757
  */
758
+ /**
759
+ * The collection object read as a config, for the two helpers that want a whole
760
+ * one.
761
+ *
762
+ * This module runs *before* anything has turned the user's config into a
763
+ * `CollectionConfig` — that is what it is for — so what it holds is an open
764
+ * record, and `getTableName` and `getEffectiveSecurityRules` are both typed on
765
+ * the finished type. Nothing structural bridges the two, so the conversion is
766
+ * irreducible here; what it should not be is scattered, which is how two call
767
+ * sites came to spell it `as unknown as CollectionConfig` independently.
768
+ *
769
+ * Both helpers read fields this module has already checked by the time they are
770
+ * called, and both are total on a record that is missing them — `getTableName`
771
+ * falls back through `slug` to `name` to `""`, and an absent `securityRules` is
772
+ * an empty rule list. Prefer {@link dataSourceOf} where only the routing fields
773
+ * are wanted: it checks them instead of claiming them.
774
+ */
775
+ function asCollectionConfig(collection) {
776
+ return collection;
777
+ }
778
+ /**
779
+ * The three fields {@link isRelationalCollection} reads, taken off a collection
780
+ * object this module has not finished validating.
781
+ *
782
+ * `isRelationalCollection` wants a `DataSourceResolvable` —
783
+ * `{ dataSource?: string; engine?: string; databaseId?: string }` — and what
784
+ * this file holds is a `Record<string, unknown>` straight out of the user's
785
+ * config, whose values are `unknown`. The call sites answered that mismatch
786
+ * with `collection as unknown as CollectionConfig`, which names a type nothing
787
+ * here asks for and asserts far more than the question needs: a config
788
+ * declaring `engine: 42` was read as an engine and routed on.
789
+ *
790
+ * Checking the three is what this module is for.
791
+ */
792
+ function dataSourceOf(collection) {
793
+ const text = (value) => typeof value === "string" ? value : void 0;
794
+ return {
795
+ dataSource: text(collection.dataSource),
796
+ engine: text(collection.engine),
797
+ databaseId: text(collection.databaseId)
798
+ };
799
+ }
759
800
  function checkTenant(collection, at, collect) {
760
801
  const declared = collection.tenant;
761
802
  if (declared === void 0) return;
@@ -763,7 +804,7 @@ function checkTenant(collection, at, collect) {
763
804
  collect.error(`${at}.tenant`, "`tenant` must be an object — `{ field, from }`.");
764
805
  return;
765
806
  }
766
- if (!isRelationalCollection(collection)) {
807
+ if (!isRelationalCollection(dataSourceOf(collection))) {
767
808
  collect.error(`${at}.tenant`, "`tenant` is Postgres-only: row-level security is what enforces the boundary, and an application-layer filter on an engine without it is one a raw query goes around. Remove `tenant`, or move this collection to a Postgres data source.");
768
809
  return;
769
810
  }
@@ -840,7 +881,7 @@ function checkTenant(collection, at, collect) {
840
881
  */
841
882
  function checkPrimaryKeyStrategy(collection, at, collect) {
842
883
  if (!isPlainObject$3(collection.properties)) return;
843
- if (!isRelationalCollection(collection)) return;
884
+ if (!isRelationalCollection(dataSourceOf(collection))) return;
844
885
  const ids = Object.entries(collection.properties).filter(([, property]) => isPlainObject$3(property) && Boolean(property.isId)).map(([key, property]) => [key, property]);
845
886
  if (ids.length > 1) collect.error(`${at}.properties`, `${ids.length} properties are marked \`isId\` (${ids.map(([key]) => `\`${key}\``).join(", ")}), and composite primary keys are not supported: the generated table would carry two PRIMARY KEY clauses, which Postgres refuses, and the boot-time schema ensure would create the table without the second column. Give exactly one property \`isId\`, and express the second key with \`indexes: [{ on: [...], unique: true, reason: "…" }]\`.`);
846
887
  for (const [key, property] of ids) {
@@ -993,7 +1034,7 @@ function checkCollectionsTogether(collections, sources, collect) {
993
1034
  if (seen) seen.push(index);
994
1035
  else bySlug.set(slug, [index]);
995
1036
  }
996
- const table = getTableName(collection);
1037
+ const table = getTableName(asCollectionConfig(collection));
997
1038
  if (!table) return;
998
1039
  const qualified = `${typeof collection.schema === "string" && collection.schema ? collection.schema : "public"}.${table}`;
999
1040
  const seen = byTable.get(qualified);
@@ -1084,7 +1125,7 @@ function checkTenantMemberships(collections, collect) {
1084
1125
  collect.error(`${path}.collection`, `\`membership.collection: "${slug}"\` names no collection in this project. The policy compiles to a subquery over its table, so a slug nothing resolves to is a policy over a table that does not exist — which fails to apply and leaves the collection denying every row.`);
1085
1126
  continue;
1086
1127
  }
1087
- if (!isRelationalCollection(target)) {
1128
+ if (!isRelationalCollection(dataSourceOf(target))) {
1088
1129
  collect.error(`${path}.collection`, `'${slug}' is not stored in Postgres, so there is no table for the tenancy policy's subquery to read. Both collections have to live in the same SQL database.`);
1089
1130
  continue;
1090
1131
  }
@@ -1093,7 +1134,7 @@ function checkTenantMemberships(collections, collect) {
1093
1134
  if (isPlainObject$3(targetProperties[name])) continue;
1094
1135
  collect.error(`${path}.${key}`, `'${slug}' has no property '${name}'. The tenancy policy reads it inside a subquery, and a policy naming a column that does not exist fails to apply — which leaves the table with RLS enabled and no policy, denying every row.`);
1095
1136
  }
1096
- if (!grantsSelfRead(target, userField)) collect.error(`${path}.collection`, `'${slug}' does not let a signed-in caller read their own membership rows, and the tenancy policy on '${at}' is a subquery over it — evaluated as the caller, so '${slug}'s own RLS applies inside it. Every Rebase table denies by default, so as written that subquery is false for everybody and '${at}' returns no rows to anyone but \`admin\`. Add the rule that makes it readable:
1137
+ if (!grantsSelfRead(asCollectionConfig(target), userField)) collect.error(`${path}.collection`, `'${slug}' does not let a signed-in caller read their own membership rows, and the tenancy policy on '${at}' is a subquery over it — evaluated as the caller, so '${slug}'s own RLS applies inside it. Every Rebase table denies by default, so as written that subquery is false for everybody and '${at}' returns no rows to anyone but \`admin\`. Add the rule that makes it readable:
1097
1138
  securityRules: [{ name: "${slug}_self_read", operations: ["select"], ownerField: "${userField}" }]`, "incoherent");
1098
1139
  }
1099
1140
  }
@@ -2016,6 +2057,48 @@ function createRoutedRealtimeService(opts) {
2016
2057
  //#endregion
2017
2058
  //#region src/api/rest/write-validation.ts
2018
2059
  /**
2060
+ * The relations of `collection` that reach through a junction carrying its own
2061
+ * columns, each with the synthetic collection those columns are declared on.
2062
+ *
2063
+ * Keyed by the relation's wire name, which is what a membership write names
2064
+ * (`{ tags: [...] }`). A junction with no `through.properties` is not here at
2065
+ * all: there is nothing a `_pivot` could legally say about it, and the write
2066
+ * path refuses one with the relation named.
2067
+ */
2068
+ function junctionsWithPayload(collection) {
2069
+ const out = [];
2070
+ for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) {
2071
+ if (!isManyToMany(relation)) continue;
2072
+ if (Object.keys(relation.through.properties).length === 0) continue;
2073
+ out.push([key, getJunctionConfigForRelation(relation.through)]);
2074
+ }
2075
+ return out;
2076
+ }
2077
+ /**
2078
+ * The `_pivot` objects in a membership value, with the index each came from.
2079
+ *
2080
+ * Tolerant on purpose: a value that is not an array, an element that is a bare
2081
+ * id, and a `_pivot` that is not an object are all *someone else's* error to
2082
+ * report — `relationLinkElements` in the driver refuses an element with no id,
2083
+ * and a non-object `_pivot` is refused where it is serialized. Reporting them
2084
+ * twice, differently, is how two layers come to disagree about what a request
2085
+ * meant.
2086
+ */
2087
+ function pivotsIn(value) {
2088
+ if (!Array.isArray(value)) return [];
2089
+ const out = [];
2090
+ value.forEach((element, index) => {
2091
+ if (!element || typeof element !== "object" || Array.isArray(element)) return;
2092
+ const pivot = element[JUNCTION_PIVOT_KEY];
2093
+ if (!pivot || typeof pivot !== "object" || Array.isArray(pivot)) return;
2094
+ out.push({
2095
+ index,
2096
+ pivot
2097
+ });
2098
+ });
2099
+ return out;
2100
+ }
2101
+ /**
2019
2102
  * The two ways a field can be closed to a write, told apart.
2020
2103
  *
2021
2104
  * `excluded` is `access.write: []` — the server's own column, closed to every
@@ -2129,6 +2212,7 @@ function assertKnownWriteFields(values, collection, options) {
2129
2212
  for (const name of restrictedFieldNames(collection, options?.viewer, "write").declared) known.delete(name);
2130
2213
  for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.kind === "belongsTo") known.add(fieldKeyForColumn(collection, relation.localKey));
2131
2214
  for (const field of options?.extraKnownFields ?? []) known.add(field);
2215
+ for (const [key, junction] of junctionsWithPayload(collection)) for (const { pivot } of pivotsIn(values[key])) assertKnownWriteFields(pivot, junction, { viewer: options?.viewer });
2132
2216
  const unknown = Object.keys(values).filter((key) => !known.has(key));
2133
2217
  if (unknown.length === 0) return;
2134
2218
  if (unknown.includes("id") && !known.has("id")) {
@@ -2392,7 +2476,7 @@ function isCallerSupplied(property) {
2392
2476
  * (`author`) or the foreign-key column under its wire name (`authorId`). Both
2393
2477
  * put the same value in the same column.
2394
2478
  */
2395
- function collectMissingRequired(values, collection, into) {
2479
+ function collectMissingRequired(values, collection, into, at) {
2396
2480
  if (collection.callbacks?.beforeSave) return;
2397
2481
  const properties = collection.properties ?? {};
2398
2482
  /** Relation property name → the FK wire key that also satisfies it. */
@@ -2408,10 +2492,11 @@ function collectMissingRequired(values, collection, into) {
2408
2492
  if (supplied(key)) continue;
2409
2493
  const alias = relationAlias.get(key);
2410
2494
  if (alias && supplied(alias)) continue;
2495
+ const field = at ? `${at}.${key}` : key;
2411
2496
  into.push({
2412
- field: key,
2497
+ field,
2413
2498
  code: "required",
2414
- message: property.validation.requiredMessage ?? `'${key}' is required.`
2499
+ message: property.validation.requiredMessage ?? `'${field}' is required.`
2415
2500
  });
2416
2501
  }
2417
2502
  }
@@ -2466,6 +2551,14 @@ function assertWriteValuesValid(values, collection, options) {
2466
2551
  const property = properties[key];
2467
2552
  if (property) collectViolations(key, property, value, violations);
2468
2553
  }
2554
+ for (const [key, junction] of junctionsWithPayload(collection)) for (const { index, pivot } of pivotsIn(values[key])) {
2555
+ const at = `${key}[${index}].${JUNCTION_PIVOT_KEY}`;
2556
+ for (const [pivotKey, pivotValue] of Object.entries(pivot)) {
2557
+ const property = junction.properties?.[pivotKey];
2558
+ if (property) collectViolations(`${at}.${pivotKey}`, property, pivotValue, violations);
2559
+ }
2560
+ if (options?.status === "new" || options?.status === "copy") collectMissingRequired(pivot, junction, violations, at);
2561
+ }
2469
2562
  if (options?.status === "new" || options?.status === "copy") collectMissingRequired(values, collection, violations);
2470
2563
  if (violations.length === 0) return;
2471
2564
  const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
@@ -3539,6 +3632,70 @@ var RestApiGenerator = class {
3539
3632
  return current;
3540
3633
  }
3541
3634
  /**
3635
+ * `PATCH <collection>/<id>/<relation>/<targetId>` with a `_pivot` body: set
3636
+ * the columns that one many-to-many link carries.
3637
+ *
3638
+ * The membership array on the parent (`PATCH posts/1 { tags: [...] }`) sets
3639
+ * *which* links exist; this sets what one of them holds. They are separate
3640
+ * because the array cannot express a single-link edit without either
3641
+ * unlinking everything it omits or re-sending the whole set — and re-sending
3642
+ * the set to change one `role` is exactly the lost update that the
3643
+ * membership diff exists to avoid.
3644
+ *
3645
+ * Validated against the junction's own properties, through the same
3646
+ * `assertWriteRequestValid` a row's values go through: `required`, `enum`,
3647
+ * `min`/`max`/`matches` and per-field `access.write` mean the same thing on
3648
+ * a payload column as on a collection's, or the promise that they are
3649
+ * "declared exactly like collection properties" is not one.
3650
+ */
3651
+ async updateRelationPivotFromBody(c, driver, collectionPath, targetId, body) {
3652
+ const extras = Object.keys(body).filter((key) => key !== JUNCTION_PIVOT_KEY);
3653
+ if (extras.length > 0) throw ApiError.badRequest(`A \`${JUNCTION_PIVOT_KEY}\` write sets the link's own columns, so it cannot also carry ${extras.map((k) => `'${k}'`).join(", ")} — those belong to the row on the far side. Send them as a separate request to the same address without \`_pivot\`.`, "VALIDATION_UNKNOWN_FIELDS", {
3654
+ fields: extras,
3655
+ path: collectionPath
3656
+ });
3657
+ const pivot = body[JUNCTION_PIVOT_KEY];
3658
+ if (!pivot || typeof pivot !== "object" || Array.isArray(pivot)) throw ApiError.badRequest(`\`${JUNCTION_PIVOT_KEY}\` must be an object of the junction's columns.`, "VALIDATION_CONSTRAINT", { path: collectionPath });
3659
+ const junction = this.resolveJunctionCollection(collectionPath);
3660
+ if (!junction) throw ApiError.badRequest(`'${collectionPath}' does not reach its target through a \`manyToMany\` that declares \`through.properties\`, so there is no link payload to write.`, "RELATION_HAS_NO_PIVOT", { path: collectionPath });
3661
+ assertWriteRequestValid(pivot, junction, { viewer: requestViewer(c) });
3662
+ if (!driver.updateRelationPivot) throw ApiError.badRequest("This data source cannot write junction columns.", "RELATION_PIVOT_UNSUPPORTED", { path: collectionPath });
3663
+ await driver.updateRelationPivot({
3664
+ path: collectionPath,
3665
+ targetId,
3666
+ pivot
3667
+ });
3668
+ return new Response(null, { status: 204 });
3669
+ }
3670
+ /**
3671
+ * The synthetic collection standing in for the junction a nested path's last
3672
+ * hop reaches through, or `undefined` when that hop is not a `manyToMany`
3673
+ * carrying `through.properties`.
3674
+ *
3675
+ * Built from the relation rather than by walking every collection: the same
3676
+ * `getJunctionConfigForRelation` the schema planner and the driver use, so
3677
+ * the shape validated here is the shape the columns were emitted from.
3678
+ */
3679
+ resolveJunctionCollection(collectionPath) {
3680
+ const segments = collectionPath.split("/").filter((s) => s && s !== "undefined");
3681
+ if (segments.length < 3) return void 0;
3682
+ let current = this.collections.find((c) => c.slug === segments[0]);
3683
+ let relation;
3684
+ for (let i = 2; i < segments.length && current; i += 2) {
3685
+ relation = findRelation(resolveCollectionRelations(current), segments[i]);
3686
+ if (!relation) return void 0;
3687
+ try {
3688
+ const target = relation.target();
3689
+ current = this.collections.find((c) => c.slug === target?.slug) ?? target;
3690
+ } catch {
3691
+ return;
3692
+ }
3693
+ }
3694
+ if (!relation || !isManyToMany(relation)) return void 0;
3695
+ if (Object.keys(relation.through.properties).length === 0) return void 0;
3696
+ return getJunctionConfigForRelation(relation.through);
3697
+ }
3698
+ /**
3542
3699
  * Get the request-scoped driver. Throws if none is set — never falls
3543
3700
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
3544
3701
  */
@@ -4090,6 +4247,7 @@ var RestApiGenerator = class {
4090
4247
  const driver = this.getScopedDriver(c);
4091
4248
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4092
4249
  const body = await parseJsonBody(c);
4250
+ if ("_pivot" in body) return this.updateRelationPivotFromBody(c, driver, parsed.collectionPath, parsed.id, body);
4093
4251
  const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4094
4252
  if (targetCollection) {
4095
4253
  assertKnownWriteFields(body, targetCollection, { viewer: requestViewer(c) });
@@ -7042,7 +7200,7 @@ async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections,
7042
7200
  basePath,
7043
7201
  requireAuth
7044
7202
  }));
7045
- const { generateOpenApiSpec } = await import("./openapi-generator-DGyLbISS.js");
7203
+ const { generateOpenApiSpec } = await import("./openapi-generator-CAFKF36E.js");
7046
7204
  generateOpenApiSpecFn = generateOpenApiSpec;
7047
7205
  if (isPublic) app.get(`${basePath}/docs`, (c) => spec(c));
7048
7206
  else app.get(`${basePath}/docs`, createRequireAuth({}), requireAdmin, (c) => spec(c));
@@ -7206,7 +7364,10 @@ async function drainBackgroundWork(timeoutMs = 5e3) {
7206
7364
  let timer;
7207
7365
  const expired = new Promise((resolve) => {
7208
7366
  timer = setTimeout(() => resolve("timeout"), timeoutMs);
7209
- timer.unref?.();
7367
+ if (timer !== void 0 && typeof timer === "object") {
7368
+ const release = timer.unref;
7369
+ if (typeof release === "function") release.call(timer);
7370
+ }
7210
7371
  });
7211
7372
  try {
7212
7373
  await Promise.race([Promise.allSettled([...set]), expired]);
@@ -8391,19 +8552,42 @@ var ALL_RUNTIME_SURFACES = [
8391
8552
  "functions",
8392
8553
  "cron",
8393
8554
  "meta",
8394
- "realtime"
8555
+ "realtime",
8556
+ "mcp"
8395
8557
  ];
8396
8558
  /**
8397
- * Resolve a partial surface set against the default, which is everything on.
8559
+ * Surfaces that default to OFF, against the rule below.
8398
8560
  *
8399
- * Defaulting to on rather than off is the whole compatibility story: a caller
8400
- * that says nothing gets the process this server has always booted, and a
8401
- * surface added later is mounted by every existing deployment without anyone
8402
- * editing a list.
8561
+ * `mcp` is the first, and the exception is deliberate. The default-on rule is a
8562
+ * compatibility story — a caller that says nothing gets the process this server
8563
+ * has always booted — and it is the right rule for a surface that exposes
8564
+ * something the deployment already exposes by another name. It is the wrong
8565
+ * rule for this one.
8566
+ *
8567
+ * `mcp` mounts an OAuth authorization server that issues credentials to
8568
+ * third-party software, on a public path, to anyone who completes a consent
8569
+ * screen. Defaulting it on would mean every existing deployment starts doing
8570
+ * that at the next upgrade, because a library was updated — nobody would have
8571
+ * decided it, and most operators would not know it had happened. "A surface
8572
+ * added later is mounted by every existing deployment without anyone editing a
8573
+ * list" is precisely what must NOT happen here.
8574
+ *
8575
+ * So the compatibility story for this surface is inverted: an operator opts in
8576
+ * once, and everything else keeps behaving exactly as it did.
8577
+ */
8578
+ var DEFAULT_OFF = ["mcp"];
8579
+ /**
8580
+ * Resolve a partial surface set against the default, which is everything on
8581
+ * except {@link DEFAULT_OFF}.
8582
+ *
8583
+ * Defaulting to on rather than off is the compatibility story: a caller that
8584
+ * says nothing gets the process this server has always booted, and a surface
8585
+ * added later is mounted by every existing deployment without anyone editing a
8586
+ * list. See {@link DEFAULT_OFF} for the one place that rule is wrong and why.
8403
8587
  */
8404
8588
  function resolveSurfaces(options) {
8405
8589
  const resolved = {};
8406
- for (const surface of ALL_RUNTIME_SURFACES) resolved[surface] = options?.[surface] ?? true;
8590
+ for (const surface of ALL_RUNTIME_SURFACES) resolved[surface] = options?.[surface] ?? !DEFAULT_OFF.includes(surface);
8407
8591
  return resolved;
8408
8592
  }
8409
8593
  /** Resolve partial ownership against the default, which is owning everything. */
@@ -8414,10 +8598,62 @@ function resolveOwnership(options) {
8414
8598
  rlsAudit: options?.rlsAudit ?? true
8415
8599
  };
8416
8600
  }
8417
- /** The surfaces a process is not serving, for a boot log line. */
8601
+ /** The surfaces a process is not serving. Literal: off is off, however it got there. */
8418
8602
  function disabledSurfaces(resolved) {
8419
8603
  return ALL_RUNTIME_SURFACES.filter((surface) => !resolved[surface]);
8420
8604
  }
8605
+ /**
8606
+ * The surfaces whose absence is worth a line in the boot log.
8607
+ *
8608
+ * NOT the same question as {@link disabledSurfaces}, and conflating them made
8609
+ * every deployment in the fleet announce itself as partial.
8610
+ *
8611
+ * The line exists to tell one thing from another: a request answering 404
8612
+ * because this process was never meant to serve it is, from the client side,
8613
+ * indistinguishable from a broken deployment. That is worth saying — about a
8614
+ * surface somebody TURNED OFF.
8615
+ *
8616
+ * A {@link DEFAULT_OFF} surface is a different fact. Nobody turned `mcp` off;
8617
+ * it has never been on, and a deployment without it is not partial, it is the
8618
+ * deployment. Counting it made `offSurfaces` non-empty on every single boot —
8619
+ * managed tenant, self-hosted container, local dev — so a line that means
8620
+ * "somebody trimmed this process" started appearing on processes nobody had
8621
+ * trimmed. `split-roles-e2e.test.ts` calls this "the compatibility assertion for
8622
+ * the whole feature", and it has been failing since the MCP surface landed.
8623
+ *
8624
+ * An operator who explicitly disables a default-off surface still gets no line,
8625
+ * which is right: the process serves exactly what it would have served anyway.
8626
+ */
8627
+ function trimmedSurfaces(resolved) {
8628
+ return disabledSurfaces(resolved).filter((surface) => !DEFAULT_OFF.includes(surface));
8629
+ }
8630
+ //#endregion
8631
+ //#region src/init/callback-client.ts
8632
+ /**
8633
+ * Give every data source's driver the server client its callbacks read as
8634
+ * `context.client`.
8635
+ *
8636
+ * A driver is created before the client — the client depends on the mounted
8637
+ * Hono app — so the client is injected afterwards. It used to be injected into
8638
+ * the DEFAULT driver only. Each declared `database(...)` gets a bootstrapper and
8639
+ * a driver of its own, so a collection on a second source ran its callbacks with
8640
+ * `context.client === undefined`, typed as present through a cast in the driver:
8641
+ * a `beforeSave` calling `context.client.functions.invoke(...)` worked on the
8642
+ * default database and threw on the other one.
8643
+ *
8644
+ * Returns how many drivers took the client, which is what a test can hold.
8645
+ */
8646
+ function injectCallbackClient(results, client) {
8647
+ let injected = 0;
8648
+ for (const result of results) {
8649
+ const driver = result.internals?.driver;
8650
+ if (driver && "client" in driver) {
8651
+ driver.client = client;
8652
+ injected++;
8653
+ }
8654
+ }
8655
+ return injected;
8656
+ }
8421
8657
  //#endregion
8422
8658
  //#region src/init/process-safety.ts
8423
8659
  /**
@@ -8467,6 +8703,1750 @@ function installUnhandledRejectionHandler() {
8467
8703
  };
8468
8704
  }
8469
8705
  //#endregion
8706
+ //#region src/mcp/oauth-store.ts
8707
+ var CLIENTS = "rebase.oauth_clients";
8708
+ var CODES = "rebase.oauth_authorization_codes";
8709
+ var REFRESH = "rebase.oauth_refresh_tokens";
8710
+ var CONSENTS = "rebase.oauth_consents";
8711
+ /** Every table this store owns, for the revoke sweep and for tests. */
8712
+ var OAUTH_TABLES = [
8713
+ CLIENTS,
8714
+ CODES,
8715
+ REFRESH,
8716
+ CONSENTS
8717
+ ];
8718
+ /**
8719
+ * How long an authorization code lives.
8720
+ *
8721
+ * OAuth 2.1 says a code SHOULD be short-lived and names one minute as the
8722
+ * benchmark. The code never leaves the user-agent's redirect hop, so there is
8723
+ * no legitimate flow that needs longer, and a code sitting in browser history
8724
+ * or a proxy log stops being useful within a minute of being written there.
8725
+ */
8726
+ var AUTHORIZATION_CODE_TTL_MS = 6e4;
8727
+ /**
8728
+ * A store backed by the driver's SQL escape hatch.
8729
+ *
8730
+ * Returns null when the driver cannot run SQL — Mongo, for one. The OAuth
8731
+ * surface is then not mounted at all rather than half-mounted: an authorization
8732
+ * server that cannot persist a code is not a degraded authorization server, it
8733
+ * is one that hands out credentials it cannot check.
8734
+ */
8735
+ function createOAuthStore(driver) {
8736
+ const admin = driver.admin;
8737
+ if (!isSQLAdmin(admin)) return null;
8738
+ const exec = (sql, params) => admin.executeSql(sql, params ? { params } : void 0);
8739
+ const ddl = createDdlBootstrapper((sql) => exec(sql), "oauth-store");
8740
+ return {
8741
+ async ensureTables() {
8742
+ await ddl.ensureObject(CLIENTS, `
8743
+ CREATE TABLE IF NOT EXISTS ${CLIENTS} (
8744
+ client_id text PRIMARY KEY,
8745
+ client_secret_hash text,
8746
+ client_name text NOT NULL,
8747
+ redirect_uris text[] NOT NULL,
8748
+ grant_types text[] NOT NULL,
8749
+ scope text NOT NULL,
8750
+ token_endpoint_auth_method text NOT NULL,
8751
+ created_at timestamptz NOT NULL DEFAULT now()
8752
+ )
8753
+ `);
8754
+ await ddl.ensureObject(CODES, `
8755
+ CREATE TABLE IF NOT EXISTS ${CODES} (
8756
+ code_hash text PRIMARY KEY,
8757
+ client_id text NOT NULL,
8758
+ uid text NOT NULL,
8759
+ roles text[] NOT NULL DEFAULT '{}',
8760
+ redirect_uri text NOT NULL,
8761
+ code_challenge text NOT NULL,
8762
+ code_challenge_method text NOT NULL,
8763
+ scope text NOT NULL,
8764
+ resource text NOT NULL,
8765
+ expires_at timestamptz NOT NULL,
8766
+ consumed_at timestamptz
8767
+ )
8768
+ `);
8769
+ await ddl.ensureObject(REFRESH, `
8770
+ CREATE TABLE IF NOT EXISTS ${REFRESH} (
8771
+ token_hash text PRIMARY KEY,
8772
+ family text NOT NULL,
8773
+ client_id text NOT NULL,
8774
+ uid text NOT NULL,
8775
+ roles text[] NOT NULL DEFAULT '{}',
8776
+ scope text NOT NULL,
8777
+ resource text NOT NULL,
8778
+ issued_at timestamptz NOT NULL DEFAULT now(),
8779
+ expires_at timestamptz NOT NULL,
8780
+ consumed_at timestamptz,
8781
+ revoked_at timestamptz
8782
+ )
8783
+ `);
8784
+ await ddl.ensureObject(`${REFRESH} family index`, `
8785
+ CREATE INDEX IF NOT EXISTS oauth_refresh_family_idx ON ${REFRESH} (family)
8786
+ `);
8787
+ await ddl.ensureObject(CONSENTS, `
8788
+ CREATE TABLE IF NOT EXISTS ${CONSENTS} (
8789
+ uid text NOT NULL,
8790
+ client_id text NOT NULL,
8791
+ scope text NOT NULL,
8792
+ granted_at timestamptz NOT NULL DEFAULT now(),
8793
+ PRIMARY KEY (uid, client_id)
8794
+ )
8795
+ `);
8796
+ for (const table of OAUTH_TABLES) {
8797
+ const [schema, name] = table.split(".");
8798
+ await ddl.step(`revoke ${table}`, () => exec(revokeInternalTableSql(schema, name)));
8799
+ }
8800
+ await ddl.step("sweep expired codes", () => exec(`DELETE FROM ${CODES} WHERE expires_at < now() - interval '1 hour'`));
8801
+ await ddl.step("sweep expired refresh tokens", () => exec(`DELETE FROM ${REFRESH} WHERE expires_at < now() - interval '30 days'`));
8802
+ const missing = [];
8803
+ for (const table of OAUTH_TABLES) if ((await exec(`SELECT to_regclass($1) AS present`, [table]))[0]?.present == null) missing.push(table);
8804
+ if (missing.length > 0) throw new Error(`The OAuth tables were not created: ${missing.join(", ")}. The MCP surface cannot be served without them — check the boot log above for the statement that failed, which is usually a permissions problem on the \`rebase\` schema.`);
8805
+ },
8806
+ async registerClient(client) {
8807
+ await exec(`INSERT INTO ${CLIENTS}
8808
+ (client_id, client_secret_hash, client_name, redirect_uris,
8809
+ grant_types, scope, token_endpoint_auth_method)
8810
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
8811
+ client.clientId,
8812
+ client.clientSecretHash,
8813
+ client.clientName,
8814
+ client.redirectUris,
8815
+ client.grantTypes,
8816
+ client.scope,
8817
+ client.tokenEndpointAuthMethod
8818
+ ]);
8819
+ },
8820
+ async getClient(clientId) {
8821
+ const row = (await exec(`SELECT * FROM ${CLIENTS} WHERE client_id = $1`, [clientId]))[0];
8822
+ if (!row) return null;
8823
+ return {
8824
+ clientId: String(row.client_id),
8825
+ clientSecretHash: row.client_secret_hash == null ? null : String(row.client_secret_hash),
8826
+ clientName: String(row.client_name),
8827
+ redirectUris: toStringArray(row.redirect_uris),
8828
+ grantTypes: toStringArray(row.grant_types),
8829
+ scope: String(row.scope),
8830
+ tokenEndpointAuthMethod: String(row.token_endpoint_auth_method)
8831
+ };
8832
+ },
8833
+ async countClients() {
8834
+ const rows = await exec(`SELECT count(*)::int AS n FROM ${CLIENTS}`);
8835
+ return Number(rows[0]?.n ?? 0);
8836
+ },
8837
+ async saveAuthorizationCode(code, record, expiresAt) {
8838
+ await exec(`INSERT INTO ${CODES}
8839
+ (code_hash, client_id, uid, roles, redirect_uri,
8840
+ code_challenge, code_challenge_method, scope, resource, expires_at)
8841
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [
8842
+ await sha256Hex(code),
8843
+ record.clientId,
8844
+ record.uid,
8845
+ record.roles,
8846
+ record.redirectUri,
8847
+ record.codeChallenge,
8848
+ record.codeChallengeMethod,
8849
+ record.scope,
8850
+ record.resource,
8851
+ expiresAt.toISOString()
8852
+ ]);
8853
+ },
8854
+ async consumeAuthorizationCode(code) {
8855
+ const row = (await exec(`UPDATE ${CODES}
8856
+ SET consumed_at = now()
8857
+ WHERE code_hash = $1
8858
+ AND consumed_at IS NULL
8859
+ AND expires_at > now()
8860
+ RETURNING client_id, uid, roles, redirect_uri,
8861
+ code_challenge, code_challenge_method, scope, resource`, [await sha256Hex(code)]))[0];
8862
+ if (!row) return null;
8863
+ return {
8864
+ clientId: String(row.client_id),
8865
+ uid: String(row.uid),
8866
+ roles: toStringArray(row.roles),
8867
+ redirectUri: String(row.redirect_uri),
8868
+ codeChallenge: String(row.code_challenge),
8869
+ codeChallengeMethod: String(row.code_challenge_method),
8870
+ scope: String(row.scope),
8871
+ resource: String(row.resource)
8872
+ };
8873
+ },
8874
+ async saveRefreshToken(token, record, expiresAt) {
8875
+ await exec(`INSERT INTO ${REFRESH}
8876
+ (token_hash, family, client_id, uid, roles, scope, resource, expires_at)
8877
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
8878
+ await sha256Hex(token),
8879
+ record.family,
8880
+ record.clientId,
8881
+ record.uid,
8882
+ record.roles,
8883
+ record.scope,
8884
+ record.resource,
8885
+ expiresAt.toISOString()
8886
+ ]);
8887
+ },
8888
+ async consumeRefreshToken(token) {
8889
+ const hash = await sha256Hex(token);
8890
+ const row = (await exec(`UPDATE ${REFRESH}
8891
+ SET consumed_at = now()
8892
+ WHERE token_hash = $1
8893
+ AND consumed_at IS NULL
8894
+ AND revoked_at IS NULL
8895
+ AND expires_at > now()
8896
+ RETURNING family, client_id, uid, roles, scope, resource`, [hash]))[0];
8897
+ if (row) return {
8898
+ family: String(row.family),
8899
+ clientId: String(row.client_id),
8900
+ uid: String(row.uid),
8901
+ roles: toStringArray(row.roles),
8902
+ scope: String(row.scope),
8903
+ resource: String(row.resource)
8904
+ };
8905
+ const family = (await exec(`SELECT family FROM ${REFRESH} WHERE token_hash = $1 AND consumed_at IS NOT NULL`, [hash]))[0]?.family;
8906
+ if (family) {
8907
+ logger.error("[oauth] Refresh token replayed — revoking the whole family", { family });
8908
+ await exec(`UPDATE ${REFRESH} SET revoked_at = now() WHERE family = $1 AND revoked_at IS NULL`, [String(family)]);
8909
+ }
8910
+ return null;
8911
+ },
8912
+ async revokeFamily(family) {
8913
+ await exec(`UPDATE ${REFRESH} SET revoked_at = now() WHERE family = $1 AND revoked_at IS NULL`, [family]);
8914
+ },
8915
+ async revokeTokenForClient(token, clientId) {
8916
+ return (await exec(`UPDATE ${REFRESH}
8917
+ SET revoked_at = now()
8918
+ WHERE revoked_at IS NULL
8919
+ AND family = (
8920
+ SELECT family FROM ${REFRESH}
8921
+ WHERE token_hash = $1 AND client_id = $2
8922
+ )
8923
+ RETURNING token_hash`, [await sha256Hex(token), clientId])).length > 0;
8924
+ },
8925
+ async recordConsent(uid, clientId, scope) {
8926
+ await exec(`INSERT INTO ${CONSENTS} (uid, client_id, scope)
8927
+ VALUES ($1, $2, $3)
8928
+ ON CONFLICT (uid, client_id)
8929
+ DO UPDATE SET scope = EXCLUDED.scope, granted_at = now()`, [
8930
+ uid,
8931
+ clientId,
8932
+ scope
8933
+ ]);
8934
+ },
8935
+ async listGrants(uid) {
8936
+ return (await exec(`SELECT c.client_id,
8937
+ COALESCE(cl.client_name, '(unknown application)') AS client_name,
8938
+ c.scope,
8939
+ c.granted_at,
8940
+ (SELECT count(*)::int
8941
+ FROM ${REFRESH} r
8942
+ WHERE r.uid = c.uid
8943
+ AND r.client_id = c.client_id
8944
+ AND r.revoked_at IS NULL
8945
+ AND r.consumed_at IS NULL
8946
+ AND r.expires_at > now()) AS active_tokens
8947
+ FROM ${CONSENTS} c
8948
+ LEFT JOIN ${CLIENTS} cl ON cl.client_id = c.client_id
8949
+ WHERE c.uid = $1
8950
+ ORDER BY c.granted_at DESC`, [uid])).map((row) => ({
8951
+ clientId: String(row.client_id),
8952
+ clientName: String(row.client_name),
8953
+ scope: String(row.scope),
8954
+ grantedAt: new Date(String(row.granted_at)).toISOString(),
8955
+ activeTokens: Number(row.active_tokens ?? 0)
8956
+ }));
8957
+ },
8958
+ async revokeGrant(uid, clientId) {
8959
+ const revoked = await exec(`UPDATE ${REFRESH}
8960
+ SET revoked_at = now()
8961
+ WHERE uid = $1 AND client_id = $2 AND revoked_at IS NULL
8962
+ RETURNING token_hash`, [uid, clientId]);
8963
+ const consent = await exec(`DELETE FROM ${CONSENTS} WHERE uid = $1 AND client_id = $2 RETURNING client_id`, [uid, clientId]);
8964
+ const found = revoked.length > 0 || consent.length > 0;
8965
+ if (found) logger.info("[oauth] Grant revoked by the user", {
8966
+ uid,
8967
+ clientId,
8968
+ refreshTokensRevoked: revoked.length
8969
+ });
8970
+ return found;
8971
+ }
8972
+ };
8973
+ }
8974
+ /**
8975
+ * Read a `text[]` column back as strings.
8976
+ *
8977
+ * Drivers disagree about array columns: `pg` parses them into a JS array, while
8978
+ * a driver that goes through a generic JSON path can hand back the Postgres
8979
+ * literal `{a,b}`. Normalising here keeps that disagreement out of every caller.
8980
+ */
8981
+ function toStringArray(value) {
8982
+ if (Array.isArray(value)) return value.map(String);
8983
+ if (typeof value === "string") {
8984
+ const inner = value.replace(/^\{|\}$/g, "");
8985
+ if (!inner) return [];
8986
+ return inner.split(",").map((s) => s.replace(/^"|"$/g, ""));
8987
+ }
8988
+ return [];
8989
+ }
8990
+ //#endregion
8991
+ //#region src/mcp/oauth-metadata.ts
8992
+ /**
8993
+ * The discovery documents and the small pure rules the OAuth surface is built
8994
+ * from: canonical URIs, PKCE, scopes, redirect-URI matching.
8995
+ *
8996
+ * Everything here is a function of its arguments. That is deliberate — these
8997
+ * are the parts a specification pins down exactly, and the parts where being
8998
+ * approximately right is indistinguishable from being wrong until a client
8999
+ * refuses to connect. Keeping them free of Hono, of the database and of the
9000
+ * server's config means the tests can state the spec's rules directly.
9001
+ *
9002
+ * References, because every rule below traces to one:
9003
+ * RFC 9728 OAuth 2.0 Protected Resource Metadata
9004
+ * RFC 8414 OAuth 2.0 Authorization Server Metadata
9005
+ * RFC 7591 Dynamic Client Registration
9006
+ * RFC 8707 Resource Indicators
9007
+ * RFC 7636 PKCE
9008
+ * OAuth 2.1 draft-ietf-oauth-v2-1
9009
+ */
9010
+ /**
9011
+ * What a token may do at `/mcp`.
9012
+ *
9013
+ * Two, and no more. RFC 9728 asks `scopes_supported` to be the minimal set that
9014
+ * makes the resource usable, and the MCP specification's scope-minimization
9015
+ * guidance points the same way: a client that only needs to read should be able
9016
+ * to ask for exactly that, and a consent screen listing eleven fine-grained
9017
+ * permissions is one nobody reads.
9018
+ *
9019
+ * They are not a substitute for RLS. A `mcp:read` token still reads through the
9020
+ * user's own policies — the scope decides whether the tool exists for this
9021
+ * session, the database decides which rows come back.
9022
+ */
9023
+ var MCP_SCOPES = ["mcp:read", "mcp:write"];
9024
+ /** The scope a client gets when it asks for nothing in particular. */
9025
+ var DEFAULT_MCP_SCOPE = "mcp:read";
9026
+ /**
9027
+ * The canonical resource identifier for this server's MCP endpoint.
9028
+ *
9029
+ * RFC 8707 §2 and the MCP specification agree on the shape: an absolute URI,
9030
+ * lowercase scheme and host, no fragment, and — where the spec expresses a
9031
+ * preference — no trailing slash. Clients send this back as the `resource`
9032
+ * parameter and the token's audience is bound to it, so a value that disagrees
9033
+ * with what the client computed rejects every token. It is worth being fussy.
9034
+ */
9035
+ function canonicalResourceUri(publicUrl, mcpPath = "/mcp") {
9036
+ const url = new URL(publicUrl);
9037
+ url.hash = "";
9038
+ url.search = "";
9039
+ url.protocol = url.protocol.toLowerCase();
9040
+ url.hostname = url.hostname.toLowerCase();
9041
+ const base = url.origin;
9042
+ const path = mcpPath.startsWith("/") ? mcpPath : `/${mcpPath}`;
9043
+ return path === "/" ? base : `${base}${path.replace(/\/+$/, "")}`;
9044
+ }
9045
+ /** The issuer identifier: the origin, with no path. */
9046
+ function issuerFor(publicUrl) {
9047
+ return new URL(publicUrl).origin;
9048
+ }
9049
+ /**
9050
+ * Where the protected-resource metadata for a given resource lives.
9051
+ *
9052
+ * RFC 9728 §3.1 inserts `.well-known/oauth-protected-resource` **between** the
9053
+ * host and the resource's own path — `https://h/mcp` is described at
9054
+ * `https://h/.well-known/oauth-protected-resource/mcp`, not at
9055
+ * `…/mcp/.well-known/…`. Getting this backwards is a 404 the client reports as
9056
+ * "no authorization server", which points at everything except the path.
9057
+ */
9058
+ function protectedResourceMetadataPath(mcpPath = "/mcp") {
9059
+ const path = mcpPath.startsWith("/") ? mcpPath : `/${mcpPath}`;
9060
+ return path === "/" ? "/.well-known/oauth-protected-resource" : `/.well-known/oauth-protected-resource${path.replace(/\/+$/, "")}`;
9061
+ }
9062
+ function protectedResourceMetadata(publicUrl, mcpPath = "/mcp") {
9063
+ return {
9064
+ resource: canonicalResourceUri(publicUrl, mcpPath),
9065
+ authorization_servers: [issuerFor(publicUrl)],
9066
+ scopes_supported: [...MCP_SCOPES],
9067
+ bearer_methods_supported: ["header"],
9068
+ resource_documentation: "https://rebase.pro/docs/ai/mcp"
9069
+ };
9070
+ }
9071
+ function authorizationServerMetadata(publicUrl, oauthBasePath) {
9072
+ const issuer = issuerFor(publicUrl);
9073
+ const base = `${issuer}${oauthBasePath}`;
9074
+ return {
9075
+ issuer,
9076
+ authorization_endpoint: `${base}/authorize`,
9077
+ token_endpoint: `${base}/token`,
9078
+ registration_endpoint: `${base}/register`,
9079
+ revocation_endpoint: `${base}/revoke`,
9080
+ revocation_endpoint_auth_methods_supported: [
9081
+ "client_secret_post",
9082
+ "client_secret_basic",
9083
+ "none"
9084
+ ],
9085
+ scopes_supported: [...MCP_SCOPES],
9086
+ response_types_supported: ["code"],
9087
+ grant_types_supported: ["authorization_code", "refresh_token"],
9088
+ code_challenge_methods_supported: ["S256"],
9089
+ token_endpoint_auth_methods_supported: [
9090
+ "client_secret_post",
9091
+ "client_secret_basic",
9092
+ "none"
9093
+ ],
9094
+ authorization_response_iss_parameter_supported: true,
9095
+ service_documentation: "https://rebase.pro/docs/ai/mcp"
9096
+ };
9097
+ }
9098
+ /**
9099
+ * Verify a PKCE code verifier against the challenge recorded at authorize time.
9100
+ *
9101
+ * RFC 7636 §4.6: BASE64URL(SHA256(ASCII(verifier))) === challenge, with the
9102
+ * base64url unpadded. Only S256 is accepted — `plain` compares the verifier to
9103
+ * itself, which protects against nothing once the code has leaked, and OAuth
9104
+ * 2.1 removes it.
9105
+ */
9106
+ async function verifyPkce(verifier, challenge, method) {
9107
+ if (method !== "S256") return false;
9108
+ if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)) return false;
9109
+ return base64UrlEncode(await sha256Bytes(verifier)) === challenge;
9110
+ }
9111
+ /** Unpadded base64url, as every one of these RFCs means it. */
9112
+ function base64UrlEncode(bytes) {
9113
+ let binary = "";
9114
+ for (const byte of bytes) binary += String.fromCharCode(byte);
9115
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9116
+ }
9117
+ /**
9118
+ * Is `candidate` one of the client's registered redirect URIs?
9119
+ *
9120
+ * Exact string comparison, which is what OAuth 2.1 §4.1.2.1 requires and what
9121
+ * closes open redirection. No prefix matching, no ignoring the query, no
9122
+ * "same origin is close enough" — every one of those has been the published
9123
+ * root cause of a token-stealing redirect, because an attacker who controls any
9124
+ * path or parameter on a permitted origin controls where the code lands.
9125
+ *
9126
+ * The one concession is loopback: RFC 8252 §7.3 requires the port to be ignored
9127
+ * for `http://127.0.0.1` and `http://[::1]`, because a native client binds an
9128
+ * ephemeral port it cannot know at registration time. `localhost` is
9129
+ * deliberately NOT included — RFC 8252 §8.3 recommends against it, since it
9130
+ * resolves through a name service an attacker may influence.
9131
+ */
9132
+ function redirectUriAllowed(candidate, registered) {
9133
+ if (registered.includes(candidate)) return true;
9134
+ let url;
9135
+ try {
9136
+ url = new URL(candidate);
9137
+ } catch {
9138
+ return false;
9139
+ }
9140
+ if (url.protocol !== "http:") return false;
9141
+ if (url.hostname !== "127.0.0.1" && url.hostname !== "[::1]" && url.hostname !== "::1") return false;
9142
+ return registered.some((entry) => {
9143
+ let known;
9144
+ try {
9145
+ known = new URL(entry);
9146
+ } catch {
9147
+ return false;
9148
+ }
9149
+ return known.protocol === url.protocol && known.hostname === url.hostname && known.pathname === url.pathname && known.search === url.search;
9150
+ });
9151
+ }
9152
+ /**
9153
+ * Narrow a requested scope to what this server issues.
9154
+ *
9155
+ * An unknown scope is dropped rather than refused. RFC 6749 §3.3 allows either,
9156
+ * and dropping is what keeps a client that asks for `openid profile email` out
9157
+ * of habit from failing to connect at all — it gets the MCP scopes it is
9158
+ * entitled to and none of the ones it invented.
9159
+ */
9160
+ function narrowScope(requested) {
9161
+ if (!requested) return DEFAULT_MCP_SCOPE;
9162
+ const granted = requested.split(/\s+/).filter(Boolean).filter((s) => MCP_SCOPES.includes(s));
9163
+ return granted.length ? [...new Set(granted)].join(" ") : DEFAULT_MCP_SCOPE;
9164
+ }
9165
+ /** Does a granted scope string carry this permission? */
9166
+ function scopeAllows(scope, needed) {
9167
+ return scope.split(/\s+/).filter(Boolean).includes(needed);
9168
+ }
9169
+ /**
9170
+ * The `WWW-Authenticate` value for an unauthenticated request.
9171
+ *
9172
+ * RFC 6750 §3 plus the MCP specification's requirement that the challenge point
9173
+ * at the protected-resource metadata: a client with no token discovers the
9174
+ * entire authorization server from this one header, so an omitted
9175
+ * `resource_metadata` is what makes a server look like it has no auth at all.
9176
+ */
9177
+ function bearerChallenge(publicUrl, mcpPath, scope) {
9178
+ const parts = [`Bearer resource_metadata="${`${issuerFor(publicUrl)}${protectedResourceMetadataPath(mcpPath)}`}"`];
9179
+ if (scope) parts.push(`scope="${scope}"`);
9180
+ return parts.join(", ");
9181
+ }
9182
+ /** The 403 challenge for a token that is valid but too narrow. */
9183
+ function insufficientScopeChallenge(publicUrl, mcpPath, needed, description) {
9184
+ const metadataUrl = `${issuerFor(publicUrl)}${protectedResourceMetadataPath(mcpPath)}`;
9185
+ return [
9186
+ `Bearer error="insufficient_scope"`,
9187
+ `scope="${needed}"`,
9188
+ `resource_metadata="${metadataUrl}"`,
9189
+ `error_description="${description.replace(/"/g, "'")}"`
9190
+ ].join(", ");
9191
+ }
9192
+ /**
9193
+ * Does the `resource` a client asked for name this server?
9194
+ *
9195
+ * RFC 8707 lets a client send a resource more specific than the one the server
9196
+ * advertises, so this compares on origin plus path prefix rather than equality
9197
+ * — `https://h/mcp` accepts `https://h/mcp` and rejects `https://h/mcpx` and
9198
+ * anything on another origin. Rejecting is the point: a token minted for
9199
+ * another audience must never be usable here, and the check that stops that
9200
+ * starts at the authorize request.
9201
+ */
9202
+ function resourceMatches(requested, canonical) {
9203
+ let asked;
9204
+ try {
9205
+ asked = new URL(requested);
9206
+ } catch {
9207
+ return false;
9208
+ }
9209
+ if (asked.hash) return false;
9210
+ const mine = new URL(canonical);
9211
+ if (asked.origin.toLowerCase() !== mine.origin.toLowerCase()) return false;
9212
+ const askedPath = asked.pathname.replace(/\/+$/, "");
9213
+ const minePath = mine.pathname.replace(/\/+$/, "");
9214
+ return askedPath === minePath || askedPath.startsWith(`${minePath}/`);
9215
+ }
9216
+ //#endregion
9217
+ //#region src/mcp/consent-page.ts
9218
+ /**
9219
+ * The headers a consent screen must carry.
9220
+ *
9221
+ * `frame-ancestors 'none'` and `X-Frame-Options` are the ones that matter, and
9222
+ * they were missing. A consent screen is the single page in an OAuth flow that
9223
+ * must not be frameable: an attacker who can iframe it, make it transparent and
9224
+ * float a button under the user's cursor gets "Allow" pressed for a client they
9225
+ * registered — clickjacking, with the prize being the victim's data. The user
9226
+ * sees nothing, because the page they consented on was never visible.
9227
+ *
9228
+ * The rest follows from what the page is. `no-store`, because the HTML embeds a
9229
+ * signed authorization request. `no-referrer`, so the `client_id` and `state`
9230
+ * in this URL do not travel to whatever the redirect target loads. `form-action
9231
+ * 'self'`, so an injection cannot repoint the form at its own collector.
9232
+ */
9233
+ function consentPageHeaders(nonce) {
9234
+ return {
9235
+ "Content-Type": "text/html; charset=utf-8",
9236
+ "X-Frame-Options": "DENY",
9237
+ "Content-Security-Policy": [
9238
+ "default-src 'none'",
9239
+ `script-src 'nonce-${nonce}'`,
9240
+ "style-src 'unsafe-inline'",
9241
+ "connect-src 'self'",
9242
+ "form-action 'self'",
9243
+ "frame-ancestors 'none'",
9244
+ "base-uri 'none'"
9245
+ ].join("; "),
9246
+ "Referrer-Policy": "no-referrer",
9247
+ "Cache-Control": "no-store",
9248
+ "X-Content-Type-Options": "nosniff"
9249
+ };
9250
+ }
9251
+ function renderConsentPage(params) {
9252
+ const clientName = escapeHtml$2(params.clientName.slice(0, 120));
9253
+ return `<!doctype html>
9254
+ <html lang="en">
9255
+ <head>
9256
+ <meta charset="utf-8">
9257
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9258
+ <meta name="robots" content="noindex, nofollow">
9259
+ <title>Authorize ${clientName}</title>
9260
+ <style>
9261
+ :root {
9262
+ color-scheme: light dark;
9263
+ --bg: #f6f7f9; --card: #ffffff; --ink: #16181d; --muted: #5b6472;
9264
+ --line: #e3e6ea; --accent: #2f6df6; --danger: #b42318;
9265
+ }
9266
+ @media (prefers-color-scheme: dark) {
9267
+ :root { --bg:#0f1115; --card:#171a21; --ink:#e9ecf1; --muted:#9aa4b2; --line:#262b34; }
9268
+ }
9269
+ * { box-sizing: border-box; }
9270
+ body {
9271
+ margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px;
9272
+ background: var(--bg); color: var(--ink);
9273
+ font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
9274
+ }
9275
+ .card {
9276
+ width: 100%; max-width: 460px; background: var(--card);
9277
+ border: 1px solid var(--line); border-radius: 14px; padding: 28px;
9278
+ }
9279
+ h1 { font-size: 19px; margin: 0 0 6px; letter-spacing: -0.01em; }
9280
+ .resource { color: var(--muted); font-size: 13px; margin: 0 0 20px; word-break: break-all; }
9281
+ ul { margin: 0 0 8px; padding-left: 20px; }
9282
+ li { margin: 6px 0; }
9283
+ li strong { font-weight: 550; }
9284
+ .limits {
9285
+ margin: 18px 0 22px; padding: 12px 14px; border-radius: 9px;
9286
+ background: color-mix(in srgb, var(--accent) 8%, transparent);
9287
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
9288
+ font-size: 13.5px; color: var(--muted);
9289
+ }
9290
+ label { display: block; font-size: 13px; color: var(--muted); margin: 12px 0 5px; }
9291
+ input[type=email], input[type=password] {
9292
+ width: 100%; padding: 9px 11px; border-radius: 8px;
9293
+ border: 1px solid var(--line); background: var(--bg); color: var(--ink); font: inherit;
9294
+ }
9295
+ .row { display: flex; gap: 10px; margin-top: 22px; }
9296
+ button {
9297
+ flex: 1; padding: 10px 14px; border-radius: 8px; font: inherit; font-weight: 550; cursor: pointer;
9298
+ border: 1px solid var(--line); background: transparent; color: var(--ink);
9299
+ }
9300
+ button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
9301
+ button[disabled] { opacity: .55; cursor: progress; }
9302
+ .error { color: var(--danger); font-size: 13px; margin-top: 12px; min-height: 1em; }
9303
+ .who { font-size: 13px; color: var(--muted); margin-top: 4px; }
9304
+ </style>
9305
+ </head>
9306
+ <body>
9307
+ <main class="card">
9308
+ <h1>&ldquo;${clientName}&rdquo; wants access to your account</h1>
9309
+ <p class="resource">${escapeHtml$2(params.resource)}</p>
9310
+
9311
+ <p style="margin:0 0 8px">If you allow it, this application will be able to:</p>
9312
+ <ul>${params.scopeDescriptions.map((s) => `<li><strong>${escapeHtml$2(s.description)}</strong></li>`).join("")}</ul>
9313
+
9314
+ <div class="limits">
9315
+ It will act <strong>as you</strong>, and can never see more than you can:
9316
+ every row it reads is filtered by the same permissions that apply when you
9317
+ use this application yourself. You can disconnect it later, which stops it
9318
+ renewing its access.
9319
+ </div>
9320
+
9321
+ <form id="consent" method="post" action="${escapeAttr(params.decisionUrl)}">
9322
+ <input type="hidden" name="request_token" value="${escapeAttr(params.requestToken)}">
9323
+ <input type="hidden" name="session_token" id="session_token" value="">
9324
+ <input type="hidden" name="decision" id="decision" value="allow">
9325
+
9326
+ <div id="signin">
9327
+ <label for="email">Email</label>
9328
+ <input type="email" id="email" name="email" autocomplete="username" required>
9329
+ <label for="password">Password</label>
9330
+ <input type="password" id="password" name="password" autocomplete="current-password" required>
9331
+ </div>
9332
+ <p class="who" id="who" hidden></p>
9333
+
9334
+ <div class="row">
9335
+ <button type="button" id="deny">Deny</button>
9336
+ <button type="submit" class="primary" id="allow">Allow</button>
9337
+ </div>
9338
+ <p class="error" id="error"></p>
9339
+ </form>
9340
+ </main>
9341
+
9342
+ <script nonce="${escapeAttr(params.nonce)}">
9343
+ (function () {
9344
+ var form = document.getElementById("consent");
9345
+ var email = document.getElementById("email");
9346
+ var password = document.getElementById("password");
9347
+ var sessionToken = document.getElementById("session_token");
9348
+ var decision = document.getElementById("decision");
9349
+ var error = document.getElementById("error");
9350
+ var allow = document.getElementById("allow");
9351
+
9352
+ document.getElementById("deny").addEventListener("click", function () {
9353
+ decision.value = "deny";
9354
+ // The password fields are required, and a denial must not be blocked by
9355
+ // them: refusing is the one answer that should never need a credential.
9356
+ email.removeAttribute("required");
9357
+ password.removeAttribute("required");
9358
+ form.submit();
9359
+ });
9360
+
9361
+ form.addEventListener("submit", function (event) {
9362
+ if (decision.value === "deny") return;
9363
+ if (sessionToken.value) return; // already signed in; let it post
9364
+
9365
+ event.preventDefault();
9366
+ error.textContent = "";
9367
+ allow.disabled = true;
9368
+
9369
+ // The password goes to the EXISTING login endpoint and nowhere else. This
9370
+ // page never posts it to the OAuth surface, which sees only the resulting
9371
+ // session token.
9372
+ fetch(${JSON.stringify(params.loginUrl)}, {
9373
+ method: "POST",
9374
+ headers: { "Content-Type": "application/json" },
9375
+ body: JSON.stringify({ email: email.value, password: password.value })
9376
+ })
9377
+ .then(function (res) { return res.json().then(function (b) { return { ok: res.ok, body: b }; }); })
9378
+ .then(function (r) {
9379
+ // buildAuthResponse returns { user, tokens: { accessToken } }. The
9380
+ // flatter spellings are fallbacks rather than guesses: the login
9381
+ // response passes through applyTransformHook, so a deployment can
9382
+ // legitimately reshape it, and a consent screen that broke because
9383
+ // someone customised their auth response would be a very hard failure
9384
+ // to attribute.
9385
+ //
9386
+ // No backticks anywhere in this script: it lives inside a template
9387
+ // literal, and one would end the string early — which is exactly how
9388
+ // this comment broke the build the first time it was written.
9389
+ var tokens = (r.body && r.body.tokens) || {};
9390
+ var token = tokens.accessToken || (r.body && (r.body.accessToken || r.body.access_token));
9391
+ if (!r.ok || !token) {
9392
+ throw new Error((r.body && (r.body.error || r.body.message)) || "Sign-in failed.");
9393
+ }
9394
+ sessionToken.value = token;
9395
+ form.submit();
9396
+ })
9397
+ .catch(function (err) {
9398
+ allow.disabled = false;
9399
+ error.textContent = err.message || "Sign-in failed.";
9400
+ });
9401
+ });
9402
+ })();
9403
+ <\/script>
9404
+ </body>
9405
+ </html>`;
9406
+ }
9407
+ function escapeHtml$2(value) {
9408
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9409
+ }
9410
+ /**
9411
+ * Escape for an attribute value.
9412
+ *
9413
+ * Same as {@link escapeHtml} today, kept separate because the two have
9414
+ * different jobs and the attribute one must never be relaxed — a client name
9415
+ * containing a quote would otherwise close the attribute and open an event
9416
+ * handler.
9417
+ */
9418
+ function escapeAttr(value) {
9419
+ return escapeHtml$2(value);
9420
+ }
9421
+ //#endregion
9422
+ //#region src/mcp/oauth-routes.ts
9423
+ /**
9424
+ * The OAuth 2.1 authorization server that issues tokens for `/mcp`.
9425
+ *
9426
+ * Three endpoints and a decision handler:
9427
+ *
9428
+ * POST /register RFC 7591 dynamic client registration
9429
+ * GET /authorize the user-facing consent hop
9430
+ * POST /authorize/decision what the consent page posts back
9431
+ * POST /token code and refresh grants
9432
+ *
9433
+ * The one shape worth explaining up front is `/authorize/decision`. This server
9434
+ * already has a login endpoint, a password policy, rate limits and whatever MFA
9435
+ * the deployment configured, and duplicating any of that here would mean a
9436
+ * second credential path with its own bugs. So the consent page never sees a
9437
+ * password: it posts to the EXISTING `${basePath}/auth/login`, gets an ordinary
9438
+ * session token back, and hands that to `/authorize/decision` as proof of who
9439
+ * is consenting. This file authenticates a person exactly once, with the same
9440
+ * function `/api/data` uses, and otherwise deals only in grants.
9441
+ *
9442
+ * The authorization request itself is carried across that hop as a signed,
9443
+ * 10-minute token rather than as form fields. Without that, `/authorize/
9444
+ * decision` would mint a code for whatever `client_id` and `redirect_uri` its
9445
+ * caller typed — an open code-minting endpoint that skips every check
9446
+ * `/authorize` performs.
9447
+ */
9448
+ /** How long an issued MCP access token lives. */
9449
+ var ACCESS_TOKEN_TTL_SECONDS = 3600;
9450
+ /** How long a refresh token lives. Rotated on every use. */
9451
+ var REFRESH_TOKEN_TTL_MS = 720 * 60 * 60 * 1e3;
9452
+ /** How long the signed authorization request survives the login hop. */
9453
+ var AUTHORIZE_REQUEST_TTL_SECONDS = 600;
9454
+ /**
9455
+ * A ceiling on open dynamic registration.
9456
+ *
9457
+ * RFC 7591 registration has to be open for the MCP flow to work at all — the
9458
+ * client is a piece of software the operator has never heard of, and there is
9459
+ * nobody to hand a client ID to in advance. Open means anyone can POST, so the
9460
+ * table is a free write for the internet. The cap turns "unbounded rows" into
9461
+ * "a bounded, boring amount of junk", which is the difference between a nuisance
9462
+ * and a disk-exhaustion vector.
9463
+ */
9464
+ var MAX_REGISTERED_CLIENTS = 5e3;
9465
+ function createOAuthRoutes(config) {
9466
+ const router = new Hono();
9467
+ const { store, publicUrl, mcpPath } = config;
9468
+ const issuer = issuerFor(publicUrl);
9469
+ const canonicalResource = canonicalResourceUri(publicUrl, mcpPath);
9470
+ /**
9471
+ * Registration is an unauthenticated write, so it gets the tightest limit here.
9472
+ *
9473
+ * The cap above bounds the *total* damage; this bounds the rate at which one
9474
+ * source can approach it. Without both, a single host can fill five thousand
9475
+ * rows in a few seconds and every legitimate client that arrives afterwards is
9476
+ * refused — the cap turns into the denial of service rather than the defence
9477
+ * against one. Ten registrations in fifteen minutes is generous for the real
9478
+ * pattern, which is a person connecting an application once.
9479
+ */
9480
+ const registrationLimiter = createRateLimiter({
9481
+ windowMs: 900 * 1e3,
9482
+ limit: 10,
9483
+ message: "Too many client registrations from this address. Try again later."
9484
+ });
9485
+ /**
9486
+ * The token endpoint, which is where a stolen code or refresh token gets spent.
9487
+ *
9488
+ * Looser than registration because a busy deployment legitimately refreshes
9489
+ * often — one token per client per hour, times however many clients a user has
9490
+ * connected, times however many users share an egress IP. 120 in fifteen
9491
+ * minutes leaves that comfortable while making an offline guessing loop against
9492
+ * a code or a client secret pointless: both are 256 bits, so the limit is
9493
+ * belt-and-braces rather than the actual protection.
9494
+ */
9495
+ const tokenLimiter = createRateLimiter({
9496
+ windowMs: 900 * 1e3,
9497
+ limit: 120,
9498
+ message: "Too many token requests, please try again later."
9499
+ });
9500
+ /**
9501
+ * The authorize endpoint and its decision hop.
9502
+ *
9503
+ * Rendering a consent page is cheap, but the decision hop verifies a session
9504
+ * token on every call, and an unauthenticated caller can drive it. Shared
9505
+ * between the two because they are two halves of one flow and a limit that
9506
+ * only covers the cheap half is decorative.
9507
+ */
9508
+ const authorizeLimiter = createRateLimiter({
9509
+ windowMs: 900 * 1e3,
9510
+ limit: 60,
9511
+ message: "Too many authorization requests, please try again later."
9512
+ });
9513
+ router.post("/register", registrationLimiter, async (c) => {
9514
+ if (!config.allowDynamicRegistration) return c.json({
9515
+ error: "access_denied",
9516
+ error_description: "Dynamic client registration is disabled on this deployment."
9517
+ }, 403);
9518
+ let body;
9519
+ try {
9520
+ body = await c.req.json();
9521
+ } catch {
9522
+ return c.json({
9523
+ error: "invalid_client_metadata",
9524
+ error_description: "Body must be JSON."
9525
+ }, 400);
9526
+ }
9527
+ const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : [];
9528
+ if (redirectUris.length === 0) return c.json({
9529
+ error: "invalid_redirect_uri",
9530
+ error_description: "At least one redirect_uri is required."
9531
+ }, 400);
9532
+ for (const uri of redirectUris) {
9533
+ const problem = redirectUriProblem(uri);
9534
+ if (problem) return c.json({
9535
+ error: "invalid_redirect_uri",
9536
+ error_description: `${uri}: ${problem}`
9537
+ }, 400);
9538
+ }
9539
+ if (await store.countClients() >= MAX_REGISTERED_CLIENTS) {
9540
+ logger.error("[oauth] Registration refused: client cap reached", { cap: MAX_REGISTERED_CLIENTS });
9541
+ return c.json({
9542
+ error: "invalid_client_metadata",
9543
+ error_description: "This deployment has reached its registered-client limit."
9544
+ }, 400);
9545
+ }
9546
+ const isPublic = (typeof body.token_endpoint_auth_method === "string" ? body.token_endpoint_auth_method : "client_secret_post") === "none";
9547
+ const clientId = `mcp_${randomHex(16)}`;
9548
+ const clientSecret = isPublic ? null : randomHex(32);
9549
+ const grantTypes = Array.isArray(body.grant_types) && body.grant_types.length ? body.grant_types.map(String).filter((g) => g === "authorization_code" || g === "refresh_token") : ["authorization_code", "refresh_token"];
9550
+ if (!grantTypes.includes("authorization_code")) return c.json({
9551
+ error: "invalid_client_metadata",
9552
+ error_description: "Only authorization_code (with optional refresh_token) is supported."
9553
+ }, 400);
9554
+ const scope = narrowScope(typeof body.scope === "string" ? body.scope : void 0);
9555
+ await store.registerClient({
9556
+ clientId,
9557
+ clientSecretHash: clientSecret ? await sha256Hex(clientSecret) : null,
9558
+ clientName: typeof body.client_name === "string" && body.client_name.trim() ? body.client_name.slice(0, 200) : "Unnamed MCP client",
9559
+ redirectUris,
9560
+ grantTypes,
9561
+ scope,
9562
+ tokenEndpointAuthMethod: isPublic ? "none" : "client_secret_post"
9563
+ });
9564
+ logger.info("[oauth] Registered MCP client", {
9565
+ clientId,
9566
+ redirectUris
9567
+ });
9568
+ return c.json({
9569
+ client_id: clientId,
9570
+ ...clientSecret ? { client_secret: clientSecret } : {},
9571
+ client_id_issued_at: Math.floor(Date.now() / 1e3),
9572
+ ...clientSecret ? { client_secret_expires_at: 0 } : {},
9573
+ redirect_uris: redirectUris,
9574
+ grant_types: grantTypes,
9575
+ response_types: ["code"],
9576
+ token_endpoint_auth_method: isPublic ? "none" : "client_secret_post",
9577
+ scope
9578
+ }, 201);
9579
+ });
9580
+ router.get("/authorize", authorizeLimiter, async (c) => {
9581
+ const q = c.req.query();
9582
+ const clientId = q.client_id;
9583
+ if (!clientId) return authorizeInputError(c, "invalid_request", "client_id is required.");
9584
+ const client = await store.getClient(clientId);
9585
+ if (!client) return authorizeInputError(c, "invalid_client", "Unknown client_id.");
9586
+ const redirectUri = q.redirect_uri;
9587
+ if (!redirectUri) return authorizeInputError(c, "invalid_request", "redirect_uri is required.");
9588
+ if (!redirectUriAllowed(redirectUri, client.redirectUris)) return authorizeInputError(c, "invalid_request", "redirect_uri does not match a registered redirect URI for this client.");
9589
+ const state = q.state;
9590
+ if (state !== void 0 && state.length > 512) return authorizeInputError(c, "invalid_request", "state is too long (512 characters maximum).");
9591
+ const fail = (error, description) => c.redirect(errorRedirect(redirectUri, error, description, state, issuer), 302);
9592
+ if (q.response_type !== "code") return fail("unsupported_response_type", "Only the authorization code flow is supported.");
9593
+ const codeChallenge = q.code_challenge;
9594
+ if (!codeChallenge) return fail("invalid_request", "code_challenge is required (PKCE).");
9595
+ const codeChallengeMethod = q.code_challenge_method ?? "plain";
9596
+ if (codeChallengeMethod !== "S256") return fail("invalid_request", "code_challenge_method must be S256.");
9597
+ const resource = q.resource;
9598
+ if (!resource) return fail("invalid_request", "resource is required (RFC 8707).");
9599
+ if (!resourceMatches(resource, canonicalResource)) return fail("invalid_target", `resource must identify ${canonicalResource}.`);
9600
+ const scope = narrowScope(q.scope);
9601
+ const requestToken = await signPurposeToken("mcp-authorize-request", {
9602
+ clientId,
9603
+ redirectUri,
9604
+ codeChallenge,
9605
+ codeChallengeMethod,
9606
+ scope,
9607
+ resource: canonicalResource,
9608
+ ...state ? { state } : {}
9609
+ }, AUTHORIZE_REQUEST_TTL_SECONDS);
9610
+ const nonce = randomHex(16);
9611
+ return c.body(renderConsentPage({
9612
+ clientName: client.clientName,
9613
+ scope,
9614
+ scopeDescriptions: describeScopes(scope),
9615
+ requestToken,
9616
+ loginUrl: `${config.authBasePath}/login`,
9617
+ decisionUrl: `${c.req.path}/decision`,
9618
+ resource: canonicalResource,
9619
+ nonce
9620
+ }), 200, consentPageHeaders(nonce));
9621
+ });
9622
+ /**
9623
+ * What the consent page posts once the person has signed in and pressed
9624
+ * Allow. Mints the authorization code and sends it home.
9625
+ */
9626
+ router.post("/authorize/decision", authorizeLimiter, async (c) => {
9627
+ const form = await c.req.parseBody();
9628
+ const requestToken = String(form.request_token ?? "");
9629
+ const sessionToken = String(form.session_token ?? "");
9630
+ const approved = String(form.decision ?? "") === "allow";
9631
+ const request = await verifyPurposeToken("mcp-authorize-request", requestToken);
9632
+ if (!request) return c.html("<h1>This authorization request has expired</h1><p>Start again from the application that sent you here.</p>", 400);
9633
+ if (!approved) return c.redirect(errorRedirect(request.redirectUri, "access_denied", "The user declined.", request.state, issuer), 302);
9634
+ const session = await verifyAccessToken(sessionToken);
9635
+ if (!session) return c.redirect(errorRedirect(request.redirectUri, "access_denied", "Not signed in.", request.state, issuer), 302);
9636
+ const code = randomHex(32);
9637
+ await store.saveAuthorizationCode(code, {
9638
+ clientId: request.clientId,
9639
+ uid: session.uid,
9640
+ roles: session.roles ?? [],
9641
+ redirectUri: request.redirectUri,
9642
+ codeChallenge: request.codeChallenge,
9643
+ codeChallengeMethod: request.codeChallengeMethod,
9644
+ scope: request.scope,
9645
+ resource: request.resource
9646
+ }, new Date(Date.now() + AUTHORIZATION_CODE_TTL_MS));
9647
+ await store.recordConsent(session.uid, request.clientId, request.scope);
9648
+ const target = new URL(request.redirectUri);
9649
+ target.searchParams.set("code", code);
9650
+ if (request.state) target.searchParams.set("state", request.state);
9651
+ target.searchParams.set("iss", issuer);
9652
+ logger.info("[oauth] Authorization code issued", {
9653
+ clientId: request.clientId,
9654
+ uid: session.uid,
9655
+ scope: request.scope
9656
+ });
9657
+ return c.redirect(target.toString(), 302);
9658
+ });
9659
+ router.post("/token", tokenLimiter, async (c) => {
9660
+ const form = await c.req.parseBody();
9661
+ const grantType = String(form.grant_type ?? "");
9662
+ const clientId = String(form.client_id ?? "") || basicAuthClientId(c.req.header("authorization"));
9663
+ if (!clientId) return c.json({
9664
+ error: "invalid_client",
9665
+ error_description: "client_id is required."
9666
+ }, 401);
9667
+ const client = await store.getClient(clientId);
9668
+ if (!client) return c.json({
9669
+ error: "invalid_client",
9670
+ error_description: "Unknown client."
9671
+ }, 401);
9672
+ if (client.clientSecretHash) {
9673
+ const presented = String(form.client_secret ?? "") || basicAuthSecret(c.req.header("authorization"));
9674
+ if (!presented || !constantTimeEqual(await sha256Hex(presented), client.clientSecretHash)) return c.json({
9675
+ error: "invalid_client",
9676
+ error_description: "Client authentication failed."
9677
+ }, 401);
9678
+ }
9679
+ if (grantType === "authorization_code") return handleAuthorizationCodeGrant(c, form, client);
9680
+ if (grantType === "refresh_token") return handleRefreshGrant(c, form, client);
9681
+ return c.json({
9682
+ error: "unsupported_grant_type",
9683
+ error_description: "Supported grants: authorization_code, refresh_token."
9684
+ }, 400);
9685
+ });
9686
+ async function handleAuthorizationCodeGrant(c, form, client) {
9687
+ const code = String(form.code ?? "");
9688
+ if (!code) return c.json({
9689
+ error: "invalid_request",
9690
+ error_description: "code is required."
9691
+ }, 400);
9692
+ const record = await store.consumeAuthorizationCode(code);
9693
+ if (!record) return c.json({
9694
+ error: "invalid_grant",
9695
+ error_description: "Code is invalid, expired or already used."
9696
+ }, 400);
9697
+ if (record.clientId !== client.clientId) {
9698
+ logger.error("[oauth] Code redeemed by the wrong client", {
9699
+ mintedFor: record.clientId,
9700
+ presentedBy: client.clientId
9701
+ });
9702
+ return c.json({
9703
+ error: "invalid_grant",
9704
+ error_description: "Code was not issued to this client."
9705
+ }, 400);
9706
+ }
9707
+ if (String(form.redirect_uri ?? "") !== record.redirectUri) return c.json({
9708
+ error: "invalid_grant",
9709
+ error_description: "redirect_uri does not match the authorization request."
9710
+ }, 400);
9711
+ if (!await verifyPkce(String(form.code_verifier ?? ""), record.codeChallenge, record.codeChallengeMethod)) return c.json({
9712
+ error: "invalid_grant",
9713
+ error_description: "PKCE verification failed."
9714
+ }, 400);
9715
+ if (!resourceMatches(form.resource ? String(form.resource) : record.resource, record.resource)) return c.json({
9716
+ error: "invalid_target",
9717
+ error_description: "resource does not match the authorization request."
9718
+ }, 400);
9719
+ return issueTokens(c, {
9720
+ clientId: record.clientId,
9721
+ uid: record.uid,
9722
+ roles: record.roles,
9723
+ scope: record.scope,
9724
+ resource: record.resource,
9725
+ family: randomHex(16)
9726
+ });
9727
+ }
9728
+ async function handleRefreshGrant(c, form, client) {
9729
+ const presented = String(form.refresh_token ?? "");
9730
+ if (!presented) return c.json({
9731
+ error: "invalid_request",
9732
+ error_description: "refresh_token is required."
9733
+ }, 400);
9734
+ const record = await store.consumeRefreshToken(presented);
9735
+ if (!record) return c.json({
9736
+ error: "invalid_grant",
9737
+ error_description: "Refresh token is invalid, expired or revoked."
9738
+ }, 400);
9739
+ if (record.clientId !== client.clientId) {
9740
+ await store.revokeFamily(record.family);
9741
+ return c.json({
9742
+ error: "invalid_grant",
9743
+ error_description: "Refresh token was not issued to this client."
9744
+ }, 400);
9745
+ }
9746
+ const held = new Set(record.scope.split(" ").filter(Boolean));
9747
+ let scope = record.scope;
9748
+ if (form.scope) {
9749
+ const granted = narrowScope(String(form.scope)).split(" ").filter(Boolean).filter((s) => held.has(s));
9750
+ if (granted.length === 0) return c.json({
9751
+ error: "invalid_scope",
9752
+ error_description: "The requested scope is not a subset of the scope originally granted."
9753
+ }, 400);
9754
+ scope = granted.join(" ");
9755
+ }
9756
+ return issueTokens(c, {
9757
+ clientId: record.clientId,
9758
+ uid: record.uid,
9759
+ roles: record.roles,
9760
+ scope,
9761
+ resource: record.resource,
9762
+ family: record.family
9763
+ });
9764
+ }
9765
+ async function issueTokens(c, grant) {
9766
+ const accessToken = await generateMcpAccessToken({
9767
+ uid: grant.uid,
9768
+ roles: grant.roles,
9769
+ scope: grant.scope,
9770
+ clientId: grant.clientId,
9771
+ aud: grant.resource,
9772
+ iss: issuer
9773
+ }, ACCESS_TOKEN_TTL_SECONDS);
9774
+ const refreshToken = randomHex(32);
9775
+ await store.saveRefreshToken(refreshToken, {
9776
+ clientId: grant.clientId,
9777
+ uid: grant.uid,
9778
+ roles: grant.roles,
9779
+ scope: grant.scope,
9780
+ resource: grant.resource,
9781
+ family: grant.family
9782
+ }, new Date(Date.now() + REFRESH_TOKEN_TTL_MS));
9783
+ c.header("Cache-Control", "no-store");
9784
+ c.header("Pragma", "no-cache");
9785
+ return c.json({
9786
+ access_token: accessToken,
9787
+ token_type: "Bearer",
9788
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
9789
+ refresh_token: refreshToken,
9790
+ scope: grant.scope
9791
+ });
9792
+ }
9793
+ /**
9794
+ * Who is asking, from an ORDINARY session token.
9795
+ *
9796
+ * Deliberately not an MCP token. These endpoints are how a person governs
9797
+ * the applications they have authorized, and letting one of those
9798
+ * applications present its own token here would let it enumerate the
9799
+ * others — or revoke them. A grant to read your data is not a grant to
9800
+ * manage your grants.
9801
+ */
9802
+ async function sessionUser(c) {
9803
+ const header = c.req.header("authorization") ?? "";
9804
+ if (!header.toLowerCase().startsWith("bearer ")) return null;
9805
+ const session = await verifyAccessToken(header.slice(7).trim());
9806
+ return session ? { uid: session.uid } : null;
9807
+ }
9808
+ router.get("/grants", async (c) => {
9809
+ const user = await sessionUser(c);
9810
+ if (!user) return c.json({
9811
+ error: "unauthorized",
9812
+ error_description: "Sign in to see connected applications."
9813
+ }, 401);
9814
+ return c.json({ grants: await store.listGrants(user.uid) });
9815
+ });
9816
+ router.delete("/grants/:clientId", async (c) => {
9817
+ const user = await sessionUser(c);
9818
+ if (!user) return c.json({
9819
+ error: "unauthorized",
9820
+ error_description: "Sign in to disconnect an application."
9821
+ }, 401);
9822
+ if (!await store.revokeGrant(user.uid, c.req.param("clientId"))) return c.json({
9823
+ error: "not_found",
9824
+ error_description: "You have not connected that application."
9825
+ }, 404);
9826
+ return c.json({
9827
+ revoked: true,
9828
+ note: "Refresh tokens are revoked immediately. An access token already issued keeps working until it expires, at most one hour."
9829
+ });
9830
+ });
9831
+ /**
9832
+ * RFC 7009 token revocation, for a client retiring its own credential.
9833
+ *
9834
+ * Separate from `/grants` above and answering a different question: this is
9835
+ * a client saying "I am done with this token", where that one is a person
9836
+ * saying "I am done with this application". A client cannot revoke another
9837
+ * client's token — the family it names has to belong to it — and the
9838
+ * response is 200 either way, which RFC 7009 §2.2 requires: an error would
9839
+ * turn this endpoint into an oracle for which tokens exist.
9840
+ */
9841
+ router.post("/revoke", tokenLimiter, async (c) => {
9842
+ const form = await c.req.parseBody();
9843
+ const clientId = String(form.client_id ?? "") || basicAuthClientId(c.req.header("authorization"));
9844
+ const token = String(form.token ?? "");
9845
+ if (clientId && token) {
9846
+ const client = await store.getClient(clientId);
9847
+ if (client) {
9848
+ if (client.clientSecretHash) {
9849
+ const presented = String(form.client_secret ?? "") || basicAuthSecret(c.req.header("authorization"));
9850
+ if (!presented || !constantTimeEqual(await sha256Hex(presented), client.clientSecretHash)) return c.json({ error: "invalid_client" }, 401);
9851
+ }
9852
+ await store.revokeTokenForClient(token, clientId);
9853
+ }
9854
+ }
9855
+ return c.body(null, 200);
9856
+ });
9857
+ return router;
9858
+ }
9859
+ /**
9860
+ * Why a redirect URI cannot be registered, or null if it can.
9861
+ *
9862
+ * Refusing at registration is cheaper than refusing at authorize time, and it
9863
+ * means a stored row is never itself the hazard.
9864
+ */
9865
+ function redirectUriProblem(uri) {
9866
+ let url;
9867
+ try {
9868
+ url = new URL(uri);
9869
+ } catch {
9870
+ return "not an absolute URI";
9871
+ }
9872
+ if (url.hash) return "must not contain a fragment";
9873
+ if (url.protocol === "https:") return null;
9874
+ if (url.protocol === "http:") return url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1" ? null : "http is only allowed for loopback redirects";
9875
+ if (/^[a-z][a-z0-9+.-]*:$/.test(url.protocol) && url.protocol.includes(".")) return null;
9876
+ return `scheme ${url.protocol} is not allowed`;
9877
+ }
9878
+ /** Build the error redirect an authorization failure sends to the client. */
9879
+ function errorRedirect(redirectUri, error, description, state, issuer) {
9880
+ const url = new URL(redirectUri);
9881
+ url.searchParams.set("error", error);
9882
+ url.searchParams.set("error_description", description);
9883
+ if (state) url.searchParams.set("state", state);
9884
+ url.searchParams.set("iss", issuer);
9885
+ return url.toString();
9886
+ }
9887
+ /** An error we must NOT redirect: render it instead. */
9888
+ function authorizeInputError(c, error, description) {
9889
+ return c.html(`<!doctype html><meta charset="utf-8"><title>Authorization error</title><h1>${escapeHtml$1(error)}</h1><p>${escapeHtml$1(description)}</p><p>This request was not redirected back to the application, because the details it sent could not be trusted.</p>`, 400);
9890
+ }
9891
+ function escapeHtml$1(value) {
9892
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9893
+ }
9894
+ /** Human-readable consent lines, one per granted scope. */
9895
+ function describeScopes(scope) {
9896
+ const text = {
9897
+ "mcp:read": "Read data you already have access to",
9898
+ "mcp:write": "Create, change and delete data you already have access to"
9899
+ };
9900
+ return scope.split(" ").filter((s) => MCP_SCOPES.includes(s)).map((s) => ({
9901
+ scope: s,
9902
+ description: text[s]
9903
+ }));
9904
+ }
9905
+ function decodeBasic(header) {
9906
+ if (!header?.startsWith("Basic ")) return null;
9907
+ try {
9908
+ const decoded = atob(header.slice(6));
9909
+ const at = decoded.indexOf(":");
9910
+ if (at < 0) return null;
9911
+ return {
9912
+ id: decodeURIComponent(decoded.slice(0, at)),
9913
+ secret: decodeURIComponent(decoded.slice(at + 1))
9914
+ };
9915
+ } catch {
9916
+ return null;
9917
+ }
9918
+ }
9919
+ function basicAuthClientId(header) {
9920
+ return decodeBasic(header)?.id ?? "";
9921
+ }
9922
+ function basicAuthSecret(header) {
9923
+ return decodeBasic(header)?.secret ?? "";
9924
+ }
9925
+ //#endregion
9926
+ //#region src/mcp/mcp-tools.ts
9927
+ /** How many rows a single call may return, whatever it asks for. */
9928
+ var MAX_ROWS = 200;
9929
+ var DEFAULT_ROWS = 25;
9930
+ /**
9931
+ * Resolve a collection the caller named.
9932
+ *
9933
+ * Refuses anything not in the registry rather than passing the string to the
9934
+ * driver. A collection path is interpolated into a table name downstream, and
9935
+ * "the caller may name any table" is how an integration becomes a way to read
9936
+ * `rebase.oauth_clients`.
9937
+ */
9938
+ function resolveCollection(ctx, raw) {
9939
+ const path = String(raw ?? "");
9940
+ const found = ctx.collections.find((c) => c.slug === path || getCollectionDataPath(c) === path);
9941
+ if (!found) throw new McpToolError(`Unknown collection "${path}". Call list_collections to see what exists.`);
9942
+ return found;
9943
+ }
9944
+ /** An error whose message is safe to hand back to the model. */
9945
+ var McpToolError = class extends Error {};
9946
+ /** The caller's own driver: RLS-scoped, every time, with no way to skip it. */
9947
+ async function scopedDriver(ctx) {
9948
+ return scopeDataDriver(ctx.driver, {
9949
+ uid: ctx.caller.uid,
9950
+ roles: ctx.caller.roles,
9951
+ isAnonymous: false
9952
+ });
9953
+ }
9954
+ function collectionPath(collection) {
9955
+ return getCollectionDataPath(collection);
9956
+ }
9957
+ function clampLimit(raw) {
9958
+ const asked = Number(raw ?? DEFAULT_ROWS);
9959
+ if (!Number.isFinite(asked) || asked <= 0) return DEFAULT_ROWS;
9960
+ return Math.min(Math.floor(asked), MAX_ROWS);
9961
+ }
9962
+ /** A non-negative row offset. `NaN`, `-1` and `"soon"` all mean "from the start". */
9963
+ function clampOffset(raw) {
9964
+ const asked = Number(raw ?? 0);
9965
+ if (!Number.isFinite(asked) || asked <= 0) return 0;
9966
+ return Math.floor(asked);
9967
+ }
9968
+ /**
9969
+ * A field name the collection actually declares.
9970
+ *
9971
+ * Every caller-supplied identifier that reaches the driver goes through here.
9972
+ * `filter` had this check from the start and `orderBy` did not, which is the
9973
+ * same class of gap twice over: an identifier is not a value, so it cannot be
9974
+ * bound as a parameter, and whether it is safe depends entirely on what the
9975
+ * driver does with it. The registry is the only thing that knows which names
9976
+ * are real, so the check belongs here rather than in each driver's hope that
9977
+ * it quoted everything.
9978
+ *
9979
+ * `id` is admitted alongside the declared properties because every collection
9980
+ * has one and none of them declare it.
9981
+ */
9982
+ function assertKnownField(collection, field, what) {
9983
+ const properties = collection.properties ?? {};
9984
+ if (field !== "id" && !(field in properties)) throw new McpToolError(`"${field}" is not a field of ${collectionPath(collection)}, so it cannot be used to ${what}. Known fields: ${["id", ...Object.keys(properties)].join(", ")}.`);
9985
+ return field;
9986
+ }
9987
+ /**
9988
+ * Translate the model's filter object into the driver's filter shape.
9989
+ *
9990
+ * The wire form is `{ field: [op, value] }` — `FilterValues` — and it is
9991
+ * accepted only for fields the collection actually declares. An unknown field
9992
+ * is refused rather than dropped: silently ignoring a filter turns "show me the
9993
+ * unpaid invoices" into "show me every invoice", which is a worse answer than
9994
+ * an error and looks like a correct one.
9995
+ */
9996
+ function buildFilter(collection, raw) {
9997
+ if (raw == null) return void 0;
9998
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new McpToolError("filter must be an object of { field: [operator, value] }.");
9999
+ const filter = {};
10000
+ for (const [field, condition] of Object.entries(raw)) {
10001
+ assertKnownField(collection, field, "filter");
10002
+ if (!Array.isArray(condition) || condition.length !== 2 || typeof condition[0] !== "string") throw new McpToolError(`filter.${field} must be [operator, value], e.g. ["==", "paid"].`);
10003
+ filter[field] = [condition[0], condition[1]];
10004
+ }
10005
+ return filter;
10006
+ }
10007
+ var MCP_TOOLS = [
10008
+ {
10009
+ name: "list_collections",
10010
+ description: "List the collections in this project, with their fields. Call this first — every other tool takes a collection name from here.",
10011
+ requiredScope: "mcp:read",
10012
+ inputSchema: {
10013
+ type: "object",
10014
+ properties: {},
10015
+ additionalProperties: false
10016
+ },
10017
+ async run(_args, ctx) {
10018
+ return { collections: ctx.collections.map((c) => ({
10019
+ name: collectionPath(c),
10020
+ title: c.name,
10021
+ description: c.description ?? null,
10022
+ fields: Object.entries(c.properties ?? {}).filter(([, property]) => !property.excludeFromApi).map(([key, property]) => ({
10023
+ name: key,
10024
+ type: property.type ?? "unknown",
10025
+ title: property.name ?? key,
10026
+ description: property.description ?? null
10027
+ }))
10028
+ })) };
10029
+ }
10030
+ },
10031
+ {
10032
+ name: "query_collection",
10033
+ description: "Read rows from a collection. Returns only rows the signed-in user is allowed to see, so an empty result can mean 'none match' or 'none visible to you'.",
10034
+ requiredScope: "mcp:read",
10035
+ inputSchema: {
10036
+ type: "object",
10037
+ properties: {
10038
+ collection: {
10039
+ type: "string",
10040
+ description: "Collection name from list_collections."
10041
+ },
10042
+ filter: {
10043
+ type: "object",
10044
+ description: "Field conditions, e.g. {\"status\": [\"==\", \"paid\"], \"total\": [\">\", 100]}.",
10045
+ additionalProperties: { type: "array" }
10046
+ },
10047
+ orderBy: {
10048
+ type: "string",
10049
+ description: "Field to sort by."
10050
+ },
10051
+ order: {
10052
+ type: "string",
10053
+ enum: ["asc", "desc"],
10054
+ description: "Sort direction."
10055
+ },
10056
+ limit: {
10057
+ type: "number",
10058
+ description: `Rows to return (max ${MAX_ROWS}).`
10059
+ },
10060
+ offset: {
10061
+ type: "number",
10062
+ description: "Rows to skip."
10063
+ }
10064
+ },
10065
+ required: ["collection"],
10066
+ additionalProperties: false
10067
+ },
10068
+ async run(args, ctx) {
10069
+ const collection = resolveCollection(ctx, args.collection);
10070
+ const driver = await scopedDriver(ctx);
10071
+ const limit = clampLimit(args.limit);
10072
+ const rows = await driver.fetchCollection({
10073
+ path: collectionPath(collection),
10074
+ collection,
10075
+ filter: buildFilter(collection, args.filter),
10076
+ limit,
10077
+ offset: clampOffset(args.offset),
10078
+ orderBy: args.orderBy ? [[assertKnownField(collection, String(args.orderBy), "sort"), args.order === "desc" ? "desc" : "asc"]] : void 0
10079
+ });
10080
+ return {
10081
+ collection: collectionPath(collection),
10082
+ count: rows.length,
10083
+ truncated: rows.length === limit,
10084
+ rows
10085
+ };
10086
+ }
10087
+ },
10088
+ {
10089
+ name: "get_document",
10090
+ description: "Read one row by id. Fails if the signed-in user cannot see it.",
10091
+ requiredScope: "mcp:read",
10092
+ inputSchema: {
10093
+ type: "object",
10094
+ properties: {
10095
+ collection: { type: "string" },
10096
+ id: { type: "string" }
10097
+ },
10098
+ required: ["collection", "id"],
10099
+ additionalProperties: false
10100
+ },
10101
+ async run(args, ctx) {
10102
+ const collection = resolveCollection(ctx, args.collection);
10103
+ const row = await (await scopedDriver(ctx)).fetchOne({
10104
+ path: collectionPath(collection),
10105
+ collection,
10106
+ id: String(args.id)
10107
+ });
10108
+ if (!row) throw new McpToolError(`No row with id "${args.id}" in ${collectionPath(collection)}.`);
10109
+ return row;
10110
+ }
10111
+ },
10112
+ {
10113
+ name: "create_document",
10114
+ description: "Create a row. Subject to the same permissions as creating it in the app.",
10115
+ requiredScope: "mcp:write",
10116
+ inputSchema: {
10117
+ type: "object",
10118
+ properties: {
10119
+ collection: { type: "string" },
10120
+ values: {
10121
+ type: "object",
10122
+ description: "Field values for the new row."
10123
+ },
10124
+ id: {
10125
+ type: "string",
10126
+ description: "Optional explicit id."
10127
+ }
10128
+ },
10129
+ required: ["collection", "values"],
10130
+ additionalProperties: false
10131
+ },
10132
+ async run(args, ctx) {
10133
+ const collection = resolveCollection(ctx, args.collection);
10134
+ const saved = await (await scopedDriver(ctx)).save({
10135
+ path: collectionPath(collection),
10136
+ collection,
10137
+ values: args.values,
10138
+ id: args.id ? String(args.id) : void 0,
10139
+ status: "new"
10140
+ });
10141
+ logger.info("[mcp] Row created", {
10142
+ collection: collectionPath(collection),
10143
+ uid: ctx.caller.uid,
10144
+ clientId: ctx.caller.clientId
10145
+ });
10146
+ return saved;
10147
+ }
10148
+ },
10149
+ {
10150
+ name: "update_document",
10151
+ description: "Change fields on an existing row. Only the fields given are touched.",
10152
+ requiredScope: "mcp:write",
10153
+ inputSchema: {
10154
+ type: "object",
10155
+ properties: {
10156
+ collection: { type: "string" },
10157
+ id: { type: "string" },
10158
+ values: {
10159
+ type: "object",
10160
+ description: "Fields to change."
10161
+ }
10162
+ },
10163
+ required: [
10164
+ "collection",
10165
+ "id",
10166
+ "values"
10167
+ ],
10168
+ additionalProperties: false
10169
+ },
10170
+ async run(args, ctx) {
10171
+ const collection = resolveCollection(ctx, args.collection);
10172
+ const saved = await (await scopedDriver(ctx)).save({
10173
+ path: collectionPath(collection),
10174
+ collection,
10175
+ values: args.values,
10176
+ id: String(args.id),
10177
+ status: "existing"
10178
+ });
10179
+ logger.info("[mcp] Row updated", {
10180
+ collection: collectionPath(collection),
10181
+ id: String(args.id),
10182
+ uid: ctx.caller.uid,
10183
+ clientId: ctx.caller.clientId
10184
+ });
10185
+ return saved;
10186
+ }
10187
+ },
10188
+ {
10189
+ name: "delete_document",
10190
+ description: "Delete a row by id. This cannot be undone.",
10191
+ requiredScope: "mcp:write",
10192
+ inputSchema: {
10193
+ type: "object",
10194
+ properties: {
10195
+ collection: { type: "string" },
10196
+ id: { type: "string" }
10197
+ },
10198
+ required: ["collection", "id"],
10199
+ additionalProperties: false
10200
+ },
10201
+ async run(args, ctx) {
10202
+ const collection = resolveCollection(ctx, args.collection);
10203
+ await (await scopedDriver(ctx)).delete({
10204
+ row: {
10205
+ id: String(args.id),
10206
+ path: collectionPath(collection)
10207
+ },
10208
+ collection
10209
+ });
10210
+ logger.info("[mcp] Row deleted", {
10211
+ collection: collectionPath(collection),
10212
+ id: String(args.id),
10213
+ uid: ctx.caller.uid,
10214
+ clientId: ctx.caller.clientId
10215
+ });
10216
+ return {
10217
+ deleted: true,
10218
+ id: String(args.id)
10219
+ };
10220
+ }
10221
+ }
10222
+ ];
10223
+ /** The tools a caller holding `scope` may see and call. */
10224
+ function toolsForScope(scope) {
10225
+ return MCP_TOOLS.filter((tool) => scopeAllows(scope, tool.requiredScope));
10226
+ }
10227
+ /** Look up a tool by name, honouring scope — an unlisted tool is not callable. */
10228
+ function findTool(name, scope) {
10229
+ return toolsForScope(scope).find((tool) => tool.name === name);
10230
+ }
10231
+ //#endregion
10232
+ //#region src/mcp/mcp-routes.ts
10233
+ /**
10234
+ * The MCP endpoint: an OAuth 2.1 resource server speaking JSON-RPC.
10235
+ *
10236
+ * The protocol subset is deliberately hand-written rather than taken from
10237
+ * `@modelcontextprotocol/sdk`. The SDK's HTTP transport is built around Node's
10238
+ * `req`/`res`, this server is Hono, and the adapter would be more code than the
10239
+ * protocol — which, for a server that offers tools and neither prompts nor
10240
+ * sampling, is `initialize`, `tools/list`, `tools/call` and `ping`. It also
10241
+ * keeps a dependency out of `@rebasepro/server`, whose import graph feeds the
10242
+ * portable functions bundle.
10243
+ *
10244
+ * Streamable HTTP, in its simplest legal form: a POST carrying one JSON-RPC
10245
+ * message, answered with one JSON response. The specification permits a server
10246
+ * to answer `application/json` instead of opening an SSE stream, and nothing
10247
+ * here pushes server-initiated messages, so `GET /mcp` returns 405 with an
10248
+ * `Allow` header rather than pretending to hold a stream open.
10249
+ *
10250
+ * The authorization half is the part that matters, and it runs before any of
10251
+ * the above: no token is 401 with the `WWW-Authenticate` challenge that tells a
10252
+ * client where the authorization server is, a token for the wrong audience is
10253
+ * 401, and a token too narrow for the tool it names is 403 `insufficient_scope`.
10254
+ */
10255
+ /**
10256
+ * The protocol revisions this server implements.
10257
+ *
10258
+ * `initialize` echoes back the client's version when we know it, and our own
10259
+ * latest when we do not — which is what the specification asks for and what
10260
+ * keeps a client one revision ahead from failing the handshake outright.
10261
+ */
10262
+ var SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"];
10263
+ var LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
10264
+ /** JSON-RPC 2.0 error codes, plus the one MCP adds. */
10265
+ var PARSE_ERROR = -32700;
10266
+ var INVALID_REQUEST = -32600;
10267
+ var METHOD_NOT_FOUND = -32601;
10268
+ var INVALID_PARAMS = -32602;
10269
+ var INTERNAL_ERROR = -32603;
10270
+ /**
10271
+ * The `.well-known` documents.
10272
+ *
10273
+ * Mounted at the ROOT, not under `basePath`, because RFC 8414 and RFC 9728 both
10274
+ * define the path relative to the origin — the same reason `jwks.json` sits
10275
+ * there. A client fetches these before it has any token, so they are public and
10276
+ * uncredentialed by design.
10277
+ */
10278
+ function createMcpWellKnownRoutes(config) {
10279
+ const wellKnown = new Hono();
10280
+ const resourcePath = protectedResourceMetadataPath(config.mcpPath);
10281
+ const serveResourceMetadata = (c) => c.json(protectedResourceMetadata(config.publicUrl, config.mcpPath));
10282
+ wellKnown.get(resourcePath, serveResourceMetadata);
10283
+ wellKnown.get("/.well-known/oauth-protected-resource", serveResourceMetadata);
10284
+ wellKnown.get("/.well-known/oauth-authorization-server", (c) => c.json(authorizationServerMetadata(config.publicUrl, config.oauthBasePath)));
10285
+ return wellKnown;
10286
+ }
10287
+ /** The MCP endpoint itself. */
10288
+ function createMcpRoutes(config) {
10289
+ const router = new Hono();
10290
+ const canonicalResource = canonicalResourceUri(config.publicUrl, config.mcpPath);
10291
+ /**
10292
+ * Nothing is served without a token bound to THIS resource.
10293
+ *
10294
+ * The challenge on a 401 is the client's entire discovery path: with no
10295
+ * token it knows only the URL, and `resource_metadata` is what turns that
10296
+ * into an authorization server, a registration endpoint and a consent
10297
+ * screen. Omitting it makes a protected server look like a broken one.
10298
+ */
10299
+ async function authenticate(c) {
10300
+ const header = c.req.header("authorization") ?? "";
10301
+ if (!header.toLowerCase().startsWith("bearer ")) return c.json({
10302
+ error: "unauthorized",
10303
+ error_description: "An OAuth access token is required."
10304
+ }, 401, { "WWW-Authenticate": bearerChallenge(config.publicUrl, config.mcpPath, DEFAULT_MCP_SCOPE) });
10305
+ const payload = await verifyMcpAccessToken(header.slice(7).trim(), canonicalResource);
10306
+ if (!payload) return c.json({
10307
+ error: "invalid_token",
10308
+ error_description: "The access token is invalid, expired, or for another resource."
10309
+ }, 401, { "WWW-Authenticate": bearerChallenge(config.publicUrl, config.mcpPath, DEFAULT_MCP_SCOPE) });
10310
+ return {
10311
+ uid: payload.uid,
10312
+ roles: payload.roles,
10313
+ scope: payload.scope,
10314
+ clientId: payload.clientId
10315
+ };
10316
+ }
10317
+ router.post("/", async (c) => {
10318
+ const caller = await authenticate(c);
10319
+ if (caller instanceof Response) return caller;
10320
+ let message;
10321
+ try {
10322
+ message = await c.req.json();
10323
+ } catch {
10324
+ return c.json(rpcError(null, PARSE_ERROR, "Invalid JSON."), 400);
10325
+ }
10326
+ if (Array.isArray(message)) {
10327
+ const replies = [];
10328
+ for (const item of message) {
10329
+ const reply = await dispatch(c, item, caller);
10330
+ if (reply) replies.push(reply);
10331
+ }
10332
+ return replies.length ? c.json(replies) : c.body(null, 202);
10333
+ }
10334
+ const reply = await dispatch(c, message, caller);
10335
+ return reply ? c.json(reply) : c.body(null, 202);
10336
+ });
10337
+ /**
10338
+ * No server-initiated stream.
10339
+ *
10340
+ * A client opens `GET /mcp` to receive messages the server starts. This one
10341
+ * never does — it answers tool calls and nothing else — so the honest reply
10342
+ * is 405 with `Allow`, not an SSE stream that stays silent forever and
10343
+ * leaves the client waiting on a heartbeat that is not coming.
10344
+ */
10345
+ router.get("/", async (c) => {
10346
+ const caller = await authenticate(c);
10347
+ if (caller instanceof Response) return caller;
10348
+ return c.json({
10349
+ error: "method_not_allowed",
10350
+ error_description: "This server does not open server-initiated streams."
10351
+ }, 405, { Allow: "POST, DELETE" });
10352
+ });
10353
+ /** Session teardown. Stateless here, so there is nothing to tear down. */
10354
+ router.delete("/", (c) => c.body(null, 204));
10355
+ async function dispatch(c, message, caller) {
10356
+ if (typeof message !== "object" || message === null) return rpcError(null, INVALID_REQUEST, "A JSON-RPC message must be an object.");
10357
+ const { id = null, method, params } = message;
10358
+ if (typeof method !== "string") return rpcError(id, INVALID_REQUEST, "`method` is required.");
10359
+ const isNotification = id === null || id === void 0;
10360
+ const reply = await route();
10361
+ return isNotification ? null : reply;
10362
+ async function route() {
10363
+ switch (method) {
10364
+ case "initialize": {
10365
+ const asked = typeof params?.protocolVersion === "string" ? params.protocolVersion : void 0;
10366
+ return rpcResult(id, {
10367
+ protocolVersion: asked && SUPPORTED_PROTOCOL_VERSIONS.includes(asked) ? asked : LATEST_PROTOCOL_VERSION,
10368
+ capabilities: { tools: { listChanged: false } },
10369
+ serverInfo: config.serverInfo,
10370
+ instructions: "Data tools for this Rebase project. Every call runs as the signed-in user, so results are limited to what that user is allowed to see. Call list_collections first to learn the schema."
10371
+ });
10372
+ }
10373
+ case "notifications/initialized":
10374
+ case "notifications/cancelled": return null;
10375
+ case "ping": return rpcResult(id, {});
10376
+ case "tools/list": return rpcResult(id, { tools: toolsForScope(caller.scope).map((tool) => ({
10377
+ name: tool.name,
10378
+ description: tool.description,
10379
+ inputSchema: tool.inputSchema
10380
+ })) });
10381
+ case "tools/call": return callTool(c, id, params, caller);
10382
+ default: return rpcError(id, METHOD_NOT_FOUND, `Unknown method: ${method}`);
10383
+ }
10384
+ }
10385
+ }
10386
+ async function callTool(c, id, params, caller) {
10387
+ const name = typeof params?.name === "string" ? params.name : "";
10388
+ const args = params?.arguments ?? {};
10389
+ const tool = findTool(name, caller.scope);
10390
+ if (!tool) {
10391
+ const outOfScope = toolsForScope("mcp:read mcp:write").find((t) => t.name === name);
10392
+ if (outOfScope) {
10393
+ c.header("WWW-Authenticate", insufficientScopeChallenge(config.publicUrl, config.mcpPath, outOfScope.requiredScope, `${name} requires the ${outOfScope.requiredScope} scope`));
10394
+ return rpcError(id, INVALID_PARAMS, `"${name}" requires the ${outOfScope.requiredScope} scope, which this token does not hold.`);
10395
+ }
10396
+ return rpcError(id, METHOD_NOT_FOUND, `Unknown tool: ${name}`);
10397
+ }
10398
+ const driver = config.getDriver();
10399
+ if (!driver) return rpcError(id, INTERNAL_ERROR, "The data layer is not ready.");
10400
+ try {
10401
+ const result = await tool.run(args, {
10402
+ driver,
10403
+ collections: config.getCollections(),
10404
+ caller
10405
+ });
10406
+ return rpcResult(id, {
10407
+ content: [{
10408
+ type: "text",
10409
+ text: JSON.stringify(result, null, 2)
10410
+ }],
10411
+ structuredContent: result
10412
+ });
10413
+ } catch (error) {
10414
+ const message = error instanceof McpToolError ? error.message : "The call failed. This is usually a permission rule refusing the operation for your account.";
10415
+ if (!(error instanceof McpToolError)) logger.error("[mcp] Tool call failed", {
10416
+ tool: name,
10417
+ uid: caller.uid,
10418
+ clientId: caller.clientId,
10419
+ error
10420
+ });
10421
+ return rpcResult(id, {
10422
+ content: [{
10423
+ type: "text",
10424
+ text: message
10425
+ }],
10426
+ isError: true
10427
+ });
10428
+ }
10429
+ }
10430
+ return router;
10431
+ }
10432
+ function rpcResult(id, result) {
10433
+ return {
10434
+ jsonrpc: "2.0",
10435
+ id,
10436
+ result
10437
+ };
10438
+ }
10439
+ function rpcError(id, code, message) {
10440
+ return {
10441
+ jsonrpc: "2.0",
10442
+ id,
10443
+ error: {
10444
+ code,
10445
+ message
10446
+ }
10447
+ };
10448
+ }
10449
+ //#endregion
8470
10450
  //#region src/auth/collection-callback-warning.ts
8471
10451
  /** The data callbacks the auth write path does not run. */
8472
10452
  var DATA_CALLBACKS = [
@@ -11074,6 +13054,23 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
11074
13054
  "channel_history"
11075
13055
  ]);
11076
13056
  /**
13057
+ * The other direction: the frame types the *server* addresses by channel.
13058
+ *
13059
+ * Not the mirror of {@link CHANNEL_MESSAGE_TYPES} — a request and its answer
13060
+ * have different names (`presence_state` is the one type in both) — so the two
13061
+ * sets are listed separately rather than derived from each other. These are
13062
+ * exactly the members of `ChannelMessage`; adding one here without declaring it
13063
+ * there leaves the handler with a frame it cannot read.
13064
+ */
13065
+ var CHANNEL_FRAME_TYPES = /* @__PURE__ */ new Set([
13066
+ "broadcast",
13067
+ "presence_state",
13068
+ "presence_diff",
13069
+ "channel_history",
13070
+ "error",
13071
+ "ERROR"
13072
+ ]);
13073
+ /**
11077
13074
  * Low-level realtime WebSocket client.
11078
13075
  *
11079
13076
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
@@ -11084,6 +13081,28 @@ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
11084
13081
  * `@rebasepro/client-postgres` was removed; its surface may change without a
11085
13082
  * major bump.
11086
13083
  */
13084
+ /**
13085
+ * Narrow a frame to the collection-update shape. See its use below.
13086
+ *
13087
+ * `WebSocketMessage.type` is `string`, so the message types that extend it do
13088
+ * not form a discriminated union and `type === "collection_update"` narrows
13089
+ * nothing on its own.
13090
+ */
13091
+ function isCollectionUpdate(message) {
13092
+ return message.type === "collection_update";
13093
+ }
13094
+ /**
13095
+ * Narrow a frame to one of the channel-addressed shapes.
13096
+ *
13097
+ * Both halves of the test are load-bearing. The type says which member of
13098
+ * {@link ChannelMessage} it is; `channel` is what a channel frame is *addressed
13099
+ * by*, and an `ERROR` from a server older than the change that names the
13100
+ * channel has none — it is a generic error, and falls through to the catch-all
13101
+ * warning rather than being delivered to a channel that was never named.
13102
+ */
13103
+ function isChannelMessage(message) {
13104
+ return typeof message.channel === "string" && CHANNEL_FRAME_TYPES.has(message.type);
13105
+ }
11087
13106
  var RebaseWebSocketClient = class {
11088
13107
  websocketUrl;
11089
13108
  ws = null;
@@ -11267,6 +13286,7 @@ var RebaseWebSocketClient = class {
11267
13286
  clearTimeout(this.reconnectTimeout);
11268
13287
  this.reconnectTimeout = null;
11269
13288
  }
13289
+ this.suspendSubscribeWatchdogs();
11270
13290
  if (this.ws) {
11271
13291
  this.ws.onclose = null;
11272
13292
  this.ws.onerror = null;
@@ -11448,7 +13468,7 @@ var RebaseWebSocketClient = class {
11448
13468
  }
11449
13469
  return;
11450
13470
  }
11451
- if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history" || type === "ERROR" || type === "error")) {
13471
+ if (isChannelMessage(message)) {
11452
13472
  const handlers = this.channelHandlers.get(message.channel);
11453
13473
  if (handlers) for (const handler of [...handlers]) try {
11454
13474
  handler(message);
@@ -11457,15 +13477,16 @@ var RebaseWebSocketClient = class {
11457
13477
  }
11458
13478
  return;
11459
13479
  }
11460
- if (subscriptionId && type === "collection_update") {
13480
+ if (subscriptionId && isCollectionUpdate(message)) {
13481
+ const collectionUpdate = message;
11461
13482
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
11462
13483
  if (subscriptionKey) {
11463
13484
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
11464
13485
  if (collectionSub) {
11465
13486
  const incomingRows = message.rows || [];
11466
- const updatePks = message.pks;
13487
+ const updatePks = collectionUpdate.pks;
11467
13488
  if (updatePks) collectionSub.pks = updatePks;
11468
- const updateMeta = message.meta;
13489
+ const updateMeta = collectionUpdate.meta;
11469
13490
  if (updateMeta) collectionSub.latestMeta = updateMeta;
11470
13491
  const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
11471
13492
  collectionSub.latestData = rows;
@@ -12329,7 +14350,7 @@ var RebaseRealtimeChannel = class {
12329
14350
  this.catchUpInFlight = true;
12330
14351
  if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
12331
14352
  this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
12332
- this.catchUpTimeout.unref?.();
14353
+ unref(this.catchUpTimeout);
12333
14354
  try {
12334
14355
  await this.send("channel_history", {
12335
14356
  sinceSeq: this.lastSeq,
@@ -12375,7 +14396,7 @@ var RebaseRealtimeChannel = class {
12375
14396
  if (!this.trackedState) return;
12376
14397
  this.send("presence_track", { state: this.trackedState }).catch(() => {});
12377
14398
  }, PRESENCE_HEARTBEAT_MS);
12378
- this.heartbeat.unref?.();
14399
+ unref(this.heartbeat);
12379
14400
  }
12380
14401
  }
12381
14402
  /** Stop publishing presence, without leaving the channel. */
@@ -12503,12 +14524,11 @@ var RebaseRealtimeChannel = class {
12503
14524
  handle(message) {
12504
14525
  switch (message.type) {
12505
14526
  case "presence_state":
12506
- this.presences = message.presences ?? {};
14527
+ this.presences = message.presences;
12507
14528
  this.emitPresence();
12508
14529
  break;
12509
14530
  case "presence_diff": {
12510
- const joins = message.joins ?? {};
12511
- const leaves = message.leaves ?? {};
14531
+ const { joins, leaves } = message;
12512
14532
  for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
12513
14533
  for (const id of Object.keys(leaves)) delete this.presences[id];
12514
14534
  this.emitPresence({
@@ -12518,7 +14538,7 @@ var RebaseRealtimeChannel = class {
12518
14538
  break;
12519
14539
  }
12520
14540
  case "broadcast": {
12521
- const seq = typeof message.seq === "number" ? message.seq : void 0;
14541
+ const seq = message.seq;
12522
14542
  const event = {
12523
14543
  event: message.event,
12524
14544
  payload: message.payload,
@@ -12543,9 +14563,7 @@ var RebaseRealtimeChannel = class {
12543
14563
  clearTimeout(this.catchUpTimeout);
12544
14564
  this.catchUpTimeout = null;
12545
14565
  }
12546
- const entries = message.messages ?? [];
12547
- const retained = message.retained === true;
12548
- const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
14566
+ const { messages: entries, retained, latestSeq } = message;
12549
14567
  for (const resolve of this.historyWaiters.splice(0)) resolve({
12550
14568
  messages: entries,
12551
14569
  retained,
@@ -12567,8 +14585,8 @@ var RebaseRealtimeChannel = class {
12567
14585
  case "ERROR":
12568
14586
  case "error": {
12569
14587
  const raw = message.payload?.error;
12570
- const asObject = typeof raw === "object" && raw !== null ? raw : void 0;
12571
- const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ?? (typeof message.error === "string" ? message.error : void 0) ?? "The server refused a channel operation.";
14588
+ const asObject = typeof raw === "object" ? raw : void 0;
14589
+ const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ?? message.error ?? "The server refused a channel operation.";
12572
14590
  const error = new RebaseApiError(text, { ...asObject?.code ? { code: asObject.code } : {} });
12573
14591
  if (this.errorHandlers.size === 0) {
12574
14592
  console.warn(`[Rebase] Channel "${this.name}" error${asObject?.code ? ` (${asObject.code})` : ""}: ${text}. Attach \`channel.onError(...)\` to handle it.`);
@@ -12863,7 +14881,7 @@ var ConnectivityMonitor = class {
12863
14881
  this.timer = void 0;
12864
14882
  this.onRetryDue?.();
12865
14883
  }, delay);
12866
- this.timer.unref?.();
14884
+ unref(this.timer);
12867
14885
  }
12868
14886
  clearPendingTimer() {
12869
14887
  if (this.timer !== void 0) {
@@ -13509,6 +15527,31 @@ function generateOfflineNumericId() {
13509
15527
  lastOfflineNumericId = lastOfflineNumericId === 0 ? candidate : Math.min(candidate, lastOfflineNumericId - 1);
13510
15528
  return lastOfflineNumericId;
13511
15529
  }
15530
+ /**
15531
+ * The row an optimistic write puts in the cache before the server has seen it.
15532
+ *
15533
+ * It is NOT an `M`, and saying so is the point of collecting this in one place.
15534
+ * `M` is the row as the database has it — every server default, every generated
15535
+ * column, every `afterRead` transform. What an optimistic write has is the
15536
+ * fields the caller supplied (plus, on an update, whatever was already cached)
15537
+ * and an id, which may itself be a locally-minted placeholder.
15538
+ *
15539
+ * The two are reconciled when the queued write drains and the server's row
15540
+ * replaces this one. Until then the SDK hands the caller this, under `M`,
15541
+ * because that is what the offline API's return type promises — and there is
15542
+ * nothing in this file that can make that promise true.
15543
+ *
15544
+ * So: one deliberate assertion, named, rather than five spread across the
15545
+ * create / createMany / update / upsert paths, each free to assemble the fields
15546
+ * differently. Fixing it properly means the offline surface saying what it
15547
+ * actually returns, which is a change to its public types.
15548
+ */
15549
+ function optimisticRow(fields, id) {
15550
+ return {
15551
+ ...fields,
15552
+ id
15553
+ };
15554
+ }
13512
15555
  var MISSING = "\0missing";
13513
15556
  /**
13514
15557
  * Replays to spend on a mutation whose idempotency key the server is still
@@ -13595,7 +15638,7 @@ var OfflineManager = class {
13595
15638
  if ((config.crossTab ?? this.store instanceof IndexedDBOfflineStore) && typeof BroadcastChannel !== "undefined") try {
13596
15639
  this.channel = new BroadcastChannel("rebase-offline");
13597
15640
  this.channel.onmessage = (event) => this.onBroadcast(event.data);
13598
- this.channel.unref?.();
15641
+ unref(this.channel);
13599
15642
  } catch {}
13600
15643
  this.api = {
13601
15644
  sync: () => this.sync(),
@@ -13758,10 +15801,7 @@ var OfflineManager = class {
13758
15801
  }
13759
15802
  const providedId = id ?? data.id;
13760
15803
  const rowId = providedId ?? this.mintOfflineId(slug);
13761
- const row = {
13762
- ...data,
13763
- id: rowId
13764
- };
15804
+ const row = optimisticRow(data, rowId);
13765
15805
  await this.enqueue({
13766
15806
  collection: slug,
13767
15807
  type: "create",
@@ -13789,10 +15829,7 @@ var OfflineManager = class {
13789
15829
  if (!isNetworkError(error)) throw error;
13790
15830
  this.connectivity.markFailure();
13791
15831
  }
13792
- const rows = data.map((r) => ({
13793
- ...r,
13794
- id: r.id ?? this.mintOfflineId(slug)
13795
- }));
15832
+ const rows = data.map((r) => optimisticRow(r, r.id ?? this.mintOfflineId(slug)));
13796
15833
  const rollback = {};
13797
15834
  for (const row of rows) {
13798
15835
  const key = String(row.id);
@@ -13835,10 +15872,7 @@ var OfflineManager = class {
13835
15872
  }
13836
15873
  const rowId = data.id;
13837
15874
  if (options?.onConflict?.length || rowId === void 0) throw new RebaseClientError(`Cannot upsert into "${slug}" while offline: the row it would replace can only be found by the server. Upserting on the primary key, with the key in the row, is queued; upserting on a natural key is not.`, { code: "OFFLINE_UPSERT_UNSUPPORTED" });
13838
- const row = {
13839
- ...data,
13840
- id: rowId
13841
- };
15875
+ const row = optimisticRow(data, rowId);
13842
15876
  await this.enqueue({
13843
15877
  collection: slug,
13844
15878
  type: "createMany",
@@ -13870,11 +15904,10 @@ var OfflineManager = class {
13870
15904
  for (const { id, data } of updates) {
13871
15905
  const base = this.rawLocalRow(slug, id);
13872
15906
  rollback[String(id)] = base ?? null;
13873
- optimistic.push({
15907
+ optimistic.push(optimisticRow({
13874
15908
  ...base ?? {},
13875
- ...data,
13876
- id
13877
- });
15909
+ ...data
15910
+ }, id));
13878
15911
  }
13879
15912
  await this.enqueue({
13880
15913
  collection: slug,
@@ -13937,11 +15970,10 @@ var OfflineManager = class {
13937
15970
  data,
13938
15971
  rollback: { rows: { [String(id)]: base ?? null } }
13939
15972
  });
13940
- const optimistic = {
15973
+ const optimistic = optimisticRow({
13941
15974
  ...base ?? {},
13942
- ...data,
13943
- id
13944
- };
15975
+ ...data
15976
+ }, id);
13945
15977
  this.setLocalRow(slug, id, optimistic);
13946
15978
  this.notifyCollection(slug);
13947
15979
  return optimistic;
@@ -16610,7 +18642,7 @@ async function _initializeRebaseBackend(config) {
16610
18642
  const isProduction = process.env.NODE_ENV === "production";
16611
18643
  const surfaces = resolveSurfaces(config.surfaces);
16612
18644
  const ownership = resolveOwnership(config.ownership);
16613
- const offSurfaces = disabledSurfaces(surfaces);
18645
+ const offSurfaces = trimmedSurfaces(surfaces);
16614
18646
  if (offSurfaces.length > 0) logger.info("Partial runtime surface — some routes are not served by this process", {
16615
18647
  serving: ALL_RUNTIME_SURFACES.filter((surface) => surfaces[surface]),
16616
18648
  notServing: offSurfaces
@@ -16947,7 +18979,7 @@ async function _initializeRebaseBackend(config) {
16947
18979
  ]) {
16948
18980
  const providerConfig = safeAuthConfig[key];
16949
18981
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
16950
- const createFn = (await import("./auth-DJsLXsCR.js").then((n) => n.t))[factory];
18982
+ const createFn = (await import("./auth-gzoRjnuh.js").then((n) => n.t))[factory];
16951
18983
  oauthProviders.push(createFn(providerConfig));
16952
18984
  }
16953
18985
  }
@@ -17000,6 +19032,51 @@ async function _initializeRebaseBackend(config) {
17000
19032
  config.app.route("/.well-known", createJwksRoutes());
17001
19033
  if (hasAsymmetricSigningKey()) logger.info("Access tokens are signed asymmetrically; public keys at /.well-known/jwks.json");
17002
19034
  }
19035
+ if (surfaces.mcp) {
19036
+ const publicUrl = process.env.REBASE_PUBLIC_URL;
19037
+ const driver = defaultDriver;
19038
+ if (!publicUrl) logger.warn("[mcp] Surface enabled but REBASE_PUBLIC_URL is not set — not mounting. Set it to this deployment's externally reachable origin, e.g. https://app.example.com");
19039
+ else if (!isJwtConfigured()) logger.warn("[mcp] Surface enabled but no JWT secret is configured — not mounting.");
19040
+ else if (!driver || !supportsRlsScoping(driver)) logger.error("[mcp] Surface enabled but this data driver cannot scope by user — not mounting. Every MCP call must run under the caller's own row-level security; a driver without `withAuth()` would run them as the database owner instead.");
19041
+ else {
19042
+ const store = createOAuthStore(driver);
19043
+ if (!store) logger.error("[mcp] Surface enabled but the driver cannot persist OAuth state — not mounting.");
19044
+ else {
19045
+ let ready = true;
19046
+ try {
19047
+ await store.ensureTables();
19048
+ } catch (error) {
19049
+ ready = false;
19050
+ logger.error("[mcp] Could not prepare the OAuth tables — not mounting.", { error });
19051
+ }
19052
+ if (ready) {
19053
+ const mcpPath = "/mcp";
19054
+ const oauthBasePath = `${basePath}/oauth`;
19055
+ const mcpConfig = {
19056
+ publicUrl,
19057
+ mcpPath,
19058
+ oauthBasePath,
19059
+ getDriver: () => defaultDriver,
19060
+ getCollections: () => activeCollections,
19061
+ serverInfo: {
19062
+ name: "rebase",
19063
+ version: readRuntimeVersion([process.cwd()]) ?? "unknown"
19064
+ }
19065
+ };
19066
+ config.app.route("/", createMcpWellKnownRoutes(mcpConfig));
19067
+ config.app.route(mcpPath, createMcpRoutes(mcpConfig));
19068
+ config.app.route(oauthBasePath, createOAuthRoutes({
19069
+ store,
19070
+ publicUrl,
19071
+ mcpPath,
19072
+ authBasePath: `${basePath}/auth`,
19073
+ allowDynamicRegistration: process.env.REBASE_MCP_OPEN_REGISTRATION !== "false"
19074
+ }));
19075
+ logger.info(`MCP endpoint mounted at ${publicUrl}${mcpPath} (OAuth at ${oauthBasePath})`);
19076
+ }
19077
+ }
19078
+ }
19079
+ }
17003
19080
  const adminSurfacesGated = !!authAdapter && (isAuthAdapter(config.auth) || !!config.auth.jwtSecret);
17004
19081
  const applyAdminGate = (router, surface) => {
17005
19082
  if (!adminSurfacesGated) {
@@ -17057,7 +19134,7 @@ async function _initializeRebaseBackend(config) {
17057
19134
  let schemaEditorOff = schemaEditorUnavailable();
17058
19135
  let schemaEditorRoutes;
17059
19136
  if (!schemaEditorOff && config.collectionsDir) try {
17060
- schemaEditorRoutes = (await import("./schema-editor-routes-C3TLZqAC.js")).createSchemaEditorRoutes(config.collectionsDir);
19137
+ schemaEditorRoutes = (await import("./schema-editor-routes-BKOmdf4M.js")).createSchemaEditorRoutes(config.collectionsDir);
17061
19138
  } catch (err) {
17062
19139
  if (err?.code === "ERR_MODULE_NOT_FOUND") {
17063
19140
  schemaEditorOff = {
@@ -17124,7 +19201,7 @@ async function _initializeRebaseBackend(config) {
17124
19201
  },
17125
19202
  writeSource: schemaEditorRoutes ? async (change) => {
17126
19203
  const applyEdit = async (dir, collectionId, collection) => {
17127
- const { AstSchemaEditor } = await import("./ast-schema-editor-C6mDz0XN.js");
19204
+ const { AstSchemaEditor } = await import("./ast-schema-editor-CslO8Oje.js");
17128
19205
  await new AstSchemaEditor(dir).saveCollection(collectionId, collection, { partial: false });
17129
19206
  };
17130
19207
  if (remoteRepo) {
@@ -17344,10 +19421,7 @@ async function _initializeRebaseBackend(config) {
17344
19421
  const serverSingleton = serverClient;
17345
19422
  _initRebase(serverSingleton);
17346
19423
  logger.debug("Rebase singleton initialized");
17347
- if (defaultDriverResult.internals) {
17348
- const driver = defaultDriverResult.internals.driver;
17349
- if (driver && "client" in driver) driver.client = serverClient;
17350
- }
19424
+ injectCallbackClient(driverResults.values(), serverClient);
17351
19425
  if (config.functionsDir && surfaces.functions) {
17352
19426
  const { loadFunctionsWithDiagnostics } = await import("./function-loader-xnbDAPfa.js").then((n) => n.t);
17353
19427
  const { createFunctionRoutes } = await import("./function-routes-C4nB2h0z.js").then((n) => n.n);
@@ -17406,8 +19480,8 @@ async function _initializeRebaseBackend(config) {
17406
19480
  if (surfaces.cron || config.cronsDir && ownership.cronScheduler) {
17407
19481
  const { loadCronJobsWithDiagnostics } = await import("./cron-loader-CQjvjpEw.js").then((n) => n.t);
17408
19482
  const { CronScheduler } = await import("./cron-scheduler-D47tdB9T.js").then((n) => n.n);
17409
- const { createCronRoutes } = await import("./cron-routes-Bfwni8Zg.js").then((n) => n.n);
17410
- const { createCronStore } = await import("./cron-store-Bsiw4Q6u.js").then((n) => n.n);
19483
+ const { createCronRoutes } = await import("./cron-routes-BamgO0Xr.js").then((n) => n.n);
19484
+ const { createCronStore } = await import("./cron-store-yXOyIEmn.js").then((n) => n.n);
17411
19485
  const { jobs: loadedCronJobs, problems: cronProblems } = config.cronsDir ? await loadCronJobsWithDiagnostics(config.cronsDir) : {
17412
19486
  jobs: [],
17413
19487
  problems: []
@@ -17464,7 +19538,7 @@ async function _initializeRebaseBackend(config) {
17464
19538
  }
17465
19539
  let metricsHistory;
17466
19540
  try {
17467
- const { createMetricsHistory } = await import("./history-recorder-r5_IzSHK.js");
19541
+ const { createMetricsHistory } = await import("./history-recorder-hBGuXcZc.js");
17468
19542
  metricsHistory = createMetricsHistory(defaultDriver);
17469
19543
  if (metricsHistory) {
17470
19544
  await metricsHistory.ensure();
@@ -17476,7 +19550,7 @@ async function _initializeRebaseBackend(config) {
17476
19550
  }
17477
19551
  let jobQueue;
17478
19552
  if (config.jobs?.enabled) {
17479
- const { createJobStore, createJobQueue } = await import("./jobs-CW5lm_Ix.js").then((n) => n.t);
19553
+ const { createJobStore, createJobQueue } = await import("./jobs-DZ8DLLcU.js").then((n) => n.t);
17480
19554
  const store = createJobStore(defaultDriver);
17481
19555
  if (store) {
17482
19556
  await store.ensureTable();
@@ -18489,19 +20563,29 @@ var WebhookDispatcher = class {
18489
20563
  * for a third party. It also means a receiver that is down can no longer
18490
20564
  * fail the customer's write.
18491
20565
  *
18492
- * The cost, when no `jobQueue` is configured: the queue is in-process and
18493
- * in-memory. A crash or a deploy between the enqueue and the delivery drops
18494
- * the event, and a receiver may see the notification a few milliseconds
18495
- * before the row it describes is committed. Use {@link flush} on shutdown,
18496
- * and `onDelivery` to record failures.
20566
+ * Called inside a write from a callback — the deliveries are held until
20567
+ * that write commits and dropped if it rolls back. They used to go out
20568
+ * whatever happened to the row: a receiver could hear about a change a few
20569
+ * milliseconds before it committed, or about one that never did.
18497
20570
  *
18498
- * With `jobQueue` set, only the second half of that remains: the delivery
18499
- * becomes a row in `rebase.jobs` and survives the crash, the deploy and the
18500
- * pod being rescheduled.
20571
+ * The cost, when no `jobQueue` is configured: the queue is in-process and
20572
+ * in-memory. A crash or a deploy between the commit and the delivery drops
20573
+ * the event. Use {@link flush} on shutdown, and `onDelivery` to record
20574
+ * failures. With `jobQueue` set, the delivery becomes a row in
20575
+ * `rebase.jobs` and survives the deploy and the pod being rescheduled — the
20576
+ * only gap left is a crash between the commit and that insert.
18501
20577
  */
18502
20578
  enqueueEntityChange(table, event, id, entity, previousEntity) {
18503
20579
  const jobs = this.buildDeliveries(table, event, entity, previousEntity);
18504
20580
  if (jobs.length === 0) return;
20581
+ const ambient = currentAmbientTransaction();
20582
+ if (ambient) {
20583
+ ambient.afterCommit(() => this.dispatchDeliveries(jobs));
20584
+ return;
20585
+ }
20586
+ this.dispatchDeliveries(jobs);
20587
+ }
20588
+ dispatchDeliveries(jobs) {
18505
20589
  if (this.options.jobQueue) {
18506
20590
  for (const job of jobs) this.options.jobQueue.enqueue(WEBHOOK_DELIVERY_TASK, {
18507
20591
  webhookId: job.webhook.id,
@@ -19544,6 +21628,32 @@ var bootEnvExtension = z$1.object({
19544
21628
  ""
19545
21629
  ]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
19546
21630
  /**
21631
+ * Serve `/mcp` and the OAuth authorization server that issues its tokens,
21632
+ * so AI clients can read and write this project **as the signed-in user**.
21633
+ *
21634
+ * Off unless set, and unlike every other surface no role turns it on. The
21635
+ * others describe a process shape; this one is a decision to hand
21636
+ * credentials to third-party software, and it should be made by a person
21637
+ * rather than inherited from a container's job title.
21638
+ *
21639
+ * `REBASE_PUBLIC_URL` must be set alongside it: every document the surface
21640
+ * serves names absolute URLs, and the token audience is one of them. With
21641
+ * it missing the surface declines to mount and says so.
21642
+ */
21643
+ REBASE_MCP_ENABLED: z$1.enum([
21644
+ "true",
21645
+ "false",
21646
+ ""
21647
+ ]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
21648
+ /**
21649
+ * This deployment's externally reachable origin, e.g. `https://app.example.com`.
21650
+ *
21651
+ * Read by the MCP surface, which cannot derive it: taking the origin from
21652
+ * the `Host` header would make the issuer identity — and the audience its
21653
+ * own tokens are checked against — a value the caller supplies.
21654
+ */
21655
+ REBASE_PUBLIC_URL: z$1.string().url().optional(),
21656
+ /**
19547
21657
  * Comma-separated function names this process serves. Unset means all.
19548
21658
  *
19549
21659
  * `functions` role only. A name that is not in the bundle is a boot failure,
@@ -19770,9 +21880,11 @@ function resolveRole(env) {
19770
21880
  if (env.REBASE_CRON_SCHEDULER !== void 0) ownership.cronScheduler = env.REBASE_CRON_SCHEDULER;
19771
21881
  if (env.REBASE_JOB_WORKERS !== void 0) ownership.jobWorkers = env.REBASE_JOB_WORKERS;
19772
21882
  if (env.REBASE_RLS_AUDIT !== void 0) ownership.rlsAudit = env.REBASE_RLS_AUDIT;
21883
+ const surfaces = { ...base.surfaces };
21884
+ if (env.REBASE_MCP_ENABLED !== void 0) surfaces.mcp = env.REBASE_MCP_ENABLED;
19773
21885
  return {
19774
21886
  role,
19775
- surfaces: { ...base.surfaces },
21887
+ surfaces,
19776
21888
  ownership,
19777
21889
  provisionSchema: base.provisionSchema,
19778
21890
  functionsSelection: {
@@ -20196,6 +22308,31 @@ function resolveAuthOptions(env, usersCollection) {
20196
22308
  }
20197
22309
  //#endregion
20198
22310
  //#region src/metrics/index.ts
22311
+ /**
22312
+ * Paths that are the platform OBSERVING this process, not traffic to it.
22313
+ *
22314
+ * `/metrics` is scraped by the control plane for the console's live view and
22315
+ * for the rollout gate, and the middleware is installed on `/*` before the
22316
+ * route is mounted — so every scrape counted itself. At a scrape every few
22317
+ * seconds that is ~20/min forever, which on a real tenant added up to 23,000
22318
+ * requests a day of pure self-observation, dwarfing its actual traffic by
22319
+ * three orders of magnitude and putting a 0.0% error rate and a flat 9ms
22320
+ * latency over the top of whatever the app was really doing.
22321
+ *
22322
+ * Excluded rather than relabelled: a request the platform makes to look at the
22323
+ * tenant is not the tenant's traffic under any label, and leaving it in a row
22324
+ * of its own would still have it dominating every total on the page.
22325
+ */
22326
+ var UNCOUNTED_PATHS = /* @__PURE__ */ new Set([
22327
+ "/metrics",
22328
+ "/health",
22329
+ "/livez",
22330
+ "/readyz"
22331
+ ]);
22332
+ /** Whether a path is the platform looking in, rather than a request served. */
22333
+ function isUncountedPath(pathname) {
22334
+ return UNCOUNTED_PATHS.has(pathname);
22335
+ }
20199
22336
  var LATENCY_BUCKETS_MS = [
20200
22337
  5,
20201
22338
  10,
@@ -20372,7 +22509,7 @@ var MetricsRegistry = class MetricsRegistry {
20372
22509
  */
20373
22510
  function classifySurface(pathname, basePath = "/api") {
20374
22511
  const prefix = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath;
20375
- if (!pathname.startsWith(prefix)) return { surface: "other" };
22512
+ if (!pathname.startsWith(prefix)) return { surface: "app" };
20376
22513
  const [head, second] = pathname.slice(prefix.length).replace(/^\/+/, "").split("/");
20377
22514
  switch (head) {
20378
22515
  case "data": return {
@@ -20402,7 +22539,9 @@ function createMetricsMiddleware(basePath = "/api") {
20402
22539
  let known;
20403
22540
  const middleware = async (c, next) => {
20404
22541
  const started = performance.now();
20405
- const { surface, collection } = classifySurface(new URL(c.req.url).pathname, basePath);
22542
+ const pathname = new URL(c.req.url).pathname;
22543
+ if (isUncountedPath(pathname)) return next();
22544
+ const { surface, collection } = classifySurface(pathname, basePath);
20406
22545
  try {
20407
22546
  await next();
20408
22547
  } finally {
@@ -20800,7 +22939,10 @@ async function downloadTo(tarball, options, fetchImpl) {
20800
22939
  } finally {
20801
22940
  clearTimeout(timer);
20802
22941
  }
20803
- if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
22942
+ if (!response.ok) {
22943
+ const retryAfter = response.headers?.get?.("retry-after") ?? void 0;
22944
+ throw new Error(`${response.status} ${response.statusText}` + (retryAfter ? ` (retry-after: ${retryAfter})` : ""));
22945
+ }
20804
22946
  if (!response.body) throw new Error("empty response body");
20805
22947
  await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
20806
22948
  const { size } = fs$1.statSync(tarball);
@@ -20815,6 +22957,34 @@ async function downloadTo(tarball, options, fetchImpl) {
20815
22957
  * refuse. Writing the whole tarball first means a truncated download is caught
20816
22958
  * by `tar` as a corrupt archive, which is an error.
20817
22959
  */
22960
+ /**
22961
+ * The 4xx statuses that mean "not yet" rather than "no".
22962
+ *
22963
+ * 429 is a server asking to be asked again; 408 is it saying the attempt ran out
22964
+ * of time. Both clear on their own, and both are indistinguishable from a bad
22965
+ * credential to a rule that reads only the first digit.
22966
+ */
22967
+ function isTransientStatus(detail) {
22968
+ return /^(429|408) /.test(detail);
22969
+ }
22970
+ /**
22971
+ * The wait a `Retry-After` asks for, in milliseconds, or undefined.
22972
+ *
22973
+ * Seconds or an HTTP date, per RFC 9110. Capped, because this runs during a
22974
+ * pod's startup probe window: a server asking for five minutes gets the cap and
22975
+ * another attempt, which is better than a container marked unhealthy while it
22976
+ * politely waits.
22977
+ */
22978
+ function retryAfterMs(detail, now = Date.now()) {
22979
+ const raw = /\(retry-after: ([^)]+)\)/.exec(detail)?.[1]?.trim();
22980
+ if (!raw) return void 0;
22981
+ const seconds = Number(raw);
22982
+ const ms = Number.isFinite(seconds) ? seconds * 1e3 : Number.isFinite(Date.parse(raw)) ? Date.parse(raw) - now : NaN;
22983
+ if (!Number.isFinite(ms)) return void 0;
22984
+ return Math.min(Math.max(ms, 0), MAX_RETRY_AFTER_MS);
22985
+ }
22986
+ /** Ceiling on an honoured `Retry-After`. See `retryAfterMs`. */
22987
+ var MAX_RETRY_AFTER_MS = 3e4;
20818
22988
  async function fetchBundle(options) {
20819
22989
  const fetchImpl = options.fetchImpl ?? fetch;
20820
22990
  const extract = options.extract ?? extractWithTar;
@@ -20831,13 +23001,15 @@ async function fetchBundle(options) {
20831
23001
  } catch (error) {
20832
23002
  lastError = error;
20833
23003
  const detail = error instanceof Error ? error.message : String(error);
20834
- if (/^4\d\d /.test(detail) || attempt === attempts) break;
23004
+ if (/^4\d\d /.test(detail) && !isTransientStatus(detail) || attempt === attempts) break;
23005
+ const wait = retryAfterMs(detail) ?? retryDelayMs;
20835
23006
  logger.warn("Bundle download failed; retrying", {
20836
23007
  attempt,
20837
23008
  of: attempts,
20838
- error: detail
23009
+ error: detail,
23010
+ waitMs: wait
20839
23011
  });
20840
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
23012
+ await new Promise((resolve) => setTimeout(resolve, wait));
20841
23013
  }
20842
23014
  if (lastError) {
20843
23015
  fs$1.rmSync(tarball, { force: true });
@@ -21433,6 +23605,7 @@ var TOPOLOGY_ENV_VARS = [
21433
23605
  "REBASE_CRON_SCHEDULER",
21434
23606
  "REBASE_JOB_WORKERS",
21435
23607
  "REBASE_RLS_AUDIT",
23608
+ "REBASE_MCP_ENABLED",
21436
23609
  "REBASE_MIGRATE_ON_BOOT",
21437
23610
  "TRUSTED_PROXY_HOPS",
21438
23611
  "REBASE_RATE_LIMIT_STORE",
@@ -21493,6 +23666,6 @@ var RUNTIME_STARTUP_BUDGET_SECONDS = 300;
21493
23666
  /** Where a bundle is mounted, and what the runtime is told to read. */
21494
23667
  var RUNTIME_BUNDLE_MOUNT = "/bundle";
21495
23668
  //#endregion
21496
- export { ACCOUNT_SCOPED_STORAGE_BASES, ApiError, BATCH_REF_KEY, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CollectionConfigError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, ETAG_HEADER, FIELD_OPERATORS, GCSStorageController, IF_MATCH_HEADER, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RUNTIME_BUNDLE_MOUNT, RUNTIME_HEALTH_PATH, RUNTIME_LIVENESS_PATH, RUNTIME_MIN_TERMINATION_GRACE_SECONDS, RUNTIME_PRESTOP_DRAIN_SECONDS, RUNTIME_PROBE_PATHS, RUNTIME_STARTUP_BUDGET_SECONDS, RawHtml, S3StorageController, SMTPEmailService, StoragePolicyError, TOPOLOGY_ENV_VARS, TransformCache, TusHandler, UPLOAD_COLLECTION_FIELD, UPLOAD_PROPERTY_FIELD, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, activeDevEmailSink, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertEveryKindBindable, assertFieldOpsValid, assertIfMatch, assertNoFieldOpsOnCreate, assertUploadWithinPropertyLimits, assertWriteRequestValid, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, clearActiveDevEmailSink, compileStoragePolicies, configureJwt, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDevEmailSink, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createRlsAudit, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, createUnconfiguredEmailService, createUploadConstraintResolver, declaredUniqueTargets, defaultBackoff, defineCron, defineFunction, drainBackgroundWork, envSuffixForKey, errorHandler, escapeHtml, extractLinks, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getApiKey, getDriver, getEmailOtpTemplate, getEmailVerificationTemplate, getEnv, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getRequestId, getRoles, getUser, getUserId, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hasFieldOps, hasRole, hashPassword, html, httpMethodToOperation, identityResolved, implicitDeclaration, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isAcceptedFile, isAdmin, isApiKeyToken, isAuthAdapter, isAuthenticated, isConcurrentDdlRace, isDatabaseAdapter, isDuplicateObjectRace, isLocalhostOrigin, isNodeRuntime, isOperationAllowed, isTopologyEnvVar, isTransformableImage, lazyResource, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadEnv, loadFunctionsFromDirectory, loadFunctionsWithDiagnostics, loadUsersCollection, logger, looksLikeFieldOp, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseFieldOp, parseTransformOptions, pendingBackgroundWork, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, rawQueryLoggingEnabled, readBackupBytes, readBundleManifest, readUploadPropertyContext, rebase, registerDevEmailSink, registerResourceResolver, requireAdmin, requireAuth, requireDriver, requireEnv, requireRole, resolveAuthHooks, resolveAuthOptions, resolveBatchRefs, resolveConflictTarget, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageAccessControl, resolveStorageBackend, resolveStorageSources, resourceResolver, resourceResolvers, rowETag, runFromBundle, runtimeKey, safeCompare, schemaDriftRemedy, serveSPA, splitFieldOps, summarize as summarizeRlsScan, transformImage, tryVerifyOidcIdToken, unbindableKinds, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword, versionProperty, waitUntil, z };
23669
+ export { ACCOUNT_SCOPED_STORAGE_BASES, ApiError, BATCH_REF_KEY, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CollectionConfigError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, ETAG_HEADER, FIELD_OPERATORS, GCSStorageController, IF_MATCH_HEADER, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RUNTIME_BUNDLE_MOUNT, RUNTIME_HEALTH_PATH, RUNTIME_LIVENESS_PATH, RUNTIME_MIN_TERMINATION_GRACE_SECONDS, RUNTIME_PRESTOP_DRAIN_SECONDS, RUNTIME_PROBE_PATHS, RUNTIME_STARTUP_BUDGET_SECONDS, RawHtml, S3StorageController, SMTPEmailService, StoragePolicyError, TOPOLOGY_ENV_VARS, TransformCache, TusHandler, UPLOAD_COLLECTION_FIELD, UPLOAD_PROPERTY_FIELD, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, activeDevEmailSink, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertEveryKindBindable, assertFieldOpsValid, assertIfMatch, assertNoFieldOpsOnCreate, assertUploadWithinPropertyLimits, assertWriteRequestValid, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, clearActiveDevEmailSink, compileStoragePolicies, configureJwt, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDevEmailSink, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createRlsAudit, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, createUnconfiguredEmailService, createUploadConstraintResolver, declaredUniqueTargets, defaultBackoff, defineCron, defineFunction, drainBackgroundWork, envSuffixForKey, errorHandler, escapeHtml, extractLinks, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getApiKey, getDriver, getEmailOtpTemplate, getEmailVerificationTemplate, getEnv, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getRequestId, getRoles, getUser, getUserId, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hasFieldOps, hasRole, hashPassword, html, httpMethodToOperation, identityResolved, implicitDeclaration, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isAcceptedFile, isAdmin, isApiKeyToken, isAuthAdapter, isAuthenticated, isConcurrentDdlRace, isDatabaseAdapter, isDuplicateObjectRace, isLocalhostOrigin, isNodeRuntime, isOperationAllowed, isTopologyEnvVar, isTransformableImage, isUncountedPath, lazyResource, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadEnv, loadFunctionsFromDirectory, loadFunctionsWithDiagnostics, loadUsersCollection, logger, looksLikeFieldOp, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseFieldOp, parseTransformOptions, pendingBackgroundWork, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, rawQueryLoggingEnabled, readBackupBytes, readBundleManifest, readUploadPropertyContext, rebase, registerDevEmailSink, registerResourceResolver, requireAdmin, requireAuth, requireDriver, requireEnv, requireRole, resolveAuthHooks, resolveAuthOptions, resolveBatchRefs, resolveConflictTarget, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageAccessControl, resolveStorageBackend, resolveStorageSources, resourceResolver, resourceResolvers, rowETag, runFromBundle, runtimeKey, safeCompare, schemaDriftRemedy, serveSPA, splitFieldOps, summarize as summarizeRlsScan, transformImage, tryVerifyOidcIdToken, unbindableKinds, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword, versionProperty, waitUntil, z };
21497
23670
 
21498
23671
  //# sourceMappingURL=index.es.js.map