Skip to content

feat(storage): consolidate GCS uploads into @acme/storage package#469

Open
mcmxcdev wants to merge 10 commits into
devfrom
feat/storage-consolidation-462
Open

feat(storage): consolidate GCS uploads into @acme/storage package#469
mcmxcdev wants to merge 10 commits into
devfrom
feat/storage-consolidation-462

Conversation

@mcmxcdev

@mcmxcdev mcmxcdev commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Replaces per-app GoogleAuth + raw fetch GCS calls with a shared createPublicImageStorage factory in packages/storage/src/public-images.ts
  • Collapses GOOGLE_LOGO_BUCKET_CLIENT_EMAIL, GOOGLE_LOGO_BUCKET_PRIVATE_KEY, GOOGLE_LOGO_BUCKET_BUCKET_NAME, GOOGLE_LOGO_BUCKET_PROJECT_ID into a single base64-encoded GCS_CREDENTIALS env var (consistent with apps/me)
  • Adds F3_CHANNEL to apps/map and apps/admin to derive the correct bucket (f3-public-images vs f3-public-images-staging) at runtime
  • Removes client-side scaleAndCropImage pre-processing from the upload flow; resizing now happens server-side inside prepareImageForStorage

Bug fixes (found during review)

  • isAllowedPublicImageUrl rejected emulator URLs — local-dev avatar saves always 400'd because emulator returns http:// URLs but validation only checked https://storage.googleapis.com/…. Now accepts http://${GCS_EMULATOR_HOST}/… when the emulator is active.
  • orgId validation accepted "0"isNaN(Number("0")) is false, so org 0 was silently accepted in both upload-logo routes. Replaced with Number.isInteger(n) && n > 0.

Test plan

  • Local dev: upload org logo via map app → verify emulator receives file and returned URL is accepted by subsequent profile saves
  • Local dev: upload avatar via me app → same emulator flow
  • Staging deploy: GCS_CREDENTIALS secret populated, F3_CHANNEL=staging → uploads land in f3-public-images-staging
  • Send orgId=0 to /api/upload-logo → expect 400 Invalid orgId

Closes #462

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Unified cloud storage credential system simplifies configuration across applications.
    • Channel-based environment configuration for managing staging and production deployments.
  • Improvements

    • Enhanced input validation for image uploads with clearer error messages.
    • Streamlined environment variable setup with reduced configuration complexity.
  • Tests

    • Added comprehensive test coverage for storage operations and image upload functionality.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces createPublicImageStorage factory in packages/storage that centralizes bucket selection, object paths, image resizing, and URL allow-listing for org logos and user avatars. All three apps (admin, map, me) are migrated to this new abstraction via per-app lib/storage.ts singletons. Legacy GOOGLE_LOGO_BUCKET_* and GCS_BUCKET env vars are replaced with unified GCS_CREDENTIALS and F3_CHANNEL across all app env schemas, deployment scripts, and documentation.

Changes

GCS Public Image Storage Refactor

Layer / File(s) Summary
PublicImageStorage interface and createPublicImageStorage factory
packages/storage/src/public-images.ts
Introduces prod/staging bucket constants, PublicImageStorage interface, createPublicImageStorage factory with lazy credential validation, and uploadToBucket/deleteFromBucket helpers with emulator awareness and isAllowedPublicImageUrl URL validation logic.
Storage package exports and env schema
packages/storage/src/index.ts, packages/storage/src/env.ts, packages/env/src/index.ts
Re-exports createPublicImageStorage and PublicImageStorage from the package index, makes GCS_BUCKET optional in storage env, and removes old GOOGLE_LOGO_BUCKET_* variables from the shared env package.
Public-images comprehensive test suite
packages/storage/src/public-images.test.ts, packages/storage/src/storage.test.ts
300-line Vitest suite covering URL allow-listing, emulator-mode upload/delete for both channels, production credential validation, correct object path URL-encoding, and module reset between tests.
Per-app env schemas and storage client bootstrap
apps/admin/src/env.ts, apps/admin/src/lib/storage.ts, apps/map/src/env.ts, apps/map/src/lib/storage.ts, apps/map/src/lib/logging.ts, apps/me/src/env.ts, apps/me/src/lib/storage.ts, apps/map/package.json
Adds GCS_CREDENTIALS, GCS_EMULATOR_HOST, and F3_CHANNEL to all app env schemas; creates lib/storage.ts in each app with deriveStorageChannel and a singleton storage client; adds logging.ts to map; adds @acme/storage workspace dependency to map.
API route rewrites and avatar delegation
apps/admin/src/app/api/upload-logo/route.ts, apps/map/src/app/api/upload-logo/route.ts, apps/me/src/lib/gcs.ts, apps/me/src/app/api/profile/route.ts, apps/me/src/app/api/profile/avatar/route.ts
Rewrites admin and map upload-logo handlers to validate MIME type, 10 MB cap, positive-integer orgId, optional size, and delegate to storage.uploadOrgLogo; switches me avatar to storage.uploadUserAvatar; replaces inline URL allow-list with storage.isAllowedPublicImageUrl; narrows avatar credential error message.
Client-side form simplification
apps/map/src/app/_components/forms/location-event-form.tsx, apps/map/src/utils/image/upload-logo.ts
Removes scaleAndCropImage and requestId from LocationEventForm; simplifies AO logo onChange to a single uploadLogo({ file, orgId }) call; refactors uploadLogo utility to drop requestId/size, improve non-2xx error parsing, and remove debug logs.
Deployment scripts, env templates, and configuration
.github/workflows/ci.yml, apps/*/scripts/cloud-run-env.sh, apps/*/.env*.example, scripts/local-setup.sh, docs/LOCAL_DEV_DOCKER.md, apps/me/README.md, apps/me/docs/SECURITY.md, apps/me/vitest.config.ts, pnpm-workspace.yaml
Adds GCS_CREDENTIALS to CI env and Cloud Run secret maps; removes GOOGLE_LOGO_BUCKET_* and GCS_BUCKET from all env templates and scripts; adds F3_CHANNEL to me and map deployment scripts; expands local setup to provision both GCS buckets; updates dev docs to reflect new variable names and staging bucket; raises Vitest coverage thresholds.
App-level test coverage for storage and uploads
apps/map/__tests__/lib/storage.test.ts, apps/map/__tests__/utils/image/upload-logo.test.ts, apps/me/__tests__/lib/storage.test.ts, apps/me/__tests__/lib/gcs.test.ts, apps/me/__tests__/api/profile.test.ts, apps/me/__tests__/lib/auth/tokens.test.ts
Adds suites for map/me storage channel selection, upload-logo utility (success, error, unparseable response), avatar upload delegation, profile malformed JSON 400, and token verification fallback logic.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant UploadRoute as POST /api/upload-logo
  participant AppStorage as lib/storage.ts (app)
  participant PkgFactory as createPublicImageStorage
  participant GCSEmulator as GCS Emulator
  participant GCSProd as Google Cloud Storage

  Browser->>UploadRoute: multipart/form-data (file, orgId, size?)
  UploadRoute->>UploadRoute: validate MIME, size ≤10MB, orgId integer, parseOptionalSize
  UploadRoute->>AppStorage: storage.uploadOrgLogo(orgId, buffer, {size})
  AppStorage->>PkgFactory: prepareImageForStorage → square JPEG
  alt GCS_EMULATOR_HOST set
    PkgFactory->>GCSEmulator: HTTP PUT /f3-public-images-staging/org-logos/{id}.jpg
    GCSEmulator-->>PkgFactory: 2xx + emulator URL
  else production
    PkgFactory->>GCSProd: GCS SDK file.save (contentType, cacheControl)
    GCSProd-->>PkgFactory: public URL
  end
  PkgFactory-->>AppStorage: canonical URL string
  AppStorage-->>UploadRoute: URL string
  UploadRoute-->>Browser: JSON { url }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #462 – Refactor packages/storage into business-specific public image API: This PR directly implements all acceptance criteria from the issue, including createPublicImageStorage factory, fixed bucket/folder/filename rules, optional size, isAllowedPublicImageUrl as a method, per-app singleton clients, and migration of all three apps away from raw GCS access.

Possibly related PRs

  • F3-Nation/f3-nation#163: Modifies the same apps/me avatar/GCS upload flow and related env/storage wiring that this PR rewrites via the new PublicImageStorage abstraction.
  • F3-Nation/f3-nation#282: Both PRs modify apps/map/src/app/api/upload-logo/route.ts and GCS_EMULATOR_HOST handling, with this PR completely rewriting the handler to delegate to the shared storage client.
  • F3-Nation/f3-nation#334: Both PRs touch apps/admin/src/app/api/upload-logo/route.ts's POST handler, including structured logError-based logging on upload failure.

Suggested reviewers

  • dnishiyama
  • evanpetzoldt
  • taterhead247

Poem

🐇 Hop hop, the bucket names are gone,
No more GOOGLE_LOGO_BUCKET_* to carry on!
One GCS_CREDENTIALS rules them all,
createPublicImageStorage answers the call.
Staging and prod, each gets its own shelf—
The rabbit refactored this all by himself! 🪣✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.75% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and concisely describes the main change: consolidating GCS uploads into the @acme/storage package, which aligns with the primary objective.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #462: createPublicImageStorage factory with channel/credentials config, canonical filenames (entity-id.jpg), optional size parameters, centralized URL validation, per-app storage clients, and package ownership of bucket selection and folder structure.
Out of Scope Changes check ✅ Passed All changes are directly related to consolidating GCS uploads into packages/storage and migrating apps/me, apps/admin, and apps/map. Vitest coverage threshold updates in apps/me/vitest.config.ts are minimal and related to test infrastructure quality for this refactor.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/storage-consolidation-462

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@mcmxcdev mcmxcdev marked this pull request as ready for review June 17, 2026 23:17

@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: 7

Caution

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

⚠️ Outside diff range comments (4)
apps/map/src/app/_components/forms/location-event-form.tsx (2)

153-154: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove console.log — use @acme/logger instead.

Same issue as above — debug logging should use the shared logger or be removed.

🧹 Proposed fix
-              console.log("eventTypes", eventTypes, field.value);
🤖 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 `@apps/map/src/app/_components/forms/location-event-form.tsx` around lines 153
- 154, Remove the console.log statement that logs "eventTypes", eventTypes, and
field.value in the location-event-form.tsx file. If debug logging is actually
needed for this section, replace the console.log with appropriate logging from
the `@acme/logger` package instead, as the codebase uses a shared logger for
consistency rather than direct console statements.

Source: Coding guidelines


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

Remove console.log — use @acme/logger instead.

Per coding guidelines, logging must go through the shared @acme/logger package. This debug statement should be removed or replaced with the appropriate log function from ~/lib/logging.

🧹 Proposed fix
-  console.log("form eventTypeIds", form.getValues().eventTypeIds);
🤖 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 `@apps/map/src/app/_components/forms/location-event-form.tsx` at line 35,
Remove the console.log statement that logs "form eventTypeIds" and
form.getValues().eventTypeIds in location-event-form.tsx. If this debug logging
is necessary, replace it with the appropriate logging function from the
`@acme/logger` package imported from ~/lib/logging instead of using console.log
directly, as per the coding guidelines for centralized logging.

Source: Coding guidelines

apps/map/src/app/api/upload-logo/route.ts (1)

23-71: ⚠️ Potential issue | 🔴 Critical

Missing authentication — any caller can overwrite any organization's logo.

The map route lacks the requireAccessToken() check present in the admin route (apps/admin/src/app/api/upload-logo/route.ts, line 26). Combined with the storage layer accepting any orgId without authorization validation, this allows unauthenticated users to overwrite logos for any organization by supplying an arbitrary orgId. The form passes formRegionId directly from user input with no permission validation.

Add authentication and authorization checks to validate the caller is authorized to upload for the specified organization.

🤖 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 `@apps/map/src/app/api/upload-logo/route.ts` around lines 23 - 71, The POST
function in the upload-logo route lacks authentication and authorization checks,
allowing unauthenticated users to overwrite logos for any organization by
providing an arbitrary orgId. Add a requireAccessToken() check at the beginning
of the POST function to verify the caller is authenticated, then add
authorization validation to ensure the authenticated user has permission to
upload a logo for the specified orgId before proceeding with the file validation
and upload logic. This should mirror the security pattern used in the admin
route upload-logo endpoint.
apps/map/scripts/cloud-run-env.sh (1)

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

Replace readlink -f with a macOS-compatible absolute path expansion.

readlink -f is GNU-only and will fail on macOS; with set -e, this can abort the script before deployment steps run.

Suggested fix
-echo "Env file:     $(readlink -f "$ENV_FILE")"
+ENV_FILE_ABS="$(cd "$(dirname "$ENV_FILE")" && pwd -P)/$(basename "$ENV_FILE")"
+echo "Env file:     $ENV_FILE_ABS"

As per coding guidelines, **/*.sh scripts must be tested on macOS and WSL 2 and avoid platform-specific commands without a Linux fallback.

🤖 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 `@apps/map/scripts/cloud-run-env.sh` at line 117, The echo statement at line
117 uses readlink -f which is a GNU-only command that fails on macOS and will
abort the script due to set -e. Replace the readlink -f "$ENV_FILE" call with a
macOS-compatible absolute path expansion method. Consider using a combination of
cd and pwd or another POSIX-compatible approach that works across both macOS and
Linux environments to resolve the absolute path of the ENV_FILE variable.

Source: Coding guidelines

🤖 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 `@apps/map/src/app/api/upload-logo/route.ts`:
- Around line 14-21: The parseOptionalSize function is duplicated across
multiple route files and should be extracted to a shared utility module. Move
the parseOptionalSize function from this file to a shared utility location in
packages/storage or another appropriate shared package, then import and use it
in both the apps/map and apps/admin upload-logo route files, removing the
duplicate function definitions from both locations.

In `@apps/me/docs/SECURITY.md`:
- Line 124: The F3_CHANNEL environment variable description in the security
documentation is misleading as it doesn't accurately reflect the actual behavior
of the deriveStorageChannel function. Update the description for the F3_CHANNEL
entry in the documentation table to clarify that while three values exist
(local, staging, and prod), the local and staging values both map to the staging
bucket, while prod maps to the production bucket. Replace the current
description with clearer language that explains this bucket selection behavior.

In `@apps/me/README.md`:
- Line 125: Update the description for the F3_CHANNEL environment variable in
the README to clarify the actual channel behavior. The current description
misleadingly suggests that local is a standalone channel, when in fact both
local and staging map to the staging bucket, while only prod maps to the
production bucket. Replace the description with text that explicitly states this
mapping, such as indicating that local/staging values both use the staging
storage bucket and prod uses the production bucket, to make the behavior clear
to developers.

In `@apps/me/src/app/api/profile/avatar/route.ts`:
- Around line 27-32: The error message condition in the avatar route handler is
checking for "gcs_credentials is not set" but this text does not match the
actual error messages thrown by the upstream GCS credential validation in
public-images.ts. Update the if statement that checks the lower cased error
message to instead match one of the actual error messages: either "invalid
gcs_credentials payload" or "gcs_credentials is missing required service account
fields". This will ensure the condition properly catches GCS credential
configuration errors instead of falling through to the generic 500 response.

In `@docs/LOCAL_DEV_DOCKER.md`:
- Around line 379-380: The GCS emulator documentation section has inconsistent
bucket name references. Lines 379-380 correctly specify that local uploads use
the `f3-public-images-staging` bucket, but subsequent examples at lines 390,
409, and 494 still reference `f3-public-images` instead. Update all bucket name
references throughout the GCS emulator section to consistently use
`f3-public-images-staging` to match the initial guidance, or if both bucket
names are intentionally used for different scenarios, add explicit documentation
clarifying when each bucket should be used in local development.

In `@packages/storage/src/public-images.test.ts`:
- Around line 82-84: Replace all direct globalThis.fetch property assignments
with vi.stubGlobal("fetch", ...) throughout the test file to ensure proper
cleanup by Vitest. Specifically, change the pattern at lines 82–84, 97–99,
112–114, 166–175, 205–207, 217–219, 246–248, and 259–268 from globalThis.fetch =
vi.fn(...) to vi.stubGlobal("fetch", ...). Additionally, add
vi.unstubAllGlobals() in all afterEach hooks to properly clean up the stubbed
globals and prevent test pollution and flakiness across test runs.

In `@packages/storage/src/public-images.ts`:
- Around line 134-157: Add validation at the start of the uploadOrgLogo,
deleteOrgLogo, uploadUserAvatar, and deleteUserAvatar methods to ensure orgId
and userId parameters are positive integers. Reject invalid inputs such as zero,
negative numbers, non-integers, or NaN by throwing an appropriate error before
these IDs are used to construct the bucket paths. This enforces the contract
that only valid positive integer IDs are accepted at the storage API boundary.

---

Outside diff comments:
In `@apps/map/scripts/cloud-run-env.sh`:
- Line 117: The echo statement at line 117 uses readlink -f which is a GNU-only
command that fails on macOS and will abort the script due to set -e. Replace the
readlink -f "$ENV_FILE" call with a macOS-compatible absolute path expansion
method. Consider using a combination of cd and pwd or another POSIX-compatible
approach that works across both macOS and Linux environments to resolve the
absolute path of the ENV_FILE variable.

In `@apps/map/src/app/_components/forms/location-event-form.tsx`:
- Around line 153-154: Remove the console.log statement that logs "eventTypes",
eventTypes, and field.value in the location-event-form.tsx file. If debug
logging is actually needed for this section, replace the console.log with
appropriate logging from the `@acme/logger` package instead, as the codebase uses
a shared logger for consistency rather than direct console statements.
- Line 35: Remove the console.log statement that logs "form eventTypeIds" and
form.getValues().eventTypeIds in location-event-form.tsx. If this debug logging
is necessary, replace it with the appropriate logging function from the
`@acme/logger` package imported from ~/lib/logging instead of using console.log
directly, as per the coding guidelines for centralized logging.

In `@apps/map/src/app/api/upload-logo/route.ts`:
- Around line 23-71: The POST function in the upload-logo route lacks
authentication and authorization checks, allowing unauthenticated users to
overwrite logos for any organization by providing an arbitrary orgId. Add a
requireAccessToken() check at the beginning of the POST function to verify the
caller is authenticated, then add authorization validation to ensure the
authenticated user has permission to upload a logo for the specified orgId
before proceeding with the file validation and upload logic. This should mirror
the security pattern used in the admin route upload-logo endpoint.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f7e8f620-b3b2-41e1-a3ab-11974c07db22

📥 Commits

Reviewing files that changed from the base of the PR and between e2dea96 and bb3e9dc.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (36)
  • .github/workflows/ci.yml
  • apps/admin/.env.cloud-run.example
  • apps/admin/.env.example
  • apps/admin/scripts/cloud-run-env.sh
  • apps/admin/src/app/api/upload-logo/route.ts
  • apps/admin/src/env.ts
  • apps/admin/src/lib/storage.ts
  • apps/api/vitest.config.ts
  • apps/map/.env.cloud-run.example
  • apps/map/.env.example
  • apps/map/package.json
  • apps/map/scripts/cloud-run-env.sh
  • apps/map/src/app/_components/forms/location-event-form.tsx
  • apps/map/src/app/api/upload-logo/route.ts
  • apps/map/src/env.ts
  • apps/map/src/lib/logging.ts
  • apps/map/src/lib/storage.ts
  • apps/map/src/utils/image/upload-logo.ts
  • apps/me/.env.cloud-run.example
  • apps/me/.env.example
  • apps/me/README.md
  • apps/me/docs/SECURITY.md
  • apps/me/scripts/cloud-run-env.sh
  • apps/me/src/app/api/profile/avatar/route.ts
  • apps/me/src/app/api/profile/route.ts
  • apps/me/src/env.ts
  • apps/me/src/lib/gcs.ts
  • apps/me/src/lib/storage.ts
  • apps/me/vitest.config.ts
  • docs/LOCAL_DEV_DOCKER.md
  • packages/env/src/index.ts
  • packages/storage/src/env.ts
  • packages/storage/src/index.ts
  • packages/storage/src/public-images.test.ts
  • packages/storage/src/public-images.ts
  • scripts/local-setup.sh
💤 Files with no reviewable changes (2)
  • packages/env/src/index.ts
  • apps/admin/scripts/cloud-run-env.sh

Comment on lines +14 to +21
function parseOptionalSize(
sizeRaw: FormDataEntryValue | null,
): number | undefined | "invalid" {
if (!sizeRaw) return undefined;
const parsed = Number(sizeRaw);
if (!Number.isFinite(parsed) || parsed <= 0) return "invalid";
return parsed;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider extracting parseOptionalSize to a shared utility.

This helper is duplicated verbatim in apps/admin/src/app/api/upload-logo/route.ts (lines 16-23). Moving it to packages/storage or a shared utility would reduce duplication.

🤖 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 `@apps/map/src/app/api/upload-logo/route.ts` around lines 14 - 21, The
parseOptionalSize function is duplicated across multiple route files and should
be extracted to a shared utility module. Move the parseOptionalSize function
from this file to a shared utility location in packages/storage or another
appropriate shared package, then import and use it in both the apps/map and
apps/admin upload-logo route files, removing the duplicate function definitions
from both locations.

Comment thread apps/me/docs/SECURITY.md Outdated
Comment thread apps/me/README.md Outdated
Comment thread apps/me/src/app/api/profile/avatar/route.ts Outdated
Comment thread docs/LOCAL_DEV_DOCKER.md
Comment thread packages/storage/src/public-images.test.ts Outdated
Comment thread packages/storage/src/public-images.ts
@github-actions

Copy link
Copy Markdown

Recently published dependencies

Found 1 package published in the last 3 days:

  • nodemailer@9.0.1 published 2026-06-17 in @acme/auth, @acme/mail, f3-auth, f3-map

Publish-time check health

All package publish-time checks completed successfully.

@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

🤖 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 `@apps/map/__tests__/utils/image/upload-logo.test.ts`:
- Around line 5-7: The afterEach hook in the test file currently only calls
vi.restoreAllMocks(), which does not clean up stubbed globals such as the fetch
global that is likely being stubbed in the test cases. Add vi.unstubAllGlobals()
to the afterEach function alongside the existing vi.restoreAllMocks() call to
ensure all stubbed globals are properly cleaned up after each test, preventing
test leakage and pollution to subsequent tests.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a22dff57-c423-46f2-9edb-fe3fb4eac5cf

📥 Commits

Reviewing files that changed from the base of the PR and between bb3e9dc and b04bc6c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • .github/workflows/ci.yml
  • apps/map/__tests__/lib/storage.test.ts
  • apps/map/__tests__/utils/image/upload-logo.test.ts
  • apps/map/package.json
  • apps/map/src/app/_components/forms/location-event-form.tsx
  • apps/me/README.md
  • apps/me/__tests__/api/profile.test.ts
  • apps/me/__tests__/lib/auth/tokens.test.ts
  • apps/me/__tests__/lib/gcs.test.ts
  • apps/me/__tests__/lib/storage.test.ts
  • apps/me/docs/SECURITY.md
  • apps/me/src/app/api/profile/avatar/route.ts
  • apps/me/vitest.config.ts
  • docs/LOCAL_DEV_DOCKER.md
  • packages/storage/src/public-images.test.ts
  • packages/storage/src/public-images.ts
  • packages/storage/src/storage.test.ts
💤 Files with no reviewable changes (1)
  • apps/map/src/app/_components/forms/location-event-form.tsx

Comment on lines +5 to +7
afterEach(() => {
vi.restoreAllMocks();
});

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether stubbed globals are being reverted globally or per-test.
rg -n --type=ts '\bvi\.stubGlobal\s*\('
rg -n --type=ts '\bvi\.unstubAllGlobals\s*\('
rg -n --type=ts 'unstubGlobals\s*:\s*true'

Repository: F3-Nation/f3-nation

Length of output: 1260


🏁 Script executed:

cat -n apps/map/__tests__/utils/image/upload-logo.test.ts

Repository: F3-Nation/f3-nation

Length of output: 2367


🏁 Script executed:

# Find vitest config files
fd -e 'config.ts' -e 'vitest.config.ts' -e 'vitest.config.js'

Repository: F3-Nation/f3-nation

Length of output: 662


🏁 Script executed:

cat -n apps/map/vitest.config.ts

Repository: F3-Nation/f3-nation

Length of output: 1998


🏁 Script executed:

fd -type f -name 'vitest-config' packages/ && find packages -name '*vitest*' -type f | head -20

Repository: F3-Nation/f3-nation

Length of output: 234


🏁 Script executed:

find packages -name '*vitest*' | head -20

Repository: F3-Nation/f3-nation

Length of output: 349


🏁 Script executed:

rg -l '`@acme/vitest-config`' packages/

Repository: F3-Nation/f3-nation

Length of output: 45


🏁 Script executed:

rg '`@acme/vitest-config`' --type ts --type tsx

Repository: F3-Nation/f3-nation

Length of output: 91


🏁 Script executed:

rg '`@acme/vitest-config`' --type ts

Repository: F3-Nation/f3-nation

Length of output: 305


🏁 Script executed:

find . -path ./node_modules -prune -o -name 'package.json' -type f -exec grep -l '`@acme/vitest-config`' {} \;

Repository: F3-Nation/f3-nation

Length of output: 164


🏁 Script executed:

cat -n tooling/vitest/package.json && echo "---" && ls -la tooling/vitest/ && echo "---" && find tooling/vitest -type f -name '*.ts' -o -name 'index.js'

Repository: F3-Nation/f3-nation

Length of output: 1404


🏁 Script executed:

cat -n tooling/vitest/coverage.ts

Repository: F3-Nation/f3-nation

Length of output: 1285


Add vi.unstubAllGlobals() to afterEach to prevent test leakage.

vi.restoreAllMocks() does not unstub globals set via vi.stubGlobal. The suite must explicitly call vi.unstubAllGlobals() in the afterEach cleanup (or enable unstubGlobals: true in vitest config) to prevent stubbed fetch globals from leaking into other tests.

Affected test cases
  • Lines 10-32: "returns url on successful upload"
  • Lines 34-53: "throws API-provided error message on failed upload"
  • Lines 55-74: "throws fallback error when failed upload body is unreadable"
🤖 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 `@apps/map/__tests__/utils/image/upload-logo.test.ts` around lines 5 - 7, The
afterEach hook in the test file currently only calls vi.restoreAllMocks(), which
does not clean up stubbed globals such as the fetch global that is likely being
stubbed in the test cases. Add vi.unstubAllGlobals() to the afterEach function
alongside the existing vi.restoreAllMocks() call to ensure all stubbed globals
are properly cleaned up after each test, preventing test leakage and pollution
to subsequent tests.

@taterhead247 taterhead247 force-pushed the feat/storage-consolidation-462 branch from b04bc6c to 1a367c4 Compare June 19, 2026 14:59

@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: 4

Caution

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

⚠️ Outside diff range comments (1)
apps/map/src/app/_components/forms/location-event-form.tsx (1)

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

Handle AO logo upload failures in the async change handler.

At Line 433, a rejected uploadLogo promise will bubble out of the event handler, leading to an unhandled rejection and no user-visible error. Catch and surface the failure.

Suggested fix
                     onChange={async (e) => {
                       if (formRegionId == null) {
                         toast.error("Please select a region first");
                         return;
                       }
                       const file = e.target.files?.[0];
                       if (!file) return;
-
-                      const url = await uploadLogo({
-                        file,
-                        orgId: formRegionId,
-                      });
-                      onChange(url);
+                      try {
+                        const url = await uploadLogo({
+                          file,
+                          orgId: formRegionId,
+                        });
+                        onChange(url);
+                      } catch (err) {
+                        toast.error(
+                          err instanceof Error
+                            ? err.message
+                            : "Failed to upload logo",
+                        );
+                      }
                     }}
🤖 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 `@apps/map/src/app/_components/forms/location-event-form.tsx` around lines 425
- 438, The async onChange handler in the location-event-form does not handle
failures from the uploadLogo function call, which will result in an unhandled
promise rejection. Wrap the uploadLogo call and the subsequent onChange call in
a try-catch block, and use toast.error to display an error message to the user
when the upload fails, similar to how the region validation error is already
being handled.
🤖 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 `@apps/map/src/utils/image/upload-logo.ts`:
- Around line 24-25: The code at lines 24-25 in the upload-logo.ts file uses a
TypeScript type assertion without runtime validation, meaning a malformed 2xx
response with a missing or invalid url property will silently pass through.
After parsing the JSON response in the section handling (await response.json())
as { url: string }, add explicit runtime validation to check that data.url
exists and is a valid string before returning it. If the validation fails, throw
a descriptive error to provide clear feedback to callers instead of allowing
undefined to propagate silently.

In `@apps/me/src/env.ts`:
- Line 18: The GCS_CREDENTIALS validation in the environment schema only checks
for a non-empty string but does not validate that it is base64-encoded JSON,
causing malformed credentials to pass at startup and fail later during upload
operations. Enhance the GCS_CREDENTIALS schema definition to add validation for
base64-encoded JSON format using a refine method, regex pattern, or custom
validator combined with z.string().min(1) to ensure the validation fails fast at
environment parse time. Apply the same enhanced validation to the
GCS_CREDENTIALS field in all three environment configuration files.

In `@packages/storage/src/public-images.test.ts`:
- Around line 71-130: Add regression test cases to the uploadOrgLogo test suite
to verify that the function properly rejects invalid orgId inputs. Within the
describe block for uploadOrgLogo (emulator mode), add test cases that verify the
function throws an error when passed non-positive or non-integer ID values such
as 0, negative numbers, floats like 1.5, and NaN. Each test case should call
storage.uploadOrgLogo with one of these invalid ID values and assert that it
rejects with an appropriate error message. Additionally, apply the same
regression tests to any other similar upload methods mentioned in the file
(around lines 205-300) that accept userId or orgId parameters.

In `@packages/storage/src/public-images.ts`:
- Around line 141-147: Add validation for the options?.size parameter in the
uploadOrgLogo method to ensure it is a positive integer before passing it to
prepareImageForStorage. Reject invalid values such as zero, negative numbers,
non-integers, NaN, and undefined by throwing a deterministic contract error
(e.g., using an assertion utility like assertPositiveIntegerId if applicable, or
creating a similar validation). Apply the same validation to the other related
methods mentioned at lines 156-163 that also accept options?.size as a dimension
parameter.

---

Outside diff comments:
In `@apps/map/src/app/_components/forms/location-event-form.tsx`:
- Around line 425-438: The async onChange handler in the location-event-form
does not handle failures from the uploadLogo function call, which will result in
an unhandled promise rejection. Wrap the uploadLogo call and the subsequent
onChange call in a try-catch block, and use toast.error to display an error
message to the user when the upload fails, similar to how the region validation
error is already being handled.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e5b086b7-1d5e-4f86-8045-96a0ba88f856

📥 Commits

Reviewing files that changed from the base of the PR and between b04bc6c and 3af08a3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (43)
  • .github/workflows/ci.yml
  • apps/admin/.env.cloud-run.example
  • apps/admin/.env.example
  • apps/admin/scripts/cloud-run-env.sh
  • apps/admin/src/app/api/upload-logo/route.ts
  • apps/admin/src/env.ts
  • apps/admin/src/lib/storage.ts
  • apps/map/.env.cloud-run.example
  • apps/map/.env.example
  • apps/map/__tests__/lib/storage.test.ts
  • apps/map/__tests__/utils/image/upload-logo.test.ts
  • apps/map/package.json
  • apps/map/scripts/cloud-run-env.sh
  • apps/map/src/app/_components/forms/location-event-form.tsx
  • apps/map/src/app/api/upload-logo/route.ts
  • apps/map/src/env.ts
  • apps/map/src/lib/logging.ts
  • apps/map/src/lib/storage.ts
  • apps/map/src/utils/image/upload-logo.ts
  • apps/me/.env.cloud-run.example
  • apps/me/.env.example
  • apps/me/README.md
  • apps/me/__tests__/api/profile.test.ts
  • apps/me/__tests__/lib/auth/tokens.test.ts
  • apps/me/__tests__/lib/gcs.test.ts
  • apps/me/__tests__/lib/storage.test.ts
  • apps/me/docs/SECURITY.md
  • apps/me/scripts/cloud-run-env.sh
  • apps/me/src/app/api/profile/avatar/route.ts
  • apps/me/src/app/api/profile/route.ts
  • apps/me/src/env.ts
  • apps/me/src/lib/gcs.ts
  • apps/me/src/lib/storage.ts
  • apps/me/vitest.config.ts
  • docs/LOCAL_DEV_DOCKER.md
  • packages/env/src/index.ts
  • packages/storage/src/env.ts
  • packages/storage/src/index.ts
  • packages/storage/src/public-images.test.ts
  • packages/storage/src/public-images.ts
  • packages/storage/src/storage.test.ts
  • pnpm-workspace.yaml
  • scripts/local-setup.sh
💤 Files with no reviewable changes (2)
  • apps/admin/scripts/cloud-run-env.sh
  • packages/env/src/index.ts

Comment on lines +24 to +25
const data = (await response.json()) as { url: string };
return data.url;

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 | 🟠 Major | ⚡ Quick win

Guard the success payload shape before returning url.

Line 24-25 trusts a type assertion; a malformed 2xx response can return undefined and silently break consumers. Validate data.url and throw a deterministic error when missing.

Suggested fix
-  const data = (await response.json()) as { url: string };
-  return data.url;
+  const data = (await response.json()) as { url?: unknown };
+  if (typeof data.url !== "string" || data.url.length === 0) {
+    throw new Error("Failed to upload logo");
+  }
+  return data.url;
📝 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 data = (await response.json()) as { url: string };
return data.url;
const data = (await response.json()) as { url?: unknown };
if (typeof data.url !== "string" || data.url.length === 0) {
throw new Error("Failed to upload logo");
}
return data.url;
🤖 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 `@apps/map/src/utils/image/upload-logo.ts` around lines 24 - 25, The code at
lines 24-25 in the upload-logo.ts file uses a TypeScript type assertion without
runtime validation, meaning a malformed 2xx response with a missing or invalid
url property will silently pass through. After parsing the JSON response in the
section handling (await response.json()) as { url: string }, add explicit
runtime validation to check that data.url exists and is a valid string before
returning it. If the validation fails, throw a descriptive error to provide
clear feedback to callers instead of allowing undefined to propagate silently.

Comment thread apps/me/src/env.ts
OAUTH_REDIRECT_URI: z.string().url(),
F3_CHANNEL: z.enum(["local", "ci", "branch", "dev", "staging", "prod"]),
// Base64-encoded service-account JSON for GCS public-image uploads.
GCS_CREDENTIALS: z.string().min(1),

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 | 🟠 Major | ⚡ Quick win

Validate GCS_CREDENTIALS format at env-parse time.

Line 18 currently accepts any non-empty string, so malformed credentials pass startup and fail later in upload paths. Enforce “base64-encoded JSON” in schema parsing to fail fast and consistently (and mirror this in apps/admin/src/env.ts Line 22 and apps/map/src/env.ts Line 34).

Suggested fix
-    GCS_CREDENTIALS: z.string().min(1),
+    GCS_CREDENTIALS: z
+      .string()
+      .min(1)
+      .refine((v) => {
+        try {
+          const decoded = Buffer.from(v, "base64").toString("utf8");
+          JSON.parse(decoded);
+          return true;
+        } catch {
+          return false;
+        }
+      }, "GCS_CREDENTIALS must be base64-encoded JSON"),
📝 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
GCS_CREDENTIALS: z.string().min(1),
GCS_CREDENTIALS: z
.string()
.min(1)
.refine((v) => {
try {
const decoded = Buffer.from(v, "base64").toString("utf8");
JSON.parse(decoded);
return true;
} catch {
return false;
}
}, "GCS_CREDENTIALS must be base64-encoded JSON"),
🤖 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 `@apps/me/src/env.ts` at line 18, The GCS_CREDENTIALS validation in the
environment schema only checks for a non-empty string but does not validate that
it is base64-encoded JSON, causing malformed credentials to pass at startup and
fail later during upload operations. Enhance the GCS_CREDENTIALS schema
definition to add validation for base64-encoded JSON format using a refine
method, regex pattern, or custom validator combined with z.string().min(1) to
ensure the validation fails fast at environment parse time. Apply the same
enhanced validation to the GCS_CREDENTIALS field in all three environment
configuration files.

Comment on lines +71 to +130
describe("uploadOrgLogo (emulator mode)", () => {
beforeEach(() => {
process.env.GCS_EMULATOR_HOST = EMULATOR_HOST;
});

afterEach(() => {
delete process.env.GCS_EMULATOR_HOST;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("uploads to prod bucket and returns canonical URL", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("{}", { status: 200 }))),
);

const storage = createPublicImageStorage({
channel: "prod",
credentials: FAKE_CREDENTIALS,
});
const url = await storage.uploadOrgLogo(123, Buffer.from("img"));
expect(url).toBe(
`http://${EMULATOR_HOST}/f3-public-images/org-logos/123.jpg`,
);
});

it("uploads to staging bucket and returns canonical URL", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(new Response("{}", { status: 200 }))),
);

const storage = createPublicImageStorage({
channel: "staging",
credentials: FAKE_CREDENTIALS,
});
const url = await storage.uploadOrgLogo(42, Buffer.from("img"));
expect(url).toBe(
`http://${EMULATOR_HOST}/f3-public-images-staging/org-logos/42.jpg`,
);
});

it("throws when emulator returns non-2xx", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(new Response("bucket not found", { status: 404 })),
),
);

const storage = createPublicImageStorage({
channel: "staging",
credentials: FAKE_CREDENTIALS,
});
await expect(storage.uploadOrgLogo(1, Buffer.from("img"))).rejects.toThrow(
"GCS emulator upload failed: HTTP 404 bucket not found",
);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add regression tests for invalid orgId/userId inputs.

The runtime contract now rejects non-positive/non-integer IDs, but this suite only asserts happy-path IDs. Add explicit 0/negative/float/NaN cases to lock this behavior.

Suggested test block
+describe("id validation", () => {
+  const storage = createPublicImageStorage({
+    channel: "staging",
+    credentials: FAKE_CREDENTIALS,
+  });
+
+  it.each([0, -1, 1.5, Number.NaN])(
+    "rejects invalid orgId %p",
+    async (orgId) => {
+      await expect(
+        storage.uploadOrgLogo(orgId as number, Buffer.from("img")),
+      ).rejects.toThrow("orgId must be a positive integer");
+      await expect(storage.deleteOrgLogo(orgId as number)).rejects.toThrow(
+        "orgId must be a positive integer",
+      );
+    },
+  );
+
+  it.each([0, -1, 2.25, Number.NaN])(
+    "rejects invalid userId %p",
+    async (userId) => {
+      await expect(
+        storage.uploadUserAvatar(userId as number, Buffer.from("img")),
+      ).rejects.toThrow("userId must be a positive integer");
+      await expect(storage.deleteUserAvatar(userId as number)).rejects.toThrow(
+        "userId must be a positive integer",
+      );
+    },
+  );
+});

Also applies to: 205-300

🤖 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 `@packages/storage/src/public-images.test.ts` around lines 71 - 130, Add
regression test cases to the uploadOrgLogo test suite to verify that the
function properly rejects invalid orgId inputs. Within the describe block for
uploadOrgLogo (emulator mode), add test cases that verify the function throws an
error when passed non-positive or non-integer ID values such as 0, negative
numbers, floats like 1.5, and NaN. Each test case should call
storage.uploadOrgLogo with one of these invalid ID values and assert that it
rejects with an appropriate error message. Additionally, apply the same
regression tests to any other similar upload methods mentioned in the file
(around lines 205-300) that accept userId or orgId parameters.

Comment on lines +141 to +147
async uploadOrgLogo(orgId, file, options) {
assertPositiveIntegerId("orgId", orgId);
const size = options?.size ?? 640;
const jpg = await prepareImageForStorage(file, {
width: size,
height: size,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Validate options.size at the storage boundary.

options?.size is used directly for Sharp dimensions. Invalid values (e.g., 0, negatives, floats, NaN) currently fail deep in image processing instead of returning a deterministic contract error from this API.

Suggested patch
+  function assertPositiveIntegerSize(
+    value: number | undefined,
+    fallback: number,
+  ): number {
+    const size = value ?? fallback;
+    if (!Number.isInteger(size) || size <= 0) {
+      throw new Error("size must be a positive integer");
+    }
+    return size;
+  }
+
   return {
     async uploadOrgLogo(orgId, file, options) {
       assertPositiveIntegerId("orgId", orgId);
-      const size = options?.size ?? 640;
+      const size = assertPositiveIntegerSize(options?.size, 640);
       const jpg = await prepareImageForStorage(file, {
         width: size,
         height: size,
       });
       return uploadToBucket(`org-logos/${orgId}.jpg`, jpg, "image/jpeg");
@@
     async uploadUserAvatar(userId, file, options) {
       assertPositiveIntegerId("userId", userId);
-      const size = options?.size ?? 512;
+      const size = assertPositiveIntegerSize(options?.size, 512);
       const jpg = await prepareImageForStorage(file, {
         width: size,
         height: size,
       });
       return uploadToBucket(`user-avatars/${userId}.jpg`, jpg, "image/jpeg");

Also applies to: 156-163

🤖 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 `@packages/storage/src/public-images.ts` around lines 141 - 147, Add validation
for the options?.size parameter in the uploadOrgLogo method to ensure it is a
positive integer before passing it to prepareImageForStorage. Reject invalid
values such as zero, negative numbers, non-integers, NaN, and undefined by
throwing a deterministic contract error (e.g., using an assertion utility like
assertPositiveIntegerId if applicable, or creating a similar validation). Apply
the same validation to the other related methods mentioned at lines 156-163 that
also accept options?.size as a dimension parameter.

@BigGillyStyle BigGillyStyle left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thorough, well-tested consolidation — nice work removing the per-app GoogleAuth boilerplate and centralizing the URL allow-list. Most comments are suggestions/cleanups; the one to weigh before merge is the unauthenticated map upload-logo route writing to a now-predictable shared path (see inline). Posted via Claude Code review.

return parsed;
}

export async function POST(request: Request) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[security] This route is unauthenticated (map middleware.ts has an empty matcher and there's no requireAccessToken() here, unlike the admin equivalent). Combined with the now-deterministic object path org-logos/{orgId}.jpg, any anonymous caller can POST with an arbitrary positive orgId and overwrite that region's live logo — orgId is validated as a positive integer but ownership is never checked. The missing auth pre-dates this PR, but the predictable shared path makes targeted overwrite trivial. Recommend adding an auth/ownership guard, or confirming the endpoint is intentionally public.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reminds me of a use case that @dnishiyama brought up that I forgot. Any user (authenticated, but not admin/editor), can submit a change request. That request could include a region logo image. The request has to be approved by an admin/editor. So the question was where does that logo live while it's in the requested state? Maybe that's related to this comment?

Comment on lines +133 to +138
function assertPositiveIntegerId(label: "orgId" | "userId", value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive integer`);
}
return value;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[tests] This guard (the orgId=0 / non-positive fix highlighted in the PR description) has no direct test, and there's no route-level test for the upload-logo routes covering orgId<=0, bad type, oversize, or parseOptionalSize. Worth adding so the documented bugfix is locked in.

Comment on lines +116 to +131
function isAllowedPublicImageUrl(url: string): boolean {
if (
url.startsWith(`https://storage.googleapis.com/${BUCKETS.prod}/`) ||
url.startsWith(`https://storage.googleapis.com/${BUCKETS.staging}/`)
) {
return true;
}
const emulatorHost = getEmulatorHost();
if (emulatorHost) {
return (
url.startsWith(`http://${emulatorHost}/${BUCKETS.prod}/`) ||
url.startsWith(`http://${emulatorHost}/${BUCKETS.staging}/`)
);
}
return false;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[tests] The https cases are well covered (incl. the prefix-trick test 👍), but the emulator branch (http://${host}/...) — which is exactly what the allow-list bugfix added — isn't tested. A case with GCS_EMULATOR_HOST set would cover it.

Comment thread .github/workflows/ci.yml
GOOGLE_LOGO_BUCKET_PRIVATE_KEY: mock-private-key
GOOGLE_LOGO_BUCKET_CLIENT_EMAIL: mock@example.iam.gserviceaccount.com
GOOGLE_LOGO_BUCKET_BUCKET_NAME: mock-bucket
GCS_CREDENTIALS: eyJjbGllbnRfZW1haWwiOiJtb2NrQGV4YW1wbGUuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJwcml2YXRlX2tleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbmZha2Vcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tIn0=

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[cleanup] The three GOOGLE_LOGO_BUCKET_* vars just above are now removed from packages/env and every app env.ts, so they're dead config here. Safe to delete lines 36–38.

Comment on lines +5 to +10
function deriveStorageChannel(channel: string): "staging" | "prod" {
return channel === "prod" ? "prod" : "staging";
}

export const storage = createPublicImageStorage({
channel: deriveStorageChannel(env.F3_CHANNEL),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[dry] This helper is duplicated verbatim in the admin and me wrappers too. Consider exporting it (and the F3_CHANNEL literal type) from @acme/storage so the "only prod → prod bucket" rule lives in one place. Also confirming intent: dev/branch/local/ci all map to the staging bucket — fine if there's no long-lived prod-data dev env.

return NextResponse.json({ url: publicUrl });
} catch (error) {
console.error("Error uploading file:", error);
return NextResponse.json({ url });

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[ux] Objects now live at a stable path with cacheControl: public, max-age=300, so a re-uploaded logo can show stale for ~5 min. The me avatar route returns a cache-busted ?v=Date.now() URL; consider doing the same here (or noting why logos don't need it).

vi.doMock("@acme/storage", () => ({
createPublicImageStorage,
}));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[tests] "preview" isn't a member of the real enum (local|ci|branch|dev|staging|prod). The mock bypasses validation so it passes, but using a real non-prod value like "dev" keeps the test honest.

GCS_BUCKET: z.string().min(1),
// Used only by the low-level uploadFile/deleteFile helpers. Apps using
// createPublicImageStorage derive the bucket from channel and do not set this.
GCS_BUCKET: z.string().min(1).optional(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[cleanup] GCS_BUCKET and the low-level uploadFile/deleteFile helpers (upload.ts/delete.ts/client.ts) now appear unused by any app — only this comment references them. Recommend removing them (env var, helpers, exports in index.ts, and storage.test.ts cases) so there's a single storage entry point and no drift between the two paths.

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

Labels

None yet

Projects

Status: Draft

Development

Successfully merging this pull request may close these issues.

Refactor packages/storage into business-specific public image API

3 participants