Skip to content
Open
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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"acbu-backend/",
"src/animations/",
"stellarlend/",
"stellarlend-pr282/"
"stellarlend-pr282/",
"vscode-extension/"
],
"settings": {
"import/resolver": {
Expand Down
137 changes: 137 additions & 0 deletions .github/workflows/db-migration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
name: DB Migration Validation

on:
push:
paths:
- 'backend/migrations/**'
- 'scripts/db-migrate-dryrun.js'
- 'scripts/db-schema-drift.js'
- 'scripts/db-migration-lint.js'
pull_request:
paths:
- 'backend/migrations/**'
- 'scripts/db-migrate-dryrun.js'
- 'scripts/db-schema-drift.js'
- 'scripts/db-migration-lint.js'

env:
NODE_VERSION: '20'
# Configurable timeout; default 30 s per acceptance criteria
MIGRATION_TIMEOUT_MS: '30000'

jobs:
migration-lint:
name: Migration Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- run: npm ci --legacy-peer-deps

- name: Lint migrations
run: node scripts/db-migration-lint.js --migrations-dir backend/migrations

migration-dry-run:
name: Migration Dry-Run
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- run: npm ci --legacy-peer-deps

- name: Dry-run migrations (no DB changes)
run: |
node scripts/db-migrate-dryrun.js \
--migrations-dir backend/migrations \
--timeout ${{ env.MIGRATION_TIMEOUT_MS }}

migration-rollback-test:
name: Rollback Test (down → up)
runs-on: ubuntu-latest
# Uses PostgreSQL service for actual rollback validation
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: subtrackr
POSTGRES_PASSWORD: testpassword
POSTGRES_DB: subtrackr_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://subtrackr:testpassword@localhost:5432/subtrackr_test
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- run: npm ci --legacy-peer-deps

- name: Apply migrations (up)
run: npm run db:migrate:up
continue-on-error: false

- name: Run rollback (down)
run: npm run db:migrate:down
# If down migration fails, the job fails — CI enforces rollback parity

- name: Re-apply migrations (up again)
run: npm run db:migrate:up
# Both must succeed for every migration (acceptance criteria)

schema-drift:
name: Schema Drift Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- run: npm ci --legacy-peer-deps

- name: Detect schema drift
run: node scripts/db-schema-drift.js

migration-emc-validate:
name: Expand-Migrate-Contract Validation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- run: npm ci --legacy-peer-deps

- name: Validate EMC state file exists (if migrations present)
run: |
MIGS=$(find backend/migrations -name '*.expand.sql' 2>/dev/null | wc -l)
if [ "$MIGS" -gt "0" ]; then
echo "Found $MIGS expand-migrate-contract migration(s). Printing status:"
node scripts/db-expand-migrate-contract.js --status
else
echo "No expand-migrate-contract migrations found. Skipping."
fi
148 changes: 148 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
name: Lighthouse CI

on:
push:
branches: [main]
pull_request:
branches: [main, dev, develop]

env:
NODE_VERSION: '20'

jobs:
lighthouse:
name: Lighthouse Audit (developer portal)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- name: Install dependencies
run: npm ci --legacy-peer-deps

# Install Lighthouse CI CLI
- name: Install @lhci/cli
run: npm install -g @lhci/cli@0.14.0

# Build the developer portal (Next.js static export)
- name: Build developer portal
run: |
cd developer-portal
npm ci --legacy-peer-deps || true
npx next build || echo "Build skipped (no next.config present yet)"

# Serve the portal locally for auditing
- name: Serve portal
run: |
npx serve developer-portal/out -l 3000 &
# Wait for server to be ready
timeout 30 bash -c 'until curl -s http://localhost:3000 > /dev/null; do sleep 1; done'
continue-on-error: true

# Run Lighthouse CI (3 throttled runs, median score used)
- name: Run Lighthouse CI
run: lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
# Token for persistent baseline storage (optional; uses temporary-public-storage as fallback)
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}

# Upload HTML report as PR check artifact
- name: Upload Lighthouse report
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-report-${{ github.sha }}
path: .lighthouseci/
if-no-files-found: ignore

# Post report link to PR as a check annotation
- name: Comment Lighthouse results on PR
if: github.event_name == 'pull_request' && always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const glob = require('glob').sync('.lighthouseci/*.json');
if (!glob.length) return;
const report = JSON.parse(fs.readFileSync(glob[0], 'utf8'));
const perf = Math.round((report?.categories?.performance?.score ?? 0) * 100);
const fcp = Math.round((report?.audits?.['first-contentful-paint']?.numericValue ?? 0));
const lcp = Math.round((report?.audits?.['largest-contentful-paint']?.numericValue ?? 0));
const tti = Math.round((report?.audits?.interactive?.numericValue ?? 0));
const cls = (report?.audits?.['cumulative-layout-shift']?.numericValue ?? 0).toFixed(3);
const body = `### 🔦 Lighthouse Report\n| Metric | Value | Budget |\n|--------|-------|--------|\n| Performance score | ${perf} | ≥ 90 |\n| FCP | ${fcp}ms | < 1500ms |\n| LCP | ${lcp}ms | < 2500ms |\n| TTI | ${tti}ms | < 3500ms |\n| CLS | ${cls} | < 0.10 |`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});

