mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
Fix/plugin audit findings (#2031)
* fix(plugins): enforce manifest apiVersion at install and activation * test(plugins): exercise the absent-apiVersion default * feat(plugins): generate the core-event catalog into host-facts * docs(plugins): explain the packing.listBags write-permission binding * test(plugins): cover apiVersion upload refusal; tidy docblocks and generator header * fix(plugin-sdk): validate settings options and oauth like the host * fix(plugin-sdk): model the full manifest shape and stop dropping fields * fix(plugin-sdk): drop non-string routeProfiles icons like the host * docs(plugin-sdk): document host limits, oauth contract, and ignored asUserId * feat(plugin-sdk): enforce daily budgets and meta quotas in the mock host * feat(plugin-sdk): expose the generated core-event catalog * feat(plugin-sdk): scaffold trek range >=3.4.0 <5.0.0 ahead of TREK 4.0 * feat(plugin-sdk): scaffold the oauth broker settings for oauth:client plugins * chore(plugin-sdk): fix stale mirror docs and the colliding permissions export * docs(plugin-sdk): add the rate-limit string; restore trek docblock; match host meta sizing * feat(plugin-sdk): raise the scaffold trek floor to 4.0.0 * docs(wiki): bring the plugin pages in line with the current runtime * docs: bring the workspace CLAUDE.md guides up to date and make them committable * feat(plugin-sdk): roll back failed publishes, add unrelease, sync the fork, retro-sign updates * docs(wiki): document the publish rollback, unrelease, fork sync, and retro-signing * chore(plugin-sdk): 1.6.0
This commit is contained in:
@@ -106,7 +106,7 @@ const manifestTrekRange: OfflineCheck = {
|
||||
? fail(
|
||||
`trek: "${trek}"`,
|
||||
'This claims support for every TREK version, including ones that do not exist yet. It opts you out of the\n' +
|
||||
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=3.4.0 <4.0.0".',
|
||||
'one mechanism that stops your plugin running on a host it was never tested against. Pin a range like ">=4.0.0 <5.0.0".',
|
||||
)
|
||||
: pass(trek);
|
||||
},
|
||||
|
||||
@@ -57,6 +57,23 @@ export interface ScaffoldOptions {
|
||||
|
||||
export const TEMPLATES = ['blank', 'notification-channel'] as const;
|
||||
|
||||
/**
|
||||
* The credential fields the OAuth broker (server-side `oauth.getToken`) needs to run the
|
||||
* authorization-code flow on the plugin's behalf. ALL `scope: 'instance'` — unlike the
|
||||
* notification-channel template's `scope: 'user'` settings above, these are configured ONCE
|
||||
* per install by an admin, not per recipient: the broker itself reads them at token-exchange
|
||||
* time, there is no per-user client id/secret. Deliberately not user-scoped.
|
||||
*/
|
||||
const OAUTH_SETTINGS: Array<{
|
||||
key: string; label: string; input_type: string; required: boolean; secret?: boolean; scope: 'instance';
|
||||
}> = [
|
||||
{ key: 'oauth_authorize_url', label: 'OAuth authorize URL', input_type: 'text', required: true, scope: 'instance' },
|
||||
{ key: 'oauth_token_url', label: 'OAuth token URL', input_type: 'text', required: true, scope: 'instance' },
|
||||
{ key: 'oauth_scopes', label: 'OAuth scopes (space-separated)', input_type: 'text', required: false, scope: 'instance' },
|
||||
{ key: 'oauth_client_id', label: 'OAuth client id', input_type: 'text', required: true, scope: 'instance' },
|
||||
{ key: 'oauth_client_secret', label: 'OAuth client secret', input_type: 'text', required: true, secret: true, scope: 'instance' },
|
||||
];
|
||||
|
||||
/** Mirrors the server's widget slots (and the manifest validator's). */
|
||||
export const WIDGET_SLOTS = ['sidebar', 'hero', 'place-detail', 'day-detail', 'reservation-detail'] as const;
|
||||
export type WidgetSlot = (typeof WIDGET_SLOTS)[number];
|
||||
@@ -121,11 +138,13 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
|
||||
author: opts.author || 'Your Name',
|
||||
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
|
||||
type,
|
||||
// 3.4.0, not 3.3.0. The `ctx` namespaces this scaffold is written against — ctx.meta,
|
||||
// ctx.places, ctx.days, ctx.itinerary, ctx.costs, ctx.packing, ctx.files — only exist from
|
||||
// 3.4.0. Claiming 3.3.0 lets the plugin install on a host where they are `undefined`, which
|
||||
// fails at the first call with a TypeError instead of a clear "incompatible" at install.
|
||||
trek: '>=3.4.0 <4.0.0',
|
||||
// Floor 4.0.0: the host surface this scaffold is written against — the enforced
|
||||
// apiVersion gate, the generated core-event catalog (EVENT_FAMILIES /
|
||||
// EVENT_SNAPSHOT_GRANT), the OAuth-broker settings contract — is the TREK 4 surface.
|
||||
// Claiming an older floor lets the plugin install on a host where parts of it are
|
||||
// missing, which fails at the first call instead of a clear "incompatible" at install.
|
||||
// Ceiling 5.0.0: bounded, per the trek-range rule; widen only against a tested host.
|
||||
trek: '>=4.0.0 <5.0.0',
|
||||
nativeModules: false,
|
||||
permissions: perms,
|
||||
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
|
||||
@@ -183,6 +202,16 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
|
||||
},
|
||||
];
|
||||
}
|
||||
// The broker (ctx.oauth.getToken) needs somewhere to read the authorize/token URLs and the
|
||||
// app's client id/secret — an admin configures them once, instance-wide, and the broker
|
||||
// reads them at token-exchange time. Merge with any settings the template above already
|
||||
// wrote (a channel template never grants oauth:client today, so there is no real overlap,
|
||||
// but keys are deduped anyway rather than trusting that stays true).
|
||||
if (perms.includes('oauth:client')) {
|
||||
const existing = (manifest.settings as Array<{ key: string }> | undefined) ?? [];
|
||||
const seen = new Set(existing.map((s) => s.key));
|
||||
manifest.settings = [...existing, ...OAUTH_SETTINGS.filter((s) => !seen.has(s.key))];
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(root, 'trek-plugin.json'), JSON.stringify(manifest, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(root, 'server', 'index.js'), isChannel ? CHANNEL_JS : SERVER_JS(perms.includes('db:own')));
|
||||
|
||||
@@ -56,7 +56,7 @@ interface Entry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower bound of a `trek` range like ">=3.2.0 <4.0.0" -> "3.2.0" (matches the server).
|
||||
* Lower bound of a `trek` range like ">=4.0.0 <5.0.0" -> "4.0.0" (matches the server).
|
||||
*
|
||||
* Read off the range with semver rather than by finding the first version-shaped substring:
|
||||
* for "<4.0.0" the first such substring is 4.0.0, which is the range's *upper* bound and
|
||||
@@ -91,7 +91,7 @@ export function buildEntry(opts: {
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
|
||||
// minVersion() is null for a range nothing can satisfy (">=4.0.0 <3.0.0" parses fine), so
|
||||
// this doubles as the satisfiability check — TREK would refuse to install such a plugin.
|
||||
if (!minTrekFrom(manifest.trek)) throw new Error('manifest has no valid "trek" version range (e.g. "trek": ">=3.2.0 <4.0.0")');
|
||||
if (!minTrekFrom(manifest.trek)) throw new Error('manifest has no valid "trek" version range (e.g. "trek": ">=4.0.0 <5.0.0")');
|
||||
if (!fs.existsSync(opts.zipPath)) throw new Error(`artifact not found: ${opts.zipPath} — run \`trek-plugin pack\` first`);
|
||||
|
||||
const buf = fs.readFileSync(opts.zipPath);
|
||||
|
||||
@@ -18,7 +18,7 @@ export const VERSION_LINE = (v: string): string => `trek-plugin-sdk ${v}`;
|
||||
export const PATH_COMMANDS = ['create', 'dev', 'status', 'publish'] as const;
|
||||
|
||||
/** Real, supported, and not what a newcomer needs to see first. */
|
||||
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release'] as const;
|
||||
export const OTHER_COMMANDS = ['validate', 'pack', 'shot', 'keygen', 'sign', 'entry', 'preflight', 'submit', 'release', 'unrelease'] as const;
|
||||
|
||||
export interface CommandHelp {
|
||||
/** One line, for the top-level list. */
|
||||
@@ -85,7 +85,7 @@ you like.`,
|
||||
|
||||
publish: {
|
||||
summary: 'release + open the registry PR',
|
||||
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign]',
|
||||
usage: 'trek-plugin publish [dir] --repo <owner/name> --tag <vX.Y.Z> [--sign] [--keep-release]',
|
||||
body: `The whole release, in order:
|
||||
|
||||
1. check every registry gate that can be checked locally
|
||||
@@ -98,6 +98,12 @@ Step 1 comes first for a reason: a GitHub release is effectively immutable, beca
|
||||
pins its sha256. If a check fails, NOTHING is packed, tagged, pushed or released — so you can fix
|
||||
it and re-run against the same version.
|
||||
|
||||
And when a LATER step fails, publish rolls back exactly what the run created — the release, the
|
||||
pushed tag, the local tag — so the same tag is free to re-run against once you have fixed the
|
||||
problem. Pass --keep-release to leave them in place instead (the error then prints the manual
|
||||
cleanup). Anything that existed before the run is never touched. A leftover tag from an earlier
|
||||
failed run is recognised (no release for it, not on HEAD) and moved to HEAD rather than reused.
|
||||
|
||||
In a terminal it OFFERS TO SIGN, and creates a key for you if you have none. A signature proves the
|
||||
artifact came from you, not just that its bytes match what the registry saw. You can add signing
|
||||
later — publishing unsigned now and signing at v1.4.0 breaks nobody — but you can never take it
|
||||
@@ -217,6 +223,19 @@ hand, or to re-check a release you have already cut.
|
||||
body: `Packs, cuts the GitHub release, and prints the registry entry — but does NOT open the registry PR.
|
||||
For when you want to release now and submit later. \`publish\` is the whole thing.`,
|
||||
},
|
||||
unrelease: {
|
||||
summary: 'delete a stranded tag + release (never a published one)',
|
||||
usage: 'trek-plugin unrelease <vX.Y.Z> [dir] --repo <owner/name> [--registry owner/name] [--yes]',
|
||||
body: `Undoes a release that never made it into the registry: deletes the GitHub release, the remote
|
||||
tag, and the local tag — each reported, each skipped when already gone. For when a publish
|
||||
failed halfway (though \`publish\` now rolls its own failures back), or you changed your mind
|
||||
before the registry PR merged.
|
||||
|
||||
The one hard rule: a version that IS published in the registry is immutable — the registry pins
|
||||
the artifact's sha256, so deleting its release breaks install/update for everyone who has it.
|
||||
unrelease checks the published index first and refuses those outright, with no override. If the
|
||||
registry cannot be reached to check, it refuses unless you pass --yes.`,
|
||||
},
|
||||
};
|
||||
|
||||
export function topLevelHelp(version: string): string {
|
||||
|
||||
@@ -38,6 +38,7 @@ export const ADVANCED_MENU: MenuItem[] = [
|
||||
{ value: 'preflight', label: 'Preflight', hint: 'The registry checks that need the release to exist' },
|
||||
{ value: 'submit', label: 'Submit', hint: 'Open the registry PR' },
|
||||
{ value: 'release', label: 'Release', hint: 'Pack → GitHub release → print entry' },
|
||||
{ value: 'unrelease', label: 'Unrelease', hint: 'Delete a stranded tag + release (never a published one)' },
|
||||
{ value: 'back', label: '← Back', hint: '' },
|
||||
];
|
||||
|
||||
|
||||
@@ -31,8 +31,12 @@ import { plainLog, type LogSink } from './ui.js';
|
||||
function git(dir: string, args: string[], quiet = true): string {
|
||||
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: quiet ? 'pipe' : 'inherit' }).toString().trim();
|
||||
}
|
||||
function tagExists(dir: string, tag: string): boolean {
|
||||
try { git(dir, ['rev-parse', `${tag}^{commit}`]); return true; } catch { return false; }
|
||||
/** The commit a local tag points at, or null when the tag does not exist. */
|
||||
function tagSha(dir: string, tag: string): string | null {
|
||||
try { return git(dir, ['rev-parse', `${tag}^{commit}`]); } catch { return null; }
|
||||
}
|
||||
function remoteTagExists(dir: string, tag: string): boolean {
|
||||
try { return git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { return false; }
|
||||
}
|
||||
function releaseExists(repo: string, tag: string): boolean {
|
||||
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); return true; } catch { return false; }
|
||||
@@ -45,6 +49,8 @@ export async function publishPlugin(opts: {
|
||||
force?: boolean;
|
||||
/** Skip the local gates. An escape hatch for a re-run, never a good idea on a first publish. */
|
||||
skipChecks?: boolean;
|
||||
/** On failure, leave the tag/release this run created in place instead of rolling them back. */
|
||||
keepRelease?: boolean;
|
||||
/**
|
||||
* A signing state the CLI already looked up (it does, to know whether to offer signing). Passed
|
||||
* in so a single publish does not hit the registry twice for the same answer.
|
||||
@@ -93,14 +99,36 @@ export async function publishPlugin(opts: {
|
||||
|
||||
// 3. Tag (if needed) + push + GitHub release with the artifact attached
|
||||
step(3, `Tagging ${opts.tag} + creating the GitHub release…`);
|
||||
if (!tagExists(dir, opts.tag)) git(dir, ['tag', opts.tag]);
|
||||
try { git(dir, ['push', 'origin', opts.tag]); } catch {
|
||||
const head = git(dir, ['rev-parse', 'HEAD']);
|
||||
const existingTagSha = tagSha(dir, opts.tag);
|
||||
const relExisted = releaseExists(opts.repo, opts.tag);
|
||||
let createdLocalTag = false;
|
||||
let movedTag = false;
|
||||
if (existingTagSha === null) {
|
||||
git(dir, ['tag', opts.tag]);
|
||||
createdLocalTag = true;
|
||||
} else if (existingTagSha !== head) {
|
||||
// A tag that doesn't point at HEAD and has no release is debris from a failed run — reusing
|
||||
// it silently would release the UNFIXED commit. Move it. One that HAS a release is immutable.
|
||||
if (relExisted && !opts.force) {
|
||||
throw new Error(
|
||||
`tag ${opts.tag} points at ${existingTagSha.slice(0, 7)}, not HEAD (${head.slice(0, 7)}) — and its release already exists on ${opts.repo}.\n` +
|
||||
`A released artifact is immutable (the registry pins its sha256). Cut a new version,\n` +
|
||||
`or pass --force if this release was never merged into the registry.`,
|
||||
);
|
||||
}
|
||||
log(` ! tag ${opts.tag} pointed at ${existingTagSha.slice(0, 7)} (debris from an earlier run) — moving it to HEAD`);
|
||||
git(dir, ['tag', '-f', opts.tag]);
|
||||
movedTag = true;
|
||||
}
|
||||
const remoteTagExisted = remoteTagExists(dir, opts.tag);
|
||||
try { git(dir, ['push', ...(movedTag ? ['--force'] : []), 'origin', opts.tag]); } catch {
|
||||
throw new Error(`could not push tag ${opts.tag} — is "origin" your plugin's GitHub repo and are you authenticated? (git push origin ${opts.tag})`);
|
||||
}
|
||||
// A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes of a
|
||||
// release that is already in the registry breaks the checksum for everyone who has that version
|
||||
// — they can no longer install or update it. Refuse by default.
|
||||
if (releaseExists(opts.repo, opts.tag)) {
|
||||
if (relExisted) {
|
||||
if (!opts.force) {
|
||||
throw new Error(
|
||||
`release ${opts.tag} already exists on ${opts.repo}.\n` +
|
||||
@@ -115,31 +143,65 @@ export async function publishPlugin(opts: {
|
||||
}
|
||||
log(` ✓ release ${opts.tag} on ${opts.repo}`);
|
||||
|
||||
// 4. Build the entry, then run the gates that need the tag and the release to exist
|
||||
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
|
||||
if (opts.skipPreflight) {
|
||||
log('[4/5] Preflight skipped (--no-preflight).');
|
||||
} else {
|
||||
step(4, 'Preflight — the gates that need the release to exist…');
|
||||
const rep = await preflight(entry, { dir, registry: opts.registry });
|
||||
for (const f of rep.failures) log(' ✗ ' + f);
|
||||
if (!rep.ok) {
|
||||
// What THIS run created — the exact scope of a rollback. Anything that existed before the run
|
||||
// (a merged release, a pre-pushed tag) is deliberately out of bounds.
|
||||
const created = { release: !relExisted, remoteTag: !remoteTagExisted, localTag: createdLocalTag || movedTag };
|
||||
const manualCleanup =
|
||||
` gh release delete ${opts.tag} --repo ${opts.repo} --yes\n` +
|
||||
` git push origin :refs/tags/${opts.tag}\n` +
|
||||
` git tag -d ${opts.tag}`;
|
||||
const rollback = () => {
|
||||
if (created.release) {
|
||||
try { execFileSync('gh', ['release', 'delete', opts.tag, '--repo', opts.repo, '--yes'], { stdio: 'pipe' }); log(` ↩ deleted release ${opts.tag}`); }
|
||||
catch { log(` ! could not delete release ${opts.tag} — run: gh release delete ${opts.tag} --repo ${opts.repo} --yes`); }
|
||||
}
|
||||
if (created.remoteTag) {
|
||||
try { git(dir, ['push', 'origin', `:refs/tags/${opts.tag}`]); log(` ↩ deleted remote tag ${opts.tag}`); }
|
||||
catch { log(` ! could not delete remote tag ${opts.tag} — run: git push origin :refs/tags/${opts.tag}`); }
|
||||
}
|
||||
if (created.localTag) {
|
||||
try { git(dir, ['tag', '-d', opts.tag]); log(` ↩ deleted local tag ${opts.tag}`); }
|
||||
catch { log(` ! could not delete local tag ${opts.tag} — run: git tag -d ${opts.tag}`); }
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 4. Build the entry, then run the gates that need the tag and the release to exist
|
||||
const entry = buildEntry({ dir, repo: opts.repo, tag: opts.tag, zipPath: packed.artifact, signKeyPath: opts.signKeyPath, now: opts.now });
|
||||
if (opts.skipPreflight) {
|
||||
log('[4/5] Preflight skipped (--no-preflight).');
|
||||
} else {
|
||||
step(4, 'Preflight — the gates that need the release to exist…');
|
||||
const rep = await preflight(entry, { dir, registry: opts.registry });
|
||||
for (const f of rep.failures) log(' ✗ ' + f);
|
||||
if (!rep.ok) {
|
||||
throw new Error(
|
||||
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
|
||||
`Did you push your code to ${opts.repo} before publishing?`,
|
||||
);
|
||||
}
|
||||
log(` ✓ all ${rep.passed.length} checks passed`);
|
||||
}
|
||||
|
||||
// 5. Open the registry PR
|
||||
step(5, 'Opening the registry PR…');
|
||||
const { prUrl } = await submitEntry(entry, { registry: opts.registry, draft: opts.draft, signKeyPath: opts.signKeyPath });
|
||||
log(' ✓ done');
|
||||
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
|
||||
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
|
||||
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
|
||||
log(` artifact kept at ${zip}`);
|
||||
return { prUrl };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (opts.keepRelease) {
|
||||
throw new Error(
|
||||
`preflight found ${rep.failures.length} problem(s) — nothing was submitted.\n` +
|
||||
`The release is cut, so fix these, commit, and re-run against a NEW tag (or --force if this one was never merged).\n` +
|
||||
`Did you push your code to ${opts.repo} before publishing?`,
|
||||
`${msg}\n\nThe tag and release were left in place (--keep-release). To clean up by hand:\n${manualCleanup}`,
|
||||
);
|
||||
}
|
||||
log(` ✓ all ${rep.passed.length} checks passed`);
|
||||
rollback();
|
||||
throw new Error(
|
||||
`${msg}\n\nEverything this run created was rolled back — the tag ${opts.tag} is free again. Fix the problem and re-run.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Open the registry PR
|
||||
step(5, 'Opening the registry PR…');
|
||||
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
|
||||
log(' ✓ done');
|
||||
// Keep the artifact. It is the exact bytes the release and the entry's sha256 pin were computed
|
||||
// from — a re-pack on another machine or SDK version can differ (CRLF, walk order), so anyone
|
||||
// re-running `entry`/`sign` afterwards must hash THIS file, not a rebuild.
|
||||
log(` artifact kept at ${zip}`);
|
||||
return { prUrl };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Retro-signing: the missing half of "you can start signing late".
|
||||
*
|
||||
* Server-side, adding a key at v1.4.0 breaks nobody — TREK pins the key only when a signed
|
||||
* version installs. But the REGISTRY refuses a mixed entry (key present, older versions
|
||||
* unsigned), and for good reason: once an instance pins your key, it refuses the unsigned
|
||||
* older versions, and "install latest compatible" can legitimately resolve an older host to
|
||||
* one of them. So every version must be signed the moment a key appears — which used to mean
|
||||
* downloading each old artifact and signing it by hand.
|
||||
*
|
||||
* This does that tedious part: for each unsigned version it downloads the pinned artifact,
|
||||
* verifies the bytes still hash to the entry's pinned sha256 (a mismatch means the release
|
||||
* asset was changed — a far bigger problem than signing), and signs those exact bytes.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { loadPrivateKey, publicKeyBase64, signArtifact } from './sign.js';
|
||||
|
||||
export interface RetroSignableEntry {
|
||||
id?: string;
|
||||
authorPublicKey?: string;
|
||||
versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }>;
|
||||
}
|
||||
|
||||
/** True when the entry carries a key but not a signature on every version. */
|
||||
export function needsRetroSign(entry: RetroSignableEntry): boolean {
|
||||
return !!entry.authorPublicKey && entry.versions.some((v) => !v.signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign every unsigned version in place with the key at `keyPath`. Returns the versions it
|
||||
* signed. Throws — without half-signing the entry — when the key doesn't match the entry's
|
||||
* `authorPublicKey`, or when any artifact can't be fetched or no longer matches its pin.
|
||||
*/
|
||||
export async function retroSignVersions(
|
||||
entry: RetroSignableEntry,
|
||||
keyPath: string,
|
||||
log: (line: string) => void = () => {},
|
||||
): Promise<string[]> {
|
||||
if (!needsRetroSign(entry)) return [];
|
||||
|
||||
const key = loadPrivateKey(keyPath);
|
||||
if (publicKeyBase64(key) !== entry.authorPublicKey) {
|
||||
throw new Error(
|
||||
'this signing key differs from the entry\'s authorPublicKey — retro-signing older versions with it would produce signatures TREK rejects. Use the original key.',
|
||||
);
|
||||
}
|
||||
|
||||
const unsigned = entry.versions.filter((v) => !v.signature);
|
||||
const signatures = new Map<string, string>();
|
||||
const failures: string[] = [];
|
||||
for (const v of unsigned) {
|
||||
try {
|
||||
const r = await fetch(v.downloadUrl, { headers: { 'User-Agent': 'trek-plugin' } });
|
||||
if (!r.ok) { failures.push(`v${v.version}: HTTP ${r.status} fetching ${v.downloadUrl}`); continue; }
|
||||
const bytes = Buffer.from(await r.arrayBuffer());
|
||||
const hash = createHash('sha256').update(bytes).digest('hex');
|
||||
if (hash !== v.sha256) {
|
||||
failures.push(`v${v.version}: the release asset no longer hashes to its pinned sha256 (got ${hash.slice(0, 12)}…, pinned ${v.sha256.slice(0, 12)}…) — the artifact was changed after publishing, which is a bigger problem than signing`);
|
||||
continue;
|
||||
}
|
||||
signatures.set(v.version, signArtifact(bytes, key));
|
||||
} catch (e) {
|
||||
failures.push(`v${v.version}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
throw new Error(
|
||||
`could not retro-sign ${failures.length} older version(s) — the registry requires every version signed once a key is present:\n` +
|
||||
failures.map((f) => ` ✗ ${f}`).join('\n') +
|
||||
'\n\nNothing was signed. Fix the artifacts above (or remove those versions from the entry) and re-run.',
|
||||
);
|
||||
}
|
||||
|
||||
// All-or-nothing: only mutate the entry once every artifact verified and signed.
|
||||
for (const v of unsigned) {
|
||||
v.signature = signatures.get(v.version)!;
|
||||
log(` ✓ retro-signed v${v.version}`);
|
||||
}
|
||||
return unsigned.map((v) => v.version);
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readJsonFile } from './json.js';
|
||||
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
|
||||
|
||||
const DEFAULT_REGISTRY = 'liketrek/TREK-Plugins';
|
||||
|
||||
interface EntryLike {
|
||||
id: string; name?: string; authorPublicKey?: string;
|
||||
versions: { version: string }[];
|
||||
versions: { version: string; downloadUrl: string; sha256: string; signature?: string }[];
|
||||
}
|
||||
|
||||
function git(cwd: string, ...a: string[]): string {
|
||||
@@ -42,7 +43,7 @@ function mergeOnto(existing: EntryLike, fresh: EntryLike): EntryLike {
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean } = {}): { prUrl: string } {
|
||||
export async function submitEntry(entry: EntryLike, opts: { registry?: string; branch?: string; draft?: boolean; keep?: boolean; signKeyPath?: string } = {}): Promise<{ prUrl: string }> {
|
||||
const registry = opts.registry || DEFAULT_REGISTRY;
|
||||
const name = registry.split('/')[1];
|
||||
const login = gh('api', 'user', '--jq', '.login');
|
||||
@@ -51,6 +52,17 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
|
||||
// Ensure the fork exists (idempotent — prints "already exists" if it does).
|
||||
try { gh('repo', 'fork', registry, '--clone=false'); } catch { /* already forked */ }
|
||||
|
||||
// Fast-forward the fork from the registry. The PR branch is based on upstream/main either
|
||||
// way, but a fork left far behind has broken pushes and PRs in the wild — and costs nothing
|
||||
// to keep current. Best-effort: a diverged fork can't be fast-forwarded, and that alone
|
||||
// must not sink the submit.
|
||||
try { gh('repo', 'sync', `${login}/${name}`, '--source', registry); } catch {
|
||||
console.error(
|
||||
`! could not fast-forward your fork ${login}/${name} (diverged?) — continuing, the PR branch is based on the registry's main.\n` +
|
||||
` If the push or PR fails, reset the fork with: gh repo sync ${login}/${name} --force`,
|
||||
);
|
||||
}
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-submit-'));
|
||||
try {
|
||||
// Clone the fork (pushable via gh auth), then base a branch on the CURRENT upstream main.
|
||||
@@ -77,6 +89,19 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
|
||||
toWrite = mergeOnto(existing, entry);
|
||||
action = 'update';
|
||||
}
|
||||
// First signed update onto an unsigned history: the registry refuses a mixed entry (key
|
||||
// present, older versions unsigned) — see retro-sign.ts for why. Sign the older versions
|
||||
// with the same key, or say exactly what is missing.
|
||||
if (needsRetroSign(toWrite)) {
|
||||
if (!opts.signKeyPath) {
|
||||
throw new Error(
|
||||
`this update adds your signing key, but ${toWrite.versions.filter((v) => !v.signature).length} older version(s) are unsigned — ` +
|
||||
'the registry requires every version signed once a key is present. Re-run with --sign so they can be signed for you.',
|
||||
);
|
||||
}
|
||||
console.error(' older versions are unsigned — signing them with your key so the registry accepts the update');
|
||||
await retroSignVersions(toWrite, opts.signKeyPath, (line) => console.error(line));
|
||||
}
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, JSON.stringify(toWrite, null, 2) + '\n');
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import { runShot } from './shot.js';
|
||||
import { preflight } from './preflight.js';
|
||||
import { submitEntry } from './submit.js';
|
||||
import { publishPlugin } from './publish.js';
|
||||
import { unrelease } from './unrelease.js';
|
||||
import { needsRetroSign, retroSignVersions } from './retro-sign.js';
|
||||
import { loadContext } from './checks/context.js';
|
||||
import { runOffline } from './checks/index.js';
|
||||
import { renderPlain } from './checks/report.js';
|
||||
@@ -81,7 +83,8 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
||||
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all', 'registry'],
|
||||
submit: ['repo', 'tag', 'zip', 'commit', 'sign', 'key', 'registry', 'branch', 'draft', 'keep'],
|
||||
release: ['repo', 'tag', 'out', 'notes', 'commit', 'merge', 'sign', 'key'],
|
||||
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force'],
|
||||
publish: ['repo', 'tag', 'sign', 'key', 'registry', 'draft', 'notes', 'no-preflight', 'no-checks', 'force', 'keep-release'],
|
||||
unrelease: ['repo', 'tag', 'registry', 'yes'],
|
||||
};
|
||||
|
||||
function assertKnownFlags(command: string, f: Flags): void {
|
||||
@@ -107,6 +110,26 @@ function signKey(f: Flags): string | undefined {
|
||||
return f.sign === 'true' ? (f.key || defaultKeyPath()) : f.sign;
|
||||
}
|
||||
|
||||
/**
|
||||
* First signed update onto an unsigned history (`--merge` + `--sign`): the registry requires
|
||||
* every version signed once a key is present, so sign the older versions too — or say exactly
|
||||
* what is missing. See retro-sign.ts.
|
||||
*/
|
||||
async function maybeRetroSign(
|
||||
entry: { authorPublicKey?: string; versions: Array<{ version: string; downloadUrl: string; sha256: string; signature?: string }> },
|
||||
keyPath: string | undefined,
|
||||
): Promise<void> {
|
||||
if (!needsRetroSign(entry)) return;
|
||||
if (!keyPath) {
|
||||
fail(
|
||||
`this entry carries your signing key, but ${entry.versions.filter((v) => !v.signature).length} older version(s) are unsigned — ` +
|
||||
'the registry requires every version signed once a key is present. Re-run with --sign so they can be signed for you.',
|
||||
);
|
||||
}
|
||||
console.error('older versions are unsigned — signing them with your key so the registry accepts the update');
|
||||
await retroSignVersions(entry, keyPath!, (line) => console.error(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the repo + tag every publishing command needs. Interactive: prompt for
|
||||
* whatever is missing. Non-interactive: reproduce the command's exact error so
|
||||
@@ -322,6 +345,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
commit: f.commit, asset: f.asset, mergePath: f.merge,
|
||||
signKeyPath: signKey(f), now: new Date().toISOString(),
|
||||
});
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
const json = JSON.stringify(entry, null, 2) + '\n';
|
||||
if (f.out) {
|
||||
fs.writeFileSync(f.out, json);
|
||||
@@ -384,6 +408,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
dir, repo, tag,
|
||||
signKeyPath, signing, registry: f.registry, draft: !!f.draft,
|
||||
notes: f.notes, skipPreflight: !!f['no-preflight'], skipChecks: !!f['no-checks'], force: !!f.force,
|
||||
keepRelease: !!f['keep-release'],
|
||||
now: new Date().toISOString(),
|
||||
log: tui ? clackLogSink : undefined,
|
||||
});
|
||||
@@ -400,12 +425,12 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
const ok = await promptConfirm({ message: 'Open the registry PR now?', initialValue: true });
|
||||
if (!ok) { outro('Cancelled — no PR opened.'); return; }
|
||||
const s = spinner(); s.start('Opening the registry PR');
|
||||
const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
|
||||
s.stop('Registry PR opened');
|
||||
console.log(prUrl);
|
||||
} else {
|
||||
console.error(`Opening a registry PR for ${entry.id} ${entry.versions[0].version}…`);
|
||||
const { prUrl } = submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep });
|
||||
const { prUrl } = await submitEntry(entry, { registry: f.registry, branch: f.branch, draft: !!f.draft, keep: !!f.keep, signKeyPath: signKey(f) });
|
||||
console.log(prUrl);
|
||||
}
|
||||
} else if (command === 'release') {
|
||||
@@ -421,6 +446,7 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'pipe' });
|
||||
s.stop(`Released ${tag} on ${repo}`);
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
logInfo(`Registry entry (add as registry/plugins/${entry.id}.json, or run \`npx trek-plugin-sdk submit\`):`);
|
||||
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
|
||||
} else {
|
||||
@@ -429,9 +455,28 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
|
||||
console.error(`Creating GitHub release ${tag} on ${repo}…`);
|
||||
execFileSync('gh', ['release', 'create', tag, packed.artifact, '--repo', repo, '--title', tag, '--notes', f.notes || `Release ${tag}`], { stdio: 'inherit' });
|
||||
const entry = buildEntry({ dir, repo, tag, zipPath: packed.artifact, commit: f.commit, mergePath: f.merge, signKeyPath: signKey(f), now: new Date().toISOString() });
|
||||
await maybeRetroSign(entry, signKey(f));
|
||||
console.error('\nRegistry entry (add as registry/plugins/' + entry.id + '.json in a TREK-Plugins PR, or run `npx trek-plugin-sdk submit`):\n');
|
||||
process.stdout.write(JSON.stringify(entry, null, 2) + '\n');
|
||||
}
|
||||
} else if (command === 'unrelease') {
|
||||
if (positional[0]) f.tag = positional[0];
|
||||
const { repo, tag } = await ensureRepoTag(f, 'unrelease needs a tag and --repo <owner/name>: trek-plugin unrelease vX.Y.Z --repo <owner/name>');
|
||||
const dir = positional[1] || '.';
|
||||
let yes = !!f.yes;
|
||||
if (tui && !yes) {
|
||||
const ok = await promptConfirm({
|
||||
message: `Delete the GitHub release, remote tag and local tag ${tag} on ${repo}?`,
|
||||
initialValue: false,
|
||||
});
|
||||
if (!ok) { outro('Cancelled — nothing was deleted.'); return; }
|
||||
yes = true; // the human just consented — that covers the registry-unreachable guard too
|
||||
}
|
||||
const { deleted } = await unrelease({ dir, tag, repo, registry: f.registry, yes, log: tui ? clackLogSink : undefined });
|
||||
const summary = deleted.length
|
||||
? `Deleted: ${deleted.join(', ')} — ${tag} is free to publish again.`
|
||||
: `Nothing to delete — no release or tags found for ${tag}.`;
|
||||
if (tui) logSuccess(summary); else console.error(summary);
|
||||
} else {
|
||||
console.error(`unknown command: ${command}\n${USAGE}`);
|
||||
process.exit(2);
|
||||
|
||||
@@ -268,7 +268,7 @@ export const PERMISSION_CATALOG: { value: string; label: string; hint: string; f
|
||||
);
|
||||
|
||||
/** Every grantable permission id. Derived — never hand-maintained. */
|
||||
export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
|
||||
export const PICKER_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
|
||||
|
||||
// ── reporter seam (for commands that log their own progress, e.g. publish) ────
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* trek-plugin unrelease <tag> — undo a release that never made it into the registry.
|
||||
*
|
||||
* A failed publish (or a change of heart before the PR merges) leaves up to three things
|
||||
* behind: the GitHub release, the remote tag, and the local tag. Cleaning them up by hand
|
||||
* is three commands in the right order; this is that, with one hard safety rail:
|
||||
*
|
||||
* A version that IS published in the registry is immutable — the registry pins the
|
||||
* artifact's sha256, so deleting or re-cutting its release breaks install/update for
|
||||
* everyone who already has it. unrelease refuses those outright, with no override.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readJsonFile } from './json.js';
|
||||
import { DEFAULT_REGISTRY } from './checks/network.js';
|
||||
import { plainLog, type LogSink } from './ui.js';
|
||||
|
||||
function git(dir: string, args: string[]): string {
|
||||
return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: 'pipe' }).toString().trim();
|
||||
}
|
||||
|
||||
interface PublishedIndex { plugins?: Array<{ id?: string; versions?: Array<{ version?: string }> }> }
|
||||
|
||||
/**
|
||||
* The published registry index, or null when it cannot be read (offline, mirror down).
|
||||
* Null is the dangerous answer — the caller must then have explicit consent (--yes).
|
||||
*/
|
||||
async function publishedIndex(registry: string): Promise<PublishedIndex | null> {
|
||||
try {
|
||||
const r = await fetch(`https://raw.githubusercontent.com/${registry}/main/dist/index.json`, {
|
||||
headers: { 'User-Agent': 'trek-plugin' },
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
return (await r.json()) as PublishedIndex;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unrelease(opts: {
|
||||
dir: string; tag: string; repo: string; registry?: string;
|
||||
/** Consent to delete when the registry index cannot be verified. */
|
||||
yes?: boolean;
|
||||
log?: LogSink;
|
||||
}): Promise<{ deleted: string[] }> {
|
||||
const log = opts.log ?? plainLog;
|
||||
const dir = path.resolve(opts.dir);
|
||||
const { tag, repo } = opts;
|
||||
const registry = opts.registry || DEFAULT_REGISTRY;
|
||||
const manifest = readJsonFile<{ id?: unknown }>(path.join(dir, 'trek-plugin.json'));
|
||||
const id = String(manifest.id ?? '');
|
||||
const version = tag.replace(/^v/, '');
|
||||
|
||||
const index = await publishedIndex(registry);
|
||||
if (index === null) {
|
||||
if (!opts.yes) {
|
||||
throw new Error(
|
||||
`could not read the registry index (${registry}) to verify ${id} ${version} is not published.\n` +
|
||||
`Deleting a PUBLISHED version's release breaks its sha256 pin for everyone who installed it.\n` +
|
||||
`If you are sure this version never merged into the registry, re-run with --yes.`,
|
||||
);
|
||||
}
|
||||
log(` ! registry unreachable — proceeding on --yes`);
|
||||
} else {
|
||||
const mine = index.plugins?.find((p) => p.id === id);
|
||||
if (mine?.versions?.some((v) => v.version === version)) {
|
||||
throw new Error(
|
||||
`${id} ${version} is PUBLISHED in the registry (${registry}) — its release is immutable.\n` +
|
||||
`The registry pins this artifact's sha256; deleting the release would break install/update\n` +
|
||||
`for everyone who has it. Ship a new version instead. (There is deliberately no override.)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in blast-radius order: release, remote tag, local tag. Each is best-effort and
|
||||
// reported, so a partial previous cleanup doesn't stop the rest.
|
||||
const deleted: string[] = [];
|
||||
|
||||
let hasRelease = false;
|
||||
try { execFileSync('gh', ['release', 'view', tag, '--repo', repo], { stdio: 'pipe' }); hasRelease = true; } catch { /* absent */ }
|
||||
if (hasRelease) {
|
||||
execFileSync('gh', ['release', 'delete', tag, '--repo', repo, '--yes'], { stdio: 'pipe' });
|
||||
deleted.push('release');
|
||||
log(` ✓ deleted release ${tag} on ${repo}`);
|
||||
} else {
|
||||
log(` - no release ${tag} on ${repo}`);
|
||||
}
|
||||
|
||||
let hasRemoteTag = false;
|
||||
try { hasRemoteTag = git(dir, ['ls-remote', '--tags', 'origin', `refs/tags/${tag}`]) !== ''; } catch { /* no origin — nothing to delete */ }
|
||||
if (hasRemoteTag) {
|
||||
git(dir, ['push', 'origin', `:refs/tags/${tag}`]);
|
||||
deleted.push('remote tag');
|
||||
log(` ✓ deleted remote tag ${tag}`);
|
||||
} else {
|
||||
log(` - no tag ${tag} on origin`);
|
||||
}
|
||||
|
||||
let hasLocalTag = false;
|
||||
try { git(dir, ['rev-parse', `${tag}^{commit}`]); hasLocalTag = true; } catch { /* absent */ }
|
||||
if (hasLocalTag) {
|
||||
git(dir, ['tag', '-d', tag]);
|
||||
deleted.push('local tag');
|
||||
log(` ✓ deleted local tag ${tag}`);
|
||||
} else {
|
||||
log(` - no local tag ${tag}`);
|
||||
}
|
||||
|
||||
return { deleted };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// GENERATED — do not edit by hand.
|
||||
// Source: server/src/nest/plugins/protocol/envelope.ts
|
||||
// Source: server/src/nest/plugins/protocol/envelope.ts + server/src/plugin-event-sink.ts
|
||||
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
|
||||
|
||||
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
||||
@@ -316,3 +316,36 @@ export const USER_DATA_PERMISSION = 'hook:user-data';
|
||||
export const EVENTS_PERMISSION = 'events:subscribe';
|
||||
export const JOBS_PERMISSION = 'jobs:run';
|
||||
export const HTTP_OUTBOUND_PREFIX = 'http:outbound:';
|
||||
|
||||
/**
|
||||
* 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[] = [
|
||||
'accommodation',
|
||||
'assignment',
|
||||
'budget',
|
||||
'day',
|
||||
'dayNote',
|
||||
'file',
|
||||
'packing',
|
||||
'place',
|
||||
'reservation',
|
||||
'trip',
|
||||
];
|
||||
|
||||
export const EVENT_SNAPSHOT_GRANT: Readonly<Record<string, string>> = {
|
||||
place: 'db:read:trips',
|
||||
day: 'db:read:trips',
|
||||
reservation: 'db:read:trips',
|
||||
accommodation: 'db:read:trips',
|
||||
assignment: 'db:read:trips',
|
||||
trip: 'db:read:trips',
|
||||
budget: 'db:read:costs',
|
||||
packing: 'db:read:packing',
|
||||
dayNote: 'db:read:daynotes',
|
||||
file: 'db:read:files',
|
||||
};
|
||||
|
||||
+72
-19
@@ -23,6 +23,11 @@ export interface BudgetItem { id: number; trip_id?: number; name?: string; total
|
||||
export interface Assignment { id: number; day_id?: number; place_id?: number; notes?: string | null; [k: string]: unknown }
|
||||
export interface User { id: number; username?: string; display_name?: string | null; avatar?: string | null; [k: string]: unknown }
|
||||
|
||||
/** Every ctx.* call is rate-limited per plugin at the host RPC dispatch boundary:
|
||||
* burst 60, sustained 20/s, 16 in-flight. A throttled call is refused (retryable)
|
||||
* rather than executed, with:
|
||||
* HOST_ERROR: rate limit exceeded — slow down ctx.* calls
|
||||
* A legitimate plugin never hits the generous burst. See README § Runtime limits. */
|
||||
export interface PluginContext {
|
||||
readonly id: string;
|
||||
readonly config: Readonly<Record<string, unknown>>;
|
||||
@@ -32,6 +37,9 @@ export interface PluginContext {
|
||||
settings: {
|
||||
get(key: string): Promise<unknown>;
|
||||
};
|
||||
/** Your OWN sqlite database (`db:own`) — a separate file the plugin never gets a
|
||||
* path or connection to directly. Quota: 256 MB per plugin (writes past it fail);
|
||||
* result sets are capped at 100,000 rows. See `tx()` below for the atomic-batch cap. */
|
||||
db: {
|
||||
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
|
||||
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
|
||||
@@ -42,10 +50,15 @@ export interface PluginContext {
|
||||
tx(ops: Array<{ sql: string; args?: unknown[] }>): Promise<{ results: Array<{ changes?: number; rows?: unknown[] }> }>;
|
||||
};
|
||||
trips: {
|
||||
getById(tripId: number, asUserId?: number): Promise<Trip | null>;
|
||||
getPlaces(tripId: number, asUserId?: number): Promise<Place[]>;
|
||||
// `asUserId` is accepted for source compatibility but IGNORED by the host —
|
||||
// trip reads are always membership-checked against the authenticated user of
|
||||
// the current invocation (the request's `req.user`), which the plugin cannot
|
||||
// override. Only reachable from a route handler (a user context); a job has
|
||||
// no user and its trip reads are refused.
|
||||
getById(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Trip | null>;
|
||||
getPlaces(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Place[]>;
|
||||
/** Hydrated like the REST list: each row carries `endpoints`, `day_positions` + the day/place joins. */
|
||||
getReservations(tripId: number, asUserId?: number): Promise<Reservation[]>;
|
||||
getReservations(tripId: number, /** @deprecated ignored by the host */ asUserId?: number): Promise<Reservation[]>;
|
||||
/** The trip's days with their `assignments` + `notes_items` (the planner GET's shape). Needs `db:read:trips`. */
|
||||
getDays(tripId: number): Promise<Day[]>;
|
||||
/** The trip's lodging blocks (day_accommodations) with joined place fields. Needs `db:read:trips`. */
|
||||
@@ -97,7 +110,9 @@ export interface PluginContext {
|
||||
update(tripId: number, itemId: number, input: Record<string, unknown>): Promise<PackingItem>;
|
||||
/** Delete a packing item. Needs `db:write:packing` + packing_edit. */
|
||||
delete(tripId: number, itemId: number): Promise<{ deleted: boolean }>;
|
||||
/** List/create/update/delete packing bags + set members (no privacy). Needs `db:write:packing` + packing_edit. */
|
||||
// List/create/update/delete packing bags + set members (no privacy). Needs
|
||||
// `db:write:packing` + packing_edit.
|
||||
/** Needs 'db:write:packing' (intentional — bags are the write-side structure; packing.list is the read surface). */
|
||||
listBags(tripId: number): Promise<unknown[]>;
|
||||
createBag(tripId: number, input: { name: string; color?: string }): Promise<unknown>;
|
||||
updateBag(tripId: number, bagId: number, input: Record<string, unknown>): Promise<unknown>;
|
||||
@@ -133,13 +148,17 @@ export interface PluginContext {
|
||||
};
|
||||
/** Host-mediated notification. The plugin supplies only target + plain text; the host
|
||||
* owns delivery + preferences. Recipient is FORCED to the acting user (scope 'user',
|
||||
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`. */
|
||||
* targetId = the acting user) or a trip they belong to (scope 'trip'). Needs `notify:send`.
|
||||
* Daily budget: 100/day per plugin (UTC midnight rollover); past it `send` throws
|
||||
* "daily notification budget exhausted (resets at UTC midnight)". See README § Runtime limits. */
|
||||
notify: {
|
||||
send(input: { title: string; body: string; link?: string; scope: 'user' | 'trip'; targetId: number }): Promise<{ sent: boolean }>;
|
||||
};
|
||||
/** Host-mediated LLM using the admin/user-configured provider — the plugin never holds a
|
||||
* key. `complete` returns { text }; `extract` returns { results } for your JSON schema.
|
||||
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`. */
|
||||
* Output is DATA: to persist it, push it through the gated write methods yourself. Needs `ai:invoke`.
|
||||
* Daily budget: 200/day per plugin (UTC midnight rollover); past it these throw
|
||||
* "daily AI budget exhausted (resets at UTC midnight)". See README § Runtime limits. */
|
||||
ai: {
|
||||
complete(prompt: string, system?: string): Promise<{ text: string }>;
|
||||
extract(text: string, jsonSchema: object, prompt?: string): Promise<{ results: Record<string, unknown>[] }>;
|
||||
@@ -147,7 +166,17 @@ export interface PluginContext {
|
||||
/** Host-brokered outbound OAuth: a short-lived access token for the ACTING USER of a
|
||||
* third-party service the host connected on their behalf (Settings → Plugins → Connect).
|
||||
* Returns null when the user hasn't connected or in a userless context. The host holds
|
||||
* the refresh token + client secret — you never see them. Needs `oauth:client`. */
|
||||
* the refresh token + client secret — you never see them. Needs `oauth:client`.
|
||||
*
|
||||
* The host runs the whole flow (authorize -> callback -> token exchange -> refresh)
|
||||
* with PKCE + a 10-minute state TTL. Provider config is the plugin's admin-owned
|
||||
* INSTANCE settings — declare `scope:'instance'` manifest fields named
|
||||
* `oauth_authorize_url`, `oauth_token_url`, `oauth_scopes` (optional),
|
||||
* `oauth_client_id`, `oauth_client_secret` and the admin fills them in. Both
|
||||
* endpoint URLs must be https and may not point at loopback/`.local`/`.internal`/
|
||||
* private/metadata addresses. The host exposes
|
||||
* `/api/plugin-oauth/:id/status|connect|callback|disconnect` for the connect UI —
|
||||
* plugin code only ever calls `getAccessToken()`. See README § OAuth broker. */
|
||||
oauth: {
|
||||
getAccessToken(): Promise<string | null>;
|
||||
};
|
||||
@@ -282,7 +311,9 @@ export interface PluginContext {
|
||||
};
|
||||
// Your OWN namespaced key/value store on a trip/place/day (#1429) — enrich core
|
||||
// entities without forking the schema. Needs `db:meta`; the entity must belong to
|
||||
// a trip the current user can access. Values are JSON-serialisable.
|
||||
// a trip the current user can access. Values are JSON-serialisable. Quotas (per
|
||||
// plugin + entity): 64 KB serialized JSON per value, 256 chars per key, 100 keys.
|
||||
// See README § Runtime limits.
|
||||
meta: {
|
||||
get(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string): Promise<unknown>;
|
||||
set(entityType: 'trip' | 'place' | 'day' | 'reservation' | 'accommodation', entityId: number, key: string, value: unknown): Promise<unknown>;
|
||||
@@ -361,7 +392,8 @@ export interface Photo {
|
||||
export interface PhotoProvider {
|
||||
/** Search your photo backend. `ctx` is the last arg so you can reach it via
|
||||
* ctx.settings/oauth/http. Needs `hook:photo-provider`. Surfaced in the photo picker
|
||||
* and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https. */
|
||||
* and at `GET /api/plugin-photos/search`; thumbnail/full URLs must be http/https.
|
||||
* The host caps results at 60 photos per page. */
|
||||
search(query: string, opts: { page: number; limit: number }, ctx: PluginContext): Promise<{ photos: Photo[]; total: number; hasMore: boolean }>;
|
||||
getById(id: string, ctx: PluginContext): Promise<Photo | null>;
|
||||
}
|
||||
@@ -421,18 +453,21 @@ export interface CalendarSource {
|
||||
// start/end are ISO strings — the host->plugin boundary is JSON, so a Date would
|
||||
// arrive as a string anyway (kept in lockstep with the runtime SDK copy). `ctx` is the
|
||||
// last arg. Aggregated for the signed-in user at `GET /api/plugin-calendar`.
|
||||
// The host caps results at 500 events per source per request.
|
||||
getEvents(userId: number, start: string, end: string, ctx: PluginContext): Promise<CalendarEvent[]>;
|
||||
}
|
||||
/** One row of extra place info TREK renders natively (reviews/ratings/links/…). */
|
||||
export interface PlaceDetailItem { label: string; value?: string; url?: string; }
|
||||
export interface PlaceDetailProvider {
|
||||
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`. */
|
||||
/** Extra info for a place; core calls this for a `place-detail` panel. Needs `hook:place-detail-provider`.
|
||||
* The host caps results at 12 items per provider. */
|
||||
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
|
||||
}
|
||||
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
|
||||
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
|
||||
export interface WarningProvider {
|
||||
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`. */
|
||||
/** Problems/warnings for a trip (e.g. overpacked day, place closed). Needs `hook:trip-warning-provider`.
|
||||
* The host caps results at 20 warnings per provider, each message truncated to 300 chars. */
|
||||
getWarnings(tripId: number, ctx: PluginContext): Promise<TripWarning[]>;
|
||||
}
|
||||
|
||||
@@ -466,7 +501,8 @@ export type TableContribution = TableColumnContribution | TableActionContributio
|
||||
export interface TableContributor {
|
||||
/** `view` is one of 'reservations' | 'places' | 'day' | 'costs' | 'packing' | 'files'.
|
||||
* Runs with the current user bound, on a short timeout; a slow/failing call is
|
||||
* skipped, never fatal. Needs `hook:table-contributor`. */
|
||||
* skipped, never fatal. Needs `hook:table-contributor`. The host caps results at
|
||||
* 20 columns and 10 actions per entity. */
|
||||
getContributions(view: string, tripId: number, ctx: PluginContext): Promise<TableContribution[]>;
|
||||
}
|
||||
|
||||
@@ -485,7 +521,7 @@ export interface MapMarkerContribution {
|
||||
export interface MapMarkerProvider {
|
||||
/** Return markers to overlay on a trip's map. Runs with the current user bound,
|
||||
* on a short timeout; the host caps the marker count and skips a failing call.
|
||||
* Needs `hook:map-marker-provider`. */
|
||||
* Needs `hook:map-marker-provider`. The host caps results at 200 markers per provider. */
|
||||
getMarkers(tripId: number, ctx: PluginContext): Promise<MapMarkerContribution[]>;
|
||||
}
|
||||
|
||||
@@ -652,7 +688,8 @@ export interface PdfSection {
|
||||
export interface PdfSectionProvider {
|
||||
/** Return sections to append to a trip's PDF export. Runs with the current user
|
||||
* bound, on a short timeout; the host caps counts/lengths and skips a failing
|
||||
* call. Needs `hook:pdf-section-provider`. */
|
||||
* call. Needs `hook:pdf-section-provider`. The host caps results at 5 sections
|
||||
* per provider (see `PdfSection` above for the per-section paragraph/table caps). */
|
||||
getSections(tripId: number, ctx: PluginContext): Promise<PdfSection[]>;
|
||||
}
|
||||
|
||||
@@ -668,7 +705,8 @@ export interface AtlasLayerProvider {
|
||||
/** Return tint layers for the ACTING USER's Atlas map. User-scoped — the host binds
|
||||
* the current user; the hook takes no target parameter. Runs on a short timeout;
|
||||
* the host caps the layer/country counts and skips a failing call.
|
||||
* Needs `hook:atlas-layer-provider`. */
|
||||
* Needs `hook:atlas-layer-provider`. The host caps results at 3 layers per provider
|
||||
* (see `AtlasLayer` above for the per-layer country cap). */
|
||||
getLayers(ctx: PluginContext): Promise<AtlasLayer[]>;
|
||||
}
|
||||
|
||||
@@ -687,7 +725,8 @@ export interface TripCardProvider {
|
||||
/** Return badges for the dashboard trip cards currently on screen. `tripIds` are
|
||||
* exactly those cards (each already access-checked for the acting user). Runs with
|
||||
* the current user bound, on a short timeout; the host caps the badge count and
|
||||
* skips a failing call. Needs `hook:trip-card-provider`. */
|
||||
* skips a failing call. Needs `hook:trip-card-provider`. The host caps results at
|
||||
* 4 badges per trip, 240 total per provider. */
|
||||
getCards(tripIds: number[], ctx: PluginContext): Promise<TripCardContribution[]>;
|
||||
}
|
||||
|
||||
@@ -703,11 +742,12 @@ export interface JournalEntryProvider {
|
||||
/** A core-event subscription (#1429 eco). Handlers run with NO user (like a job).
|
||||
* Needs `events:subscribe`. */
|
||||
export interface PluginEventSubscription {
|
||||
/** A core event name (e.g. `place:created`, `day:updated`, `file:created`) or `*` for all. */
|
||||
/** A core event name `<family>:<verb>` (e.g. `place:created`, `day:updated`, `file:created`)
|
||||
* where `<family>` is a value in EVENT_FAMILIES, or `*` for all. */
|
||||
on: string;
|
||||
// `entity` = the event family (e.g. 'reservation'); `entityId` = WHICH entity changed,
|
||||
// when known. `snapshot` = a whitelisted field view of the changed entity, delivered
|
||||
// only when the plugin also holds the family's db:read:* grant — never user ids,
|
||||
// only when the plugin also holds EVENT_SNAPSHOT_GRANT[family] — never user ids,
|
||||
// private packing items or secrets; delete/reorder/bulk events carry none. Still no
|
||||
// acting user: a trip read from the handler is refused.
|
||||
handler(payload: { event: string; tripId: number; entity?: string; entityId?: number; snapshot?: Record<string, unknown> }, ctx: PluginContext): Promise<void> | void;
|
||||
@@ -778,7 +818,16 @@ export function definePlugin(def: PluginDefinition): PluginDefinition {
|
||||
return def;
|
||||
}
|
||||
|
||||
export { validateManifest, CHANNEL_EVENTS, type PluginManifest, type ValidationResult } from './manifest.js';
|
||||
export {
|
||||
validateManifest,
|
||||
CHANNEL_EVENTS,
|
||||
type PluginManifest,
|
||||
type NormalizedManifest,
|
||||
type ValidationResult,
|
||||
type ManifestSettingField,
|
||||
type ManifestAction,
|
||||
type ManifestCapabilities,
|
||||
} from './manifest.js';
|
||||
export { createMockHost, type MockHostOptions } from './mock-host.js';
|
||||
// The permissions TREK enforces OUTSIDE ctx — hooks, events, jobs, egress. An entry
|
||||
// point you implement without its grant is never called in production, silently; these
|
||||
@@ -787,6 +836,10 @@ export {
|
||||
PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
|
||||
grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints,
|
||||
} from './permissions.js';
|
||||
// The core-event catalog: families and the snapshot-delivery permission each family requires.
|
||||
export {
|
||||
EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS,
|
||||
} from './generated/host-facts.js';
|
||||
|
||||
/** Scope for host-managed, per-user session state in a sandboxed plugin UI. */
|
||||
export type PluginSessionStorageScope = 'plugin' | 'trip';
|
||||
|
||||
+121
-23
@@ -10,24 +10,69 @@ export interface PluginDependency {
|
||||
id: string;
|
||||
version: string;
|
||||
}
|
||||
export interface ManifestSettingField {
|
||||
key: string;
|
||||
label?: string;
|
||||
input_type?: string;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
scope?: 'instance' | 'user';
|
||||
options?: Array<string | number | { value: string | number; label?: string }>;
|
||||
oauth?: { initPath?: string; callbackPath?: string };
|
||||
}
|
||||
export interface ManifestAction {
|
||||
key: string;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
export interface ManifestCapabilities {
|
||||
settingsUi?: boolean;
|
||||
widget?: { title?: string; defaultSize?: string; slot?: 'sidebar' | 'hero' | 'place-detail' | 'day-detail' | 'reservation-detail' };
|
||||
tripPage?: { replaces?: string[]; position?: number };
|
||||
notificationChannel?: { title?: string; events?: string[] };
|
||||
routeProfiles?: Array<{ id: string; label: string; icon?: string }>;
|
||||
provides?: string[];
|
||||
emits?: string[];
|
||||
}
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
apiVersion: number;
|
||||
/** The semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
|
||||
/** The semver RANGE of TREK versions this plugin supports (">=4.0.0 <5.0.0"). */
|
||||
trek: string;
|
||||
type: 'integration' | 'page' | 'widget' | 'trip-page';
|
||||
apiVersion?: number;
|
||||
permissions?: string[];
|
||||
egress?: string[];
|
||||
nativeModules?: boolean;
|
||||
operatorEgress?: boolean;
|
||||
requiredAddons?: string[];
|
||||
pluginDependencies?: PluginDependency[];
|
||||
capabilities?: ManifestCapabilities;
|
||||
settings?: ManifestSettingField[];
|
||||
actions?: ManifestAction[];
|
||||
icon?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
homepage?: string;
|
||||
tags?: string[];
|
||||
license?: string;
|
||||
}
|
||||
export interface NormalizedManifest extends PluginManifest {
|
||||
apiVersion: number;
|
||||
permissions: string[];
|
||||
egress: string[];
|
||||
nativeModules?: boolean;
|
||||
nativeModules: false;
|
||||
requiredAddons: string[];
|
||||
pluginDependencies: PluginDependency[];
|
||||
}
|
||||
export interface ValidationResult {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
manifest?: PluginManifest;
|
||||
manifest?: NormalizedManifest;
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z][a-z0-9-]{2,39}$/;
|
||||
@@ -123,8 +168,8 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
if (!isSatisfiableRange(m.trek)) {
|
||||
errors.push(
|
||||
typeof m.trek === 'string' && m.trek
|
||||
? `"trek" is not a satisfiable semver range: "${m.trek}" (e.g. ">=3.2.0 <4.0.0")`
|
||||
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=3.2.0 <4.0.0"',
|
||||
? `"trek" is not a satisfiable semver range: "${m.trek}" (e.g. ">=4.0.0 <5.0.0")`
|
||||
: 'missing "trek" — declare the TREK versions this plugin supports, e.g. ">=4.0.0 <5.0.0"',
|
||||
);
|
||||
}
|
||||
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
|
||||
@@ -209,6 +254,10 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
// Mirrors the server's routeProfiles parsing: the planner's route picker shows these,
|
||||
// so they must be well-formed and only exist alongside the routeProvider grant.
|
||||
const routeProfiles = capabilities?.routeProfiles;
|
||||
// Normalized routeProfiles entries (icon dropped when non-string, like the server's
|
||||
// optStr()) — populated only when routeProfiles is present and shaped as an array;
|
||||
// used to build the output below in place of the raw, possibly-dirty input array.
|
||||
let normalizedRouteProfiles: Array<{ id: string; label: string; icon?: string }> | undefined;
|
||||
if (routeProfiles !== undefined) {
|
||||
if (!permissions.includes('hook:route-provider')) {
|
||||
errors.push('capabilities.routeProfiles requires the "hook:route-provider" permission');
|
||||
@@ -217,14 +266,21 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
else {
|
||||
if (routeProfiles.length > 3) errors.push('capabilities.routeProfiles: at most 3 profiles');
|
||||
const seen = new Set<string>();
|
||||
normalizedRouteProfiles = [];
|
||||
for (const v of routeProfiles) {
|
||||
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown };
|
||||
const p = (v && typeof v === 'object' ? v : {}) as { id?: unknown; label?: unknown; icon?: unknown };
|
||||
const id = typeof p.id === 'string' ? p.id : '';
|
||||
if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) errors.push('capabilities.routeProfiles: id must be lowercase [a-z][a-z0-9-], max 24 chars');
|
||||
else if (seen.has(id)) errors.push(`capabilities.routeProfiles: duplicate id "${id}"`);
|
||||
else seen.add(id);
|
||||
const label = typeof p.label === 'string' ? p.label.trim() : '';
|
||||
if (!label || label.length > 40) errors.push('capabilities.routeProfiles: label is required (max 40 chars)');
|
||||
// icon is optional; a non-string icon is silently dropped, not rejected — mirrors
|
||||
// the server's optStr() (server/src/nest/plugins/install/manifest.ts:519-521),
|
||||
// which returns undefined for a non-string value rather than throwing. Length is
|
||||
// never rejected either — the server slices to 40 chars at install time.
|
||||
const icon = typeof p.icon === 'string' ? p.icon : undefined;
|
||||
normalizedRouteProfiles.push(icon !== undefined ? { id, label, icon } : { id, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -243,6 +299,33 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
if (s.scope !== undefined && s.scope !== 'user' && s.scope !== 'instance') {
|
||||
errors.push(`settings["${key}"].scope must be "user" or "instance"`);
|
||||
}
|
||||
if (s.options !== undefined) {
|
||||
if (!Array.isArray(s.options)) {
|
||||
errors.push('settings option list must be an array');
|
||||
} else {
|
||||
for (const o of s.options) {
|
||||
if (typeof o === 'string' || typeof o === 'number') continue;
|
||||
if (o && typeof o === 'object') {
|
||||
const value = (o as { value?: unknown }).value;
|
||||
if (value === undefined || value === null || String(value) === '') {
|
||||
errors.push('settings option must have a non-empty "value"');
|
||||
}
|
||||
} else {
|
||||
errors.push(`invalid settings option ${JSON.stringify(o)} (expected a string or { value, label })`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (s.oauth !== undefined) {
|
||||
if (!s.oauth || typeof s.oauth !== 'object' || Array.isArray(s.oauth)) {
|
||||
errors.push('settings oauth must be an object');
|
||||
} else {
|
||||
for (const k of ['initPath', 'callbackPath'] as const) {
|
||||
const v = (s.oauth as Record<string, unknown>)[k];
|
||||
if (v !== undefined && typeof v !== 'string') errors.push(`settings oauth.${k} must be a string`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settings-page action buttons ("Test connection"). Keys share the settings-key rules.
|
||||
@@ -269,23 +352,38 @@ export function validateManifest(raw: unknown): ValidationResult {
|
||||
const pluginDependencies = validatePluginDependencies(m.pluginDependencies, typeof m.id === 'string' ? m.id : '', errors);
|
||||
|
||||
if (errors.length) return { ok: false, errors };
|
||||
return {
|
||||
ok: true,
|
||||
errors: [],
|
||||
manifest: {
|
||||
id: m.id as string,
|
||||
name: m.name as string,
|
||||
version: m.version as string,
|
||||
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
|
||||
trek: m.trek as string,
|
||||
type: m.type as PluginManifest['type'],
|
||||
permissions,
|
||||
egress,
|
||||
nativeModules: false,
|
||||
requiredAddons,
|
||||
pluginDependencies,
|
||||
},
|
||||
const manifest: NormalizedManifest = {
|
||||
id: m.id as string,
|
||||
name: m.name as string,
|
||||
version: m.version as string,
|
||||
apiVersion: typeof m.apiVersion === 'number' ? m.apiVersion : 1,
|
||||
trek: m.trek as string,
|
||||
type: m.type as PluginManifest['type'],
|
||||
permissions,
|
||||
egress,
|
||||
nativeModules: false,
|
||||
requiredAddons,
|
||||
pluginDependencies,
|
||||
};
|
||||
// Carried through verbatim when present — absent stays absent (no undefined keys) — except
|
||||
// routeProfiles, which is rebuilt from normalizedRouteProfiles so a dropped (non-string)
|
||||
// icon doesn't leak the raw input value back out.
|
||||
if (m.capabilities !== undefined) {
|
||||
const rawCapabilities = m.capabilities as ManifestCapabilities;
|
||||
manifest.capabilities = normalizedRouteProfiles !== undefined
|
||||
? { ...rawCapabilities, routeProfiles: normalizedRouteProfiles }
|
||||
: rawCapabilities;
|
||||
}
|
||||
if (m.settings !== undefined) manifest.settings = m.settings as ManifestSettingField[];
|
||||
if (m.actions !== undefined) manifest.actions = m.actions as ManifestAction[];
|
||||
if (m.operatorEgress !== undefined) manifest.operatorEgress = m.operatorEgress as boolean;
|
||||
if (m.icon !== undefined) manifest.icon = m.icon as string;
|
||||
if (m.author !== undefined) manifest.author = m.author as string;
|
||||
if (m.description !== undefined) manifest.description = m.description as string;
|
||||
if (m.homepage !== undefined) manifest.homepage = m.homepage as string;
|
||||
if (m.tags !== undefined) manifest.tags = m.tags as string[];
|
||||
if (m.license !== undefined) manifest.license = m.license as string;
|
||||
return { ok: true, errors: [], manifest };
|
||||
}
|
||||
|
||||
// Export/event names exposed to other plugins (dots allowed for event names).
|
||||
|
||||
@@ -107,6 +107,18 @@ export interface MockHostOptions {
|
||||
aiResults?: Record<string, unknown>[];
|
||||
/** The acting user's connected-service token for ctx.oauth.getAccessToken (default null). */
|
||||
oauthAccessToken?: string | null;
|
||||
/** Daily cap on ctx.ai.complete/ctx.ai.extract calls, mirroring the host's
|
||||
* per-plugin daily-budget broker (daily-budget.ts). Default 200; `0` disables
|
||||
* the broker entirely, so the FIRST call fails with the exhaustion string.
|
||||
* Unlike the real host, this is a plain in-memory counter for the life of this
|
||||
* mock host: it never rolls over at UTC midnight — there is no timer here, and
|
||||
* no wall-clock window. A test that wants to see the budget "reset" should
|
||||
* create a fresh mock host rather than wait. */
|
||||
aiPerDay?: number;
|
||||
/** Same idea as `aiPerDay`, for ctx.notify.send. Default 100; `0` disables the
|
||||
* broker (first call fails with the exhaustion string). See `aiPerDay` for the
|
||||
* no-rollover caveat — this counter never resets on its own either. */
|
||||
notifyPerDay?: number;
|
||||
}
|
||||
|
||||
/** Drives a plugin's OWN entry points against the mock ctx — the missing half of a
|
||||
@@ -234,6 +246,13 @@ function settingsBlob(src: Record<string, unknown> | undefined, what: string): R
|
||||
const PLACE_STR_LIMITS: Record<string, number> = { name: 200, description: 2000, address: 500, notes: 2000 };
|
||||
const TRIP_STR_LIMITS: Record<string, number> = { title: 200, description: 2000 };
|
||||
|
||||
// ctx.meta quotas, mirrored from meta.rpc.ts's disk-DoS guard: value size (characters
|
||||
// of the serialized JSON, matching the host — UTF-16 code units, not UTF-8 bytes),
|
||||
// key length, and keys per (plugin, entity).
|
||||
const META_VALUE_MAX = 64 * 1024;
|
||||
const META_KEY_MAX = 256;
|
||||
const META_KEYS_MAX = 100;
|
||||
|
||||
export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
||||
const grants = new Set(opts.grants ?? []);
|
||||
const calls: MockHost['calls'] = [];
|
||||
@@ -372,6 +391,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
||||
const metaStore: Record<string, unknown> = {};
|
||||
const metaKey = (et: string, eid: number, key: string) => `${et}:${eid}:${key}`;
|
||||
|
||||
// Per-host daily budgets for ai.*/notify.send, mirroring daily-budget.ts. Plain
|
||||
// counters, no timers: this mock never rolls the window over at UTC midnight (see
|
||||
// the MockHostOptions docblocks) — a test wanting a fresh budget creates a new host.
|
||||
const aiPerDay = opts.aiPerDay ?? 200;
|
||||
const notifyPerDay = opts.notifyPerDay ?? 100;
|
||||
let aiUsed = 0;
|
||||
let notifyUsed = 0;
|
||||
|
||||
const buildCtx = (actingUserId: number | undefined): PluginContext => {
|
||||
const requireActingUser = (): number => {
|
||||
if (actingUserId === undefined) throw new Error('RESOURCE_FORBIDDEN: this call requires an authenticated user context');
|
||||
@@ -816,6 +843,8 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
||||
if (!input.link.startsWith('/') || input.link.startsWith('//')) throw new Error('link must be an in-app path starting with /');
|
||||
link = input.link.slice(0, 512);
|
||||
}
|
||||
if (notifyUsed >= notifyPerDay) throw new Error('daily notification budget exhausted (resets at UTC midnight)');
|
||||
notifyUsed += 1;
|
||||
notifications.push({ title, body, ...(link ? { link } : {}), scope, targetId: input.targetId });
|
||||
return { sent: true };
|
||||
},
|
||||
@@ -823,10 +852,14 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
||||
ai: {
|
||||
async complete() {
|
||||
need('ai:invoke', 'ai.complete');
|
||||
if (aiUsed >= aiPerDay) throw new Error('daily AI budget exhausted (resets at UTC midnight)');
|
||||
aiUsed += 1;
|
||||
return { text: opts.aiText ?? '' };
|
||||
},
|
||||
async extract() {
|
||||
need('ai:invoke', 'ai.extract');
|
||||
if (aiUsed >= aiPerDay) throw new Error('daily AI budget exhausted (resets at UTC midnight)');
|
||||
aiUsed += 1;
|
||||
return { results: opts.aiResults ?? [] };
|
||||
},
|
||||
},
|
||||
@@ -1351,7 +1384,21 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
|
||||
async set(entityType, entityId, key, value) {
|
||||
need('db:meta', 'meta.set');
|
||||
metaGate(entityType, entityId);
|
||||
metaStore[metaKey(entityType, entityId, key)] = value ?? null;
|
||||
// Quota order mirrors meta.rpc.ts: key length -> value size -> key count.
|
||||
if (key.length > META_KEY_MAX) throw new Error(`metadata key too long (>${META_KEY_MAX} chars)`);
|
||||
const json = JSON.stringify(value ?? null);
|
||||
// Matches the host exactly: json.length (UTF-16 code units of the serialized
|
||||
// JSON), not Buffer.byteLength (UTF-8 bytes) — see meta.rpc.ts:71.
|
||||
if (json.length > META_VALUE_MAX) {
|
||||
throw new Error(`metadata value too large (>${META_VALUE_MAX} bytes)`);
|
||||
}
|
||||
const fullKey = metaKey(entityType, entityId, key);
|
||||
if (!(fullKey in metaStore)) {
|
||||
const prefix = `${entityType}:${entityId}:`;
|
||||
const count = Object.keys(metaStore).filter((k) => k.startsWith(prefix)).length;
|
||||
if (count >= META_KEYS_MAX) throw new Error(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`);
|
||||
}
|
||||
metaStore[fullKey] = value ?? null;
|
||||
return { key, value: value ?? null };
|
||||
},
|
||||
async list(entityType, entityId) {
|
||||
|
||||
Reference in New Issue
Block a user