@rebasepro/server 0.9.1-canary.fd3754b → 0.10.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/api/errors.d.ts +16 -1
- package/dist/api/rest/api-generator.d.ts +13 -1
- package/dist/api/rest/write-validation.d.ts +22 -0
- package/dist/api/types.d.ts +2 -2
- package/dist/auth/admin-users-route.d.ts +3 -3
- package/dist/auth/api-keys/api-key-middleware.d.ts +60 -1
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +33 -0
- package/dist/auth/api-keys/api-key-types.d.ts +21 -8
- package/dist/auth/api-keys/index.d.ts +2 -2
- package/dist/auth/auth-hooks.d.ts +7 -7
- package/dist/auth/collection-callback-warning.d.ts +21 -0
- package/dist/auth/index.d.ts +4 -1
- package/dist/auth/interfaces.d.ts +28 -28
- package/dist/auth/jwt.d.ts +20 -4
- package/dist/auth/magic-link-routes.d.ts +2 -2
- package/dist/auth/mfa-routes.d.ts +1 -1
- package/dist/auth/middleware.d.ts +24 -3
- package/dist/auth/rate-limit-store.d.ts +49 -0
- package/dist/auth/rate-limiter.d.ts +68 -6
- package/dist/auth/reset-password-admin.d.ts +1 -1
- package/dist/auth/session-routes.d.ts +2 -2
- package/dist/cron/cron-store.d.ts +18 -0
- package/dist/env.d.ts +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.es.js +2124 -505
- package/dist/index.es.js.map +1 -1
- package/dist/init/middlewares.d.ts +1 -0
- package/dist/init.d.ts +62 -0
- package/dist/{jwt-BwIn8xmk.js → jwt-B3zjddCa.js} +177 -9
- package/dist/jwt-B3zjddCa.js.map +1 -0
- package/dist/{openapi-generator-Z9oYWLf_.js → openapi-generator-DqSIwNLV.js} +8 -2
- package/dist/openapi-generator-DqSIwNLV.js.map +1 -0
- package/dist/{src-B4OLmNVa.js → src-CsHhSKbi.js} +60 -2
- package/dist/src-CsHhSKbi.js.map +1 -0
- package/dist/storage/LocalStorageController.d.ts +19 -2
- package/dist/storage/routes.d.ts +8 -1
- package/dist/storage/tus-handler.d.ts +21 -1
- package/dist/storage/types.d.ts +33 -0
- package/dist/utils/compression.d.ts +16 -0
- package/dist/utils/sql.d.ts +2 -2
- package/package.json +12 -11
- package/dist/index.umd.js +0 -48745
- package/dist/index.umd.js.map +0 -1
- package/dist/jwt-BwIn8xmk.js.map +0 -1
- package/dist/ms-DnYXB-Wd.js +0 -162
- package/dist/ms-DnYXB-Wd.js.map +0 -1
- package/dist/openapi-generator-Z9oYWLf_.js.map +0 -1
- package/dist/src-B4OLmNVa.js.map +0 -1
- package/dist/src-B9tjYmqP.js +0 -24598
- package/dist/src-B9tjYmqP.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import process from "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import { a as
|
|
5
|
-
import { _ as
|
|
4
|
+
import { a as getAccessTokenExpiry, d as require_jsonwebtoken, f as __commonJSMin, h as __toESM, i as generateRefreshToken, l as verifyAccessToken, m as __require, n as generateAccessToken, o as getRefreshTokenExpiry, p as __exportAll, r as generateDownloadToken, s as hashRefreshToken, t as configureJwt, u as verifyDownloadToken } from "./jwt-B3zjddCa.js";
|
|
5
|
+
import { _ as RebaseApiError, a as policy, c as isPostgresCollectionConfig, d as REST_TO_CANONICAL, f as toCanonicalOp, g as Vector, h as GeoPoint, i as isSQLAdmin, l as CANONICAL_TO_REST, m as EntityRelation, o as getCollectionDataPath, p as EntityReference, r as getDataSourceCapabilities, s as getDeclaredSubcollections, t as DEFAULT_STORAGE_SOURCE_KEY, u as NULL_OPS, v as RebaseClientError } from "./src-CsHhSKbi.js";
|
|
6
6
|
import { t as logger } from "./logger-BYU66ENZ.js";
|
|
7
|
-
import { a as getAccessTokenExpiry, d as require_jsonwebtoken, i as generateRefreshToken, l as verifyAccessToken, n as generateAccessToken, o as getRefreshTokenExpiry, r as generateDownloadToken, s as hashRefreshToken, t as configureJwt, u as verifyDownloadToken } from "./jwt-BwIn8xmk.js";
|
|
8
7
|
import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
|
|
9
8
|
import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
10
9
|
import * as fs$2 from "fs";
|
|
@@ -18,6 +17,7 @@ import * as crypto from "crypto";
|
|
|
18
17
|
import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
|
|
19
18
|
import { bodyLimit } from "hono/body-limit";
|
|
20
19
|
import { csrf } from "hono/csrf";
|
|
20
|
+
import { compress } from "hono/compress";
|
|
21
21
|
import fs$1 from "node:fs";
|
|
22
22
|
import fsp from "node:fs/promises";
|
|
23
23
|
import { mkdir, open, unlink, writeFile } from "fs/promises";
|
|
@@ -1108,6 +1108,76 @@ function normalizeToEntityRelation(value, propertyType) {
|
|
|
1108
1108
|
if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
|
|
1109
1109
|
return new EntityRelation(obj.id, obj.path, obj.data);
|
|
1110
1110
|
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Derive a row's address from its key columns.
|
|
1113
|
+
*
|
|
1114
|
+
* Single key → the value as a string. Composite → each part joined by
|
|
1115
|
+
* {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
|
|
1116
|
+
* {@link parseIdValues} expects to invert.
|
|
1117
|
+
*/
|
|
1118
|
+
function buildCompositeId(values, primaryKeys) {
|
|
1119
|
+
if (primaryKeys.length === 0) return "";
|
|
1120
|
+
if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
|
|
1121
|
+
return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* The primary keys of a collection, as declared by its properties.
|
|
1125
|
+
*
|
|
1126
|
+
* This is the only tier both sides can read, because it is the only one written
|
|
1127
|
+
* in the config: the postgres driver can also infer keys from the Drizzle
|
|
1128
|
+
* schema, which the browser never sees and is never sent — the admin compiles
|
|
1129
|
+
* the collection files into its own bundle rather than being served them. A key
|
|
1130
|
+
* that lives only in the Drizzle schema is therefore invisible here, and the
|
|
1131
|
+
* server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
|
|
1132
|
+
* to add.
|
|
1133
|
+
*
|
|
1134
|
+
* Returns an empty array when a collection declares none, which callers must
|
|
1135
|
+
* treat as "not addressable" rather than defaulting to `id`: guessing a key
|
|
1136
|
+
* that is not the real one produces confidently wrong addresses.
|
|
1137
|
+
*/
|
|
1138
|
+
function getDeclaredPrimaryKeys(collection) {
|
|
1139
|
+
const properties = collection.properties;
|
|
1140
|
+
if (!properties) return [];
|
|
1141
|
+
const keys = [];
|
|
1142
|
+
for (const [fieldName, propRaw] of Object.entries(properties)) {
|
|
1143
|
+
const prop = propRaw;
|
|
1144
|
+
if (!prop || typeof prop !== "object") continue;
|
|
1145
|
+
if (!("isId" in prop) || !prop.isId) continue;
|
|
1146
|
+
keys.push({
|
|
1147
|
+
fieldName,
|
|
1148
|
+
type: prop.type === "number" ? "number" : "string",
|
|
1149
|
+
isUUID: prop.isId === "uuid"
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
return keys;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* The keys to address a collection's rows with, resolved the way the driver
|
|
1156
|
+
* resolves them — minus the tier the browser cannot reach.
|
|
1157
|
+
*
|
|
1158
|
+
* The postgres driver tries, in order: properties marked `isId`; the primary
|
|
1159
|
+
* keys of the Drizzle schema; and finally a column literally named `id`. Only
|
|
1160
|
+
* the first and last are visible in a `CollectionConfig`, which is what both
|
|
1161
|
+
* sides share.
|
|
1162
|
+
*
|
|
1163
|
+
* So the two agree except on a collection that declares no `isId` and whose key
|
|
1164
|
+
* is known only to Drizzle. There, the driver reads the real key, and this
|
|
1165
|
+
* either resolves nothing (reported to the console by the caller) or — if the
|
|
1166
|
+
* table happens to have an unrelated `id` property — resolves `id`, which is
|
|
1167
|
+
* the wrong key and cannot be detected from here: the addresses look right and
|
|
1168
|
+
* route wrong. Only the config can settle it, so the server names both cases
|
|
1169
|
+
* at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
|
|
1170
|
+
*/
|
|
1171
|
+
function resolvePrimaryKeys(collection) {
|
|
1172
|
+
const declared = getDeclaredPrimaryKeys(collection);
|
|
1173
|
+
if (declared.length > 0) return declared;
|
|
1174
|
+
const idProp = collection.properties?.id;
|
|
1175
|
+
if (idProp && typeof idProp === "object") return [{
|
|
1176
|
+
fieldName: "id",
|
|
1177
|
+
type: idProp.type === "number" ? "number" : "string"
|
|
1178
|
+
}];
|
|
1179
|
+
return [];
|
|
1180
|
+
}
|
|
1111
1181
|
//#endregion
|
|
1112
1182
|
//#region ../common/src/util/enums.ts
|
|
1113
1183
|
function enumToObjectEntries(enumValues) {
|
|
@@ -1320,6 +1390,8 @@ function getSubcollections(collection) {
|
|
|
1320
1390
|
}
|
|
1321
1391
|
return [];
|
|
1322
1392
|
}
|
|
1393
|
+
policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
1394
|
+
policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
|
|
1323
1395
|
(/* @__PURE__ */ __commonJSMin(((exports, module) => {
|
|
1324
1396
|
(function(root, factory) {
|
|
1325
1397
|
if (typeof define === "function" && define.amd) define(factory);
|
|
@@ -2752,18 +2824,45 @@ function deserializeLogicalCondition(str) {
|
|
|
2752
2824
|
}
|
|
2753
2825
|
//#endregion
|
|
2754
2826
|
//#region ../common/src/data/buildRebaseData.ts
|
|
2827
|
+
function createPrimaryKeyResolver(options) {
|
|
2828
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2829
|
+
const warned = /* @__PURE__ */ new Set();
|
|
2830
|
+
return function primaryKeysFor(slug) {
|
|
2831
|
+
const cached = cache.get(slug);
|
|
2832
|
+
if (cached) return cached;
|
|
2833
|
+
const collection = options?.resolveCollection?.(slug);
|
|
2834
|
+
if (!collection) return [];
|
|
2835
|
+
const keys = resolvePrimaryKeys(collection);
|
|
2836
|
+
if (keys.length > 0) {
|
|
2837
|
+
cache.set(slug, keys);
|
|
2838
|
+
return keys;
|
|
2839
|
+
}
|
|
2840
|
+
if (!warned.has(slug)) {
|
|
2841
|
+
warned.add(slug);
|
|
2842
|
+
console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
|
|
2843
|
+
}
|
|
2844
|
+
return keys;
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2755
2847
|
/**
|
|
2756
|
-
*
|
|
2757
|
-
*
|
|
2848
|
+
* Give a flat row the Entity view-model the admin renders.
|
|
2849
|
+
*
|
|
2850
|
+
* The address is *derived here* — it is not a column, and the row it came from
|
|
2851
|
+
* does not contain one. Rows carry exactly what the table has, with the types
|
|
2852
|
+
* Postgres returned; the id is this layer's invention, and this is the only
|
|
2853
|
+
* place it is minted.
|
|
2854
|
+
*
|
|
2855
|
+
* `primaryKeys` empty falls back to a literal `id` on the row: drivers other
|
|
2856
|
+
* than postgres still serve rows with one, and this keeps them working.
|
|
2758
2857
|
*/
|
|
2759
|
-
function rowToEntity(row, slug) {
|
|
2858
|
+
function rowToEntity(row, slug, primaryKeys = []) {
|
|
2760
2859
|
return {
|
|
2761
|
-
id: row.id,
|
|
2860
|
+
id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
|
|
2762
2861
|
path: slug,
|
|
2763
2862
|
values: row
|
|
2764
2863
|
};
|
|
2765
2864
|
}
|
|
2766
|
-
function createDriverAccessor(driver, slug) {
|
|
2865
|
+
function createDriverAccessor(driver, slug, getPks = () => []) {
|
|
2767
2866
|
const accessor = {
|
|
2768
2867
|
async find(params) {
|
|
2769
2868
|
const filter = params?.where ? deserializeFilter(params.where) : void 0;
|
|
@@ -2796,7 +2895,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
2796
2895
|
hasMore = offset + rows.length < total;
|
|
2797
2896
|
}
|
|
2798
2897
|
return {
|
|
2799
|
-
data: rows.map((row) => rowToEntity(row, slug)),
|
|
2898
|
+
data: rows.map((row) => rowToEntity(row, slug, getPks())),
|
|
2800
2899
|
meta: {
|
|
2801
2900
|
total,
|
|
2802
2901
|
limit,
|
|
@@ -2810,7 +2909,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
2810
2909
|
path: slug,
|
|
2811
2910
|
id
|
|
2812
2911
|
});
|
|
2813
|
-
return row ? rowToEntity(row, slug) : void 0;
|
|
2912
|
+
return row ? rowToEntity(row, slug, getPks()) : void 0;
|
|
2814
2913
|
},
|
|
2815
2914
|
async create(data, id) {
|
|
2816
2915
|
return rowToEntity(await driver.save({
|
|
@@ -2818,15 +2917,22 @@ function createDriverAccessor(driver, slug) {
|
|
|
2818
2917
|
values: data,
|
|
2819
2918
|
id,
|
|
2820
2919
|
status: "new"
|
|
2821
|
-
}), slug);
|
|
2920
|
+
}), slug, getPks());
|
|
2822
2921
|
},
|
|
2922
|
+
createMany: driver.saveMany ? async (data, options) => {
|
|
2923
|
+
return (await driver.saveMany({
|
|
2924
|
+
path: slug,
|
|
2925
|
+
rows: data,
|
|
2926
|
+
upsert: options?.upsert
|
|
2927
|
+
})).map((row) => rowToEntity(row, slug, getPks()));
|
|
2928
|
+
} : void 0,
|
|
2823
2929
|
async update(id, data) {
|
|
2824
2930
|
return rowToEntity(await driver.save({
|
|
2825
2931
|
path: slug,
|
|
2826
2932
|
values: data,
|
|
2827
2933
|
id,
|
|
2828
2934
|
status: "existing"
|
|
2829
|
-
}), slug);
|
|
2935
|
+
}), slug, getPks());
|
|
2830
2936
|
},
|
|
2831
2937
|
async delete(id) {
|
|
2832
2938
|
return driver.delete({ row: {
|
|
@@ -2855,7 +2961,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
2855
2961
|
searchString: params?.searchString,
|
|
2856
2962
|
onUpdate: (entities) => {
|
|
2857
2963
|
onUpdate({
|
|
2858
|
-
data: entities.map((row) => rowToEntity(row, slug)),
|
|
2964
|
+
data: entities.map((row) => rowToEntity(row, slug, getPks())),
|
|
2859
2965
|
meta: {
|
|
2860
2966
|
total: entities.length,
|
|
2861
2967
|
limit,
|
|
@@ -2871,7 +2977,7 @@ function createDriverAccessor(driver, slug) {
|
|
|
2871
2977
|
return driver.listenOne({
|
|
2872
2978
|
path: slug,
|
|
2873
2979
|
id,
|
|
2874
|
-
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
|
|
2980
|
+
onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
|
|
2875
2981
|
onError
|
|
2876
2982
|
});
|
|
2877
2983
|
} : void 0,
|
|
@@ -2910,12 +3016,13 @@ function createDriverAccessor(driver, slug) {
|
|
|
2910
3016
|
* await data.products.create({ name: "Camera", price: 299 });
|
|
2911
3017
|
* const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
|
|
2912
3018
|
*/
|
|
2913
|
-
function buildRebaseData(driver) {
|
|
3019
|
+
function buildRebaseData(driver, options) {
|
|
2914
3020
|
const cache = /* @__PURE__ */ new Map();
|
|
3021
|
+
const primaryKeysFor = createPrimaryKeyResolver(options);
|
|
2915
3022
|
function getAccessor(slug) {
|
|
2916
3023
|
let accessor = cache.get(slug);
|
|
2917
3024
|
if (!accessor) {
|
|
2918
|
-
accessor = createDriverAccessor(driver, slug);
|
|
3025
|
+
accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
|
|
2919
3026
|
cache.set(slug, accessor);
|
|
2920
3027
|
}
|
|
2921
3028
|
return accessor;
|
|
@@ -2928,8 +3035,9 @@ function buildRebaseData(driver) {
|
|
|
2928
3035
|
} });
|
|
2929
3036
|
}
|
|
2930
3037
|
/**
|
|
2931
|
-
* Unwrap a Entity into
|
|
2932
|
-
*
|
|
3038
|
+
* Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
|
|
3039
|
+
* the row untouched under `.values` and derives `.id` alongside it, so dropping
|
|
3040
|
+
* the wrapper is the whole operation — the address was never part of the row.
|
|
2933
3041
|
*/
|
|
2934
3042
|
function entityToRow(entity) {
|
|
2935
3043
|
return entity.values;
|
|
@@ -3016,6 +3124,12 @@ function toSdkCollectionClient(snap) {
|
|
|
3016
3124
|
async create(data, id) {
|
|
3017
3125
|
return entityToRow(await snap.create(data, id));
|
|
3018
3126
|
},
|
|
3127
|
+
async createMany(data, options) {
|
|
3128
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
3129
|
+
if (data.length === 0) return [];
|
|
3130
|
+
if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
|
|
3131
|
+
return (await snap.createMany(data, options)).map(entityToRow);
|
|
3132
|
+
},
|
|
3019
3133
|
async update(id, data) {
|
|
3020
3134
|
return entityToRow(await snap.update(id, data));
|
|
3021
3135
|
},
|
|
@@ -3191,7 +3305,7 @@ var BackendCollectionRegistry = class extends CollectionRegistry {
|
|
|
3191
3305
|
//#endregion
|
|
3192
3306
|
//#region src/collections/loader.ts
|
|
3193
3307
|
function isCollectionFile(file) {
|
|
3194
|
-
return (file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
|
|
3308
|
+
return (file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
|
|
3195
3309
|
}
|
|
3196
3310
|
async function importModule(filePath) {
|
|
3197
3311
|
return await import(pathToFileURL(filePath).href);
|
|
@@ -3388,6 +3502,21 @@ function createRoutedRealtimeService(opts) {
|
|
|
3388
3502
|
//#region src/api/errors.ts
|
|
3389
3503
|
/** Tracks whether we've already shown the doctor hint (once per process). */
|
|
3390
3504
|
var _schemaDriftHinted = false;
|
|
3505
|
+
/** 5-character SQLSTATE, e.g. `42501`, `23505`. */
|
|
3506
|
+
var SQLSTATE_RE = /^[0-9A-Z]{5}$/;
|
|
3507
|
+
/**
|
|
3508
|
+
* Walk the cause chain for the underlying database error, identified by a
|
|
3509
|
+
* 5-char SQLSTATE `code`. Drizzle wraps the pg error in `.cause`, and route
|
|
3510
|
+
* code sometimes wraps drizzle again, so the real error may sit several
|
|
3511
|
+
* levels down.
|
|
3512
|
+
*/
|
|
3513
|
+
function extractDbError(error, depth = 0) {
|
|
3514
|
+
if (!error || typeof error !== "object" || depth > 8) return null;
|
|
3515
|
+
const e = error;
|
|
3516
|
+
if (typeof e.code === "string" && SQLSTATE_RE.test(e.code)) return e;
|
|
3517
|
+
if (e.cause && typeof e.cause === "object") return extractDbError(e.cause, depth + 1);
|
|
3518
|
+
return null;
|
|
3519
|
+
}
|
|
3391
3520
|
/**
|
|
3392
3521
|
* Extract the missing table or column name from a PG error.
|
|
3393
3522
|
* PG 42P01 messages look like: 'relation "my_table" does not exist'
|
|
@@ -3410,12 +3539,23 @@ var ApiError = class ApiError extends Error {
|
|
|
3410
3539
|
statusCode;
|
|
3411
3540
|
code;
|
|
3412
3541
|
details;
|
|
3413
|
-
|
|
3542
|
+
/**
|
|
3543
|
+
* Whether this outcome is a routine part of normal operation rather than
|
|
3544
|
+
* something an operator should look at. Expected errors log at debug; every
|
|
3545
|
+
* other operational error logs at warn.
|
|
3546
|
+
*
|
|
3547
|
+
* The motivating case is `POST /auth/refresh` with no session: clients
|
|
3548
|
+
* refresh on page load before they know whether one exists, so every
|
|
3549
|
+
* anonymous page view is a 401 — correct, and not worth a warning line.
|
|
3550
|
+
*/
|
|
3551
|
+
expected;
|
|
3552
|
+
constructor(statusCode, code, message, details, expected = false) {
|
|
3414
3553
|
super(message);
|
|
3415
3554
|
this.name = "ApiError";
|
|
3416
3555
|
this.statusCode = statusCode;
|
|
3417
3556
|
this.code = code;
|
|
3418
3557
|
this.details = details;
|
|
3558
|
+
this.expected = expected;
|
|
3419
3559
|
}
|
|
3420
3560
|
static badRequest(message, code = "BAD_REQUEST", details) {
|
|
3421
3561
|
return new ApiError(400, code, message, details);
|
|
@@ -3423,6 +3563,13 @@ var ApiError = class ApiError extends Error {
|
|
|
3423
3563
|
static unauthorized(message, code = "UNAUTHORIZED") {
|
|
3424
3564
|
return new ApiError(401, code, message);
|
|
3425
3565
|
}
|
|
3566
|
+
/**
|
|
3567
|
+
* A 401 that is a normal outcome, not an incident — logged at debug.
|
|
3568
|
+
* See {@link ApiError.expected}.
|
|
3569
|
+
*/
|
|
3570
|
+
static unauthenticated(message, code = "UNAUTHORIZED") {
|
|
3571
|
+
return new ApiError(401, code, message, void 0, true);
|
|
3572
|
+
}
|
|
3426
3573
|
static forbidden(message, code = "FORBIDDEN") {
|
|
3427
3574
|
return new ApiError(403, code, message);
|
|
3428
3575
|
}
|
|
@@ -3455,7 +3602,10 @@ var errorHandler = (err, c) => {
|
|
|
3455
3602
|
const error = err;
|
|
3456
3603
|
const reqId = typeof c.get === "function" ? c.get("requestId") : void 0;
|
|
3457
3604
|
if (error instanceof ApiError || error.name === "ApiError") {
|
|
3458
|
-
|
|
3605
|
+
const expected = error instanceof ApiError && error.expected;
|
|
3606
|
+
const line = `[API] ${c.req.method} ${c.req.path} → ${error.statusCode} ${error.code}: ${error.message}` + (reqId ? ` [${reqId}]` : "");
|
|
3607
|
+
if (expected) logger.debug(line);
|
|
3608
|
+
else logger.warn(`⚠️ ${line}`);
|
|
3459
3609
|
return c.json({ error: {
|
|
3460
3610
|
message: error.message,
|
|
3461
3611
|
code: error.code || "INTERNAL_ERROR",
|
|
@@ -3472,26 +3622,31 @@ var errorHandler = (err, c) => {
|
|
|
3472
3622
|
if (cause.code === "ECONNREFUSED" && !cause.address && Array.isArray(cause.errors)) resolvedCause = cause.errors.find((e) => e.address) || cause;
|
|
3473
3623
|
else resolvedCause = cause;
|
|
3474
3624
|
}
|
|
3475
|
-
|
|
3625
|
+
const dbError = extractDbError(error);
|
|
3626
|
+
if (resolvedCause && (resolvedCause.code === "ENETUNREACH" || resolvedCause.code === "ECONNREFUSED")) {
|
|
3476
3627
|
const cause = resolvedCause;
|
|
3477
3628
|
if (cause.code === "ENETUNREACH") logMessage = `Network unreachable. Cannot connect to database at ${cause.address}:${cause.port}.`;
|
|
3478
|
-
else
|
|
3479
|
-
else if (cause.code === "42703" || cause.code === "42P01") {
|
|
3480
|
-
code = "SCHEMA_DRIFT";
|
|
3481
|
-
logMessage = `Schema drift: ${cause.code === "42703" ? "column" : "table"} "${cause.table || cause.column || extractMissingIdentifier(cause.message) || "unknown"}" does not exist in the database. Run \`pnpm db:push\` to sync your schema, or \`pnpm db:migrate\` to apply pending migrations.`;
|
|
3482
|
-
}
|
|
3629
|
+
else logMessage = `Connection refused to database at ${cause.address}:${cause.port}. Is PostgreSQL running?`;
|
|
3483
3630
|
} else if ("code" in error && error.code === "ENETUNREACH") {
|
|
3484
3631
|
const netErr = error;
|
|
3485
3632
|
logMessage = `Network unreachable. Cannot connect to service at ${netErr.address}:${netErr.port}.`;
|
|
3486
|
-
} else if (
|
|
3633
|
+
} else if (dbError && (dbError.code === "42703" || dbError.code === "42P01")) {
|
|
3487
3634
|
code = "SCHEMA_DRIFT";
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3635
|
+
logMessage = `Schema drift: ${dbError.code === "42703" ? "column" : "table"} "${dbError.table || dbError.column || extractMissingIdentifier(dbError.message) || "unknown"}" does not exist in the database. Run \`pnpm db:push\` to sync your schema, or \`pnpm db:migrate\` to apply pending migrations.`;
|
|
3636
|
+
} else if (dbError) {
|
|
3637
|
+
const parts = [`[PG ${dbError.code}] ${dbError.message}`];
|
|
3638
|
+
if (dbError.detail) parts.push(`Detail: ${dbError.detail}`);
|
|
3639
|
+
if (dbError.hint) parts.push(`Hint: ${dbError.hint}`);
|
|
3640
|
+
if (dbError.table) parts.push(`Table: ${dbError.table}`);
|
|
3641
|
+
if (dbError.column) parts.push(`Column: ${dbError.column}`);
|
|
3642
|
+
if (dbError.constraint) parts.push(`Constraint: ${dbError.constraint}`);
|
|
3643
|
+
if (dbError.code === "42501") {
|
|
3644
|
+
code = "DB_PERMISSION_DENIED";
|
|
3645
|
+
parts.push(`The database rejected the statement for lack of privilege — usually a row-level security policy${dbError.table ? ` on "${dbError.table}"` : ""} denying this role, or a stale FORCE ROW LEVEL SECURITY flag binding the owner connection.`);
|
|
3646
|
+
}
|
|
3647
|
+
logMessage = parts.join(". ");
|
|
3648
|
+
}
|
|
3649
|
+
const isDbSchemaMismatch = code === "SCHEMA_DRIFT";
|
|
3495
3650
|
if (isDbSchemaMismatch) {
|
|
3496
3651
|
logger.warn(`⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` + (reqId ? ` [${reqId}]` : ""));
|
|
3497
3652
|
if (!_schemaDriftHinted && process.env.NODE_ENV !== "production") {
|
|
@@ -3510,18 +3665,27 @@ var errorHandler = (err, c) => {
|
|
|
3510
3665
|
].join("\n"));
|
|
3511
3666
|
}
|
|
3512
3667
|
} else logger.error(`❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` + (reqId ? ` [${reqId}]` : ""));
|
|
3513
|
-
if (!(isDbSchemaMismatch || statusCode < 500 && code === "BAD_REQUEST")) logger.error(String(error.stack || error));
|
|
3668
|
+
if (!(isDbSchemaMismatch || dbError !== null || statusCode < 500 && code === "BAD_REQUEST")) logger.error(String(error.stack || error));
|
|
3514
3669
|
let clientMessage = "An unexpected error occurred";
|
|
3515
3670
|
if (statusCode < 500 && error.message) clientMessage = error.message;
|
|
3516
3671
|
else if (error instanceof ApiError || error.name === "ApiError") clientMessage = error.message;
|
|
3517
3672
|
else if (code === "SCHEMA_DRIFT") {
|
|
3518
|
-
const pgErr =
|
|
3519
|
-
clientMessage = `Schema drift: ${pgErr.code === "42703"
|
|
3520
|
-
} else if (code === "
|
|
3673
|
+
const pgErr = dbError || error;
|
|
3674
|
+
clientMessage = `Schema drift: ${pgErr.code === "42703" ? "column" : "table"} "${pgErr.table || pgErr.column || extractMissingIdentifier(pgErr.message || error.message) || "unknown"}" does not exist. Run \`pnpm db:push\` to sync your schema.`;
|
|
3675
|
+
} else if (code === "DB_PERMISSION_DENIED") clientMessage = `Permission denied by the database${dbError?.table ? ` on "${dbError.table}"` : ""} (row-level security). Check the RLS policies for this table.`;
|
|
3676
|
+
else if (code === "INTERNAL_ERROR") clientMessage = "Internal Server Error";
|
|
3677
|
+
const dbDetails = dbError ? {
|
|
3678
|
+
dbCode: dbError.code,
|
|
3679
|
+
...process.env.NODE_ENV !== "production" && {
|
|
3680
|
+
dbMessage: dbError.message,
|
|
3681
|
+
...dbError.detail && { detail: dbError.detail },
|
|
3682
|
+
...dbError.hint && { hint: dbError.hint }
|
|
3683
|
+
}
|
|
3684
|
+
} : void 0;
|
|
3521
3685
|
return c.json({ error: {
|
|
3522
3686
|
message: clientMessage,
|
|
3523
3687
|
code,
|
|
3524
|
-
...error.details !== void 0
|
|
3688
|
+
...error.details !== void 0 ? { details: error.details } : dbDetails !== void 0 ? { details: dbDetails } : {},
|
|
3525
3689
|
...reqId && { requestId: reqId }
|
|
3526
3690
|
} }, statusCode);
|
|
3527
3691
|
};
|
|
@@ -3543,6 +3707,7 @@ function codeToStatus(code) {
|
|
|
3543
3707
|
EMAIL_EXISTS: 409,
|
|
3544
3708
|
ROLE_EXISTS: 409,
|
|
3545
3709
|
SCHEMA_DRIFT: 500,
|
|
3710
|
+
DB_PERMISSION_DENIED: 500,
|
|
3546
3711
|
INTERNAL_ERROR: 500,
|
|
3547
3712
|
NOT_CONFIGURED: 503,
|
|
3548
3713
|
SERVICE_UNAVAILABLE: 503
|
|
@@ -3668,6 +3833,41 @@ function parseQueryOptions(query) {
|
|
|
3668
3833
|
return options;
|
|
3669
3834
|
}
|
|
3670
3835
|
//#endregion
|
|
3836
|
+
//#region src/api/rest/write-validation.ts
|
|
3837
|
+
/**
|
|
3838
|
+
* Reject a write naming a field the collection does not have.
|
|
3839
|
+
*
|
|
3840
|
+
* Unknown keys used to travel all the way into the INSERT, where Postgres
|
|
3841
|
+
* rejected them — so a typo came back as `column "titel" does not exist`,
|
|
3842
|
+
* phrased by the database, from a stack the caller cannot see, and only if the
|
|
3843
|
+
* column really was absent. It is a request problem and belongs in a 400.
|
|
3844
|
+
*
|
|
3845
|
+
* What counts as known:
|
|
3846
|
+
* - a declared property (for an introspected BaaS collection these *are* the
|
|
3847
|
+
* columns, so the set is exact);
|
|
3848
|
+
* - the foreign-key column behind an owning relation, which callers may write
|
|
3849
|
+
* directly instead of through the relation property;
|
|
3850
|
+
* - anything named in `options.extraKnownFields` — for an auth collection the
|
|
3851
|
+
* credential keys the auth adapter consumes before a row is ever built;
|
|
3852
|
+
* - nothing else. `id` in particular is not automatically known — see below.
|
|
3853
|
+
*/
|
|
3854
|
+
function assertKnownWriteFields(values, collection, options) {
|
|
3855
|
+
if (collection.strictWrites === false) return;
|
|
3856
|
+
if (!collection.properties || Object.keys(collection.properties).length === 0) return;
|
|
3857
|
+
const known = new Set(Object.keys(collection.properties));
|
|
3858
|
+
for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.localKey) known.add(relation.localKey);
|
|
3859
|
+
for (const field of options?.extraKnownFields ?? []) known.add(field);
|
|
3860
|
+
const unknown = Object.keys(values).filter((key) => !known.has(key));
|
|
3861
|
+
if (unknown.length === 0) return;
|
|
3862
|
+
const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
|
|
3863
|
+
if (unknown.includes("id") && !known.has("id")) {
|
|
3864
|
+
const keys = Object.entries(collection.properties ?? {}).filter(([, prop]) => "isId" in prop && Boolean(prop.isId)).map(([name]) => `'${name}'`);
|
|
3865
|
+
const keyDesc = keys.length > 0 ? keys.join(" + ") : "its own key column";
|
|
3866
|
+
throw ApiError.badRequest(`${where}'${collection.slug}' has no 'id' column — it is keyed on ${keyDesc}. The \`id\` argument of \`create(data, id)\` is written as an \`id\` column, so for this collection put the key in \`data\` instead.`, "VALIDATION_UNKNOWN_FIELDS");
|
|
3867
|
+
}
|
|
3868
|
+
throw ApiError.badRequest(`${where}'${collection.slug}' has no field${unknown.length > 1 ? "s" : ""} ${unknown.map((f) => `'${f}'`).join(", ")}. Known fields: ${[...known].sort().map((f) => `'${f}'`).join(", ")}.`, "VALIDATION_UNKNOWN_FIELDS");
|
|
3869
|
+
}
|
|
3870
|
+
//#endregion
|
|
3671
3871
|
//#region src/auth/api-keys/api-key-permission-guard.ts
|
|
3672
3872
|
/**
|
|
3673
3873
|
* Map an HTTP method string to an `ApiKeyOperation`.
|
|
@@ -3703,6 +3903,44 @@ function isOperationAllowed(permissions, collection, operation) {
|
|
|
3703
3903
|
for (const perm of permissions) if ((perm.collection === "*" || perm.collection === collection) && perm.operations.includes(operation)) return true;
|
|
3704
3904
|
return false;
|
|
3705
3905
|
}
|
|
3906
|
+
/**
|
|
3907
|
+
* Check whether the given permissions array allows a storage operation.
|
|
3908
|
+
*
|
|
3909
|
+
* Storage, like functions, lives outside the collection namespace: an entry
|
|
3910
|
+
* with collection `"storage"` grants storage access for the listed
|
|
3911
|
+
* operations (GET routes → `read`, upload/folder/tus → `write`,
|
|
3912
|
+
* DELETE routes → `delete`), and the global `"*"` wildcard also matches.
|
|
3913
|
+
* A collection-scoped key gets no storage access at all.
|
|
3914
|
+
*
|
|
3915
|
+
* Matching is exactly {@link isOperationAllowed} with `"storage"` as the
|
|
3916
|
+
* resource name — delegated so the semantics can never drift.
|
|
3917
|
+
*/
|
|
3918
|
+
function isStorageAllowed(permissions, operation) {
|
|
3919
|
+
return isOperationAllowed(permissions, "storage", operation);
|
|
3920
|
+
}
|
|
3921
|
+
/**
|
|
3922
|
+
* Check whether the given permissions array allows invoking a custom function.
|
|
3923
|
+
*
|
|
3924
|
+
* Functions live outside the collection namespace, so they have their own
|
|
3925
|
+
* resource names in the permission list:
|
|
3926
|
+
*
|
|
3927
|
+
* - `{ collection: "functions", ... }` grants every function
|
|
3928
|
+
* - `{ collection: "functions/<name>", ... }` grants a single function
|
|
3929
|
+
* - the global `"*"` wildcard (full-access keys) also matches
|
|
3930
|
+
*
|
|
3931
|
+
* The operation is derived from the HTTP method as usual (GET → read,
|
|
3932
|
+
* POST/PUT/PATCH → write, DELETE → delete). The functions index route is
|
|
3933
|
+
* checked as `functionName === ""`, which only the `"functions"` and `"*"`
|
|
3934
|
+
* entries can grant.
|
|
3935
|
+
*
|
|
3936
|
+
* A collection-scoped key (e.g. read-only on `events`) therefore can NOT
|
|
3937
|
+
* invoke functions — before this guard existed, any valid key could call
|
|
3938
|
+
* every function.
|
|
3939
|
+
*/
|
|
3940
|
+
function isFunctionAllowed(permissions, functionName, operation) {
|
|
3941
|
+
for (const perm of permissions) if ((perm.collection === "*" || perm.collection === "functions" || functionName !== "" && perm.collection === `functions/${functionName}`) && perm.operations.includes(operation)) return true;
|
|
3942
|
+
return false;
|
|
3943
|
+
}
|
|
3706
3944
|
//#endregion
|
|
3707
3945
|
//#region src/api/rest/api-generator.ts
|
|
3708
3946
|
/**
|
|
@@ -3724,15 +3962,19 @@ async function parseJsonBody(c) {
|
|
|
3724
3962
|
* Lightweight REST API generator that leverages existing Rebase DataDriver.
|
|
3725
3963
|
* Supports `include` query parameter for eager-loading relations via Drizzle.
|
|
3726
3964
|
*/
|
|
3965
|
+
/** Rows accepted by a single POST /<collection>/bulk. See `maxBulkRows`. */
|
|
3966
|
+
var DEFAULT_MAX_BULK_ROWS = 1e3;
|
|
3727
3967
|
var RestApiGenerator = class {
|
|
3728
3968
|
collections;
|
|
3729
3969
|
router;
|
|
3730
3970
|
driver;
|
|
3971
|
+
maxBulkRows;
|
|
3731
3972
|
authAdapter;
|
|
3732
|
-
constructor(collections, driver, authAdapter) {
|
|
3973
|
+
constructor(collections, driver, authAdapter, maxBulkRows = DEFAULT_MAX_BULK_ROWS) {
|
|
3733
3974
|
this.collections = collections;
|
|
3734
3975
|
this.driver = driver;
|
|
3735
3976
|
this.authAdapter = authAdapter;
|
|
3977
|
+
this.maxBulkRows = maxBulkRows;
|
|
3736
3978
|
this.router = new Hono();
|
|
3737
3979
|
}
|
|
3738
3980
|
/**
|
|
@@ -3757,6 +3999,17 @@ var RestApiGenerator = class {
|
|
|
3757
3999
|
if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
|
|
3758
4000
|
}
|
|
3759
4001
|
/**
|
|
4002
|
+
* API key permission check for nested paths. The operation targets the
|
|
4003
|
+
* LAST collection in the path (e.g. "posts" for /authors/1/posts), so
|
|
4004
|
+
* that is the slug the key must hold permission for — checking the
|
|
4005
|
+
* parent instead would let a key scoped to "authors" write "posts".
|
|
4006
|
+
* `parseSubPath` always yields a collectionPath ending in a collection
|
|
4007
|
+
* slug, never an id.
|
|
4008
|
+
*/
|
|
4009
|
+
enforceSubcollectionApiKeyPermission(c, collectionPath) {
|
|
4010
|
+
this.enforceApiKeyPermission(c, collectionPath.split("/").pop());
|
|
4011
|
+
}
|
|
4012
|
+
/**
|
|
3760
4013
|
* Get the request-scoped driver. Throws if none is set — never falls
|
|
3761
4014
|
* back to the unscoped `this.driver` to avoid bypassing RLS/auth.
|
|
3762
4015
|
*/
|
|
@@ -3817,6 +4070,33 @@ var RestApiGenerator = class {
|
|
|
3817
4070
|
if (!entity) throw ApiError.notFound("Entity not found");
|
|
3818
4071
|
return c.json(entity);
|
|
3819
4072
|
});
|
|
4073
|
+
this.router.post(`${basePath}/bulk`, async (c) => {
|
|
4074
|
+
this.enforceApiKeyPermission(c, collection.slug);
|
|
4075
|
+
const driver = this.getScopedDriver(c);
|
|
4076
|
+
const path = collection.slug;
|
|
4077
|
+
const body = await parseJsonBody(c);
|
|
4078
|
+
if (!Array.isArray(body?.rows)) throw ApiError.badRequest("Expected a JSON body of { rows: [...] }.", "INVALID_BULK_BODY");
|
|
4079
|
+
if (body.rows.length === 0) return c.json({
|
|
4080
|
+
data: [],
|
|
4081
|
+
meta: { written: 0 }
|
|
4082
|
+
});
|
|
4083
|
+
if (body.rows.some((row) => typeof row !== "object" || row === null || Array.isArray(row))) throw ApiError.badRequest("Every entry in `rows` must be an object.", "INVALID_BULK_BODY");
|
|
4084
|
+
if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
|
|
4085
|
+
const maxRows = this.maxBulkRows;
|
|
4086
|
+
if (body.rows.length > maxRows) throw ApiError.badRequest(`Too many rows: ${body.rows.length} exceeds the ${maxRows}-row limit for a single bulk write. Send it in chunks of ${maxRows} or fewer.`, "BULK_TOO_LARGE");
|
|
4087
|
+
if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
|
|
4088
|
+
body.rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
|
|
4089
|
+
const rows = await driver.saveMany({
|
|
4090
|
+
path,
|
|
4091
|
+
rows: body.rows,
|
|
4092
|
+
collection: resolvedCollection,
|
|
4093
|
+
upsert: body.upsert === true
|
|
4094
|
+
});
|
|
4095
|
+
return c.json({
|
|
4096
|
+
data: rows.map((row) => this.formatResponse(row)),
|
|
4097
|
+
meta: { written: rows.length }
|
|
4098
|
+
});
|
|
4099
|
+
});
|
|
3820
4100
|
this.router.post(basePath, async (c) => {
|
|
3821
4101
|
try {
|
|
3822
4102
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
@@ -3824,8 +4104,14 @@ var RestApiGenerator = class {
|
|
|
3824
4104
|
const path = collection.slug;
|
|
3825
4105
|
const body = await parseJsonBody(c);
|
|
3826
4106
|
const isAuth = collection.auth;
|
|
3827
|
-
|
|
3828
|
-
|
|
4107
|
+
const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
|
|
4108
|
+
const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
|
|
4109
|
+
if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
|
|
4110
|
+
else {
|
|
4111
|
+
const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
|
|
4112
|
+
if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
|
|
4113
|
+
}
|
|
4114
|
+
if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
|
|
3829
4115
|
const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
|
|
3830
4116
|
const entity = await driver.save({
|
|
3831
4117
|
path,
|
|
@@ -3838,7 +4124,7 @@ var RestApiGenerator = class {
|
|
|
3838
4124
|
invitationSent: prepared.invitationSent
|
|
3839
4125
|
} : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
|
|
3840
4126
|
id: entity.id,
|
|
3841
|
-
values: entity
|
|
4127
|
+
values: entity
|
|
3842
4128
|
}, prepared.clearPassword) : { invitationSent: false };
|
|
3843
4129
|
const response = this.formatResponse(entity);
|
|
3844
4130
|
return c.json({
|
|
@@ -3874,6 +4160,7 @@ var RestApiGenerator = class {
|
|
|
3874
4160
|
collection: resolvedCollection
|
|
3875
4161
|
})) throw ApiError.notFound("Entity not found");
|
|
3876
4162
|
const body = await parseJsonBody(c);
|
|
4163
|
+
assertKnownWriteFields(body, resolvedCollection);
|
|
3877
4164
|
const entity = await driver.save({
|
|
3878
4165
|
path: getCollectionDataPath(collection),
|
|
3879
4166
|
id: String(id),
|
|
@@ -3902,7 +4189,7 @@ var RestApiGenerator = class {
|
|
|
3902
4189
|
if (!existingEntity) throw ApiError.notFound("Entity not found");
|
|
3903
4190
|
await driver.delete({
|
|
3904
4191
|
row: {
|
|
3905
|
-
id:
|
|
4192
|
+
id: String(id),
|
|
3906
4193
|
path: getCollectionDataPath(collection),
|
|
3907
4194
|
values: existingEntity
|
|
3908
4195
|
},
|
|
@@ -3950,7 +4237,7 @@ var RestApiGenerator = class {
|
|
|
3950
4237
|
const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
|
|
3951
4238
|
if (!parsed) return next();
|
|
3952
4239
|
const driver = this.getScopedDriver(c);
|
|
3953
|
-
this.
|
|
4240
|
+
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
3954
4241
|
if (parsed.id === "count") {
|
|
3955
4242
|
const queryDict = c.req.queries();
|
|
3956
4243
|
const queryOptions = parseQueryOptions(queryDict);
|
|
@@ -4002,7 +4289,7 @@ var RestApiGenerator = class {
|
|
|
4002
4289
|
const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
|
|
4003
4290
|
if (!parsed || parsed.id) return next();
|
|
4004
4291
|
const driver = this.getScopedDriver(c);
|
|
4005
|
-
this.
|
|
4292
|
+
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
4006
4293
|
const body = await parseJsonBody(c);
|
|
4007
4294
|
const entity = await driver.save({
|
|
4008
4295
|
path: parsed.collectionPath,
|
|
@@ -4018,7 +4305,7 @@ var RestApiGenerator = class {
|
|
|
4018
4305
|
const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
|
|
4019
4306
|
if (!parsed || !parsed.id) return next();
|
|
4020
4307
|
const driver = this.getScopedDriver(c);
|
|
4021
|
-
this.
|
|
4308
|
+
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
4022
4309
|
const body = await parseJsonBody(c);
|
|
4023
4310
|
const entity = await driver.save({
|
|
4024
4311
|
path: parsed.collectionPath,
|
|
@@ -4035,14 +4322,14 @@ var RestApiGenerator = class {
|
|
|
4035
4322
|
const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
|
|
4036
4323
|
if (!parsed || !parsed.id) return next();
|
|
4037
4324
|
const driver = this.getScopedDriver(c);
|
|
4038
|
-
this.
|
|
4325
|
+
this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
|
|
4039
4326
|
const existingEntity = await driver.fetchOne({
|
|
4040
4327
|
path: parsed.collectionPath,
|
|
4041
4328
|
id: parsed.id
|
|
4042
4329
|
});
|
|
4043
4330
|
if (!existingEntity) throw ApiError.notFound("Entity not found");
|
|
4044
4331
|
await driver.delete({ row: {
|
|
4045
|
-
id:
|
|
4332
|
+
id: parsed.id,
|
|
4046
4333
|
path: parsed.collectionPath,
|
|
4047
4334
|
values: existingEntity
|
|
4048
4335
|
} });
|
|
@@ -4165,6 +4452,15 @@ function safeCompare(a, b) {
|
|
|
4165
4452
|
* 4. Sets `c.set("user", ...)` and `c.set("apiKey", ...)` for downstream use
|
|
4166
4453
|
* 5. Scopes the DataDriver via `withAuth()` using the API key's service identity
|
|
4167
4454
|
*
|
|
4455
|
+
* Authorization is double-gated for API keys: the key's own permission list
|
|
4456
|
+
* (checked by the REST generator) is one ceiling, and Postgres RLS is another,
|
|
4457
|
+
* independent one — `withAuth()` runs API-key requests as the restricted
|
|
4458
|
+
* `rebase_user` role like any other caller. An `admin` key passes RLS via the
|
|
4459
|
+
* injected `default_admin` policies; a non-admin key (roles `["service"]`,
|
|
4460
|
+
* uid `api-key:<id>`) only sees rows that a policy explicitly grants to the
|
|
4461
|
+
* `service` role or to the public. Owner-style policies
|
|
4462
|
+
* (`owner_id = auth.uid()`) never match an API key's synthetic uid.
|
|
4463
|
+
*
|
|
4168
4464
|
* @module
|
|
4169
4465
|
*/
|
|
4170
4466
|
/**
|
|
@@ -4204,10 +4500,10 @@ async function validateApiKey(c, token, options) {
|
|
|
4204
4500
|
message: "API key has expired",
|
|
4205
4501
|
code: "UNAUTHORIZED"
|
|
4206
4502
|
} }, 401);
|
|
4207
|
-
const
|
|
4503
|
+
const uid = `api-key:${apiKey.id}`;
|
|
4208
4504
|
const roles = apiKey.admin ? ["admin", "service"] : ["service"];
|
|
4209
4505
|
c.set("user", {
|
|
4210
|
-
|
|
4506
|
+
uid,
|
|
4211
4507
|
roles
|
|
4212
4508
|
});
|
|
4213
4509
|
const masked = {
|
|
@@ -4227,7 +4523,7 @@ async function validateApiKey(c, token, options) {
|
|
|
4227
4523
|
c.set("apiKey", masked);
|
|
4228
4524
|
try {
|
|
4229
4525
|
const scopedDriver = await scopeDataDriver(driver, {
|
|
4230
|
-
uid
|
|
4526
|
+
uid,
|
|
4231
4527
|
roles
|
|
4232
4528
|
});
|
|
4233
4529
|
c.set("driver", scopedDriver);
|
|
@@ -4238,9 +4534,113 @@ async function validateApiKey(c, token, options) {
|
|
|
4238
4534
|
code: "INTERNAL_ERROR"
|
|
4239
4535
|
} }, 500);
|
|
4240
4536
|
}
|
|
4241
|
-
|
|
4537
|
+
const lastTouch = lastUsedTouchedAt.get(apiKey.id);
|
|
4538
|
+
const now = Date.now();
|
|
4539
|
+
if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
|
|
4540
|
+
lastUsedTouchedAt.set(apiKey.id, now);
|
|
4541
|
+
store.updateLastUsed(apiKey.id).catch(() => {});
|
|
4542
|
+
}
|
|
4242
4543
|
return true;
|
|
4243
4544
|
}
|
|
4545
|
+
/** Per-process debounce state for last_used_at touches. */
|
|
4546
|
+
var lastUsedTouchedAt = /* @__PURE__ */ new Map();
|
|
4547
|
+
var LAST_USED_DEBOUNCE_MS = 6e4;
|
|
4548
|
+
/**
|
|
4549
|
+
* Shared 403 response for API-key permission denials outside the REST
|
|
4550
|
+
* generator (storage and functions guards). One envelope, one code — a
|
|
4551
|
+
* change to the error contract happens in one place.
|
|
4552
|
+
*/
|
|
4553
|
+
function forbidApiKey(c, operation, resource, grantHint) {
|
|
4554
|
+
return c.json({ error: {
|
|
4555
|
+
message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
|
|
4556
|
+
code: "API_KEY_FORBIDDEN"
|
|
4557
|
+
} }, 403);
|
|
4558
|
+
}
|
|
4559
|
+
/**
|
|
4560
|
+
* Permission guard for API-key requests to the storage router.
|
|
4561
|
+
*
|
|
4562
|
+
* Storage previously did not accept API keys at all (`rk_` tokens were
|
|
4563
|
+
* misparsed as JWTs and 401'd). Now that the pre-auth middleware
|
|
4564
|
+
* authenticates them, this guard decides what they may do: the key needs a
|
|
4565
|
+
* `"storage"` permission entry (or the global `"*"` wildcard) covering the
|
|
4566
|
+
* operation derived from the HTTP method. Requests not authenticated via an
|
|
4567
|
+
* API key pass through to the storage router's own auth gates.
|
|
4568
|
+
*
|
|
4569
|
+
* TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
|
|
4570
|
+
* for EVERY method: the protocol's offset check is a GET and its cancel is a
|
|
4571
|
+
* DELETE, but both are steps of an upload — a write-scoped key must be able
|
|
4572
|
+
* to complete (and abort) its own resumable upload without also holding
|
|
4573
|
+
* `read`/`delete` on stored objects.
|
|
4574
|
+
*/
|
|
4575
|
+
function createStorageApiKeyGuard() {
|
|
4576
|
+
return async (c, next) => {
|
|
4577
|
+
const apiKey = c.get("apiKey");
|
|
4578
|
+
if (!apiKey) return next();
|
|
4579
|
+
const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
|
|
4580
|
+
if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
|
|
4581
|
+
return next();
|
|
4582
|
+
};
|
|
4583
|
+
}
|
|
4584
|
+
/**
|
|
4585
|
+
* Permission guard for API-key requests to the custom-functions router.
|
|
4586
|
+
*
|
|
4587
|
+
* The collection permission guard lives in the REST generator and never sees
|
|
4588
|
+
* function routes, so before this middleware existed ANY valid API key —
|
|
4589
|
+
* however narrowly scoped — could invoke every custom function. This guard
|
|
4590
|
+
* closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
|
|
4591
|
+
* permission entry (or the global `"*"` wildcard) for the derived operation.
|
|
4592
|
+
*
|
|
4593
|
+
* Non-API-key requests (JWT, service key, anonymous) pass through untouched —
|
|
4594
|
+
* functions decide their own auth for those, as before.
|
|
4595
|
+
*
|
|
4596
|
+
* @param mountPrefix - The path the functions router is mounted at
|
|
4597
|
+
* (e.g. `/api/functions`), used to extract the function
|
|
4598
|
+
* name from the request path.
|
|
4599
|
+
*/
|
|
4600
|
+
function createFunctionApiKeyGuard(mountPrefix) {
|
|
4601
|
+
return async (c, next) => {
|
|
4602
|
+
const apiKey = c.get("apiKey");
|
|
4603
|
+
if (!apiKey) return next();
|
|
4604
|
+
const path = c.req.path;
|
|
4605
|
+
const idx = path.indexOf(mountPrefix);
|
|
4606
|
+
const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
|
|
4607
|
+
let functionName;
|
|
4608
|
+
try {
|
|
4609
|
+
functionName = decodeURIComponent(rawName);
|
|
4610
|
+
} catch {
|
|
4611
|
+
functionName = rawName;
|
|
4612
|
+
}
|
|
4613
|
+
const operation = httpMethodToOperation(c.req.method);
|
|
4614
|
+
if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
|
|
4615
|
+
return next();
|
|
4616
|
+
};
|
|
4617
|
+
}
|
|
4618
|
+
/**
|
|
4619
|
+
* Standalone pre-auth middleware for `rk_` bearer tokens.
|
|
4620
|
+
*
|
|
4621
|
+
* Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
|
|
4622
|
+
* the admin surfaces: admin users/roles, api-keys management, cron, backups,
|
|
4623
|
+
* logs, schema editor) don't know about API keys. Mounting this middleware in
|
|
4624
|
+
* front of them authenticates `rk_` tokens and populates the request context;
|
|
4625
|
+
* the downstream gates then see the already-resolved user and apply their
|
|
4626
|
+
* role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
|
|
4627
|
+
* a non-admin key is rejected with 403.
|
|
4628
|
+
*
|
|
4629
|
+
* Requests without an `rk_` bearer token pass through untouched. An invalid,
|
|
4630
|
+
* revoked, or expired `rk_` token is rejected here (401) rather than falling
|
|
4631
|
+
* through to be misparsed as a JWT.
|
|
4632
|
+
*/
|
|
4633
|
+
function createApiKeyPreAuth(options) {
|
|
4634
|
+
return async (c, next) => {
|
|
4635
|
+
if (c.get("apiKey")) return next();
|
|
4636
|
+
const authHeader = c.req.header("authorization") || "";
|
|
4637
|
+
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
|
|
4638
|
+
if (!token || !isApiKeyToken(token)) return next();
|
|
4639
|
+
const result = await validateApiKey(c, token, options);
|
|
4640
|
+
if (result !== true) return result;
|
|
4641
|
+
return next();
|
|
4642
|
+
};
|
|
4643
|
+
}
|
|
4244
4644
|
//#endregion
|
|
4245
4645
|
//#region src/auth/middleware.ts
|
|
4246
4646
|
/**
|
|
@@ -4254,14 +4654,12 @@ async function validateApiKey(c, token, options) {
|
|
|
4254
4654
|
* need query-string tokens (e.g. storage file serving for `<img src>`).
|
|
4255
4655
|
*/
|
|
4256
4656
|
var requireAuth = async (c, next) => {
|
|
4657
|
+
if (c.get("user")) return next();
|
|
4257
4658
|
const authHeader = c.req.header("authorization");
|
|
4258
|
-
if (!(authHeader && authHeader.startsWith("Bearer "))) {
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
code: "UNAUTHORIZED"
|
|
4263
|
-
} }, 401);
|
|
4264
|
-
}
|
|
4659
|
+
if (!(authHeader && authHeader.startsWith("Bearer "))) return c.json({ error: {
|
|
4660
|
+
message: "Authorization header missing or invalid",
|
|
4661
|
+
code: "UNAUTHORIZED"
|
|
4662
|
+
} }, 401);
|
|
4265
4663
|
const payload = verifyAccessToken(authHeader.substring(7));
|
|
4266
4664
|
if (!payload) return c.json({ error: {
|
|
4267
4665
|
message: "Invalid or expired token",
|
|
@@ -4284,18 +4682,16 @@ function createRequireAuth(options) {
|
|
|
4284
4682
|
if (!options?.serviceKey) return requireAuth;
|
|
4285
4683
|
const key = options.serviceKey;
|
|
4286
4684
|
return async (c, next) => {
|
|
4685
|
+
if (c.get("user")) return next();
|
|
4287
4686
|
const authHeader = c.req.header("authorization");
|
|
4288
|
-
if (!(authHeader && authHeader.startsWith("Bearer "))) {
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
code: "UNAUTHORIZED"
|
|
4293
|
-
} }, 401);
|
|
4294
|
-
}
|
|
4687
|
+
if (!(authHeader && authHeader.startsWith("Bearer "))) return c.json({ error: {
|
|
4688
|
+
message: "Authorization header missing or invalid",
|
|
4689
|
+
code: "UNAUTHORIZED"
|
|
4690
|
+
} }, 401);
|
|
4295
4691
|
const token = authHeader.substring(7);
|
|
4296
4692
|
if (safeCompare(token, key)) {
|
|
4297
4693
|
c.set("user", {
|
|
4298
|
-
|
|
4694
|
+
uid: "service",
|
|
4299
4695
|
roles: ["admin"]
|
|
4300
4696
|
});
|
|
4301
4697
|
return next();
|
|
@@ -4374,11 +4770,11 @@ function createAuthMiddleware(options) {
|
|
|
4374
4770
|
if (validator) try {
|
|
4375
4771
|
const authResult = await validator(c);
|
|
4376
4772
|
if (authResult && typeof authResult === "object") {
|
|
4377
|
-
const id = ("
|
|
4773
|
+
const id = ("uid" in authResult ? authResult.uid : void 0) || ("uid" in authResult ? authResult.uid : void 0);
|
|
4378
4774
|
if (id) {
|
|
4379
4775
|
const roles = authResult.roles || [];
|
|
4380
4776
|
c.set("user", {
|
|
4381
|
-
|
|
4777
|
+
uid: id,
|
|
4382
4778
|
roles
|
|
4383
4779
|
});
|
|
4384
4780
|
const user = {
|
|
@@ -4393,7 +4789,7 @@ function createAuthMiddleware(options) {
|
|
|
4393
4789
|
}));
|
|
4394
4790
|
} else if (authResult === true) {
|
|
4395
4791
|
c.set("user", {
|
|
4396
|
-
|
|
4792
|
+
uid: "default",
|
|
4397
4793
|
roles: []
|
|
4398
4794
|
});
|
|
4399
4795
|
c.set("driver", await scopeDataDriver(driver, {
|
|
@@ -4416,7 +4812,7 @@ function createAuthMiddleware(options) {
|
|
|
4416
4812
|
const token = authHeader.substring(7);
|
|
4417
4813
|
if (serviceKey && safeCompare(token, serviceKey)) {
|
|
4418
4814
|
c.set("user", {
|
|
4419
|
-
|
|
4815
|
+
uid: "service",
|
|
4420
4816
|
roles: ["admin"]
|
|
4421
4817
|
});
|
|
4422
4818
|
try {
|
|
@@ -4443,7 +4839,7 @@ function createAuthMiddleware(options) {
|
|
|
4443
4839
|
c.set("user", payload);
|
|
4444
4840
|
try {
|
|
4445
4841
|
const user = {
|
|
4446
|
-
uid: payload.
|
|
4842
|
+
uid: payload.uid,
|
|
4447
4843
|
roles: payload.roles
|
|
4448
4844
|
};
|
|
4449
4845
|
c.set("driver", await scopeDataDriver(driver, user));
|
|
@@ -4524,7 +4920,7 @@ var publicObjectAuth = async (c, next) => {
|
|
|
4524
4920
|
const idx = fullPath.indexOf(prefix);
|
|
4525
4921
|
const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
|
|
4526
4922
|
if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
|
|
4527
|
-
|
|
4923
|
+
uid: "public",
|
|
4528
4924
|
roles: ["public"]
|
|
4529
4925
|
});
|
|
4530
4926
|
return next();
|
|
@@ -4532,8 +4928,25 @@ var publicObjectAuth = async (c, next) => {
|
|
|
4532
4928
|
/**
|
|
4533
4929
|
* Helper to match paths for scoped file tokens.
|
|
4534
4930
|
* Matches exact paths or prefixes (exact folder prefixes or ending with /).
|
|
4931
|
+
*
|
|
4932
|
+
* The traversal check is defence in depth, not the load-bearing defence.
|
|
4933
|
+
*
|
|
4934
|
+
* A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
|
|
4935
|
+
* starts with `user1/`, so a token scoped to `user1/` would authorize reading
|
|
4936
|
+
* user2's file — and the storage controller would not object, because its own
|
|
4937
|
+
* guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
|
|
4938
|
+
* comfortably inside it. The grant is what would be escaped, and this is the
|
|
4939
|
+
* only layer that knows what was granted.
|
|
4940
|
+
*
|
|
4941
|
+
* Nothing arrives here in that shape today: the URL parser resolves `..` (and
|
|
4942
|
+
* `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
|
|
4943
|
+
* Hono routes the request, so this comparison already runs on a resolved path.
|
|
4944
|
+
* But that is a guarantee of the runtime rather than of this code, and it is
|
|
4945
|
+
* one line to not depend on it. The same rule already guards the public-object
|
|
4946
|
+
* path — see `isPublicStoragePath`.
|
|
4535
4947
|
*/
|
|
4536
4948
|
function isPathMatch(requested, allowed) {
|
|
4949
|
+
if (requested.split("/").some((seg) => seg === "..")) return false;
|
|
4537
4950
|
if (requested === allowed) return true;
|
|
4538
4951
|
if (allowed.endsWith("/")) return requested.startsWith(allowed);
|
|
4539
4952
|
return requested.startsWith(allowed + "/");
|
|
@@ -4573,7 +4986,7 @@ var fileTokenAuth = async (c, next) => {
|
|
|
4573
4986
|
const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
|
|
4574
4987
|
if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
|
|
4575
4988
|
c.set("user", {
|
|
4576
|
-
|
|
4989
|
+
uid: "download-token",
|
|
4577
4990
|
roles: ["reader"]
|
|
4578
4991
|
});
|
|
4579
4992
|
return next();
|
|
@@ -4597,7 +5010,7 @@ var fileTokenAuth = async (c, next) => {
|
|
|
4597
5010
|
const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
|
|
4598
5011
|
if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
|
|
4599
5012
|
c.set("user", {
|
|
4600
|
-
|
|
5013
|
+
uid: "download-token",
|
|
4601
5014
|
roles: ["reader"]
|
|
4602
5015
|
});
|
|
4603
5016
|
return next();
|
|
@@ -4646,7 +5059,7 @@ function createAdapterAuthMiddleware(options) {
|
|
|
4646
5059
|
}
|
|
4647
5060
|
if (authenticatedUser) {
|
|
4648
5061
|
c.set("user", {
|
|
4649
|
-
|
|
5062
|
+
uid: authenticatedUser.uid,
|
|
4650
5063
|
email: authenticatedUser.email,
|
|
4651
5064
|
roles: authenticatedUser.roles
|
|
4652
5065
|
});
|
|
@@ -5357,60 +5770,118 @@ function resolveAuthHooks(hooks) {
|
|
|
5357
5770
|
};
|
|
5358
5771
|
}
|
|
5359
5772
|
//#endregion
|
|
5773
|
+
//#region src/auth/rate-limit-store.ts
|
|
5774
|
+
/**
|
|
5775
|
+
* The default store: a sliding window in this process's memory.
|
|
5776
|
+
*
|
|
5777
|
+
* Sliding rather than fixed: a fixed window lets a caller spend its whole
|
|
5778
|
+
* allowance in the last second of one window and again in the first second of
|
|
5779
|
+
* the next, which is twice the limit over an instant. Timestamps outside the
|
|
5780
|
+
* window are dropped on read, and swept periodically so an idle key does not
|
|
5781
|
+
* pin its array forever.
|
|
5782
|
+
*
|
|
5783
|
+
* Per-process, so N replicas enforce N times the limit between them. That is
|
|
5784
|
+
* the honest reason {@link RateLimitStore} exists.
|
|
5785
|
+
*/
|
|
5786
|
+
var MemoryRateLimitStore = class {
|
|
5787
|
+
store = /* @__PURE__ */ new Map();
|
|
5788
|
+
cleanupInterval;
|
|
5789
|
+
constructor(sweepMs = 900 * 1e3) {
|
|
5790
|
+
this.cleanupInterval = setInterval(() => this.sweep(sweepMs), sweepMs);
|
|
5791
|
+
this.cleanupInterval.unref?.();
|
|
5792
|
+
}
|
|
5793
|
+
async hit(key, windowMs, limit) {
|
|
5794
|
+
const now = Date.now();
|
|
5795
|
+
const timestamps = (this.store.get(key) ?? []).filter((t) => now - t < windowMs);
|
|
5796
|
+
if (timestamps.length >= limit) {
|
|
5797
|
+
this.store.set(key, timestamps);
|
|
5798
|
+
return {
|
|
5799
|
+
allowed: false,
|
|
5800
|
+
remaining: 0,
|
|
5801
|
+
retryAfterMs: Math.max(0, timestamps[0] + windowMs - now)
|
|
5802
|
+
};
|
|
5803
|
+
}
|
|
5804
|
+
timestamps.push(now);
|
|
5805
|
+
this.store.set(key, timestamps);
|
|
5806
|
+
return {
|
|
5807
|
+
allowed: true,
|
|
5808
|
+
remaining: limit - timestamps.length,
|
|
5809
|
+
retryAfterMs: 0
|
|
5810
|
+
};
|
|
5811
|
+
}
|
|
5812
|
+
sweep(windowMs) {
|
|
5813
|
+
const now = Date.now();
|
|
5814
|
+
for (const [key, timestamps] of this.store.entries()) {
|
|
5815
|
+
const live = timestamps.filter((t) => now - t < windowMs);
|
|
5816
|
+
if (live.length === 0) this.store.delete(key);
|
|
5817
|
+
else this.store.set(key, live);
|
|
5818
|
+
}
|
|
5819
|
+
}
|
|
5820
|
+
dispose() {
|
|
5821
|
+
if (this.cleanupInterval) clearInterval(this.cleanupInterval);
|
|
5822
|
+
this.cleanupInterval = void 0;
|
|
5823
|
+
this.store.clear();
|
|
5824
|
+
}
|
|
5825
|
+
};
|
|
5826
|
+
//#endregion
|
|
5360
5827
|
//#region src/auth/rate-limiter.ts
|
|
5361
5828
|
/**
|
|
5829
|
+
* Resolve the number of trusted proxy hops from an explicit option, the
|
|
5830
|
+
* `TRUSTED_PROXY_HOPS` env var, or the default of 1.
|
|
5831
|
+
*/
|
|
5832
|
+
function resolveTrustedProxyHops(optionValue) {
|
|
5833
|
+
if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue >= 0) return Math.floor(optionValue);
|
|
5834
|
+
const fromEnv = Number(process.env.TRUSTED_PROXY_HOPS);
|
|
5835
|
+
if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.floor(fromEnv);
|
|
5836
|
+
return 1;
|
|
5837
|
+
}
|
|
5838
|
+
/**
|
|
5362
5839
|
* Create a rate-limiting middleware.
|
|
5363
5840
|
*
|
|
5364
|
-
* Uses a sliding window
|
|
5365
|
-
* `windowMs` milliseconds are counted. Old entries are garbage-collected
|
|
5366
|
-
* every `windowMs` to prevent unbounded memory growth.
|
|
5841
|
+
* Uses a sliding window: only hits within the last `windowMs` are counted.
|
|
5367
5842
|
*/
|
|
5368
5843
|
function createRateLimiter(options = {}) {
|
|
5369
|
-
const
|
|
5370
|
-
const
|
|
5371
|
-
const cleanupInterval = setInterval(() => {
|
|
5372
|
-
const now = Date.now();
|
|
5373
|
-
for (const [key, entry] of store.entries()) {
|
|
5374
|
-
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
|
|
5375
|
-
if (entry.timestamps.length === 0) store.delete(key);
|
|
5376
|
-
}
|
|
5377
|
-
}, windowMs);
|
|
5378
|
-
if (cleanupInterval.unref) cleanupInterval.unref();
|
|
5844
|
+
const trustedProxyHops = resolveTrustedProxyHops(options.trustedProxyHops);
|
|
5845
|
+
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", store = new MemoryRateLimitStore(windowMs), resolveLimit } = options;
|
|
5379
5846
|
return async (c, next) => {
|
|
5380
|
-
const
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
if (entry.timestamps.length >= limit) {
|
|
5389
|
-
const retryAfterMs = entry.timestamps[0] + windowMs - now;
|
|
5390
|
-
const retryAfterSec = Math.ceil(retryAfterMs / 1e3);
|
|
5847
|
+
const effectiveLimit = resolveLimit ? resolveLimit(c) : limit;
|
|
5848
|
+
if (effectiveLimit === null) return next();
|
|
5849
|
+
const activeLimit = effectiveLimit ?? limit;
|
|
5850
|
+
const decision = await store.hit(keyGenerator(c), windowMs, activeLimit);
|
|
5851
|
+
c.header("X-RateLimit-Limit", String(activeLimit));
|
|
5852
|
+
c.header("X-RateLimit-Remaining", String(decision.remaining));
|
|
5853
|
+
if (!decision.allowed) {
|
|
5854
|
+
const retryAfterSec = Math.ceil(decision.retryAfterMs / 1e3);
|
|
5391
5855
|
c.header("Retry-After", String(retryAfterSec));
|
|
5392
|
-
c.header("X-RateLimit-
|
|
5393
|
-
c.header("X-RateLimit-Remaining", "0");
|
|
5394
|
-
c.header("X-RateLimit-Reset", String(Math.ceil((now + retryAfterMs) / 1e3)));
|
|
5856
|
+
c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + decision.retryAfterMs) / 1e3)));
|
|
5395
5857
|
return c.json({ error: {
|
|
5396
5858
|
message,
|
|
5397
5859
|
code: "RATE_LIMITED"
|
|
5398
5860
|
} }, 429);
|
|
5399
5861
|
}
|
|
5400
|
-
entry.timestamps.push(now);
|
|
5401
|
-
c.header("X-RateLimit-Limit", String(limit));
|
|
5402
|
-
c.header("X-RateLimit-Remaining", String(limit - entry.timestamps.length));
|
|
5403
5862
|
return next();
|
|
5404
5863
|
};
|
|
5405
5864
|
}
|
|
5406
5865
|
/**
|
|
5407
5866
|
* Default key generator: extract client IP from standard headers.
|
|
5867
|
+
*
|
|
5868
|
+
* `X-Forwarded-For` is a client-writable header; only the entries appended by
|
|
5869
|
+
* trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
|
|
5870
|
+
* front, each appends the address it saw, so the real client IP is the
|
|
5871
|
+
* `trustedProxyHops`-th entry from the right — everything further left is
|
|
5872
|
+
* client-supplied and must be ignored. This is what prevents a caller from
|
|
5873
|
+
* spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
|
|
5874
|
+
*
|
|
5875
|
+
* With `trustedProxyHops === 0` (server exposed directly, no proxy),
|
|
5876
|
+
* `X-Forwarded-For` is not trusted at all and `X-Real-IP` is used instead.
|
|
5408
5877
|
*/
|
|
5409
|
-
function defaultKeyGenerator(c) {
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5878
|
+
function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
|
|
5879
|
+
if (trustedProxyHops > 0) {
|
|
5880
|
+
const forwardedFor = c.req.header("x-forwarded-for");
|
|
5881
|
+
if (forwardedFor) {
|
|
5882
|
+
const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
|
|
5883
|
+
if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
|
|
5884
|
+
}
|
|
5414
5885
|
}
|
|
5415
5886
|
return c.req.header("x-real-ip") || "unknown";
|
|
5416
5887
|
}
|
|
@@ -5445,54 +5916,65 @@ function apiKeyKeyGenerator(c) {
|
|
|
5445
5916
|
return defaultKeyGenerator(c);
|
|
5446
5917
|
}
|
|
5447
5918
|
/**
|
|
5919
|
+
* Rate limiting for the data API.
|
|
5920
|
+
*
|
|
5921
|
+
* Every request is in exactly one bucket, resolved most-specific first: an API
|
|
5922
|
+
* key by its id, a signed-in user by their uid, anyone else by IP. Previously
|
|
5923
|
+
* only the first of those was limited at all — the middleware returned early
|
|
5924
|
+
* for any request without an API key — so JWT and anonymous traffic to
|
|
5925
|
+
* `/api/data/*` was unbounded, which is most of the traffic a BaaS gets.
|
|
5926
|
+
*
|
|
5927
|
+
* Per bucket, not per route: the point is to bound what one caller costs, and
|
|
5928
|
+
* they can spend it wherever they like.
|
|
5929
|
+
*
|
|
5930
|
+
* The defaults are deliberately loose. This is a floor against a runaway client
|
|
5931
|
+
* or a naive scraper, not a quota — a deployment that wants real quotas should
|
|
5932
|
+
* set them, and one that already has a proxy doing this should pass
|
|
5933
|
+
* `enabled: false` rather than pay for it twice.
|
|
5934
|
+
*/
|
|
5935
|
+
function createDataRateLimiter(config = {}) {
|
|
5936
|
+
const { windowMs = 900 * 1e3, apiKey: apiKeyLimit = 1e3, user: userLimit = 1e3, anonymous: anonLimit = 300, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
5937
|
+
return createRateLimiter({
|
|
5938
|
+
windowMs,
|
|
5939
|
+
store,
|
|
5940
|
+
message: "Too many requests, please try again later.",
|
|
5941
|
+
keyGenerator: (c) => {
|
|
5942
|
+
const key = c.get("apiKey");
|
|
5943
|
+
if (key) return `api-key:${key.id}`;
|
|
5944
|
+
const user = c.get("user");
|
|
5945
|
+
if (user?.uid && user.uid !== "anon") return `user:${user.uid}`;
|
|
5946
|
+
return `ip:${defaultKeyGenerator(c)}`;
|
|
5947
|
+
},
|
|
5948
|
+
resolveLimit: (c) => {
|
|
5949
|
+
const key = c.get("apiKey");
|
|
5950
|
+
if (key) return key.rate_limit ?? apiKeyLimit;
|
|
5951
|
+
const user = c.get("user");
|
|
5952
|
+
if (user?.uid && user.uid !== "anon") return userLimit;
|
|
5953
|
+
return anonLimit;
|
|
5954
|
+
}
|
|
5955
|
+
});
|
|
5956
|
+
}
|
|
5957
|
+
/**
|
|
5448
5958
|
* Create a rate limiter specifically for API key requests.
|
|
5449
5959
|
*
|
|
5450
|
-
*
|
|
5451
|
-
*
|
|
5452
|
-
*
|
|
5960
|
+
* @deprecated Use {@link createDataRateLimiter}, which limits signed-in users
|
|
5961
|
+
* and anonymous callers too. This one skips every request that is not
|
|
5962
|
+
* API-key-authenticated, which was most of them.
|
|
5453
5963
|
*
|
|
5454
5964
|
* @param defaultLimit - Fallback limit when the key has no `rate_limit` set.
|
|
5455
5965
|
* @param windowMs - Time window in milliseconds (default: 15 minutes).
|
|
5456
5966
|
*/
|
|
5457
5967
|
function createApiKeyRateLimiter(defaultLimit = 1e3, windowMs = 900 * 1e3) {
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
if (cleanupInterval.unref) cleanupInterval.unref();
|
|
5467
|
-
return async (c, next) => {
|
|
5468
|
-
const apiKey = c.get("apiKey");
|
|
5469
|
-
if (!apiKey) return next();
|
|
5470
|
-
const limit = apiKey.rate_limit ?? defaultLimit;
|
|
5471
|
-
const key = `api-key:${apiKey.id}`;
|
|
5472
|
-
const now = Date.now();
|
|
5473
|
-
let entry = store.get(key);
|
|
5474
|
-
if (!entry) {
|
|
5475
|
-
entry = { timestamps: [] };
|
|
5476
|
-
store.set(key, entry);
|
|
5477
|
-
}
|
|
5478
|
-
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
|
|
5479
|
-
if (entry.timestamps.length >= limit) {
|
|
5480
|
-
const retryAfterMs = entry.timestamps[0] + windowMs - now;
|
|
5481
|
-
const retryAfterSec = Math.ceil(retryAfterMs / 1e3);
|
|
5482
|
-
c.header("Retry-After", String(retryAfterSec));
|
|
5483
|
-
c.header("X-RateLimit-Limit", String(limit));
|
|
5484
|
-
c.header("X-RateLimit-Remaining", "0");
|
|
5485
|
-
c.header("X-RateLimit-Reset", String(Math.ceil((now + retryAfterMs) / 1e3)));
|
|
5486
|
-
return c.json({ error: {
|
|
5487
|
-
message: "API key rate limit exceeded, please try again later.",
|
|
5488
|
-
code: "RATE_LIMITED"
|
|
5489
|
-
} }, 429);
|
|
5968
|
+
return createRateLimiter({
|
|
5969
|
+
windowMs,
|
|
5970
|
+
message: "API key rate limit exceeded, please try again later.",
|
|
5971
|
+
keyGenerator: apiKeyKeyGenerator,
|
|
5972
|
+
resolveLimit: (c) => {
|
|
5973
|
+
const apiKey = c.get("apiKey");
|
|
5974
|
+
if (!apiKey) return null;
|
|
5975
|
+
return apiKey.rate_limit ?? defaultLimit;
|
|
5490
5976
|
}
|
|
5491
|
-
|
|
5492
|
-
c.header("X-RateLimit-Limit", String(limit));
|
|
5493
|
-
c.header("X-RateLimit-Remaining", String(limit - entry.timestamps.length));
|
|
5494
|
-
return next();
|
|
5495
|
-
};
|
|
5977
|
+
});
|
|
5496
5978
|
}
|
|
5497
5979
|
//#endregion
|
|
5498
5980
|
//#region ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/core.js
|
|
@@ -9319,7 +9801,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9319
9801
|
const body = await c.req.json().catch(() => ({}));
|
|
9320
9802
|
const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
|
|
9321
9803
|
const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
|
|
9322
|
-
const user = await authRepo.getUserById(userCtx.
|
|
9804
|
+
const user = await authRepo.getUserById(userCtx.uid);
|
|
9323
9805
|
if (!user) throw ApiError.notFound("User not found");
|
|
9324
9806
|
const { secret, uri } = generateTotpSecret(issuer, user.email);
|
|
9325
9807
|
const encryptedSecret = encryptTotpSecret(secret);
|
|
@@ -9353,7 +9835,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9353
9835
|
code: string().length(6, "Code must be 6 digits")
|
|
9354
9836
|
}), await c.req.json());
|
|
9355
9837
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
9356
|
-
if (!factor || factor.
|
|
9838
|
+
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
9357
9839
|
if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
|
|
9358
9840
|
if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
|
|
9359
9841
|
await authRepo.verifyMfaFactor(factorId);
|
|
@@ -9371,7 +9853,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9371
9853
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9372
9854
|
const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
|
|
9373
9855
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
9374
|
-
if (!factor || factor.
|
|
9856
|
+
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
9375
9857
|
if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
|
|
9376
9858
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
9377
9859
|
const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
|
|
@@ -9395,19 +9877,19 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9395
9877
|
const challenge = await authRepo.getMfaChallengeById(challengeId);
|
|
9396
9878
|
if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
|
|
9397
9879
|
const factor = await authRepo.getMfaFactorById(challenge.factorId);
|
|
9398
|
-
if (!factor || factor.
|
|
9880
|
+
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
9399
9881
|
let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
|
|
9400
9882
|
if (!isValid) {
|
|
9401
9883
|
const codeHash = hashRecoveryCode(code);
|
|
9402
|
-
isValid = await authRepo.useRecoveryCode(userCtx.
|
|
9884
|
+
isValid = await authRepo.useRecoveryCode(userCtx.uid, codeHash);
|
|
9403
9885
|
}
|
|
9404
9886
|
if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
|
|
9405
9887
|
await authRepo.verifyMfaChallenge(challengeId);
|
|
9406
|
-
const roleIds = (await authRepo.getUserRoles(userCtx.
|
|
9407
|
-
const accessToken = generateAccessToken(userCtx.
|
|
9888
|
+
const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
|
|
9889
|
+
const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
|
|
9408
9890
|
const refreshToken = generateRefreshToken();
|
|
9409
|
-
await authRepo.createRefreshToken(userCtx.
|
|
9410
|
-
if (ops.onMfaVerified) ops.onMfaVerified(userCtx.
|
|
9891
|
+
await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
|
|
9892
|
+
if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
|
|
9411
9893
|
logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
|
|
9412
9894
|
});
|
|
9413
9895
|
let mfaResponse = { tokens: {
|
|
@@ -9415,7 +9897,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9415
9897
|
refreshToken,
|
|
9416
9898
|
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
9417
9899
|
} };
|
|
9418
|
-
if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.
|
|
9900
|
+
if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
|
|
9419
9901
|
return c.json(mfaResponse);
|
|
9420
9902
|
});
|
|
9421
9903
|
/**
|
|
@@ -9425,7 +9907,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9425
9907
|
router.get("/mfa/factors", requireAuth, async (c) => {
|
|
9426
9908
|
const userCtx = c.get("user");
|
|
9427
9909
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9428
|
-
const factors = await authRepo.getMfaFactors(userCtx.
|
|
9910
|
+
const factors = await authRepo.getMfaFactors(userCtx.uid);
|
|
9429
9911
|
return c.json({ factors: factors.map((f) => ({
|
|
9430
9912
|
id: f.id,
|
|
9431
9913
|
factorType: f.factorType,
|
|
@@ -9444,9 +9926,9 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
|
|
|
9444
9926
|
if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
|
|
9445
9927
|
const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
|
|
9446
9928
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
9447
|
-
if (!factor || factor.
|
|
9448
|
-
await authRepo.deleteMfaFactor(factorId, userCtx.
|
|
9449
|
-
if (!await authRepo.hasVerifiedMfaFactors(userCtx.
|
|
9929
|
+
if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
|
|
9930
|
+
await authRepo.deleteMfaFactor(factorId, userCtx.uid);
|
|
9931
|
+
if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) await authRepo.deleteAllRecoveryCodes(userCtx.uid);
|
|
9450
9932
|
return c.json({
|
|
9451
9933
|
success: true,
|
|
9452
9934
|
message: "MFA factor removed"
|
|
@@ -9548,9 +10030,9 @@ function mountSessionRoutes(opts) {
|
|
|
9548
10030
|
clearRefreshCookie(c, config.cookieAuth);
|
|
9549
10031
|
const authHeader = c.req.header("authorization");
|
|
9550
10032
|
if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
|
|
9551
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
10033
|
+
const { verifyAccessToken } = await import("./jwt-B3zjddCa.js").then((n) => n.c);
|
|
9552
10034
|
const payload = verifyAccessToken(authHeader.substring(7));
|
|
9553
|
-
if (payload) ops.afterLogout(payload.
|
|
10035
|
+
if (payload) ops.afterLogout(payload.uid).catch((err) => {
|
|
9554
10036
|
logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
|
|
9555
10037
|
});
|
|
9556
10038
|
}
|
|
@@ -9565,7 +10047,7 @@ function mountSessionRoutes(opts) {
|
|
|
9565
10047
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9566
10048
|
const currentRefreshToken = c.req.header("x-refresh-token");
|
|
9567
10049
|
const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
|
|
9568
|
-
const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.
|
|
10050
|
+
const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.uid)).map((s) => ({
|
|
9569
10051
|
id: s.id,
|
|
9570
10052
|
userAgent: s.userAgent,
|
|
9571
10053
|
ipAddress: s.ipAddress,
|
|
@@ -9581,7 +10063,7 @@ function mountSessionRoutes(opts) {
|
|
|
9581
10063
|
router.delete("/sessions", requireAuth, async (c) => {
|
|
9582
10064
|
const userCtx = c.get("user");
|
|
9583
10065
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9584
|
-
await authRepo.deleteAllRefreshTokensForUser(userCtx.
|
|
10066
|
+
await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
|
|
9585
10067
|
return c.json({
|
|
9586
10068
|
success: true,
|
|
9587
10069
|
message: "All sessions revoked successfully"
|
|
@@ -9596,7 +10078,7 @@ function mountSessionRoutes(opts) {
|
|
|
9596
10078
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9597
10079
|
const id = c.req.param("id");
|
|
9598
10080
|
if (!id) throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
|
|
9599
|
-
await authRepo.deleteRefreshTokenById(id, userCtx.
|
|
10081
|
+
await authRepo.deleteRefreshTokenById(id, userCtx.uid);
|
|
9600
10082
|
return c.json({
|
|
9601
10083
|
success: true,
|
|
9602
10084
|
message: "Session revoked successfully"
|
|
@@ -9609,7 +10091,7 @@ function mountSessionRoutes(opts) {
|
|
|
9609
10091
|
router.get("/me", requireAuth, async (c) => {
|
|
9610
10092
|
const userCtx = c.get("user");
|
|
9611
10093
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9612
|
-
const result = await authRepo.getUserWithRoles(userCtx.
|
|
10094
|
+
const result = await authRepo.getUserWithRoles(userCtx.uid);
|
|
9613
10095
|
if (!result) throw ApiError.notFound("User not found");
|
|
9614
10096
|
return c.json({ user: {
|
|
9615
10097
|
uid: result.user.id,
|
|
@@ -9649,11 +10131,11 @@ function mountSessionRoutes(opts) {
|
|
|
9649
10131
|
const userCtx = c.get("user");
|
|
9650
10132
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9651
10133
|
const { displayName, photoURL } = parseBody(updateProfileSchema, await c.req.json());
|
|
9652
|
-
if (!await authRepo.updateUser(userCtx.
|
|
10134
|
+
if (!await authRepo.updateUser(userCtx.uid, {
|
|
9653
10135
|
displayName: displayName !== void 0 ? displayName : void 0,
|
|
9654
10136
|
photoUrl: photoURL !== void 0 ? photoURL : void 0
|
|
9655
10137
|
})) throw ApiError.notFound("User not found");
|
|
9656
|
-
const result = await authRepo.getUserWithRoles(userCtx.
|
|
10138
|
+
const result = await authRepo.getUserWithRoles(userCtx.uid);
|
|
9657
10139
|
if (!result) throw ApiError.notFound("User not found");
|
|
9658
10140
|
return c.json({ user: {
|
|
9659
10141
|
uid: result.user.id,
|
|
@@ -9715,7 +10197,7 @@ function mountSessionRoutes(opts) {
|
|
|
9715
10197
|
router.post("/anonymous/link", requireAuth, async (c) => {
|
|
9716
10198
|
const userCtx = c.get("user");
|
|
9717
10199
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
9718
|
-
const user = await authRepo.getUserById(userCtx.
|
|
10200
|
+
const user = await authRepo.getUserById(userCtx.uid);
|
|
9719
10201
|
if (!user?.isAnonymous) throw ApiError.badRequest("User is not anonymous", "NOT_ANONYMOUS");
|
|
9720
10202
|
const { email, password } = parseBody(linkSchema, await c.req.json());
|
|
9721
10203
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
@@ -9804,7 +10286,7 @@ function mountMagicLinkRoutes(deps) {
|
|
|
9804
10286
|
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
9805
10287
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
9806
10288
|
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
9807
|
-
const user = await authRepo.getUserById(storedToken.
|
|
10289
|
+
const user = await authRepo.getUserById(storedToken.uid);
|
|
9808
10290
|
if (!user) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
9809
10291
|
if (!user.emailVerified) {
|
|
9810
10292
|
await authRepo.setEmailVerified(user.id, true);
|
|
@@ -9862,11 +10344,11 @@ function createAuthRoutes(config) {
|
|
|
9862
10344
|
* Errors are caught and logged — the untransformed response is returned
|
|
9863
10345
|
* as a graceful fallback so auth never breaks due to a hook failure.
|
|
9864
10346
|
*/
|
|
9865
|
-
async function applyTransformHook(response, method, request,
|
|
10347
|
+
async function applyTransformHook(response, method, request, uid) {
|
|
9866
10348
|
if (!ops.transformAuthResponse) return response;
|
|
9867
10349
|
try {
|
|
9868
10350
|
return await ops.transformAuthResponse(response, {
|
|
9869
|
-
|
|
10351
|
+
uid,
|
|
9870
10352
|
method,
|
|
9871
10353
|
request
|
|
9872
10354
|
});
|
|
@@ -9893,7 +10375,7 @@ function createAuthRoutes(config) {
|
|
|
9893
10375
|
oldPassword: string().min(1, "Old password is required").max(128),
|
|
9894
10376
|
newPassword: string().min(1, "New password is required").max(128)
|
|
9895
10377
|
});
|
|
9896
|
-
const refreshSchema = object({ refreshToken:
|
|
10378
|
+
const refreshSchema = object({ refreshToken: string().min(1).optional() });
|
|
9897
10379
|
object({ refreshToken: string().optional() });
|
|
9898
10380
|
object({
|
|
9899
10381
|
displayName: string().max(255).optional(),
|
|
@@ -9944,23 +10426,23 @@ function createAuthRoutes(config) {
|
|
|
9944
10426
|
/**
|
|
9945
10427
|
* Helper to generate and store session tokens
|
|
9946
10428
|
*/
|
|
9947
|
-
async function createSessionAndTokens(
|
|
9948
|
-
const roleIds = (await authRepo.getUserRoles(
|
|
10429
|
+
async function createSessionAndTokens(uid, userAgent, ipAddress) {
|
|
10430
|
+
const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
|
|
9949
10431
|
let customClaims;
|
|
9950
10432
|
if (ops.customizeAccessToken) {
|
|
9951
|
-
const user = await authRepo.getUserById(
|
|
10433
|
+
const user = await authRepo.getUserById(uid);
|
|
9952
10434
|
if (user) {
|
|
9953
10435
|
const defaultClaims = {
|
|
9954
|
-
|
|
10436
|
+
uid,
|
|
9955
10437
|
roles: roleIds,
|
|
9956
10438
|
aal: "aal1"
|
|
9957
10439
|
};
|
|
9958
10440
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
9959
10441
|
}
|
|
9960
10442
|
}
|
|
9961
|
-
const accessToken = generateAccessToken(
|
|
10443
|
+
const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
|
|
9962
10444
|
const refreshToken = generateRefreshToken();
|
|
9963
|
-
await authRepo.createRefreshToken(
|
|
10445
|
+
await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
|
|
9964
10446
|
return {
|
|
9965
10447
|
roleIds,
|
|
9966
10448
|
accessToken,
|
|
@@ -10024,7 +10506,7 @@ function createAuthRoutes(config) {
|
|
|
10024
10506
|
logger.warn("[Security Audit] Auth login failure", {
|
|
10025
10507
|
eventType: "auth.login.failure",
|
|
10026
10508
|
email,
|
|
10027
|
-
|
|
10509
|
+
uid: user.id
|
|
10028
10510
|
});
|
|
10029
10511
|
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
10030
10512
|
}
|
|
@@ -10035,7 +10517,7 @@ function createAuthRoutes(config) {
|
|
|
10035
10517
|
});
|
|
10036
10518
|
logger.info("[Security Audit] Auth login success", {
|
|
10037
10519
|
eventType: "auth.login.success",
|
|
10038
|
-
|
|
10520
|
+
uid: user.id,
|
|
10039
10521
|
email
|
|
10040
10522
|
});
|
|
10041
10523
|
const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, "password"), "login", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
|
|
@@ -10044,54 +10526,101 @@ function createAuthRoutes(config) {
|
|
|
10044
10526
|
/**
|
|
10045
10527
|
* Dynamically mount OAuth provider routes
|
|
10046
10528
|
*/
|
|
10047
|
-
if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders)
|
|
10048
|
-
|
|
10049
|
-
|
|
10050
|
-
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
|
|
10054
|
-
|
|
10055
|
-
|
|
10056
|
-
|
|
10057
|
-
|
|
10058
|
-
|
|
10059
|
-
|
|
10060
|
-
|
|
10061
|
-
if (
|
|
10062
|
-
|
|
10063
|
-
|
|
10064
|
-
|
|
10065
|
-
|
|
10066
|
-
|
|
10067
|
-
|
|
10068
|
-
|
|
10069
|
-
|
|
10070
|
-
|
|
10071
|
-
|
|
10072
|
-
|
|
10073
|
-
|
|
10074
|
-
|
|
10075
|
-
|
|
10076
|
-
|
|
10077
|
-
|
|
10529
|
+
if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
|
|
10530
|
+
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
10531
|
+
const payload = parseBody(provider.schema, await c.req.json());
|
|
10532
|
+
let externalUser;
|
|
10533
|
+
try {
|
|
10534
|
+
externalUser = await provider.verify(payload);
|
|
10535
|
+
} catch (err) {
|
|
10536
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10537
|
+
throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
|
|
10538
|
+
}
|
|
10539
|
+
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
10540
|
+
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
10541
|
+
if (!user) {
|
|
10542
|
+
user = await authRepo.getUserByEmail(externalUser.email);
|
|
10543
|
+
if (user) {
|
|
10544
|
+
if (!externalUser.emailVerified) throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
|
|
10545
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
10546
|
+
await authRepo.updateUser(user.id, {
|
|
10547
|
+
displayName: user.displayName || externalUser.displayName || void 0,
|
|
10548
|
+
photoUrl: user.photoUrl || externalUser.photoUrl || void 0
|
|
10549
|
+
});
|
|
10550
|
+
} else {
|
|
10551
|
+
user = await authRepo.createUser({
|
|
10552
|
+
email: externalUser.email.toLowerCase(),
|
|
10553
|
+
displayName: externalUser.displayName || void 0,
|
|
10554
|
+
photoUrl: externalUser.photoUrl || void 0
|
|
10555
|
+
});
|
|
10556
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
10557
|
+
if (ops.afterUserCreate) try {
|
|
10558
|
+
await ops.afterUserCreate(user);
|
|
10559
|
+
} catch (err) {
|
|
10560
|
+
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
10561
|
+
}
|
|
10562
|
+
const allUsers = await authRepo.listUsers();
|
|
10563
|
+
if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
|
|
10564
|
+
else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
10565
|
+
sendWelcomeEmail({
|
|
10566
|
+
email: user.email,
|
|
10567
|
+
displayName: user.displayName
|
|
10568
|
+
});
|
|
10078
10569
|
}
|
|
10079
|
-
|
|
10080
|
-
|
|
10081
|
-
|
|
10082
|
-
|
|
10083
|
-
|
|
10084
|
-
|
|
10085
|
-
|
|
10570
|
+
} else await authRepo.updateUser(user.id, {
|
|
10571
|
+
displayName: externalUser.displayName || user.displayName || void 0,
|
|
10572
|
+
photoUrl: externalUser.photoUrl || user.photoUrl || void 0
|
|
10573
|
+
});
|
|
10574
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
|
|
10575
|
+
const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
|
|
10576
|
+
return c.json(finalResponse);
|
|
10577
|
+
});
|
|
10578
|
+
/**
|
|
10579
|
+
* POST /auth/link/:provider
|
|
10580
|
+
* Attach an OAuth identity to the *already authenticated* account.
|
|
10581
|
+
*
|
|
10582
|
+
* This is the escape hatch from the `EMAIL_NOT_VERIFIED` rejection
|
|
10583
|
+
* on the sign-in route above, and the way to attach a provider
|
|
10584
|
+
* whose email differs from the account's.
|
|
10585
|
+
*
|
|
10586
|
+
* Note the deliberate asymmetry with sign-in: linking here does
|
|
10587
|
+
* NOT require the provider to have verified the email, and does
|
|
10588
|
+
* not require the emails to match at all. On the sign-in route the
|
|
10589
|
+
* provider's email is the *only* evidence tying the incoming
|
|
10590
|
+
* identity to an existing account, so an unverified address would
|
|
10591
|
+
* let an attacker claim someone else's account. Here the caller
|
|
10592
|
+
* has already proven ownership by holding a valid session, and the
|
|
10593
|
+
* OAuth credential proves control of the provider identity — the
|
|
10594
|
+
* email plays no part in the decision, so its verification status
|
|
10595
|
+
* is irrelevant.
|
|
10596
|
+
*/
|
|
10597
|
+
router.post(`/link/${provider.id}`, defaultAuthLimiter, requireAuth, async (c) => {
|
|
10598
|
+
const userCtx = c.get("user");
|
|
10599
|
+
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10600
|
+
const payload = parseBody(provider.schema, await c.req.json());
|
|
10601
|
+
let externalUser;
|
|
10602
|
+
try {
|
|
10603
|
+
externalUser = await provider.verify(payload);
|
|
10604
|
+
} catch (err) {
|
|
10605
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
10606
|
+
throw ApiError.unauthorized(`${provider.id} link failed: ${msg}`, "OAUTH_ERROR");
|
|
10086
10607
|
}
|
|
10087
|
-
|
|
10088
|
-
|
|
10089
|
-
|
|
10608
|
+
if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
10609
|
+
const identityOwner = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
10610
|
+
if (identityOwner && identityOwner.id !== userCtx.uid) throw ApiError.conflict(`That ${provider.id} account is already linked to a different user.`, "IDENTITY_ALREADY_LINKED");
|
|
10611
|
+
if (identityOwner) return c.json({
|
|
10612
|
+
success: true,
|
|
10613
|
+
provider: provider.id,
|
|
10614
|
+
alreadyLinked: true
|
|
10615
|
+
});
|
|
10616
|
+
await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
10617
|
+
return c.json({
|
|
10618
|
+
success: true,
|
|
10619
|
+
provider: provider.id,
|
|
10620
|
+
alreadyLinked: false
|
|
10621
|
+
});
|
|
10090
10622
|
});
|
|
10091
|
-
|
|
10092
|
-
const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
|
|
10093
|
-
return c.json(finalResponse);
|
|
10094
|
-
});
|
|
10623
|
+
}
|
|
10095
10624
|
/**
|
|
10096
10625
|
* POST /auth/forgot-password
|
|
10097
10626
|
* Request password reset email
|
|
@@ -10143,10 +10672,10 @@ function createAuthRoutes(config) {
|
|
|
10143
10672
|
const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
|
|
10144
10673
|
if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
|
|
10145
10674
|
const passwordHash = await ops.hashPassword(password);
|
|
10146
|
-
await authRepo.updatePassword(storedToken.
|
|
10675
|
+
await authRepo.updatePassword(storedToken.uid, passwordHash);
|
|
10147
10676
|
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
10148
|
-
await authRepo.deleteAllRefreshTokensForUser(storedToken.
|
|
10149
|
-
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.
|
|
10677
|
+
await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
|
|
10678
|
+
if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
|
|
10150
10679
|
logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
|
|
10151
10680
|
});
|
|
10152
10681
|
return c.json({
|
|
@@ -10162,7 +10691,7 @@ function createAuthRoutes(config) {
|
|
|
10162
10691
|
const userCtx = c.get("user");
|
|
10163
10692
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10164
10693
|
const { oldPassword, newPassword } = parseBody(changePasswordSchema, await c.req.json());
|
|
10165
|
-
const user = await authRepo.getUserById(userCtx.
|
|
10694
|
+
const user = await authRepo.getUserById(userCtx.uid);
|
|
10166
10695
|
if (!user || !user.passwordHash) throw ApiError.badRequest("Cannot change password for this account", "INVALID_ACCOUNT");
|
|
10167
10696
|
if (!await ops.verifyPassword(oldPassword, user.passwordHash)) throw ApiError.unauthorized("Current password is incorrect", "INVALID_CREDENTIALS");
|
|
10168
10697
|
const passwordValidation = ops.validatePasswordStrength(newPassword);
|
|
@@ -10183,7 +10712,7 @@ function createAuthRoutes(config) {
|
|
|
10183
10712
|
const userCtx = c.get("user");
|
|
10184
10713
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
10185
10714
|
if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
|
|
10186
|
-
const user = await authRepo.getUserById(userCtx.
|
|
10715
|
+
const user = await authRepo.getUserById(userCtx.uid);
|
|
10187
10716
|
if (!user) throw ApiError.notFound("User not found");
|
|
10188
10717
|
if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
10189
10718
|
const token = generateSecureToken();
|
|
@@ -10229,8 +10758,8 @@ function createAuthRoutes(config) {
|
|
|
10229
10758
|
* Refresh access token using refresh token
|
|
10230
10759
|
*/
|
|
10231
10760
|
router.post("/refresh", async (c) => {
|
|
10232
|
-
const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json()), config.cookieAuth);
|
|
10233
|
-
if (!refreshToken) throw ApiError.
|
|
10761
|
+
const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json().catch(() => ({}))), config.cookieAuth);
|
|
10762
|
+
if (!refreshToken) throw ApiError.unauthenticated("No refresh token presented", "NO_SESSION");
|
|
10234
10763
|
const tokenHash = hashRefreshToken(refreshToken);
|
|
10235
10764
|
const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
|
|
10236
10765
|
if (!storedToken) {
|
|
@@ -10242,10 +10771,10 @@ function createAuthRoutes(config) {
|
|
|
10242
10771
|
clearRefreshCookie(c, config.cookieAuth);
|
|
10243
10772
|
throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
|
|
10244
10773
|
}
|
|
10245
|
-
const roleIds = (await authRepo.getUserRoles(storedToken.
|
|
10246
|
-
const user = await authRepo.getUserById(storedToken.
|
|
10774
|
+
const roleIds = (await authRepo.getUserRoles(storedToken.uid)).map((r) => r.id);
|
|
10775
|
+
const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
|
|
10247
10776
|
logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
|
|
10248
|
-
|
|
10777
|
+
uid: storedToken.uid,
|
|
10249
10778
|
error: err instanceof Error ? err.message : String(err)
|
|
10250
10779
|
});
|
|
10251
10780
|
return null;
|
|
@@ -10253,18 +10782,18 @@ function createAuthRoutes(config) {
|
|
|
10253
10782
|
let customClaims;
|
|
10254
10783
|
if (ops.customizeAccessToken && user) {
|
|
10255
10784
|
const defaultClaims = {
|
|
10256
|
-
|
|
10785
|
+
uid: storedToken.uid,
|
|
10257
10786
|
roles: roleIds,
|
|
10258
10787
|
aal: "aal1"
|
|
10259
10788
|
};
|
|
10260
10789
|
customClaims = await ops.customizeAccessToken(defaultClaims, user);
|
|
10261
10790
|
}
|
|
10262
|
-
const newAccessToken = generateAccessToken(storedToken.
|
|
10791
|
+
const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
|
|
10263
10792
|
const newRefreshToken = generateRefreshToken();
|
|
10264
10793
|
const userAgent = c.req.header("user-agent") || "unknown";
|
|
10265
10794
|
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
10266
10795
|
await authRepo.deleteRefreshToken(tokenHash);
|
|
10267
|
-
await authRepo.createRefreshToken(storedToken.
|
|
10796
|
+
await authRepo.createRefreshToken(storedToken.uid, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
|
|
10268
10797
|
const tokensOnlyResponse = { tokens: {
|
|
10269
10798
|
accessToken: newAccessToken,
|
|
10270
10799
|
refreshToken: newRefreshToken,
|
|
@@ -10277,7 +10806,7 @@ function createAuthRoutes(config) {
|
|
|
10277
10806
|
logger.warn("[Auth] Could not build enriched refresh response; returning tokens only", { error: err instanceof Error ? err.message : String(err) });
|
|
10278
10807
|
refreshResponse = tokensOnlyResponse;
|
|
10279
10808
|
}
|
|
10280
|
-
const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.
|
|
10809
|
+
const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.uid), c, newRefreshToken, config.cookieAuth);
|
|
10281
10810
|
return c.json(finalResponse);
|
|
10282
10811
|
});
|
|
10283
10812
|
mountSessionRoutes({
|
|
@@ -10314,7 +10843,7 @@ function createAuthRoutes(config) {
|
|
|
10314
10843
|
/**
|
|
10315
10844
|
* Create a standalone admin route for resetting user passwords.
|
|
10316
10845
|
*
|
|
10317
|
-
* Mounts: POST /users/:
|
|
10846
|
+
* Mounts: POST /users/:uid/reset-password
|
|
10318
10847
|
*/
|
|
10319
10848
|
function createResetPasswordRoute(config) {
|
|
10320
10849
|
const router = new Hono();
|
|
@@ -10323,9 +10852,9 @@ function createResetPasswordRoute(config) {
|
|
|
10323
10852
|
const ops = resolveAuthHooks(config.authHooks);
|
|
10324
10853
|
router.onError(errorHandler);
|
|
10325
10854
|
router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
|
|
10326
|
-
router.post("/users/:
|
|
10327
|
-
const
|
|
10328
|
-
const existing = await authRepo.getUserById(
|
|
10855
|
+
router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
|
|
10856
|
+
const uid = c.req.param("uid");
|
|
10857
|
+
const existing = await authRepo.getUserById(uid);
|
|
10329
10858
|
if (!existing) throw ApiError.notFound("User not found");
|
|
10330
10859
|
let invitationSent = false;
|
|
10331
10860
|
let temporaryPassword;
|
|
@@ -10439,10 +10968,10 @@ function createAdminRolesRoute(config) {
|
|
|
10439
10968
|
*
|
|
10440
10969
|
* Mounts:
|
|
10441
10970
|
* GET /users
|
|
10442
|
-
* GET /users/:
|
|
10971
|
+
* GET /users/:uid
|
|
10443
10972
|
* POST /users
|
|
10444
|
-
* PUT /users/:
|
|
10445
|
-
* DELETE /users/:
|
|
10973
|
+
* PUT /users/:uid
|
|
10974
|
+
* DELETE /users/:uid
|
|
10446
10975
|
* POST /bootstrap
|
|
10447
10976
|
*/
|
|
10448
10977
|
function createAdminUsersRoute(config) {
|
|
@@ -10477,16 +11006,36 @@ function createAdminUsersRoute(config) {
|
|
|
10477
11006
|
break;
|
|
10478
11007
|
}
|
|
10479
11008
|
if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
|
|
10480
|
-
const
|
|
10481
|
-
if (!
|
|
10482
|
-
if (!await authRepo.getUserById(
|
|
10483
|
-
|
|
11009
|
+
const uid = "uid" in user ? user.uid : "uid" in user ? user.uid : void 0;
|
|
11010
|
+
if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
|
|
11011
|
+
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
|
|
11012
|
+
if (users.length > 0) {
|
|
11013
|
+
const earliest = users.reduce((a, b) => {
|
|
11014
|
+
const at = new Date(a.createdAt).getTime();
|
|
11015
|
+
const bt = new Date(b.createdAt).getTime();
|
|
11016
|
+
if (at !== bt) return at < bt ? a : b;
|
|
11017
|
+
return a.id < b.id ? a : b;
|
|
11018
|
+
});
|
|
11019
|
+
if (earliest.id !== uid) {
|
|
11020
|
+
logger.warn("[Security Audit] Bootstrap denied: caller is not the earliest-registered user", {
|
|
11021
|
+
eventType: "auth.bootstrap.denied",
|
|
11022
|
+
callerId: uid,
|
|
11023
|
+
earliestUserId: earliest.id
|
|
11024
|
+
});
|
|
11025
|
+
throw ApiError.forbidden("Only the first registered user may claim the initial admin role. Ask that user to bootstrap, or assign the admin role using the service key.", "BOOTSTRAP_NOT_FIRST_USER");
|
|
11026
|
+
}
|
|
11027
|
+
}
|
|
11028
|
+
await authRepo.setUserRoles(uid, ["admin"]);
|
|
11029
|
+
logger.info("[Security Audit] Initial admin bootstrapped", {
|
|
11030
|
+
eventType: "auth.bootstrap.success",
|
|
11031
|
+
uid
|
|
11032
|
+
});
|
|
10484
11033
|
if (config.setBootstrapCompleted) await config.setBootstrapCompleted();
|
|
10485
11034
|
return c.json({
|
|
10486
11035
|
success: true,
|
|
10487
11036
|
message: "You are now an admin",
|
|
10488
11037
|
user: {
|
|
10489
|
-
uid
|
|
11038
|
+
uid,
|
|
10490
11039
|
roles: ["admin"]
|
|
10491
11040
|
}
|
|
10492
11041
|
});
|
|
@@ -10517,9 +11066,9 @@ function createAdminUsersRoute(config) {
|
|
|
10517
11066
|
offset: result.offset
|
|
10518
11067
|
});
|
|
10519
11068
|
});
|
|
10520
|
-
router.get("/users/:
|
|
10521
|
-
const
|
|
10522
|
-
const result = await authRepo.getUserWithRoles(
|
|
11069
|
+
router.get("/users/:uid", requireAdmin, async (c) => {
|
|
11070
|
+
const uid = c.req.param("uid");
|
|
11071
|
+
const result = await authRepo.getUserWithRoles(uid);
|
|
10523
11072
|
if (!result) throw ApiError.notFound("User not found");
|
|
10524
11073
|
const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
|
|
10525
11074
|
return c.json({ user: adminUser });
|
|
@@ -10562,10 +11111,10 @@ function createAdminUsersRoute(config) {
|
|
|
10562
11111
|
...finalizeResult.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
|
|
10563
11112
|
}, 201);
|
|
10564
11113
|
});
|
|
10565
|
-
router.put("/users/:
|
|
10566
|
-
const
|
|
11114
|
+
router.put("/users/:uid", requireAdmin, async (c) => {
|
|
11115
|
+
const uid = c.req.param("uid");
|
|
10567
11116
|
const { password, email, displayName, roles } = await c.req.json();
|
|
10568
|
-
if (!await authRepo.getUserById(
|
|
11117
|
+
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
10569
11118
|
const updates = {};
|
|
10570
11119
|
if (email !== void 0) updates.email = email.toLowerCase();
|
|
10571
11120
|
if (displayName !== void 0) updates.displayName = displayName;
|
|
@@ -10574,9 +11123,9 @@ function createAdminUsersRoute(config) {
|
|
|
10574
11123
|
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
|
|
10575
11124
|
updates.passwordHash = await ops.hashPassword(password);
|
|
10576
11125
|
}
|
|
10577
|
-
if (Object.keys(updates).length > 0) await authRepo.updateUser(
|
|
11126
|
+
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
10578
11127
|
if (roles !== void 0 && Array.isArray(roles)) {
|
|
10579
|
-
const wasAdmin = (await authRepo.getUserRoleIds(
|
|
11128
|
+
const wasAdmin = (await authRepo.getUserRoleIds(uid)).includes("admin");
|
|
10580
11129
|
const willBeAdmin = roles.includes("admin");
|
|
10581
11130
|
if (wasAdmin && !willBeAdmin) {
|
|
10582
11131
|
if ((await authRepo.listUsersPaginated({
|
|
@@ -10584,24 +11133,23 @@ function createAdminUsersRoute(config) {
|
|
|
10584
11133
|
limit: 1
|
|
10585
11134
|
})).total <= 1) throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
|
|
10586
11135
|
}
|
|
10587
|
-
await authRepo.setUserRoles(
|
|
11136
|
+
await authRepo.setUserRoles(uid, roles);
|
|
10588
11137
|
}
|
|
10589
|
-
const result = await authRepo.getUserWithRoles(
|
|
11138
|
+
const result = await authRepo.getUserWithRoles(uid);
|
|
10590
11139
|
const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
|
|
10591
11140
|
return c.json({ user: adminUser });
|
|
10592
11141
|
});
|
|
10593
|
-
router.delete("/users/:
|
|
10594
|
-
const
|
|
10595
|
-
|
|
10596
|
-
if ((
|
|
10597
|
-
if (
|
|
10598
|
-
if ((await authRepo.getUserRoleIds(userId)).includes("admin")) {
|
|
11142
|
+
router.delete("/users/:uid", requireAdmin, async (c) => {
|
|
11143
|
+
const uid = c.req.param("uid");
|
|
11144
|
+
if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
|
|
11145
|
+
if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
|
|
11146
|
+
if ((await authRepo.getUserRoleIds(uid)).includes("admin")) {
|
|
10599
11147
|
if ((await authRepo.listUsersPaginated({
|
|
10600
11148
|
roleId: "admin",
|
|
10601
11149
|
limit: 1
|
|
10602
11150
|
})).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
|
|
10603
11151
|
}
|
|
10604
|
-
await authRepo.deleteUser(
|
|
11152
|
+
await authRepo.deleteUser(uid);
|
|
10605
11153
|
return c.json({ success: true });
|
|
10606
11154
|
});
|
|
10607
11155
|
return router;
|
|
@@ -10623,10 +11171,8 @@ function createBuiltinAuthAdapter(config) {
|
|
|
10623
11171
|
serviceKey,
|
|
10624
11172
|
async verifyRequest(request) {
|
|
10625
11173
|
const authHeader = request.headers.get("authorization");
|
|
10626
|
-
|
|
10627
|
-
const
|
|
10628
|
-
if (!hasBearer && !queryToken) return null;
|
|
10629
|
-
const token = hasBearer ? authHeader.substring(7) : queryToken;
|
|
11174
|
+
if (!authHeader?.startsWith("Bearer ")) return null;
|
|
11175
|
+
const token = authHeader.substring(7);
|
|
10630
11176
|
if (serviceKey && safeCompare(token, serviceKey)) return {
|
|
10631
11177
|
uid: "service",
|
|
10632
11178
|
email: "service@rebase.internal",
|
|
@@ -10638,16 +11184,16 @@ function createBuiltinAuthAdapter(config) {
|
|
|
10638
11184
|
if (!payload) return null;
|
|
10639
11185
|
let roles = payload.roles || [];
|
|
10640
11186
|
try {
|
|
10641
|
-
roles = await authRepository.getUserRoleIds(payload.
|
|
11187
|
+
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
10642
11188
|
} catch (err) {
|
|
10643
11189
|
logger.warn("Role lookup from repository failed, using token roles as fallback", {
|
|
10644
|
-
|
|
11190
|
+
uid: payload.uid,
|
|
10645
11191
|
error: err
|
|
10646
11192
|
});
|
|
10647
11193
|
}
|
|
10648
11194
|
const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
|
|
10649
11195
|
return {
|
|
10650
|
-
uid: payload.
|
|
11196
|
+
uid: payload.uid,
|
|
10651
11197
|
email: payload.email ?? "",
|
|
10652
11198
|
displayName: payload.displayName ?? null,
|
|
10653
11199
|
roles,
|
|
@@ -10667,16 +11213,16 @@ function createBuiltinAuthAdapter(config) {
|
|
|
10667
11213
|
if (!payload) return null;
|
|
10668
11214
|
let roles = payload.roles || [];
|
|
10669
11215
|
try {
|
|
10670
|
-
roles = await authRepository.getUserRoleIds(payload.
|
|
11216
|
+
roles = await authRepository.getUserRoleIds(payload.uid);
|
|
10671
11217
|
} catch (err) {
|
|
10672
11218
|
logger.warn("Role lookup from repository failed, using token roles as fallback", {
|
|
10673
|
-
|
|
11219
|
+
uid: payload.uid,
|
|
10674
11220
|
error: err
|
|
10675
11221
|
});
|
|
10676
11222
|
}
|
|
10677
11223
|
const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
|
|
10678
11224
|
return {
|
|
10679
|
-
uid: payload.
|
|
11225
|
+
uid: payload.uid,
|
|
10680
11226
|
email: payload.email ?? "",
|
|
10681
11227
|
displayName: payload.displayName ?? null,
|
|
10682
11228
|
roles,
|
|
@@ -10735,6 +11281,16 @@ function createBuiltinAuthAdapter(config) {
|
|
|
10735
11281
|
collectionAuthConfig: collectionAuth ?? collectionAuthConfig
|
|
10736
11282
|
});
|
|
10737
11283
|
},
|
|
11284
|
+
describeUserCreationContract(collectionAuth) {
|
|
11285
|
+
if ((collectionAuth ?? collectionAuthConfig)?.onCreateUser || resolvedOps.onAdminCreateUser) return {
|
|
11286
|
+
validate: false,
|
|
11287
|
+
extraFields: []
|
|
11288
|
+
};
|
|
11289
|
+
return {
|
|
11290
|
+
validate: true,
|
|
11291
|
+
extraFields: ["password"]
|
|
11292
|
+
};
|
|
11293
|
+
},
|
|
10738
11294
|
async finalizeUserCreation(entity, clearPassword) {
|
|
10739
11295
|
return finalizeAdminUserCreation(entity, clearPassword, {
|
|
10740
11296
|
authRepo: authRepository,
|
|
@@ -10824,11 +11380,11 @@ function createUserManagementFromRepo(repo, resolvedOps) {
|
|
|
10824
11380
|
logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
|
|
10825
11381
|
});
|
|
10826
11382
|
},
|
|
10827
|
-
async getUserRoles(
|
|
10828
|
-
return repo.getUserRoleIds(
|
|
11383
|
+
async getUserRoles(uid) {
|
|
11384
|
+
return repo.getUserRoleIds(uid);
|
|
10829
11385
|
},
|
|
10830
|
-
async setUserRoles(
|
|
10831
|
-
await repo.setUserRoles(
|
|
11386
|
+
async setUserRoles(uid, roleIds) {
|
|
11387
|
+
await repo.setUserRoles(uid, roleIds);
|
|
10832
11388
|
}
|
|
10833
11389
|
};
|
|
10834
11390
|
}
|
|
@@ -10863,6 +11419,33 @@ function configureLogLevel(logLevel) {
|
|
|
10863
11419
|
if (currentLevel < 0) console.error = () => {};
|
|
10864
11420
|
}
|
|
10865
11421
|
//#endregion
|
|
11422
|
+
//#region src/utils/compression.ts
|
|
11423
|
+
/**
|
|
11424
|
+
* Response compression (gzip/deflate), negotiated from `Accept-Encoding`.
|
|
11425
|
+
*
|
|
11426
|
+
* Wraps Hono's `compress` with two corrections it does not make itself:
|
|
11427
|
+
*
|
|
11428
|
+
* - **`Vary: Accept-Encoding`** on every response it guards. Without it a shared
|
|
11429
|
+
* cache may hand a gzipped body to a client that asked for identity.
|
|
11430
|
+
* - **Range responses are left alone.** `Content-Range` describes offsets into
|
|
11431
|
+
* the identity body, so compressing a 206 desyncs the framing from the bytes
|
|
11432
|
+
* actually sent.
|
|
11433
|
+
*
|
|
11434
|
+
* No brotli: `CompressionStream` has no "br", so a br-only client would fall
|
|
11435
|
+
* back to identity — every real client sends gzip too.
|
|
11436
|
+
*/
|
|
11437
|
+
function responseCompression() {
|
|
11438
|
+
const gzip = compress();
|
|
11439
|
+
return async (c, next) => {
|
|
11440
|
+
await next();
|
|
11441
|
+
const vary = c.res.headers.get("Vary");
|
|
11442
|
+
if (!vary) c.res.headers.set("Vary", "Accept-Encoding");
|
|
11443
|
+
else if (!/\baccept-encoding\b/i.test(vary)) c.res.headers.append("Vary", "Accept-Encoding");
|
|
11444
|
+
if (c.res.status === 206 || c.res.headers.has("Content-Range")) return;
|
|
11445
|
+
await gzip(c, async () => {});
|
|
11446
|
+
};
|
|
11447
|
+
}
|
|
11448
|
+
//#endregion
|
|
10866
11449
|
//#region src/utils/request-id.ts
|
|
10867
11450
|
/**
|
|
10868
11451
|
* X-Request-ID Middleware for Hono.
|
|
@@ -10917,36 +11500,133 @@ function requestLogger(options) {
|
|
|
10917
11500
|
const reqId = c.get("requestId");
|
|
10918
11501
|
if (reqId) data.requestId = reqId;
|
|
10919
11502
|
if (contentLength) data.contentLength = parseInt(contentLength, 10);
|
|
10920
|
-
const
|
|
10921
|
-
if (
|
|
11503
|
+
const uid = c.get("user")?.uid;
|
|
11504
|
+
if (uid) data.uid = uid;
|
|
10922
11505
|
if (status >= 500) logger.error("request", data);
|
|
10923
11506
|
else if (status >= 400) logger.warn("request", data);
|
|
10924
11507
|
else logger.info("request", data);
|
|
10925
11508
|
};
|
|
10926
11509
|
}
|
|
10927
11510
|
//#endregion
|
|
10928
|
-
//#region src/
|
|
10929
|
-
|
|
10930
|
-
|
|
10931
|
-
|
|
10932
|
-
|
|
10933
|
-
|
|
10934
|
-
|
|
10935
|
-
|
|
10936
|
-
|
|
10937
|
-
|
|
10938
|
-
|
|
10939
|
-
|
|
10940
|
-
|
|
10941
|
-
|
|
10942
|
-
|
|
10943
|
-
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
11511
|
+
//#region src/api/logs-routes.ts
|
|
11512
|
+
var logs_routes_exports = /* @__PURE__ */ __exportAll({
|
|
11513
|
+
addLog: () => addLog,
|
|
11514
|
+
default: () => app,
|
|
11515
|
+
logBuffer: () => logBuffer,
|
|
11516
|
+
logMiddleware: () => logMiddleware
|
|
11517
|
+
});
|
|
11518
|
+
var LogRingBuffer = class {
|
|
11519
|
+
buffer = [];
|
|
11520
|
+
maxSize;
|
|
11521
|
+
idCounter = 0;
|
|
11522
|
+
constructor(maxSize = 1e4) {
|
|
11523
|
+
this.maxSize = maxSize;
|
|
11524
|
+
}
|
|
11525
|
+
push(entry) {
|
|
11526
|
+
const id = `log_${++this.idCounter}`;
|
|
11527
|
+
this.buffer.push({
|
|
11528
|
+
...entry,
|
|
11529
|
+
id
|
|
11530
|
+
});
|
|
11531
|
+
if (this.buffer.length > this.maxSize) this.buffer.shift();
|
|
11532
|
+
}
|
|
11533
|
+
query(options) {
|
|
11534
|
+
let filtered = this.buffer;
|
|
11535
|
+
if (options.level) filtered = filtered.filter((e) => e.level === options.level);
|
|
11536
|
+
if (options.source) filtered = filtered.filter((e) => e.source === options.source);
|
|
11537
|
+
if (options.search) {
|
|
11538
|
+
const searchLower = options.search.toLowerCase();
|
|
11539
|
+
filtered = filtered.filter((e) => e.message.toLowerCase().includes(searchLower));
|
|
11540
|
+
}
|
|
11541
|
+
if (options.since) {
|
|
11542
|
+
const sinceValue = options.since;
|
|
11543
|
+
filtered = filtered.filter((e) => e.timestamp >= sinceValue);
|
|
11544
|
+
}
|
|
11545
|
+
const sorted = [...filtered].reverse();
|
|
11546
|
+
const total = sorted.length;
|
|
11547
|
+
const limit = options.limit || 100;
|
|
11548
|
+
const offset = options.offset || 0;
|
|
11549
|
+
return {
|
|
11550
|
+
entries: sorted.slice(offset, offset + limit),
|
|
11551
|
+
total
|
|
11552
|
+
};
|
|
11553
|
+
}
|
|
11554
|
+
getLatest(count = 50) {
|
|
11555
|
+
return this.buffer.slice(-count).reverse();
|
|
10947
11556
|
}
|
|
10948
|
-
|
|
11557
|
+
};
|
|
11558
|
+
var logBuffer = new LogRingBuffer();
|
|
11559
|
+
/** Add a log entry */
|
|
11560
|
+
function addLog(level, source, message, metadata) {
|
|
11561
|
+
logBuffer.push({
|
|
11562
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
11563
|
+
level,
|
|
11564
|
+
source,
|
|
11565
|
+
message,
|
|
11566
|
+
metadata
|
|
11567
|
+
});
|
|
11568
|
+
}
|
|
11569
|
+
/** Hono middleware to log API requests */
|
|
11570
|
+
function logMiddleware() {
|
|
11571
|
+
return async (c, next) => {
|
|
11572
|
+
const start = Date.now();
|
|
11573
|
+
await next();
|
|
11574
|
+
const duration = Date.now() - start;
|
|
11575
|
+
const reqId = c.get("requestId");
|
|
11576
|
+
addLog("info", "api", `${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`, {
|
|
11577
|
+
method: c.req.method,
|
|
11578
|
+
path: c.req.path,
|
|
11579
|
+
status: c.res.status,
|
|
11580
|
+
duration,
|
|
11581
|
+
...reqId && { requestId: reqId }
|
|
11582
|
+
});
|
|
11583
|
+
};
|
|
11584
|
+
}
|
|
11585
|
+
var app = new Hono();
|
|
11586
|
+
app.get("/", (c) => {
|
|
11587
|
+
const query = c.req.query();
|
|
11588
|
+
const result = logBuffer.query({
|
|
11589
|
+
level: query.level,
|
|
11590
|
+
source: query.source,
|
|
11591
|
+
search: query.search,
|
|
11592
|
+
limit: query.limit ? parseInt(query.limit) : void 0,
|
|
11593
|
+
offset: query.offset ? parseInt(query.offset) : void 0,
|
|
11594
|
+
since: query.since
|
|
11595
|
+
});
|
|
11596
|
+
return c.json(result);
|
|
11597
|
+
});
|
|
11598
|
+
app.get("/latest", (c) => {
|
|
11599
|
+
const count = parseInt(c.req.query("count") || "50");
|
|
11600
|
+
return c.json({ entries: logBuffer.getLatest(count) });
|
|
11601
|
+
});
|
|
11602
|
+
//#endregion
|
|
11603
|
+
//#region src/init/middlewares.ts
|
|
11604
|
+
function configureMiddlewares(app, basePath, isProduction, config) {
|
|
11605
|
+
app.use(`${basePath}/*`, requestId());
|
|
11606
|
+
if (config.compression !== false) {
|
|
11607
|
+
app.use(`${basePath}/*`, responseCompression());
|
|
11608
|
+
logger.info("Response compression enabled");
|
|
11609
|
+
}
|
|
11610
|
+
const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
|
|
11611
|
+
if (maxBodySize > 0) {
|
|
11612
|
+
app.use(`${basePath}/*`, bodyLimit({
|
|
11613
|
+
maxSize: maxBodySize,
|
|
11614
|
+
onError: (c) => {
|
|
11615
|
+
return c.json({ error: {
|
|
11616
|
+
message: `Request body too large. Maximum size is ${Math.round(maxBodySize / 1024 / 1024)}MB.`,
|
|
11617
|
+
code: "PAYLOAD_TOO_LARGE"
|
|
11618
|
+
} }, 413);
|
|
11619
|
+
}
|
|
11620
|
+
}));
|
|
11621
|
+
logger.info("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
|
|
11622
|
+
}
|
|
11623
|
+
if (config.csrf?.origin) {
|
|
11624
|
+
app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
|
|
11625
|
+
logger.info("CSRF protection enabled");
|
|
11626
|
+
}
|
|
11627
|
+
if (!process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn((isProduction ? "[PRODUCTION] " : "") + "No CORS configuration detected (CORS_ORIGINS / FRONTEND_URL not set). If your app does not install its own CORS middleware, the API may accept requests from any origin. Set CORS_ORIGINS to restrict access.");
|
|
10949
11628
|
app.use(`${basePath}/*`, requestLogger());
|
|
11629
|
+
app.use(`${basePath}/*`, logMiddleware());
|
|
10950
11630
|
}
|
|
10951
11631
|
//#endregion
|
|
10952
11632
|
//#region src/storage/types.ts
|
|
@@ -11027,11 +11707,21 @@ var LocalStorageController = class {
|
|
|
11027
11707
|
}
|
|
11028
11708
|
}
|
|
11029
11709
|
/**
|
|
11030
|
-
* Get the full filesystem path for a storage path
|
|
11031
|
-
*
|
|
11710
|
+
* Get the full filesystem path for a storage path, with a traversal guard
|
|
11711
|
+
* that keeps the result inside the bucket directory.
|
|
11712
|
+
*
|
|
11713
|
+
* Defaults the bucket the way `putObject` does.
|
|
11714
|
+
*
|
|
11715
|
+
* `putObject` has always written into `default` when given no bucket, while
|
|
11716
|
+
* the read side resolved a bare key against the storage root — where
|
|
11717
|
+
* nothing is. The two disagreed silently: `getObject` returned null (reads
|
|
11718
|
+
* as "file missing"), `deleteObject` deleted nothing (404s are swallowed by
|
|
11719
|
+
* design), and `listObjects` returned an empty page. So the obvious
|
|
11720
|
+
* `putObject({ key })` → `getObject(key)` did not round-trip and nothing
|
|
11721
|
+
* said why. One default, applied everywhere, removes the whole class.
|
|
11032
11722
|
*/
|
|
11033
11723
|
getFullPath(storagePath, bucket) {
|
|
11034
|
-
const bucketPath =
|
|
11724
|
+
const bucketPath = path$1.join(this.basePath, bucket ?? "default");
|
|
11035
11725
|
const resolved = path$1.resolve(path$1.join(bucketPath, storagePath));
|
|
11036
11726
|
if (!resolved.startsWith(bucketPath + path$1.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
11037
11727
|
return resolved;
|
|
@@ -11192,8 +11882,10 @@ var LocalStorageController = class {
|
|
|
11192
11882
|
let count = 0;
|
|
11193
11883
|
const maxResults = options?.maxResults ?? 1e3;
|
|
11194
11884
|
const startIndex = options?.pageToken ? parseInt(options.pageToken, 10) : 0;
|
|
11885
|
+
let scanned = startIndex;
|
|
11195
11886
|
for (let i = startIndex; i < entries.length && count < maxResults; i++) {
|
|
11196
11887
|
const entry = entries[i];
|
|
11888
|
+
scanned = i + 1;
|
|
11197
11889
|
if (entry.name.endsWith(".metadata.json")) continue;
|
|
11198
11890
|
const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
11199
11891
|
const bucket = options?.bucket ?? "default";
|
|
@@ -11212,7 +11904,7 @@ var LocalStorageController = class {
|
|
|
11212
11904
|
return {
|
|
11213
11905
|
items,
|
|
11214
11906
|
prefixes,
|
|
11215
|
-
nextPageToken:
|
|
11907
|
+
nextPageToken: scanned < entries.length ? String(scanned) : void 0
|
|
11216
11908
|
};
|
|
11217
11909
|
} catch (error) {
|
|
11218
11910
|
const code = error?.code;
|
|
@@ -11486,7 +12178,7 @@ var gcsModule = null;
|
|
|
11486
12178
|
async function loadGCS() {
|
|
11487
12179
|
if (gcsModule) return gcsModule;
|
|
11488
12180
|
try {
|
|
11489
|
-
gcsModule = await import("
|
|
12181
|
+
gcsModule = await import("@google-cloud/storage");
|
|
11490
12182
|
return gcsModule;
|
|
11491
12183
|
} catch {
|
|
11492
12184
|
throw new Error("Google Cloud Storage is required for the GCS storage controller. Install it with: pnpm add @google-cloud/storage");
|
|
@@ -11692,6 +12384,47 @@ async function loadGoogleAuth() {
|
|
|
11692
12384
|
return _googleAuth;
|
|
11693
12385
|
}
|
|
11694
12386
|
/**
|
|
12387
|
+
* Verify a Google **access token** and resolve it to a profile.
|
|
12388
|
+
*
|
|
12389
|
+
* SECURITY: an access token must be checked against Google's `tokeninfo`
|
|
12390
|
+
* endpoint so we can confirm its `aud` (the OAuth client the token was issued
|
|
12391
|
+
* to) matches *our* `clientId`. The `userinfo` endpoint does **not** verify
|
|
12392
|
+
* audience — it returns a profile for any valid Google access token — so
|
|
12393
|
+
* trusting it alone lets an attacker present a token they obtained for a
|
|
12394
|
+
* *different* client (e.g. their own app, via a phished consent) and log in as
|
|
12395
|
+
* whatever account that token belongs to. The `aud` check is what binds the
|
|
12396
|
+
* token to this application.
|
|
12397
|
+
*
|
|
12398
|
+
* Identity (`sub` / `email` / `email_verified`) is taken from the
|
|
12399
|
+
* audience-checked `tokeninfo` response. `userinfo` is used only, best-effort,
|
|
12400
|
+
* to enrich display fields (name, picture).
|
|
12401
|
+
*/
|
|
12402
|
+
async function verifyGoogleAccessToken(accessToken, expectedClientId) {
|
|
12403
|
+
const tokenInfoRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(accessToken)}`);
|
|
12404
|
+
if (!tokenInfoRes.ok) throw new Error(`Google tokeninfo request failed with status ${tokenInfoRes.status}`);
|
|
12405
|
+
const tokenInfo = await tokenInfoRes.json();
|
|
12406
|
+
if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
|
|
12407
|
+
if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
|
|
12408
|
+
const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === "true";
|
|
12409
|
+
let displayName = null;
|
|
12410
|
+
let photoUrl = null;
|
|
12411
|
+
try {
|
|
12412
|
+
const res = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${accessToken}` } });
|
|
12413
|
+
if (res.ok) {
|
|
12414
|
+
const info = await res.json();
|
|
12415
|
+
displayName = info.name || null;
|
|
12416
|
+
photoUrl = info.picture || null;
|
|
12417
|
+
}
|
|
12418
|
+
} catch {}
|
|
12419
|
+
return {
|
|
12420
|
+
providerId: tokenInfo.sub,
|
|
12421
|
+
email: tokenInfo.email,
|
|
12422
|
+
displayName,
|
|
12423
|
+
photoUrl,
|
|
12424
|
+
emailVerified
|
|
12425
|
+
};
|
|
12426
|
+
}
|
|
12427
|
+
/**
|
|
11695
12428
|
* Creates a Google OAuth Provider integration.
|
|
11696
12429
|
*
|
|
11697
12430
|
* Supports three verification paths:
|
|
@@ -11741,22 +12474,10 @@ function createGoogleProvider(config) {
|
|
|
11741
12474
|
email: content.email || "",
|
|
11742
12475
|
displayName: content.name || null,
|
|
11743
12476
|
photoUrl: content.picture || null,
|
|
11744
|
-
emailVerified: true
|
|
11745
|
-
};
|
|
11746
|
-
}
|
|
11747
|
-
if (payload.accessToken) {
|
|
11748
|
-
const res = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${payload.accessToken}` } });
|
|
11749
|
-
if (!res.ok) throw new Error(`Google userinfo request failed with status ${res.status}`);
|
|
11750
|
-
const info = await res.json();
|
|
11751
|
-
if (!info.sub || !info.email) throw new Error("Google userinfo response missing sub or email");
|
|
11752
|
-
return {
|
|
11753
|
-
providerId: info.sub,
|
|
11754
|
-
email: info.email,
|
|
11755
|
-
displayName: info.name || null,
|
|
11756
|
-
photoUrl: info.picture || null,
|
|
11757
|
-
emailVerified: true
|
|
12477
|
+
emailVerified: content.email_verified === true
|
|
11758
12478
|
};
|
|
11759
12479
|
}
|
|
12480
|
+
if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
|
|
11760
12481
|
if (payload.code && payload.redirectUri) {
|
|
11761
12482
|
if (!clientSecret) throw new Error("Google authorization code flow requires clientSecret. Configure GOOGLE_CLIENT_SECRET in your environment.");
|
|
11762
12483
|
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
@@ -11787,22 +12508,10 @@ function createGoogleProvider(config) {
|
|
|
11787
12508
|
email: content.email || "",
|
|
11788
12509
|
displayName: content.name || null,
|
|
11789
12510
|
photoUrl: content.picture || null,
|
|
11790
|
-
emailVerified: true
|
|
11791
|
-
};
|
|
11792
|
-
}
|
|
11793
|
-
if (tokenData.access_token) {
|
|
11794
|
-
const userInfoRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${tokenData.access_token}` } });
|
|
11795
|
-
if (!userInfoRes.ok) throw new Error(`Google userinfo request failed after code exchange (${userInfoRes.status})`);
|
|
11796
|
-
const info = await userInfoRes.json();
|
|
11797
|
-
if (!info.sub || !info.email) return null;
|
|
11798
|
-
return {
|
|
11799
|
-
providerId: info.sub,
|
|
11800
|
-
email: info.email,
|
|
11801
|
-
displayName: info.name || null,
|
|
11802
|
-
photoUrl: info.picture || null,
|
|
11803
|
-
emailVerified: true
|
|
12511
|
+
emailVerified: content.email_verified === true
|
|
11804
12512
|
};
|
|
11805
12513
|
}
|
|
12514
|
+
if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
|
|
11806
12515
|
throw new Error("Google token exchange returned neither id_token nor access_token");
|
|
11807
12516
|
}
|
|
11808
12517
|
throw new Error("No valid Google credential provided (expected idToken, accessToken, or code+redirectUri)");
|
|
@@ -11992,12 +12701,13 @@ function createMicrosoftProvider(config) {
|
|
|
11992
12701
|
logger.error("Microsoft user has no email");
|
|
11993
12702
|
return null;
|
|
11994
12703
|
}
|
|
12704
|
+
const emailVerified = Boolean(profileData.mail);
|
|
11995
12705
|
return {
|
|
11996
12706
|
providerId: profileData.id,
|
|
11997
12707
|
email,
|
|
11998
12708
|
displayName: profileData.displayName || null,
|
|
11999
12709
|
photoUrl: null,
|
|
12000
|
-
emailVerified
|
|
12710
|
+
emailVerified
|
|
12001
12711
|
};
|
|
12002
12712
|
} catch (error) {
|
|
12003
12713
|
logger.error("Microsoft OAuth error", { error });
|
|
@@ -12794,7 +13504,7 @@ function createApiKeyRoutes(options) {
|
|
|
12794
13504
|
if (parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
|
|
12795
13505
|
}
|
|
12796
13506
|
const user = c.get("user");
|
|
12797
|
-
const createdBy = user && typeof user === "object" && "
|
|
13507
|
+
const createdBy = user && typeof user === "object" && "uid" in user ? user.uid : "unknown";
|
|
12798
13508
|
const request = {
|
|
12799
13509
|
name: name.trim(),
|
|
12800
13510
|
permissions,
|
|
@@ -12890,6 +13600,7 @@ function createCustomAuthAdapter(options) {
|
|
|
12890
13600
|
//#endregion
|
|
12891
13601
|
//#region src/auth/index.ts
|
|
12892
13602
|
var auth_exports = /* @__PURE__ */ __exportAll({
|
|
13603
|
+
MemoryRateLimitStore: () => MemoryRateLimitStore,
|
|
12893
13604
|
apiKeyKeyGenerator: () => apiKeyKeyGenerator,
|
|
12894
13605
|
configureJwt: () => configureJwt,
|
|
12895
13606
|
createAdapterAuthMiddleware: () => createAdapterAuthMiddleware,
|
|
@@ -12902,6 +13613,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
12902
13613
|
createBitbucketProvider: () => createBitbucketProvider,
|
|
12903
13614
|
createBuiltinAuthAdapter: () => createBuiltinAuthAdapter,
|
|
12904
13615
|
createCustomAuthAdapter: () => createCustomAuthAdapter,
|
|
13616
|
+
createDataRateLimiter: () => createDataRateLimiter,
|
|
12905
13617
|
createDiscordProvider: () => createDiscordProvider,
|
|
12906
13618
|
createFacebookProvider: () => createFacebookProvider,
|
|
12907
13619
|
createGitHubProvider: () => createGitHubProvider,
|
|
@@ -13143,12 +13855,14 @@ var UPLOAD_EXPIRY_MS = 1440 * 60 * 1e3;
|
|
|
13143
13855
|
var TusHandler = class {
|
|
13144
13856
|
storageController;
|
|
13145
13857
|
storageRegistry;
|
|
13858
|
+
authorizeUpload;
|
|
13146
13859
|
uploads = /* @__PURE__ */ new Map();
|
|
13147
13860
|
tusDir;
|
|
13148
13861
|
cleanupTimer;
|
|
13149
|
-
constructor(storageBaseDir, storageController, storageRegistry) {
|
|
13862
|
+
constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
|
|
13150
13863
|
this.storageController = storageController;
|
|
13151
13864
|
this.storageRegistry = storageRegistry;
|
|
13865
|
+
this.authorizeUpload = authorizeUpload;
|
|
13152
13866
|
this.tusDir = join(storageBaseDir, ".tus-uploads");
|
|
13153
13867
|
}
|
|
13154
13868
|
/** Ensure the temp directory exists. */
|
|
@@ -13212,6 +13926,10 @@ var TusHandler = class {
|
|
|
13212
13926
|
if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
|
|
13213
13927
|
if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
|
|
13214
13928
|
const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
|
|
13929
|
+
if (this.authorizeUpload) {
|
|
13930
|
+
const key = metadata.key || metadata.filename || "";
|
|
13931
|
+
await this.authorizeUpload(c, key, metadata.bucket || "default");
|
|
13932
|
+
}
|
|
13215
13933
|
const id = randomUUID$1();
|
|
13216
13934
|
const filePath = join(this.tusDir, id);
|
|
13217
13935
|
await writeFile(filePath, Buffer.alloc(0));
|
|
@@ -13397,7 +14115,7 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
|
|
|
13397
14115
|
} }, 401);
|
|
13398
14116
|
}
|
|
13399
14117
|
if (authenticatedUser) c.set("user", {
|
|
13400
|
-
|
|
14118
|
+
uid: authenticatedUser.uid,
|
|
13401
14119
|
email: authenticatedUser.email,
|
|
13402
14120
|
roles: authenticatedUser.roles
|
|
13403
14121
|
});
|
|
@@ -13419,7 +14137,34 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
|
|
|
13419
14137
|
function createStorageRoutes(config) {
|
|
13420
14138
|
const router = new Hono();
|
|
13421
14139
|
router.onError(errorHandler);
|
|
13422
|
-
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter } = config;
|
|
14140
|
+
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
|
|
14141
|
+
/**
|
|
14142
|
+
* Run the per-object authorization hook, if one is configured.
|
|
14143
|
+
*
|
|
14144
|
+
* Denials are 403 rather than 404: the route already established that the
|
|
14145
|
+
* caller is authenticated, so hiding existence buys nothing, and a
|
|
14146
|
+
* distinguishable status is what makes a misconfigured policy debuggable.
|
|
14147
|
+
* A hook that throws denies too — an ownership lookup that fails must not
|
|
14148
|
+
* fall open.
|
|
14149
|
+
*/
|
|
14150
|
+
const checkAuthorized = async (c, operation, key, bucket, storageId) => {
|
|
14151
|
+
if (!authorize) return;
|
|
14152
|
+
const user = c.get("user") ?? null;
|
|
14153
|
+
if (user?.uid === "download-token" || user?.uid === "public") return;
|
|
14154
|
+
let allowed;
|
|
14155
|
+
try {
|
|
14156
|
+
allowed = await authorize({
|
|
14157
|
+
key,
|
|
14158
|
+
bucket,
|
|
14159
|
+
operation,
|
|
14160
|
+
user,
|
|
14161
|
+
storageId: storageId ?? void 0
|
|
14162
|
+
});
|
|
14163
|
+
} catch {
|
|
14164
|
+
allowed = false;
|
|
14165
|
+
}
|
|
14166
|
+
if (!allowed) throw ApiError.forbidden("Not authorized for this object");
|
|
14167
|
+
};
|
|
13423
14168
|
/**
|
|
13424
14169
|
* Resolve the storage controller for a request.
|
|
13425
14170
|
* Looks up by `storageId` in the registry, falls back to the single
|
|
@@ -13469,6 +14214,7 @@ function createStorageRoutes(config) {
|
|
|
13469
14214
|
const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
|
|
13470
14215
|
const metadata = {};
|
|
13471
14216
|
for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
|
|
14217
|
+
await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
|
|
13472
14218
|
const result = await resolveController(storageId).putObject({
|
|
13473
14219
|
file: uploadedFile,
|
|
13474
14220
|
key: finalKey,
|
|
@@ -13489,7 +14235,12 @@ function createStorageRoutes(config) {
|
|
|
13489
14235
|
const rawPath = extractWildcardPath(c);
|
|
13490
14236
|
if (!rawPath) throw ApiError.notFound("File not found");
|
|
13491
14237
|
const filePath = decodeURIComponent(rawPath);
|
|
13492
|
-
const
|
|
14238
|
+
const storageId = c.req.query("storageId");
|
|
14239
|
+
const resolved = resolveController(storageId);
|
|
14240
|
+
{
|
|
14241
|
+
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14242
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
14243
|
+
}
|
|
13493
14244
|
const transformOpts = parseTransformOptions(c.req.query());
|
|
13494
14245
|
if (resolved.getType() === "local") {
|
|
13495
14246
|
const localController = resolved;
|
|
@@ -13552,8 +14303,10 @@ function createStorageRoutes(config) {
|
|
|
13552
14303
|
fileNotFound: true
|
|
13553
14304
|
}, 404);
|
|
13554
14305
|
const filePath = decodeURIComponent(rawPath);
|
|
13555
|
-
const
|
|
14306
|
+
const storageId = c.req.query("storageId");
|
|
14307
|
+
const resolved = resolveController(storageId);
|
|
13556
14308
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14309
|
+
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
13557
14310
|
const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
|
|
13558
14311
|
if (downloadConfig.fileNotFound) throw ApiError.notFound("File not found");
|
|
13559
14312
|
if (downloadConfig.metadata) {
|
|
@@ -13579,8 +14332,10 @@ function createStorageRoutes(config) {
|
|
|
13579
14332
|
message: "No file to delete"
|
|
13580
14333
|
});
|
|
13581
14334
|
const filePath = decodeURIComponent(rawPath);
|
|
13582
|
-
const
|
|
14335
|
+
const storageId = c.req.query("storageId");
|
|
14336
|
+
const resolved = resolveController(storageId);
|
|
13583
14337
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
14338
|
+
await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
|
|
13584
14339
|
await resolved.deleteObject(resolvedPath, bucket);
|
|
13585
14340
|
return c.json({
|
|
13586
14341
|
success: true,
|
|
@@ -13595,7 +14350,9 @@ function createStorageRoutes(config) {
|
|
|
13595
14350
|
const bucket = c.req.query("bucket");
|
|
13596
14351
|
const maxResults = c.req.query("maxResults");
|
|
13597
14352
|
const pageToken = c.req.query("pageToken");
|
|
13598
|
-
const
|
|
14353
|
+
const storageId = c.req.query("storageId");
|
|
14354
|
+
const resolved = resolveController(storageId);
|
|
14355
|
+
await checkAuthorized(c, "list", storagePrefix, bucket ?? "default", storageId);
|
|
13599
14356
|
const result = await resolved.listObjects(storagePrefix, {
|
|
13600
14357
|
bucket: bucket ?? (resolved.getType() === "local" ? "default" : void 0),
|
|
13601
14358
|
maxResults: maxResults ? parseInt(maxResults, 10) : void 0,
|
|
@@ -13618,6 +14375,7 @@ function createStorageRoutes(config) {
|
|
|
13618
14375
|
const resolved = resolveController(storageId);
|
|
13619
14376
|
const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
|
|
13620
14377
|
if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
|
|
14378
|
+
await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
|
|
13621
14379
|
if (resolved.getType() === "local") {
|
|
13622
14380
|
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
|
|
13623
14381
|
fs$1.mkdirSync(absolutePath, { recursive: true });
|
|
@@ -13635,7 +14393,9 @@ function createStorageRoutes(config) {
|
|
|
13635
14393
|
}, 201);
|
|
13636
14394
|
});
|
|
13637
14395
|
const defaultCtrl = getDefaultController();
|
|
13638
|
-
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry)
|
|
14396
|
+
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
|
|
14397
|
+
await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
|
|
14398
|
+
} : void 0);
|
|
13639
14399
|
tusHandler.startCleanup();
|
|
13640
14400
|
router.options("/tus", (_c) => tusHandler.options());
|
|
13641
14401
|
router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
|
|
@@ -13776,7 +14536,7 @@ async function initializeStorage(storageConfig, isProduction) {
|
|
|
13776
14536
|
const toController = async (entry, label) => {
|
|
13777
14537
|
if (typeof entry.putObject === "function") return entry;
|
|
13778
14538
|
const conf = entry;
|
|
13779
|
-
if (isProduction && conf.type === "local")
|
|
14539
|
+
if (isProduction && conf.type === "local" && !process.env.FORCE_LOCAL_STORAGE) throw new Error(`Storage backend "${label}" is set to "local" in production. Local storage is the container filesystem, so uploaded files are destroyed on the next restart or redeploy. Configure S3-compatible storage (STORAGE_TYPE=s3) or GCS (STORAGE_TYPE=gcs), or pass a custom StorageController. If this deployment really does have a durable volume mounted at the storage path, set FORCE_LOCAL_STORAGE=true to proceed.`);
|
|
13780
14540
|
return await createStorageController(conf);
|
|
13781
14541
|
};
|
|
13782
14542
|
if (typeof storageConfig === "object" && ("type" in storageConfig || typeof storageConfig.putObject === "function")) controllers[DEFAULT_STORAGE_ID] = await toController(storageConfig, DEFAULT_STORAGE_ID);
|
|
@@ -13796,7 +14556,7 @@ async function initializeStorage(storageConfig, isProduction) {
|
|
|
13796
14556
|
//#region src/init/docs.ts
|
|
13797
14557
|
async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
|
|
13798
14558
|
if (enableSwagger === false || activeCollections.length === 0) return;
|
|
13799
|
-
const { generateOpenApiSpec } = await import("./openapi-generator-
|
|
14559
|
+
const { generateOpenApiSpec } = await import("./openapi-generator-DqSIwNLV.js");
|
|
13800
14560
|
app.get(`${basePath}/docs`, (c) => {
|
|
13801
14561
|
const spec = generateOpenApiSpec(activeCollections, {
|
|
13802
14562
|
basePath,
|
|
@@ -13950,6 +14710,38 @@ function createShutdown(config) {
|
|
|
13950
14710
|
};
|
|
13951
14711
|
}
|
|
13952
14712
|
//#endregion
|
|
14713
|
+
//#region src/auth/collection-callback-warning.ts
|
|
14714
|
+
/** The data callbacks the auth write path does not run. */
|
|
14715
|
+
var DATA_CALLBACKS = [
|
|
14716
|
+
"beforeSave",
|
|
14717
|
+
"afterSave",
|
|
14718
|
+
"beforeDelete",
|
|
14719
|
+
"afterDelete"
|
|
14720
|
+
];
|
|
14721
|
+
/**
|
|
14722
|
+
* Warn when the auth collection hangs data callbacks that auth will not fire.
|
|
14723
|
+
*
|
|
14724
|
+
* Creating a user through the auth subsystem — registration, OAuth, the admin
|
|
14725
|
+
* user routes — writes to the user store directly, because that path owns
|
|
14726
|
+
* password hashing, identity rows and its own transaction. It deliberately does
|
|
14727
|
+
* not go through the collection save pipeline: a `beforeSave` able to rewrite
|
|
14728
|
+
* `password_hash` on its way to the database is a footgun, not a feature, and
|
|
14729
|
+
* the auth hooks (`afterUserCreate`, `beforeUserCreate`, …) exist to hang
|
|
14730
|
+
* behaviour off those events with the right contract.
|
|
14731
|
+
*
|
|
14732
|
+
* The cost is a reasonable expectation quietly not being met: someone puts
|
|
14733
|
+
* "send the welcome email" in `afterSave` on their users collection, tests it
|
|
14734
|
+
* by creating a user in the admin, and it works — because *that* is a
|
|
14735
|
+
* collection write. Then a real signup does nothing at all. Which is why this
|
|
14736
|
+
* is said at boot, naming the callbacks that will not run.
|
|
14737
|
+
*/
|
|
14738
|
+
function warnOnAuthCollectionDataCallbacks(collection) {
|
|
14739
|
+
if (!collection?.callbacks) return;
|
|
14740
|
+
const declared = DATA_CALLBACKS.filter((name) => typeof collection.callbacks?.[name] === "function");
|
|
14741
|
+
if (declared.length === 0) return;
|
|
14742
|
+
logger.warn(`[Auth] The auth collection "${collection.slug}" defines ${declared.join("/")} callback(s), but these do NOT fire when users are created or updated through the auth system (registration, admin, OAuth) — that path bypasses the collection save pipeline. Use auth hooks (afterUserCreate, beforeUserCreate, afterUserDelete, …) for those side effects.`);
|
|
14743
|
+
}
|
|
14744
|
+
//#endregion
|
|
13953
14745
|
//#region ../client/dist/index.es.js
|
|
13954
14746
|
function rebaseReviver(_key, value) {
|
|
13955
14747
|
if (value && typeof value === "object" && "__type" in value) {
|
|
@@ -14001,6 +14793,28 @@ function buildQueryString(params) {
|
|
|
14001
14793
|
}
|
|
14002
14794
|
return parts.length > 0 ? "?" + parts.join("&") : "";
|
|
14003
14795
|
}
|
|
14796
|
+
/**
|
|
14797
|
+
* The base every request and every caller-built URL resolves against.
|
|
14798
|
+
*
|
|
14799
|
+
* `baseUrl` is optional because the common production shape is a Rebase
|
|
14800
|
+
* backend serving its own SPA, where the API is simply the page's origin.
|
|
14801
|
+
* Leaving it unset is therefore the *correct* configuration there — and the
|
|
14802
|
+
* one that keeps working when a second hostname (a custom domain) points at
|
|
14803
|
+
* the same app.
|
|
14804
|
+
*
|
|
14805
|
+
* When unset in a browser this resolves to the page origin rather than "".
|
|
14806
|
+
* Requests behave identically either way, but the empty string is a trap for
|
|
14807
|
+
* anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
|
|
14808
|
+
* throws, so apps "fixed" it by baking an absolute host into their bundle —
|
|
14809
|
+
* which is exactly what breaks the day a custom domain is added, and which no
|
|
14810
|
+
* amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
|
|
14811
|
+
* not sent cross-site either.
|
|
14812
|
+
*/
|
|
14813
|
+
function resolveBaseUrl(configured) {
|
|
14814
|
+
if (configured) return configured.replace(/\/$/, "");
|
|
14815
|
+
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
|
14816
|
+
return "";
|
|
14817
|
+
}
|
|
14004
14818
|
function createTransport(config) {
|
|
14005
14819
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
14006
14820
|
const apiPath = config.apiPath || "/api";
|
|
@@ -14015,7 +14829,7 @@ function createTransport(config) {
|
|
|
14015
14829
|
};
|
|
14016
14830
|
}
|
|
14017
14831
|
async function request(path, init) {
|
|
14018
|
-
const url = (config.baseUrl
|
|
14832
|
+
const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
|
|
14019
14833
|
let activeToken = token;
|
|
14020
14834
|
if (tokenGetter) try {
|
|
14021
14835
|
const fetched = await tokenGetter();
|
|
@@ -14090,7 +14904,7 @@ function createTransport(config) {
|
|
|
14090
14904
|
onUnauthorizedHandler = handler;
|
|
14091
14905
|
},
|
|
14092
14906
|
get baseUrl() {
|
|
14093
|
-
return config.baseUrl
|
|
14907
|
+
return resolveBaseUrl(config.baseUrl);
|
|
14094
14908
|
},
|
|
14095
14909
|
get apiPath() {
|
|
14096
14910
|
return apiPath;
|
|
@@ -14550,6 +15364,30 @@ function createAuth(transport, options) {
|
|
|
14550
15364
|
})
|
|
14551
15365
|
});
|
|
14552
15366
|
}
|
|
15367
|
+
/**
|
|
15368
|
+
* Link an OAuth provider to the **currently signed-in** account.
|
|
15369
|
+
*
|
|
15370
|
+
* Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
|
|
15371
|
+
* with that email already exists under a different sign-in method — or to
|
|
15372
|
+
* attach a provider whose email differs from the account's.
|
|
15373
|
+
*
|
|
15374
|
+
* The payload is the same one the provider's sign-in method takes, e.g.
|
|
15375
|
+
* `linkProvider("google", { idToken })`.
|
|
15376
|
+
*
|
|
15377
|
+
* Unlike sign-in, this does not require the provider to have verified the
|
|
15378
|
+
* email, and the emails need not match: the active session already proves
|
|
15379
|
+
* account ownership.
|
|
15380
|
+
*
|
|
15381
|
+
* Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
|
|
15382
|
+
* attached to a different user. Succeeds idempotently (`alreadyLinked:
|
|
15383
|
+
* true`) if it is already attached to the current one.
|
|
15384
|
+
*/
|
|
15385
|
+
async function linkProvider(providerId, payload) {
|
|
15386
|
+
return transport.request(authPath + "/link/" + providerId, {
|
|
15387
|
+
method: "POST",
|
|
15388
|
+
body: JSON.stringify(payload)
|
|
15389
|
+
});
|
|
15390
|
+
}
|
|
14553
15391
|
async function sendVerificationEmail() {
|
|
14554
15392
|
return transport.request(authPath + "/send-verification", { method: "POST" });
|
|
14555
15393
|
}
|
|
@@ -14671,6 +15509,7 @@ function createAuth(transport, options) {
|
|
|
14671
15509
|
resetPasswordForEmail,
|
|
14672
15510
|
resetPassword,
|
|
14673
15511
|
changePassword,
|
|
15512
|
+
linkProvider,
|
|
14674
15513
|
sendVerificationEmail,
|
|
14675
15514
|
verifyEmail,
|
|
14676
15515
|
sendMagicLink,
|
|
@@ -14933,7 +15772,7 @@ var SDKQueryBuilder = class {
|
|
|
14933
15772
|
* Listen to realtime updates matching this query.
|
|
14934
15773
|
*/
|
|
14935
15774
|
listen(onUpdate, onError) {
|
|
14936
|
-
if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
|
|
15775
|
+
if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.");
|
|
14937
15776
|
return this.collection.listen(this.params, onUpdate, onError);
|
|
14938
15777
|
}
|
|
14939
15778
|
};
|
|
@@ -14966,6 +15805,17 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
14966
15805
|
body: JSON.stringify(body)
|
|
14967
15806
|
});
|
|
14968
15807
|
},
|
|
15808
|
+
async createMany(data, options) {
|
|
15809
|
+
if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
|
|
15810
|
+
if (data.length === 0) return [];
|
|
15811
|
+
return (await transport.request(`${basePath}/bulk`, {
|
|
15812
|
+
method: "POST",
|
|
15813
|
+
body: JSON.stringify({
|
|
15814
|
+
rows: data,
|
|
15815
|
+
...options?.upsert ? { upsert: true } : {}
|
|
15816
|
+
})
|
|
15817
|
+
})).data || [];
|
|
15818
|
+
},
|
|
14969
15819
|
async update(id, data) {
|
|
14970
15820
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
14971
15821
|
method: "PUT",
|
|
@@ -15274,12 +16124,29 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
|
|
|
15274
16124
|
function extractMessageError(message) {
|
|
15275
16125
|
const payload = message.payload;
|
|
15276
16126
|
const errPayload = payload?.error;
|
|
16127
|
+
const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
|
|
16128
|
+
const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
|
|
15277
16129
|
return {
|
|
15278
|
-
errorMessage: typeof
|
|
15279
|
-
errorCode
|
|
16130
|
+
errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
|
|
16131
|
+
errorCode
|
|
15280
16132
|
};
|
|
15281
16133
|
}
|
|
15282
16134
|
/**
|
|
16135
|
+
* Broadcast and presence frames.
|
|
16136
|
+
*
|
|
16137
|
+
* Fire-and-forget (the server sends no response envelope), and exempt from the
|
|
16138
|
+
* client-side auth gate — a public channel is usable without an account.
|
|
16139
|
+
*/
|
|
16140
|
+
var CHANNEL_MESSAGE_TYPES = new Set([
|
|
16141
|
+
"join_channel",
|
|
16142
|
+
"leave_channel",
|
|
16143
|
+
"broadcast",
|
|
16144
|
+
"presence_track",
|
|
16145
|
+
"presence_untrack",
|
|
16146
|
+
"presence_state",
|
|
16147
|
+
"channel_history"
|
|
16148
|
+
]);
|
|
16149
|
+
/**
|
|
15283
16150
|
* Low-level realtime WebSocket client.
|
|
15284
16151
|
*
|
|
15285
16152
|
* @internal Not a stable app-facing API. `createRebaseClient()` constructs and
|
|
@@ -15294,6 +16161,36 @@ var RebaseWebSocketClient = class {
|
|
|
15294
16161
|
getAuthToken;
|
|
15295
16162
|
subscriptions = /* @__PURE__ */ new Map();
|
|
15296
16163
|
listeners = /* @__PURE__ */ new Map();
|
|
16164
|
+
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
16165
|
+
channelHandlers = /* @__PURE__ */ new Map();
|
|
16166
|
+
/** Set by `close()`. Blocks any later operation from silently redialling. */
|
|
16167
|
+
closedByCaller = false;
|
|
16168
|
+
/**
|
|
16169
|
+
* Whether a socket exists at all (open or still opening).
|
|
16170
|
+
*
|
|
16171
|
+
* Lets callers distinguish "authenticate the live socket" from "there is
|
|
16172
|
+
* nothing to authenticate yet", without that question forcing a dial.
|
|
16173
|
+
*/
|
|
16174
|
+
get hasSocket() {
|
|
16175
|
+
return this.ws !== null;
|
|
16176
|
+
}
|
|
16177
|
+
/** So the "no WebSocket in this environment" warning is said once, not per call. */
|
|
16178
|
+
warnedNoWebSocket = false;
|
|
16179
|
+
/** Subscribe to broadcast/presence frames for one channel. */
|
|
16180
|
+
onChannelMessage(channel, handler) {
|
|
16181
|
+
if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
|
|
16182
|
+
this.channelHandlers.get(channel).add(handler);
|
|
16183
|
+
return () => {
|
|
16184
|
+
const handlers = this.channelHandlers.get(channel);
|
|
16185
|
+
if (!handlers) return;
|
|
16186
|
+
handlers.delete(handler);
|
|
16187
|
+
if (handlers.size === 0) this.channelHandlers.delete(channel);
|
|
16188
|
+
};
|
|
16189
|
+
}
|
|
16190
|
+
/** Notified after the socket comes back, so channels can re-join. */
|
|
16191
|
+
onReconnect(handler) {
|
|
16192
|
+
return this.on("reconnect", handler);
|
|
16193
|
+
}
|
|
15297
16194
|
on(event, cb) {
|
|
15298
16195
|
if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
|
|
15299
16196
|
this.listeners.get(event).add(cb);
|
|
@@ -15312,6 +16209,7 @@ var RebaseWebSocketClient = class {
|
|
|
15312
16209
|
isConnected = false;
|
|
15313
16210
|
messageQueue = [];
|
|
15314
16211
|
requestTimeoutMs = 3e4;
|
|
16212
|
+
subscriptionTimeoutMs = 3e4;
|
|
15315
16213
|
reconnectTimeout = null;
|
|
15316
16214
|
isAuthenticated = false;
|
|
15317
16215
|
authPromise = null;
|
|
@@ -15323,8 +16221,25 @@ var RebaseWebSocketClient = class {
|
|
|
15323
16221
|
this.getAuthToken = config.getAuthToken;
|
|
15324
16222
|
this.onUnauthorized = config.onUnauthorized;
|
|
15325
16223
|
this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
|
|
15326
|
-
|
|
15327
|
-
|
|
16224
|
+
}
|
|
16225
|
+
/**
|
|
16226
|
+
* Open the socket if it is not open (or opening) already.
|
|
16227
|
+
*
|
|
16228
|
+
* Idempotent, synchronous, and safe to call on every operation that needs a
|
|
16229
|
+
* live socket — `initWebSocket` already no-ops on an open socket and is
|
|
16230
|
+
* re-entrant, since the reconnect path has always called it.
|
|
16231
|
+
*/
|
|
16232
|
+
ensureConnected() {
|
|
16233
|
+
if (this.closedByCaller) return;
|
|
16234
|
+
if (!this.WebSocketConstructor) {
|
|
16235
|
+
if (!this.warnedNoWebSocket) {
|
|
16236
|
+
this.warnedNoWebSocket = true;
|
|
16237
|
+
console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
|
|
16238
|
+
}
|
|
16239
|
+
return;
|
|
16240
|
+
}
|
|
16241
|
+
if (this.ws || this.reconnectTimeout) return;
|
|
16242
|
+
this.initWebSocket();
|
|
15328
16243
|
}
|
|
15329
16244
|
/**
|
|
15330
16245
|
* Authenticate the WebSocket connection
|
|
@@ -15374,7 +16289,16 @@ var RebaseWebSocketClient = class {
|
|
|
15374
16289
|
});
|
|
15375
16290
|
}
|
|
15376
16291
|
}
|
|
15377
|
-
|
|
16292
|
+
/**
|
|
16293
|
+
* Drop the socket.
|
|
16294
|
+
*
|
|
16295
|
+
* `permanent` distinguishes the two callers. Signing out drops the socket
|
|
16296
|
+
* but the client stays usable — a later subscribe should reconnect
|
|
16297
|
+
* anonymously. `client.close()` is the caller saying they are done, and
|
|
16298
|
+
* must not be undone by a stray queued frame.
|
|
16299
|
+
*/
|
|
16300
|
+
disconnect(permanent = false) {
|
|
16301
|
+
if (permanent) this.closedByCaller = true;
|
|
15378
16302
|
this.isAuthenticated = false;
|
|
15379
16303
|
this.authPromise = null;
|
|
15380
16304
|
if (this.reconnectTimeout) {
|
|
@@ -15417,6 +16341,7 @@ var RebaseWebSocketClient = class {
|
|
|
15417
16341
|
this.emit(wasReconnect ? "reconnect" : "connect");
|
|
15418
16342
|
this.processMessageQueue();
|
|
15419
16343
|
if (wasReconnect) this.resubscribeAll();
|
|
16344
|
+
this.armPendingSubscribeWatchdogs();
|
|
15420
16345
|
};
|
|
15421
16346
|
this.ws.onmessage = (event) => {
|
|
15422
16347
|
try {
|
|
@@ -15431,6 +16356,7 @@ var RebaseWebSocketClient = class {
|
|
|
15431
16356
|
this.isConnected = false;
|
|
15432
16357
|
this.isAuthenticated = false;
|
|
15433
16358
|
this.authPromise = null;
|
|
16359
|
+
this.suspendSubscribeWatchdogs();
|
|
15434
16360
|
this.emit("disconnect");
|
|
15435
16361
|
for (const [reqId, request] of this.pendingRequests.entries()) {
|
|
15436
16362
|
if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
|
|
@@ -15462,6 +16388,7 @@ var RebaseWebSocketClient = class {
|
|
|
15462
16388
|
attemptReconnect() {
|
|
15463
16389
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
15464
16390
|
console.error("Max reconnection attempts reached");
|
|
16391
|
+
this.failAllPendingSubscriptions(new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" }));
|
|
15465
16392
|
return;
|
|
15466
16393
|
}
|
|
15467
16394
|
this.reconnectAttempts++;
|
|
@@ -15516,29 +16443,18 @@ var RebaseWebSocketClient = class {
|
|
|
15516
16443
|
subscription.backendSubscriptionId = newBackendId;
|
|
15517
16444
|
backendKeyMap.delete(oldBackendId);
|
|
15518
16445
|
backendKeyMap.set(newBackendId, subscriptionKey);
|
|
15519
|
-
this.
|
|
15520
|
-
|
|
15521
|
-
|
|
15522
|
-
...subscription.props,
|
|
15523
|
-
subscriptionId: newBackendId
|
|
15524
|
-
}
|
|
15525
|
-
}).catch((error) => {
|
|
15526
|
-
console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
|
|
15527
|
-
subscription.callbacks.forEach((callback) => {
|
|
15528
|
-
if (callback.onError) callback.onError(error);
|
|
15529
|
-
});
|
|
15530
|
-
});
|
|
15531
|
-
} else {
|
|
15532
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
15533
|
-
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
15534
|
-
subscription.callbacks.forEach((callback) => {
|
|
15535
|
-
if (callback.onError) callback.onError(error);
|
|
15536
|
-
});
|
|
16446
|
+
if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
|
|
16447
|
+
else this.sendEntitySubscribe(subscriptionKey);
|
|
16448
|
+
return;
|
|
15537
16449
|
}
|
|
16450
|
+
const { errorMessage, errorCode } = extractMessageError(message);
|
|
16451
|
+
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
16452
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
16453
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
15538
16454
|
}).catch((err) => {
|
|
15539
|
-
|
|
15540
|
-
|
|
15541
|
-
|
|
16455
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
16456
|
+
if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
|
|
16457
|
+
else this.failEntitySubscription(subscriptionKey, error);
|
|
15542
16458
|
});
|
|
15543
16459
|
}
|
|
15544
16460
|
handleWebSocketMessage(message) {
|
|
@@ -15567,16 +16483,30 @@ var RebaseWebSocketClient = class {
|
|
|
15567
16483
|
}
|
|
15568
16484
|
return;
|
|
15569
16485
|
}
|
|
16486
|
+
if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
|
|
16487
|
+
const handlers = this.channelHandlers.get(message.channel);
|
|
16488
|
+
if (handlers) for (const handler of [...handlers]) try {
|
|
16489
|
+
handler(message);
|
|
16490
|
+
} catch (error) {
|
|
16491
|
+
console.error("Error in channel handler:", error);
|
|
16492
|
+
}
|
|
16493
|
+
return;
|
|
16494
|
+
}
|
|
15570
16495
|
if (subscriptionId && type === "collection_update") {
|
|
15571
16496
|
const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
|
|
15572
16497
|
if (subscriptionKey) {
|
|
15573
16498
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
15574
16499
|
if (collectionSub) {
|
|
15575
16500
|
const incomingRows = message.rows || [];
|
|
15576
|
-
const
|
|
16501
|
+
const updatePks = message.pks;
|
|
16502
|
+
if (updatePks) collectionSub.pks = updatePks;
|
|
16503
|
+
const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
|
|
15577
16504
|
collectionSub.latestData = rows;
|
|
15578
16505
|
collectionSub.lastUpdated = Date.now();
|
|
15579
16506
|
collectionSub.isInitialDataReceived = true;
|
|
16507
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
16508
|
+
collectionSub.subscribeTimeout = void 0;
|
|
16509
|
+
collectionSub.subscribeInFlight = false;
|
|
15580
16510
|
collectionSub.callbacks.forEach((callback) => {
|
|
15581
16511
|
try {
|
|
15582
16512
|
callback.onUpdate(rows);
|
|
@@ -15595,12 +16525,14 @@ var RebaseWebSocketClient = class {
|
|
|
15595
16525
|
const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
|
|
15596
16526
|
if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
|
|
15597
16527
|
const patchWireEntity = message.row ?? null;
|
|
15598
|
-
const
|
|
16528
|
+
const patchMessage = message;
|
|
16529
|
+
const patchEntityId = patchMessage.id;
|
|
16530
|
+
if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
|
|
15599
16531
|
const patchRow = patchWireEntity ? patchWireEntity : null;
|
|
15600
16532
|
let updated;
|
|
15601
|
-
if (patchRow === null) updated = collectionSub.latestData.filter((e) =>
|
|
16533
|
+
if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
|
|
15602
16534
|
else {
|
|
15603
|
-
const idx = collectionSub.latestData.findIndex((e) =>
|
|
16535
|
+
const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
|
|
15604
16536
|
if (idx >= 0) {
|
|
15605
16537
|
updated = [...collectionSub.latestData];
|
|
15606
16538
|
updated[idx] = patchRow;
|
|
@@ -15630,6 +16562,9 @@ var RebaseWebSocketClient = class {
|
|
|
15630
16562
|
entitySub.latestData = row;
|
|
15631
16563
|
entitySub.lastUpdated = Date.now();
|
|
15632
16564
|
entitySub.isInitialDataReceived = true;
|
|
16565
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
16566
|
+
entitySub.subscribeTimeout = void 0;
|
|
16567
|
+
entitySub.subscribeInFlight = false;
|
|
15633
16568
|
entitySub.callbacks.forEach((callback) => {
|
|
15634
16569
|
try {
|
|
15635
16570
|
callback.onUpdate(row);
|
|
@@ -15651,6 +16586,9 @@ var RebaseWebSocketClient = class {
|
|
|
15651
16586
|
this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
|
|
15652
16587
|
return;
|
|
15653
16588
|
}
|
|
16589
|
+
if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
|
|
16590
|
+
collectionSub.subscribeTimeout = void 0;
|
|
16591
|
+
collectionSub.subscribeInFlight = false;
|
|
15654
16592
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
15655
16593
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
15656
16594
|
collectionSub.callbacks.forEach((callback) => {
|
|
@@ -15667,6 +16605,9 @@ var RebaseWebSocketClient = class {
|
|
|
15667
16605
|
this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
|
|
15668
16606
|
return;
|
|
15669
16607
|
}
|
|
16608
|
+
if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
|
|
16609
|
+
entitySub.subscribeTimeout = void 0;
|
|
16610
|
+
entitySub.subscribeInFlight = false;
|
|
15670
16611
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
15671
16612
|
const error = new RebaseApiError(errorMessage, { code: errorCode });
|
|
15672
16613
|
entitySub.callbacks.forEach((callback) => {
|
|
@@ -15739,21 +16680,28 @@ var RebaseWebSocketClient = class {
|
|
|
15739
16680
|
throw error;
|
|
15740
16681
|
}
|
|
15741
16682
|
}
|
|
16683
|
+
/**
|
|
16684
|
+
* Public because `RebaseRealtimeChannel` sends channel frames through it.
|
|
16685
|
+
* Not part of the stable surface — prefer `client.realtime.channel(name)`.
|
|
16686
|
+
*/
|
|
15742
16687
|
sendMessage(message) {
|
|
15743
16688
|
const queuedMsg = message;
|
|
15744
16689
|
if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
|
|
15745
|
-
if (!this.isConnected || !this.ws)
|
|
15746
|
-
|
|
15747
|
-
|
|
15748
|
-
|
|
15749
|
-
|
|
15750
|
-
|
|
16690
|
+
if (!this.isConnected || !this.ws) {
|
|
16691
|
+
this.ensureConnected();
|
|
16692
|
+
return new Promise((resolve, reject) => {
|
|
16693
|
+
const queueable = message;
|
|
16694
|
+
queueable._queuedResolve = resolve;
|
|
16695
|
+
queueable._queuedReject = reject;
|
|
16696
|
+
this.messageQueue.push(message);
|
|
16697
|
+
});
|
|
16698
|
+
}
|
|
15751
16699
|
return new Promise((resolve, reject) => {
|
|
15752
16700
|
this.doSendMessage(message, resolve, reject);
|
|
15753
16701
|
});
|
|
15754
16702
|
}
|
|
15755
16703
|
async doSendMessage(message, resolve, reject) {
|
|
15756
|
-
if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
|
|
16704
|
+
if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
|
|
15757
16705
|
await this.ensureAuthenticated();
|
|
15758
16706
|
} catch (error) {
|
|
15759
16707
|
reject(new RebaseApiError(error instanceof Error ? error.message : "Authentication required"));
|
|
@@ -15761,17 +16709,7 @@ var RebaseWebSocketClient = class {
|
|
|
15761
16709
|
}
|
|
15762
16710
|
const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
15763
16711
|
message.requestId = requestId;
|
|
15764
|
-
const expectsResponse = !
|
|
15765
|
-
"subscribe_collection",
|
|
15766
|
-
"subscribe_one",
|
|
15767
|
-
"unsubscribe",
|
|
15768
|
-
"join_channel",
|
|
15769
|
-
"leave_channel",
|
|
15770
|
-
"broadcast",
|
|
15771
|
-
"presence_track",
|
|
15772
|
-
"presence_untrack",
|
|
15773
|
-
"presence_state"
|
|
15774
|
-
].includes(message.type);
|
|
16712
|
+
const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
|
|
15775
16713
|
if (expectsResponse && !this.pendingRequests.has(requestId)) {
|
|
15776
16714
|
const timeoutHandle = setTimeout(() => {
|
|
15777
16715
|
if (this.pendingRequests.has(requestId)) {
|
|
@@ -15841,6 +16779,9 @@ var RebaseWebSocketClient = class {
|
|
|
15841
16779
|
async fetchAvailableRoles() {
|
|
15842
16780
|
return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
|
|
15843
16781
|
}
|
|
16782
|
+
async fetchApplicationRoles() {
|
|
16783
|
+
return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
|
|
16784
|
+
}
|
|
15844
16785
|
async fetchCurrentDatabase() {
|
|
15845
16786
|
return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
|
|
15846
16787
|
}
|
|
@@ -15950,17 +16891,39 @@ var RebaseWebSocketClient = class {
|
|
|
15950
16891
|
return val;
|
|
15951
16892
|
}
|
|
15952
16893
|
/**
|
|
16894
|
+
* The address of a row, for matching it against another copy of itself.
|
|
16895
|
+
*
|
|
16896
|
+
* A row is exactly its columns and carries no address, so it is derived
|
|
16897
|
+
* from the key columns the server named — including the ordinary case where
|
|
16898
|
+
* that key is `id`, which the server reports like any other.
|
|
16899
|
+
*
|
|
16900
|
+
* Undefined when there are no keys, which means the server could not
|
|
16901
|
+
* resolve any: such rows genuinely cannot be recognised, and guessing at a
|
|
16902
|
+
* column called `id` would be inventing an identity for a table that has
|
|
16903
|
+
* none.
|
|
16904
|
+
*/
|
|
16905
|
+
rowAddress(row, pks) {
|
|
16906
|
+
if (!pks || pks.length === 0) return void 0;
|
|
16907
|
+
const address = buildCompositeId(row, pks);
|
|
16908
|
+
if (!address || address.split(":::").every((part) => part === "")) return void 0;
|
|
16909
|
+
return address;
|
|
16910
|
+
}
|
|
16911
|
+
/**
|
|
15953
16912
|
* Merge incoming rows with cached data, preserving cached references
|
|
15954
16913
|
* for rows whose values haven't changed. This avoids unnecessary
|
|
15955
16914
|
* React re-renders when the server refetches all rows but most
|
|
15956
16915
|
* haven't actually changed.
|
|
15957
16916
|
*/
|
|
15958
|
-
mergeRows(cached, incoming) {
|
|
16917
|
+
mergeRows(cached, incoming, pks) {
|
|
15959
16918
|
if (!cached || cached.length === 0) return incoming;
|
|
15960
16919
|
const cachedById = /* @__PURE__ */ new Map();
|
|
15961
|
-
for (const row of cached)
|
|
16920
|
+
for (const row of cached) {
|
|
16921
|
+
const address = this.rowAddress(row, pks);
|
|
16922
|
+
if (address !== void 0) cachedById.set(address, row);
|
|
16923
|
+
}
|
|
15962
16924
|
return incoming.map((incomingRow) => {
|
|
15963
|
-
const
|
|
16925
|
+
const address = this.rowAddress(incomingRow, pks);
|
|
16926
|
+
const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
|
|
15964
16927
|
if (!cachedRow) return incomingRow;
|
|
15965
16928
|
const normCached = this.normalizeForComparison(cachedRow);
|
|
15966
16929
|
const normIncoming = this.normalizeForComparison(incomingRow);
|
|
@@ -15972,12 +16935,13 @@ var RebaseWebSocketClient = class {
|
|
|
15972
16935
|
cached: normCached[key],
|
|
15973
16936
|
incoming: normIncoming[key]
|
|
15974
16937
|
};
|
|
15975
|
-
console.debug(`[RebaseWS] Row ${
|
|
16938
|
+
console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
|
|
15976
16939
|
}
|
|
15977
16940
|
return incomingRow;
|
|
15978
16941
|
});
|
|
15979
16942
|
}
|
|
15980
16943
|
listenCollection(props, onUpdate, onError) {
|
|
16944
|
+
this.ensureConnected();
|
|
15981
16945
|
const subscriptionKey = this.createCollectionSubscriptionKey(props);
|
|
15982
16946
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
15983
16947
|
const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
@@ -15993,9 +16957,12 @@ var RebaseWebSocketClient = class {
|
|
|
15993
16957
|
console.error("Error in collection subscription callback:", error);
|
|
15994
16958
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
15995
16959
|
}
|
|
16960
|
+
else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
|
|
15996
16961
|
return () => {
|
|
15997
16962
|
callbackMap.delete(callbackId);
|
|
15998
16963
|
if (callbackMap.size === 0) {
|
|
16964
|
+
if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
16965
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
15999
16966
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
16000
16967
|
this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
|
|
16001
16968
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -16017,21 +16984,14 @@ var RebaseWebSocketClient = class {
|
|
|
16017
16984
|
props
|
|
16018
16985
|
});
|
|
16019
16986
|
this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
|
|
16020
|
-
this.
|
|
16021
|
-
type: "subscribe_collection",
|
|
16022
|
-
payload: {
|
|
16023
|
-
...props,
|
|
16024
|
-
subscriptionId: backendSubscriptionId
|
|
16025
|
-
}
|
|
16026
|
-
}).catch((error) => {
|
|
16027
|
-
if (onError) onError(error);
|
|
16028
|
-
});
|
|
16987
|
+
this.sendCollectionSubscribe(subscriptionKey);
|
|
16029
16988
|
return () => {
|
|
16030
16989
|
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
16031
16990
|
if (subscription) {
|
|
16032
16991
|
const callbacks = subscription.callbacks;
|
|
16033
16992
|
callbacks.delete(callbackId);
|
|
16034
16993
|
if (callbacks.size === 0) {
|
|
16994
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
16035
16995
|
this.collectionSubscriptions.delete(subscriptionKey);
|
|
16036
16996
|
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
16037
16997
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -16043,6 +17003,7 @@ var RebaseWebSocketClient = class {
|
|
|
16043
17003
|
};
|
|
16044
17004
|
}
|
|
16045
17005
|
listenOne(props, onUpdate, onError) {
|
|
17006
|
+
this.ensureConnected();
|
|
16046
17007
|
const subscriptionKey = this.createSingleSubscriptionKey(props);
|
|
16047
17008
|
const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
16048
17009
|
const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
|
|
@@ -16058,9 +17019,12 @@ var RebaseWebSocketClient = class {
|
|
|
16058
17019
|
console.error("Error in row subscription callback:", error);
|
|
16059
17020
|
if (onError) onError(error instanceof Error ? error : new Error(String(error)));
|
|
16060
17021
|
}
|
|
17022
|
+
else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
|
|
16061
17023
|
return () => {
|
|
16062
17024
|
callbackMap.delete(callbackId);
|
|
16063
17025
|
if (callbackMap.size === 0) {
|
|
17026
|
+
if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
|
|
17027
|
+
if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
|
|
16064
17028
|
this.singleSubscriptions.delete(subscriptionKey);
|
|
16065
17029
|
this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
|
|
16066
17030
|
if (this.isConnected && this.ws) this.sendMessage({
|
|
@@ -16082,15 +17046,7 @@ var RebaseWebSocketClient = class {
|
|
|
16082
17046
|
props
|
|
16083
17047
|
});
|
|
16084
17048
|
this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
|
|
16085
|
-
this.
|
|
16086
|
-
type: "subscribe_one",
|
|
16087
|
-
payload: {
|
|
16088
|
-
...props,
|
|
16089
|
-
subscriptionId: backendSubscriptionId
|
|
16090
|
-
}
|
|
16091
|
-
}).catch((error) => {
|
|
16092
|
-
if (onError) onError(error);
|
|
16093
|
-
});
|
|
17049
|
+
this.sendEntitySubscribe(subscriptionKey);
|
|
16094
17050
|
return () => {
|
|
16095
17051
|
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
16096
17052
|
if (subscription) {
|
|
@@ -16108,6 +17064,157 @@ var RebaseWebSocketClient = class {
|
|
|
16108
17064
|
};
|
|
16109
17065
|
}
|
|
16110
17066
|
/**
|
|
17067
|
+
* Send a `subscribe_collection` for an already-registered subscription and
|
|
17068
|
+
* arm its watchdog.
|
|
17069
|
+
*
|
|
17070
|
+
* Every path that registers a collection subscription goes through here, so
|
|
17071
|
+
* that a subscribe which never lands — a rejected send, or a server that
|
|
17072
|
+
* never answers — always ends up in `failCollectionSubscription` rather than
|
|
17073
|
+
* leaving the entry parked with `isInitialDataReceived === false` forever.
|
|
17074
|
+
*/
|
|
17075
|
+
sendCollectionSubscribe(subscriptionKey) {
|
|
17076
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
17077
|
+
if (!subscription) return;
|
|
17078
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
17079
|
+
subscription.subscribeInFlight = true;
|
|
17080
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
17081
|
+
subscription.subscribeTimeout = void 0;
|
|
17082
|
+
if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
|
|
17083
|
+
this.sendMessage({
|
|
17084
|
+
type: "subscribe_collection",
|
|
17085
|
+
payload: {
|
|
17086
|
+
...subscription.props,
|
|
17087
|
+
subscriptionId: backendSubscriptionId
|
|
17088
|
+
}
|
|
17089
|
+
}).catch((error) => {
|
|
17090
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
17091
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
17092
|
+
this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
17093
|
+
});
|
|
17094
|
+
}
|
|
17095
|
+
/** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
|
|
17096
|
+
sendEntitySubscribe(subscriptionKey) {
|
|
17097
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
17098
|
+
if (!subscription) return;
|
|
17099
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
17100
|
+
subscription.subscribeInFlight = true;
|
|
17101
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
17102
|
+
subscription.subscribeTimeout = void 0;
|
|
17103
|
+
if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
|
|
17104
|
+
this.sendMessage({
|
|
17105
|
+
type: "subscribe_one",
|
|
17106
|
+
payload: {
|
|
17107
|
+
...subscription.props,
|
|
17108
|
+
subscriptionId: backendSubscriptionId
|
|
17109
|
+
}
|
|
17110
|
+
}).catch((error) => {
|
|
17111
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
17112
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
17113
|
+
this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
|
|
17114
|
+
});
|
|
17115
|
+
}
|
|
17116
|
+
/**
|
|
17117
|
+
* Report a subscribe failure to every listener and drop the registration.
|
|
17118
|
+
*
|
|
17119
|
+
* Dropping it is the point: the callbacks stay live (their components are
|
|
17120
|
+
* still mounted and have been told), but the next `listenCollection` for
|
|
17121
|
+
* these params finds no entry and issues a fresh subscribe instead of
|
|
17122
|
+
* silently attaching to a dead one.
|
|
17123
|
+
*/
|
|
17124
|
+
failCollectionSubscription(subscriptionKey, error) {
|
|
17125
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
17126
|
+
if (!subscription) return;
|
|
17127
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
17128
|
+
subscription.subscribeInFlight = false;
|
|
17129
|
+
this.collectionSubscriptions.delete(subscriptionKey);
|
|
17130
|
+
this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
|
|
17131
|
+
subscription.callbacks.forEach((callback) => {
|
|
17132
|
+
if (callback.onError) try {
|
|
17133
|
+
callback.onError(error);
|
|
17134
|
+
} catch (callbackError) {
|
|
17135
|
+
console.error("Error in collection subscription error callback:", callbackError);
|
|
17136
|
+
}
|
|
17137
|
+
});
|
|
17138
|
+
}
|
|
17139
|
+
/** The `listenOne` counterpart of {@link failCollectionSubscription}. */
|
|
17140
|
+
failEntitySubscription(subscriptionKey, error) {
|
|
17141
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
17142
|
+
if (!subscription) return;
|
|
17143
|
+
if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
|
|
17144
|
+
subscription.subscribeInFlight = false;
|
|
17145
|
+
this.singleSubscriptions.delete(subscriptionKey);
|
|
17146
|
+
this.backendToEntityKey.delete(subscription.backendSubscriptionId);
|
|
17147
|
+
subscription.callbacks.forEach((callback) => {
|
|
17148
|
+
if (callback.onError) try {
|
|
17149
|
+
callback.onError(error);
|
|
17150
|
+
} catch (callbackError) {
|
|
17151
|
+
console.error("Error in row subscription error callback:", callbackError);
|
|
17152
|
+
}
|
|
17153
|
+
});
|
|
17154
|
+
}
|
|
17155
|
+
/**
|
|
17156
|
+
* Stop the watchdogs without failing anything — used when the socket drops,
|
|
17157
|
+
* since the reconnect path re-subscribes everything anyway and a watchdog
|
|
17158
|
+
* firing mid-reconnect would tear down healthy subscriptions.
|
|
17159
|
+
*/
|
|
17160
|
+
suspendSubscribeWatchdogs() {
|
|
17161
|
+
for (const sub of this.collectionSubscriptions.values()) {
|
|
17162
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
17163
|
+
sub.subscribeTimeout = void 0;
|
|
17164
|
+
sub.subscribeInFlight = false;
|
|
17165
|
+
}
|
|
17166
|
+
for (const sub of this.singleSubscriptions.values()) {
|
|
17167
|
+
if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
|
|
17168
|
+
sub.subscribeTimeout = void 0;
|
|
17169
|
+
sub.subscribeInFlight = false;
|
|
17170
|
+
}
|
|
17171
|
+
}
|
|
17172
|
+
/**
|
|
17173
|
+
* Arm watchdogs for subscribes that were requested while offline and have
|
|
17174
|
+
* just been flushed to the socket. Their timers were deliberately not set at
|
|
17175
|
+
* request time, so without this they would have no timeout at all.
|
|
17176
|
+
*/
|
|
17177
|
+
armPendingSubscribeWatchdogs() {
|
|
17178
|
+
for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
|
|
17179
|
+
for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
|
|
17180
|
+
}
|
|
17181
|
+
sendCollectionSubscribeWatchdog(subscriptionKey) {
|
|
17182
|
+
const subscription = this.collectionSubscriptions.get(subscriptionKey);
|
|
17183
|
+
if (!subscription) return;
|
|
17184
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
17185
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
17186
|
+
const current = this.collectionSubscriptions.get(subscriptionKey);
|
|
17187
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
17188
|
+
if (!current.subscribeInFlight) return;
|
|
17189
|
+
this.failCollectionSubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
17190
|
+
}, this.subscriptionTimeoutMs);
|
|
17191
|
+
}
|
|
17192
|
+
sendEntitySubscribeWatchdog(subscriptionKey) {
|
|
17193
|
+
const subscription = this.singleSubscriptions.get(subscriptionKey);
|
|
17194
|
+
if (!subscription) return;
|
|
17195
|
+
const backendSubscriptionId = subscription.backendSubscriptionId;
|
|
17196
|
+
subscription.subscribeTimeout = setTimeout(() => {
|
|
17197
|
+
const current = this.singleSubscriptions.get(subscriptionKey);
|
|
17198
|
+
if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
|
|
17199
|
+
if (!current.subscribeInFlight) return;
|
|
17200
|
+
this.failEntitySubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
|
|
17201
|
+
}, this.subscriptionTimeoutMs);
|
|
17202
|
+
}
|
|
17203
|
+
/**
|
|
17204
|
+
* Fail every subscription that never received data. Called when reconnection
|
|
17205
|
+
* is given up on, so views surface an error instead of spinning forever.
|
|
17206
|
+
*/
|
|
17207
|
+
failAllPendingSubscriptions(error) {
|
|
17208
|
+
for (const key of [...this.collectionSubscriptions.keys()]) {
|
|
17209
|
+
const sub = this.collectionSubscriptions.get(key);
|
|
17210
|
+
if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
|
|
17211
|
+
}
|
|
17212
|
+
for (const key of [...this.singleSubscriptions.keys()]) {
|
|
17213
|
+
const sub = this.singleSubscriptions.get(key);
|
|
17214
|
+
if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
|
|
17215
|
+
}
|
|
17216
|
+
}
|
|
17217
|
+
/**
|
|
16111
17218
|
* Re-send all active subscriptions to the backend after a reconnect.
|
|
16112
17219
|
* The server wipes subscription state when a client disconnects, so
|
|
16113
17220
|
* we need to re-register everything to resume receiving updates.
|
|
@@ -16120,15 +17227,7 @@ var RebaseWebSocketClient = class {
|
|
|
16120
17227
|
sub.backendSubscriptionId = newBackendId;
|
|
16121
17228
|
this.backendToCollectionKey.delete(oldBackendId);
|
|
16122
17229
|
this.backendToCollectionKey.set(newBackendId, key);
|
|
16123
|
-
this.
|
|
16124
|
-
type: "subscribe_collection",
|
|
16125
|
-
payload: {
|
|
16126
|
-
...sub.props,
|
|
16127
|
-
subscriptionId: newBackendId
|
|
16128
|
-
}
|
|
16129
|
-
}).catch((error) => {
|
|
16130
|
-
console.error("[WS] Failed to re-subscribe collection:", key, error);
|
|
16131
|
-
});
|
|
17230
|
+
this.sendCollectionSubscribe(key);
|
|
16132
17231
|
}
|
|
16133
17232
|
for (const [key, sub] of this.singleSubscriptions.entries()) {
|
|
16134
17233
|
const oldBackendId = sub.backendSubscriptionId;
|
|
@@ -16136,15 +17235,7 @@ var RebaseWebSocketClient = class {
|
|
|
16136
17235
|
sub.backendSubscriptionId = newBackendId;
|
|
16137
17236
|
this.backendToEntityKey.delete(oldBackendId);
|
|
16138
17237
|
this.backendToEntityKey.set(newBackendId, key);
|
|
16139
|
-
this.
|
|
16140
|
-
type: "subscribe_one",
|
|
16141
|
-
payload: {
|
|
16142
|
-
...sub.props,
|
|
16143
|
-
subscriptionId: newBackendId
|
|
16144
|
-
}
|
|
16145
|
-
}).catch((error) => {
|
|
16146
|
-
console.error("[WS] Failed to re-subscribe row:", key, error);
|
|
16147
|
-
});
|
|
17238
|
+
this.sendEntitySubscribe(key);
|
|
16148
17239
|
}
|
|
16149
17240
|
}
|
|
16150
17241
|
createCollectionSubscriptionKey(props) {
|
|
@@ -16171,6 +17262,377 @@ var RebaseWebSocketClient = class {
|
|
|
16171
17262
|
}
|
|
16172
17263
|
};
|
|
16173
17264
|
/**
|
|
17265
|
+
* Re-send presence comfortably inside the server's 30s expiry.
|
|
17266
|
+
*
|
|
17267
|
+
* Two-thirds of the window: one lost heartbeat still leaves time for the next
|
|
17268
|
+
* before the entry is reaped, so a single dropped frame is not a disappearance.
|
|
17269
|
+
*/
|
|
17270
|
+
var PRESENCE_HEARTBEAT_MS = 2e4;
|
|
17271
|
+
/**
|
|
17272
|
+
* How long live messages are held back waiting for a catch-up response.
|
|
17273
|
+
*
|
|
17274
|
+
* Short, because the cost of waiting is visible — on a collaborative document
|
|
17275
|
+
* this is a stall in everyone else's edits appearing. Long enough that a slow
|
|
17276
|
+
* replay of a busy channel is not abandoned needlessly.
|
|
17277
|
+
*/
|
|
17278
|
+
var CATCH_UP_TIMEOUT_MS = 1e4;
|
|
17279
|
+
var RebaseRealtimeChannel = class {
|
|
17280
|
+
name;
|
|
17281
|
+
transport;
|
|
17282
|
+
presenceHandlers = /* @__PURE__ */ new Set();
|
|
17283
|
+
broadcastHandlers = /* @__PURE__ */ new Set();
|
|
17284
|
+
unsubscribers = [];
|
|
17285
|
+
/** Last known roster, kept so handlers always get a full picture. */
|
|
17286
|
+
presences = {};
|
|
17287
|
+
/** What this client last tracked, replayed on reconnect and heartbeat. */
|
|
17288
|
+
trackedState = null;
|
|
17289
|
+
heartbeat = null;
|
|
17290
|
+
joined = false;
|
|
17291
|
+
/** Whether this handle asks the server to replay missed messages. */
|
|
17292
|
+
wantsHistory;
|
|
17293
|
+
/**
|
|
17294
|
+
* Highest sequence number delivered to handlers so far.
|
|
17295
|
+
*
|
|
17296
|
+
* This is the resume point sent as `sinceSeq`, and the watermark that makes
|
|
17297
|
+
* replay idempotent: catch-up ranges overlap with what arrived live, and
|
|
17298
|
+
* anything at or below this has already been seen.
|
|
17299
|
+
*/
|
|
17300
|
+
lastSeq = 0;
|
|
17301
|
+
/**
|
|
17302
|
+
* Live messages that arrived while a catch-up was in flight.
|
|
17303
|
+
*
|
|
17304
|
+
* Without this they would be delivered ahead of the older messages being
|
|
17305
|
+
* fetched, and — worse — would advance {@link lastSeq} past them, so the
|
|
17306
|
+
* catch-up response would then be discarded as already-seen and those
|
|
17307
|
+
* messages would be lost for good. Held here and flushed, in order, once
|
|
17308
|
+
* the replay lands.
|
|
17309
|
+
*/
|
|
17310
|
+
pendingLive = [];
|
|
17311
|
+
catchUpInFlight = false;
|
|
17312
|
+
/**
|
|
17313
|
+
* Deadline for a catch-up response.
|
|
17314
|
+
*
|
|
17315
|
+
* Buffering live messages is only safe because the wait is bounded. A
|
|
17316
|
+
* catch-up frame that never arrives — a server that dropped it, a socket
|
|
17317
|
+
* that died between request and reply — would otherwise leave the channel
|
|
17318
|
+
* silently holding every subsequent edit forever, which is a worse failure
|
|
17319
|
+
* than the one replay was added to fix.
|
|
17320
|
+
*/
|
|
17321
|
+
catchUpTimeout = null;
|
|
17322
|
+
/**
|
|
17323
|
+
* Callers of {@link history} awaiting the next `channel_history` frame.
|
|
17324
|
+
*
|
|
17325
|
+
* These frames are addressed by channel rather than by request id, so they
|
|
17326
|
+
* are matched in arrival order. Requests on one channel are serialized by
|
|
17327
|
+
* the socket, so FIFO is the right correlation here.
|
|
17328
|
+
*/
|
|
17329
|
+
historyWaiters = [];
|
|
17330
|
+
constructor(name, transport, options = {}) {
|
|
17331
|
+
this.name = name;
|
|
17332
|
+
this.transport = transport;
|
|
17333
|
+
this.wantsHistory = options.history ?? false;
|
|
17334
|
+
}
|
|
17335
|
+
/**
|
|
17336
|
+
* Turn on catch-up for a handle that was created without it.
|
|
17337
|
+
*
|
|
17338
|
+
* The client hands back the same channel object for a given name, so a
|
|
17339
|
+
* later `channel(name, { history: true })` has no new object to configure —
|
|
17340
|
+
* it upgrades this one instead. Idempotent, and never downgrades: one
|
|
17341
|
+
* caller asking for history must not be switched off by another that did
|
|
17342
|
+
* not ask.
|
|
17343
|
+
*/
|
|
17344
|
+
enableHistory() {
|
|
17345
|
+
if (this.wantsHistory) return;
|
|
17346
|
+
this.wantsHistory = true;
|
|
17347
|
+
if (this.joined) this.requestHistory();
|
|
17348
|
+
}
|
|
17349
|
+
/**
|
|
17350
|
+
* Join the channel and ask for the current roster.
|
|
17351
|
+
*
|
|
17352
|
+
* Called automatically by `track`, `broadcast`, `onPresence` and
|
|
17353
|
+
* `onBroadcast`; calling it directly is only needed to start receiving
|
|
17354
|
+
* before there is anything to send.
|
|
17355
|
+
*/
|
|
17356
|
+
/**
|
|
17357
|
+
* Send a channel message.
|
|
17358
|
+
*
|
|
17359
|
+
* Every channel message is read by the server out of a `payload` envelope
|
|
17360
|
+
* (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
|
|
17361
|
+
* fields flat does not error: `payload?.channel` simply reads as
|
|
17362
|
+
* `undefined`, so the client is registered into channel `undefined` with
|
|
17363
|
+
* empty state, and the echo comes back with no `channel` for
|
|
17364
|
+
* `onChannelMessage` to match — presence and broadcast both go quiet with
|
|
17365
|
+
* nothing logged. Funnelled through one place so a new message type cannot
|
|
17366
|
+
* reintroduce that.
|
|
17367
|
+
*/
|
|
17368
|
+
send(type, fields = {}) {
|
|
17369
|
+
return this.transport.sendMessage({
|
|
17370
|
+
type,
|
|
17371
|
+
payload: {
|
|
17372
|
+
channel: this.name,
|
|
17373
|
+
...fields
|
|
17374
|
+
}
|
|
17375
|
+
});
|
|
17376
|
+
}
|
|
17377
|
+
async join() {
|
|
17378
|
+
if (this.joined) return;
|
|
17379
|
+
this.joined = true;
|
|
17380
|
+
this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
|
|
17381
|
+
this.unsubscribers.push(this.transport.onReconnect(() => {
|
|
17382
|
+
this.rejoin();
|
|
17383
|
+
}));
|
|
17384
|
+
await this.send("join_channel");
|
|
17385
|
+
await this.send("presence_state");
|
|
17386
|
+
if (this.wantsHistory) await this.requestHistory();
|
|
17387
|
+
}
|
|
17388
|
+
async rejoin() {
|
|
17389
|
+
try {
|
|
17390
|
+
await this.send("join_channel");
|
|
17391
|
+
await this.send("presence_state");
|
|
17392
|
+
if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
|
|
17393
|
+
if (this.wantsHistory) await this.requestHistory();
|
|
17394
|
+
} catch {}
|
|
17395
|
+
}
|
|
17396
|
+
/**
|
|
17397
|
+
* Ask the server for everything after {@link lastSeq}.
|
|
17398
|
+
*
|
|
17399
|
+
* Live messages are buffered from here until the answer arrives — see
|
|
17400
|
+
* {@link pendingLive}.
|
|
17401
|
+
*/
|
|
17402
|
+
async requestHistory(limit) {
|
|
17403
|
+
this.catchUpInFlight = true;
|
|
17404
|
+
if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
|
|
17405
|
+
this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
|
|
17406
|
+
this.catchUpTimeout.unref?.();
|
|
17407
|
+
try {
|
|
17408
|
+
await this.send("channel_history", {
|
|
17409
|
+
sinceSeq: this.lastSeq,
|
|
17410
|
+
...limit !== void 0 ? { limit } : {}
|
|
17411
|
+
});
|
|
17412
|
+
} catch {
|
|
17413
|
+
this.abandonCatchUp();
|
|
17414
|
+
}
|
|
17415
|
+
}
|
|
17416
|
+
/**
|
|
17417
|
+
* Give up waiting for a catch-up and release what was held back.
|
|
17418
|
+
*
|
|
17419
|
+
* The buffered messages are still the freshest thing this client has, so
|
|
17420
|
+
* they are delivered rather than dropped. Callers of {@link history} are
|
|
17421
|
+
* answered with `retained: false` — accurate in the sense that matters:
|
|
17422
|
+
* this client has no history to work from and has to resync.
|
|
17423
|
+
*/
|
|
17424
|
+
abandonCatchUp() {
|
|
17425
|
+
if (this.catchUpTimeout) {
|
|
17426
|
+
clearTimeout(this.catchUpTimeout);
|
|
17427
|
+
this.catchUpTimeout = null;
|
|
17428
|
+
}
|
|
17429
|
+
if (!this.catchUpInFlight) return;
|
|
17430
|
+
this.catchUpInFlight = false;
|
|
17431
|
+
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
17432
|
+
messages: [],
|
|
17433
|
+
retained: false
|
|
17434
|
+
});
|
|
17435
|
+
this.flushPendingLive();
|
|
17436
|
+
}
|
|
17437
|
+
/**
|
|
17438
|
+
* Publish this client's presence state, and keep publishing it.
|
|
17439
|
+
*
|
|
17440
|
+
* Calling `track` again replaces the state (and restarts the heartbeat),
|
|
17441
|
+
* which is how you update e.g. a cursor position.
|
|
17442
|
+
*/
|
|
17443
|
+
async track(state) {
|
|
17444
|
+
await this.join();
|
|
17445
|
+
this.trackedState = state;
|
|
17446
|
+
await this.send("presence_track", { state });
|
|
17447
|
+
if (!this.heartbeat) {
|
|
17448
|
+
this.heartbeat = setInterval(() => {
|
|
17449
|
+
if (!this.trackedState) return;
|
|
17450
|
+
this.send("presence_track", { state: this.trackedState }).catch(() => {});
|
|
17451
|
+
}, PRESENCE_HEARTBEAT_MS);
|
|
17452
|
+
this.heartbeat.unref?.();
|
|
17453
|
+
}
|
|
17454
|
+
}
|
|
17455
|
+
/** Stop publishing presence, without leaving the channel. */
|
|
17456
|
+
async untrack() {
|
|
17457
|
+
this.stopHeartbeat();
|
|
17458
|
+
this.trackedState = null;
|
|
17459
|
+
if (this.joined) await this.send("presence_untrack");
|
|
17460
|
+
}
|
|
17461
|
+
/**
|
|
17462
|
+
* Observe the roster. The handler fires immediately with what is already
|
|
17463
|
+
* known, then on every change.
|
|
17464
|
+
*/
|
|
17465
|
+
onPresence(handler) {
|
|
17466
|
+
this.presenceHandlers.add(handler);
|
|
17467
|
+
this.join();
|
|
17468
|
+
if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
|
|
17469
|
+
return () => this.presenceHandlers.delete(handler);
|
|
17470
|
+
}
|
|
17471
|
+
/** Send a broadcast. The sender does not receive its own message. */
|
|
17472
|
+
async broadcast(event, payload) {
|
|
17473
|
+
await this.join();
|
|
17474
|
+
await this.send("broadcast", {
|
|
17475
|
+
event,
|
|
17476
|
+
payload
|
|
17477
|
+
});
|
|
17478
|
+
}
|
|
17479
|
+
onBroadcast(eventOrHandler, maybeHandler) {
|
|
17480
|
+
const wrapped = typeof eventOrHandler === "string" ? (e) => {
|
|
17481
|
+
if (e.event === eventOrHandler) maybeHandler(e.payload);
|
|
17482
|
+
} : eventOrHandler;
|
|
17483
|
+
this.broadcastHandlers.add(wrapped);
|
|
17484
|
+
this.join();
|
|
17485
|
+
return () => this.broadcastHandlers.delete(wrapped);
|
|
17486
|
+
}
|
|
17487
|
+
/**
|
|
17488
|
+
* The last sequence number this channel has delivered.
|
|
17489
|
+
*
|
|
17490
|
+
* Zero on a channel that retains nothing. Persist it if you want catch-up
|
|
17491
|
+
* to survive a page reload as well as a reconnect, and pass it back via
|
|
17492
|
+
* {@link history}.
|
|
17493
|
+
*/
|
|
17494
|
+
get sequence() {
|
|
17495
|
+
return this.lastSeq;
|
|
17496
|
+
}
|
|
17497
|
+
/**
|
|
17498
|
+
* Fetch retained messages explicitly, instead of waiting for join or
|
|
17499
|
+
* reconnect to do it.
|
|
17500
|
+
*
|
|
17501
|
+
* Defaults to resuming from {@link sequence}. Messages are delivered to
|
|
17502
|
+
* `onBroadcast` handlers as usual — the returned value is for callers that
|
|
17503
|
+
* want to inspect the batch, or to learn from `retained` that the channel
|
|
17504
|
+
* keeps no history at all.
|
|
17505
|
+
*/
|
|
17506
|
+
async history(options = {}) {
|
|
17507
|
+
await this.join();
|
|
17508
|
+
if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
|
|
17509
|
+
const result = new Promise((resolve) => {
|
|
17510
|
+
this.historyWaiters.push(resolve);
|
|
17511
|
+
});
|
|
17512
|
+
await this.requestHistory(options.limit);
|
|
17513
|
+
return result;
|
|
17514
|
+
}
|
|
17515
|
+
/** Leave the channel and release every listener and timer. */
|
|
17516
|
+
async leave() {
|
|
17517
|
+
this.stopHeartbeat();
|
|
17518
|
+
this.trackedState = null;
|
|
17519
|
+
this.presences = {};
|
|
17520
|
+
this.presenceHandlers.clear();
|
|
17521
|
+
this.broadcastHandlers.clear();
|
|
17522
|
+
this.lastSeq = 0;
|
|
17523
|
+
this.pendingLive = [];
|
|
17524
|
+
this.catchUpInFlight = false;
|
|
17525
|
+
if (this.catchUpTimeout) {
|
|
17526
|
+
clearTimeout(this.catchUpTimeout);
|
|
17527
|
+
this.catchUpTimeout = null;
|
|
17528
|
+
}
|
|
17529
|
+
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
17530
|
+
messages: [],
|
|
17531
|
+
retained: false
|
|
17532
|
+
});
|
|
17533
|
+
for (const off of this.unsubscribers) off();
|
|
17534
|
+
this.unsubscribers = [];
|
|
17535
|
+
if (this.joined) {
|
|
17536
|
+
this.joined = false;
|
|
17537
|
+
await this.send("leave_channel");
|
|
17538
|
+
}
|
|
17539
|
+
}
|
|
17540
|
+
stopHeartbeat() {
|
|
17541
|
+
if (this.heartbeat) {
|
|
17542
|
+
clearInterval(this.heartbeat);
|
|
17543
|
+
this.heartbeat = null;
|
|
17544
|
+
}
|
|
17545
|
+
}
|
|
17546
|
+
/** Fold an incoming frame into the roster and fan it out. */
|
|
17547
|
+
handle(message) {
|
|
17548
|
+
switch (message.type) {
|
|
17549
|
+
case "presence_state":
|
|
17550
|
+
this.presences = message.presences ?? {};
|
|
17551
|
+
this.emitPresence();
|
|
17552
|
+
break;
|
|
17553
|
+
case "presence_diff": {
|
|
17554
|
+
const joins = message.joins ?? {};
|
|
17555
|
+
const leaves = message.leaves ?? {};
|
|
17556
|
+
for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
|
|
17557
|
+
for (const id of Object.keys(leaves)) delete this.presences[id];
|
|
17558
|
+
this.emitPresence({
|
|
17559
|
+
joins,
|
|
17560
|
+
leaves
|
|
17561
|
+
});
|
|
17562
|
+
break;
|
|
17563
|
+
}
|
|
17564
|
+
case "broadcast": {
|
|
17565
|
+
const seq = typeof message.seq === "number" ? message.seq : void 0;
|
|
17566
|
+
const event = {
|
|
17567
|
+
event: message.event,
|
|
17568
|
+
payload: message.payload,
|
|
17569
|
+
...seq !== void 0 ? { seq } : {}
|
|
17570
|
+
};
|
|
17571
|
+
if (seq === void 0) {
|
|
17572
|
+
this.deliver(event);
|
|
17573
|
+
break;
|
|
17574
|
+
}
|
|
17575
|
+
if (this.catchUpInFlight) {
|
|
17576
|
+
this.pendingLive.push(event);
|
|
17577
|
+
break;
|
|
17578
|
+
}
|
|
17579
|
+
if (seq <= this.lastSeq) break;
|
|
17580
|
+
this.lastSeq = seq;
|
|
17581
|
+
this.deliver(event);
|
|
17582
|
+
break;
|
|
17583
|
+
}
|
|
17584
|
+
case "channel_history": {
|
|
17585
|
+
this.catchUpInFlight = false;
|
|
17586
|
+
if (this.catchUpTimeout) {
|
|
17587
|
+
clearTimeout(this.catchUpTimeout);
|
|
17588
|
+
this.catchUpTimeout = null;
|
|
17589
|
+
}
|
|
17590
|
+
const entries = message.messages ?? [];
|
|
17591
|
+
const retained = message.retained === true;
|
|
17592
|
+
const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
|
|
17593
|
+
for (const resolve of this.historyWaiters.splice(0)) resolve({
|
|
17594
|
+
messages: entries,
|
|
17595
|
+
retained,
|
|
17596
|
+
latestSeq
|
|
17597
|
+
});
|
|
17598
|
+
for (const entry of entries) {
|
|
17599
|
+
if (entry.seq <= this.lastSeq) continue;
|
|
17600
|
+
this.lastSeq = entry.seq;
|
|
17601
|
+
this.deliver({
|
|
17602
|
+
event: entry.event,
|
|
17603
|
+
payload: entry.payload,
|
|
17604
|
+
seq: entry.seq,
|
|
17605
|
+
replayed: true
|
|
17606
|
+
});
|
|
17607
|
+
}
|
|
17608
|
+
this.flushPendingLive();
|
|
17609
|
+
break;
|
|
17610
|
+
}
|
|
17611
|
+
}
|
|
17612
|
+
}
|
|
17613
|
+
/** Deliver everything held back during a catch-up, in sequence order. */
|
|
17614
|
+
flushPendingLive() {
|
|
17615
|
+
if (this.pendingLive.length === 0) return;
|
|
17616
|
+
const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
|
|
17617
|
+
this.pendingLive = [];
|
|
17618
|
+
for (const event of buffered) {
|
|
17619
|
+
const seq = event.seq;
|
|
17620
|
+
if (seq !== void 0) {
|
|
17621
|
+
if (seq <= this.lastSeq) continue;
|
|
17622
|
+
this.lastSeq = seq;
|
|
17623
|
+
}
|
|
17624
|
+
this.deliver(event);
|
|
17625
|
+
}
|
|
17626
|
+
}
|
|
17627
|
+
deliver(event) {
|
|
17628
|
+
for (const handler of [...this.broadcastHandlers]) handler(event);
|
|
17629
|
+
}
|
|
17630
|
+
emitPresence(diff) {
|
|
17631
|
+
const snapshot = { ...this.presences };
|
|
17632
|
+
for (const handler of this.presenceHandlers) handler(snapshot, diff);
|
|
17633
|
+
}
|
|
17634
|
+
};
|
|
17635
|
+
/**
|
|
16174
17636
|
* Derive a WebSocket URL from an HTTP base URL.
|
|
16175
17637
|
* `http://` → `ws://`, `https://` → `wss://`.
|
|
16176
17638
|
*/
|
|
@@ -16217,8 +17679,10 @@ function createRebaseClient(options) {
|
|
|
16217
17679
|
});
|
|
16218
17680
|
return storageSourcesPromise;
|
|
16219
17681
|
};
|
|
16220
|
-
const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
|
|
17682
|
+
const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
|
|
16221
17683
|
let ws;
|
|
17684
|
+
/** One channel object per name — see `realtime.channel`. */
|
|
17685
|
+
const realtimeChannels = /* @__PURE__ */ new Map();
|
|
16222
17686
|
if (resolvedWsUrl) {
|
|
16223
17687
|
ws = new RebaseWebSocketClient({
|
|
16224
17688
|
websocketUrl: resolvedWsUrl,
|
|
@@ -16242,7 +17706,7 @@ function createRebaseClient(options) {
|
|
|
16242
17706
|
if (!ws) return;
|
|
16243
17707
|
if (event === "SIGNED_OUT") ws.disconnect();
|
|
16244
17708
|
else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
|
16245
|
-
if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
|
|
17709
|
+
if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
|
|
16246
17710
|
}
|
|
16247
17711
|
});
|
|
16248
17712
|
}
|
|
@@ -16329,6 +17793,36 @@ function createRebaseClient(options) {
|
|
|
16329
17793
|
createStorageSource,
|
|
16330
17794
|
fetchStorageSources,
|
|
16331
17795
|
ws,
|
|
17796
|
+
realtime: {
|
|
17797
|
+
/**
|
|
17798
|
+
* Join a broadcast/presence channel.
|
|
17799
|
+
*
|
|
17800
|
+
* Repeated calls with the same name return the same channel, so
|
|
17801
|
+
* separate components can attach handlers without each opening its
|
|
17802
|
+
* own membership — and `leave()` from one would otherwise silently
|
|
17803
|
+
* cut off the others.
|
|
17804
|
+
*/
|
|
17805
|
+
channel: (name, options) => {
|
|
17806
|
+
if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
|
|
17807
|
+
let existing = realtimeChannels.get(name);
|
|
17808
|
+
if (!existing) {
|
|
17809
|
+
existing = new RebaseRealtimeChannel(name, ws, options);
|
|
17810
|
+
realtimeChannels.set(name, existing);
|
|
17811
|
+
} else if (options?.history) existing.enableHistory();
|
|
17812
|
+
return existing;
|
|
17813
|
+
} },
|
|
17814
|
+
/**
|
|
17815
|
+
* Release the realtime socket and its reconnect timer.
|
|
17816
|
+
*
|
|
17817
|
+
* Until this returns, the open socket keeps the Node event loop alive
|
|
17818
|
+
* and the process will not exit on its own. Safe to call when realtime
|
|
17819
|
+
* was never started, and safe to call twice.
|
|
17820
|
+
*/
|
|
17821
|
+
close: () => {
|
|
17822
|
+
for (const channel of realtimeChannels.values()) channel.leave();
|
|
17823
|
+
realtimeChannels.clear();
|
|
17824
|
+
ws?.disconnect(true);
|
|
17825
|
+
},
|
|
16332
17826
|
setToken: transport.setToken,
|
|
16333
17827
|
setAuthTokenGetter: transport.setAuthTokenGetter,
|
|
16334
17828
|
setOnUnauthorized: transport.setOnUnauthorized,
|
|
@@ -16763,16 +18257,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
16763
18257
|
const authDs = resolveDataSource(safeAuthConfig.collection, dataSourceRegistry);
|
|
16764
18258
|
if (authDs.key !== "(default)") logger.warn(`[Auth] The auth collection "${safeAuthConfig.collection.slug}" is on data source "${authDs.key}", but the built-in auth system always uses the default data source. Move the auth collection to the default data source, or replace auth with an AuthAdapter that manages users in "${authDs.key}".`);
|
|
16765
18259
|
}
|
|
16766
|
-
|
|
16767
|
-
const cb = safeAuthConfig.collection.callbacks;
|
|
16768
|
-
const dataCallbacks = [
|
|
16769
|
-
"beforeSave",
|
|
16770
|
-
"afterSave",
|
|
16771
|
-
"beforeDelete",
|
|
16772
|
-
"afterDelete"
|
|
16773
|
-
].filter((k) => typeof cb[k] === "function");
|
|
16774
|
-
if (dataCallbacks.length > 0) logger.warn(`[Auth] The auth collection "${safeAuthConfig.collection.slug}" defines ${dataCallbacks.join("/")} callback(s), but these do NOT fire when users are created or updated through the auth system (registration, admin, OAuth) — that path bypasses the collection save pipeline. Use auth hooks (afterUserCreate, beforeUserCreate, afterUserDelete, …) for those side effects.`);
|
|
16775
|
-
}
|
|
18260
|
+
warnOnAuthCollectionDataCallbacks(safeAuthConfig.collection);
|
|
16776
18261
|
safeAuthConfig.collection && safeAuthConfig.collection.auth;
|
|
16777
18262
|
if (safeAuthConfig.jwtSecret) configureJwt({
|
|
16778
18263
|
secret: safeAuthConfig.jwtSecret,
|
|
@@ -16800,6 +18285,33 @@ async function _initializeRebaseBackend(config) {
|
|
|
16800
18285
|
}
|
|
16801
18286
|
logger.info("Entity history initialized");
|
|
16802
18287
|
} else logger.warn("History requested but default bootstrapper does not support initializeHistory");
|
|
18288
|
+
const internalServiceKey = serviceKey || randomBytes(48).toString("base64");
|
|
18289
|
+
if (!serviceKey) logger.info("No REBASE_SERVICE_KEY configured. Generated internal per-boot key for singleton control-plane APIs.");
|
|
18290
|
+
if (authAdapter && !authAdapter.serviceKey) authAdapter.serviceKey = internalServiceKey;
|
|
18291
|
+
let apiKeyStore;
|
|
18292
|
+
const apiKeyStoreResult = createApiKeyStore(defaultDriver);
|
|
18293
|
+
if (apiKeyStoreResult) {
|
|
18294
|
+
apiKeyStore = apiKeyStoreResult;
|
|
18295
|
+
await apiKeyStore.ensureTable();
|
|
18296
|
+
logger.info("Service API Keys initialized");
|
|
18297
|
+
}
|
|
18298
|
+
const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
|
|
18299
|
+
store: apiKeyStore,
|
|
18300
|
+
driver: defaultDriver
|
|
18301
|
+
}) : void 0;
|
|
18302
|
+
if (apiKeyPreAuth) config.app.use(`${basePath}/admin/*`, apiKeyPreAuth);
|
|
18303
|
+
if (apiKeyStore) {
|
|
18304
|
+
const apiKeyRoutes = createApiKeyRoutes({
|
|
18305
|
+
store: apiKeyStore,
|
|
18306
|
+
serviceKey: internalServiceKey
|
|
18307
|
+
});
|
|
18308
|
+
config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
|
|
18309
|
+
logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
|
|
18310
|
+
}
|
|
18311
|
+
const rateLimitConfig = config.rateLimit?.enabled !== false ? {
|
|
18312
|
+
...config.rateLimit,
|
|
18313
|
+
store: config.rateLimit?.store ?? new MemoryRateLimitStore(config.rateLimit?.windowMs ?? 900 * 1e3)
|
|
18314
|
+
} : void 0;
|
|
16803
18315
|
const { storageRegistry, storageController } = await initializeStorage(config.storage, isProduction);
|
|
16804
18316
|
if (config.auth) {
|
|
16805
18317
|
config.app.get(`${basePath}/auth/config`, async (c) => {
|
|
@@ -16892,7 +18404,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
16892
18404
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
16893
18405
|
defaultRole: safeAuthConfig.defaultRole,
|
|
16894
18406
|
oauthProviders,
|
|
16895
|
-
serviceKey,
|
|
18407
|
+
serviceKey: serviceKey || internalServiceKey,
|
|
16896
18408
|
authHooks: safeAuthConfig.hooks,
|
|
16897
18409
|
collectionAuthConfig,
|
|
16898
18410
|
enableMagicLink: safeAuthConfig.magicLink ?? false,
|
|
@@ -16917,33 +18429,31 @@ async function _initializeRebaseBackend(config) {
|
|
|
16917
18429
|
}
|
|
16918
18430
|
}
|
|
16919
18431
|
}
|
|
16920
|
-
const
|
|
16921
|
-
|
|
16922
|
-
|
|
16923
|
-
|
|
16924
|
-
|
|
16925
|
-
|
|
16926
|
-
|
|
16927
|
-
|
|
16928
|
-
|
|
16929
|
-
const apiKeyRoutes = createApiKeyRoutes({
|
|
16930
|
-
store: apiKeyStore,
|
|
16931
|
-
serviceKey: internalServiceKey
|
|
16932
|
-
});
|
|
16933
|
-
config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
|
|
16934
|
-
logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
|
|
16935
|
-
}
|
|
18432
|
+
const adminSurfacesGated = !!authAdapter && (isAuthAdapter(config.auth) || config.auth.requireAuth !== false && !!config.auth.jwtSecret);
|
|
18433
|
+
const applyAdminGate = (router, surface) => {
|
|
18434
|
+
if (!adminSurfacesGated) {
|
|
18435
|
+
logger.warn(`${surface} routes are mounted WITHOUT an auth gate (no auth configured, requireAuth: false, or no jwtSecret) — anyone who can reach the server can call them.`);
|
|
18436
|
+
return;
|
|
18437
|
+
}
|
|
18438
|
+
if (apiKeyPreAuth) router.use("/*", apiKeyPreAuth);
|
|
18439
|
+
router.use("/*", createRequireAuth({ serviceKey: internalServiceKey }), requireAdmin);
|
|
18440
|
+
};
|
|
16936
18441
|
const schemaEditorEnabled = config.schemaEditor ?? (!!config.collectionsDir && process.env.NODE_ENV !== "production" && mode === "cms");
|
|
16937
18442
|
if (schemaEditorEnabled && !config.collectionsDir) logger.warn("schemaEditor is enabled but no collectionsDir is set — the schema editor has nowhere to write. Skipping.");
|
|
16938
18443
|
if (schemaEditorEnabled && config.collectionsDir) {
|
|
16939
|
-
|
|
16940
|
-
|
|
16941
|
-
|
|
16942
|
-
|
|
16943
|
-
if (
|
|
16944
|
-
|
|
16945
|
-
|
|
16946
|
-
|
|
18444
|
+
let editorModule;
|
|
18445
|
+
try {
|
|
18446
|
+
editorModule = await import("./schema-editor-routes-D3ef8zu1.js");
|
|
18447
|
+
} catch (err) {
|
|
18448
|
+
if (err?.code === "ERR_MODULE_NOT_FOUND") logger.warn("Schema Editor disabled: its dependency ts-morph is not installed. Run `npm install ts-morph@28.0.0` to enable it.");
|
|
18449
|
+
else throw err;
|
|
18450
|
+
}
|
|
18451
|
+
if (editorModule) {
|
|
18452
|
+
const schemaEditorRoutes = editorModule.createSchemaEditorRoutes(config.collectionsDir);
|
|
18453
|
+
applyAdminGate(schemaEditorRoutes, "Schema editor");
|
|
18454
|
+
config.app.route(`${basePath}/schema-editor`, schemaEditorRoutes);
|
|
18455
|
+
logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
|
|
18456
|
+
}
|
|
16947
18457
|
}
|
|
16948
18458
|
if (storageController) {
|
|
16949
18459
|
const storageMaxSize = (config.storage && typeof config.storage === "object" && "type" in config.storage ? config.storage.maxFileSize : void 0) ?? 50 * 1024 * 1024;
|
|
@@ -16952,9 +18462,12 @@ async function _initializeRebaseBackend(config) {
|
|
|
16952
18462
|
registry: storageRegistry,
|
|
16953
18463
|
sources: config.storageSources,
|
|
16954
18464
|
requireAuth: resolveRequireAuth(config.auth),
|
|
16955
|
-
authAdapter
|
|
18465
|
+
authAdapter,
|
|
18466
|
+
authorize: config.storageAuthorize
|
|
16956
18467
|
});
|
|
16957
|
-
|
|
18468
|
+
const storageRouter = new Hono();
|
|
18469
|
+
if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
|
|
18470
|
+
storageRouter.use("/upload", bodyLimit({
|
|
16958
18471
|
maxSize: storageMaxSize,
|
|
16959
18472
|
onError: (c) => {
|
|
16960
18473
|
return c.json({ error: {
|
|
@@ -16963,7 +18476,8 @@ async function _initializeRebaseBackend(config) {
|
|
|
16963
18476
|
} }, 413);
|
|
16964
18477
|
}
|
|
16965
18478
|
}));
|
|
16966
|
-
|
|
18479
|
+
storageRouter.route("/", storageRoutes);
|
|
18480
|
+
config.app.route(`${basePath}/storage`, storageRouter);
|
|
16967
18481
|
}
|
|
16968
18482
|
if (activeCollections.length > 0) {
|
|
16969
18483
|
const dataRouter = new Hono();
|
|
@@ -16992,7 +18506,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
16992
18506
|
serviceKey: internalServiceKey,
|
|
16993
18507
|
apiKeyStore
|
|
16994
18508
|
}));
|
|
16995
|
-
if (
|
|
18509
|
+
if (rateLimitConfig) dataRouter.use("/*", createDataRateLimiter(rateLimitConfig));
|
|
16996
18510
|
if (historyConfigResult && historyConfigResult.historyService) {
|
|
16997
18511
|
const historyRoutes = createHistoryRoutes({
|
|
16998
18512
|
historyService: historyConfigResult.historyService,
|
|
@@ -17037,6 +18551,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
17037
18551
|
dataAsAdmin: serverData
|
|
17038
18552
|
});
|
|
17039
18553
|
logger.info("Native data plane attached to singleton (bypasses HTTP loop)");
|
|
18554
|
+
if (storageController) {
|
|
18555
|
+
Object.assign(serverClient, { storage: storageController });
|
|
18556
|
+
logger.info("Native storage attached to singleton (bypasses HTTP loop)");
|
|
18557
|
+
}
|
|
17040
18558
|
let emailService;
|
|
17041
18559
|
if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
|
|
17042
18560
|
else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
|
|
@@ -17081,6 +18599,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
17081
18599
|
serviceKey: internalServiceKey,
|
|
17082
18600
|
apiKeyStore
|
|
17083
18601
|
}));
|
|
18602
|
+
functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
|
|
18603
|
+
if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
|
|
18604
|
+
...rateLimitConfig,
|
|
18605
|
+
anonymous: null
|
|
18606
|
+
}));
|
|
17084
18607
|
const fnRoutes = createFunctionRoutes(loadedFunctions);
|
|
17085
18608
|
functionsRouter.route("/", fnRoutes);
|
|
17086
18609
|
config.app.route(`${basePath}/functions`, functionsRouter);
|
|
@@ -17107,10 +18630,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
17107
18630
|
cronScheduler.setStore(store);
|
|
17108
18631
|
}
|
|
17109
18632
|
const cronRouter = new Hono();
|
|
17110
|
-
|
|
17111
|
-
const safeAuth = config.auth;
|
|
17112
|
-
if (safeAuth.requireAuth !== false && !!safeAuth.jwtSecret) cronRouter.use("/*", requireAuth, requireAdmin);
|
|
17113
|
-
} else if (authAdapter) cronRouter.use("/*", requireAuth, requireAdmin);
|
|
18633
|
+
applyAdminGate(cronRouter, "Cron");
|
|
17114
18634
|
cronRouter.route("/", createCronRoutes(cronScheduler));
|
|
17115
18635
|
config.app.route(`${basePath}/cron`, cronRouter);
|
|
17116
18636
|
cronScheduler.start();
|
|
@@ -17123,10 +18643,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
17123
18643
|
{
|
|
17124
18644
|
const { createBackupRoutes, parseBackupDestination } = await Promise.resolve().then(() => backup_exports);
|
|
17125
18645
|
const backupRouter = new Hono();
|
|
17126
|
-
|
|
17127
|
-
const safeAuth = config.auth;
|
|
17128
|
-
if (safeAuth.requireAuth !== false && !!safeAuth.jwtSecret) backupRouter.use("/*", requireAuth, requireAdmin);
|
|
17129
|
-
} else if (authAdapter) backupRouter.use("/*", requireAuth, requireAdmin);
|
|
18646
|
+
applyAdminGate(backupRouter, "Backup");
|
|
17130
18647
|
backupRouter.route("/", createBackupRoutes({
|
|
17131
18648
|
getDestination: () => {
|
|
17132
18649
|
const out = process.env.BACKUP_DESTINATION?.trim();
|
|
@@ -17137,6 +18654,14 @@ async function _initializeRebaseBackend(config) {
|
|
|
17137
18654
|
config.app.route(`${basePath}/admin/backups`, backupRouter);
|
|
17138
18655
|
logger.info("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
|
|
17139
18656
|
}
|
|
18657
|
+
{
|
|
18658
|
+
const { default: logsRoutes } = await Promise.resolve().then(() => logs_routes_exports);
|
|
18659
|
+
const logsRouter = new Hono();
|
|
18660
|
+
applyAdminGate(logsRouter, "Logs");
|
|
18661
|
+
logsRouter.route("/", logsRoutes);
|
|
18662
|
+
config.app.route(`${basePath}/logs`, logsRouter);
|
|
18663
|
+
logger.info("Logs routes mounted", { path: `${basePath}/logs` });
|
|
18664
|
+
}
|
|
17140
18665
|
const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
|
|
17141
18666
|
providers: realtimeServices,
|
|
17142
18667
|
defaultKey: defaultDriverId,
|
|
@@ -17150,9 +18675,34 @@ async function _initializeRebaseBackend(config) {
|
|
|
17150
18675
|
cronScheduler,
|
|
17151
18676
|
realtimeServices
|
|
17152
18677
|
});
|
|
18678
|
+
/**
|
|
18679
|
+
* Every registry a driver might resolve callbacks from, plus the backend's
|
|
18680
|
+
* own. Each holds its own normalized copy of a collection, so callbacks have
|
|
18681
|
+
* to be written to all of them.
|
|
18682
|
+
*/
|
|
18683
|
+
const callbackTargets = () => {
|
|
18684
|
+
const targets = [collectionRegistry];
|
|
18685
|
+
for (const key of [DEFAULT_DRIVER_ID, ...driverRegistry.list()]) {
|
|
18686
|
+
const d = driverRegistry.get(key);
|
|
18687
|
+
if (d?.registry && typeof d.registry.get === "function" && !targets.includes(d.registry)) targets.push(d.registry);
|
|
18688
|
+
}
|
|
18689
|
+
return targets;
|
|
18690
|
+
};
|
|
18691
|
+
const setCollectionCallbacks = (slug, callbacks) => {
|
|
18692
|
+
let attached = 0;
|
|
18693
|
+
for (const registry of callbackTargets()) {
|
|
18694
|
+
const collection = registry.get(slug);
|
|
18695
|
+
if (collection) {
|
|
18696
|
+
collection.callbacks = callbacks;
|
|
18697
|
+
attached++;
|
|
18698
|
+
}
|
|
18699
|
+
}
|
|
18700
|
+
if (attached === 0) logger.warn(`[callbacks] Collection "${slug}" not found in any registry — callbacks not attached.`);
|
|
18701
|
+
};
|
|
17153
18702
|
return {
|
|
17154
18703
|
driverRegistry,
|
|
17155
18704
|
driver: defaultDriver,
|
|
18705
|
+
setCollectionCallbacks,
|
|
17156
18706
|
realtimeServices,
|
|
17157
18707
|
realtimeService: effectiveRealtimeService,
|
|
17158
18708
|
auth: authConfigResult,
|
|
@@ -17182,7 +18732,7 @@ async function loadFunctionsFromDirectory(directory, importModule = nativeDynami
|
|
|
17182
18732
|
const problems = [];
|
|
17183
18733
|
if (!fs$2.existsSync(directory)) return functions;
|
|
17184
18734
|
const files = fs$2.readdirSync(directory);
|
|
17185
|
-
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
18735
|
+
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
17186
18736
|
const filePath = path$1.join(directory, file);
|
|
17187
18737
|
try {
|
|
17188
18738
|
const fileUrl = pathToFileURL(filePath).href;
|
|
@@ -17311,7 +18861,7 @@ async function loadCronJobsFromDirectory(directory, importModule = nativeDynamic
|
|
|
17311
18861
|
const jobs = [];
|
|
17312
18862
|
if (!fs$2.existsSync(directory)) return jobs;
|
|
17313
18863
|
const files = fs$2.readdirSync(directory);
|
|
17314
|
-
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
18864
|
+
for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
|
|
17315
18865
|
const filePath = path$1.join(directory, file);
|
|
17316
18866
|
try {
|
|
17317
18867
|
const fileUrl = pathToFileURL(filePath).href;
|
|
@@ -17585,6 +19135,7 @@ var CronScheduler = class {
|
|
|
17585
19135
|
logger.warn("[cron] Failed to seed job stats from database", { error: err });
|
|
17586
19136
|
});
|
|
17587
19137
|
for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
|
|
19138
|
+
if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
|
|
17588
19139
|
logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
|
|
17589
19140
|
}
|
|
17590
19141
|
/**
|
|
@@ -17703,6 +19254,19 @@ var CronScheduler = class {
|
|
|
17703
19254
|
this.scheduleNext(id);
|
|
17704
19255
|
return;
|
|
17705
19256
|
}
|
|
19257
|
+
if (this.store?.tryClaimRun) {
|
|
19258
|
+
let claimed = true;
|
|
19259
|
+
try {
|
|
19260
|
+
claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
|
|
19261
|
+
} catch (err) {
|
|
19262
|
+
logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
|
|
19263
|
+
}
|
|
19264
|
+
if (!claimed) {
|
|
19265
|
+
logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
|
|
19266
|
+
if (this.started && job.enabled) this.scheduleNext(id);
|
|
19267
|
+
return;
|
|
19268
|
+
}
|
|
19269
|
+
}
|
|
17706
19270
|
await this.executeJob(job, false);
|
|
17707
19271
|
if (this.started && job.enabled) this.scheduleNext(id);
|
|
17708
19272
|
}, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
|
|
@@ -17871,6 +19435,25 @@ function createCronRoutes(scheduler) {
|
|
|
17871
19435
|
//#region src/cron/cron-store.ts
|
|
17872
19436
|
var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
|
|
17873
19437
|
var TABLE = "rebase.cron_logs";
|
|
19438
|
+
var CLAIMS_TABLE = "rebase.cron_claims";
|
|
19439
|
+
/** Claims older than this are garbage-collected on startup. */
|
|
19440
|
+
var CLAIM_RETENTION_DAYS = 7;
|
|
19441
|
+
/**
|
|
19442
|
+
* Detect a unique-constraint violation anywhere in an error's cause chain.
|
|
19443
|
+
* Drizzle wraps the PG error — match the SQLSTATE code, never message text.
|
|
19444
|
+
* Also covers SQLite ("UNIQUE constraint failed") and MySQL (ER_DUP_ENTRY 1062)
|
|
19445
|
+
* for future SQL drivers.
|
|
19446
|
+
*/
|
|
19447
|
+
function isUniqueViolation(err) {
|
|
19448
|
+
let current = err;
|
|
19449
|
+
for (let depth = 0; depth < 10 && current; depth++) if (typeof current === "object") {
|
|
19450
|
+
const e = current;
|
|
19451
|
+
if (e.code === "23505" || e.errno === 1062) return true;
|
|
19452
|
+
if (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) return true;
|
|
19453
|
+
current = e.cause;
|
|
19454
|
+
} else break;
|
|
19455
|
+
return false;
|
|
19456
|
+
}
|
|
17874
19457
|
function createCronStore(driver) {
|
|
17875
19458
|
const admin = driver.admin;
|
|
17876
19459
|
if (!isSQLAdmin(admin)) {
|
|
@@ -17900,6 +19483,15 @@ function createCronStore(driver) {
|
|
|
17900
19483
|
CREATE INDEX IF NOT EXISTS idx_cron_logs_job
|
|
17901
19484
|
ON ${TABLE}(job_id, started_at DESC)
|
|
17902
19485
|
`);
|
|
19486
|
+
await exec(`
|
|
19487
|
+
CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (
|
|
19488
|
+
job_id TEXT NOT NULL,
|
|
19489
|
+
slot TIMESTAMPTZ NOT NULL,
|
|
19490
|
+
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
19491
|
+
PRIMARY KEY (job_id, slot)
|
|
19492
|
+
)
|
|
19493
|
+
`);
|
|
19494
|
+
await exec(`DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`, { params: [CLAIM_RETENTION_DAYS] });
|
|
17903
19495
|
logger.info("✅ Cron logs table ready");
|
|
17904
19496
|
} catch (err) {
|
|
17905
19497
|
logger.error("❌ Failed to create cron logs table", { error: err });
|
|
@@ -17959,6 +19551,18 @@ function createCronStore(driver) {
|
|
|
17959
19551
|
logger.error("[cron-store] Failed to fetch job stats", { error: err });
|
|
17960
19552
|
}
|
|
17961
19553
|
return stats;
|
|
19554
|
+
},
|
|
19555
|
+
async tryClaimRun(jobId, slot) {
|
|
19556
|
+
try {
|
|
19557
|
+
return (await exec(`INSERT INTO ${CLAIMS_TABLE} (job_id, slot)
|
|
19558
|
+
VALUES ($1, $2)
|
|
19559
|
+
ON CONFLICT (job_id, slot) DO NOTHING
|
|
19560
|
+
RETURNING job_id`, { params: [jobId, slot] })).length > 0;
|
|
19561
|
+
} catch (err) {
|
|
19562
|
+
if (isUniqueViolation(err)) return false;
|
|
19563
|
+
logger.warn(`[cron-store] Claim check failed for "${jobId}" — running uncoordinated`, { error: err });
|
|
19564
|
+
return true;
|
|
19565
|
+
}
|
|
17962
19566
|
}
|
|
17963
19567
|
};
|
|
17964
19568
|
}
|
|
@@ -18138,10 +19742,10 @@ var backup_exports = /* @__PURE__ */ __exportAll({
|
|
|
18138
19742
|
/**
|
|
18139
19743
|
* Returns a SQL chunk calling `auth.uid()` — the current user's ID.
|
|
18140
19744
|
* This is a PostgreSQL RLS helper function created in the `auth` schema
|
|
18141
|
-
* that reads `app.
|
|
19745
|
+
* that reads `app.uid` set per-transaction by `withAuth()`.
|
|
18142
19746
|
*
|
|
18143
19747
|
* @example
|
|
18144
|
-
* sql`${table.
|
|
19748
|
+
* sql`${table.uid} = ${authUid()}`
|
|
18145
19749
|
*/
|
|
18146
19750
|
var authUid = () => {
|
|
18147
19751
|
return sql`auth.uid()`;
|
|
@@ -18247,14 +19851,21 @@ var rebaseEnvSchema = object({
|
|
|
18247
19851
|
DATABASE_DIRECT_URL: string().url().optional(),
|
|
18248
19852
|
DATABASE_READ_URL: string().url().optional(),
|
|
18249
19853
|
FORCE_LOCAL_STORAGE: optionalBoolString,
|
|
18250
|
-
STORAGE_TYPE: _enum([
|
|
19854
|
+
STORAGE_TYPE: _enum([
|
|
19855
|
+
"local",
|
|
19856
|
+
"s3",
|
|
19857
|
+
"gcs"
|
|
19858
|
+
]).default("local"),
|
|
18251
19859
|
STORAGE_PATH: string().optional(),
|
|
18252
19860
|
S3_BUCKET: string().optional(),
|
|
18253
19861
|
S3_REGION: string().optional(),
|
|
18254
19862
|
S3_ACCESS_KEY_ID: string().optional(),
|
|
18255
19863
|
S3_SECRET_ACCESS_KEY: string().optional(),
|
|
18256
19864
|
S3_ENDPOINT: string().url().optional(),
|
|
18257
|
-
S3_FORCE_PATH_STYLE: optionalBoolString
|
|
19865
|
+
S3_FORCE_PATH_STYLE: optionalBoolString,
|
|
19866
|
+
GCS_BUCKET: string().optional(),
|
|
19867
|
+
GCS_PROJECT_ID: string().optional(),
|
|
19868
|
+
GCS_KEY_FILENAME: string().optional()
|
|
18258
19869
|
});
|
|
18259
19870
|
function loadEnv(options) {
|
|
18260
19871
|
const isProduction = process.env.NODE_ENV === "production";
|
|
@@ -18415,7 +20026,11 @@ function writeStateFile(projectRoot, port, serviceKey) {
|
|
|
18415
20026
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
18416
20027
|
};
|
|
18417
20028
|
if (serviceKey) state.serviceKey = serviceKey;
|
|
18418
|
-
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2),
|
|
20029
|
+
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), {
|
|
20030
|
+
encoding: "utf-8",
|
|
20031
|
+
mode: 384
|
|
20032
|
+
});
|
|
20033
|
+
fs.chmodSync(stateFile, 384);
|
|
18419
20034
|
} catch {}
|
|
18420
20035
|
}
|
|
18421
20036
|
//#endregion
|
|
@@ -18436,7 +20051,11 @@ function serveSPA(app, config) {
|
|
|
18436
20051
|
logger.warn(" SPA serving is disabled. Build your frontend first.");
|
|
18437
20052
|
return;
|
|
18438
20053
|
}
|
|
18439
|
-
app.use("/*",
|
|
20054
|
+
app.use("/*", responseCompression());
|
|
20055
|
+
app.use("/*", serveStatic({
|
|
20056
|
+
root: path$1.relative(process.cwd(), frontendPath),
|
|
20057
|
+
precompressed: true
|
|
20058
|
+
}));
|
|
18440
20059
|
const allExcludePaths = [apiBasePath, ...excludePaths];
|
|
18441
20060
|
let cachedHtml = null;
|
|
18442
20061
|
app.get("*", async (c, next) => {
|