Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions .deploy-config.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Private deployment configuration. Copy this to .deploy-config or let
# ./deploy.sh create it interactively. Do not commit the real file.

PROJECT_NAME=finance
DATABASE_NAME=finance-db
DATABASE_ID=your-d1-database-id
API_SECRET=your-browser-api-key
ALLOWED_ORIGINS=https://finance.example.com
# API_URL is written by deploy.sh after the API Worker deploys. Set it manually
# only when Wrangler cannot report the worker URL.
API_URL=https://finance-api.example.workers.dev

# The root deploy asks once and remembers this choice. Use ./deploy.sh --mcp or
# ./deploy.sh --no-mcp to change it later.
DEPLOY_MCP=false
MCP_WORKER_NAME=finance-mcp
MCP_ACCESS_TEAM_DOMAIN=https://your-team.cloudflareaccess.com
MCP_ACCESS_AUD=your-access-application-audience-tag
MCP_ALLOWED_EMAIL=you@example.com
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ dev-dist
.dev.vars
api/wrangler.toml
api/wrangler.prod.toml
mcp/wrangler.toml
mcp/.dev.vars
.deploy-config

# Database backups (may contain sensitive data)
Expand All @@ -136,4 +138,3 @@ client/.env.production
.DS_Store
Thumbs.db
sw.js

12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,22 @@ npx wrangler pages deploy dist --project-name=finance-client

### Automated Deployment

Use the deployment script to deploy everything at once:
Use the deployment script to deploy the database, API, and client together:

```bash
./deploy.sh finance-client
npm run deploy
```

This script handles:
1. **Database schema updates** — Applies migrations to your D1 database
2. **API deployment** — Deploys backend to Cloudflare Workers
3. **Client build & deploy** — Builds React app and deploys to Cloudflare Pages

On its first run it also asks whether to deploy the read-only Finance MCP
Worker. That choice is stored privately in `.deploy-config`; use
`npm run deploy:mcp` to include MCP immediately, or `npm run deploy -- --no-mcp`
to change the saved default. See [the MCP deployment guide](mcp/README.md).

---

## Architecture
Expand Down Expand Up @@ -582,8 +587,7 @@ curl -H "X-API-Key: your-key" \

---

**API Version**: 1.1.4
**Client Version**: 1.2.3
**Application Version**: 2.5
**License**: MIT
**Maintained by**: apptrackit

Expand Down
56 changes: 1 addition & 55 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,61 +116,7 @@ app.onError((err, c) => {
return c.json({ error: 'Internal server error', code: 'INTERNAL_ERROR' }, 500)
})

// Helper to check if the public API feature is enabled
function isPublicApiEnabled(apiKey: string | undefined): boolean {
if (!apiKey) return false
const disabledValues = ['', 'off', 'disabled', 'none', 'your-public-api-key-here']
return !disabledValues.includes(apiKey.toLowerCase())
}

