Extract catalog remote loading seam

This commit is contained in:
OpenPets Dev 2026-06-19 21:05:28 +00:00
parent 161ec56bf0
commit 8ff7ae1625
6 changed files with 264 additions and 197 deletions

View file

@ -67,6 +67,7 @@ const behaviorTests = [
".test-dist/tests/zip-safety.test.js",
".test-dist/tests/tts-engine.test.js",
".test-dist/tests/codex-familiars.test.js",
".test-dist/tests/catalog-remote-seams.test.js",
".test-dist/tests/claude-memory.test.js",
".test-dist/tests/prompt-memory-extraction.test.js",
".test-dist/tests/familiaros-memory-search.test.js",

View file

@ -0,0 +1,195 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json";
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
type CatalogLoadResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
const fixtureRelativePath = "catalog.v2.fixture.json";
const maxCatalogBytes = 1_000_000;
const maxCatalogV3PageBytes = 256_000;
const fetchTimeoutMs = 5_000;
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
let v2CatalogPromise: Promise<CatalogV2> | null = null;
export async function tryLoadRemoteCatalogV3Index(): Promise<CatalogLoadResult<CatalogV3Index>> {
try {
return { ok: true, value: await getRemoteCatalogV3Index() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<CatalogLoadResult<readonly CatalogPetV2[]>> {
try {
return { ok: true, value: await getRemoteCatalogV3Page(page, index) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function tryLoadRemoteCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
try {
return { ok: true, value: await getRemoteCatalogV2() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function tryLoadFixtureCatalog(): Promise<CatalogLoadResult<CatalogV2>> {
try {
return { ok: true, value: validateCatalogV2(await loadFixtureCatalog()) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
export async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
return await v3IndexPromise;
}
export async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
const cached = v3PageCache.get(page);
if (cached) return cached;
const pageUrl = index.pages[page];
if (!pageUrl) throw new Error("Catalog page is out of range.");
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
const familiars = payload.familiars.map(toCatalogPetV2Compat);
assertUniquePetIds(familiars);
v3PageCache.set(page, familiars);
return familiars;
}
export async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
v3SearchPromise ||= Promise.resolve().then(async () => {
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
const pages = await Promise.all(
searchIndex.pages.map(async (pageUrl, page) =>
validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length),
),
);
const familiars = pages.flatMap((page) => page.familiars);
if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
return familiars;
});
return await v3SearchPromise;
}
export async function getV2CatalogOrFixture(): Promise<CatalogV2> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) return remote.value;
const fixture = await tryLoadFixtureCatalog();
if (fixture.ok) return fixture.value;
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
}
function toCatalogPetV2Compat(familiar: {
readonly id: string;
readonly displayName: string;
readonly description: string;
readonly thumbnail: string;
readonly spritesheet: string;
readonly zip: string;
readonly category: "western" | "asian";
readonly subcategory?: string;
readonly original?: boolean;
readonly featured?: boolean;
}): CatalogPetV2 {
const entry: CatalogPetV2 = {
id: familiar.id,
displayName: familiar.displayName,
description: familiar.description,
preview: familiar.thumbnail,
spritesheet: familiar.spritesheet,
zip: familiar.zip,
category: familiar.category,
};
return {
...entry,
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
...(familiar.original === undefined ? {} : { original: familiar.original }),
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
};
}
async function getRemoteCatalogV2(): Promise<CatalogV2> {
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
return await v2CatalogPromise;
}
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateCatalogEndpoint(response.url, url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await readLimitedResponse(response, maxBytes);
} finally {
clearTimeout(timeout);
}
}
async function loadFixtureCatalog(): Promise<unknown> {
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
}
async function readLimitedResponse(response: Response, maxBytes: number): Promise<string> {
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw new Error("Catalog response is too large.");
chunks.push(value);
}
return new TextDecoder().decode(concatChunks(chunks, total));
}
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
const output = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function validateCatalogEndpoint(value: string, expected: string): void {
const url = new URL(value);
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
}
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
const ids = new Set<string>();
for (const familiar of familiars) {
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
ids.add(familiar.id);
}
}

View file

@ -1,17 +1,8 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { app } from "electron";
import { filterSurfaceablePets, isSurfaceablePet, summarizeSurfaceablePets } from "./catalog-surfaceable.js";
import { validateCatalogV2, validateCatalogV3Index, validateCatalogV3Page, validateCatalogV3SearchIndex, validateCatalogV3SearchPage, type CatalogPetV2, type CatalogV2, type CatalogV3Index, type CatalogV3SearchPet } from "./catalog-validation.js";
import { getRemoteCatalogV3Page, getRemoteCatalogV3Search, getV2CatalogOrFixture, tryLoadFixtureCatalog, tryLoadRemoteCatalog, tryLoadRemoteCatalogV3Index } from "./catalog-remote.js";
import type { CatalogPetV2, CatalogV3Index, CatalogV3SearchPet } from "./catalog-validation.js";
export const catalogUrl = "https://familiaros.dev/familiars/catalog.v2.json";
export const catalogV3Url = "https://familiaros.dev/familiars/catalog.v3.json";
const fixtureRelativePath = "catalog.v2.fixture.json";
const maxCatalogBytes = 1_000_000;
const maxCatalogV3PageBytes = 256_000;
const fetchTimeoutMs = 5_000;
export { catalogUrl, catalogV3Url } from "./catalog-remote.js";
export interface CatalogUiState {
readonly source: "remote" | "fixture" | "error";
@ -35,30 +26,25 @@ export interface CatalogSearchUiState {
readonly error?: string;
}
const v3PageCache = new Map<number, readonly CatalogPetV2[]>();
let v3IndexPromise: Promise<CatalogV3Index> | null = null;
let v3SearchPromise: Promise<readonly CatalogV3SearchPet[]> | null = null;
let v2CatalogPromise: Promise<CatalogV2> | null = null;
export async function getCatalogUiState(): Promise<CatalogUiState> {
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (remoteV3.ok) {
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.index);
const firstPage = await tryLoadSurfaceableCatalogV3Page(0, remoteV3.value);
if (!firstPage.ok) return await getV2OrFixtureCatalogUiState(`v3 page unavailable: ${firstPage.error}`);
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index.pageSize);
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
return {
source: "remote",
familiars: filterSurfaceablePets(firstPage.familiars),
generatedAt: remoteV3.index.generatedAt,
generatedAt: remoteV3.value.generatedAt,
version: 3,
total: surfaceableStats.total,
categories: remoteV3.index.filters.categories,
categories: remoteV3.value.filters.categories,
page: 0,
pageCount: surfaceableStats.pageCount,
supportsCategories: true,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
originalsCount: remoteV3.value.filters.originalsCount,
featuredCount: remoteV3.value.filters.featuredCount,
};
}
@ -69,23 +55,23 @@ export async function getCatalogPageUiState(page: number): Promise<CatalogUiStat
if (!Number.isInteger(page) || page < 0) throw new Error("Catalog page must be a non-negative integer.");
const remoteV3 = await tryLoadRemoteCatalogV3Index();
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index.pageSize);
const surfaceableStats = summarizeSurfaceablePets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value.pageSize);
if (page >= surfaceableStats.pageCount) throw new Error("Catalog page is out of range.");
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.index);
const pageResult = await tryLoadSurfaceableCatalogV3Page(page, remoteV3.value);
if (!pageResult.ok) return { source: "error", familiars: [], error: pageResult.error };
return {
source: "remote",
familiars: filterSurfaceablePets(pageResult.familiars),
generatedAt: remoteV3.index.generatedAt,
generatedAt: remoteV3.value.generatedAt,
version: 3,
total: surfaceableStats.total,
categories: remoteV3.index.filters.categories,
categories: remoteV3.value.filters.categories,
page,
pageCount: surfaceableStats.pageCount,
supportsCategories: true,
originalsCount: remoteV3.index.filters.originalsCount,
featuredCount: remoteV3.index.filters.featuredCount,
originalsCount: remoteV3.value.filters.originalsCount,
featuredCount: remoteV3.value.filters.featuredCount,
};
}
@ -94,7 +80,7 @@ export async function getCatalogSearchUiState(): Promise<CatalogSearchUiState> {
if (!remoteV3.ok) return { source: "error", familiars: [], error: remoteV3.error };
try {
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.index), remoteV3.index);
const surfacedPets = getSurfaceableSearchPets(await getRemoteCatalogV3Search(remoteV3.value), remoteV3.value);
return { source: "remote", familiars: surfacedPets, total: surfacedPets.length };
} catch (error) {
return { source: "error", familiars: [], error: error instanceof Error ? error.message : "unknown error" };
@ -106,14 +92,14 @@ export async function getCatalogPet(petId: string): Promise<CatalogPetV2> {
if (remoteV3.ok) {
let blockedHiddenV3Pet = false;
try {
const searchPets = await getRemoteCatalogV3Search(remoteV3.index);
const searchPets = await getRemoteCatalogV3Search(remoteV3.value);
const searchPet = searchPets.find((familiar) => familiar.id === petId);
if (searchPet && !isSurfaceablePet(searchPet)) {
blockedHiddenV3Pet = true;
throw new Error(`Familiar is not available in the curated catalog: ${petId}`);
}
if (searchPet) {
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.index);
const page = await getRemoteCatalogV3Page(searchPet.catalogPage, remoteV3.value);
const familiar = page.find((candidate) => candidate.id === petId);
if (familiar && isSurfaceablePet(familiar)) return familiar;
}
@ -135,10 +121,10 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (remote.ok) {
return {
source: "remote",
familiars: filterSurfaceablePets(remote.catalog.familiars),
generatedAt: remote.catalog.generatedAt,
familiars: filterSurfaceablePets(remote.value.familiars),
generatedAt: remote.value.generatedAt,
version: 2,
total: filterSurfaceablePets(remote.catalog.familiars).length,
total: filterSurfaceablePets(remote.value.familiars).length,
supportsCategories: false,
};
}
@ -148,11 +134,11 @@ async function getV2OrFixtureCatalogUiState(remoteV3Error: string): Promise<Cata
if (fixture.ok) {
return {
source: "fixture",
familiars: filterSurfaceablePets(fixture.catalog.familiars),
generatedAt: fixture.catalog.generatedAt,
familiars: filterSurfaceablePets(fixture.value.familiars),
generatedAt: fixture.value.generatedAt,
error: `Catalog unavailable: ${remoteV3Error}; v2 unavailable: ${remote.error}`,
version: 2,
total: filterSurfaceablePets(fixture.catalog.familiars).length,
total: filterSurfaceablePets(fixture.value.familiars).length,
supportsCategories: false,
};
}
@ -171,23 +157,6 @@ function getSurfaceableSearchPets(familiars: readonly CatalogV3SearchPet[], inde
}));
}
async function tryLoadRemoteCatalogV3Index(): Promise<{ readonly ok: true; readonly index: CatalogV3Index } | { readonly ok: false; readonly error: string }> {
try {
const index = await getRemoteCatalogV3Index();
return { ok: true, index };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function tryLoadRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, familiars: await getRemoteCatalogV3Page(page, index) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Index): Promise<{ readonly ok: true; readonly familiars: readonly CatalogPetV2[] } | { readonly ok: false; readonly error: string }> {
try {
const searchPets = filterSurfaceablePets(await getRemoteCatalogV3Search(index));
@ -201,144 +170,3 @@ async function tryLoadSurfaceableCatalogV3Page(page: number, index: CatalogV3Ind
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function getRemoteCatalogV3Index(): Promise<CatalogV3Index> {
v3IndexPromise ||= Promise.resolve().then(async () => validateCatalogV3Index(JSON.parse(await fetchLimitedText(catalogV3Url, maxCatalogV3PageBytes)) as unknown));
return await v3IndexPromise;
}
async function getRemoteCatalogV3Page(page: number, index: CatalogV3Index): Promise<readonly CatalogPetV2[]> {
const cached = v3PageCache.get(page);
if (cached) return cached;
const pageUrl = index.pages[page];
if (!pageUrl) throw new Error("Catalog page is out of range.");
const payload = validateCatalogV3Page(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page);
const familiars = payload.familiars.map(toCatalogPetV2Compat);
assertUniquePetIds(familiars);
v3PageCache.set(page, familiars);
return familiars;
}
async function getRemoteCatalogV3Search(index: CatalogV3Index): Promise<readonly CatalogV3SearchPet[]> {
v3SearchPromise ||= Promise.resolve().then(async () => {
const searchIndex = validateCatalogV3SearchIndex(JSON.parse(await fetchLimitedText(index.search, maxCatalogV3PageBytes)) as unknown);
const pages = await Promise.all(searchIndex.pages.map(async (pageUrl, page) => validateCatalogV3SearchPage(JSON.parse(await fetchLimitedText(pageUrl, maxCatalogV3PageBytes)) as unknown, page, index.pages.length)));
const familiars = pages.flatMap((page) => page.familiars);
if (familiars.length !== index.total) throw new Error("Catalog v3 search total does not match index total.");
return familiars;
});
return await v3SearchPromise;
}
function toCatalogPetV2Compat(familiar: { readonly id: string; readonly displayName: string; readonly description: string; readonly thumbnail: string; readonly spritesheet: string; readonly zip: string; readonly category: "western" | "asian"; readonly subcategory?: string; readonly original?: boolean; readonly featured?: boolean }): CatalogPetV2 {
const entry: CatalogPetV2 = {
id: familiar.id,
displayName: familiar.displayName,
description: familiar.description,
preview: familiar.thumbnail,
spritesheet: familiar.spritesheet,
zip: familiar.zip,
category: familiar.category,
};
return {
...entry,
...(familiar.subcategory ? { subcategory: familiar.subcategory } : {}),
...(familiar.original === undefined ? {} : { original: familiar.original }),
...(familiar.featured === undefined ? {} : { featured: familiar.featured }),
};
}
async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, catalog: await getRemoteCatalogV2() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function getRemoteCatalogV2(): Promise<CatalogV2> {
v2CatalogPromise ||= Promise.resolve().then(async () => validateCatalogV2(JSON.parse(await fetchLimitedText(catalogUrl, maxCatalogBytes)) as unknown));
return await v2CatalogPromise;
}
async function getV2CatalogOrFixture(): Promise<CatalogV2> {
const remote = await tryLoadRemoteCatalog();
if (remote.ok) return remote.catalog;
const fixture = await tryLoadFixtureCatalog();
if (fixture.ok) return fixture.catalog;
throw new Error(`Catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`);
}
async function fetchLimitedText(url: string, maxBytes: number): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "error",
credentials: "omit",
});
validateCatalogEndpoint(response.url, url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await readLimitedResponse(response, maxBytes);
} finally {
clearTimeout(timeout);
}
}
async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> {
try {
return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "unknown error" };
}
}
async function loadFixtureCatalog(): Promise<unknown> {
const fixturePath = join(app.getAppPath(), fixtureRelativePath);
return JSON.parse(await readFile(fixturePath, "utf8")) as unknown;
}
async function readLimitedResponse(response: Response, maxBytes: number): Promise<string> {
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog response body is unavailable for bounded reading.");
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) throw new Error("Catalog response is too large.");
chunks.push(value);
}
return new TextDecoder().decode(concatChunks(chunks, total));
}
function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array {
const output = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return output;
}
function validateCatalogEndpoint(value: string, expected: string): void {
const url = new URL(value);
if (url.href !== expected) throw new Error("Catalog final URL is not allowed.");
}
function assertUniquePetIds(familiars: readonly CatalogPetV2[]): void {
const ids = new Set<string>();
for (const familiar of familiars) {
if (ids.has(familiar.id)) throw new Error(`Duplicate catalog v3 familiar id: ${familiar.id}`);
ids.add(familiar.id);
}
}

