@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.
- package/dist/{admin_block-0Xu0r6eZ.js → admin_block-DxKLmdiv.js} +26 -8
- package/dist/{admin_block-0Xu0r6eZ.js.map → admin_block-DxKLmdiv.js.map} +1 -1
- package/dist/api/ast-schema-editor.d.ts +15 -0
- package/dist/api/rest/api-generator.d.ts +28 -0
- package/dist/{ast-schema-editor-C6mDz0XN.js → ast-schema-editor-CslO8Oje.js} +24 -4
- package/dist/ast-schema-editor-CslO8Oje.js.map +1 -0
- package/dist/auth/jwt.d.ts +67 -0
- package/dist/auth/rls-scope.d.ts +14 -0
- package/dist/{auth-DJsLXsCR.js → auth-gzoRjnuh.js} +24 -12
- package/dist/auth-gzoRjnuh.js.map +1 -0
- package/dist/boot/env.d.ts +8 -2
- package/dist/boot/fetch-bundle.d.ts +19 -0
- package/dist/boot/role.d.ts +2 -0
- package/dist/contract-routes-eLxV0le1.js.map +1 -1
- package/dist/{cron-routes-Bfwni8Zg.js → cron-routes-BamgO0Xr.js} +2 -2
- package/dist/{cron-routes-Bfwni8Zg.js.map → cron-routes-BamgO0Xr.js.map} +1 -1
- package/dist/{cron-store-Bsiw4Q6u.js → cron-store-yXOyIEmn.js} +2 -3
- package/dist/{cron-store-Bsiw4Q6u.js.map → cron-store-yXOyIEmn.js.map} +1 -1
- package/dist/db/ambient-transaction.d.ts +48 -0
- package/dist/ddl-bootstrap-CfNvxMuK.js.map +1 -1
- package/dist/deploy/pod-contract.d.ts +1 -1
- package/dist/errors-DMImyqyR.js.map +1 -1
- package/dist/functions/index.js.map +1 -1
- package/dist/{history-recorder-r5_IzSHK.js → history-recorder-hBGuXcZc.js} +2 -2
- package/dist/{history-recorder-r5_IzSHK.js.map → history-recorder-hBGuXcZc.js.map} +1 -1
- package/dist/{history-store-D4RVK-uZ.js → history-store-C9EtGtoL.js} +5 -6
- package/dist/history-store-C9EtGtoL.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.es.js +2266 -93
- package/dist/index.es.js.map +1 -1
- package/dist/init/callback-client.d.ts +16 -0
- package/dist/init/surfaces.d.ts +37 -7
- package/dist/jobs/types.d.ts +5 -0
- package/dist/{jobs-CW5lm_Ix.js → jobs-DZ8DLLcU.js} +82 -13
- package/dist/jobs-DZ8DLLcU.js.map +1 -0
- package/dist/{jwt-DATvkKB_.js → jwt-C4OW-DNq.js} +136 -4
- package/dist/jwt-C4OW-DNq.js.map +1 -0
- package/dist/logger-DO2PZc4i.js.map +1 -1
- package/dist/mcp/consent-page.d.ts +75 -0
- package/dist/mcp/mcp-routes.d.ts +52 -0
- package/dist/mcp/mcp-tools.d.ts +51 -0
- package/dist/mcp/oauth-metadata.d.ts +123 -0
- package/dist/mcp/oauth-routes.d.ts +52 -0
- package/dist/mcp/oauth-store.d.ts +157 -0
- package/dist/metrics/index.d.ts +16 -1
- package/dist/{openapi-generator-DGyLbISS.js → openapi-generator-CAFKF36E.js} +2 -8
- package/dist/openapi-generator-CAFKF36E.js.map +1 -0
- package/dist/{query-parser-BQiPZrM-.js → query-parser-uqzkZ82i.js} +2 -2
- package/dist/{query-parser-BQiPZrM-.js.map → query-parser-uqzkZ82i.js.map} +1 -1
- package/dist/{schema-editor-routes-C3TLZqAC.js → schema-editor-routes-BKOmdf4M.js} +2 -2
- package/dist/{schema-editor-routes-C3TLZqAC.js.map → schema-editor-routes-BKOmdf4M.js.map} +1 -1
- package/dist/services/webhook-service.d.ts +11 -8
- package/dist/sql-rows-C6GEc2oE.js +45 -0
- package/dist/sql-rows-C6GEc2oE.js.map +1 -0
- package/dist/src-Br6ARbs6.js.map +1 -1
- package/dist/{src-DqZ9YiGA.js → src-DW1cQ_28.js} +261 -17
- package/dist/src-DW1cQ_28.js.map +1 -0
- package/dist/utils/portable-crypto.d.ts +8 -0
- package/package.json +5 -5
- package/dist/ast-schema-editor-C6mDz0XN.js.map +0 -1
- package/dist/auth-DJsLXsCR.js.map +0 -1
- package/dist/history-store-D4RVK-uZ.js.map +0 -1
- package/dist/internal-tables-DYVcFFSv.js +0 -117
- package/dist/internal-tables-DYVcFFSv.js.map +0 -1
- package/dist/jobs-CW5lm_Ix.js.map +0 -1
- package/dist/jwt-DATvkKB_.js.map +0 -1
- package/dist/openapi-generator-DGyLbISS.js.map +0 -1
- 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 {
|
|
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-
|
|
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
|
|
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-
|
|
15
|
-
import {
|
|
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-
|
|
29
|
-
import { t as createCronStore } from "./cron-store-
|
|
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-
|
|
32
|
-
import { t as METRIC_SERIES } from "./history-store-
|
|
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
|
|
2497
|
+
field,
|
|
2413
2498
|
code: "required",
|
|
2414
|
-
message: property.validation.requiredMessage ?? `'${
|
|
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-
|
|
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
|
|
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
|
-
*
|
|
8559
|
+
* Surfaces that default to OFF, against the rule below.
|
|
8398
8560
|
*
|
|
8399
|
-
*
|
|
8400
|
-
* that says nothing gets the process this server
|
|
8401
|
-
*
|
|
8402
|
-
*
|
|
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] ??
|
|
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,
|
|
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>“${clientName}” 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 (
|
|
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 &&
|
|
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 =
|
|
13487
|
+
const updatePks = collectionUpdate.pks;
|
|
11467
13488
|
if (updatePks) collectionSub.pks = updatePks;
|
|
11468
|
-
const updateMeta =
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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"
|
|
12571
|
-
const text = asObject?.message ?? (typeof raw === "string" ? raw : void 0) ??
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
17410
|
-
const { createCronStore } = await import("./cron-store-
|
|
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-
|
|
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-
|
|
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
|
-
*
|
|
18493
|
-
*
|
|
18494
|
-
* the
|
|
18495
|
-
* before
|
|
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
|
-
*
|
|
18499
|
-
*
|
|
18500
|
-
*
|
|
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
|
|
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: "
|
|
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
|
|
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)
|
|
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,
|
|
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
|