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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ jobs:
id: cf
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
if [ -n "$CLOUDFLARE_API_TOKEN" ]; then
if [ -n "$CLOUDFLARE_API_TOKEN" ] && [ -n "$CLOUDFLARE_ACCOUNT_ID" ]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,9 @@ export function SentryClientTest() {
{eventId ? "Captured" : "Waiting"}
</Stamp>
</div>
<p className="ab-body-sm text-ink-2">
Use this only in development and preview. Production returns not found.
</p>
<p className="ab-body-sm text-ink-2">Use this only in development and preview.</p>
{eventId ? (
<p className="break-all font-mono text-sm tabular-nums text-ink">
{eventId}
</p>
<p className="break-all font-mono text-sm tabular-nums text-ink">{eventId}</p>
) : null}
<Button onClick={captureClientEvent}>
<Send aria-hidden="true" />
Expand Down
54 changes: 54 additions & 0 deletions app/admin/sentry-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ExternalLink } from "lucide-react";
import { cookies } from "next/headers";
import Link from "next/link";
import { notFound } from "next/navigation";

import { Button } from "@/app/components/ui/button";
import { Card } from "@/app/components/ui/card";
import { Stamp } from "@/app/components/ui/stamp";
import { ADMIN_COOKIE_NAME, verifyAdminSession } from "@/lib/auth/admin-session";
import { appEnvironment } from "@/lib/env";

import { SentryClientTest } from "./client-test";

export const dynamic = "force-dynamic";

export default async function SentryTestPage() {
const cookieStore = await cookies();
const session = cookieStore.get(ADMIN_COOKIE_NAME)?.value;
if (appEnvironment === "production" || !(await verifyAdminSession(session))) {
notFound();
}

return (
<main className="min-h-screen bg-paper px-4 py-8 text-ink sm:px-8">
<div className="mx-auto grid max-w-3xl gap-6">
<div className="grid gap-4">
<Stamp status="new">Preview only</Stamp>
<h1 className="ab-display-md">Sentry test counter</h1>
<p className="ab-body text-ink-2">
Send one server event and one browser event before closing Phase 1.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card className="grid gap-5">
<div>
<p className="ab-eyebrow">Server event</p>
<h2 className="ab-h2 mt-2">Send a route test</h2>
</div>
<p className="ab-body-sm text-ink-2">
The route captures a server exception and returns the event id.
</p>
<Button asChild>
<Link href="/admin/sentry-test/server">
Open server route
<ExternalLink aria-hidden="true" />
</Link>
</Button>
</Card>
<SentryClientTest />
</div>
</div>
</main>
);
}
30 changes: 30 additions & 0 deletions app/admin/sentry-test/server/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as Sentry from "@sentry/nextjs";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

import { ADMIN_COOKIE_NAME, verifyAdminSession } from "@/lib/auth/admin-session";
import { appEnvironment } from "@/lib/env";

export const dynamic = "force-dynamic";

export async function GET() {
const cookieStore = await cookies();
const session = cookieStore.get(ADMIN_COOKIE_NAME)?.value;
if (
appEnvironment === "production" ||
!(await verifyAdminSession(session, Date.now()))
) {
return NextResponse.json({ message: "Not found." }, { status: 404 });
}

const eventId = Sentry.captureException(new Error("Stoop server Sentry test event"));

return NextResponse.json(
{ eventId, message: "Sentry server test event captured." },
{
headers: {
"Cache-Control": "no-store"
}
}
);
}
27 changes: 3 additions & 24 deletions app/api/sentry-test/route.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,7 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";

import { appEnvironment } from "@/lib/env";

export const dynamic = "force-dynamic";

