import { n as validateJsonFieldName } from "./validate-VPnKoIzW.mjs";
import { s as jsonExtractExpr } from "./dialect-helpers-DRI5pyY3.mjs";
import { t as ContentRepository } from "./content-DhgEth4k.mjs";
import { a as encodeCursor, i as decodeCursor } from "./types-CuW0zWDA.mjs";
import { t as MediaRepository } from "./media-BrXIaMFN.mjs";
import { t as UserRepository } from "./user-BjAOvU3Z.mjs";
import { t as OptionsRepository } from "./options-DTTML-Tx.mjs";
import { t as withTransaction } from "./transaction-qfqpPVpu.mjs";
import { t as enrichImageMetadata } from "./enrich-BQ0mxJRs.mjs";
import { t as SeoRepository } from "./seo-DXu2UQnC.mjs";
import { a as markContentMediaUsageCollectionStaleSafely } from "./content-refresh-CA4uKwzH.mjs";
import { o as invalidateSiteSettingsCache } from "./settings-zhmdKyJQ.mjs";
import { i as stripCredentialHeaders, n as resolveAndValidateExternalUrl, t as SsrfError } from "./ssrf-1g7usGBg.mjs";
import { t as CronAccessImpl } from "./cron-v4FTEFR_.mjs";
import { sql } from "kysely";
import { ulid } from "ulidx";

//#region src/plugins/storage-query.ts
/**
* Error thrown when querying non-indexed fields
*/
var StorageQueryError = class extends Error {
	constructor(message, field, suggestion) {
		super(message);
		this.field = field;
		this.suggestion = suggestion;
		this.name = "StorageQueryError";
	}
};
/**
* Check if a value is a range filter
*/
function isRangeFilter(value) {
	if (typeof value !== "object" || value === null) return false;
	return "gt" in value || "gte" in value || "lt" in value || "lte" in value;
}
/**
* Check if a value is an IN filter
*/
function isInFilter(value) {
	if (typeof value !== "object" || value === null) return false;
	return "in" in value && Array.isArray(value.in);
}
/**
* Check if a value is a startsWith filter
*/
function isStartsWithFilter(value) {
	if (typeof value !== "object" || value === null) return false;
	return "startsWith" in value && typeof value.startsWith === "string";
}
/**
* Get the set of indexed fields from index declarations
*/
function getIndexedFields(indexes) {
	const fields = /* @__PURE__ */ new Set();
	for (const index of indexes) if (Array.isArray(index)) for (const field of index) fields.add(field);
	else fields.add(index);
	return fields;
}
/**
* Validate that all fields in a where clause are indexed
*/
function validateWhereClause(where, indexedFields, pluginId, collection) {
	for (const field of Object.keys(where)) if (!indexedFields.has(field)) throw new StorageQueryError(`Cannot query on non-indexed field '${field}'.`, field, `Add '${field}' to storage.${collection}.indexes in plugin '${pluginId}' to enable this query.`);
}
/**
* Validate orderBy fields are indexed
*/
function validateOrderByClause(orderBy, indexedFields, pluginId, collection) {
	for (const field of Object.keys(orderBy)) if (!indexedFields.has(field)) throw new StorageQueryError(`Cannot order by non-indexed field '${field}'.`, field, `Add '${field}' to storage.${collection}.indexes in plugin '${pluginId}' to enable ordering by this field.`);
}
/**
* SQL expression for extracting JSON field.
*
* Validates the field name before interpolation to prevent SQL injection
* via crafted JSON path expressions.
*/
function jsonExtract(db, field) {
	validateJsonFieldName(field, "query field name");
	return jsonExtractExpr(db, "data", field);
}
/**
* Build a WHERE clause condition for a single field
*/
function buildCondition(db, field, value) {
	const extract = jsonExtract(db, field);
	if (value === null) return {
		sql: `${extract} IS NULL`,
		params: []
	};
	if (typeof value === "string" || typeof value === "number") return {
		sql: `${extract} = ?`,
		params: [value]
	};
	if (typeof value === "boolean") return {
		sql: `${extract} = ?`,
		params: [value]
	};
	if (isInFilter(value)) return {
		sql: `${extract} IN (${value.in.map(() => "?").join(", ")})`,
		params: value.in
	};
	if (isStartsWithFilter(value)) return {
		sql: `${extract} LIKE ?`,
		params: [`${value.startsWith}%`]
	};
	if (isRangeFilter(value)) {
		const conditions = [];
		const params = [];
		if (value.gt !== void 0) {
			conditions.push(`${extract} > ?`);
			params.push(value.gt);
		}
		if (value.gte !== void 0) {
			conditions.push(`${extract} >= ?`);
			params.push(value.gte);
		}
		if (value.lt !== void 0) {
			conditions.push(`${extract} < ?`);
			params.push(value.lt);
		}
		if (value.lte !== void 0) {
			conditions.push(`${extract} <= ?`);
			params.push(value.lte);
		}
		return {
			sql: conditions.join(" AND "),
			params
		};
	}
	throw new StorageQueryError(`Unknown filter type for field '${field}'`);
}
/**
* Build a complete WHERE clause from a WhereClause object
*/
function buildWhereClause(db, where) {
	const conditions = [];
	const params = [];
	for (const [field, value] of Object.entries(where)) {
		const condition = buildCondition(db, field, value);
		conditions.push(condition.sql);
		params.push(...condition.params);
	}
	if (conditions.length === 0) return {
		sql: "",
		params: []
	};
	return {
		sql: conditions.join(" AND "),
		params
	};
}

