feat: add Cloudflare Worker backend under cloudflare-worker/#111
feat: add Cloudflare Worker backend under cloudflare-worker/#111support371 wants to merge 2 commits into
Conversation
- Hono-based API with production endpoints: health, ready, version, auth/session validation, RBAC, KYC service hooks, document vault (R2), service requests, audit logging, and notifications - D1 migration for operational tables (audit_logs, kyc_events, document_vault, service_requests, notifications) - R2 integration for secure document storage with upload/download - KV namespace for caching - JWT auth middleware sharing secret with Vercel frontend - CORS middleware with configurable origin whitelist - OpenAPI 3.1 spec - Isolated tsconfig.json to avoid breaking Vercel builds - Root tsconfig.json and .vercelignore updated to exclude cloudflare-worker/ - Documentation: README, deployment guide, operational runbook - Wrangler config with production/staging/dev environments Note: GitHub Actions workflow (.github/workflows/cloudflare-worker.yml) must be added manually via GitHub web UI due to OAuth scope limitation. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Reviewer's GuideIntroduces a fully isolated Cloudflare Worker backend under cloudflare-worker/ using Hono, D1, R2, and KV, wires up auth/RBAC/KYC/documents/service-requests/audit/notifications routes with shared JWT auth, defines the D1 schema and Wrangler bindings, and adjusts root config so the existing Vercel Next.js frontend build remains unaffected. Sequence diagram for authenticated document download via Cloudflare WorkersequenceDiagram
actor User
participant Frontend as Vercel_frontend
participant Worker as Cloudflare_Worker
participant AuthMW as auth_middleware
participant D1 as D1_DB
participant R2 as R2_VAULT
participant Audit as audit_helper
User->>Frontend: Click_download_on_document(documentId)
Frontend->>Worker: GET /api/documents/documentId/download\nAuthorization: Bearer_JWT
Worker->>AuthMW: validate_JWT_and_attach_session
AuthMW->>Worker: session(userId,role)
Worker->>D1: SELECT r2_key,file_name,user_id\nFROM document_vault\nWHERE id = documentId
D1-->>Worker: r2_key,file_name,user_id
Worker->>Worker: check_owner_or_admin(session.role,session.userId)
Worker->>R2: get(r2_key)
R2-->>Worker: object_body,httpMetadata
Worker->>Audit: emitAuditLog(document_download)
Audit->>D1: INSERT INTO audit_logs(...)
D1-->>Audit: ok
Worker-->>Frontend: 200 Response\nContent_Type,Content_Disposition
Frontend-->>User: Browser_downloads_file
ER diagram for new D1 schemaerDiagram
audit_logs {
TEXT id PK
TEXT user_id
TEXT action
TEXT resource
TEXT resource_id
TEXT metadata
TEXT ip_address
TEXT user_agent
TEXT created_at
}
kyc_events {
TEXT id PK
TEXT application_id
TEXT user_id
TEXT event
TEXT status
TEXT metadata
TEXT created_at
}
document_vault {
TEXT id PK
TEXT user_id
TEXT r2_key
TEXT file_name
TEXT file_type
INTEGER file_size
TEXT category
TEXT deleted_at
TEXT created_at
}
service_requests {
TEXT id PK
TEXT user_id
TEXT title
TEXT description
TEXT priority
TEXT status
TEXT assigned_to
TEXT resolution
TEXT metadata
TEXT created_at
TEXT updated_at
}
notifications {
TEXT id PK
TEXT user_id
TEXT title
TEXT message
TEXT channel
INTEGER read
TEXT metadata
TEXT created_at
}
Class diagram for core Worker types and API modelsclassDiagram
class Env {
<<interface>>
DB D1Database
VAULT R2Bucket
CACHE KVNamespace
NOTIFICATION_QUEUE Queue~NotificationPayload~
JWT_SECRET string
CLOUDFLARE_API_TOKEN string
CLOUDFLARE_ACCOUNT_ID string
CLOUDFLARE_ZONE_ID string
ENVIRONMENT string
APP_NAME string
APP_VERSION string
FRONTEND_URL string
CORS_ORIGINS string
}
class NotificationPayload {
userId string
channel string
title string
message string
metadata Record~string,unknown~
}
class SessionPayload {
userId string
email string
role UserRole
kycStatus string
kycApplicationId string
entitlements string[]
portfolioId string
organizationId string
iat number
exp number
}
class UserRole {
<<type alias>>
client
analyst
admin
super_admin
internal
}
class ApiResponse_T_ {
<<interface>>
success boolean
data T
error string
timestamp string
}
class PaginatedResponse_T_ {
<<interface>>
success boolean
data T[]
timestamp string
page number
pageSize number
total number
totalPages number
}
class HealthResponse {
status string
timestamp string
version string
environment string
d1 string
r2 string
kv string
}
class ReadyResponse {
ready boolean
database boolean
storage boolean
cache boolean
secrets boolean
}
class VersionResponse {
version string
environment string
appName string
buildDate string
compatibilityDate string
}
class AuditLogEntry {
id string
userId string
action string
resource string
resourceId string
metadata string
ipAddress string
userAgent string
createdAt string
}
class ServiceRequestEntry {
id string
userId string
title string
description string
priority string
status string
assignedTo string
metadata string
createdAt string
updatedAt string
}
class NotificationEntry {
id string
userId string
title string
message string
channel string
read boolean
metadata string
createdAt string
}
Env --> NotificationPayload
Env --> SessionPayload
SessionPayload --> UserRole
ApiResponse_T_ <|-- PaginatedResponse_T_
AuditLogEntry --> SessionPayload
ServiceRequestEntry --> SessionPayload
NotificationEntry --> SessionPayload
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- The
document_vaultsoft-delete behavior setsdeleted_aton DELETE but the list (GET /api/documents) and download (GET /api/documents/:id/download) queries don’t filter ondeleted_at IS NULL, so deleted documents will still appear and be downloadable; consider excluding soft-deleted rows in those queries. - The RBAC role assignment endpoint (
POST /api/rbac/assign) only writes an audit log and does not persist the new role to any backing store, which means assigned roles will not actually take effect; either wire this into the real user/role storage or clearly treat it as a stub. - In the CORS middleware, consider adding a
Vary: Originheader on responses when settingAccess-Control-Allow-Originso that caches don’t incorrectly reuse responses across different origins.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `document_vault` soft-delete behavior sets `deleted_at` on DELETE but the list (`GET /api/documents`) and download (`GET /api/documents/:id/download`) queries don’t filter on `deleted_at IS NULL`, so deleted documents will still appear and be downloadable; consider excluding soft-deleted rows in those queries.
- The RBAC role assignment endpoint (`POST /api/rbac/assign`) only writes an audit log and does not persist the new role to any backing store, which means assigned roles will not actually take effect; either wire this into the real user/role storage or clearly treat it as a stub.
- In the CORS middleware, consider adding a `Vary: Origin` header on responses when setting `Access-Control-Allow-Origin` so that caches don’t incorrectly reuse responses across different origins.
## Individual Comments
### Comment 1
<location path="cloudflare-worker/src/routes/documents.ts" line_range="93-102" />
<code_context>
+ const pageSize = parseInt(c.req.query("pageSize") ?? "20", 10);
+ const offset = (page - 1) * pageSize;
+
+ let countQuery = "SELECT COUNT(*) as total FROM document_vault WHERE user_id = ?";
+ let dataQuery =
+ "SELECT id, file_name, file_type, file_size, category, created_at FROM document_vault WHERE user_id = ?";
+ const params: (string | number)[] = [session.userId];
+
+ if (category) {
+ countQuery += " AND category = ?";
+ dataQuery += " AND category = ?";
+ params.push(category);
+ }
+
+ dataQuery += " ORDER BY created_at DESC LIMIT ? OFFSET ?";
+
+ const countResult = await c.env.DB.prepare(countQuery).bind(...params).first<{ total: number }>();
</code_context>
<issue_to_address>
**issue (bug_risk):** Soft-deleted documents are still included in listings
The `document_vault` table uses `deleted_at` for soft deletes, and the DELETE handler sets this field, but this list query doesn’t filter on it. This means soft-deleted documents will still appear in `/api/documents`. Please filter both the count and data queries with `AND deleted_at IS NULL` (and apply the same condition to any other read paths that should hide deleted documents).
</issue_to_address>
### Comment 2
<location path="cloudflare-worker/src/routes/documents.ts" line_range="142-151" />
<code_context>
+ return c.json({ success: false, error: "Forbidden", timestamp: new Date().toISOString() }, 403);
+ }
+
+ const object = await c.env.VAULT.get(doc.r2_key);
+ if (!object) {
+ return c.json({ success: false, error: "File not found in storage", timestamp: new Date().toISOString() }, 404);
+ }
+
+ await emitAuditLog({
+ db: c.env.DB,
+ userId: session.userId,
+ action: "document_download",
+ resource: "document_vault",
+ resourceId: documentId,
+ ipAddress: getClientIp(c.req.raw),
+ userAgent: c.req.header("User-Agent"),
+ });
+
+ return new Response(object.body, {
+ headers: {
+ "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
</code_context>
<issue_to_address>
**issue (bug_risk):** Download endpoint allows access to soft-deleted documents
The DELETE route only sets `deleted_at`, but the download handler only checks existence and ownership/role and ignores `deleted_at`, so soft-deleted documents can still be downloaded. If soft-delete is supposed to revoke access, ensure downloads filter on `deleted_at IS NULL` or explicitly return 404/410 when `deleted_at` is set.
</issue_to_address>
### Comment 3
<location path="cloudflare-worker/src/routes/rbac.ts" line_range="114-123" />
<code_context>
+ role: z.enum(["client", "analyst", "admin"]),
+});
+
+// POST /api/rbac/assign — assign a role to a user (admin+, cannot escalate to super_admin/internal)
+rbac.post("/assign", requireRole("admin", "super_admin", "internal"), async (c) => {
+ const body = assignRoleSchema.safeParse(await c.req.json());
+ if (!body.success) {
+ return c.json({ success: false, error: "Invalid request body", timestamp: new Date().toISOString() }, 400);
+ }
+
+ const session = getSession(c);
+
+ await emitAuditLog({
+ db: c.env.DB,
+ userId: session.userId,
</code_context>
<issue_to_address>
**question (bug_risk):** RBAC role assignment endpoint does not persist role changes
This route validates the request and writes an audit log but never updates the user’s role (in D1 or any external system). Given its name and comment, callers will expect it to actually change roles. If role changes are handled elsewhere (e.g. IdP or another service), either invoke that integration here or rename/repurpose this endpoint so it’s clearly non-mutating.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| let countQuery = "SELECT COUNT(*) as total FROM document_vault WHERE user_id = ?"; | ||
| let dataQuery = | ||
| "SELECT id, file_name, file_type, file_size, category, created_at FROM document_vault WHERE user_id = ?"; | ||
| const params: (string | number)[] = [session.userId]; | ||
|
|
||
| if (category) { | ||
| countQuery += " AND category = ?"; | ||
| dataQuery += " AND category = ?"; | ||
| params.push(category); | ||
| } |
There was a problem hiding this comment.
issue (bug_risk): Soft-deleted documents are still included in listings
The document_vault table uses deleted_at for soft deletes, and the DELETE handler sets this field, but this list query doesn’t filter on it. This means soft-deleted documents will still appear in /api/documents. Please filter both the count and data queries with AND deleted_at IS NULL (and apply the same condition to any other read paths that should hide deleted documents).
| const object = await c.env.VAULT.get(doc.r2_key); | ||
| if (!object) { | ||
| return c.json({ success: false, error: "File not found in storage", timestamp: new Date().toISOString() }, 404); | ||
| } | ||
|
|
||
| await emitAuditLog({ | ||
| db: c.env.DB, | ||
| userId: session.userId, | ||
| action: "document_download", | ||
| resource: "document_vault", |
There was a problem hiding this comment.
issue (bug_risk): Download endpoint allows access to soft-deleted documents
The DELETE route only sets deleted_at, but the download handler only checks existence and ownership/role and ignores deleted_at, so soft-deleted documents can still be downloaded. If soft-delete is supposed to revoke access, ensure downloads filter on deleted_at IS NULL or explicitly return 404/410 when deleted_at is set.
| // POST /api/rbac/assign — assign a role to a user (admin+, cannot escalate to super_admin/internal) | ||
| rbac.post("/assign", requireRole("admin", "super_admin", "internal"), async (c) => { | ||
| const body = assignRoleSchema.safeParse(await c.req.json()); | ||
| if (!body.success) { | ||
| return c.json({ success: false, error: "Invalid request body", timestamp: new Date().toISOString() }, 400); | ||
| } | ||
|
|
||
| const session = getSession(c); | ||
|
|
||
| await emitAuditLog({ |
There was a problem hiding this comment.
question (bug_risk): RBAC role assignment endpoint does not persist role changes
This route validates the request and writes an audit log but never updates the user’s role (in D1 or any external system). Given its name and comment, callers will expect it to actually change roles. If role changes are handled elsewhere (e.g. IdP or another service), either invoke that integration here or rename/repurpose this endpoint so it’s clearly non-mutating.
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
gem-enterprise | 93e7887 | May 11 2026, 12:09 AM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2276b96ef1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const session = getSession(c); | ||
|
|
||
| const doc = await c.env.DB.prepare( | ||
| "SELECT r2_key, file_name, user_id FROM document_vault WHERE id = ?", |
There was a problem hiding this comment.
Exclude soft-deleted docs from download lookup
This lookup does not filter on deleted_at, so a document soft-deleted via DELETE /api/documents/:id remains retrievable as long as the caller knows the ID (and is owner/admin). Because deletion only sets deleted_at and never removes the R2 object, this makes soft-delete ineffective for access control and retention expectations.
Useful? React with 👍 / 👎.
| await emitAuditLog({ | ||
| db: c.env.DB, | ||
| userId: session.userId, | ||
| action: "role_change", | ||
| resource: "user", |
There was a problem hiding this comment.
Persist role changes before returning success
This handler logs a role_change event and returns success: true, but it never updates any user-role record in storage. In practice, POST /api/rbac/assign reports a successful assignment that does not actually change authorization state, which can lead admins to believe access was changed when it was not.
Useful? React with 👍 / 👎.
|
|
||
| // POST /api/service-requests — create a new service request | ||
| serviceRequests.post("/", async (c) => { | ||
| const body = createRequestSchema.safeParse(await c.req.json()); |
There was a problem hiding this comment.
Guard JSON parsing errors before schema validation
await c.req.json() can throw on malformed or empty JSON bodies, so this line may bypass safeParse and fall into the global error handler, returning a 500 instead of the intended 400 validation response. Any client sending invalid JSON to this endpoint will get an internal-server-error path rather than a request-validation error.
Useful? React with 👍 / 👎.
- VAULT and CACHE are now optional in Env interface - Health/ready endpoints report not_configured for missing bindings - Document upload/download returns 503 when R2 is not configured - Updated wrangler.toml with real D1 database ID - R2 and KV bindings commented out until resources are created Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Summary
Adds a production-grade Cloudflare Worker backend isolated under
cloudflare-worker/, keeping the Vercel Next.js frontend completely untouched. The Worker uses Hono as the framework and integrates with D1 (database), R2 (document storage), and KV (caching).What's included
Endpoints (28 routes):
GET /api/health,/api/ready,/api/version— operational healthPOST /api/auth/validate,GET /api/auth/session— JWT validation (shared secret with Vercel frontend)GET/POST /api/rbac/permissions|check|roles|assign— role-based access control (5 roles, prevents escalation to super_admin/internal)POST /api/kyc/webhook,GET /api/kyc/status/:id,GET /api/kyc/pending— KYC service hooksPOST /api/documents/upload,GET /api/documents,GET /api/documents/:id/download,DELETE /api/documents/:id— R2 document vaultGET/POST/PATCH /api/service-requests— service request managementGET /api/audit/logs,GET /api/audit/summary— audit log queries (admin+)GET/POST/PATCH /api/notifications+ bulk send — notification managementInfrastructure:
wrangler.tomlwith D1, R2, KV bindings and production/staging/dev environments0001_initial_schema.sql) with 5 tables and proper indexescloudflare-worker/openapi/spec.yamltsconfig.json— root tsconfig excludescloudflare-worker/Documentation:
cloudflare-worker/README.md— quick start, endpoint table, project structuredocs/CLOUDFLARE_BACKEND_DEPLOYMENT.md— step-by-step deployment guidedocs/BACKEND_RUNBOOK.md— operational runbook with incident responseVercel isolation:
tsconfig.json→ addedcloudflare-workertoexclude.vercelignore→ addedcloudflare-worker/anddocs/Not included (requires manual action)
.github/workflows/cloudflare-worker.yml) — OAuth token lacksworkflowscope; workflow file is generated locally and its content is included in the PR description below for manual addition via GitHub web UIwranglerCLI (see deployment guide)JWT_SECRET,CLOUDFLARE_API_TOKEN,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_ZONE_IDmust be set viawrangler secret putReview & Testing Checklist for Human
tsconfig.jsonexcludescloudflare-worker/so Next.js should not attempt to compile Worker TypeScript. Confirm by checking that the Vercel deployment still succeeds after mergeREPLACE_WITH_D1_DATABASE_IDandREPLACE_WITH_KV_NAMESPACE_IDwith real IDs after creating the Cloudflare resources.github/workflows/cloudflare-worker.ymlfrom the local branch or create it via GitHub web editor (content provided below)cd cloudflare-worker && pnpm install && pnpm run db:migrate && pnpm dev, thencurl http://localhost:8787/api/healthjoselibrary and HS256 algorithm as the Vercel frontend. Ensure the sameJWT_SECRETis configured in both environmentsRecommended test plan
cd cloudflare-worker && pnpm install && pnpm run db:migrate && pnpm devcurl http://localhost:8787/api/health→ expect{"status":"ok",...}curl http://localhost:8787/api/ready→ expect{"ready":true,...}or503if secrets not setcurl http://localhost:8787/api/version→ expect version JSONpnpm build && pnpm test && pnpm lintfrom repo root to confirm frontend is unaffectedNotes
GitHub Actions workflow content (add via GitHub web UI at
.github/workflows/cloudflare-worker.yml):Vercel frontend verification:
pnpm buildpasses (158 routes),pnpm testpasses (100/100),pnpm lintpasses (0 warnings). Thecloudflare-worker/directory typecheck also passes independently.Link to Devin session: https://app.devin.ai/sessions/8f3d4b175dd245dd9d4382b79083dac7
Requested by: @support371
Summary by Sourcery
Add a new Cloudflare Worker backend under a separate cloudflare-worker workspace while keeping the existing Vercel Next.js frontend build isolated.
New Features:
Enhancements:
Documentation: