* fix(plugins): unknown column

* fix(mcp): reuse MCP sessions instead of creating one per tool call

The /mcp CORS layer never set exposedHeaders, so Access-Control-Expose-Headers
was absent and browser-context MCP clients (Claude Desktop connectors,
Claude.ai, MCP Inspector) could not read Mcp-Session-Id off the initialize
response. Unable to echo it back, every request looked like a fresh initialize:
one McpServer and one session per tool call, until the per-user cap returned a
429 and the integration died until the container was restarted.

The idle sweep was not at fault — it expires on lastActivity with a 1h default,
so sessions born seconds apart are nowhere near expiry, hence 'cleaned 0'.

- expose Mcp-Session-Id, MCP-Protocol-Version and WWW-Authenticate
- evict a user's least-recently-active session at the cap rather than
  refusing the request, so a client that cannot persist its session id (or a
  proxy that strips the header) can never wedge the server
- close the McpServer/transport orphaned by every session-less non-initialize
  POST, which was leaked: never mapped, never swept, never closed
- return the cap error as JSON-RPC so clients surface the real reason
- warn on session-less POSTs to make a header-stripping proxy diagnosable

* chore(deps): declare @modelcontextprotocol/sdk ^1.29.0

Matches the version already resolved in the lockfile; no dependency-tree change.

* docs(mcp): add the reverse proxy specs for MCP

* fix(plugins): stop the row ⋯ menu being clipped by the sidebar

The menu was an in-flow `absolute` div, and its ancestor (PageSidebar) is
`overflow-hidden` — which clips absolutely-positioned descendants regardless
of z-index. With enough plugins installed a row sits low enough that its menu
runs past the sidebar's bottom edge and gets chopped, taking Delete with it,
so the plugin could no longer be uninstalled from the UI.

Portal the menu to <body> and position it `fixed` against the ⋯ button,
flipping upward when the bottom is tight and re-anchoring on scroll/resize.
A fixed child of <body> has no overflow ancestor, so nothing can clip it.

Closes #1523

* feat(plugins): surface author-signature status and add a scoped re-trust override

TREK has always verified an author's Ed25519 signature and TOFU-pinned the key on
first install, but none of it was ever shown: a successfully-installed UNSIGNED
plugin looked identical to a signed one, and a signature-refused update left the
plugin quietly pinned at its old version with the reason dying in a toast.

Give the four refusal conditions machine-readable codes (SIGNATURE_MISSING /
_INCOMPLETE / _KEY_CHANGED / _INVALID), persist a refusal on the plugin row so the
admin list keeps showing it, and badge Signed/Unsigned in the list and in Discover.

Only SIGNATURE_KEY_CHANGED is overridable — an author can legitimately rotate a key;
a signature that does not verify means the bytes are not what the author signed, and
there is no story where waving that through is right. The override re-pins and
updates in ONE call (POST :id/retrust): a re-pin that waited for a follow-up /update
would leave the plugin pinned to a key no install had ever been verified against if
that second call never came. The artifact must still verify under the new key, so a
re-trust moves the pin from one verified key to another.

assertRetrustable re-derives the condition server-side, so the UI hiding the button
is a convenience, not the control, and it echoes back the full key the admin was
shown so a re-key since the dialog rendered is refused. The rotation is written to
the admin audit log with both fingerprints — after an incident, "which key did we
move from, and to what?" is the question a single key cannot answer.

* fix(plugins): let a plugin's frame reach the hosts an admin added for it

The frame's connect-src was built from the manifest's http:outbound grants alone, but
the child's egress guard is the UNION of those and the hosts an admin added after
install for an operatorEgress plugin (a self-hosted Gotify, an ntfy — hosts the author
cannot know in advance). So such a plugin WITH A UI could call the operator's host from
its server and was CSP-blocked in its own iframe.

Match the frame to the child. The admin consented to these hosts at install and the
child already reaches them, so this widens no trust boundary that isn't already crossed.
Both sources stay validated on the way in, and the interpolation filter is unchanged.

* fix(plugins): report a rejected events.emit on the plugin's own log stream

The host can reject an emit (an undeclared event name, a rate limit). The rejection must
not escape — a detached rejection crashes the child and terminally disables the plugin
over one bad emit — but swallowing it silently left an author with no way to discover
that `emits` was missing from their manifest. Surface it as a warning instead.

* feat(plugin-sdk): expose the raw request body for webhook signature checks

A webhook author must run their HMAC over the exact bytes the sender signed. `body` is
the PARSED value, and re-serializing it will not reproduce those bytes — key order,
whitespace and unicode escaping all differ — so the signature never matches. Document
`rawBodyBase64`, which the host already sets on auth:false routes.

* fix(plugin-sdk): refuse to overwrite a released artifact, and keep the packed zip

A released artifact is IMMUTABLE: the registry pins its sha256, so overwriting the bytes
of a release already in the registry breaks the checksum for everyone who installed that
version — they can no longer install or update it. The old code blanket-caught every
`gh release create` failure (auth, network, a bad repo) and turned it into a --clobber
upload. Probe for the release explicitly and refuse unless --force.

Also stop deleting plugin.zip on the way out. 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 THAT
file, not a rebuild.

* fix(plugin-sdk): don't fail submit when the fork already has an upstream remote

`gh repo clone` of a fork may already have wired `upstream`, in which case a bare
`remote add` exits non-zero and took the whole submit down. Set it either way.

* fix(plugin-sdk): make the dev server behave like the host

Three ways `dev` lied to an author about how their plugin would run in production:

- ctx.db: one try/catch wrapped both the node:sqlite probe AND opening the database, so
  an mkdir/permission failure silently degraded to the in-memory stub — which swallows
  every write while reporting success. A db:own plugin "worked" in dev and persisted
  nothing. Probe separately: fail loudly on a real error, degrade only on old Node, and
  say plainly that the stub discards writes.
- notificationChannel: the host fires it with no acting user and hands the recipient's
  decrypted settings in as a separate `config` argument — send(msg, config, ctx) /
  test(config, ctx). Firing it like an ordinary hook passed `ctx` where `config` belongs,
  so a channel plugin read its settings off ctx and was broken in production.
