Skip to content

295 backend typescript migration#296

Merged
InfinityBowman merged 12 commits into
mainfrom
295-backend-typescript-migration
Jan 14, 2026
Merged

295 backend typescript migration#296
InfinityBowman merged 12 commits into
mainfrom
295-backend-typescript-migration

Conversation

@InfinityBowman
Copy link
Copy Markdown
Owner

@InfinityBowman InfinityBowman commented Jan 14, 2026

Summary by CodeRabbit

  • Chores
    • Completed comprehensive TypeScript migration with enhanced type safety throughout the codebase.
    • Improved error handling and structured logging for better observability and debugging.
    • Refactored internal architecture for improved code organization and maintainability.

✏️ Tip: You can customize this high-level summary in your review settings.

@InfinityBowman InfinityBowman linked an issue Jan 14, 2026 that may be closed by this pull request
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 14, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

📝 Walkthrough

Walkthrough

This pull request comprehensively migrates the backend workers codebase from JavaScript to TypeScript, introducing strong type safety across authentication, database access, middleware, routes, and utility functions. The effort includes removing planning documentation, adding type definitions for environment and application context, establishing type-first configuration constants, and refactoring all middleware and route handlers with explicit type annotations and error handling patterns.

Changes

Cohort / File(s) Summary
ESLint Configuration
eslint.config.js
Added Cloudflare Workers globals (Cloudflare, D1Database, DurableObject, R2Bucket, KVNamespace, ExecutionContext, ScheduledController, Env) to readonly globals in two worker config blocks.
Documentation Removal
packages/docs/audits/centralized-authorization-plan.md, packages/docs/audits/command-pattern-implementation-plan.md
Removed planning documents describing proposed authorization and command pattern architectures (~1700 lines total).
TypeScript Configuration & Project Setup
packages/workers/jsconfig.json, packages/workers/tsconfig.json, packages/workers/package.json, packages/workers/vitest.config.ts, packages/workers/drizzle.config.ts
Removed jsconfig.json; added tsconfig.json with strict TypeScript settings; added typecheck script and typescript dependency; updated test include pattern to .ts; updated drizzle schema path from .js to .ts.
Type System Foundation
packages/workers/src/types/env.ts, packages/workers/src/types/context.ts, packages/workers/src/types/index.ts
Established new type modules exporting Env, AuthUser, AuthSession, OrgContext, ProjectContext, OrgBilling, AppVariables, and AppContext for consistent typing across the application.
Configuration & Constants Migration
packages/workers/src/config/constants.js (removed), packages/workers/src/config/constants.ts, packages/workers/src/config/origins.js (removed), packages/workers/src/config/origins.ts, packages/workers/src/config/validation.ts
Removed untyped constants.js and origins.js; added typed constants.ts with role/subscription types and validators; added typed origins.ts with interface EnvWithOrigins; added validation.ts with generic ValidationResult and typed schema validators.
Authentication & Config
packages/workers/src/auth/admin.ts, packages/workers/src/auth/config.ts, packages/workers/src/auth/email.ts, packages/workers/src/auth/emailTemplates.ts, packages/workers/src/auth/routes.ts, packages/workers/src/auth/templates.ts
Added explicit function signatures; refactored createAuth/verifyAuth with full type definitions; introduced EmailService interface; typed email template functions with parameter interfaces; added StripeEvent interface to auth/routes; added return type annotations to template functions.
Database Layer Migration
packages/workers/src/db/client.js (removed), packages/workers/src/db/client.ts, packages/workers/src/db/orgAccessGrants.js (removed), packages/workers/src/db/orgAccessGrants.ts, packages/workers/src/db/stripeEventLedger.js (removed), packages/workers/src/db/stripeEventLedger.ts
Removed untyped JS modules; added type-safe client.ts, orgAccessGrants.ts, and stripeEventLedger.ts with full CRUD interfaces, typed data structures (OrgAccessGrant, StripeEventLedgerEntry), and query options.
Durable Objects
packages/workers/src/durable-objects/EmailQueue.ts, packages/workers/src/durable-objects/ProjectDoc.ts, packages/workers/src/durable-objects/UserSession.ts, packages/workers/src/durable-objects/dev-handlers.ts
Refactored all Durable Objects with explicit DurableObject interface implementation; added EmailPayload, EmailRecord, SessionData, SyncRequestBody interfaces; introduced request/response typing; added type-safe state management and WebSocket handling.
Library Utilities Migration
packages/workers/src/lib/access.js (removed), packages/workers/src/lib/access.ts, packages/workers/src/lib/escapeHtml.js (removed), packages/workers/src/lib/escapeHtml.ts, packages/workers/src/lib/entitlements.js (removed), packages/workers/src/lib/entitlements.ts, packages/workers/src/lib/quotaTransaction.js (removed), packages/workers/src/lib/quotaTransaction.ts, packages/workers/src/lib/project-doc-id.js (removed), packages/workers/src/lib/project-doc-id.ts, packages/workers/src/lib/project-sync.js (removed), packages/workers/src/lib/project-sync.ts
Removed untyped JS modules; added typed TypeScript variants with explicit function signatures, parameter types, return types, and data structures (Subscription, EntitlementsError, QuotaCheckResult, CreateGrantData, MemberData, ProjectMeta).
Library: Observability & SSRF
packages/workers/src/lib/observability/logger.ts, packages/workers/src/lib/mock-templates.ts, packages/workers/src/lib/notify.ts, packages/workers/src/lib/billingResolver.ts, packages/workers/src/lib/ssrf-protection.ts
Refactored logger with createLogger factory, LogEntry/StripeLogData/Logger interfaces; added TypeScript generics to mock-templates; added NotifyEvent/NotifyResult/NotifyOrgOptions interfaces to notify; typed billingResolver with OrgBilling/PlanChangeValidation; added SSRFValidationOptions/SSRFValidationResult interfaces to ssrf-protection.
Middleware: Error Handling & Auth
packages/workers/src/middleware/errorHandler.js (removed), packages/workers/src/middleware/errorHandler.ts, packages/workers/src/middleware/auth.ts, packages/workers/src/middleware/requireAdmin.js (removed), packages/workers/src/middleware/requireAdmin.ts
Removed untyped errorHandler.js; added typed errorHandler.ts with ErrorHandler interface and domain error mapping; updated auth.ts with MiddlewareHandler typing; removed requireAdmin.js; added requireAdmin.ts with typed middleware.
Middleware: Access Control
packages/workers/src/middleware/cors.ts, packages/workers/src/middleware/csrf.ts, packages/workers/src/middleware/rateLimit.ts, packages/workers/src/middleware/requireOrg.ts, packages/workers/src/middleware/requireEntitlement.js (removed), packages/workers/src/middleware/requireEntitlement.ts, packages/workers/src/middleware/requireOrgWriteAccess.js (removed), packages/workers/src/middleware/requireOrgWriteAccess.ts, packages/workers/src/middleware/requireQuota.js (removed), packages/workers/src/middleware/requireQuota.ts
Updated cors/csrf with explicit MiddlewareHandler return types; refactored rateLimit with RateLimitOptions/RateLimitRecord interfaces; updated requireOrg with OrgContext/ProjectContext typing; removed untyped entitlement/writeAccess/quota modules; added typed variants with explicit middleware signatures and error handling.
Middleware: Security
packages/workers/src/middleware/securityHeaders.ts, packages/workers/src/middleware/subscription.js (removed), packages/workers/src/middleware/subscription.ts
Added MiddlewareHandler return type to securityHeaders; removed subscription.js; added subscription.ts with typed getSubscription function.
Routes: Core Infrastructure
packages/workers/src/routes/admin/index.ts, packages/workers/src/routes/health.ts, packages/workers/src/schemas/common.ts
Updated all route files to use OpenAPIHono with typed Bindings: Env; added ServiceStatus/HealthChecks types; updated error schema details to z.record(z.string(), z.unknown()); added OpenAPI examples and ContentfulStatusCode typing throughout.
Routes: Feature Routes
packages/workers/src/routes/avatars.ts, packages/workers/src/routes/contact.ts, packages/workers/src/routes/email.ts, packages/workers/src/routes/members.ts, packages/workers/src/routes/users.ts
Refactored all routes with OpenAPIHono<{ Bindings: Env }> and explicit context types; added ContentfulStatusCode casting; strengthened validation error handling; added domain error usage (AUTH_ERRORS, VALIDATION_ERRORS); improved type safety in request/response schemas and error details.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Backend typescript migration #295 — Directly matches the PR's purpose: systematic migration of backend worker modules (functions, classes, middleware, DB accessors, Durable Objects, type definitions) from JavaScript to typed TypeScript.

