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:
jubnl
2026-08-24 23:48:36 +02:00
committed by GitHub
parent 4bac447ac8
commit d110b3bb6d
48 changed files with 1895 additions and 169 deletions
+1 -1
View File
@@ -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);
},
+34 -5
View File
@@ -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')));
+2 -2
View File
@@ -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);
+21 -2
View File
@@ -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 {
+1
View File
@@ -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: '' },
];
+90 -28
View File
@@ -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 };
}
+81
View File
@@ -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);
}
+27 -2
View File
@@ -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');
+48 -3
View File
@@ -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);
+1 -1
View File
@@ -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) ────
+110
View File
@@ -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 };
}
+34 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+48 -1
View File
@@ -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) {