- /preview pinned tripId 42 while the scaffold seeds trip 1, so the widget's first
  trek:invoke hit assertMember(42) and 500'd. Preview against a trip that exists.

* fix(plugin-sdk): reject unknown flags, wire up create's new flags, and add --help

`parse()` accepts any --x, so a flag a command does not read was silently dropped:
`create --template notification-channel` cheerfully scaffolded a blank plugin. Silently
ignoring an author's explicit instruction is worse than refusing it, so unknown flags are
now an error, and create actually forwards --template/--egress/--required-addons.

A bare --permissions used to split the string "true" into a permission literally named
`true`; listFlag() now treats a valueless flag as absent.

Since an unknown flag is now fatal, `--help` has to exist: it is intercepted before the
flag check and prints usage on stdout with exit 0.

* chore(plugins): drop three unused eslint-disable directives

The no-console rule is not enabled for these files, so the directives were dead and
eslint reports them as unused.

* fix(plugin-sdk): bring preflight back in step with the registry's gates

preflight exists to tell an author what TREK-Plugins' CI will say before they open the
PR. The registry now verifies author signatures, and preflight didn't — so it drifted
into the one failure mode it must never have: a false green. An author trusts a green.

- Verify the signature against the artifact bytes. preflight already downloads them for
  the sha256 check, so this costs one call. Without it, signing with the wrong key (or
  re-packing after signing) sails through and is caught at review.
- Check the signature SHAPE (checkSignatureShape): a key with no signed version, a
  signature with no key, a malformed key or signature. TREK refuses to install a
  half-signed entry, so such an entry is dead on arrival.
- Default apiVersion to 1 before comparing. It is OPTIONAL in the manifest — install/
  manifest.ts and `entry` both default it — so a manifest that legally omits it was
  failing preflight with "manifest apiVersion undefined != entry 1" while the registry
  passed it. A false RED, which teaches authors to ignore the tool.
- Check requiredAddons/pluginDependencies parity, and operatorEgress without an
  http:outbound grant.

The verifier is a port of the host's install/verify-signature.ts (the registry has its
own port); sign.ts's verifyArtifact only understands the bare key/signature pair the SDK
itself emits and cannot judge a minisign key. A test pins all three to the same verdicts.

* feat(plugins): enforce compatibility range

* chore: bump sdk version

* test(e2e): repair the trip-creation specs

create-trip and trip-planner have been failing for a while — long enough for
three separate UI changes to drift past them, which nothing caught because CI
runs vitest only and never invokes Playwright.

Each failure was masking the next:

- The release-notice modal greets a freshly seeded user and its backdrop
  swallowed the click on .add-trip-card. Added a shared dismissSystemNotices()
  helper (the X only shows on the notice's last page, so it has to page through
  first).
- .modal-backdrop no longer exists — the class was namespaced to
  .trek-modal-backdrop so content blockers stop hiding it.
- input[type=text].first() is no longer the Title field: the cover-image search
  inputs now sit above it, so the specs were typing the trip name into the photo
  search box and creating an untitled trip that never matched getByText(title).