View file

@ -72,6 +72,8 @@ const petWindowSource = readFileSync(join(appDir, "src", "familiar-window.ts"),
const petWindowHostSource = readFileSync(join(appDir, "src", "familiar-window-host.ts"), "utf8");
const familiarInstallationSource = readFileSync(join(appDir, "src", "familiar-installation.ts"), "utf8");
const familiarInstallationArchiveSource = readFileSync(join(appDir, "src", "familiar-installation-archive.ts"), "utf8");
const catalogSource = readFileSync(join(appDir, "src", "catalog.ts"), "utf8");
const catalogRemoteSource = readFileSync(join(appDir, "src", "catalog-remote.ts"), "utf8");
const petWindowContentSource = readFileSync(join(appDir, "src", "familiar-window-content.ts"), "utf8");
const petWindowInteractionSource = readFileSync(join(appDir, "src", "familiar-window-interactions.ts"), "utf8");
const petWindowMouseInteropSource = readFileSync(join(appDir, "src", "familiar-window-mouse-interop.ts"), "utf8");
@ -353,6 +355,16 @@ assert.match(familiarInstallationArchiveSource, /redirect:\s*"error"/, "familiar
assert.match(familiarInstallationArchiveSource, /validateZipUrl\(response\.url\)/, "familiar-installation archive downloads must re-validate the final URL.");
assert.match(familiarInstallationArchiveSource, /strictFileNames:\s*true/, "familiar-installation archive extraction must keep yauzl strict file-name validation.");
assert.match(familiarInstallationArchiveSource, /safePath\.relativeOutputPath === "spritesheet\.webp"[\s\S]*?maxCodexSpritesheetBytes/, "familiar-installation archive extraction must cap spritesheets at the codex size limit.");
assert.match(catalogSource, /from "\.\/catalog-remote(?:\.js)?"/, "catalog must import the extracted remote helper seam.");
assert.match(catalogSource, /export \{ catalogUrl, catalogV3Url \} from "\.\/catalog-remote(?:\.js)?"/, "catalog must re-export catalog URLs through the extracted remote seam.");
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Index/, "catalog remote seam must export the v3 index loader.");
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Page/, "catalog remote seam must export the v3 page loader.");
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalog/, "catalog remote seam must export the v2 remote loader.");
assert.match(catalogRemoteSource, /export async function tryLoadFixtureCatalog/, "catalog remote seam must export the fixture loader.");
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Index/, "catalog remote seam must export the v3 index fetch/cache helper.");
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Page/, "catalog remote seam must export the v3 page fetch/cache helper.");
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Search/, "catalog remote seam must export the v3 search fetch/cache helper.");
assert.match(catalogRemoteSource, /export async function getV2CatalogOrFixture/, "catalog remote seam must export the v2/fixture fallback helper.");
assert.match(petWindowContentSource, /export async function createDefaultPetRender/, "familiar-window content helper must export default familiar rendering.");
assert.match(petWindowContentSource, /export async function createInstalledPetRender/, "familiar-window content helper must export installed familiar rendering.");
assert.match(petWindowLayoutSource, /export function getBasePetWindowSize/, "familiar-window-layout must export base window sizing.");

