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
85 changes: 51 additions & 34 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# ==========================================
# Web3 Student Lab - Environment Configuration
# ==========================================

# ===================================# Web3 Student Lab - Environment Configuration
# ===================================
# ------------------------------------------
# Server Configuration
# ------------------------------------------
Expand Down Expand Up @@ -194,10 +192,8 @@ FRONTEND_URL="http://localhost:3000"
# Backend URL (used in OAuth and other callbacks)
BACKEND_URL="http://localhost:8080"

# ============================================
# Database Backup Configuration
# ============================================
# Nightly backup cron schedule (default: daily at 2:00 AM)
# =====================================# Database Backup Configuration
# =====================================# Nightly backup cron schedule (default: daily at 2:00 AM)
BACKUP_CRON_SCHEDULE="0 2 * * *"

# Number of days to retain backups in S3
Expand All @@ -209,10 +205,8 @@ BACKUP_COMPRESS=true
# Temporary directory for backup files
BACKUP_TEMP_DIR=/tmp/backups

# ============================================
# AWS S3 Backup Storage
# ============================================
# S3 bucket for database backups
# =====================================# AWS S3 Backup Storage
# =====================================# S3 bucket for database backups
BACKUP_S3_BUCKET=""

# AWS region for the S3 bucket
Expand All @@ -226,18 +220,15 @@ BACKUP_S3_SECRET_ACCESS_KEY=""
# Leave empty to use default AWS S3 endpoint
BACKUP_S3_ENDPOINT=""

# ============================================
# Metrics / Monitoring (see METRICS_DOCUMENTATION.md)
# ============================================
# Shared secret monitoring agents send as X-Metrics-Token or Authorization: Bearer.
# =====================================# Metrics / Monitoring (see METRICS_DOCUMENTATION.md)
# =====================================# Shared secret monitoring agents send as X-Metrics-Token or Authorization: Bearer.
# Required in production: without it the metrics endpoints return 503.
METRICS_AUTH_TOKEN=""

# Max metrics scrapes per minute per identity
METRICS_RATE_LIMIT=120
# Payload Encryption Key Rotation
# ============================================
# AES-256-GCM keys used to encrypt sensitive payload fields at rest.
# =====================================# AES-256-GCM keys used to encrypt sensitive payload fields at rest.
# Each key is a 64-character hex string (32 bytes).
#
# Generate a new key:
Expand Down Expand Up @@ -266,20 +257,50 @@ ENCRYPTION_ROTATION_BATCH_SIZE=100
# Set to 'false' to hide the GET /api/v1/security/key-versions endpoint
ENCRYPTION_EXPOSE_KEY_VERSIONS=true

# ==========================================
# Webhook Delivery Engine (BE-015)
# ==========================================
# Master secret; endpoints without their own secret get a derived per-endpoint key
# ─── Tamper-evident audit chain (BE-009) ─────────────────────────────────────
# Start the in-process verification + Stellar anchoring schedulers
AUDIT_CHAIN_JOBS_ENABLED=false
AUDIT_ANCHOR_SECRET=""
AUDIT_ANCHOR_INTERVAL_MS=900000
AUDIT_VERIFY_INTERVAL_MS=300000
# Optional webhook (Slack/PagerDuty-compatible JSON) for critical tamper alarms
AUDIT_ALARM_WEBHOOK_URL=""

# ─── IPFS / Filecoin pinning (BE-011) ────────────────────────────────────────
PINATA_JWT=""
KUBO_API_URL="http://127.0.0.1:5001/api/v0/add"
WEB3STORAGE_TOKEN=""
IPFS_CDN_BASE_URL="https://cloudflare-ipfs.com"
IPFS_SECONDARY_GATEWAYS="https://gateway.pinata.cloud,https://w3s.link,https://ipfs.io"
IPFS_PIN_MAX_ATTEMPTS=3
IPFS_PIN_BASE_DELAY_MS=150
IPFS_PIN_MAX_DELAY_MS=2000
IPFS_PIN_ATTEMPT_TIMEOUT_MS=2500
IPFS_CERTIFICATE_PIN_SLA_MS=3000

# ─── Dynamic rate limiting (BE-012) ──────────────────────────────────────────
DYNAMIC_RATE_LIMIT_ENABLED=true
REPUTATION_CACHE_TTL_MS=60000
# Stellar asset whose balance counts as an active stake
STAKING_ASSET_CODE=""
STAKING_ASSET_ISSUER=""
STAKING_MIN_BALANCE=1

