Skip to content

Fix insecure draft storage - #640

Merged
leoisadev1 merged 44 commits into
mainfrom
tembo/fix/prompt-drafts-localstorage-security
Feb 14, 2026
Merged

Fix insecure draft storage#640
leoisadev1 merged 44 commits into
mainfrom
tembo/fix/prompt-drafts-localstorage-security

Conversation

@tembo

@tembo tembo Bot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrated prompt draft storage from localStorage to sessionStorage to mitigate security risks. Drafts containing sensitive user input are now automatically cleared when the browser tab closes, reducing exposure via XSS or compromised browser profiles.

Changes:

  • Replaced localStorage with sessionStorage in apps/web/src/stores/prompt-draft.ts
  • Reduced draft expiry from 7 days to 24 hours (defensive measure for session-scoped storage)
  • Updated comments in apps/web/src/hooks/use-prompt-draft.ts and apps/web/src/stores/prompt-draft.ts to reflect security rationale

Files modified:

  • apps/web/src/hooks/use-prompt-draft.ts
  • apps/web/src/stores/prompt-draft.ts

Fixes OSS-34


Want tembo to make any changes? Add a review or comment with @tembo and i'll get back to work!

View on Tembo View Agent Settings


Summary by cubic

Switched prompt draft storage to sessionStorage to limit sensitive data exposure. Drafts clear on tab close, expire after 24h, and the models API enforces IP rate limits with a 10s timeout (OSS-34).

Written for commit 394083f. Summary will update on new commits.

leoisadev1 and others added 30 commits February 11, 2026 11:30
…o add-upstash-rate-limits-and

# Conflicts:
#	apps/web/src/hooks/use-persistent-chat.ts
#	apps/web/src/routes/api/typing.ts
Co-authored-by: cubic[bot] <cubic[bot]@users.noreply.github.com>
Co-authored-by: cubic[bot] <cubic[bot]@users.noreply.github.com>
- cleanup.ts: require token unconditionally, timing-safe compare, off-by-one fix
- crons.ts: timing-safe token comparison
- generate-title.ts: remove x-convex-token forwarding to QStash
- delete-account.ts: remove x-convex-token forwarding and trust
- export-chat.ts: remove x-convex-token forwarding and trust
- backgroundStream.ts: restore increment-before-search TOCTOU fix, strict maxSteps validation
- server-auth.ts: don't fall through to cookie on 5xx errors
- cleanup.ts: compare buffer byte lengths in safeCompare (non-ASCII safety)
- server-auth.ts: only return null on 4xx, let 5xx fall through to cookie
- crons.ts: avoid leaking token length in constant-time comparison
- cleanup.ts: constant-time safeCompare without length leak, stop forwarding
  WORKFLOW_CLEANUP_TOKEN to QStash
- delete-account.ts: bounded batch loops (MAX_DELETE_BATCHES=500), rate limiting
- export-chat.ts: apply exportRatelimit before processing
- generate-title.ts: add rate limiting before workflow trigger
- upstashUsage.ts: validate dateKey to prevent NaN/forever-TTL keys
- check-redis.ts: add 5s fetch timeout
- server/package.json: quote $VERCEL_GIT_COMMIT_REF
- upstash.ts: remove unused chatIpRatelimit/chatRatelimit exports
Replace hand-rolled XOR loop with crypto.createHmac('sha256') +
timingSafeEqual for verified constant-time comparison in Node.js runtime.
…s and error handling

- Move runCleanupBatchForWorkflow to cleanupAction.ts with 'use node' for
  crypto.timingSafeEqual (fixes hand-rolled XOR timing side-channel in crons.ts)
- Keep as public action() so ConvexHttpClient can call it (token-gated via HMAC)
- Fix server-auth.ts: return null when token endpoint returns 200 OK with no token
  (prevents auth bypass via cookie fallback)
- Move getMidnightUtcEpochSeconds inside try/catch in upstashUsage.ts
- Update cleanup.ts workflow to reference new api.cleanupAction path
- Remove stale comment from crons.ts
…nd bounds validation

- Remove session cookie forwarding from all 3 workflow trigger headers
  (generate-title, export-chat, delete-account) — cookies no longer sent
  to third-party QStash infrastructure
