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
7 changes: 6 additions & 1 deletion lib/client.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/client.js.map

Large diffs are not rendered by default.

21 changes: 19 additions & 2 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,28 @@ const PROVIDER_REGISTRY = [
];
const PROVIDER_IDS = PROVIDER_REGISTRY.map((e) => e.id);
const KEYLESS_IDS = PROVIDER_REGISTRY.filter((e) => e.key === void 0).map((e) => e.id);
/**
* Provider ids that once shipped and have since been removed from PROVIDER_REGISTRY.
*
* The rule: an id that has ever been released stays valid in `sources` forever. A user may have
* whitelisted it in `cordis.patch.yml`, and a stored config that no longer validates fails the
* plugin at startup. So when a provider is removed, move its id here instead of just deleting it.
* A retired id is accepted by `sources` and is a no-op: `enabledProviders` filters by registry
* entries, so it enables nothing. Mirror every change in RETIRED_SOURCE_IDS
* (src/client/settings-model.ts): tests/config.test.ts (SHIPPED_IDS) fails until RETIRED_IDS has
* it, and tests/settings-model.test.ts until RETIRED_SOURCE_IDS mirrors it.
*/
const RETIRED_IDS = [];
/** The ids `sources` accepts: every live provider id plus every retired one. */
function sourceIdUnion(current, retired) {
return z.union([...current, ...retired]);
}
const secret = (text) => z.string().role("secret").description(text).volatile();
/**
* Schemastery schema. Every field is volatile: a settings edit commits into the
* same references and emits `loader/volatile-update` instead of remounting the
* plugin. An unknown `sources` id fails validation, so the Host refuses the write.
* plugin. An unknown `sources` id fails validation, so the Host refuses the write; a retired
* one (RETIRED_IDS) still validates, so a stored whitelist survives a provider's removal.
*/
const Config = z.object({
unsplashAccessKey: secret("Unsplash access key (free at unsplash.com/developers). Empty disables unsplash. Env: REFKIT_UNSPLASH_KEY / UNSPLASH_KEY."),
Expand All @@ -236,7 +253,7 @@ const Config = z.object({
jamendoClientId: secret("Jamendo client id. Env: REFKIT_JAMENDO_CLIENT_ID / JAMENDO_CLIENT_ID."),
europeanaApiKey: secret("Europeana API key (free). Env: REFKIT_EUROPEANA_KEY / EUROPEANA_KEY."),
openverseToken: secret("Optional Openverse OAuth2 token; anonymous works with lower rate limits. Env: REFKIT_OPENVERSE_TOKEN."),
sources: z.array(z.union(PROVIDER_IDS)).default([]).description("Provider ids to enable (empty = every source whose key is present).").volatile(),
sources: z.array(sourceIdUnion(PROVIDER_IDS, RETIRED_IDS)).default([]).description("Provider ids to enable (empty = every source whose key is present).").volatile(),
limit: z.number().step(1).min(1).max(30).default(DEFAULTS.limit).description("Default results per call; also caps per-item detail fetches for met, rijksmuseum and polyhaven.").volatile(),
poolFactor: z.number().step(1).min(1).max(4).default(DEFAULTS.poolFactor).description("Rank-fusion pool multiplier.").volatile(),
deadlineMs: z.number().step(1).min(1e3).max(MAX_DEADLINE_MS).default(DEFAULTS.deadlineMs).description("Whole-search deadline in ms.").volatile(),
Expand Down
5 changes: 5 additions & 0 deletions lib/types/client/settings-model.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export declare const KEY_FIELDS_CLIENT: readonly string[];
export declare const KEY_ENV_HINT: Readonly<Record<string, readonly string[]>>;
/** Every provider id `sources` accepts (mirrors `PROVIDER_IDS`). */
export declare const SOURCE_IDS: readonly string[];
/**
* Ids that once shipped and were since removed (mirrors `RETIRED_IDS`). `sources` still accepts
* them, so a stored one never blocks saving the page, but the page does not list them.
*/
export declare const RETIRED_SOURCE_IDS: readonly string[];
/** Inclusive bounds and schema default of one whole-number field. */
export interface NumberBounds {
min: number;
Expand Down
2 changes: 1 addition & 1 deletion lib/types/client/settings-model.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion lib/types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,25 @@ export interface ProviderEntry {
export declare const PROVIDER_REGISTRY: readonly ProviderEntry[];
export declare const PROVIDER_IDS: readonly string[];
export declare const KEYLESS_IDS: readonly string[];
/**
* Provider ids that once shipped and have since been removed from PROVIDER_REGISTRY.
*
* The rule: an id that has ever been released stays valid in `sources` forever. A user may have
* whitelisted it in `cordis.patch.yml`, and a stored config that no longer validates fails the
* plugin at startup. So when a provider is removed, move its id here instead of just deleting it.
* A retired id is accepted by `sources` and is a no-op: `enabledProviders` filters by registry
* entries, so it enables nothing. Mirror every change in RETIRED_SOURCE_IDS
* (src/client/settings-model.ts): tests/config.test.ts (SHIPPED_IDS) fails until RETIRED_IDS has
* it, and tests/settings-model.test.ts until RETIRED_SOURCE_IDS mirrors it.
*/
export declare const RETIRED_IDS: readonly string[];
/** The ids `sources` accepts: every live provider id plus every retired one. */
export declare function sourceIdUnion(current: readonly string[], retired: readonly string[]): z<string, string, "plain">;
/**
* Schemastery schema. Every field is volatile: a settings edit commits into the
* same references and emits `loader/volatile-update` instead of remounting the
* plugin. An unknown `sources` id fails validation, so the Host refuses the write.
* plugin. An unknown `sources` id fails validation, so the Host refuses the write; a retired
* one (RETIRED_IDS) still validates, so a stored whitelist survives a provider's removal.
*/
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
unsplashAccessKey: z<string, string, "volatile">;
Expand Down
2 changes: 1 addition & 1 deletion lib/types/config.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,10 @@
"@deepseek-ai/schemastery": "~3.18.4",
"@types/node": "^22.20.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"eslint": "^10.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tsdown": "0.22.2",
"typescript": "~5.7.2",
"typescript-eslint": "^8.64.0",
Expand Down
33 changes: 33 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 9 additions & 5 deletions scripts/check-client-bundle.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Asserts the browser bundle is the dsh loader's closure-factory artifact and
// carries the slot key. Runs in CI after `pnpm build`.
// carries the slot key, and requires nothing outside the shared externals
// table (scripts/client-externals.json, also read by tsdown.config.ts). Runs in
// CI after `pnpm build`.
import { readFileSync } from 'node:fs'
import externals from './client-externals.json' with { type: 'json' }

const js = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
// Rolldown re-wraps the banner/footer across lines, so compare with whitespace
Expand All @@ -11,8 +14,9 @@ const banner = squash('window.__ModuleLoader__.load({ id: "@refkit/dsh-plugin",
if (!body.startsWith(banner)) { console.error('client bundle: missing loader banner'); process.exit(1) }
if (!body.endsWith(squash('return module.exports; } });'))) { console.error('client bundle: missing loader footer'); process.exit(1) }
if (!js.includes('"refkit_search"')) { console.error('client bundle: slot key not found'); process.exit(1) }
const forbidden = [...js.matchAll(/require\("(@deepseek-ai\/[^"]+)"\)/g)].map(m => m[1])
const allowed = new Set(['@deepseek-ai/cordis', '@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-dockkit'])
const bad = forbidden.filter(id => !allowed.has(id))
if (bad.length > 0) { console.error('client bundle: non-platform requires', bad); process.exit(1) }
// Every require("...") string-literal call in the bundle must name a table entry.
const allowed = new Set(externals)
const required = [...js.matchAll(/require\(\s*(["'`])(.*?)\1\s*\)/g)].map(m => m[2])
const bad = [...new Set(required.filter(id => !allowed.has(id)))]
if (bad.length > 0) { console.error('client bundle: requires outside the externals table', bad); process.exit(1) }
console.log('client bundle ok', js.length, 'bytes')
Loading
Loading