// Public API endpoint - bypasses CORS, only requires API key
// This endpoint can be accessed via curl with X-API-Key header
// Set PUBLIC_API_KEY to 'off' or leave empty to disable this feature
app.get('/public/recent-expenses', async (c) => {
// Check if public API feature is enabled
if (!isPublicApiEnabled(c.env.PUBLIC_API_KEY)) {
return c.json({ error: 'Public API is disabled' }, 404)
}

// Check API key (uses dedicated PUBLIC_API_KEY)
const apiKey = c.req.header('X-API-Key')
if (!apiKey || apiKey !== c.env.PUBLIC_API_KEY) {
return c.json({ error: 'Unauthorized' }, 401)
}

const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()

// Start of last month
const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1
const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear
const startDate = `${lastMonthYear}-${String(lastMonth + 1).padStart(2, '0')}-01`

// End of current month
const endOfMonth = new Date(currentYear, currentMonth + 1, 0)
const endDate = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(endOfMonth.getDate()).padStart(2, '0')}`

const transactionRepo = new TransactionRepository(c.env.DB)
const transactions = await transactionRepo.findRecentExpensesFromCashAccounts(startDate, endDate)

// Return only essential transaction data with names
const result = transactions.map(t => ({
amount: t.amount,
description: t.description,
date: t.date,
account: t.account_name,
category: t.category_name || null
}))

return c.json({
period: { start: startDate, end: endDate },
count: result.length,
transactions: result
})
})

// Apply CORS middleware globally (except for public endpoints defined above)
// Apply CORS middleware globally.
app.use('/*', corsMiddleware)

// Apply authentication middleware globally (except OPTIONS which is handled by CORS)
Expand Down
25 changes: 0 additions & 25 deletions api/src/repositories/transaction.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@ type RawTransactionRow = Omit<Transaction, 'exclude_from_estimate'> & {
status?: TransactionStatus | null
}

type RawExpenseRow = RawTransactionRow & {
account_name: string
category_name: string | null
}

type D1Value = string | number | null

export class TransactionRepository {
Expand Down Expand Up @@ -216,24 +211,4 @@ export class TransactionRepository {
&& cleanupResult.meta.changes === 0
}

async findRecentExpensesFromCashAccounts(startDate: string, endDate: string): Promise<(Transaction & { account_name: string; category_name?: string })[]> {
const query = `
SELECT t.*, a.name as account_name, c.name as category_name
FROM transactions t
INNER JOIN accounts a ON t.account_id = a.id
LEFT JOIN categories c ON t.category_id = c.id
WHERE a.type = 'cash'
AND t.amount < 0
AND t.status = 'posted'
AND t.date >= ?
AND t.date <= ?
ORDER BY t.date DESC
`
const { results } = await this.db.prepare(query).bind(startDate, endDate).all<RawExpenseRow>()
return results.map(r => ({
...this.mapTransaction(r),
account_name: r.account_name,
category_name: r.category_name ?? undefined
}))
}
}
1 change: 0 additions & 1 deletion api/src/types/environment.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@ export type Bindings = {
DB: D1Database
API_SECRET: string
ALLOWED_ORIGINS?: string // Comma-separated list of allowed origins
PUBLIC_API_KEY?: string // API key for public endpoints (curl access)
}
3 changes: 0 additions & 3 deletions api/wrangler.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,3 @@ crons = ["0 0 * * *"] # Run daily at midnight to process recurring schedules
[vars]
API_SECRET = "your-api-secret-here"
ALLOWED_ORIGINS = "https://your-frontend-domain.com"
# PUBLIC_API_KEY enables /public/* endpoints for curl access
# Set to a secure random string to enable, or "off"/"disabled"/empty to disable
PUBLIC_API_KEY = "off" # Change to a secure key to enable public API endpoints
142 changes: 132 additions & 10 deletions deploy.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
#!/bin/bash
set -e

MCP_PREFERENCE_OVERRIDE=""
case "${1:-}" in
"") ;;
--mcp) MCP_PREFERENCE_OVERRIDE="true" ;;
--no-mcp) MCP_PREFERENCE_OVERRIDE="false" ;;
*)
echo "Usage: ./deploy.sh [--mcp|--no-mcp]" >&2
exit 2
;;
esac

RED='\033[0;31m'
GREEN='\033[0;32m'
DIM='\033[2m'
Expand Down Expand Up @@ -52,16 +63,38 @@ step() {

# ─── Config helpers ───────────────────────────────────────────────────────────

CONFIG_FILE="$(git rev-parse --show-toplevel 2>/dev/null || echo .)/.deploy-config"
ROOT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT_DIR"
CONFIG_FILE="${ROOT_DIR}/.deploy-config"
Comment thread
Copilot marked this conversation as resolved.

get_cfg() { grep "^${1}=" "$CONFIG_FILE" 2>/dev/null | cut -d= -f2-; }
get_cfg() {
awk -v key="$1" '
index($0, key "=") == 1 { value = substr($0, length(key) + 2) }
END { if (value != "") print value }
' "$CONFIG_FILE" 2>/dev/null
}

set_cfg() {
if grep -q "^${1}=" "$CONFIG_FILE" 2>/dev/null; then
sed -i '' "s|^${1}=.*|${1}=${2}|" "$CONFIG_FILE"
local key="$1" value="$2" tmp
tmp=$(mktemp "${CONFIG_FILE}.XXXXXX")
if [ -f "$CONFIG_FILE" ]; then
# Keep the last known value and collapse any duplicate entries for this key.
awk -v key="$key" -v value="$value" '
index($0, key "=") == 1 {
if (!written) {
print key "=" value
written = 1
}
next
}
{ print }
END { if (!written) print key "=" value }
' "$CONFIG_FILE" > "$tmp"
else
echo "${1}=${2}" >> "$CONFIG_FILE"
printf '%s=%s\n' "$key" "$value" > "$tmp"
fi
mv "$tmp" "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
}

need() {
Expand All @@ -75,12 +108,64 @@ need() {
printf "%s: " "$label" >&2; read -r val
fi
[ -z "$val" ] && err "${label} is required."
set_cfg "$key" "$val"
fi
set_cfg "$key" "$val"
printf '%s' "$val"
}

need_default() {
local key="$1" default="$2" val
val=$(get_cfg "$key")
[ -n "$val" ] || val="$default"
set_cfg "$key" "$val"
printf '%s' "$val"
}

legacy_mcp_value() {
local key="$1" file="${ROOT_DIR}/mcp/wrangler.toml"
[ -f "$file" ] || return 0
sed -nE "s/^[[:space:]]*${key}[[:space:]]*=[[:space:]]*\"([^\"]*)\"[[:space:]]*(#.*)?$/\1/p" "$file" | head -n 1
}

need_with_fallback() {
local key="$1" label="$2" fallback="$3" secret="${4:-}" val
val=$(get_cfg "$key")
[ -n "$val" ] || val="$fallback"
if [ -z "$val" ]; then
if [ -n "$secret" ]; then
printf "%s: " "$label" >&2; read -rs val; echo >&2
else
printf "%s: " "$label" >&2; read -r val
fi
[ -z "$val" ] && err "${label} is required."
fi
set_cfg "$key" "$val"
printf '%s' "$val"
}

d1() { npx wrangler d1 execute finance-db --remote --yes --config wrangler.prod.toml "$@"; }
resolve_mcp_preference() {
local saved answer

if [ -n "$MCP_PREFERENCE_OVERRIDE" ]; then
DEPLOY_MCP="$MCP_PREFERENCE_OVERRIDE"
else
saved=$(get_cfg "DEPLOY_MCP")
case "$saved" in
true|yes|y|1) DEPLOY_MCP="true" ;;
false|no|n|0) DEPLOY_MCP="false" ;;
"")
printf "Deploy the read-only MCP server too? (y/N) "
read -r answer
[[ "$answer" =~ ^[Yy]$ ]] && DEPLOY_MCP="true" || DEPLOY_MCP="false"
;;
*) err "DEPLOY_MCP must be true or false in .deploy-config." ;;
esac
fi

set_cfg "DEPLOY_MCP" "$DEPLOY_MCP"
}

d1() { npx wrangler d1 execute "$DATABASE_NAME" --remote --yes --config wrangler.prod.toml "$@"; }

migration_is_new() {
local count
Expand All @@ -106,9 +191,19 @@ fi
echo ""
PROJECT_NAME=$(need PROJECT_NAME "Project name")
DATABASE_ID=$(need DATABASE_ID "Database ID")
DATABASE_NAME=$(need_default DATABASE_NAME "finance-db")
API_SECRET=$(need API_SECRET "API secret" secret)
ORIGINS=$(need ALLOWED_ORIGINS "Allowed origins (comma-separated)")
PUB_KEY=$(need PUBLIC_API_KEY "Public API key (or 'off')")
resolve_mcp_preference

if [ "$DEPLOY_MCP" = "true" ]; then
LEGACY_MCP_WORKER_NAME=$(legacy_mcp_value "name")
[ -n "$LEGACY_MCP_WORKER_NAME" ] || LEGACY_MCP_WORKER_NAME="finance-mcp"
MCP_WORKER_NAME=$(need_with_fallback MCP_WORKER_NAME "MCP Worker name" "$LEGACY_MCP_WORKER_NAME")
MCP_ACCESS_TEAM_DOMAIN=$(need_with_fallback MCP_ACCESS_TEAM_DOMAIN "Cloudflare Access team domain" "$(legacy_mcp_value "CF_ACCESS_TEAM_DOMAIN")")
MCP_ACCESS_AUD=$(need_with_fallback MCP_ACCESS_AUD "Cloudflare Access application audience" "$(legacy_mcp_value "CF_ACCESS_AUD")")
MCP_ALLOWED_EMAIL=$(need_with_fallback MCP_ALLOWED_EMAIL "Allowed MCP email" "$(legacy_mcp_value "ALLOWED_EMAIL")")
fi
echo ""

# ─── Wrangler check ───────────────────────────────────────────────────────────
Expand Down Expand Up @@ -149,7 +244,7 @@ __dirname = "'/'"

[[d1_databases]]
binding = "DB"
database_name = "finance-db"
database_name = "${DATABASE_NAME}"
database_id = "${DATABASE_ID}"

[triggers]
Expand All @@ -159,7 +254,7 @@ TOML
# ─── Secrets ──────────────────────────────────────────────────────────────────

echo " Secrets"
for entry in "API_SECRET:${API_SECRET}" "ALLOWED_ORIGINS:${ORIGINS}" "PUBLIC_API_KEY:${PUB_KEY}"; do
for entry in "API_SECRET:${API_SECRET}" "ALLOWED_ORIGINS:${ORIGINS}"; do
key="${entry%%:*}"
val="${entry#*:}"
spin " %-20s" "$key" &
Expand Down Expand Up @@ -236,6 +331,33 @@ else
exit 1
fi

# ─── MCP ─────────────────────────────────────────────────────────────────────

if [ "$DEPLOY_MCP" = "true" ]; then
cd ../mcp

cat > wrangler.toml << TOML
name = "${MCP_WORKER_NAME}"
main = "src/index.ts"
compatibility_date = "2026-07-01"
workers_dev = false

[[d1_databases]]
binding = "DB"
database_name = "${DATABASE_NAME}"
database_id = "${DATABASE_ID}"

[vars]
CF_ACCESS_TEAM_DOMAIN = "${MCP_ACCESS_TEAM_DOMAIN}"
CF_ACCESS_AUD = "${MCP_ACCESS_AUD}"
ALLOWED_EMAIL = "${MCP_ALLOWED_EMAIL}"
TOML

step "MCP tests" npm run test
step "MCP typecheck" npm run build
step "MCP deploy" npx wrangler deploy --minify --config wrangler.toml
fi

# ─── Client ───────────────────────────────────────────────────────────────────

cd ../client
Expand Down
Loading
Loading