//#endregion
//#region src/database/repositories/plugin-storage.ts
/**
* Plugin Storage Repository
*
* Implements the StorageCollection interface for a specific plugin and collection.
*/
var PluginStorageRepository = class {
	indexedFields;
	constructor(db, pluginId, collection, indexes) {
		this.db = db;
		this.pluginId = pluginId;
		this.collection = collection;
		this.indexedFields = getIndexedFields(indexes);
	}
	/**
	* Get a document by ID
	*/
	async get(id) {
		const row = await this.db.selectFrom("_plugin_storage").select("data").where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection).where("id", "=", id).executeTakeFirst();
		if (!row) return null;
		return JSON.parse(row.data);
	}
	/**
	* Store a document
	*/
	async put(id, data) {
		const now = (/* @__PURE__ */ new Date()).toISOString();
		const jsonData = JSON.stringify(data);
		await this.db.insertInto("_plugin_storage").values({
			plugin_id: this.pluginId,
			collection: this.collection,
			id,
			data: jsonData,
			created_at: now,
			updated_at: now
		}).onConflict((oc) => oc.columns([
			"plugin_id",
			"collection",
			"id"
		]).doUpdateSet({
			data: jsonData,
			updated_at: now
		})).execute();
	}
	/**
	* Delete a document
	*/
	async delete(id) {
		return ((await this.db.deleteFrom("_plugin_storage").where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection).where("id", "=", id).executeTakeFirst()).numDeletedRows ?? 0) > 0;
	}
	/**
	* Check if a document exists
	*/
	async exists(id) {
		return !!await this.db.selectFrom("_plugin_storage").select("id").where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection).where("id", "=", id).executeTakeFirst();
	}
	/**
	* Get multiple documents by ID
	*/
	async getMany(ids) {
		if (ids.length === 0) return /* @__PURE__ */ new Map();
		const rows = await this.db.selectFrom("_plugin_storage").select(["id", "data"]).where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection).where("id", "in", ids).execute();
		const result = /* @__PURE__ */ new Map();
		for (const row of rows) result.set(row.id, JSON.parse(row.data));
		return result;
	}
	/**
	* Store multiple documents
	*/
	async putMany(items) {
		if (items.length === 0) return;
		const now = (/* @__PURE__ */ new Date()).toISOString();
		await withTransaction(this.db, async (trx) => {
			for (const item of items) {
				const jsonData = JSON.stringify(item.data);
				await trx.insertInto("_plugin_storage").values({
					plugin_id: this.pluginId,
					collection: this.collection,
					id: item.id,
					data: jsonData,
					created_at: now,
					updated_at: now
				}).onConflict((oc) => oc.columns([
					"plugin_id",
					"collection",
					"id"
				]).doUpdateSet({
					data: jsonData,
					updated_at: now
				})).execute();
			}
		});
	}
	/**
	* Delete multiple documents
	*/
	async deleteMany(ids) {
		if (ids.length === 0) return 0;
		const result = await this.db.deleteFrom("_plugin_storage").where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection).where("id", "in", ids).executeTakeFirst();
		return Number(result.numDeletedRows ?? 0);
	}
	/**
	* Query documents with filters
	*/
	async query(options = {}) {
		const { where = {}, orderBy = {}, cursor } = options;
		const limit = Math.min(options.limit ?? 50, 100);
		validateWhereClause(where, this.indexedFields, this.pluginId, this.collection);
		if (Object.keys(orderBy).length > 0) validateOrderByClause(orderBy, this.indexedFields, this.pluginId, this.collection);
		let query = this.db.selectFrom("_plugin_storage").select([
			"id",
			"data",
			"created_at"
		]).where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection);
		const whereResult = buildWhereClause(this.db, where);
		if (whereResult.sql) {
			const whereSqlParts = [];
			let paramIndex = 0;
			const sqlParts = whereResult.sql.split("?");
			for (let i = 0; i < sqlParts.length; i++) {
				if (i > 0) whereSqlParts.push(sql`${whereResult.params[paramIndex++]}`);
				if (sqlParts[i]) whereSqlParts.push(sql.raw(sqlParts[i]));
			}
			query = query.where(({ eb }) => eb(sql.join(whereSqlParts, sql.raw("")), "=", sql.raw("1")));
		}
		if (cursor) {
			const decoded = decodeCursor(cursor);
			query = query.where(({ eb }) => eb(sql`(created_at, id)`, ">", sql`(${decoded.orderValue}, ${decoded.id})`));
		}
		if (Object.keys(orderBy).length > 0) for (const [field, direction] of Object.entries(orderBy)) {
			const extract = jsonExtract(this.db, field);
			const orderExpr = direction === "desc" ? sql`${sql.raw(extract)} desc` : sql`${sql.raw(extract)} asc`;
			query = query.orderBy(orderExpr);
		}
		else query = query.orderBy("created_at", "asc").orderBy("id", "asc");
		query = query.limit(limit + 1);
		const rows = await query.execute();
		const hasMore = rows.length > limit;
		const items = rows.slice(0, limit).map((row) => ({
			id: row.id,
			data: JSON.parse(row.data)
		}));
		let nextCursor;
		if (hasMore) {
			const lastItem = rows[limit - 1];
			if (lastItem) nextCursor = encodeCursor(lastItem.created_at, lastItem.id);
		}
		return {
			items,
			cursor: nextCursor,
			hasMore
		};
	}
	/**
	* Count documents matching a filter
	*/
	async count(where) {
		if (where && Object.keys(where).length > 0) validateWhereClause(where, this.indexedFields, this.pluginId, this.collection);
		let query = this.db.selectFrom("_plugin_storage").select(sql`COUNT(*)`.as("count")).where("plugin_id", "=", this.pluginId).where("collection", "=", this.collection);
		if (where && Object.keys(where).length > 0) {
			const whereResult = buildWhereClause(this.db, where);
			if (whereResult.sql) {
				const whereSqlParts = [];
				let paramIndex = 0;
				const sqlParts = whereResult.sql.split("?");
				for (let i = 0; i < sqlParts.length; i++) {
					if (i > 0) whereSqlParts.push(sql`${whereResult.params[paramIndex++]}`);
					if (sqlParts[i]) whereSqlParts.push(sql.raw(sqlParts[i]));
				}
				query = query.where(({ eb }) => eb(sql.join(whereSqlParts, sql.raw("")), "=", sql.raw("1")));
			}
		}
		return (await query.executeTakeFirst())?.count ?? 0;
	}
};

