ab57b8a
commit ab57b8a
6 files changed
+96 -32
Showing 6 changed files+96-32
▼.github/workflows/ci.yml
+26-1
| @@ -176,6 +176,21 @@ jobs: | ||
176 | 176 | runs-on: ubuntu-latest |
177 | 177 | needs: test |
178 | 178 | timeout-minutes: 20 |
179 | + services: | |
180 | + postgres: | |
181 | + image: postgres:16-alpine | |
182 | + env: | |
183 | + POSTGRES_USER: test | |
184 | + POSTGRES_PASSWORD: test | |
185 | + POSTGRES_DB: test | |
186 | + ports: | |
187 | + - 5432:5432 | |
188 | + options: >- | |
189 | + --health-cmd pg_isready | |
190 | + --health-interval 10s | |
191 | + --health-timeout 5s | |
192 | + --health-retries 5 | |
193 | + | |
179 | 194 | steps: |
180 | 195 | - uses: actions/checkout@v4 |
181 | 196 | |
| @@ -196,12 +211,22 @@ jobs: | ||
196 | 211 | - name: Install Playwright browsers |
197 | 212 | run: bunx playwright install --with-deps chromium |
198 | 213 | |
214 | + - name: Push Drizzle schema to test database | |
215 | + env: | |
216 | + DATABASE_URL: postgresql://test:test@localhost:5432/test | |
217 | + DATABASE_DRIVER: postgres | |
218 | + run: bunx drizzle-kit push --force | |
219 | + | |
199 | 220 | - name: Run E2E tests |
200 | 221 | run: bunx playwright test |
201 | 222 | env: |
202 | - DATABASE_URL: "file:./test.db" | |
223 | + DATABASE_URL: postgresql://test:test@localhost:5432/test | |
224 | + DATABASE_DRIVER: postgres | |
203 | 225 | JWT_SECRET: "ci-test-secret-key-for-e2e-tests" |
204 | 226 | SITE_URL: "http://localhost:4321" |
227 | + INTERNAL_HOOK_SECRET: "ci-test-internal-hook-secret" | |
228 | + CSRF_SKIP_DEV: "true" | |
229 | + RATE_LIMIT_SKIP_DEV: "true" | |
205 | 230 | |
206 | 231 | - name: Upload Playwright report |
207 | 232 | if: always() |
208 | 233 | |
▼playwright.config.ts
+6-2
| @@ -23,8 +23,12 @@ export default defineConfig({ | ||
23 | 23 | reuseExistingServer: !process.env.CI, |
24 | 24 | timeout: 120_000, |
25 | 25 | env: { |
26 | - // E2E tests need these to bypass security middleware and ensure the | |
27 | - // pre-receive git hook can reach the dev server on localhost. | |
26 | + // Inherit the parent process env so CI can inject DATABASE_URL, JWT_SECRET, | |
27 | + // SITE_URL, etc. without us having to mirror them here. | |
28 | + ...process.env, | |
29 | + // E2E-specific overrides: bypass security middleware so tests don't get | |
30 | + // rate-limited, and pin the site URL to the dev server so the | |
31 | + // pre-receive git hook can reach it. | |
28 | 32 | RATE_LIMIT_SKIP_DEV: 'true', |
29 | 33 | CSRF_SKIP_DEV: 'true', |
30 | 34 | SITE_URL: 'http://localhost:4321', |
31 | 35 | |
▼scripts/security/audit.mjs
+34-16
| @@ -15,7 +15,7 @@ | ||
15 | 15 | * 1 — fail (disallowed high/critical vulnerability present) |
16 | 16 | * 2 — script error |
17 | 17 | */ |
18 | -import { existsSync, readFileSync, statSync } from "node:fs"; | |
18 | +import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs"; | |
19 | 19 | import { resolve } from "node:path"; |
20 | 20 | import { spawnSync } from "node:child_process"; |
21 | 21 | |
| @@ -29,21 +29,26 @@ const log = (level, msg) => console.log(`[${new Date().toISOString()}] [${level} | ||
29 | 29 | |
30 | 30 | function regenerateLockfile() { |
31 | 31 | log("info", "Regenerating package-lock.json from package.json (--package-lock-only)…"); |
32 | - const r = spawnSync( | |
33 | - "npm", | |
34 | - [ | |
35 | - "install", | |
36 | - "--package-lock-only", | |
37 | - "--no-audit", | |
38 | - "--no-fund", | |
39 | - `--registry=${AUDIT_REGISTRY}`, | |
40 | - ], | |
41 | - { stdio: "inherit" } | |
42 | - ); | |
43 | - if (r.status !== 0) { | |
44 | - log("error", "Failed to regenerate package-lock.json"); | |
45 | - process.exit(2); | |
32 | + // Some npm versions crash with `Cannot read properties of null (reading 'matches')` | |
33 | + // when the existing lockfile is stale or contains shapes they don't expect. | |
34 | + // Try the simple invocation first; fall back to deleting and regenerating | |
35 | + // from scratch; finally fall back to auditing whatever lockfile exists. | |
36 | + const attempts = [ | |
37 | + ["npm", ["install", "--package-lock-only", "--no-audit", "--no-fund", `--registry=${AUDIT_REGISTRY}`]], | |
38 | + ["npm", ["install", "--package-lock-only", "--no-audit", "--no-fund", "--ignore-scripts", "--legacy-peer-deps", `--registry=${AUDIT_REGISTRY}`]], | |
39 | + ]; | |
40 | + for (let i = 0; i < attempts.length; i++) { | |
41 | + const [cmd, args] = attempts[i]; | |
42 | + const r = spawnSync(cmd, args, { stdio: "inherit" }); | |
43 | + if (r.status === 0) return; | |
44 | + log("warn", `Lockfile regen attempt ${i + 1} failed (exit ${r.status})`); | |
45 | + } | |
46 | + if (existsSync(LOCKFILE)) { | |
47 | + log("warn", "Falling back to existing package-lock.json (may be stale)"); | |
48 | + return; | |
46 | 49 | } |
50 | + log("error", "Failed to regenerate package-lock.json and no fallback exists"); | |
51 | + process.exit(2); | |
47 | 52 | } |
48 | 53 | |
49 | 54 | function lockfileIsFresh() { |
| @@ -65,13 +70,26 @@ function loadAllowlist() { | ||
65 | 70 | |
66 | 71 | function runAudit() { |
67 | 72 | log("info", `Running npm audit (registry=${AUDIT_REGISTRY})…`); |
73 | + if (!existsSync(LOCKFILE)) { | |
74 | + log("error", "No package-lock.json to audit against"); | |
75 | + process.exit(2); | |
76 | + } | |
68 | 77 | const r = spawnSync( |
69 | 78 | "npm", |
70 | 79 | ["audit", `--registry=${AUDIT_REGISTRY}`, "--json"], |
71 | 80 | { encoding: "utf8" } |
72 | 81 | ); |
73 | 82 | |
74 | - const report = JSON.parse(r.stdout || "null") ?? {}; | |
83 | + // npm may exit non-zero when vulnerabilities are found AND when the lockfile | |
84 | + // is malformed. We rely on the JSON report regardless of exit code. | |
85 | + let report = {}; | |
86 | + try { | |
87 | + report = JSON.parse(r.stdout || "{}"); | |
88 | + } catch (e) { | |
89 | + log("error", `Could not parse npm audit output: ${e.message}`); | |
90 | + if (r.stderr) console.error(r.stderr); | |
91 | + process.exit(2); | |
92 | + } | |
75 | 93 | const meta = report.metadata?.vulnerabilities ?? {}; |
76 | 94 | const summary = { |
77 | 95 | critical: meta.critical ?? 0, |
78 | 96 | |
▼src/db/index.ts
+19-4
| @@ -26,19 +26,34 @@ let db: | ||
26 | 26 | | NodePgDatabase<typeof schema> |
27 | 27 | | null = null; |
28 | 28 | |
29 | +/** | |
30 | + * Infer database driver from connection URL when no explicit driver is set. | |
31 | + * Keeps `DATABASE_DRIVER` authoritative when present (production safety) but | |
32 | + * makes `file:./test.db` work in CI without extra config. | |
33 | + */ | |
34 | +function inferDriverFromUrl(url: string): DatabaseDriver { | |
35 | + if (url.startsWith("file:")) return "sqlite"; | |
36 | + if (url.startsWith("libsql:") || url.startsWith("https:")) return "libsql"; | |
37 | + if (url.startsWith("postgres://") || url.startsWith("postgresql://")) return "postgres"; | |
38 | + return "postgres"; | |
39 | +} | |
40 | + | |
29 | 41 | /** |
30 | 42 | * Get database configuration from environment |
31 | 43 | */ |
32 | 44 | function getDbConfig(): { driver: DatabaseDriver; url: string } { |
33 | - // Prefer Astro/Vite env (loaded from .env), then fallback to process.env for scripts. | |
45 | + // process.env wins when explicitly set (CI runners, scripts, systemd), | |
46 | + // then Astro/Vite import.meta.env (loaded from .env), then a sensible default. | |
47 | + // This way CI can set `DATABASE_URL=file:./test.db` without a .env override. | |
34 | 48 | const envDriver = |
35 | - import.meta.env?.DATABASE_DRIVER || process.env.DATABASE_DRIVER; | |
36 | - const envUrl = import.meta.env?.DATABASE_URL || process.env.DATABASE_URL; | |
49 | + process.env.DATABASE_DRIVER || import.meta.env?.DATABASE_DRIVER; | |
50 | + const envUrl = | |
51 | + process.env.DATABASE_URL || import.meta.env?.DATABASE_URL; | |
37 | 52 | |
38 | 53 | // Default to PostgreSQL since all schemas use pgTable |
39 | - const driver = (envDriver || "postgres") as DatabaseDriver; | |
40 | 54 | const url = |
41 | 55 | envUrl || "postgresql://opencodehub:opencodehub@localhost:5432/opencodehub"; |
56 | + const driver = (envDriver || inferDriverFromUrl(url)) as DatabaseDriver; | |
42 | 57 | |
43 | 58 | return { driver, url }; |
44 | 59 | } |
45 | 60 | |
▼src/lib/auth.ts
+6-6
| @@ -40,13 +40,13 @@ export interface SessionData { | ||
40 | 40 | let _jwtSecret: Uint8Array | null = null; |
41 | 41 | function getJwtSecret(): Uint8Array { |
42 | 42 | if (!_jwtSecret) { |
43 | + // process.env wins when explicitly set (CI runners, scripts, systemd). | |
43 | 44 | // Vite/Astro exposes .env values on import.meta.env at build time and |
44 | - // only injects vars prefixed with PUBLIC_ into the client bundle. Server | |
45 | - // code can read the full set via import.meta.env, but process.env is | |
46 | - // not populated for unprefixed keys. Fall back to process.env for | |
47 | - // production-style deployments. | |
48 | - const fromMeta = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env?.JWT_SECRET; | |
49 | - const raw = fromMeta || process.env.JWT_SECRET; | |
45 | + // only injects vars prefixed with PUBLIC_ into the client bundle, so | |
46 | + // server code must fall back to import.meta.env for unprefixed keys | |
47 | + // when .env is the only source. | |
48 | + const metaEnv = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env; | |
49 | + const raw = process.env.JWT_SECRET || metaEnv?.JWT_SECRET; | |
50 | 50 | if (!raw) { |
51 | 51 | throw new Error("JWT_SECRET environment variable is required"); |
52 | 52 | } |
53 | 53 | |
▼src/lib/git.ts
+5-3
| @@ -1562,13 +1562,15 @@ export async function installHooks(repoPath: string) { | ||
1562 | 1562 | } |
1563 | 1563 | |
1564 | 1564 | // Use environment variable for site URL, fallback to localhost for development |
1565 | + // process.env wins when explicitly set (CI runners, scripts, systemd); | |
1566 | + // otherwise fall back to import.meta.env (loaded from .env). | |
1567 | + const metaEnv = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env; | |
1565 | 1568 | const siteUrl = |
1566 | - (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env?.SITE_URL || | |
1567 | 1569 | process.env.SITE_URL || |
1570 | + metaEnv?.SITE_URL || | |
1568 | 1571 | process.env.PUBLIC_URL || |
1569 | 1572 | "http://localhost:3000"; |
1570 | - const metaEnv = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env; | |
1571 | - const hookSecret = metaEnv?.INTERNAL_HOOK_SECRET || process.env.INTERNAL_HOOK_SECRET; | |
1573 | + const hookSecret = process.env.INTERNAL_HOOK_SECRET || metaEnv?.INTERNAL_HOOK_SECRET; | |
1572 | 1574 | if (!hookSecret) { |
1573 | 1575 | throw new Error( |
1574 | 1576 | "INTERNAL_HOOK_SECRET environment variable is required to install git hooks", |
1575 | 1577 | |