Possibly related PRs

  • can now merge orcid accounts, fix lint issues #136 — Modifies the same eslint.config.js file by adding entries to languageOptions.globals for Cloudflare Workers configuration.
  • 252 stripe hardening #253 — Overlaps at the code level by modifying Stripe webhook/ledger and billing code paths, including ledger types, DB modules, and billing-related handlers.
  • 212 stripe better auth #214 — Makes overlapping changes to the Stripe/org billing surface: adds/replaces stripeEventLedger and orgAccessGrants DB modules, schema changes, and billing resolver/routes/middleware.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '295 backend typescript migration' clearly identifies the main objective of the PR, which is a TypeScript migration of backend code.

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


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

❤️ Share

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

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Jan 14, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
corates 87a5875 Commit Preview URL Jan 14 2026, 06:09 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
🔵 In progress
View logs
corates-workers-prod fb5789b Jan 14 2026, 05:43 PM

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 20

Caution

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

⚠️ Outside diff range comments (2)
packages/workers/src/durable-objects/EmailQueue.ts (1)

105-109: setTimeout may not execute reliably in Durable Objects.

Durable Objects can be evicted from memory between requests, which means setTimeout callbacks may never run. For reliable cleanup, use the alarm API instead.

🔧 Use alarm for reliable cleanup
         // Keep record for a short time for debugging, then delete
         await this.state.storage.put(`email:${emailRecord.id}`, emailRecord);
-        // Schedule cleanup
-        setTimeout(() => this.cleanupSentEmail(emailRecord.id), 60000);
+        // Schedule cleanup via alarm (more reliable than setTimeout in DOs)
+        await this.state.storage.put(`cleanup:${emailRecord.id}`, Date.now() + 60000);
         return true;

Then handle cleanup in processRetryQueue or alarm by checking for cleanup: prefixed entries.

packages/workers/src/config/validation.ts (1)

207-228: Use Zod v4's discriminated union pattern to safely access issue properties without type assertions.

The function should switch on issue.code directly (not a fallback to kind) and rely on TypeScript's type narrowing to access code-specific properties like type and minimum safely. Replace the type assertions with proper type narrowing:

Suggested refactor
function mapZodErrorToValidationCode(issue: z.ZodIssue): ValidationCode {
  switch (issue.code) {
    case 'too_small':
      if (issue.type === 'string' && issue.minimum === 1) {
        return 'VALIDATION_FIELD_REQUIRED';
      }
      return 'VALIDATION_FIELD_TOO_SHORT';
    case 'too_big':
      return 'VALIDATION_FIELD_TOO_LONG';
    case 'invalid_string':
      return 'VALIDATION_FIELD_INVALID_FORMAT';
    case 'invalid_type':
      return 'VALIDATION_FIELD_INVALID_FORMAT';
    default:
      return 'VALIDATION_FAILED';
  }
}
🤖 Fix all issues with AI agents
In `@packages/workers/src/auth/config.ts`:
- Around line 196-199: Update the Stripe client instantiation to use the same
API version as the rest of the codebase: change the Stripe constructor call that
creates stripeClient (the new Stripe(env.STRIPE_SECRET_KEY, { apiVersion:
'2025-12-15.clover' }) instance guarded by env.STRIPE_SECRET_KEY &&
env.STRIPE_WEBHOOK_SECRET_AUTH) to use '2025-11-17.clover' so the apiVersion
matches other Stripe clients across the project.

In `@packages/workers/src/auth/email.ts`:
- Around line 165-169: The subject currently uses HTML escaping via the imported
escapeHtml (safeProjectName → subject), which turns characters like & into
&amp;; instead, stop using escapeHtml for the email subject and replace it with
a subject-specific sanitizer: strip control/newline characters, collapse
whitespace, and enforce a max length (e.g., 78 characters) before interpolating
into the template. Update the code around the escapeHtml import and the
safeProjectName/subject construction (remove escapeHtml usage for subject and
call a new or existing sanitizeSubject function that performs control-character
removal, trimming, and length truncation).

In `@packages/workers/src/auth/routes.ts`:
- Around line 440-444: The catch block in the auth route currently returns
err.message to clients (see the catch using const err = error as Error and
c.json), which can leak internals; change the response to always return a
generic error like { error: 'Authentication error' } (no err.message) and keep
the full error details logged server-side (e.g., enhance the existing
console.error('Auth route error:', error) or use the app logger to log
error.stack); optionally, if you must return details for non-production, gate
including err.message behind an environment check (e.g., NODE_ENV !==
'production') so only safe environments receive it.

In `@packages/workers/src/config/constants.ts`:
- Around line 26-29: ACTIVE_STATUSES is using fragile index-based access
(SUBSCRIPTION_STATUSES[0] and [3]); replace those index lookups with explicit
status identifiers so the intent is clear and resilient to reordering—use the
concrete SubscriptionStatus enum members or the exact status string literals
that correspond to the intended active statuses (e.g., SubscriptionStatus.<NAME>
or "status-name") when assigning ACTIVE_STATUSES and update any related
usages/tests accordingly.

In `@packages/workers/src/db/stripeEventLedger.ts`:
- Around line 266-281: The function
getProcessedCheckoutSessionsWithoutSubscription currently fetches all
'checkout.session.completed' rows then filters for LedgerStatus.PROCESSED in
memory and does not check for missing subscriptions; change the DB query in
getProcessedCheckoutSessionsWithoutSubscription to include both conditions in
the WHERE clause (eq(stripeEventLedger.type, 'checkout.session.completed') AND
eq(stripeEventLedger.status, LedgerStatus.PROCESSED) AND
isNull/eq(stripeEventLedger.stripeSubscriptionId, null) or the equivalent for
your query builder) so filtering happens in the database and only rows with
stripeSubscriptionId null are returned; also either rename the function to
reflect its behavior if you intend a different filter, or keep the name but
ensure the query enforces the "without subscription" condition.

In `@packages/workers/src/durable-objects/UserSession.ts`:
- Around line 86-87: The destructuring using Object.values(webSocketPair) is
unsafe because it doesn't guarantee the client/server order; instead access the
pair by index to ensure [0] is the client and [1] is the server. Replace the
Object.values(...) destructuring in the WebSocketPair creation so you explicitly
assign client = webSocketPair[0] and server = webSocketPair[1] (cast server to
WebSocketWithUser if needed), keeping the surrounding logic in the UserSession
class intact.

In `@packages/workers/src/lib/billingResolver.ts`:
- Around line 26-55: There are two divergent isSubscriptionActive
implementations (one in billingResolver and one in entitlements) causing
inconsistent semantics around 'trialing'; consolidate by creating a single
shared function (e.g., isSubscriptionActive(subscription: SubscriptionRecord |
Subscription | null, now?: Date|number, includeTrial = false)) in a common
utility module, implement unified logic handling status ('active', 'past_due',
and conditional 'trialing' when includeTrial=true), periodEnd conversion, and
cancelAtPeriodEnd checks, then replace the current isSubscriptionActive usages
so billingResolver calls it with includeTrial=true and entitlements calls it
with includeTrial=false (or alternatively rename the two existing functions to
clearly indicate their different semantics if you prefer not to refactor).

In `@packages/workers/src/lib/entitlements.ts`:
- Around line 56-67: The quotas double-cast is unsafe and doesn't handle missing
keys: in hasQuota, get the limit via a typed lookup (e.g., const limit =
getEffectiveQuotas(subscription)[quotaKey] or cast to Record<string,
number|undefined> instead of using "as unknown as Record<string,number>"), then
explicitly handle undefined (e.g., if (limit === undefined) return false or
another sensible default) before calling isUnlimitedQuota(limit) and comparing
used+requested <= limit; update the function to remove the "as unknown as"
pattern and add the undefined check.
- Around line 10-22: The isSubscriptionActive implementation in entitlements.ts
only treats status 'active' as active, causing inconsistency with
billingResolver.ts which also treats 'trialing' and 'past_due' (as long as
currentPeriodEnd > now) as active; update entitlements.ts isSubscriptionActive
to match that logic (or import/reuse the isSubscriptionActive from
billingResolver.ts) so it considers 'trialing' and 'past_due' as active when
within the currentPeriodEnd, preserving the existing period-end date handling
and timestamp conversion logic.