- Pass pre-resolved auth token via payload instead of cookie headers
- Remove getAuthTokenFromWorkflowHeaders from all workflow files
- Add IP-based rate limiting to /api/models endpoint (30 req/60s)
- Add 10s fetch timeout to OpenRouter models call
- Fix OpenRouter proxy: use Accept header instead of Content-Type on GET,
  return 502 for upstream errors instead of forwarding status codes
- Fix backgroundStream.ts double-counting: separate Convex mutation retry
  from Upstash increment to prevent billing double-charge on retry
- Add bounds validation to cleanupAction.ts (retentionDays 1-3650,
  batchSize 1-1000)
- Use env-based HMAC key in cleanupAction.ts safeCompare
- Log warning when Upstash Redis is not configured (rate limiting disabled)
…ndle fetch errors

- Fix P0: all 3 workflow payload parsers now pass through authToken field
  (generate-title, export-chat, delete-account) — prevents 'Unauthorized'
  on every workflow execution
- Remove hardcoded HMAC key from safeCompare in cleanup.ts and
  cleanupAction.ts — key is now passed as parameter from the validated
  env var, eliminating static key in source code
- Wrap fetchModelsFromOpenRouter in try/catch to handle timeout and
  network errors gracefully (returns 502 instead of unhandled 500)
- Use Math.ceil for sub-cent usageCents in upstashUsage.ts to prevent
  silent usage loss from Math.floor(0.5) = 0
- Add Number.isFinite() checks to retentionDays and batchSize validation
  in cleanupAction.ts (NaN bypasses comparison operators)
- Log OpenRouter fetch failures in models.ts catch block for observability
…odel endpoints

- Replace QStash payload authToken with short-lived authTokenRef keys stored in Upstash Redis for generate-title/export-chat/delete-account workflows
- Make cleanup batch executor internalAction and add Convex HTTP bridge endpoint to avoid public action exposure
- Harden /api/models with trusted IP extraction, missing-IP rejection, and fully wrapped upstream fetch/body handling
- Fix stream meta parsing for Upstash auto-deserialized objects and batch delete readStatus/promptTemplates in users cleanup
- Add workflow payload validation, IPv6 localhost support, and clean indentation issues flagged by reviews
…handling

- Encrypt workflow auth tokens at rest and consume via getdel with strict key-prefix validation
- Harden Convex cleanup batch HTTP bridge with typed error status mapping
- Require full QStash config (URL+token) and add production misconfig warning
- Tighten models IP derivation with trust-proxy gating and Retry-After on 429
- Add provisional atomic usage reservation/adjustment path to reduce daily-limit race window
- Normalize IPv6 localhost checks and finish remaining formatting/alignment fixes
* fix(web): validate chat ownership before Redis stream init

Add Convex-based chat ownership validation in both GET and POST handlers
of /api/chat to prevent unauthorized Redis stream access. Previously,
an authenticated user could use another user's chatId to overwrite
stream metadata and read streaming tokens.

- GET handler: validate ownership via api.chats.get before reading stream
- POST handler: validate ownership via api.chats.get before redis.stream.init

Closes OSS-22

* fix(api): add chat ownership validation before redis stream operations

* fix(chat): reuse convex client to eliminate redundant http request

---------

Co-authored-by: Tembo <tembo@opencode.ai>
Co-authored-by: tembo[bot] <208362400+tembo[bot]@users.noreply.github.com>
leoisadev1 and others added 13 commits February 12, 2026 00:19
- Remove dead jonMode/dynamicPrompt fields from validator, schema, client store, hooks, and settings UI
- Use exact-match error classification in http.ts instead of fragile substring matching
- Fix inconsistent indentation in chats.ts JSON.stringify
- Promote chatReadStatuses/promptTemplates deletion to workflow steps for proper transaction isolation
- Deprecate deleteAccount mutation in favor of workflow-based deleteAccountWorkflowStep
- Use static error message in app-sidebar toast instead of raw error.message
- Fix DELETE rate-limit message in openrouter-key.ts
Add Upstash rate limits and secure auto chat titles
…isting documents

Existing production streamJobs documents contain these fields. Convex
schema validation rejects documents with extra fields, so they must
remain in the schema until a migration removes them from all documents.
@tembo tembo Bot added the tembo Pull request created by Tembo label Feb 13, 2026
@railway-app

