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
60 changes: 60 additions & 0 deletions .github/workflows/env-secret-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Env Secret Scan

on:
pull_request:
paths:
- '.env*'
push:
branches: [main]
paths:
- '.env*'

jobs:
secret-scan:
name: Scan tracked .env files for secrets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Scan .env files for high-entropy secret patterns
run: |
ENV_FILES=$(git ls-files '.env*')
if [[ -z "$ENV_FILES" ]]; then
echo "No tracked .env files found."
exit 0
fi

FOUND=0
# Patterns that indicate real credentials (not placeholders)
SECRET_PATTERNS=(
'sk_live_[A-Za-z0-9]'
'sk_test_[A-Za-z0-9]'
'rk_live_[A-Za-z0-9]'
'AKIA[0-9A-Z]{16}'
'ghp_[A-Za-z0-9]{36}'
'gho_[A-Za-z0-9]{36}'
'github_pat_[A-Za-z0-9]{22}'
'xox[baprs]-[A-Za-z0-9]'
'-----BEGIN.*PRIVATE KEY-----'
'eyJhbGciOi[A-Za-z0-9_-]+\.eyJ'
'postgres(ql)?://[^:]+:[^@]+@'
'redis://:[^@]+@'
'mongodb(\+srv)?://[^:]+:[^@]+@'
)

for file in $ENV_FILES; do
for pattern in "${SECRET_PATTERNS[@]}"; do
if grep -qE "$pattern" "$file" 2>/dev/null; then
echo "❌ High-entropy secret pattern found in $file (pattern: $pattern)"
FOUND=1
fi
done
done

if [[ "$FOUND" -eq 1 ]]; then
echo ""
echo "Tracked .env files must contain only placeholders — no live credentials."
exit 1
fi

echo "✅ No secrets found in tracked .env files."
63 changes: 63 additions & 0 deletions .github/workflows/repo-hygiene.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Repo Hygiene

on:
pull_request:
paths:
- '**'

jobs:
reject-clutter:
name: Reject PR clutter files at repo root
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check for forbidden clutter files
run: |
# Files that must not appear at the repo root
FORBIDDEN_PATTERNS=(
'PR_*SUMMARY*.md'
'IMPLEMENTATION_SUMMARY.md'
'*FIX*.md'
'*GUIDE*.md'
'verify_*.sh'
'fix-*.sh'
)
# Directories that must not appear
FORBIDDEN_DIRS=(
'docs/superpowers/plans'
)

VIOLATIONS=""

# Check files added in this PR
ADDED=$(git diff --name-only --diff-filter=A origin/${{ github.base_ref }}...HEAD)

