@murumets-ee/media 0.70.0 → 0.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/admin.d.mts.map +1 -1
  2. package/dist/admin.mjs +1 -1
  3. package/dist/client-CSOH7Zun.mjs +2 -0
  4. package/dist/client-CSOH7Zun.mjs.map +1 -0
  5. package/dist/client.d.mts +1 -1
  6. package/dist/client.d.mts.map +1 -1
  7. package/dist/client.mjs +1 -1
  8. package/dist/client.mjs.map +1 -1
  9. package/dist/crop.d.mts +2 -0
  10. package/dist/crop.mjs +1 -0
  11. package/dist/entity-CNxAERYG.mjs +2 -0
  12. package/dist/entity-CNxAERYG.mjs.map +1 -0
  13. package/dist/entity-DKFCdQJT.mjs +2 -0
  14. package/dist/entity-DKFCdQJT.mjs.map +1 -0
  15. package/dist/focal-point-BWhtJzef.mjs +2 -0
  16. package/dist/focal-point-BWhtJzef.mjs.map +1 -0
  17. package/dist/focal-point-C_0drV03.d.mts +125 -0
  18. package/dist/focal-point-C_0drV03.d.mts.map +1 -0
  19. package/dist/image-styles-settings-DEnu-V2E.mjs +2 -0
  20. package/dist/{image-styles-settings-ClIA2MJI.mjs.map → image-styles-settings-DEnu-V2E.mjs.map} +1 -1
  21. package/dist/image-styles-settings.d.mts +1 -1
  22. package/dist/image-styles-settings.mjs +1 -1
  23. package/dist/index.d.mts +1 -1
  24. package/dist/index.mjs +1 -1
  25. package/dist/picker.d.mts +107 -5
  26. package/dist/picker.d.mts.map +1 -1
  27. package/dist/picker.mjs +1 -1
  28. package/dist/picker.mjs.map +1 -1
  29. package/dist/{plugin-BkEED6qj.mjs → plugin-B_ZtfiAc.mjs} +2 -2
  30. package/dist/{plugin-BkEED6qj.mjs.map → plugin-B_ZtfiAc.mjs.map} +1 -1
  31. package/dist/plugin.d.mts +1 -1
  32. package/dist/plugin.mjs +1 -1
  33. package/dist/plugin.mjs.map +1 -1
  34. package/dist/process-image-WByEHTXQ.mjs +2 -0
  35. package/dist/process-image-WByEHTXQ.mjs.map +1 -0
  36. package/dist/processing.d.mts +1 -1
  37. package/dist/processing.d.mts.map +1 -1
  38. package/dist/processing.mjs +1 -1
  39. package/dist/public-resolver.d.mts +14 -1
  40. package/dist/public-resolver.d.mts.map +1 -1
  41. package/dist/public-resolver.mjs +1 -1
  42. package/dist/public-resolver.mjs.map +1 -1
  43. package/dist/query-client.d.mts +1 -1
  44. package/dist/query-client.mjs +1 -1
  45. package/dist/{regenerate-variants-sit6LbUo.mjs → regenerate-variants-2Lmo5kf8.mjs} +2 -2
  46. package/dist/{regenerate-variants-sit6LbUo.mjs.map → regenerate-variants-2Lmo5kf8.mjs.map} +1 -1
  47. package/dist/{regenerate-variants-BysyeIoA.mjs → regenerate-variants-B9AXXN6N.mjs} +2 -2
  48. package/dist/{regenerate-variants-BysyeIoA.mjs.map → regenerate-variants-B9AXXN6N.mjs.map} +1 -1
  49. package/dist/{resolve-image-styles-BzSRuXUs.mjs → resolve-image-styles-Cakg1Qpp.mjs} +2 -2
  50. package/dist/{resolve-image-styles-BzSRuXUs.mjs.map → resolve-image-styles-Cakg1Qpp.mjs.map} +1 -1
  51. package/dist/routes-C7Fx-055.mjs +2 -0
  52. package/dist/routes-C7Fx-055.mjs.map +1 -0
  53. package/dist/{types-1idCpe1k.d.mts → types-CnORVkLZ.d.mts} +24 -3
  54. package/dist/types-CnORVkLZ.d.mts.map +1 -0
  55. package/dist/variant-key-RKke2RfS.mjs +2 -0
  56. package/dist/variant-key-RKke2RfS.mjs.map +1 -0
  57. package/package.json +12 -8
  58. package/dist/client-YlSoGADl.mjs +0 -2
  59. package/dist/client-YlSoGADl.mjs.map +0 -1
  60. package/dist/entity-Caba_NFw.mjs +0 -2
  61. package/dist/entity-Caba_NFw.mjs.map +0 -1
  62. package/dist/entity-fxw-Qywj.mjs +0 -2
  63. package/dist/entity-fxw-Qywj.mjs.map +0 -1
  64. package/dist/image-styles-settings-ClIA2MJI.mjs +0 -2
  65. package/dist/process-image-DYDTMGUJ.mjs +0 -2
  66. package/dist/process-image-DYDTMGUJ.mjs.map +0 -1
  67. package/dist/routes-RCy0SYsK.mjs +0 -2
  68. package/dist/routes-RCy0SYsK.mjs.map +0 -1
  69. package/dist/types-1idCpe1k.d.mts.map +0 -1
  70. package/dist/variant-key-CLlac3_1.mjs +0 -2
  71. package/dist/variant-key-CLlac3_1.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/media-config.ts","../src/admin/routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * The media plugin's resolved configuration — defaults in one place, read\n * from the framework's `PluginConfigRegistry`.\n *\n * The plugin used to stash its config in a module variable from `init`, and\n * the upload route hardcoded 50 MB with a comment explaining it could not\n * trust that variable to be set yet. So `acceptedTypes` and `maxUploadSize`\n * were declared, documented, logged at boot — and enforced nowhere (F007).\n * `server.config` is placed in the registry at the merge step, BEFORE any\n * `init`, which removes the reason the route could not read it.\n */\n\nimport { getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\nimport type { MediaPluginConfig } from './types.js'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/media': Required<MediaPluginConfig>\n }\n}\n\nexport const MEDIA_PLUGIN_NAME = '@murumets-ee/media'\n\n/**\n * The default `acceptedTypes`, matched against what `detectMimeType` (file-type\n * 21) actually REPORTS — not what a browser claims.\n *\n * Wider than the image/video/audio/PDF the option's docs used to list, on\n * purpose: nothing enforced that list, so modern Office and OpenDocument files\n * were accepted and are in libraries today. Enforcing the narrow list would\n * have turned an F007 memory fix into a silent feature removal.\n *\n * Deliberately NOT in the default, each refused with 415 unless an app lists it:\n * - **Legacy binary Office** (`.doc`, `.xls`, `.ppt`). file-type reports them as\n * `application/x-cfb` — the same container as `.msi` installers and Outlook\n * `.msg` files, so it cannot be allowed for one without the others.\n * - **Macro-enabled Office** (`.docm`, `.xlsm`, `.pptm`), reported with their own\n * `…macroenabled…` types.\n * - **Archives and executables**, and the text types (`application/json`,\n * `text/markdown`, TSV) that `detectMimeType` trusts but the library does not\n * classify as documents.\n *\n * Named office families rather than `application/vnd.*`, which also matches\n * executables and archives.\n */\nexport const DEFAULT_MEDIA_ACCEPTED_TYPES: readonly string[] = [\n 'image/*',\n 'video/*',\n 'audio/*',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.*',\n 'application/vnd.oasis.opendocument.*',\n 'text/plain',\n 'text/csv',\n]\n\n/** Fill every unset option with its default. */\nexport function resolveMediaConfig(config?: MediaPluginConfig): Required<MediaPluginConfig> {\n return {\n acceptedTypes: config?.acceptedTypes ?? [...DEFAULT_MEDIA_ACCEPTED_TYPES],\n // Non-positive means unset, as it does for storage's `maxFileSize`.\n maxUploadSize:\n config?.maxUploadSize !== undefined && config.maxUploadSize > 0\n ? config.maxUploadSize\n : 50 * 1024 * 1024,\n // Resolved for the picker's own use, and deliberately NOT applied by the\n // upload route: an upload that sends no visibility stays private (rent's\n // inspection photos rely on it). See the route's deny-by-default comment.\n defaultVisibility: config?.defaultVisibility ?? 'public',\n imageStyles: config?.imageStyles ?? {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n },\n }\n}\n\n/**\n * The registered config, or the defaults when the plugin is not registered\n * (a unit test, a CLI path that never ran `createApp`). The defaults are what\n * `media()` with no arguments resolves to, so the fallback is never looser\n * than a registered plugin that configured nothing.\n */\nexport function readMediaConfig(): Required<MediaPluginConfig> {\n return hasPluginConfig(MEDIA_PLUGIN_NAME)\n ? getPluginConfig(MEDIA_PLUGIN_NAME)\n : resolveMediaConfig()\n}\n","/**\n * Media admin routes — one static `defineAdminRoute` per HTTP method.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/media — list with search/filter + URLs\n * - GET /api/admin/media/<id> — single record with URL\n * - GET /api/admin/media/<id>/usage — entities referencing this media\n * - POST /api/admin/media — upload (multipart FormData)\n * - POST /api/admin/media/regenerate-variants — bulk variant regeneration\n * - DELETE /api/admin/media/<id> — delete + storage cleanup\n *\n * Standard entity CRUD (PATCH with translations) falls through to the\n * generic entity handler — add `Media` to `entities` in your handler config.\n *\n * **Why per-method `defineAdminRoute`** (vs. the previous top-level\n * `resource: 'media'` + auto-mapped permission):\n *\n * - The wrapper (`guardedHandler` from `@murumets-ee/core`) owns the\n * permission gate, `permission.denied` audit, and 403 body shape\n * uniformly with every other migrated plugin. The previous shape\n * relied on `METHOD_TO_ACTION` auto-mapping at the api-handler\n * dispatch level which couldn't emit per-route audit metadata.\n * - Inline `if (!checkPermission('media', 'create')) ...` defenses\n * at the regenerate-variants / upload / delete branches are now\n * redundant — the wrapper enforces the permission BEFORE the\n * handler runs.\n *\n * **`matchAnyPath` rationale:** media's URL surface predates the\n * static-second-segment convention every other plugin follows\n * (`/taxonomy/<vocab>/<id>`, `/settings/<namespace>/<rest>`). The\n * second segment under `/media` is the media id itself — a runtime\n * UUID — which the dispatcher's literal `segments[0]` match would\n * miss for every request. The catch-all flag declares \"this entry\n * claims every (prefix, method) sub-path no other entry matched\" so\n * the existing URL contract works under the per-method dispatch\n * model. Static POST sub-paths (`regenerate-variants`) take\n * precedence over the catch-all entry on POST. See\n * `DefineAdminRouteSpec.matchAnyPath` in `@murumets-ee/core` for the\n * full contract.\n *\n * **Default roles:** broad for view (`['admin', 'editor', 'agent',\n * 'viewer']`), narrower for writes (`['admin', 'editor']`).\n * Conservative starting point — apps can grant more via the\n * Permissions UI.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { mediaRoutes } from '@murumets-ee/media/admin'\n * import { Media } from '@murumets-ee/media'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [Article, Media],\n * routes: [...mediaRoutes()],\n * })\n * ```\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n safeAudit,\n} from '@murumets-ee/core'\nimport type { MediaClient } from '../client.js'\nimport { readMediaConfig } from '../media-config.js'\nimport type { MediaPickerItem, MediaPickerListResult } from '../picker/types.js'\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isValidUuid(value: string | undefined): value is string {\n return value !== undefined && UUID_RE.test(value)\n}\n\n/**\n * Closed enum of allowed `mediaType` filter values, in lockstep with the\n * `mediaType` field on the Media entity. The list MUST match\n * `MediaListOptions.mediaType` (declared in `../types.js`) — TypeScript\n * doesn't link the two automatically, so the small drift risk is\n * accepted in exchange for not pulling a runtime dep on the entity\n * schema package just to read its enum.\n *\n * Used as both the runtime allowlist (`isAllowedMediaType`) and the\n * source of truth for the narrowed type the guard produces — keeping\n * `as` casts off the handler's hot path.\n */\nconst ALLOWED_MEDIA_TYPES = ['image', 'video', 'audio', 'document', 'other'] as const\ntype AllowedMediaType = (typeof ALLOWED_MEDIA_TYPES)[number]\n\nfunction isAllowedMediaType(value: string): value is AllowedMediaType {\n // Cast to readonly string[] so the typed-tuple `includes` check works\n // with an arbitrary string input — `Array.prototype.includes`'s\n // signature on a typed tuple only accepts members of the tuple\n // (TS 4.9+ literal-narrowing), which is exactly what we want to\n // sidestep here.\n return (ALLOWED_MEDIA_TYPES as readonly string[]).includes(value)\n}\n\n/**\n * Hard cap on the `offset` query param. The dispatcher's allowed-pages\n * model is `limit ∈ [1, 100]`; even with the max page size that's\n * 1000 pages before hitting MAX_OFFSET, which is well beyond any\n * realistic admin-UI scroll depth. Forces slow-discard offset attacks\n * (`?offset=999999999`) to revert to a sane upper bound. Cursor-based\n * pagination would let this go higher, but the media list is\n * offset-based by convention with the entity-list shell.\n */\nconst MAX_OFFSET = 100_000\n\n/**\n * Strict non-negative integer parser for query params.\n *\n * `Number(raw)` silently coerces blanks (`'' → 0`), hex (`'0x05' → 5`),\n * exponent notation (`'1e2' → 100`), and signed (`'+5'/'-5'`) values —\n * all of which pass a downstream `Number.isInteger(n) && n >= 0` check.\n * Pre-filtering with `/^\\d+$/` keeps the contract honest: \"if you don't\n * send a pure non-negative integer string, you get the default.\" Same\n * pattern as `packages/taxonomy/src/admin/routes.ts:handleGet` tree\n * branch from PR-C-taxonomy.\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n // The regex above stops hex / exponent / signed / whitespace / blank\n // inputs, but a long digit-only string still slips past — `Number(...)`\n // returns `Infinity` past ~309 digits, and precision is lost past\n // 2^53. Both would propagate through `client.findMany`'s\n // `offset: parsed` into a Postgres query that either crashes the\n // driver (`Infinity` is not a valid bigint) or silently rounds (the\n // 2^53 precision-loss boundary). Reject via `Number.isSafeInteger`\n // so overflowing inputs revert to `fallback` like every other\n // malformed value.\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory + cached storage client\n// ---------------------------------------------------------------------------\n//\n// MediaClient and its AdminClient MUST be built per-request — AdminClient's\n// context resolver is captured eagerly at construction (see\n// buildContextResolver in @murumets-ee/core/clients). A module-level singleton\n// would bake the first request's user + tenant scope into every subsequent\n// request's writes — a cross-request permission/scope leak.\n//\n// Storage config is process-global and safe to cache once.\n\ntype StorageClient = Awaited<ReturnType<typeof import('@murumets-ee/storage').createStorageClient>>\n\ninterface StorageState {\n clientPromise: Promise<StorageClient> | null\n}\n\n// Module-level state. `null` once eviction fires on a rejection so the next\n// request gets a fresh resolution attempt (see `getStorage`'s `.catch`).\nconst storageState: StorageState = { clientPromise: null }\n\n/**\n * Resolve the process-wide storage client. The pending-promise slot is\n * cleared on rejection so a transient `createStorageClient` failure\n * (DB hiccup, misconfig) doesn't poison every subsequent media request\n * for the lifetime of the Node process. Same pattern as\n * `packages/taxonomy/src/admin/routes.ts:getClient` from PR-C-taxonomy.\n */\nasync function getStorage(): Promise<StorageClient> {\n if (!storageState.clientPromise) {\n const pending = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n })()\n // Clear the slot on rejection so a transient failure doesn't poison\n // every subsequent request. `pending.catch(handler)` attaches a\n // synchronous rejection listener that resets the cached slot — the\n // handler returns `undefined`, NOT a re-throw — so the new\n // `.catch`-returned promise resolves, but we never await or hold\n // that promise. The ORIGINAL `pending` (stored in\n // `storageState.clientPromise`) is what callers `await`, and it's\n // still in its rejected state — so the caller observing this\n // resolution sees the underlying error. The `.catch` also marks\n // the rejection as \"handled\" for Node's unhandled-rejection\n // tracker. `storageState.clientPromise` holds the original `pending`\n // so concurrent in-flight callers share one resolution.\n pending.catch(() => {\n if (storageState.clientPromise === pending) storageState.clientPromise = null\n })\n storageState.clientPromise = pending\n }\n return storageState.clientPromise\n}\n\nasync function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { MediaClient } = await import('../client.js')\n const { Media } = await import('../entity.js')\n const storage = await getStorage()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleGet: AdminRouteHandler = async (req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n const segments = ctx.segments\n\n // GET /media/<id>/usage — DB-only lookup, safe even when storage is\n // unconfigured. Validate the UUID first so a malformed id 400s\n // uniformly with the storage-configured branch below.\n if (segments.length === 2 && segments[1] === 'usage') {\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { findMediaUsages } = await import('../usage.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const usages = await findMediaUsages(id, app)\n return jsonResponse({ usages })\n }\n\n // Reject unknown sub-paths explicitly. The wrapper-gate has already\n // passed (caller has `media:view`); a handler-level 404 here matches\n // the dispatcher's miss-shape so unknown URLs don't fall through to\n // the single-id branch and 400/500 with a misleading reason. The\n // only valid 2-segment URL is `<id>/usage`, which was already\n // claimed by the early-return above — anything else with >=2\n // segments is not a recognized media route.\n if (segments.length >= 2) return jsonError('Not found', 404)\n\n // GET /media/<id> — validate UUID up front so a bad request still\n // 400s when storage happens to be unconfigured (matches DELETE).\n const singleId = segments.length > 0 ? segments[0] : undefined\n if (singleId !== undefined && !isValidUuid(singleId)) {\n return jsonError('Invalid media ID format', 400)\n }\n\n // Everything below needs a working storage client. If env isn't\n // wired, return a structured \"disabled\" response rather than 500 —\n // the media list page renders a banner and new admins can finish\n // onboarding without being blocked on a crash loop.\n if (!isStorageConfigured()) {\n const reason = getStorageConfigReason() ?? 'Storage not configured'\n if (segments.length > 0) {\n // Single item — treat as not-found-ish to avoid leaking\n // existence; include reason so the UI can surface it.\n return jsonResponse({ error: reason, configured: false, reason }, 503)\n }\n // GET /media — empty list + disabled flag for the picker's banner.\n const response: MediaPickerListResult & { configured: false; reason: string } = {\n items: [],\n total: 0,\n configured: false,\n reason,\n }\n return jsonResponse(response)\n }\n\n const client = await getMediaClient()\n\n // GET /media/<id> — single item with URL (full record for EntityForm + picker)\n if (singleId !== undefined) {\n const record = await client.findById(singleId)\n if (!record) return jsonError('Media not found', 404)\n\n const url = await client.getUrl(singleId)\n return jsonResponse({ ...record, url })\n }\n\n // GET /media — list with search/filter + batch URL resolution\n const url = new URL(req.url)\n const search = url.searchParams.get('search') ?? undefined\n // mediaType MUST be validated against the entity's closed enum before\n // it reaches `client.findMany` — the previous shape force-cast the raw\n // query value with `as 'image' | ...` which is a type-system lie. A\n // bogus value like `?mediaType=bogus` would parameterize into\n // `WHERE media_type = 'bogus'` (no SQL injection — Drizzle is\n // parameterized — but the row count is silently zero, which is\n // confusing). Empty string from `?mediaType=` is also passed-through\n // by the prior `?? undefined` shape since empty string is not null.\n // Allowlist + type guard drops bogus / empty values cleanly so the\n // filter is omitted instead of applied with a no-match value.\n const rawMediaType = url.searchParams.get('mediaType')\n const mediaType =\n rawMediaType !== null && isAllowedMediaType(rawMediaType) ? rawMediaType : undefined\n // Strict parsing keeps the contract honest — see `parseUintParam`\n // JSDoc. Cap limit to [1, 100] and offset to [0, MAX_OFFSET] so a\n // request can't ask for an unboundedly large page nor force a\n // slow-discard scan with `?offset=99999999`.\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 24), 1), 100)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n const result = await client.findMany({\n ...(search !== undefined && { search }),\n ...(mediaType !== undefined && { mediaType }),\n limit,\n offset,\n })\n\n // Resolve original URLs for all items, but thumbnail variants ONLY for\n // images — a video/audio/document/other id will never have a `thumbnail`\n // variant, so asking `getVariantUrls` to resolve one is pure waste (a\n // storage lookup that falls through to the original, then gets discarded\n // below anyway). `media-list-page.tsx` filters the same way.\n const ids = result.items.map((item) => item.id)\n const imageIds = result.items.filter((item) => item.mediaType === 'image').map((item) => item.id)\n const [urlMap, thumbMap] = await Promise.all([\n client.getUrls(ids),\n client.getVariantUrls(imageIds, 'thumbnail'),\n ])\n\n const items: (MediaPickerItem & { thumbnailUrl?: string })[] = result.items.map((item) => {\n // F115: `getVariantUrls` falls back to the ORIGINAL asset URL for any id\n // with no `thumbnail` variant — correct for an image that just hasn't\n // been processed yet, wrong for a video/audio/document/other row, which\n // will never have one. Gating on `mediaType` keeps `thumbnailUrl` ABSENT\n // rather than pointing a consumer's `<img src>` at a `.mp4`.\n const thumbnailUrl = item.mediaType === 'image' ? thumbMap.get(item.id) : undefined\n return {\n id: item.id,\n title: item.title ?? null,\n alt: item.alt ?? null,\n filename: item.filename,\n mimeType: item.mimeType,\n size: item.size,\n mediaType: item.mediaType,\n url: urlMap.get(item.id) ?? '',\n ...(thumbnailUrl !== undefined && { thumbnailUrl }),\n width: item.width ?? null,\n height: item.height ?? null,\n }\n })\n\n const response: MediaPickerListResult = { items, total: result.total }\n return jsonResponse(response)\n}\n\nconst handleUpload: AdminRouteHandler = async (req, ctx) => {\n const {\n isStorageConfigured,\n getStorageConfigReason,\n detectMimeType,\n isMimeTypeAllowed,\n readBoundedMultipart,\n tooLarge,\n } = await import('@murumets-ee/storage')\n\n // Upload is registered at `path: ''` (NOT matchAnyPath) — the\n // dispatcher only invokes this handler when segments=[]. The api-\n // handler ALSO rejects empty/whitespace-only first segments with a\n // 400 before any plugin dispatcher runs (see\n // `packages/admin-ui/src/server/api-handler/index.ts` segment-\n // validation block). So this guard is defense-in-depth — per HANDOFF\n // §\"Defense-in-depth `&& segments[i]` guards are fine\" — protecting\n // against a future framework invariant change that lets a non-empty\n // segment reach a `path: ''` static entry.\n if (ctx.segments.length > 0) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // The plugin's `maxUploadSize` / `acceptedTypes`, from the registry the\n // merge step filled before any init — see `media-config.ts` (F007).\n const { maxUploadSize, acceptedTypes } = readMediaConfig()\n\n // F007: bound the body BEFORE `formData()` makes it resident — and before\n // building a MediaClient for a request that is about to be refused.\n const envelope = await readBoundedMultipart(req, maxUploadSize)\n if (!envelope.ok) return jsonError(envelope.error, envelope.status)\n const formData = envelope.value\n\n const file = formData.get('file')\n // FormData `get` returns string | File | null. Reject anything that\n // isn't a File so the typed `file.size`/`file.arrayBuffer()` below\n // is safe without a runtime cast.\n if (!(file instanceof File) || file.size === 0) {\n return jsonError('No file provided', 400)\n }\n\n // The envelope bound covers the whole body; this covers the file, before\n // `arrayBuffer()` copies it.\n if (file.size > maxUploadSize) {\n const refused = tooLarge(maxUploadSize)\n return jsonError(refused.error, refused.status)\n }\n\n const client = await getMediaClient()\n\n const buffer = Buffer.from(await file.arrayBuffer())\n\n // Detect actual MIME type from file content (prevents spoofing).\n const { mimeType, mismatch } = await detectMimeType(\n buffer,\n file.type || 'application/octet-stream',\n )\n if (mismatch) {\n return jsonError(\n `File content doesn't match declared type: claimed ${file.type}, detected ${mimeType}`,\n 400,\n )\n }\n // `acceptedTypes` against the DETECTED type, so a renamed file cannot\n // claim its way past the allowlist.\n if (!isMimeTypeAllowed(mimeType, acceptedTypes)) {\n return jsonError(`File type ${mimeType} is not accepted`, 415)\n }\n\n // Per-upload visibility, DEFAULTING TO PRIVATE — the caller opts in.\n //\n // This route serves two populations with opposite needs. The media picker\n // uploads publishable web assets: a page's hero image MUST be public or the\n // public site resolves it to `null` and renders no images at all, with a 200\n // on every page and one warn log (F030). `packages/rent-ui`'s inspection\n // wizard posts to this SAME route with handover and return photos — vehicle\n // and apartment condition shots, plates, interiors, occasionally documents —\n // which must stay behind time-limited signed urls.\n //\n // Deny-by-default decides the direction, and the asymmetry is the argument:\n // forget the opt-in and an image is invisible, which is annoying, visible and\n // fixable; default to public and forget an opt-out and those photos become\n // permanently world-readable. A missed opt-in cannot leak. So the default is\n // unchanged (`private` via StorageClient's own fallback) and rent's caller is\n // untouched — it sends nothing and behaves exactly as before.\n const visibilityRaw = formData.get('visibility')\n if (visibilityRaw !== null && visibilityRaw !== 'public' && visibilityRaw !== 'private') {\n return jsonError(\"Field 'visibility' must be 'public' or 'private'\", 400)\n }\n const result = await client.upload(buffer, {\n filename: file.name,\n mimeType,\n size: file.size,\n uploadedBy: ctx.user.id,\n ...(visibilityRaw !== null && { visibility: visibilityRaw }),\n })\n\n const item: MediaPickerItem = {\n id: result.media.id,\n title: result.media.title ?? null,\n alt: result.media.alt ?? null,\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n url: result.url,\n width: result.media.width ?? null,\n height: result.media.height ?? null,\n }\n\n safeAudit(ctx, {\n action: 'media.upload',\n entityType: 'media',\n entityId: result.media.id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n changes: {\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n },\n })\n\n return jsonResponse(item, 201)\n}\n\nconst handleRegenerateVariants: AdminRouteHandler = async (_req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n\n // Static POST path='regenerate-variants' — the dispatcher only invokes\n // this handler when `segments[0] === 'regenerate-variants'`. Trailing\n // segments (e.g. POST /media/regenerate-variants/extra) aren't a\n // supported sub-route.\n if (ctx.segments.length !== 1) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const { regenerateAllVariants } = await import('../regenerate-variants.js')\n const { getApp, getContext } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('../resolve-image-styles.js')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const styles = await resolveImageStyles(app, app.logger)\n if (!styles || Object.keys(styles).length === 0) {\n return jsonError('No image styles configured', 400)\n }\n\n const storageConfig = getStorageConfig()\n const storage = createStorageClient(storageConfig, { app })\n\n const result = await regenerateAllVariants({\n app,\n storage,\n logger: app.logger.child({ media: true }),\n styles,\n contextResolver: () => {\n const requestCtx = getContext()\n if (!requestCtx?.user || !requestCtx?.checker) return undefined\n return {\n user: requestCtx.user,\n checker: requestCtx.checker,\n ...(requestCtx.scope !== undefined && { scope: requestCtx.scope }),\n }\n },\n })\n\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { total: result.total, processed: result.processed, errors: result.errors },\n })\n\n return jsonResponse(result)\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const segments = ctx.segments\n\n // DELETE /media — no id supplied. Reject as 400, NOT 404, so a\n // caller confusing \"no id\" with \"id not found\" gets a meaningful\n // error. The wrapper-gate has already validated the caller has\n // media:delete, so the 400 isn't an information-disclosure vector.\n if (segments.length === 0) return jsonError('Media ID required', 400)\n\n // Reject sub-paths beyond /<id> — DELETE /media/<id>/usage etc.\n if (segments.length > 1) return jsonError('Not found', 404)\n\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n // Can't safely delete — MediaClient needs storage to remove the\n // actual object, and partial delete (DB row gone, object orphaned)\n // would leak storage. Surface the reason so the UI can display it.\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // AdminClient.delete() checks entity_refs and throws\n // ReferencedEntityError if this media is still referenced. The\n // error bubbles to the caller's error handler which returns 409\n // with usage details.\n const client = await getMediaClient()\n await client.delete(id)\n\n safeAudit(ctx, {\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n })\n\n return jsonResponse({ deleted: 1 })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Default roles for view: broad reach across the admin shell.\n *\n * `BUILT_IN_ROLES` in `packages/auth/src/permissions.ts` is\n * `['admin', 'public', 'authenticated', 'agent', 'customer']` — `admin` is\n * hardcoded as an unconditional yes in `buildPermissionChecker`,\n * `agent` is the only non-admin built-in that flows through\n * `buildInitialRoleDefinitions()` + `upsertBuiltInRoles` with default\n * media access (`customer` seeds with zero permissions — see that\n * function's JSDoc). The `agent` entry in this list is what gives ops\n * agents read-only media access on first boot of a fresh install.\n *\n * `editor` and `viewer` are CONVENTIONAL app-defined roles —\n * apps that follow the scaffold pattern create them as part of\n * their own role catalog. Including them in `defaultRoles` doesn't\n * seed them (the seeder only iterates the built-ins above), but\n * the values DO flow into the process-local permission catalog\n * (see `registerPermission` in `@murumets-ee/core`), which the\n * forthcoming Permission Matrix UI (PR-D) reads to show \"this\n * route's recommended default grants\" against the app's actual\n * role set. The catalog is also the mechanism via which a future\n * upgrade of `upsertBuiltInRoles` could extend the seed surface\n * to app-defined roles without each plugin having to re-declare\n * its defaults.\n */\nconst VIEW_DEFAULT_ROLES = ['admin', 'editor', 'agent', 'viewer'] as const\n\n/** Default roles for create/delete — narrower than view. */\nconst WRITE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Build admin API routes for media management.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry per\n * `(prefix, method)` after grouping. Callers spread the result into\n * their top-level routes list: `routes: [...mediaRoutes()]`.\n *\n * Four `defineAdminRoute` entries:\n *\n * - GET matchAnyPath with `media:view` defaultRoles: VIEW_DEFAULT_ROLES\n * - POST path='' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - POST path='regenerate-variants' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - DELETE matchAnyPath with `media:delete` defaultRoles: WRITE_DEFAULT_ROLES\n *\n * Permissions are auto-registered by the entity catalog\n * (`buildResourceCatalog` in `@murumets-ee/auth`) AND by\n * `registerPermission` inside `defineAdminRoute`. Idempotent —\n * `registerPermission` unions `defaultRoles` on repeat registration,\n * so the framework's two pathways agree on the final grant set.\n *\n * Standard entity CRUD (PATCH with translations) is NOT registered\n * here — it falls through to the generic entity handler. Add `Media`\n * to the `entities` array in your handler config to enable it.\n */\nexport function mediaRoutes(): AdminRoute[] {\n const entries = [\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'GET',\n permission: 'media:view',\n defaultRoles: VIEW_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Read media — list, single item, or referencing-entity usage report.',\n handler: handleGet,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Upload a media file (multipart FormData with `file` field).',\n handler: handleUpload,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: 'regenerate-variants',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Regenerate every image-style variant for every media row.',\n handler: handleRegenerateVariants,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'DELETE',\n permission: 'media:delete',\n defaultRoles: WRITE_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Delete a media record + its storage object.',\n handler: handleDelete,\n }),\n ]\n\n return combineAdminRoutes(entries)\n}\n","/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin, getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport { ImageStylesManager, RegenerateVariantsAction } from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { MEDIA_PLUGIN_NAME, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): Required<MediaPluginConfig> {\n if (!hasPluginConfig(MEDIA_PLUGIN_NAME)) {\n throw new Error('@murumets-ee/media plugin not initialized. Add media() to your plugins array.')\n }\n return getPluginConfig(MEDIA_PLUGIN_NAME)\n}\n\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"plugin.mjs","names":[],"sources":["../src/media-config.ts","../src/admin/routes.ts","../src/plugin.ts"],"sourcesContent":["/**\n * The media plugin's resolved configuration — defaults in one place, read\n * from the framework's `PluginConfigRegistry`.\n *\n * The plugin used to stash its config in a module variable from `init`, and\n * the upload route hardcoded 50 MB with a comment explaining it could not\n * trust that variable to be set yet. So `acceptedTypes` and `maxUploadSize`\n * were declared, documented, logged at boot — and enforced nowhere (F007).\n * `server.config` is placed in the registry at the merge step, BEFORE any\n * `init`, which removes the reason the route could not read it.\n */\n\nimport { getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\nimport type { MediaPluginConfig } from './types.js'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/media': Required<MediaPluginConfig>\n }\n}\n\nexport const MEDIA_PLUGIN_NAME = '@murumets-ee/media'\n\n/**\n * The default `acceptedTypes`, matched against what `detectMimeType` (file-type\n * 21) actually REPORTS — not what a browser claims.\n *\n * Wider than the image/video/audio/PDF the option's docs used to list, on\n * purpose: nothing enforced that list, so modern Office and OpenDocument files\n * were accepted and are in libraries today. Enforcing the narrow list would\n * have turned an F007 memory fix into a silent feature removal.\n *\n * Deliberately NOT in the default, each refused with 415 unless an app lists it:\n * - **Legacy binary Office** (`.doc`, `.xls`, `.ppt`). file-type reports them as\n * `application/x-cfb` — the same container as `.msi` installers and Outlook\n * `.msg` files, so it cannot be allowed for one without the others.\n * - **Macro-enabled Office** (`.docm`, `.xlsm`, `.pptm`), reported with their own\n * `…macroenabled…` types.\n * - **Archives and executables**, and the text types (`application/json`,\n * `text/markdown`, TSV) that `detectMimeType` trusts but the library does not\n * classify as documents.\n *\n * Named office families rather than `application/vnd.*`, which also matches\n * executables and archives.\n */\nexport const DEFAULT_MEDIA_ACCEPTED_TYPES: readonly string[] = [\n 'image/*',\n 'video/*',\n 'audio/*',\n 'application/pdf',\n 'application/vnd.openxmlformats-officedocument.*',\n 'application/vnd.oasis.opendocument.*',\n 'text/plain',\n 'text/csv',\n]\n\n/** Fill every unset option with its default. */\nexport function resolveMediaConfig(config?: MediaPluginConfig): Required<MediaPluginConfig> {\n return {\n acceptedTypes: config?.acceptedTypes ?? [...DEFAULT_MEDIA_ACCEPTED_TYPES],\n // Non-positive means unset, as it does for storage's `maxFileSize`.\n maxUploadSize:\n config?.maxUploadSize !== undefined && config.maxUploadSize > 0\n ? config.maxUploadSize\n : 50 * 1024 * 1024,\n // Resolved for the picker's own use, and deliberately NOT applied by the\n // upload route: an upload that sends no visibility stays private (rent's\n // inspection photos rely on it). See the route's deny-by-default comment.\n defaultVisibility: config?.defaultVisibility ?? 'public',\n imageStyles: config?.imageStyles ?? {\n thumbnail: { width: 200, height: 200, fit: 'cover', format: 'webp', quality: 80 },\n },\n }\n}\n\n/**\n * The registered config, or the defaults when the plugin is not registered\n * (a unit test, a CLI path that never ran `createApp`). The defaults are what\n * `media()` with no arguments resolves to, so the fallback is never looser\n * than a registered plugin that configured nothing.\n */\nexport function readMediaConfig(): Required<MediaPluginConfig> {\n return hasPluginConfig(MEDIA_PLUGIN_NAME)\n ? getPluginConfig(MEDIA_PLUGIN_NAME)\n : resolveMediaConfig()\n}\n","/**\n * Media admin routes — one static `defineAdminRoute` per HTTP method.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/media — list with search/filter + URLs\n * - GET /api/admin/media/<id> — single record with URL\n * - GET /api/admin/media/<id>/usage — entities referencing this media\n * - POST /api/admin/media — upload (multipart FormData)\n * - POST /api/admin/media/regenerate-variants — bulk variant regeneration\n * - DELETE /api/admin/media/<id> — delete + storage cleanup\n * - DELETE /api/admin/media — bulk delete (body.ids[], ≤100)\n *\n * Standard entity CRUD (PATCH with translations) falls through to the\n * generic entity handler — add `Media` to `entities` in your handler config.\n *\n * **Why per-method `defineAdminRoute`** (vs. the previous top-level\n * `resource: 'media'` + auto-mapped permission):\n *\n * - The wrapper (`guardedHandler` from `@murumets-ee/core`) owns the\n * permission gate, `permission.denied` audit, and 403 body shape\n * uniformly with every other migrated plugin. The previous shape\n * relied on `METHOD_TO_ACTION` auto-mapping at the api-handler\n * dispatch level which couldn't emit per-route audit metadata.\n * - Inline `if (!checkPermission('media', 'create')) ...` defenses\n * at the regenerate-variants / upload / delete branches are now\n * redundant — the wrapper enforces the permission BEFORE the\n * handler runs.\n *\n * **`matchAnyPath` rationale:** media's URL surface predates the\n * static-second-segment convention every other plugin follows\n * (`/taxonomy/<vocab>/<id>`, `/settings/<namespace>/<rest>`). The\n * second segment under `/media` is the media id itself — a runtime\n * UUID — which the dispatcher's literal `segments[0]` match would\n * miss for every request. The catch-all flag declares \"this entry\n * claims every (prefix, method) sub-path no other entry matched\" so\n * the existing URL contract works under the per-method dispatch\n * model. Static POST sub-paths (`regenerate-variants`) take\n * precedence over the catch-all entry on POST. See\n * `DefineAdminRouteSpec.matchAnyPath` in `@murumets-ee/core` for the\n * full contract.\n *\n * **Default roles:** broad for view (`['admin', 'editor', 'agent',\n * 'viewer']`), narrower for writes (`['admin', 'editor']`).\n * Conservative starting point — apps can grant more via the\n * Permissions UI.\n *\n * @example\n * ```typescript\n * import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'\n * import { mediaRoutes } from '@murumets-ee/media/admin'\n * import { Media } from '@murumets-ee/media'\n *\n * const handler = createAdminApiHandler({\n * authenticate: async (req) => { ... },\n * entities: [Article, Media],\n * routes: [...mediaRoutes()],\n * })\n * ```\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n runBounded,\n safeAudit,\n safeAuditMany,\n} from '@murumets-ee/core'\nimport type { MediaClient } from '../client.js'\nimport { readMediaConfig } from '../media-config.js'\nimport type { MediaPickerItem, MediaPickerListResult } from '../picker/types.js'\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isValidUuid(value: string | undefined): value is string {\n return value !== undefined && UUID_RE.test(value)\n}\n\n/**\n * Closed enum of allowed `mediaType` filter values, in lockstep with the\n * `mediaType` field on the Media entity. The list MUST match\n * `MediaListOptions.mediaType` (declared in `../types.js`) — TypeScript\n * doesn't link the two automatically, so the small drift risk is\n * accepted in exchange for not pulling a runtime dep on the entity\n * schema package just to read its enum.\n *\n * Used as both the runtime allowlist (`isAllowedMediaType`) and the\n * source of truth for the narrowed type the guard produces — keeping\n * `as` casts off the handler's hot path.\n */\nconst ALLOWED_MEDIA_TYPES = ['image', 'video', 'audio', 'document', 'other'] as const\ntype AllowedMediaType = (typeof ALLOWED_MEDIA_TYPES)[number]\n\nfunction isAllowedMediaType(value: string): value is AllowedMediaType {\n // Cast to readonly string[] so the typed-tuple `includes` check works\n // with an arbitrary string input — `Array.prototype.includes`'s\n // signature on a typed tuple only accepts members of the tuple\n // (TS 4.9+ literal-narrowing), which is exactly what we want to\n // sidestep here.\n return (ALLOWED_MEDIA_TYPES as readonly string[]).includes(value)\n}\n\n/**\n * Hard cap on the `offset` query param. The dispatcher's allowed-pages\n * model is `limit ∈ [1, 100]`; even with the max page size that's\n * 1000 pages before hitting MAX_OFFSET, which is well beyond any\n * realistic admin-UI scroll depth. Forces slow-discard offset attacks\n * (`?offset=999999999`) to revert to a sane upper bound. Cursor-based\n * pagination would let this go higher, but the media list is\n * offset-based by convention with the entity-list shell.\n */\nconst MAX_OFFSET = 100_000\n\n/**\n * Strict non-negative integer parser for query params.\n *\n * `Number(raw)` silently coerces blanks (`'' → 0`), hex (`'0x05' → 5`),\n * exponent notation (`'1e2' → 100`), and signed (`'+5'/'-5'`) values —\n * all of which pass a downstream `Number.isInteger(n) && n >= 0` check.\n * Pre-filtering with `/^\\d+$/` keeps the contract honest: \"if you don't\n * send a pure non-negative integer string, you get the default.\" Same\n * pattern as `packages/taxonomy/src/admin/routes.ts:handleGet` tree\n * branch from PR-C-taxonomy.\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n // The regex above stops hex / exponent / signed / whitespace / blank\n // inputs, but a long digit-only string still slips past — `Number(...)`\n // returns `Infinity` past ~309 digits, and precision is lost past\n // 2^53. Both would propagate through `client.findMany`'s\n // `offset: parsed` into a Postgres query that either crashes the\n // driver (`Infinity` is not a valid bigint) or silently rounds (the\n // 2^53 precision-loss boundary). Reject via `Number.isSafeInteger`\n // so overflowing inputs revert to `fallback` like every other\n // malformed value.\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n// ---------------------------------------------------------------------------\n// Response helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n// ---------------------------------------------------------------------------\n// Per-request MediaClient factory + cached storage client\n// ---------------------------------------------------------------------------\n//\n// MediaClient and its AdminClient MUST be built per-request — AdminClient's\n// context resolver is captured eagerly at construction (see\n// buildContextResolver in @murumets-ee/core/clients). A module-level singleton\n// would bake the first request's user + tenant scope into every subsequent\n// request's writes — a cross-request permission/scope leak.\n//\n// Storage config is process-global and safe to cache once.\n\ntype StorageClient = Awaited<ReturnType<typeof import('@murumets-ee/storage').createStorageClient>>\n\ninterface StorageState {\n clientPromise: Promise<StorageClient> | null\n}\n\n// Module-level state. `null` once eviction fires on a rejection so the next\n// request gets a fresh resolution attempt (see `getStorage`'s `.catch`).\nconst storageState: StorageState = { clientPromise: null }\n\n/**\n * Resolve the process-wide storage client. The pending-promise slot is\n * cleared on rejection so a transient `createStorageClient` failure\n * (DB hiccup, misconfig) doesn't poison every subsequent media request\n * for the lifetime of the Node process. Same pattern as\n * `packages/taxonomy/src/admin/routes.ts:getClient` from PR-C-taxonomy.\n */\nasync function getStorage(): Promise<StorageClient> {\n if (!storageState.clientPromise) {\n const pending = (async () => {\n const { getApp } = await import('@murumets-ee/core')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n const app = getApp()\n return createStorageClient(getStorageConfig(), { app })\n })()\n // Clear the slot on rejection so a transient failure doesn't poison\n // every subsequent request. `pending.catch(handler)` attaches a\n // synchronous rejection listener that resets the cached slot — the\n // handler returns `undefined`, NOT a re-throw — so the new\n // `.catch`-returned promise resolves, but we never await or hold\n // that promise. The ORIGINAL `pending` (stored in\n // `storageState.clientPromise`) is what callers `await`, and it's\n // still in its rejected state — so the caller observing this\n // resolution sees the underlying error. The `.catch` also marks\n // the rejection as \"handled\" for Node's unhandled-rejection\n // tracker. `storageState.clientPromise` holds the original `pending`\n // so concurrent in-flight callers share one resolution.\n pending.catch(() => {\n if (storageState.clientPromise === pending) storageState.clientPromise = null\n })\n storageState.clientPromise = pending\n }\n return storageState.clientPromise\n}\n\nasync function getMediaClient(): Promise<MediaClient> {\n const { createAdminClient } = await import('@murumets-ee/core/clients')\n const { MediaClient } = await import('../client.js')\n const { Media } = await import('../entity.js')\n const storage = await getStorage()\n const admin = createAdminClient(Media)\n return new MediaClient({ admin, storage })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleGet: AdminRouteHandler = async (req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n const segments = ctx.segments\n\n // GET /media/<id>/usage — DB-only lookup, safe even when storage is\n // unconfigured. Validate the UUID first so a malformed id 400s\n // uniformly with the storage-configured branch below.\n if (segments.length === 2 && segments[1] === 'usage') {\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { findMediaUsages } = await import('../usage.js')\n const { getApp } = await import('@murumets-ee/core')\n const app = getApp()\n const usages = await findMediaUsages(id, app)\n return jsonResponse({ usages })\n }\n\n // Reject unknown sub-paths explicitly. The wrapper-gate has already\n // passed (caller has `media:view`); a handler-level 404 here matches\n // the dispatcher's miss-shape so unknown URLs don't fall through to\n // the single-id branch and 400/500 with a misleading reason. The\n // only valid 2-segment URL is `<id>/usage`, which was already\n // claimed by the early-return above — anything else with >=2\n // segments is not a recognized media route.\n if (segments.length >= 2) return jsonError('Not found', 404)\n\n // GET /media/<id> — validate UUID up front so a bad request still\n // 400s when storage happens to be unconfigured (matches DELETE).\n const singleId = segments.length > 0 ? segments[0] : undefined\n if (singleId !== undefined && !isValidUuid(singleId)) {\n return jsonError('Invalid media ID format', 400)\n }\n\n // Everything below needs a working storage client. If env isn't\n // wired, return a structured \"disabled\" response rather than 500 —\n // the media list page renders a banner and new admins can finish\n // onboarding without being blocked on a crash loop.\n if (!isStorageConfigured()) {\n const reason = getStorageConfigReason() ?? 'Storage not configured'\n if (segments.length > 0) {\n // Single item — treat as not-found-ish to avoid leaking\n // existence; include reason so the UI can surface it.\n return jsonResponse({ error: reason, configured: false, reason }, 503)\n }\n // GET /media — empty list + disabled flag for the picker's banner.\n const response: MediaPickerListResult & { configured: false; reason: string } = {\n items: [],\n total: 0,\n configured: false,\n reason,\n }\n return jsonResponse(response)\n }\n\n const client = await getMediaClient()\n\n // GET /media/<id> — single item with URL (full record for EntityForm + picker)\n if (singleId !== undefined) {\n const record = await client.findById(singleId)\n if (!record) return jsonError('Media not found', 404)\n\n const url = await client.getUrl(singleId)\n return jsonResponse({ ...record, url })\n }\n\n // GET /media — list with search/filter + batch URL resolution\n const url = new URL(req.url)\n const search = url.searchParams.get('search') ?? undefined\n // mediaType MUST be validated against the entity's closed enum before\n // it reaches `client.findMany` — the previous shape force-cast the raw\n // query value with `as 'image' | ...` which is a type-system lie. A\n // bogus value like `?mediaType=bogus` would parameterize into\n // `WHERE media_type = 'bogus'` (no SQL injection — Drizzle is\n // parameterized — but the row count is silently zero, which is\n // confusing). Empty string from `?mediaType=` is also passed-through\n // by the prior `?? undefined` shape since empty string is not null.\n // Allowlist + type guard drops bogus / empty values cleanly so the\n // filter is omitted instead of applied with a no-match value.\n const rawMediaType = url.searchParams.get('mediaType')\n const mediaType =\n rawMediaType !== null && isAllowedMediaType(rawMediaType) ? rawMediaType : undefined\n // Strict parsing keeps the contract honest — see `parseUintParam`\n // JSDoc. Cap limit to [1, 100] and offset to [0, MAX_OFFSET] so a\n // request can't ask for an unboundedly large page nor force a\n // slow-discard scan with `?offset=99999999`.\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 24), 1), 100)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n const result = await client.findMany({\n ...(search !== undefined && { search }),\n ...(mediaType !== undefined && { mediaType }),\n limit,\n offset,\n })\n\n // Resolve original URLs for all items, but thumbnail variants ONLY for\n // images — a video/audio/document/other id will never have a `thumbnail`\n // variant, so asking `getVariantUrls` to resolve one is pure waste (a\n // storage lookup that falls through to the original, then gets discarded\n // below anyway). `media-list-page.tsx` filters the same way.\n const ids = result.items.map((item) => item.id)\n const imageIds = result.items.filter((item) => item.mediaType === 'image').map((item) => item.id)\n const [urlMap, thumbMap] = await Promise.all([\n client.getUrls(ids),\n client.getVariantUrls(imageIds, 'thumbnail'),\n ])\n\n const items: (MediaPickerItem & { thumbnailUrl?: string })[] = result.items.map((item) => {\n // F115: `getVariantUrls` falls back to the ORIGINAL asset URL for any id\n // with no `thumbnail` variant — correct for an image that just hasn't\n // been processed yet, wrong for a video/audio/document/other row, which\n // will never have one. Gating on `mediaType` keeps `thumbnailUrl` ABSENT\n // rather than pointing a consumer's `<img src>` at a `.mp4`.\n const thumbnailUrl = item.mediaType === 'image' ? thumbMap.get(item.id) : undefined\n return {\n id: item.id,\n title: item.title ?? null,\n alt: item.alt ?? null,\n filename: item.filename,\n mimeType: item.mimeType,\n size: item.size,\n mediaType: item.mediaType,\n url: urlMap.get(item.id) ?? '',\n ...(thumbnailUrl !== undefined && { thumbnailUrl }),\n width: item.width ?? null,\n height: item.height ?? null,\n }\n })\n\n const response: MediaPickerListResult = { items, total: result.total }\n return jsonResponse(response)\n}\n\nconst handleUpload: AdminRouteHandler = async (req, ctx) => {\n const {\n isStorageConfigured,\n getStorageConfigReason,\n detectMimeType,\n isMimeTypeAllowed,\n readBoundedMultipart,\n tooLarge,\n } = await import('@murumets-ee/storage')\n\n // Upload is registered at `path: ''` (NOT matchAnyPath) — the\n // dispatcher only invokes this handler when segments=[]. The api-\n // handler ALSO rejects empty/whitespace-only first segments with a\n // 400 before any plugin dispatcher runs (see\n // `packages/admin-ui/src/server/api-handler/index.ts` segment-\n // validation block). So this guard is defense-in-depth — per HANDOFF\n // §\"Defense-in-depth `&& segments[i]` guards are fine\" — protecting\n // against a future framework invariant change that lets a non-empty\n // segment reach a `path: ''` static entry.\n if (ctx.segments.length > 0) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // The plugin's `maxUploadSize` / `acceptedTypes`, from the registry the\n // merge step filled before any init — see `media-config.ts` (F007).\n const { maxUploadSize, acceptedTypes } = readMediaConfig()\n\n // F007: bound the body BEFORE `formData()` makes it resident — and before\n // building a MediaClient for a request that is about to be refused.\n const envelope = await readBoundedMultipart(req, maxUploadSize)\n if (!envelope.ok) return jsonError(envelope.error, envelope.status)\n const formData = envelope.value\n\n const file = formData.get('file')\n // FormData `get` returns string | File | null. Reject anything that\n // isn't a File so the typed `file.size`/`file.arrayBuffer()` below\n // is safe without a runtime cast.\n if (!(file instanceof File) || file.size === 0) {\n return jsonError('No file provided', 400)\n }\n\n // The envelope bound covers the whole body; this covers the file, before\n // `arrayBuffer()` copies it.\n if (file.size > maxUploadSize) {\n const refused = tooLarge(maxUploadSize)\n return jsonError(refused.error, refused.status)\n }\n\n const client = await getMediaClient()\n\n const buffer = Buffer.from(await file.arrayBuffer())\n\n // Detect actual MIME type from file content (prevents spoofing).\n const { mimeType, mismatch } = await detectMimeType(\n buffer,\n file.type || 'application/octet-stream',\n )\n if (mismatch) {\n return jsonError(\n `File content doesn't match declared type: claimed ${file.type}, detected ${mimeType}`,\n 400,\n )\n }\n // `acceptedTypes` against the DETECTED type, so a renamed file cannot\n // claim its way past the allowlist.\n if (!isMimeTypeAllowed(mimeType, acceptedTypes)) {\n return jsonError(`File type ${mimeType} is not accepted`, 415)\n }\n\n // Per-upload visibility, DEFAULTING TO PRIVATE — the caller opts in.\n //\n // This route serves two populations with opposite needs. The media picker\n // uploads publishable web assets: a page's hero image MUST be public or the\n // public site resolves it to `null` and renders no images at all, with a 200\n // on every page and one warn log (F030). `packages/rent-ui`'s inspection\n // wizard posts to this SAME route with handover and return photos — vehicle\n // and apartment condition shots, plates, interiors, occasionally documents —\n // which must stay behind time-limited signed urls.\n //\n // Deny-by-default decides the direction, and the asymmetry is the argument:\n // forget the opt-in and an image is invisible, which is annoying, visible and\n // fixable; default to public and forget an opt-out and those photos become\n // permanently world-readable. A missed opt-in cannot leak. So the default is\n // unchanged (`private` via StorageClient's own fallback) and rent's caller is\n // untouched — it sends nothing and behaves exactly as before.\n const visibilityRaw = formData.get('visibility')\n if (visibilityRaw !== null && visibilityRaw !== 'public' && visibilityRaw !== 'private') {\n return jsonError(\"Field 'visibility' must be 'public' or 'private'\", 400)\n }\n const result = await client.upload(buffer, {\n filename: file.name,\n mimeType,\n size: file.size,\n uploadedBy: ctx.user.id,\n ...(visibilityRaw !== null && { visibility: visibilityRaw }),\n })\n\n const item: MediaPickerItem = {\n id: result.media.id,\n title: result.media.title ?? null,\n alt: result.media.alt ?? null,\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n url: result.url,\n width: result.media.width ?? null,\n height: result.media.height ?? null,\n }\n\n safeAudit(ctx, {\n action: 'media.upload',\n entityType: 'media',\n entityId: result.media.id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n changes: {\n filename: result.media.filename,\n mimeType: result.media.mimeType,\n size: result.media.size,\n mediaType: result.media.mediaType,\n },\n })\n\n return jsonResponse(item, 201)\n}\n\nconst handleRegenerateVariants: AdminRouteHandler = async (_req, ctx) => {\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n\n // Static POST path='regenerate-variants' — the dispatcher only invokes\n // this handler when `segments[0] === 'regenerate-variants'`. Trailing\n // segments (e.g. POST /media/regenerate-variants/extra) aren't a\n // supported sub-route.\n if (ctx.segments.length !== 1) return jsonError('Not found', 404)\n\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const { regenerateAllVariants } = await import('../regenerate-variants.js')\n const { getApp, getContext } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('../resolve-image-styles.js')\n const { createStorageClient } = await import('@murumets-ee/storage')\n const { getStorageConfig } = await import('@murumets-ee/storage/plugin')\n\n const app = getApp()\n const styles = await resolveImageStyles(app, app.logger)\n if (!styles || Object.keys(styles).length === 0) {\n return jsonError('No image styles configured', 400)\n }\n\n const storageConfig = getStorageConfig()\n const storage = createStorageClient(storageConfig, { app })\n\n const result = await regenerateAllVariants({\n app,\n storage,\n logger: app.logger.child({ media: true }),\n styles,\n contextResolver: () => {\n const requestCtx = getContext()\n if (!requestCtx?.user || !requestCtx?.checker) return undefined\n return {\n user: requestCtx.user,\n checker: requestCtx.checker,\n ...(requestCtx.scope !== undefined && { scope: requestCtx.scope }),\n }\n },\n })\n\n safeAudit(ctx, {\n action: 'media.regenerate_variants',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { total: result.total, processed: result.processed, errors: result.errors },\n })\n\n return jsonResponse(result)\n}\n\n/**\n * Cap on a bulk delete — the same number the generic entity CRUD route's own\n * bulk delete uses (`entity-crud.ts`'s `MAX_BULK_DELETE`), so a caller sees\n * one consistent limit regardless of which admin list it is deleting from.\n */\nconst MAX_BULK_DELETE = 100\n\n/**\n * Bound on the bulk-delete request body. A body here is `{ ids: string[] }`\n * of short UUID strings — generous headroom for {@link MAX_BULK_DELETE} of\n * them (each ~38 bytes quoted-and-comma'd).\n */\nconst MAX_BULK_DELETE_BODY_BYTES = 16 * 1024\n\n/** A validated bulk-delete request, or the error response to send instead. */\ntype BulkDeleteBody = { ids: string[] } | { error: string; status: number }\n\n/**\n * Read and parse the bulk-delete body, refusing anything past\n * {@link MAX_BULK_DELETE_BODY_BYTES}.\n *\n * `content-length` alone is NOT a bound — it is client-supplied, so a\n * chunked request (no header at all) or a spoofed small one sails through a\n * header-only check straight into an unbounded `req.json()` buffering\n * whatever the caller sends (review finding, entity-table PR06). Streamed\n * and byte-counted instead, same shape `entity-crud.ts`'s `readJsonBody`\n * uses for arbitrary write bodies — this one is sized for a much smaller,\n * closed shape, not copied wholesale, because `@murumets-ee/media` must not\n * depend on `@murumets-ee/admin-ui` (wrong dependency direction).\n */\nasync function readBulkDeleteBody(req: Request): Promise<BulkDeleteBody> {\n const declared = Number(req.headers.get('content-length'))\n if (Number.isFinite(declared) && declared > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n\n const body = req.body\n let text: string\n if (body === null) {\n text = await req.text()\n if (Buffer.byteLength(text, 'utf8') > MAX_BULK_DELETE_BODY_BYTES) {\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n } else {\n const reader = body.getReader()\n const chunks: Uint8Array[] = []\n let total = 0\n try {\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value === undefined) continue\n total += value.byteLength\n if (total > MAX_BULK_DELETE_BODY_BYTES) {\n // Stop pulling — without the cancel the peer keeps sending into a\n // reader nobody drains, which is the connection this refusal\n // exists to end.\n await reader.cancel().catch(() => undefined)\n return { error: 'Request body exceeds the maximum size', status: 413 }\n }\n chunks.push(value)\n }\n } finally {\n reader.releaseLock()\n }\n text = Buffer.concat(chunks).toString('utf8')\n }\n\n let raw: unknown\n try {\n raw = JSON.parse(text)\n } catch {\n return { error: 'Invalid JSON body', status: 400 }\n }\n if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {\n return { error: 'Body must be a JSON object', status: 400 }\n }\n\n const ids = (raw as Record<string, unknown>).ids\n if (!Array.isArray(ids) || ids.length === 0) {\n return { error: 'Body must contain \"ids\" array', status: 400 }\n }\n if (ids.length > MAX_BULK_DELETE) {\n return { error: `Bulk delete limited to ${MAX_BULK_DELETE} items per request`, status: 400 }\n }\n\n // Reject anything that isn't a well-formed media id before any mutation —\n // `client.delete()` would otherwise throw mid-batch, leaving a partially\n // deleted set for a request that was malformed from the start.\n //\n // `Array.isArray` narrows to `any[]` (its lib.es5.d.ts signature is\n // `arg is any[]`), so without the cast below every read of the loop\n // variable counts as an `any` against `verify:types` — `as unknown[]`\n // resets it to a type `typeof`/`isValidUuid` still narrow correctly, one\n // step later than the guard that actually proves it.\n const validated: string[] = []\n for (const id of ids as unknown[]) {\n if (typeof id !== 'string' || !isValidUuid(id)) {\n return { error: 'Body \"ids\" must contain valid media IDs', status: 400 }\n }\n validated.push(id)\n }\n return { ids: validated }\n}\n\nconst handleDelete: AdminRouteHandler = async (req, ctx) => {\n const segments = ctx.segments\n\n // DELETE /media — no id supplied. Bulk delete (body: { ids: string[] }),\n // matching the generic entity CRUD route's own bulk shape — `EntityList`'s\n // \"Delete selected\" always sends this, and Media's `matchAnyPath` DELETE\n // entry claims the whole `/media` prefix, so without this branch that\n // request lands here and gets refused with \"Media ID required\" (found via\n // browser verification, entity-table PR06 — S8's demonstration on a real\n // Media list is what surfaced it: nothing before this PR had exercised a\n // bulk delete against the live Media admin page).\n if (segments.length === 0) {\n const parsed = await readBulkDeleteBody(req)\n if ('error' in parsed) return jsonError(parsed.error, parsed.status)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n const client = await getMediaClient()\n // MAX_BULK_DELETE bounds the TOTAL; `runBounded` bounds the CONCURRENCY —\n // each `delete()` does entity DB work AND storage object/metadata work,\n // so 100 ids fired via a bare `Promise.all` would open up to 100 of both\n // at once, which is exactly the shared-pool-contention shape CLAUDE.md's\n // \"No Unbounded Fan-Out\" rule exists to catch (CodeRabbit CLI finding,\n // entity-table PR06). The worker catches internally and returns a\n // settled-shaped result instead of letting `runBounded` see a rejection —\n // that keeps its own fail-fast behavior from firing, so this stays\n // allSettled-equivalent: every id gets attempted regardless of a sibling's\n // failure, and the audit trail for the ones that DID succeed survives one\n // that didn't (`safeAuditMany` below would otherwise be skipped entirely\n // on any rejection, auditing NONE of them). The first failure is still\n // rethrown after the audit write, so the response shape is unchanged —\n // no new 207-partial-success contract.\n const results = await runBounded(parsed.ids, async (id) => {\n try {\n await client.delete(id)\n return { status: 'fulfilled' as const, id }\n } catch (reason) {\n return { status: 'rejected' as const, id, reason }\n }\n })\n const deletedIds = results.filter((r) => r.status === 'fulfilled').map((r) => r.id)\n\n // ONE batched write, awaited — not N unawaited `safeAudit` calls racing\n // the connection pool after the response is already decided (review\n // finding, entity-table PR06; `safeAuditMany`'s own docblock names this\n // exact loop shape as the reason it exists).\n await safeAuditMany(\n ctx,\n deletedIds.map((id) => ({\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: { bulk: true },\n })),\n )\n\n const failure = results.find((r) => r.status === 'rejected')\n if (failure && failure.status === 'rejected') throw failure.reason\n\n return jsonResponse({ deleted: deletedIds.length })\n }\n\n // Reject sub-paths beyond /<id> — DELETE /media/<id>/usage etc.\n if (segments.length > 1) return jsonError('Not found', 404)\n\n const id = segments[0]\n if (!isValidUuid(id)) return jsonError('Invalid media ID format', 400)\n\n const { isStorageConfigured, getStorageConfigReason } = await import('@murumets-ee/storage')\n if (!isStorageConfigured()) {\n // Can't safely delete — MediaClient needs storage to remove the\n // actual object, and partial delete (DB row gone, object orphaned)\n // would leak storage. Surface the reason so the UI can display it.\n return jsonError(getStorageConfigReason() ?? 'Storage not configured', 503)\n }\n\n // AdminClient.delete() checks entity_refs and throws\n // ReferencedEntityError if this media is still referenced. The\n // error bubbles to the caller's error handler which returns 409\n // with usage details.\n const client = await getMediaClient()\n await client.delete(id)\n\n safeAudit(ctx, {\n action: 'media.delete',\n entityType: 'media',\n entityId: id,\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n })\n\n return jsonResponse({ deleted: 1 })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Default roles for view: broad reach across the admin shell.\n *\n * `BUILT_IN_ROLES` in `packages/auth/src/permissions.ts` is\n * `['admin', 'public', 'authenticated', 'agent', 'customer']` — `admin` is\n * hardcoded as an unconditional yes in `buildPermissionChecker`,\n * `agent` is the only non-admin built-in that flows through\n * `buildInitialRoleDefinitions()` + `upsertBuiltInRoles` with default\n * media access (`customer` seeds with zero permissions — see that\n * function's JSDoc). The `agent` entry in this list is what gives ops\n * agents read-only media access on first boot of a fresh install.\n *\n * `editor` and `viewer` are CONVENTIONAL app-defined roles —\n * apps that follow the scaffold pattern create them as part of\n * their own role catalog. Including them in `defaultRoles` doesn't\n * seed them (the seeder only iterates the built-ins above), but\n * the values DO flow into the process-local permission catalog\n * (see `registerPermission` in `@murumets-ee/core`), which the\n * forthcoming Permission Matrix UI (PR-D) reads to show \"this\n * route's recommended default grants\" against the app's actual\n * role set. The catalog is also the mechanism via which a future\n * upgrade of `upsertBuiltInRoles` could extend the seed surface\n * to app-defined roles without each plugin having to re-declare\n * its defaults.\n */\nconst VIEW_DEFAULT_ROLES = ['admin', 'editor', 'agent', 'viewer'] as const\n\n/** Default roles for create/delete — narrower than view. */\nconst WRITE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Build admin API routes for media management.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry per\n * `(prefix, method)` after grouping. Callers spread the result into\n * their top-level routes list: `routes: [...mediaRoutes()]`.\n *\n * Four `defineAdminRoute` entries:\n *\n * - GET matchAnyPath with `media:view` defaultRoles: VIEW_DEFAULT_ROLES\n * - POST path='' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - POST path='regenerate-variants' `media:create` defaultRoles: WRITE_DEFAULT_ROLES\n * - DELETE matchAnyPath with `media:delete` defaultRoles: WRITE_DEFAULT_ROLES\n *\n * Permissions are auto-registered by the entity catalog\n * (`buildResourceCatalog` in `@murumets-ee/auth`) AND by\n * `registerPermission` inside `defineAdminRoute`. Idempotent —\n * `registerPermission` unions `defaultRoles` on repeat registration,\n * so the framework's two pathways agree on the final grant set.\n *\n * Standard entity CRUD (PATCH with translations) is NOT registered\n * here — it falls through to the generic entity handler. Add `Media`\n * to the `entities` array in your handler config to enable it.\n */\nexport function mediaRoutes(): AdminRoute[] {\n const entries = [\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'GET',\n permission: 'media:view',\n defaultRoles: VIEW_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Read media — list, single item, or referencing-entity usage report.',\n handler: handleGet,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Upload a media file (multipart FormData with `file` field).',\n handler: handleUpload,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: 'regenerate-variants',\n method: 'POST',\n permission: 'media:create',\n defaultRoles: WRITE_DEFAULT_ROLES,\n description: 'Regenerate every image-style variant for every media row.',\n handler: handleRegenerateVariants,\n }),\n defineAdminRoute({\n prefix: 'media',\n path: '',\n method: 'DELETE',\n permission: 'media:delete',\n defaultRoles: WRITE_DEFAULT_ROLES,\n matchAnyPath: true,\n description: 'Delete a media record + its storage object.',\n handler: handleDelete,\n }),\n ]\n\n return combineAdminRoutes(entries)\n}\n","/**\n * Media plugin — auto-registers the Media entity, mounts admin routes, and\n * contributes sidebar + default-route scaffolding for the admin shell.\n *\n * @example\n * ```typescript\n * import { media } from '@murumets-ee/media/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * storage(),\n * media({ maxUploadSize: 10 * 1024 * 1024 }),\n * ],\n * })\n * ```\n */\n\nimport { definePlugin, getPluginConfig, hasPluginConfig } from '@murumets-ee/core'\n// Self-reference the `./image-styles` subpath (not the internal source\n// files) so the plugin bundle externalizes the React components instead\n// of inlining them — the subpath is built separately with a `'use client'`\n// banner, and Next.js needs to see that boundary preserved.\nimport { ImageStylesManager, RegenerateVariantsAction } from '@murumets-ee/media/image-styles'\nimport { mediaRoutes } from './admin/routes.js'\nimport { Media } from './entity.js'\n// Relative: `src/i18n.ts` is a sibling entry of this one in the same tsdown\n// config, so the loader lands in a shared chunk rather than being\n// duplicated, and its `import('./messages/et.json')` resolves against\n// `dist/`, which this package's build script populates.\nimport { getMediaMessages } from './i18n.js'\nimport { imageStylesSettings } from './image-styles-settings.js'\nimport { MEDIA_PLUGIN_NAME, resolveMediaConfig } from './media-config.js'\nimport type { MediaPluginConfig } from './types.js'\n\n/**\n * Get the resolved media plugin configuration.\n * Throws if plugin not registered.\n *\n * Reads the framework's `PluginConfigRegistry`, which the merge step fills\n * from `server.config` below before any plugin `init` runs.\n */\nexport function getMediaConfig(): Required<MediaPluginConfig> {\n if (!hasPluginConfig(MEDIA_PLUGIN_NAME)) {\n throw new Error('@murumets-ee/media plugin not initialized. Add media() to your plugins array.')\n }\n return getPluginConfig(MEDIA_PLUGIN_NAME)\n}\n\n/**\n * Media plugin factory.\n *\n * - Registers the Media entity\n * - Mounts the media admin API routes\n * - Contributes sidebar + default-route metadata\n * - Declares @murumets-ee/storage + @murumets-ee/settings as required deps\n * (framework-validated before any plugin init runs — see `requires` field below)\n * - Declares its resolved configuration (`server.config`) for `getMediaConfig()`\n * and the upload route\n */\nexport function media(config?: MediaPluginConfig) {\n const resolvedConfig = resolveMediaConfig(config)\n\n return definePlugin({\n name: '@murumets-ee/media',\n // Declarative dep validation — replaces the per-plugin\n // `if (!app.plugins.has(...)) throw` loop that used to live in\n // `init`. The framework validates BEFORE any init runs, so a\n // missing dep surfaces immediately with the full punchlist\n // instead of throwing partway through boot.\n requires: ['@murumets-ee/storage', '@murumets-ee/settings'],\n shared: {\n // The interface-message catalogue, declared rather than left for each\n // app to remember (commerce-launch F148/F155). The framework gathers\n // it at the merge step; nothing loads a locale until one is asked for.\n messages: getMediaMessages,\n // Self-contributes the media.imageStyles namespace. The merge engine\n // auto-derives the permission resource (`settings_media.imageStyles`)\n // and the sidebar entry under the \"Settings\" group. The app's\n // admin-api-handler call site aggregates plugin-contributed\n // namespaces into `settingsRoutes(...)` (see apps/admin's\n // `getAllSettings()` for the canonical pattern); apps that follow\n // the scaffold pattern get this for free.\n // Drop the `as PluginSettingsDefinition` cast — it would widen the\n // namespace literal (e.g. `'media.imageStyles'`) to `string`,\n // leaking `settings_${string}:view|update` into the resolved\n // permission union via `<const N>` capture loss.\n // `imageStylesSettings` is structurally a superset of\n // `PluginSettingsDefinition` (full vs. narrow `SettingsDefinition`),\n // which TS accepts directly. The `readonly` outer tuple comes from\n // the parent definePlugin's `<const S>` capture.\n settings: [imageStylesSettings],\n },\n server: {\n entities: [Media],\n routes: [...mediaRoutes()],\n // Declarative config — registered at the merge step, before any init,\n // so the upload route can enforce `acceptedTypes` / `maxUploadSize`\n // (F007) instead of hardcoding a limit it could not trust to be set.\n config: resolvedConfig,\n init: async (app) => {\n // Image style defaults live in the plugin config and are exposed via\n // `getMediaConfig()`. Persistent overrides go to the settings DB\n // through the generic settings API at `/api/admin/settings/media.imageStyles`\n // — see `image-styles-settings.ts`. `resolveImageStyles` reads\n // DB-first, config-fallback.\n //\n // We deliberately do NOT seed the DB here. Init must stay lightweight:\n // it runs in every context (Next.js server, Next.js build, CLI commands).\n // The prior eager-seed path pulled in `@murumets-ee/settings`'s main entry —\n // which carries `import 'server-only'` — and crashed non-RSC Node bootstrap.\n\n app.logger.info(\n {\n acceptedTypes: resolvedConfig.acceptedTypes,\n maxUploadSize: resolvedConfig.maxUploadSize,\n defaultVisibility: resolvedConfig.defaultVisibility,\n },\n 'Media plugin initialized',\n )\n },\n },\n adminUi: {\n sidebar: [\n {\n id: 'media',\n group: 'Library',\n label: 'Media',\n href: '/admin/media',\n iconName: 'image',\n },\n ],\n defaultRoutes: [\n // Routing only — sidebar nav comes from `adminUi.sidebar` above\n // (admin-page-routing SD003).\n {\n path: 'media',\n factory: 'MediaListPage',\n },\n {\n path: 'media/[id]',\n factory: 'MediaEditPage',\n },\n ],\n // Self-contributes the rich image-styles editor and the\n // \"Regenerate All Variants\" action. Apps don't have to wire either\n // — they appear automatically on /admin/settings/media.imageStyles\n // (route + sidebar entry auto-derived from `shared.settings`).\n settingRenderers: {\n 'media.imageStyles': ImageStylesManager,\n },\n settingsActions: {\n 'media.imageStyles': RegenerateVariantsAction,\n },\n },\n })\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import e from"sharp";const t=new Set([`image/svg+xml`,`image/gif`]);function n(e){return e.startsWith(`image/`)&&!t.has(e)}async function r(t,n){let{width:r,height:i}=a(await e(t).metadata()),o=new Map,s=Object.entries(n);return await Promise.all(s.map(async([n,r])=>{let i=r.format??`webp`,a=r.quality??80,s=r.fit??`cover`,{data:c,info:l}=await e(t).rotate().resize({width:r.width,height:r.height,fit:s,withoutEnlargement:!0})[i]({quality:a}).toBuffer({resolveWithObject:!0});o.set(n,{buffer:c,format:i,mimeType:`image/${i}`,width:l.width,height:l.height})})),{width:r,height:i,variants:o}}async function i(t){return a(await e(t).metadata())}function a(e){return{width:e.autoOrient?.width??e.width??0,height:e.autoOrient?.height??e.height??0}}async function o(t){let{data:n,info:r}=await e(t).rotate().resize(512,512,{fit:`inside`,withoutEnlargement:!0}).raw().toBuffer({resolveWithObject:!0}),{width:i,height:a,channels:o}=r;if(i<8||a<8)return null;let u=await e(n,{raw:{width:i,height:a,channels:o}}).flop().flip().raw().toBuffer(),[d,f]=await Promise.all([s(n,i,a,o),s(u,i,a,o)]);if(d===null||f===null)return null;let p=c(d.x,1-f.x),m=c(d.y,1-f.y);return p===null&&m===null?null:{x:l(p??.5),y:l(m??.5)}}async function s(t,n,r,i){let a=n>=r,{info:o}=await e(t,{raw:{width:n,height:r,channels:i}}).resize(a?n-1:n,a?r:r-1,{fit:`cover`,position:e.strategy.attention}).toBuffer({resolveWithObject:!0}),{attentionX:s,attentionY:c}=o;return s===void 0||c===void 0?null:{x:s/n,y:c/r}}function c(e,t){return Math.abs(e-t)<=.25?(e+t)/2:null}function l(e){return Number.isFinite(e)?Math.min(Math.max(Math.round(e*1e4)/1e4,0),1):.5}export{r as i,i as n,n as r,o as t};
2
+ //# sourceMappingURL=process-image-WByEHTXQ.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-image-WByEHTXQ.mjs","names":[],"sources":["../src/process-image.ts"],"sourcesContent":["/**\n * Server-side image processing via Sharp.\n *\n * Pure processing module — no DB or storage dependencies.\n * Takes a buffer and image styles, returns metadata + variant buffers.\n */\n\nimport sharp from 'sharp'\nimport type { FocalPoint } from './crop/focal-point.js'\nimport type { ImageStyle } from './types.js'\n\n/** Result of processing an image through Sharp */\nexport interface ProcessedImage {\n /** Original image width in pixels */\n width: number\n /** Original image height in pixels */\n height: number\n /** Generated variant buffers keyed by style name */\n variants: Map<string, ProcessedVariant>\n}\n\n/** A single processed variant */\nexport interface ProcessedVariant {\n buffer: Buffer\n format: string\n mimeType: string\n width: number\n height: number\n}\n\n/** MIME types that should NOT be processed (vectors, animations) */\nconst SKIP_MIME_TYPES = new Set(['image/svg+xml', 'image/gif'])\n\n/**\n * Check if a MIME type is eligible for Sharp processing.\n * Returns false for SVG, GIF, and non-image types.\n */\nexport function isProcessableImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !SKIP_MIME_TYPES.has(mimeType)\n}\n\n/**\n * Extract image dimensions and generate resized variants.\n *\n * @param buffer - Original image file as a Buffer\n * @param styles - Named image style presets to generate\n * @returns Metadata (width/height) and variant buffers\n */\nexport async function processImage(\n buffer: Buffer,\n styles: Record<string, ImageStyle>,\n): Promise<ProcessedImage> {\n const { width, height } = displayDimensions(await sharp(buffer).metadata())\n\n const variants = new Map<string, ProcessedVariant>()\n\n const entries = Object.entries(styles)\n await Promise.all(\n entries.map(async ([name, style]) => {\n const fmt = style.format ?? 'webp'\n const quality = style.quality ?? 80\n const fit = style.fit ?? 'cover'\n\n // `.rotate()` first: without it an EXIF-rotated phone photo's variant is\n // written SIDEWAYS — the output format drops the orientation tag — while\n // the row's width/height (`displayDimensions`) and its focal point are in\n // the orientation a browser draws. Keys are unchanged; only the pixels\n // of a rotated original change, and they change to what was intended.\n const resized = sharp(buffer).rotate().resize({\n width: style.width,\n height: style.height,\n fit,\n withoutEnlargement: true,\n })\n\n const { data: variantBuffer, info } = await resized[fmt]({ quality }).toBuffer({\n resolveWithObject: true,\n })\n\n variants.set(name, {\n buffer: variantBuffer,\n format: fmt,\n mimeType: `image/${fmt}`,\n width: info.width,\n height: info.height,\n })\n }),\n )\n\n return { width, height, variants }\n}\n\n/**\n * Extract only image dimensions (no variant generation).\n * Useful for getting width/height when styles are empty.\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width: number; height: number }> {\n return displayDimensions(await sharp(buffer).metadata())\n}\n\n/**\n * The size a browser DRAWS the image at — after EXIF orientation — not the\n * sensor's.\n *\n * `metadata().width/height` are the stored pixel grid, so a phone portrait\n * tagged orientation 6 reports 400×200 while every browser draws it 200×400\n * (`image-orientation: from-image` is the default). Those numbers become the\n * media row's `width`/`height`, which are the `<img>`'s CLS reservation AND the\n * image ratio the focal-point crop is computed against (images PR02) — a\n * swapped ratio positions the crop on the wrong axis. `autoOrient` is sharp's\n * own post-orientation size (0.33+).\n */\nfunction displayDimensions(meta: sharp.Metadata): { width: number; height: number } {\n return {\n width: meta.autoOrient?.width ?? meta.width ?? 0,\n height: meta.autoOrient?.height ?? meta.height ?? 0,\n }\n}\n\n/**\n * The longest edge the attention pass looks at. libvips scores saliency on a\n * ~32px grid, so a larger buffer buys no precision — it only costs decode time.\n */\nconst ATTENTION_EDGE = 512\n\n/** Below this on either side there is nothing to guess about — see {@link detectFocalPoint}. */\nconst MIN_ATTENTION_EDGE = 8\n\n/**\n * How far the two attention passes may disagree on an axis and still be the\n * same subject. libvips places its hotspot on a ~32-cell grid (1/32 ≈ 0.03), so\n * a real subject agrees to within a cell or two (measured ≤ 0.07); a FLAT axis\n * disagrees by the whole image (0 in one pass, 1 in the mirrored one).\n */\nconst AXIS_AGREEMENT = 0.25\n\n/**\n * Guess where the subject is, with sharp's `attention` strategy — the seed for\n * a new photograph's focal point, so an editor who opens nothing still gets\n * something other than a blind centre crop (images D002).\n *\n * ## Two passes, one of them mirrored\n *\n * When the saliency map is flat along an axis — a white product background, a\n * sky, a horizontal band, a plain texture — libvips reports the FIRST cell of\n * it: 0. Stored, that pins every shaped slot to the left or top edge, which is\n * worse than no guess. But a subject genuinely near the edge also reports 0 (a\n * disc at 5% from the corner does, measured), so \"0 means flat\" is not a rule.\n *\n * So the same buffer is scored twice: as-is, and flipped on both axes. A real\n * subject moves with the flip and the two answers agree once the second is\n * mirrored back; a flat axis reports 0 both times, i.e. 0 and 1 once mirrored.\n * Per axis: agree → their mean; disagree → that axis says nothing → 0.5. Both\n * axes flat → `null` (the caller stores it; it renders as the centre, D010).\n * The second pass runs on the already-decoded ≤512px raw buffer, so it costs a\n * saliency pass over ~0.25 MP, not a second decode.\n *\n * Also `null`: an image under {@link MIN_ATTENTION_EDGE} on either side — a\n * strip or an icon has no subject worth cropping around.\n *\n * ## Why not read `info.attentionX` off an ordinary resize\n *\n * Because it is WRONG whenever the same call resizes. sharp converts the\n * libvips hotspot with `attention_x * jpegShrinkOnLoad / scale`\n * (`src/pipeline.cc:505`, 0.34.5 and 0.35.3 alike), and those terms do not\n * describe the image smartcrop ran on: a disc drawn at x = 0.80 reported 0.50\n * from a JPEG and 0.25 from a PNG (images R012). The crop OFFSET beside it is\n * right; only the reported point is not.\n *\n * So each pass runs at SCALE 1: decode once to a bounded raw buffer, then crop\n * that buffer to its own size minus one pixel on the long axis. No\n * shrink-on-load and no scale, so the reported point is in that buffer's own\n * pixels and dividing by its size is the answer. A square image has no long\n * axis, but trimming either one still runs the strategy, and libvips takes the\n * hotspot over the whole saliency map whichever axis it crops.\n *\n * `.rotate()` first, so the point is in the orientation a browser DRAWS — an\n * EXIF-rotated phone photo would otherwise be seeded in sensor coordinates.\n *\n * ⚠️ Attention scores luminance frequency, saturation and skin tones. It is a\n * guess, not detection, and on a product shot the product can win (R012).\n */\nexport async function detectFocalPoint(buffer: Buffer): Promise<FocalPoint | null> {\n const { data, info } = await sharp(buffer)\n .rotate()\n .resize(ATTENTION_EDGE, ATTENTION_EDGE, { fit: 'inside', withoutEnlargement: true })\n .raw()\n .toBuffer({ resolveWithObject: true })\n const { width, height, channels } = info\n if (width < MIN_ATTENTION_EDGE || height < MIN_ATTENTION_EDGE) return null\n const raw = { raw: { width, height, channels } }\n const mirrored = await sharp(data, raw).flop().flip().raw().toBuffer()\n const [straight, flipped] = await Promise.all([\n attentionHotspot(data, width, height, channels),\n attentionHotspot(mirrored, width, height, channels),\n ])\n if (straight === null || flipped === null) return null\n const x = agreeingAxis(straight.x, 1 - flipped.x)\n const y = agreeingAxis(straight.y, 1 - flipped.y)\n if (x === null && y === null) return null\n return { x: clampFraction(x ?? 0.5), y: clampFraction(y ?? 0.5) }\n}\n\n/** One attention pass at scale 1, as fractions of the buffer. */\nasync function attentionHotspot(\n data: Buffer,\n width: number,\n height: number,\n channels: 1 | 2 | 3 | 4,\n): Promise<FocalPoint | null> {\n const landscape = width >= height\n const { info } = await sharp(data, { raw: { width, height, channels } })\n .resize(landscape ? width - 1 : width, landscape ? height : height - 1, {\n fit: 'cover',\n position: sharp.strategy.attention,\n })\n .toBuffer({ resolveWithObject: true })\n const { attentionX, attentionY } = info\n if (attentionX === undefined || attentionY === undefined) return null\n return { x: attentionX / width, y: attentionY / height }\n}\n\n/** The two passes' answer for one axis, or `null` when they disagree (a flat axis). */\nfunction agreeingAxis(straight: number, mirrored: number): number | null {\n return Math.abs(straight - mirrored) <= AXIS_AGREEMENT ? (straight + mirrored) / 2 : null\n}\n\n/** Round to four decimals and keep inside `[0, 1]`, so the stored value passes `isFocalPoint`. */\nfunction clampFraction(value: number): number {\n if (!Number.isFinite(value)) return 0.5\n return Math.min(Math.max(Math.round(value * 10000) / 10000, 0), 1)\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { t as ImageStyle } from "./types-1idCpe1k.mjs";
1
+ import { t as ImageStyle } from "./types-CnORVkLZ.mjs";
2
2
 
3
3
  //#region src/process-image.d.ts
4
4
  /** Result of processing an image through Sharp */
@@ -1 +1 @@
1
- {"version":3,"file":"processing.d.mts","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts"],"mappings":";;;;UAWiB,cAAA;EAMf;EAJA,KAAA;EAIsB;EAFtB,MAAA;EAEsC;EAAtC,QAAA,EAAU,GAAG,SAAS,gBAAA;AAAA;;UAIP,gBAAA;EACf,MAAA,EAAQ,MAAM;EACd,MAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;AAAA;;AAAM;AAUR;;iBAAgB,kBAAA,CAAmB,QAAgB;;AAAA;AAWnD;;;;;iBAAsB,YAAA,CACpB,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,MAAA,SAAe,UAAA,IACtB,OAAA,CAAQ,cAAA;;;;;iBA0CW,kBAAA,CACpB,MAAA,EAAQ,MAAA,GACP,OAAO;EAAG,KAAA;EAAe,MAAA;AAAA;;;;;;AAnF5B;;;;;;;;;;AAMwC;AAIxC;;;;iBCFgB,gBAAA,CAAiB,WAAA,UAAqB,SAAA,UAAmB,MAAA"}
1
+ {"version":3,"file":"processing.d.mts","names":[],"sources":["../src/process-image.ts","../src/variant-key.ts"],"mappings":";;;;UAYiB,cAAA;EAML;EAJV,KAAA;EAIsC;EAFtC,MAAA;EAMe;EAJf,QAAA,EAAU,GAAG,SAAS,gBAAA;AAAA;;UAIP,gBAAA;EACf,MAAA,EAAQ,MAAM;EACd,MAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;AAAA;AAAM;AAUR;;;AAVQ,iBAUQ,kBAAA,CAAmB,QAAgB;AAAA;AAWnD;;;;;;AAXmD,iBAW7B,YAAA,CACpB,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,MAAA,SAAe,UAAA,IACtB,OAAA,CAAQ,cAAA;;;;;iBA6CW,kBAAA,CACpB,MAAA,EAAQ,MAAA,GACP,OAAO;EAAG,KAAA;EAAe,MAAA;AAAA;;;;;;AAtF5B;;;;;;;;;;AAMwC;AAIxC;;;;iBCHgB,gBAAA,CAAiB,WAAA,UAAqB,SAAA,UAAmB,MAAA"}
@@ -1 +1 @@
1
- import{n as e,r as t,t as n}from"./process-image-DYDTMGUJ.mjs";import{t as r}from"./variant-key-JBTJXPL1.mjs";export{r as deriveVariantKey,n as getImageDimensions,e as isProcessableImage,t as processImage};
1
+ import{i as e,n as t,r as n}from"./process-image-WByEHTXQ.mjs";import{t as r}from"./variant-key-JBTJXPL1.mjs";export{r as deriveVariantKey,t as getImageDimensions,n as isProcessableImage,e as processImage};
@@ -1,4 +1,5 @@
1
- import { t as ImageStyle } from "./types-1idCpe1k.mjs";
1
+ import { t as ImageStyle } from "./types-CnORVkLZ.mjs";
2
+ import { n as FocalPoint } from "./focal-point-C_0drV03.mjs";
2
3
 
3
4
  //#region src/async-cache.d.ts
4
5
  /**
@@ -72,6 +73,16 @@ interface PublicMediaProjection {
72
73
  * in. A style whose variant file is missing or not public is simply absent.
73
74
  */
74
75
  variants: Record<string, string>;
76
+ /**
77
+ * Where the subject is, as `{ x, y }` fractions of the image (images D002).
78
+ *
79
+ * ALWAYS present: a row with no focal point, or one whose stored value is
80
+ * unusable, projects as the centre, so no consumer branches on absence (D010).
81
+ * It rides the PROJECTION and never the stored block ref — the resolver
82
+ * substitutes fresh data on every read, so a point edited today reaches a page
83
+ * saved last year with nothing rewritten (D007).
84
+ */
85
+ focalPoint: FocalPoint;
75
86
  }
