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
61 changes: 38 additions & 23 deletions .github/workflows/register.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
- 'deploy/system-wrangler.toml'
- '.github/workflows/register.yml'
workflow_dispatch:
inputs:
skip_preflight:
description: 'Skip health/status preflight (use when CF WAF blocks GH runners)'
type: boolean
default: false

concurrency:
group: register-${{ github.ref }}
Expand All @@ -16,43 +21,53 @@ concurrency:
jobs:
preflight:
name: Preflight Checks
if: ${{ github.event.inputs.skip_preflight != 'true' }}
runs-on: ubuntu-latest
outputs:
health_ok: ${{ steps.health.outputs.ok }}
status_ok: ${{ steps.status.outputs.ok }}
steps:
- name: Health endpoint
- name: Health endpoint (with retry)
id: health
run: |
resp=$(curl -sf https://finance.chitty.cc/health 2>/dev/null || echo '{}')
status=$(echo "$resp" | jq -r '.status // empty')
if [ "$status" = "ok" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
echo "Health check passed: $resp"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::error::Health check failed: $resp"
exit 1
fi
for attempt in 1 2 3; do
resp=$(curl -sS --max-time 10 https://finance.chitty.cc/health 2>&1 || echo '{}')
status=$(echo "$resp" | jq -r '.status // empty' 2>/dev/null)
if [ "$status" = "ok" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
echo "Health check passed (attempt $attempt): $resp"
exit 0
fi
echo "Attempt $attempt failed: $resp"
[ "$attempt" -lt 3 ] && sleep $((attempt * 5))
done
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::error::Health check failed after 3 attempts (last response: $resp)"
exit 1

- name: Status endpoint
- name: Status endpoint (with retry)
id: status
run: |
resp=$(curl -sf https://finance.chitty.cc/api/v1/status 2>/dev/null || echo '{}')
version=$(echo "$resp" | jq -r '.version // empty')
mode=$(echo "$resp" | jq -r '.mode // empty')
if [ -n "$version" ] && [ "$mode" = "system" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
echo "Status check passed: v${version} mode=${mode}"
else
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::error::Status check failed: $resp"
exit 1
fi
for attempt in 1 2 3; do
resp=$(curl -sS --max-time 10 https://finance.chitty.cc/api/v1/status 2>&1 || echo '{}')
version=$(echo "$resp" | jq -r '.version // empty' 2>/dev/null)
mode=$(echo "$resp" | jq -r '.mode // empty' 2>/dev/null)
if [ -n "$version" ] && [ "$mode" = "system" ]; then
echo "ok=true" >> "$GITHUB_OUTPUT"
echo "Status check passed (attempt $attempt): v${version} mode=${mode}"
exit 0
fi
echo "Attempt $attempt failed: $resp"
[ "$attempt" -lt 3 ] && sleep $((attempt * 5))
done
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::error::Status check failed after 3 attempts (last response: $resp)"
exit 1

register:
name: Submit Registration
needs: preflight
if: ${{ always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/security-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,6 @@ jobs:
run: |
set -euo pipefail
cd "${{ steps.pkg.outputs.dir }}"
pnpm audit --prod --audit-level high
# CVE-2024-45296: picomatch ReDoS in transitive deps (neonctl, tailwindcss)
# Cannot be resolved via overrides β€” parent packages pin vulnerable versions
pnpm audit --prod --audit-level high --ignore CVE-2024-45296
142 changes: 142 additions & 0 deletions scripts/rotate-db-secret.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""
rotate-db-secret.py

1. Reads the Neon API key from 1Password Connect.
2. Resets neondb_owner password on the ChittyRental Neon project via the
Neon API.
3. Builds the pooled DATABASE_URL entirely in Python β€” the credential never
touches a shell variable or a command-line argument.
4. Writes the DATABASE_URL to a temp file (mode 0600, deleted after use),
then execs wrangler secret put reading from that file via stdin so the
value is never exposed in ps/env output.

Run:
python3 scripts/rotate-db-secret.py
"""

import json
import os
import stat
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
Comment on lines +23 to +24
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

urllib.error is imported but never used. Remove the import, or (if you add the suggested request error handling) use it for HTTPError/URLError catching.

Copilot uses AI. Check for mistakes.

# ── Config ────────────────────────────────────────────────────────────────────

OP_HOST = os.environ.get("OP_CONNECT_HOST", "").rstrip("/")
OP_TOKEN = os.environ.get("OP_CONNECT_TOKEN", "")
Comment on lines +28 to +29
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

OP_CONNECT_HOST/OP_CONNECT_TOKEN are allowed to be empty, which will cause op_get() to build an invalid URL and crash with a stack trace. Add an explicit preflight check that both env vars are set (and ideally that OP_CONNECT_HOST includes a scheme), and exit with a clear error message before making any requests.

Copilot uses AI. Check for mistakes.

NEON_KEY_VAULT = "oxwo63jlcbo66c7kwx67lquw4i" # ChittyOS-Core
NEON_KEY_ITEM = "yze3gaaxpopweq5b7uab6sq4ji" # chittyfoundation_neon_api_key
NEON_KEY_FIELD = "neon_api_key"

NEON_PROJECT = "young-mouse-42795827" # ChittyRental
NEON_BRANCH = "br-hidden-hill-ajef0w5d"
NEON_ROLE = "neondb_owner"
NEON_DB = "neondb"
POOLER_HOST = "ep-delicate-breeze-aj9gmu1i-pooler.c-3.us-east-2.aws.neon.tech"

# ── Helpers ───────────────────────────────────────────────────────────────────

def op_get(path):
req = urllib.request.Request(
f"{OP_HOST}{path}",
headers={"Authorization": f"Bearer {OP_TOKEN}"},
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
Comment on lines +44 to +49
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

urlopen() is called without a timeout, so the script can hang indefinitely if 1Password Connect becomes unreachable. Pass an explicit timeout and consider catching HTTPError/URLError to print a controlled error and exit non-zero.

Copilot uses AI. Check for mistakes.


def neon_post(path, api_key):
req = urllib.request.Request(
f"https://console.neon.tech/api/v2{path}",
data=b"",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())


# ── Step 1: retrieve Neon API key from 1Password ──────────────────────────────

print("[1] Retrieving Neon API key from 1Password Connect...", file=sys.stderr)
item = op_get(f"/v1/vaults/{NEON_KEY_VAULT}/items/{NEON_KEY_ITEM}")
neon_api_key = next(
(f["value"] for f in item.get("fields", []) if f.get("label") == NEON_KEY_FIELD),
None,
)
if not neon_api_key:
print("ERROR: neon_api_key field not found or empty", file=sys.stderr)
sys.exit(1)
print("[1] OK", file=sys.stderr)

# ── Step 2: reset neondb_owner password via Neon API ─────────────────────────

print(f"[2] Resetting {NEON_ROLE} password on project {NEON_PROJECT}...", file=sys.stderr)
reset = neon_post(
f"/projects/{NEON_PROJECT}/branches/{NEON_BRANCH}/roles/{NEON_ROLE}/reset_password",
neon_api_key,
)
new_password = reset.get("role", {}).get("password", "")
if not new_password:
print(f"ERROR: no password in Neon reset response: {json.dumps(reset)[:200]}", file=sys.stderr)
sys.exit(1)
print("[2] Password reset OK", file=sys.stderr)

# ── Step 3: build DATABASE_URL entirely in Python ────────────────────────────

database_url = (
f"postgresql://{NEON_ROLE}:{new_password}"
f"@{POOLER_HOST}/{NEON_DB}?sslmode=require"
)
print("[3] DATABASE_URL constructed", file=sys.stderr)

# ── Step 4: write DATABASE_URL to a 0600 temp file, pipe into wrangler ────────

print("[4] Writing DATABASE_URL to secure temp file and calling wrangler...", file=sys.stderr)

wrangler_config = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"deploy", "system-wrangler.toml",
)

fd, tmp_path = tempfile.mkstemp(prefix="chittyfinance_db_", suffix=".tmp")
try:
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
with os.fdopen(fd, "w") as fh:
fh.write(database_url)
fh.flush()

with open(tmp_path, "r") as stdin_fh:
result = subprocess.run(
[
"npx", "wrangler", "secret", "put", "DATABASE_URL",
"--config", wrangler_config,
],
Comment on lines +118 to +121
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

This runs wrangler secret put without --env. In this repo, deploy/system-wrangler.toml documents secrets as being set per-environment (e.g. --env production), so this script may update the wrong secret namespace and not affect production. Add an explicit --env production (or a CLI/env option to choose the env) so the rotation reliably updates the intended Worker environment.

Copilot uses AI. Check for mistakes.
stdin=stdin_fh,
capture_output=True,
text=True,
)

if result.returncode == 0:
print("[4] wrangler secret put succeeded", file=sys.stderr)
print(result.stdout, file=sys.stderr)
else:
print(f"ERROR: wrangler exited {result.returncode}", file=sys.stderr)
print(result.stdout, file=sys.stderr)
print(result.stderr, file=sys.stderr)
sys.exit(1)
finally:
try:
os.unlink(tmp_path)
print("[4] Temp file deleted", file=sys.stderr)
except OSError:
pass

print("[5] Done. DATABASE_URL secret updated on chittyfinance Worker.", file=sys.stderr)
Loading