# ─── OpenTelemetry tracing (BE-013) ──────────────────────────────────────────
OTEL_SDK_DISABLED=false
OTEL_SERVICE_NAME="web3-student-lab-backend"
# Jaeger OTLP/HTTP collector endpoint
OTEL_EXPORTER_JAEGER_ENDPOINT="http://localhost:4318/v1/traces"
# ===================================# Webhook Delivery Engine (BE-015)
# ===================================# Master secret; endpoints without their own secret get a derived per-endpoint key
WEBHOOK_SIGNING_SECRET="CHANGE_THIS_WEBHOOK_SIGNING_SECRET"
# Consecutive 5xx/network failures before an endpoint is paused
WEBHOOK_ENDPOINT_FAILURE_THRESHOLD=5
# How long a paused endpoint waits before a probe delivery (ms)
WEBHOOK_ENDPOINT_PAUSE_MS=1800000

# ==========================================
# Decentralized Identity & Verifiable Credentials (BE-016)
# ==========================================
# Institutional master keypair (Stellar secret seed S...); its account id is the issuer DID
# ===================================# Decentralized Identity & Verifiable Credentials (BE-016)
# ===================================# Institutional master keypair (Stellar secret seed S...); its account id is the issuer DID
INSTITUTION_MASTER_SECRET=""
INSTITUTION_NAME="Web3 Student Lab"
# Soroban DID registry (contracts/did_registry), used for did:soroban and registry bindings
Expand All @@ -289,18 +310,14 @@ CREDENTIAL_STATUS_CONTRACT_ID=""
# Reject credentials whose on-chain status cannot be confirmed as active
CREDENTIAL_REQUIRE_ONCHAIN_STATUS=false

# ==========================================
# Payment Stream Indexer (BE-018)
# ==========================================
# Comma-separated Soroban stream contract ids to index
# ===================================# Payment Stream Indexer (BE-018)
# ===================================# Comma-separated Soroban stream contract ids to index
STREAM_CONTRACT_IDS=""
STREAM_INDEXER_START_LEDGER=
STREAM_INDEXER_POLL_MS=5000