* fix(planner): gate drag & drop on pointer type, not viewport width (#1432)

The 3.2.1 fix disabled drag on "mobile", but nothing in the client has ever
detected touch — "mobile" was inferred from viewport width, at four independent
breakpoints. A tablet is a coarse-pointer device at a *desktop* width, so an
iPad (820-1366px) fell on the wrong side of all four, which is why iPhone was
fixed and iPad was not:

- useTripPlanner's isMobile (<768px) is what disarmed `draggable`, so on iPad
  rows stayed draggable and a scroll swipe became an HTML5 drag.
- TripPlannerPage hardcoded isMobile={false} on the desktop PlacesSidebar, so
  its drop handlers and the drop-to-import overlay could never be disabled —
  that overlay is the reported symptom.
- The arrow-button reorder fallback was revealed only below 767px, leaving iPad
  with no drag *and* no fallback.
- touchDragPolyfill loaded drag-drop-touch at >=1024px, synthesising drags from
  touchmove — on a landscape iPad that re-armed the very gesture hijack 3.2.1
  removed.

Adds useIsTouch() ((pointer: coarse)) as a signal separate from isMobile: layout
stays width-driven, so the iPad keeps the desktop two-pane planner, while every
drag affordance is gated on isMobile || isTouch. The reorder arrows now show on
coarse pointers, and the polyfill loads only on hybrid laptops
((pointer: fine) and (any-pointer: coarse)), which also removes the #1440
phantom-dblclick map zoom on tablets.

Guarded by unit tests plus an e2e spec on real WebKit in an iPad Pro 11 context
— the engine matters, since every browser on iPadOS is WebKit underneath, which
is why the reporter hit this in all three they tried.

* test(planner): guard the day-plan reorder arrows against phantom clicks

The hover rule that reveals the reorder arrows had been dead since the
TypeScript migration: it targeted `.place-row:hover .reorder-btns`, and neither
class exists — the component renders `.reorder-buttons` inside an unclassed row.
The buttons sat at opacity:0 on desktop with nothing to reveal them.

That was not merely invisible. opacity:0 still hit-tests, so every itinerary row
and day note carried an invisible, fully clickable target that silently
reordered the trip:

  opacity: "0", visibility: "visible", pointerEvents: "auto"
  elementFromPoint(centre) -> BUTTON, inside .reorder-buttons

The repair — pointer-events: none while hidden, plus a working
.dp-row:hover/:focus-within reveal (focus-within so the buttons are also
reachable by keyboard, which the file's JS-hover pattern cannot do) — lives in
index.css and DayPlanSidebar.tsx. Both files also carry the #1432 drag gating,
so they went with that commit rather than being split mid-file; this commit is
the regression guard for them.

E2E rather than unit, because jsdom does not evaluate :hover. Against the old
CSS it fails on exactly the right assertion: "hidden arrows must not swallow
clicks" — expected false, received true.

* fix(pdf): keep the header gap on day-header overflow pages (#1531)

The repeated <thead> day header (#1471) carried no gap before the first
card on overflow pages: the 12px sat in .day-body's block-start padding,
which a fragmented box only paints on its first fragment. Move it to the
thead cell so it repeats with the header; the day's first page renders
pixel-identically.

* fix(budget): keep "no one paid yet" when editing an expense (#1533)

The ExpenseModal payer initializer fell back to the current user whenever the
edited item had no payer, so reopening an expense saved with "no one paid yet"
silently reselected "You" — and re-saving then recorded the current user as the
payer, corrupting the balances. An absent payer on an existing expense is a
deliberate value, so only a brand-new expense defaults to me.
This commit is contained in:
jubnl
2026-07-13 12:40:00 +02:00
committed by GitHub
parent e60fbe7dd6
commit ebc98f0a3f
114 changed files with 5148 additions and 425 deletions
+51 -16
View File
@@ -92,9 +92,31 @@ function flatBind(args: unknown[]): unknown[] {
/** A dev db backed by node:sqlite if available, else an in-memory recorder that returns []. */
export function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close: () => void } {
// node:sqlite ships on Node 22.5+. Probe for it SEPARATELY from opening the database:
// one try/catch around both meant an mkdir/permission failure silently degraded to the
// stub too, and the stub swallows every write while reporting success — a db:own plugin
// "works" in dev and persists nothing. Fail loudly on a real error; degrade only on old Node.
let DatabaseSync: (new (p: string) => SqliteDb) | undefined;
try {
// node:sqlite is available on Node 22.5+ (experimental). Fall back gracefully if not.
const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb };
({ DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb });
} catch {
console.warn(
`\n ⚠ node:sqlite is unavailable on this Node (${process.version}) — ctx.db is a STUB.\n` +
` Every query returns [] and every write reports 0 changes: a db:own plugin will look\n` +
` like it works and persist NOTHING. Upgrade to Node 22.5+ to test db:own for real.\n`,
);
return {
note: `db:own → in-memory STUB (writes are discarded — upgrade to Node 22.5+; you are on ${process.version})`,
close: () => {},
db: {
async query(sql: string) { guardSql(sql); return []; },
async exec(sql: string) { guardSql(sql); return { changes: 0 }; },
async migrate(_id: string, sql: string) { guardSql(sql); return { applied: true }; },
},
};
}
{
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
const sq = new DatabaseSync(dbFile);
const applied = new Set<string>();
@@ -116,16 +138,6 @@ export function createDevDb(dbFile: string): { db: PluginContextDb; note: string
async migrate(id: string, sql: string) { guardSql(sql); if (applied.has(id)) return { applied: false }; sq.exec(sql); applied.add(id); return { applied: true }; },
},
};
} catch {
return {
note: 'db:own → in-memory stub (upgrade to Node 22.5+ for a real SQLite dev db)',
close: () => {},
db: {
async query(sql: string) { guardSql(sql); return []; },
async exec(sql: string) { guardSql(sql); return { changes: 0 }; },
async migrate(_id: string, sql: string) { guardSql(sql); return { applied: true }; },
},
};
}
}
@@ -341,7 +353,26 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
else if (kind === 'event') { for (const s of plugin.events ?? []) if (s.on === name || s.on === '*') await s.handler({ event: name, tripId: 0, ...(payload as object) }, userlessCtx); out = { delivered: true }; }
else if (kind === 'deleteUserData') { if (!plugin.deleteUserData) return send(404, 'no deleteUserData handler'); out = await plugin.deleteUserData({ userId: Number((payload as { userId?: unknown })?.userId ?? name ?? 0) }, userlessCtx); }
else if (kind === 'exportUserData') { if (!plugin.exportUserData) return send(404, 'no exportUserData handler'); out = await plugin.exportUserData({ userId: Number((payload as { userId?: unknown })?.userId ?? name ?? 0) }, userlessCtx); }
else if (kind === 'hook') { const impl = plugin.hooks?.[name]; if (!impl || typeof impl[fn] !== 'function') return send(404, `no hook ${name}.${fn}`); out = await impl[fn](...(Array.isArray(payload) ? payload : payload != null ? [payload] : []), ctx); }
else if (kind === 'hook') {
const impl = plugin.hooks?.[name];
if (!impl || typeof impl[fn] !== 'function') return send(404, `no hook ${name}.${fn}`);
if (name === 'notificationChannel') {
// A notification channel is NOT like the other hooks: the host fires it with no
// acting user (it delivers to an arbitrary recipient), and hands the recipient's
// decrypted settings in as a separate `config` argument —
// send(msg, config, ctx) · test(config, ctx)
// Firing it like a normal hook passed `ctx` where `config` belongs, so a channel
// plugin read its settings off the ctx object and "worked" in dev while being
// broken in production. Mirror the host (and createMockHost) exactly.
const config = (fx.userSettings ?? {}) as Record<string, unknown>;
const args = Array.isArray(payload) ? (payload as unknown[]) : payload != null ? [payload] : [];
out = fn === 'test'
? await impl[fn](config, userlessCtx)
: await impl[fn](args[0], (args[1] as Record<string, unknown>) ?? config, userlessCtx);
} else {
out = await impl[fn](...(Array.isArray(payload) ? payload : payload != null ? [payload] : []), ctx);
}
}
else return send(400, `unknown fire kind "${kind}"`);
return send(200, JSON.stringify(out ?? { ok: true }, null, 2), 'application/json; charset=utf-8');
} catch (e) {
@@ -357,7 +388,11 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
// proxies trek:invoke to your /api routes, and honours resize/notify/navigate. This
// is where the design kit actually renders themed.
if (url.pathname === '/preview' && String(manifest.type) !== 'integration') {
return send(200, preview(id, String(manifest.type)), 'text/html; charset=utf-8');
// Preview against a trip that actually EXISTS in the fixtures. This used to be
// hard-coded to 42 while the scaffold seeds trip 1, so the widget's first
// trek:invoke hit assertMember(42, …) and 500'd with RESOURCE_FORBIDDEN.
const previewTripId = Number(Object.keys(fx.trips ?? {})[0] ?? 1) || 1;
return send(200, preview(id, String(manifest.type), previewTripId), 'text/html; charset=utf-8');
}
// Static plugin UI at /ui (page/widget client bundle). The iframe loads
@@ -485,7 +520,7 @@ ${LIVE_RELOAD}`;
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
*/
function preview(id: string, type: string): string {
function preview(id: string, type: string, tripId: number): string {
const maxW = type === 'widget' ? '440px' : '1000px';
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${id} · preview</title>
@@ -535,7 +570,7 @@ function ctx(){
document.body.classList.toggle("dark", theme==="dark");
var tokens={}; var src=theme==="dark"?DA[accent]:LA[accent]; for(var k in src){tokens[k]=src[k];}
return {type:"trek:context",theme:theme,locale:"en",hostOrigin:location.origin,
tripId: val("trip").checked?42:null, userId:"1",
tripId: val("trip").checked?${tripId}:null, userId:"1",
user:{name:"Dev User",avatar:null,isAdmin:true},
appearance:{scheme:accent,density:"comfortable",reducedMotion:val("rm").checked,noTransparency:val("nt").checked},
formats:{locale:"en",currency:"EUR",timeFormat:"24h",distanceUnit:"metric",temperatureUnit:"celsius",timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},
+33 -9
View File
@@ -4,8 +4,8 @@
* [--merge registry/plugins/<id>.json] [--out file]
*
* Generates the ready-to-PR TREK-Plugins registry entry from the manifest + the
* packed plugin.zip + the git tag — so the sha256, size, commitSha, downloadUrl
* and minTrekVersion an author would compute by hand all come out of one command.
* packed plugin.zip + the git tag — so the sha256, size, commitSha and downloadUrl
* an author would compute by hand all come out of one command.
* With --merge it prepends the new version onto an existing entry (the update
* case), keeping versions newest-first.
*/
@@ -13,12 +13,25 @@ import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { minVersion, validRange } from 'semver';
import { loadPrivateKey, signArtifact, publicKeyBase64 } from './sign.js';
import { readJsonFile } from './json.js';
interface Version {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; minTrekVersion: string; size: number; apiVersion: number;
sha256: string;
/**
* The manifest's `trek` range, verbatim — the ONE compatibility field an entry carries.
* It is what TREK gates installs and activation on, and the only field that can express
* an exclusive upper bound.
*
* There is deliberately no `minTrekVersion` here any more. It said the same thing (the
* range's lower bound) in a weaker form, and a second field restating a derived fact is a
* second field that can be wrong. The registry still ACCEPTS one on entries published
* before `trek` existed — and checks it agrees with the range — but nothing emits one now.
*/
trek: string;
size: number; apiVersion: number;
nativeModules: false; operatorEgress?: boolean; publishedAt: string; signature?: string;
}
interface Entry {
@@ -26,10 +39,20 @@ interface Entry {
homepage?: string; tags?: string[]; type: string; authorPublicKey?: string; versions: Version[];
}
/** 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 ">=3.2.0 <4.0.0" -> "3.2.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
* would have been published as the minimum — the precise inverse of what the plugin says.
*/
function minTrekFrom(trek: unknown): string | null {
if (typeof trek !== 'string') return null;
return trek.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null;
if (typeof trek !== 'string' || validRange(trek) === null) return null;
try {
return minVersion(trek)?.version ?? null;
} catch {
return null;
}
}
function resolveCommit(dir: string, tag: string, override?: string): string {
@@ -50,8 +73,9 @@ export function buildEntry(opts: {
}): Entry {
const manifest = readJsonFile<Record<string, unknown>>(path.join(opts.dir, 'trek-plugin.json'));
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`);
const minTrek = minTrekFrom(manifest.trek);
if (!minTrek) throw new Error('manifest has no "trek" version range to derive minTrekVersion from (e.g. "trek": ">=3.2.0 <4.0.0")');
// 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 (!fs.existsSync(opts.zipPath)) throw new Error(`artifact not found: ${opts.zipPath} — run \`trek-plugin pack\` first`);
const buf = fs.readFileSync(opts.zipPath);
@@ -62,7 +86,7 @@ export function buildEntry(opts: {
commitSha: resolveCommit(opts.dir, opts.tag, opts.commit),
downloadUrl: `https://github.com/${opts.repo}/releases/download/${opts.tag}/${asset}`,
sha256: createHash('sha256').update(buf).digest('hex'),
minTrekVersion: minTrek,
trek: String(manifest.trek),
size: buf.length,
apiVersion: typeof manifest.apiVersion === 'number' ? manifest.apiVersion : 1,
nativeModules: false,
+53 -5
View File
@@ -1,24 +1,31 @@
/**
* trek-plugin preflight — run the registry CI checks locally, over the network,
* BEFORE you open a PR. It mirrors TREK-Plugins' validate-entry.mjs (tag→commit,
* manifest parity, artifact sha256/size, native-binary scan) and check-readme.mjs
* (required sections, real prose, a resolving screenshot, permission parity), so
* you catch what CI would reject without a round-trip through review.
* manifest parity incl. operatorEgress/requiredAddons/pluginDependencies, artifact
* sha256/size, author-signature shape + verification, native-binary scan) and
* check-readme.mjs (required sections, real prose, a resolving screenshot, permission
* parity), so you catch what CI would reject without a round-trip through review.
*
* That mirroring is the whole contract: a gate the registry has and preflight doesn't is
* a false green, and an author trusts a green. Keep the two in step.
*
* Dependency-free: global fetch + Node built-ins + our own zip reader.
*/
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { listZipNames } from '../zip.js';
import { verifyAuthorSignature, checkSignatureShape, SignatureError } from './verify-signature.js';
const REQUIRED_HEADINGS = ['what it does', 'screenshots', 'permissions', 'setup'];
const PLACEHOLDER_RE = [/\{\{[^}]*\}\}/, /\bREPLACE_ME\b/i, /\bDescribe (what|the)\b/i, /\byour-name\/trek-plugin/i];
const MIN_PROSE = 400;
const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
export interface PluginDependency { id: string; version: string }
export interface EntryVersion {
version: string; gitTag: string; commitSha: string; downloadUrl: string;
sha256: string; size: number; apiVersion: number; nativeModules?: boolean; operatorEgress?: boolean; signature?: string;
requiredAddons?: string[]; pluginDependencies?: PluginDependency[];
}
export interface Entry {
id: string; name: string; type: string; repo: string; authorPublicKey?: string; versions: EntryVersion[];
@@ -48,7 +55,10 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
if (!/^[a-z][a-z0-9-]{2,39}$/.test(entry.id)) fail(`id "${entry.id}" is not a valid slug (^[a-z][a-z0-9-]{2,39}$)`);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(entry.repo)) fail(`repo "${entry.repo}" is not "owner/name"`);
if (!['integration', 'page', 'widget', 'trip-page'].includes(entry.type)) fail(`type "${entry.type}" is not integration|page|widget|trip-page`);
if (entry.authorPublicKey && !entry.versions[0]?.signature) fail('entry has authorPublicKey but the newest version has no signature (sign the version too)');
// Signature SHAPE (mirrors the registry's checkSignatureShape): a key and a signature
// must be both-present or both-absent, and each must parse. TREK refuses to install a
// half-signed entry, so one is dead on arrival — better to say so now than at review.
for (const p of checkSignatureShape(entry)) fail(p);
const versions = opts.all ? entry.versions : entry.versions.slice(0, 1);
for (const v of versions) {
@@ -79,7 +89,12 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
if (m.id !== entry.id) fail(`${tag}: manifest id "${m.id}" != entry id "${entry.id}"`);
if (m.version !== v.version) fail(`${tag}: manifest version "${m.version}" != entry "${v.version}"`);
if (m.type !== entry.type) fail(`${tag}: manifest type "${m.type}" != entry "${entry.type}"`);
if (m.apiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${m.apiVersion} != entry ${v.apiVersion}`);
// apiVersion is OPTIONAL in the manifest: the host defaults it to 1 at install
// (install/manifest.ts) and `entry` defaults it to 1 when building the entry. Compare
// the DEFAULTED values, or a manifest that legally omits it fails here with
// "manifest apiVersion undefined != entry 1" while the registry's CI passes it.
const mApiVersion = typeof m.apiVersion === 'number' ? m.apiVersion : 1;
if (mApiVersion !== v.apiVersion) fail(`${tag}: manifest apiVersion ${mApiVersion} != entry ${v.apiVersion}`);
if (m.nativeModules === true) fail(`${tag}: manifest declares nativeModules:true (forbidden)`);
// Mirrors the registry's operatorEgress parity check (validate-entry.mjs): the entry
// must not understate the plugin's network reach.
@@ -96,6 +111,22 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
}
if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`);
}
if (mOperatorEgress && !manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
fail(`${tag}: operatorEgress declared without an http:outbound permission`);
}
// Dependency parity (mirrors validate-entry.mjs): the enriched entry must mirror the
// manifest, because TREK resolves requiredAddons/pluginDependencies from the INDEX
// before it ever downloads the artifact. An entry that understates them resolves
// against a dependency set the code doesn't actually have.
const normAddons = (a: unknown) => (Array.isArray(a) ? [...a].map(String).sort() : []);
if (JSON.stringify(normAddons(m.requiredAddons)) !== JSON.stringify(normAddons(v.requiredAddons))) {
fail(`${tag}: manifest requiredAddons != entry requiredAddons`);
}
const normDeps = (d: unknown) =>
(Array.isArray(d) ? d.map((x) => `${(x as PluginDependency)?.id}@${(x as PluginDependency)?.version}`).sort() : []);
if (JSON.stringify(normDeps(m.pluginDependencies)) !== JSON.stringify(normDeps(v.pluginDependencies))) {
fail(`${tag}: manifest pluginDependencies != entry pluginDependencies`);
}
if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`);
}
} catch (e) { fail(`${tag}: manifest parity failed: ${(e as Error).message}`); }
@@ -110,6 +141,23 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
const sha = createHash('sha256').update(bytes).digest('hex');
if (sha !== v.sha256) fail(`${tag}: sha256 mismatch — release has ${sha.slice(0, 12)}…, entry pins ${v.sha256.slice(0, 12)}… (re-pack + re-upload?)`);
else ok(`${tag}: artifact sha256 matches the release`);
// Author signature over those same bytes. sha256 proves they are what the REGISTRY
// vouches for; the signature proves they came from the AUTHOR's key. The registry's
// CI verifies this and TREK verifies it again at install, so a signature that does
// not check out is an entry nobody can install — and the author would only find out
// at review. We already have the bytes, so it costs nothing to say so now.
if (v.signature && entry.authorPublicKey) {
try {
if (!verifyAuthorSignature(bytes, v.signature, entry.authorPublicKey)) {
fail(`${tag}: author signature does not verify against authorPublicKey — did you sign with a different key, or re-pack after signing?`);
} else ok(`${tag}: author signature verifies`);
} catch (e) {
if (e instanceof SignatureError) fail(`${tag}: signature/key is malformed: ${e.message}`);
else throw e;
}
}
let names: string[] = [];
try { names = bytes[0] === 0x50 && bytes[1] === 0x4b ? listZipNames(bytes) : []; } catch { /* not a zip */ }
if (names.some((n) => NATIVE_RE.test(n))) fail(`${tag}: artifact contains native binaries (.node / binding.gyp / prebuilds)`);
+25 -6
View File
@@ -7,7 +7,6 @@
*
* Requires `git` + `gh` (authenticated), same as `release`/`submit`.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { packPluginDir } from './pack.js';
@@ -22,10 +21,15 @@ function git(dir: string, args: string[], quiet = true): string {
function tagExists(dir: string, tag: string): boolean {
try { git(dir, ['rev-parse', `${tag}^{commit}`]); return true; } 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; }
}
export async function publishPlugin(opts: {
dir: string; repo: string; tag: string; now: string;
signKeyPath?: string; registry?: string; draft?: boolean; notes?: string; skipPreflight?: boolean;
/** Overwrite the artifact on an existing release. Only safe if it was never merged. */
force?: boolean;
/** Progress sink. Defaults to the plain console.error lines (CI parity). */
log?: LogSink;
}): Promise<{ prUrl: string }> {
@@ -45,11 +49,23 @@ export async function publishPlugin(opts: {
try { git(dir, ['push', '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})`);
}
try {
execFileSync('gh', ['release', 'create', opts.tag, packed.artifact, '--repo', opts.repo, '--title', opts.tag, '--notes', opts.notes || `Release ${opts.tag}`], { stdio: 'pipe' });
} catch {
// Release already exists — (re)upload the packed artifact so its bytes match the pin.
// 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.
// (The old code blanket-caught every `gh release create` failure — auth, network, a bad
// repo — and turned it into a --clobber upload.)
if (releaseExists(opts.repo, opts.tag)) {
if (!opts.force) {
throw new Error(
`release ${opts.tag} already exists on ${opts.repo}.\n` +
`Overwriting a released artifact breaks the sha256 pin for everyone who already installed it.\n` +
`Cut a new version, or pass --force if this release was never merged into the registry.`,
);
}
log(` ! release ${opts.tag} exists — overwriting the artifact (--force)`);
execFileSync('gh', ['release', 'upload', opts.tag, packed.artifact, '--repo', opts.repo, '--clobber'], { stdio: 'pipe' });
} else {
execFileSync('gh', ['release', 'create', opts.tag, packed.artifact, '--repo', opts.repo, '--title', opts.tag, '--notes', opts.notes || `Release ${opts.tag}`], { stdio: 'pipe' });
}
log(` ✓ release ${opts.tag} on ${opts.repo}`);
@@ -69,6 +85,9 @@ export async function publishPlugin(opts: {
step(4, 'Opening the registry PR…');
const { prUrl } = submitEntry(entry, { registry: opts.registry, draft: opts.draft });
log(' ✓ done');
fs.rmSync(zip, { force: true });
// 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 };
}
+4 -1
View File
@@ -61,7 +61,10 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch?
}
if (!cloned) throw new Error(`could not clone your fork ${login}/${name} (is the fork ready on GitHub?)`);
git(tmp, 'remote', 'add', 'upstream', `https://github.com/${registry}.git`);
// `gh repo clone` of a fork may already have wired an `upstream` remote, in which case a
// bare `remote add` exits non-zero and takes the whole submit down. Set it either way.
try { git(tmp, 'remote', 'add', 'upstream', `https://github.com/${registry}.git`); }
catch { git(tmp, 'remote', 'set-url', 'upstream', `https://github.com/${registry}.git`); }
git(tmp, 'fetch', '--depth=1', 'upstream', 'main');
git(tmp, 'checkout', '-B', branch, 'upstream/main');
+81 -3
View File
@@ -3,6 +3,8 @@
* `trek-plugin <command>` — the plugin author CLI (#plugins).
*
* create [name] [--type t] [--interactive] scaffold a plugin (wizard if no name)
* [--template blank|notification-channel] type: integration|page|widget|trip-page
* [--egress host,host] [--required-addons a,b]
* dev [dir] [--port 4317] run locally with hot reload
* validate [dir] check the manifest + layout
* pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size
@@ -64,6 +66,43 @@ const { flags, pos } = parse(args);
type Flags = Record<string, string>;
/**
* Every flag each command reads. `parse()` accepts any `--x`, so a flag a command
* does NOT read used to be silently dropped — `create --template notification-channel`
* cheerfully scaffolded a blank plugin. Silently ignoring an author's explicit
* instruction is worse than refusing it, so unknown flags are now an error.
*/
const COMMAND_FLAGS: Record<string, readonly string[]> = {
create: ['type', 'interactive', 'author', 'description', 'permissions', 'template', 'egress', 'required-addons'],
dev: ['port'],
validate: [],
pack: ['out', 'json'],
keygen: ['key'],
sign: ['key'],
entry: ['repo', 'tag', 'dir', 'zip', 'commit', 'asset', 'merge', 'sign', 'key', 'out'],
preflight: ['repo', 'tag', 'entry', 'zip', 'commit', 'sign', 'key', 'all'],
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', 'force'],
};
function assertKnownFlags(command: string, f: Flags): void {
const known = COMMAND_FLAGS[command];
if (!known) return;
const unknown = Object.keys(f).filter((k) => !known.includes(k));
if (unknown.length) {
const list = unknown.map((u) => `--${u}`).join(', ');
fail(`unknown flag${unknown.length > 1 ? 's' : ''} for \`${command}\`: ${list}\n ${command} accepts: ${known.map((k) => '--' + k).join(', ') || '(none)'}`);
}
}
/** `--egress a,b` / `--permissions "a b"` → a string[]. */
function listFlag(v: string | undefined): string[] | undefined {
if (!v || v === 'true') return undefined;
const parts = v.split(/[\s,]+/).filter(Boolean);
return parts.length ? parts : undefined;
}
/** --sign, --sign <keyfile>, or absent → the key path to sign with (or undefined). */
function signKey(f: Flags): string | undefined {
if (!f.sign) return undefined;
@@ -91,7 +130,41 @@ async function ensureRepoTag(f: Flags, failMsg: string): Promise<{ repo: string;
const USAGE = 'usage: trek-plugin <create|dev|validate|pack|keygen|sign|entry|preflight|submit|release|publish> [...]';
const HELP = `${USAGE}
create [name] [--type integration|page|widget|trip-page] scaffold a plugin (wizard if no name)
[--template blank|notification-channel] [--interactive]
[--author a] [--description d] [--permissions "a b"]
[--egress host,host] [--required-addons a,b]
dev [dir] [--port 4317] run locally with hot reload
validate [dir] check the manifest + layout
pack [dir] [--out plugin.zip] [--json] build plugin.zip, print sha256 + size
keygen [--key file] create an Ed25519 signing key
sign [zip] [--key file] print a signature + public key for an artifact
entry --repo o/n --tag vX [--zip z] print the ready-to-PR registry entry
[--dir d] [--commit c] [--asset a] [--merge entry.json] [--sign [key]] [--out f]
preflight [dir] --repo o/n --tag vX run the registry CI checks locally
[--entry e] [--zip z] [--commit c] [--sign [key]] [--all]
submit [dir] --repo o/n --tag vX open the registry PR for you
[--sign [key]] [--registry o/n] [--branch b] [--draft] [--keep]
release [dir] --repo o/n --tag vX pack -> gh release -> print entry
[--out f] [--notes n] [--sign [key]] [--merge entry.json]
publish [dir] --repo o/n --tag vX the lot: pack -> tag+release ->
[--sign [key]] [--no-preflight] preflight -> open the registry PR
[--registry o/n] [--notes n] [--draft]
[--force] overwrite an existing release's artifact
(breaks the sha256 pin if it was ever merged)
The goal: create -> dev -> publish, and never hand-compute sha256/size/commitSha
or hand-write the registry JSON.`;
async function main(): Promise<void> {
// Before dispatch, so assertKnownFlags never sees `--help` and rejects it as unknown.
// Help is not an error: it goes to stdout and exits 0.
if (cmd === 'help' || cmd === '--help' || cmd === '-h' || flags.help) {
console.log(HELP);
return;
}
if (!cmd) {
// Bare invocation: a menu in a terminal, the usage line for scripts.
if (!isInteractive()) { console.error(USAGE); process.exit(2); }
@@ -104,16 +177,20 @@ async function main(): Promise<void> {
async function dispatch(command: string, f: Flags, positional: string[]): Promise<void> {
const tui = isInteractive();
assertKnownFlags(command, f);
if (command === 'create') {
const name = positional[0];
if (!name || f.interactive) {
if (!tui) fail('create needs a plugin name in non-interactive mode: create <name> [--type integration|page|widget]');
if (!tui) fail('create needs a plugin name in non-interactive mode: create <name> [--type integration|page|widget|trip-page]');
await interactiveScaffold(process.cwd(), name);
return;
}
scaffold(name, f.type || 'integration', process.cwd(), {
author: f.author, description: f.description,
permissions: f.permissions ? f.permissions.split(/[\s,]+/).filter(Boolean) : undefined,
permissions: listFlag(f.permissions),
egress: listFlag(f.egress),
requiredAddons: listFlag(f['required-addons']),
template: f.template as 'blank' | 'notification-channel' | undefined,
});
console.log(`Created ${name}/ — build server/index.js, add docs/screenshot.png, then \`npx trek-plugin-sdk dev ${name}\`.`);
} else if (command === 'dev') {
@@ -216,7 +293,8 @@ async function dispatch(command: string, f: Flags, positional: string[]): Promis
const { prUrl } = await publishPlugin({
dir: positional[0] || '.', repo, tag,
signKeyPath: signKey(f), registry: f.registry, draft: !!f.draft,
notes: f.notes, skipPreflight: !!f['no-preflight'], now: new Date().toISOString(),
notes: f.notes, skipPreflight: !!f['no-preflight'], force: !!f.force,
now: new Date().toISOString(),
log: tui ? clackLogSink : undefined,
});
if (tui) logSuccess('Published — registry PR:'); else console.error('\n✓ published — registry PR:');
+8 -1
View File
@@ -7,7 +7,7 @@
*/
import fs from 'node:fs';
import path from 'node:path';
import { validateManifest, KNOWN_ADDONS } from '../manifest.js';
import { validateManifest, isUnboundedRange, KNOWN_ADDONS } from '../manifest.js';
import { readJsonFile } from './json.js';
export interface ValidateReport {
@@ -33,6 +33,13 @@ export function validatePluginDir(dir: string): ValidateReport {
if (manifestId && path.basename(path.resolve(dir)) !== manifestId) {
warnings.push(`directory name should equal the plugin id "${manifestId}"`);
}
// A `trek` of "*" installs on every TREK ever released and every one still to come.
// It is legal — a plugin may honestly have no version-specific surface — but it is far
// more often an author who didn't want to think about it, and it opts them out of the
// one mechanism that stops their plugin running on a host it was never tested against.
if (isUnboundedRange(res.manifest?.trek ?? '')) {
warnings.push('trek: "*" claims support for every TREK version, including ones that do not exist yet — pin a range like ">=3.2.0 <4.0.0"');
}
// A well-formed but unrecognised addon id is not an error (the plugin may target
// a newer TREK), but it can never enable here — surface it as a warning.
for (const a of res.manifest?.requiredAddons ?? []) {
+125
View File
@@ -0,0 +1,125 @@
/**
* Author-signature verification for `trek-plugin preflight`.
*
* This is a port of TREK's installer-side verifier (server/src/nest/plugins/install/
* verify-signature.ts), and of the registry's port of it (TREK-Plugins'
* scripts/lib/verify-signature.mjs). All three MUST accept exactly the same inputs.
*
* The point of preflight is to tell an author what the registry's CI would say before
* they open the PR. `sign.ts`'s verifyArtifact only understands the bare key/signature
* pair the SDK itself emits, so it cannot answer that question for a minisign key — and
* a preflight that says "green" on a signature the registry would reject is worse than
* no preflight at all, because the author trusts it.
*
* node:crypto only, like the rest of the SDK.
*/
import crypto from 'node:crypto';
// SPKI DER prefix for a raw 32-byte Ed25519 public key (RFC 8410).
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
export class SignatureError extends Error {}
/** Wrap a raw 32-byte Ed25519 public key as a node KeyObject. */
function ed25519KeyFromRaw(raw: Buffer): crypto.KeyObject {
if (raw.length !== 32) throw new SignatureError('public key is not a 32-byte Ed25519 key');
return crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), format: 'der', type: 'spki' });
}
/**
* Parse a minisign public key: either the two-line minisign format (comment + base64
* payload) or a bare base64 payload. The 10-byte payload header is `Ed` + an 8-byte key
* id; the trailing 32 bytes are the key. A bare 32-byte base64 key (what `keygen` emits)
* is also accepted.
*/
function parseMinisignPubKey(pub: string): { key: crypto.KeyObject; keyId: Buffer | null } {
const line = pub.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('untrusted comment')).pop();
if (!line) throw new SignatureError('empty public key');
const buf = Buffer.from(line, 'base64');
if (buf.length === 32) return { key: ed25519KeyFromRaw(buf), keyId: null };
if (buf.length === 42 && buf.subarray(0, 2).toString('latin1') === 'Ed') {
return { key: ed25519KeyFromRaw(buf.subarray(10)), keyId: buf.subarray(2, 10) };
}
throw new SignatureError('unrecognized public key format');
}
/**
* Parse a minisign signature: 2-byte algorithm (`Ed` legacy | `ED` prehashed) + 8-byte
* key id + 64-byte signature. A bare 64-byte base64 signature (what `sign` emits, over
* the raw artifact bytes) is also accepted.
*/
function parseMinisignSignature(sig: string): { algo: 'Ed' | 'ED' | 'raw'; keyId: Buffer | null; signature: Buffer } {
const line = sig.trim().split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('untrusted comment')).shift();
if (!line) throw new SignatureError('empty signature');
const buf = Buffer.from(line, 'base64');
if (buf.length === 64) return { algo: 'raw', keyId: null, signature: buf };
if (buf.length === 74) {
const algo = buf.subarray(0, 2).toString('latin1');
if (algo !== 'Ed' && algo !== 'ED') throw new SignatureError(`unsupported signature algorithm ${algo}`);
return { algo, keyId: buf.subarray(2, 10), signature: buf.subarray(10) };
}
throw new SignatureError('unrecognized signature format');
}
/**
* Verify that `bytes` were signed by the holder of `publicKeyB64`'s private key. Returns
* true on a valid signature, false on a well-formed but non-matching one, and throws
* SignatureError on malformed input.
*/
export function verifyAuthorSignature(bytes: Buffer, signatureB64: string, publicKeyB64: string): boolean {
const { key, keyId } = parseMinisignPubKey(publicKeyB64);
const { algo, keyId: sigKeyId, signature } = parseMinisignSignature(signatureB64);
// If both carry a key id, they must name the same key (catches a wrong-key mixup).
if (keyId && sigKeyId && !keyId.equals(sigKeyId)) return false;
const message = algo === 'ED' ? crypto.createHash('blake2b512').update(bytes).digest() : bytes;
try {
return crypto.verify(null, message, key, signature);
} catch {
return false;
}
}
/**
* Structural check that needs no artifact (and so no network): a key and a signature must
* be both-present or both-absent, and each must parse.
*
* TREK refuses to install a half-signed entry ("incomplete signature: an author key and a
* version signature must both be present"), so such an entry is dead on arrival. Mirrors
* the registry's checkSignatureShape. Returns a list of problems (empty === fine).
*/
export function checkSignatureShape(entry: { authorPublicKey?: string; versions?: Array<{ version: string; signature?: string }> }): string[] {
const problems: string[] = [];
const key = entry.authorPublicKey;
const versions = Array.isArray(entry.versions) ? entry.versions : [];
if (key) {
try {
parseMinisignPubKey(key);
} catch (e) {
problems.push(`authorPublicKey is not a valid Ed25519/minisign public key: ${(e as Error).message}`);
}
}
for (const v of versions) {
if (v.signature && !key) {
problems.push(`${v.version}: has a signature but the entry has no authorPublicKey — TREK refuses to install a half-signed entry`);
}
if (v.signature) {
try {
parseMinisignSignature(v.signature);
} catch (e) {
problems.push(`${v.version}: signature is malformed: ${(e as Error).message}`);
}
}
}
// A key with no signed version at all is a publishing mistake: TREK pins the key on
// first install and then requires every later version to be signed by it.
if (key && versions.length && !versions.some((v) => v.signature)) {
problems.push('authorPublicKey is set but no version carries a signature — either sign the release or drop the key');
}
return problems;
}
+7
View File
@@ -322,6 +322,13 @@ export interface PluginRequest {
* providers; never Cookie/Authorization/session). Empty on authenticated routes.
* Verify a provider signature against a secret you hold in `ctx.config`/`ctx.settings`. */
headers: Record<string, string>;
/**
* The RAW request body, base64-encoded — set only on `auth:false` routes (webhooks),
* `null` elsewhere. This is what you must run an HMAC over: `body` above is the PARSED
* value, and re-serializing it will not reproduce the exact bytes the sender signed
* (key order, whitespace and unicode escaping all differ), so the signature won't match.
*/
rawBodyBase64?: string | null;
user: { id: number; username: string; isAdmin: boolean } | null;
}
export interface PluginResponse {
+35 -1
View File
@@ -4,7 +4,7 @@
* result (no throw) so the CLI can print every problem at once.
*/
import { validRange } from 'semver';
import { minVersion, validRange } from 'semver';
export interface PluginDependency {
id: string;
@@ -15,6 +15,8 @@ export interface PluginManifest {
name: string;
version: string;
apiVersion: number;
/** The semver RANGE of TREK versions this plugin supports (">=3.2.0 <4.0.0"). */
trek: string;
type: 'integration' | 'page' | 'widget' | 'trip-page';
permissions: string[];
egress: string[];
@@ -93,6 +95,28 @@ function isKnownPermission(p: string): boolean {
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
}
/**
* A semver range a plugin may declare in `trek`. Mirrors the server's isValidTrekRange.
*
* validRange() alone is not enough: ">=4.0.0 <3.0.0" is a valid range no version can ever
* satisfy, so a plugin declaring it would be uninstallable everywhere with nothing to
* explain why. minVersion() is null for exactly that, and throws on junk like "latest".
*/
export function isSatisfiableRange(r: unknown): r is string {
if (typeof r !== 'string' || !r.trim()) return false;
if (validRange(r) === null) return false;
try {
return minVersion(r) !== null;
} catch {
return false;
}
}
/** A range that admits literally every TREK version, past and future ("*", "x", ">=0"). */
export function isUnboundedRange(r: string): boolean {
return isSatisfiableRange(r) && validRange(r) === '*';
}
export function validateManifest(raw: unknown): ValidationResult {
const errors: string[] = [];
const m = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
@@ -107,6 +131,15 @@ export function validateManifest(raw: unknown): ValidationResult {
if (typeof m.id === 'string' && RESERVED_IDS.has(m.id)) errors.push(`id "${m.id}" is reserved`);
if (typeof m.version === 'string' && !SEMVER_RE.test(m.version)) errors.push(`version "${m.version}" is not semver`);
if (typeof m.type === 'string' && !TYPES.includes(m.type)) errors.push(`type must be one of ${TYPES.join('/')}`);
// TREK refuses to install a plugin that doesn't say which TREK versions it runs on, so
// an author must find that out here rather than from a rejected install.
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"',
);
}
if (m.apiVersion !== undefined && typeof m.apiVersion !== 'number') errors.push('apiVersion must be a number');
if (m.nativeModules === true) errors.push('native modules are not allowed (v1)');
@@ -230,6 +263,7 @@ export function validateManifest(raw: unknown): ValidationResult {
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,