railway-app Bot commented Feb 13, 2026

Copy link
Copy Markdown

This PR was not deployed automatically as @tembo[bot] does not have access to the Railway project.

In order to get automatic PR deploys, please add @tembo[bot] to your workspace on Railway.

@tembo

tembo Bot commented Feb 13, 2026

Copy link
Copy Markdown
Contributor Author

Requesting review from @leoisadev1 who has experience with the following files modified in this PR:

  • apps/web/src/stores/prompt-draft.ts
  • apps/web/src/hooks/use-prompt-draft.ts

@tembo
tembo Bot requested a review from leoisadev1 February 13, 2026 10:08
@leoisadev1
leoisadev1 marked this pull request as ready for review February 14, 2026 02:30

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

14 issues found across 45 files

Confidence score: 2/5

  • High risk due to workflow misuse: apps/web/src/routes/api/workflow/generate-title.ts makes the OpenRouter fetch() outside context.run(), which can re-run on each step and cause repeated external calls and inconsistent behavior.
  • Security/data handling concerns: draft migration leaves sensitive data in localStorage (apps/web/src/stores/prompt-draft.ts) and decrypted auth tokens are persisted as workflow step results in apps/web/src/routes/api/workflow/delete-account.ts.
  • Concurrency/data integrity risk in apps/server/convex/lib/upstashUsage.ts where the negative-balance floor check is split across pipelines, allowing a race between read and write.
  • Pay close attention to apps/web/src/routes/api/workflow/generate-title.ts, apps/web/src/stores/prompt-draft.ts, apps/web/src/routes/api/workflow/delete-account.ts, apps/server/convex/lib/upstashUsage.ts - replays/retries, sensitive data persistence, and race conditions.
Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/web/src/hooks/use-persistent-chat.ts">

<violation number="1" location="apps/web/src/hooks/use-persistent-chat.ts:1126">
P2: Inconsistent error handling: `forkMessage` still exposes raw `parsedError.message` to the user via toast, while `editMessage` and `retryMessage` in this same PR were updated to use `getUserFriendlyError()`. This can leak internal error details (API structures, DB field names, etc.) to the end user.</violation>
</file>

<file name="apps/web/src/stores/prompt-draft.ts">

<violation number="1" location="apps/web/src/stores/prompt-draft.ts:123">
P1: Switching to `sessionStorage` doesn't remove previously stored drafts from `localStorage`. Existing sensitive draft data under the `"openchat-prompt-drafts"` key will persist indefinitely in `localStorage`, undermining the security improvement this PR intends to make. Add a one-time migration cleanup to remove the old `localStorage` entry.</violation>
</file>

<file name="apps/web/src/routes/api/workflow/generate-title.ts">

<violation number="1" location="apps/web/src/routes/api/workflow/generate-title.ts:249">
P1: The `fetch()` call to the OpenRouter API is not wrapped in a workflow step (`context.run()` or `context.call()`). In Upstash Workflow, the endpoint is re-invoked for each step, and only operations inside `context.run()`/`context.call()` are memoized and skipped on replay. This bare fetch will be re-executed every time the workflow replays for subsequent steps (e.g., `save-title`), causing duplicate LLM calls and wasted API credits.

Use `context.call()` which is specifically designed for external HTTP requests in workflows — it offloads the call to Upstash and automatically handles retries and replay.</violation>
</file>

<file name="apps/server/convex/chats.ts">

<violation number="1" location="apps/server/convex/chats.ts:571">
P3: Indentation of `body` (2 tabs) doesn't match its sibling properties `method` and `headers` (3 tabs) in the fetch options object. This makes it look like `body` is outside the options object. Align it with the other properties.</violation>
</file>

<file name="apps/web/src/components/app-sidebar.tsx">

<violation number="1" location="apps/web/src/components/app-sidebar.tsx:454">
P2: Fragile and inconsistent rate-limit detection: this uses string matching on the error message, while the same file (line 520, bulk-delete handler) and the server (`throwRateLimitError`) use the structured `error.name === "RateLimitError"` check. The message-based check will silently break if the server wording changes, and the hardcoded toast loses the dynamic retry-after time the server provides.</violation>
</file>

<file name="apps/server/convex/lib/upstashUsage.ts">