for file in $ADDED; do
# Only check repo root (no / in path)
if [[ "$file" != */* ]]; then
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
if [[ "$file" == $pattern ]]; then
VIOLATIONS="$VIOLATIONS\n FORBIDDEN FILE: $file"
fi
done
fi
# Check forbidden directories
for dir in "${FORBIDDEN_DIRS[@]}"; do
if [[ "$file" == "$dir"/* ]]; then
VIOLATIONS="$VIOLATIONS\n FORBIDDEN PATH: $file"
fi
done
done

if [[ -n "$VIOLATIONS" ]]; then
echo -e "❌ Repo hygiene check failed. The following files are not allowed:$VIOLATIONS"
echo ""
echo "Durable guidance belongs in docs/ (MIGRATIONS.md, RUNBOOK.md, etc.)."
echo "See CONTRIBUTING.md for the full policy."
exit 1
fi

echo "✅ Repo hygiene check passed."
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ node_modules
.env
.env.*
!.env.example
!.env.development
!.env.production
!.env.test
dist
logs/
postgres/
Expand Down
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ Every issue carries exactly one primary type label, plus community and dispositi

When a triage pass (a **Backlog Cleanup** issue) touches a batch of issues, list every item and its outcome in the pass issue so the decision trail stays auditable.

## Repo Hygiene

Durable guidance belongs in `docs/` (MIGRATIONS.md, RUNBOOK.md, etc.). The CI job in `.github/workflows/repo-hygiene.yml` **fails a PR** when it adds any of these at the repo root:

- `PR_*SUMMARY*.md`, `IMPLEMENTATION_SUMMARY.md`, `*FIX*.md`, `*GUIDE*.md`
- `verify_*.sh`, `fix-*.sh`
- Anything under `docs/superpowers/plans/`

One-off implementation notes, fix scripts, and rehearsal guides do not belong in the repository root. If you find yourself writing one, put the durable parts into `docs/` and leave the rest in the PR description.

## Env File Policy

Tracked env files (`.env.example`, `.env.development`, `.env.production`, `.env.test`) contain **placeholders only** — never real credentials. The `.gitignore` explicitly allows exactly these four files. The CI job in `.github/workflows/env-secret-scan.yml` fails if a high-entropy secret pattern is found in any tracked `.env*` file.

## Need Help?

- Check existing [documentation](docs/)
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"dev": "nodemon --exec ts-node src/app.ts",
"build": "tsc -p tsconfig.build.json",
"test": "jest",
"pretest": "npx prisma generate",
"prebuild": "npm run clean && npm run build:spec && npx prisma generate",
"test:unit": "jest tests/unit",
"test:integration": "jest tests/integration",
"prisma:generate": "npx prisma generate",
Expand All @@ -33,7 +35,6 @@
"docs:rate-limits": "ts-node scripts/rate-limit-doc-tables.ts",
"docs:scopes": "ts-node scripts/scope-doc-tables.ts",
"build:spec": "mkdir -p dist/docs && cp docs/openapi.yaml dist/docs/openapi.yaml",
"prebuild": "npm run clean && npm run build:spec",
"prestart": "npm run build",
"release:preflight": "bash scripts/release-preflight.sh"
},
Expand Down
78 changes: 62 additions & 16 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2624,6 +2624,12 @@ router.get(
'/erasure/requests',
requireAdminScope('read'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { status, limit, offset } = req.query
const result = await listErasureRequests({
Expand Down Expand Up @@ -2661,6 +2667,12 @@ router.post(
erasureRateLimiter,
requireAdminScope('write'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { userId, reason } = req.body
const adminAuth = res.locals.adminAuth
Expand Down Expand Up @@ -2710,9 +2722,14 @@ router.post(
erasureRateLimiter,
requireAdminScope('super'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { id } = req.params
const adminAuth = res.locals.adminAuth

const request = await approveErasureRequest(id, adminAuth.name)

Expand Down Expand Up @@ -2757,6 +2774,12 @@ router.post(
erasureRateLimiter,
requireAdminScope('super'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { id } = req.params
const { rejectionReason } = req.body
Expand Down Expand Up @@ -2819,9 +2842,14 @@ router.post(
erasureRateLimiter,
requireAdminScope('super'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { id } = req.params
const adminAuth = res.locals.adminAuth
const confirm =
typeof req.body?.confirm === 'string' ? req.body.confirm : undefined

Expand Down Expand Up @@ -2878,6 +2906,12 @@ router.get(
erasureRateLimiter,
requireAdminScope('read'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { userId } = req.params

Expand Down Expand Up @@ -2917,6 +2951,12 @@ router.get(
'/referrals/review',
requireAdminScope('read'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const conversions = await listConversionsForReview()

Expand Down Expand Up @@ -2946,9 +2986,14 @@ router.post(
'/referrals/review/:id/approve',
requireAdminScope('write'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { id } = req.params
const adminAuth = res.locals.adminAuth

await approveReferralConversion(id, adminAuth.name)

Expand Down Expand Up @@ -2986,10 +3031,15 @@ router.post(
'/referrals/review/:id/reject',
requireAdminScope('write'),
async (req: Request, res: Response) => {
const adminAuth = getAdminAuth(res)
if (!adminAuth) {
return res
.status(401)
.json({ success: false, error: 'Admin authentication required' })
}
try {
const { id } = req.params
const { rejectionReason } = req.body
const adminAuth = res.locals.adminAuth

if (!rejectionReason) {
return res.status(400).json({
Expand Down Expand Up @@ -3235,22 +3285,18 @@ router.put(
}

if (allowlist !== undefined && !Array.isArray(allowlist)) {
res
.status(400)
.json({
success: false,
error: 'allowlist must be an array of strings',
})
res.status(400).json({
success: false,
error: 'allowlist must be an array of strings',
})
return
}

if (blocklist !== undefined && !Array.isArray(blocklist)) {
res
.status(400)
.json({
success: false,
error: 'blocklist must be an array of strings',
})
res.status(400).json({
success: false,
error: 'blocklist must be an array of strings',
})
return
}

Expand Down
2 changes: 1 addition & 1 deletion src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export { redactSensitiveData, SENSITIVE_PATTERNS }
*/
export function logBackgroundJob(
jobName: string,
status: 'success' | 'failed',
status: 'success' | 'failed' | 'dry_run',
durationSeconds: number,
correlationId?: string,
details?: Record<string, any>
Expand Down
2 changes: 1 addition & 1 deletion src/utils/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ export function recordAnalyticsRequest(
*/
export function recordBackgroundJob(
job: string,
status: 'success' | 'failed',
status: 'success' | 'failed' | 'dry_run',
durationSeconds: number
): void {
backgroundJobsTotal.inc({ job, status })
Expand Down