In `@packages/workers/src/lib/observability/logger.ts`:
- Around line 248-257: The withTiming function currently mutates the caught
error by attaching durationMs, which can cause surprising side effects; change
the catch block to compute durationMs and throw a new wrapper error (e.g., a
custom TimingError or a new Error with name/message) that includes the original
error as the cause and exposes durationMs (preserve original error via the cause
property or a wrappedError field and copy the stack if needed), so callers
receive duration metadata without mutating the original error object returned
from fn.

In `@packages/workers/src/lib/project-sync.ts`:
- Around line 51-60: The call to projectDoc.fetch in syncProjectToDO does not
validate the response, so HTTP failures are silently ignored; update
syncProjectToDO to capture the Response returned by projectDoc.fetch (the POST
to 'https://internal/sync'), check response.ok or response.status, and throw or
return an error when the status is not in the 2xx range so sync failures are
properly surfaced (mirror the response status check used elsewhere in the repo).
- Around line 31-40: The call to projectDoc.fetch(...) does not validate the
Response, so HTTP errors from the Durable Object are ignored; update the code
that calls projectDoc.fetch (the POST to '/sync-member') to capture the returned
Response, check response.ok (or status), and handle non-2xx results by throwing
or logging an error (include status and text) and/or returning a failure so sync
failures are not silent; ensure this logic lives immediately after the
projectDoc.fetch call in the same function so errors propagate properly.

In `@packages/workers/src/lib/quotaTransaction.ts`:
- Around line 16-51: The function checkQuotaForInsert uses an unsafe double-cast
on orgBilling.quotas and lacks DB error handling; update the Quotas type to
include an index signature (e.g., Record<string, number>) or add a runtime type
guard to safely read quotaKey from orgBilling.quotas instead of
(orgBilling.quotas as unknown as Record<string, number>), and wrap the database
calls (the db.select(...).from(table).where(...) and any await resolveOrgAccess
calls) in a try-catch that converts DB errors into domain errors using
createDomainError(SYSTEM_ERRORS.DB_ERROR, { reason: 'db_query_failed', quotaKey,
orgId }, 'DB query failed') so the function returns a proper domain error when
DB operations fail.

In `@packages/workers/src/middleware/errorHandler.ts`:
- Around line 83-89: The current error handler uses process.env which isn't
available in Cloudflare Workers; update the logic around
createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR) to use the Hono context
environment instead (c.env.ENVIRONMENT) or refactor into a factory like
createErrorHandler(isProduction: boolean) that returns the ErrorHandler
signature used by the middleware; then conditionally include originalError and
stack when not in production based on c.env.ENVIRONMENT or the injected
isProduction flag and return c.json(error, error.statusCode as 500) as before.

In `@packages/workers/src/middleware/requireOrgWriteAccess.ts`:
- Around line 23-24: The call to resolveOrgAccess (using createDb((c as
AppContext).env.DB)) can throw on DB failures; wrap the database operation in a
try-catch inside the requireOrgWriteAccess middleware, catching errors from
resolveOrgAccess, and return the domain error using SYSTEM_ERRORS.DB_ERROR
(include the caught error details in the returned/logged payload) instead of
letting the exception propagate; keep references to createDb, AppContext,
resolveOrgAccess, orgBilling and SYSTEM_ERRORS.DB_ERROR to locate where to make
the change.

In `@packages/workers/src/routes/avatars.ts`:
- Around line 175-178: Remove the redundant null/undefined checks that follow
the requireAuth middleware: delete the `if (!user) { const error =
createDomainError(AUTH_ERRORS.REQUIRED, { reason: 'no_user' }); return
c.json(error, error.statusCode as ContentfulStatusCode); }` blocks (the ones
after retrieving `user` at both locations) because `requireAuth` guarantees
`session.user` and sets `c.set('user', session.user)` so handlers always have a
defined user; after removing them, update any subsequent usage to treat `user`
as non-null (use the existing `getAuth`/`c.get('user')` return as defined or add
a non-null assertion) and run tests to ensure no other code relies on those
early returns.

In `@packages/workers/src/routes/members.ts`:
- Around line 501-503: The code unsafely asserts c.env to access BASEPATH; add
BASEPATH?: string to the Env interface (packages/workers/src/types/env.ts) so
BASEPATH can be accessed type-safely, then update the access in members.ts to
use c.env.BASEPATH ?? '' (or use optional chaining like c.env?.BASEPATH ?? '')
instead of the (c.env as unknown as Record<...>).BASEPATH cast; ensure
imports/types align so TypeScript recognizes the new Env field.
🧹 Nitpick comments (35)
packages/workers/src/lib/access.ts (1)

1-4: Consider exporting the interface and using a stricter status type.

The Subscription interface is not exported, which limits reusability for consumers of these utilities. Additionally, status as a generic string reduces type safety—subscription statuses from providers like Stripe have well-known values.