76
87
  /** The Media rows this resolver needs. Structural, so any read path can supply them. */
77
88
  interface PublicMediaRow {
@@ -82,6 +93,8 @@ interface PublicMediaRow {
82
93
  height?: number | null;
83
94
  alt?: string | null;
84
95
  title?: string | null;
96
+ /** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */
97
+ focalPoint?: unknown;
85
98
  }
86
99
  interface PublicMediaResolverDeps {
87
100
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":";;;;;;AA2BA;;;;;;;;;;;;;;;;AAAmF;;;;AC4BnF;;;iBD5BgB,uBAAA,GAAA,CAA2B,IAAA,QAAY,OAAA,CAAQ,CAAA,UAAW,OAAA,CAAQ,CAAA;;;;;ACsDhE;AAIlB;;;;;;;;;;;;UA9BiB,qBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAmD0C;;;;;;;;;;;;;;;;;;EAhC1C,QAAA,EAAU,MAAM;AAAA;;UAID,cAAA;EACf,EAAA;EACA,OAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;AAAA;AAAA,UAGe,uBAAA;EA0CP;;;;;;;;;EAhCR,SAAA,GAAY,GAAA,qBAAwB,MAAA,cAAoB,OAAA,CAAQ,cAAA;EAgCtB;AAAA;AA2F5C;;;EArHE,aAAA,GAAgB,IAAA,wBAA4B,OAAA,CAAQ,GAAA;EAsHxC;EApHZ,WAAA,QAAmB,OAAA,CAAQ,MAAA,SAAe,UAAA;EAuHnC;EArHP,UAAA,GAAa,OAAA,UAAiB,SAAA,UAAmB,MAAA;AAAA;;;;;;;;;;AAqHjC;AAelB;;;;;iBAlHsB,kBAAA,CACpB,GAAA,qBACA,IAAA,EAAM,uBAAA,EACN,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;;;AAiHuB;AAuE9C;;;;;;;;;AAIiB;AAgCjB;;;;;;;;;;;;iBAjIgB,cAAA,GAAA,CACd,IAAA,QAAY,OAAA,CAAQ,CAAA,GACpB,KAAA,UACA,GAAA,wBACO,OAAA,CAAQ,CAAA;AAAA,iBAeD,yBAAA,CAAA,IACd,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,qBAAA;AAwHzB;;;;;AAAA,UAjDiB,WAAA;EAAA,SACN,GAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;AAAA;;;;;AA+Ce;;;;;;;;;;;;;;;;;;;;;;;iBAfJ,mBAAA,CACpB,GAAA,qBACA,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,WAAA;;;;;;iBAUP,cAAA,CACd,KAAA,EAAO,WAAA,SAAoB,qBAAA,IAC1B,GAAA,SAAY,WAAA"}
1
+ {"version":3,"file":"public-resolver.d.mts","names":[],"sources":["../src/async-cache.ts","../src/public-resolver.ts"],"mappings":";;;;;;;;AA2BA;;;;;;;;;;;;;;;;AAAmF;;;;AC6BnF;;iBD7BgB,uBAAA,GAAA,CAA2B,IAAA,QAAY,OAAA,CAAQ,CAAA,UAAW,OAAA,CAAQ,CAAA;;;;;;;ACiE1D;AAIxB;;;;;;;;;;UAxCiB,qBAAA;EACf,EAAA;EACA,GAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EAuDwD;;;;;;;;;;;;;;;;;;EApCxD,QAAA,EAAU,MAAA;EA4CiB;;;;;;;AAEoC;AAkBjE;EAtDE,UAAA,EAAY,UAAU;AAAA;;UAIP,cAAA;EACf,EAAA;EACA,OAAA;EACA,QAAA;EACA,KAAA;EACA,MAAA;EACA,GAAA;EACA,KAAA;EA8CQ;EA5CR,UAAA;AAAA;AAAA,UAGe,uBAAA;EA0CM;;AAAqB;AA4F5C;;;;;;EA5HE,SAAA,GAAY,GAAA,qBAAwB,MAAA,cAAoB,OAAA,CAAQ,cAAA;EAgIlD;;;;;EA1Hd,aAAA,GAAgB,IAAA,wBAA4B,OAAA,CAAQ,GAAA;EAwHpD;EAtHA,WAAA,QAAmB,OAAA,CAAQ,MAAA,SAAe,UAAA;EAwHnC;EAtHP,UAAA,GAAa,OAAA,UAAiB,SAAA,UAAmB,MAAA;AAAA;AAsHjC;AAelB;;;;;;;;;;;;;;AAfkB,iBApGI,kBAAA,CACpB,GAAA,qBACA,IAAA,EAAM,uBAAA,EACN,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;;;;AA6LN;AAgCjB;;;;;;;;;;;;;;;AAGkC;AAUlC;;;;;iBA9IgB,cAAA,GAAA,CACd,IAAA,QAAY,OAAA,CAAQ,CAAA,GACpB,KAAA,UACA,GAAA,wBACO,OAAA,CAAQ,CAAA;AAAA,iBAeD,yBAAA,CAAA,IACd,GAAA,qBACA,IAAA;EAAS,MAAA;AAAA,MACN,OAAA,CAAQ,GAAA,SAAY,qBAAA;;;;;;UAuER,WAAA;EAAA,SACN,GAAA;EAAA,SACA,GAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA;AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCW,mBAAA,CACpB,GAAA,qBACA,IAAA;EAAQ,MAAA;AAAA,IACP,OAAA,CAAQ,GAAA,SAAY,WAAA;;;;;;iBAUP,cAAA,CACd,KAAA,EAAO,WAAA,SAAoB,qBAAA,IAC1B,GAAA,SAAY,WAAA"}
@@ -1,2 +1,2 @@
1
- import{t as e}from"./async-cache-C_Ycvs7b.mjs";async function t(e,t,n={}){let r=new Map,i=[...new Set(e)];if(i.length===0)return r;let a=await t.readMedia(i,n.locale);if(a.length===0)return r;let o=await t.imageStyles(),s=Object.entries(o),c=[],l=new Map;for(let e of a){if(typeof e.fileKey!=`string`||e.fileKey===``)continue;let n=s.map(([n,r])=>[n,t.variantKey(e.fileKey,n,r.format??`webp`)]);l.set(e.id,{fileKey:e.fileKey,variantKeys:n}),c.push(e.fileKey,...n.map(([,e])=>e))}let u=await t.getPublicUrls(c);for(let e of a){let t=l.get(e.id);if(!t)continue;let n=u.get(t.fileKey);if(n===void 0)continue;let i={};for(let[e,n]of t.variantKeys){let t=u.get(n);t!==void 0&&(i[e]=t)}r.set(e.id,{id:e.id,url:n,mimeType:e.mimeType??null,width:e.width??null,height:e.height??null,alt:e.alt??null,title:e.title??null,variants:i})}return r}function n(e,t,n=Date.now){let r=null;return async()=>{let i=n();if(r&&i-r.at<t)return r.value;let a=e();return r={value:a,at:i},a.catch(()=>{r?.value===a&&(r=null)}),a}}function r(){let e=n(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{resolveImageStyles:t}=await import(`./resolve-image-styles-iN9JbZYf.mjs`);return t(e())},3e4);return async(n,r={})=>{let[{createMediaQueryClient:i},{getSharedStorageClient:a},{deriveVariantKey:o}]=await Promise.all([import(`./query-client.mjs`),import(`./client.mjs`),import(`./variant-key-JBTJXPL1.mjs`).then(e=>e.n)]),[s,c]=await Promise.all([i(),a()]);return t(n,{readMedia:async(e,t)=>{let{schemaRegistry:n}=await import(`@murumets-ee/db`),{getTableColumns:r,inArray:i}=await import(`drizzle-orm`),a=n.get(`media`);if(!a)return[];let o=r(a).id;return o?await s.findMany({where:i(o,[...e]),limit:e.length,...t!==void 0&&{locale:t}}):[]},getPublicUrls:e=>c.getPublicUrls(e),imageStyles:e,variantKey:o},r)}}let i;async function a(e,t){return i??=r(),o(await i(e,t))}function o(e){let t=new Map;for(let[n,r]of e)r.mimeType?.startsWith(`image/`)===!0&&t.set(n,{url:r.url,alt:r.alt,width:r.width,height:r.height});return t}export{e as cacheOnceUnlessRejected,r as createPublicMediaResolver,n as memoiseWithTtl,a as resolveCanvasImages,t as resolvePublicMedia,o as toCanvasImages};
1
+ import{s as e}from"./focal-point-BWhtJzef.mjs";import{t}from"./async-cache-C_Ycvs7b.mjs";async function n(t,n,r={}){let i=new Map,a=[...new Set(t)];if(a.length===0)return i;let o=await n.readMedia(a,r.locale);if(o.length===0)return i;let s=await n.imageStyles(),c=Object.entries(s),l=[],u=new Map;for(let e of o){if(typeof e.fileKey!=`string`||e.fileKey===``)continue;let t=c.map(([t,r])=>[t,n.variantKey(e.fileKey,t,r.format??`webp`)]);u.set(e.id,{fileKey:e.fileKey,variantKeys:t}),l.push(e.fileKey,...t.map(([,e])=>e))}let d=await n.getPublicUrls(l);for(let t of o){let n=u.get(t.id);if(!n)continue;let r=d.get(n.fileKey);if(r===void 0)continue;let a={};for(let[e,t]of n.variantKeys){let n=d.get(t);n!==void 0&&(a[e]=n)}i.set(t.id,{id:t.id,url:r,mimeType:t.mimeType??null,width:t.width??null,height:t.height??null,alt:t.alt??null,title:t.title??null,variants:a,focalPoint:e(t.focalPoint)})}return i}function r(e,t,n=Date.now){let r=null;return async()=>{let i=n();if(r&&i-r.at<t)return r.value;let a=e();return r={value:a,at:i},a.catch(()=>{r?.value===a&&(r=null)}),a}}function i(){let e=r(async()=>{let{getApp:e}=await import(`@murumets-ee/core`),{resolveImageStyles:t}=await import(`./resolve-image-styles-iN9JbZYf.mjs`);return t(e())},3e4);return async(t,r={})=>{let[{createMediaQueryClient:i},{getSharedStorageClient:a},{deriveVariantKey:o}]=await Promise.all([import(`./query-client.mjs`),import(`./client.mjs`),import(`./variant-key-JBTJXPL1.mjs`).then(e=>e.n)]),[s,c]=await Promise.all([i(),a()]);return n(t,{readMedia:async(e,t)=>{let{schemaRegistry:n}=await import(`@murumets-ee/db`),{getTableColumns:r,inArray:i}=await import(`drizzle-orm`),a=n.get(`media`);if(!a)return[];let o=r(a).id;return o?await s.findMany({where:i(o,[...e]),limit:e.length,...t!==void 0&&{locale:t}}):[]},getPublicUrls:e=>c.getPublicUrls(e),imageStyles:e,variantKey:o},r)}}let a;async function o(e,t){return a??=i(),s(await a(e,t))}function s(e){let t=new Map;for(let[n,r]of e)r.mimeType?.startsWith(`image/`)===!0&&t.set(n,{url:r.url,alt:r.alt,width:r.width,height:r.height});return t}export{t as cacheOnceUnlessRejected,i as createPublicMediaResolver,r as memoiseWithTtl,o as resolveCanvasImages,n as resolvePublicMedia,s as toCanvasImages};
2
2
  //# sourceMappingURL=public-resolver.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"public-resolver.mjs","names":[],"sources":["../src/public-resolver.ts"],"sourcesContent":["/**\n * The PUBLIC media resolver — media as data, for a consumer that builds its own\n * markup (plan/api phase 06, D021).\n *\n * **Why not `resolveMediaRefs`.** That helper parses `[media:type:id:variant]`\n * tags out of a text string and substitutes rendered HTML (`<img …>`, `<a\n * href=…>`) through per-type renderers. Right for a server-rendered page, wrong\n * for an HTTP API: a frontend needs `{url, width, height, alt}` so it can emit\n * its own `<picture>` with its own `srcset`, not a pre-baked tag it has to parse\n * back apart. Nothing here deprecates `resolveMediaRefs`; the API simply uses a\n * different, data-shaped path.\n *\n * **Why the entity cannot just be whitelisted.** `Media` is a real entity, but\n * it has no `url` field and cannot have one: what is stored is `fileKey`, and\n * the URL is DERIVED by the storage layer, which also decides whether it is a\n * direct public link or a time-limited signed one. Serving `media` as an\n * ordinary whitelisted entity would hand a consumer a storage key — useless for\n * rendering, and a gratuitous disclosure of storage layout.\n *\n * **This resolver can never emit a signed URL, structurally.** It asks storage\n * for PUBLIC urls (`getPublicUrls`) rather than asking for \"a url\" and\n * inspecting what came back. The second shape produces a signed URL on the\n * happy path and relies on a later check to suppress it — and a signed URL is a\n * bearer capability that outlives the permission check that minted it, on a\n * response the public role is deliberately allowed to cache. There is no branch\n * here to forget.\n *\n * D021 permits a permission-gated scoped URL for NON-public principals on\n * responses already forced to `no-store`. It is deliberately not built: no\n * consumer needs it, and an unexercised signed-URL branch is a check that\n * cannot fail. The shape is fixed so nobody re-derives it; the path waits for a\n * named consumer.\n */\n\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\nexport { cacheOnceUnlessRejected } from './async-cache.js'\n\nimport type { ImageStyle } from './types.js'\n\n/**\n * One media item as it reaches an HTTP consumer.\n *\n * A STRUCTURAL match for `@murumets-ee/content-api`'s `MediaProjection`, not an\n * import of it: `content-api` is a leaf that this package's consumers wire into\n * by hand, exactly like `ContentApiResolution` mirrors content's\n * `ResolveResult`. The app that wires the two is where TypeScript checks they\n * agree.\n *\n * `width`/`height` ride along because a frontend needs them to reserve layout\n * space before the image loads; omitting them forces either a layout shift or a\n * second request, and the second request is the 4+N failure D001 exists to\n * prevent. `alt`/`title` are translatable on the Media entity, so this\n * projection is locale-dependent.\n */\nexport interface PublicMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n /**\n * Variant URLs by image-style name, INLINED as data rather than handed over\n * as a URL pattern to interpolate.\n *\n * Two reasons, and the second is a security property. D008's rule is that a\n * client derives nothing — a consumer that constructs URLs from a pattern\n * encodes an assumption the server never published, and nothing can detect\n * when it goes stale. And a URL pattern is necessarily public-SHAPED: the\n * moment a consumer can build its own URL, the server has lost the ability to\n * decide what that consumer may address, and the \"never a signed URL\"\n * guarantee becomes unenforceable because the consumer stopped asking.\n *\n * The payload multiplier is bounded by CONFIGURATION, not by content: the set\n * of image styles is operator-chosen and neither the caller nor the editor can\n * grow it. So this needs no `?populate=`-style opt-in — unlike reference\n * expansion, where the caller could otherwise drag arbitrarily large targets\n * in. A style whose variant file is missing or not public is simply absent.\n */\n variants: Record<string, string>\n}\n\n/** The Media rows this resolver needs. Structural, so any read path can supply them. */\nexport interface PublicMediaRow {\n id: string\n fileKey: string\n mimeType?: string | null\n width?: number | null\n height?: number | null\n alt?: string | null\n title?: string | null\n}\n\nexport interface PublicMediaResolverDeps {\n /**\n * Read media rows by id through a PUBLISHED-ONLY path.\n *\n * **The wiring must guarantee this** — pass a `MediaQueryClient`/`QueryClient`\n * read, never an `AdminClient` one. This module cannot enforce the choice (it\n * is injected), and an admin read would surface unpublished media on the\n * anonymous surface. Same posture, and same reason, as\n * `ContentApiResolution.resolvePath`.\n */\n readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>\n /**\n * Public URLs for a batch of storage keys. Anything missing or non-public is\n * ABSENT from the map — see `StorageClient.getPublicUrls`, which is the ask-\n * for-public shape this whole module rests on.\n */\n getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>\n /** The configured image styles. Operator-owned; neither caller nor editor grows it. */\n imageStyles: () => Promise<Record<string, ImageStyle>>\n /** Derives a variant's storage key from the original's. */\n variantKey: (fileKey: string, styleName: string, format: string) => string\n}\n\n/**\n * Resolve media ids to public projections.\n *\n * Ids that do not exist, are unpublished for this caller, or whose file is not\n * public are ABSENT from the returned map — one absence, four causes, exactly\n * as phase 05's failure semantics require. The caller renders without them.\n *\n * Cost is THREE round trips at most, regardless of batch size: the image-style\n * lookup (usually served from the caller's cache — see\n * {@link createPublicMediaResolver}), one entity read, and one storage read\n * covering every original and every variant key. Emphatically NOT\n * N×(styles+1), which is what a per-item convenience method would have made it.\n * The storage read is internally chunked when the key count exceeds what one\n * query may carry, so \"one storage read\" is `ceil(keys / 500)` in the extreme.\n */\nexport async function resolvePublicMedia(\n ids: readonly string[],\n deps: PublicMediaResolverDeps,\n opts: { locale?: string | undefined } = {},\n): Promise<Map<string, PublicMediaProjection>> {\n const out = new Map<string, PublicMediaProjection>()\n const unique = [...new Set(ids)]\n if (unique.length === 0) return out\n\n const rows = await deps.readMedia(unique, opts.locale)\n if (rows.length === 0) return out\n\n const styles = await deps.imageStyles()\n const styleEntries = Object.entries(styles)\n\n // Every key we might need — originals AND variants — resolved in one storage\n // query. `getPublicUrls` returns nothing for a key that is missing or not\n // public, so a private original drops the whole item and a private variant\n // drops only that variant.\n const keysToLookUp: string[] = []\n /** Per row: its original key, plus (styleName → variant key). */\n const perRow = new Map<string, { fileKey: string; variantKeys: Array<[string, string]> }>()\n\n for (const row of rows) {\n if (typeof row.fileKey !== 'string' || row.fileKey === '') continue\n const variantKeys: Array<[string, string]> = styleEntries.map(([name, style]) => [\n name,\n deps.variantKey(row.fileKey, name, style.format ?? 'webp'),\n ])\n perRow.set(row.id, { fileKey: row.fileKey, variantKeys })\n keysToLookUp.push(row.fileKey, ...variantKeys.map(([, key]) => key))\n }\n\n const publicUrls = await deps.getPublicUrls(keysToLookUp)\n\n for (const row of rows) {\n const keys = perRow.get(row.id)\n if (!keys) continue\n const url = publicUrls.get(keys.fileKey)\n // No public URL for the ORIGINAL → the item does not resolve at all. Not\n // \"resolves with variants only\": the variants of a private original are an\n // accident of processing, not a sanctioned public view of it.\n if (url === undefined) continue\n\n const variants: Record<string, string> = {}\n for (const [styleName, variantKey] of keys.variantKeys) {\n const variantUrl = publicUrls.get(variantKey)\n if (variantUrl !== undefined) variants[styleName] = variantUrl\n }\n\n out.set(row.id, {\n id: row.id,\n url,\n mimeType: row.mimeType ?? null,\n width: row.width ?? null,\n height: row.height ?? null,\n alt: row.alt ?? null,\n title: row.title ?? null,\n variants,\n })\n }\n\n return out\n}\n\n/**\n * Wire {@link resolvePublicMedia} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveMedia })`.\n *\n * Every dependency is resolved through the PUBLIC read path:\n * `MediaQueryClient` (so the publish filter, the `view` gate and locale merging\n * all apply, and `alt`/`title` come back in the requested locale), and\n * `StorageClient.getPublicUrls` (which has no signed-URL branch at all).\n *\n * Imports are dynamic for the same reason `createMediaQueryClient`'s are: this\n * must be callable from a route module without dragging the storage/settings\n * graph into whatever bundle imports it.\n */\n/**\n * A single-value cache with a wall-clock TTL.\n *\n * Extracted rather than inlined so it can be tested with an injected clock:\n * the thing being asserted is time-dependent, and new stateful code on the\n * anonymous request path with no coverage is how a cache ends up serving the\n * wrong thing for a window nobody measured.\n *\n * Caches the PROMISE, not the settled value, so concurrent callers arriving\n * before the first load resolves share it. Caching only the value would let N\n * simultaneous requests each start their own read — the stampede this memo\n * exists to remove, on the one path where bursts are expected.\n *\n * A rejection is still NOT pinned for the TTL: the entry is dropped when the\n * promise rejects, so the next call retries rather than replaying a transient\n * outage for the rest of the window.\n */\nexport function memoiseWithTtl<T>(\n load: () => Promise<T>,\n ttlMs: number,\n now: () => number = Date.now,\n): () => Promise<T> {\n let cached: { value: Promise<T>; at: number } | null = null\n return async () => {\n const t = now()\n if (cached && t - cached.at < ttlMs) return cached.value\n const value = load()\n cached = { value, at: t }\n value.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached?.value === value) cached = null\n })\n return value\n }\n}\n\nexport function createPublicMediaResolver(): (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, PublicMediaProjection>> {\n // The image-style set, memoised with a SHORT TTL.\n //\n // `resolveImageStyles` reads the settings DB and is documented as not cached,\n // so without this every media pass costs a settings query — and there is more\n // than one pass per response (the root rows, plus one per `?populate=`\n // expansion group), on an anonymous request path. A process-lifetime cache\n // would be wrong in the other direction: image styles are ADMIN-EDITABLE at\n // runtime, so it would serve a stale style set until redeploy.\n //\n // 30s mirrors the TTL `content-api` uses for its permission checker, and the\n // bound is the same shape: per process, so a multi-instance deployment sees a\n // new style within one TTL of each instance's own expiry. The cost of being\n // stale here is a variant URL for a style that was just added or removed —\n // the \"missing or not public\" path already handles it as an absent variant.\n const STYLES_TTL_MS = 30_000\n const imageStyles = memoiseWithTtl(async (): Promise<Record<string, ImageStyle>> => {\n const { getApp } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('./resolve-image-styles.js')\n return resolveImageStyles(getApp())\n }, STYLES_TTL_MS)\n\n return async (ids, opts = {}) => {\n const [{ createMediaQueryClient }, { getSharedStorageClient }, { deriveVariantKey }] =\n await Promise.all([\n import('./query-client.js'),\n import('./client.js'),\n import('./variant-key.js'),\n ])\n\n const [mediaQuery, storage] = await Promise.all([\n createMediaQueryClient(),\n getSharedStorageClient(),\n ])\n\n return resolvePublicMedia(\n ids,\n {\n readMedia: async (mediaIds, locale) => {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { getTableColumns, inArray } = await import('drizzle-orm')\n const table = schemaRegistry.get('media')\n if (!table) return []\n // Column refs come from drizzle's own typed accessor, never from a bare\n // `table.id` — the registry hands back a dynamically-generated table\n // whose property access is untyped, so reaching through it would put an\n // `any` straight into the WHERE clause.\n const columns = getTableColumns(table) as Record<string, PgColumn>\n const idColumn = columns.id\n if (!idColumn) return []\n const rows = await mediaQuery.findMany({\n where: inArray(idColumn, [...mediaIds]),\n limit: mediaIds.length,\n ...(locale !== undefined && { locale }),\n })\n return rows as unknown as PublicMediaRow[]\n },\n getPublicUrls: (keys) => storage.getPublicUrls(keys),\n imageStyles,\n variantKey: deriveVariantKey,\n },\n opts,\n )\n }\n}\n\n/**\n * One image the CANVAS can draw for a media id — the shape `@murumets-ee/blocks`'\n * `CanvasImageResolver` asks for, declared structurally so this package does not\n * depend on blocks.\n */\nexport interface CanvasImage {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n}\n\nlet canvasResolver: ReturnType<typeof createPublicMediaResolver> | undefined\n\n/**\n * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`\n * column (frontend-delivery F112) and a collection card's picture.\n *\n * The ready-made `blocksPlugin({ resolveImages })` value, so an app wires one lazy\n * line instead of copying a resolver (frontend-delivery D025 — no builder's\n * manual):\n *\n * ```ts\n * blocksPlugin({\n * resolveImages: async (ids, opts) =>\n * (await import('@murumets-ee/media/public')).resolveCanvasImages(ids, opts),\n * })\n * ```\n *\n * Two properties are the point, not conveniences:\n *\n * - **The SAME public resolver content-api uses**, so the canvas cannot show an\n * image the published site would not (a private asset draws as empty on both).\n * - **Images only.** A `field.media({ kind: 'image' })` constrains only the\n * picker's list (editor F116), so a video or PDF can sit in the column; drawn\n * through an `<img>` it would be a broken image. Anything that is not\n * `image/*` is left out, and the canvas draws its empty placeholder instead.\n *\n * The resolver is built once per process — it memoises the image-style set, which\n * a fresh one per call would re-read.\n */\nexport async function resolveCanvasImages(\n ids: readonly string[],\n opts: { locale: string },\n): Promise<Map<string, CanvasImage>> {\n canvasResolver ??= createPublicMediaResolver()\n return toCanvasImages(await canvasResolver(ids, opts))\n}\n\n/**\n * The pure half of {@link resolveCanvasImages}: keep images, drop everything else,\n * project to what the canvas draws. Exported so the filter has a test that does\n * not need a database.\n */\nexport function toCanvasImages(\n found: ReadonlyMap<string, PublicMediaProjection>,\n): Map<string, CanvasImage> {\n const images = new Map<string, CanvasImage>()\n for (const [id, media] of found) {\n if (media.mimeType?.startsWith('image/') !== true) continue\n images.set(id, { url: media.url, alt: media.alt, width: media.width, height: media.height })\n }\n return images\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"public-resolver.mjs","names":[],"sources":["../src/public-resolver.ts"],"sourcesContent":["/**\n * The PUBLIC media resolver — media as data, for a consumer that builds its own\n * markup (plan/api phase 06, D021).\n *\n * **Why not `resolveMediaRefs`.** That helper parses `[media:type:id:variant]`\n * tags out of a text string and substitutes rendered HTML (`<img …>`, `<a\n * href=…>`) through per-type renderers. Right for a server-rendered page, wrong\n * for an HTTP API: a frontend needs `{url, width, height, alt}` so it can emit\n * its own `<picture>` with its own `srcset`, not a pre-baked tag it has to parse\n * back apart. Nothing here deprecates `resolveMediaRefs`; the API simply uses a\n * different, data-shaped path.\n *\n * **Why the entity cannot just be whitelisted.** `Media` is a real entity, but\n * it has no `url` field and cannot have one: what is stored is `fileKey`, and\n * the URL is DERIVED by the storage layer, which also decides whether it is a\n * direct public link or a time-limited signed one. Serving `media` as an\n * ordinary whitelisted entity would hand a consumer a storage key — useless for\n * rendering, and a gratuitous disclosure of storage layout.\n *\n * **This resolver can never emit a signed URL, structurally.** It asks storage\n * for PUBLIC urls (`getPublicUrls`) rather than asking for \"a url\" and\n * inspecting what came back. The second shape produces a signed URL on the\n * happy path and relies on a later check to suppress it — and a signed URL is a\n * bearer capability that outlives the permission check that minted it, on a\n * response the public role is deliberately allowed to cache. There is no branch\n * here to forget.\n *\n * D021 permits a permission-gated scoped URL for NON-public principals on\n * responses already forced to `no-store`. It is deliberately not built: no\n * consumer needs it, and an unexercised signed-URL branch is a check that\n * cannot fail. The shape is fixed so nobody re-derives it; the path waits for a\n * named consumer.\n */\n\nimport type { PgColumn } from 'drizzle-orm/pg-core'\n\nexport { cacheOnceUnlessRejected } from './async-cache.js'\n\nimport { type FocalPoint, readFocalPoint } from './crop/focal-point.js'\nimport type { ImageStyle } from './types.js'\n\n/**\n * One media item as it reaches an HTTP consumer.\n *\n * A STRUCTURAL match for `@murumets-ee/content-api`'s `MediaProjection`, not an\n * import of it: `content-api` is a leaf that this package's consumers wire into\n * by hand, exactly like `ContentApiResolution` mirrors content's\n * `ResolveResult`. The app that wires the two is where TypeScript checks they\n * agree.\n *\n * `width`/`height` ride along because a frontend needs them to reserve layout\n * space before the image loads; omitting them forces either a layout shift or a\n * second request, and the second request is the 4+N failure D001 exists to\n * prevent. `alt`/`title` are translatable on the Media entity, so this\n * projection is locale-dependent.\n */\nexport interface PublicMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n /**\n * Variant URLs by image-style name, INLINED as data rather than handed over\n * as a URL pattern to interpolate.\n *\n * Two reasons, and the second is a security property. D008's rule is that a\n * client derives nothing — a consumer that constructs URLs from a pattern\n * encodes an assumption the server never published, and nothing can detect\n * when it goes stale. And a URL pattern is necessarily public-SHAPED: the\n * moment a consumer can build its own URL, the server has lost the ability to\n * decide what that consumer may address, and the \"never a signed URL\"\n * guarantee becomes unenforceable because the consumer stopped asking.\n *\n * The payload multiplier is bounded by CONFIGURATION, not by content: the set\n * of image styles is operator-chosen and neither the caller nor the editor can\n * grow it. So this needs no `?populate=`-style opt-in — unlike reference\n * expansion, where the caller could otherwise drag arbitrarily large targets\n * in. A style whose variant file is missing or not public is simply absent.\n */\n variants: Record<string, string>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n *\n * ALWAYS present: a row with no focal point, or one whose stored value is\n * unusable, projects as the centre, so no consumer branches on absence (D010).\n * It rides the PROJECTION and never the stored block ref — the resolver\n * substitutes fresh data on every read, so a point edited today reaches a page\n * saved last year with nothing rewritten (D007).\n */\n focalPoint: FocalPoint\n}\n\n/** The Media rows this resolver needs. Structural, so any read path can supply them. */\nexport interface PublicMediaRow {\n id: string\n fileKey: string\n mimeType?: string | null\n width?: number | null\n height?: number | null\n alt?: string | null\n title?: string | null\n /** The stored `media.focalPoint` — untrusted here, read through `readFocalPoint`. */\n focalPoint?: unknown\n}\n\nexport interface PublicMediaResolverDeps {\n /**\n * Read media rows by id through a PUBLISHED-ONLY path.\n *\n * **The wiring must guarantee this** — pass a `MediaQueryClient`/`QueryClient`\n * read, never an `AdminClient` one. This module cannot enforce the choice (it\n * is injected), and an admin read would surface unpublished media on the\n * anonymous surface. Same posture, and same reason, as\n * `ContentApiResolution.resolvePath`.\n */\n readMedia: (ids: readonly string[], locale?: string) => Promise<PublicMediaRow[]>\n /**\n * Public URLs for a batch of storage keys. Anything missing or non-public is\n * ABSENT from the map — see `StorageClient.getPublicUrls`, which is the ask-\n * for-public shape this whole module rests on.\n */\n getPublicUrls: (keys: readonly string[]) => Promise<Map<string, string>>\n /** The configured image styles. Operator-owned; neither caller nor editor grows it. */\n imageStyles: () => Promise<Record<string, ImageStyle>>\n /** Derives a variant's storage key from the original's. */\n variantKey: (fileKey: string, styleName: string, format: string) => string\n}\n\n/**\n * Resolve media ids to public projections.\n *\n * Ids that do not exist, are unpublished for this caller, or whose file is not\n * public are ABSENT from the returned map — one absence, four causes, exactly\n * as phase 05's failure semantics require. The caller renders without them.\n *\n * Cost is THREE round trips at most, regardless of batch size: the image-style\n * lookup (usually served from the caller's cache — see\n * {@link createPublicMediaResolver}), one entity read, and one storage read\n * covering every original and every variant key. Emphatically NOT\n * N×(styles+1), which is what a per-item convenience method would have made it.\n * The storage read is internally chunked when the key count exceeds what one\n * query may carry, so \"one storage read\" is `ceil(keys / 500)` in the extreme.\n */\nexport async function resolvePublicMedia(\n ids: readonly string[],\n deps: PublicMediaResolverDeps,\n opts: { locale?: string | undefined } = {},\n): Promise<Map<string, PublicMediaProjection>> {\n const out = new Map<string, PublicMediaProjection>()\n const unique = [...new Set(ids)]\n if (unique.length === 0) return out\n\n const rows = await deps.readMedia(unique, opts.locale)\n if (rows.length === 0) return out\n\n const styles = await deps.imageStyles()\n const styleEntries = Object.entries(styles)\n\n // Every key we might need — originals AND variants — resolved in one storage\n // query. `getPublicUrls` returns nothing for a key that is missing or not\n // public, so a private original drops the whole item and a private variant\n // drops only that variant.\n const keysToLookUp: string[] = []\n /** Per row: its original key, plus (styleName → variant key). */\n const perRow = new Map<string, { fileKey: string; variantKeys: Array<[string, string]> }>()\n\n for (const row of rows) {\n if (typeof row.fileKey !== 'string' || row.fileKey === '') continue\n const variantKeys: Array<[string, string]> = styleEntries.map(([name, style]) => [\n name,\n deps.variantKey(row.fileKey, name, style.format ?? 'webp'),\n ])\n perRow.set(row.id, { fileKey: row.fileKey, variantKeys })\n keysToLookUp.push(row.fileKey, ...variantKeys.map(([, key]) => key))\n }\n\n const publicUrls = await deps.getPublicUrls(keysToLookUp)\n\n for (const row of rows) {\n const keys = perRow.get(row.id)\n if (!keys) continue\n const url = publicUrls.get(keys.fileKey)\n // No public URL for the ORIGINAL → the item does not resolve at all. Not\n // \"resolves with variants only\": the variants of a private original are an\n // accident of processing, not a sanctioned public view of it.\n if (url === undefined) continue\n\n const variants: Record<string, string> = {}\n for (const [styleName, variantKey] of keys.variantKeys) {\n const variantUrl = publicUrls.get(variantKey)\n if (variantUrl !== undefined) variants[styleName] = variantUrl\n }\n\n out.set(row.id, {\n id: row.id,\n url,\n mimeType: row.mimeType ?? null,\n width: row.width ?? null,\n height: row.height ?? null,\n alt: row.alt ?? null,\n title: row.title ?? null,\n variants,\n focalPoint: readFocalPoint(row.focalPoint),\n })\n }\n\n return out\n}\n\n/**\n * Wire {@link resolvePublicMedia} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveMedia })`.\n *\n * Every dependency is resolved through the PUBLIC read path:\n * `MediaQueryClient` (so the publish filter, the `view` gate and locale merging\n * all apply, and `alt`/`title` come back in the requested locale), and\n * `StorageClient.getPublicUrls` (which has no signed-URL branch at all).\n *\n * Imports are dynamic for the same reason `createMediaQueryClient`'s are: this\n * must be callable from a route module without dragging the storage/settings\n * graph into whatever bundle imports it.\n */\n/**\n * A single-value cache with a wall-clock TTL.\n *\n * Extracted rather than inlined so it can be tested with an injected clock:\n * the thing being asserted is time-dependent, and new stateful code on the\n * anonymous request path with no coverage is how a cache ends up serving the\n * wrong thing for a window nobody measured.\n *\n * Caches the PROMISE, not the settled value, so concurrent callers arriving\n * before the first load resolves share it. Caching only the value would let N\n * simultaneous requests each start their own read — the stampede this memo\n * exists to remove, on the one path where bursts are expected.\n *\n * A rejection is still NOT pinned for the TTL: the entry is dropped when the\n * promise rejects, so the next call retries rather than replaying a transient\n * outage for the rest of the window.\n */\nexport function memoiseWithTtl<T>(\n load: () => Promise<T>,\n ttlMs: number,\n now: () => number = Date.now,\n): () => Promise<T> {\n let cached: { value: Promise<T>; at: number } | null = null\n return async () => {\n const t = now()\n if (cached && t - cached.at < ttlMs) return cached.value\n const value = load()\n cached = { value, at: t }\n value.catch(() => {\n // Only clear OUR entry — a later call may already have replaced it.\n if (cached?.value === value) cached = null\n })\n return value\n }\n}\n\nexport function createPublicMediaResolver(): (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, PublicMediaProjection>> {\n // The image-style set, memoised with a SHORT TTL.\n //\n // `resolveImageStyles` reads the settings DB and is documented as not cached,\n // so without this every media pass costs a settings query — and there is more\n // than one pass per response (the root rows, plus one per `?populate=`\n // expansion group), on an anonymous request path. A process-lifetime cache\n // would be wrong in the other direction: image styles are ADMIN-EDITABLE at\n // runtime, so it would serve a stale style set until redeploy.\n //\n // 30s mirrors the TTL `content-api` uses for its permission checker, and the\n // bound is the same shape: per process, so a multi-instance deployment sees a\n // new style within one TTL of each instance's own expiry. The cost of being\n // stale here is a variant URL for a style that was just added or removed —\n // the \"missing or not public\" path already handles it as an absent variant.\n const STYLES_TTL_MS = 30_000\n const imageStyles = memoiseWithTtl(async (): Promise<Record<string, ImageStyle>> => {\n const { getApp } = await import('@murumets-ee/core')\n const { resolveImageStyles } = await import('./resolve-image-styles.js')\n return resolveImageStyles(getApp())\n }, STYLES_TTL_MS)\n\n return async (ids, opts = {}) => {\n const [{ createMediaQueryClient }, { getSharedStorageClient }, { deriveVariantKey }] =\n await Promise.all([\n import('./query-client.js'),\n import('./client.js'),\n import('./variant-key.js'),\n ])\n\n const [mediaQuery, storage] = await Promise.all([\n createMediaQueryClient(),\n getSharedStorageClient(),\n ])\n\n return resolvePublicMedia(\n ids,\n {\n readMedia: async (mediaIds, locale) => {\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { getTableColumns, inArray } = await import('drizzle-orm')\n const table = schemaRegistry.get('media')\n if (!table) return []\n // Column refs come from drizzle's own typed accessor, never from a bare\n // `table.id` — the registry hands back a dynamically-generated table\n // whose property access is untyped, so reaching through it would put an\n // `any` straight into the WHERE clause.\n const columns = getTableColumns(table) as Record<string, PgColumn>\n const idColumn = columns.id\n if (!idColumn) return []\n const rows = await mediaQuery.findMany({\n where: inArray(idColumn, [...mediaIds]),\n limit: mediaIds.length,\n ...(locale !== undefined && { locale }),\n })\n return rows as unknown as PublicMediaRow[]\n },\n getPublicUrls: (keys) => storage.getPublicUrls(keys),\n imageStyles,\n variantKey: deriveVariantKey,\n },\n opts,\n )\n }\n}\n\n/**\n * One image the CANVAS can draw for a media id — the shape `@murumets-ee/blocks`'\n * `CanvasImageResolver` asks for, declared structurally so this package does not\n * depend on blocks.\n */\nexport interface CanvasImage {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n}\n\nlet canvasResolver: ReturnType<typeof createPublicMediaResolver> | undefined\n\n/**\n * Media ids → images the block-editor CANVAS may draw: a BOUND `field.media()`\n * column (frontend-delivery F112) and a collection card's picture.\n *\n * The ready-made `blocksPlugin({ resolveImages })` value, so an app wires one lazy\n * line instead of copying a resolver (frontend-delivery D025 — no builder's\n * manual):\n *\n * ```ts\n * blocksPlugin({\n * resolveImages: async (ids, opts) =>\n * (await import('@murumets-ee/media/public')).resolveCanvasImages(ids, opts),\n * })\n * ```\n *\n * Two properties are the point, not conveniences:\n *\n * - **The SAME public resolver content-api uses**, so the canvas cannot show an\n * image the published site would not (a private asset draws as empty on both).\n * - **Images only.** A `field.media({ kind: 'image' })` constrains only the\n * picker's list (editor F116), so a video or PDF can sit in the column; drawn\n * through an `<img>` it would be a broken image. Anything that is not\n * `image/*` is left out, and the canvas draws its empty placeholder instead.\n *\n * The resolver is built once per process — it memoises the image-style set, which\n * a fresh one per call would re-read.\n */\nexport async function resolveCanvasImages(\n ids: readonly string[],\n opts: { locale: string },\n): Promise<Map<string, CanvasImage>> {\n canvasResolver ??= createPublicMediaResolver()\n return toCanvasImages(await canvasResolver(ids, opts))\n}\n\n/**\n * The pure half of {@link resolveCanvasImages}: keep images, drop everything else,\n * project to what the canvas draws. Exported so the filter has a test that does\n * not need a database.\n */\nexport function toCanvasImages(\n found: ReadonlyMap<string, PublicMediaProjection>,\n): Map<string, CanvasImage> {\n const images = new Map<string, CanvasImage>()\n for (const [id, media] of found) {\n if (media.mimeType?.startsWith('image/') !== true) continue\n images.set(id, { url: media.url, alt: media.alt, width: media.width, height: media.height })\n }\n return images\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { a as MediaRecord, l as Media } from "./types-1idCpe1k.mjs";
1
+ import { a as MediaRecord, l as Media } from "./types-CnORVkLZ.mjs";
2
2
  import { CountOptions, FindByIdOptions, FindManyOptions, QueryClient } from "@murumets-ee/entity/query";
3
3
 
4
4
  //#region src/query-client.d.ts
@@ -1,2 +1,2 @@
1
- var e=class{query;constructor(e){this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};async function t(){let{createQueryClient:t}=await import(`@murumets-ee/core/clients`),{Media:n}=await import(`./entity-fxw-Qywj.mjs`).then(e=>e.n);return new e({query:t(n)})}export{e as MediaQueryClient,t as createMediaQueryClient};
1
+ var e=class{query;constructor(e){this.query=e.query}async findById(e,t){return this.query.findById(e,t)}async findMany(e){return this.query.findMany(e)}async count(e){return this.query.count(e)}};async function t(){let{createQueryClient:t}=await import(`@murumets-ee/core/clients`),{Media:n}=await import(`./entity-DKFCdQJT.mjs`).then(e=>e.n);return new e({query:t(n)})}export{e as MediaQueryClient,t as createMediaQueryClient};
2
2
  //# sourceMappingURL=query-client.mjs.map
@@ -1,2 +1,2 @@
1
- import{n as e,r as t}from"./process-image-DYDTMGUJ.mjs";import{t as n}from"./variant-key-JBTJXPL1.mjs";import"server-only";async function r(r){let{app:i,storage:a,logger:o,styles:s}=r,c=i.db.readWrite,{AdminClient:l}=await import(`@murumets-ee/entity/admin`),{Media:u}=await import(`./entity-fxw-Qywj.mjs`).then(e=>e.n),{schemaRegistry:d}=await import(`@murumets-ee/db`),{eq:f}=await import(`drizzle-orm`),p=new l({entity:u,db:c,logger:o,contextResolver:r.contextResolver}),m=d.get(`media`);if(!m)throw Error(`Media schema not registered`);let h={total:0,processed:0,skipped:0,errors:0},g=0;for(o?.info({styles:Object.keys(s)},`Starting variant regeneration`);;){let r=await p.findMany({where:f(m.mediaType,`image`),limit:100,offset:g});if(r.length===0)break;h.total+=r.length;for(let i of r)try{if(!e(i.mimeType)){h.skipped++;continue}let r=await a.download(i.fileKey),c;if(Buffer.isBuffer(r.body))c=r.body;else{let e=[],t=r.body.getReader();for(;;){let{done:n,value:r}=await t.read();if(n)break;r&&e.push(r)}c=Buffer.concat(e)}let l=await t(c,s),u=await a.getMetadata(i.fileKey),d=u?.metadata?.variants;d&&await Promise.all(Object.values(d).map(e=>a.delete(e).catch(()=>{})));let f={},p=u?.visibility??`public`,m=await Promise.all([...l.variants].map(async([e,t])=>{let r=n(i.fileKey,e,t.format);try{return await a.upload(t.buffer,{key:r,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:p,metadata:{variantOf:i.fileKey,style:e}}),{styleName:e,vKey:r,ok:!0}}catch(t){return o?.warn({style:e,key:r,error:t},`Failed to upload regenerated variant (non-fatal)`),{styleName:e,vKey:r,ok:!1}}}));for(let e of m)e.ok&&(f[e.styleName]=e.vKey);Object.keys(f).length>0&&await a.updateMetadata(i.fileKey,{metadata:{...u?.metadata??{},variants:f}}).catch(e=>{o?.warn({key:i.fileKey,error:e},`Failed to update variant metadata (non-fatal)`)}),h.processed++,o?.debug({id:i.id,variants:Object.keys(f)},`Regenerated variants`)}catch(e){h.errors++,o?.error({id:i.id,fileKey:i.fileKey,error:e},`Failed to regenerate variants for media record`)}if(g+=100,r.length<100)break}return o?.info(h,`Variant regeneration complete`),h}export{r as regenerateAllVariants};
2
- //# sourceMappingURL=regenerate-variants-sit6LbUo.mjs.map
1
+ import{i as e,r as t}from"./process-image-WByEHTXQ.mjs";import{t as n}from"./variant-key-JBTJXPL1.mjs";import"server-only";async function r(r){let{app:i,storage:a,logger:o,styles:s}=r,c=i.db.readWrite,{AdminClient:l}=await import(`@murumets-ee/entity/admin`),{Media:u}=await import(`./entity-DKFCdQJT.mjs`).then(e=>e.n),{schemaRegistry:d}=await import(`@murumets-ee/db`),{eq:f}=await import(`drizzle-orm`),p=new l({entity:u,db:c,logger:o,contextResolver:r.contextResolver}),m=d.get(`media`);if(!m)throw Error(`Media schema not registered`);let h={total:0,processed:0,skipped:0,errors:0},g=0;for(o?.info({styles:Object.keys(s)},`Starting variant regeneration`);;){let r=await p.findMany({where:f(m.mediaType,`image`),limit:100,offset:g});if(r.length===0)break;h.total+=r.length;for(let i of r)try{if(!t(i.mimeType)){h.skipped++;continue}let r=await a.download(i.fileKey),c;if(Buffer.isBuffer(r.body))c=r.body;else{let e=[],t=r.body.getReader();for(;;){let{done:n,value:r}=await t.read();if(n)break;r&&e.push(r)}c=Buffer.concat(e)}let l=await e(c,s),u=await a.getMetadata(i.fileKey),d=u?.metadata?.variants;d&&await Promise.all(Object.values(d).map(e=>a.delete(e).catch(()=>{})));let f={},p=u?.visibility??`public`,m=await Promise.all([...l.variants].map(async([e,t])=>{let r=n(i.fileKey,e,t.format);try{return await a.upload(t.buffer,{key:r,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:p,metadata:{variantOf:i.fileKey,style:e}}),{styleName:e,vKey:r,ok:!0}}catch(t){return o?.warn({style:e,key:r,error:t},`Failed to upload regenerated variant (non-fatal)`),{styleName:e,vKey:r,ok:!1}}}));for(let e of m)e.ok&&(f[e.styleName]=e.vKey);Object.keys(f).length>0&&await a.updateMetadata(i.fileKey,{metadata:{...u?.metadata??{},variants:f}}).catch(e=>{o?.warn({key:i.fileKey,error:e},`Failed to update variant metadata (non-fatal)`)}),h.processed++,o?.debug({id:i.id,variants:Object.keys(f)},`Regenerated variants`)}catch(e){h.errors++,o?.error({id:i.id,fileKey:i.fileKey,error:e},`Failed to regenerate variants for media record`)}if(g+=100,r.length<100)break}return o?.info(h,`Variant regeneration complete`),h}export{r as regenerateAllVariants};
2
+ //# sourceMappingURL=regenerate-variants-2Lmo5kf8.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"regenerate-variants-sit6LbUo.mjs","names":[],"sources":["../src/regenerate-variants.ts"],"sourcesContent":["/**\n * Batch variant regeneration — reprocesses all image media with updated styles.\n *\n * Downloads each original from storage, generates new variants via Sharp,\n * cleans up old variant files, and uploads new ones.\n *\n * Per-image errors are logged but don't stop the batch.\n */\n\nimport 'server-only'\n\nimport type { Logger, ToolkitApp } from '@murumets-ee/core'\nimport type { ContextResolver } from '@murumets-ee/entity'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type { ImageStyle } from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\nconst BATCH_SIZE = 100\n\nexport interface RegenerateOptions {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n storage: StorageClient\n logger?: Logger\n /** Current image styles to generate */\n styles: Record<string, ImageStyle>\n /** Security context resolver — passed through to AdminClient. */\n contextResolver?: ContextResolver\n}\n\nexport interface RegenerateResult {\n /** Total image media records found */\n total: number\n /** Successfully reprocessed */\n processed: number\n /** Skipped (non-processable mimeType, download failed, etc.) */\n skipped: number\n /** Failed with errors */\n errors: number\n}\n\n/**\n * Regenerate variants for all image media.\n * Processes in batches of 100 to avoid memory pressure.\n */\nexport async function regenerateAllVariants(options: RegenerateOptions): Promise<RegenerateResult> {\n const { app, storage, logger, styles } = options\n const db = app.db.readWrite\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n const { Media } = await import('./entity.js')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { eq } = await import('drizzle-orm')\n\n const admin = new AdminClient<typeof Media.allFields>({\n entity: Media,\n db,\n logger,\n contextResolver: options.contextResolver,\n })\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered')\n\n const result: RegenerateResult = { total: 0, processed: 0, skipped: 0, errors: 0 }\n let offset = 0\n\n logger?.info({ styles: Object.keys(styles) }, 'Starting variant regeneration')\n\n // Process in batches\n while (true) {\n const batch = await admin.findMany({\n where: eq(table.mediaType, 'image'),\n limit: BATCH_SIZE,\n offset,\n })\n\n if (batch.length === 0) break\n result.total += batch.length\n\n for (const record of batch) {\n try {\n // Skip non-processable images (SVG, GIF)\n if (!isProcessableImage(record.mimeType)) {\n result.skipped++\n continue\n }\n\n // Download original from storage\n const downloaded = await storage.download(record.fileKey)\n let buffer: Buffer\n if (Buffer.isBuffer(downloaded.body)) {\n buffer = downloaded.body\n } else {\n // ReadableStream → Buffer\n const chunks: Uint8Array[] = []\n const reader = downloaded.body.getReader()\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value) chunks.push(value)\n }\n buffer = Buffer.concat(chunks)\n }\n\n // Generate new variants\n const processed = await processImage(buffer, styles)\n\n // Delete old variant files (best-effort, parallel)\n const oldFileRecord = await storage.getMetadata(record.fileKey)\n const oldVariants = (oldFileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n if (oldVariants) {\n await Promise.all(\n Object.values(oldVariants).map((vKey) => storage.delete(vKey).catch(() => {})),\n )\n }\n\n // Upload new variants (parallel)\n const newVariantKeys: Record<string, string> = {}\n const visibility = oldFileRecord?.visibility ?? 'public'\n const uploadResults = await Promise.all(\n [...processed.variants].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(record.fileKey, styleName, variant.format)\n try {\n await storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${record.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: record.fileKey, style: styleName },\n })\n return { styleName, vKey, ok: true as const }\n } catch (uploadErr) {\n logger?.warn(\n { style: styleName, key: vKey, error: uploadErr },\n 'Failed to upload regenerated variant (non-fatal)',\n )\n return { styleName, vKey, ok: false as const }\n }\n }),\n )\n for (const r of uploadResults) {\n if (r.ok) newVariantKeys[r.styleName] = r.vKey\n }\n\n // Update original file's metadata with new variant keys\n if (Object.keys(newVariantKeys).length > 0) {\n await storage\n .updateMetadata(record.fileKey, {\n metadata: {\n ...(oldFileRecord?.metadata ?? {}),\n variants: newVariantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n logger?.warn(\n { key: record.fileKey, error: metaErr },\n 'Failed to update variant metadata (non-fatal)',\n )\n })\n }\n\n result.processed++\n logger?.debug(\n { id: record.id, variants: Object.keys(newVariantKeys) },\n 'Regenerated variants',\n )\n } catch (err) {\n result.errors++\n logger?.error(\n { id: record.id, fileKey: record.fileKey, error: err },\n 'Failed to regenerate variants for media record',\n )\n }\n }\n\n offset += BATCH_SIZE\n if (batch.length < BATCH_SIZE) break\n }\n\n logger?.info(result, 'Variant regeneration complete')\n return result\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"regenerate-variants-2Lmo5kf8.mjs","names":[],"sources":["../src/regenerate-variants.ts"],"sourcesContent":["/**\n * Batch variant regeneration — reprocesses all image media with updated styles.\n *\n * Downloads each original from storage, generates new variants via Sharp,\n * cleans up old variant files, and uploads new ones.\n *\n * Per-image errors are logged but don't stop the batch.\n */\n\nimport 'server-only'\n\nimport type { Logger, ToolkitApp } from '@murumets-ee/core'\nimport type { ContextResolver } from '@murumets-ee/entity'\nimport type { StorageClient } from '@murumets-ee/storage'\nimport { isProcessableImage, processImage } from './process-image.js'\nimport type { ImageStyle } from './types.js'\nimport { deriveVariantKey } from './variant-key.js'\n\nconst BATCH_SIZE = 100\n\nexport interface RegenerateOptions {\n /** Toolkit app (provides db). */\n app: ToolkitApp\n storage: StorageClient\n logger?: Logger\n /** Current image styles to generate */\n styles: Record<string, ImageStyle>\n /** Security context resolver — passed through to AdminClient. */\n contextResolver?: ContextResolver\n}\n\nexport interface RegenerateResult {\n /** Total image media records found */\n total: number\n /** Successfully reprocessed */\n processed: number\n /** Skipped (non-processable mimeType, download failed, etc.) */\n skipped: number\n /** Failed with errors */\n errors: number\n}\n\n/**\n * Regenerate variants for all image media.\n * Processes in batches of 100 to avoid memory pressure.\n */\nexport async function regenerateAllVariants(options: RegenerateOptions): Promise<RegenerateResult> {\n const { app, storage, logger, styles } = options\n const db = app.db.readWrite\n const { AdminClient } = await import('@murumets-ee/entity/admin')\n const { Media } = await import('./entity.js')\n const { schemaRegistry } = await import('@murumets-ee/db')\n const { eq } = await import('drizzle-orm')\n\n const admin = new AdminClient<typeof Media.allFields>({\n entity: Media,\n db,\n logger,\n contextResolver: options.contextResolver,\n })\n const table = schemaRegistry.get('media')\n if (!table) throw new Error('Media schema not registered')\n\n const result: RegenerateResult = { total: 0, processed: 0, skipped: 0, errors: 0 }\n let offset = 0\n\n logger?.info({ styles: Object.keys(styles) }, 'Starting variant regeneration')\n\n // Process in batches\n while (true) {\n const batch = await admin.findMany({\n where: eq(table.mediaType, 'image'),\n limit: BATCH_SIZE,\n offset,\n })\n\n if (batch.length === 0) break\n result.total += batch.length\n\n for (const record of batch) {\n try {\n // Skip non-processable images (SVG, GIF)\n if (!isProcessableImage(record.mimeType)) {\n result.skipped++\n continue\n }\n\n // Download original from storage\n const downloaded = await storage.download(record.fileKey)\n let buffer: Buffer\n if (Buffer.isBuffer(downloaded.body)) {\n buffer = downloaded.body\n } else {\n // ReadableStream → Buffer\n const chunks: Uint8Array[] = []\n const reader = downloaded.body.getReader()\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n if (value) chunks.push(value)\n }\n buffer = Buffer.concat(chunks)\n }\n\n // Generate new variants\n const processed = await processImage(buffer, styles)\n\n // Delete old variant files (best-effort, parallel)\n const oldFileRecord = await storage.getMetadata(record.fileKey)\n const oldVariants = (oldFileRecord?.metadata as Record<string, unknown> | null)?.variants as\n | Record<string, string>\n | undefined\n if (oldVariants) {\n await Promise.all(\n Object.values(oldVariants).map((vKey) => storage.delete(vKey).catch(() => {})),\n )\n }\n\n // Upload new variants (parallel)\n const newVariantKeys: Record<string, string> = {}\n const visibility = oldFileRecord?.visibility ?? 'public'\n const uploadResults = await Promise.all(\n [...processed.variants].map(async ([styleName, variant]) => {\n const vKey = deriveVariantKey(record.fileKey, styleName, variant.format)\n try {\n await storage.upload(variant.buffer, {\n key: vKey,\n filename: `${styleName}_${record.filename}`,\n mimeType: variant.mimeType,\n size: variant.buffer.byteLength,\n visibility,\n metadata: { variantOf: record.fileKey, style: styleName },\n })\n return { styleName, vKey, ok: true as const }\n } catch (uploadErr) {\n logger?.warn(\n { style: styleName, key: vKey, error: uploadErr },\n 'Failed to upload regenerated variant (non-fatal)',\n )\n return { styleName, vKey, ok: false as const }\n }\n }),\n )\n for (const r of uploadResults) {\n if (r.ok) newVariantKeys[r.styleName] = r.vKey\n }\n\n // Update original file's metadata with new variant keys\n if (Object.keys(newVariantKeys).length > 0) {\n await storage\n .updateMetadata(record.fileKey, {\n metadata: {\n ...(oldFileRecord?.metadata ?? {}),\n variants: newVariantKeys,\n },\n })\n .catch((metaErr: unknown) => {\n logger?.warn(\n { key: record.fileKey, error: metaErr },\n 'Failed to update variant metadata (non-fatal)',\n )\n })\n }\n\n result.processed++\n logger?.debug(\n { id: record.id, variants: Object.keys(newVariantKeys) },\n 'Regenerated variants',\n )\n } catch (err) {\n result.errors++\n logger?.error(\n { id: record.id, fileKey: record.fileKey, error: err },\n 'Failed to regenerate variants for media record',\n )\n }\n }\n\n offset += BATCH_SIZE\n if (batch.length < BATCH_SIZE) break\n }\n\n logger?.info(result, 'Variant regeneration complete')\n return result\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{n as e,r as t,t as n}from"./variant-key-CLlac3_1.mjs";import"server-only";async function r(r){let{app:i,storage:a,logger:o,styles:s}=r,c=i.db.readWrite,{AdminClient:l}=await import(`@murumets-ee/entity/admin`),{Media:u}=await import(`./entity-Caba_NFw.mjs`),{schemaRegistry:d}=await import(`@murumets-ee/db`),{eq:f}=await import(`drizzle-orm`),p=new l({entity:u,db:c,logger:o,contextResolver:r.contextResolver}),m=d.get(`media`);if(!m)throw Error(`Media schema not registered`);let h={total:0,processed:0,skipped:0,errors:0},g=0;for(o?.info({styles:Object.keys(s)},`Starting variant regeneration`);;){let r=await p.findMany({where:f(m.mediaType,`image`),limit:100,offset:g});if(r.length===0)break;h.total+=r.length;for(let i of r)try{if(!e(i.mimeType)){h.skipped++;continue}let r=await a.download(i.fileKey),c;if(Buffer.isBuffer(r.body))c=r.body;else{let e=[],t=r.body.getReader();for(;;){let{done:n,value:r}=await t.read();if(n)break;r&&e.push(r)}c=Buffer.concat(e)}let l=await t(c,s),u=await a.getMetadata(i.fileKey),d=u?.metadata?.variants;d&&await Promise.all(Object.values(d).map(e=>a.delete(e).catch(()=>{})));let f={},p=u?.visibility??`public`,m=await Promise.all([...l.variants].map(async([e,t])=>{let r=n(i.fileKey,e,t.format);try{return await a.upload(t.buffer,{key:r,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:p,metadata:{variantOf:i.fileKey,style:e}}),{styleName:e,vKey:r,ok:!0}}catch(t){return o?.warn({style:e,key:r,error:t},`Failed to upload regenerated variant (non-fatal)`),{styleName:e,vKey:r,ok:!1}}}));for(let e of m)e.ok&&(f[e.styleName]=e.vKey);Object.keys(f).length>0&&await a.updateMetadata(i.fileKey,{metadata:{...u?.metadata??{},variants:f}}).catch(e=>{o?.warn({key:i.fileKey,error:e},`Failed to update variant metadata (non-fatal)`)}),h.processed++,o?.debug({id:i.id,variants:Object.keys(f)},`Regenerated variants`)}catch(e){h.errors++,o?.error({id:i.id,fileKey:i.fileKey,error:e},`Failed to regenerate variants for media record`)}if(g+=100,r.length<100)break}return o?.info(h,`Variant regeneration complete`),h}export{r as regenerateAllVariants};
2
- //# sourceMappingURL=regenerate-variants-BysyeIoA.mjs.map
1
+ import{i as e,r as t,t as n}from"./variant-key-RKke2RfS.mjs";import"server-only";async function r(r){let{app:i,storage:a,logger:o,styles:s}=r,c=i.db.readWrite,{AdminClient:l}=await import(`@murumets-ee/entity/admin`),{Media:u}=await import(`./entity-CNxAERYG.mjs`),{schemaRegistry:d}=await import(`@murumets-ee/db`),{eq:f}=await import(`drizzle-orm`),p=new l({entity:u,db:c,logger:o,contextResolver:r.contextResolver}),m=d.get(`media`);if(!m)throw Error(`Media schema not registered`);let h={total:0,processed:0,skipped:0,errors:0},g=0;for(o?.info({styles:Object.keys(s)},`Starting variant regeneration`);;){let r=await p.findMany({where:f(m.mediaType,`image`),limit:100,offset:g});if(r.length===0)break;h.total+=r.length;for(let i of r)try{if(!t(i.mimeType)){h.skipped++;continue}let r=await a.download(i.fileKey),c;if(Buffer.isBuffer(r.body))c=r.body;else{let e=[],t=r.body.getReader();for(;;){let{done:n,value:r}=await t.read();if(n)break;r&&e.push(r)}c=Buffer.concat(e)}let l=await e(c,s),u=await a.getMetadata(i.fileKey),d=u?.metadata?.variants;d&&await Promise.all(Object.values(d).map(e=>a.delete(e).catch(()=>{})));let f={},p=u?.visibility??`public`,m=await Promise.all([...l.variants].map(async([e,t])=>{let r=n(i.fileKey,e,t.format);try{return await a.upload(t.buffer,{key:r,filename:`${e}_${i.filename}`,mimeType:t.mimeType,size:t.buffer.byteLength,visibility:p,metadata:{variantOf:i.fileKey,style:e}}),{styleName:e,vKey:r,ok:!0}}catch(t){return o?.warn({style:e,key:r,error:t},`Failed to upload regenerated variant (non-fatal)`),{styleName:e,vKey:r,ok:!1}}}));for(let e of m)e.ok&&(f[e.styleName]=e.vKey);Object.keys(f).length>0&&await a.updateMetadata(i.fileKey,{metadata:{...u?.metadata??{},variants:f}}).catch(e=>{o?.warn({key:i.fileKey,error:e},`Failed to update variant metadata (non-fatal)`)}),h.processed++,o?.debug({id:i.id,variants:Object.keys(f)},`Regenerated variants`)}catch(e){h.errors++,o?.error({id:i.id,fileKey:i.fileKey,error:e},`Failed to regenerate variants for media record`)}if(g+=100,r.length<100)break}return o?.info(h,`Variant regeneration complete`),h}export{r as regenerateAllVariants};
2
+ //# sourceMappingURL=regenerate-variants-B9AXXN6N.mjs.map