Files
TREK/server/scripts/gen-plugin-facts.ts
T
Maurice 95f9f031c4 feat(plugins): cut entity results down to a published field list
The entity reads of the plugin RPC surface handed plugins whatever their
query produced, mostly SELECT t.*, with credentials stripped by a denylist
(withoutFeedToken). Every column a migration added reached every plugin
holding the read grant, and renaming one was an unversioned break of a
published API.

protocol/output-contract.ts now states, for every wire method, what its
result is: rows of one of the nine entities the SDK types (trip, place,
day, reservation, packingItem, tripFile, budgetItem, assignment, user), a
value the host builds itself, or a read model passed on as the REST route
returns it. The router applies it to each result on the way out, so an
entity row keeps exactly the fields plugins received when the list was
drawn, in the same order and with the same values, minus the trip's
feed_token (listMine, create and update used to carry it as null). The
table is keyed by every method, so a new one does not compile without an
entry.

output-contract.schema.test.ts holds the lists to the migrated schema: a
new column on a whole-row table fails until it is published or withheld,
and each entity method is called on the real services to prove the
contract drops nothing but the withheld fields.

gen:plugin-facts publishes the lists to the SDK as PLUGIN_ENTITY_FIELDS
and PLUGIN_METHOD_RESULT, and the SDK's entity interfaces are type-checked
against them. Place.day_id, which no read ever delivered, is deprecated
rather than removed. KNOWN_ADDONS is generated from ADDON_IDS as well,
which adds the dawarich, roadtrip and tours ids the hand-kept copy lacked.
The SDK goes to 1.9.0, and its README and the plugin wiki pages describe
what a ctx call returns.
2026-10-08 18:41:01 +02:00

225 lines
8.9 KiB
TypeScript

