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
2 changes: 1 addition & 1 deletion components/gallery/GalleryGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
No passports match your current filters. Try broadening your search or
be the first to create one.
</p>
<a

Check warning on line 76 in components/gallery/GalleryGrid.tsx

View workflow job for this annotation

GitHub Actions / Run Tests

Do not use an `<a>` element to navigate to `/`. Use `<Link />` from `next/link` instead. See: https://nextjs.org/docs/messages/no-html-link-for-pages
href="/"
className="inline-flex items-center gap-2 rounded-xl bg-accent text-accent-foreground px-7 py-3 text-sm font-semibold hover:brightness-110 transition-all shadow-lg shadow-accent/20"
>
Expand All @@ -89,7 +89,7 @@
{/* Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
{passports.map((passport, i) => (
<PassportCard key={passport.agent_id} passport={passport} index={i} />
<PassportCard key={passport.slug || passport.agent_id} passport={passport} index={i} />
))}
</div>

Expand Down
2 changes: 1 addition & 1 deletion components/gallery/PassportCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ interface PassportCardProps {
}

export function PassportCard({ passport, index = 0 }: PassportCardProps) {
const avatarUri = getAvatarDataUri(passport.agent_id, 64);
const avatarUri = getAvatarDataUri(passport.slug || passport.agent_id || '', 64);
const primaryFramework = passport.framework?.[0]
? getFrameworkById(passport.framework[0])
: null;
Expand Down
2 changes: 1 addition & 1 deletion components/layout/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function Header({ currentPage = "home" }: HeaderProps) {
Breeds
</a>
<a
href={apiConfig.aportDomain}
href="https://aport.io"
className="transition-colors hover:text-foreground"
>
Platform
Expand Down
45 changes: 43 additions & 2 deletions functions/api/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type { AppEnv } from "../lib/types";
import { getCorsHeaders, handleCorsPreflightRequest } from "../lib/cors";
import { jsonResponse, errorResponse } from "../lib/response";
import { fetchGalleryPassports } from "../lib/gallery";
import { checkRateLimit, getClientIp } from "../lib/rate-limit";
import { verifyRequestToken } from "../lib/request-token";

export const onRequestOptions: PagesFunction<AppEnv> = async (context) => {
const res = handleCorsPreflightRequest(context.request);
Expand All @@ -21,12 +23,46 @@ export const onRequestGet: PagesFunction<AppEnv> = async (context) => {
const { env, request } = context;
const cors = getCorsHeaders(request);

// Rate limit: 10 req/min per IP — normal browsing is ~2-3 req/min
const ip = getClientIp(request);
const rateLimit = await checkRateLimit(env.APORT_ID_KV, ip, {
maxRequests: 10,
windowMs: 60_000,
});
if (!rateLimit.allowed) {
const retryAfterSecs = Math.ceil(
Math.max(0, rateLimit.resetAt - Date.now()) / 1000,
);
return errorResponse("Too many requests. Please try again later.", 429, {
...cors,
"Retry-After": String(retryAfterSecs),
});
}

// Layer 1: Origin check — blocks cross-origin browser requests
const origin = request.headers.get("origin") || "";
const referer = request.headers.get("referer") || "";
const allowedHosts = ["aport.id", "localhost", "127.0.0.1"];
const originAllowed = allowedHosts.some(
(host) => origin.includes(host) || referer.includes(host),
);
if (!originAllowed) {
return errorResponse("Forbidden", 403, cors);
}

// Layer 2: Time-rotating token — blocks replayed/scripted requests
const token = request.headers.get("x-ag-token") || "";
const tokenValid = await verifyRequestToken(token);
if (!tokenValid) {
return errorResponse("Forbidden", 403, cors);
}

if (!env.APORT_API_KEY || !env.APORT_ORG_ID) {
return errorResponse("Server misconfigured", 500, cors);
}

const url = new URL(request.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10), 100);
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10), 50);
const offset = Math.max(parseInt(url.searchParams.get("offset") || "0", 10), 0);
const role = url.searchParams.get("role") || undefined;
const region = url.searchParams.get("region") || undefined;
Expand All @@ -43,10 +79,15 @@ export const onRequestGet: PagesFunction<AppEnv> = async (context) => {
status,
});

// Strip agent_id from gallery listing — use slug for navigation
const safePassports = result.passports.map(({ agent_id, ...rest }) => rest);

return jsonResponse(
{
ok: true,
...result,
passports: safePassports,
total: result.total,
hasMore: result.hasMore,
},
200,
{
Expand Down
76 changes: 75 additions & 1 deletion functions/api/passport/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,61 @@ import type { AppEnv } from '../../lib/types';
import { getCorsHeaders, handleCorsPreflightRequest } from '../../lib/cors';
import { jsonResponse, errorResponse } from '../../lib/response';
import { createAPortService } from '../../lib/services/aport';
import { checkRateLimit, getClientIp } from '../../lib/rate-limit';

/**
* Obfuscate an email address: "uchi.uchibeke@gmail.com" → "u*************@gmail.com"
*/
function obfuscateEmail(email: string): string {
if (!email || !email.includes('@')) return '';
const [local, domain] = email.split('@');
return `${local[0]}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`;
}

/**
* Strip or obfuscate sensitive fields from passport data before returning to client.
* Walks the response recursively to catch nested email fields.
*/
function sanitizePassportData(data: any): any {
if (!data || typeof data !== 'object') return data;

if (Array.isArray(data)) {
return data.map(sanitizePassportData);
}

const sanitized = { ...data };

// Obfuscate known email fields
if (typeof sanitized.contact === 'string' && sanitized.contact.includes('@')) {
sanitized.contact = obfuscateEmail(sanitized.contact);
}

// Obfuscate pending_owner email
if (sanitized.pending_owner && typeof sanitized.pending_owner === 'object') {
sanitized.pending_owner = { ...sanitized.pending_owner };
if (typeof sanitized.pending_owner.email === 'string') {
sanitized.pending_owner.email = obfuscateEmail(sanitized.pending_owner.email);
}
}

// Obfuscate owner email if present
if (sanitized.owner && typeof sanitized.owner === 'object') {
sanitized.owner = { ...sanitized.owner };
if (typeof sanitized.owner.email === 'string') {
sanitized.owner.email = obfuscateEmail(sanitized.owner.email);
}
}

// Recurse into nested passport/data objects
if (sanitized.passport && typeof sanitized.passport === 'object') {
sanitized.passport = sanitizePassportData(sanitized.passport);
}
if (sanitized.data && typeof sanitized.data === 'object') {
sanitized.data = sanitizePassportData(sanitized.data);
}

return sanitized;
}

export const onRequestOptions: PagesFunction<AppEnv> = async (context) => {
const res = handleCorsPreflightRequest(context.request);
Expand All @@ -18,6 +73,23 @@ export const onRequestOptions: PagesFunction<AppEnv> = async (context) => {
export const onRequestGet: PagesFunction<AppEnv> = async (context) => {
const { env, request, params } = context;
const cors = getCorsHeaders(request);

// Rate limit: 20 req/min per IP — normal browsing is a few passports/min
const ip = getClientIp(request);
const rateLimit = await checkRateLimit(env.APORT_ID_KV, ip, {
maxRequests: 20,
windowMs: 60_000,
});
if (!rateLimit.allowed) {
const retryAfterSecs = Math.ceil(
Math.max(0, rateLimit.resetAt - Date.now()) / 1000,
);
return errorResponse('Too many requests. Please try again later.', 429, {
...cors,
'Retry-After': String(retryAfterSecs),
});
}

const idOrSlug = params.id as string;

if (!idOrSlug) {
Expand All @@ -41,7 +113,9 @@ export const onRequestGet: PagesFunction<AppEnv> = async (context) => {
);
}

return jsonResponse(result.data, 200, {
const sanitized = sanitizePassportData(result.data);

return jsonResponse(sanitized, 200, {
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=300',
});
Expand Down
6 changes: 3 additions & 3 deletions functions/lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Sliding window implementation: stores an array of request timestamps
* per IP. Fails open — if KV errors, the request is allowed through.
*
* See PRD E3: 10 requests per hour per IP.
* See PRD E3. ~2 req/sec (120/min) allows rapid creation and retries.
*/
import type { AppEnv } from "./types";
import { getCorsHeaders } from "./cors";
Expand All @@ -22,8 +22,8 @@ interface RateLimitOptions {
windowMs?: number;
}

const DEFAULT_MAX_REQUESTS = 10;
const DEFAULT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const DEFAULT_MAX_REQUESTS = 120;
const DEFAULT_WINDOW_MS = 60 * 1000; // 1 minute (~2 req/sec)
const LOCALHOST_MULTIPLIER = 10;

const KV_PREFIX = "rl:";
Expand Down
56 changes: 56 additions & 0 deletions functions/lib/request-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Time-rotating request token for same-app verification.
*
* Not a secret — the algorithm is in open source. But it rotates every
* 5 minutes, so a token copied from DevTools expires quickly, and
* reproducing it requires reading and reimplementing the code.
*
* This is one layer in a stack: origin check + rate limit + rotating token.
*/

const WINDOW_MS = 5 * 60 * 1000; // 5 minutes
const SALT = "aport:gallery:v1";

function getWindow(now: number): number {
return Math.floor(now / WINDOW_MS);
}

/**
* Generate a token for the current time window.
* Uses Web Crypto API (available in Cloudflare Workers and browsers).
*/
export async function generateRequestToken(): Promise<string> {
const window = getWindow(Date.now());
const payload = `${SALT}:${window}`;
const encoded = new TextEncoder().encode(payload);
const hash = await crypto.subtle.digest("SHA-256", encoded);
const bytes = new Uint8Array(hash);
return Array.from(bytes.slice(0, 8))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

/**
* Verify a token is valid for the current or previous time window.
* Accepts previous window to handle requests near the boundary.
*/
export async function verifyRequestToken(token: string): Promise<boolean> {
if (!token || token.length !== 16) return false;

const now = Date.now();
const currentWindow = getWindow(now);

// Check current and previous window
for (const w of [currentWindow, currentWindow - 1]) {
const payload = `${SALT}:${w}`;
const encoded = new TextEncoder().encode(payload);
const hash = await crypto.subtle.digest("SHA-256", encoded);
const bytes = new Uint8Array(hash);
const expected = Array.from(bytes.slice(0, 8))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (token === expected) return true;
}

return false;
}
18 changes: 18 additions & 0 deletions lib/request-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Client-side request token generator.
* Matches the server-side verification in functions/lib/request-token.ts.
*/

const WINDOW_MS = 5 * 60 * 1000;
const SALT = "aport:gallery:v1";

export async function generateRequestToken(): Promise<string> {
const window = Math.floor(Date.now() / WINDOW_MS);
const payload = `${SALT}:${window}`;
const encoded = new TextEncoder().encode(payload);
const hash = await crypto.subtle.digest("SHA-256", encoded);
const bytes = new Uint8Array(hash);
return Array.from(bytes.slice(0, 8))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
8 changes: 6 additions & 2 deletions lib/services/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
*/

import { apiConfig } from "@/lib/config/api";
import { generateRequestToken } from "@/lib/request-token";

export interface GalleryPassport {
agent_id: string;
agent_id?: string;
slug: string;
name: string;
description: string;
Expand Down Expand Up @@ -51,7 +52,10 @@ export async function fetchGallery(
const qs = params.toString();
const url = `${apiConfig.endpoints.gallery}${qs ? `?${qs}` : ""}`;

const res = await fetch(url);
const token = await generateRequestToken();
const res = await fetch(url, {
headers: { "x-ag-token": token },
});

if (!res.ok) {
return { ok: false, passports: [], total: 0, hasMore: false };
Expand Down
Loading
Loading