// Legacy public diagnostic URL. The functional Sentry test is now under /admin/sentry-test so an
// unauthenticated request can never create monitoring events in preview deployments.
export function GET() {
if (appEnvironment === "production") {
return NextResponse.json(
{ message: "Sentry test events are disabled in production." },
{ status: 404 }
);
}

const eventId = Sentry.captureException(
new Error("Stoop server Sentry test event")
);

return NextResponse.json(
{ eventId, message: "Sentry server test event captured." },
{
headers: {
"Cache-Control": "no-store"
}
}
);
return NextResponse.json({ message: "Not found." }, { status: 404 });
}
3 changes: 2 additions & 1 deletion app/dashboard/qr/sharing-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const CHANNEL_LABELS: Record<string, string> = {
direct: "Direct",
instagram: "Instagram bio",
whatsapp: "WhatsApp",
poster: "Printed poster"
poster: "Printed poster",
qr: "QR code"
};

function channelLabel(src: string): string {
Expand Down
50 changes: 3 additions & 47 deletions app/sentry-test/page.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,6 @@
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";

import { Button } from "@/app/components/ui/button";
import { Card } from "@/app/components/ui/card";
import { Stamp } from "@/app/components/ui/stamp";
import { appEnvironment } from "@/lib/env";

import { SentryClientTest } from "./client-test";

export const dynamic = "force-dynamic";

export default function SentryTestPage() {
if (appEnvironment === "production") {
notFound();
}

return (
<main className="min-h-screen bg-paper px-4 py-8 text-ink sm:px-8">
<div className="mx-auto grid max-w-3xl gap-6">
<div className="grid gap-4">
<Stamp status="new">Preview only</Stamp>
<h1 className="ab-display-md">Sentry test counter</h1>
<p className="ab-body text-ink-2">
Send one server event and one browser event before closing Phase 1.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card className="grid gap-5">
<div>
<p className="ab-eyebrow">Server event</p>
<h2 className="ab-h2 mt-2">Send a route test</h2>
</div>
<p className="ab-body-sm text-ink-2">
The route captures a server exception and returns the event id.
</p>
<Button asChild>
<Link href="/api/sentry-test">
Open server route
<ExternalLink aria-hidden="true" />
</Link>
</Button>
</Card>
<SentryClientTest />
</div>
</div>
</main>
);
// Keep the legacy public URL non-enumerable after moving the diagnostic behind founder access.
export default function LegacySentryTestPage() {
notFound();
}
13 changes: 12 additions & 1 deletion image-processor/container/process-image.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import sharp from "sharp";

export const MAX_EDGE = 2048;
export const MAX_BYTES = 4 * 1024 * 1024;
export const MAX_INPUT_PIXELS = MAX_EDGE * MAX_EDGE;
export const ALLOWED_FORMATS = new Set(["jpeg", "png", "webp"]);
export const GENERIC_REASON = "That image didn't work — try a JPG or PNG under 4 MB.";
export const ANIMATED_REASON = "Animated images aren't supported yet — try a still photo.";
Expand All @@ -22,13 +23,23 @@ export async function processImage(buf) {
}

try {
const image = sharp(buf, { failOn: "error" });
// Limit pixels before Sharp decodes raster data so a small compressed image cannot force a
// disproportionate allocation. The explicit edge check below keeps the product's 2048px rule.
const image = sharp(buf, { failOn: "error", limitInputPixels: MAX_INPUT_PIXELS });
const meta = await image.metadata();

// Reject svg (vector / script-bearing), unknown formats, and animated frames.
if (!meta.format || !ALLOWED_FORMATS.has(meta.format)) {
return { ok: false, reason: GENERIC_REASON };
}
if (
!meta.width ||
!meta.height ||
meta.width > MAX_EDGE ||
meta.height > MAX_EDGE
Comment on lines +38 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve downscaling for valid large uploads

For any JPG/PNG under 4 MB whose source dimensions exceed 2048 px on an edge, this new check rejects before the existing resize({ fit: "inside" }) can downscale it. The upload path still only pre-validates MIME and byte size, and the processor was designed to clamp dimensions, so common phone photos that used to be accepted will now fail with the misleading “under 4 MB” error; keep a separate input-pixel bomb cap while still allowing ordinary oversized photos to be normalized.

Useful? React with 👍 / 👎.

) {
return { ok: false, reason: GENERIC_REASON };
}
if ((meta.pages ?? 1) > 1) {
return { ok: false, reason: ANIMATED_REASON };
}
Expand Down
6 changes: 2 additions & 4 deletions image-processor/container/process-image.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,14 @@ describe("processImage", () => {
expect(result.data.subarray(8, 12).toString("ascii")).toBe("WEBP");
});

it("clamps an oversized image to the max edge", async () => {
it("rejects an image with a source edge over the max", async () => {
const huge = await sharp({
create: { width: 3000, height: 1200, channels: 3, background: { r: 10, g: 10, b: 10 } }
})
.png()
.toBuffer();
const result = await processImage(huge);
expect(result.ok).toBe(true);
expect(result.width).toBe(2048);
expect(result.height).toBeLessThanOrEqual(2048);
expect(result).toEqual({ ok: false, reason: GENERIC_REASON });
});

it("rejects an SVG (vector / script-bearing)", async () => {
Expand Down
24 changes: 21 additions & 3 deletions lib/actions/refunds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";

import { writeAuditLog } from "@/lib/audit/log";
import { getStripe } from "@/lib/stripe/client";
import { createSupabaseSecretClient } from "@/lib/supabase/secret";
import { createSupabaseServerClient } from "@/lib/supabase/server";

// Phase 5.8: refund an order. v1 is full refunds only — stripe.refunds.create with no amount
Expand Down Expand Up @@ -34,7 +35,9 @@ export async function refundOrder(orderId: string): Promise<RefundResult> {
// ownership check. Explicit columns, never select * against orders (hard invariant 6).
const { data: order } = await supabase
.from("orders")
.select("id, payment_mode, payment_status, stripe_payment_intent_id, idempotency_key")
.select(
"id, total_cents, currency, payment_mode, payment_status, stripe_payment_intent_id, idempotency_key"
)
.eq("id", parsed.data)
.maybeSingle();
if (!order) {
Expand All @@ -53,11 +56,25 @@ export async function refundOrder(orderId: string): Promise<RefundResult> {
}

try {
const stripe = getStripe();
const paymentIntent = await stripe.paymentIntents.retrieve(
order.stripe_payment_intent_id
);
// Payment metadata is written only when Stoop creates Checkout. Verifying it here prevents a
// stale or previously tampered database value from targeting another Stripe PaymentIntent.
if (
paymentIntent.metadata.order_id !== order.id ||
paymentIntent.amount !== order.total_cents ||
paymentIntent.currency !== order.currency.toLowerCase()
) {
return { ok: false, error: "Only a paid online order can be refunded." };
}

// Destination charge: the funds settled on the seller's connected account and Stoop took an
// application fee. A full refund must pull the transfer back from the connected account
// (reverse_transfer) and return our fee (refund_application_fee) — otherwise the platform
// balance funds the refund and the seller keeps both the sale and the fee.
await getStripe().refunds.create(
await stripe.refunds.create(
{
payment_intent: order.stripe_payment_intent_id,
reverse_transfer: true,
Expand All @@ -73,7 +90,8 @@ export async function refundOrder(orderId: string): Promise<RefundResult> {
// timeline, and the refund can still fail. Move to the in-between refund_pending state now; the
// charge.refunded webhook confirms it to 'refunded', a refund-failure event flips it to
// 'refund_failed'. The guarded paid→refund_pending update also serializes a double-click.
const { data: updated, error: updateError } = await supabase
const secret = createSupabaseSecretClient();
const { data: updated, error: updateError } = await secret
.from("orders")
.update({ payment_status: "refund_pending" })
.eq("id", order.id)
Expand Down
15 changes: 12 additions & 3 deletions lib/auth/admin-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,32 @@ import {
export const ADMIN_COOKIE_NAME = "admin_session";
// 12 hours: long enough for a founder working session, short enough that a leaked cookie expires.
export const ADMIN_COOKIE_TTL_SECONDS = 12 * 60 * 60;
const ADMIN_SESSION_AUDIENCE = "stoop-admin-session";

interface AdminSessionPayload {
audience: typeof ADMIN_SESSION_AUDIENCE;
issuedAt: number;
}

/** Returns the signed cookie value proving the holder cleared the /admin login gate. */
export async function signAdminSession(issuedAt: number): Promise<string> {
return signCookiePayload({ issuedAt } satisfies AdminSessionPayload);
return signCookiePayload({
audience: ADMIN_SESSION_AUDIENCE,
issuedAt
} satisfies AdminSessionPayload);
}

/** True only when the cookie was signed by us and is still within its TTL. */
export async function verifyAdminSession(
raw: string | undefined,
now: number
now = Date.now()
): Promise<boolean> {
const parsed = (await readCookiePayload(raw)) as Partial<AdminSessionPayload> | null;
if (!parsed || typeof parsed.issuedAt !== "number") {
if (
!parsed ||
parsed.audience !== ADMIN_SESSION_AUDIENCE ||
typeof parsed.issuedAt !== "number"
) {
return false;
}
return now - parsed.issuedAt <= ADMIN_COOKIE_TTL_SECONDS * 1000;
Expand Down
20 changes: 10 additions & 10 deletions lib/ratelimit/anon-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { clientIp } from "@/lib/security/request-ip";
import { verifyTurnstile } from "@/lib/security/turnstile";

// Phase 9.3: the soft abuse-control gate shared by the anon order + subscribe server actions. Both
// resolve the edge IP, reserve a per-(ip, store) + per-store KV window pair as one decision, then
// run the Turnstile challenge. The local hard cap goes first so a scripted flood can't force
// unlimited third-party siteverify calls with random tokens. The orchestration is identical; only
// the keys/limits and the caller-facing copy differ, so callers pass a window builder and map the
// reason to their own message. KV-null (plain `next dev` / tests) fails open — the same soft-control
// contract as the rest of the limiter.
// resolve the edge IP, verify the Turnstile challenge, then reserve a per-(ip, store) + per-store
// KV window pair as one decision. Failed challenges must not consume a store's finite public-write
// capacity; otherwise an attacker could deny service without solving Turnstile. The orchestration is
// identical; only the keys/limits and the caller-facing copy differ, so callers pass a window builder
// and map the reason to their own message. KV-null (plain `next dev` / tests) fails open — the same
// soft-control contract as the rest of the limiter.

export type AnonGuardResult =
| { ok: true }
Expand All @@ -23,6 +23,10 @@ export async function guardAnonWrite(
buildWindows: (ip: string, now: number) => RateLimitReservation[]
): Promise<AnonGuardResult> {
const ip = await clientIp();
if (!(await verifyTurnstile(turnstileToken, ip))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the pre-Turnstile per-IP throttle

When TURNSTILE_SECRET_KEY is configured, a request with a missing or invalid token returns here before addToWindows, so the per-(ip, store) caps built by the order/subscribe callers never apply to failed challenges. A scripted flood can therefore force unbounded Cloudflare siteverify fetches without hitting KV, which removes the local abuse cap this guard is meant to provide; split a cheap per-IP/IP-store limit before Turnstile or otherwise throttle failed challenges without consuming the store-wide bucket.

Useful? React with 👍 / 👎.

return { ok: false, reason: "turnstile" };
}

const kv = getRateLimitKv();
if (kv) {
const now = Date.now();
Expand All @@ -32,9 +36,5 @@ export async function guardAnonWrite(
}
}

if (!(await verifyTurnstile(turnstileToken, ip))) {
return { ok: false, reason: "turnstile" };
}

return { ok: true };
}
Loading
Loading