Skip to content

feat(media): implement complete media management system (#28)#38

Open
temi-Dee wants to merge 8 commits into
ACTA-Team:developfrom
temi-Dee:feat/media-management-system
Open

feat(media): implement complete media management system (#28)#38
temi-Dee wants to merge 8 commits into
ACTA-Team:developfrom
temi-Dee:feat/media-management-system

Conversation

@temi-Dee

@temi-Dee temi-Dee commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #28

Implements the complete media management system as specified, using Supabase Storage as the primary layer with image optimization, a media library admin UI, and optional Stellar hash anchoring for verifiable integrity.


What was implemented

Supabase Storage Configuration

  • 3 buckets: article-covers (10 MB), article-content (10 MB), author-avatars (5 MB)
  • Allowed MIME types: image/jpeg, image/png, image/webp, image/gif, image/svg+xml
  • RLS: public read on all buckets, authenticated write/update/delete per bucket
  • Migration: supabase/migrations/0002_media_library.sql

Upload Service

  • Server-side handler at POST /api/media/upload
  • Validates MIME type and file size against per-bucket limits
  • Generates unique path {year}/{month}/{uuid}.{ext}
  • Runs Sharp optimization pipeline, uploads original + all variants
  • Inserts media_library record; optionally anchors on Stellar

Image Optimization Pipeline (src/lib/storage/optimize.ts)

  • Variants: thumb (200px), sm (640px), md (1024px), lg (1920px), og (1200x630)
  • All variants converted to WebP (quality 82)
  • EXIF metadata stripped, aspect ratio preserved
  • SVGs returned as-is

Database Schema

  • media_library table with all 15 columns from the spec
  • Indexes on bucket, created_at, usage_count
  • RLS: public read, authenticated write

Media Library Admin UI (/admin/media)

  • Grid view with thumbnail, filename, dimensions, size, upload date, uploader, usage count
  • Search by filename, filter by bucket / date range / usage (used/unused)
  • Bulk delete with confirmation, bulk set alt text
  • Detail modal: full-size preview, all metadata, articles using the image, editable alt text, variant links
  • Stellar verification badge on anchored images

Article Editor Integration

  • ImagePickerModal with Library tab (search + grid) and Upload tab (drag-and-drop)
  • CoverImagePicker replaces raw URL input for cover image field

Next.js Image Integration

  • NewsCard and NewsDetail use next/image with fill, lazy loading, and sizes
  • next.config.ts has remotePatterns for **.supabase.co/storage/v1/object/public/**

Stellar Hash Anchoring (src/lib/stellar/anchor.ts)

  • SHA-256 of original file computed on every upload
  • Opt-in anchorOnStellar toggle per upload
  • Uses manageData operation on Stellar
  • Stellar explorer link respects NEXT_PUBLIC_STELLAR_NETWORK env var

Migration Script (src/lib/storage/migrate-images.ts)

  • Scans all news_articles for external cover_image_url and inline content images
  • Downloads, optimizes, uploads to Storage, updates article URLs
  • Generates success/fail report; safe to re-run via npm run migrate

Orphan Detection (/admin/media/orphans)

  • Admin-triggered rescan via POST /api/media/orphans
  • Recalculates usage_count for all media items
  • No auto-delete: always requires explicit admin confirmation

Security Fixes

  • SSRF: URL allowlist blocks private/loopback IP ranges in migration script
  • Path traversal: MIME type validated against allowlist before Sharp; filename sanitized with basename()
  • OS command injection: file extension validated against /[a-zA-Z0-9]{1,5}$/
  • Log injection: user-controlled values sanitized before console.error

Other

  • Root middleware.ts wiring up Supabase session refresh
  • .vscode/settings.json and .vscode/extensions.json for development consistency
  • tsx added as devDependency; npm run migrate script added

Acceptance Criteria

  • Images upload to Supabase Storage with automatic variant generation (thumb, sm, md, lg, og)
  • Media library admin page shows all uploads with search, filter, and metadata editing
  • Article editor has an image picker that browses the library or uploads new images
  • Cover image field uses the media library instead of a raw URL input
  • All images render through next/image with lazy loading and responsive sizes
  • File validation enforces type and size limits
  • Optional Stellar hash anchoring works for individual images
  • Migration script can process existing external image URLs
  • Orphan detection identifies unreferenced media for admin review
  • RLS policies enforce public read and admin-only write on all buckets

Summary by CodeRabbit

  • New Features

    • Full admin media library: upload, search, filtering, pagination, thumbnails
    • Media detail modal with alt-text editing, usage info, and delete
    • Bulk actions and orphaned-media rescan/cleanup UI
    • Image picker and cover-image picker for quick selection/upload
    • News articles now support/display cover images
    • Optional blockchain anchoring for media integrity verification
  • Chores

    • Workspace and environment configuration updates (VS Code, env example)
    • Added image optimization, migration script, and tooling scripts/dependencies

…an detection

- Add media library admin interface with grid view and filtering capabilities
- Implement media upload functionality with drag-and-drop zone support
- Add orphan media detection to identify unused assets in storage
- Create cover image picker component for article integration
- Add media detail modal for viewing and managing asset metadata
- Implement media API routes for CRUD operations and orphan detection
- Add Stellar hash anchoring support for media attestation
- Configure Sharp image processing library for server-side optimization
- Add media service layer for storage operations and migrations
- Create database migration for media library schema
- Update environment configuration with Stellar network settings
- Add VSCode workspace settings for development consistency
- Add media_library table migration with Storage bucket policies and RLS
- Add upload service with Sharp optimization pipeline (thumb/sm/md/lg/og variants)
- Add media library admin UI with grid view, filters, bulk actions, and detail modal
- Add image picker modal and cover image picker for article editor integration
- Add media API routes for upload, CRUD, orphan detection, and article refs
- Add Stellar SHA-256 hash anchoring with opt-in per upload
- Add one-time migration script for existing external image URLs
- Add root middleware for Supabase session refresh
- Fix SSRF, path traversal, log injection, and OS command injection vulnerabilities
- Configure next/image remotePatterns for Supabase Storage domain
- Add VSCode workspace settings and extension recommendations
@vercel

vercel Bot commented Jun 2, 2026

Copy link
Copy Markdown

@temi-Dee is attempting to deploy a commit to the ACTA Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Supabase-backed media library (upload, optimization, variants, DB + RLS, admin UI, migration, orphaning, optional Stellar anchoring) and a set of Claude agent prompts/workflow files plus workspace/editor config and Next.js middleware.

Changes

Media Management System

Layer / File(s) Summary
Types, constants, and DB types
src/@types/media.ts, src/lib/storage/constants.ts, src/lib/supabase/database.types.ts
Media domain types, variant specs, allowed MIME/bucket size constants, and media_library TypeScript DB types.
Database & Storage migration
supabase/migrations/0002_media_library.sql, .env.example
Adds media_library table, storage bucket upserts, indexes, and RLS policies; documents optional Stellar env vars.
Server config & middleware
next.config.ts, middleware.ts
Adds Supabase image remotePattern and serverExternalPackages for sharp; middleware delegates to updateSession for auth cookie refresh.
Upload & optimize services
src/lib/storage/upload.ts, src/lib/storage/optimize.ts, src/lib/storage/index.ts
Server-side upload pipeline: validation, SHA-256, deterministic path, Sharp-based variant generation (WebP), upload, metadata DB insert, and delete helper; exported barrel.
Media service APIs
src/lib/storage/media.service.ts
Supabase-backed list/detail/ref lookups, alt-text/tx-hash updates, usage-count recalculation, orphaned-media query.
Stellar anchoring
src/lib/stellar/anchor.ts, src/lib/stellar/index.ts
Server-only dynamic import of @stellar/stellar-sdk, anchorHashOnStellar and verifyAnchor helpers with network selection and manageData usage.
Migration script
src/lib/storage/migrate-images.ts, package.json (script)
Standalone migration scanning article cover/content images, downloading, optimizing, uploading, inserting media_library rows, and rewriting articles; migrate script added.
API routes
src/app/api/media/upload/route.ts, src/app/api/media/route.ts, src/app/api/media/[id]/route.ts, src/app/api/media/orphans/route.ts, src/app/api/media/[id]/articles/route.ts
Routes for upload (admin-only, multipart, optional non-blocking Stellar anchoring), list with filters/paging, PATCH alt-text, DELETE media, orphan listing/recalc, and article references for an item; auth/admin checks and error handling.
Admin UI components & utils
src/components/modules/admin/media/*.tsx, src/components/modules/admin/media/utils.ts, src/components/modules/admin/media/index.ts
Client components: MediaLibraryClient, MediaGrid, MediaFilters, MediaDetailModal, ImagePickerModal, UploadZone, OrphanViewer, CoverImagePicker; utilities for public URLs, variant selection, formatting; barrel exports.
Admin pages & layout
src/app/admin/layout.tsx, src/app/admin/page.tsx, src/app/admin/media/page.tsx, src/app/admin/media/orphans/page.tsx
Admin layout gating to admin users, redirect to /admin/media, server-rendered media pages fetching initial data.
Article rendering
src/components/modules/news/ui/NewsCard.tsx, src/components/modules/news/ui/NewsDetail.tsx
Render optional cover images with Next.js Image and updated layout wrappers.

Claude AI Specification Workflow Framework

Layer / File(s) Summary
Agent prompts: requirements, design, tasks, impl, test, judge
.claude/agents/kfc/spec-requirements.md, spec-design.md, spec-tasks.md, spec-impl.md, spec-test.md, spec-judge.md, spec-system-prompt-loader.md
New agent specification markdown files defining input contracts, prerequisites, step processes, output schemas, and constraints for staged spec creation, revision, evaluation, tasking, implementation, and testing.
Workflow orchestration & settings
.claude/system-prompts/spec-workflow-starter.md, .claude/settings/kfc-settings.json
System prompt mapping phases to sub-agents, sequential approval gating, parallel/candidate evaluation rules, and workspace settings for the spec workflow.
Editor workspace recommendations
.vscode/settings.json, .vscode/extensions.json
Workspace formatting, ESLint/TypeScript settings, Tailwind parsing, and recommended VS Code extensions.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API_Route as /api/media/upload
    participant UploadService as uploadMedia()
    participant Optimize as generateVariants()
    participant SupabaseStorage as Supabase Storage
    participant Database as media_library
    participant Stellar as Stellar (optional)

    Client->>API_Route: POST /api/media/upload (FormData, anchorOnStellar?)
    API_Route->>UploadService: validate + compute sha256 + get dimensions
    UploadService->>Optimize: generateVariants(buffer)
    Optimize-->>UploadService: variants[]
    UploadService->>SupabaseStorage: upload original + variants
    UploadService->>Database: insert media_library row
    alt anchorOnStellar true
      UploadService->>Stellar: anchorHashOnStellar(mediaId, contentHash)
      Stellar-->>UploadService: txHash
      UploadService->>Database: update stellar_tx_hash
    end
    UploadService-->>API_Route: UploadResponse (media, publicUrl)
    API_Route-->>Client: 201 Created { media, publicUrl }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

"🐰
I hopped through files, chest full of specs and jars,
Anchored hashes and thumbnails, and neat little mars,
Agents lined the burrow to draft, judge, and test,
Now media sleeps tidy — in buckets, trimmed, and dressed."

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (22)
src/lib/storage/migrate-images.ts-183-194 (1)

183-194: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing error handling for media_library insert.

The insert operation returns { data, error } but the error is never checked. If the insert fails (e.g., constraint violation, network issue), the function still returns the Storage URL, leaving an orphan file with no tracking record.

🐛 Proposed fix
   // Insert media_library record
-  await supabase.from('media_library').insert({
+  const { error: insertError } = await supabase.from('media_library').insert({
     bucket,
     path,
     original_name: filename,
     mime_type: mimeType,
     size_bytes: buffer.byteLength,
     width: width || null,
     height: height || null,
     variants: variantPaths,
     content_hash: contentHash,
     uploaded_by: uploadedBy,
   });
+
+  if (insertError) {
+    throw new Error(`media_library insert failed: ${insertError.message}`);
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` around lines 183 - 194, The insert into
the media_library via supabase.from('media_library').insert(...) currently
ignores the returned { data, error } — check that result and on error log the
error and clean up the just-uploaded object (call
supabase.storage.from(bucket).remove([path]) or equivalent) then throw or return
a failure so the caller doesn't get a Storage URL for an untracked file; ensure
the function only returns the Storage URL after the insert succeeds and include
the error details in the thrown/logged message for debugging.
.claude/agents/kfc/spec-design.md-127-127 (1)

127-127: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Design output path is contradictory (.claude vs .kiro).

Line 127 requires .claude/specs/{feature_name}/design.md, but Line 133 requires .kiro/specs/{feature_name}/design.md. This creates nondeterministic behavior and breaks orchestration contracts with the starter workflow.

Proposed fix
- - The model MUST create a detailed design document at '.kiro/specs/{feature_name}/design.md'
+ - The model MUST create a detailed design document at '.claude/specs/{feature_name}/design.md'

Also applies to: 133-133

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/agents/kfc/spec-design.md at line 127, The spec-design doc contains
two conflicting output paths ('.claude/specs/{feature_name}/design.md' vs
'.kiro/specs/{feature_name}/design.md'); pick one canonical path and make all
references consistent (e.g., replace the '.kiro/...' occurrence with
'.claude/...' or vice versa) or centralize the path behind a single
constant/variable (e.g., SPEC_PATH or SPEC_DIR) used by the checks that mention
these paths so both Line 127 and Line 133 resolve to the same deterministic
path.
.claude/agents/kfc/spec-judge.md-17-17 (1)

17-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Judge input contract conflicts with workflow caller contract.

This file expects documents (list), but the workflow starter defines doc_path for judge calls. That mismatch will cause invocation failures or dropped candidates.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/agents/kfc/spec-judge.md at line 17, The judge spec expects a
parameter named "documents" but the workflow caller sends "doc_path", causing
invocation mismatches; update the judge contract in
.claude/agents/kfc/spec-judge.md to accept "doc_path" (or add "doc_path" as an
alias) and ensure any referenced handler or entrypoint that reads "documents"
(the judge input contract) is updated to read "doc_path" instead so the workflow
starter and judge use the same parameter name.
.claude/agents/kfc/spec-requirements.md-48-49 (1)

48-49: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Inconsistent requirements filename contract will break parallel workflow handoff.

Line 48–49 defines requirements{output_suffix}.md (e.g., requirements_v1.md), but Line 73 requires requirements_{output_suffix}.md. With suffixes like _v1, Line 73 yields requirements__v1.md, which conflicts with the rest of the workflow and judge patterns.

Proposed fix
- - The model MUST create a '.claude/specs/{feature_name}/requirements_{output_suffix}.md' file if it doesn't already exist
+ - The model MUST create '.claude/specs/{feature_name}/requirements{output_suffix}.md' when output_suffix is provided; otherwise '.claude/specs/{feature_name}/requirements.md'

Also applies to: 73-73

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/agents/kfc/spec-requirements.md around lines 48 - 49, The codebase
has two conflicting filename templates for the requirements file:
"requirements{output_suffix}.md" and "requirements_{output_suffix}.md", causing
double underscores when output_suffix already includes a leading underscore;
pick one convention and make it consistent. Fix by normalizing how output_suffix
is composed and used: either (A) require output_suffix to have no leading
underscore and use the "requirements_{output_suffix}.md" template everywhere, or
(B) allow output_suffix to include a leading underscore and use
"requirements{output_suffix}.md" everywhere; update the occurrences of the
templates (the places referencing output_suffix and the two templates) to the
chosen single form and ensure validators/judge patterns expect that same
pattern. Ensure all references to output_suffix, requirements{output_suffix}.md,
and requirements_{output_suffix}.md are updated to the single agreed convention.
.claude/agents/kfc/spec-test.md-88-90 (1)

88-90: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

{module} is undefined in inputs, so test artifact naming is under-specified.

The process and constraints require {module}.md / {module}.test.ts, but no input provides module and no derivation rule is specified. Different agents may produce inconsistent filenames.

Proposed fix
 ## INPUT
 ...
 - spec_base_path: Spec document base path
+ - module_name: Target module/file stem for test artifacts (e.g., "media-service")

 ...
-  - First create test case documentation ({module}.md)
-  - Create corresponding test code ({module}.test.ts) based on test case documentation
+  - First create test case documentation ({module_name}.md)
+  - Create corresponding test code ({module_name}.test.ts) based on test case documentation

Also applies to: 103-103

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/agents/kfc/spec-test.md around lines 88 - 90, The spec references
placeholder filenames `{module}.md` and `{module}.test.ts` without defining
`module`, causing under-specified artifact naming; update the spec in
.claude/agents/kfc/spec-test.md to require a concrete `module` input (or define
a deterministic derivation rule such as using `artifactName`, `packageName`, or
the primary feature identifier) and add validation steps: require `module` in
inputs, or auto-derive it (e.g., from `task.name` or `targetFile`), and specify
the exact filename template to use; ensure the spec text and any downstream
generator/validator enforce and error if `module` is missing so produced
filenames are consistent across agents.
.claude/system-prompts/spec-workflow-starter.md-196-204 (1)

196-204: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Judge invocation schema mismatches the judge agent interface.

This workflow defines doc_path, but spec-judge requires documents (list of candidate paths). Parallel evaluation will fail or evaluate only one candidate unless the schema is aligned.

Proposed fix
- - doc_path: Document path
+ - documents: List of document paths to evaluate
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/system-prompts/spec-workflow-starter.md around lines 196 - 204, The
spec-judge invocation schema is using doc_path (single path) but the spec-judge
agent expects documents (a list of candidate paths), so update the workflow
schema: replace or augment the doc_path field with documents: string[] and
ensure the judge block (named "Judge - spec-judge" / spec-judge invocation) uses
the documents array; remove any single-path-only handling and adjust any
consumers to iterate over documents for parallel evaluation.
.claude/agents/kfc/spec-judge.md-90-90 (1)

90-90: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Score aggregation logic is incorrect in evaluation pseudocode.

'total': sum(scores) sums the outer accumulator, not the current document’s criteria. This can produce invalid totals and wrong winner selection.

Proposed fix
-            'total': sum(scores),
+            'total': (
+                evaluate_completeness(doc)
+                + evaluate_clarity(doc)
+                + evaluate_feasibility(doc)
+                + evaluate_innovation(doc)
+            ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/agents/kfc/spec-judge.md at line 90, The 'total' field is wrongly
computed with 'total': sum(scores) which sums the outer accumulator instead of
the current document's criteria; change it to sum the per-document criteria
scores (e.g., 'total': sum(criteria_scores) or 'total': sum(scores.values())
depending on whether per-document scores are stored in a list or dict) so that
the 'total' reflects the current document's criteria sum; ensure you reference
the per-document variable used in the surrounding loop (e.g., criteria_scores,
perDocumentScores, or scores when it is the per-doc map) rather than the outer
accumulator.
src/lib/storage/media.service.ts-189-194 (1)

189-194: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Throw on failed usage_count updates inside the rescan loop.

Each per-item .update({ usage_count: count }) in recalculateUsageCounts() awaits the write but doesn’t check/throw on the returned Supabase error, so a failed update can leave stale usage_count while the function still resolves.

Suggested change
   for (const item of mediaItems ?? []) {
     const count = countOccurrences(allText, item.path);
-    await supabase
+    const { error } = await supabase
       .from('media_library')
       .update({ usage_count: count })
       .eq('id', item.id);
+    if (error) throw error;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/media.service.ts` around lines 189 - 194, In
recalculateUsageCounts(), the per-item Supabase update inside the loop (for ...
of mediaItems) awaits the .update({ usage_count: count }).eq('id', item.id) call
but doesn't check the returned response for an error; modify the loop to capture
the update response (e.g., const { error } = await
supabase.from('media_library').update(...).eq(...)) and throw or propagate a new
Error containing the item.id and the Supabase error if error is truthy so failed
updates do not silently leave stale usage_count values.
next.config.ts-5-13 (1)

5-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Scope next/image remotePatterns to your project Supabase host.

next.config.ts currently allows https://**.supabase.co/storage/v1/object/public/**. cover_image_url from the DB is forwarded unmodified into <Image src={article.coverImageUrl} /> (and the same pattern for monthly reviews), so any stored Supabase tenant URL matching that wildcard will be fetched/optimized. Narrow the hostname to your own NEXT_PUBLIC_SUPABASE_URL host.

Suggested change
+const supabaseHostname = process.env.NEXT_PUBLIC_SUPABASE_URL
+  ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).hostname
+  : undefined;
+
 const nextConfig: NextConfig = {
   reactCompiler: true,
   images: {
-    remotePatterns: [
-      {
-        protocol: 'https',
-        hostname: '**.supabase.co',
-        pathname: '/storage/v1/object/public/**',
-      },
-    ],
+    remotePatterns: supabaseHostname
+      ? [
+          {
+            protocol: 'https',
+            hostname: supabaseHostname,
+            pathname: '/storage/v1/object/public/**',
+          },
+        ]
+      : [],
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@next.config.ts` around lines 5 - 13, The images.remotePatterns in
next.config.ts is too permissive (hostname '**.supabase.co')—restrict it to your
project's Supabase host by reading NEXT_PUBLIC_SUPABASE_URL and using that exact
hostname instead of the wildcard; update the images.remotePatterns entry (the
images object in next.config.ts) to use the specific host derived from
NEXT_PUBLIC_SUPABASE_URL so <Image src={article.coverImageUrl} /> only allows
your tenant’s storage URLs.
src/lib/storage/optimize.ts-30-40 (1)

30-40: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bypass SVG variants instead of returning raw SVG as "thumb".

uploadMedia() currently uploads every variant with a .webp suffix and contentType: 'image/webp'. Returning the original SVG buffer here means SVG uploads will produce a *-thumb.webp object that actually contains SVG bytes.

Return no variants for SVGs, or carry per-variant format metadata through the pipeline so the uploader can preserve the real MIME type and extension.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/optimize.ts` around lines 30 - 40, The current SVG branch in
optimize (the mimeType === 'image/svg+xml' block) returns a single "thumb"
variant which later causes uploadMedia to save an SVG buffer with a
.webp/contentType:image/webp; change this behavior by returning no variants for
SVGs (i.e., return an empty array) or alternatively enrich the variant objects
with explicit format/ mime metadata so uploadMedia can preserve the original
MIME type and extension; update the optimize function's SVG branch (and any
variant shape/type definitions it uses) and ensure uploadMedia reads per-variant
format fields if you choose the metadata approach.
src/lib/stellar/anchor.ts-96-115 (1)

96-115: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Actually verify the anchored hash, not just transaction success.

expectedHash is ignored, so any successful transaction hash currently returns verified: true. That makes the verification badge a false positive whenever the tx exists but does not contain this media item's hash.

Fetch the transaction operations and compare the manage_data entry's key/value against the expected media key and hash before returning verified: true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/stellar/anchor.ts` around lines 96 - 115, The current verifyAnchor
function returns verified based only on transaction success; modify verifyAnchor
to fetch the transaction operations (use
server.operations().forTransaction(txHash).call()), iterate the returned
operations and locate any op where op.type === 'manage_data', then compare that
operation's key/value against the expected media entry: decode op.value if it is
base64-encoded and check value === expectedHash (and optionally check
op.name/op.key equals the expected media key if your codebase has a specific key
name); only return { verified: true, ledger } when a matching manage_data entry
is found, otherwise return { verified: false }.
src/lib/storage/upload.ts-130-185 (1)

130-185: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rollback uploaded objects when the metadata insert fails.

The original file and any successful variants are already in public storage before the media_library insert runs. If that insert fails, this throws and leaves orphaned objects behind with no DB record to manage them.

Suggested fix
   // 5. Upload original to Storage
   const originalPath = generatePath(typedMime);
+  const uploadedPaths = [originalPath];
   const { error: uploadError } = await supabase.storage
     .from(bucket)
     .upload(originalPath, fileBuffer, {
       contentType: mimeType,
       upsert: false,
@@
     if (variantError) {
       // Non-fatal: log and continue
       const safeKey = variant.key.replace(/[\r\n]/g, '_');
       console.error(`Failed to upload variant ${safeKey}:`, variantError.message);
     } else {
       variantPaths[variant.key] = variantPath;
+      uploadedPaths.push(variantPath);
     }
   }
@@
   if (insertError || !record) {
+    await supabase.storage.from(bucket).remove(uploadedPaths).catch(() => undefined);
     throw new Error(`Failed to record media metadata: ${insertError?.message}`);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/upload.ts` around lines 130 - 185, If the final insert into
media_library (check insertError or !record) fails, remove the uploaded files to
avoid orphaned objects: call supabase.storage.from(bucket).remove([...]) with
the originalPath plus any values from variantPaths (only include successful
variantPaths entries), log any removal errors, and then rethrow the original
insertion error; update the error-handling branch that references
insertError/record to perform this cleanup using the existing symbols
originalPath, variantPaths, bucket, supabase.storage and then throw the same
failure Error as before.
src/app/api/media/route.ts-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp and validate pagination inputs before querying.

Number(...) accepts NaN, 0, negative values, and arbitrarily large pageSize. Since this endpoint is public, a crafted query can force malformed ranges or very large scans/responses. Reject non-finite values and cap pageSize server-side.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/media/route.ts` around lines 17 - 25, The pagination inputs in
the MediaListFilters construction (fields page and pageSize in
src/app/api/media/route.ts) are currently parsed with Number(...) which can
yield NaN, 0, negatives or huge values; update the parsing to validate and
clamp: parse as integer, ensure isFinite and >= 1 for page (default to 1 if
invalid) and ensure pageSize is an integer within an acceptable server-side cap
(e.g. clamp to 1..100, default 24 if invalid); implement this validation before
building the MediaListFilters object (refer to the page and pageSize properties
and MediaListFilters) and return or sanitize inputs rather than relying on
client-provided values.
src/app/api/media/upload/route.ts-70-79 (1)

70-79: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Move Stellar anchoring off the request path.

anchorHashOnStellar() is awaited inside the upload handler, so opted-in uploads still block on an external network call despite the “non-blocking” comment. Slow or flaky anchoring can turn a successful upload into a timeout. Persist the upload first, then anchor asynchronously with its own retry path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/media/upload/route.ts` around lines 70 - 79, The Stellar
anchoring call is currently awaited inside the upload handler
(anchorHashOnStellar and updateStellarTxHash), blocking the request; change this
so the handler persists the upload and returns immediately, then kick off
anchoring asynchronously (e.g., enqueue a background job or spawn a non-awaited
worker using result.media.id and result.media.contentHash) that performs
anchorHashOnStellar and updateStellarTxHash with its own retry/backoff and error
logging; ensure the upload route does not await
anchorHashOnStellar/updateStellarTxHash and still records the media ID so the
background job can pick it up and store stellarTxHash when successful.
src/app/admin/layout.tsx-8-33 (1)

8-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Protect the /admin subtree in the layout.

There is no session/role check here, so /admin/media can render for anonymous users and /admin/media/orphans is reachable until downstream fetching fails. The admin area needs a redirect or 403 at the layout boundary, using the same admin-role check as the mutating APIs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/layout.tsx` around lines 8 - 33, AdminLayout currently renders
the admin subtree without any auth check; update the AdminLayout component to
perform the same admin-role/session validation used by your mutating APIs and,
if the user is not an admin, either redirect them away or return a 403 response
at the layout boundary. Implement this by making AdminLayout an async server
component that calls your existing session/role check helper (the same function
used by mutating APIs), and when the check fails use Next.js navigation redirect
or throw the appropriate Response/redirect to stop rendering children (so
/admin/media and /admin/media/orphans are protected).
src/app/api/media/orphans/route.ts-14-22 (1)

14-22: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict orphan operations to admins.

Any authenticated user can enumerate orphaned assets and trigger a full usage-count rescan here. That exposes admin-only inventory data and lets non-admin users run an expensive maintenance operation.

Also applies to: 34-42

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/media/orphans/route.ts` around lines 14 - 22, The route currently
allows any authenticated user to list or rescan orphans; after obtaining the
user via createClient() and supabase.auth.getUser(), verify the user is an admin
(e.g., check a role flag in user.app_metadata or via a dedicated
isAdmin/getUserRole helper) and return a 403 NextResponse.json({ error:
'Forbidden' }, { status: 403 }) for non-admins; apply the same admin-check logic
to the other orphan-related block (the second auth check) so only admin users
can enumerate orphaned assets or trigger the rescan.
src/app/api/media/upload/route.ts-27-35 (1)

27-35: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce admin authorization here, not just authentication.

This write endpoint only checks that a user exists. The PR contract says media writes are admin-only, so any authenticated account can currently upload files into all buckets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/media/upload/route.ts` around lines 27 - 35, The endpoint
currently only verifies a user exists; enforce admin-only writes by checking the
authenticated user's admin role before proceeding: after calling createClient()
and awaiting supabase.auth.getUser(), inspect a clear admin flag on the returned
user (e.g., user.role === 'admin' or user.user_metadata?.is_admin depending on
your auth schema) and if not present return NextResponse.json({ error:
'Forbidden' }, { status: 403 }); update any downstream logic that assumes auth
to only run for users who pass this admin check. Ensure you reference the
existing supabase variable and the getUser() result when adding the role check.
src/app/api/media/[id]/route.ts-19-27 (1)

19-27: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

PATCH and DELETE need the same admin gate as upload.

Both handlers stop at getUser(), which means any authenticated account can edit alt text or delete assets. That breaks the admin-only write/delete contract for the media library.

Also applies to: 48-56

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/media/`[id]/route.ts around lines 19 - 27, PATCH and DELETE
handlers currently only call createClient() and supabase.auth.getUser() which
allows any authenticated user to modify or delete media; add the same admin gate
used by the upload path by verifying the user's admin role before proceeding.
After calling supabase.auth.getUser(), check the admin flag (for example
user.app_metadata.role === 'admin' or via your existing isAdmin helper) and if
the check fails return NextResponse.json({ error: 'Unauthorized' }, { status:
401 }); apply this check in both the PATCH and DELETE handlers in route.ts (the
blocks using createClient, supabase.auth.getUser and NextResponse.json).
src/components/modules/admin/media/ImagePickerModal.tsx-73-75 (1)

73-75: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return the preferred variant after uploads too.

The library path uses getVariantUrl(item, preferVariant), but the upload path returns publicUrl directly. In CoverImagePicker, that means selecting an existing cover stores the lg variant while uploading a new cover stores the original asset, which bypasses the pre-generated variants on the upload flow.

Suggested fix
-  function handleUploaded(item: MediaItem, publicUrl: string) {
-    onSelect(item, publicUrl);
+  function handleUploaded(item: MediaItem) {
+    onSelect(item, getVariantUrl(item, preferVariant));
     onClose();
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/ImagePickerModal.tsx` around lines 73 -
75, handleUploaded currently calls onSelect(item, publicUrl) which returns the
raw uploaded URL and bypasses variant selection; change it to resolve the
preferred variant URL using getVariantUrl(item, preferVariant) (or fallback to
publicUrl if variant missing) and pass that result to onSelect so uploads and
existing selections use the same preferred variant behavior; update the
handleUploaded function to compute const url = getVariantUrl(item,
preferVariant) || publicUrl and call onSelect(item, url) before onClose().
src/components/modules/admin/media/MediaLibraryClient.tsx-16-17 (1)

16-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The initial fetch guard never runs.

filters is a MediaListFilters object and initialData is a MediaListResponse, so the comparison on Line 28 is always false. This re-fetches /api/media on first paint, causes a loading flash, and can replace the server-rendered results with default page-1 state. Skip the first effect run explicitly, or hydrate filters from the server query state instead.

Suggested fix
-'use client';
+'use client';
 
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
@@
 export function MediaLibraryClient({ initialData }: MediaLibraryClientProps) {
   const [data, setData] = useState<MediaListResponse>(initialData);
   const [filters, setFilters] = useState<MediaListFilters>({ page: 1 });
+  const didInitRef = useRef(false);
@@
   useEffect(() => {
-    if (filters === initialData) return; // Skip initial render
+    if (!didInitRef.current) {
+      didInitRef.current = true;
+      return;
+    }
     fetchMedia(filters);
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [filters]);

Also applies to: 27-31

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/MediaLibraryClient.tsx` around lines 16 -
17, The effect guard fails because you compare filters (MediaListFilters) to
initialData (MediaListResponse), so the first render always triggers a refetch;
in MediaLibraryClient either initialize filters from the server-provided query
(use initialData.query / initialData.page) by seeding useState<MediaListFilters>
with that value instead of { page: 1 }, or implement an explicit skip-first-run
flag (e.g., skipInitialFetchRef checked inside the effect that calls fetch /
setLoading) to prevent the initial effect execution; update references to
filters, setFilters, and initialData accordingly so the initial client state
matches the server-rendered query and the guard works.
src/components/modules/admin/media/OrphanViewer.tsx-20-29 (1)

20-29: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset selection after replacing the orphan list.

handleScan updates items on Lines 28-29 but keeps selectedIds and confirmDelete intact. After a rescan, the bulk-delete UI can still target IDs that are no longer visible, so the next confirm may delete files from the previous result set. Clear the selection/confirm state when fresh scan results arrive.

Suggested fix
   async function handleScan() {
     setScanning(true);
     setError(null);
     setScanResult(null);
     try {
       const res = await fetch('/api/media/orphans', { method: 'POST' });
       if (!res.ok) throw new Error('Scan failed');
       const data = await res.json();
       setItems(data.items ?? []);
+      setSelectedIds(new Set());
+      setConfirmDelete(false);
       setScanResult(`Scan complete. Found ${data.orphanCount} orphaned file${data.orphanCount !== 1 ? 's' : ''}.`);
     } catch (err) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/OrphanViewer.tsx` around lines 20 - 29,
handleScan currently updates setItems but leaves selectedIds and confirmDelete
from the previous scan, allowing bulk-delete to target stale IDs; after
successfully setting items in handleScan (in the try branch, where
setItems(data.items ?? []) is called) also clear the selection and confirmation
state by calling setSelectedIds([]) and setConfirmDelete(false) (or the
appropriate state setters) so any previous selections or confirm dialogs are
reset when fresh scan results arrive.
src/components/modules/admin/media/MediaDetailModal.tsx-33-39 (1)

33-39: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't treat failed article lookups as “unused”.

On Lines 35-38, every response is parsed as a success. If /api/media/${item.id}/articles returns a 4xx/5xx JSON body, this falls through to data.items ?? [] and the modal shows “Not used in any articles,” which can drive an unsafe delete decision. Check r.ok before reading items and surface a dedicated load error instead.

Suggested fix
+  const [articlesError, setArticlesError] = useState<string | null>(null);
+
   useEffect(() => {
     setLoadingArticles(true);
+    setArticlesError(null);
     fetch(`/api/media/${item.id}/articles`)
-      .then((r) => r.json())
+      .then(async (r) => {
+        if (!r.ok) {
+          throw new Error('Failed to load article references');
+        }
+        return r.json();
+      })
       .then((data) => setArticles(data.items ?? []))
-      .catch(() => setArticles([]))
+      .catch((err) => {
+        setArticles([]);
+        setArticlesError(err instanceof Error ? err.message : 'Failed to load article references');
+      })
       .finally(() => setLoadingArticles(false));
   }, [item.id]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/MediaDetailModal.tsx` around lines 33 -
39, The fetch in the useEffect within MediaDetailModal.tsx currently treats any
HTTP response as success; update the handler that calls
fetch(`/api/media/${item.id}/articles`) so it checks the Response.ok before
parsing JSON (e.g., in the first .then use if (!r.ok) throw new Error(`status
${r.status}`) ), and on error set a dedicated load error state (e.g.,
setArticlesLoadError / setArticlesError) instead of silently setting articles to
[] — keep using setLoadingArticles(false) in finally. Ensure subsequent UI uses
this error state to show a load error message rather than “Not used in any
articles.” Reference: the useEffect, fetch(...), setArticles, setLoadingArticles
and the existing .catch/.finally flow.
🟡 Minor comments (5)
src/lib/storage/migrate-images.ts-189-190 (1)

189-190: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Falsy coercion stores null instead of 0 for SVG dimensions.

width || null evaluates to null when width is 0. Per the upstream getImageDimensions contract, SVGs return { width: 0, height: 0 }. This coercion would store null rather than the intended 0.

🔧 Proposed fix
-    width: width || null,
-    height: height || null,
+    width: width ?? null,
+    height: height ?? null,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` around lines 189 - 190, The code uses
falsy coercion (width || null, height || null) which converts valid zero
dimensions to null; change these to nullish checks so 0 is preserved (e.g. use
the nullish coalescing operator or an explicit undefined check) when assigning
width and height inside the migration logic in migrate-images.ts (look for the
width and height assignments in the function that writes image metadata). Ensure
undefined becomes null but numeric 0 remains 0.
.vscode/settings.json-20-23 (1)

20-23: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix Tailwind classRegex so cn()/clsx() multi-arg calls are parsed correctly

In .vscode/settings.json lines 20-23, the inner capture ([^']*) stops only on ', so cn("bg-red-500", active && "text-white") gets matched as one block instead of separate class strings—breaking Tailwind IntelliSense. Exclude ', ", and ` from the capture.

Proposed fix
   "tailwindCSS.experimental.classRegex": [
-    ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
-    ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
+    ["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^'\"`]*)(?:'|\"|`)"],
+    ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^'\"`]*)(?:'|\"|`)"]
   ],
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.vscode/settings.json around lines 20 - 23, The Tailwind VSCode classRegex
entries for "tailwindCSS.experimental.classRegex" incorrectly use a capture that
only excludes single quotes, causing multi-argument calls like cn("bg-red-500",
active && "text-white") to be treated as one block; update both regexes for
"clsx\\(([^)]*)\\)" and "cn\\(([^)]*)\\)" so the inner string-capture excludes
single, double and backtick quotes (replace the inner capture with a character
class that omits ', " and `) to allow separate quoted class strings to be
matched correctly by Tailwind IntelliSense.
src/components/modules/admin/media/UploadZone.tsx-67-71 (1)

67-71: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear the reset timer before a later upload starts.

This timeout can fire during a second upload and flip the UI back to idle, hiding the real progress/error state. Keep the timer id in a ref, clear it before starting a new upload, and cancel it on unmount.

Suggested fix
 export function UploadZone({ bucket, anchorOnStellar = false, onUploaded, onError }: UploadZoneProps) {
   const [state, setState] = useState<UploadState>({ status: 'idle', progress: 0 });
   const [isDragging, setIsDragging] = useState(false);
   const inputRef = useRef<HTMLInputElement>(null);
+  const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

   const uploadFile = useCallback(
     async (file: File) => {
+      if (resetTimerRef.current) {
+        clearTimeout(resetTimerRef.current);
+        resetTimerRef.current = null;
+      }
+
       // Client-side validation
       const limitBytes = BUCKET_SIZE_LIMITS[bucket];
...
-        setTimeout(() => setState({ status: 'idle', progress: 0 }), 2000);
+        resetTimerRef.current = setTimeout(() => {
+          setState({ status: 'idle', progress: 0 });
+          resetTimerRef.current = null;
+        }, 2000);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/UploadZone.tsx` around lines 67 - 71, The
success-state reset timeout can race with a subsequent upload and revert the UI;
store the timeout id in a ref (e.g., resetTimerRef), clear it (clearTimeout)
before starting any new upload (inside the upload start/handleUpload function),
assign the new timer id when calling setTimeout after success, and cancel/clear
the timer in a useEffect cleanup on unmount so the timeout never fires after the
component is gone; update references around setState({ status: 'success', ... })
and the upload starter (handleUpload/onUpload) to implement this.
src/components/modules/admin/media/MediaFilters.tsx-31-37 (1)

31-37: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add an accessible name to the search field.

The input on Line 31 only has placeholder text, so assistive tech gets an unnamed control here while the other filters are labeled. Add a <label> or aria-label.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/MediaFilters.tsx` around lines 31 - 37,
The search input in the MediaFilters component lacks an accessible name; update
the input (in MediaFilters where filters and update are used) to provide an
accessible label by either adding a visually associated <label> element for the
search field or adding an aria-label/aria-labelledby attribute (e.g., "Search by
filename") so assistive tech can identify the control; ensure the label text
matches the placeholder and that the update({ search: ... }) handler remains
unchanged.
src/components/modules/admin/media/MediaLibraryClient.tsx-135-156 (1)

135-156: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Bulk alt-text updates hide partial failures.

Lines 140-153 swallow both network and HTTP errors, then Lines 155-156 clear the input and selection anyway. That leaves the UI looking successful even when some items were not updated. Collect failures like handleBulkDelete does, and keep failed IDs selected for retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/modules/admin/media/MediaLibraryClient.tsx` around lines 135 -
156, In handleBulkSetAltText, don’t swallow network/HTTP failures silently:
mirror handleBulkDelete by tracking failed IDs and errors; for each id check
res.ok and on failure push id into a failures array (and capture error details
from thrown exceptions or non-ok responses), after the loop call
setBulkActionError with a summary if failures.length>0, call handleAltTextSaved
only for successful ids, call setSelectedIds to keep only failed IDs (so user
can retry) and only clear setBulkAltText when there are no failures; use the
existing symbols handleBulkSetAltText, handleAltTextSaved, handleBulkDelete (as
reference), setBulkActionError, setSelectedIds, and setBulkAltText to implement
this behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@supabase/migrations/0002_media_library.sql`:
- Around line 53-58: The "authenticated can write media" policy on
public.media_library currently grants all mutations to the entire authenticated
role; change its target and predicates to restrict write/delete to admins only
by replacing the role "authenticated" and the unconditional using/with check
(true) with an admin-claim check (e.g., validate a JWT claim or auth.role
indicating admin) so only requests presenting that admin claim can
INSERT/UPDATE/DELETE; apply the same change to the other related policies in the
file (the block referenced around lines 117-160) so all mutation policies for
media_library and any bucket-object access use the admin claim check rather than
allowing every authenticated user.

---

Major comments:
In @.claude/agents/kfc/spec-design.md:
- Line 127: The spec-design doc contains two conflicting output paths
('.claude/specs/{feature_name}/design.md' vs
'.kiro/specs/{feature_name}/design.md'); pick one canonical path and make all
references consistent (e.g., replace the '.kiro/...' occurrence with
'.claude/...' or vice versa) or centralize the path behind a single
constant/variable (e.g., SPEC_PATH or SPEC_DIR) used by the checks that mention
these paths so both Line 127 and Line 133 resolve to the same deterministic
path.

In @.claude/agents/kfc/spec-judge.md:
- Line 17: The judge spec expects a parameter named "documents" but the workflow
caller sends "doc_path", causing invocation mismatches; update the judge
contract in .claude/agents/kfc/spec-judge.md to accept "doc_path" (or add
"doc_path" as an alias) and ensure any referenced handler or entrypoint that
reads "documents" (the judge input contract) is updated to read "doc_path"
instead so the workflow starter and judge use the same parameter name.
- Line 90: The 'total' field is wrongly computed with 'total': sum(scores) which
sums the outer accumulator instead of the current document's criteria; change it
to sum the per-document criteria scores (e.g., 'total': sum(criteria_scores) or
'total': sum(scores.values()) depending on whether per-document scores are
stored in a list or dict) so that the 'total' reflects the current document's
criteria sum; ensure you reference the per-document variable used in the
surrounding loop (e.g., criteria_scores, perDocumentScores, or scores when it is
the per-doc map) rather than the outer accumulator.

In @.claude/agents/kfc/spec-requirements.md:
- Around line 48-49: The codebase has two conflicting filename templates for the
requirements file: "requirements{output_suffix}.md" and
"requirements_{output_suffix}.md", causing double underscores when output_suffix
already includes a leading underscore; pick one convention and make it
consistent. Fix by normalizing how output_suffix is composed and used: either
(A) require output_suffix to have no leading underscore and use the
"requirements_{output_suffix}.md" template everywhere, or (B) allow
output_suffix to include a leading underscore and use
"requirements{output_suffix}.md" everywhere; update the occurrences of the
templates (the places referencing output_suffix and the two templates) to the
chosen single form and ensure validators/judge patterns expect that same
pattern. Ensure all references to output_suffix, requirements{output_suffix}.md,
and requirements_{output_suffix}.md are updated to the single agreed convention.

In @.claude/agents/kfc/spec-test.md:
- Around line 88-90: The spec references placeholder filenames `{module}.md` and
`{module}.test.ts` without defining `module`, causing under-specified artifact
naming; update the spec in .claude/agents/kfc/spec-test.md to require a concrete
`module` input (or define a deterministic derivation rule such as using
`artifactName`, `packageName`, or the primary feature identifier) and add
validation steps: require `module` in inputs, or auto-derive it (e.g., from
`task.name` or `targetFile`), and specify the exact filename template to use;
ensure the spec text and any downstream generator/validator enforce and error if
`module` is missing so produced filenames are consistent across agents.

In @.claude/system-prompts/spec-workflow-starter.md:
- Around line 196-204: The spec-judge invocation schema is using doc_path
(single path) but the spec-judge agent expects documents (a list of candidate
paths), so update the workflow schema: replace or augment the doc_path field
with documents: string[] and ensure the judge block (named "Judge - spec-judge"
/ spec-judge invocation) uses the documents array; remove any single-path-only
handling and adjust any consumers to iterate over documents for parallel
evaluation.

In `@next.config.ts`:
- Around line 5-13: The images.remotePatterns in next.config.ts is too
permissive (hostname '**.supabase.co')—restrict it to your project's Supabase
host by reading NEXT_PUBLIC_SUPABASE_URL and using that exact hostname instead
of the wildcard; update the images.remotePatterns entry (the images object in
next.config.ts) to use the specific host derived from NEXT_PUBLIC_SUPABASE_URL
so <Image src={article.coverImageUrl} /> only allows your tenant’s storage URLs.

In `@src/app/admin/layout.tsx`:
- Around line 8-33: AdminLayout currently renders the admin subtree without any
auth check; update the AdminLayout component to perform the same
admin-role/session validation used by your mutating APIs and, if the user is not
an admin, either redirect them away or return a 403 response at the layout
boundary. Implement this by making AdminLayout an async server component that
calls your existing session/role check helper (the same function used by
mutating APIs), and when the check fails use Next.js navigation redirect or
throw the appropriate Response/redirect to stop rendering children (so
/admin/media and /admin/media/orphans are protected).

In `@src/app/api/media/`[id]/route.ts:
- Around line 19-27: PATCH and DELETE handlers currently only call
createClient() and supabase.auth.getUser() which allows any authenticated user
to modify or delete media; add the same admin gate used by the upload path by
verifying the user's admin role before proceeding. After calling
supabase.auth.getUser(), check the admin flag (for example
user.app_metadata.role === 'admin' or via your existing isAdmin helper) and if
the check fails return NextResponse.json({ error: 'Unauthorized' }, { status:
401 }); apply this check in both the PATCH and DELETE handlers in route.ts (the
blocks using createClient, supabase.auth.getUser and NextResponse.json).

In `@src/app/api/media/orphans/route.ts`:
- Around line 14-22: The route currently allows any authenticated user to list
or rescan orphans; after obtaining the user via createClient() and
supabase.auth.getUser(), verify the user is an admin (e.g., check a role flag in
user.app_metadata or via a dedicated isAdmin/getUserRole helper) and return a
403 NextResponse.json({ error: 'Forbidden' }, { status: 403 }) for non-admins;
apply the same admin-check logic to the other orphan-related block (the second
auth check) so only admin users can enumerate orphaned assets or trigger the
rescan.

In `@src/app/api/media/route.ts`:
- Around line 17-25: The pagination inputs in the MediaListFilters construction
(fields page and pageSize in src/app/api/media/route.ts) are currently parsed
with Number(...) which can yield NaN, 0, negatives or huge values; update the
parsing to validate and clamp: parse as integer, ensure isFinite and >= 1 for
page (default to 1 if invalid) and ensure pageSize is an integer within an
acceptable server-side cap (e.g. clamp to 1..100, default 24 if invalid);
implement this validation before building the MediaListFilters object (refer to
the page and pageSize properties and MediaListFilters) and return or sanitize
inputs rather than relying on client-provided values.

In `@src/app/api/media/upload/route.ts`:
- Around line 70-79: The Stellar anchoring call is currently awaited inside the
upload handler (anchorHashOnStellar and updateStellarTxHash), blocking the
request; change this so the handler persists the upload and returns immediately,
then kick off anchoring asynchronously (e.g., enqueue a background job or spawn
a non-awaited worker using result.media.id and result.media.contentHash) that
performs anchorHashOnStellar and updateStellarTxHash with its own retry/backoff
and error logging; ensure the upload route does not await
anchorHashOnStellar/updateStellarTxHash and still records the media ID so the
background job can pick it up and store stellarTxHash when successful.
- Around line 27-35: The endpoint currently only verifies a user exists; enforce
admin-only writes by checking the authenticated user's admin role before
proceeding: after calling createClient() and awaiting supabase.auth.getUser(),
inspect a clear admin flag on the returned user (e.g., user.role === 'admin' or
user.user_metadata?.is_admin depending on your auth schema) and if not present
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); update any
downstream logic that assumes auth to only run for users who pass this admin
check. Ensure you reference the existing supabase variable and the getUser()
result when adding the role check.

In `@src/components/modules/admin/media/ImagePickerModal.tsx`:
- Around line 73-75: handleUploaded currently calls onSelect(item, publicUrl)
which returns the raw uploaded URL and bypasses variant selection; change it to
resolve the preferred variant URL using getVariantUrl(item, preferVariant) (or
fallback to publicUrl if variant missing) and pass that result to onSelect so
uploads and existing selections use the same preferred variant behavior; update
the handleUploaded function to compute const url = getVariantUrl(item,
preferVariant) || publicUrl and call onSelect(item, url) before onClose().

In `@src/components/modules/admin/media/MediaDetailModal.tsx`:
- Around line 33-39: The fetch in the useEffect within MediaDetailModal.tsx
currently treats any HTTP response as success; update the handler that calls
fetch(`/api/media/${item.id}/articles`) so it checks the Response.ok before
parsing JSON (e.g., in the first .then use if (!r.ok) throw new Error(`status
${r.status}`) ), and on error set a dedicated load error state (e.g.,
setArticlesLoadError / setArticlesError) instead of silently setting articles to
[] — keep using setLoadingArticles(false) in finally. Ensure subsequent UI uses
this error state to show a load error message rather than “Not used in any
articles.” Reference: the useEffect, fetch(...), setArticles, setLoadingArticles
and the existing .catch/.finally flow.

In `@src/components/modules/admin/media/MediaLibraryClient.tsx`:
- Around line 16-17: The effect guard fails because you compare filters
(MediaListFilters) to initialData (MediaListResponse), so the first render
always triggers a refetch; in MediaLibraryClient either initialize filters from
the server-provided query (use initialData.query / initialData.page) by seeding
useState<MediaListFilters> with that value instead of { page: 1 }, or implement
an explicit skip-first-run flag (e.g., skipInitialFetchRef checked inside the
effect that calls fetch / setLoading) to prevent the initial effect execution;
update references to filters, setFilters, and initialData accordingly so the
initial client state matches the server-rendered query and the guard works.

In `@src/components/modules/admin/media/OrphanViewer.tsx`:
- Around line 20-29: handleScan currently updates setItems but leaves
selectedIds and confirmDelete from the previous scan, allowing bulk-delete to
target stale IDs; after successfully setting items in handleScan (in the try
branch, where setItems(data.items ?? []) is called) also clear the selection and
confirmation state by calling setSelectedIds([]) and setConfirmDelete(false) (or
the appropriate state setters) so any previous selections or confirm dialogs are
reset when fresh scan results arrive.

In `@src/lib/stellar/anchor.ts`:
- Around line 96-115: The current verifyAnchor function returns verified based
only on transaction success; modify verifyAnchor to fetch the transaction
operations (use server.operations().forTransaction(txHash).call()), iterate the
returned operations and locate any op where op.type === 'manage_data', then
compare that operation's key/value against the expected media entry: decode
op.value if it is base64-encoded and check value === expectedHash (and
optionally check op.name/op.key equals the expected media key if your codebase
has a specific key name); only return { verified: true, ledger } when a matching
manage_data entry is found, otherwise return { verified: false }.

In `@src/lib/storage/media.service.ts`:
- Around line 189-194: In recalculateUsageCounts(), the per-item Supabase update
inside the loop (for ... of mediaItems) awaits the .update({ usage_count: count
}).eq('id', item.id) call but doesn't check the returned response for an error;
modify the loop to capture the update response (e.g., const { error } = await
supabase.from('media_library').update(...).eq(...)) and throw or propagate a new
Error containing the item.id and the Supabase error if error is truthy so failed
updates do not silently leave stale usage_count values.

In `@src/lib/storage/migrate-images.ts`:
- Around line 183-194: The insert into the media_library via
supabase.from('media_library').insert(...) currently ignores the returned {
data, error } — check that result and on error log the error and clean up the
just-uploaded object (call supabase.storage.from(bucket).remove([path]) or
equivalent) then throw or return a failure so the caller doesn't get a Storage
URL for an untracked file; ensure the function only returns the Storage URL
after the insert succeeds and include the error details in the thrown/logged
message for debugging.

In `@src/lib/storage/optimize.ts`:
- Around line 30-40: The current SVG branch in optimize (the mimeType ===
'image/svg+xml' block) returns a single "thumb" variant which later causes
uploadMedia to save an SVG buffer with a .webp/contentType:image/webp; change
this behavior by returning no variants for SVGs (i.e., return an empty array) or
alternatively enrich the variant objects with explicit format/ mime metadata so
uploadMedia can preserve the original MIME type and extension; update the
optimize function's SVG branch (and any variant shape/type definitions it uses)
and ensure uploadMedia reads per-variant format fields if you choose the
metadata approach.

In `@src/lib/storage/upload.ts`:
- Around line 130-185: If the final insert into media_library (check insertError
or !record) fails, remove the uploaded files to avoid orphaned objects: call
supabase.storage.from(bucket).remove([...]) with the originalPath plus any
values from variantPaths (only include successful variantPaths entries), log any
removal errors, and then rethrow the original insertion error; update the
error-handling branch that references insertError/record to perform this cleanup
using the existing symbols originalPath, variantPaths, bucket, supabase.storage
and then throw the same failure Error as before.

---

Minor comments:
In @.vscode/settings.json:
- Around line 20-23: The Tailwind VSCode classRegex entries for
"tailwindCSS.experimental.classRegex" incorrectly use a capture that only
excludes single quotes, causing multi-argument calls like cn("bg-red-500",
active && "text-white") to be treated as one block; update both regexes for
"clsx\\(([^)]*)\\)" and "cn\\(([^)]*)\\)" so the inner string-capture excludes
single, double and backtick quotes (replace the inner capture with a character
class that omits ', " and `) to allow separate quoted class strings to be
matched correctly by Tailwind IntelliSense.

In `@src/components/modules/admin/media/MediaFilters.tsx`:
- Around line 31-37: The search input in the MediaFilters component lacks an
accessible name; update the input (in MediaFilters where filters and update are
used) to provide an accessible label by either adding a visually associated
<label> element for the search field or adding an aria-label/aria-labelledby
attribute (e.g., "Search by filename") so assistive tech can identify the
control; ensure the label text matches the placeholder and that the update({
search: ... }) handler remains unchanged.

In `@src/components/modules/admin/media/MediaLibraryClient.tsx`:
- Around line 135-156: In handleBulkSetAltText, don’t swallow network/HTTP
failures silently: mirror handleBulkDelete by tracking failed IDs and errors;
for each id check res.ok and on failure push id into a failures array (and
capture error details from thrown exceptions or non-ok responses), after the
loop call setBulkActionError with a summary if failures.length>0, call
handleAltTextSaved only for successful ids, call setSelectedIds to keep only
failed IDs (so user can retry) and only clear setBulkAltText when there are no
failures; use the existing symbols handleBulkSetAltText, handleAltTextSaved,
handleBulkDelete (as reference), setBulkActionError, setSelectedIds, and
setBulkAltText to implement this behavior.

In `@src/components/modules/admin/media/UploadZone.tsx`:
- Around line 67-71: The success-state reset timeout can race with a subsequent
upload and revert the UI; store the timeout id in a ref (e.g., resetTimerRef),
clear it (clearTimeout) before starting any new upload (inside the upload
start/handleUpload function), assign the new timer id when calling setTimeout
after success, and cancel/clear the timer in a useEffect cleanup on unmount so
the timeout never fires after the component is gone; update references around
setState({ status: 'success', ... }) and the upload starter
(handleUpload/onUpload) to implement this.

In `@src/lib/storage/migrate-images.ts`:
- Around line 189-190: The code uses falsy coercion (width || null, height ||
null) which converts valid zero dimensions to null; change these to nullish
checks so 0 is preserved (e.g. use the nullish coalescing operator or an
explicit undefined check) when assigning width and height inside the migration
logic in migrate-images.ts (look for the width and height assignments in the
function that writes image metadata). Ensure undefined becomes null but numeric
0 remains 0.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

Comment thread supabase/migrations/0002_media_library.sql Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/lib/storage/optimize.ts (1)

68-71: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix pipeline order: .rotate() must precede .withMetadata({}) to preserve EXIF orientation.

The current order clears metadata before auto-rotate reads the EXIF orientation tag, so images will not be rotated correctly.

🐛 Proposed fix
   let pipeline = sharp(input)
-    // Strip EXIF metadata for privacy — use withMetadata({}) to clear all
-    .withMetadata({})
-    .rotate(); // Auto-rotate based on EXIF orientation before stripping
+    .rotate() // Auto-rotate based on EXIF orientation first
+    .withMetadata({}); // Then strip EXIF metadata for privacy
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/optimize.ts` around lines 68 - 71, The sharp pipeline
currently calls .withMetadata({}) before .rotate(), which strips EXIF
orientation before rotate can read it; in the pipeline built in optimize.ts (the
variable pipeline created from sharp(input)), move the .rotate() call to come
before .withMetadata({}) so auto-rotation runs while EXIF data is still present
(i.e., chain .rotate() immediately after sharp(input) and then call
.withMetadata({})).
src/lib/stellar/anchor.ts (1)

58-59: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: 8-character mediaId prefix causes anchor collisions.

Truncating the UUID to 8 characters (32 bits) for the manageData key creates significant collision risk. Two media items with the same first 8 hex characters will overwrite each other's anchor data on the Stellar account. With ~65K media items, collision probability exceeds 50% (birthday paradox).

Use more of the UUID or hash both mediaId and contentHash together into the key.

🐛 Proposed fix: use full hash-based key
-  // manageData key: "media:{first-8-chars-of-id}" (max 64 bytes for key)
-  const dataKey = `media:${mediaId.slice(0, 8)}`;
+  // manageData key: use content hash prefix to ensure uniqueness per file
+  // Key format: "m:{first 58 chars of hash}" (64 byte limit: 2 + 1 + 58 + null padding)
+  const dataKey = `m:${contentHash.slice(0, 58)}`;

Alternatively, store the full mediaId in the value alongside the hash, or use a separate data entry per anchor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/stellar/anchor.ts` around lines 58 - 59, The current dataKey creation
in anchor.ts uses an 8-char prefix (const dataKey = `media:${mediaId.slice(0,
8)}`), which causes collisions; change it to derive dataKey from a cryptographic
hash of mediaId (and contentHash if available) instead: compute SHA-256(mediaId
[+ contentHash]), hex-encode it and use that hex (or its first 64 bytes if
needed to meet Stellar's 64-byte key limit) as the suffix (e.g.,
`media:${hashHex}`), and store the full mediaId (and contentHash) inside the
manageData value so you can always recover the original identifiers.
src/lib/storage/migrate-images.ts (2)

108-134: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Major: Missing MIME type and size validation.

The downloadImage function accepts any MIME type from the remote server and doesn't enforce size limits before processing. This bypasses the bucket's intended restrictions:

  1. MIME type: According to PR objectives, only image/jpeg, image/png, image/webp, image/gif, image/svg+xml should be allowed. A malicious server could return text/html, application/javascript, or other non-image content.
  2. Size limit: Buckets have 10 MB limits for article images, but the buffer is not checked before optimization and upload.
🛡️ Proposed fix: Add validation after download
+const ALLOWED_MIME_TYPES = [
+  'image/jpeg',
+  'image/png',
+  'image/webp',
+  'image/gif',
+  'image/svg+xml',
+];
+const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
+
 async function downloadImage(url: string): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
   if (!isSafeUrl(url)) {
     throw new Error(`Blocked URL (private/internal address not allowed): ${url}`);
   }

   const response = await fetch(url, {
     headers: { 'User-Agent': 'ACTA-News-Migration/1.0' },
     signal: AbortSignal.timeout(30_000),
   });

   if (!response.ok) {
     throw new Error(`HTTP ${response.status}: ${response.statusText}`);
   }

   const contentType = response.headers.get('content-type') ?? 'image/jpeg';
   const mimeType = contentType.split(';')[0].trim();
+  
+  if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
+    throw new Error(`Unsupported MIME type: ${mimeType}`);
+  }

   const arrayBuffer = await response.arrayBuffer();
   const buffer = Buffer.from(arrayBuffer);
+  
+  if (buffer.byteLength > MAX_SIZE_BYTES) {
+    throw new Error(`File too large: ${buffer.byteLength} bytes (max ${MAX_SIZE_BYTES})`);
+  }

   // Extract and sanitize filename from URL — strip directory components and
   // allow only safe characters to prevent path traversal / command injection.
   const rawName = basename(new URL(url).pathname);
   const filename = rawName.replace(/[^a-zA-Z0-9._-]/g, '_') || `image-${randomUUID()}.jpg`;

   return { buffer, mimeType, filename };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` around lines 108 - 134, The downloadImage
function must enforce allowed MIME types and a size limit: after deriving
mimeType (variable mimeType) validate it against the allowed set [image/jpeg,
image/png, image/webp, image/gif, image/svg+xml] and throw a clear Error if not
allowed; then check Buffer.byteLength (the buffer created from arrayBuffer) and
reject if it exceeds 10 * 1024 * 1024 bytes (10 MB) with a descriptive Error
before proceeding to filename sanitization and returning; ensure errors are
thrown from downloadImage so callers don’t continue processing invalid content.

73-90: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Critical: SSRF protection is incomplete—missing IPv6 and DNS rebinding defenses.

The current isSafeUrl implementation only blocks IPv4 private ranges and will not prevent SSRF attacks using:

  1. IPv6 loopback and link-local addresses: http://[::1]/, http://[fe80::1]/, http://[::ffff:127.0.0.1]/
  2. DNS rebinding: A malicious domain that resolves to 127.0.0.1 or other private IPs will pass the hostname check
  3. Additional cloud metadata endpoints: Alibaba Cloud (100.100.x.x), Azure (169.254.169.254 via IPv6), etc.

This allows an attacker to migrate a malicious article with cover/content URLs pointing to internal services, which this script will fetch and potentially expose.

🔒 Recommended fix: Add IPv6 checks and use an allowlist
 function isSafeUrl(url: string): boolean {
   try {
     const { hostname, protocol } = new URL(url);
     if (!['http:', 'https:'].includes(protocol)) return false;
+    
+    // Block IPv6 loopback and link-local
+    if (
+      hostname === '::1' ||
+      hostname.startsWith('fe80:') ||
+      hostname.startsWith('[::1]') ||
+      hostname.startsWith('[fe80:')
+    ) return false;
+    
+    // Block IPv4-mapped IPv6 addresses (::ffff:127.0.0.1)
+    if (/^\[?::ffff:(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/i.test(hostname)) {
+      return false;
+    }
+    
     if (
       hostname === 'localhost' ||
       /^127\./.test(hostname) ||
       /^10\./.test(hostname) ||
       /^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
       /^192\.168\./.test(hostname) ||
       /^169\.254\./.test(hostname) ||
+      /^100\.100\./.test(hostname) ||  // Alibaba Cloud metadata
       hostname === '0.0.0.0'
     ) return false;
+    
     return true;
   } catch {
     return false;
   }
 }

Note: This still doesn't prevent DNS rebinding. For production use, consider implementing a domain allowlist or using a dedicated SSRF protection library.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` around lines 73 - 90, isSafeUrl currently
only checks URL.protocol and hostname text, missing IPv6, mapped IPv4-in-IPv6,
cloud metadata ranges and DNS rebinding; update isSafeUrl (and any call sites
that fetch remote URLs) to: 1) parse the URL in isSafeUrl, reject non-http(s);
2) detect IPv6 literal hosts and reject loopback (::1), link-local (fe80::/10),
ULA (fc00::/7), IPv4-mapped addresses (::ffff:0:0/96 or ::ffff:127.0.0.1) and
other reserved IPv6 ranges; 3) perform a DNS resolution (e.g.
dns.lookup/dns.promises.lookup or equivalent) for hostnames and validate each
resolved IP against a denylist of private/reserved ranges including 10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, 100.100.0.0/16
(Alibaba), and cloud metadata addresses, rejecting if any resolved address falls
into those ranges; and 4) optionally add an explicit domain allowlist that
short-circuits checks for trusted hosts. Update isSafeUrl to return false on any
resolution failure or if any resolved address is private/mapped/reserved.
🧹 Nitpick comments (6)
src/lib/storage/optimize.ts (1)

90-91: 💤 Low value

Minor optimization: use toBuffer({ resolveWithObject: true }) to avoid re-parsing the buffer.

The current code calls sharp(buffer).metadata() after toBuffer(), which re-decodes the image. Sharp can return metadata alongside the buffer in one operation.

♻️ Proposed refactor
-  const buffer = await pipeline.toBuffer();
-  const metadata = await sharp(buffer).metadata();
+  const { data: buffer, info } = await pipeline.toBuffer({ resolveWithObject: true });

   return {
     key: spec.key,
     buffer,
-    width: metadata.width ?? spec.width,
-    height: metadata.height ?? 0,
+    width: info.width ?? spec.width,
+    height: info.height ?? 0,
     sizeBytes: buffer.byteLength,
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/optimize.ts` around lines 90 - 91, Replace the two-step
decode (await pipeline.toBuffer(); then await sharp(buffer).metadata()) with a
single call that returns both buffer and metadata by using pipeline.toBuffer({
resolveWithObject: true }); update references to use the returned object's .data
(or similar) as the image buffer and .info (or .metadata) for metadata so you
avoid re-parsing; locate the usage around pipeline.toBuffer and the subsequent
sharp(buffer).metadata() calls in optimize.ts and adapt downstream variables
accordingly.
supabase/migrations/0002_media_library.sql (1)

26-26: 💤 Low value

Consider adding a foreign key to auth.users for uploaded_by.

The uploaded_by column is defined as TEXT without a foreign key constraint. While this offers flexibility (e.g., storing user identifiers from external systems), it also means orphaned references can occur if users are deleted. If uploaded_by always stores Supabase auth.users.id, consider:

uploaded_by uuid references auth.users(id) on delete set null

This is optional and depends on whether you need to track uploaders from non-Supabase sources.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/0002_media_library.sql` at line 26, The uploaded_by
column is currently TEXT and lacks a foreign key to auth.users; change its type
to uuid (or nullable uuid) and add a foreign key constraint referencing
auth.users(id) with an appropriate ON DELETE behavior (e.g., ON DELETE SET NULL)
so deleted users don’t leave invalid references; update the column definition
for uploaded_by and add a REFERENCES auth.users(id) constraint (or keep TEXT if
you intentionally need external identifiers).
src/lib/stellar/anchor.ts (1)

110-118: ⚡ Quick win

Improve type safety for Horizon operation response.

The type assertion as { type: string; value: string } is fragile. The manage_data operation response from Horizon has a specific shape. Consider using the SDK's types or a more defensive approach.

♻️ Proposed refactor
     for (const op of ops.records) {
       if (op.type !== 'manage_data') continue;
-      const manageDataOp = op as { type: string; value: string };
-      // value is base64-encoded
-      const decoded = Buffer.from(manageDataOp.value, 'base64').toString('utf8');
+      // Horizon returns manage_data ops with a `value` field (base64-encoded)
+      const value = (op as { value?: string }).value;
+      if (!value) continue;
+      const decoded = Buffer.from(value, 'base64').toString('utf8');
       if (decoded === expectedHash.slice(0, 64)) {
         return { verified: true, ledger: (tx.ledger as unknown) as number };
       }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/stellar/anchor.ts` around lines 110 - 118, The loop over ops.records
uses a brittle type assertion for manage_data ops; replace the cast on
manageDataOp with a proper runtime/type-safe check or the SDK's typed response:
import the Horizon/Operation response type for manage_data (e.g., the SDK's
manage data operation record type) and use it for the variable, or keep a
runtime guard that ensures 'value' exists and is a string before decoding;
update the block that references ops.records, manageDataOp, expectedHash and
tx.ledger to validate op.value and only decode when safe so you avoid unsafe
assertions and improve type safety.
src/lib/storage/migrate-images.ts (3)

273-273: 💤 Low value

Minor: Consider redacting URLs in console output.

Line 273 logs the first 60 characters of external image URLs, which could contain sensitive query parameters, tokens, or PII (e.g., https://cdn.example.com/img?key=SECRET&user=email@example.com). While the final report at line 319 redacts URLs, the real-time console output at line 273 does not.

♻️ Proposed fix
-        console.log(`  Migrating content image: ${url.slice(0, 60)}…`);
+        const urlHost = new URL(url).hostname;
+        console.log(`  Migrating content image from ${urlHost}…`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` at line 273, The console.log at the start
of the image migration ("console.log(`  Migrating content image: ${url.slice(0,
60)}…`)") prints raw URL fragments that may include sensitive query params;
change this to print a redacted URL instead by reusing the same redaction logic
used for the final report at line 319 (i.e., replace the url.slice call with the
redacted form produced for the final report), ensuring you reference the
existing redaction helper or variable that generates the redacted URL for
consistency.

52-60: ⚡ Quick win

Remove unused 'skipped' status from type definition.

The MigrationResult type defines a 'skipped' status, but this value is never assigned in the actual migration logic (lines 230-296). All results are initialized to 'failed' and only transition to 'success'. The report at lines 303-313 will always show 0 skipped items.

♻️ Proposed fix
 interface MigrationResult {
   articleId: string;
   articleSlug: string;
   field: 'cover_image_url' | 'content';
   originalUrl: string;
   newUrl?: string;
-  status: 'success' | 'skipped' | 'failed';
+  status: 'success' | 'failed';
   error?: string;
 }

And remove the skipped report line:

   console.log(`✓ Succeeded     : ${succeeded.length}`);
   console.log(`✗ Failed        : ${failed.length}`);
-  console.log(`→ Skipped       : ${skipped.length}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` around lines 52 - 60, The MigrationResult
type currently includes a 'skipped' status that is never assigned; update the
MigrationResult interface to remove 'skipped' from the status union (leaving
only 'success' | 'failed'), then remove the corresponding "skipped" row from the
migration report generation so it no longer prints a skipped count; ensure any
logic that might reference MigrationResult.status or build the report
(functions/blocks around MigrationResult usage and the report summarization) is
adjusted to expect only 'success' or 'failed'.

92-106: ⚡ Quick win

Extend regex patterns to capture single-quoted and unquoted image URLs.

The current regex only matches src="..." with double quotes. Valid HTML also allows single quotes (src='...') and unquoted values (src=https://...), which will be skipped by this migration.

♻️ Proposed fix
 function extractImageUrls(content: string): string[] {
   const urls: string[] = [];
-  // Match src="..." in img tags
-  const imgSrcRegex = /src="(https?:\/\/[^"]+)"/g;
+  // Match src with double quotes, single quotes, or no quotes
+  const imgSrcRegex = /src=["']?(https?:\/\/[^\s"'>]+)["']?/gi;
   let match;
   while ((match = imgSrcRegex.exec(content)) !== null) {
     urls.push(match[1]);
   }
   // Match markdown images ![alt](url)
   const mdImgRegex = /!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g;
   while ((match = mdImgRegex.exec(content)) !== null) {
     urls.push(match[1]);
   }
   return [...new Set(urls)];
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/storage/migrate-images.ts` around lines 92 - 106, The
extractImageUrls function only matches src="..." double-quoted URLs; update the
imgSrcRegex and its usage to also capture single-quoted and unquoted values
(e.g., src='https://...' and src=https://...), for example by using an
alternation that creates three capture groups (double-quoted, single-quoted,
unquoted) and then pushing the first non-empty capture (instead of match[1])
into urls; keep the existing mdImgRegex logic as-is and preserve the Set
de-duplication and return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/admin/layout.tsx`:
- Around line 16-17: The current isAdmin check uses user.user_metadata which is
user-controlled and allows privilege escalation; update the logic in the admin
layout so isAdmin only checks trusted data (e.g., user.app_metadata?.role ===
'admin') and remove any use of user.user_metadata?.is_admin; ensure the redirect
still happens when the trusted isAdmin is false and consider adding a
server-side/DB-backed authorization check (RLS or endpoint) for further
enforcement in any server-rendered or API paths that serve admin data.

In `@src/app/api/media/`[id]/route.ts:
- Around line 17-22: The isAdmin function currently trusts user.user_metadata to
grant admin rights; remove that vector by only checking server-controlled
app_metadata. Update the isAdmin(user) implementation to return true only when
user.app_metadata?.role === 'admin' (remove the user.user_metadata?.is_admin
check), and ensure any callers (e.g., PATCH/DELETE authorization guards) rely on
this updated isAdmin to prevent user-editable metadata from granting privileges.

---

Outside diff comments:
In `@src/lib/stellar/anchor.ts`:
- Around line 58-59: The current dataKey creation in anchor.ts uses an 8-char
prefix (const dataKey = `media:${mediaId.slice(0, 8)}`), which causes
collisions; change it to derive dataKey from a cryptographic hash of mediaId
(and contentHash if available) instead: compute SHA-256(mediaId [+
contentHash]), hex-encode it and use that hex (or its first 64 bytes if needed
to meet Stellar's 64-byte key limit) as the suffix (e.g., `media:${hashHex}`),
and store the full mediaId (and contentHash) inside the manageData value so you
can always recover the original identifiers.

In `@src/lib/storage/migrate-images.ts`:
- Around line 108-134: The downloadImage function must enforce allowed MIME
types and a size limit: after deriving mimeType (variable mimeType) validate it
against the allowed set [image/jpeg, image/png, image/webp, image/gif,
image/svg+xml] and throw a clear Error if not allowed; then check
Buffer.byteLength (the buffer created from arrayBuffer) and reject if it exceeds
10 * 1024 * 1024 bytes (10 MB) with a descriptive Error before proceeding to
filename sanitization and returning; ensure errors are thrown from downloadImage
so callers don’t continue processing invalid content.
- Around line 73-90: isSafeUrl currently only checks URL.protocol and hostname
text, missing IPv6, mapped IPv4-in-IPv6, cloud metadata ranges and DNS
rebinding; update isSafeUrl (and any call sites that fetch remote URLs) to: 1)
parse the URL in isSafeUrl, reject non-http(s); 2) detect IPv6 literal hosts and
reject loopback (::1), link-local (fe80::/10), ULA (fc00::/7), IPv4-mapped
addresses (::ffff:0:0/96 or ::ffff:127.0.0.1) and other reserved IPv6 ranges; 3)
perform a DNS resolution (e.g. dns.lookup/dns.promises.lookup or equivalent) for
hostnames and validate each resolved IP against a denylist of private/reserved
ranges including 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8,
169.254.0.0/16, 100.100.0.0/16 (Alibaba), and cloud metadata addresses,
rejecting if any resolved address falls into those ranges; and 4) optionally add
an explicit domain allowlist that short-circuits checks for trusted hosts.
Update isSafeUrl to return false on any resolution failure or if any resolved
address is private/mapped/reserved.

In `@src/lib/storage/optimize.ts`:
- Around line 68-71: The sharp pipeline currently calls .withMetadata({}) before
.rotate(), which strips EXIF orientation before rotate can read it; in the
pipeline built in optimize.ts (the variable pipeline created from sharp(input)),
move the .rotate() call to come before .withMetadata({}) so auto-rotation runs
while EXIF data is still present (i.e., chain .rotate() immediately after
sharp(input) and then call .withMetadata({})).

---

Nitpick comments:
In `@src/lib/stellar/anchor.ts`:
- Around line 110-118: The loop over ops.records uses a brittle type assertion
for manage_data ops; replace the cast on manageDataOp with a proper
runtime/type-safe check or the SDK's typed response: import the
Horizon/Operation response type for manage_data (e.g., the SDK's manage data
operation record type) and use it for the variable, or keep a runtime guard that
ensures 'value' exists and is a string before decoding; update the block that
references ops.records, manageDataOp, expectedHash and tx.ledger to validate
op.value and only decode when safe so you avoid unsafe assertions and improve
type safety.

In `@src/lib/storage/migrate-images.ts`:
- Line 273: The console.log at the start of the image migration ("console.log(` 
Migrating content image: ${url.slice(0, 60)}…`)") prints raw URL fragments that
may include sensitive query params; change this to print a redacted URL instead
by reusing the same redaction logic used for the final report at line 319 (i.e.,
replace the url.slice call with the redacted form produced for the final
report), ensuring you reference the existing redaction helper or variable that
generates the redacted URL for consistency.
- Around line 52-60: The MigrationResult type currently includes a 'skipped'
status that is never assigned; update the MigrationResult interface to remove
'skipped' from the status union (leaving only 'success' | 'failed'), then remove
the corresponding "skipped" row from the migration report generation so it no
longer prints a skipped count; ensure any logic that might reference
MigrationResult.status or build the report (functions/blocks around
MigrationResult usage and the report summarization) is adjusted to expect only
'success' or 'failed'.
- Around line 92-106: The extractImageUrls function only matches src="..."
double-quoted URLs; update the imgSrcRegex and its usage to also capture
single-quoted and unquoted values (e.g., src='https://...' and src=https://...),
for example by using an alternation that creates three capture groups
(double-quoted, single-quoted, unquoted) and then pushing the first non-empty
capture (instead of match[1]) into urls; keep the existing mdImgRegex logic
as-is and preserve the Set de-duplication and return behavior.

In `@src/lib/storage/optimize.ts`:
- Around line 90-91: Replace the two-step decode (await pipeline.toBuffer();
then await sharp(buffer).metadata()) with a single call that returns both buffer
and metadata by using pipeline.toBuffer({ resolveWithObject: true }); update
references to use the returned object's .data (or similar) as the image buffer
and .info (or .metadata) for metadata so you avoid re-parsing; locate the usage
around pipeline.toBuffer and the subsequent sharp(buffer).metadata() calls in
optimize.ts and adapt downstream variables accordingly.

In `@supabase/migrations/0002_media_library.sql`:
- Line 26: The uploaded_by column is currently TEXT and lacks a foreign key to
auth.users; change its type to uuid (or nullable uuid) and add a foreign key
constraint referencing auth.users(id) with an appropriate ON DELETE behavior
(e.g., ON DELETE SET NULL) so deleted users don’t leave invalid references;
update the column definition for uploaded_by and add a REFERENCES auth.users(id)
constraint (or keep TEXT if you intentionally need external identifiers).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 86835f32-6060-4f9c-8ae8-173892697f73

📥 Commits

Reviewing files that changed from the base of the PR and between 030519a and 5871d37.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (21)
  • .env.example
  • .vscode/settings.json
  • package.json
  • src/app/admin/layout.tsx
  • src/app/api/media/[id]/route.ts
  • src/app/api/media/orphans/route.ts
  • src/app/api/media/route.ts
  • src/app/api/media/upload/route.ts
  • src/components/modules/admin/media/ImagePickerModal.tsx
  • src/components/modules/admin/media/MediaDetailModal.tsx
  • src/components/modules/admin/media/MediaFilters.tsx
  • src/components/modules/admin/media/MediaLibraryClient.tsx
  • src/components/modules/admin/media/OrphanViewer.tsx
  • src/components/modules/admin/media/UploadZone.tsx
  • src/lib/stellar/anchor.ts
  • src/lib/storage/media.service.ts
  • src/lib/storage/migrate-images.ts
  • src/lib/storage/optimize.ts
  • src/lib/storage/upload.ts
  • src/lib/supabase/database.types.ts
  • supabase/migrations/0002_media_library.sql
✅ Files skipped from review due to trivial changes (1)
  • .env.example
🚧 Files skipped from review as they are similar to previous changes (11)
  • .vscode/settings.json
  • package.json
  • src/app/api/media/upload/route.ts
  • src/components/modules/admin/media/OrphanViewer.tsx
  • src/app/api/media/orphans/route.ts
  • src/lib/supabase/database.types.ts
  • src/lib/storage/media.service.ts
  • src/components/modules/admin/media/MediaLibraryClient.tsx
  • src/lib/storage/upload.ts
  • src/components/modules/admin/media/MediaDetailModal.tsx
  • src/components/modules/admin/media/UploadZone.tsx

Comment thread src/app/admin/layout.tsx
Comment on lines +16 to +17
const isAdmin = user.app_metadata?.role === 'admin' || user.user_metadata?.is_admin === true;
if (!isAdmin) redirect('/');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Do not trust user_metadata for admin authorization (privilege escalation).

src/app/admin/layout.tsx (Lines 16-17) authorizes admin access using user.user_metadata?.is_admin === true. In Supabase, user_metadata is user-controlled and not meant for security-sensitive authorization, so a user can set is_admin: true for themselves and bypass this UI gate. Restrict this check to app_metadata (or a DB-backed role enforced with RLS/server-side authorization).

Suggested fix
-  const isAdmin = user.app_metadata?.role === 'admin' || user.user_metadata?.is_admin === true;
+  const isAdmin = user.app_metadata?.role === 'admin';
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isAdmin = user.app_metadata?.role === 'admin' || user.user_metadata?.is_admin === true;
if (!isAdmin) redirect('/');
const isAdmin = user.app_metadata?.role === 'admin';
if (!isAdmin) redirect('/');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/layout.tsx` around lines 16 - 17, The current isAdmin check
uses user.user_metadata which is user-controlled and allows privilege
escalation; update the logic in the admin layout so isAdmin only checks trusted
data (e.g., user.app_metadata?.role === 'admin') and remove any use of
user.user_metadata?.is_admin; ensure the redirect still happens when the trusted
isAdmin is false and consider adding a server-side/DB-backed authorization check
(RLS or endpoint) for further enforcement in any server-rendered or API paths
that serve admin data.

Comment on lines +17 to +22
function isAdmin(user: { app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> }): boolean {
return (
user.app_metadata?.role === 'admin' ||
user.user_metadata?.is_admin === true
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant section of the route handler
sed -n '1,120p' src/app/api/media/[id]/route.ts | cat -n

# Extract only the isAdmin helper and all usages of it in this file
rg -n "function isAdmin|isAdmin\(" src/app/api/media/[id]/route.ts

# Show the rest of the file where other handlers may exist
sed -n '120,240p' src/app/api/media/[id]/route.ts | cat -n

Repository: ACTA-Team/news-acta

Length of output: 1960


🌐 Web query:

Supabase auth user_metadata user-editable unsafe for authorization app_metadata roles guidance

💡 Result:

In Supabase Auth, it is unsafe to use user_metadata for authorization logic or Row Level Security (RLS) policies because this field is user-editable [1][2]. Because users can modify their own user_metadata via the standard client API, relying on it for sensitive decisions—such as assigning roles, permissions, or access levels—poses a significant security risk [1][3][2]. For security-sensitive metadata, you must use app_metadata [2]. Key distinctions and guidance: 1. user_metadata: - Purpose: Storing non-sensitive user profile information (e.g., display name, avatar URL, preferences) [4]. - Security: User-editable via the client-side SDK (e.g., supabase.auth.updateUser) [4][2]. - Risk: Never use this for RLS or authorization logic, as users can escalate their own privileges [1][2]. 2. app_metadata: - Purpose: Storing system-level information, including roles, permissions, or organization IDs [4][5]. - Security: Admin-only access. It cannot be modified by the user directly; it must be updated via the Supabase Admin API using the service_role key (server-side) [4][3][5]. - Usage: This is the appropriate place to store authorization claims. When you update app_metadata, these claims are included in the user's JWT [5][2]. Implementation Guidance: - Use the Supabase Admin API (e.g., supabaseAdmin.auth.admin.updateUserById) from a secure server-side environment (like Edge Functions or a backend server) to assign roles to app_metadata [3][5]. - In your RLS policies, access these roles via the auth.jwt function, which reads from the JWT claims [5][2]. For example: using (auth.jwt -> 'app_metadata' ->> 'role' = 'admin'); - Be aware that JWTs are cached; updates to app_metadata will not reflect in RLS policies until the user's token is refreshed or expires [5][2].

Citations:


Fix privilege escalation: remove user_metadata trust from isAdmin()

isAdmin() currently authorizes via user.user_metadata?.is_admin === true. Supabase documents user_metadata as user-editable and unsafe for authorization/RLS decisions, so any user who can update their own user_metadata could gain “admin” and pass PATCH/DELETE guards. Use only app_metadata (or server-owned roles) for this check. https://supabase.com/docs/guides/auth/users

Suggested fix
 function isAdmin(user: { app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> }): boolean {
-  return (
-    user.app_metadata?.role === 'admin' ||
-    user.user_metadata?.is_admin === true
-  );
+  return user.app_metadata?.role === 'admin';
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isAdmin(user: { app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> }): boolean {
return (
user.app_metadata?.role === 'admin' ||
user.user_metadata?.is_admin === true
);
}
function isAdmin(user: { app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown> }): boolean {
return user.app_metadata?.role === 'admin';
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/media/`[id]/route.ts around lines 17 - 22, The isAdmin function
currently trusts user.user_metadata to grant admin rights; remove that vector by
only checking server-controlled app_metadata. Update the isAdmin(user)
implementation to return true only when user.app_metadata?.role === 'admin'
(remove the user.user_metadata?.is_admin check), and ensure any callers (e.g.,
PATCH/DELETE authorization guards) rely on this updated isAdmin to prevent
user-editable metadata from granting privileges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complete Media Management System with Verifiable Storage

1 participant