Suggested improvement
-interface Subscription {
-  status: string;
+export interface Subscription {
+  status: 'active' | 'canceled' | 'incomplete' | 'incomplete_expired' | 'past_due' | 'trialing' | 'unpaid' | 'paused';
   currentPeriodEnd?: number | null;
 }
packages/workers/src/middleware/requireOrgWriteAccess.ts (1)

24-24: Redundant type assertion.

resolveOrgAccess already returns Promise<OrgBilling>, so the as OrgBilling cast is unnecessary.

packages/workers/src/lib/mock-templates.ts (1)

247-254: Consider using crypto.randomUUID() for consistency.

The generateId function uses Math.random() to generate a 12-character ID, while the test factory in packages/workers/src/__tests__/factories/utils.js uses crypto.randomUUID(). For mock data, the current implementation works fine, but using crypto.randomUUID() would provide better uniqueness guarantees and consistency across the codebase.

♻️ Suggested refactor
-function generateId(prefix: string = ''): string {
-  const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
-  let id = '';
-  for (let i = 0; i < 12; i++) {
-    id += chars[Math.floor(Math.random() * chars.length)];
-  }
-  return prefix ? `${prefix}_${id}` : id;
+function generateId(prefix: string = ''): string {
+  const uuid = crypto.randomUUID();
+  return prefix ? `${prefix}_${uuid.slice(0, 8)}` : uuid;
 }
packages/workers/src/durable-objects/dev-handlers.ts (1)

506-511: Consider adding a type guard before casting to Y.Map.

The code assumes that all intermediate path values are Y.Map<unknown>. If a path targets a primitive value (e.g., studies.id.name.something), the cast on line 511 could lead to unexpected behavior when trying to call get() on a non-map value.

For dev-only code this is acceptable, but a defensive check could improve robustness:

♻️ Suggested improvement
             const next = yMap.get(part);
             if (!next) {
               throw new Error(`Path not found: ${pathParts.slice(0, i + 1).join('.')}`);
             }
+            if (!(next instanceof Y.Map)) {
+              throw new Error(`Path element is not a map: ${pathParts.slice(0, i + 1).join('.')}`);
+            }
             target = next as Y.Map<unknown>;
packages/workers/src/middleware/securityHeaders.ts (1)

43-57: Consider tightening CSP for production HTML responses.

The production CSP includes 'unsafe-inline' for style-src, which weakens XSS protections. If inline styles can be avoided or nonce-based, this would improve security posture.

packages/workers/src/durable-objects/UserSession.ts (1)

94-100: Add error handling for Durable Object storage operations.

Storage operations (storage.get, storage.put) can fail. Without try-catch, failures will cause unhandled rejections and potentially leave the WebSocket in an inconsistent state.

🔧 Proposed fix
     // Send any pending notifications
-    const pending = (await this.state.storage.get<Notification[]>('pendingNotifications')) || [];
-    if (pending.length > 0) {
-      for (const notification of pending) {
-        server.send(JSON.stringify(notification));
+    try {
+      const pending = (await this.state.storage.get<Notification[]>('pendingNotifications')) || [];
+      if (pending.length > 0) {
+        for (const notification of pending) {
+          server.send(JSON.stringify(notification));
+        }
+        await this.state.storage.put('pendingNotifications', []);
       }
-      await this.state.storage.put('pendingNotifications', []);
+    } catch (err) {
+      console.error('Failed to process pending notifications:', err);
     }
packages/workers/src/middleware/requireOrg.ts (2)

29-39: Add try-catch around database query for consistency.

The requireProjectAccess middleware wraps its DB operations in try-catch blocks (lines 98-117, 134-151), but requireOrgMembership does not. For consistency and resilience, wrap this query as well.

🔧 Proposed fix
     const db = createDb((c as AppContext).env.DB);

+    let membership;
+    try {
-    const membership = await db
+      membership = await db
         .select({
           id: member.id,
           role: member.role,
           orgName: organization.name,
           orgSlug: organization.slug,
         })
         .from(member)
         .innerJoin(organization, eq(member.organizationId, organization.id))
         .where(and(eq(member.organizationId, orgId), eq(member.userId, user.id)))
         .get();
+    } catch (err) {
+      const dbError = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+        operation: 'check_org_membership',
+        orgId,
+        userId: user.id,
+        originalError: err instanceof Error ? err.message : String(err),
+      });
+      return c.json(dbError, dbError.statusCode as 500);
+    }

70-93: Consider reordering user check before orgId check.

The user authentication check (lines 90-93) happens after the orgId check. For consistency with requireOrgMembership and to fail fast on unauthenticated requests, consider moving the user check earlier.

🔧 Proposed reordering
 export function requireProjectAccess(minRole?: string): MiddlewareHandler {
   return async (c, next) => {
     const { user } = getAuth(c);
+
+    if (!user) {
+      const error = createDomainError(AUTH_ERRORS.REQUIRED);
+      return c.json(error, error.statusCode as 401);
+    }
+
     const orgId = c.get('orgId') as string | undefined;
     const projectId = c.req.param('projectId');

     if (!orgId) {
       const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
         reason: 'org_context_required',
       });
       return c.json(error, error.statusCode as 403);
     }

     if (!projectId) {
       const error = createDomainError(AUTH_ERRORS.FORBIDDEN, {
         reason: 'project_id_required',
       });
       return c.json(error, error.statusCode as 403);
     }

-    if (!user) {
-      const error = createDomainError(AUTH_ERRORS.REQUIRED);
-      return c.json(error, error.statusCode as 401);
-    }
packages/workers/src/config/origins.ts (1)

13-16: Consider exporting EnvWithOrigins for reuse.

This interface may be useful in other modules that need to type environment bindings with origin-related fields. If this interface is only used internally, it's fine as-is.

♻️ Suggested change
-interface EnvWithOrigins {
+export interface EnvWithOrigins {
   ALLOWED_ORIGINS?: string;
   AUTH_BASE_URL?: string;
 }
packages/workers/src/lib/entitlements.ts (1)

46-49: Unsafe type assertion bypasses type checking.

Casting entitlements as unknown as Record<string, boolean> defeats the purpose of the Entitlements type. If Entitlements is a well-defined type from @corates/shared/plans, consider using proper type-safe access or adding type guards.

♻️ Suggested approach

If Entitlements has a known shape, define a type-safe accessor:

export function hasEntitlement(
  subscription: Subscription | null,
  entitlement: keyof Entitlements,
): boolean {
  const entitlements = getEffectiveEntitlements(subscription);
  return entitlements[entitlement] === true;
}

Alternatively, if entitlement keys are dynamic, consider validating entitlement against known keys at runtime.

packages/workers/src/lib/notify.ts (1)

89-98: Sequential notification may be slow for organizations with many members.

Notifications are sent one-by-one in a loop. For organizations with many members, this could cause significant latency. Consider using Promise.all or Promise.allSettled for parallel execution.

♻️ Suggested parallel notification approach
-    for (const m of members) {
-      if (excludeUserIds.includes(m.userId)) continue;
-
-      const result = await notifyUser(env, m.userId, event);
-      if (result.success) {
-        notified++;
-      } else {
-        failed++;
-      }
-    }
+    const eligibleMembers = members.filter(m => !excludeUserIds.includes(m.userId));
+    const results = await Promise.allSettled(
+      eligibleMembers.map(m => notifyUser(env, m.userId, event))
+    );
+
+    for (const result of results) {
+      if (result.status === 'fulfilled' && result.value.success) {
+        notified++;
+      } else {
+        failed++;
+      }
+    }
packages/workers/src/db/orgAccessGrants.ts (1)

29-51: Post-query filtering is inefficient; consider SQL-based filtering.

The function fetches all non-revoked grants for an org, then filters by startsAt and expiresAt in JavaScript. For organizations with many grants, this is inefficient. Since the schema stores these as integers (Unix timestamps), you can filter directly in the query.

♻️ Suggested SQL-based filtering
 export async function getActiveGrantsByOrgId(
   db: Database,
   orgId: string,
   now: Date | number,
 ): Promise<OrgAccessGrant[]> {
   const nowTimestamp = now instanceof Date ? Math.floor(now.getTime() / 1000) : now;
+  const { lte, gt } = await import('drizzle-orm');
+  
   const results = await db
     .select()
     .from(orgAccessGrants)
-    .where(and(eq(orgAccessGrants.orgId, orgId), isNull(orgAccessGrants.revokedAt)))
+    .where(
+      and(
+        eq(orgAccessGrants.orgId, orgId),
+        isNull(orgAccessGrants.revokedAt),
+        lte(orgAccessGrants.startsAt, nowTimestamp),
+        gt(orgAccessGrants.expiresAt, nowTimestamp)
+      )
+    )
     .orderBy(desc(orgAccessGrants.expiresAt))
     .all();

-  return results.filter(grant => {
-    const startsAt =
-      grant.startsAt instanceof Date ? Math.floor(grant.startsAt.getTime() / 1000) : grant.startsAt;
-    const expiresAt =
-      grant.expiresAt instanceof Date ?
-        Math.floor(grant.expiresAt.getTime() / 1000)
-      : grant.expiresAt;
-    return startsAt <= nowTimestamp && nowTimestamp < expiresAt;
-  });
+  return results;
 }
packages/workers/src/durable-objects/ProjectDoc.ts (1)

214-225: Unused reqWithUser variable.

The reqWithUser is cast and user is assigned to it at line 224, but it's never used afterward. The authenticated user is verified but the enriched request object isn't passed to any handler. Consider removing this dead code if the user context isn't needed downstream.

♻️ Suggested cleanup
       // For HTTP requests verify auth (unless it's an upgrade to websocket)
-      const reqWithUser = request as RequestWithUser;
       if (upgradeHeader !== 'websocket') {
         const { user } = await verifyAuth(request, this.env);
         if (!user) {
           return new Response(JSON.stringify({ error: 'Authentication required' }), {
             status: 401,
             headers: { 'Content-Type': 'application/json' },
           });
         }
-        reqWithUser.user = user as { id: string; [key: string]: unknown };
       }
packages/workers/src/db/stripeEventLedger.ts (1)

51-54: since option in QueryOptions is never utilized.

The since?: Date field is defined in the interface but none of the query functions use it to filter results. Either implement the filtering or remove the unused option to avoid confusion.

packages/workers/src/middleware/csrf.ts (2)

31-32: Consider using the actual statusCode instead of casting.

The cast as 403 bypasses type safety. If AUTH_ERRORS.FORBIDDEN.statusCode were ever changed, this would silently become a type lie. Since the error object already contains the status code, you could use error.statusCode directly if the response type allows, or ensure the error constant is typed with a literal 403.


43-48: Same statusCode casting concern applies here.

Same observation as above regarding the as 403 cast.

packages/workers/src/middleware/requireQuota.ts (3)

11-16: Remove redundant local Logger interface.

The Logger interface duplicates the type exported by createLogger. Import the type instead to ensure consistency:

Suggested fix
-interface Logger {
-  error: (_message: string, _data?: Record<string, unknown>) => void;
-  warn: (_message: string, _data?: Record<string, unknown>) => void;
-  info: (_message: string, _data?: Record<string, unknown>) => void;
-  debug: (_message: string, _data?: Record<string, unknown>) => void;
-}
+import type { Logger } from '../lib/observability/logger';

62-62: Double type cast indicates type mismatch.

The as unknown as Record<string, number> double cast is a code smell. The Quotas type from OrgBilling should already be indexable by string if quotaKey is dynamic.

Consider typing quotaKey more strictly or ensuring Quotas type supports index signatures. The double cast bypasses type safety and could mask runtime errors if quota keys don't exist.

-  const limit = (orgBilling.quotas as unknown as Record<string, number>)[quotaKey];
+  const limit = orgBilling.quotas[quotaKey as keyof typeof orgBilling.quotas];

If quotaKey must be dynamic, add a runtime check:

const limit = quotaKey in orgBilling.quotas 
  ? orgBilling.quotas[quotaKey as keyof typeof orgBilling.quotas] 
  : 0;

63-70: Redundant ternary in error message.

On line 67, isUnlimitedQuota(limit) ? 'unlimited' : limit is always limit because the condition on line 63 already ensures !isUnlimitedQuota(limit).

Simplify the error message
       const error = createDomainError(
         AUTH_ERRORS.FORBIDDEN,
         { reason: 'quota_exceeded', quotaKey, used, limit, requested },
-        `Quota exceeded: ${quotaKey}. Current usage: ${used}, Limit: ${isUnlimitedQuota(limit) ? 'unlimited' : limit}, Requested: ${requested}`,
+        `Quota exceeded: ${quotaKey}. Current usage: ${used}, Limit: ${limit}, Requested: ${requested}`,
       );
packages/workers/src/middleware/auth.ts (1)

47-52: Consider a type-safe alternative for context access.

The type casts on c.get() are necessary but could be improved by leveraging Hono's typed context if AppContext is used consistently throughout the codebase.

If AppContext from ../types/context is used as the context type in routes, this function could be typed more strictly:

♻️ Optional improvement
-export function getAuth(c: Context): { user: AuthUser | null; session: AuthSession | null } {
+import type { AppContext } from '../types/context';
+
+export function getAuth(c: AppContext): { user: AuthUser | null; session: AuthSession | null } {
   return {
-    user: c.get('user') as AuthUser | null,
-    session: c.get('session') as AuthSession | null,
+    user: c.get('user'),
+    session: c.get('session'),
   };
 }
packages/workers/src/types/context.ts (1)

69-86: Weak typing on subscription field.

The subscription?: unknown on line 82 loses type safety. Consider using a more specific type or the same structure as OrgBilling.subscription.

♻️ Suggested improvement
 export interface AppVariables {
   user: AuthUser | null;
   session: AuthSession | null;
   orgId?: string;
   orgRole?: string;
   org?: OrgContext;
   projectId?: string;
   projectRole?: string;
   project?: ProjectContext;
   orgBilling?: OrgBilling;
   entitlements?: Entitlements;
   quotas?: Quotas;
   isAdmin?: boolean;
-  subscription?: unknown;
+  subscription?: OrgBilling['subscription'];
   tier?: string;
 }
packages/workers/src/routes/email.ts (1)

124-151: Document the @ts-expect-error reason more explicitly.

The @ts-expect-error comment would benefit from a more specific explanation of what the type mismatch is, helping future maintainers understand the workaround.

♻️ Suggested improvement
-// `@ts-expect-error` OpenAPIHono strict return types don't account for error responses
+// `@ts-expect-error` OpenAPIHono expects handler to return only schema-defined response types,
+// but we also return error responses (400, 429, 500) which are valid but not inferred
 emailRoutes.openapi(queueEmailRoute, async c => {

The error handling correctly uses createDomainError with SYSTEM_ERRORS.EMAIL_SEND_FAILED per guidelines.

packages/workers/src/middleware/rateLimit.ts (1)

155-166: Test environment detection could be more robust.

The type assertion on import.meta is necessary but fragile. Consider extracting this to a shared utility for consistency across the codebase.

♻️ Optional improvement
+function isTestEnvironment(): boolean {
+  if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') {
+    return true;
+  }
+  if (typeof import.meta !== 'undefined') {
+    const meta = import.meta as { env?: { MODE?: string } };
+    return meta.env?.MODE === 'test';
+  }
+  return false;
+}
+
 export function clearRateLimitStore(): void {
-  const isTest =
-    (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') ||
-    (typeof import.meta !== 'undefined' &&
-      (import.meta as { env?: { MODE?: string } }).env?.MODE === 'test');
-
-  if (isTest) {
+  if (isTestEnvironment()) {
     rateLimitStore.clear();
   } else {
     console.warn('Attempted to clear rate limit store outside of test environment');
   }
 }
packages/workers/src/routes/contact.ts (1)

185-186: Unsafe type cast for environment variable access.

The cast (env as unknown as Record<string, string | undefined>).CONTACT_EMAIL bypasses TypeScript's type safety. If CONTACT_EMAIL is a valid environment variable, it should be added to the Env type definition. If it's optional or dynamically set, consider a more explicit approach.

♻️ Suggested approaches

Option 1: Add CONTACT_EMAIL to Env type (preferred)

// In packages/workers/src/types/env.ts
export interface Env {
  // ... existing properties
  CONTACT_EMAIL?: string;
}

Then use it directly:

-  const contactEmail =
-    (env as unknown as Record<string, string | undefined>).CONTACT_EMAIL ?? 'contact@corates.org';
+  const contactEmail = env.CONTACT_EMAIL ?? 'contact@corates.org';

Option 2: If truly dynamic, document why:

// CONTACT_EMAIL may be set dynamically in certain environments
const contactEmail = (env as Record<string, unknown>).CONTACT_EMAIL as string | undefined ?? 'contact@corates.org';
packages/workers/src/lib/billingResolver.ts (1)

73-89: Consider improving type inference from Drizzle queries.

The repeated as SubscriptionRecord casts suggest the Drizzle query results don't match the expected interface. While functional, this could mask type mismatches.

♻️ Alternative approach using Drizzle's type inference

You could leverage Drizzle's $inferSelect or define the query result type more precisely:

// Option: Use Drizzle's inferred type and map explicitly
const subscriptions = await db
  .select({
    id: subscription.id,
    referenceId: subscription.referenceId,
    status: subscription.status,
    plan: subscription.plan,
    periodEnd: subscription.periodEnd,
    cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
  })
  .from(subscription)
  .where(eq(subscription.referenceId, orgId))
  .orderBy(desc(subscription.periodEnd))
  .all();

// Now subscriptions should be properly typed without casting
packages/workers/src/middleware/errorHandler.ts (2)

108-112: asyncHandler is a pass-through with no additional behavior.

This wrapper doesn't add error handling - it just returns the function unchanged. If it's only for TypeScript type inference, consider adding a comment explaining its purpose. If error catching was intended, the implementation is incomplete.

💡 If this is intentional for typing
+/**
+ * Type-preserving wrapper for async route handlers.
+ * Error handling is delegated to Hono's onError middleware.
+ */
 export function asyncHandler<T>(
   fn: (_c: Context, _next: () => Promise<void>) => Promise<T>,
 ): (_c: Context, _next: () => Promise<void>) => Promise<T> {
   return (c, next) => fn(c, next);
 }

14-20: Consider using Zod's actual type guard for more robust detection.

The current isZodError relies on checking .name === 'ZodError' or the presence of an .errors array, which could match non-Zod objects. If zod is already a dependency, you can use instanceof or Zod's exported type.

♻️ More robust approach
import { ZodError as ZodErrorType } from 'zod';

function isZodError(error: unknown): error is ZodErrorType {
  return error instanceof ZodErrorType;
}

Or if you want to avoid the import for bundle size, the current approach is acceptable with a comment explaining the heuristic.

packages/workers/src/routes/health.ts (1)

40-50: Consider using z.infer to avoid type duplication.

The HealthChecks interface manually duplicates the shape defined in HealthResponseSchema. Using Zod's type inference would keep them in sync automatically.

♻️ Use z.infer for consistency
 type ServiceStatus = z.infer<typeof ServiceStatusSchema>;
+type HealthChecks = z.infer<typeof HealthResponseSchema>;

-interface HealthChecks {
-  status: 'healthy' | 'degraded';
-  timestamp: string;
-  services: {
-    database?: ServiceStatus;
-    storage?: ServiceStatus;
-    durableObjects?: ServiceStatus;
-  };
-}
packages/workers/src/routes/avatars.ts (1)

230-237: Old avatar cleanup is not atomic.

The deletion of old avatars iterates and deletes objects sequentially. If a failure occurs mid-loop, some old avatars may remain. While this is wrapped in try-catch and logged as a warning (acceptable for cleanup), consider noting that partial cleanup is a known possibility.

💡 Optional: Parallel deletion for better performance
       try {
         const oldAvatars = await c.env.PDF_BUCKET.list({ prefix: `avatars/${user.id}/` });
-        for (const obj of oldAvatars.objects) {
-          await c.env.PDF_BUCKET.delete(obj.key);
-        }
+        await Promise.all(oldAvatars.objects.map(obj => c.env.PDF_BUCKET.delete(obj.key)));
       } catch (e) {
         console.warn('Failed to delete old avatar:', e);
       }
packages/workers/src/durable-objects/EmailQueue.ts (1)

46-51: Consider using Zod for payload validation.

The current validation is manual. Per coding guidelines, Zod should be used for schema validation on the backend. This would provide better type safety and more informative error messages.

♻️ Suggested Zod validation
+import { z } from 'zod';
+
+const emailPayloadSchema = z.object({
+  to: z.string().email(),
+  subject: z.string().min(1),
+  html: z.string().optional(),
+  text: z.string().optional(),
+}).refine(data => data.html || data.text, {
+  message: 'Either html or text is required',
+});

// In fetch method:
-      const payload = (await request.json()) as EmailPayload | null;
-      // Minimally validate
-      if (!payload?.to || !payload?.subject || (!payload?.html && !payload?.text)) {
-        return new Response(JSON.stringify({ error: 'Invalid payload' }), { status: 400 });
-      }
+      const parsed = emailPayloadSchema.safeParse(await request.json());
+      if (!parsed.success) {
+        return new Response(JSON.stringify({ error: parsed.error.message }), { status: 400 });
+      }
+      const payload = parsed.data;
packages/workers/src/config/validation.ts (1)

303-311: Consider logging JSON parse errors for debugging.

The empty catch clause discards potentially useful debugging information about malformed request bodies.

♻️ Add debug logging
-    } catch {
+    } catch (err) {
+      console.debug('Invalid JSON in request body:', (err as Error).message);
       const invalidJsonError = createValidationError(
         'body',
         'VALIDATION_INVALID_INPUT',
         null,
         'invalid_json',
       );
       return c.json(invalidJsonError, invalidJsonError.statusCode as 400);
     }
packages/workers/src/auth/config.ts (1)

518-573: Personal org bootstrap hook is well-implemented.

The after hook correctly:

  • Checks for existing memberships before creating
  • Creates org and membership atomically
  • Sets activeOrganizationId on session
  • Handles errors gracefully without failing auth

However, per coding guidelines, consider using db.batch() for the two related inserts (organization and member) to ensure atomicity.

♻️ Consider using db.batch() for atomic inserts
-          await db.insert(schema.organization).values({
-            id: orgId,
-            name: `${userName}'s Workspace`,
-            slug,
-            metadata: JSON.stringify({ type: 'personal' }),
-            createdAt: now,
-          });
-
-          await db.insert(schema.member).values({
-            id: memberId,
-            userId,
-            organizationId: orgId,
-            role: 'owner',
-            createdAt: now,
-          });
+          await db.batch([
+            db.insert(schema.organization).values({
+              id: orgId,
+              name: `${userName}'s Workspace`,
+              slug,
+              metadata: JSON.stringify({ type: 'personal' }),
+              createdAt: now,
+            }),
+            db.insert(schema.member).values({
+              id: memberId,
+              userId,
+              organizationId: orgId,
+              role: 'owner',
+              createdAt: now,
+            }),
+          ]);

Based on learnings, use db.batch() for multiple related database operations in Drizzle to ensure atomicity.

packages/workers/src/lib/observability/logger.ts (1)

46-55: Logger interface is well-designed.

The interface provides a clean API with proper return types. The underscore prefixes in parameter names (_message, _data, etc.) are unconventional for interface definitions - these are typically used for unused parameters, but here they serve as parameter names.

💡 Consider removing underscore prefixes from interface parameters
 export interface Logger {
   requestId: string;
   cfRay: string | null;
-  debug: (_message: string, _data?: Record<string, unknown>) => LogEntry;
-  info: (_message: string, _data?: Record<string, unknown>) => LogEntry;
-  warn: (_message: string, _data?: Record<string, unknown>) => LogEntry;
-  error: (_message: string, _data?: Record<string, unknown>) => LogEntry;
-  stripe: (_action: string, _data?: StripeLogData) => LogEntry;
-  child: (_context: Record<string, unknown>) => Logger;
+  debug: (message: string, data?: Record<string, unknown>) => LogEntry;
+  info: (message: string, data?: Record<string, unknown>) => LogEntry;
+  warn: (message: string, data?: Record<string, unknown>) => LogEntry;
+  error: (message: string, data?: Record<string, unknown>) => LogEntry;
+  stripe: (action: string, data?: StripeLogData) => LogEntry;
+  child: (context: Record<string, unknown>) => Logger;
 }
packages/workers/src/routes/members.ts (2)

328-329: Consider addressing the OpenAPIHono type issue systematically.

Multiple @ts-expect-error comments indicate a recurring type mismatch. This could be addressed by extending the route response types or creating a wrapper utility.


733-733: Non-null assertions on memberId are risky.

The memberId! assertions assume the path parameter is always present, but the route param definition doesn't guarantee this at the type level. Consider adding a guard or using the validated params.

💡 Add early guard for memberId
 memberRoutes.openapi(updateRoleRoute, async c => {
   const projectId = c.get('projectId');
   const memberId = c.req.param('userId');
   const db = createDb(c.env.DB);
   const { role } = c.req.valid('json');
   const { user: authUser } = getAuth(c);

+  if (!memberId) {
+    const error = createDomainError(VALIDATION_ERRORS.FIELD_REQUIRED, { field: 'userId' });
+    return c.json(error, error.statusCode as ContentfulStatusCode);
+  }
+
   if (!authUser) {
     const error = createDomainError(AUTH_ERRORS.REQUIRED, { reason: 'no_user' });
     return c.json(error, error.statusCode as ContentfulStatusCode);
   }

   try {
     await requireMemberManagement(db, authUser.id, projectId);
-    await requireSafeRoleChange(db, projectId, memberId!, role);
+    await requireSafeRoleChange(db, projectId, memberId, role);

This applies similarly to removeMemberRoute where memberId is also used with non-null assertions.

Also applies to: 745-745, 749-749

Comment on lines 196 to 199
if (env.STRIPE_SECRET_KEY && env.STRIPE_WEBHOOK_SECRET_AUTH) {
const stripeClient = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2025-11-17.clover',
apiVersion: '2025-12-15.clover',
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat packages/workers/src/auth/config.ts | head -220 | tail -30

Repository: InfinityBowman/corates

Length of output: 1262


🏁 Script executed:

# Search for Stripe version patterns in the codebase
rg "apiVersion" packages/workers/src/auth/ -A 2 -B 2

Repository: InfinityBowman/corates

Length of output: 1962


🏁 Script executed:

# Check if there are other Stripe client instantiations for comparison
rg "new Stripe" packages/ -A 3 -B 1

Repository: InfinityBowman/corates

Length of output: 7466


🌐 Web query:

Stripe API version 2025-12-15 clover valid format

💡 Result:

Yes — "2025-12-15.clover" is a valid Stripe API version string. Format: YYYY-MM-DD.clover (date + ".clover" release tag); the API major name is "clover". [1][2]

Sources:
[1] Stripe changelog (Clover releases listing 2025-12-15.clover).
[2] Stripe SDK type file showing ApiVersion like "2025-10-29.clover" and ApiMajorVersion = "clover".


Align Stripe API version with rest of codebase.

The version '2025-12-15.clover' is valid, but it's inconsistent with other Stripe client instantiations in the codebase, which use '2025-11-17.clover'. Found in:

  • packages/workers/src/routes/admin/stats.js
  • packages/workers/src/routes/billing/webhooks.js
  • packages/workers/src/routes/billing/checkout.js
  • packages/workers/src/routes/billing/invoices.js
  • packages/workers/src/routes/admin/stripe-tools.js
  • packages/workers/scripts/setup-stripe-test.mjs
  • packages/workers/src/routes/admin/billing-observability.js

Update to '2025-11-17.clover' to maintain consistency across the application.

🤖 Prompt for AI Agents
In `@packages/workers/src/auth/config.ts` around lines 196 - 199, Update the
Stripe client instantiation to use the same API version as the rest of the
codebase: change the Stripe constructor call that creates stripeClient (the new
Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2025-12-15.clover' }) instance
guarded by env.STRIPE_SECRET_KEY && env.STRIPE_WEBHOOK_SECRET_AUTH) to use
'2025-11-17.clover' so the apiVersion matches other Stripe clients across the
project.

Comment thread packages/workers/src/auth/email.ts Outdated
Comment on lines 165 to 169
// Note: Email subjects are plain text, not HTML, so we don't need HTML escaping
// However, we should still sanitize to prevent issues with email clients
const { escapeHtml } = await import('../lib/escapeHtml.js');
const { escapeHtml } = await import('../lib/escapeHtml');
const safeProjectName = escapeHtml(projectName);
const subject = `You're Invited to "${safeProjectName}" - CoRATES`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

HTML escaping in email subjects may produce unwanted output.

The comment correctly notes that email subjects are plain text, but the code still applies HTML escaping. This could result in subjects like You're Invited to "R&amp;D Project" instead of You're Invited to "R&D Project".

Consider sanitizing differently for subjects (removing control characters, limiting length) versus HTML body content.

🔧 Suggested approach
     // Note: Email subjects are plain text, not HTML, so we don't need HTML escaping
-    // However, we should still sanitize to prevent issues with email clients
-    const { escapeHtml } = await import('../lib/escapeHtml');
-    const safeProjectName = escapeHtml(projectName);
+    // Sanitize for email subject: remove control chars, trim excessive length
+    const safeProjectName = projectName.replace(/[\x00-\x1f]/g, '').slice(0, 100);
     const subject = `You're Invited to "${safeProjectName}" - CoRATES`;
🤖 Prompt for AI Agents
In `@packages/workers/src/auth/email.ts` around lines 165 - 169, The subject
currently uses HTML escaping via the imported escapeHtml (safeProjectName →
subject), which turns characters like & into &amp;; instead, stop using
escapeHtml for the email subject and replace it with a subject-specific
sanitizer: strip control/newline characters, collapse whitespace, and enforce a
max length (e.g., 78 characters) before interpolating into the template. Update
the code around the escapeHtml import and the safeProjectName/subject
construction (remove escapeHtml usage for subject and call a new or existing
sanitizeSubject function that performs control-character removal, trimming, and
length truncation).

Comment on lines 440 to 444
} catch (error) {
const err = error as Error;
console.error('Auth route error:', error);
return c.json({ error: 'Authentication error', details: error.message }, 500);
return c.json({ error: 'Authentication error', details: err.message }, 500);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid exposing internal error details to clients.

Line 443 returns err.message directly in the response, which could leak internal implementation details or stack traces to clients. Consider using a generic error message in production.

🔒 Suggested fix
   } catch (error) {
     const err = error as Error;
     console.error('Auth route error:', error);
-    return c.json({ error: 'Authentication error', details: err.message }, 500);
+    return c.json({ error: 'Authentication error' }, 500);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
const err = error as Error;
console.error('Auth route error:', error);
return c.json({ error: 'Authentication error', details: error.message }, 500);
return c.json({ error: 'Authentication error', details: err.message }, 500);
}
} catch (error) {
const err = error as Error;
console.error('Auth route error:', error);
return c.json({ error: 'Authentication error' }, 500);
}
🤖 Prompt for AI Agents
In `@packages/workers/src/auth/routes.ts` around lines 440 - 444, The catch block
in the auth route currently returns err.message to clients (see the catch using
const err = error as Error and c.json), which can leak internals; change the
response to always return a generic error like { error: 'Authentication error' }
(no err.message) and keep the full error details logged server-side (e.g.,
enhance the existing console.error('Auth route error:', error) or use the app
logger to log error.stack); optionally, if you must return details for
non-production, gate including err.message behind an environment check (e.g.,
NODE_ENV !== 'production') so only safe environments receive it.

Comment on lines +26 to +29
export const ACTIVE_STATUSES: readonly SubscriptionStatus[] = [
SUBSCRIPTION_STATUSES[0],
SUBSCRIPTION_STATUSES[3],
];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fragile index-based access for ACTIVE_STATUSES.

Using SUBSCRIPTION_STATUSES[0] and SUBSCRIPTION_STATUSES[3] is brittle—if the array order changes, this will silently break. Use explicit values for clarity and safety.

🐛 Suggested fix
 export const ACTIVE_STATUSES: readonly SubscriptionStatus[] = [
-  SUBSCRIPTION_STATUSES[0],
-  SUBSCRIPTION_STATUSES[3],
+  'active',
+  'trialing',
 ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const ACTIVE_STATUSES: readonly SubscriptionStatus[] = [
SUBSCRIPTION_STATUSES[0],
SUBSCRIPTION_STATUSES[3],
];
export const ACTIVE_STATUSES: readonly SubscriptionStatus[] = [
'active',
'trialing',
];
🤖 Prompt for AI Agents
In `@packages/workers/src/config/constants.ts` around lines 26 - 29,
ACTIVE_STATUSES is using fragile index-based access (SUBSCRIPTION_STATUSES[0]
and [3]); replace those index lookups with explicit status identifiers so the
intent is clear and resilient to reordering—use the concrete SubscriptionStatus
enum members or the exact status string literals that correspond to the intended
active statuses (e.g., SubscriptionStatus.<NAME> or "status-name") when
assigning ACTIVE_STATUSES and update any related usages/tests accordingly.

Comment on lines +266 to +281
export async function getProcessedCheckoutSessionsWithoutSubscription(
db: Database,
options: QueryOptions = {},
): Promise<StripeEventLedgerEntry[]> {
const { limit = 50 } = options;

const results = await db
.select()
.from(stripeEventLedger)
.where(eq(stripeEventLedger.type, 'checkout.session.completed'))
.orderBy(stripeEventLedger.receivedAt)
.limit(limit)
.all();

return results.filter(entry => entry.status === LedgerStatus.PROCESSED);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inefficient query and potentially misleading function name.

Two issues:

  1. In-memory filtering is inefficient: The function queries all checkout.session.completed events then filters for PROCESSED status in memory. Use a compound WHERE clause instead.

  2. Function name doesn't match behavior: The name suggests filtering entries "without subscription", but there's no check for stripeSubscriptionId IS NULL.

🔧 Suggested fix
+import { eq, desc, and, isNull } from 'drizzle-orm';
...

 export async function getProcessedCheckoutSessionsWithoutSubscription(
   db: Database,
   options: QueryOptions = {},
 ): Promise<StripeEventLedgerEntry[]> {
   const { limit = 50 } = options;

   const results = await db
     .select()
     .from(stripeEventLedger)
-    .where(eq(stripeEventLedger.type, 'checkout.session.completed'))
+    .where(
+      and(
+        eq(stripeEventLedger.type, 'checkout.session.completed'),
+        eq(stripeEventLedger.status, LedgerStatus.PROCESSED),
+        isNull(stripeEventLedger.stripeSubscriptionId)
+      )
+    )
     .orderBy(stripeEventLedger.receivedAt)
     .limit(limit)
     .all();

-  return results.filter(entry => entry.status === LedgerStatus.PROCESSED);
+  return results;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function getProcessedCheckoutSessionsWithoutSubscription(
db: Database,
options: QueryOptions = {},
): Promise<StripeEventLedgerEntry[]> {
const { limit = 50 } = options;
const results = await db
.select()
.from(stripeEventLedger)
.where(eq(stripeEventLedger.type, 'checkout.session.completed'))
.orderBy(stripeEventLedger.receivedAt)
.limit(limit)
.all();
return results.filter(entry => entry.status === LedgerStatus.PROCESSED);
}
export async function getProcessedCheckoutSessionsWithoutSubscription(
db: Database,
options: QueryOptions = {},
): Promise<StripeEventLedgerEntry[]> {
const { limit = 50 } = options;
const results = await db
.select()
.from(stripeEventLedger)
.where(
and(
eq(stripeEventLedger.type, 'checkout.session.completed'),
eq(stripeEventLedger.status, LedgerStatus.PROCESSED),
isNull(stripeEventLedger.stripeSubscriptionId)
)
)
.orderBy(stripeEventLedger.receivedAt)
.limit(limit)
.all();
return results;
}
🤖 Prompt for AI Agents
In `@packages/workers/src/db/stripeEventLedger.ts` around lines 266 - 281, The
function getProcessedCheckoutSessionsWithoutSubscription currently fetches all
'checkout.session.completed' rows then filters for LedgerStatus.PROCESSED in
memory and does not check for missing subscriptions; change the DB query in
getProcessedCheckoutSessionsWithoutSubscription to include both conditions in
the WHERE clause (eq(stripeEventLedger.type, 'checkout.session.completed') AND
eq(stripeEventLedger.status, LedgerStatus.PROCESSED) AND
isNull/eq(stripeEventLedger.stripeSubscriptionId, null) or the equivalent for
your query builder) so filtering happens in the database and only rows with
stripeSubscriptionId null are returned; also either rename the function to
reflect its behavior if you intend a different filter, or keep the name but
ensure the query enforces the "without subscription" condition.

Comment on lines +83 to +89
const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
...(process.env.ENVIRONMENT !== 'production' && {
originalError: err.message,
stack: err.stack,
}),
});
return c.json(error, error.statusCode as 500);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

process.env is not available in Cloudflare Workers runtime.

Cloudflare Workers don't have access to process.env. Environment variables should be accessed from the Hono context (c.env). This condition will always evaluate to false in production.

🐛 Proposed fix

Since the error handler doesn't have direct access to c.env.ENVIRONMENT, you could either:

Option 1: Check a global or use a different approach

-  const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
-    ...(process.env.ENVIRONMENT !== 'production' && {
-      originalError: err.message,
-      stack: err.stack,
-    }),
-  });
+  // In Workers, we can't easily check environment in error handlers
+  // Consider always excluding sensitive info, or use a build-time flag
+  const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {});

Option 2: Pass environment through a factory pattern

export function createErrorHandler(isProduction: boolean): ErrorHandler {
  return (err, c) => {
    // ... error handling logic
    const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
      ...(!isProduction && {
        originalError: err.message,
        stack: err.stack,
      }),
    });
    return c.json(error, error.statusCode as 500);
  };
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {
...(process.env.ENVIRONMENT !== 'production' && {
originalError: err.message,
stack: err.stack,
}),
});
return c.json(error, error.statusCode as 500);
// In Workers, we can't easily check environment in error handlers
// Consider always excluding sensitive info, or use a build-time flag
const error = createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR, {});
return c.json(error, error.statusCode as 500);
🤖 Prompt for AI Agents
In `@packages/workers/src/middleware/errorHandler.ts` around lines 83 - 89, The
current error handler uses process.env which isn't available in Cloudflare
Workers; update the logic around createDomainError(SYSTEM_ERRORS.INTERNAL_ERROR)
to use the Hono context environment instead (c.env.ENVIRONMENT) or refactor into
a factory like createErrorHandler(isProduction: boolean) that returns the
ErrorHandler signature used by the middleware; then conditionally include
originalError and stack when not in production based on c.env.ENVIRONMENT or the
injected isProduction flag and return c.json(error, error.statusCode as 500) as
before.

Comment on lines +26 to +27
const db = createDb((c as AppContext).env.DB);
const orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Wrap resolveOrgAccess in try-catch for resilience.

Per coding guidelines, database operations should be wrapped in try-catch blocks and return domain errors using createDomainError with SYSTEM_ERRORS.DB_ERROR. If resolveOrgAccess throws, this middleware will cause an unhandled exception.

🔧 Proposed fix
+import { SYSTEM_ERRORS } from '@corates/shared';
+
     const db = createDb((c as AppContext).env.DB);
-    const orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
+    let orgBilling: OrgBilling;
+    try {
+      orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
+    } catch (err) {
+      const error = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+        operation: 'resolve_org_access',
+        orgId,
+        originalError: err instanceof Error ? err.message : String(err),
+      });
+      return c.json(error, error.statusCode as 500);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const db = createDb((c as AppContext).env.DB);
const orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
const db = createDb((c as AppContext).env.DB);
let orgBilling: OrgBilling;
try {
orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
} catch (err) {
const error = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
operation: 'resolve_org_access',
orgId,
originalError: err instanceof Error ? err.message : String(err),
});
return c.json(error, error.statusCode as 500);
}