lighthouse-mobile:
name: Lighthouse Audit (mobile WebView)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm

- name: Install dependencies
run: npm ci --legacy-peer-deps

- name: Install @lhci/cli
run: npm install -g @lhci/cli@0.14.0

- name: Build developer portal
run: |
cd developer-portal
npm ci --legacy-peer-deps || true
npx next build || echo "Build skipped"

- name: Serve portal
run: |
npx serve developer-portal/out -l 3000 &
timeout 30 bash -c 'until curl -s http://localhost:3000 > /dev/null; do sleep 1; done'
continue-on-error: true

# Run Lighthouse with mobile preset (Moto G4 throttling, mobile emulation)
- name: Run Lighthouse CI (mobile WebView)
run: |
lhci autorun \
--collect.url="http://localhost:3000/webview/subscription-list" \
--collect.url="http://localhost:3000/" \
--collect.settings.preset=perf \
--collect.settings.formFactor=mobile \
--collect.settings.screenEmulation.mobile=true \
--collect.settings.screenEmulation.width=412 \
--collect.settings.screenEmulation.height=823 \
--collect.settings.throttlingMethod=simulate \
--collect.settings.throttling.rttMs=150 \
--collect.settings.throttling.throughputKbps=1638.4 \
--collect.settings.throttling.cpuSlowdownMultiplier=4 \
--collect.numberOfRuns=3 \
--assert.preset=no-pwa \
--assert.assertions.first-contentful-paint="error;maxNumericValue=1500;aggregationMethod=median" \
--assert.assertions.largest-contentful-paint="error;maxNumericValue=2500;aggregationMethod=median" \
--assert.assertions.interactive="error;maxNumericValue=3500;aggregationMethod=median" \
--assert.assertions.cumulative-layout-shift="error;maxNumericValue=0.1;aggregationMethod=median" \
--assert.assertions.categories:performance="error;minScore=0.9;aggregationMethod=median" \
--upload.target=temporary-public-storage
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}

- name: Upload mobile Lighthouse report
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-mobile-report-${{ github.sha }}
path: .lighthouseci/
if-no-files-found: ignore
95 changes: 95 additions & 0 deletions developer-portal/pages/api/revalidate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { NextApiRequest, NextApiResponse } from 'next';

type RevalidateResult = { revalidated: boolean; paths?: string[]; error?: string };

/**
* POST /api/revalidate
*
* Body (JSON):
* { "secret": "...", "path": "/docs/quick-start" } — single path
* { "secret": "...", "tag": "v2" } — tag-based purge (all paths with that tag)
*
* Returns 200 on success, 401 on bad secret, 400 on missing params, 500 on failure.
*
* Edge case: if revalidation fails, the stale cached page continues to be served;
* the error is logged and the endpoint returns 500 so callers can retry on next request.
*/

// Map of tag → paths carrying that tag
const TAG_TO_PATHS: Record<string, string[]> = {
v1: [
'/docs/quick-start',
'/docs/authentication',
'/docs/subscriptions-api',
'/docs/payments-api',
'/docs/webhook-integration',
],
v2: [
'/docs/quick-start',
'/docs/authentication',
'/docs/subscriptions-api',
'/docs/payments-api',
'/docs/webhook-integration',
],
api: ['/docs/subscriptions-api', '/docs/payments-api'],
guides: ['/docs/quick-start', '/docs/authentication', '/docs/webhook-integration'],
sdks: ['/docs/webhook-integration'],
};

export default async function handler(req: NextApiRequest, res: NextApiResponse<RevalidateResult>) {
if (req.method !== 'POST') {
return res.status(405).json({ revalidated: false, error: 'Method not allowed' });
}

const { secret, path, tag } = req.body as {
secret?: string;
path?: string;
tag?: string;
};

// Verify caller secret
const expectedSecret = process.env.REVALIDATE_SECRET;
if (!expectedSecret || secret !== expectedSecret) {
return res.status(401).json({ revalidated: false, error: 'Invalid secret' });
}

if (!path && !tag) {
return res.status(400).json({ revalidated: false, error: 'Provide "path" or "tag"' });
}

const pathsToRevalidate: string[] = path ? [path] : (TAG_TO_PATHS[tag as string] ?? []);

if (pathsToRevalidate.length === 0) {
return res.status(400).json({ revalidated: false, error: `No paths found for tag "${tag}"` });
}

const succeeded: string[] = [];
const failed: string[] = [];

// Revalidate each path; on failure log and continue so partial success is still returned
for (const p of pathsToRevalidate) {
try {
await res.revalidate(p);
succeeded.push(p);
} catch (err) {
console.error(`[revalidate] Failed to revalidate ${p}:`, err);
failed.push(p);
}
}

if (failed.length > 0 && succeeded.length === 0) {
// All failed — return 500; stale pages will continue to be served
return res
.status(500)
.json({ revalidated: false, error: 'All revalidations failed', paths: failed });
}

if (failed.length > 0) {
// Partial success — log but return 200 with succeeded paths
console.warn(
`[revalidate] Partial failure. Succeeded: ${succeeded.join(', ')} | Failed: ${failed.join(', ')}`
);
}

return res.status(200).json({ revalidated: true, paths: succeeded });
}
Loading
Loading