/**
* Regenerates every derived copy of the plugin permission facts from the single source,
* server/src/nest/plugins/protocol/envelope.ts, plus the output contract
* (protocol/output-contract.ts) and the addon ids (src/addons.ts) the SDK publishes.
*
* node --import tsx server/scripts/gen-plugin-facts.ts # write
* node --import tsx server/scripts/gen-plugin-facts.ts --check # exit 1 on drift
*
* It *imports* envelope.ts rather than scraping it. The guard it replaces
* (plugin-sdk/test/permissions-parity.test.ts) pulled the block out with a regex, which
* goes quietly null the moment someone reformats a closing brace — and that test only ran
* from prepublishOnly, so the failure would have surfaced as a broken npm release.
*
* The generated files are CHECKED IN: publish-plugin-sdk runs inside a standalone
* plugin-sdk/ checkout with no server present, so a build-time artefact would ship an SDK
* with an empty permission list.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
} from '../src/nest/plugins/protocol/envelope';
import { SNAPSHOT_GRANT, ENTITY_ID_KEYS } from '../src/plugin-event-sink';
import {
PLUGIN_ENTITY_CONTRACT, PLUGIN_METHOD_OUTPUT, pluginEntityFields, type PluginEntityName, type PluginMethodOutput,
} from '../src/nest/plugins/protocol/output-contract';
import { ADDON_IDS } from '../src/addons';
// A relative import of the shared SOURCE, not @trek/shared: the plugin-facts CI job
// installs only the server, so shared's dist does not exist there. The file has no
// imports of its own for exactly this reason.
import {
PLUGIN_POI_ICONS, PLUGIN_POI_LABEL_MAX, PLUGIN_POI_MAX_CATEGORIES,
} from '../../shared/src/plugins/plugin-poi-facts';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const HEADER = [
'// GENERATED — do not edit by hand.',
'// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts',
'// + server/src/nest/plugins/protocol/output-contract.ts + server/src/addons.ts',
'// + shared/src/plugins/plugin-poi-facts.ts',
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
'',
].join('\n');
const list = (xs: readonly string[]) => xs.map((x) => ` '${x}',`).join('\n');
const pairs = (o: Readonly<Record<string, string>>) =>
Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n');
/**
* Types here are deliberately WIDE — Readonly<Record<string, string>> and string[] —
* matching exactly what plugin-sdk published before this file existed. An `as const`
* literal would be TS7053 under plugin-sdk/tsconfig.json's "strict": true, where
* grantGaps indexes HOOK_PERMISSION[key] with a plain string, and it would be a
* source-breaking type change for any plugin author doing a dynamic lookup. Strictness
* belongs on the host side, where the data source lives.
*/
const EVENT_FAMILIES = [...new Set([...Object.keys(ENTITY_ID_KEYS), ...Object.keys(SNAPSHOT_GRANT)])].sort();
/** `'trip'`, `'trip[]'`, `'host'` or `'readModel'`: what a method's result is. */
const describeOutput = (output: PluginMethodOutput): string =>
output.kind === 'entity' ? `${output.entity}${output.many ? '[]' : ''}` : output.kind;
const fieldList = (fields: readonly string[]) => fields.map((f) => ` '${f}',`).join('\n');
const ENTITY_FIELDS = (Object.keys(PLUGIN_ENTITY_CONTRACT) as PluginEntityName[])
.map((entity) => ` ${entity}: [\n${fieldList(pluginEntityFields(entity))}\n ],`)
.join('\n');
const METHOD_RESULT = Object.fromEntries(
Object.entries(PLUGIN_METHOD_OUTPUT).map(([method, output]) => [method, describeOutput(output)]),
);
const SDK_FACTS = `${HEADER}
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
};
export const KNOWN_PERMISSIONS: string[] = [
${list(KNOWN_PERMISSIONS)}
];
export const METHOD_PERMISSION: Readonly<Record<string, string>> = {
${pairs(METHOD_PERMISSION)}
};
export const KNOWN_METHODS: string[] = [
${list(KNOWN_METHODS)}
];
export const USER_DATA_PERMISSION = '${USER_DATA_PERMISSION}';
export const EVENTS_PERMISSION = '${EVENTS_PERMISSION}';
export const JOBS_PERMISSION = '${JOBS_PERMISSION}';
export const HTTP_OUTBOUND_PREFIX = '${HTTP_OUTBOUND_PREFIX}';
/**
* Core-event catalog. Delivery names are the WebSocket broadcast names,
* \`<family>:<verb>\` (e.g. \`place:created\`). A subscribed plugin receives
* \`{event, tripId, entity?, entityId?, snapshot?}\`; \`snapshot\` is delivered only
* when the plugin also holds EVENT_SNAPSHOT_GRANT[family]. Delete/reorder/bulk
* events carry no snapshot.
*/
export const EVENT_FAMILIES: readonly string[] = [
${list(EVENT_FAMILIES)}
];
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
${pairs(SNAPSHOT_GRANT)}
};
/**
* \`capabilities.poiCategories\` (#1781): the lucide icons a category may use, how many
* categories one plugin may declare, and the longest label.
*/
export const POI_CATEGORY_ICONS: string[] = [
${list(PLUGIN_POI_ICONS)}
];
export const POI_CATEGORY_MAX = ${PLUGIN_POI_MAX_CATEGORIES};
export const POI_CATEGORY_LABEL_MAX = ${PLUGIN_POI_LABEL_MAX};
/**
* The addon ids a manifest's \`requiredAddons\` may name: TREK's ADDON_IDS.
*/
export const KNOWN_ADDONS: string[] = [
${list(Object.values(ADDON_IDS))}
];
/**
* The fields each entity result carries, and nothing else: the row's published
* columns, then the keys the host adds (joined names, counts, hydrated children).
* A column TREK adds later is not delivered until it is listed here. A literal,
* unlike the wide tables above, because it is new (nothing published depends on a
* wider type) and the SDK's entity interfaces are type-checked against it.
*/
export const PLUGIN_ENTITY_FIELDS = {
${ENTITY_FIELDS}
} as const;
/**
* What each ctx method returns: an entity from PLUGIN_ENTITY_FIELDS (\`trip\`, or
* \`trip[]\` for a list), \`host\` for a value the host builds itself or data the
* plugin owns, or \`readModel\` for a domain read model passed on as the app's own
* REST route returns it.
*/
export const PLUGIN_METHOD_RESULT: Readonly<Record<string, string>> = {
${pairs(METHOD_RESULT)}
};
`;
const SHARED_FACTS = `${HEADER}
/** Widened on purpose so client callers can .includes() with a plain string. */
export const PLUGIN_PERMISSIONS: readonly string[] = [
${list(KNOWN_PERMISSIONS)}
];
export const PLUGIN_HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
};
`;
const OUTPUTS: Array<[string, string]> = [
['plugin-sdk/src/generated/host-facts.ts', SDK_FACTS],
['shared/src/plugin-permissions.ts', SHARED_FACTS],
];
/**
* Coverage assertions the type system cannot make, because the targets are not TypeScript
* unions. Both failure modes are silent today: a new permission reaches the consent screen
* as a raw code with no translation, and `trek-plugin permissions` prints it with no hint.
*/
function coverageProblems(): string[] {
const problems: string[] = [];
// Match the whole quoted key, not a prefix of it. `includes('plugins.perm.db:own')`
// also matches 'plugins.perm.db:ownX', and several permissions are prefixes of others
// ('db:read:files' vs 'db:read:files:content'), so a substring test passes on exactly
// the drift it is supposed to catch.
const enAdmin = fs.readFileSync(path.join(REPO, 'shared/src/i18n/en/admin.ts'), 'utf8');
const missingI18n = KNOWN_PERMISSIONS.filter((p) => !enAdmin.includes(`'admin.plugins.perm.${p}':`));
if (missingI18n.length) {
problems.push(
`shared/src/i18n/en/admin.ts is missing 'admin.plugins.perm.<permission>' for: ${missingI18n.join(', ')}`,
);
}
const ui = fs.readFileSync(path.join(REPO, 'plugin-sdk/src/cli/ui.ts'), 'utf8');
const missingHint = KNOWN_PERMISSIONS.filter((p) => !ui.includes(`'${p}'`));
if (missingHint.length) {
problems.push(
`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`,
);
}
return problems;
}
const check = process.argv.includes('--check');
let failed = false;
for (const [rel, content] of OUTPUTS) {
const abs = path.join(REPO, rel);
const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
if (current === content) continue;
if (check) {
console.error(`DRIFT: ${rel} does not match envelope.ts.`);
console.error(' Run: node --import tsx server/scripts/gen-plugin-facts.ts');
failed = true;
} else {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
console.log(`wrote ${rel}`);
}
}
for (const problem of coverageProblems()) {
console.error(`COVERAGE: ${problem}`);
failed = true;
}
if (failed) process.exit(1);
if (check) console.log('plugin facts are in sync with envelope.ts');