diff --git a/.workers/plugins-registry/src/bundles.test.mjs b/.workers/plugins-registry/src/bundles.test.mjs new file mode 100644 index 0000000..d650af5 --- /dev/null +++ b/.workers/plugins-registry/src/bundles.test.mjs @@ -0,0 +1,178 @@ +/** + * bundles.test.mjs — GET /bundles.json route + schema validation (P6-E4-W3-S3-T8, ADR-P6-03) + * + * Covers validateBundlesJson() (structural check against bundles-schema.json's + * constraints, without a JSON-Schema library dependency) and handleBundlesJson() + * (KV cache hit/miss, upstream fetch failure -> 502, schema failure -> 502). + * Follows index.test.mjs's convention: Node's built-in test runner against + * index.js directly (this package's real, deployed source — no vitest/jest). + * + * Run: node --test src/*.test.mjs + * (from .workers/plugins-registry) + */ + +import { test, describe, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { validateBundlesJson, handleBundlesJson } from './index.js'; + +// --------------------------------------------------------------------------- +// validateBundlesJson() — structural schema check +// --------------------------------------------------------------------------- + +function validBundle(overrides = {}) { + return { + display: 'Task Bundle', + tier: 'free', + price_monthly: 0, + price_yearly: 0, + saas: 'task.nself.org', + page: 'nself.org/task', + plugins: ['notifications', 'jobs'], + ...overrides, + }; +} + +function validBundlesFile() { + return { + schema_version: '2.0.0', + bundles: { + task: validBundle(), + chat: validBundle({ tier: 'paid', price_monthly: 0.99, price_yearly: 9.99, saas: 'chat.nself.org', page: 'nself.org/chat' }), + claw: validBundle({ tier: 'paid', price_monthly: 0.99, price_yearly: 9.99, saas: 'claw.nself.org', page: 'nself.org/claw' }), + family: validBundle({ tier: 'paid', price_monthly: 0.99, price_yearly: 9.99, saas: 'family.nself.org', page: 'nself.org/family' }), + sentry: validBundle({ tier: 'paid', price_monthly: 0.99, price_yearly: 9.99, saas: 'sentry.nself.org', page: 'nself.org/sentry' }), + clawde: validBundle({ tier: 'paid', price_monthly: 0.99, price_yearly: 9.99, saas: null, page: 'clawde.io' }), + }, + }; +} + +describe('validateBundlesJson()', () => { + test('a well-formed bundles.json (6 canonical slugs) passes', () => { + const { valid, errors } = validateBundlesJson(validBundlesFile()); + assert.equal(valid, true); + assert.deepEqual(errors, []); + }); + + test('non-object input fails', () => { + assert.equal(validateBundlesJson(null).valid, false); + assert.equal(validateBundlesJson('not json').valid, false); + assert.equal(validateBundlesJson(42).valid, false); + }); + + test('missing schema_version fails', () => { + const data = validBundlesFile(); + delete data.schema_version; + const { valid, errors } = validateBundlesJson(data); + assert.equal(valid, false); + assert.ok(errors.some(e => e.includes('schema_version'))); + }); + + test('a 7th slug (e.g. stale "tv") fails — TV Bundle retired 2026-08-31, schema is 6 slugs only', () => { + const data = validBundlesFile(); + data.bundles.tv = validBundle({ tier: 'paid' }); + const { valid, errors } = validateBundlesJson(data); + assert.equal(valid, false); + assert.ok(errors.some(e => e.includes('unexpected bundle slug') && e.includes('tv'))); + }); + + test('a missing canonical slug fails', () => { + const data = validBundlesFile(); + delete data.bundles.clawde; + const { valid, errors } = validateBundlesJson(data); + assert.equal(valid, false); + assert.ok(errors.some(e => e.includes('missing bundle slug') && e.includes('clawde'))); + }); + + test('a bundle entry missing a required field fails', () => { + const data = validBundlesFile(); + delete data.bundles.task.plugins; + const { valid, errors } = validateBundlesJson(data); + assert.equal(valid, false); + assert.ok(errors.some(e => e.includes('task') && e.includes('plugins'))); + }); + + test('an invalid tier value fails', () => { + const data = validBundlesFile(); + data.bundles.task.tier = 'premium'; + const { valid, errors } = validateBundlesJson(data); + assert.equal(valid, false); + assert.ok(errors.some(e => e.includes('invalid tier'))); + }); +}); + +// --------------------------------------------------------------------------- +// handleBundlesJson() — route behaviour (KV hit/miss, fetch failure, schema failure) +// --------------------------------------------------------------------------- + +const CTX = { waitUntil: () => {} }; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('handleBundlesJson()', () => { + test('no GH_ACCESS_TOKEN and no KV cache -> 502, never calls fetch', async () => { + let fetchCalled = false; + globalThis.fetch = async () => { fetchCalled = true; throw new Error('should not be called'); }; + + const env = {}; // no PLUGINS_KV, no GH_ACCESS_TOKEN + const res = await handleBundlesJson(env, CTX); + assert.equal(res.status, 502); + assert.equal(fetchCalled, false); + }); + + test('fresh KV cache -> 200 with cached data, X-Cache: HIT, never calls fetch', async () => { + let fetchCalled = false; + globalThis.fetch = async () => { fetchCalled = true; throw new Error('should not be called'); }; + + const cachedData = validBundlesFile(); + const env = { + PLUGINS_KV: { get: async () => ({ data: cachedData, timestamp: Date.now() }) }, + }; + const res = await handleBundlesJson(env, CTX); + assert.equal(res.status, 200); + assert.equal(res.headers.get('X-Cache'), 'HIT'); + assert.equal(res.headers.get('Cache-Control'), 'public, s-maxage=60, stale-while-revalidate=300'); + const body = await res.json(); + assert.deepEqual(Object.keys(body.bundles), Object.keys(cachedData.bundles)); + assert.equal(fetchCalled, false); + }); + + test('GH API fetch failure -> 502', async () => { + globalThis.fetch = async () => new Response('rate limited', { status: 403 }); + const env = { GH_ACCESS_TOKEN: 'fake-token' }; + const res = await handleBundlesJson(env, CTX); + assert.equal(res.status, 502); + }); + + test('GH API returns content that fails schema validation -> 502 with details', async () => { + const invalid = validBundlesFile(); + delete invalid.bundles.task; // now missing a canonical slug + globalThis.fetch = async () => new Response( + JSON.stringify({ content: Buffer.from(JSON.stringify(invalid)).toString('base64') }), + { status: 200 }, + ); + const env = { GH_ACCESS_TOKEN: 'fake-token' }; + const res = await handleBundlesJson(env, CTX); + assert.equal(res.status, 502); + const body = await res.json(); + assert.match(body.error, /schema validation/); + assert.ok(Array.isArray(body.details) && body.details.length > 0); + }); + + test('GH API returns valid bundles.json -> 200, X-Cache: MISS, correct Cache-Control', async () => { + const valid = validBundlesFile(); + globalThis.fetch = async () => new Response( + JSON.stringify({ content: Buffer.from(JSON.stringify(valid)).toString('base64') }), + { status: 200 }, + ); + const env = { GH_ACCESS_TOKEN: 'fake-token' }; // no PLUGINS_KV -> cache read is a no-op miss + const res = await handleBundlesJson(env, CTX); + assert.equal(res.status, 200); + assert.equal(res.headers.get('X-Cache'), 'MISS'); + assert.equal(res.headers.get('Cache-Control'), 'public, s-maxage=60, stale-while-revalidate=300'); + const body = await res.json(); + assert.deepEqual(Object.keys(body.bundles).sort(), ['chat', 'claw', 'clawde', 'family', 'sentry', 'task']); + }); +}); diff --git a/.workers/plugins-registry/src/index.js b/.workers/plugins-registry/src/index.js index cbe4f1d..d76a43c 100644 --- a/.workers/plugins-registry/src/index.js +++ b/.workers/plugins-registry/src/index.js @@ -18,6 +18,8 @@ * GET /plugins/:name/signature Ed25519 signature metadata for the plugin tarball * GET /categories All categories (merged from both registries) * GET /manifest.json CLI-compatible flat plugin array + * GET /bundles.json Bundle-to-plugin membership map (ADR-P6-03) + * GET /bundles-schema.json Schema for /bundles.json * GET /health Health check * GET /stats Cache statistics * POST /api/sync Force-refresh KV cache (webhook from GitHub Actions) @@ -26,6 +28,8 @@ * registry:free — cached free registry raw JSON (with timestamp envelope) * registry:pro — cached pro registry raw JSON (with timestamp envelope) * registry:combined — cached merged output (with timestamp envelope) + * bundles-json-v1 — cached bundles.json raw JSON (with timestamp envelope) + * bundles-schema-v1 — cached bundles-schema.json raw JSON (with timestamp envelope) * revocations:list — JSON array of revoked plugin versions * stats:global — request statistics * @@ -63,6 +67,25 @@ const KV_STATS = 'stats:global'; const DEFAULT_CACHE_TTL = 300; // seconds +// bundles.json — P6-E4-W3-S3-T8 (ADR-P6-03: served by this worker at +// plugins.nself.org/bundles.json). Lives in plugins-pro alongside registry.json +// today; the path becomes nself-org/bundles/contents/bundles.json once the +// plugins-pro -> bundles repo rename (ADR-P6-01 / W3-S3-T6) has landed — update +// these two URL constants then, nothing else in this route needs to change. +// Own KV keys, distinct from registry:*, so a bundles.json refresh/miss never +// invalidates the unrelated registry cache. +const BUNDLES_JSON_API_URL = + 'https://api.github.com/repos/nself-org/plugins-pro/contents/bundles.json'; +const BUNDLES_SCHEMA_API_URL = + 'https://api.github.com/repos/nself-org/plugins-pro/contents/bundles-schema.json'; +const KV_BUNDLES_JSON = 'bundles-json-v1'; +const KV_BUNDLES_SCHEMA = 'bundles-schema-v1'; +const BUNDLES_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=300'; + +// Canonical bundle slugs, ordering-canon order (nSelf PPI: task -> chat -> +// claw -> family -> sentry -> clawde). TV Bundle retired 2026-08-31; 6 slugs. +const CANONICAL_BUNDLE_SLUGS = ['task', 'chat', 'claw', 'family', 'sentry', 'clawde']; + // --------------------------------------------------------------------------- // CORS + response helpers // --------------------------------------------------------------------------- @@ -121,6 +144,12 @@ export default { if (method === 'GET' && path === '/manifest.json') { return await handleManifest(env, ctx); } + if (method === 'GET' && path === '/bundles.json') { + return await handleBundlesJson(env, ctx); + } + if (method === 'GET' && path === '/bundles-schema.json') { + return await handleBundlesSchema(env, ctx); + } if (method === 'GET' && path === '/health') { return handleHealth(env); } @@ -197,6 +226,8 @@ export default { 'GET /plugins/:name/signature', 'GET /categories', 'GET /manifest.json', + 'GET /bundles.json', + 'GET /bundles-schema.json', 'GET /health', 'GET /stats', 'GET /marketplace[?tier=free|pro][&category=X][&bundle=Y][&q=search]', @@ -694,6 +725,8 @@ async function handleSync(request, env, ctx) { env.PLUGINS_KV.delete(KV_FREE), env.PLUGINS_KV.delete(KV_PRO), env.PLUGINS_KV.delete(KV_COMBINED), + env.PLUGINS_KV.delete(KV_BUNDLES_JSON), + env.PLUGINS_KV.delete(KV_BUNDLES_SCHEMA), ]); } @@ -781,6 +814,149 @@ async function handleManifest(env, ctx) { }); } +// --------------------------------------------------------------------------- +// Bundles — GET /bundles.json, GET /bundles-schema.json (P6-E4-W3-S3-T8, +// ADR-P6-03). Fetch + KV-cache mirror fetchProRegistry's pattern above +// (authenticated GitHub Contents API fetch, base64 decode, timestamp- +// envelope KV cache) reused rather than re-implemented, per DRY. +// --------------------------------------------------------------------------- + +/** + * Structural validation of a fetched bundles.json body against the shape + * bundles-schema.json requires. No JSON-Schema library dependency (this + * worker is dependency-light by design) — checks exactly the constraints the + * schema encodes: schema_version present, bundles keyed by exactly the 6 + * canonical slugs, each entry carrying its required fields. + */ +function validateBundlesJson(data) { + const errors = []; + + if (typeof data !== 'object' || data === null) { + return { valid: false, errors: ['bundles.json is not an object'] }; + } + if (typeof data.schema_version !== 'string' || !/^\d+\.\d+\.\d+$/.test(data.schema_version)) { + errors.push('schema_version missing or not a semver string'); + } + if (typeof data.bundles !== 'object' || data.bundles === null || Array.isArray(data.bundles)) { + errors.push('bundles field missing or not an object'); + return { valid: false, errors }; + } + + const keys = Object.keys(data.bundles); + const unexpected = keys.filter(k => !CANONICAL_BUNDLE_SLUGS.includes(k)); + const missing = CANONICAL_BUNDLE_SLUGS.filter(k => !keys.includes(k)); + if (unexpected.length > 0) errors.push(`unexpected bundle slug(s): ${unexpected.join(', ')}`); + if (missing.length > 0) errors.push(`missing bundle slug(s): ${missing.join(', ')}`); + + const requiredFields = ['display', 'tier', 'price_monthly', 'price_yearly', 'saas', 'page', 'plugins']; + for (const [slug, entry] of Object.entries(data.bundles)) { + if (typeof entry !== 'object' || entry === null) { + errors.push(`bundle "${slug}" is not an object`); + continue; + } + for (const field of requiredFields) { + if (!(field in entry)) errors.push(`bundle "${slug}" missing required field "${field}"`); + } + if ('tier' in entry && entry.tier !== 'free' && entry.tier !== 'paid') { + errors.push(`bundle "${slug}" has invalid tier "${entry.tier}"`); + } + if ('plugins' in entry && !Array.isArray(entry.plugins)) { + errors.push(`bundle "${slug}" plugins field is not an array`); + } + } + + return { valid: errors.length === 0, errors }; +} + +async function fetchGitHubContentsJson(url, env) { + if (!env.GH_ACCESS_TOKEN) { + console.warn(`GH_ACCESS_TOKEN not set — cannot fetch ${url}`); + return null; + } + + const resp = await fetch(url, { + headers: { + 'Authorization': `token ${env.GH_ACCESS_TOKEN}`, + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'nself-plugin-registry/2.0', + }, + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ''); + console.error(`Fetch failed for ${url}: ${resp.status} — ${body.slice(0, 200)}`); + return null; + } + + const envelope = await resp.json(); + try { + return JSON.parse(atob(envelope.content.replace(/\n/g, ''))); + } catch (e) { + console.error(`Failed to decode content for ${url}:`, e.message); + return null; + } +} + +async function handleBundlesJson(env, ctx) { + const cacheTtl = parseInt(env.CACHE_TTL || DEFAULT_CACHE_TTL, 10); + + const cached = await kvGet(env, KV_BUNDLES_JSON); + if (cached && isFresh(cached, cacheTtl)) { + return jsonResponse(cached.data, 200, { + 'Cache-Control': BUNDLES_CACHE_CONTROL, + 'X-Cache': 'HIT', + }); + } + + const data = await fetchGitHubContentsJson(BUNDLES_JSON_API_URL, env); + if (data === null) { + return jsonResponse( + { error: 'bundles.json unavailable — upstream fetch failed or GH_ACCESS_TOKEN unset' }, + 502, + ); + } + + const { valid, errors } = validateBundlesJson(data); + if (!valid) { + console.error('bundles.json failed schema validation:', errors.join('; ')); + return jsonResponse({ error: 'bundles.json failed schema validation', details: errors }, 502); + } + + ctx.waitUntil(kvPutWrapped(env, KV_BUNDLES_JSON, data)); + + return jsonResponse(data, 200, { + 'Cache-Control': BUNDLES_CACHE_CONTROL, + 'X-Cache': 'MISS', + }); +} + +async function handleBundlesSchema(env, ctx) { + const cacheTtl = parseInt(env.CACHE_TTL || DEFAULT_CACHE_TTL, 10); + + const cached = await kvGet(env, KV_BUNDLES_SCHEMA); + if (cached && isFresh(cached, cacheTtl)) { + return jsonResponse(cached.data, 200, { + 'Cache-Control': BUNDLES_CACHE_CONTROL, + 'X-Cache': 'HIT', + }); + } + + const data = await fetchGitHubContentsJson(BUNDLES_SCHEMA_API_URL, env); + if (data === null) { + return jsonResponse( + { error: 'bundles-schema.json unavailable — upstream fetch failed or GH_ACCESS_TOKEN unset' }, + 502, + ); + } + + ctx.waitUntil(kvPutWrapped(env, KV_BUNDLES_SCHEMA, data)); + + return jsonResponse(data, 200, { + 'Cache-Control': BUNDLES_CACHE_CONTROL, + 'X-Cache': 'MISS', + }); +} + // --------------------------------------------------------------------------- // KV helpers — all values stored as { data, timestamp } envelope // --------------------------------------------------------------------------- @@ -864,4 +1040,4 @@ function toTitleCase(str) { // not part of the Worker's request surface. // --------------------------------------------------------------------------- -export { isDirectDownloadTier, handlePluginTarball }; +export { isDirectDownloadTier, handlePluginTarball, validateBundlesJson, handleBundlesJson }; diff --git a/.workers/plugins-registry/src/index.ts b/.workers/plugins-registry/src/index.ts index 8d5f655..5bbc370 100644 --- a/.workers/plugins-registry/src/index.ts +++ b/.workers/plugins-registry/src/index.ts @@ -16,6 +16,8 @@ * GET /registry — Alias for /registry.json * GET /categories — Category list * GET /manifest.json — Flat CLI manifest for nself plugin outdated + * GET /bundles.json — Bundle-to-plugin map (ADR-P6-03) + * GET /bundles-schema.json — Schema for /bundles.json * GET /marketplace — Enriched marketplace view * GET /stats — Cache statistics * GET /.well-known/revoked-authors.json — Author CRL (S58-T09, polled daily by CLI) @@ -25,6 +27,8 @@ * registry:free — free registry (timestamp envelope) * registry:pro — pro registry (timestamp envelope) * registry:combined — merged output (timestamp envelope) + * bundles-json-v1 — bundles.json (timestamp envelope) + * bundles-schema-v1 — bundles-schema.json (timestamp envelope) * revocations:list — JSON array of RevocationEntry * revocations:authors — JSON array of RevokedAuthorEntry (S58-T09) * stats:global — request counters @@ -42,6 +46,11 @@ import { fetchFreeRegistry, fetchProRegistry, fetchAllPlugins, + fetchBundlesJson, + fetchBundlesSchema, + validateBundlesJson, + KV_BUNDLES_JSON, + KV_BUNDLES_SCHEMA, cacheTtl, kvGet, kvPutWrapped, @@ -172,6 +181,14 @@ export default { return handleManifest(env, ctx); } + // Bundles — P6-E4-W3-S3-T8 / ADR-P6-03 + if (method === "GET" && resolvedPath === "/bundles.json") { + return handleBundlesJson(env, ctx); + } + if (method === "GET" && resolvedPath === "/bundles-schema.json") { + return handleBundlesSchema(env, ctx); + } + // Stats if (method === "GET" && resolvedPath === "/stats") { return handleStats(env); @@ -281,6 +298,8 @@ export default { "GET /registry.json", "GET /categories", "GET /manifest.json", + "GET /bundles.json", + "GET /bundles-schema.json", "GET /marketplace", "GET /marketplace/ratings/:name", "POST /marketplace/ratings/:name", @@ -631,6 +650,58 @@ async function handleManifest(env: Env, ctx: ExecutionContext): Promise { + const data = await fetchBundlesJson(env, ctx); + if (data === null) { + return jsonResponse( + { error: "bundles.json unavailable — upstream fetch failed or GH_ACCESS_TOKEN unset" }, + 502, + ); + } + + const { valid, errors } = validateBundlesJson(data); + if (!valid) { + console.error("bundles.json failed schema validation:", errors.join("; ")); + return jsonResponse( + { error: "bundles.json failed schema validation", details: errors }, + 502, + ); + } + + return jsonResponse(data, 200, { "Cache-Control": BUNDLES_CACHE_CONTROL }); +} + +// --------------------------------------------------------------------------- +// GET /bundles-schema.json — the schema bundles.json validates against. +// Passthrough + cache only; not itself schema-validated. +// --------------------------------------------------------------------------- + +async function handleBundlesSchema(env: Env, ctx: ExecutionContext): Promise { + const data = await fetchBundlesSchema(env, ctx); + if (data === null) { + return jsonResponse( + { error: "bundles-schema.json unavailable — upstream fetch failed or GH_ACCESS_TOKEN unset" }, + 502, + ); + } + return jsonResponse(data, 200, { "Cache-Control": BUNDLES_CACHE_CONTROL }); +} + // --------------------------------------------------------------------------- // GET /.well-known/revoked-authors.json — Author Certificate Revocation List // (S58-T09) @@ -728,6 +799,8 @@ async function handleSync( kv.delete("registry:pro"), kv.delete(KV_COMBINED), kv.delete(KV_MANIFEST), + kv.delete(KV_BUNDLES_JSON), + kv.delete(KV_BUNDLES_SCHEMA), ]); } diff --git a/.workers/plugins-registry/src/registry.ts b/.workers/plugins-registry/src/registry.ts index 602a808..193926a 100644 --- a/.workers/plugins-registry/src/registry.ts +++ b/.workers/plugins-registry/src/registry.ts @@ -427,4 +427,196 @@ export async function fetchAllPlugins( return { free, pro, all: [...free, ...pro] }; } +// --------------------------------------------------------------------------- +// bundles.json — P6-E4-W3-S3-T8 (ADR-P6-03: served by this worker at +// plugins.nself.org/bundles.json). Lives in plugins-pro alongside +// registry.json today; the path becomes nself-org/bundles/contents/*.json +// once the plugins-pro -> bundles repo rename (ADR-P6-01 / W3-S3-T6) has +// landed — update the two URL constants below then, nothing else here needs +// to change. Fetched the same way as the pro registry via the GitHub +// Contents API and cached under its own KV key so a bundles.json miss/ +// refresh never invalidates the unrelated pro-registry cache. +// --------------------------------------------------------------------------- + +const BUNDLES_JSON_API_URL = + "https://api.github.com/repos/nself-org/plugins-pro/contents/bundles.json"; +const BUNDLES_SCHEMA_API_URL = + "https://api.github.com/repos/nself-org/plugins-pro/contents/bundles-schema.json"; + +export const KV_BUNDLES_JSON = "bundles-json-v1"; +export const KV_BUNDLES_SCHEMA = "bundles-schema-v1"; + +// Canonical bundle slugs, in ordering-canon order (nSelf PPI "Ordering" rule: +// task → chat → claw → family → sentry → clawde). The TV Bundle was retired +// 2026-08-31 (owner directive) and bundles-schema.json's own propertyNames +// enum already excludes it — 6 slugs, not 7. +export const CANONICAL_BUNDLE_SLUGS = [ + "task", + "chat", + "claw", + "family", + "sentry", + "clawde", +] as const; + +export interface BundlesJsonFile { + schema_version: string; + bundles: Record; +} + +/** + * Structural validation of a fetched bundles.json body against the shape + * bundles-schema.json requires, without pulling in a JSON-Schema library + * (this worker is dependency-light by design). Checks exactly the + * constraints the schema encodes: schema_version present, bundles keyed by + * exactly the 6 canonical slugs, and each bundle entry carries its required + * fields. + */ +export function validateBundlesJson(data: unknown): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (typeof data !== "object" || data === null) { + return { valid: false, errors: ["bundles.json is not an object"] }; + } + const file = data as Partial; + + if (typeof file.schema_version !== "string" || !/^\d+\.\d+\.\d+$/.test(file.schema_version)) { + errors.push("schema_version missing or not a semver string"); + } + if (typeof file.bundles !== "object" || file.bundles === null || Array.isArray(file.bundles)) { + errors.push("bundles field missing or not an object"); + return { valid: false, errors }; + } + + const keys = Object.keys(file.bundles); + const unexpected = keys.filter((k) => !(CANONICAL_BUNDLE_SLUGS as readonly string[]).includes(k)); + const missing = CANONICAL_BUNDLE_SLUGS.filter((k) => !keys.includes(k)); + if (unexpected.length > 0) errors.push(`unexpected bundle slug(s): ${unexpected.join(", ")}`); + if (missing.length > 0) errors.push(`missing bundle slug(s): ${missing.join(", ")}`); + + const requiredFields = ["display", "tier", "price_monthly", "price_yearly", "saas", "page", "plugins"]; + for (const [slug, entry] of Object.entries(file.bundles)) { + if (typeof entry !== "object" || entry === null) { + errors.push(`bundle "${slug}" is not an object`); + continue; + } + const rec = entry as Record; + for (const field of requiredFields) { + if (!(field in rec)) errors.push(`bundle "${slug}" missing required field "${field}"`); + } + if ("tier" in rec && rec.tier !== "free" && rec.tier !== "paid") { + errors.push(`bundle "${slug}" has invalid tier "${String(rec.tier)}"`); + } + if ("plugins" in rec && !Array.isArray(rec.plugins)) { + errors.push(`bundle "${slug}" plugins field is not an array`); + } + } + + return { valid: errors.length === 0, errors }; +} + +/** + * Fetches bundles.json from the plugins-pro repo via the GitHub Contents + * API, mirroring fetchProRegistry's auth + KV-cache pattern above (reused + * rather than re-implemented per DRY). Returns the raw parsed JSON + * (unvalidated — callers run validateBundlesJson separately so a schema + * failure can be reported distinctly from a fetch failure) or null on + * fetch/decode failure. + */ +export async function fetchBundlesJson( + env: Env, + ctx: ExecutionContext, + bypass = false, +): Promise { + if (!env.GH_ACCESS_TOKEN) { + console.warn("GH_ACCESS_TOKEN not set — bundles.json unavailable"); + return null; + } + + const kv = env.REGISTRY ?? env.PLUGINS_KV; + const ttl = cacheTtl(env); + + if (!bypass && kv) { + const cached = await kvGet(kv, KV_BUNDLES_JSON); + if (cached && isFresh(cached, ttl)) { + return cached.data; + } + } + + const resp = await fetch(BUNDLES_JSON_API_URL, { + headers: { + Authorization: `token ${env.GH_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "nself-plugin-registry/2.0", + }, + }); + + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + console.error(`bundles.json fetch failed: ${resp.status} — ${body.slice(0, 200)}`); + return null; + } + + let data: unknown; + try { + const envelope = (await resp.json()) as GitHubContentsResponse; + data = decodeGitHubContent(envelope.content); + } catch (e) { + console.error("Failed to decode bundles.json content:", (e as Error).message); + return null; + } + + if (kv) ctx.waitUntil(kvPutWrapped(kv, KV_BUNDLES_JSON, data)); + return data; +} + +/** + * Fetches bundles-schema.json — same repo, same auth pattern, its own KV key. + * Served as-is (no validation of the schema against itself). + */ +export async function fetchBundlesSchema( + env: Env, + ctx: ExecutionContext, + bypass = false, +): Promise { + if (!env.GH_ACCESS_TOKEN) { + return null; + } + + const kv = env.REGISTRY ?? env.PLUGINS_KV; + const ttl = cacheTtl(env); + + if (!bypass && kv) { + const cached = await kvGet(kv, KV_BUNDLES_SCHEMA); + if (cached && isFresh(cached, ttl)) { + return cached.data; + } + } + + const resp = await fetch(BUNDLES_SCHEMA_API_URL, { + headers: { + Authorization: `token ${env.GH_ACCESS_TOKEN}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "nself-plugin-registry/2.0", + }, + }); + + if (!resp.ok) { + console.error(`bundles-schema.json fetch failed: ${resp.status}`); + return null; + } + + let data: unknown; + try { + const envelope = (await resp.json()) as GitHubContentsResponse; + data = decodeGitHubContent(envelope.content); + } catch (e) { + console.error("Failed to decode bundles-schema.json content:", (e as Error).message); + return null; + } + + if (kv) ctx.waitUntil(kvPutWrapped(kv, KV_BUNDLES_SCHEMA, data)); + return data; +} + export { cacheTtl, kvGet, kvPutWrapped, isFresh, DEFAULT_CACHE_TTL };