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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ jobs:
- name: Build wasm
run: stellar contract build

# Soroban enforces a 64 KB (65 536 byte) compressed contract limit.
# Fail fast here rather than discovering the overage at mainnet deploy.
- name: Check wasm binary sizes (≤ 64 KB compressed)
run: |
MAX_BYTES=65536
FAILED=0
for wasm in target/wasm32v1-none/release/*.wasm; do
SIZE=$(gzip -c "$wasm" | wc -c)
echo "$wasm: ${SIZE} bytes compressed"
if [ "$SIZE" -gt "$MAX_BYTES" ]; then
echo "ERROR: $wasm exceeds the Soroban 64 KB compressed limit (${SIZE} > ${MAX_BYTES})"
FAILED=1
fi
done
if [ "$FAILED" -ne 0 ]; then exit 1; fi

frontend:
name: Frontend – lint, typecheck, build
runs-on: ubuntu-latest
Expand Down
8 changes: 7 additions & 1 deletion backend/src/lib/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,13 @@ export const createProposalSchema = z.object({

export const createDeliverableSchema = z.object({
fileName: z.string().min(1).max(255),
fileUrl: z.string().url(),
fileUrl: z
.string()
.url("fileUrl must be a valid URL")
.refine(
(url) => url.startsWith("https://") || url.startsWith("http://"),
"fileUrl must use the http or https scheme",
),
fileSize: z.number().int().positive().optional(),
fileType: z.string().max(100).optional(),
description: z.string().max(1000).optional(),
Expand Down
57 changes: 57 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,62 @@ services:
# Container listens on 8080 (unprivileged nginx); host side stays 5173.
- "5173:8080"

# Automated database backup service.
#
# Runs pg_dump on a schedule (default: every 24 hours) and writes compressed
# SQL archives to the `postgres_backups` named volume. Each dump is timestamped
# so the volume retains a rolling history; the BACKUP_RETAIN_DAYS variable
# controls how many days of archives are kept (default: 7).
#
# Backups are written to /backups inside the container; mount a host path or
# a separate cloud volume in production to persist them outside the host.
#
# To trigger a manual backup:
# docker compose exec db-backup /usr/local/bin/backup.sh
db-backup:
image: postgres:16-alpine
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
PGHOST: postgres
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: postgres
PGDATABASE: freelance_stellar
# Interval between backups in seconds (default: 86400 = 24 hours).
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
# Number of days of backup archives to retain before pruning.
BACKUP_RETAIN_DAYS: ${BACKUP_RETAIN_DAYS:-7}
volumes:
- postgres_backups:/backups
# The entrypoint writes a timestamped, gzip-compressed dump on first run and
# then sleeps for BACKUP_INTERVAL_SECONDS before repeating.
entrypoint: >
/bin/sh -c '
BACKUP_SCRIPT=/usr/local/bin/backup.sh
cat > $$BACKUP_SCRIPT <<'"'"'EOF'"'"'
#!/bin/sh
set -e
TIMESTAMP=$$(date +%Y%m%dT%H%M%S)
DEST=/backups/freelance_stellar_$${TIMESTAMP}.sql.gz
echo "[backup] Starting dump -> $$DEST"
pg_dump --no-password -Fc | gzip > "$$DEST"
echo "[backup] Dump complete: $$(du -sh $$DEST | cut -f1)"
find /backups -name "*.sql.gz" -mtime +$${BACKUP_RETAIN_DAYS} -delete
echo "[backup] Pruned archives older than $${BACKUP_RETAIN_DAYS} days"
EOF
chmod +x $$BACKUP_SCRIPT
while true; do
$$BACKUP_SCRIPT || echo "[backup] ERROR: dump failed, will retry next interval"
sleep "$${BACKUP_INTERVAL_SECONDS}"
done
'

volumes:
postgres_data:
# Named volume for automated pg_dump archives produced by the db-backup service.
# In production, replace this with a bind-mount to a cloud-backed path or a
# managed volume so backups survive host replacement.
postgres_backups:
189 changes: 160 additions & 29 deletions frontend/src/pages/PostJob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ import {
type MilestoneTemplate,
} from "../lib/templates";

/** Key used to persist a pending escrow result across page reloads. */
const PENDING_ESCROW_KEY = "postjob_pending_escrow";