<violation number="1" location="apps/server/convex/lib/upstashUsage.ts:189">
P1: Race condition (TOCTOU): The negative-balance floor check reads the total from the first pipeline, then issues a separate `SET key 0` in a second pipeline. A concurrent `INCRBY` between the two pipelines would be silently wiped out, causing usage data loss. Consider using a Lua script via `EVAL` to atomically check-and-floor:

["EVAL", "local v=redis.call('INCRBY',KEYS[1],ARGV[1]) if v<0 then redis.call('SET',KEYS[1],0) end return v", 1, key, roundedDelta]

</file>

<file name="apps/web/src/lib/server-auth.ts">

<violation number="1" location="apps/web/src/lib/server-auth.ts:82">
P1: `ALLOW_AUTH_COOKIE_FALLBACK` is defined and guarded against production use, but never actually checked in the fallback code path. The cookie-based JWT fallback is unconditionally available in dev/test, defeating the purpose of the opt-in env var. The fallback guard should also require `ALLOW_AUTH_COOKIE_FALLBACK` to be `true`.</violation>
</file>

<file name="apps/server/convex/lib/sanitize.ts">

<violation number="1" location="apps/server/convex/lib/sanitize.ts:44">
P2: Regex-based HTML stripping `/<[^>]*>/g` will corrupt legitimate titles containing angle brackets (e.g., `"Understanding template<T>"` → `"Understanding template"`). For an AI chat app with code-related conversations, this is a realistic data loss scenario. Consider encoding angle brackets instead of removing them (e.g., replacing `<` with `&lt;`) or using a proper HTML sanitization library that distinguishes tags from text.</violation>
</file>

<file name="scripts/dev.ts">

<violation number="1" location="scripts/dev.ts:25">
P2: The local variable `process` shadows the global `process` object. Rename it to avoid confusion and potential bugs if this function is later extended (e.g., someone adding `process.env` access inside `run` would silently get the subprocess object, not the global).</violation>
</file>

<file name="apps/server/convex/cleanupAction.ts">

<violation number="1" location="apps/server/convex/cleanupAction.ts:38">
P2: Validation mismatch: `Number.isFinite` allows non-integer values (e.g. `1.5`) that the downstream mutation rejects with `Number.isInteger`. Use `Number.isInteger` here to match the mutation's validation and catch invalid input at the correct boundary.</violation>
</file>

<file name="apps/web/src/routes/api/workflow/delete-account.ts">

<violation number="1" location="apps/web/src/routes/api/workflow/delete-account.ts:204">
P2: The decrypted auth token is returned from `context.run("resolve-auth", ...)`, which causes Upstash Workflow to persist it in plain text as a step result for replay. This partially undermines the encrypt/`getdel`/TTL protections in `workflow-auth-token.ts`. Consider restructuring so the token isn't stored as a step result — e.g., re-resolve the token inside each `context.run` step that needs it, or pass it via workflow request headers instead of step return values.</violation>
</file>

<file name="apps/web/src/routes/api/workflow/export-chat.ts">

<violation number="1" location="apps/web/src/routes/api/workflow/export-chat.ts:120">
P2: `runExportChatInline` should validate that `userId` is defined before proceeding. The function currently trusts its caller to always provide a `userId`, but its type signature allows `undefined`. Add a guard at the top of the function.</violation>
</file>

<file name="apps/server/convex/users.ts">

<violation number="1" location="apps/server/convex/users.ts:870">
P2: `batchSize` is not forwarded for `delete-chat-read-statuses` and `delete-prompt-templates` cases, unlike all other deletion steps. This prevents callers from controlling the batch size for these two steps. Pass `batchSize: args.batchSize` for consistency.</violation>
</file>

<file name="apps/server/convex/chats.test.ts">

<violation number="1" location="apps/server/convex/chats.test.ts:1287">
P2: This test doesn't mock `fetch`, relying on the implementation short-circuiting before the API call. If the implementation order changes, this test will make a real HTTP request or fail unexpectedly. Mock `fetch` and assert it was **not** called — this both prevents accidental network calls and documents the expected behavior.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