View file

@ -285,7 +285,8 @@ plugin-service-local-support.ts → plugin-local-loader.ts validates selected fo
- `familiar-paths.ts`: Safe path resolution for familiar directories
- `codex-familiars.ts`: Import from `~/.codex/familiars/` with validation
- `codex-familiars-core.ts`: Codex metadata validation constants
- `catalog.ts`: Remote catalog fetch with V3 pagination support, search, fixture fallback
- `catalog.ts`: Catalog UI-state assembly, v3 surfaceability paging, pet lookup, and fixture fallback presentation.
- `catalog-remote.ts`: Extracted remote catalog v2/v3 fetch/cache plumbing, fixture loading, and bounded response validation.
- `catalog-validation.ts`: CatalogV2/V3 schema validation
- `zip-safety.ts`: ZIP entry path validation (traversal prevention, case collision detection)

View file

@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const desktopRoot = process.env.FAMILIAROS_DESKTOP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
const catalogSource = readFileSync(resolve(desktopRoot, "src/catalog.ts"), "utf8");
const catalogRemoteSource = readFileSync(resolve(desktopRoot, "src/catalog-remote.ts"), "utf8");
assert.match(catalogSource, /from "\.\/catalog-remote(?:\.js)?"/, "catalog must import the extracted remote helper seam.");
assert.match(catalogSource, /export \{ catalogUrl, catalogV3Url \} from "\.\/catalog-remote(?:\.js)?"/, "catalog must re-export catalog URLs through the extracted remote seam.");
assert.match(catalogSource, /getRemoteCatalogV3Page/, "catalog must delegate v3 page reads through the extracted remote seam.");
assert.match(catalogSource, /getRemoteCatalogV3Search/, "catalog must delegate v3 search reads through the extracted remote seam.");
assert.match(catalogSource, /tryLoadRemoteCatalogV3Index/, "catalog must delegate v3 index reads through the extracted remote seam.");
assert.match(catalogSource, /tryLoadRemoteCatalog/, "catalog must delegate v2 remote reads through the extracted remote seam.");
assert.match(catalogSource, /tryLoadFixtureCatalog/, "catalog must delegate fixture fallback reads through the extracted remote seam.");
assert.match(catalogSource, /getV2CatalogOrFixture/, "catalog must delegate v2-or-fixture fallback through the extracted remote seam.");
assert.match(catalogRemoteSource, /export const catalogUrl/, "catalog remote seam must export the v2 catalog URL.");
assert.match(catalogRemoteSource, /export const catalogV3Url/, "catalog remote seam must export the v3 catalog URL.");
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Index/, "catalog remote seam must export the v3 index loader.");
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalogV3Page/, "catalog remote seam must export the v3 page loader.");
assert.match(catalogRemoteSource, /export async function tryLoadRemoteCatalog/, "catalog remote seam must export the v2 remote loader.");
assert.match(catalogRemoteSource, /export async function tryLoadFixtureCatalog/, "catalog remote seam must export the fixture loader.");
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Index/, "catalog remote seam must export the v3 index fetch/cache helper.");
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Page/, "catalog remote seam must export the v3 page fetch/cache helper.");
assert.match(catalogRemoteSource, /export async function getRemoteCatalogV3Search/, "catalog remote seam must export the v3 search fetch/cache helper.");
assert.match(catalogRemoteSource, /export async function getV2CatalogOrFixture/, "catalog remote seam must export the v2/fixture fallback helper.");
console.error("Catalog remote seam validation passed.");