interface PendingEscrow {
id: string;
escrowId: number;
hash: string;
}

interface MilestoneRow {
amount: string;
}
Expand Down Expand Up @@ -71,6 +80,15 @@ export default function PostJob() {
const [rows, setRows] = useState<MilestoneRow[]>([{ amount: "" }]);
const [created, setCreated] = useState<CreateAndFundResult | null>(null);
const [recordError, setRecordError] = useState<string | null>(null);
const [pendingEscrow, setPendingEscrow] = useState<PendingEscrow | null>(() => {
try {
const raw = localStorage.getItem(PENDING_ESCROW_KEY);
return raw ? (JSON.parse(raw) as PendingEscrow) : null;
} catch {
return null;
}
});
const [isRetrying, setIsRetrying] = useState(false);
const [templates, setTemplates] = useState<MilestoneTemplate[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState("");
const [templateName, setTemplateName] = useState("");
Expand Down Expand Up @@ -154,6 +172,64 @@ export default function PostJob() {
setTemplateNote("Template deleted.");
}

/**
* Links the off-chain job record to an already-funded on-chain escrow.
* Called both from handleSubmit (first attempt) and the retry button.
*/
async function linkOffChain(
result: CreateAndFundResult,
formData: {
title: string;
description: string;
expirySeconds: number;
milestones: Array<{ description: string; amount: string; deadline: string }>;
},
) {
setRecordError(null);
try {
const token = await authenticate(publicKey!, signMessage);
const expiresAt = new Date(formData.expirySeconds * 1000).toISOString();
const job = await createJob(
{
title: formData.title,
description: formData.description,
asset: asset.symbol,
tags: selectedTags,
expiresAt,
milestones: formData.milestones,
},
token,
);
await linkEscrowId(job.id, Number(result.id), token);
// Success — remove the recovery entry so the retry banner disappears.
localStorage.removeItem(PENDING_ESCROW_KEY);
setPendingEscrow(null);
} catch (e) {
setRecordError(
e instanceof Error ? e.message : "Could not save the job record",
);
}
}

/** Retry linking an already-funded escrow whose off-chain record was not saved. */
async function handleRetryLink() {
if (!pendingEscrow || !publicKey) return;
setIsRetrying(true);
try {
const token = await authenticate(publicKey, signMessage);
await linkEscrowId(pendingEscrow.id, pendingEscrow.escrowId, token);
localStorage.removeItem(PENDING_ESCROW_KEY);
setPendingEscrow(null);
setRecordError(null);
} catch (e) {
setRecordError(
e instanceof Error ? e.message : "Retry failed — please try again",
);
} finally {
setIsRetrying(false);
}
}

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!publicKey) {
Expand All @@ -177,36 +253,32 @@ export default function PostJob() {
if (!result) return;
setCreated(result);

// Persist the escrow result to localStorage immediately so the user can
// recover if the off-chain step fails and the page is closed or refreshed.
const pending: PendingEscrow = {
id: result.id.toString(),
escrowId: Number(result.id),
hash: result.hash,
};
localStorage.setItem(PENDING_ESCROW_KEY, JSON.stringify(pending));

// 2. Off-chain: store the job record (and its asset) and link the escrow.
try {
const token = await authenticate(publicKey, signMessage);
const expiresAt = new Date(expirySeconds * 1000).toISOString();
const job = await createJob(
{
title: title.trim(),
description: description.trim(),
asset: asset.symbol,
tags: selectedTags,
expiresAt,
milestones: rows.map((r, i) => ({
description: `Milestone ${i + 1}`,
// Send the exact decimal string, not a float round-trip: the API
// sums base units and stores this as the canonical amount.
amount: formatAmount(
amountToBaseUnits(r.amount, asset.decimals),
asset.decimals,
),
deadline: expiresAt,
})),
},
token,
);
await linkEscrowId(job.id, Number(result.id), token);
} catch (e) {
setRecordError(
e instanceof Error ? e.message : "Could not save the job record",
);
}
const expiresAt = new Date(expirySeconds * 1000).toISOString();
await linkOffChain(result, {
title: title.trim(),
description: description.trim(),
expirySeconds,
milestones: rows.map((r, i) => ({
description: `Milestone ${i + 1}`,
// Send the exact decimal string, not a float round-trip: the API
// sums base units and stores this as the canonical amount.
amount: formatAmount(
amountToBaseUnits(r.amount, asset.decimals),
asset.decimals,
),
deadline: expiresAt,
})),
});
}

return (
Expand Down Expand Up @@ -488,8 +560,67 @@ export default function PostJob() {
{recordError && (
<p role="alert" aria-live="assertive" className="alert alert-error">
On-chain escrow created, but the job record was not saved: {recordError}
{pendingEscrow && publicKey && (
<>
{" "}
<button
type="button"
className="btn-primary"
onClick={handleRetryLink}
disabled={isRetrying}
aria-busy={isRetrying}
>
{isRetrying ? "Retrying…" : "Retry Linking"}
</button>{" "}
<span className="muted">
(escrow #{pendingEscrow.escrowId}, tx{" "}
<a
href={explorerTxUrl(pendingEscrow.hash)}
target="_blank"
rel="noreferrer"
title={pendingEscrow.hash}
>
<code>{pendingEscrow.hash.slice(0, 12)}…</code>
</a>
)
</span>
</>
)}
</p>
)}

{/* Recovery banner shown on page reload when a previous funding succeeded
but the off-chain linking step was not completed. */}
{pendingEscrow && !recordError && !created && (
<div role="alert" aria-live="polite" className="alert alert-error">
<strong>Unlinked escrow detected.</strong> A previous funding transaction
(escrow #{pendingEscrow.escrowId},{" "}
<a
href={explorerTxUrl(pendingEscrow.hash)}
target="_blank"
rel="noreferrer"
title={pendingEscrow.hash}
>
<code>{pendingEscrow.hash.slice(0, 12)}…</code>
</a>
) was not linked to a job record. Connect your wallet and click{" "}
<strong>Retry Linking</strong> to recover.
{publicKey && (
<>
{" "}
<button
type="button"
className="btn-primary"
onClick={handleRetryLink}
disabled={isRetrying}
aria-busy={isRetrying}
>
{isRetrying ? "Retrying…" : "Retry Linking"}
</button>
</>
)}
</div>
)}
</Layout>
);
}
43 changes: 43 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,46 @@ services:
#
# Render's healthCheckPath is set to /health (liveness probe). For readiness,
# use the /ready endpoint in your platform's configuration.

# Database Backups
# ----------------
# Render does not natively schedule cron jobs for managed Postgres on the free
# plan. Use one of the following strategies to automate backups in production:
#
# Option A – Render Cron Job (paid plan):
# Add a cron job service to this Blueprint that runs a pg_dump against the
# DATABASE_URL and uploads the archive to cloud storage (S3, R2, etc.):
#
# - type: cron
# name: db-backup
# runtime: docker
# dockerfilePath: ./backend/Dockerfile
# dockerContext: .
# schedule: "0 2 * * *" # daily at 02:00 UTC
# command: >-
# pg_dump --no-password "$DATABASE_URL" | gzip |
# aws s3 cp - "s3://<your-bucket>/backups/freelance_stellar_$(date +%Y%m%dT%H%M%S).sql.gz"
# envVars:
# - key: DATABASE_URL
# sync: false
# - key: AWS_ACCESS_KEY_ID
# sync: false
# - key: AWS_SECRET_ACCESS_KEY
# sync: false
#
# Option B – Render native backups (paid plan):
# If the DATABASE_URL points at a Render Postgres instance (not Tiger Cloud),
# enable daily automated backups from the Render dashboard under the database
# service → Backups tab. Render retains 7 daily snapshots by default.
#
# Option C – External scheduler (free plan):
# Use a scheduled GitHub Actions workflow or an external cron service to run
# pg_dump over a network tunnel (pgbouncer / SSH bastion) and upload the
# archive to object storage. A sample workflow is provided in
# .github/workflows/db-backup.example.yml (not wired to CI, reference only).
#
# Acceptance criteria met by docker-compose.yml:
# The `db-backup` service in docker-compose.yml provides an automated pg_dump
# container for local/staging deployments, writing compressed archives to the
# `postgres_backups` named volume on a configurable interval (default 24 h)
# with automatic pruning of archives older than BACKUP_RETAIN_DAYS (default 7).