Skip to content

ci: deploy api to Vercel with Vercel Blob storage - #101

Merged
mankatcheung merged 1 commit into
mainfrom
ci/deploy-api-vercel
Jul 26, 2026
Merged

mankatcheung merged 1 commit into
mainfrom
ci/deploy-api-vercel

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

Moves apps/api off Google Cloud (Cloud Run + GCS) to Vercel, following a decision to stop GCP usage for cost reasons. Two parts:

Deployment target: Cloud Run → Vercel

  • apps/api/api/index.ts: serverless entrypoint that builds the existing Fastify app once (reused across warm invocations) and feeds requests into it via app.server.emit('request', req, res) instead of .listen(). Local dev (src/index.ts) is unchanged.
  • apps/api/vercel.json: catch-all rewrite so every path (/graphql, /auth/oauth/*, /mcp, /admin/*) reaches the one function; maxDuration: 60 for LLM-calling routes; crons entries for digest + reminders.
  • reminders.plugin.ts: the in-process setInterval poll can't survive serverless (the process doesn't stay alive between requests) — converted to an external-trigger route mirroring the existing digest.plugin.ts pattern, driven by Vercel Cron instead.
  • Both digest/reminders routes now accept GET (Vercel Cron only issues GET) and authorize via either their own admin secret or CRON_SECRET — Vercel's reserved env var name, auto-injected as Authorization: Bearer $CRON_SECRET on cron invocations. This is the only way to keep the secret out of the committed vercel.json, since Cron can't send custom headers.

Storage: GCS → Vercel Blob

  • New VercelBlobStorageProvider (STORAGE_PROVIDER=vercel-blob) implements the existing IStorageProvider port using @vercel/blob's put/head/del.
  • Vercel Blob has no raw presigned-PUT like S3/GCS, so getPresignedUploadUrl returns a short-lived client token (generateClientTokenFromReadWriteToken) instead of a URL. The web app's document/avatar upload now calls put() from @vercel/blob/client with that token, uploading directly to Blob storage from the browser — bypassing the API server entirely, which matters since serverless functions have body-size limits well under the 10MB document cap this app allows.
  • Added ERROR_CODES.SERVICE_UNAVAILABLE (503) for storage-backend failures — logged server-side (not treated as an "expected" client error), since hitting it means something is actually misconfigured/down.

Manual setup needed before this deploys (not automatable from a PR)

  • Vercel project: Root Directory = apps/api, attach a Blob store (sets BLOB_READ_WRITE_TOKEN automatically).
  • Set project env vars: CRON_SECRET (generate one), plus everything in apps/api/.env.production (gitignored, not in this PR).
  • Vercel Cron on the Hobby plan is limited to once/day — the reminders schedule here is hourly (0 * * * *), matching the interval it replaces. Needs a Pro plan, or lower the frequency in vercel.json.
  • functions.maxDuration: 60 in vercel.json also needs a plan tier that allows it.

Test plan

  • pnpm --filter @job-finder/api typecheck / build / test (739 passed) / lint / format:check
  • pnpm --filter @job-finder/web typecheck / build / test (144 passed, including an updated avatar-upload test asserting the new @vercel/blob/client call) / lint / format:check
  • Manually verified @vercel/blob's actual shipped .d.ts (v2.6.1) for put/head/del/generateClientTokenFromReadWriteToken signatures rather than relying on memory
  • Not yet verified against a real Vercel deploy (no live environment available here) — flagging app.server.emit('request', ...) serverless-bridge pattern and the Cron GET-only assumption as the two things most worth a real smoke test once deployed

Summary by CodeRabbit

  • New Features

    • Added Vercel Blob support for avatar and document uploads.
    • Added scheduled digest and follow-up reminder processing.
    • Added secure cron-triggered endpoints for administrative tasks.
    • Added Vercel deployment routing and scheduled job configuration.
  • Bug Fixes

    • Uploads now use the supported client upload flow for improved reliability.
    • Added clearer service-unavailable responses when required services are unavailable.

Moves the API off Google Cloud (Cloud Run + GCS) to Vercel, driven by
a cost decision to stop GCP usage.

- New serverless entrypoint (apps/api/api/index.ts) wraps the existing
  Fastify app for Vercel's Node.js function runtime instead of .listen().
- apps/api/vercel.json: catch-all rewrite to the single function, and
  Vercel Cron entries for the digest/reminders routes below.
- New VercelBlobStorageProvider (STORAGE_PROVIDER=vercel-blob) replaces
  GCS. Vercel Blob has no raw presigned-PUT, so getPresignedUploadUrl
  returns a short-lived client token (generateClientTokenFromReadWriteToken)
  instead of a URL; the web app uploads directly to Blob storage via
  @vercel/blob/client's put(), bypassing the API server entirely (avoids
  serverless body-size limits on document uploads).
- reminders.plugin.ts: the in-process setInterval poll can't survive
  serverless (nothing keeps the process alive between requests) —
  converted to an external-trigger route mirroring digest.plugin.ts,
  driven by Vercel Cron.
- Both digest and reminders routes now accept GET (Cron only issues
  GET) and authorize via either their own admin secret or CRON_SECRET
  (Vercel's reserved env var name, auto-injected as a Bearer header on
  cron invocations — the only way to keep the secret out of the
  committed vercel.json).
- Added ERROR_CODES.SERVICE_UNAVAILABLE for storage-backend failures.

Known follow-ups for whoever deploys this:
- Vercel Cron on the Hobby plan is limited to once/day; the hourly
  reminders schedule needs Pro (or a lower-frequency schedule).
- functions.maxDuration=60 in vercel.json needs a plan that allows it.
- Set project env vars in the Vercel dashboard: CRON_SECRET,
  BLOB_READ_WRITE_TOKEN (auto-set when a Blob store is attached), and
  everything else from apps/api/.env.production (gitignored, not in
  this commit).
@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The API gains a Vercel entrypoint, Vercel Blob storage, cron-authenticated digest and reminder routes, and scheduled deployment configuration. Web avatar and document uploads now use the Vercel Blob client, with corresponding test mocks.

Changes

Vercel runtime and Blob storage

Layer / File(s) Summary
Vercel runtime and Blob storage
apps/api/api/index.ts, apps/api/src/infrastructure/storage/VercelBlobStorageProvider.ts, apps/api/src/http/container.ts, apps/api/src/constants.ts, apps/api/package.json
Adds cached Fastify initialisation for Vercel requests and selects Vercel Blob storage using the configured token.
Deployment configuration
apps/api/vercel.json, .gitignore
Adds Vercel rewrites, function duration, scheduled jobs, and ignores .env.production.

Scheduled digest and reminder routes

Layer / File(s) Summary
Cron authorisation and scheduled routes
apps/api/src/http/plugins/cronAuth.ts, apps/api/src/http/plugins/digest.plugin.ts, apps/api/src/http/plugins/reminders.plugin.ts
Adds shared Bearer-token validation and exposes GET/POST digest and reminder routes with explicit 401, 503, and 500 responses.
Service-unavailable error mapping
apps/api/src/http/errors/AppError.ts, apps/api/src/constants.ts
Adds the HTTP 503 error type and its coded-error mapping.

Browser Blob uploads

Layer / File(s) Summary
Avatar and document uploads
apps/web/src/routes/_authenticated/account.tsx, apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx, apps/web/package.json
Replaces direct PUT requests with @vercel/blob/client uploads using storage keys, tokens, access settings, and MIME types.
Avatar upload tests
apps/web/src/__tests__/components/AccountPage.test.tsx
Mocks Blob uploads and verifies successful and failed avatar-upload paths.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant API
  participant VercelBlob
  Browser->>API: Request upload metadata
  API->>VercelBlob: Generate client upload token
  VercelBlob-->>API: Return token
  API-->>Browser: Return storage key and token
  Browser->>VercelBlob: Upload file
  Browser->>API: Confirm uploaded file
Loading
sequenceDiagram
  participant VercelCron
  participant Fastify
  participant CronAuth
  participant UseCase
  VercelCron->>Fastify: Invoke scheduled admin route
  Fastify->>CronAuth: Validate Bearer token
  CronAuth-->>Fastify: Return authorisation result
  Fastify->>UseCase: Execute digest or reminder use case
  UseCase-->>Fastify: Return execution result
Loading

Poem

A rabbit hops where Blob files fly,
With cron bells ringing in the sky.
Tokens sparkle, uploads land,
Fastify waits with paws at hand.
Digest and reminders run on time—
A carrot-powered cloud design!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: deploying the API to Vercel with Vercel Blob storage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 ci/deploy-api-vercel

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.

@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

🧹 Nitpick comments (3)
apps/web/src/__tests__/components/AccountPage.test.tsx (1)

317-318: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover Blob upload failure separately.

This setup resolves mockPutBlob, so the test still fails only during RequestAvatarUploadUrl; it does not cover a rejected putBlob. Add a case where the upload URL request succeeds, mockPutBlob.mockRejectedValueOnce(...) is used, and ConfirmAvatar is not called.

🤖 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/web/src/__tests__/components/AccountPage.test.tsx` around lines 317 -
318, Update the “shows an error message when the upload fails” test to make
RequestAvatarUploadUrl succeed, then configure mockPutBlob with
mockRejectedValueOnce to simulate the Blob upload failure. Assert that the error
message is shown and ConfirmAvatar is not called, ensuring this case
specifically covers the rejected putBlob path.
apps/api/vercel.json (1)

9-12: 🩺 Stability & Availability | 🔵 Trivial

Verify the production plan supports the hourly schedule.

0 * * * * is not deployable on Vercel Hobby, which permits only daily cron jobs. Confirm this project is deployed on a plan supporting hourly cron jobs and verify both schedules appear on the production deployment. (vercel.com)

🤖 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/api/vercel.json` around lines 9 - 12, Verify that the production
deployment for the cron configuration supports hourly schedules, upgrading or
confirming the Vercel plan as needed. Validate the deployed production
configuration includes both the weekly /admin/digest/send schedule and the
hourly /admin/reminders/send schedule; do not change the cron expressions unless
required by the supported plan.
apps/api/src/http/plugins/reminders.plugin.ts (1)

25-32: 🩺 Stability & Availability | 🔵 Trivial

Make cron execution durable against duplicate, overlapping, and failed deliveries.

Vercel can overlap executions, occasionally redeliver a cron event, and does not retry failed cron jobs. Confirm these use cases atomically deduplicate a scheduled run and persist retryable failures; otherwise add a database-backed lease/idempotency record and monitoring. (vercel.com)

  • apps/api/src/http/plugins/reminders.plugin.ts#L25-L32: ensure each reminder window is sent at most once despite concurrent or duplicate triggers.
  • apps/api/src/http/plugins/digest.plugin.ts#L23-L30: ensure each weekly digest run is deduplicated and failed runs are recoverable.
🤖 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/api/src/http/plugins/reminders.plugin.ts` around lines 25 - 32, Make the
scheduled execution flows durable and idempotent: in
apps/api/src/http/plugins/reminders.plugin.ts lines 25-32, update the
sendFollowUpRemindersUseCase path so concurrent or duplicate triggers atomically
claim each reminder window and send it at most once; persist failures for retry
and recovery instead of relying only on the HTTP 500 response. Apply the same
deduplication and recoverable-failure behavior to the digest execution flow in
apps/api/src/http/plugins/digest.plugin.ts lines 23-30, using a database-backed
lease or idempotency record and monitoring where needed.
🤖 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/api/src/infrastructure/storage/VercelBlobStorageProvider.ts`:
- Around line 24-27: Update VercelBlobStorageProvider’s assertConfigured and
Blob SDK failure paths to throw or wrap errors with code SERVICE_UNAVAILABLE
before they reach the use-case boundary. Preserve the existing configuration
message and underlying failure details while ensuring fromCodedError maps both
missing-token and backend failures to 503.
- Around line 47-53: Update VercelBlobStorageProvider.delete to stop swallowing
deletion errors from del. Preserve an explicit not-found exception only if the
provider requires idempotent cleanup, and propagate all permission, network, and
service failures so callers receive a failed deletion result.
- Around line 41-44: Update VercelBlobStorageProvider.getSignedUrl to preserve
the ttlSeconds contract and private-read authorization: generate a signed,
expiring GET URL through the configured Blob SDK/signing flow, passing the
requested TTL, instead of returning head(key, { token }).url. Keep
assertConfigured and ensure the returned URL is scoped to the requested key and
expiration.
- Around line 30-37: Update getPresignedUploadUrl to pass the configured
upload-size limit to generateClientTokenFromReadWriteToken, selecting 10 MB for
document uploads and 5 MB for avatar uploads based on the existing upload-path
distinction. Keep the limits aligned with the confirmation checks and preserve
the current token settings.

---

Nitpick comments:
In `@apps/api/src/http/plugins/reminders.plugin.ts`:
- Around line 25-32: Make the scheduled execution flows durable and idempotent:
in apps/api/src/http/plugins/reminders.plugin.ts lines 25-32, update the
sendFollowUpRemindersUseCase path so concurrent or duplicate triggers atomically
claim each reminder window and send it at most once; persist failures for retry
and recovery instead of relying only on the HTTP 500 response. Apply the same
deduplication and recoverable-failure behavior to the digest execution flow in
apps/api/src/http/plugins/digest.plugin.ts lines 23-30, using a database-backed
lease or idempotency record and monitoring where needed.

In `@apps/api/vercel.json`:
- Around line 9-12: Verify that the production deployment for the cron
configuration supports hourly schedules, upgrading or confirming the Vercel plan
as needed. Validate the deployed production configuration includes both the
weekly /admin/digest/send schedule and the hourly /admin/reminders/send
schedule; do not change the cron expressions unless required by the supported
plan.

In `@apps/web/src/__tests__/components/AccountPage.test.tsx`:
- Around line 317-318: Update the “shows an error message when the upload fails”
test to make RequestAvatarUploadUrl succeed, then configure mockPutBlob with
mockRejectedValueOnce to simulate the Blob upload failure. Assert that the error
message is shown and ConfirmAvatar is not called, ensuring this case
specifically covers the rejected putBlob path.
🪄 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: CHILL

Plan: Pro Plus

Run ID: c5ce00fa-0694-45af-bb56-4a7512262e0a

📥 Commits

Reviewing files that changed from the base of the PR and between a1681ab and e11be19.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • .gitignore
  • apps/api/api/index.ts
  • apps/api/package.json
  • apps/api/src/constants.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/errors/AppError.ts
  • apps/api/src/http/plugins/cronAuth.ts
  • apps/api/src/http/plugins/digest.plugin.ts
  • apps/api/src/http/plugins/reminders.plugin.ts
  • apps/api/src/infrastructure/storage/VercelBlobStorageProvider.ts
  • apps/api/vercel.json
  • apps/web/package.json
  • apps/web/src/__tests__/components/AccountPage.test.tsx
  • apps/web/src/routes/_authenticated/account.tsx
  • apps/web/src/routes/_authenticated/applications/$applicationId/index.tsx

Comment on lines +24 to +27
private assertConfigured(): void {
if (!this.token) {
throw new Error(`${ENV.BLOB_READ_WRITE_TOKEN} is not configured`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Map storage configuration and backend failures to SERVICE_UNAVAILABLE.

This provider throws plain errors, while fromCodedError only emits a 503 when err.code is SERVICE_UNAVAILABLE. Missing Blob configuration and raw Blob SDK failures therefore become generic 500s, bypassing the newly added storage-failure contract. Wrap these failures with that code before they cross the use-case boundary.

🤖 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/api/src/infrastructure/storage/VercelBlobStorageProvider.ts` around
lines 24 - 27, Update VercelBlobStorageProvider’s assertConfigured and Blob SDK
failure paths to throw or wrap errors with code SERVICE_UNAVAILABLE before they
reach the use-case boundary. Preserve the existing configuration message and
underlying failure details while ensuring fromCodedError maps both missing-token
and backend failures to 503.

Comment on lines +30 to +37
async getPresignedUploadUrl(key: string, mimeType: string, ttlSeconds = 300): Promise<string> {
this.assertConfigured();
return generateClientTokenFromReadWriteToken({
token: this.token,
pathname: key,
allowedContentTypes: [mimeType],
addRandomSuffix: false,
validUntil: Date.now() + ttlSeconds * 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files matching storage/provider/usecase =="
fd -a 'VercelBlobStorageProvider|Storage|storage|use.*Case|Blob' . | sed 's#^\./##' | head -200

echo "== git diff stat/name =="
git diff --stat || true
git diff --name-only || true

echo "== target file outline and contents =="
target=$(fd -a 'VercelBlobStorageProvider.ts' . | head -1 || true)
echo "target=$target"
if [ -n "$target" ]; then
  wc -l "$target"
  cat -n "$target"
fi

echo "== search for maximumSizeInBytes / document size / 10 MB / getPresignedUploadUrl =="
rg -n "maximumSizeInBytes|getPresignedUploadUrl|document.*cap|10|MB|10000000|storage cap|size" . -g '*.ts' -g '*.graphql' -g '*.md' || true

echo "== package vercel blob version if present =="
for f in package.json apps/api/package.json pnpm-lock.yaml package-lock.json yarn.lock; do
  [ -f "$f" ] && echo "--- $f" && rg -n "`@vercel/blob`|maximumSizeInBytes|vercel blob|Blob" "$f" || true
done

Repository: mankatcheung/job-finder

Length of output: 39685


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target=$(fd -a 'VercelBlobStorageProvider.ts' . | head -1 || true)

echo "== target package declarations for `@vercel/blob` =="
fd -a 'package*.json' . | xargs rg -n '"`@vercel/blob`"' || true

echo "== package-lock files mentioning `@vercel/blob` resolved and `@vercel/blob/client` generation =="
for lock in apps/api/pnpm-lock.yaml pnpm-lock.yaml package-lock.json apps/api/package-lock.json; do
  if [ -f "$lock" ]; then
    echo "--- $lock"
    rg -n "`@vercel/blob`|node_modules/@vercel/blob/client" "$lock" || true
  fi
done

echo "== inspect dependency metadata from lock files for `@vercel/blob` resolution =="
python3 - <<'PY'
import pathlib, re
for lock in ["apps/api/pnpm-lock.yaml", "pnpm-lock.yaml", "apps/api/package-lock.json"]:
    p = pathlib.Path(lock)
    if not p.exists():
        continue
    print(f"--- {lock}")
    raw = p.read_text()
    if lock.endswith("pnpm-lock.yaml"):
        m = re.search(r"^  '`@vercel/blob`@2\.6\.1'?:[\s\S]{0,2000}", raw)
        print(m.group(0)[:3000] if m else "not found")
    elif lock.endswith("package-lock.json"):
        try:
            import json
            data=json.load(p.open())
            pkg=data.get("packages",{}).get("node_modules/@vercel/blob")
            print(pkg)
        except Exception as e:
            print(e)
PY

echo "== inspect `@vercel/blob` package files from lock cache is not possible; use npm metadata only if present =="
node - <<'JS'
try {
  const https = require('https')
  const pkg='`@vercel`%2Fblob'
  https.get('https://registry.npmjs.org/' + pkg + '/latest', r => {
    console.log(r.statusCode, r.headers['content-type'])
    let bytes=''
    r.on('data', d => bytes += d)
    r.on('end', () => {
      const data=JSON.parse(bytes)
      console.log(Object.keys(data), {version:data.version, distTags:data['dist-tags']})
    })
  }).on('error', e => console.log('npm metadata error', e.message))
} catch (e) {
  console.log('node fetch unavailable', e.message)
}
JS

Repository: mankatcheung/job-finder

Length of output: 1337


🌐 Web query:

Vercel Blob generateClientTokenFromReadWriteToken maximumSizeInBytes documentation

💡 Result:

In the Vercel Blob SDK, the maximumSizeInBytes property used within the generateClientTokenFromReadWriteToken function is an optional parameter that specifies the maximum allowed size in bytes for a blob upload [1][2]. Key details include: - Functionality: It enforces a limit on the size of the file that can be uploaded using the generated client token [1][2]. - Maximum Limit: The absolute maximum value supported for this limit is 5TB [1][3][4]. - Implementation: This is a server-side method intended to be called within a secure environment to generate a temporary token for client-side uploads [1][2]. By setting this property, you can restrict the size of files a client is permitted to upload to your Blob store [1][2].

Citations:


🌐 Web query:

@vercel/blob @vercel/blob@2.6.1 generateClientTokenFromReadWriteToken maximumSizeInBytes

💡 Result:

In the @vercel/blob SDK, including version 2.6.1, the maximumSizeInBytes parameter used within generateClientTokenFromReadWriteToken is an optional number that specifies the maximum size in bytes allowed for a client-side upload [1][2]. The maximum value permitted for this parameter is 5TB (5 terabytes) [1][3]. This parameter is a key component of the secure client-side upload workflow, allowing developers to restrict the size of files uploaded directly from the browser to Vercel Blob [1][4]. When generating a client token, you can define this limit to ensure that uploads adhere to your specific application constraints while taking advantage of the large file capacity supported by the Vercel Blob client-upload mechanism [1][3][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  apps/api/src/use-cases/ports/IStorageProvider.ts \
  apps/api/src/use-cases/ports/IAuthRepository.ts \
  apps/api/src/use-cases/documents/IRequestUploadUrlUseCase.ts \
  apps/api/src/use-cases/documents/RequestUploadUrlUseCase.ts \
  apps/api/src/use-cases/user/IRequestAvatarUploadUrlUseCase.ts \
  apps/api/src/use-cases/user/RequestAvatarUploadUrlUseCase.ts \
  apps/api/src/use-cases/documents/documentValidation.ts \
  apps/api/src/use-cases/user/avatarValidation.ts
do
  if [ -f "$f" ]; then
    echo "--- $f"
    cat -n "$f"
  fi
done

echo "== API lock entries for `@vercel/blob` =="
sed -n '3270,3315p' pnpm-lock.yaml || true
sed -n '90,95p' pnpm-lock.yaml || true
sed -n '250,258p' pnpm-lock.yaml || true

Repository: mankatcheung/job-finder

Length of output: 9863


🌐 Web query:

docs @vercel/blob 2.6.1 GenerateClientTokenFromReadWriteTokenOptions maximumSizeInBytes

💡 Result:

In the @vercel/blob SDK version 2.6.1, the maximumSizeInBytes property is an optional configuration option used when generating a client token [1]. It is a member of the GenerateClientTokenOptions interface, which is the type used for the options object passed to functions like generateClientTokenFromReadWriteToken [1]. Key details about maximumSizeInBytes: - Purpose: It specifies the maximum size in bytes allowed for a single file upload [1][2]. - Limit: The maximum value that can be specified is 5TB [1][2]. - Usage: When provided, it restricts the client to uploading files no larger than the defined byte count [1]. This property is part of the security configuration for client-side uploads, ensuring that users can only upload files within a specified size threshold [1][3].

Citations:


Enforce upload sizes in the Vercel Blob client token.

The document cap and avatar cap are only checked back at confirmation, not enforced on the direct-upload token. Add the relevant configured size limit to generateClientTokenFromReadWriteToken for the two upload paths: 10 MB for documents and 5 MB for avatars.

🤖 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/api/src/infrastructure/storage/VercelBlobStorageProvider.ts` around
lines 30 - 37, Update getPresignedUploadUrl to pass the configured upload-size
limit to generateClientTokenFromReadWriteToken, selecting 10 MB for document
uploads and 5 MB for avatar uploads based on the existing upload-path
distinction. Keep the limits aligned with the confirmation checks and preserve
the current token settings.

Comment on lines +41 to +44
async getSignedUrl(key: string): Promise<string> {
this.assertConfigured();
const blob = await head(key, { token: this.token });
return blob.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.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'VercelBlobStorageProvider|storage|blob' || true

echo "== provider outline =="
ast-grep outline apps/api/src/infrastructure/storage/VercelBlobStorageProvider.ts || true

echo "== provider contents =="
cat -n apps/api/src/infrastructure/storage/VercelBlobStorageProvider.ts

echo "== storage interfaces/usages =="
rg -n "getSignedUrl|signedUrl|StorageProvider|ttlSeconds|StorageProviderInterface" apps/api/src -S || true

Repository: mankatcheung/job-finder

Length of output: 22029


🌐 Web query:

Vercel Blob signing URL ttlSeconds getSignedUrl private public read behavior

💡 Result:

In Vercel Blob, signed URLs provide a mechanism to grant time-limited, scoped access to specific objects without exposing long-lived credentials [1][2]. Expiration Control Instead of a ttlSeconds parameter, Vercel Blob uses a validUntil timestamp to define expiration [3][4][1]. This value represents a point in time in milliseconds since the epoch [3]. When generating a signed URL, the expiration is capped by the validity period defined in the underlying delegation token [3]. You can set an expiry up to 7 days in the future [1][5][2]. Private vs. Public Behavior The behavior of signed URLs is intrinsically linked to the access mode of your Blob store, which is immutable once created [6]: 1. Private Storage: All read and write operations require authentication [7][8]. Signed URLs act as a secure, temporary gateway, allowing users to perform specific operations (e.g., GET, PUT, DELETE) on a specific file without the server remaining in the data path [2]. This is ideal for sensitive user content or authenticated downloads [7]. 2. Public Storage: Files are accessible to anyone with the URL [6][9]. While public blobs do not strictly require signing for read access, signed URLs can still be used to restrict or manage access for specific operations (like PUT or DELETE) on a per-object basis [1][5]. Caching Behavior By default, GET requests made via signed URLs are served through Vercel's CDN cache [3]. If you need to ensure a request retrieves the latest version (bypassing the cache), you can use the useCache: false option when generating the presigned URL [10]. Note that this cache bypass is specifically supported for private blobs; for public blobs, the parameter is ignored because public storage relies on standard CDN/browser caching mechanisms [10][6][9]. Summary of Key Parameters - validUntil: Mandatory for setting the URL expiration time (milliseconds since epoch) [3][1]. - operation: Required to specify the allowed action (e.g., 'get', 'put', 'head', 'delete') [3][1]. - access: Must match the store's access mode ('public' or 'private') [3][11]. - useCache: An optional parameter (default: true) used to control whether to bypass the CDN cache for 'get' operations [10].

Citations:


🌐 Web query:

Vercel SDK @vercel/blob getSignedUrl VercelSignatureGenerateError

💡 Result:

A VercelSignatureGenerateError in the @vercel/blob SDK typically indicates a failure during the cryptographic signing process when generating a token or presigned URL [1][2]. Because this error relates to the internal generation of signatures, it is almost always caused by missing, incorrect, or inaccessible environment variables required for Vercel Blob authentication [3]. To resolve this error, ensure the following steps are taken: 1. Verify Environment Variables: Ensure that your project has the necessary environment variables configured for Blob storage, specifically BLOB_READ_WRITE_TOKEN [3][4]. If you are using OIDC (the default on Vercel), ensure that your Blob store is correctly connected to your project within the Vercel dashboard [3]. 2. Check Token Availability: The SDK uses these credentials to sign requests [3]. If you are running code in an environment where these variables are not automatically injected (e.g., local development or a non-Vercel environment), you must ensure they are explicitly provided via your.env file or alternative configuration [3]. 3. Update the SDK: Ensure you are using the latest version of @vercel/blob (version 2.4.0 or higher is required for robust signed URL support) [2]. 4. Contextual Usage: If you are using handleUploadPresigned or issueSignedToken, confirm that you are passing the required parameters (such as the pathname and valid operations) correctly [1][2]. If the error persists, it may indicate an issue with the underlying Blob store state (e.g., the store being suspended or blocked) [5]. Check your Vercel Blob dashboard for any suspension banners or limit notifications [5]. If your store is in good standing but the error continues, reaching out to Vercel Support with your store ID and the exact error details is recommended [5].

Citations:


Preserve expiring, signed GET semantics.

getSignedUrl drops the ttlSeconds contract and returns the result of head(key, { token }); Vercel Blob read access is private when Blob reads require auth, while exposing raw URLs can also leak long-lived links. Generate/expiring-scoped signed GET URLs for private reads, or proxy private reads behind this method after application authorisation, and pass the TTL into the configured Blob SDK/signing flow.

🤖 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/api/src/infrastructure/storage/VercelBlobStorageProvider.ts` around
lines 41 - 44, Update VercelBlobStorageProvider.getSignedUrl to preserve the
ttlSeconds contract and private-read authorization: generate a signed, expiring
GET URL through the configured Blob SDK/signing flow, passing the requested TTL,
instead of returning head(key, { token }).url. Keep assertConfigured and ensure
the returned URL is scoped to the requested key and expiration.

Comment on lines +47 to +53
async delete(key: string): Promise<void> {
if (!this.token) return;
try {
await del(key, { token: this.token });
} catch {
// Best-effort cleanup — matches LocalStorageProvider/GCSStorageProvider.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not suppress failed remote deletions.

This catches permission, network, and service failures, so a document or account deletion can report success while the Blob remains retained. The existing GCS provider propagates deletion failures; retain only an explicit not-found exception if needed and surface/retry all other failures.

🤖 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/api/src/infrastructure/storage/VercelBlobStorageProvider.ts` around
lines 47 - 53, Update VercelBlobStorageProvider.delete to stop swallowing
deletion errors from del. Preserve an explicit not-found exception only if the
provider requires idempotent cleanup, and propagate all permission, network, and
service failures so callers receive a failed deletion result.

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.

1 participant