ci: deploy api to Vercel with Vercel Blob storage - #101
Conversation
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).
WalkthroughThe 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. ChangesVercel runtime and Blob storage
Scheduled digest and reminder routes
Browser Blob uploads
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
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
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
apps/web/src/__tests__/components/AccountPage.test.tsx (1)
317-318: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover Blob upload failure separately.
This setup resolves
mockPutBlob, so the test still fails only duringRequestAvatarUploadUrl; it does not cover a rejectedputBlob. Add a case where the upload URL request succeeds,mockPutBlob.mockRejectedValueOnce(...)is used, andConfirmAvataris 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 | 🔵 TrivialVerify 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 | 🔵 TrivialMake 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
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (15)
.gitignoreapps/api/api/index.tsapps/api/package.jsonapps/api/src/constants.tsapps/api/src/http/container.tsapps/api/src/http/errors/AppError.tsapps/api/src/http/plugins/cronAuth.tsapps/api/src/http/plugins/digest.plugin.tsapps/api/src/http/plugins/reminders.plugin.tsapps/api/src/infrastructure/storage/VercelBlobStorageProvider.tsapps/api/vercel.jsonapps/web/package.jsonapps/web/src/__tests__/components/AccountPage.test.tsxapps/web/src/routes/_authenticated/account.tsxapps/web/src/routes/_authenticated/applications/$applicationId/index.tsx
| private assertConfigured(): void { | ||
| if (!this.token) { | ||
| throw new Error(`${ENV.BLOB_READ_WRITE_TOKEN} is not configured`); | ||
| } |
There was a problem hiding this comment.
🩺 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.
| 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, |
There was a problem hiding this comment.
🔒 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
doneRepository: 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)
}
JSRepository: 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:
- 1: https://github.com/vercel/storage/blob/f23cb891/packages/blob/src/client.ts
- 2: https://jspm-packages.deno.dev/package/@vercel/blob@0.11.0
- 3: https://vercel.com/docs/vercel-blob/using-blob-sdk.md
- 4: https://vercel.com/docs/vercel-blob/using-blob-sdk
🌐 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:
- 1: https://github.com/vercel/storage/blob/f23cb891/packages/blob/src/client.ts
- 2: https://jspm-packages.deno.dev/package/@vercel/blob@0.11.0
- 3: https://vercel.com/docs/vercel-blob/using-blob-sdk
- 4: https://www.npmjs.com/package/@vercel/blob
🏁 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 || trueRepository: 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:
- 1: https://github.com/vercel/storage/blob/f23cb891/packages/blob/src/client.ts
- 2: https://vercel.com/docs/vercel-blob/using-blob-sdk
- 3: https://jspm-packages.deno.dev/package/@vercel/blob@2.6.1
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.
| async getSignedUrl(key: string): Promise<string> { | ||
| this.assertConfigured(); | ||
| const blob = await head(key, { token: this.token }); | ||
| return blob.url; |
There was a problem hiding this comment.
🔒 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 || trueRepository: 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:
- 1: https://vercel.com/changelog/signed-urls-are-now-available-for-vercel-blob
- 2: https://vercel.com/changelog/vercel-private-blob-is-now-generally-available
- 3: https://vercel.com/docs/vercel-blob/vercel-signed-urls
- 4: vercel/storage@20eeaff
- 5: https://vercel.com/changelog/vercel-cli-now-supports-signing-blob-urls
- 6: https://vercel.com/docs/vercel-blob
- 7: https://vercel.com/docs/vercel-blob/private-storage
- 8: https://vercel.com/docs/vercel-blob/security
- 9: https://vercel.com/docs/vercel-blob/public-storage
- 10: [@vercel/blob] Add useCache option to presigned get URLs vercel/storage#1084
- 11: https://vercel.com/docs/vercel-blob/using-blob-sdk
🌐 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:
- 1: https://vercel.com/docs/vercel-blob/vercel-signed-urls
- 2: https://vercel.com/changelog/signed-urls-are-now-available-for-vercel-blob
- 3: https://vercel.com/docs/vercel-blob/using-blob-sdk
- 4: https://tessl.io/registry/tessl/npm-vercel--blob/1.1.0
- 5: https://community.vercel.com/t/blob-store-blocked-despite-usage-under-hobby-plan-limits-please-reactivate/44059
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.
| async delete(key: string): Promise<void> { | ||
| if (!this.token) return; | ||
| try { | ||
| await del(key, { token: this.token }); | ||
| } catch { | ||
| // Best-effort cleanup — matches LocalStorageProvider/GCSStorageProvider. | ||
| } |
There was a problem hiding this comment.
🔒 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.
Summary
Moves
apps/apioff 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 viaapp.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: 60for LLM-calling routes;cronsentries for digest + reminders.reminders.plugin.ts: the in-processsetIntervalpoll can't survive serverless (the process doesn't stay alive between requests) — converted to an external-trigger route mirroring the existingdigest.plugin.tspattern, driven by Vercel Cron instead.GET(Vercel Cron only issues GET) and authorize via either their own admin secret orCRON_SECRET— Vercel's reserved env var name, auto-injected asAuthorization: Bearer $CRON_SECRETon cron invocations. This is the only way to keep the secret out of the committedvercel.json, since Cron can't send custom headers.Storage: GCS → Vercel Blob
VercelBlobStorageProvider(STORAGE_PROVIDER=vercel-blob) implements the existingIStorageProviderport using@vercel/blob'sput/head/del.getPresignedUploadUrlreturns a short-lived client token (generateClientTokenFromReadWriteToken) instead of a URL. The web app's document/avatar upload now callsput()from@vercel/blob/clientwith 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.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)
apps/api, attach a Blob store (setsBLOB_READ_WRITE_TOKENautomatically).CRON_SECRET(generate one), plus everything inapps/api/.env.production(gitignored, not in this PR).0 * * * *), matching the interval it replaces. Needs a Pro plan, or lower the frequency invercel.json.functions.maxDuration: 60invercel.jsonalso needs a plan tier that allows it.Test plan
pnpm --filter @job-finder/api typecheck/build/test(739 passed) /lint/format:checkpnpm --filter @job-finder/web typecheck/build/test(144 passed, including an updated avatar-upload test asserting the new@vercel/blob/clientcall) /lint/format:check@vercel/blob's actual shipped.d.ts(v2.6.1) forput/head/del/generateClientTokenFromReadWriteTokensignatures rather than relying on memoryapp.server.emit('request', ...)serverless-bridge pattern and the Cron GET-only assumption as the two things most worth a real smoke test once deployedSummary by CodeRabbit
New Features
Bug Fixes