···5353 "maxSize": 200000,
5454 "description": "Optional vector icon (SVG) intended for developers building badges, app showcases, sign-in flows, etc. Not displayed on the public Explore profile. Sanitised on upload (script tags, event handlers, foreignObject and javascript:/data: hrefs are stripped)."
5555 },
5656+ "screenshots": {
5757+ "type": "array",
5858+ "maxLength": 4,
5959+ "items": {
6060+ "type": "ref",
6161+ "ref": "#screenshotEntry"
6262+ },
6363+ "description": "Optional screenshots for the project. Up to 4 PNG, JPEG, or WebP images."
6464+ },
5665 "categories": {
5766 "type": "array",
5867 "minLength": 1,
···93102 "format": "datetime",
94103 "maxLength": 64
95104 }
105105+ }
106106+ }
107107+ },
108108+ "screenshotEntry": {
109109+ "type": "object",
110110+ "required": ["image"],
111111+ "properties": {
112112+ "image": {
113113+ "type": "blob",
114114+ "accept": ["image/png", "image/jpeg", "image/webp"],
115115+ "maxSize": 5000000,
116116+ "description": "Screenshot image. Up to 5MB."
96117 }
97118 }
98119 },
+60
lib/db.ts
···9898 categories TEXT NOT NULL DEFAULT '[]',
9999 subcategories TEXT NOT NULL DEFAULT '[]',
100100 links TEXT NOT NULL DEFAULT '[]',
101101+ screenshots TEXT NOT NULL DEFAULT '[]',
101102 avatar_cid TEXT,
102103 avatar_mime TEXT,
103104 icon_cid TEXT,
···194195 )`,
195196 `CREATE INDEX IF NOT EXISTS report_status_target ON report(status, target_did)`,
196197 `CREATE INDEX IF NOT EXISTS report_dedup ON report(target_did, reporter_ip_hash, reason, created_at)`,
198198+ /**
199199+ * Signed-in user reviews for registry profiles. Reviews are AppView-owned
200200+ * moderation data, not ATProto records: this keeps aggregates and admin
201201+ * actions local to the Explore surface.
202202+ */
203203+ `CREATE TABLE IF NOT EXISTS review (
204204+ id INTEGER PRIMARY KEY AUTOINCREMENT,
205205+ target_did TEXT NOT NULL,
206206+ reviewer_did TEXT NOT NULL,
207207+ rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
208208+ body TEXT NOT NULL DEFAULT '',
209209+ status TEXT NOT NULL DEFAULT 'visible',
210210+ created_at INTEGER NOT NULL,
211211+ updated_at INTEGER NOT NULL,
212212+ hidden_at INTEGER,
213213+ hidden_by TEXT,
214214+ removed_at INTEGER,
215215+ removed_by TEXT,
216216+ admin_notes TEXT
217217+ )`,
218218+ `CREATE UNIQUE INDEX IF NOT EXISTS review_target_reviewer ON review(target_did, reviewer_did)`,
219219+ `CREATE INDEX IF NOT EXISTS review_target_status_rating ON review(target_did, status, rating)`,
220220+ `CREATE INDEX IF NOT EXISTS review_target_status_created ON review(target_did, status, created_at)`,
221221+ /**
222222+ * Reports against individual reviews. Kept separate from profile reports
223223+ * because moderation targets and action surfaces differ.
224224+ */
225225+ `CREATE TABLE IF NOT EXISTS review_report (
226226+ id INTEGER PRIMARY KEY AUTOINCREMENT,
227227+ review_id INTEGER NOT NULL,
228228+ reporter_did TEXT,
229229+ reporter_ip_hash TEXT,
230230+ reason TEXT NOT NULL,
231231+ details TEXT,
232232+ status TEXT NOT NULL DEFAULT 'open',
233233+ admin_notes TEXT,
234234+ created_at INTEGER NOT NULL,
235235+ resolved_at INTEGER,
236236+ resolved_by TEXT
237237+ )`,
238238+ `CREATE INDEX IF NOT EXISTS review_report_status_review ON review_report(status, review_id)`,
239239+ `CREATE INDEX IF NOT EXISTS review_report_dedup ON review_report(review_id, reporter_ip_hash, reason, created_at)`,
240240+ /**
241241+ * Optional developer response for App Store-style owner replies. One
242242+ * response per review; hidden/removed parent reviews are not served publicly.
243243+ */
244244+ `CREATE TABLE IF NOT EXISTS review_response (
245245+ review_id INTEGER PRIMARY KEY,
246246+ responder_did TEXT NOT NULL,
247247+ body TEXT NOT NULL,
248248+ created_at INTEGER NOT NULL,
249249+ updated_at INTEGER NOT NULL
250250+ )`,
197251];
198252199253/**
···256310 table: "profile",
257311 column: "links",
258312 ddl: "ALTER TABLE profile ADD COLUMN links TEXT NOT NULL DEFAULT '[]'",
313313+ },
314314+ {
315315+ table: "profile",
316316+ column: "screenshots",
317317+ ddl:
318318+ "ALTER TABLE profile ADD COLUMN screenshots TEXT NOT NULL DEFAULT '[]'",
259319 },
260320 {
261321 table: "profile",
+59-5
lib/lexicons.ts
···3131 "developerTool",
3232] as const;
3333export type Category = typeof CATEGORIES[number];
3434+export const PUBLIC_CATEGORIES = [
3535+ "app",
3636+ "accountProvider",
3737+] as const satisfies readonly Category[];
34383539export const APP_SUBCATEGORIES = [
3640 "microblog",
···108112 ref: { $link: string };
109113 mimeType: string;
110114 size: number;
115115+}
116116+117117+export interface ScreenshotEntry {
118118+ image: BlobRef;
111119}
112120113121export interface ProfileRecord {
···130138 * public profile. Must be `image/svg+xml`; we sanitise on upload.
131139 */
132140 icon?: BlobRef;
141141+ /** Optional detail-page screenshots. Stored as PDS blobs and lazy-loaded
142142+ * only on the profile detail page. */
143143+ screenshots?: ScreenshotEntry[];
133144 /** All categories that apply to the project (1-4). The first item is the
134145 * primary category used for sort/grouping in lists. */
135146 categories: string[];
···184195 return true;
185196}
186197198198+function validateScreenshots(
199199+ input: unknown,
200200+): { ok: true; value: ScreenshotEntry[] } | { ok: false; error: string } {
201201+ if (input === undefined) return { ok: true, value: [] };
202202+ if (!Array.isArray(input)) {
203203+ return { ok: false, error: "screenshots: must be an array" };
204204+ }
205205+ if (input.length > 4) {
206206+ return { ok: false, error: "screenshots: at most 4" };
207207+ }
208208+ const out: ScreenshotEntry[] = [];
209209+ const seen = new Set<string>();
210210+ for (const raw of input) {
211211+ if (!raw || typeof raw !== "object") {
212212+ return { ok: false, error: "screenshots: items must be objects" };
213213+ }
214214+ const image = (raw as Record<string, unknown>).image;
215215+ if (!isBlob(image)) {
216216+ return { ok: false, error: "screenshots[].image: invalid blob ref" };
217217+ }
218218+ if (
219219+ image.mimeType !== "image/png" &&
220220+ image.mimeType !== "image/jpeg" &&
221221+ image.mimeType !== "image/webp"
222222+ ) {
223223+ return {
224224+ ok: false,
225225+ error: "screenshots[].image: must be png, jpeg, or webp",
226226+ };
227227+ }
228228+ if (image.size > 5_000_000) {
229229+ return { ok: false, error: "screenshots[].image: max 5MB" };
230230+ }
231231+ if (seen.has(image.ref.$link)) continue;
232232+ seen.add(image.ref.$link);
233233+ out.push({ image });
234234+ }
235235+ return { ok: true, value: out };
236236+}
237237+187238export interface ValidationResult<T> {
188239 ok: boolean;
189240 value?: T;
···386437 return { ok: false, error: "icon: must be image/svg+xml" };
387438 }
388439 }
440440+ const screenshotsRes = validateScreenshots(v.screenshots);
441441+ if (!screenshotsRes.ok) return { ok: false, error: screenshotsRes.error };
389442 const linksRes = normalizeLinks(v.links);
390443 if (!linksRes.ok) return { ok: false, error: linksRes.error };
391444 if (v.subcategories !== undefined) {
···413466 androidLink: normalizedAndroidLink,
414467 avatar: v.avatar as BlobRef | undefined,
415468 icon: v.icon as BlobRef | undefined,
469469+ screenshots: screenshotsRes.value.length > 0
470470+ ? screenshotsRes.value
471471+ : undefined,
416472 categories: normalizedCategories,
417473 subcategories: v.subcategories as string[] | undefined,
418474 links: linksRes.value.length > 0 ? linksRes.value : undefined,
···483539 [PERMISSION_SET_NSID]: "fullPermissions.json",
484540 };
485541 const filename = fileMap[nsid];
486486- const url = new URL(
487487- `../lexicons/com/atmosphereaccount/registry/${filename}`,
488488- import.meta.url,
489489- );
490542 try {
491491- const text = await Deno.readTextFile(url);
543543+ const text = await Deno.readTextFile(
544544+ `lexicons/com/atmosphereaccount/registry/${filename}`,
545545+ );
492546 return JSON.parse(text);
493547 } catch (_) {
494548 return null;
+9-1
lib/public-profile.ts
···1010 * “verified” badge as Explore (`icon_access_status === 'granted'`), without
1111 * exposing emails, timestamps, or admin DIDs.
1212 */
1313-import type { LinkEntry } from "./lexicons.ts";
1313+import type { LinkEntry, ScreenshotEntry } from "./lexicons.ts";
1414import type { ProfileRow } from "./registry.ts";
15151616export interface PublicProfileJson {
···2424 categories: string[];
2525 subcategories: string[];
2626 links: LinkEntry[];
2727+ screenshots: ScreenshotEntry[];
2828+ /** Fully-qualified URLs for lazily loaded detail-page screenshots. */
2929+ screenshotUrls: string[];
2730 avatarCid: string | null;
2831 avatarMime: string | null;
2932 /** Fully-qualified URL for the profile avatar image proxy, or null. */
···6063 profile.iconAccessStatus === "granted"
6164 ? `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}`
6265 : null;
6666+ const screenshotUrls = profile.screenshots.map((_, i) =>
6767+ `${origin}/api/registry/screenshot/${encodeURIComponent(profile.did)}/${i}`
6868+ );
63696470 const out: PublicProfileJson = {
6571 did: profile.did,
···7480 // `website` was the former Landing Page button. The current public
7581 // API exposes the primary web destination via `mainLink` instead.
7682 links: profile.links.filter((entry) => entry.kind !== "website"),
8383+ screenshots: profile.screenshots,
8484+ screenshotUrls,
7785 avatarCid: profile.avatarCid,
7886 avatarMime: profile.avatarMime,
7987 avatarUrl,