//#endregion
//#region src/plugins/context.ts
/**
* Create KV accessor for a plugin
* All keys are automatically prefixed with the plugin ID
*/
function createKVAccess(optionsRepo, pluginId) {
	const prefix = `plugin:${pluginId}:`;
	return {
		async get(key) {
			return optionsRepo.get(`${prefix}${key}`);
		},
		async set(key, value) {
			await optionsRepo.set(`${prefix}${key}`, value);
		},
		async delete(key) {
			return optionsRepo.delete(`${prefix}${key}`);
		},
		async list(keyPrefix) {
			const fullPrefix = `${prefix}${keyPrefix ?? ""}`;
			const entriesMap = await optionsRepo.getByPrefix(fullPrefix);
			const result = [];
			for (const [fullKey, value] of entriesMap) result.push({
				key: fullKey.slice(prefix.length),
				value
			});
			return result;
		}
	};
}
/**
* Create storage collection accessor for a plugin
* Wraps PluginStorageRepository with the v2 interface (no async iterators)
*/
function createStorageCollection(db, pluginId, collectionName, indexes) {
	const repo = new PluginStorageRepository(db, pluginId, collectionName, indexes);
	return {
		get: (id) => repo.get(id),
		put: (id, data) => repo.put(id, data),
		delete: (id) => repo.delete(id),
		exists: (id) => repo.exists(id),
		getMany: (ids) => repo.getMany(ids),
		putMany: (items) => repo.putMany(items),
		deleteMany: (ids) => repo.deleteMany(ids),
		count: (where) => repo.count(where),
		async query(options) {
			const result = await repo.query({
				where: options?.where,
				orderBy: options?.orderBy,
				limit: options?.limit,
				cursor: options?.cursor
			});
			return {
				items: result.items,
				cursor: result.cursor,
				hasMore: result.hasMore
			};
		}
	};
}
/**
* Create storage accessor with all declared collections
*/
function createStorageAccess(db, pluginId, storageConfig) {
	const storage = {};
	for (const [collectionName, config] of Object.entries(storageConfig)) storage[collectionName] = createStorageCollection(db, pluginId, collectionName, [...config.indexes, ...config.uniqueIndexes ?? []]);
	return storage;
}
/**
* Extract `seo` from a plugin-supplied content write input and return both
* parts. Mutates nothing — returns a new field map without the `seo` key.
*/
function splitSeoFromInput(input) {
	const { seo, ...fields } = input;
	if (seo !== void 0 && (seo === null || typeof seo !== "object" || Array.isArray(seo))) throw new Error("content.seo must be an object");
	return {
		fields,
		seo
	};
}
/**
* Reject writing SEO to a collection that does not have it enabled.
* Matches the REST API behavior (VALIDATION_ERROR).
*/
async function assertSeoEnabled(seoRepo, collection, seo) {
	const hasSeo = await seoRepo.isEnabled(collection);
	if (seo !== void 0 && !hasSeo) throw new Error(`Collection "${collection}" does not have SEO enabled. Remove the seo field or enable SEO on this collection.`);
	return hasSeo;
}
/**
* Create read-only content access
*/
function createContentAccess(db) {
	const contentRepo = new ContentRepository(db);
	const seoRepo = new SeoRepository(db);
	return {
		async get(collection, id) {
			const item = await contentRepo.findById(collection, id);
			if (!item) return null;
			const result = {
				id: item.id,
				type: item.type,
				slug: item.slug,
				status: item.status,
				data: item.data,
				createdAt: item.createdAt,
				updatedAt: item.updatedAt,
				locale: item.locale,
				publishedAt: item.publishedAt
			};
			if (await seoRepo.isEnabled(collection)) result.seo = await seoRepo.get(collection, item.id);
			return result;
		},
		async list(collection, options) {
			let orderBy;
			if (options?.orderBy) {
				const first = Object.entries(options.orderBy)[0];
				if (first) orderBy = {
					field: first[0],
					direction: first[1]
				};
			}
			const result = await contentRepo.findMany(collection, {
				limit: options?.limit ?? 50,
				cursor: options?.cursor,
				orderBy,
				where: options?.where
			});
			const items = result.items.map((item) => ({
				id: item.id,
				type: item.type,
				slug: item.slug,
				status: item.status,
				data: item.data,
				createdAt: item.createdAt,
				updatedAt: item.updatedAt,
				locale: item.locale,
				publishedAt: item.publishedAt
			}));
			if (items.length > 0 && await seoRepo.isEnabled(collection)) {
				const seoMap = await seoRepo.getMany(collection, items.map((i) => i.id));
				for (const item of items) {
					const seo = seoMap.get(item.id);
					if (seo) item.seo = seo;
				}
			}
			return {
				items,
				cursor: result.nextCursor,
				hasMore: !!result.nextCursor
			};
		}
	};
}
/**
* Create full content access with write operations.
*
* `create` and `update` accept a reserved `seo` key in their `data`
* argument. When present, it is routed to the core SEO panel
* (`_emdash_seo`) via `SeoRepository.upsert`, in the same transaction as
* the content write. The returned `ContentItem.seo` reflects the resulting
* SEO state for SEO-enabled collections.
*/
function createContentAccessWithWrite(db) {
	return {
		...createContentAccess(db),
		async create(collection, data) {
			const { fields, seo } = splitSeoFromInput(data);
			let contentMutated = false;
			try {
				const created = await withTransaction(db, async (trx) => {
					const trxContentRepo = new ContentRepository(trx);
					const trxSeoRepo = new SeoRepository(trx);
					const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);
					const item = await trxContentRepo.create({
						type: collection,
						data: fields
					});
					contentMutated = true;
					const result = {
						id: item.id,
						type: item.type,
						slug: item.slug,
						status: item.status,
						data: item.data,
						createdAt: item.createdAt,
						updatedAt: item.updatedAt,
						locale: item.locale,
						publishedAt: item.publishedAt
					};
					if (hasSeo) result.seo = seo !== void 0 ? await trxSeoRepo.upsert(collection, item.id, seo) : await trxSeoRepo.get(collection, item.id);
					return result;
				});
				await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE");
				return created;
			} catch (error) {
				if (contentMutated) await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE");
				throw error;
			}
		},
		async update(collection, id, data) {
			const { fields, seo } = splitSeoFromInput(data);
			const hasFieldUpdates = Object.keys(fields).length > 0;
			let contentMutated = false;
			try {
				const updated = await withTransaction(db, async (trx) => {
					const trxContentRepo = new ContentRepository(trx);
					const trxSeoRepo = new SeoRepository(trx);
					const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo);
					const item = hasFieldUpdates ? await trxContentRepo.update(collection, id, { data: fields }) : await (async () => {
						const existing = await trxContentRepo.findById(collection, id);
						if (!existing) throw new Error("Content not found");
						return existing;
					})();
					if (hasFieldUpdates) contentMutated = true;
					const result = {
						id: item.id,
						type: item.type,
						slug: item.slug,
						status: item.status,
						data: item.data,
						createdAt: item.createdAt,
						updatedAt: item.updatedAt,
						locale: item.locale,
						publishedAt: item.publishedAt
					};
					if (hasSeo) result.seo = seo !== void 0 ? await trxSeoRepo.upsert(collection, item.id, seo) : await trxSeoRepo.get(collection, item.id);
					return result;
				});
				if (hasFieldUpdates) await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE");
				return updated;
			} catch (error) {
				if (contentMutated) await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE");
				throw error;
			}
		},
		async delete(collection, id) {
			const deleted = await new ContentRepository(db).delete(collection, id);
			if (deleted) await markContentMediaUsageCollectionStaleSafely(db, collection, "CONTENT_USAGE_STALE");
			return deleted;
		}
	};
}
/**
* Create read-only media access
*/
function createMediaAccess(db) {
	const mediaRepo = new MediaRepository(db);
	return {
		async get(id) {
			const item = await mediaRepo.findById(id);
			if (!item) return null;
			return {
				id: item.id,
				filename: item.filename,
				mimeType: item.mimeType,
				size: item.size,
				url: `/media/${item.id}/${item.filename}`,
				createdAt: item.createdAt
			};
		},
		async list(options) {
			const result = await mediaRepo.findMany({
				limit: options?.limit ?? 50,
				cursor: options?.cursor,
				mimeType: options?.mimeType
			});
			return {
				items: result.items.map((item) => ({
					id: item.id,
					filename: item.filename,
					mimeType: item.mimeType,
					size: item.size,
					url: `/media/${item.id}/${item.filename}`,
					createdAt: item.createdAt
				})),
				cursor: result.nextCursor,
				hasMore: !!result.nextCursor
			};
		}
	};
}
/**
* Create full media access with write operations.
*
* `getUploadUrlFn` is optional: when omitted, `getUploadUrl()` is derived from
* `storage` (create a pending record + a signed PUT URL), mirroring the REST
* `/_emdash/api/media/upload-url` endpoint. `upload()` only needs `storage`.
* If storage is not provided, both throw at call time.
*/
function createMediaAccessWithWrite(db, getUploadUrlFn, storage) {
	const mediaRepo = new MediaRepository(db);
	const readAccess = createMediaAccess(db);
	const getUploadUrl = getUploadUrlFn ?? (async (filename, contentType) => {
		if (!storage) throw new Error("Media getUploadUrl() requires a storage backend. Configure storage in PluginContextFactoryOptions.");
		const basename = filename.split("/").pop() ?? filename;
		const dotIdx = basename.lastIndexOf(".");
		const ext = dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : "";
		const storageKey = `${ulid()}${ext}`;
		const media = await mediaRepo.createPending({
			filename: basename,
			mimeType: contentType,
			storageKey
		});
		return {
			uploadUrl: (await storage.getSignedUploadUrl({
				key: storageKey,
				contentType,
				expiresIn: 3600
			})).url,
			mediaId: media.id
		};
	});
	return {
		...readAccess,
		getUploadUrl,
		async upload(filename, contentType, bytes) {
			if (!storage) throw new Error("Media upload() requires a storage backend. Configure storage in PluginContextFactoryOptions.");
			const keyPrefix = ulid();
			const basename = filename.split("/").pop() ?? filename;
			const dotIdx = basename.lastIndexOf(".");
			const storageKey = `${keyPrefix}${dotIdx > 0 ? basename.slice(dotIdx).toLowerCase() : ""}`;
			await storage.upload({
				key: storageKey,
				body: new Uint8Array(bytes),
				contentType
			});
			const enriched = await enrichImageMetadata(new Uint8Array(bytes), contentType);
			let media;
			try {
				media = await mediaRepo.create({
					filename: basename,
					mimeType: contentType,
					size: bytes.byteLength,
					storageKey,
					status: "ready",
					width: enriched.width,
					height: enriched.height,
					blurhash: enriched.blurhash,
					dominantColor: enriched.dominantColor
				});
			} catch (error) {
				try {
					await storage.delete(storageKey);
				} catch {}
				throw error;
			}
			return {
				mediaId: media.id,
				storageKey,
				url: `/_emdash/api/media/file/${storageKey}`
			};
		},
		async delete(id) {
			const deleted = await mediaRepo.delete(id);
			if (deleted) invalidateSiteSettingsCache();
			return deleted;
		}
	};
}
/** Maximum number of redirects to follow in plugin HTTP access */
const MAX_PLUGIN_REDIRECTS = 5;
/**
* Check if a hostname matches any pattern in the allowed list.
* Patterns: "*" matches all, "*.example.com" matches subdomains AND bare "example.com",
* "api.example.com" matches exactly.
*/
function isHostAllowed(host, allowedHosts) {
	return allowedHosts.some((pattern) => {
		if (pattern === "*") return true;
		if (pattern.startsWith("*.")) {
			const suffix = pattern.slice(1);
			return host.endsWith(suffix) || host === pattern.slice(2);
		}
		return host === pattern;
	});
}
/**
* Create HTTP access with host validation.
*
* Uses redirect: "manual" to re-validate each redirect target against
* the allowedHosts list, preventing redirects to unauthorized hosts.
*/
function createHttpAccess(pluginId, allowedHosts) {
	return { async fetch(url, init) {
		if (allowedHosts.length === 0) throw new Error(`Plugin "${pluginId}" has no allowed hosts configured. Add hosts to the plugin's allowedHosts array to enable HTTP requests.`);
		let currentUrl = url;
		let currentInit = init;
		for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) {
			const hostname = new URL(currentUrl).hostname;
			if (!isHostAllowed(hostname, allowedHosts)) throw new Error(`Plugin "${pluginId}" is not allowed to fetch from host "${hostname}". Allowed hosts: ${allowedHosts.join(", ")}`);
			const response = await globalThis.fetch(currentUrl, {
				...currentInit,
				redirect: "manual"
			});
			if (response.status < 300 || response.status >= 400) return response;
			const location = response.headers.get("Location");
			if (!location) return response;
			const previousOrigin = new URL(currentUrl).origin;
			currentUrl = new URL(location, currentUrl).href;
			if (previousOrigin !== new URL(currentUrl).origin && currentInit) currentInit = stripCredentialHeaders(currentInit);
		}
		throw new Error(`Plugin "${pluginId}": too many redirects (max ${MAX_PLUGIN_REDIRECTS})`);
	} };
}
/**
* Create unrestricted HTTP access (for plugins with network:fetch:any capability).
* No host validation, but applies SSRF protection on redirect targets to
* prevent plugins from being tricked into reaching internal services.
*/
function createUnrestrictedHttpAccess(pluginId) {
	return { async fetch(url, init) {
		let currentUrl = url;
		let currentInit = init;
		for (let i = 0; i <= MAX_PLUGIN_REDIRECTS; i++) {
			try {
				await resolveAndValidateExternalUrl(currentUrl);
			} catch (e) {
				const msg = e instanceof SsrfError ? e.message : "SSRF validation failed";
				throw new Error(`Plugin "${pluginId}": blocked fetch to "${new URL(currentUrl).hostname}": ${msg}`, { cause: e });
			}
			const response = await globalThis.fetch(currentUrl, {
				...currentInit,
				redirect: "manual"
			});
			if (response.status < 300 || response.status >= 400) return response;
			const location = response.headers.get("Location");
			if (!location) return response;
			const previousOrigin = new URL(currentUrl).origin;
			currentUrl = new URL(location, currentUrl).href;
			if (previousOrigin !== new URL(currentUrl).origin && currentInit) currentInit = stripCredentialHeaders(currentInit);
		}
		throw new Error(`Plugin "${pluginId}": too many redirects (max ${MAX_PLUGIN_REDIRECTS})`);
	} };
}
/**
* Create logger for a plugin
*/
function createLogAccess(pluginId) {
	const prefix = `[plugin:${pluginId}]`;
	return {
		debug(message, data) {
			if (data !== void 0) console.debug(prefix, message, data);
			else console.debug(prefix, message);
		},
		info(message, data) {
			if (data !== void 0) console.info(prefix, message, data);
			else console.info(prefix, message);
		},
		warn(message, data) {
			if (data !== void 0) console.warn(prefix, message, data);
			else console.warn(prefix, message);
		},
		error(message, data) {
			if (data !== void 0) console.error(prefix, message, data);
			else console.error(prefix, message);
		}
	};
}
const TRAILING_SLASH_RE = /\/$/;
/**
* Create site info from config and settings.
*
* Resolution order for URL:
* 1. options table (emdash:site_url)
* 2. Astro `site` config
* 3. fallback to empty string
*/
function createSiteInfo(options) {
	return {
		name: options.siteName ?? "",
		url: (options.siteUrl ?? "").replace(TRAILING_SLASH_RE, ""),
		locale: options.locale ?? "en"
	};
}
/**
* Create a URL helper that generates absolute URLs from relative paths.
* Validates that path starts with "/" and rejects protocol-relative paths ("//").
*/
function createUrlHelper(siteUrl) {
	const base = siteUrl.replace(TRAILING_SLASH_RE, "");
	return (path) => {
		if (!path.startsWith("/")) throw new Error(`URL path must start with "/", got: "${path}"`);
		if (path.startsWith("//")) throw new Error(`URL path must not be protocol-relative, got: "${path}"`);
		return `${base}${path}`;
	};
}
/**
* Convert a UserRepository user to the plugin-facing UserInfo shape.
* Strips sensitive fields (avatarUrl, emailVerified, data).
*/
function toUserInfo(user) {
	return {
		id: user.id,
		email: user.email,
		name: user.name,
		role: user.role,
		createdAt: user.createdAt
	};
}
/**
* Create read-only user access for plugins.
* Excludes sensitive fields (password hashes, sessions, passkeys, avatar URL, data).
*/
function createUserAccess(db) {
	const userRepo = new UserRepository(db);
	return {
		async get(id) {
			const user = await userRepo.findById(id);
			if (!user) return null;
			return toUserInfo(user);
		},
		async getByEmail(email) {
			const user = await userRepo.findByEmail(email);
			if (!user) return null;
			return toUserInfo(user);
		},
		async list(opts) {
			const result = await userRepo.findMany({
				role: opts?.role,
				cursor: opts?.cursor,
				limit: opts?.limit
			});
			return {
				items: result.items.map(toUserInfo),
				nextCursor: result.nextCursor
			};
		}
	};
}
/**
* Factory for creating plugin contexts
*/
var PluginContextFactory = class {
	resolveDb;
	storage;
	getUploadUrl;
	site;
	urlHelper;
	cronReschedule;
	emailPipeline;
	/**
	* Plugin IDs already warned about a missing media-write backend, so the
	* warning fires once per factory instead of on every hook/route context
	* creation (which would spam logs for hook-participating plugins).
	*/
	warnedMissingMediaBackend = /* @__PURE__ */ new Set();
	constructor(options) {
		const fixedDb = options.db;
		this.resolveDb = options.getDb ?? (() => fixedDb);
		this.storage = options.storage;
		this.getUploadUrl = options.getUploadUrl;
		this.site = createSiteInfo(options.siteInfo ?? {});
		this.urlHelper = createUrlHelper(this.site.url);
		this.cronReschedule = options.cronReschedule;
		this.emailPipeline = options.emailPipeline;
	}
	/**
	* Create the unified plugin context
	*/
	createContext(plugin) {
		const capabilities = new Set(plugin.capabilities);
		const db = this.resolveDb();
		const kv = createKVAccess(new OptionsRepository(db), plugin.id);
		const log = createLogAccess(plugin.id);
		const storage = createStorageAccess(db, plugin.id, plugin.storage);
		let content;
		if (capabilities.has("content:write")) content = createContentAccessWithWrite(db);
		else if (capabilities.has("content:read")) content = createContentAccess(db);
		let media;
		if (capabilities.has("media:write")) if (this.getUploadUrl || this.storage) media = createMediaAccessWithWrite(db, this.getUploadUrl, this.storage);
		else {
			if (!this.warnedMissingMediaBackend.has(plugin.id)) {
				this.warnedMissingMediaBackend.add(plugin.id);
				log.warn("declares the media:write capability but no storage backend is configured; upload() is unavailable.");
			}
			if (capabilities.has("media:read")) media = createMediaAccess(db);
		}
		else if (capabilities.has("media:read")) media = createMediaAccess(db);
		let http;
		if (capabilities.has("network:request:unrestricted")) http = createUnrestrictedHttpAccess(plugin.id);
		else if (capabilities.has("network:request")) http = createHttpAccess(plugin.id, plugin.allowedHosts);
		let users;
		if (capabilities.has("users:read")) users = createUserAccess(db);
		let cron;
		if (this.cronReschedule) cron = new CronAccessImpl(db, plugin.id, this.cronReschedule);
		let email;
		if (capabilities.has("email:send") && this.emailPipeline?.isAvailable()) {
			const pipeline = this.emailPipeline;
			const pluginId = plugin.id;
			email = { send: (message) => pipeline.send(message, pluginId) };
		}
		return {
			plugin: {
				id: plugin.id,
				version: plugin.version
			},
			storage,
			kv,
			content,
			media,
			http,
			log,
			site: this.site,
			url: this.urlHelper,
			users,
			cron,
			email
		};
	}
};

//#endregion
export { PluginStorageRepository as a, createUnrestrictedHttpAccess as i, createHttpAccess as n, createStorageAccess as r, PluginContextFactory as t };
//# sourceMappingURL=context-CUsR9dXL.mjs.map