Comment on lines +23 to +24
const db = createDb((c as AppContext).env.DB);
const orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Wrap database operation in try-catch.

The call to resolveOrgAccess can throw on database errors. As per coding guidelines, database operations should be wrapped in try-catch and return domain errors using SYSTEM_ERRORS.DB_ERROR.

Proposed fix
+import { createDomainError, AUTH_ERRORS, SYSTEM_ERRORS } from '@corates/shared';
-import { createDomainError, AUTH_ERRORS } from '@corates/shared';
     const db = createDb((c as AppContext).env.DB);
-    const orgBilling = (await resolveOrgAccess(db, orgId)) as OrgBilling;
+    let orgBilling: OrgBilling;
+    try {
+      orgBilling = await resolveOrgAccess(db, orgId);
+    } catch (err) {
+      const error = createDomainError(SYSTEM_ERRORS.DB_ERROR, {
+        reason: 'billing_resolution_failed',
+      });
+      return c.json(error, error.statusCode as 500);
+    }
🤖 Prompt for AI Agents
In `@packages/workers/src/middleware/requireOrgWriteAccess.ts` around lines 23 -
24, The call to resolveOrgAccess (using createDb((c as AppContext).env.DB)) can
throw on DB failures; wrap the database operation in a try-catch inside the
requireOrgWriteAccess middleware, catching errors from resolveOrgAccess, and
return the domain error using SYSTEM_ERRORS.DB_ERROR (include the caught error
details in the returned/logged payload) instead of letting the exception
propagate; keep references to createDb, AppContext, resolveOrgAccess, orgBilling
and SYSTEM_ERRORS.DB_ERROR to locate where to make the change.