# ==========================================
# Sybil Resistance (BE-019)
# ==========================================
GITCOIN_PASSPORT_API_KEY=""
# ===================================# Sybil Resistance (BE-019)
# ===================================GITCOIN_PASSPORT_API_KEY=""
GITCOIN_PASSPORT_SCORER_ID=""
WORLDCOIN_APP_ID=""
WORLDCOIN_ACTION="sybil-resistance"
Expand Down
5 changes: 5 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
"@aws-sdk/client-kms": "^3.1076.0",
"@aws-sdk/client-s3": "^3.1117.0",
"@aws-sdk/lib-storage": "^3.1076.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-node": "^0.220.0",
"@opentelemetry/semantic-conventions": "^1.43.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@prisma/config": "^7.8.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
-- BE-011: immutable IPFS/Filecoin pins
CREATE TABLE "pinned_assets" (
"id" TEXT NOT NULL,
"resourceType" TEXT NOT NULL,
"resourceId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"cid" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"filecoinProvider" TEXT,
"filecoinCid" TEXT,
"filecoinStatus" TEXT NOT NULL DEFAULT 'pending',
"cdnUrl" TEXT NOT NULL,
"gatewayVerified" BOOLEAN NOT NULL DEFAULT false,
"verifiedGateway" TEXT,
"verifiedAt" TIMESTAMP(3),
"pinLatencyMs" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "pinned_assets_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "pinned_assets_resourceType_resourceId_kind_key" ON "pinned_assets"("resourceType", "resourceId", "kind");
CREATE INDEX "pinned_assets_cid_idx" ON "pinned_assets"("cid");

-- A pinned CID is content-addressed and must never be rewritten.
CREATE OR REPLACE FUNCTION pinned_assets_immutable_cid() RETURNS trigger AS $$
BEGIN
IF NEW."cid" <> OLD."cid" OR NEW."sha256" <> OLD."sha256" THEN
RAISE EXCEPTION 'pinned_assets.cid is immutable';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER "pinned_assets_immutable_cid"
BEFORE UPDATE ON "pinned_assets"
FOR EACH ROW EXECUTE FUNCTION pinned_assets_immutable_cid();

-- BE-009: tamper-evident audit hash chain
CREATE TABLE "audit_chain_entries" (
"sequence" BIGINT NOT NULL,
"eventType" TEXT NOT NULL,
"actorId" TEXT,
"entity" TEXT,
"entityId" TEXT,
"payload" JSONB NOT NULL,
"prevHash" TEXT NOT NULL,
"entryHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "audit_chain_entries_pkey" PRIMARY KEY ("sequence")
);

CREATE UNIQUE INDEX "audit_chain_entries_entryHash_key" ON "audit_chain_entries"("entryHash");
CREATE INDEX "audit_chain_entries_createdAt_idx" ON "audit_chain_entries"("createdAt");

-- Defence in depth: the application never updates or deletes chain rows.
-- (A privileged attacker can drop this trigger; the hash chain and the
-- Stellar-anchored Merkle roots still expose any change they make.)
CREATE OR REPLACE FUNCTION audit_chain_entries_append_only() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'audit_chain_entries is append-only';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER "audit_chain_entries_append_only"
BEFORE UPDATE OR DELETE ON "audit_chain_entries"
FOR EACH ROW EXECUTE FUNCTION audit_chain_entries_append_only();

CREATE TABLE "audit_chain_anchors" (
"id" TEXT NOT NULL,
"fromSequence" BIGINT NOT NULL,
"toSequence" BIGINT NOT NULL,
"entryCount" INTEGER NOT NULL,
"merkleRoot" TEXT NOT NULL,
"headHash" TEXT NOT NULL,
"network" TEXT NOT NULL,
"txHash" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"anchoredAt" TIMESTAMP(3),

CONSTRAINT "audit_chain_anchors_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "audit_chain_anchors_toSequence_idx" ON "audit_chain_anchors"("toSequence");
CREATE INDEX "audit_chain_anchors_status_idx" ON "audit_chain_anchors"("status");
61 changes: 61 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,67 @@ model PaymentRecord {
@@map("payment_records")
}

// Immutable IPFS/Filecoin pins for certificates and course media (BE-011)
model PinnedAsset {
id String @id @default(cuid())
resourceType String
resourceId String
kind String
cid String
contentType String
sizeBytes Int
sha256 String
provider String
filecoinProvider String?
filecoinCid String?
filecoinStatus String @default("pending") // pending | stored | failed | disabled
cdnUrl String
gatewayVerified Boolean @default(false)
verifiedGateway String?
verifiedAt DateTime?
pinLatencyMs Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([resourceType, resourceId, kind])
@@index([cid])
@@map("pinned_assets")
}

// Append-only SHA-256 hash chain of audit events (BE-009)
model AuditChainEntry {
sequence BigInt @id
eventType String
actorId String?
entity String?
entityId String?
payload Json
prevHash String
entryHash String @unique
createdAt DateTime

@@index([createdAt])
@@map("audit_chain_entries")
}

// Merkle roots of audit chain ranges anchored on the Stellar ledger (BE-009)
model AuditChainAnchor {
id String @id @default(cuid())
fromSequence BigInt
toSequence BigInt
entryCount Int
merkleRoot String
headHash String
network String
txHash String?
status String @default("pending") // pending | anchored | failed
error String?
createdAt DateTime @default(now())
anchoredAt DateTime?

@@index([toSequence])
@@index([status])
@@map("audit_chain_anchors")
// Off-chain state channel between a student and a mentor (#1589 / BE-017).
// Only funding and the final (or disputed) state are settled on Soroban.
model StateChannel {
Expand Down
12 changes: 12 additions & 0 deletions backend/src/certificates/CertificateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import prisma from '../db/index.js';
import { storageService } from '../services/storage/index.js';
import { ipfsPinningService } from '../services/ipfsPinningService.js';
import { certificateBlockchainService } from '../blockchain/CertificateBlockchainService.js';
import {
Certificate,
Expand Down Expand Up @@ -208,6 +209,17 @@ export class CertificateService {
txHash: mintResult.transactionHash,
});

// Auto-pin the certificate SVG + metadata to IPFS/Filecoin (BE-011).
// Runs alongside the email step so issuance latency is unaffected.
void ipfsPinningService
.autoPinCertificate({
certificateId,
metadata: { ...(metadata as unknown as Record<string, unknown>) },
})
.catch((pinError) => {
logger.error(`Failed to auto-pin certificate ${certificateId} to IPFS:`, pinError);
});

// Send transactional email with PDF attachment and social sharing links
try {
const pdfBuffer = await certificateImageGenerator.generateCertificateImage({
Expand Down
Loading