{
name: "openchat-prompt-drafts",
storage: createJSONStorage(() => localStorage),
storage: createJSONStorage(() => sessionStorage),

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Switching to sessionStorage doesn't remove previously stored drafts from localStorage. Existing sensitive draft data under the "openchat-prompt-drafts" key will persist indefinitely in localStorage, undermining the security improvement this PR intends to make. Add a one-time migration cleanup to remove the old localStorage entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/stores/prompt-draft.ts, line 123:

<comment>Switching to `sessionStorage` doesn't remove previously stored drafts from `localStorage`. Existing sensitive draft data under the `"openchat-prompt-drafts"` key will persist indefinitely in `localStorage`, undermining the security improvement this PR intends to make. Add a one-time migration cleanup to remove the old `localStorage` entry.</comment>

<file context>
@@ -115,7 +120,7 @@ export const usePromptDraftStore = create<PromptDraftState>()(
 			{
 				name: "openchat-prompt-drafts",
-				storage: createJSONStorage(() => localStorage),
+				storage: createJSONStorage(() => sessionStorage),
 			},
 		),
</file context>
Fix with Cubic

let llmResponseStatus = 0;
let llmResponseBody: { choices?: Array<{ message?: { content?: string } }> } | null = null;
try {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: The fetch() call to the OpenRouter API is not wrapped in a workflow step (context.run() or context.call()). In Upstash Workflow, the endpoint is re-invoked for each step, and only operations inside context.run()/context.call() are memoized and skipped on replay. This bare fetch will be re-executed every time the workflow replays for subsequent steps (e.g., save-title), causing duplicate LLM calls and wasted API credits.

Use context.call() which is specifically designed for external HTTP requests in workflows — it offloads the call to Upstash and automatically handles retries and replay.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/routes/api/workflow/generate-title.ts, line 249:

<comment>The `fetch()` call to the OpenRouter API is not wrapped in a workflow step (`context.run()` or `context.call()`). In Upstash Workflow, the endpoint is re-invoked for each step, and only operations inside `context.run()`/`context.call()` are memoized and skipped on replay. This bare fetch will be re-executed every time the workflow replays for subsequent steps (e.g., `save-title`), causing duplicate LLM calls and wasted API credits.

Use `context.call()` which is specifically designed for external HTTP requests in workflows — it offloads the call to Upstash and automatically handles retries and replay.</comment>

<file context>
@@ -0,0 +1,438 @@
+	let llmResponseStatus = 0;
+	let llmResponseBody: { choices?: Array<{ message?: { content?: string } }> } | null = null;
+	try {
+		const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
+			method: "POST",
+			headers: {
</file context>
Fix with Cubic

: typeof total === "string"
? Number.parseInt(total, 10)
: null;
if (typeof parsedTotal === "number" && Number.isFinite(parsedTotal) && parsedTotal < 0) {

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Race condition (TOCTOU): The negative-balance floor check reads the total from the first pipeline, then issues a separate SET key 0 in a second pipeline. A concurrent INCRBY between the two pipelines would be silently wiped out, causing usage data loss. Consider using a Lua script via EVAL to atomically check-and-floor:

["EVAL", "local v=redis.call('INCRBY',KEYS[1],ARGV[1]) if v<0 then redis.call('SET',KEYS[1],0) end return v", 1, key, roundedDelta]
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/convex/lib/upstashUsage.ts, line 189:

<comment>Race condition (TOCTOU): The negative-balance floor check reads the total from the first pipeline, then issues a separate `SET key 0` in a second pipeline. A concurrent `INCRBY` between the two pipelines would be silently wiped out, causing usage data loss. Consider using a Lua script via `EVAL` to atomically check-and-floor:

["EVAL", "local v=redis.call('INCRBY',KEYS[1],ARGV[1]) if v<0 then redis.call('SET',KEYS[1],0) end return v", 1, key, roundedDelta]


<file context>
@@ -0,0 +1,200 @@
+				: typeof total === "string"
+					? Number.parseInt(total, 10)
+					: null;
+		if (typeof parsedTotal === "number" && Number.isFinite(parsedTotal) && parsedTotal < 0) {
+			await executePipeline([
+				["SET", key, 0],
</file context>
Fix with Cubic

if (response.status >= 400 && response.status < 500) {
return null;
}
if (!IS_LOCAL_DEV) return null;

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: ALLOW_AUTH_COOKIE_FALLBACK is defined and guarded against production use, but never actually checked in the fallback code path. The cookie-based JWT fallback is unconditionally available in dev/test, defeating the purpose of the opt-in env var. The fallback guard should also require ALLOW_AUTH_COOKIE_FALLBACK to be true.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/lib/server-auth.ts, line 82:

<comment>`ALLOW_AUTH_COOKIE_FALLBACK` is defined and guarded against production use, but never actually checked in the fallback code path. The cookie-based JWT fallback is unconditionally available in dev/test, defeating the purpose of the opt-in env var. The fallback guard should also require `ALLOW_AUTH_COOKIE_FALLBACK` to be `true`.</comment>

<file context>
@@ -16,18 +16,81 @@ type AuthSessionResponse = {
+			if (response.status >= 400 && response.status < 500) {
+				return null;
+			}
+			if (!IS_LOCAL_DEV) return null;
+		} catch {
+			if (!IS_LOCAL_DEV) return null;
</file context>
Fix with Cubic

} catch (err) {
const parsedError = err instanceof Error ? err : new Error("Unknown error");
toast.error("Failed to branch off", {
description: parsedError.message,

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Inconsistent error handling: forkMessage still exposes raw parsedError.message to the user via toast, while editMessage and retryMessage in this same PR were updated to use getUserFriendlyError(). This can leak internal error details (API structures, DB field names, etc.) to the end user.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/hooks/use-persistent-chat.ts, line 1126:

<comment>Inconsistent error handling: `forkMessage` still exposes raw `parsedError.message` to the user via toast, while `editMessage` and `retryMessage` in this same PR were updated to use `getUserFriendlyError()`. This can leak internal error details (API structures, DB field names, etc.) to the end user.</comment>

<file context>
@@ -1146,47 +1103,43 @@ export function usePersistentChat({
+		} catch (err) {
+			const parsedError = err instanceof Error ? err : new Error("Unknown error");
+			toast.error("Failed to branch off", {
+				description: parsedError.message,
+			});
+			return undefined;
</file context>
Fix with Cubic

return EMPTY_DELETE_RESULT;
}

const authToken = await context.run("resolve-auth", async () => {

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The decrypted auth token is returned from context.run("resolve-auth", ...), which causes Upstash Workflow to persist it in plain text as a step result for replay. This partially undermines the encrypt/getdel/TTL protections in workflow-auth-token.ts. Consider restructuring so the token isn't stored as a step result — e.g., re-resolve the token inside each context.run step that needs it, or pass it via workflow request headers instead of step return values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/routes/api/workflow/delete-account.ts, line 204:

<comment>The decrypted auth token is returned from `context.run("resolve-auth", ...)`, which causes Upstash Workflow to persist it in plain text as a step result for replay. This partially undermines the encrypt/`getdel`/TTL protections in `workflow-auth-token.ts`. Consider restructuring so the token isn't stored as a step result — e.g., re-resolve the token inside each `context.run` step that needs it, or pass it via workflow request headers instead of step return values.</comment>

<file context>
@@ -0,0 +1,387 @@
+		return EMPTY_DELETE_RESULT;
+	}
+
+	const authToken = await context.run("resolve-auth", async () => {
+		return getWorkflowAuthToken(authTokenRef);
+	});
</file context>
Fix with Cubic

payload: ExportChatPayload,
authToken: string,
): Promise<{ downloadUrl: string; byteLength: number; fileName: string }> {
const { chatId, userId, format = "markdown" } = payload;

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: runExportChatInline should validate that userId is defined before proceeding. The function currently trusts its caller to always provide a userId, but its type signature allows undefined. Add a guard at the top of the function.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/src/routes/api/workflow/export-chat.ts, line 120:

<comment>`runExportChatInline` should validate that `userId` is defined before proceeding. The function currently trusts its caller to always provide a `userId`, but its type signature allows `undefined`. Add a guard at the top of the function.</comment>

<file context>
@@ -0,0 +1,226 @@
+	payload: ExportChatPayload,
+	authToken: string,
+): Promise<{ downloadUrl: string; byteLength: number; fileName: string }> {
+	const { chatId, userId, format = "markdown" } = payload;
+	const convexClient = createConvexServerClient(authToken);
+	const chatExportData = await convexClient.query(api.chats.getChatExportData, {
</file context>
Fix with Cubic

Comment on lines +870 to +877
case "delete-chat-read-statuses":
return await ctx.runMutation(internal.users.deleteUserChatReadStatuses, {
userId,
});
case "delete-prompt-templates":
return await ctx.runMutation(internal.users.deleteUserPromptTemplates, {
userId,
});

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: batchSize is not forwarded for delete-chat-read-statuses and delete-prompt-templates cases, unlike all other deletion steps. This prevents callers from controlling the batch size for these two steps. Pass batchSize: args.batchSize for consistency.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/convex/users.ts, line 870:

<comment>`batchSize` is not forwarded for `delete-chat-read-statuses` and `delete-prompt-templates` cases, unlike all other deletion steps. This prevents callers from controlling the batch size for these two steps. Pass `batchSize: args.batchSize` for consistency.</comment>

<file context>
@@ -598,18 +596,309 @@ export const updateName = mutation({
+					userId,
+					batchSize: args.batchSize,
+				});
+			case "delete-chat-read-statuses":
+				return await ctx.runMutation(internal.users.deleteUserChatReadStatuses, {
+					userId,
</file context>
Suggested change
case "delete-chat-read-statuses":
return await ctx.runMutation(internal.users.deleteUserChatReadStatuses, {
userId,
});
case "delete-prompt-templates":
return await ctx.runMutation(internal.users.deleteUserPromptTemplates, {
userId,
});
case "delete-chat-read-statuses":
return await ctx.runMutation(internal.users.deleteUserChatReadStatuses, {
userId,
batchSize: args.batchSize,
});
case "delete-prompt-templates":
return await ctx.runMutation(internal.users.deleteUserPromptTemplates, {
userId,
batchSize: args.batchSize,
});
Fix with Cubic

expect(chat?.title).toBe('Helpful Testing Title');
});

it('does not overwrite existing custom titles unless forced', async () => {

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This test doesn't mock fetch, relying on the implementation short-circuiting before the API call. If the implementation order changes, this test will make a real HTTP request or fail unexpectedly. Mock fetch and assert it was not called — this both prevents accidental network calls and documents the expected behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/convex/chats.test.ts, line 1287:

<comment>This test doesn't mock `fetch`, relying on the implementation short-circuiting before the API call. If the implementation order changes, this test will make a real HTTP request or fail unexpectedly. Mock `fetch` and assert it was **not** called — this both prevents accidental network calls and documents the expected behavior.</comment>

<file context>
@@ -1111,3 +1111,194 @@ describe('chats.checkExportRateLimit', () => {
+		expect(chat?.title).toBe('Helpful Testing Title');
+	});
+
+	it('does not overwrite existing custom titles unless forced', async () => {
+		await t.run(async (ctx) => {
+			await ctx.db.patch(chatId, { title: 'Custom Existing Title' });
</file context>
Fix with Cubic

"HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io",
"X-Title": "OSSChat",
},
body: JSON.stringify({

@cubic-dev-ai cubic-dev-ai Bot Feb 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: Indentation of body (2 tabs) doesn't match its sibling properties method and headers (3 tabs) in the fetch options object. This makes it look like body is outside the options object. Align it with the other properties.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/convex/chats.ts, line 571:

<comment>Indentation of `body` (2 tabs) doesn't match its sibling properties `method` and `headers` (3 tabs) in the fetch options object. This makes it look like `body` is outside the options object. Align it with the other properties.</comment>

<file context>
@@ -526,6 +526,87 @@ const TITLE_STYLE_PROMPTS: Record<"short" | "standard" | "long", string> = {
+				"HTTP-Referer": process.env.CONVEX_SITE_URL || "https://osschat.io",
+				"X-Title": "OSSChat",
+			},
+		body: JSON.stringify({
+			model: TITLE_MODEL_ID,
+			messages: [
</file context>
Suggested change
body: JSON.stringify({
body: JSON.stringify({
Fix with Cubic

@vercel

vercel Bot commented Feb 14, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
osschat-web Error Error Feb 14, 2026 2:54am

@leoisadev1
leoisadev1 merged commit b5e3677 into main Feb 14, 2026
4 of 5 checks passed
@leoisadev1
leoisadev1 deleted the tembo/fix/prompt-drafts-localstorage-security branch February 14, 2026 02:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

tembo Pull request created by Tembo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant