Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
96f38b8
docs: add audit remediation design (6-phase fix plan)
createpjf Jun 10, 2026
188274a
docs: add Phase 0 security hardening implementation plan
createpjf Jun 10, 2026
fd02b26
fix(gateway): bind to 127.0.0.1 to prevent LAN exposure (C1)
createpjf Jun 10, 2026
ee7ee6a
fix(gateway): constant-time token comparison (C3)
createpjf Jun 10, 2026
f6f99cf
fix(gateway): mask auth token in startup logs (C2a)
createpjf Jun 10, 2026
100067c
fix(desktop): discard gateway stdout to prevent leak/deadlock (C2b)
createpjf Jun 10, 2026
7113bd3
fix(gateway): SSRF allowlist + no-redirect for local providers (H2-sec)
createpjf Jun 10, 2026
acfe210
harden(gateway): no-redirect on forwarding + document SSRF limitation…
createpjf Jun 10, 2026
9a8d54a
fix(cloud): do not reflect ACAO '*' for no-origin requests (H3-sec)
createpjf Jun 10, 2026
6ca123d
feat(gateway): add AES-256-GCM secrets module (M4-sec)
createpjf Jun 10, 2026
d03be60
feat(gateway): encrypt provider keys and auth token at rest (C2c, M4-…
createpjf Jun 10, 2026
7597dc9
feat(cloud): AES-256-GCM secret helpers + enforce encryption key in p…
createpjf Jun 10, 2026
35475ec
feat(cloud): encrypt provider keys at rest, decrypt on read (M4-sec)
createpjf Jun 10, 2026
671f97a
feat(desktop): provision ROUTEBOX_DB_KEY from keychain for at-rest en…
createpjf Jun 10, 2026
55637e3
chore(desktop): document/narrow Tauri entitlements (H4-sec)
createpjf Jun 10, 2026
e8d267e
harden: skip undecryptable provider keys at startup instead of crashi…
createpjf Jun 10, 2026
d2d6886
docs: add Phase 1 plan — packages/llm-core (mechanism-only extraction)
createpjf Jun 11, 2026
2f969b7
feat(llm-core): scaffold shared package + tsconfig path resolution (P…
createpjf Jun 11, 2026
7eec32b
test(gateway): fix suite isolation — call-time token read + raw key t…
createpjf Jun 11, 2026
985b8b0
feat(llm-core): shared types (ProviderFormat, ProviderTemplate, Model…
createpjf Jun 11, 2026
3ee2439
feat(llm-core): data-injected pricing algorithm (pricingForModel, cal…
createpjf Jun 11, 2026
3e51be4
feat(llm-core): data-injected alias resolver
createpjf Jun 11, 2026
6bda204
refactor(gateway): use @routebox/llm-core pricing/alias/types (no beh…
createpjf Jun 11, 2026
40dba72
refactor(cloud): use @routebox/llm-core pricing/types (no behavior ch…
createpjf Jun 11, 2026
b728d8e
chore(llm-core): drop unused LLM_CORE_VERSION smoke-test placeholder
createpjf Jun 11, 2026
6fd3a8f
docs: add Phase 2a plan — gateway routing/correctness (H1, M4, H3)
createpjf Jun 11, 2026
ed633cc
fix(gateway): provider recovery cooldown so 3-strike-down self-heals …
createpjf Jun 11, 2026
59a2965
fix(gateway): fallback only succeeds on 2xx; 4xx returns upstream err…
createpjf Jun 11, 2026
f520ca8
fix(gateway): update route on network-error fallback so served provid…
createpjf Jun 11, 2026
6ab891e
test(gateway): add required id field to reset() RequestRecord (tsc hy…
createpjf Jun 11, 2026
052a628
docs: add Phase 2b plan — streaming robustness (H2, H4, M8)
createpjf Jun 11, 2026
f5ca8ad
fix(gateway): harden SSE transformers for overflow and idle timeout (H4)
createpjf Jun 11, 2026
5e5dee2
fix(gateway): use connect timeout and propagate client abort for stre…
createpjf Jun 11, 2026
f3334cf
fix(cloud): clear request timeout when streaming begins (H2)
createpjf Jun 11, 2026
0c76d98
fix(gateway): estimate stream meta tokens when usage is omitted
createpjf Jun 11, 2026
7a0105a
docs: add Phase 3a plan — cloud accounting correctness
createpjf Jun 11, 2026
9b9853f
fix(cloud): escape Prometheus label values
createpjf Jun 11, 2026
66f466f
fix(cloud): bound provider metric model labels
createpjf Jun 11, 2026
728a25a
fix(cloud): bound retry metric model labels
createpjf Jun 11, 2026
d5661ec
fix(cloud): account for actual served model after routing fallback
createpjf Jun 11, 2026
2286248
test(cloud): cover served model billing regressions
createpjf Jun 11, 2026
b8ea6ec
fix(cloud): roll back starter quota on upstream 4xx
createpjf Jun 11, 2026
9bc3dbf
fix(cloud): log quota rollback failures
createpjf Jun 11, 2026
d855869
docs: add Phase 3b plan — ledger migration stats
createpjf Jun 11, 2026
58e926c
fix(cloud): add transaction idempotency constraints
createpjf Jun 11, 2026
c74c06e
fix(cloud): handle legacy duplicate transaction refs
createpjf Jun 11, 2026
0a45395
fix(cloud): make deposit crediting idempotent
createpjf Jun 11, 2026
6e223be
docs: add Phase 4 plan — cheap-high-value UX
createpjf Jun 11, 2026
d5270f4
fix(desktop): onboarding/settings point to Account tab and actually n…
createpjf Jun 11, 2026
29539d0
test(desktop): add missing User/MessageSquare to lucide-react mock (f…
createpjf Jun 11, 2026
ea0aa6a
fix(desktop): two-step confirm + visible error for key deletion (H2-ux)
createpjf Jun 11, 2026
5d31ee1
fix(desktop): gateway-failed banner is tappable to open Settings (H5-ux)
createpjf Jun 11, 2026
afcf78e
fix(desktop): pause balance polling while panel hidden, refresh on sh…
createpjf Jun 11, 2026
a53c674
fix(desktop): provider startup retry, web-search save error, activity…
createpjf Jun 11, 2026
0a61b61
fix(desktop): make provider startup retry a bounded poll, honest copy…
createpjf Jun 11, 2026
1a32829
docs: add Phase 5 plan — landing CTA, admin responsive, tray status
createpjf Jun 11, 2026
bc0f274
fix(landing): point Pro/Max CTAs to #download instead of looping to s…
createpjf Jun 11, 2026
72d73ce
fix(admin): mobile-responsive layout + remove dead duplicate admin/in…
createpjf Jun 11, 2026
002ea8a
feat(desktop): tray status tooltip + menu actions driven by gateway s…
createpjf Jun 11, 2026
f016147
fix(gateway): make bind host configurable so container deploy is reac…
createpjf Jun 11, 2026
fc51b48
fix(cloud): cast static-file Response body for TS5.7; sync lockfile f…
createpjf Jun 11, 2026
04d6c97
fix(docker): build from repo root so @routebox/llm-core resolves in i…
createpjf Jun 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: apps/gateway
context: .
file: apps/gateway/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Expand Down Expand Up @@ -138,7 +139,8 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: apps/cloud-gateway
context: .
file: apps/cloud-gateway/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,15 @@ jobs:
- uses: actions/checkout@v4

- name: Build Docker image
run: docker build -t routebox-gateway apps/gateway
run: docker build -t routebox-gateway -f apps/gateway/Dockerfile .

- name: Smoke test container
run: |
docker run -d --name gw -p 3001:3001 \
-e ROUTEBOX_TOKEN=test \
-e OPENAI_API_KEY=sk-fake \
routebox-gateway
sleep 2
sleep 3
curl -sf http://localhost:3001/health | grep -q '"status":"ok"'
docker stop gw

Expand All @@ -93,7 +93,7 @@ jobs:
- uses: actions/checkout@v4

- name: Build Docker image
run: docker build -t routebox-cloud-gateway apps/cloud-gateway
run: docker build -t routebox-cloud-gateway -f apps/cloud-gateway/Dockerfile .

- name: Smoke test container
run: |
Expand Down
19 changes: 13 additions & 6 deletions apps/cloud-gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Build context is the REPO ROOT (so packages/llm-core is available).
# CI: docker build -f apps/cloud-gateway/Dockerfile .
# The monorepo layout is preserved in the image so the runtime resolves
# @routebox/llm-core via tsconfig paths (../../packages/llm-core/src).

FROM oven/bun:1-alpine

# Install curl for Docker health checks
Expand All @@ -7,19 +12,21 @@ RUN apk add --no-cache curl
RUN addgroup -g 1001 -S routebox && \
adduser -S routebox -u 1001 -G routebox

WORKDIR /app
WORKDIR /repo

# Install deps
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
COPY apps/cloud-gateway/package.json apps/cloud-gateway/bun.lock* apps/cloud-gateway/
RUN cd apps/cloud-gateway && bun install --frozen-lockfile --production

# Copy source
COPY . .
# Copy source + the shared workspace package (preserve relative layout)
COPY apps/cloud-gateway apps/cloud-gateway
COPY packages packages

# Fix permissions and switch to non-root user
RUN chown -R routebox:routebox /app
RUN chown -R routebox:routebox /repo
USER routebox

WORKDIR /repo/apps/cloud-gateway
EXPOSE 3001

CMD ["bun", "run", "src/index.ts"]
20 changes: 20 additions & 0 deletions apps/cloud-gateway/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@
.mt-2 { margin-top: 8px; }
.mb-2 { margin-bottom: 8px; }
.flex-between { display: flex; align-items: center; justify-content: space-between; }

/* Mobile responsive (<=768px). Desktop (>768px) unchanged. */
@media (max-width: 768px) {
.app { display: block; }
/* Sidebar becomes a horizontal top bar instead of a fixed 220px column. */
.sidebar { position: static; width: 100%; height: auto; bottom: auto; padding: 12px 0; display: flex; flex-wrap: wrap; align-items: center; }
.sidebar h1 { width: 100%; padding: 0 16px 12px; }
.nav-group { display: flex; flex-wrap: wrap; padding: 4px 8px; }
.nav-group + .nav-group { margin-top: 0; border-top: none; padding-top: 4px; }
.nav-section { width: 100%; padding: 8px 16px 4px; }
.nav-item { margin-right: 0; border-radius: 6px; padding: 7px 12px; }
.nav-item.active { border-left: none; padding-left: 12px; }
/* Main content no longer offset by the fixed sidebar. */
.main { margin-left: 0; padding: 16px; }
/* Modals and fixed-width boxes fit the viewport. */
.modal { width: calc(100vw - 24px); max-width: 480px; }
.auth-box { width: calc(100vw - 24px); max-width: 380px; }
/* Wide tables scroll horizontally instead of overflowing. */
table { display: block; overflow-x: auto; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" onerror="window.__chartJsFailed=true"></script>
</head>
Expand Down
1,764 changes: 0 additions & 1,764 deletions apps/cloud-gateway/admin/index.html

This file was deleted.

4 changes: 2 additions & 2 deletions apps/cloud-gateway/landing.html
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ <h2 class="headline text-[32px] mb-2">Simple, transparent</h2>
500 req/min rate limit
</li>
</ul>
<a href="https://api.routebox.dev" class="btn-ember-primary w-full justify-center text-sm py-2.5">Get Pro</a>
<a href="#download" title="Download the app, then upgrade in the Account tab" class="btn-ember-primary w-full justify-center text-sm py-2.5">Get Pro</a>
</div>
</div>

Expand Down Expand Up @@ -592,7 +592,7 @@ <h2 class="headline text-[32px] mb-2">Simple, transparent</h2>
Maximum savings
</li>
</ul>
<a href="https://api.routebox.dev" class="btn-primary w-full justify-center text-sm py-2.5">Get Max</a>
<a href="#download" title="Download the app, then upgrade in the Account tab" class="btn-primary w-full justify-center text-sm py-2.5">Get Max</a>
</div>
</div>

Expand Down
70 changes: 70 additions & 0 deletions apps/cloud-gateway/migrations/025_transaction_idempotency.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
-- ---------------------------------------------------------------------------
-- Migration 025: Transaction idempotency hardening
-- ---------------------------------------------------------------------------

ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS idempotency_key TEXT;

-- Clear legacy duplicate payment refs before enforcing uniqueness. Keep the
-- earliest ledger row for each ref and preserve later rows for audit history.
WITH duplicate_payment_refs AS (
SELECT id
FROM (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY payment_ref ORDER BY created_at, id) AS rn
FROM transactions
WHERE payment_ref IS NOT NULL
) ranked
WHERE rn > 1
)
UPDATE transactions
SET payment_ref = NULL,
description = concat_ws(
' ',
NULLIF(transactions.description, ''),
'[duplicate payment_ref cleared before idempotency index]'
)
FROM duplicate_payment_refs
WHERE transactions.id = duplicate_payment_refs.id;

-- Preserve historical payment references as idempotency keys where possible.
UPDATE transactions
SET idempotency_key = payment_ref
WHERE idempotency_key IS NULL
AND payment_ref IS NOT NULL;

-- Clear legacy duplicate bonus idempotency keys before enforcing uniqueness.
-- Keep the earliest bonus row per user/key and preserve later rows for audit.
WITH duplicate_bonus_idempotency AS (
SELECT id
FROM (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY user_id, type, idempotency_key ORDER BY created_at, id) AS rn
FROM transactions
WHERE type = 'bonus'
AND idempotency_key IS NOT NULL
) ranked
WHERE rn > 1
)
UPDATE transactions
SET idempotency_key = NULL,
description = concat_ws(
' ',
NULLIF(transactions.description, ''),
'[duplicate bonus idempotency_key cleared before idempotency index]'
)
FROM duplicate_bonus_idempotency
WHERE transactions.id = duplicate_bonus_idempotency.id;

-- Prevent concurrent duplicate deposits for the same provider payment/session.
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_payment_ref_unique
ON transactions (payment_ref)
WHERE payment_ref IS NOT NULL;

-- Prevent duplicate bonus application for the same user/reason key while still
-- allowing shared promo names across different users when callers choose that.
CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_bonus_idempotency_unique
ON transactions (user_id, type, idempotency_key)
WHERE type = 'bonus' AND idempotency_key IS NOT NULL;
13 changes: 9 additions & 4 deletions apps/cloud-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ app.use(
"*",
cors({
origin: (origin) => {
// Non-browser requests (Tauri desktop, curl, server-to-server)
if (!origin) return "*";
// H3-sec: 非浏览器请求(无 Origin)不下发 ACAO —— CORS 仅约束浏览器,
// 浏览器请求必带 Origin,故无需通配
if (!origin) return null;
return ALLOWED_ORIGINS.includes(origin) ? origin : null;
},
allowHeaders: ["Content-Type", "Authorization"],
Expand Down Expand Up @@ -146,7 +147,9 @@ app.get("/static/*", async (c) => {
const fileName = c.req.path.replace("/static/", "");
const result = serveStaticFile(fileName);
if (!result) return c.notFound();
return new Response(result.data, {
// result.data is a Uint8Array; cast to satisfy TS 5.7's generic-TypedArray
// BodyInit typing (valid BodyInit at runtime).
return new Response(result.data as BodyInit, {
status: 200,
headers: { "Content-Type": result.contentType, "Cache-Control": "public, max-age=86400, immutable" },
});
Expand All @@ -156,7 +159,9 @@ app.get("/static/*", async (c) => {
app.get("/favicon.ico", async (c) => {
const result = serveStaticFile("favicon.ico");
if (!result) return c.notFound();
return new Response(result.data, {
// result.data is a Uint8Array; cast to satisfy TS 5.7's generic-TypedArray
// BodyInit typing (valid BodyInit at runtime).
return new Response(result.data as BodyInit, {
status: 200,
headers: { "Content-Type": result.contentType, "Cache-Control": "public, max-age=86400, immutable" },
});
Expand Down
38 changes: 33 additions & 5 deletions apps/cloud-gateway/src/lib/credits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ beforeEach(() => {
globalThis.__dbMockTxResults = [];
// @ts-ignore
globalThis.__dbMockSqlCalls = [];
// @ts-ignore
globalThis.__dbMockTxCalls = [];
});

// ── ledger idempotency migration ───────────────────────────────────────────

describe("transaction idempotency migration", () => {
test("adds idempotency_key and unique partial indexes", async () => {
const migration = await Bun.file(
new URL("../../migrations/025_transaction_idempotency.sql", import.meta.url),
).text();

expect(migration).toContain("ADD COLUMN IF NOT EXISTS idempotency_key");
expect(migration).toContain("ROW_NUMBER() OVER (PARTITION BY payment_ref");
expect(migration).toContain("duplicate payment_ref cleared before idempotency index");
expect(migration).toContain("idx_transactions_payment_ref_unique");
expect(migration).toContain("WHERE payment_ref IS NOT NULL");
expect(migration).toContain("ROW_NUMBER() OVER (PARTITION BY user_id, type, idempotency_key");
expect(migration).toContain("duplicate bonus idempotency_key cleared before idempotency index");
expect(migration).toContain("idx_transactions_bonus_idempotency_unique");
expect(migration).toContain("WHERE type = 'bonus' AND idempotency_key IS NOT NULL");
});
});

// ── getBalance ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -105,20 +127,26 @@ describe("addCredits", () => {
test("adds credits and returns new balance", async () => {
// @ts-ignore
globalThis.__dbMockTxResults = [
[], // Check duplicate session
[{ balance_cents: 2500 }], // UPDATE RETURNING
[], // INSERT transaction
[{ id: "tx-claim" }], // INSERT deposit claim
[{ balance_cents: 2500 }], // UPDATE credits RETURNING
[], // UPDATE transaction balance_after_cents
];

const newBalance = await credits.addCredits("user-1", 1000, "cs_test_123", "Top up");
expect(newBalance).toBe(2500);

// @ts-ignore
const txCalls = globalThis.__dbMockTxCalls as unknown[][];
expect(txCalls[0]).toContain("cs_test_123");
expect(txCalls[2]).toContain("tx-claim");
expect(txCalls[2]).toContain(2500);
});

test("returns existing balance for duplicate payment ref", async () => {
// @ts-ignore
globalThis.__dbMockTxResults = [
[{ id: "existing-tx" }],
[{ balance_cents: 1500 }],
[], // INSERT claim hit ON CONFLICT DO NOTHING
[{ balance_cents: 1500 }], // Current balance lookup
];

const newBalance = await credits.addCredits("user-1", 1000, "cs_duplicate");
Expand Down
30 changes: 18 additions & 12 deletions apps/cloud-gateway/src/lib/credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,21 @@ export async function addCredits(
description?: string,
): Promise<number> {
const result = await withTx(async (tx) => {
// Check for duplicate payment_ref to prevent double-crediting
const [existing] = await tx`
SELECT id FROM transactions
WHERE payment_ref = ${paymentRef}
const desc = description ?? "Credit purchase";

const [claim] = await tx`
INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents,
description, payment_ref, idempotency_key)
VALUES (${userId}, 'deposit', ${amountCents}, 0, ${desc}, ${paymentRef}, ${paymentRef})
ON CONFLICT (payment_ref) WHERE payment_ref IS NOT NULL DO NOTHING
RETURNING id
`;
if (existing) {
const [row] = await tx`SELECT balance_cents FROM credits WHERE user_id = ${userId}`;
return (row?.balance_cents as number) ?? 0;

if (!claim) {
const [current] = await tx`
SELECT balance_cents FROM credits WHERE user_id = ${userId}
`;
return (current?.balance_cents as number) ?? 0;
}

const [row] = await tx`
Expand All @@ -115,13 +122,12 @@ export async function addCredits(
RETURNING balance_cents
`;

const newBalance = row.balance_cents as number;
const newBalance = (row?.balance_cents as number) ?? 0;

await tx`
INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents,
description, payment_ref)
VALUES (${userId}, 'deposit', ${amountCents}, ${newBalance},
${description ?? 'Credit purchase'}, ${paymentRef})
UPDATE transactions
SET balance_after_cents = ${newBalance}
WHERE id = ${claim.id}
`;

return newBalance;
Expand Down
28 changes: 28 additions & 0 deletions apps/cloud-gateway/src/lib/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test, expect, beforeEach } from "bun:test";

const KEY_HEX = "a".repeat(64);

beforeEach(() => {
process.env.PROVIDER_KEY_ENCRYPTION_KEY = KEY_HEX;
});

test("encrypt then decrypt round-trips", async () => {
const { encryptSecret, decryptSecret } = await import("./crypto");
const plain = "sk-cloud-secret-001";
const enc = encryptSecret(plain);
expect(enc.startsWith("enc:v1:")).toBe(true);
expect(enc).not.toContain(plain);
expect(decryptSecret(enc)).toBe(plain);
});

test("decrypt passes through legacy plaintext", async () => {
const { decryptSecret } = await import("./crypto");
expect(decryptSecret("sk-legacy")).toBe("sk-legacy");
});

test("sha256Hex still works", async () => {
const { sha256Hex } = await import("./crypto");
expect(await sha256Hex("abc")).toBe(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
});
Loading
Loading