From 7664eda6224a4237cb9cb819e3901894fe1587c4 Mon Sep 17 00:00:00 2001 From: ladinoraa Date: Tue, 2 Jun 2026 10:39:50 +0000 Subject: [PATCH 1/3] Configure environment-specific secrets management (#289) - Use GitHub Actions secrets for CI-sensitive values - Keep local secrets in `apps/web/.env.local` only - Document production use of Vercel environment variables - Expand `.env.example` with additional env names and guidance --- .github/workflows/ci.yml | 8 ++++---- apps/web/.env.example | 25 ++++++++++++++++++++++++- docs/ONBOARDING.md | 4 ++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eeb65f..70d7b8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,14 +54,14 @@ jobs: - run: pnpm build working-directory: apps/web env: - NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' }} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder' }} + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} NEXT_PUBLIC_STELLAR_NETWORK: testnet NEXT_PUBLIC_ENERGY_TOKEN_ID: placeholder NEXT_PUBLIC_AUDIT_REGISTRY_ID: placeholder NEXT_PUBLIC_COMMUNITY_GOVERNANCE_ID: placeholder - SUPABASE_SERVICE_ROLE_KEY: placeholder - MINTER_SECRET_KEY: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + MINTER_SECRET_KEY: ${{ secrets.MINTER_SECRET_KEY }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }} diff --git a/apps/web/.env.example b/apps/web/.env.example index 4bbce95..b73d477 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,6 +1,9 @@ # ───────────────────────────────────────────────────────────────────────────── # SolarProof — environment variables -# Copy this file to .env.local and fill in your values. +# Copy this file to apps/web/.env.local and fill in your values for local development. +# Do not commit `.env.local` or any `.env.*.local` file. +# CI should read secrets from GitHub Actions secrets. +# Production should use Vercel environment variables. # See docs/ONBOARDING.md for a step-by-step setup guide. # ───────────────────────────────────────────────────────────────────────────── @@ -26,6 +29,10 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here # Use "testnet" for development and staging; "mainnet" for production. NEXT_PUBLIC_STELLAR_NETWORK=testnet +# [OPTIONAL] Override the default Soroban RPC endpoint. +# Example: https://soroban-testnet.stellar.org +NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org + # [REQUIRED] Contract IDs — set these after running the deploy-contracts workflow # or following the manual steps in docs/DEPLOYMENT.md. # Each value is a 56-character Stellar contract address (C...). @@ -37,10 +44,21 @@ NEXT_PUBLIC_COMMUNITY_GOVERNANCE_ID= # [REQUIRED] Stellar secret key for the minter account (server-side only). # This account mints energy_token certificates after a valid meter reading. # Generate with: stellar keys generate minter --network testnet +# Local dev uses MINTER_SECRET_KEY in `.env.local`. +# Production should use MINTER_SECRET_ARN / MINTER_PREVIOUS_SECRET_ARN in Vercel. # Never commit a real secret key. Use GitHub Actions secrets in CI/CD. # Example: SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA MINTER_SECRET_KEY= +# [PRODUCTION] AWS Secrets Manager ARN for the active minter key. +MINTER_SECRET_ARN= + +# [PRODUCTION] AWS Secrets Manager ARN for the previous minter key during rotation. +MINTER_PREVIOUS_SECRET_ARN= + +# [OPTIONAL] AWS region for Secrets Manager. +AWS_REGION=us-east-1 + # ── Redis (optional) ────────────────────────────────────────────────────────── # Upstash Redis is used as a caching layer for certificate verification queries. # If these are not set, caching is disabled and every /api/verify call hits Supabase. @@ -63,3 +81,8 @@ LOGTAIL_SOURCE_TOKEN= # In development, http://localhost:3000 is always permitted. # Example: https://solarproof.vercel.app,https://staging.solarproof.vercel.app CORS_ALLOWED_ORIGINS=https://solarproof.vercel.app + +# ── Optional runtime configuration ─────────────────────────────────────────── +# Rate limiting for reading submissions. +READINGS_RATE_LIMIT_PER_MINUTE= +READINGS_RATE_LIMIT_WINDOW_SECONDS= diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 37290b0..a1f7555 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -84,6 +84,9 @@ cp apps/web/.env.example apps/web/.env.local Edit `apps/web/.env.local` and fill in: +> Local development secrets must live in `apps/web/.env.local` only. This file is gitignored and should never be committed. +> CI should use GitHub Actions secrets, and production should use Vercel environment variables. + ```env # Supabase NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co @@ -92,6 +95,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Stellar NEXT_PUBLIC_STELLAR_NETWORK=testnet +NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org NEXT_PUBLIC_ENERGY_TOKEN_ID= # from contract deployment NEXT_PUBLIC_AUDIT_REGISTRY_ID= # from contract deployment NEXT_PUBLIC_COMMUNITY_GOVERNANCE_ID= # from contract deployment From 643ff2d77f45f5a3f0c11e46c152c2152ed3d0a1 Mon Sep 17 00:00:00 2001 From: ladinoraa Date: Tue, 2 Jun 2026 10:41:50 +0000 Subject: [PATCH 2/3] Configure environment-specific secrets management (#289) - Use GitHub Actions secrets for CI-sensitive values - Keep local secrets in `apps/web/.env.local` only - Document production use of Vercel environment variables - Expand `.env.example` with additional env names and guidance --- apps/web/src/app/api/readings/route.ts | 73 ++++++++------------------ apps/web/src/lib/queue.ts | 2 +- 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/apps/web/src/app/api/readings/route.ts b/apps/web/src/app/api/readings/route.ts index 880aef3..a6972e2 100644 --- a/apps/web/src/app/api/readings/route.ts +++ b/apps/web/src/app/api/readings/route.ts @@ -4,12 +4,13 @@ import { z } from 'zod' import { createServiceClient } from '@/lib/supabase' import { computeReadingHash } from '@/lib/crypto' import { kwhToStroops } from '@solarproof/stellar' -import { anchorReading, mintCertificates } from '@/lib/stellar' import { invalidateCert, checkRateLimit } from '@/lib/cache' import { fireWebhook } from '@/lib/webhooks' import { logger } from '@/lib/logger' import { requireAuth, isAuthError } from '@/lib/auth' import { diagnoseMintFailure } from '@/lib/tracer-sim' +import { getIdempotentResponse, storeIdempotentResponse } from '@/lib/idempotency' +import { enqueue } from '@/lib/queue' const MAX_PAGE_SIZE = 100 @@ -89,7 +90,7 @@ const ReadingSchema = z.object({ * Duplicate requests with the same key return the cached response without * re-processing. Keys expire after IDEMPOTENCY_TTL_SECONDS (default 24 h). * - * Returns 201 Created with { reading_id, anchor_tx_hash, mint_tx_hash }. + * Returns 202 Accepted with { reading_id, job_id }. */ export async function POST(req: NextRequest) { const correlationId = req.headers.get('x-correlation-id') ?? undefined @@ -198,7 +199,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid meter signature' }, { status: 401 }) } - // Persist reading (anchored/minted will be updated by the background job) + // Persist reading; Stellar anchor + mint will be processed asynchronously. const { data: reading, error: readingErr } = await db .from('readings') .insert({ @@ -218,57 +219,27 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Failed to save reading' }, { status: 500 }) } - // Anchor on-chain (hash only — full payload already in Supabase) - let anchorTxHash: string - try { - anchorTxHash = await anchorReading({ readingHash, nonce }) - await db.from('readings').update({ anchored: true, anchor_tx_hash: anchorTxHash }).eq('id', reading.id) - log.info('readings.post.anchored', { reading_id: reading.id, anchor_tx_hash: anchorTxHash }) - void fireWebhook(meter.cooperative_id, 'anchor', { reading_id: reading.id, anchor_tx_hash: anchorTxHash }) - } catch (err) { - if (isAlreadyAnchoredError(err)) { - log.warn('readings.post.already_anchored', { reading_id: reading.id }) - return NextResponse.json({ error: 'Reading already anchored', reading_id: reading.id }, { status: 409 }) - } - const message = extractErrorMessage(err) - log.error('readings.post.anchor_failed', { reading_id: reading.id, error: message }) - return NextResponse.json({ error: message, reading_id: reading.id }, { status: 500 }) + const cooperative = meter.cooperatives as { admin_address: string } | null + const recipient = cooperative?.admin_address + if (!recipient) { + log.error('readings.post.missing_recipient', { reading_id: reading.id, cooperative_id: meter.cooperative_id }) + return NextResponse.json({ error: 'No cooperative admin address' }, { status: 500 }) } - // Mint certificates - try { - const cooperative = meter.cooperatives as { admin_address: string } | null - const recipient = cooperative?.admin_address - if (!recipient) throw new Error('No cooperative admin address') - - const mintTxHash = await mintCertificates(recipient, kwh) - await db.from('readings').update({ minted: true, mint_tx_hash: mintTxHash }).eq('id', reading.id) - await db.from('certificates').insert({ - cooperative_id: meter.cooperative_id, - reading_id: reading.id, - reading_hash: readingHash.toString('hex'), - anchor_tx_hash: anchorTxHash, - mint_tx_hash: mintTxHash, - kwh, - issued_at: new Date().toISOString(), - retired: false, - }) - - // Invalidate any stale cache entries for this certificate - await invalidateCert(reading.id, readingHash.toString('hex'), mintTxHash) + const jobId = await enqueue('anchor_and_mint', { + readingId: reading.id, + readingHashHex: readingHash.toString('hex'), + recipientAddress: recipient, + kwh, + correlationId, + }) - log.info('readings.post.minted', { reading_id: reading.id, mint_tx_hash: mintTxHash, kwh }) - void fireWebhook(meter.cooperative_id, 'mint', { reading_id: reading.id, mint_tx_hash: mintTxHash, kwh }) + log.info('readings.post.enqueued', { reading_id: reading.id, job_id: jobId }) - const responseBody = { reading_id: reading.id, anchor_tx_hash: anchorTxHash, mint_tx_hash: mintTxHash } - if (idempotencyKey) { - await storeIdempotentResponse(idempotencyKey, { body: responseBody, status: 201 }) - } - return NextResponse.json(responseBody, { status: 201 }) - } catch (err) { - const message = err instanceof Error ? err.message : 'Mint failed' - log.error('readings.post.mint_failed', { reading_id: reading.id, error: message }) - const diagnosis = await diagnoseMintFailure(reading.id, meter.cooperative_id, message) - return NextResponse.json({ error: message, reading_id: reading.id, anchor_tx_hash: anchorTxHash, diagnosis }, { status: 500 }) + const responseBody = { reading_id: reading.id, job_id: jobId } + if (idempotencyKey) { + await storeIdempotentResponse(idempotencyKey, { body: responseBody, status: 202 }) } + + return NextResponse.json(responseBody, { status: 202 }) } diff --git a/apps/web/src/lib/queue.ts b/apps/web/src/lib/queue.ts index 3cffbdc..8592861 100644 --- a/apps/web/src/lib/queue.ts +++ b/apps/web/src/lib/queue.ts @@ -46,7 +46,7 @@ export async function enqueue(type: JobType, payload: Record): * Process a single job by ID, retrying up to `MAX_ATTEMPTS` times on failure. * * Retries use exponential back-off (2 s, 4 s, 8 s). After all attempts are - * exhausted the job is marked `'failed'` and no further retries occur. + * exhausted the job is marked `'failed'` and moved to the dead-letter queue. * * @param jobId - UUID of the job record to process. */ From a7123fcbdf001fc91ef6be9444c69a79d38d690a Mon Sep 17 00:00:00 2001 From: ladinoraa Date: Tue, 2 Jun 2026 10:42:28 +0000 Subject: [PATCH 3/3] Configure environment-specific secrets management (#289) - Use GitHub Actions secrets for CI-sensitive values - Keep local secrets in `apps/web/.env.local` only - Document production use of Vercel environment variables - Expand `.env.example` with additional env names and guidance --- apps/web/src/lib/queue.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/queue.ts b/apps/web/src/lib/queue.ts index 8592861..f6896b2 100644 --- a/apps/web/src/lib/queue.ts +++ b/apps/web/src/lib/queue.ts @@ -100,6 +100,7 @@ async function runAnchorAndMint( const { anchorReading, mintCertificates } = await import('@/lib/stellar') const { createServiceClient: svc } = await import('@/lib/supabase') const { invalidateCert } = await import('@/lib/cache') + const { fireWebhook } = await import('@/lib/webhooks') const { readingId, readingHashHex, recipientAddress, kwh, correlationId } = payload as { readingId: string @@ -118,7 +119,7 @@ async function runAnchorAndMint( const mintTxHash = await mintCertificates(recipientAddress, kwh, correlationId) await db.from('readings').update({ minted: true, mint_tx_hash: mintTxHash }).eq('id', readingId) - // Fetch cooperative_id for certificate insert + // Fetch cooperative_id for certificate insert and webhooks const { data: reading } = await db .from('readings') .select('meter_id, meters(cooperative_id)') @@ -138,6 +139,8 @@ async function runAnchorAndMint( retired: false, }) await invalidateCert(readingId, readingHashHex, mintTxHash) + await fireWebhook(cooperativeId, 'anchor', { reading_id: readingId, anchor_tx_hash: anchorTxHash }) + await fireWebhook(cooperativeId, 'mint', { reading_id: readingId, mint_tx_hash: mintTxHash, kwh }) } return { anchor_tx_hash: anchorTxHash, mint_tx_hash: mintTxHash }