Summary
packages/storage currently abstracts some GCS mechanics, but it still leaves bucket selection, object paths, and business naming rules up to each app. That flexibility is now working against us: we have consolidated on one public-images bucket per environment, fixed folder names, and canonical object names, but apps/me, apps/admin, and apps/map still encode those rules themselves (or bypass the package entirely).
We should move the business-specific public-image behavior into packages/storage and migrate all callers to use that API.
Problem
Today the package exports generic primitives like uploadFile(path, data, contentType) and deleteFile(path), which means apps still have to know:
- which bucket to use
- which folder to write into
- how to name the object
- which image format to store
- which cache policy / public URL shape is canonical
That has already led to drift:
apps/me wraps @acme/storage, but still hardcodes user-avatars/{userId}.jpg
apps/admin wraps @acme/storage, but still hardcodes org-logos/{orgId}.jpg
apps/map still uploads directly to GCS and uses a different filename scheme (${orgId}-${requestId}...) plus app-specific env vars
apps/me also hardcodes bucket-specific URL validation for avatar URLs
Desired behavior
packages/storage should own the public-image business rules end-to-end.
Buckets
- staging bucket:
f3-public-images-staging
- production bucket:
f3-public-images
Folders
Canonical filenames
- files should be stored as JPG
- object names should be stable and business-specific
- examples:
org-logos/123.jpg
user-avatars/4.jpg
Proposed package API
Instead of accepting credentials and channel on every call, the package should expose a factory that is called once at app startup. The resulting client object carries the configured bucket and GCS credentials, and all domain operations are methods on it.
// Called once at startup (e.g. in apps/me/src/lib/storage.ts)
export function createPublicImageStorage(config: {
channel: "staging" | "prod";
credentials: string; // base64-encoded service-account JSON (GCS_CREDENTIALS env var)
}): PublicImageStorage;
export interface PublicImageStorage {
uploadOrgLogo(orgId: number, file: Buffer, options?: { size?: number }): Promise<string>;
deleteOrgLogo(orgId: number): Promise<void>;
uploadUserAvatar(userId: number, file: Buffer, options?: { size?: number }): Promise<string>;
deleteUserAvatar(userId: number): Promise<void>;
isAllowedPublicImageUrl(url: string): boolean;
}
The generic low-level helpers (uploadFile, deleteFile, prepareImageForStorage) remain internal to the package. App code should stop depending on raw bucket names and raw object paths. Apps store the URL returned by the upload call in the database and retrieve it from there; the storage package does not need to provide URL getter methods.
Suggested implementation
Add a public-images domain module inside packages/storage:
// packages/storage/src/public-images.ts
import { deleteFile } from "./delete";
import { prepareImageForStorage } from "./resize";
import { uploadFile } from "./upload";
const BUCKETS = {
prod: "f3-public-images",
staging: "f3-public-images-staging",
} as const;
function orgLogoPath(orgId: number): string {
return `org-logos/${orgId}.jpg`;
}
function userAvatarPath(userId: number): string {
return `user-avatars/${userId}.jpg`;
}
export function createPublicImageStorage(config: {
channel: "staging" | "prod";
credentials: string; // base64-encoded GCS_CREDENTIALS
}) {
const bucket = BUCKETS[config.channel];
const baseUrl = `https://storage.googleapis.com/${bucket}`;
function isAllowedPublicImageUrl(url: string): boolean {
return (
url.startsWith(`https://storage.googleapis.com/${BUCKETS.prod}/`) ||
url.startsWith(`https://storage.googleapis.com/${BUCKETS.staging}/`)
);
}
async function uploadOrgLogo(
orgId: number,
file: Buffer,
options?: { size?: number },
): Promise<string> {
const size = options?.size ?? 640;
const jpg = await prepareImageForStorage(file, { width: size, height: size });
return uploadFile(bucket, orgLogoPath(orgId), jpg, "image/jpeg", config.credentials, {
cacheControl: "public, max-age=300",
});
}
async function deleteOrgLogo(orgId: number): Promise<void> {
await deleteFile(bucket, orgLogoPath(orgId), config.credentials);
}
async function uploadUserAvatar(
userId: number,
file: Buffer,
options?: { size?: number },
): Promise<string> {
const size = options?.size ?? 512;
const jpg = await prepareImageForStorage(file, { width: size, height: size });
return uploadFile(bucket, userAvatarPath(userId), jpg, "image/jpeg", config.credentials, {
cacheControl: "public, max-age=300",
});
}
async function deleteUserAvatar(userId: number): Promise<void> {
await deleteFile(bucket, userAvatarPath(userId), config.credentials);
}
return { uploadOrgLogo, deleteOrgLogo, uploadUserAvatar, deleteUserAvatar, isAllowedPublicImageUrl };
}
The existing uploadFile and deleteFile internals will need to be updated to accept the bucket name and credentials as parameters (rather than reading from env.GCS_BUCKET / env.GCS_CREDENTIALS) so that the public-images layer controls both.
App migrations
apps/me
- Create
apps/me/src/lib/storage.ts that calls createPublicImageStorage once with GCS_CREDENTIALS and the channel derived from F3_CHANNEL
- Replace
uploadAvatar in apps/me/src/lib/gcs.ts with storage.uploadUserAvatar
- Stop hardcoding
user-avatars/{userId}.jpg
- Replace the avatar allow-list regex in
apps/me/src/app/api/profile/route.ts with storage.isAllowedPublicImageUrl
- Remove app-local
ALLOWED_AVATAR_HOST_PATTERN
- Update tests to use
.jpg and both-bucket coverage
apps/admin
- Create
apps/admin/src/lib/storage.ts that calls createPublicImageStorage once with GCS_CREDENTIALS and the channel
- Replace
prepareImageForStorage + uploadFile(...) in apps/admin/src/app/api/upload-logo/route.ts with storage.uploadOrgLogo
- Stop hardcoding
org-logos/{orgId}.jpg
- Keep route-level validation (file required, allowed MIME types, max size), but delegate storage decisions to the package
apps/map
- Create
apps/map/src/lib/storage.ts that calls createPublicImageStorage once with the appropriate credentials and channel
- Replace the hand-rolled GCS upload in
apps/map/src/app/api/upload-logo/route.ts with storage.uploadOrgLogo
- Remove the current request-id-based filename scheme and write to canonical
org-logos/{orgId}.jpg
- Remove direct
GoogleAuth usage from the app route
- Keep route-level request validation, but delegate upload behavior to the package
Env cleanup
As part of this refactor, align all apps on the storage package's env contract.
At minimum:
packages/storage should be the only place that knows which public-images bucket is canonical for staging/prod
- apps should no longer reference bucket names directly
apps/map should stop carrying separate GOOGLE_LOGO_BUCKET_* env vars for this use case; it should instead use the same GCS_CREDENTIALS / F3_CHANNEL pattern as apps/me and apps/admin
Acceptance criteria
packages/storage exports createPublicImageStorage with a factory that accepts credentials and channel once
- bucket names and folder names are not hardcoded in app code
- canonical filenames are
{entity-id}.jpg
- both
uploadOrgLogo and uploadUserAvatar accept an optional size parameter
isAllowedPublicImageUrl is a method on the storage client, not a standalone app-local regex
apps/me, apps/admin, and apps/map each create a single storage client at startup and use it for all storage operations
apps/map no longer talks to GCS directly for logo upload
- tests cover upload, delete, and URL validation for both production and staging configurations
Current call sites / context
packages/storage/src/upload.ts
packages/storage/src/delete.ts
apps/me/src/lib/gcs.ts
apps/me/src/app/api/profile/route.ts
apps/admin/src/app/api/upload-logo/route.ts
apps/map/src/app/api/upload-logo/route.ts
Summary
packages/storagecurrently abstracts some GCS mechanics, but it still leaves bucket selection, object paths, and business naming rules up to each app. That flexibility is now working against us: we have consolidated on one public-images bucket per environment, fixed folder names, and canonical object names, butapps/me,apps/admin, andapps/mapstill encode those rules themselves (or bypass the package entirely).We should move the business-specific public-image behavior into
packages/storageand migrate all callers to use that API.Problem
Today the package exports generic primitives like
uploadFile(path, data, contentType)anddeleteFile(path), which means apps still have to know:That has already led to drift:
apps/mewraps@acme/storage, but still hardcodesuser-avatars/{userId}.jpgapps/adminwraps@acme/storage, but still hardcodesorg-logos/{orgId}.jpgapps/mapstill uploads directly to GCS and uses a different filename scheme (${orgId}-${requestId}...) plus app-specific env varsapps/mealso hardcodes bucket-specific URL validation for avatar URLsDesired behavior
packages/storageshould own the public-image business rules end-to-end.Buckets
f3-public-images-stagingf3-public-imagesFolders
org-logos/user-avatars/Canonical filenames
org-logos/123.jpguser-avatars/4.jpgProposed package API
Instead of accepting credentials and channel on every call, the package should expose a factory that is called once at app startup. The resulting client object carries the configured bucket and GCS credentials, and all domain operations are methods on it.
The generic low-level helpers (
uploadFile,deleteFile,prepareImageForStorage) remain internal to the package. App code should stop depending on raw bucket names and raw object paths. Apps store the URL returned by the upload call in the database and retrieve it from there; the storage package does not need to provide URL getter methods.Suggested implementation
Add a public-images domain module inside
packages/storage:The existing
uploadFileanddeleteFileinternals will need to be updated to accept the bucket name and credentials as parameters (rather than reading fromenv.GCS_BUCKET/env.GCS_CREDENTIALS) so that the public-images layer controls both.App migrations
apps/meapps/me/src/lib/storage.tsthat callscreatePublicImageStorageonce withGCS_CREDENTIALSand the channel derived fromF3_CHANNELuploadAvatarinapps/me/src/lib/gcs.tswithstorage.uploadUserAvataruser-avatars/{userId}.jpgapps/me/src/app/api/profile/route.tswithstorage.isAllowedPublicImageUrlALLOWED_AVATAR_HOST_PATTERN.jpgand both-bucket coverageapps/adminapps/admin/src/lib/storage.tsthat callscreatePublicImageStorageonce withGCS_CREDENTIALSand the channelprepareImageForStorage + uploadFile(...)inapps/admin/src/app/api/upload-logo/route.tswithstorage.uploadOrgLogoorg-logos/{orgId}.jpgapps/mapapps/map/src/lib/storage.tsthat callscreatePublicImageStorageonce with the appropriate credentials and channelapps/map/src/app/api/upload-logo/route.tswithstorage.uploadOrgLogoorg-logos/{orgId}.jpgGoogleAuthusage from the app routeEnv cleanup
As part of this refactor, align all apps on the storage package's env contract.
At minimum:
packages/storageshould be the only place that knows which public-images bucket is canonical for staging/prodapps/mapshould stop carrying separateGOOGLE_LOGO_BUCKET_*env vars for this use case; it should instead use the sameGCS_CREDENTIALS/F3_CHANNELpattern asapps/meandapps/adminAcceptance criteria
packages/storageexportscreatePublicImageStoragewith a factory that accepts credentials and channel once{entity-id}.jpguploadOrgLogoanduploadUserAvataraccept an optionalsizeparameterisAllowedPublicImageUrlis a method on the storage client, not a standalone app-local regexapps/me,apps/admin, andapps/mapeach create a single storage client at startup and use it for all storage operationsapps/mapno longer talks to GCS directly for logo uploadCurrent call sites / context
packages/storage/src/upload.tspackages/storage/src/delete.tsapps/me/src/lib/gcs.tsapps/me/src/app/api/profile/route.tsapps/admin/src/app/api/upload-logo/route.tsapps/map/src/app/api/upload-logo/route.ts