Comment thread packages/workers/src/routes/avatars.ts Outdated
Comment on lines +175 to +178
if (!user) {
const error = createDomainError(AUTH_ERRORS.REQUIRED, { reason: 'no_user' });
return c.json(error, error.statusCode as ContentfulStatusCode);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's look at the requireAuth middleware
cat packages/workers/src/middleware/auth.ts

Repository: InfinityBowman/corates

Length of output: 1597


🏁 Script executed:

# Also check the avatars.ts file around the lines mentioned
sed -n '40,50p' packages/workers/src/routes/avatars.ts
sed -n '170,185p' packages/workers/src/routes/avatars.ts
sed -n '400,415p' packages/workers/src/routes/avatars.ts

Repository: InfinityBowman/corates

Length of output: 1500


🏁 Script executed:

# Look for type definitions of getAuth and requireAuth
rg -n "getAuth|requireAuth" packages/workers/src/middleware/auth.ts -B 2 -A 5 --type=ts

Repository: InfinityBowman/corates

Length of output: 534


Remove redundant auth checks after requireAuth middleware.

The requireAuth middleware guarantees that route handlers are only reached when a valid user exists—it returns a 401 error if session.user is missing and never calls next(). Since the middleware sets c.set('user', session.user) before proceeding, the if (!user) checks at lines 175-178 and 404-407 are dead code and can be removed. The subsequent getAuth calls should always return a defined user.

Also applies to: 404-407

🤖 Prompt for AI Agents
In `@packages/workers/src/routes/avatars.ts` around lines 175 - 178, Remove the
redundant null/undefined checks that follow the requireAuth middleware: delete
the `if (!user) { const error = createDomainError(AUTH_ERRORS.REQUIRED, {
reason: 'no_user' }); return c.json(error, error.statusCode as
ContentfulStatusCode); }` blocks (the ones after retrieving `user` at both
locations) because `requireAuth` guarantees `session.user` and sets
`c.set('user', session.user)` so handlers always have a defined user; after
removing them, update any subsequent usage to treat `user` as non-null (use the
existing `getAuth`/`c.get('user')` return as defined or add a non-null
assertion) and run tests to ensure no other code relies on those early returns.

Comment on lines 501 to +503
try {
const appUrl = c.env.APP_URL || 'https://corates.org';
const basepath = c.env.BASEPATH || '';
const basepath = (c.env as unknown as Record<string, string | undefined>).BASEPATH ?? '';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BASEPATH access uses unsafe type assertion.

The cast (c.env as unknown as Record<string, string | undefined>).BASEPATH bypasses type safety. If BASEPATH is a valid environment variable, it should be added to the Env type definition.

💡 Add BASEPATH to Env type or use optional chaining

If BASEPATH is a valid env var, add it to the Env interface in packages/workers/src/types/env.ts:

interface Env {
  // ... existing properties
  BASEPATH?: string;
}

Then simplify the access:

-const basepath = (c.env as unknown as Record<string, string | undefined>).BASEPATH ?? '';
+const basepath = c.env.BASEPATH ?? '';
🤖 Prompt for AI Agents
In `@packages/workers/src/routes/members.ts` around lines 501 - 503, The code
unsafely asserts c.env to access BASEPATH; add BASEPATH?: string to the Env
interface (packages/workers/src/types/env.ts) so BASEPATH can be accessed
type-safely, then update the access in members.ts to use c.env.BASEPATH ?? ''
(or use optional chaining like c.env?.BASEPATH ?? '') instead of the (c.env as
unknown as Record<...>).BASEPATH cast; ensure imports/types align so TypeScript
recognizes the new Env field.

@InfinityBowman InfinityBowman merged commit 5c126b9 into main Jan 14, 2026
4 checks passed
@InfinityBowman InfinityBowman deleted the 295-backend-typescript-migration branch January 14, 2026 18:12
@coderabbitai coderabbitai Bot mentioned this pull request Feb 26, 2026
@coderabbitai coderabbitai Bot mentioned this pull request Apr 7, 2026
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.

Backend typescript migration

2 participants